Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
84 changes: 84 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,87 @@ 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 `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 } from '$app/server';

export const getFastData = query(async () => {
// cache for 100 seconds
query.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 } from '$app/server';
// ---cut---
export const getFastData = query(async () => {
query.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 () => {
query.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 `query.cache.invalidate(...)`:

```ts
/// file: src/routes/data.remote.js
import { query, command } from '$app/server';

export const getFastData = query(async () => {
query.cache({ ttl: '100s', tags: ['my-data'] });
return { data: '...' };
});

export const updateData = command(async () => {
// invalidate all queries using the my-data tag;
// the next time someone requests a query which had that tag, it will be called again
query.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';
55 changes: 54 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 [`query.cache`](https://svelte.dev/docs/kit/remote-functions#Caching)
*/
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 @@ -2178,6 +2227,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 +2263,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
10 changes: 9 additions & 1 deletion packages/kit/src/runtime/app/server/remote/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading