diff --git a/.changeset/quick-rivers-shine.md b/.changeset/quick-rivers-shine.md new file mode 100644 index 000000000000..bb3389818ea1 --- /dev/null +++ b/.changeset/quick-rivers-shine.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: send `cache-control: private, no-store` on remote function responses so personalized query results can never be cached by shared caches diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 1fe8bbc2a29b..0d7526f53ccc 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -57,6 +57,9 @@ async function handle_remote_call_internal(event, state, options, manifest, id) 'sveltekit.remote.call.name': internals.name }); + /** @type {HeadersInit | undefined} */ + const headers = state.prerendering ? undefined : { 'cache-control': 'private, no-store' }; + try { /** @type {RemoteFunctionData} */ const data = {}; @@ -226,7 +229,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data, transport) - }) + }), + { headers } ); } @@ -275,7 +279,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data, transport) - }) + }), + { headers } ); } catch (error) { if (error instanceof Redirect) { @@ -285,7 +290,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'result', data: stringify(data, transport) - }) + }), + { headers } ); } diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 5dbe1976cbaf..0e1399553f98 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -26,6 +26,15 @@ test.describe('remote functions', () => { await page.getByRole('button', { name: 'Refresh' }).click(); await expect(page.locator('p')).toHaveText('foobaz'); }); + + test('remote query responses are not cacheable', async ({ page }) => { + // the query is kicked off during SSR but fetched by the client after + // hydration, so we can observe the response headers on the wire + const response_promise = page.waitForResponse((r) => r.url().includes('/_app/remote/')); + await page.goto('/remote/query-loading-state'); + const response = await response_promise; + expect(response.headers()['cache-control']).toBe('private, no-store'); + }); }); // have to run in serial because commands mutate in-memory data on the server (should fix this at some point)