diff --git a/.changeset/pretty-beans-wash.md b/.changeset/pretty-beans-wash.md new file mode 100644 index 000000000000..bbf17a48c4a3 --- /dev/null +++ b/.changeset/pretty-beans-wash.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Implement serverFetch hook diff --git a/documentation/docs/04-hooks.md b/documentation/docs/04-hooks.md index 397ad802c9ea..1cc81424e617 100644 --- a/documentation/docs/04-hooks.md +++ b/documentation/docs/04-hooks.md @@ -87,3 +87,28 @@ export function getSession(request) { ``` > `session` must be serializable, which means it must not contain things like functions or custom classes, just built-in JavaScript data types + +### serverFetch + +This function allows you to modify (or replace) a `fetch` request for an **external resource** that happens inside a `load` function that runs on the server (or during pre-rendering). + +For example, your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet). + +```ts +type ServerFetch = (req: Request) => Promise; +``` + +```js +/** @type {import('@sveltejs/kit').ServerFetch} */ +export async function serverFetch(request) { + if (request.url.startsWith('https://api.yourapp.com/')) { + // clone the original request, but change the URL + request = new Request( + request.url.replace('https://api.yourapp.com/', 'http://localhost:9999/'), + request + ); + } + + return fetch(request); +} +``` diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 8a1664b9331e..a89c485703fe 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -363,7 +363,8 @@ async function build_server( // named imports without triggering Rollup's missing import detection const get_hooks = hooks => ({ getSession: hooks.getSession || (() => ({})), - handle: hooks.handle || (({ request, resolve }) => resolve(request)) + handle: hooks.handle || (({ request, resolve }) => resolve(request)), + serverFetch: hooks.serverFetch || fetch }); const module_lookup = { diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 7bbb20e7454a..f331d8323d78 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -214,7 +214,8 @@ class Watcher extends EventEmitter { }, hooks: { getSession: hooks.getSession || (() => ({})), - handle: hooks.handle || (({ request, resolve }) => resolve(request)) + handle: hooks.handle || (({ request, resolve }) => resolve(request)), + serverFetch: hooks.serverFetch || fetch }, hydrate: this.config.kit.hydrate, paths: this.config.kit.paths, diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index 053905ee30ec..86065c8094fe 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -99,7 +99,8 @@ export async function load_node({ if (/^[a-zA-Z]+:/.test(url)) { // external fetch - response = await fetch(url, /** @type {RequestInit} */ (opts)); + const request = new Request(url, /** @type {RequestInit} */ (opts)); + response = await options.hooks.serverFetch.call(null, request); } else { const [path, search] = url.split('?'); diff --git a/packages/kit/test/apps/basics/src/hooks.js b/packages/kit/test/apps/basics/src/hooks.js index ecd194f0a4ad..c68de807e783 100644 --- a/packages/kit/test/apps/basics/src/hooks.js +++ b/packages/kit/test/apps/basics/src/hooks.js @@ -24,3 +24,16 @@ export async function handle({ request, resolve }) { }; } } + +/** @type {import('@sveltejs/kit').ServerFetch} */ +export async function serverFetch(request) { + let newRequest = request; + if (request.url.endsWith('/server-fetch-request.json')) { + newRequest = new Request( + request.url.replace('/server-fetch-request.json', '/server-fetch-request-modified.json'), + request + ); + } + + return fetch(newRequest); +} diff --git a/packages/kit/test/apps/basics/src/routes/load/_tests.js b/packages/kit/test/apps/basics/src/routes/load/_tests.js index 6c60df9382c4..6be4beae2041 100644 --- a/packages/kit/test/apps/basics/src/routes/load/_tests.js +++ b/packages/kit/test/apps/basics/src/routes/load/_tests.js @@ -181,6 +181,43 @@ export default function (test, is_dev) { server.close(); }); + test('handles external api', '/load', async ({ base, page }) => { + const port = await ports.find(4000); + + /** @type {string[]} */ + const requested_urls = []; + + const server = http.createServer(async (req, res) => { + requested_urls.push(req.url); + + if (req.url === '/server-fetch-request-modified.json') { + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'content-type': 'application/json' + }); + + res.end(JSON.stringify({ answer: 42 })); + } else { + res.statusCode = 404; + res.end('not found'); + } + }); + + await new Promise((fulfil) => { + server.listen(port, () => fulfil()); + }); + + await page.goto(`${base}/load/server-fetch-request?port=${port}`); + + assert.equal(requested_urls, [ + '/server-fetch-request-modified.json' + ]); + + assert.equal(await page.textContent('h1'), 'the answer is 42'); + + server.close(); + }); + test( 'makes credentialed fetches to endpoints by default', '/load', diff --git a/packages/kit/test/apps/basics/src/routes/load/index.svelte b/packages/kit/test/apps/basics/src/routes/load/index.svelte index 8565ed52a359..52a1929099b4 100644 --- a/packages/kit/test/apps/basics/src/routes/load/index.svelte +++ b/packages/kit/test/apps/basics/src/routes/load/index.svelte @@ -24,3 +24,4 @@ fetch credentialed large response raw body +server fetch request diff --git a/packages/kit/test/apps/basics/src/routes/load/server-fetch-request.svelte b/packages/kit/test/apps/basics/src/routes/load/server-fetch-request.svelte new file mode 100644 index 000000000000..5af5a9b082a1 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/server-fetch-request.svelte @@ -0,0 +1,20 @@ + + + + +

the answer is {answer}

diff --git a/packages/kit/types/hooks.d.ts b/packages/kit/types/hooks.d.ts index a5dd2a57bc92..ab06b964b986 100644 --- a/packages/kit/types/hooks.d.ts +++ b/packages/kit/types/hooks.d.ts @@ -31,3 +31,5 @@ export type Handle> = (input: { request: ServerRequest; resolve: (request: ServerRequest) => MaybePromise; }) => MaybePromise; + +export type ServerFetch = (req: Request) => Promise; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 2dbba49b0657..869cd5c9a525 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -11,5 +11,6 @@ export { GetSession, Handle, ServerRequest as Request, - ServerResponse as Response -} from './hooks'; + ServerResponse as Response, + ServerFetch +} from './hooks'; \ No newline at end of file diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 6e8189f1c9b7..f9f6b85e1416 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -1,5 +1,5 @@ import { Load } from './page'; -import { Incoming, GetSession, Handle, ServerResponse } from './hooks'; +import { Incoming, GetSession, Handle, ServerResponse, ServerFetch } from './hooks'; import { RequestHandler } from './endpoint'; type PageId = string; @@ -111,6 +111,7 @@ export type SSRManifest = { export type Hooks = { getSession?: GetSession; handle?: Handle; + serverFetch?: ServerFetch; }; export type SSRNode = {