Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/kit-cache-request-event.md
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the URL is the right cache key here... it should probably be the remote function key instead. Also, tags should be additive, not replace the default key.

});

// ...
});
```

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
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const get_defaults = (prefix = '') => ({
extensions: ['.svelte'],
kit: {
adapter: null,
cache: undefined,
alias: {},
appDir: '_app',
csp: {
Expand Down
8 changes: 8 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/exports/internal/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
61 changes: 60 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,61 @@ export interface Emulator {
platform?(details: { config: any; prerender: PrerenderOption }): MaybePromise<App.Platform>;
}

/**
* Options for [`event.cache`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent)
*/
export interface CacheOptions {
ttl: string | number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a type like

`${number}d`  | `${number}h` | `${number}m` | `${number}s`  | `${number}ms`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ms/day don't really make sense imo but otherwise yes; on my list of todos once we agree on the API 👍

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<void>;
invalidate?(tags: string[]): MaybePromise<void>;
}

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<string, unknown>;
};
/**
* An object containing zero or more aliases used to replace values in `import` statements. These aliases are automatically passed to Vite and TypeScript.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2178,6 +2233,10 @@ export type RemoteQuery<T> = RemoteResource<T> & {
* This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip.
*/
refresh(): Promise<void>;
/**
* 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.
*
Expand Down Expand Up @@ -2210,7 +2269,7 @@ export type RemotePrerenderFunction<Input, Output> = (
) => RemoteResource<Output>;

/**
* 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<Input, Output> = (
arg: undefined extends Input ? Input | void : Input
Expand Down
37 changes: 21 additions & 16 deletions packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions packages/kit/src/exports/vite/preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
};
}
Expand Down
10 changes: 9 additions & 1 deletion packages/kit/src/runtime/app/server/remote/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form>` element.
Expand Down Expand Up @@ -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) {
Expand All @@ -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))
);
Expand Down
Loading
Loading