@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
612 lines (517 loc) • 17.4 kB
JavaScript
/** @import { RequestEvent } from '@sveltejs/kit' */
/** @import { RemoteForm } from '$app/server' */
/** @import { RemoteFormInternals, RemoteFunctionData, RemoteFunctionResponse, RemoteInternals, RequestState, ServerActionResult } from 'types' */
import { error } from '@sveltejs/kit';
import { Redirect, SvelteKitError } from '@sveltejs/kit/internal';
import { with_request_store, merge_tracing, record_span } from '@sveltejs/kit/internal/server';
import { app_dir, base } from '#app/paths';
import { is_form_content_type } from '../../utils/http.js';
import { create_remote_key, parse_remote_arg, split_remote_key } from '../shared.js';
import { stringify } from '#app/internal/transport';
import { handle_error_and_jsonify } from './errors.js';
import {
action_error_result,
get_action_location,
method_not_allowed_result
} from './page/actions.js';
import { deserialize_binary_form } from '../form-utils.js';
import { text_encoder } from '../utils.js';
import { with_version_header } from './utils.js';
import { manifest } from './internal.js';
/**
* How long (in milliseconds) to wait after the last message was sent before
* sending a `: keep-alive` SSE comment, to prevent proxies/load balancers with
* an idle timeout from closing an otherwise-quiet `query.live` connection.
*/
const KEEP_ALIVE_INTERVAL = 30_000;
/**
* @param {RequestEvent} event
* @param {RequestState} state
* @param {import('types').RemoteQueryLiveInternals} internals
* @param {any} arg
*/
export function create_live_query_response(event, state, internals, arg) {
const cancellation = new AbortController();
const live_event = {
...event,
request: new Request(event.request, {
signal: AbortSignal.any([event.request.signal, cancellation.signal])
})
};
const generator = internals.run(live_event, state, arg);
let open = true;
let pulling = false;
/** @type {ReadableStreamDefaultController<Uint8Array>} */
let stream_controller;
/** @type {ReturnType<typeof setTimeout> | undefined} */
let keep_alive;
/** @type {string | undefined} */
let result;
function schedule_keep_alive() {
clearTimeout(keep_alive);
keep_alive = setTimeout(() => {
if (!open) return;
if ((stream_controller.desiredSize ?? 0) > 0) {
stream_controller.enqueue(text_encoder.encode(': keep-alive\n\n'));
}
schedule_keep_alive();
}, KEEP_ALIVE_INTERVAL);
}
/** @param {any} data */
function send(data) {
if (!open) return;
stream_controller.enqueue(text_encoder.encode('data: ' + JSON.stringify(data) + '\n\n'));
schedule_keep_alive();
}
/** @param {boolean} cancelled */
function teardown(cancelled) {
if (!open) return;
open = false;
clearTimeout(keep_alive);
cancellation.abort();
if (!cancelled) stream_controller.close();
// AsyncGenerator.return() cannot interrupt a pending next(). Cleanup is
// cooperative via request.signal, so stream cancellation must not await it.
void generator.return(undefined).catch(() => {});
}
event.request.signal.addEventListener('abort', () => teardown(true), { once: true });
return new Response(
new ReadableStream({
start(controller) {
stream_controller = controller;
schedule_keep_alive();
},
async pull() {
if (!open || pulling) return;
pulling = true;
try {
while (open) {
const { value, done } = await generator.next();
if (!open) return;
if (done) {
teardown(false);
return;
}
if (result !== (result = stringify(value))) {
send({ type: 'result', result });
return;
}
}
} catch (error) {
if (!open) return;
if (error instanceof Redirect) {
send({ type: 'redirect', location: error.location });
} else {
const transformed = await handle_error_and_jsonify(event, state, error);
send({ type: 'error', error: transformed });
}
teardown(false);
} finally {
pulling = false;
}
},
cancel() {
teardown(true);
}
}),
{
headers: {
'cache-control': 'private, no-store',
'content-type': 'text/event-stream'
}
}
);
}
/** @type {typeof handle_remote_call_internal} */
export async function handle_remote_call(event, state, id) {
return record_span({
name: 'sveltekit.remote.call',
attributes: {
'sveltekit.remote.call.id': id
},
fn: async (current) => {
const traced_event = merge_tracing(event, current);
const response = await with_request_store({ event: traced_event, state }, () =>
handle_remote_call_internal(traced_event, state, id)
);
return with_version_header(response);
}
});
}
/**
* @param {RequestEvent} event
* @param {RequestState} state
* @param {string} id
*/
async function handle_remote_call_internal(event, state, id) {
const [hash, name, additional_args] = id.split('/');
const remotes = manifest.remotes;
if (!Object.hasOwn(remotes, hash)) error(404);
const module = await remotes[hash]();
const fn = Object.hasOwn(module.default, name) ? module.default[name] : undefined;
if (!fn) error(404);
/** @type {RemoteInternals} */
const internals = fn.__;
event.tracing.current.setAttributes({
'sveltekit.remote.call.type': internals.type,
'sveltekit.remote.call.name': internals.name
});
/** @type {HeadersInit | undefined} */
const headers = state.prerendering ? undefined : { 'cache-control': 'private, no-store' };
try {
/** @type {RemoteFunctionData} */
const data = {};
switch (internals.type) {
case 'query_live': {
if (event.request.method !== 'GET') {
throw new SvelteKitError(
405,
'Method Not Allowed',
`\`query.live\` functions must be invoked via GET request, not ${event.request.method}`
);
}
const payload = /** @type {string} */ (
new URL(event.request.url).searchParams.get('payload')
);
return create_live_query_response(event, state, internals, parse_remote_arg(payload));
}
case 'query_batch': {
if (event.request.method !== 'POST') {
throw new SvelteKitError(
405,
'Method Not Allowed',
`\`query.batch\` functions must be invoked via POST request, not ${event.request.method}`
);
}
/** @type {{ payloads: string[] }} */
const { payloads } = await event.request.json();
const args = await Promise.all(payloads.map((payload) => parse_remote_arg(payload)));
data._ = await with_request_store({ event, state }, () => internals.run(args));
break;
}
case 'form': {
if (event.request.method !== 'POST') {
throw new SvelteKitError(
405,
'Method Not Allowed',
`\`form\` functions must be invoked via POST request, not ${event.request.method}`
);
}
if (!is_form_content_type(event.request)) {
throw new SvelteKitError(
415,
'Unsupported Media Type',
`\`form\` functions expect form-encoded data — received ${event.request.headers.get(
'content-type'
)}`
);
}
const {
data: input,
meta,
form_data
} = await deserialize_binary_form(event.request, internals.id);
state.remote.requested = create_requested_map(meta.remote_refreshes);
// If this is a keyed form instance (created via form.for(key)), add the key to the form data (unless already set)
// Note that additional_args will only be set if the form is not enhanced, as enhanced forms transfer the key inside `data`.
if (additional_args && !('id' in input)) {
input.id = JSON.parse(decodeURIComponent(additional_args));
}
const fn = internals.fn;
data._ = await with_request_store(
{ event, state: { ...state, is_in_remote_form_or_command: true } },
() => fn(input, meta, form_data)
);
if (data._.issues) {
// special case — don't serialize refreshes/reconnects
return Response.json(
/** @type {RemoteFunctionResponse} */ ({
type: 'result',
data: stringify(data)
}),
{ headers }
);
}
break;
}
case 'command': {
/** @type {{ payload: string, refreshes?: string[] }} */
const { payload, refreshes } = await event.request.json();
state.remote.requested = create_requested_map(refreshes);
const arg = parse_remote_arg(payload);
data._ = await with_request_store(
{ event, state: { ...state, is_in_remote_form_or_command: true } },
() => fn(arg)
);
break;
}
case 'prerender': {
data._ = await with_request_store({ event, state }, () =>
fn(parse_remote_arg(additional_args))
);
break;
}
case 'query': {
const payload = /** @type {string} */ (
// new URL(...) necessary because we're hiding the URL from the user in the event object
new URL(event.request.url).searchParams.get('payload')
);
data._ = await with_request_store({ event, state }, () => fn(parse_remote_arg(payload)));
break;
}
}
await collect_remote_data(data, event, state);
if (state.remote.ignored?.size) data.i = Array.from(state.remote.ignored);
return Response.json(
/** @type {RemoteFunctionResponse} */ ({
type: 'result',
data: stringify(data)
}),
{ headers }
);
} catch (error) {
if (error instanceof Redirect) {
const data = await collect_remote_data({ redirect: error.location }, event, state);
return Response.json(
/** @type {RemoteFunctionResponse} */ ({
type: 'result',
data: stringify(data)
}),
{ headers }
);
}
const transformed = await handle_error_and_jsonify(event, state, error);
return Response.json(
/** @type {RemoteFunctionResponse} */ ({
type: 'error',
error: transformed
}),
{
// By setting a non-200 during prerendering we fail the prerender process (unless handleHttpError handles it).
// Errors at runtime will be passed to the client and are handled there
status: state.prerendering ? transformed.status : undefined,
headers: {
'cache-control': 'private, no-store'
}
}
);
}
}
/**
* Collects all the query/prerender data that was retrieved
* during the request and adds it to `data`
* @param {RemoteFunctionData} data
* @param {RequestEvent} event
* @param {RequestState} state
*/
export async function collect_remote_data(data, event, state) {
/**
*
* @param {unknown} error
* @returns {Promise<App.Error>}
*/
function convert_error(error) {
// TODO 4.0 remove the `Promise.resolve(...)`
return Promise.resolve(handle_error_and_jsonify(event, state, error));
}
/** @type {Promise<any>[]} */
const promises = [];
// Keys the explicit pass has serialized. Invoking a query's `fn` there can, as a
// side effect, register the same query in `state.remote.implicit` (via
// `get_response`), so we skip those keys in the implicit pass below to avoid
// processing them twice.
/** @type {Set<string>} */
const processed = new Set();
if (state.remote.explicit) {
const { explicit } = state.remote;
/** @type {Promise<void>[]} */
const inflight = [];
const drain = () => {
for (const [remote_key, { internals, fn }] of explicit) {
explicit.delete(remote_key);
if (processed.has(remote_key)) continue;
processed.add(remote_key);
// there were explicit refreshes/reconnects (via `refresh()`/`set()`/`reconnect()`),
// so the client should apply these single-flight updates instead of calling `invalidateAll()`
data.r = true;
const type = /** @type {'p' | 'q' | 'l'} */ (
internals.type === 'query_live' ? 'l' : internals.type[0]
);
// `fn` is deferred until now so the query runs after any state mutations
// in the command/form body. If the query was re-awaited in the meantime,
// `fn` returns the existing (fresh) cache entry rather than re-running.
inflight.push(
fn().then(
(v) => {
// a fresh value replaces the node entirely, so a re-run can't leave
// a stale error from a previous run alongside the new value
(data[type] ??= {})[remote_key] = { v };
drain();
},
async (e) => {
if (!(e instanceof Redirect)) {
// (a Redirect is already handled elsewhere)
(data[type] ??= {})[remote_key] = { e: await convert_error(e) };
}
drain();
}
)
);
}
};
drain();
// `inflight` grows as settles drain newly-refreshed queries
for (const promise of inflight) {
await promise;
}
}
if (state.remote.implicit) {
for (const [internals, record] of state.remote.implicit) {
// Private (non-exported) remote functions have no `id` and must never be
// serialized into the response — otherwise their (potentially private) result
// would be shipped to the client under a malformed `undefined/...` key.
if (!internals.id) continue;
for (const key in record) {
// form outputs are registered under the client-side action id directly
const remote_key = internals.type === 'form' ? key : create_remote_key(internals.id, key);
// already serialized by the explicit pass (which always awaits and wins),
// so don't reprocess it here with the implicit "still loading" heuristic
if (processed.has(remote_key)) continue;
const type = /** @type {'p' | 'q' | 'l' | 'f'} */ (
internals.type === 'query_live' ? 'l' : internals.type[0]
);
const promise = state.remote.data?.get(internals)?.[key] ?? record[key]();
// If the promise is still pending (e.g. the query was rendered in its loading
// state during SSR), omit it from the payload entirely so that the client
// fetches it itself — an entry without `v`/`e` would hydrate as `undefined`.
let resolved = true;
await Promise.race([
Promise.resolve(promise).then(
(v) => {
if (resolved) {
((data[type] ??= {})[remote_key] ??= {}).v = v;
}
},
(e) => {
if (e instanceof Redirect) {
// already handled elsewhere
return;
}
if (resolved) {
promises.push(
convert_error(e).then((e) => {
((data[type] ??= {})[remote_key] ??= {}).e = e;
})
);
}
}
),
Promise.resolve().then(() => (resolved = false))
]);
}
}
}
await Promise.all(promises);
return data;
}
/**
* @param {string[] | undefined} refreshes
*/
function create_requested_map(refreshes) {
/** @type {Map<string, Set<string>>} */
const requested = new Map();
for (const key of refreshes ?? []) {
const parts = split_remote_key(key);
const existing = requested.get(parts.id);
if (existing) {
existing.add(parts.payload);
} else {
requested.set(parts.id, new Set([parts.payload]));
}
}
return requested;
}
/** @type {typeof handle_remote_form_post_internal} */
export async function handle_remote_form_post(event, state, id) {
return record_span({
name: 'sveltekit.remote.form.post',
attributes: {
'sveltekit.remote.form.post.id': id
},
fn: (current) => {
const traced_event = merge_tracing(event, current);
return with_request_store({ event: traced_event, state }, () =>
handle_remote_form_post_internal(traced_event, state, id)
);
}
});
}
/**
* @param {RequestEvent} event
* @param {RequestState} state
* @param {string} id
* @returns {Promise<ServerActionResult>}
*/
async function handle_remote_form_post_internal(event, state, id) {
const location = get_action_location(event.url);
// `hash` and `name` can never contain a `/`, but the JSON-stringified key of a
// keyed (`form.for(key)`) instance can — rejoin the remaining segments
const [hash, name, ...rest] = id.split('/');
const action_id = rest.join('/');
const remotes = manifest.remotes;
const module = Object.hasOwn(remotes, hash) ? await remotes[hash]() : undefined;
let form = /** @type {RemoteForm<any, any>} */ (
module && Object.hasOwn(module.default, name) ? module.default[name] : undefined
);
if (!form) {
return method_not_allowed_result(event, location);
}
if (action_id) {
// @ts-expect-error
form = with_request_store({ event, state }, () => form.for(JSON.parse(action_id)));
}
try {
const __ = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__);
const { data, meta, form_data } = await deserialize_binary_form(event.request, __.id);
if (action_id && !('id' in data)) {
data.id = JSON.parse(decodeURIComponent(action_id));
}
await with_request_store(
{ event, state: { ...state, is_in_remote_form_or_command: true } },
() => __.fn(data, meta, form_data)
);
// We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it.
// It is instead available on `myForm.result`, setting of which happens within the remote `form` function.
return {
type: 'success',
status: 200,
location
};
} catch (e) {
return action_error_result(e, location);
}
}
/**
* @param {URL} url
*/
export function has_remote_prefix(url) {
return url.pathname.startsWith(`${base}/${app_dir}/remote/`);
}
/**
* @param {URL} url
*/
export function strip_remote_prefix(url) {
return url.pathname.replace(`${base}/${app_dir}/remote/`, '');
}
/**
* @param {URL} url
*/
export function get_remote_id(url) {
return has_remote_prefix(url) && strip_remote_prefix(url);
}
/**
* @param {URL} url
*/
export function get_remote_action(url) {
return url.searchParams.get('/remote');
}