From 7557320839940a90c12ecb5ac0995681870cb1fb Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 8 Apr 2026 22:31:11 +0200 Subject: [PATCH 1/2] feat: remote functions cache API Adds a new remote functions cache API. Simple example: ```ts /// file: src/routes/data.remote.js import { query, command } from '$app/server'; export const getFastData = query(async () => { const { cache } = getRequestEvent(); cache('100s'); return { data: '...' }; }); export const updateData = command(async () => { // invalidates getFastData; // the next time someone requests it, it will be called again getFastData().invalidate(); }); ``` For more info see the updated docs. This is very WIP, and the adapter part isn't implemented yet (there are a few ways to approach it and we need to agree on the other APIs first). But it workds in dev and preview (public cache is implemented as runtime cache which very likely needs some more hardening). TODOs/open questions: - right now `event.cache()` is "last one wins" except for tags which are merged. It probably makes sense to allow one entry for public cache and one for private, and either do "last one wins" or "lowest value wins" - is `ttl` and `stale` descriptive enough? Should it be `maxAge` and `swr` instead (closer to the web cache nomenclature)? - how to best integrate this with adapters? either they provide a file with some exports which are like hooks which we call at specific points (`setHeaders`, `invalidate` etc) or we don't do anything and do this purely via headers, and adapters can check these headers and either do runtime cache based on it and/or add cdn cache headers (though maybe they have to clone the response then; not sure how much of an overhead that is and if that matters) - this only works for remote functions right now, and it only works when you are calling them from the client. We could additionally have a SvelteKit-native runtime cache for public caching, and/or the adapter can hook into this to cache somewhere else than in memory (Vercel can use runtime cache, CF can use their cache, etc; i.e. this is related to the question above). This way we get more cache hits between client/server calls (or rather, we can get full page request cache this way, which we don't have at all right now). - can this be enhanced in a way that this is usable for full page requests, too (e.g. inside handle hook?). Private cache doesn't make sense there at least. I'd say it's possible to implement and would be intuitive with this API (we can say "do this in handle or load", or "assuming you use remote functions only we take the lowest cache across all of them as the page cache", etc etc, many possibilities) but we should do that later and not bother with it now. --- .changeset/kit-cache-request-event.md | 9 + .../20-core-concepts/60-remote-functions.md | 91 ++++++ packages/kit/src/core/config/index.js | 4 + packages/kit/src/core/config/index.spec.js | 1 + packages/kit/src/core/config/options.js | 8 + packages/kit/src/core/sync/write_server.js | 5 + packages/kit/src/exports/internal/server.js | 3 + packages/kit/src/exports/public.d.ts | 61 +++- packages/kit/src/exports/vite/dev/index.js | 37 +-- .../kit/src/exports/vite/preview/index.js | 11 +- .../src/runtime/app/server/remote/command.js | 10 +- .../kit/src/runtime/app/server/remote/form.js | 3 + .../runtime/app/server/remote/prerender.js | 10 +- .../src/runtime/app/server/remote/query.js | 21 +- .../src/runtime/app/server/remote/shared.js | 4 +- .../client/remote-functions/command.svelte.js | 5 +- .../client/remote-functions/form.svelte.js | 8 +- .../remote-functions/prerender.svelte.js | 6 +- .../client/remote-functions/query.svelte.js | 14 + .../client/remote-functions/shared.svelte.js | 166 ++++++++++- packages/kit/src/runtime/server/cache.js | 263 ++++++++++++++++++ packages/kit/src/runtime/server/cache.spec.js | 41 +++ packages/kit/src/runtime/server/index.js | 14 + packages/kit/src/runtime/server/respond.js | 21 +- .../kit/src/runtime/server/runtime-cache.js | 233 ++++++++++++++++ .../src/runtime/server/runtime-cache.spec.js | 125 +++++++++ packages/kit/src/runtime/shared.js | 36 +++ packages/kit/src/runtime/shared.spec.js | 65 ++++- packages/kit/src/types/internal.d.ts | 29 ++ packages/kit/types/index.d.ts | 61 +++- 30 files changed, 1319 insertions(+), 46 deletions(-) create mode 100644 .changeset/kit-cache-request-event.md create mode 100644 packages/kit/src/runtime/server/cache.js create mode 100644 packages/kit/src/runtime/server/cache.spec.js create mode 100644 packages/kit/src/runtime/server/runtime-cache.js create mode 100644 packages/kit/src/runtime/server/runtime-cache.spec.js diff --git a/.changeset/kit-cache-request-event.md b/.changeset/kit-cache-request-event.md new file mode 100644 index 000000000000..88ef76aed08f --- /dev/null +++ b/.changeset/kit-cache-request-event.md @@ -0,0 +1,9 @@ +--- +'@sveltejs/kit': minor +'@sveltejs/adapter-node': patch +'@sveltejs/adapter-vercel': patch +'@sveltejs/adapter-netlify': patch +'@sveltejs/adapter-cloudflare': patch +--- + +feat: add `event.cache` for responses, remote query cache/invalidation, and adapter integrations diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 7b60d7181e86..1e10928c00cf 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -1167,3 +1167,94 @@ Note that some properties of `RequestEvent` are different inside remote function ## Redirects Inside `query`, `form` and `prerender` functions it is possible to use the [`redirect(...)`](@sveltejs-kit#redirect) function. It is *not* possible inside `command` functions, as you should avoid redirecting here. (If you absolutely have to, you can return a `{ redirect: location }` object and deal with it in the client.) + +## Caching + +By default, remote functions do not cache their results. You can change this by using the `cache` function from `getRequestEvent`, which allows you to store the result of a remote function for a certain amount of time. + +```ts +/// file: src/routes/data.remote.js +// ---cut--- +import { query, getRequestEvent } from '$app/server'; + +export const getFastData = query(async () => { + const { cache } = getRequestEvent(); + + // cache for 100 seconds + cache('100s'); + + return { data: '...' }; +}); +``` + +The `cache` function accepts either a string representing the time-to-live (TTL), or an object with more detailed configuration: + +```ts +/// file: src/routes/data.remote.js +import { query, getRequestEvent } from '$app/server'; +// ---cut--- +export const getFastData = query(async () => { + const { cache } = getRequestEvent(); + + cache({ + // fresh for 1 minute + ttl: '1m', + // can serve stale up to 5 minutes + stale: '5m', + // shareable across users (CDN caching) or private to user (browser caching); default private + scope: 'private', + // used for invalidation, when not given is the URL + tags: ['my-data'], + }); + + // ... +}); +``` + +There are two variants of the cache: + +- **Private cache** (`scope: 'private'`): Per-user cache implemented using the browser's Cache API. +- **Public cache** (`scope: 'public'`): Shareable across users. The implementation is platform-specific (e.g., using `CDN-Cache-Control` and `Cache-Tag` headers on Vercel/Netlify, or a runtime cache on Node). + +### Invalidating the cache + +To invalidate the cache for a specific query, you can call its `invalidate` method: + +```ts +/// file: src/routes/data.remote.js +import { query, command } from '$app/server'; + +export const getFastData = query(async () => { + const { cache } = getRequestEvent(); + cache('100s'); + return { data: '...' }; +}); + +export const updateData = command(async () => { + // invalidates getFastData; + // the next time someone requests it, it will be called again + getFastData().invalidate(); +}); +``` + +Alternatively, if you used tags when setting up the cache, you can invalidate by tag using `cache.invalidate(...)`: + +```ts +/// file: src/routes/data.remote.js +import { query, command, getRequestEvent } from '$app/server'; + +export const getFastData = query(async () => { + const { cache } = getRequestEvent(); + cache({ ttl: '100s', tags: ['my-data'] }); + return { data: '...' }; +}); + +export const updateData = command(async () => { + const { cache } = getRequestEvent(); + // invalidate all queries using the my-data tag; + // the next time someone requests a query which had that tag, it will be called again + cache.invalidate(['my-data']); +}); +``` + +> [!NOTE] tags are public since they could invalidate the private cache in the browser diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 42146a89d4c3..02b5b8c7ddb3 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -101,6 +101,10 @@ function process_config(config, { cwd = process.cwd() } = {}) { validated.kit.outDir = path.resolve(cwd, validated.kit.outDir); + if (validated.kit.cache?.path) { + validated.kit.cache.path = path.resolve(cwd, validated.kit.cache.path); + } + for (const key in validated.kit.files) { if (key === 'hooks') { validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client); diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 6b0e9808affc..f2811caac875 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -60,6 +60,7 @@ const get_defaults = (prefix = '') => ({ extensions: ['.svelte'], kit: { adapter: null, + cache: undefined, alias: {}, appDir: '_app', csp: { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index ac30ce4fbd94..0b4d641462ee 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -81,6 +81,14 @@ const options = object( return input; }), + cache: validate(undefined, (input, keypath) => { + if (input === undefined) return undefined; + return object({ + path: string(null), + options: validate({}, object({}, true)) + })(input, keypath); + }), + alias: validate({}, (input, keypath) => { if (typeof input !== 'object') { throw new Error(`${keypath} should be an object`); diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 711a007345e0..40e2aaee2693 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -46,6 +46,11 @@ export const options = { env_private_prefix: '${config.kit.env.privatePrefix}', hash_routing: ${s(config.kit.router.type === 'hash')}, hooks: null, // added lazily, via \`get_hooks\` + kit_cache_config: ${s({ + path: config.kit.cache?.path, + options: config.kit.cache?.options ?? {} + })}, + kit_cache_handler: null, preload_strategy: ${s(config.kit.output.preloadStrategy)}, root, service_worker: ${has_service_worker}, diff --git a/packages/kit/src/exports/internal/server.js b/packages/kit/src/exports/internal/server.js index ed425062637f..0ae5472bf122 100644 --- a/packages/kit/src/exports/internal/server.js +++ b/packages/kit/src/exports/internal/server.js @@ -20,3 +20,6 @@ export { get_request_store, try_get_request_store } from './event.js'; + +export { SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER } from '../../runtime/shared.js'; +export { with_runtime_cache, RuntimeCacheStore } from '../../runtime/server/runtime-cache.js'; diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 60a50033add7..fd60514234df 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -328,12 +328,61 @@ export interface Emulator { platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; } +/** + * Options for [`event.cache`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent) + */ +export interface CacheOptions { + ttl: string | number; + stale?: string | number; + /** @default 'private' */ + scope?: 'public' | 'private'; + tags?: string[]; + /** @default false */ + refresh?: boolean; +} + +/** + * Normalized cache directive passed to custom `kit.cache` handlers. + */ +export interface KitCacheDirective { + scope: 'public' | 'private'; + maxAgeSeconds: number; + staleSeconds?: number; + tags: string[]; + refresh: boolean; +} + +/** + * Custom cache integration (e.g. platform purge hooks). Export `create` or `default` from `kit.cache.path`. + */ +export interface KitCacheHandler { + setHeaders?( + headers: Headers, + directive: KitCacheDirective, + ctx: { remote_id?: string | null } + ): MaybePromise; + invalidate?(tags: string[]): MaybePromise; +} + +export interface RequestCache { + (arg: CacheOptions | string): void; + invalidate(tags: string[]): void; +} + export interface KitConfig { /** * Your [adapter](https://svelte.dev/docs/kit/adapters) is run when executing `vite build`. It determines how the output is converted for different platforms. * @default undefined */ adapter?: Adapter; + /** + * Optional module that implements [`KitCacheHandler`](https://svelte.dev/docs/kit/@sveltejs-kit#KitCacheHandler) via a `create` or default export function. + */ + cache?: { + /** Absolute or project-relative path resolved from your app root */ + path?: string; + options?: Record; + }; /** * An object containing zero or more aliases used to replace values in `import` statements. These aliases are automatically passed to Vite and TypeScript. * @@ -1554,6 +1603,12 @@ export interface RequestEvent< */ isSubRequest: boolean; + /** + * Configure HTTP caching for this response. `public` uses shared caches (CDN / `Cache-Control`); + * `private` applies only to remote query responses stored via the browser Cache API. + */ + cache: RequestCache; + /** * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. * @since 2.31.0 @@ -2178,6 +2233,10 @@ export type RemoteQuery = RemoteResource & { * This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip. */ refresh(): Promise; + /** + * Queue cache invalidation for this query (public or private, depending on how it was cached). + */ + invalidate(): void; /** * Temporarily override a query's value during a [single-flight mutation](https://svelte.dev/docs/kit/remote-functions#Single-flight-mutations) to provide optimistic updates. * @@ -2210,7 +2269,7 @@ export type RemotePrerenderFunction = ( ) => RemoteResource; /** - * The return value of a remote `query` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation. + * The return value of a remote `query` function (client stub or shared typing). See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation. */ export type RemoteQueryFunction = ( arg: undefined extends Input ? Input | void : Input diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 8371a81ba5b1..ae7f503044e8 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -19,6 +19,7 @@ import { is_chrome_devtools_request, not_found } from '../utils.js'; import { SCHEME } from '../../../utils/url.js'; import { check_feature } from '../../../utils/features.js'; import { escape_html } from '../../../utils/escape.js'; +import { with_runtime_cache } from '../../../runtime/server/runtime-cache.js'; const cwd = process.cwd(); // vite-specifc queries that we should skip handling for css urls @@ -546,24 +547,28 @@ export async function dev(vite, vite_config, svelte_config, get_remotes) { return; } - const rendered = await server.respond(request, { - getClientAddress: () => { - const { remoteAddress } = req.socket; - if (remoteAddress) return remoteAddress; - throw new Error('Could not determine clientAddress'); - }, - read: (file) => { - if (file in manifest._.server_assets) { - return fs.readFileSync(from_fs(file)); - } + const rendered = await with_runtime_cache( + request, + { + getClientAddress: () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + }, + read: (file) => { + if (file in manifest._.server_assets) { + return fs.readFileSync(from_fs(file)); + } - return fs.readFileSync(path.join(svelte_config.kit.files.assets, file)); + return fs.readFileSync(path.join(svelte_config.kit.files.assets, file)); + }, + before_handle: (event, config, prerender) => { + async_local_storage.enterWith({ event, config, prerender }); + }, + emulator }, - before_handle: (event, config, prerender) => { - async_local_storage.enterWith({ event, config, prerender }); - }, - emulator - }); + server + ); if (rendered.status === 404) { // @ts-expect-error diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 3b900845f9af..b2634468faa3 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -5,6 +5,7 @@ import { lookup } from 'mrmime'; import sirv from 'sirv'; import { loadEnv, normalizePath } from 'vite'; import { createReadableStream, getRequest, setResponse } from '../../../exports/node/index.js'; +import { with_runtime_cache } from '../../../runtime/server/runtime-cache.js'; import { installPolyfills } from '../../../exports/node/polyfills.js'; import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import { is_chrome_devtools_request, not_found } from '../utils.js'; @@ -203,9 +204,9 @@ export async function preview(vite, vite_config, svelte_config) { request: req }); - await setResponse( - res, - await server.respond(request, { + const rendered = await with_runtime_cache( + request, + { getClientAddress: () => { const { remoteAddress } = req.socket; if (remoteAddress) return remoteAddress; @@ -219,8 +220,10 @@ export async function preview(vite, vite_config, svelte_config) { return fs.readFileSync(join(svelte_config.kit.files.assets, file)); }, emulator - }) + }, + server ); + await setResponse(res, rendered); }); }; } diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 2265ee3e7e7a..df688c679bad 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -4,6 +4,7 @@ import { get_request_store } from '@sveltejs/kit/internal/server'; import { create_validator, run_remote_function } from './shared.js'; import { MUTATIVE_METHODS } from '../../../../constants.js'; +import { create_invalidate_cache } from '../../../server/cache.js'; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -80,7 +81,14 @@ export function command(validate_or_fn, maybe_fn) { state.remote.refreshes ??= {}; const promise = Promise.resolve( - run_remote_function(event, state, true, () => validate(arg), fn) + run_remote_function( + event, + state, + true, + create_invalidate_cache(state), + () => validate(arg), + fn + ) ); // @ts-expect-error diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 1e9c8f012872..b5413ce12ada 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -13,6 +13,7 @@ import { } from '../../../form-utils.js'; import { get_cache, run_remote_function } from './shared.js'; import { ValidationError } from '@sveltejs/kit/internal'; +import { create_invalidate_cache } from '../../../server/cache.js'; /** * Creates a form object that can be spread onto a `
` element. @@ -123,6 +124,7 @@ export function form(validate_or_fn, maybe_fn) { output.submission = true; const { event, state } = get_request_store(); + const validated = await schema?.['~standard'].validate(data); if (meta.validate_only) { @@ -145,6 +147,7 @@ export function form(validate_or_fn, maybe_fn) { event, state, true, + create_invalidate_cache(state), () => data, (data) => (!maybe_fn ? fn() : fn(data, issue)) ); diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 23141d5d9b80..6724e4511b09 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -14,6 +14,7 @@ import { parse_remote_response, run_remote_function } from './shared.js'; +import { create_request_cache } from '../../../server/cache.js'; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -133,7 +134,14 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { } const promise = get_response(__, arg, state, () => - run_remote_function(event, state, false, () => validate(arg), fn) + run_remote_function( + event, + state, + false, + create_request_cache(state, __.id, arg), + () => validate(arg), + fn + ) ); if (state.prerendering) { diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index db5dc7102b8e..7b3f7f5def24 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -8,6 +8,7 @@ import { noop } from '../../../../utils/functions.js'; import { create_validator, get_cache, get_response, run_remote_function } from './shared.js'; import { handle_error_and_jsonify } from '../../../server/utils.js'; import { HttpError, SvelteKitError } from '@sveltejs/kit/internal'; +import { create_request_cache } from '../../../server/cache.js'; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -78,7 +79,14 @@ export function query(validate_or_fn, maybe_fn) { const is_validated = is_validated_argument(__, state, arg); return create_query_resource(__, arg, state, () => - run_remote_function(event, state, false, () => (is_validated ? arg : validate(arg)), fn) + run_remote_function( + event, + state, + false, + create_request_cache(state, __.id, arg), + () => (is_validated ? arg : validate(arg)), + fn + ) ); }; @@ -168,6 +176,7 @@ function batch(validate_or_fn, maybe_fn) { event, state, false, + create_request_cache(state, __.id, args), async () => Promise.all(args.map(validate)), async (/** @type {any[]} */ input) => { const get_result = await fn(input); @@ -237,6 +246,7 @@ function batch(validate_or_fn, maybe_fn) { event, state, false, + create_request_cache(state, __.id, arg), async () => Promise.all(args.map(validate)), async (input) => { const get_result = await fn(input); @@ -307,6 +317,15 @@ function create_query_resource(__, arg, state, fn) { const value = is_immediate_refresh ? get_promise() : fn(); return update_refresh_value(refresh_context, value, is_immediate_refresh); }, + invalidate() { + const { state, event } = get_request_store(); + // align with how url is constructed on the client, which is used for the cache key + const invalidate_key = + arg !== undefined + ? create_remote_key(__.id, stringify_remote_arg(arg, state.transport)) + : __.id; + event.cache.invalidate([invalidate_key]); + }, run() { // potential TODO: if we want to be able to run queries at the top level of modules / outside of the request context, we could technically remove // the requirement that `state` is defined, but that's kind of an annoying change to make, so we're going to wait on that until we have any sort of diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index 2021ba197d0f..733730e77121 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -120,14 +120,16 @@ export function parse_remote_response(data, transport) { * @param {RequestEvent} event * @param {RequestState} state * @param {boolean} allow_cookies + * @param {import('@sveltejs/kit').RequestCache} cache * @param {() => any} get_input * @param {(arg?: any) => T} fn */ -export async function run_remote_function(event, state, allow_cookies, get_input, fn) { +export async function run_remote_function(event, state, allow_cookies, cache, get_input, fn) { /** @type {RequestStore} */ const store = { event: { ...event, + cache, setHeaders: () => { throw new Error('setHeaders is not allowed in remote functions'); }, diff --git a/packages/kit/src/runtime/client/remote-functions/command.svelte.js b/packages/kit/src/runtime/client/remote-functions/command.svelte.js index 8c8983699f21..02725e56623b 100644 --- a/packages/kit/src/runtime/client/remote-functions/command.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/command.svelte.js @@ -8,7 +8,8 @@ import { stringify_remote_arg } from '../../shared.js'; import { get_remote_request_headers, apply_refreshes, - categorize_updates + categorize_updates, + apply_private_cache_invalidate_headers } from './shared.svelte.js'; /** @@ -67,6 +68,8 @@ export function command(id) { throw new Error('Failed to execute remote function'); } + apply_private_cache_invalidate_headers(response); + const result = /** @type {RemoteFunctionResponse} */ (await response.json()); if (result.type === 'redirect') { throw new Error( diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 1af3bffdcc6a..670d2edf3010 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -7,7 +7,11 @@ import { DEV } from 'esm-env'; import { HttpError } from '@sveltejs/kit/internal'; import { app, query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js'; import { tick } from 'svelte'; -import { apply_refreshes, categorize_updates } from './shared.svelte.js'; +import { + apply_private_cache_invalidate_headers, + apply_refreshes, + categorize_updates +} from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, @@ -224,6 +228,8 @@ export function form(id) { throw new Error('Failed to execute remote function'); } + apply_private_cache_invalidate_headers(response); + const form_result = /** @type { RemoteFunctionResponse} */ (await response.json()); // reset issues in case it's a redirect or error (but issues passed in that case) diff --git a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js index 8cf95b3229bc..25ed4f82108b 100644 --- a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js @@ -8,7 +8,7 @@ import { get_remote_request_headers, remote_request } from './shared.svelte.js'; import { create_remote_key, stringify_remote_arg } from '../../shared.js'; // Initialize Cache API for prerender functions -const CACHE_NAME = DEV ? `sveltekit:${Date.now()}` : `sveltekit:${version}`; +const CACHE_NAME = DEV ? `sveltekit:prerender:${Date.now()}` : `sveltekit:prerender:${version}`; /** @type {Cache | undefined} */ let prerender_cache; @@ -20,12 +20,12 @@ const prerender_cache_ready = (async () => { // Clean up old cache versions const cache_names = await caches.keys(); for (const cache_name of cache_names) { - if (cache_name.startsWith('sveltekit:') && cache_name !== CACHE_NAME) { + if (cache_name.startsWith('sveltekit:prerender:') && cache_name !== CACHE_NAME) { await caches.delete(cache_name); } } } catch (error) { - console.warn('Failed to initialize SvelteKit cache:', error); + console.warn('Failed to initialize SvelteKit prerender cache:', error); } } })(); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 069c5978e1df..350415d66b88 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -69,6 +69,11 @@ export function query(id) { }; Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id }); + Object.defineProperty(wrapper, 'invalidate', { + value() { + throw new Error('Cannot call query().invalidate() on the client'); + } + }); return wrapper; } @@ -165,6 +170,11 @@ export function query_batch(id) { }; Object.defineProperty(wrapper, QUERY_FUNCTION_ID, { value: id }); + Object.defineProperty(wrapper, 'invalidate', { + value() { + throw new Error('Cannot call query().invalidate() on the client'); + } + }); return wrapper; } @@ -609,4 +619,8 @@ class QueryProxy { get [Symbol.toStringTag]() { return 'QueryProxy'; } + + invalidate() { + throw new Error('invalidate() can only be called on the server'); + } } diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index c74bf43076d7..537fc4dda23d 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -4,8 +4,17 @@ import * as devalue from 'devalue'; import { app, goto, query_map } from '../client.js'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; import { untrack } from 'svelte'; -import { create_remote_key, split_remote_key } from '../../shared.js'; +import { + create_remote_key, + evict_cache_entries_matching_tags, + split_remote_key, + SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER, + SVELTEKIT_CACHE_CONTROL_TAGS_HEADER, + SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER +} from '../../shared.js'; +import { DEV } from 'esm-env'; import { navigating, page } from '../state.svelte.js'; +import { version } from '__sveltekit/environment'; /** Indicates a query function, as opposed to a query instance */ export const QUERY_FUNCTION_ID = Symbol('sveltekit.query_function_id'); @@ -14,6 +23,75 @@ export const QUERY_OVERRIDE_KEY = Symbol('sveltekit.query_override_key'); /** Indicates a query instance */ export const QUERY_RESOURCE_KEY = Symbol('sveltekit.query_resource_key'); +/** Cache name for remote query private directive; in DEV includes a unique suffix so each load gets a fresh cache. */ +const REMOTE_PRIVATE_CACHE_NAME = DEV + ? `sveltekit:private-cache:${Date.now()}` + : `sveltekit:private-cache:${version}`; + +/** @type {Cache | undefined} */ +let private_remote_cache; + +const private_remote_cache_ready = (async () => { + if (typeof caches === 'undefined') return; + + try { + private_remote_cache = await caches.open(REMOTE_PRIVATE_CACHE_NAME); + + const cache_names = await caches.keys(); + for (const cache_name of cache_names) { + if ( + cache_name.startsWith('sveltekit:private-cache:') && + cache_name !== REMOTE_PRIVATE_CACHE_NAME + ) { + await caches.delete(cache_name); + } + } + } catch (error) { + console.warn('Failed to initialize SvelteKit remote private cache:', error); + } +})(); + +/** + * Seconds elapsed since the cached response was generated. + * Falls back to 0 if there is no `Date` header. + * @param {Response} res + * @returns {number} + */ +function private_cache_age(res) { + const date = res.headers.get('date'); + if (!date) return 0; + return Math.max(0, (Date.now() - new Date(date).getTime()) / 1000); +} + +/** + * Parse `max-age` from {@link SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER} on the cached response. + * @param {Response} res + * @returns {number} + */ +function private_cache_max_age(res) { + const cc = res.headers.get(SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER) ?? ''; + const m = /max-age=(\d+)/i.exec(cc); + return m ? parseInt(m[1], 10) : 0; +} + +/** + * Shared unwrap logic for remote function responses (redirect / error / result). + * @param {RemoteFunctionResponse} result + * @returns {Promise} + */ +async function unwrap_remote_result(result) { + if (result.type === 'redirect') { + await goto(result.location); + throw new Redirect(307, result.location); + } + + if (result.type === 'error') { + throw new HttpError(result.status ?? 500, result.error); + } + + return result.result; +} + /** * @returns {{ 'x-sveltekit-pathname': string, 'x-sveltekit-search': string }} */ @@ -31,34 +109,100 @@ export function get_remote_request_headers() { }); } +/** + * @param {Response} response + */ +export async function apply_private_cache_invalidate_headers(response) { + const raw = response.headers.get(SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER); + if (!raw) return; + + const tags = raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + await private_remote_cache_ready; + const cache = private_remote_cache; + if (!cache || !tags.length) return; + + try { + await evict_cache_entries_matching_tags(cache, tags); + } catch { + // ignore + } +} + /** * @param {string} url * @param {HeadersInit} headers */ export async function remote_request(url, headers) { - const response = await fetch(url, { + const init = { headers: { 'Content-Type': 'application/json', ...headers } - }); + }; + + await private_remote_cache_ready; + const cache = private_remote_cache; + if (cache) { + try { + const hit = await cache.match(url); + + if (hit) { + const age = private_cache_age(hit); + const max_age = private_cache_max_age(hit); + + if (max_age > 0 && age <= max_age) { + const result = /** @type {RemoteFunctionResponse} */ (await hit.json()); + return unwrap_remote_result(result); + } + + // stale — evict + await cache.delete(url); + } + } catch (_) { + // ignore + } + } + + const response = await fetch(url, init); if (!response.ok) { throw new HttpError(500, 'Failed to execute remote function'); } - const result = /** @type {RemoteFunctionResponse} */ (await response.json()); + await apply_private_cache_invalidate_headers(response); - if (result.type === 'redirect') { - await goto(result.location); - throw new Redirect(307, result.location); - } + const result = /** @type {RemoteFunctionResponse} */ (await response.json()); + const unwrapped = unwrap_remote_result(result); - if (result.type === 'error') { - throw new HttpError(result.status ?? 500, result.error); + if (cache) { + const cache_control = response.headers.get(SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER) ?? ''; + if (cache_control.includes('private') && cache_control.includes('max-age')) { + const cache_tags = response.headers.get(SVELTEKIT_CACHE_CONTROL_TAGS_HEADER); + await cache + .put( + url, + // We need to create a new response because the original response is already consumed + new Response(JSON.stringify(result), { + headers: { + 'Content-Type': 'application/json', + date: response.headers.get('date') ?? new Date().toISOString(), + [SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER]: cache_control, + ...(cache_tags ? { [SVELTEKIT_CACHE_CONTROL_TAGS_HEADER]: cache_tags } : {}) + } + }) + ) + .catch((e) => { + console.error('Failed to put into cache:', e); + // Nothing we can do here + }); + } } - return result.result; + return unwrapped; } /** diff --git a/packages/kit/src/runtime/server/cache.js b/packages/kit/src/runtime/server/cache.js new file mode 100644 index 000000000000..d18ae6cb5ca5 --- /dev/null +++ b/packages/kit/src/runtime/server/cache.js @@ -0,0 +1,263 @@ +/** @import { RequestState } from 'types' */ +import { + SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER, + SVELTEKIT_CACHE_CONTROL_TAGS_HEADER, + stringify_remote_arg, + create_remote_key, + SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER +} from '../shared.js'; + +/** + * @typedef {object} KitCacheDirective + * @property {'public' | 'private'} scope + * @property {number} maxAgeSeconds + * @property {number} [staleSeconds] + * @property {string[]} tags + * @property {boolean} refresh + */ + +/** + * Numeric duration in seconds from strings like `30s`, `100s`, `5m`, `1h`, `1d`, or plain `0`. + * `ms` values are rounded up to at least 1 second when non-zero. + * @param {string | number} value + * @returns {number} + */ +export function parse_cache_duration(value) { + if (typeof value === 'number') { + if (!Number.isFinite(value) || value < 0) { + throw new Error('cache ttl must be a non-negative finite number'); + } + return Math.floor(value); + } + const m = String(value) + .trim() + .match(/^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i); + if (!m) { + throw new Error( + `Invalid cache duration "${value}" — expected a string like "30s", "5m", or "1h"` + ); + } + const n = Number(m[1]); + const unit = (m[2] ?? 's').toLowerCase(); + if (!Number.isFinite(n) || n < 0) { + throw new Error('cache ttl must be a non-negative number'); + } + /** @type {number} */ + let seconds; + switch (unit) { + case 's': + seconds = Math.floor(n); + break; + case 'm': + seconds = Math.floor(n * 60); + break; + case 'h': + seconds = Math.floor(n * 3600); + break; + default: + seconds = Math.floor(n); + } + return seconds; +} + +/** + * @typedef {object} NormalizedCacheInput + * @property {number} ttl + * @property {number} [stale] + * @property {'public' | 'private'} scope + * @property {string[]} [tags] + * @property {boolean} refresh + */ + +/** + * @param {string | import('@sveltejs/kit').CacheOptions} input + * @returns {NormalizedCacheInput} + */ +export function normalize_cache_input(input) { + if (typeof input === 'string') { + return { ttl: parse_cache_duration(input), scope: 'public', refresh: true }; + } + const ttl = parse_cache_duration(input.ttl); + const stale = input.stale !== undefined ? parse_cache_duration(input.stale) : undefined; + return { + ttl, + stale, + scope: input.scope ?? 'private', + tags: input.tags ? [...input.tags] : undefined, + refresh: input.refresh !== true + }; +} + +/** + * @param {string[]} tags + * @param {string | null | undefined} remote_id + */ +export function merge_remote_cache_tags(tags, remote_id) { + if (!remote_id) return tags; + const t = `sveltekit-remote:${remote_id.replace(/\//g, ':')}`; + if (tags.includes(t)) return tags; + return [...tags, t]; +} + +export function create_erroring_cache() { + function cache() { + throw new Error( + 'event.cache() can only be used inside remote functions (`query`, `query.batch`, `prerender`)' + ); + } + cache.invalidate = () => { + throw new Error('event.cache.invalidate() can only be used inside remote functions'); + }; + return cache; +} + +/** + * @param {RequestState} state + * @param {string} remote_id + * @param {any} arg + * @returns {import('@sveltejs/kit').RequestCache} + */ +export function create_request_cache(state, remote_id, arg) { + /** + * @param {string | import('@sveltejs/kit').CacheOptions} input + */ + function cache(input) { + const opts = normalize_cache_input(input); + const bag = get_bag(state); + const prev = bag.directive; + let tags = opts.tags; + if (!tags?.length) { + // align with how url is constructed on the client, which is used for the cache key + const invalidate_key = + arg !== undefined + ? create_remote_key(remote_id, stringify_remote_arg(arg, state.transport)) + : remote_id; + tags = [invalidate_key]; + } + + const staleSeconds = + opts.refresh && opts.stale !== undefined && opts.stale > 0 ? opts.stale : undefined; + + bag.directive = { + scope: opts.scope, + maxAgeSeconds: opts.ttl, + staleSeconds, + tags: unique_merge(prev?.tags, tags), + refresh: opts.refresh + }; + } + + cache.invalidate = () => { + // TODO should we allow invalidate instead? + throw new Error( + 'event.cache.invalidate() can only be used inside mutating remote functions (`command`, `form`)' + ); + }; + + return cache; +} + +/** + * @param {RequestState} state + * @returns {import('@sveltejs/kit').RequestCache} + */ +export function create_invalidate_cache(state) { + function cache() { + throw new Error( + 'event.cache() can only be used inside querying remote functions (`query`, `query.batch`, `prerender`)' + ); + } + + /** @param {string[]} tags */ + function invalidate(tags) { + const bag = get_bag(state); + for (const t of tags) { + if (!bag.invalidations.includes(t)) { + bag.invalidations.push(t); + } + } + } + + cache.invalidate = invalidate; + + return cache; +} + +/** @param {RequestState} state */ +function get_bag(state) { + state.remote.kit_cache ??= { directive: null, invalidations: [] }; + return state.remote.kit_cache; +} + +/** + * @param {string[] | undefined} a + * @param {string[]} b + */ +function unique_merge(a, b) { + const out = []; + const seen = new Set(); + for (const x of [...(a ?? []), ...b]) { + if (!seen.has(x)) { + seen.add(x); + out.push(x); + } + } + return out; +} + +/** + * @param {Headers} headers + * @param {KitCacheDirective} directive + * @param {{ remote_id?: string | null }} [ctx] + */ +export function apply_cache_headers(headers, directive, ctx = {}) { + const tags = directive.tags; + + if (directive.scope === 'private') { + const parts = ['private', `max-age=${directive.maxAgeSeconds}`]; + if (directive.staleSeconds && directive.refresh) { + parts.push(`stale-while-revalidate=${directive.staleSeconds}`); + } + headers.set(SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER, parts.join(', ')); + if (tags.length) { + headers.set(SVELTEKIT_CACHE_CONTROL_TAGS_HEADER, tags.join(',')); + } + return; + } + + const cdn = ['public', `max-age=${directive.maxAgeSeconds}`]; + if (directive.staleSeconds && directive.refresh) { + cdn.push(`stale-while-revalidate=${directive.staleSeconds}`); + } + headers.set('CDN-Cache-Control', cdn.join(', ')); + if (tags.length) { + headers.set('Cache-Tag', tags.join(',')); + } +} + +/** + * @param {Response} response + * @param {RequestState} state + * @param {string | null | undefined} remote_id + * @param {import('types').KitCacheHandler | null | undefined} handler + */ +export async function finalize_kit_cache(response, state, remote_id, handler) { + const bag = state.remote.kit_cache; + const directive = bag?.directive; + + if (response.ok && directive) { + // if (handler?.setHeaders) { + // await handler.setHeaders(response.headers, directive, { remote_id }); + // } else { + apply_cache_headers(response.headers, directive, { remote_id }); + // } + } + + if (handler?.invalidate && bag?.invalidations?.length) { + await handler.invalidate(bag.invalidations); + } + + for (const tag of bag?.invalidations ?? []) { + response.headers.append(SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER, tag); + } +} diff --git a/packages/kit/src/runtime/server/cache.spec.js b/packages/kit/src/runtime/server/cache.spec.js new file mode 100644 index 000000000000..37df15bd4140 --- /dev/null +++ b/packages/kit/src/runtime/server/cache.spec.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { + SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER, + SVELTEKIT_CACHE_CONTROL_TAGS_HEADER, + SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER +} from '../shared.js'; +import { apply_cache_headers, finalize_kit_cache, parse_cache_duration } from './cache.js'; + +describe('parse_cache_duration', () => { + test('parses units', () => { + expect(parse_cache_duration('30s')).toBe(30); + expect(parse_cache_duration('5m')).toBe(300); + expect(parse_cache_duration('2h')).toBe(7200); + expect(parse_cache_duration(12)).toBe(12); + }); +}); + +test('private scope uses x-sveltekit-cache-control', () => { + const h = new Headers(); + apply_cache_headers(h, { scope: 'private', maxAgeSeconds: 42, tags: ['t'], refresh: true }, {}); + expect(h.get(SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER)).toBe('private, max-age=42'); + expect(h.get(SVELTEKIT_CACHE_CONTROL_TAGS_HEADER)).toBe('t'); + expect(h.get('cache-control')).toBeNull(); +}); + +test('finalize_kit_cache emits header for query invalidations', async () => { + const response = new Response('{}', { status: 200 }); + const state = { + remote: { + kit_cache: { + directive: null, + invalidations: ['sveltekit-remote:h:q'] + } + } + }; + + await finalize_kit_cache(response, /** @type {any} */ (state), null, null); + expect(response.headers.get(SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER)).toBe( + 'sveltekit-remote:h:q' + ); +}); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 3d577587869b..7cb4e80fc5aa 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -10,6 +10,7 @@ import { filter_env } from '../../utils/env.js'; import { format_server_error } from './utils.js'; import { set_read_implementation, set_manifest } from '__sveltekit/server'; import { set_app } from './app.js'; +import { pathToFileURL } from 'node:url'; /** @type {Promise} */ let init_promise; @@ -140,6 +141,19 @@ export class Server { if (module.init) { await module.init(); } + + const cache_path = this.#options.kit_cache_config?.path; + if (cache_path) { + const { href } = pathToFileURL(cache_path); + const mod = await import(href); + const factory = mod.create ?? mod.default; + if (typeof factory !== 'function') { + throw new Error( + `kit.cache module at ${cache_path} must export a default or \`create\` function` + ); + } + this.#options.kit_cache_handler = factory(this.#options.kit_cache_config.options ?? {}); + } } catch (e) { if (DEV) { this.#options.hooks = { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index e32ec56431e9..69e6ef55b5c7 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -23,7 +23,11 @@ import { create_fetch } from './fetch.js'; import { PageNodes } from '../../utils/page_nodes.js'; import { validate_server_exports } from '../../utils/exports.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; -import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; +import { + INVALIDATED_PARAM, + SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER, + TRAILING_SLASH_PARAM +} from '../shared.js'; import { get_public_env } from './env_module.js'; import { resolve_route } from './page/server_routing.js'; import { validateHeaders } from './validate-headers.js'; @@ -39,6 +43,7 @@ import { server_data_serializer } from './page/data_serializer.js'; import { get_remote_id, handle_remote_call } from './remote.js'; import { record_span } from '../telemetry/record_span.js'; import { otel } from '../telemetry/otel.js'; +import { create_erroring_cache, finalize_kit_cache } from './cache.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -214,7 +219,8 @@ export async function internal_respond(request, options, manifest, state) { url, isDataRequest: is_data_request, isSubRequest: state.depth > 0, - isRemoteRequest: !!remote_id + isRemoteRequest: !!remote_id, + cache: create_erroring_cache() }; event.fetch = create_fetch({ @@ -447,7 +453,7 @@ export async function internal_respond(request, options, manifest, state) { // e.g. accessible when loading modules needed to handle the request return with_request_store(null, () => resolve(merge_tracing(event, resolve_span), page_nodes, opts).then( - (response) => { + async (response) => { // add headers/cookies here, rather than inside `resolve`, so that we // can do it once for all responses instead of once per `return` for (const key in headers) { @@ -461,6 +467,14 @@ export async function internal_respond(request, options, manifest, state) { response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); } + const remote_id_string = remote_id ? String(remote_id) : null; + await finalize_kit_cache( + response, + event_state, + remote_id_string, + options.kit_cache_handler + ); + resolve_span.setAttributes({ 'http.response.status_code': response.status, 'http.response.body.size': @@ -496,6 +510,7 @@ export async function internal_respond(request, options, manifest, state) { // https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 + set-cookie for (const key of [ 'cache-control', + SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER, 'content-location', 'date', 'expires', diff --git a/packages/kit/src/runtime/server/runtime-cache.js b/packages/kit/src/runtime/server/runtime-cache.js new file mode 100644 index 000000000000..6808d6cfb07d --- /dev/null +++ b/packages/kit/src/runtime/server/runtime-cache.js @@ -0,0 +1,233 @@ +/** + * In-memory runtime cache for responses that carry {@link https://developers.cloudflare.com/cache/how-to/cache-tags/ Cache-Tag} + * and `CDN-Cache-Control` from SvelteKit's public cache directive. Used by the Node adapter and Vite dev/preview servers. + */ + +import { SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER } from '../shared.js'; + +// TODO this is all backwards +/** + * Parse `max-age` from `CDN-Cache-Control`. + * @param {Headers} headers + * @returns {number} max-age in seconds, or 0 + */ +export function max_age_from_headers(headers) { + const raw = headers.get('CDN-Cache-Control') || ''; + const m = /max-age=(\d+)/i.exec(raw); + return m ? parseInt(m[1], 10) : 0; +} + +/** + * Parse `stale-while-revalidate` from `CDN-Cache-Control`. + * @param {Headers} headers + * @returns {number} seconds, or 0 + */ +function swr_from_headers(headers) { + const raw = headers.get('CDN-Cache-Control') || ''; + const m = /stale-while-revalidate=(\d+)/i.exec(raw); + return m ? parseInt(m[1], 10) : 0; +} + +/** + * Remove `key` from the tag index (all tag sets and the reverse map). + * Does not remove `key` from the response map; callers do that when needed. + * + * @param {Map>} tag_to_keys + * @param {Map>} key_to_tags + * @param {string} key + */ +function unregister_cache_key(tag_to_keys, key_to_tags, key) { + const tags = key_to_tags.get(key); + if (!tags) return; + + for (const tag of tags) { + const keys = tag_to_keys.get(tag); + if (keys) { + keys.delete(key); + if (keys.size === 0) { + tag_to_keys.delete(tag); + } + } + } + + key_to_tags.delete(key); +} + +/** + * @param {Response} res + * @param {Map} response_cache + * @param {Map>} tag_to_keys + * @param {Map>} key_to_tags + */ +function process_invalidations(res, response_cache, tag_to_keys, key_to_tags) { + const raw = res.headers.get(SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER); + if (!raw) return; + + const tags = raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + for (const tag of tags) { + const keys = tag_to_keys.get(tag); + if (!keys) continue; + + for (const key of [...keys]) { + response_cache.delete(key); + unregister_cache_key(tag_to_keys, key_to_tags, key); + } + } +} + +/** + * @param {string} key + * @param {Response} res + * @param {Map} response_cache + * @param {Map>} tag_to_keys + * @param {Map>} key_to_tags + */ +function store_if_cacheable(key, res, response_cache, tag_to_keys, key_to_tags) { + const max_age = max_age_from_headers(res.headers); + if (max_age <= 0 || !res.ok || res.status !== 200) return; + + const swr = swr_from_headers(res.headers); + const raw_tags = res.headers.get('Cache-Tag') ?? ''; + const tags = raw_tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + unregister_cache_key(tag_to_keys, key_to_tags, key); + + const now = Date.now(); + response_cache.set(key, { + expires: now + max_age * 1000, + stale_expires: now + (max_age + swr) * 1000, + response: res.clone() + }); + + for (const tag of tags) { + let keys = tag_to_keys.get(tag); + if (!keys) { + keys = new Set(); + tag_to_keys.set(tag, keys); + } + keys.add(key); + } + + if (tags.length > 0) { + key_to_tags.set(key, new Set(tags)); + } +} + +export class RuntimeCacheStore { + /** @type {Map} */ + #response_cache = new Map(); + + /** @type {Map>} */ + #tag_to_keys = new Map(); + + /** @type {Map>} */ + #key_to_tags = new Map(); + + /** @type {Set} */ + #revalidating = new Set(); + + /** + * Drop every cached entry and tag index entry. + */ + clear() { + this.#response_cache.clear(); + this.#tag_to_keys.clear(); + this.#key_to_tags.clear(); + this.#revalidating.clear(); + } + + /** + * Invalidate cached entries for the given tags (same semantics as tag invalidation from a response). + * @param {string[]} tags + */ + invalidate_tags(tags) { + for (const tag of tags) { + const keys = this.#tag_to_keys.get(tag); + if (!keys) continue; + for (const key of [...keys]) { + this.#response_cache.delete(key); + unregister_cache_key(this.#tag_to_keys, this.#key_to_tags, key); + } + } + } + + /** + * @template {import('@sveltejs/kit').Server} Server + * @param {Parameters[0]} request + * @param {Parameters[1]} opts + * @param {Server} server + * @returns {Promise} + */ + async respond(request, opts, server) { + const run = () => server.respond(request, opts); + + if (request.method !== 'GET' && request.method !== 'HEAD') { + const res = await run(); + process_invalidations(res, this.#response_cache, this.#tag_to_keys, this.#key_to_tags); + return res; + } + + const key = request.url; + const now = Date.now(); + const hit = this.#response_cache.get(key); + + if (hit) { + if (hit.expires > now) { + return hit.response.clone(); + } + + if (hit.stale_expires > now) { + this.#revalidate(key, request, opts, server); + return hit.response.clone(); + } + } + + const res = await run(); + process_invalidations(res, this.#response_cache, this.#tag_to_keys, this.#key_to_tags); + store_if_cacheable(key, res, this.#response_cache, this.#tag_to_keys, this.#key_to_tags); + + return res; + } + + /** + * @template {import('@sveltejs/kit').Server} Server + * @param {string} key + * @param {Parameters[0]} request + * @param {Parameters[1]} opts + * @param {Server} server + */ + #revalidate(key, request, opts, server) { + if (this.#revalidating.has(key)) return; + this.#revalidating.add(key); + + server + .respond(request.clone(), opts) + .then((res) => { + process_invalidations(res, this.#response_cache, this.#tag_to_keys, this.#key_to_tags); + store_if_cacheable(key, res, this.#response_cache, this.#tag_to_keys, this.#key_to_tags); + }) + .finally(() => { + this.#revalidating.delete(key); + }); + } +} + +const default_runtime_cache_store = new RuntimeCacheStore(); + +/** + * @template {import('@sveltejs/kit').Server} Server + * @param {Parameters[0]} request + * @param {Parameters[1]} opts + * @param {Server} server + * @returns {Promise} + */ +export function with_runtime_cache(request, opts, server) { + return default_runtime_cache_store.respond(request, opts, server); +} diff --git a/packages/kit/src/runtime/server/runtime-cache.spec.js b/packages/kit/src/runtime/server/runtime-cache.spec.js new file mode 100644 index 000000000000..9bb5d591fdd4 --- /dev/null +++ b/packages/kit/src/runtime/server/runtime-cache.spec.js @@ -0,0 +1,125 @@ +import { describe, expect, test, vi } from 'vitest'; +import { max_age_from_headers, RuntimeCacheStore } from './runtime-cache.js'; +import { SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER } from '../shared.js'; + +describe('max_age_from_headers', () => { + test('prefers CDN-Cache-Control over cache-control', () => { + const h = new Headers(); + h.set('CDN-Cache-Control', 'public, max-age=60'); + h.set('cache-control', 'max-age=10'); + expect(max_age_from_headers(h)).toBe(60); + }); + + test('falls back to cache-control', () => { + const h = new Headers(); + h.set('cache-control', 'public, max-age=30'); + expect(max_age_from_headers(h)).toBe(30); + }); + + test('returns 0 when absent', () => { + expect(max_age_from_headers(new Headers())).toBe(0); + }); +}); + +describe('RuntimeCacheStore', () => { + const minimal_opts = { getClientAddress: () => '127.0.0.1' }; + + test('serves fresh cache hit for GET without calling server twice', async () => { + const store = new RuntimeCacheStore(); + const url = 'http://localhost/cache-test'; + const server = { + init: vi.fn(), + respond: vi.fn().mockResolvedValue( + new Response('one', { + status: 200, + headers: { 'CDN-Cache-Control': 'public, max-age=60' } + }) + ) + }; + + const a = await store.respond(new Request(url), minimal_opts, server); + const b = await store.respond(new Request(url), minimal_opts, server); + + expect(await a.text()).toBe('one'); + expect(await b.text()).toBe('one'); + expect(server.respond).toHaveBeenCalledTimes(1); + }); + + test('invalidate_tags evicts matching cached entries', async () => { + const store = new RuntimeCacheStore(); + const url = 'http://localhost/tagged'; + let n = 0; + const server = { + init: vi.fn(), + respond: vi.fn().mockImplementation(() => { + n += 1; + return Promise.resolve( + new Response(String(n), { + status: 200, + headers: { + 'CDN-Cache-Control': 'public, max-age=60', + 'Cache-Tag': 'alpha' + } + }) + ); + }) + }; + + await store.respond(new Request(url), minimal_opts, server); + await store.respond(new Request(url), minimal_opts, server); + expect(server.respond).toHaveBeenCalledTimes(1); + + store.invalidate_tags(['alpha']); + const after = await store.respond(new Request(url), minimal_opts, server); + expect(await after.text()).toBe('2'); + expect(server.respond).toHaveBeenCalledTimes(2); + }); + + test('POST applies invalidations from response', async () => { + const store = new RuntimeCacheStore(); + const get_url = 'http://localhost/x'; + const server = { + init: vi.fn(), + respond: vi + .fn() + .mockResolvedValueOnce( + new Response('cached', { + status: 200, + headers: { + 'CDN-Cache-Control': 'public, max-age=60', + 'Cache-Tag': 't-post' + } + }) + ) + .mockResolvedValueOnce( + new Response(null, { + status: 204, + headers: { [SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER]: 't-post' } + }) + ) + .mockResolvedValue( + new Response('fresh', { + status: 200, + headers: { + 'CDN-Cache-Control': 'public, max-age=60', + 'Cache-Tag': 't-post' + } + }) + ) + }; + + await store.respond(new Request(get_url), minimal_opts, server); + await store.respond(new Request(get_url), minimal_opts, server); + expect(server.respond).toHaveBeenCalledTimes(1); + + await store.respond( + new Request('http://localhost/mutate', { method: 'POST' }), + minimal_opts, + server + ); + + const final = await store.respond(new Request(get_url), minimal_opts, server); + expect(await final.text()).toBe('fresh'); + expect(server.respond).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 5f50788f143c..5733d5e8c0e7 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -20,6 +20,42 @@ export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; export const TRAILING_SLASH_PARAM = 'x-sveltekit-trailing-slash'; +/** Private runtime cache TTL for remote `query` */ +export const SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER = 'x-sveltekit-cache-control'; + +/** Comma-separated tags describing a private remote cache entry (browser Cache API). */ +export const SVELTEKIT_CACHE_CONTROL_TAGS_HEADER = 'x-sveltekit-cache-control-tags'; + +/** Tags to evict from the remote cache; set when `invalidate()` runs on the server. */ +export const SVELTEKIT_CACHE_CONTROL_INVALIDATE_HEADER = 'x-sveltekit-cache-control-invalidate'; + +/** + * Delete cache entries whose `SVELTEKIT_CACHE_CONTROL_TAGS_HEADER` value contains any of the given tags. + * @param {Cache} cache + * @param {Iterable} invalidate_tags + */ +export async function evict_cache_entries_matching_tags(cache, invalidate_tags) { + const tag_set = new Set([...invalidate_tags].map((t) => String(t).trim()).filter(Boolean)); + if (!tag_set.size) return; + + const keys = await cache.keys(); + + for (const req of keys) { + const res = await cache.match(req); + if (!res) continue; + + const raw = res.headers.get(SVELTEKIT_CACHE_CONTROL_TAGS_HEADER) ?? ''; + const entry_tags = raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + if (entry_tags.some((t) => tag_set.has(t))) { + await cache.delete(req); + } + } +} + /** * @param {any} data * @param {string} [location_description] diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js index 48a00e6eb6f1..9dc346c8a527 100644 --- a/packages/kit/src/runtime/shared.spec.js +++ b/packages/kit/src/runtime/shared.spec.js @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { parse_remote_arg, stringify_remote_arg } from './shared.js'; +import { + evict_cache_entries_matching_tags, + parse_remote_arg, + stringify_remote_arg, + SVELTEKIT_CACHE_CONTROL_TAGS_HEADER +} from './shared.js'; class Thing { /** @param {number} a @param {number} z */ @@ -28,6 +33,64 @@ function set(items) { return /** @type {Set} */ (new Set(items)); } +describe('evict_cache_entries_matching_tags', () => { + test('deletes entries whose tag header overlaps', async () => { + /** @type {Map} */ + const entries = new Map(); + + const cache = { + /** @returns {Promise} */ + keys: async () => Array.from(entries.keys(), (u) => new Request(u)), + /** @param {Request} req */ + match: async (req) => entries.get(req.url) ?? null, + /** @param {Request} req */ + delete: async (req) => { + entries.delete(req.url); + } + }; + + const url = 'https://example/remote/r1?payload=x'; + entries.set( + url, + new Response('{}', { + headers: { [SVELTEKIT_CACHE_CONTROL_TAGS_HEADER]: 'alpha,beta' } + }) + ); + + await evict_cache_entries_matching_tags(/** @type {any} */ (cache), ['alpha']); + + expect(entries.size).toBe(0); + }); + + test('ignores entries with no overlap', async () => { + /** @type {Map} */ + const entries = new Map(); + + const cache = { + /** @returns {Promise} */ + keys: async () => Array.from(entries.keys(), (u) => new Request(u)), + /** @param {Request} req */ + match: async (req) => entries.get(req.url) ?? null, + /** @param {Request} req */ + delete: async (req) => { + entries.delete(req.url); + } + }; + + const url = 'https://example/remote/r1'; + entries.set( + url, + new Response('{}', { + headers: { [SVELTEKIT_CACHE_CONTROL_TAGS_HEADER]: 'beta' } + }) + ); + + await evict_cache_entries_matching_tags(/** @type {any} */ (cache), ['alpha']); + + expect(entries.size).toBe(1); + }); +}); + describe('stringify_remote_arg', () => { test('produces the same key for reordered plain object properties', () => { const a = stringify_remote_arg({ limit: 10, offset: 20 }, {}); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index de22d467faab..5c55a44fa38f 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -479,6 +479,12 @@ export interface SSROptions { env_private_prefix: string; hash_routing: boolean; hooks: ServerHooks; + kit_cache_config: { + path: string | undefined; + options: Record; + }; + /** Filled during `Server.init` when `kit.cache.path` is set */ + kit_cache_handler: KitCacheHandler | null; preload_strategy: ValidatedConfig['kit']['output']['preloadStrategy']; root: SSRComponent['default']; service_worker: boolean; @@ -648,6 +654,28 @@ export type RecordSpan = (options: { * Internal state associated with the current `RequestEvent`, * used for tracking things like remote function calls */ +export interface KitCacheDirective { + scope: 'public' | 'private'; + maxAgeSeconds: number; + staleSeconds?: number; + tags: string[]; + refresh: boolean; +} + +export interface KitCacheState { + directive: KitCacheDirective | null; + invalidations: string[]; +} + +export interface KitCacheHandler { + setHeaders?( + headers: Headers, + directive: KitCacheDirective, + ctx: { remote_id?: string | null } + ): MaybePromise; + invalidate?(tags: string[]): MaybePromise; +} + export interface RequestState { readonly prerendering: PrerenderOptions | undefined; readonly transport: ServerHooks['transport']; @@ -664,6 +692,7 @@ export interface RequestState { refreshes: null | Record>; requested: null | Map; validated: null | Map>; + kit_cache?: KitCacheState; }; readonly is_in_remote_function: boolean; readonly is_in_render: boolean; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d23a0a4b7380..90dea2276561 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -302,12 +302,61 @@ declare module '@sveltejs/kit' { platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise; } + /** + * Options for [`event.cache`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent) + */ + export interface CacheOptions { + ttl: string | number; + stale?: string | number; + /** @default 'private' */ + scope?: 'public' | 'private'; + tags?: string[]; + /** @default false */ + refresh?: boolean; + } + + /** + * Normalized cache directive passed to custom `kit.cache` handlers. + */ + export interface KitCacheDirective { + scope: 'public' | 'private'; + maxAgeSeconds: number; + staleSeconds?: number; + tags: string[]; + refresh: boolean; + } + + /** + * Custom cache integration (e.g. platform purge hooks). Export `create` or `default` from `kit.cache.path`. + */ + export interface KitCacheHandler { + setHeaders?( + headers: Headers, + directive: KitCacheDirective, + ctx: { remote_id?: string | null } + ): MaybePromise; + invalidate?(tags: string[]): MaybePromise; + } + + export interface RequestCache { + (arg: CacheOptions | string): void; + invalidate(tags: string[]): void; + } + export interface KitConfig { /** * Your [adapter](https://svelte.dev/docs/kit/adapters) is run when executing `vite build`. It determines how the output is converted for different platforms. * @default undefined */ adapter?: Adapter; + /** + * Optional module that implements [`KitCacheHandler`](https://svelte.dev/docs/kit/@sveltejs-kit#KitCacheHandler) via a `create` or default export function. + */ + cache?: { + /** Absolute or project-relative path resolved from your app root */ + path?: string; + options?: Record; + }; /** * An object containing zero or more aliases used to replace values in `import` statements. These aliases are automatically passed to Vite and TypeScript. * @@ -1528,6 +1577,12 @@ declare module '@sveltejs/kit' { */ isSubRequest: boolean; + /** + * Configure HTTP caching for this response. `public` uses shared caches (CDN / `Cache-Control`); + * `private` applies only to remote query responses stored via the browser Cache API. + */ + cache: RequestCache; + /** * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. * @since 2.31.0 @@ -2152,6 +2207,10 @@ declare module '@sveltejs/kit' { * This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip. */ refresh(): Promise; + /** + * Queue cache invalidation for this query (public or private, depending on how it was cached). + */ + invalidate(): void; /** * Temporarily override a query's value during a [single-flight mutation](https://svelte.dev/docs/kit/remote-functions#Single-flight-mutations) to provide optimistic updates. * @@ -2184,7 +2243,7 @@ declare module '@sveltejs/kit' { ) => RemoteResource; /** - * The return value of a remote `query` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation. + * The return value of a remote `query` function (client stub or shared typing). See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation. */ export type RemoteQueryFunction = ( arg: undefined extends Input ? Input | void : Input From f771d1d1eb339ae544397a113b541c59617e63c1 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 10 Apr 2026 15:26:07 +0200 Subject: [PATCH 2/2] event.cache(...) -> query.cache(...) --- .../20-core-concepts/60-remote-functions.md | 27 +++++++------------ packages/kit/src/exports/public.d.ts | 8 +----- .../src/runtime/app/server/remote/query.js | 20 ++++++++++++-- .../src/runtime/app/server/remote/shared.js | 2 +- packages/kit/src/runtime/server/cache.js | 15 +++++------ packages/kit/src/runtime/server/cache.spec.js | 2 +- packages/kit/src/runtime/server/respond.js | 4 +-- packages/kit/src/types/internal.d.ts | 4 ++- packages/kit/types/index.d.ts | 14 +++++----- 9 files changed, 50 insertions(+), 46 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 1e10928c00cf..491c7a33250e 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -1170,18 +1170,16 @@ Inside `query`, `form` and `prerender` functions it is possible to use the [`red ## Caching -By default, remote functions do not cache their results. You can change this by using the `cache` function from `getRequestEvent`, which allows you to store the result of a remote function for a certain amount of time. +By default, remote functions do not cache their results. You can change this by using `query.cache(...)`, which allows you to store the result of a remote function for a certain amount of time. ```ts /// file: src/routes/data.remote.js // ---cut--- -import { query, getRequestEvent } from '$app/server'; +import { query } from '$app/server'; export const getFastData = query(async () => { - const { cache } = getRequestEvent(); - // cache for 100 seconds - cache('100s'); + query.cache('100s'); return { data: '...' }; }); @@ -1191,12 +1189,10 @@ The `cache` function accepts either a string representing the time-to-live (TTL) ```ts /// file: src/routes/data.remote.js -import { query, getRequestEvent } from '$app/server'; +import { query } from '$app/server'; // ---cut--- export const getFastData = query(async () => { - const { cache } = getRequestEvent(); - - cache({ + query.cache({ // fresh for 1 minute ttl: '1m', // can serve stale up to 5 minutes @@ -1225,8 +1221,7 @@ To invalidate the cache for a specific query, you can call its `invalidate` meth import { query, command } from '$app/server'; export const getFastData = query(async () => { - const { cache } = getRequestEvent(); - cache('100s'); + query.cache('100s'); return { data: '...' }; }); @@ -1237,23 +1232,21 @@ export const updateData = command(async () => { }); ``` -Alternatively, if you used tags when setting up the cache, you can invalidate by tag using `cache.invalidate(...)`: +Alternatively, if you used tags when setting up the cache, you can invalidate by tag using `query.cache.invalidate(...)`: ```ts /// file: src/routes/data.remote.js -import { query, command, getRequestEvent } from '$app/server'; +import { query, command } from '$app/server'; export const getFastData = query(async () => { - const { cache } = getRequestEvent(); - cache({ ttl: '100s', tags: ['my-data'] }); + query.cache({ ttl: '100s', tags: ['my-data'] }); return { data: '...' }; }); export const updateData = command(async () => { - const { cache } = getRequestEvent(); // invalidate all queries using the my-data tag; // the next time someone requests a query which had that tag, it will be called again - cache.invalidate(['my-data']); + query.cache.invalidate(['my-data']); }); ``` diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index fd60514234df..522ce335331c 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -329,7 +329,7 @@ export interface Emulator { } /** - * Options for [`event.cache`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent) + * Options for [`query.cache`](https://svelte.dev/docs/kit/remote-functions#Caching) */ export interface CacheOptions { ttl: string | number; @@ -1603,12 +1603,6 @@ export interface RequestEvent< */ isSubRequest: boolean; - /** - * Configure HTTP caching for this response. `public` uses shared caches (CDN / `Cache-Control`); - * `private` applies only to remote query responses stored via the browser Cache API. - */ - cache: RequestCache; - /** * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. * @since 2.31.0 diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 7b3f7f5def24..5e2673231193 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -95,6 +95,20 @@ export function query(validate_or_fn, maybe_fn) { return wrapper; } +/** + * @param {string | import('@sveltejs/kit').CacheOptions} input + */ +function query_cache(input) { + get_request_store().state.cache(input); +} + +/** + * @param {string[]} tags + */ +function invalidate_query_cache(tags) { + get_request_store().state.cache.invalidate(tags); +} + /** * @param {RemoteQueryInternals} __ * @param {RequestState} state @@ -318,13 +332,13 @@ function create_query_resource(__, arg, state, fn) { return update_refresh_value(refresh_context, value, is_immediate_refresh); }, invalidate() { - const { state, event } = get_request_store(); + const { state } = get_request_store(); // align with how url is constructed on the client, which is used for the cache key const invalidate_key = arg !== undefined ? create_remote_key(__.id, stringify_remote_arg(arg, state.transport)) : __.id; - event.cache.invalidate([invalidate_key]); + invalidate_query_cache([invalidate_key]); }, run() { // potential TODO: if we want to be able to run queries at the top level of modules / outside of the request context, we could technically remove @@ -356,6 +370,8 @@ function create_query_resource(__, arg, state, fn) { // Add batch as a property to the query function Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); +Object.defineProperty(query, 'cache', { value: query_cache, enumerable: true }); +Object.defineProperty(query_cache, 'invalidate', { value: invalidate_query_cache, enumerable: true }); /** * @param {RemoteInternals} __ diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index 733730e77121..7a592fb49543 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -129,7 +129,6 @@ export async function run_remote_function(event, state, allow_cookies, cache, ge const store = { event: { ...event, - cache, setHeaders: () => { throw new Error('setHeaders is not allowed in remote functions'); }, @@ -161,6 +160,7 @@ export async function run_remote_function(event, state, allow_cookies, cache, ge }, state: { ...state, + cache, is_in_remote_function: true } }; diff --git a/packages/kit/src/runtime/server/cache.js b/packages/kit/src/runtime/server/cache.js index d18ae6cb5ca5..e61d44eaa1f9 100644 --- a/packages/kit/src/runtime/server/cache.js +++ b/packages/kit/src/runtime/server/cache.js @@ -73,7 +73,7 @@ export function parse_cache_duration(value) { * @param {string | import('@sveltejs/kit').CacheOptions} input * @returns {NormalizedCacheInput} */ -export function normalize_cache_input(input) { +function normalize_cache_input(input) { if (typeof input === 'string') { return { ttl: parse_cache_duration(input), scope: 'public', refresh: true }; } @@ -102,11 +102,11 @@ export function merge_remote_cache_tags(tags, remote_id) { export function create_erroring_cache() { function cache() { throw new Error( - 'event.cache() can only be used inside remote functions (`query`, `query.batch`, `prerender`)' + 'query.cache() can only be used inside remote functions (`query`, `query.batch`, `prerender`)' ); } cache.invalidate = () => { - throw new Error('event.cache.invalidate() can only be used inside remote functions'); + throw new Error('query.cache.invalidate() can only be used inside remote functions'); }; return cache; } @@ -150,7 +150,7 @@ export function create_request_cache(state, remote_id, arg) { cache.invalidate = () => { // TODO should we allow invalidate instead? throw new Error( - 'event.cache.invalidate() can only be used inside mutating remote functions (`command`, `form`)' + 'query.cache.invalidate() can only be used inside mutating remote functions (`command`, `form`)' ); }; @@ -164,7 +164,7 @@ export function create_request_cache(state, remote_id, arg) { export function create_invalidate_cache(state) { function cache() { throw new Error( - 'event.cache() can only be used inside querying remote functions (`query`, `query.batch`, `prerender`)' + 'query.cache() can only be used inside querying remote functions (`query`, `query.batch`, `prerender`)' ); } @@ -208,9 +208,8 @@ function unique_merge(a, b) { /** * @param {Headers} headers * @param {KitCacheDirective} directive - * @param {{ remote_id?: string | null }} [ctx] */ -export function apply_cache_headers(headers, directive, ctx = {}) { +export function apply_cache_headers(headers, directive) { const tags = directive.tags; if (directive.scope === 'private') { @@ -249,7 +248,7 @@ export async function finalize_kit_cache(response, state, remote_id, handler) { // if (handler?.setHeaders) { // await handler.setHeaders(response.headers, directive, { remote_id }); // } else { - apply_cache_headers(response.headers, directive, { remote_id }); + apply_cache_headers(response.headers, directive); // } } diff --git a/packages/kit/src/runtime/server/cache.spec.js b/packages/kit/src/runtime/server/cache.spec.js index 37df15bd4140..b8c02ef70acf 100644 --- a/packages/kit/src/runtime/server/cache.spec.js +++ b/packages/kit/src/runtime/server/cache.spec.js @@ -17,7 +17,7 @@ describe('parse_cache_duration', () => { test('private scope uses x-sveltekit-cache-control', () => { const h = new Headers(); - apply_cache_headers(h, { scope: 'private', maxAgeSeconds: 42, tags: ['t'], refresh: true }, {}); + apply_cache_headers(h, { scope: 'private', maxAgeSeconds: 42, tags: ['t'], refresh: true }); expect(h.get(SVELTEKIT_RUNTIME_CACHE_CONTROL_HEADER)).toBe('private, max-age=42'); expect(h.get(SVELTEKIT_CACHE_CONTROL_TAGS_HEADER)).toBe('t'); expect(h.get('cache-control')).toBeNull(); diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 69e6ef55b5c7..a9f373ce0228 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -165,6 +165,7 @@ export async function internal_respond(request, options, manifest, state) { */ validated: null }, + cache: create_erroring_cache(), is_in_remote_function: false, is_in_render: false, is_in_universal_load: false @@ -219,8 +220,7 @@ export async function internal_respond(request, options, manifest, state) { url, isDataRequest: is_data_request, isSubRequest: state.depth > 0, - isRemoteRequest: !!remote_id, - cache: create_erroring_cache() + isRemoteRequest: !!remote_id }; event.fetch = create_fetch({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 5c55a44fa38f..62dde47d65eb 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -22,7 +22,8 @@ import { ClientInit, Transport, HandleValidationError, - RemoteFormIssue + RemoteFormIssue, + RequestCache } from '@sveltejs/kit'; import { HttpMethod, @@ -694,6 +695,7 @@ export interface RequestState { validated: null | Map>; kit_cache?: KitCacheState; }; + readonly cache: RequestCache; readonly is_in_remote_function: boolean; readonly is_in_render: boolean; readonly is_in_universal_load: boolean; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 90dea2276561..41063613117d 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -303,7 +303,7 @@ declare module '@sveltejs/kit' { } /** - * Options for [`event.cache`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent) + * Options for [`query.cache`](https://svelte.dev/docs/kit/remote-functions#Caching) */ export interface CacheOptions { ttl: string | number; @@ -1577,12 +1577,6 @@ declare module '@sveltejs/kit' { */ isSubRequest: boolean; - /** - * Configure HTTP caching for this response. `public` uses shared caches (CDN / `Cache-Control`); - * `private` applies only to remote query responses stored via the browser Cache API. - */ - cache: RequestCache; - /** * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. * @since 2.31.0 @@ -3458,6 +3452,12 @@ declare module '$app/server' { * @since 2.35 */ function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; + + function cache(input: string | import("@sveltejs/kit").CacheOptions): void; + namespace cache { + + function invalidate(tags: string[]): void; + } } /** * In the context of a remote `command` or `form` request, returns an iterable