INIT
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
export { BROWSER as browser, DEV as dev } from 'esm-env';
|
||||
export { building, version } from '__sveltekit/environment';
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `true` if the app is running in the browser.
|
||||
*/
|
||||
export const browser: boolean;
|
||||
|
||||
/**
|
||||
* Whether the dev server is running. This is not guaranteed to correspond to `NODE_ENV` or `MODE`.
|
||||
*/
|
||||
export const dev: boolean;
|
||||
|
||||
/**
|
||||
* SvelteKit analyses your app during the `build` step by running it. During this process, `building` is `true`. This also applies during prerendering.
|
||||
*/
|
||||
export const building: boolean;
|
||||
|
||||
/**
|
||||
* The value of `config.kit.version.name`.
|
||||
*/
|
||||
export const version: string;
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import * as devalue from 'devalue';
|
||||
import { BROWSER, DEV } from 'esm-env';
|
||||
import { invalidateAll } from './navigation.js';
|
||||
import { app as client_app, applyAction } from '../client/client.js';
|
||||
import { app as server_app } from '../server/app.js';
|
||||
|
||||
export { applyAction };
|
||||
|
||||
/**
|
||||
* Use this function to deserialize the response from a form submission.
|
||||
* Usage:
|
||||
*
|
||||
* ```js
|
||||
* import { deserialize } from '$app/forms';
|
||||
*
|
||||
* async function handleSubmit(event) {
|
||||
* const response = await fetch('/form?/action', {
|
||||
* method: 'POST',
|
||||
* body: new FormData(event.target)
|
||||
* });
|
||||
*
|
||||
* const result = deserialize(await response.text());
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
* @template {Record<string, unknown> | undefined} Success
|
||||
* @template {Record<string, unknown> | undefined} Failure
|
||||
* @param {string} result
|
||||
* @returns {import('@sveltejs/kit').ActionResult<Success, Failure>}
|
||||
*/
|
||||
export function deserialize(result) {
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
if (parsed.data) {
|
||||
// the decoders should never be initialised at the top-level because `app`
|
||||
// will not be initialised yet if `kit.output.bundleStrategy` is 'single' or 'inline'
|
||||
parsed.data = devalue.parse(parsed.data, BROWSER ? client_app.decoders : server_app.decoders);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow clone an element, so that we can access e.g. `form.action` without worrying
|
||||
* that someone has added an `<input name="action">` (https://github.com/sveltejs/kit/issues/7593)
|
||||
* @template {HTMLElement} T
|
||||
* @param {T} element
|
||||
* @returns {T}
|
||||
*/
|
||||
function clone(element) {
|
||||
return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* This action enhances a `<form>` element that otherwise would work without JavaScript.
|
||||
*
|
||||
* The `submit` function is called upon submission with the given FormData and the `action` that should be triggered.
|
||||
* If `cancel` is called, the form will not be submitted.
|
||||
* You can use the abort `controller` to cancel the submission in case another one starts.
|
||||
* If a function is returned, that function is called with the response from the server.
|
||||
* If nothing is returned, the fallback will be used.
|
||||
*
|
||||
* If this function or its return value isn't set, it
|
||||
* - falls back to updating the `form` prop with the returned data if the action is on the same page as the form
|
||||
* - updates `page.status`
|
||||
* - resets the `<form>` element and invalidates all data in case of successful submission with no redirect response
|
||||
* - redirects in case of a redirect response
|
||||
* - redirects to the nearest error page in case of an unexpected error
|
||||
*
|
||||
* If you provide a custom function with a callback and want to use the default behavior, invoke `update` in your callback.
|
||||
* It accepts an options object
|
||||
* - `reset: false` if you don't want the `<form>` values to be reset after a successful submission
|
||||
* - `invalidateAll: false` if you don't want the action to call `invalidateAll` after submission
|
||||
* @template {Record<string, unknown> | undefined} Success
|
||||
* @template {Record<string, unknown> | undefined} Failure
|
||||
* @param {HTMLFormElement} form_element The form element
|
||||
* @param {import('@sveltejs/kit').SubmitFunction<Success, Failure>} submit Submit callback
|
||||
*/
|
||||
export function enhance(form_element, submit = () => {}) {
|
||||
if (DEV && clone(form_element).method !== 'post') {
|
||||
throw new Error('use:enhance can only be used on <form> fields with method="POST"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* action: URL;
|
||||
* invalidateAll?: boolean;
|
||||
* result: import('@sveltejs/kit').ActionResult;
|
||||
* reset?: boolean
|
||||
* }} opts
|
||||
*/
|
||||
const fallback_callback = async ({
|
||||
action,
|
||||
result,
|
||||
reset = true,
|
||||
invalidateAll: shouldInvalidateAll = true
|
||||
}) => {
|
||||
if (result.type === 'success') {
|
||||
if (reset) {
|
||||
// We call reset from the prototype to avoid DOM clobbering
|
||||
HTMLFormElement.prototype.reset.call(form_element);
|
||||
}
|
||||
if (shouldInvalidateAll) {
|
||||
await invalidateAll();
|
||||
}
|
||||
}
|
||||
|
||||
// For success/failure results, only apply action if it belongs to the
|
||||
// current page, otherwise `form` will be updated erroneously
|
||||
if (
|
||||
location.origin + location.pathname === action.origin + action.pathname ||
|
||||
result.type === 'redirect' ||
|
||||
result.type === 'error'
|
||||
) {
|
||||
await applyAction(result);
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {SubmitEvent} event */
|
||||
async function handle_submit(event) {
|
||||
const method = event.submitter?.hasAttribute('formmethod')
|
||||
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod
|
||||
: clone(form_element).method;
|
||||
if (method !== 'post') return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const action = new URL(
|
||||
// We can't do submitter.formAction directly because that property is always set
|
||||
event.submitter?.hasAttribute('formaction')
|
||||
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction
|
||||
: clone(form_element).action
|
||||
);
|
||||
|
||||
const enctype = event.submitter?.hasAttribute('formenctype')
|
||||
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formEnctype
|
||||
: clone(form_element).enctype;
|
||||
|
||||
const form_data = new FormData(form_element, event.submitter);
|
||||
|
||||
if (DEV && enctype !== 'multipart/form-data') {
|
||||
for (const value of form_data.values()) {
|
||||
if (value instanceof File) {
|
||||
throw new Error(
|
||||
'Your form contains <input type="file"> fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
let cancelled = false;
|
||||
const cancel = () => (cancelled = true);
|
||||
|
||||
const callback =
|
||||
(await submit({
|
||||
action,
|
||||
cancel,
|
||||
controller,
|
||||
formData: form_data,
|
||||
formElement: form_element,
|
||||
submitter: event.submitter
|
||||
})) ?? fallback_callback;
|
||||
if (cancelled) return;
|
||||
|
||||
/** @type {import('@sveltejs/kit').ActionResult} */
|
||||
let result;
|
||||
|
||||
try {
|
||||
const headers = new Headers({
|
||||
accept: 'application/json',
|
||||
'x-sveltekit-action': 'true'
|
||||
});
|
||||
|
||||
// do not explicitly set the `Content-Type` header when sending `FormData`
|
||||
// or else it will interfere with the browser's header setting
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sect4
|
||||
if (enctype !== 'multipart/form-data') {
|
||||
headers.set(
|
||||
'Content-Type',
|
||||
/^(:?application\/x-www-form-urlencoded|text\/plain)$/.test(enctype)
|
||||
? enctype
|
||||
: 'application/x-www-form-urlencoded'
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error `URLSearchParams(form_data)` is kosher, but typescript doesn't know that
|
||||
const body = enctype === 'multipart/form-data' ? form_data : new URLSearchParams(form_data);
|
||||
|
||||
const response = await fetch(action, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
body,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
result = deserialize(await response.text());
|
||||
if (result.type === 'error') result.status = response.status;
|
||||
} catch (error) {
|
||||
if (/** @type {any} */ (error)?.name === 'AbortError') return;
|
||||
result = { type: 'error', error };
|
||||
}
|
||||
|
||||
await callback({
|
||||
action,
|
||||
formData: form_data,
|
||||
formElement: form_element,
|
||||
update: (opts) =>
|
||||
fallback_callback({
|
||||
action,
|
||||
result,
|
||||
reset: opts?.reset,
|
||||
invalidateAll: opts?.invalidateAll
|
||||
}),
|
||||
// @ts-expect-error generic constraints stuff we don't care about
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
HTMLFormElement.prototype.addEventListener.call(form_element, 'submit', handle_submit);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
// @ts-expect-error
|
||||
HTMLFormElement.prototype.removeEventListener.call(form_element, 'submit', handle_submit);
|
||||
}
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
afterNavigate,
|
||||
beforeNavigate,
|
||||
disableScrollHandling,
|
||||
goto,
|
||||
invalidate,
|
||||
invalidateAll,
|
||||
refreshAll,
|
||||
onNavigate,
|
||||
preloadCode,
|
||||
preloadData,
|
||||
pushState,
|
||||
replaceState
|
||||
} from '../client/client.js';
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/** @import { Asset, RouteId, Pathname, ResolvedPathname } from '$app/types' */
|
||||
/** @import { ResolveArgs } from './types.js' */
|
||||
import { base, assets, hash_routing } from './internal/client.js';
|
||||
import { resolve_route } from '../../../utils/routing.js';
|
||||
import { get_navigation_intent } from '../../client/client.js';
|
||||
|
||||
/**
|
||||
* Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path.
|
||||
*
|
||||
* During server rendering, the base path is relative and depends on the page currently being rendered.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { asset } from '$app/paths';
|
||||
* </script>
|
||||
*
|
||||
* <img alt="a potato" src={asset('/potato.jpg')} />
|
||||
* ```
|
||||
* @since 2.26
|
||||
*
|
||||
* @param {Asset} file
|
||||
* @returns {string}
|
||||
*/
|
||||
export function asset(file) {
|
||||
return (assets || base) + file;
|
||||
}
|
||||
|
||||
const pathname_prefix = hash_routing ? '#' : '';
|
||||
|
||||
/**
|
||||
* Resolve a pathname by prefixing it with the base path, if any, or resolve a route ID by populating dynamic segments with parameters.
|
||||
*
|
||||
* During server rendering, the base path is relative and depends on the page currently being rendered.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* import { resolve } from '$app/paths';
|
||||
*
|
||||
* // using a pathname
|
||||
* const resolved = resolve(`/blog/hello-world`);
|
||||
*
|
||||
* // using a route ID plus parameters
|
||||
* const resolved = resolve('/blog/[slug]', {
|
||||
* slug: 'hello-world'
|
||||
* });
|
||||
* ```
|
||||
* @since 2.26
|
||||
*
|
||||
* @template {RouteId | Pathname} T
|
||||
* @param {ResolveArgs<T>} args
|
||||
* @returns {ResolvedPathname}
|
||||
*/
|
||||
export function resolve(...args) {
|
||||
// The type error is correct here, and if someone doesn't pass params when they should there's a runtime error,
|
||||
// but we don't want to adjust the internal resolve_route function to accept `undefined`, hence the type cast.
|
||||
return (
|
||||
base + pathname_prefix + resolve_route(args[0], /** @type {Record<string, string>} */ (args[1]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a path or URL to a route ID and extracts any parameters.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* import { match } from '$app/paths';
|
||||
*
|
||||
* const route = await match('/blog/hello-world');
|
||||
*
|
||||
* if (route?.id === '/blog/[slug]') {
|
||||
* const slug = route.params.slug;
|
||||
* const response = await fetch(`/api/posts/${slug}`);
|
||||
* const post = await response.json();
|
||||
* }
|
||||
* ```
|
||||
* @since 2.52.0
|
||||
*
|
||||
* @param {Pathname | URL | (string & {})} url
|
||||
* @returns {Promise<{ id: RouteId, params: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function match(url) {
|
||||
if (typeof url === 'string') {
|
||||
url = new URL(url, location.href);
|
||||
}
|
||||
|
||||
const intent = await get_navigation_intent(url, false);
|
||||
|
||||
if (intent) {
|
||||
return {
|
||||
id: /** @type {RouteId} */ (intent.route.id),
|
||||
params: intent.params
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { base, assets, resolve as resolveRoute };
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from '#app/paths';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const base = __SVELTEKIT_PAYLOAD__?.base ?? __SVELTEKIT_PATHS_BASE__;
|
||||
export const assets = __SVELTEKIT_PAYLOAD__?.assets ?? base ?? __SVELTEKIT_PATHS_ASSETS__;
|
||||
export const app_dir = __SVELTEKIT_APP_DIR__;
|
||||
export const hash_routing = __SVELTEKIT_HASH_ROUTING__;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
export let base = __SVELTEKIT_PATHS_BASE__;
|
||||
export let assets = __SVELTEKIT_PATHS_ASSETS__ || base;
|
||||
export const app_dir = __SVELTEKIT_APP_DIR__;
|
||||
export const relative = __SVELTEKIT_PATHS_RELATIVE__;
|
||||
|
||||
const initial = { base, assets };
|
||||
|
||||
/**
|
||||
* `base` could be overridden during rendering to be relative;
|
||||
* this one's the original non-relative base path
|
||||
*/
|
||||
export const initial_base = initial.base;
|
||||
|
||||
/**
|
||||
* @param {{ base: string, assets: string }} paths
|
||||
*/
|
||||
export function override(paths) {
|
||||
base = paths.base;
|
||||
assets = paths.assets;
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
base = initial.base;
|
||||
assets = initial.assets;
|
||||
}
|
||||
|
||||
/** @param {string} path */
|
||||
export function set_assets(path) {
|
||||
assets = initial.assets = path;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { RouteId, Pathname, ResolvedPathname } from '$app/types';
|
||||
import { ResolveArgs } from './types.js';
|
||||
|
||||
export { resolve, asset, match } from './client.js';
|
||||
|
||||
/**
|
||||
* A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths).
|
||||
*
|
||||
* Example usage: `<a href="{base}/your-page">Link</a>`
|
||||
*
|
||||
* @deprecated Use [`resolve(...)`](https://svelte.dev/docs/kit/$app-paths#resolve) instead
|
||||
*/
|
||||
export let base: '' | `/${string}`;
|
||||
|
||||
/**
|
||||
* An absolute path that matches [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths).
|
||||
*
|
||||
* > [!NOTE] If a value for `config.kit.paths.assets` is specified, it will be replaced with `'/_svelte_kit_assets'` during `vite dev` or `vite preview`, since the assets don't yet live at their eventual URL.
|
||||
*
|
||||
* @deprecated Use [`asset(...)`](https://svelte.dev/docs/kit/$app-paths#asset) instead
|
||||
*/
|
||||
export let assets: '' | `https://${string}` | `http://${string}` | '/_svelte_kit_assets';
|
||||
|
||||
/**
|
||||
* @deprecated Use [`resolve(...)`](https://svelte.dev/docs/kit/$app-paths#resolve) instead
|
||||
*/
|
||||
export function resolveRoute<T extends RouteId | Pathname>(
|
||||
...args: ResolveArgs<T>
|
||||
): ResolvedPathname;
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { base, assets, relative, initial_base } from './internal/server.js';
|
||||
import { resolve_route, find_route } from '../../../utils/routing.js';
|
||||
import { decode_pathname } from '../../../utils/url.js';
|
||||
import { try_get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { manifest } from '__sveltekit/server';
|
||||
import { get_hooks } from '__SERVER__/internal.js';
|
||||
|
||||
/** @type {import('./client.js').asset} */
|
||||
export function asset(file) {
|
||||
// @ts-expect-error we use the `resolve` mechanism, but with the 'wrong' input
|
||||
return assets ? assets + file : resolve(file);
|
||||
}
|
||||
|
||||
/** @type {import('./client.js').resolve} */
|
||||
export function resolve(id, params) {
|
||||
const resolved = resolve_route(id, /** @type {Record<string, string>} */ (params));
|
||||
|
||||
if (relative) {
|
||||
const store = try_get_request_store();
|
||||
|
||||
if (store && !store.state.prerendering?.fallback) {
|
||||
const after_base = store.event.url.pathname.slice(initial_base.length);
|
||||
const segments = after_base.split('/').slice(2);
|
||||
const prefix = segments.map(() => '..').join('/') || '.';
|
||||
|
||||
return prefix + resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return base + resolved;
|
||||
}
|
||||
|
||||
/** @type {import('./client.js').match} */
|
||||
export async function match(url) {
|
||||
const store = try_get_request_store();
|
||||
|
||||
if (typeof url === 'string') {
|
||||
const origin = store?.event.url.origin ?? 'http://internal';
|
||||
url = new URL(url, origin);
|
||||
}
|
||||
|
||||
const { reroute } = await get_hooks();
|
||||
|
||||
let resolved_path = url.pathname;
|
||||
|
||||
try {
|
||||
resolved_path = decode_pathname(
|
||||
(await reroute?.({ url: new URL(url), fetch: store?.event.fetch ?? fetch })) ?? url.pathname
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (base && resolved_path.startsWith(base)) {
|
||||
resolved_path = resolved_path.slice(base.length) || '/';
|
||||
}
|
||||
|
||||
const matchers = await manifest._.matchers();
|
||||
const result = find_route(resolved_path, manifest._.routes, matchers);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
id: /** @type {import('$app/types').RouteId} */ (result.route.id),
|
||||
params: result.params
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { base, assets, resolve as resolveRoute };
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { Pathname, RouteId, RouteParams } from '$app/types';
|
||||
|
||||
export type ResolveArgs<T extends RouteId | Pathname> = T extends RouteId
|
||||
? RouteParams<T> extends Record<string, never>
|
||||
? [route: T]
|
||||
: [route: T, params: RouteParams<T>]
|
||||
: [route: T];
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { read_implementation, manifest } from '__sveltekit/server';
|
||||
import { base } from '$app/paths';
|
||||
import { DEV } from 'esm-env';
|
||||
import { base64_decode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Read the contents of an imported asset from the filesystem
|
||||
* @example
|
||||
* ```js
|
||||
* import { read } from '$app/server';
|
||||
* import somefile from './somefile.txt';
|
||||
*
|
||||
* const asset = read(somefile);
|
||||
* const text = await asset.text();
|
||||
* ```
|
||||
* @param {string} asset
|
||||
* @returns {Response}
|
||||
* @since 2.4.0
|
||||
*/
|
||||
export function read(asset) {
|
||||
__SVELTEKIT_TRACK__('$app/server:read');
|
||||
|
||||
if (!read_implementation) {
|
||||
throw new Error(
|
||||
'No `read` implementation was provided. Please ensure that your adapter is up to date and supports this feature'
|
||||
);
|
||||
}
|
||||
|
||||
// handle inline assets internally
|
||||
const match = /^data:([^;,]+)?(;base64)?,/.exec(asset);
|
||||
if (match) {
|
||||
const type = match[1] ?? 'application/octet-stream';
|
||||
const data = asset.slice(match[0].length);
|
||||
|
||||
if (match[2] !== undefined) {
|
||||
const decoded = base64_decode(data);
|
||||
|
||||
// @ts-ignore passing a Uint8Array to `new Response(...)` is fine
|
||||
return new Response(decoded, {
|
||||
headers: {
|
||||
'Content-Length': decoded.byteLength.toString(),
|
||||
'Content-Type': type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(data);
|
||||
|
||||
return new Response(decoded, {
|
||||
headers: {
|
||||
'Content-Length': decoded.length.toString(),
|
||||
'Content-Type': type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const file = decodeURIComponent(
|
||||
DEV && asset.startsWith('/@fs') ? asset : asset.slice(base.length + 1)
|
||||
);
|
||||
|
||||
if (file in manifest._.server_assets) {
|
||||
const length = manifest._.server_assets[file];
|
||||
const type = manifest.mimeTypes[file.slice(file.lastIndexOf('.'))];
|
||||
|
||||
return new Response(read_implementation(file), {
|
||||
headers: {
|
||||
'Content-Length': '' + length,
|
||||
'Content-Type': type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Asset does not exist: ${file}`);
|
||||
}
|
||||
|
||||
export { getRequestEvent } from '@sveltejs/kit/internal/server';
|
||||
|
||||
export { query, prerender, command, form } from './remote/index.js';
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/** @import { RemoteCommand } from '@sveltejs/kit' */
|
||||
/** @import { RemoteInfo, MaybePromise } from 'types' */
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
import { get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { create_validator, run_remote_function } from './shared.js';
|
||||
|
||||
/**
|
||||
* Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#command) for full documentation.
|
||||
*
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {() => Output} fn
|
||||
* @returns {RemoteCommand<void, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#command) for full documentation.
|
||||
*
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {'unchecked'} validate
|
||||
* @param {(arg: Input) => Output} fn
|
||||
* @returns {RemoteCommand<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#command) for full documentation.
|
||||
*
|
||||
* @template {StandardSchemaV1} Schema
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {Schema} validate
|
||||
* @param {(arg: StandardSchemaV1.InferOutput<Schema>) => Output} fn
|
||||
* @returns {RemoteCommand<StandardSchemaV1.InferInput<Schema>, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @param {any} validate_or_fn
|
||||
* @param {(arg?: Input) => Output} [maybe_fn]
|
||||
* @returns {RemoteCommand<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
export function command(validate_or_fn, maybe_fn) {
|
||||
/** @type {(arg?: Input) => Output} */
|
||||
const fn = maybe_fn ?? validate_or_fn;
|
||||
|
||||
/** @type {(arg?: any) => MaybePromise<Input>} */
|
||||
const validate = create_validator(validate_or_fn, maybe_fn);
|
||||
|
||||
/** @type {RemoteInfo} */
|
||||
const __ = { type: 'command', id: '', name: '' };
|
||||
|
||||
/** @type {RemoteCommand<Input, Output> & { __: RemoteInfo }} */
|
||||
const wrapper = (arg) => {
|
||||
const { event, state } = get_request_store();
|
||||
|
||||
if (state.is_endpoint_request) {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.request.method)) {
|
||||
throw new Error(
|
||||
`Cannot call a command (\`${__.name}(${maybe_fn ? '...' : ''})\`) from a ${event.request.method} handler`
|
||||
);
|
||||
}
|
||||
} else if (!event.isRemoteRequest) {
|
||||
throw new Error(
|
||||
`Cannot call a command (\`${__.name}(${maybe_fn ? '...' : ''})\`) during server-side rendering`
|
||||
);
|
||||
}
|
||||
|
||||
state.refreshes ??= {};
|
||||
|
||||
const promise = Promise.resolve(
|
||||
run_remote_function(event, state, true, () => validate(arg), fn)
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
promise.updates = () => {
|
||||
throw new Error(`Cannot call '${__.name}(...).updates(...)' on the server`);
|
||||
};
|
||||
|
||||
return /** @type {ReturnType<RemoteCommand<Input, Output>>} */ (promise);
|
||||
};
|
||||
|
||||
Object.defineProperty(wrapper, '__', { value: __ });
|
||||
|
||||
// On the server, pending is always 0
|
||||
Object.defineProperty(wrapper, 'pending', {
|
||||
get: () => 0
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
/** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */
|
||||
/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
import { get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { DEV } from 'esm-env';
|
||||
import {
|
||||
create_field_proxy,
|
||||
set_nested_value,
|
||||
throw_on_old_property_access,
|
||||
deep_set,
|
||||
normalize_issue,
|
||||
flatten_issues
|
||||
} from '../../../form-utils.js';
|
||||
import { get_cache, run_remote_function } from './shared.js';
|
||||
import { ValidationError } from '@sveltejs/kit/internal';
|
||||
|
||||
/**
|
||||
* Creates a form object that can be spread onto a `<form>` element.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
|
||||
*
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {() => MaybePromise<Output>} fn
|
||||
* @returns {RemoteForm<void, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a form object that can be spread onto a `<form>` element.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
|
||||
*
|
||||
* @template {RemoteFormInput} Input
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {'unchecked'} validate
|
||||
* @param {(data: Input, issue: InvalidField<Input>) => MaybePromise<Output>} fn
|
||||
* @returns {RemoteForm<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a form object that can be spread onto a `<form>` element.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
|
||||
*
|
||||
* @template {StandardSchemaV1<RemoteFormInput, Record<string, any>>} Schema
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {Schema} validate
|
||||
* @param {(data: StandardSchemaV1.InferOutput<Schema>, issue: InvalidField<StandardSchemaV1.InferInput<Schema>>) => MaybePromise<Output>} fn
|
||||
* @returns {RemoteForm<StandardSchemaV1.InferInput<Schema>, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* @template {RemoteFormInput} Input
|
||||
* @template Output
|
||||
* @param {any} validate_or_fn
|
||||
* @param {(data_or_issue: any, issue?: any) => MaybePromise<Output>} [maybe_fn]
|
||||
* @returns {RemoteForm<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
// @ts-ignore we don't want to prefix `fn` with an underscore, as that will be user-visible
|
||||
export function form(validate_or_fn, maybe_fn) {
|
||||
/** @type {any} */
|
||||
const fn = maybe_fn ?? validate_or_fn;
|
||||
|
||||
/** @type {StandardSchemaV1 | null} */
|
||||
const schema =
|
||||
!maybe_fn || validate_or_fn === 'unchecked' ? null : /** @type {any} */ (validate_or_fn);
|
||||
|
||||
/**
|
||||
* @param {string | number | boolean} [key]
|
||||
*/
|
||||
function create_instance(key) {
|
||||
/** @type {RemoteForm<Input, Output>} */
|
||||
const instance = {};
|
||||
|
||||
instance.method = 'POST';
|
||||
|
||||
Object.defineProperty(instance, 'enhance', {
|
||||
value: () => {
|
||||
return { action: instance.action, method: instance.method };
|
||||
}
|
||||
});
|
||||
|
||||
/** @type {RemoteInfo} */
|
||||
const __ = {
|
||||
type: 'form',
|
||||
name: '',
|
||||
id: '',
|
||||
fn: async (data, meta, form_data) => {
|
||||
// TODO 3.0 remove this warning
|
||||
if (DEV && !data) {
|
||||
const error = () => {
|
||||
throw new Error(
|
||||
'Remote form functions no longer get passed a FormData object. ' +
|
||||
"`form` now has the same signature as `query` or `command`, i.e. it expects to be invoked like `form(schema, callback)` or `form('unchecked', callback)`. " +
|
||||
'The payload of the callback function is now a POJO instead of a FormData object. See https://kit.svelte.dev/docs/remote-functions#form for details.'
|
||||
);
|
||||
};
|
||||
data = {};
|
||||
for (const key of [
|
||||
'append',
|
||||
'delete',
|
||||
'entries',
|
||||
'forEach',
|
||||
'get',
|
||||
'getAll',
|
||||
'has',
|
||||
'keys',
|
||||
'set',
|
||||
'values'
|
||||
]) {
|
||||
Object.defineProperty(data, key, { get: error });
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ submission: true, input?: Record<string, any>, issues?: InternalRemoteFormIssue[], result: Output }} */
|
||||
const output = {};
|
||||
|
||||
// make it possible to differentiate between user submission and programmatic `field.set(...)` updates
|
||||
output.submission = true;
|
||||
|
||||
const { event, state } = get_request_store();
|
||||
const validated = await schema?.['~standard'].validate(data);
|
||||
|
||||
if (meta.validate_only) {
|
||||
return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? [];
|
||||
}
|
||||
|
||||
if (validated?.issues !== undefined) {
|
||||
handle_issues(output, validated.issues, form_data);
|
||||
} else {
|
||||
if (validated !== undefined) {
|
||||
data = validated.value;
|
||||
}
|
||||
|
||||
state.refreshes ??= {};
|
||||
|
||||
const issue = create_issues();
|
||||
|
||||
try {
|
||||
output.result = await run_remote_function(
|
||||
event,
|
||||
state,
|
||||
true,
|
||||
() => data,
|
||||
(data) => (!maybe_fn ? fn() : fn(data, issue))
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) {
|
||||
handle_issues(output, e.issues, form_data);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads
|
||||
// where only one form submission is active at the same time
|
||||
if (!event.isRemoteRequest) {
|
||||
get_cache(__, state)[''] ??= output;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(instance, '__', { value: __ });
|
||||
|
||||
Object.defineProperty(instance, 'action', {
|
||||
get: () => `?/remote=${__.id}`,
|
||||
enumerable: true
|
||||
});
|
||||
|
||||
Object.defineProperty(instance, 'fields', {
|
||||
get() {
|
||||
const data = get_cache(__)?.[''];
|
||||
const issues = flatten_issues(data?.issues ?? []);
|
||||
|
||||
return create_field_proxy(
|
||||
{},
|
||||
() => data?.input ?? {},
|
||||
(path, value) => {
|
||||
if (data?.submission) {
|
||||
// don't override a submission
|
||||
return;
|
||||
}
|
||||
|
||||
const input =
|
||||
path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value);
|
||||
|
||||
(get_cache(__)[''] ??= {}).input = input;
|
||||
},
|
||||
() => issues
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO 3.0 remove
|
||||
if (DEV) {
|
||||
throw_on_old_property_access(instance);
|
||||
|
||||
Object.defineProperty(instance, 'buttonProps', {
|
||||
get() {
|
||||
throw new Error(
|
||||
'`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
|
||||
' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(instance, 'result', {
|
||||
get() {
|
||||
try {
|
||||
return get_cache(__)?.['']?.result;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On the server, pending is always 0
|
||||
Object.defineProperty(instance, 'pending', {
|
||||
get: () => 0
|
||||
});
|
||||
|
||||
Object.defineProperty(instance, 'preflight', {
|
||||
// preflight is a noop on the server
|
||||
value: () => instance
|
||||
});
|
||||
|
||||
Object.defineProperty(instance, 'validate', {
|
||||
value: () => {
|
||||
throw new Error('Cannot call validate() on the server');
|
||||
}
|
||||
});
|
||||
|
||||
if (key == undefined) {
|
||||
Object.defineProperty(instance, 'for', {
|
||||
/** @type {RemoteForm<any, any>['for']} */
|
||||
value: (key) => {
|
||||
const { state } = get_request_store();
|
||||
const cache_key = __.id + '|' + JSON.stringify(key);
|
||||
let instance = (state.form_instances ??= new Map()).get(cache_key);
|
||||
|
||||
if (!instance) {
|
||||
instance = create_instance(key);
|
||||
instance.__.id = `${__.id}/${encodeURIComponent(JSON.stringify(key))}`;
|
||||
instance.__.name = __.name;
|
||||
|
||||
state.form_instances.set(cache_key, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
return create_instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
|
||||
* @param {readonly StandardSchemaV1.Issue[]} issues
|
||||
* @param {FormData | null} form_data - null if the form is progressively enhanced
|
||||
*/
|
||||
function handle_issues(output, issues, form_data) {
|
||||
output.issues = issues.map((issue) => normalize_issue(issue, true));
|
||||
|
||||
// if it was a progressively-enhanced submission, we don't need
|
||||
// to return the input — it's already there
|
||||
if (form_data) {
|
||||
output.input = {};
|
||||
|
||||
for (let key of form_data.keys()) {
|
||||
// redact sensitive fields
|
||||
if (/^[.\]]?_/.test(key)) continue;
|
||||
|
||||
const is_array = key.endsWith('[]');
|
||||
const values = form_data.getAll(key).filter((value) => typeof value === 'string');
|
||||
|
||||
if (is_array) key = key.slice(0, -2);
|
||||
|
||||
set_nested_value(
|
||||
/** @type {Record<string, any>} */ (output.input),
|
||||
key,
|
||||
is_array ? values : values[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an invalid function that can be used to imperatively mark form fields as invalid
|
||||
* @returns {InvalidField<any>}
|
||||
*/
|
||||
function create_issues() {
|
||||
return /** @type {InvalidField<any>} */ (
|
||||
new Proxy(
|
||||
/** @param {string} message */
|
||||
(message) => {
|
||||
// TODO 3.0 remove
|
||||
if (typeof message !== 'string') {
|
||||
throw new Error(
|
||||
'`invalid` should now be imported from `@sveltejs/kit` to throw validation issues. ' +
|
||||
"The second parameter provided to the form function (renamed to `issue`) is still used to construct issues, e.g. `invalid(issue.field('message'))`. " +
|
||||
'For more info see https://github.com/sveltejs/kit/pulls/14768'
|
||||
);
|
||||
}
|
||||
|
||||
return create_issue(message);
|
||||
},
|
||||
{
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
|
||||
|
||||
return create_issue_proxy(prop, []);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {(string | number)[]} path
|
||||
* @returns {StandardSchemaV1.Issue}
|
||||
*/
|
||||
function create_issue(message, path = []) {
|
||||
return {
|
||||
message,
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a proxy that builds up a path and returns a function to create an issue
|
||||
* @param {string | number} key
|
||||
* @param {(string | number)[]} path
|
||||
*/
|
||||
function create_issue_proxy(key, path) {
|
||||
const new_path = [...path, key];
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @returns {StandardSchemaV1.Issue}
|
||||
*/
|
||||
const issue_func = (message) => create_issue(message, new_path);
|
||||
|
||||
return new Proxy(issue_func, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'symbol') return /** @type {any} */ (target)[prop];
|
||||
|
||||
// Handle array access like invalid.items[0]
|
||||
if (/^\d+$/.test(prop)) {
|
||||
return create_issue_proxy(parseInt(prop, 10), new_path);
|
||||
}
|
||||
|
||||
// Handle property access like invalid.field.nested
|
||||
return create_issue_proxy(prop, new_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { command } from './command.js';
|
||||
export { form } from './form.js';
|
||||
export { prerender } from './prerender.js';
|
||||
export { query } from './query.js';
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */
|
||||
/** @import { RemotePrerenderInputsGenerator, RemoteInfo, MaybePromise } from 'types' */
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { DEV } from 'esm-env';
|
||||
import { get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { stringify, stringify_remote_arg } from '../../../shared.js';
|
||||
import { app_dir, base } from '$app/paths/internal/server';
|
||||
import {
|
||||
create_validator,
|
||||
get_cache,
|
||||
get_response,
|
||||
parse_remote_response,
|
||||
run_remote_function
|
||||
} from './shared.js';
|
||||
|
||||
/**
|
||||
* Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#prerender) for full documentation.
|
||||
*
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {() => MaybePromise<Output>} fn
|
||||
* @param {{ inputs?: RemotePrerenderInputsGenerator<void>, dynamic?: boolean }} [options]
|
||||
* @returns {RemotePrerenderFunction<void, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#prerender) for full documentation.
|
||||
*
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {'unchecked'} validate
|
||||
* @param {(arg: Input) => MaybePromise<Output>} fn
|
||||
* @param {{ inputs?: RemotePrerenderInputsGenerator<Input>, dynamic?: boolean }} [options]
|
||||
* @returns {RemotePrerenderFunction<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#prerender) for full documentation.
|
||||
*
|
||||
* @template {StandardSchemaV1} Schema
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {Schema} schema
|
||||
* @param {(arg: StandardSchemaV1.InferOutput<Schema>) => MaybePromise<Output>} fn
|
||||
* @param {{ inputs?: RemotePrerenderInputsGenerator<StandardSchemaV1.InferInput<Schema>>, dynamic?: boolean }} [options]
|
||||
* @returns {RemotePrerenderFunction<StandardSchemaV1.InferInput<Schema>, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @param {any} validate_or_fn
|
||||
* @param {any} [fn_or_options]
|
||||
* @param {{ inputs?: RemotePrerenderInputsGenerator<Input>, dynamic?: boolean }} [maybe_options]
|
||||
* @returns {RemotePrerenderFunction<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
export function prerender(validate_or_fn, fn_or_options, maybe_options) {
|
||||
const maybe_fn = typeof fn_or_options === 'function' ? fn_or_options : undefined;
|
||||
|
||||
/** @type {typeof maybe_options} */
|
||||
const options = maybe_options ?? (maybe_fn ? undefined : fn_or_options);
|
||||
|
||||
/** @type {(arg?: Input) => MaybePromise<Output>} */
|
||||
const fn = maybe_fn ?? validate_or_fn;
|
||||
|
||||
/** @type {(arg?: any) => MaybePromise<Input>} */
|
||||
const validate = create_validator(validate_or_fn, maybe_fn);
|
||||
|
||||
/** @type {RemoteInfo} */
|
||||
const __ = {
|
||||
type: 'prerender',
|
||||
id: '',
|
||||
name: '',
|
||||
has_arg: !!maybe_fn,
|
||||
inputs: options?.inputs,
|
||||
dynamic: options?.dynamic
|
||||
};
|
||||
|
||||
/** @type {RemotePrerenderFunction<Input, Output> & { __: RemoteInfo }} */
|
||||
const wrapper = (arg) => {
|
||||
/** @type {Promise<Output> & Partial<RemoteResource<Output>>} */
|
||||
const promise = (async () => {
|
||||
const { event, state } = get_request_store();
|
||||
const payload = stringify_remote_arg(arg, state.transport);
|
||||
const id = __.id;
|
||||
const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`;
|
||||
|
||||
if (!state.prerendering && !DEV && !event.isRemoteRequest) {
|
||||
try {
|
||||
return await get_response(__, arg, state, async () => {
|
||||
const key = stringify_remote_arg(arg, state.transport);
|
||||
const cache = get_cache(__, state);
|
||||
|
||||
// TODO adapters can provide prerendered data more efficiently than
|
||||
// fetching from the public internet
|
||||
const promise = (cache[key] ??= fetch(new URL(url, event.url.origin).href).then(
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Prerendered response not found');
|
||||
}
|
||||
|
||||
const prerendered = await response.json();
|
||||
|
||||
if (prerendered.type === 'error') {
|
||||
error(prerendered.status, prerendered.error);
|
||||
}
|
||||
|
||||
return prerendered.result;
|
||||
}
|
||||
));
|
||||
|
||||
return parse_remote_response(await promise, state.transport);
|
||||
});
|
||||
} catch {
|
||||
// not available prerendered, fallback to normal function
|
||||
}
|
||||
}
|
||||
|
||||
if (state.prerendering?.remote_responses.has(url)) {
|
||||
return /** @type {Promise<any>} */ (state.prerendering.remote_responses.get(url));
|
||||
}
|
||||
|
||||
const promise = get_response(__, arg, state, () =>
|
||||
run_remote_function(event, state, false, () => validate(arg), fn)
|
||||
);
|
||||
|
||||
if (state.prerendering) {
|
||||
state.prerendering.remote_responses.set(url, promise);
|
||||
}
|
||||
|
||||
const result = await promise;
|
||||
|
||||
if (state.prerendering) {
|
||||
const body = { type: 'result', result: stringify(result, state.transport) };
|
||||
state.prerendering.dependencies.set(url, {
|
||||
body: JSON.stringify(body),
|
||||
response: json(body)
|
||||
});
|
||||
}
|
||||
|
||||
// TODO this is missing error/loading/current/status
|
||||
return result;
|
||||
})();
|
||||
|
||||
promise.catch(() => {});
|
||||
|
||||
return /** @type {RemoteResource<Output>} */ (promise);
|
||||
};
|
||||
|
||||
Object.defineProperty(wrapper, '__', { value: __ });
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/** @import { RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */
|
||||
/** @import { RemoteInfo, MaybePromise } from 'types' */
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
import { get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { create_remote_key, stringify_remote_arg } from '../../../shared.js';
|
||||
import { prerendering } from '__sveltekit/environment';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation.
|
||||
*
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {() => MaybePromise<Output>} fn
|
||||
* @returns {RemoteQueryFunction<void, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation.
|
||||
*
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {'unchecked'} validate
|
||||
* @param {(arg: Input) => MaybePromise<Output>} fn
|
||||
* @returns {RemoteQueryFunction<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call.
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query) for full documentation.
|
||||
*
|
||||
* @template {StandardSchemaV1} Schema
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {Schema} schema
|
||||
* @param {(arg: StandardSchemaV1.InferOutput<Schema>) => MaybePromise<Output>} fn
|
||||
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/**
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @param {any} validate_or_fn
|
||||
* @param {(args?: Input) => MaybePromise<Output>} [maybe_fn]
|
||||
* @returns {RemoteQueryFunction<Input, Output>}
|
||||
* @since 2.27
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
export function query(validate_or_fn, maybe_fn) {
|
||||
/** @type {(arg?: Input) => Output} */
|
||||
const fn = maybe_fn ?? validate_or_fn;
|
||||
|
||||
/** @type {(arg?: any) => MaybePromise<Input>} */
|
||||
const validate = create_validator(validate_or_fn, maybe_fn);
|
||||
|
||||
/** @type {RemoteInfo} */
|
||||
const __ = { type: 'query', id: '', name: '' };
|
||||
|
||||
/** @type {RemoteQueryFunction<Input, Output> & { __: RemoteInfo }} */
|
||||
const wrapper = (arg) => {
|
||||
if (prerendering) {
|
||||
throw new Error(
|
||||
`Cannot call query '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead`
|
||||
);
|
||||
}
|
||||
|
||||
const { event, state } = get_request_store();
|
||||
|
||||
const get_remote_function_result = () =>
|
||||
run_remote_function(event, state, false, () => validate(arg), fn);
|
||||
|
||||
/** @type {Promise<any> & Partial<RemoteQuery<any>>} */
|
||||
const promise = get_response(__, arg, state, get_remote_function_result);
|
||||
|
||||
promise.catch(() => {});
|
||||
|
||||
promise.set = (value) => update_refresh_value(get_refresh_context(__, 'set', arg), value);
|
||||
|
||||
promise.refresh = () => {
|
||||
const refresh_context = get_refresh_context(__, 'refresh', arg);
|
||||
const is_immediate_refresh = !refresh_context.cache[refresh_context.cache_key];
|
||||
const value = is_immediate_refresh ? promise : get_remote_function_result();
|
||||
return update_refresh_value(refresh_context, value, is_immediate_refresh);
|
||||
};
|
||||
|
||||
promise.withOverride = () => {
|
||||
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
|
||||
};
|
||||
|
||||
return /** @type {RemoteQuery<Output>} */ (promise);
|
||||
};
|
||||
|
||||
Object.defineProperty(wrapper, '__', { value: __ });
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a batch query function that collects multiple calls and executes them in a single request
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
|
||||
*
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {'unchecked'} validate
|
||||
* @param {(args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} fn
|
||||
* @returns {RemoteQueryFunction<Input, Output>}
|
||||
* @since 2.35
|
||||
*/
|
||||
/**
|
||||
* Creates a batch query function that collects multiple calls and executes them in a single request
|
||||
*
|
||||
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
|
||||
*
|
||||
* @template {StandardSchemaV1} Schema
|
||||
* @template Output
|
||||
* @overload
|
||||
* @param {Schema} schema
|
||||
* @param {(args: StandardSchemaV1.InferOutput<Schema>[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput<Schema>, idx: number) => Output>} fn
|
||||
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, Output>}
|
||||
* @since 2.35
|
||||
*/
|
||||
/**
|
||||
* @template Input
|
||||
* @template Output
|
||||
* @param {any} validate_or_fn
|
||||
* @param {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} [maybe_fn]
|
||||
* @returns {RemoteQueryFunction<Input, Output>}
|
||||
* @since 2.35
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
function batch(validate_or_fn, maybe_fn) {
|
||||
/** @type {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} */
|
||||
const fn = maybe_fn ?? validate_or_fn;
|
||||
|
||||
/** @type {(arg?: any) => MaybePromise<Input>} */
|
||||
const validate = create_validator(validate_or_fn, maybe_fn);
|
||||
|
||||
/** @type {RemoteInfo & { type: 'query_batch' }} */
|
||||
const __ = {
|
||||
type: 'query_batch',
|
||||
id: '',
|
||||
name: '',
|
||||
run: async (args, options) => {
|
||||
const { event, state } = get_request_store();
|
||||
|
||||
return run_remote_function(
|
||||
event,
|
||||
state,
|
||||
false,
|
||||
async () => Promise.all(args.map(validate)),
|
||||
async (/** @type {any[]} */ input) => {
|
||||
const get_result = await fn(input);
|
||||
|
||||
return Promise.all(
|
||||
input.map(async (arg, i) => {
|
||||
try {
|
||||
return { type: 'result', data: get_result(arg, i) };
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'error',
|
||||
error: await handle_error_and_jsonify(event, state, options, error),
|
||||
status:
|
||||
error instanceof HttpError || error instanceof SvelteKitError
|
||||
? error.status
|
||||
: 500
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */
|
||||
let batching = { args: [], resolvers: [] };
|
||||
|
||||
/** @type {RemoteQueryFunction<Input, Output> & { __: RemoteInfo }} */
|
||||
const wrapper = (arg) => {
|
||||
if (prerendering) {
|
||||
throw new Error(
|
||||
`Cannot call query.batch '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead`
|
||||
);
|
||||
}
|
||||
|
||||
const { event, state } = get_request_store();
|
||||
|
||||
const get_remote_function_result = () => {
|
||||
// Collect all the calls to the same query in the same macrotask,
|
||||
// then execute them as one backend request.
|
||||
return new Promise((resolve, reject) => {
|
||||
// We don't need to deduplicate args here, because get_response already caches/reuses identical calls
|
||||
batching.args.push(arg);
|
||||
batching.resolvers.push({ resolve, reject });
|
||||
|
||||
if (batching.args.length > 1) return;
|
||||
|
||||
setTimeout(async () => {
|
||||
const batched = batching;
|
||||
batching = { args: [], resolvers: [] };
|
||||
|
||||
try {
|
||||
return await run_remote_function(
|
||||
event,
|
||||
state,
|
||||
false,
|
||||
async () => Promise.all(batched.args.map(validate)),
|
||||
async (input) => {
|
||||
const get_result = await fn(input);
|
||||
|
||||
for (let i = 0; i < batched.resolvers.length; i++) {
|
||||
try {
|
||||
batched.resolvers[i].resolve(get_result(input[i], i));
|
||||
} catch (error) {
|
||||
batched.resolvers[i].reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
for (const resolver of batched.resolvers) {
|
||||
resolver.reject(error);
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
/** @type {Promise<any> & Partial<RemoteQuery<any>>} */
|
||||
const promise = get_response(__, arg, state, get_remote_function_result);
|
||||
|
||||
promise.catch(() => {});
|
||||
|
||||
promise.set = (value) => update_refresh_value(get_refresh_context(__, 'set', arg), value);
|
||||
|
||||
promise.refresh = () => {
|
||||
const refresh_context = get_refresh_context(__, 'refresh', arg);
|
||||
const is_immediate_refresh = !refresh_context.cache[refresh_context.cache_key];
|
||||
const value = is_immediate_refresh ? promise : get_remote_function_result();
|
||||
return update_refresh_value(refresh_context, value, is_immediate_refresh);
|
||||
};
|
||||
|
||||
promise.withOverride = () => {
|
||||
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
|
||||
};
|
||||
|
||||
return /** @type {RemoteQuery<Output>} */ (promise);
|
||||
};
|
||||
|
||||
Object.defineProperty(wrapper, '__', { value: __ });
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Add batch as a property to the query function
|
||||
Object.defineProperty(query, 'batch', { value: batch, enumerable: true });
|
||||
|
||||
/**
|
||||
* @param {RemoteInfo} __
|
||||
* @param {'set' | 'refresh'} action
|
||||
* @param {any} [arg]
|
||||
* @returns {{ __: RemoteInfo; state: any; refreshes: Record<string, Promise<any>>; cache: Record<string, Promise<any>>; refreshes_key: string; cache_key: string }}
|
||||
*/
|
||||
function get_refresh_context(__, action, arg) {
|
||||
const { state } = get_request_store();
|
||||
const { refreshes } = state;
|
||||
|
||||
if (!refreshes) {
|
||||
const name = __.type === 'query_batch' ? `query.batch '${__.name}'` : `query '${__.name}'`;
|
||||
throw new Error(
|
||||
`Cannot call ${action} on ${name} because it is not executed in the context of a command/form remote function`
|
||||
);
|
||||
}
|
||||
|
||||
const cache = get_cache(__, state);
|
||||
const cache_key = stringify_remote_arg(arg, state.transport);
|
||||
const refreshes_key = create_remote_key(__.id, cache_key);
|
||||
|
||||
return { __, state, refreshes, refreshes_key, cache, cache_key };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ __: RemoteInfo; refreshes: Record<string, Promise<any>>; cache: Record<string, Promise<any>>; refreshes_key: string; cache_key: string }} context
|
||||
* @param {any} value
|
||||
* @param {boolean} [is_immediate_refresh=false]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function update_refresh_value(
|
||||
{ __, refreshes, refreshes_key, cache, cache_key },
|
||||
value,
|
||||
is_immediate_refresh = false
|
||||
) {
|
||||
const promise = Promise.resolve(value);
|
||||
|
||||
if (!is_immediate_refresh) {
|
||||
cache[cache_key] = promise;
|
||||
}
|
||||
|
||||
if (__.id) {
|
||||
refreshes[refreshes_key] = promise;
|
||||
}
|
||||
|
||||
return promise.then(() => {});
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/** @import { RequestEvent } from '@sveltejs/kit' */
|
||||
/** @import { ServerHooks, MaybePromise, RequestState, RemoteInfo, RequestStore } from 'types' */
|
||||
import { parse } from 'devalue';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { with_request_store, get_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { stringify_remote_arg } from '../../../shared.js';
|
||||
|
||||
/**
|
||||
* @param {any} validate_or_fn
|
||||
* @param {(arg?: any) => any} [maybe_fn]
|
||||
* @returns {(arg?: any) => MaybePromise<any>}
|
||||
*/
|
||||
export function create_validator(validate_or_fn, maybe_fn) {
|
||||
// prevent functions without validators being called with arguments
|
||||
if (!maybe_fn) {
|
||||
return (arg) => {
|
||||
if (arg !== undefined) {
|
||||
error(400, 'Bad Request');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// if 'unchecked', pass input through without validating
|
||||
if (validate_or_fn === 'unchecked') {
|
||||
return (arg) => arg;
|
||||
}
|
||||
|
||||
// use https://standardschema.dev validator if provided
|
||||
if ('~standard' in validate_or_fn) {
|
||||
return async (arg) => {
|
||||
// Get event before async validation to ensure it's available in server environments without AsyncLocalStorage, too
|
||||
const { event, state } = get_request_store();
|
||||
// access property and call method in one go to preserve potential this context
|
||||
const result = await validate_or_fn['~standard'].validate(arg);
|
||||
|
||||
// if the `issues` field exists, the validation failed
|
||||
if (result.issues) {
|
||||
error(
|
||||
400,
|
||||
await state.handleValidationError({
|
||||
issues: result.issues,
|
||||
event
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Invalid validator passed to remote function. Expected "unchecked" or a Standard Schema (https://standardschema.dev)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In case of a single remote function call, just returns the result.
|
||||
*
|
||||
* In case of a full page reload, returns the response for a remote function call,
|
||||
* either from the cache or by invoking the function.
|
||||
* Also saves an uneval'ed version of the result for later HTML inlining for hydration.
|
||||
*
|
||||
* @template {MaybePromise<any>} T
|
||||
* @param {RemoteInfo} info
|
||||
* @param {any} arg
|
||||
* @param {RequestState} state
|
||||
* @param {() => Promise<T>} get_result
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function get_response(info, arg, state, get_result) {
|
||||
// wait a beat, in case `myQuery().set(...)` or `myQuery().refresh()` is immediately called
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
await 0;
|
||||
|
||||
const cache = get_cache(info, state);
|
||||
|
||||
return (cache[stringify_remote_arg(arg, state.transport)] ??= get_result());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @param {ServerHooks['transport']} transport
|
||||
*/
|
||||
export function parse_remote_response(data, transport) {
|
||||
/** @type {Record<string, any>} */
|
||||
const revivers = {};
|
||||
for (const key in transport) {
|
||||
revivers[key] = transport[key].decode;
|
||||
}
|
||||
|
||||
return parse(data, revivers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`.
|
||||
* @template T
|
||||
* @param {RequestEvent} event
|
||||
* @param {RequestState} state
|
||||
* @param {boolean} allow_cookies
|
||||
* @param {() => any} get_input
|
||||
* @param {(arg?: any) => T} fn
|
||||
*/
|
||||
export async function run_remote_function(event, state, allow_cookies, get_input, fn) {
|
||||
/** @type {RequestStore} */
|
||||
const store = {
|
||||
event: {
|
||||
...event,
|
||||
setHeaders: () => {
|
||||
throw new Error('setHeaders is not allowed in remote functions');
|
||||
},
|
||||
cookies: {
|
||||
...event.cookies,
|
||||
set: (name, value, opts) => {
|
||||
if (!allow_cookies) {
|
||||
throw new Error('Cannot set cookies in `query` or `prerender` functions');
|
||||
}
|
||||
|
||||
if (opts.path && !opts.path.startsWith('/')) {
|
||||
throw new Error('Cookies set in remote functions must have an absolute path');
|
||||
}
|
||||
|
||||
return event.cookies.set(name, value, opts);
|
||||
},
|
||||
delete: (name, opts) => {
|
||||
if (!allow_cookies) {
|
||||
throw new Error('Cannot delete cookies in `query` or `prerender` functions');
|
||||
}
|
||||
|
||||
if (opts.path && !opts.path.startsWith('/')) {
|
||||
throw new Error('Cookies deleted in remote functions must have an absolute path');
|
||||
}
|
||||
|
||||
return event.cookies.delete(name, opts);
|
||||
}
|
||||
}
|
||||
},
|
||||
state: {
|
||||
...state,
|
||||
is_in_remote_function: true
|
||||
}
|
||||
};
|
||||
|
||||
// In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function
|
||||
const input = await with_request_store(store, get_input);
|
||||
return with_request_store(store, () => fn(input));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RemoteInfo} info
|
||||
* @param {RequestState} state
|
||||
*/
|
||||
export function get_cache(info, state = get_request_store().state) {
|
||||
let cache = state.remote_data?.get(info);
|
||||
|
||||
if (cache === undefined) {
|
||||
cache = {};
|
||||
(state.remote_data ??= new Map()).set(info, cache);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
page as _page,
|
||||
navigating as _navigating,
|
||||
updated as _updated
|
||||
} from '../../client/state.svelte.js';
|
||||
import { stores } from '../../client/client.js';
|
||||
|
||||
export const page = {
|
||||
get data() {
|
||||
return _page.data;
|
||||
},
|
||||
get error() {
|
||||
return _page.error;
|
||||
},
|
||||
get form() {
|
||||
return _page.form;
|
||||
},
|
||||
get params() {
|
||||
return _page.params;
|
||||
},
|
||||
get route() {
|
||||
return _page.route;
|
||||
},
|
||||
get state() {
|
||||
return _page.state;
|
||||
},
|
||||
get status() {
|
||||
return _page.status;
|
||||
},
|
||||
get url() {
|
||||
return _page.url;
|
||||
}
|
||||
};
|
||||
|
||||
export const navigating = {
|
||||
get from() {
|
||||
return _navigating.current ? _navigating.current.from : null;
|
||||
},
|
||||
get to() {
|
||||
return _navigating.current ? _navigating.current.to : null;
|
||||
},
|
||||
get type() {
|
||||
return _navigating.current ? _navigating.current.type : null;
|
||||
},
|
||||
get willUnload() {
|
||||
return _navigating.current ? _navigating.current.willUnload : null;
|
||||
},
|
||||
get delta() {
|
||||
return _navigating.current ? _navigating.current.delta : null;
|
||||
},
|
||||
get complete() {
|
||||
return _navigating.current ? _navigating.current.complete : null;
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(navigating, 'current', {
|
||||
get() {
|
||||
// between 2.12.0 and 2.12.1 `navigating.current` existed
|
||||
throw new Error('Replace navigating.current.<prop> with navigating.<prop>');
|
||||
}
|
||||
});
|
||||
|
||||
export const updated = {
|
||||
get current() {
|
||||
return _updated.current;
|
||||
},
|
||||
check: stores.updated.check
|
||||
};
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
page as client_page,
|
||||
navigating as client_navigating,
|
||||
updated as client_updated
|
||||
} from './client.js';
|
||||
import {
|
||||
page as server_page,
|
||||
navigating as server_navigating,
|
||||
updated as server_updated
|
||||
} from './server.js';
|
||||
import { BROWSER } from 'esm-env';
|
||||
|
||||
/**
|
||||
* A read-only reactive object with information about the current page, serving several use cases:
|
||||
* - retrieving the combined `data` of all pages/layouts anywhere in your component tree (also see [loading data](https://svelte.dev/docs/kit/load))
|
||||
* - retrieving the current value of the `form` prop anywhere in your component tree (also see [form actions](https://svelte.dev/docs/kit/form-actions))
|
||||
* - retrieving the page state that was set through `goto`, `pushState` or `replaceState` (also see [goto](https://svelte.dev/docs/kit/$app-navigation#goto) and [shallow routing](https://svelte.dev/docs/kit/shallow-routing))
|
||||
* - retrieving metadata such as the URL you're on, the current route and its parameters, and whether or not there was an error
|
||||
*
|
||||
* ```svelte
|
||||
* <!--- file: +layout.svelte --->
|
||||
* <script>
|
||||
* import { page } from '$app/state';
|
||||
* </script>
|
||||
*
|
||||
* <p>Currently at {page.url.pathname}</p>
|
||||
*
|
||||
* {#if page.error}
|
||||
* <span class="red">Problem detected</span>
|
||||
* {:else}
|
||||
* <span class="small">All systems operational</span>
|
||||
* {/if}
|
||||
* ```
|
||||
*
|
||||
* Changes to `page` are available exclusively with runes. (The legacy reactivity syntax will not reflect any changes)
|
||||
*
|
||||
* ```svelte
|
||||
* <!--- file: +page.svelte --->
|
||||
* <script>
|
||||
* import { page } from '$app/state';
|
||||
* const id = $derived(page.params.id); // This will correctly update id for usage on this page
|
||||
* $: badId = page.params.id; // Do not use; will never update after initial load
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* On the server, values can only be read during rendering (in other words _not_ in e.g. `load` functions). In the browser, the values can be read at any time.
|
||||
*
|
||||
* @type {import('@sveltejs/kit').Page}
|
||||
*/
|
||||
export const page = BROWSER ? client_page : server_page;
|
||||
|
||||
/**
|
||||
* A read-only object representing an in-progress navigation, with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties.
|
||||
* Values are `null` when no navigation is occurring, or during server rendering.
|
||||
* @type {import('@sveltejs/kit').Navigation | { from: null, to: null, type: null, willUnload: null, delta: null, complete: null }}
|
||||
*/
|
||||
// @ts-expect-error
|
||||
export const navigating = BROWSER ? client_navigating : server_navigating;
|
||||
|
||||
/**
|
||||
* A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.
|
||||
* @type {{ get current(): boolean; check(): Promise<boolean>; }}
|
||||
*/
|
||||
export const updated = BROWSER ? client_updated : server_updated;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { DEV } from 'esm-env';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
function context() {
|
||||
return getContext('__request__');
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function context_dev(name) {
|
||||
try {
|
||||
return context();
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Can only read '${name}' on the server during rendering (not in e.g. \`load\` functions), as it is bound to the current request via component context. This prevents state from leaking between users.` +
|
||||
'For more information, see https://svelte.dev/docs/kit/state-management#avoid-shared-state-on-the-server'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const page = {
|
||||
get data() {
|
||||
return (DEV ? context_dev('page.data') : context()).page.data;
|
||||
},
|
||||
get error() {
|
||||
return (DEV ? context_dev('page.error') : context()).page.error;
|
||||
},
|
||||
get form() {
|
||||
return (DEV ? context_dev('page.form') : context()).page.form;
|
||||
},
|
||||
get params() {
|
||||
return (DEV ? context_dev('page.params') : context()).page.params;
|
||||
},
|
||||
get route() {
|
||||
return (DEV ? context_dev('page.route') : context()).page.route;
|
||||
},
|
||||
get state() {
|
||||
return (DEV ? context_dev('page.state') : context()).page.state;
|
||||
},
|
||||
get status() {
|
||||
return (DEV ? context_dev('page.status') : context()).page.status;
|
||||
},
|
||||
get url() {
|
||||
return (DEV ? context_dev('page.url') : context()).page.url;
|
||||
}
|
||||
};
|
||||
|
||||
export const navigating = {
|
||||
from: null,
|
||||
to: null,
|
||||
type: null,
|
||||
willUnload: null,
|
||||
delta: null,
|
||||
complete: null
|
||||
};
|
||||
|
||||
export const updated = {
|
||||
get current() {
|
||||
return false;
|
||||
},
|
||||
check: () => {
|
||||
throw new Error('Can only call updated.check() in the browser');
|
||||
}
|
||||
};
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { getContext } from 'svelte';
|
||||
import { BROWSER, DEV } from 'esm-env';
|
||||
import { stores as browser_stores } from '../client/client.js';
|
||||
|
||||
/**
|
||||
* A function that returns all of the contextual stores. On the server, this must be called during component initialization.
|
||||
* Only use this if you need to defer store subscription until after the component has mounted, for some reason.
|
||||
*
|
||||
* @deprecated Use `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated))
|
||||
*/
|
||||
export const getStores = () => {
|
||||
const stores = BROWSER ? browser_stores : getContext('__svelte__');
|
||||
|
||||
return {
|
||||
/** @type {typeof page} */
|
||||
page: {
|
||||
subscribe: stores.page.subscribe
|
||||
},
|
||||
/** @type {typeof navigating} */
|
||||
navigating: {
|
||||
subscribe: stores.navigating.subscribe
|
||||
},
|
||||
/** @type {typeof updated} */
|
||||
updated: stores.updated
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A readable store whose value contains page data.
|
||||
*
|
||||
* On the server, this store can only be subscribed to during component initialization. In the browser, it can be subscribed to at any time.
|
||||
*
|
||||
* @deprecated Use `page` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated))
|
||||
* @type {import('svelte/store').Readable<import('@sveltejs/kit').Page>}
|
||||
*/
|
||||
export const page = {
|
||||
subscribe(fn) {
|
||||
const store = DEV ? get_store('page') : getStores().page;
|
||||
return store.subscribe(fn);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A readable store.
|
||||
* When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties.
|
||||
* When navigating finishes, its value reverts to `null`.
|
||||
*
|
||||
* On the server, this store can only be subscribed to during component initialization. In the browser, it can be subscribed to at any time.
|
||||
*
|
||||
* @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated))
|
||||
* @type {import('svelte/store').Readable<import('@sveltejs/kit').Navigation | null>}
|
||||
*/
|
||||
export const navigating = {
|
||||
subscribe(fn) {
|
||||
const store = DEV ? get_store('navigating') : getStores().navigating;
|
||||
return store.subscribe(fn);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A readable store whose initial value is `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.
|
||||
*
|
||||
* On the server, this store can only be subscribed to during component initialization. In the browser, it can be subscribed to at any time.
|
||||
*
|
||||
* @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated))
|
||||
* @type {import('svelte/store').Readable<boolean> & { check(): Promise<boolean> }}
|
||||
*/
|
||||
export const updated = {
|
||||
subscribe(fn) {
|
||||
const store = DEV ? get_store('updated') : getStores().updated;
|
||||
|
||||
if (BROWSER) {
|
||||
updated.check = store.check;
|
||||
}
|
||||
|
||||
return store.subscribe(fn);
|
||||
},
|
||||
check: () => {
|
||||
throw new Error(
|
||||
BROWSER
|
||||
? 'Cannot check updated store before subscribing'
|
||||
: 'Can only check updated store in browser'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @template {keyof ReturnType<typeof getStores>} Name
|
||||
* @param {Name} name
|
||||
* @returns {ReturnType<typeof getStores>[Name]}
|
||||
*/
|
||||
function get_store(name) {
|
||||
try {
|
||||
return getStores()[name];
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Cannot subscribe to '${name}' store on the server outside of a Svelte component, as it is bound to the current request via component context. This prevents state from leaking between users.` +
|
||||
'For more information, see https://svelte.dev/docs/kit/state-management#avoid-shared-state-on-the-server'
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/* if `bundleStrategy` is 'single' or 'inline', this file is used as the entry point */
|
||||
|
||||
import * as kit from './entry.js';
|
||||
|
||||
// @ts-expect-error
|
||||
import * as app from '__sveltekit/manifest';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {import('./types.js').HydrateOptions} options
|
||||
*/
|
||||
export function start(element, options) {
|
||||
void kit.start(app, element, options);
|
||||
}
|
||||
|
||||
export { app };
|
||||
+3217
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
export const SNAPSHOT_KEY = 'sveltekit:snapshot';
|
||||
export const SCROLL_KEY = 'sveltekit:scroll';
|
||||
export const STATES_KEY = 'sveltekit:states';
|
||||
export const PAGE_URL_KEY = 'sveltekit:pageurl';
|
||||
|
||||
export const HISTORY_INDEX = 'sveltekit:history';
|
||||
export const NAVIGATION_INDEX = 'sveltekit:navigation';
|
||||
|
||||
export const PRELOAD_PRIORITIES = /** @type {const} */ ({
|
||||
tap: 1,
|
||||
hover: 2,
|
||||
viewport: 3,
|
||||
eager: 4,
|
||||
off: -1,
|
||||
false: -1
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// we expose this as a separate entry point (rather than treating client.js as the entry point)
|
||||
// so that everything other than `start`/`load_css` can be treeshaken
|
||||
export { start, load_css } from './client.js';
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { BROWSER, DEV } from 'esm-env';
|
||||
import { hash } from '../../utils/hash.js';
|
||||
import { base64_decode } from '../utils.js';
|
||||
|
||||
let loading = 0;
|
||||
|
||||
/** @type {typeof fetch} */
|
||||
const native_fetch = BROWSER ? window.fetch : /** @type {any} */ (() => {});
|
||||
|
||||
export function lock_fetch() {
|
||||
loading += 1;
|
||||
}
|
||||
|
||||
export function unlock_fetch() {
|
||||
loading -= 1;
|
||||
}
|
||||
|
||||
if (DEV && BROWSER) {
|
||||
let can_inspect_stack_trace = false;
|
||||
|
||||
// detect whether async stack traces work
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const check_stack_trace = async () => {
|
||||
const stack = /** @type {string} */ (new Error().stack);
|
||||
can_inspect_stack_trace = stack.includes('check_stack_trace');
|
||||
};
|
||||
|
||||
void check_stack_trace();
|
||||
|
||||
/**
|
||||
* @param {RequestInfo | URL} input
|
||||
* @param {RequestInit & Record<string, any> | undefined} init
|
||||
*/
|
||||
window.fetch = (input, init) => {
|
||||
// Check if fetch was called via load_node. the lock method only checks if it was called at the
|
||||
// same time, but not necessarily if it was called from `load`.
|
||||
// We use just the filename as the method name sometimes does not appear on the CI.
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const stack_array = /** @type {string} */ (new Error().stack).split('\n');
|
||||
// We need to do a cutoff because Safari and Firefox maintain the stack
|
||||
// across events and for example traces a `fetch` call triggered from a button
|
||||
// back to the creation of the event listener and the element creation itself,
|
||||
// where at some point client.js will show up, leading to false positives.
|
||||
const cutoff = stack_array.findIndex((a) => a.includes('load@') || a.includes('at load'));
|
||||
const stack = stack_array.slice(0, cutoff + 2).join('\n');
|
||||
|
||||
const in_load_heuristic = can_inspect_stack_trace
|
||||
? stack.includes('src/runtime/client/client.js')
|
||||
: loading;
|
||||
|
||||
// This flag is set in initial_fetch and subsequent_fetch
|
||||
const used_kit_fetch = init?.__sveltekit_fetch__;
|
||||
|
||||
if (in_load_heuristic && !used_kit_fetch) {
|
||||
console.warn(
|
||||
`Loading ${url} using \`window.fetch\`. For best results, use the \`fetch\` that is passed to your \`load\` function: https://svelte.dev/docs/kit/load#making-fetch-requests`
|
||||
);
|
||||
}
|
||||
|
||||
const method = input instanceof Request ? input.method : init?.method || 'GET';
|
||||
|
||||
if (method !== 'GET') {
|
||||
cache.delete(build_selector(input));
|
||||
}
|
||||
|
||||
return native_fetch(input, init);
|
||||
};
|
||||
} else if (BROWSER) {
|
||||
window.fetch = (input, init) => {
|
||||
const method = input instanceof Request ? input.method : init?.method || 'GET';
|
||||
|
||||
if (method !== 'GET') {
|
||||
cache.delete(build_selector(input));
|
||||
}
|
||||
|
||||
return native_fetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Should be called on the initial run of load functions that hydrate the page.
|
||||
* Saves any requests with cache-control max-age to the cache.
|
||||
* @param {URL | string} resource
|
||||
* @param {RequestInit} [opts]
|
||||
*/
|
||||
export function initial_fetch(resource, opts) {
|
||||
const selector = build_selector(resource, opts);
|
||||
|
||||
const script = document.querySelector(selector);
|
||||
if (script?.textContent) {
|
||||
script.remove(); // In case multiple script tags match the same selector
|
||||
let { body, ...init } = JSON.parse(script.textContent);
|
||||
|
||||
const ttl = script.getAttribute('data-ttl');
|
||||
if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
|
||||
const b64 = script.getAttribute('data-b64');
|
||||
if (b64 !== null) {
|
||||
// Can't use native_fetch('data:...;base64,${body}')
|
||||
// csp can block the request
|
||||
body = base64_decode(body);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(body, init));
|
||||
}
|
||||
|
||||
return DEV ? dev_fetch(resource, opts) : window.fetch(resource, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to get the response from the cache, if max-age allows it, else does a fetch.
|
||||
* @param {URL | string} resource
|
||||
* @param {string} resolved
|
||||
* @param {RequestInit} [opts]
|
||||
*/
|
||||
export function subsequent_fetch(resource, resolved, opts) {
|
||||
if (cache.size > 0) {
|
||||
const selector = build_selector(resource, opts);
|
||||
const cached = cache.get(selector);
|
||||
if (cached) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache#value
|
||||
if (
|
||||
performance.now() < cached.ttl &&
|
||||
['default', 'force-cache', 'only-if-cached', undefined].includes(opts?.cache)
|
||||
) {
|
||||
return new Response(cached.body, cached.init);
|
||||
}
|
||||
|
||||
cache.delete(selector);
|
||||
}
|
||||
}
|
||||
|
||||
return DEV ? dev_fetch(resolved, opts) : window.fetch(resolved, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestInfo | URL} resource
|
||||
* @param {RequestInit & Record<string, any> | undefined} opts
|
||||
*/
|
||||
export function dev_fetch(resource, opts) {
|
||||
const patched_opts = { ...opts };
|
||||
// This assigns the __sveltekit_fetch__ flag and makes it non-enumerable
|
||||
Object.defineProperty(patched_opts, '__sveltekit_fetch__', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
return window.fetch(resource, patched_opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cache key for a given request
|
||||
* @param {URL | RequestInfo} resource
|
||||
* @param {RequestInit} [opts]
|
||||
*/
|
||||
function build_selector(resource, opts) {
|
||||
const url = JSON.stringify(resource instanceof Request ? resource.url : resource);
|
||||
|
||||
let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
|
||||
|
||||
if (opts?.headers || opts?.body) {
|
||||
/** @type {import('types').StrictBody[]} */
|
||||
const values = [];
|
||||
|
||||
if (opts.headers) {
|
||||
values.push([...new Headers(opts.headers)].join(','));
|
||||
}
|
||||
|
||||
if (opts.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) {
|
||||
values.push(opts.body);
|
||||
}
|
||||
|
||||
selector += `[data-hash="${hash(...values)}"]`;
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { exec, parse_route_id } from '../../utils/routing.js';
|
||||
|
||||
/**
|
||||
* @param {import('./types.js').SvelteKitApp} app
|
||||
* @returns {import('types').CSRRoute[]}
|
||||
*/
|
||||
export function parse({ nodes, server_loads, dictionary, matchers }) {
|
||||
const layouts_with_server_load = new Set(server_loads);
|
||||
|
||||
return Object.entries(dictionary).map(([id, [leaf, layouts, errors]]) => {
|
||||
const { pattern, params } = parse_route_id(id);
|
||||
|
||||
/** @type {import('types').CSRRoute} */
|
||||
const route = {
|
||||
id,
|
||||
/** @param {string} path */
|
||||
exec: (path) => {
|
||||
const match = pattern.exec(path);
|
||||
if (match) return exec(match, params, matchers);
|
||||
},
|
||||
errors: [1, ...(errors || [])].map((n) => nodes[n]),
|
||||
layouts: [0, ...(layouts || [])].map(create_layout_loader),
|
||||
leaf: create_leaf_loader(leaf)
|
||||
};
|
||||
|
||||
// bit of a hack, but ensures that layout/error node lists are the same
|
||||
// length, without which the wrong data will be applied if the route
|
||||
// manifest looks like `[[a, b], [c,], d]`
|
||||
route.errors.length = route.layouts.length = Math.max(
|
||||
route.errors.length,
|
||||
route.layouts.length
|
||||
);
|
||||
|
||||
return route;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @returns {[boolean, import('types').CSRPageNodeLoader]}
|
||||
*/
|
||||
function create_leaf_loader(id) {
|
||||
// whether or not the route uses the server data is
|
||||
// encoded using the ones' complement, to save space
|
||||
const uses_server_data = id < 0;
|
||||
if (uses_server_data) id = ~id;
|
||||
return [uses_server_data, nodes[id]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number | undefined} id
|
||||
* @returns {[boolean, import('types').CSRPageNodeLoader] | undefined}
|
||||
*/
|
||||
function create_layout_loader(id) {
|
||||
// whether or not the layout uses the server data is
|
||||
// encoded in the layouts array, to save space
|
||||
return id === undefined ? id : [layouts_with_server_load.has(id), nodes[id]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').CSRRouteServer} input
|
||||
* @param {import('types').CSRPageNodeLoader[]} app_nodes Will be modified if a new node is loaded that's not already in the array
|
||||
* @returns {import('types').CSRRoute}
|
||||
*/
|
||||
export function parse_server_route({ nodes, id, leaf, layouts, errors }, app_nodes) {
|
||||
return {
|
||||
id,
|
||||
exec: () => ({}), // dummy function; exec already happened on the server
|
||||
// By writing to app_nodes only when a loader at that index is not already defined,
|
||||
// we ensure that loaders have referential equality when they load the same node.
|
||||
// Code elsewhere in client.js relies on this referential equality to determine
|
||||
// if a loader is different and should therefore (re-)run.
|
||||
errors: errors.map((n) => (n ? (app_nodes[n] ||= nodes[n]) : undefined)),
|
||||
layouts: layouts.map((n) => (n ? [n[0], (app_nodes[n[1]] ||= nodes[n[1]])] : undefined)),
|
||||
leaf: [leaf[0], (app_nodes[leaf[1]] ||= nodes[leaf[1]])]
|
||||
};
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/** @import { RemoteCommand, RemoteQueryOverride } from '@sveltejs/kit' */
|
||||
/** @import { RemoteFunctionResponse } from 'types' */
|
||||
/** @import { Query } from './query.svelte.js' */
|
||||
import { app_dir, base } from '$app/paths/internal/client';
|
||||
import * as devalue from 'devalue';
|
||||
import { HttpError } from '@sveltejs/kit/internal';
|
||||
import { app } from '../client.js';
|
||||
import { stringify_remote_arg } from '../../shared.js';
|
||||
import { refresh_queries, release_overrides } from './shared.svelte.js';
|
||||
|
||||
/**
|
||||
* Client-version of the `command` function from `$app/server`.
|
||||
* @param {string} id
|
||||
* @returns {RemoteCommand<any, any>}
|
||||
*/
|
||||
export function command(id) {
|
||||
/** @type {number} */
|
||||
let pending_count = $state(0);
|
||||
|
||||
// Careful: This function MUST be synchronous (can't use the async keyword) because the return type has to be a promise with an updates() method.
|
||||
// If we make it async, the return type will be a promise that resolves to a promise with an updates() method, which is not what we want.
|
||||
/** @type {RemoteCommand<any, any>} */
|
||||
const command_function = (arg) => {
|
||||
/** @type {Array<Query<any> | RemoteQueryOverride>} */
|
||||
let updates = [];
|
||||
|
||||
// Increment pending count when command starts
|
||||
pending_count++;
|
||||
|
||||
/** @type {Promise<any> & { updates: (...args: any[]) => any }} */
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// Wait a tick to give room for the `updates` method to be called
|
||||
await Promise.resolve();
|
||||
|
||||
const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payload: stringify_remote_arg(arg, app.hooks.transport),
|
||||
refreshes: updates.map((u) => u._key)
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-sveltekit-pathname': location.pathname,
|
||||
'x-sveltekit-search': location.search
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
release_overrides(updates);
|
||||
// We only end up here in case of a network error or if the server has an internal error
|
||||
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
|
||||
throw new Error('Failed to execute remote function');
|
||||
}
|
||||
|
||||
const result = /** @type {RemoteFunctionResponse} */ (await response.json());
|
||||
if (result.type === 'redirect') {
|
||||
release_overrides(updates);
|
||||
throw new Error(
|
||||
'Redirects are not allowed in commands. Return a result instead and use goto on the client'
|
||||
);
|
||||
} else if (result.type === 'error') {
|
||||
release_overrides(updates);
|
||||
throw new HttpError(result.status ?? 500, result.error);
|
||||
} else {
|
||||
if (result.refreshes) {
|
||||
refresh_queries(result.refreshes, updates);
|
||||
}
|
||||
|
||||
return devalue.parse(result.result, app.decoders);
|
||||
}
|
||||
} finally {
|
||||
// Decrement pending count when command completes
|
||||
pending_count--;
|
||||
}
|
||||
})();
|
||||
|
||||
promise.updates = (/** @type {any} */ ...args) => {
|
||||
updates = args;
|
||||
// @ts-expect-error Don't allow updates to be called multiple times
|
||||
delete promise.updates;
|
||||
return promise;
|
||||
};
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
Object.defineProperty(command_function, 'pending', {
|
||||
get: () => pending_count
|
||||
});
|
||||
|
||||
return command_function;
|
||||
}
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
/** @import { RemoteFormInput, RemoteForm, RemoteQueryOverride } from '@sveltejs/kit' */
|
||||
/** @import { InternalRemoteFormIssue, RemoteFunctionResponse } from 'types' */
|
||||
/** @import { Query } from './query.svelte.js' */
|
||||
import { app_dir, base } from '$app/paths/internal/client';
|
||||
import * as devalue from 'devalue';
|
||||
import { DEV } from 'esm-env';
|
||||
import { HttpError } from '@sveltejs/kit/internal';
|
||||
import { app, remote_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js';
|
||||
import { tick } from 'svelte';
|
||||
import { refresh_queries, release_overrides } from './shared.svelte.js';
|
||||
import { createAttachmentKey } from 'svelte/attachments';
|
||||
import {
|
||||
convert_formdata,
|
||||
flatten_issues,
|
||||
create_field_proxy,
|
||||
deep_set,
|
||||
set_nested_value,
|
||||
throw_on_old_property_access,
|
||||
build_path_string,
|
||||
normalize_issue,
|
||||
serialize_binary_form,
|
||||
BINARY_FORM_CONTENT_TYPE
|
||||
} from '../../form-utils.js';
|
||||
|
||||
/**
|
||||
* Merge client issues into server issues. Server issues are persisted unless
|
||||
* a client-issue exists for the same path, in which case the client-issue overrides it.
|
||||
* @param {FormData} form_data
|
||||
* @param {InternalRemoteFormIssue[]} current_issues
|
||||
* @param {InternalRemoteFormIssue[]} client_issues
|
||||
* @returns {InternalRemoteFormIssue[]}
|
||||
*/
|
||||
function merge_with_server_issues(form_data, current_issues, client_issues) {
|
||||
const merged = [
|
||||
...current_issues.filter(
|
||||
(issue) => issue.server && !client_issues.some((i) => i.name === issue.name)
|
||||
),
|
||||
...client_issues
|
||||
];
|
||||
|
||||
const keys = Array.from(form_data.keys());
|
||||
|
||||
return merged.sort((a, b) => keys.indexOf(a.name) - keys.indexOf(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-version of the `form` function from `$app/server`.
|
||||
* @template {RemoteFormInput} T
|
||||
* @template U
|
||||
* @param {string} id
|
||||
* @returns {RemoteForm<T, U>}
|
||||
*/
|
||||
export function form(id) {
|
||||
/** @type {Map<any, { count: number, instance: RemoteForm<T, U> }>} */
|
||||
const instances = new Map();
|
||||
|
||||
/** @param {string | number | boolean} [key] */
|
||||
function create_instance(key) {
|
||||
const action_id_without_key = id;
|
||||
const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
|
||||
const action = '?/remote=' + encodeURIComponent(action_id);
|
||||
|
||||
/**
|
||||
* @type {Record<string, string | string[] | File | File[]>}
|
||||
*/
|
||||
let input = $state({});
|
||||
|
||||
/** @type {InternalRemoteFormIssue[]} */
|
||||
let raw_issues = $state.raw([]);
|
||||
|
||||
const issues = $derived(flatten_issues(raw_issues));
|
||||
|
||||
/** @type {any} */
|
||||
let result = $state.raw(remote_responses[action_id]);
|
||||
|
||||
/** @type {number} */
|
||||
let pending_count = $state(0);
|
||||
|
||||
/** @type {StandardSchemaV1 | undefined} */
|
||||
let preflight_schema = undefined;
|
||||
|
||||
/** @type {HTMLFormElement | null} */
|
||||
let element = null;
|
||||
|
||||
/** @type {Record<string, boolean>} */
|
||||
let touched = {};
|
||||
|
||||
let submitted = false;
|
||||
|
||||
/**
|
||||
* @param {FormData} form_data
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function convert(form_data) {
|
||||
const data = convert_formdata(form_data);
|
||||
if (key !== undefined && !form_data.has('id')) {
|
||||
data.id = key;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {FormData} form_data
|
||||
* @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback
|
||||
*/
|
||||
async function handle_submit(form, form_data, callback) {
|
||||
const data = convert(form_data);
|
||||
|
||||
submitted = true;
|
||||
|
||||
const validated = await preflight_schema?.['~standard'].validate(data);
|
||||
|
||||
if (validated?.issues) {
|
||||
raw_issues = merge_with_server_issues(
|
||||
form_data,
|
||||
raw_issues,
|
||||
validated.issues.map((issue) => normalize_issue(issue, false))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO 3.0 remove this warning
|
||||
if (DEV) {
|
||||
const error = () => {
|
||||
throw new Error(
|
||||
'Remote form functions no longer get passed a FormData object. The payload is now a POJO. See https://kit.svelte.dev/docs/remote-functions#form for details.'
|
||||
);
|
||||
};
|
||||
for (const key of [
|
||||
'append',
|
||||
'delete',
|
||||
'entries',
|
||||
'forEach',
|
||||
'get',
|
||||
'getAll',
|
||||
'has',
|
||||
'keys',
|
||||
'set',
|
||||
'values'
|
||||
]) {
|
||||
if (!(key in data)) {
|
||||
Object.defineProperty(data, key, { get: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await callback({
|
||||
form,
|
||||
data,
|
||||
submit: () => submit(form_data)
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e instanceof HttpError ? e.body : { message: /** @type {any} */ (e).message };
|
||||
const status = e instanceof HttpError ? e.status : 500;
|
||||
void set_nearest_error_page(error, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} data
|
||||
* @returns {Promise<any> & { updates: (...args: any[]) => any }}
|
||||
*/
|
||||
function submit(data) {
|
||||
// Store a reference to the current instance and increment the usage count for the duration
|
||||
// of the request. This ensures that the instance is not deleted in case of an optimistic update
|
||||
// (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards.
|
||||
// If the instance would be deleted in the meantime, the error property would be assigned to the old,
|
||||
// no-longer-visible instance, so it would never be shown to the user.
|
||||
const entry = instances.get(key);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
}
|
||||
|
||||
// Increment pending count when submission starts
|
||||
pending_count++;
|
||||
|
||||
/** @type {Array<Query<any> | RemoteQueryOverride>} */
|
||||
let updates = [];
|
||||
|
||||
/** @type {Promise<any> & { updates: (...args: any[]) => any }} */
|
||||
const promise = (async () => {
|
||||
try {
|
||||
await Promise.resolve();
|
||||
|
||||
const { blob } = serialize_binary_form(convert(data), {
|
||||
remote_refreshes: updates.map((u) => u._key)
|
||||
});
|
||||
|
||||
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': BINARY_FORM_CONTENT_TYPE,
|
||||
'x-sveltekit-pathname': location.pathname,
|
||||
'x-sveltekit-search': location.search
|
||||
},
|
||||
body: blob
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// We only end up here in case of a network error or if the server has an internal error
|
||||
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
|
||||
throw new Error('Failed to execute remote function');
|
||||
}
|
||||
|
||||
const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
|
||||
|
||||
// reset issues in case it's a redirect or error (but issues passed in that case)
|
||||
raw_issues = [];
|
||||
|
||||
if (form_result.type === 'result') {
|
||||
({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders));
|
||||
|
||||
if (issues.$) {
|
||||
release_overrides(updates);
|
||||
} else {
|
||||
if (form_result.refreshes) {
|
||||
refresh_queries(form_result.refreshes, updates);
|
||||
} else {
|
||||
void invalidateAll();
|
||||
}
|
||||
}
|
||||
} else if (form_result.type === 'redirect') {
|
||||
const refreshes = form_result.refreshes ?? '';
|
||||
const invalidateAll = !refreshes && updates.length === 0;
|
||||
if (!invalidateAll) {
|
||||
refresh_queries(refreshes, updates);
|
||||
}
|
||||
// Use internal version to allow redirects to external URLs
|
||||
void _goto(form_result.location, { invalidateAll }, 0);
|
||||
} else {
|
||||
throw new HttpError(form_result.status ?? 500, form_result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
result = undefined;
|
||||
release_overrides(updates);
|
||||
throw e;
|
||||
} finally {
|
||||
// Decrement pending count when submission completes
|
||||
pending_count--;
|
||||
|
||||
void tick().then(() => {
|
||||
if (entry) {
|
||||
entry.count--;
|
||||
if (entry.count === 0) {
|
||||
instances.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
promise.updates = (...args) => {
|
||||
updates = args;
|
||||
return promise;
|
||||
};
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** @type {RemoteForm<T, U>} */
|
||||
const instance = {};
|
||||
|
||||
instance.method = 'POST';
|
||||
instance.action = action;
|
||||
|
||||
/** @param {Parameters<RemoteForm<any, any>['enhance']>[0]} callback */
|
||||
const form_onsubmit = (callback) => {
|
||||
/** @param {SubmitEvent} event */
|
||||
return async (event) => {
|
||||
const form = /** @type {HTMLFormElement} */ (event.target);
|
||||
const method = event.submitter?.hasAttribute('formmethod')
|
||||
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod
|
||||
: clone(form).method;
|
||||
|
||||
if (method !== 'post') return;
|
||||
|
||||
const action = new URL(
|
||||
// We can't do submitter.formAction directly because that property is always set
|
||||
event.submitter?.hasAttribute('formaction')
|
||||
? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction
|
||||
: clone(form).action
|
||||
);
|
||||
|
||||
if (action.searchParams.get('/remote') !== action_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const form_data = new FormData(form, event.submitter);
|
||||
|
||||
if (DEV) {
|
||||
validate_form_data(form_data, clone(form).enctype);
|
||||
}
|
||||
|
||||
await handle_submit(form, form_data, callback);
|
||||
};
|
||||
};
|
||||
|
||||
/** @param {(event: SubmitEvent) => void} onsubmit */
|
||||
function create_attachment(onsubmit) {
|
||||
return (/** @type {HTMLFormElement} */ form) => {
|
||||
if (element) {
|
||||
let message = `A form object can only be attached to a single \`<form>\` element`;
|
||||
if (DEV && !key) {
|
||||
const name = id.split('/').pop();
|
||||
message += `. To create multiple instances, use \`${name}.for(key)\``;
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
element = form;
|
||||
|
||||
touched = {};
|
||||
|
||||
form.addEventListener('submit', onsubmit);
|
||||
|
||||
form.addEventListener('input', (e) => {
|
||||
// strictly speaking it can be an HTMLTextAreaElement or HTMLSelectElement
|
||||
// but that makes the types unnecessarily awkward
|
||||
const element = /** @type {HTMLInputElement} */ (e.target);
|
||||
|
||||
let name = element.name;
|
||||
if (!name) return;
|
||||
|
||||
const is_array = name.endsWith('[]');
|
||||
if (is_array) name = name.slice(0, -2);
|
||||
|
||||
const is_file = element.type === 'file';
|
||||
|
||||
touched[name] = true;
|
||||
|
||||
if (is_array) {
|
||||
let value;
|
||||
|
||||
if (element.tagName === 'SELECT') {
|
||||
value = Array.from(
|
||||
element.querySelectorAll('option:checked'),
|
||||
(e) => /** @type {HTMLOptionElement} */ (e).value
|
||||
);
|
||||
} else {
|
||||
const elements = /** @type {HTMLInputElement[]} */ (
|
||||
Array.from(form.querySelectorAll(`[name="${name}[]"]`))
|
||||
);
|
||||
|
||||
if (DEV) {
|
||||
for (const e of elements) {
|
||||
if ((e.type === 'file') !== is_file) {
|
||||
throw new Error(
|
||||
`Cannot mix and match file and non-file inputs under the same name ("${element.name}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = is_file
|
||||
? elements.map((input) => Array.from(input.files ?? [])).flat()
|
||||
: elements.map((element) => element.value);
|
||||
if (element.type === 'checkbox') {
|
||||
value = /** @type {string[]} */ (value.filter((_, i) => elements[i].checked));
|
||||
}
|
||||
}
|
||||
|
||||
set_nested_value(input, name, value);
|
||||
} else if (is_file) {
|
||||
if (DEV && element.multiple) {
|
||||
throw new Error(
|
||||
`Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"`
|
||||
);
|
||||
}
|
||||
|
||||
const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];
|
||||
|
||||
if (file) {
|
||||
set_nested_value(input, name, file);
|
||||
} else {
|
||||
// Remove the property by setting to undefined and clean up
|
||||
const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
|
||||
let current = /** @type {any} */ (input);
|
||||
for (let i = 0; i < path_parts.length - 1; i++) {
|
||||
if (current[path_parts[i]] == null) return;
|
||||
current = current[path_parts[i]];
|
||||
}
|
||||
delete current[path_parts[path_parts.length - 1]];
|
||||
}
|
||||
} else {
|
||||
set_nested_value(
|
||||
input,
|
||||
name,
|
||||
element.type === 'checkbox' && !element.checked ? null : element.value
|
||||
);
|
||||
}
|
||||
|
||||
name = name.replace(/^[nb]:/, '');
|
||||
|
||||
touched[name] = true;
|
||||
});
|
||||
|
||||
form.addEventListener('reset', async () => {
|
||||
// need to wait a moment, because the `reset` event occurs before
|
||||
// the inputs are actually updated (so that it can be cancelled)
|
||||
await tick();
|
||||
|
||||
input = convert_formdata(new FormData(form));
|
||||
});
|
||||
|
||||
return () => {
|
||||
element = null;
|
||||
preflight_schema = undefined;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
instance[createAttachmentKey()] = create_attachment(
|
||||
form_onsubmit(({ submit, form }) =>
|
||||
submit().then(() => {
|
||||
if (!issues.$) {
|
||||
form.reset();
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
let validate_id = 0;
|
||||
|
||||
// TODO 3.0 remove
|
||||
if (DEV) {
|
||||
throw_on_old_property_access(instance);
|
||||
|
||||
Object.defineProperty(instance, 'buttonProps', {
|
||||
get() {
|
||||
throw new Error(
|
||||
'`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
|
||||
' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperties(instance, {
|
||||
fields: {
|
||||
get: () =>
|
||||
create_field_proxy(
|
||||
{},
|
||||
() => input,
|
||||
(path, value) => {
|
||||
if (path.length === 0) {
|
||||
input = value;
|
||||
} else {
|
||||
deep_set(input, path.map(String), value);
|
||||
|
||||
const key = build_path_string(path);
|
||||
touched[key] = true;
|
||||
}
|
||||
},
|
||||
() => issues
|
||||
)
|
||||
},
|
||||
result: {
|
||||
get: () => result
|
||||
},
|
||||
pending: {
|
||||
get: () => pending_count
|
||||
},
|
||||
preflight: {
|
||||
/** @type {RemoteForm<T, U>['preflight']} */
|
||||
value: (schema) => {
|
||||
preflight_schema = schema;
|
||||
return instance;
|
||||
}
|
||||
},
|
||||
validate: {
|
||||
/** @type {RemoteForm<any, any>['validate']} */
|
||||
value: async ({ includeUntouched = false, preflightOnly = false } = {}) => {
|
||||
if (!element) return;
|
||||
|
||||
const id = ++validate_id;
|
||||
|
||||
// wait a tick in case the user is calling validate() right after set() which takes time to propagate
|
||||
await tick();
|
||||
|
||||
const default_submitter = /** @type {HTMLElement | undefined} */ (
|
||||
element.querySelector('button:not([type]), [type="submit"]')
|
||||
);
|
||||
|
||||
const form_data = new FormData(element, default_submitter);
|
||||
|
||||
/** @type {InternalRemoteFormIssue[]} */
|
||||
let array = [];
|
||||
|
||||
const data = convert(form_data);
|
||||
|
||||
const validated = await preflight_schema?.['~standard'].validate(data);
|
||||
|
||||
if (validate_id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (validated?.issues) {
|
||||
array = validated.issues.map((issue) => normalize_issue(issue, false));
|
||||
} else if (!preflightOnly) {
|
||||
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': BINARY_FORM_CONTENT_TYPE,
|
||||
'x-sveltekit-pathname': location.pathname,
|
||||
'x-sveltekit-search': location.search
|
||||
},
|
||||
body: serialize_binary_form(data, {
|
||||
validate_only: true
|
||||
}).blob
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (validate_id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'result') {
|
||||
array = /** @type {InternalRemoteFormIssue[]} */ (
|
||||
devalue.parse(result.result, app.decoders)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeUntouched && !submitted) {
|
||||
array = array.filter((issue) => touched[issue.name]);
|
||||
}
|
||||
|
||||
const is_server_validation = !validated?.issues && !preflightOnly;
|
||||
|
||||
raw_issues = is_server_validation
|
||||
? array
|
||||
: merge_with_server_issues(form_data, raw_issues, array);
|
||||
}
|
||||
},
|
||||
enhance: {
|
||||
/** @type {RemoteForm<any, any>['enhance']} */
|
||||
value: (callback) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
action,
|
||||
[createAttachmentKey()]: create_attachment(form_onsubmit(callback))
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
const instance = create_instance();
|
||||
|
||||
Object.defineProperty(instance, 'for', {
|
||||
/** @type {RemoteForm<T, U>['for']} */
|
||||
value: (key) => {
|
||||
const entry = instances.get(key) ?? { count: 0, instance: create_instance(key) };
|
||||
|
||||
try {
|
||||
$effect.pre(() => {
|
||||
return () => {
|
||||
entry.count--;
|
||||
|
||||
void tick().then(() => {
|
||||
if (entry.count === 0) {
|
||||
instances.delete(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
entry.count += 1;
|
||||
instances.set(key, entry);
|
||||
} catch {
|
||||
// not in an effect context
|
||||
}
|
||||
|
||||
return entry.instance;
|
||||
}
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow clone an element, so that we can access e.g. `form.action` without worrying
|
||||
* that someone has added an `<input name="action">` (https://github.com/sveltejs/kit/issues/7593)
|
||||
* @template {HTMLElement} T
|
||||
* @param {T} element
|
||||
* @returns {T}
|
||||
*/
|
||||
function clone(element) {
|
||||
return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} form_data
|
||||
* @param {string} enctype
|
||||
*/
|
||||
function validate_form_data(form_data, enctype) {
|
||||
for (const key of form_data.keys()) {
|
||||
if (/^\$[.[]?/.test(key)) {
|
||||
throw new Error(
|
||||
'`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (enctype !== 'multipart/form-data') {
|
||||
for (const value of form_data.values()) {
|
||||
if (value instanceof File) {
|
||||
throw new Error(
|
||||
'Your form contains <input type="file"> fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { command } from './command.svelte.js';
|
||||
export { form } from './form.svelte.js';
|
||||
export { prerender } from './prerender.svelte.js';
|
||||
export { query, query_batch } from './query.svelte.js';
|
||||
Generated
Vendored
+177
@@ -0,0 +1,177 @@
|
||||
import { app_dir, base } from '$app/paths/internal/client';
|
||||
import { version } from '__sveltekit/environment';
|
||||
import * as devalue from 'devalue';
|
||||
import { DEV } from 'esm-env';
|
||||
import { app, remote_responses } from '../client.js';
|
||||
import { create_remote_function, remote_request } from './shared.svelte.js';
|
||||
|
||||
// Initialize Cache API for prerender functions
|
||||
const CACHE_NAME = DEV ? `sveltekit:${Date.now()}` : `sveltekit:${version}`;
|
||||
/** @type {Cache | undefined} */
|
||||
let prerender_cache;
|
||||
|
||||
const prerender_cache_ready = (async () => {
|
||||
if (typeof caches !== 'undefined') {
|
||||
try {
|
||||
prerender_cache = await caches.open(CACHE_NAME);
|
||||
|
||||
// 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) {
|
||||
await caches.delete(cache_name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize SvelteKit cache:', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements {Partial<Promise<T>>}
|
||||
*/
|
||||
class Prerender {
|
||||
/** @type {Promise<T>} */
|
||||
#promise;
|
||||
|
||||
#loading = $state(true);
|
||||
#ready = $state(false);
|
||||
|
||||
/** @type {T | undefined} */
|
||||
#current = $state.raw();
|
||||
|
||||
#error = $state.raw(undefined);
|
||||
|
||||
/**
|
||||
* @param {() => Promise<T>} fn
|
||||
*/
|
||||
constructor(fn) {
|
||||
this.#promise = fn().then(
|
||||
(value) => {
|
||||
this.#loading = false;
|
||||
this.#ready = true;
|
||||
this.#current = value;
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
this.#loading = false;
|
||||
this.#error = error;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {((value: any) => any) | null | undefined} onfulfilled
|
||||
* @param {((reason: any) => any) | null | undefined} [onrejected]
|
||||
* @returns
|
||||
*/
|
||||
then(onfulfilled, onrejected) {
|
||||
return this.#promise.then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {((reason: any) => any) | null | undefined} onrejected
|
||||
*/
|
||||
catch(onrejected) {
|
||||
return this.#promise.catch(onrejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(() => any) | null | undefined} onfinally
|
||||
*/
|
||||
finally(onfinally) {
|
||||
return this.#promise.finally(onfinally);
|
||||
}
|
||||
|
||||
get current() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.#error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the resource is loading.
|
||||
*/
|
||||
get loading() {
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true once the resource has been loaded.
|
||||
*/
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} encoded
|
||||
*/
|
||||
function put(url, encoded) {
|
||||
return /** @type {Cache} */ (prerender_cache)
|
||||
.put(
|
||||
url,
|
||||
// We need to create a new response because the original response is already consumed
|
||||
new Response(encoded, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// Nothing we can do here
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function prerender(id) {
|
||||
return create_remote_function(id, (cache_key, payload) => {
|
||||
return new Prerender(async () => {
|
||||
await prerender_cache_ready;
|
||||
|
||||
const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`;
|
||||
|
||||
if (Object.hasOwn(remote_responses, cache_key)) {
|
||||
const data = remote_responses[cache_key];
|
||||
|
||||
if (prerender_cache) {
|
||||
void put(url, devalue.stringify(data, app.encoders));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Check the Cache API first
|
||||
if (prerender_cache) {
|
||||
try {
|
||||
const cached_response = await prerender_cache.match(url);
|
||||
|
||||
if (cached_response) {
|
||||
const cached_result = await cached_response.text();
|
||||
return devalue.parse(cached_result, app.decoders);
|
||||
}
|
||||
} catch {
|
||||
// Nothing we can do here
|
||||
}
|
||||
}
|
||||
|
||||
const encoded = await remote_request(url);
|
||||
|
||||
// For successful prerender requests, save to cache
|
||||
if (prerender_cache) {
|
||||
void put(url, encoded);
|
||||
}
|
||||
|
||||
return devalue.parse(encoded, app.decoders);
|
||||
});
|
||||
});
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
/** @import { RemoteQueryFunction } from '@sveltejs/kit' */
|
||||
/** @import { RemoteFunctionResponse } from 'types' */
|
||||
import { app_dir, base } from '$app/paths/internal/client';
|
||||
import { app, goto, query_map, remote_responses } from '../client.js';
|
||||
import { tick } from 'svelte';
|
||||
import { create_remote_function, remote_request } from './shared.svelte.js';
|
||||
import * as devalue from 'devalue';
|
||||
import { HttpError, Redirect } from '@sveltejs/kit/internal';
|
||||
import { DEV } from 'esm-env';
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {RemoteQueryFunction<any, any>}
|
||||
*/
|
||||
export function query(id) {
|
||||
if (DEV) {
|
||||
// If this reruns as part of HMR, refresh the query
|
||||
for (const [key, entry] of query_map) {
|
||||
if (key === id || key.startsWith(id + '/')) {
|
||||
// use optional chaining in case a prerender function was turned into a query
|
||||
entry.resource.refresh?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return create_remote_function(id, (cache_key, payload) => {
|
||||
return new Query(cache_key, async () => {
|
||||
if (Object.hasOwn(remote_responses, cache_key)) {
|
||||
return remote_responses[cache_key];
|
||||
}
|
||||
|
||||
const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`;
|
||||
|
||||
const result = await remote_request(url);
|
||||
return devalue.parse(result, app.decoders);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {(arg: any) => Query<any>}
|
||||
*/
|
||||
export function query_batch(id) {
|
||||
/** @type {Map<string, Array<{resolve: (value: any) => void, reject: (error: any) => void}>>} */
|
||||
let batching = new Map();
|
||||
|
||||
return create_remote_function(id, (cache_key, payload) => {
|
||||
return new Query(cache_key, () => {
|
||||
if (Object.hasOwn(remote_responses, cache_key)) {
|
||||
return remote_responses[cache_key];
|
||||
}
|
||||
|
||||
// Collect all the calls to the same query in the same macrotask,
|
||||
// then execute them as one backend request.
|
||||
return new Promise((resolve, reject) => {
|
||||
// create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function
|
||||
// is invoked multiple times with the same payload, so we need to deduplicate here
|
||||
const entry = batching.get(payload) ?? [];
|
||||
entry.push({ resolve, reject });
|
||||
batching.set(payload, entry);
|
||||
|
||||
if (batching.size > 1) return;
|
||||
|
||||
// Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them,
|
||||
// and flushes could reveal more queries that should be batched.
|
||||
setTimeout(async () => {
|
||||
const batched = batching;
|
||||
batching = new Map();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payloads: Array.from(batched.keys())
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to execute batch query');
|
||||
}
|
||||
|
||||
const result = /** @type {RemoteFunctionResponse} */ (await response.json());
|
||||
if (result.type === 'error') {
|
||||
throw new HttpError(result.status ?? 500, result.error);
|
||||
}
|
||||
|
||||
if (result.type === 'redirect') {
|
||||
await goto(result.location);
|
||||
throw new Redirect(307, result.location);
|
||||
}
|
||||
|
||||
const results = devalue.parse(result.result, app.decoders);
|
||||
|
||||
// Resolve individual queries
|
||||
// Maps guarantee insertion order so we can do it like this
|
||||
let i = 0;
|
||||
|
||||
for (const resolvers of batched.values()) {
|
||||
for (const { resolve, reject } of resolvers) {
|
||||
if (results[i].type === 'error') {
|
||||
reject(new HttpError(results[i].status, results[i].error));
|
||||
} else {
|
||||
resolve(results[i].data);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Reject all queries in the batch
|
||||
for (const resolver of batched.values()) {
|
||||
for (const { reject } of resolver) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @implements {Partial<Promise<T>>}
|
||||
*/
|
||||
export class Query {
|
||||
/** @type {string} */
|
||||
_key;
|
||||
|
||||
#init = false;
|
||||
/** @type {() => Promise<T>} */
|
||||
#fn;
|
||||
#loading = $state(true);
|
||||
/** @type {Array<() => void>} */
|
||||
#latest = [];
|
||||
|
||||
/** @type {boolean} */
|
||||
#ready = $state(false);
|
||||
/** @type {T | undefined} */
|
||||
#raw = $state.raw();
|
||||
/** @type {Promise<void>} */
|
||||
#promise;
|
||||
/** @type {Array<(old: T) => T>} */
|
||||
#overrides = $state([]);
|
||||
|
||||
/** @type {T | undefined} */
|
||||
#current = $derived.by(() => {
|
||||
// don't reduce undefined value
|
||||
if (!this.#ready) return undefined;
|
||||
|
||||
return this.#overrides.reduce((v, r) => r(v), /** @type {T} */ (this.#raw));
|
||||
});
|
||||
|
||||
#error = $state.raw(undefined);
|
||||
|
||||
/** @type {Promise<T>['then']} */
|
||||
// @ts-expect-error TS doesn't understand that the promise returns something
|
||||
#then = $derived.by(() => {
|
||||
const p = this.#promise;
|
||||
this.#overrides.length;
|
||||
|
||||
return (resolve, reject) => {
|
||||
const result = (async () => {
|
||||
await p;
|
||||
// svelte-ignore await_reactivity_loss
|
||||
await tick();
|
||||
return /** @type {T} */ (this.#current);
|
||||
})();
|
||||
|
||||
if (resolve || reject) {
|
||||
return result.then(resolve, reject);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {() => Promise<T>} fn
|
||||
*/
|
||||
constructor(key, fn) {
|
||||
this._key = key;
|
||||
this.#fn = fn;
|
||||
this.#promise = $state.raw(this.#run());
|
||||
}
|
||||
|
||||
#run() {
|
||||
// Prevent state_unsafe_mutation error on first run when the resource is created within the template
|
||||
if (this.#init) {
|
||||
this.#loading = true;
|
||||
} else {
|
||||
this.#init = true;
|
||||
}
|
||||
|
||||
// Don't use Promise.withResolvers, it's too new still
|
||||
/** @type {() => void} */
|
||||
let resolve;
|
||||
/** @type {(e?: any) => void} */
|
||||
let reject;
|
||||
/** @type {Promise<void>} */
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
this.#latest.push(
|
||||
// @ts-expect-error it's defined at this point
|
||||
resolve
|
||||
);
|
||||
|
||||
Promise.resolve(this.#fn())
|
||||
.then((value) => {
|
||||
// Skip the response if resource was refreshed with a later promise while we were waiting for this one to resolve
|
||||
const idx = this.#latest.indexOf(resolve);
|
||||
if (idx === -1) return;
|
||||
|
||||
this.#latest.splice(0, idx).forEach((r) => r());
|
||||
this.#ready = true;
|
||||
this.#loading = false;
|
||||
this.#raw = value;
|
||||
this.#error = undefined;
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch((e) => {
|
||||
const idx = this.#latest.indexOf(resolve);
|
||||
if (idx === -1) return;
|
||||
|
||||
this.#latest.splice(0, idx).forEach((r) => r());
|
||||
this.#error = e;
|
||||
this.#loading = false;
|
||||
reject(e);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
get then() {
|
||||
return this.#then;
|
||||
}
|
||||
|
||||
get catch() {
|
||||
this.#then;
|
||||
return (/** @type {any} */ reject) => {
|
||||
return this.#then(undefined, reject);
|
||||
};
|
||||
}
|
||||
|
||||
get finally() {
|
||||
this.#then;
|
||||
return (/** @type {any} */ fn) => {
|
||||
return this.#then(
|
||||
(value) => {
|
||||
fn();
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
fn();
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
get current() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.#error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the resource is loading or reloading.
|
||||
*/
|
||||
get loading() {
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true once the resource has been loaded for the first time.
|
||||
*/
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
refresh() {
|
||||
delete remote_responses[this._key];
|
||||
return (this.#promise = this.#run());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {T} value
|
||||
*/
|
||||
set(value) {
|
||||
this.#ready = true;
|
||||
this.#loading = false;
|
||||
this.#error = undefined;
|
||||
this.#raw = value;
|
||||
this.#promise = Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(old: T) => T} fn
|
||||
*/
|
||||
withOverride(fn) {
|
||||
this.#overrides.push(fn);
|
||||
|
||||
return {
|
||||
_key: this._key,
|
||||
release: () => {
|
||||
const i = this.#overrides.indexOf(fn);
|
||||
|
||||
if (i !== -1) {
|
||||
this.#overrides.splice(i, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/** @import { RemoteQueryOverride } from '@sveltejs/kit' */
|
||||
/** @import { RemoteFunctionResponse } from 'types' */
|
||||
/** @import { Query } from './query.svelte.js' */
|
||||
import * as devalue from 'devalue';
|
||||
import { app, goto, query_map, remote_responses } from '../client.js';
|
||||
import { HttpError, Redirect } from '@sveltejs/kit/internal';
|
||||
import { tick } from 'svelte';
|
||||
import { create_remote_key, stringify_remote_arg } from '../../shared.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url
|
||||
*/
|
||||
export async function remote_request(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
// TODO in future, when we support forking, we will likely need
|
||||
// to grab this from context as queries will run before
|
||||
// `location.pathname` is updated
|
||||
'x-sveltekit-pathname': location.pathname,
|
||||
'x-sveltekit-search': location.search
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(500, 'Failed to execute remote function');
|
||||
}
|
||||
|
||||
const result = /** @type {RemoteFunctionResponse} */ (await response.json());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-version of the `query`/`prerender`/`cache` function from `$app/server`.
|
||||
* @param {string} id
|
||||
* @param {(key: string, args: string) => any} create
|
||||
*/
|
||||
export function create_remote_function(id, create) {
|
||||
return (/** @type {any} */ arg) => {
|
||||
const payload = stringify_remote_arg(arg, app.hooks.transport);
|
||||
const cache_key = create_remote_key(id, payload);
|
||||
let entry = query_map.get(cache_key);
|
||||
|
||||
let tracking = true;
|
||||
try {
|
||||
$effect.pre(() => {
|
||||
if (entry) entry.count++;
|
||||
return () => {
|
||||
const entry = query_map.get(cache_key);
|
||||
if (entry) {
|
||||
entry.count--;
|
||||
void tick().then(() => {
|
||||
if (!entry.count && entry === query_map.get(cache_key)) {
|
||||
query_map.delete(cache_key);
|
||||
delete remote_responses[cache_key];
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
tracking = false;
|
||||
}
|
||||
|
||||
let resource = entry?.resource;
|
||||
if (!resource) {
|
||||
resource = create(cache_key, payload);
|
||||
|
||||
Object.defineProperty(resource, '_key', {
|
||||
value: cache_key
|
||||
});
|
||||
|
||||
query_map.set(
|
||||
cache_key,
|
||||
(entry = {
|
||||
count: tracking ? 1 : 0,
|
||||
resource
|
||||
})
|
||||
);
|
||||
|
||||
resource
|
||||
.then(() => {
|
||||
void tick().then(() => {
|
||||
if (
|
||||
!(/** @type {NonNullable<typeof entry>} */ (entry).count) &&
|
||||
entry === query_map.get(cache_key)
|
||||
) {
|
||||
// If no one is tracking this resource anymore, we can delete it from the cache
|
||||
query_map.delete(cache_key);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// error delete the resource from the cache
|
||||
// TODO is that correct?
|
||||
query_map.delete(cache_key);
|
||||
});
|
||||
}
|
||||
|
||||
return resource;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Query<any> | RemoteQueryOverride>} updates
|
||||
*/
|
||||
export function release_overrides(updates) {
|
||||
for (const update of updates) {
|
||||
if ('release' in update) {
|
||||
update.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stringified_refreshes
|
||||
* @param {Array<Query<any> | RemoteQueryOverride>} updates
|
||||
*/
|
||||
export function refresh_queries(stringified_refreshes, updates = []) {
|
||||
const refreshes = Object.entries(devalue.parse(stringified_refreshes, app.decoders));
|
||||
|
||||
// `refreshes` is a superset of `updates`
|
||||
for (const [key, value] of refreshes) {
|
||||
// If there was an optimistic update, release it right before we update the query
|
||||
const update = updates.find((u) => u._key === key);
|
||||
if (update && 'release' in update) {
|
||||
update.release();
|
||||
}
|
||||
// Update the query with the new value
|
||||
const entry = query_map.get(key);
|
||||
entry?.resource.set(value);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Read a value from `sessionStorage`
|
||||
* @param {string} key
|
||||
* @param {(value: string) => any} parse
|
||||
*/
|
||||
/*@__NO_SIDE_EFFECTS__*/
|
||||
export function get(key, parse = JSON.parse) {
|
||||
try {
|
||||
return parse(sessionStorage[key]);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to `sessionStorage`
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @param {(value: any) => string} stringify
|
||||
*/
|
||||
export function set(key, value, stringify = JSON.stringify) {
|
||||
const data = stringify(value);
|
||||
try {
|
||||
sessionStorage[key] = data;
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { updated_listener } from './utils.js';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Page} */
|
||||
export let page;
|
||||
|
||||
/** @type {{ current: import('@sveltejs/kit').Navigation | null }} */
|
||||
export let navigating;
|
||||
|
||||
/** @type {{ current: boolean }} */
|
||||
export let updated;
|
||||
|
||||
// this is a bootleg way to tell if we're in old svelte or new svelte
|
||||
const is_legacy =
|
||||
onMount.toString().includes('$$') || /function \w+\(\) \{\}/.test(onMount.toString());
|
||||
|
||||
if (is_legacy) {
|
||||
page = {
|
||||
data: {},
|
||||
form: null,
|
||||
error: null,
|
||||
params: {},
|
||||
route: { id: null },
|
||||
state: {},
|
||||
status: -1,
|
||||
url: new URL('https://example.com')
|
||||
};
|
||||
navigating = { current: null };
|
||||
updated = { current: false };
|
||||
} else {
|
||||
page = new (class Page {
|
||||
data = $state.raw({});
|
||||
form = $state.raw(null);
|
||||
error = $state.raw(null);
|
||||
params = $state.raw({});
|
||||
route = $state.raw({ id: null });
|
||||
state = $state.raw({});
|
||||
status = $state.raw(-1);
|
||||
url = $state.raw(new URL('https://example.com'));
|
||||
})();
|
||||
|
||||
navigating = new (class Navigating {
|
||||
current = $state.raw(null);
|
||||
})();
|
||||
|
||||
updated = new (class Updated {
|
||||
current = $state.raw(false);
|
||||
})();
|
||||
updated_listener.v = () => (updated.current = true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').Page} new_page
|
||||
*/
|
||||
export function update(new_page) {
|
||||
Object.assign(page, new_page);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { SvelteComponent } from 'svelte';
|
||||
import {
|
||||
ClientHooks,
|
||||
CSRPageNode,
|
||||
CSRPageNodeLoader,
|
||||
CSRRoute,
|
||||
CSRRouteServer,
|
||||
ServerDataNode,
|
||||
TrailingSlash,
|
||||
Uses
|
||||
} from 'types';
|
||||
import { Page, ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export interface SvelteKitApp {
|
||||
/**
|
||||
* A list of all the error/layout/page nodes used in the app.
|
||||
* - In case of router.resolution=client, this is filled completely upfront.
|
||||
* - In case of router.resolution=server, this is filled with the root layout and root error page
|
||||
* at the beginning and then filled up as the user navigates around the app, loading new nodes
|
||||
*/
|
||||
nodes: CSRPageNodeLoader[];
|
||||
|
||||
/**
|
||||
* A list of all layout node ids that have a server load function.
|
||||
* Pages are not present because it's shorter to encode it on the leaf itself.
|
||||
*
|
||||
* In case of router.resolution=server, this only contains one entry for the root layout.
|
||||
*/
|
||||
server_loads: number[];
|
||||
|
||||
/**
|
||||
* A map of `[routeId: string]: [leaf, layouts, errors]` tuples, which
|
||||
* is parsed into an array of routes on startup. The numbers refer to the indices in `nodes`.
|
||||
* If the leaf number is negative, it means it does use a server load function and the complement is the node index.
|
||||
* The route layout and error nodes are not referenced, they are always number 0 and 1 and always apply.
|
||||
*
|
||||
* In case of router.resolution=server, this object is empty, as resolution happens on the server.
|
||||
*/
|
||||
dictionary: Record<string, [leaf: number, layouts: number[], errors?: number[]]>;
|
||||
|
||||
/**
|
||||
* A map of `[matcherName: string]: (..) => boolean`, which is used to match route parameters.
|
||||
*
|
||||
* In case of router.resolution=server, this object is empty, as resolution happens on the server.
|
||||
*/
|
||||
matchers: Record<string, ParamMatcher>;
|
||||
|
||||
hooks: ClientHooks;
|
||||
|
||||
decode: (type: string, value: any) => any;
|
||||
|
||||
decoders: Record<string, (data: any) => any>;
|
||||
encoders: Record<string, (data: any) => any>;
|
||||
|
||||
/**
|
||||
* Whether or not we're using hash-based routing
|
||||
*/
|
||||
hash: boolean;
|
||||
|
||||
root: typeof SvelteComponent;
|
||||
}
|
||||
|
||||
export type NavigationIntent = {
|
||||
/** `url.pathname + url.search` */
|
||||
id: string;
|
||||
/** Whether we are invalidating or navigating */
|
||||
invalidating: boolean;
|
||||
/** The route parameters */
|
||||
params: Record<string, string>;
|
||||
/** The route that matches `path` */
|
||||
route: CSRRoute;
|
||||
/** The destination URL */
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type NavigationResult = NavigationRedirect | NavigationFinished;
|
||||
|
||||
export type NavigationRedirect = {
|
||||
type: 'redirect';
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type NavigationFinished = {
|
||||
type: 'loaded';
|
||||
state: NavigationState;
|
||||
props: {
|
||||
constructors: Array<typeof SvelteComponent>;
|
||||
components?: SvelteComponent[];
|
||||
page: Page;
|
||||
form?: Record<string, any> | null;
|
||||
[key: `data_${number}`]: Record<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
export type BranchNode = {
|
||||
node: CSRPageNode;
|
||||
loader: CSRPageNodeLoader;
|
||||
server: DataNode | null;
|
||||
universal: DataNode | null;
|
||||
data: Record<string, any> | null;
|
||||
slash?: TrailingSlash;
|
||||
};
|
||||
|
||||
export interface DataNode {
|
||||
type: 'data';
|
||||
data: Record<string, any> | null;
|
||||
uses: Uses;
|
||||
slash?: TrailingSlash;
|
||||
}
|
||||
|
||||
export interface NavigationState {
|
||||
branch: Array<BranchNode | undefined>;
|
||||
error: App.Error | null;
|
||||
params: Record<string, string>;
|
||||
route: CSRRoute | null;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export interface HydrateOptions {
|
||||
status: number;
|
||||
error: App.Error | null;
|
||||
node_ids: number[];
|
||||
params: Record<string, string>;
|
||||
route: { id: string | null };
|
||||
/** Only used when `router.resolution=server`; can then still be undefined in case of 404 */
|
||||
server_route?: CSRRouteServer;
|
||||
data: Array<ServerDataNode | null>;
|
||||
form: Record<string, any> | null;
|
||||
/** The results of all remote functions executed during SSR so that they can be reused during hydration */
|
||||
remote: Record<string, any>;
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
import { BROWSER, DEV } from 'esm-env';
|
||||
import { writable } from 'svelte/store';
|
||||
import { assets } from '$app/paths';
|
||||
import { version } from '__sveltekit/environment';
|
||||
import { PRELOAD_PRIORITIES } from './constants.js';
|
||||
|
||||
/* global __SVELTEKIT_APP_VERSION_FILE__, __SVELTEKIT_APP_VERSION_POLL_INTERVAL__ */
|
||||
|
||||
export const origin = BROWSER ? location.origin : '';
|
||||
|
||||
/** @param {string | URL} url */
|
||||
export function resolve_url(url) {
|
||||
if (url instanceof URL) return url;
|
||||
|
||||
let baseURI = document.baseURI;
|
||||
|
||||
if (!baseURI) {
|
||||
const baseTags = document.getElementsByTagName('base');
|
||||
baseURI = baseTags.length ? baseTags[0].href : document.URL;
|
||||
}
|
||||
|
||||
return new URL(url, baseURI);
|
||||
}
|
||||
|
||||
export function scroll_state() {
|
||||
return {
|
||||
x: pageXOffset,
|
||||
y: pageYOffset
|
||||
};
|
||||
}
|
||||
|
||||
const warned = new WeakSet();
|
||||
|
||||
/** @typedef {keyof typeof valid_link_options} LinkOptionName */
|
||||
|
||||
const valid_link_options = /** @type {const} */ ({
|
||||
'preload-code': ['', 'off', 'false', 'tap', 'hover', 'viewport', 'eager'],
|
||||
'preload-data': ['', 'off', 'false', 'tap', 'hover'],
|
||||
keepfocus: ['', 'true', 'off', 'false'],
|
||||
noscroll: ['', 'true', 'off', 'false'],
|
||||
reload: ['', 'true', 'off', 'false'],
|
||||
replacestate: ['', 'true', 'off', 'false']
|
||||
});
|
||||
|
||||
/**
|
||||
* @template {LinkOptionName} T
|
||||
* @typedef {typeof valid_link_options[T][number]} ValidLinkOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template {LinkOptionName} T
|
||||
* @param {Element} element
|
||||
* @param {T} name
|
||||
*/
|
||||
function link_option(element, name) {
|
||||
const value = /** @type {ValidLinkOptions<T> | null} */ (
|
||||
element.getAttribute(`data-sveltekit-${name}`)
|
||||
);
|
||||
|
||||
if (DEV) {
|
||||
validate_link_option(element, name, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {LinkOptionName} T
|
||||
* @template {ValidLinkOptions<T> | null} U
|
||||
* @param {Element} element
|
||||
* @param {T} name
|
||||
* @param {U} value
|
||||
*/
|
||||
function validate_link_option(element, name, value) {
|
||||
if (value === null) return;
|
||||
|
||||
// @ts-expect-error - includes is dumb
|
||||
if (!warned.has(element) && !valid_link_options[name].includes(value)) {
|
||||
console.error(
|
||||
`Unexpected value for ${name} — should be one of ${valid_link_options[name]
|
||||
.map((option) => JSON.stringify(option))
|
||||
.join(', ')}`,
|
||||
element
|
||||
);
|
||||
|
||||
warned.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
const levels = {
|
||||
...PRELOAD_PRIORITIES,
|
||||
'': PRELOAD_PRIORITIES.hover
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {Element | null}
|
||||
*/
|
||||
function parent_element(element) {
|
||||
let parent = element.assignedSlot ?? element.parentNode;
|
||||
|
||||
// @ts-expect-error handle shadow roots
|
||||
if (parent?.nodeType === 11) parent = parent.host;
|
||||
|
||||
return /** @type {Element} */ (parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @param {Element} target
|
||||
*/
|
||||
export function find_anchor(element, target) {
|
||||
while (element && element !== target) {
|
||||
if (element.nodeName.toUpperCase() === 'A' && element.hasAttribute('href')) {
|
||||
return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
|
||||
}
|
||||
|
||||
element = /** @type {Element} */ (parent_element(element));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAnchorElement | SVGAElement} a
|
||||
* @param {string} base
|
||||
* @param {boolean} uses_hash_router
|
||||
*/
|
||||
export function get_link_info(a, base, uses_hash_router) {
|
||||
/** @type {URL | undefined} */
|
||||
let url;
|
||||
|
||||
try {
|
||||
url = new URL(a instanceof SVGAElement ? a.href.baseVal : a.href, document.baseURI);
|
||||
|
||||
// if the hash doesn't start with `#/` then it's probably linking to an id on the current page
|
||||
if (uses_hash_router && url.hash.match(/^#[^/]/)) {
|
||||
const route = location.hash.split('#')[1] || '/';
|
||||
url.hash = `#${route}${url.hash}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const target = a instanceof SVGAElement ? a.target.baseVal : a.target;
|
||||
|
||||
const external =
|
||||
!url ||
|
||||
!!target ||
|
||||
is_external_url(url, base, uses_hash_router) ||
|
||||
(a.getAttribute('rel') || '').split(/\s+/).includes('external');
|
||||
|
||||
const download = url?.origin === origin && a.hasAttribute('download');
|
||||
|
||||
return { url, external, target, download };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLFormElement | HTMLAnchorElement | SVGAElement} element
|
||||
*/
|
||||
export function get_router_options(element) {
|
||||
/** @type {ValidLinkOptions<'keepfocus'> | null} */
|
||||
let keepfocus = null;
|
||||
|
||||
/** @type {ValidLinkOptions<'noscroll'> | null} */
|
||||
let noscroll = null;
|
||||
|
||||
/** @type {ValidLinkOptions<'preload-code'> | null} */
|
||||
let preload_code = null;
|
||||
|
||||
/** @type {ValidLinkOptions<'preload-data'> | null} */
|
||||
let preload_data = null;
|
||||
|
||||
/** @type {ValidLinkOptions<'reload'> | null} */
|
||||
let reload = null;
|
||||
|
||||
/** @type {ValidLinkOptions<'replacestate'> | null} */
|
||||
let replace_state = null;
|
||||
|
||||
/** @type {Element} */
|
||||
let el = element;
|
||||
|
||||
while (el && el !== document.documentElement) {
|
||||
if (preload_code === null) preload_code = link_option(el, 'preload-code');
|
||||
if (preload_data === null) preload_data = link_option(el, 'preload-data');
|
||||
if (keepfocus === null) keepfocus = link_option(el, 'keepfocus');
|
||||
if (noscroll === null) noscroll = link_option(el, 'noscroll');
|
||||
if (reload === null) reload = link_option(el, 'reload');
|
||||
if (replace_state === null) replace_state = link_option(el, 'replacestate');
|
||||
|
||||
el = /** @type {Element} */ (parent_element(el));
|
||||
}
|
||||
|
||||
/** @param {string | null} value */
|
||||
function get_option_state(value) {
|
||||
switch (value) {
|
||||
case '':
|
||||
case 'true':
|
||||
return true;
|
||||
case 'off':
|
||||
case 'false':
|
||||
return false;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
preload_code: levels[preload_code ?? 'off'],
|
||||
preload_data: levels[preload_data ?? 'off'],
|
||||
keepfocus: get_option_state(keepfocus),
|
||||
noscroll: get_option_state(noscroll),
|
||||
reload: get_option_state(reload),
|
||||
replace_state: get_option_state(replace_state)
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {any} value */
|
||||
export function notifiable_store(value) {
|
||||
const store = writable(value);
|
||||
let ready = true;
|
||||
|
||||
function notify() {
|
||||
ready = true;
|
||||
store.update((val) => val);
|
||||
}
|
||||
|
||||
/** @param {any} new_value */
|
||||
function set(new_value) {
|
||||
ready = false;
|
||||
store.set(new_value);
|
||||
}
|
||||
|
||||
/** @param {(value: any) => void} run */
|
||||
function subscribe(run) {
|
||||
/** @type {any} */
|
||||
let old_value;
|
||||
return store.subscribe((new_value) => {
|
||||
if (old_value === undefined || (ready && new_value !== old_value)) {
|
||||
run((old_value = new_value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { notify, set, subscribe };
|
||||
}
|
||||
|
||||
export const updated_listener = {
|
||||
v: () => {}
|
||||
};
|
||||
|
||||
export function create_updated_store() {
|
||||
const { set, subscribe } = writable(false);
|
||||
|
||||
if (DEV || !BROWSER) {
|
||||
return {
|
||||
subscribe,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
check: async () => false
|
||||
};
|
||||
}
|
||||
|
||||
const interval = __SVELTEKIT_APP_VERSION_POLL_INTERVAL__;
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
let timeout;
|
||||
|
||||
/** @type {() => Promise<boolean>} */
|
||||
async function check() {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (interval) timeout = setTimeout(check, interval);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${assets}/${__SVELTEKIT_APP_VERSION_FILE__}`, {
|
||||
headers: {
|
||||
pragma: 'no-cache',
|
||||
'cache-control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const updated = data.version !== version;
|
||||
|
||||
if (updated) {
|
||||
set(true);
|
||||
updated_listener.v();
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (interval) timeout = setTimeout(check, interval);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
check
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is external if
|
||||
* - origin different
|
||||
* - path doesn't start with base
|
||||
* - uses hash router and pathname is more than base
|
||||
* @param {URL} url
|
||||
* @param {string} base
|
||||
* @param {boolean} hash_routing
|
||||
*/
|
||||
export function is_external_url(url, base, hash_routing) {
|
||||
if (url.origin !== origin || !url.pathname.startsWith(base)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hash_routing) {
|
||||
return url.pathname !== location.pathname;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @type {Set<string> | null} */
|
||||
let seen = null;
|
||||
|
||||
/**
|
||||
* Used for server-side resolution, to replicate Vite's CSS loading behaviour in production.
|
||||
*
|
||||
* Closely modelled after https://github.com/vitejs/vite/blob/3dd12f4724130fdf8ba44c6d3252ebdff407fd47/packages/vite/src/node/plugins/importAnalysisBuild.ts#L214
|
||||
* (which ideally we could just use directly, but it's not exported)
|
||||
* @param {string[]} deps
|
||||
*/
|
||||
export function load_css(deps) {
|
||||
if (__SVELTEKIT_CLIENT_ROUTING__) return;
|
||||
|
||||
const csp_nonce_meta = /** @type {HTMLMetaElement} */ (
|
||||
document.querySelector('meta[property=csp-nonce]')
|
||||
);
|
||||
const csp_nonce = csp_nonce_meta?.nonce || csp_nonce_meta?.getAttribute('nonce');
|
||||
|
||||
seen ??= new Set(
|
||||
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((link) => {
|
||||
return /** @type {HTMLLinkElement} */ (link).href;
|
||||
})
|
||||
);
|
||||
|
||||
for (const dep of deps) {
|
||||
const href = new URL(dep, document.baseURI).href;
|
||||
|
||||
if (seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.crossOrigin = '';
|
||||
link.href = dep;
|
||||
if (csp_nonce) {
|
||||
link.setAttribute('nonce', csp_nonce);
|
||||
}
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1>
|
||||
<p>{$page.error?.message}</p>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<slot />
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<h1>{page.status}</h1>
|
||||
<p>{page.error?.message}</p>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { private_env as env } from '../../shared-server.js';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { public_env as env } from '../../shared-server.js';
|
||||
+838
@@ -0,0 +1,838 @@
|
||||
/** @import { RemoteForm } from '@sveltejs/kit' */
|
||||
/** @import { BinaryFormMeta, InternalRemoteFormIssue } from 'types' */
|
||||
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
|
||||
|
||||
import { DEV } from 'esm-env';
|
||||
import * as devalue from 'devalue';
|
||||
import { text_decoder, text_encoder } from './utils.js';
|
||||
import { SvelteKitError } from '@sveltejs/kit/internal';
|
||||
|
||||
/**
|
||||
* Sets a value in a nested object using a path string, mutating the original object
|
||||
* @param {Record<string, any>} object
|
||||
* @param {string} path_string
|
||||
* @param {any} value
|
||||
*/
|
||||
export function set_nested_value(object, path_string, value) {
|
||||
if (path_string.startsWith('n:')) {
|
||||
path_string = path_string.slice(2);
|
||||
value = value === '' ? undefined : parseFloat(value);
|
||||
} else if (path_string.startsWith('b:')) {
|
||||
path_string = path_string.slice(2);
|
||||
value = value === 'on';
|
||||
}
|
||||
|
||||
deep_set(object, split_path(path_string), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `FormData` into a POJO
|
||||
* @param {FormData} data
|
||||
*/
|
||||
export function convert_formdata(data) {
|
||||
/** @type {Record<string, any>} */
|
||||
const result = {};
|
||||
|
||||
for (let key of data.keys()) {
|
||||
const is_array = key.endsWith('[]');
|
||||
/** @type {any[]} */
|
||||
let values = data.getAll(key);
|
||||
|
||||
if (is_array) key = key.slice(0, -2);
|
||||
|
||||
if (values.length > 1 && !is_array) {
|
||||
throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`);
|
||||
}
|
||||
|
||||
// an empty `<input type="file">` will submit a non-existent file, bizarrely
|
||||
values = values.filter(
|
||||
(entry) => typeof entry === 'string' || entry.name !== '' || entry.size > 0
|
||||
);
|
||||
|
||||
if (key.startsWith('n:')) {
|
||||
key = key.slice(2);
|
||||
values = values.map((v) => (v === '' ? undefined : parseFloat(/** @type {string} */ (v))));
|
||||
} else if (key.startsWith('b:')) {
|
||||
key = key.slice(2);
|
||||
values = values.map((v) => v === 'on');
|
||||
}
|
||||
|
||||
set_nested_value(result, key, is_array ? values : values[0]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const BINARY_FORM_CONTENT_TYPE = 'application/x-sveltekit-formdata';
|
||||
const BINARY_FORM_VERSION = 0;
|
||||
const HEADER_BYTES = 1 + 4 + 2;
|
||||
/**
|
||||
* The binary format is as follows:
|
||||
* - 1 byte: Format version
|
||||
* - 4 bytes: Length of the header (u32)
|
||||
* - 2 bytes: Length of the file offset table (u16)
|
||||
* - header: devalue.stringify([data, meta])
|
||||
* - file offset table: JSON.stringify([offset1, offset2, ...]) (empty if no files) (offsets start from the end of the table)
|
||||
* - file1, file2, ...
|
||||
* @param {Record<string, any>} data
|
||||
* @param {BinaryFormMeta} meta
|
||||
*/
|
||||
export function serialize_binary_form(data, meta) {
|
||||
/** @type {Array<BlobPart>} */
|
||||
const blob_parts = [new Uint8Array([BINARY_FORM_VERSION])];
|
||||
|
||||
/** @type {Array<[file: File, index: number]>} */
|
||||
const files = [];
|
||||
|
||||
if (!meta.remote_refreshes?.length) {
|
||||
delete meta.remote_refreshes;
|
||||
}
|
||||
|
||||
const encoded_header = devalue.stringify([data, meta], {
|
||||
File: (file) => {
|
||||
if (!(file instanceof File)) return;
|
||||
|
||||
files.push([file, files.length]);
|
||||
return [file.name, file.type, file.size, file.lastModified, files.length - 1];
|
||||
}
|
||||
});
|
||||
|
||||
const encoded_header_buffer = text_encoder.encode(encoded_header);
|
||||
|
||||
let encoded_file_offsets = '';
|
||||
if (files.length) {
|
||||
// Sort small files to the front
|
||||
files.sort(([a], [b]) => a.size - b.size);
|
||||
|
||||
/** @type {Array<number>} */
|
||||
const file_offsets = new Array(files.length);
|
||||
let start = 0;
|
||||
for (const [file, index] of files) {
|
||||
file_offsets[index] = start;
|
||||
start += file.size;
|
||||
}
|
||||
encoded_file_offsets = JSON.stringify(file_offsets);
|
||||
}
|
||||
|
||||
const length_buffer = new Uint8Array(4);
|
||||
const length_view = new DataView(length_buffer.buffer);
|
||||
|
||||
length_view.setUint32(0, encoded_header_buffer.byteLength, true);
|
||||
blob_parts.push(length_buffer.slice());
|
||||
|
||||
length_view.setUint16(0, encoded_file_offsets.length, true);
|
||||
blob_parts.push(length_buffer.slice(0, 2));
|
||||
|
||||
blob_parts.push(encoded_header_buffer);
|
||||
blob_parts.push(encoded_file_offsets);
|
||||
|
||||
for (const [file] of files) {
|
||||
blob_parts.push(file);
|
||||
}
|
||||
|
||||
return {
|
||||
blob: new Blob(blob_parts)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @returns {Promise<{ data: Record<string, any>; meta: BinaryFormMeta; form_data: FormData | null }>}
|
||||
*/
|
||||
export async function deserialize_binary_form(request) {
|
||||
if (request.headers.get('content-type') !== BINARY_FORM_CONTENT_TYPE) {
|
||||
const form_data = await request.formData();
|
||||
return { data: convert_formdata(form_data), meta: {}, form_data };
|
||||
}
|
||||
if (!request.body) {
|
||||
throw deserialize_error('no body');
|
||||
}
|
||||
const content_length = parseInt(request.headers.get('content-length') ?? '');
|
||||
if (Number.isNaN(content_length)) {
|
||||
throw deserialize_error('invalid Content-Length header');
|
||||
}
|
||||
|
||||
const reader = request.body.getReader();
|
||||
|
||||
/** @type {Array<Promise<Uint8Array<ArrayBuffer> | undefined>>} */
|
||||
const chunks = [];
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {Promise<Uint8Array<ArrayBuffer> | undefined>}
|
||||
*/
|
||||
function get_chunk(index) {
|
||||
if (index in chunks) return chunks[index];
|
||||
|
||||
let i = chunks.length;
|
||||
while (i <= index) {
|
||||
chunks[i] = reader.read().then((chunk) => chunk.value);
|
||||
i++;
|
||||
}
|
||||
return chunks[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offset
|
||||
* @param {number} length
|
||||
* @returns {Promise<Uint8Array | null>}
|
||||
*/
|
||||
async function get_buffer(offset, length) {
|
||||
/** @type {Uint8Array} */
|
||||
let start_chunk;
|
||||
let chunk_start = 0;
|
||||
/** @type {number} */
|
||||
let chunk_index;
|
||||
for (chunk_index = 0; ; chunk_index++) {
|
||||
const chunk = await get_chunk(chunk_index);
|
||||
if (!chunk) return null;
|
||||
|
||||
const chunk_end = chunk_start + chunk.byteLength;
|
||||
// If this chunk contains the target offset
|
||||
if (offset >= chunk_start && offset < chunk_end) {
|
||||
start_chunk = chunk;
|
||||
break;
|
||||
}
|
||||
chunk_start = chunk_end;
|
||||
}
|
||||
// If the buffer is completely contained in one chunk, do a subarray
|
||||
if (offset + length <= chunk_start + start_chunk.byteLength) {
|
||||
return start_chunk.subarray(offset - chunk_start, offset + length - chunk_start);
|
||||
}
|
||||
// Otherwise, copy the data into a new buffer
|
||||
const chunks = [start_chunk.subarray(offset - chunk_start)];
|
||||
let cursor = start_chunk.byteLength - offset + chunk_start;
|
||||
while (cursor < length) {
|
||||
chunk_index++;
|
||||
let chunk = await get_chunk(chunk_index);
|
||||
if (!chunk) return null;
|
||||
if (chunk.byteLength > length - cursor) {
|
||||
chunk = chunk.subarray(0, length - cursor);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
cursor += chunk.byteLength;
|
||||
}
|
||||
const buffer = new Uint8Array(length);
|
||||
cursor = 0;
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, cursor);
|
||||
cursor += chunk.byteLength;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const header = await get_buffer(0, HEADER_BYTES);
|
||||
if (!header) throw deserialize_error('too short');
|
||||
|
||||
if (header[0] !== BINARY_FORM_VERSION) {
|
||||
throw deserialize_error(`got version ${header[0]}, expected version ${BINARY_FORM_VERSION}`);
|
||||
}
|
||||
const header_view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
||||
const data_length = header_view.getUint32(1, true);
|
||||
|
||||
if (HEADER_BYTES + data_length > content_length) {
|
||||
throw deserialize_error('data overflow');
|
||||
}
|
||||
|
||||
const file_offsets_length = header_view.getUint16(5, true);
|
||||
|
||||
if (HEADER_BYTES + data_length + file_offsets_length > content_length) {
|
||||
throw deserialize_error('file offset table overflow');
|
||||
}
|
||||
|
||||
// Read the form data
|
||||
const data_buffer = await get_buffer(HEADER_BYTES, data_length);
|
||||
if (!data_buffer) throw deserialize_error('data too short');
|
||||
|
||||
/** @type {Array<number>} */
|
||||
let file_offsets;
|
||||
/** @type {number} */
|
||||
let files_start_offset;
|
||||
if (file_offsets_length > 0) {
|
||||
// Read the file offset table
|
||||
const file_offsets_buffer = await get_buffer(HEADER_BYTES + data_length, file_offsets_length);
|
||||
if (!file_offsets_buffer) throw deserialize_error('file offset table too short');
|
||||
|
||||
file_offsets = /** @type {Array<number>} */ (
|
||||
JSON.parse(text_decoder.decode(file_offsets_buffer))
|
||||
);
|
||||
files_start_offset = HEADER_BYTES + data_length + file_offsets_length;
|
||||
}
|
||||
|
||||
const [data, meta] = devalue.parse(text_decoder.decode(data_buffer), {
|
||||
File: ([name, type, size, last_modified, index]) => {
|
||||
if (files_start_offset + file_offsets[index] + size > content_length) {
|
||||
throw deserialize_error('file data overflow');
|
||||
}
|
||||
return new Proxy(
|
||||
new LazyFile(
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
last_modified,
|
||||
get_chunk,
|
||||
files_start_offset + file_offsets[index]
|
||||
),
|
||||
{
|
||||
getPrototypeOf() {
|
||||
// Trick validators into thinking this is a normal File
|
||||
return File.prototype;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Read the request body asyncronously so it doesn't stall
|
||||
void (async () => {
|
||||
let has_more = true;
|
||||
while (has_more) {
|
||||
const chunk = await get_chunk(chunks.length);
|
||||
has_more = !!chunk;
|
||||
}
|
||||
})();
|
||||
|
||||
return { data, meta, form_data: null };
|
||||
}
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
function deserialize_error(message) {
|
||||
return new SvelteKitError(400, 'Bad Request', `Could not deserialize binary form: ${message}`);
|
||||
}
|
||||
|
||||
/** @implements {File} */
|
||||
class LazyFile {
|
||||
/** @type {(index: number) => Promise<Uint8Array<ArrayBuffer> | undefined>} */
|
||||
#get_chunk;
|
||||
/** @type {number} */
|
||||
#offset;
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} type
|
||||
* @param {number} size
|
||||
* @param {number} last_modified
|
||||
* @param {(index: number) => Promise<Uint8Array<ArrayBuffer> | undefined>} get_chunk
|
||||
* @param {number} offset
|
||||
*/
|
||||
constructor(name, type, size, last_modified, get_chunk, offset) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.size = size;
|
||||
this.lastModified = last_modified;
|
||||
this.webkitRelativePath = '';
|
||||
this.#get_chunk = get_chunk;
|
||||
this.#offset = offset;
|
||||
|
||||
// TODO - hacky, required for private members to be accessed on proxy
|
||||
this.arrayBuffer = this.arrayBuffer.bind(this);
|
||||
this.bytes = this.bytes.bind(this);
|
||||
this.slice = this.slice.bind(this);
|
||||
this.stream = this.stream.bind(this);
|
||||
this.text = this.text.bind(this);
|
||||
}
|
||||
/** @type {ArrayBuffer | undefined} */
|
||||
#buffer;
|
||||
async arrayBuffer() {
|
||||
this.#buffer ??= await new Response(this.stream()).arrayBuffer();
|
||||
return this.#buffer;
|
||||
}
|
||||
async bytes() {
|
||||
return new Uint8Array(await this.arrayBuffer());
|
||||
}
|
||||
/**
|
||||
* @param {number=} start
|
||||
* @param {number=} end
|
||||
* @param {string=} contentType
|
||||
*/
|
||||
slice(start = 0, end = this.size, contentType = this.type) {
|
||||
// https://github.com/nodejs/node/blob/a5f3cd8cb5ba9e7911d93c5fd3ebc6d781220dd8/lib/internal/blob.js#L240
|
||||
if (start < 0) {
|
||||
start = Math.max(this.size + start, 0);
|
||||
} else {
|
||||
start = Math.min(start, this.size);
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
end = Math.max(this.size + end, 0);
|
||||
} else {
|
||||
end = Math.min(end, this.size);
|
||||
}
|
||||
const size = Math.max(end - start, 0);
|
||||
const file = new LazyFile(
|
||||
this.name,
|
||||
contentType,
|
||||
size,
|
||||
this.lastModified,
|
||||
this.#get_chunk,
|
||||
this.#offset + start
|
||||
);
|
||||
|
||||
return file;
|
||||
}
|
||||
stream() {
|
||||
let cursor = 0;
|
||||
let chunk_index = 0;
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
let chunk_start = 0;
|
||||
let start_chunk = null;
|
||||
for (chunk_index = 0; ; chunk_index++) {
|
||||
const chunk = await this.#get_chunk(chunk_index);
|
||||
if (!chunk) return null;
|
||||
|
||||
const chunk_end = chunk_start + chunk.byteLength;
|
||||
// If this chunk contains the target offset
|
||||
if (this.#offset >= chunk_start && this.#offset < chunk_end) {
|
||||
start_chunk = chunk;
|
||||
break;
|
||||
}
|
||||
chunk_start = chunk_end;
|
||||
}
|
||||
// If the buffer is completely contained in one chunk, do a subarray
|
||||
if (this.#offset + this.size <= chunk_start + start_chunk.byteLength) {
|
||||
controller.enqueue(
|
||||
start_chunk.subarray(this.#offset - chunk_start, this.#offset + this.size - chunk_start)
|
||||
);
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(start_chunk.subarray(this.#offset - chunk_start));
|
||||
cursor = start_chunk.byteLength - this.#offset + chunk_start;
|
||||
}
|
||||
},
|
||||
pull: async (controller) => {
|
||||
chunk_index++;
|
||||
let chunk = await this.#get_chunk(chunk_index);
|
||||
if (!chunk) {
|
||||
controller.error('incomplete file data');
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (chunk.byteLength > this.size - cursor) {
|
||||
chunk = chunk.subarray(0, this.size - cursor);
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
cursor += chunk.byteLength;
|
||||
if (cursor >= this.size) {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async text() {
|
||||
return text_decoder.decode(await this.arrayBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
const path_regex = /^[a-zA-Z_$]\w*(\.[a-zA-Z_$]\w*|\[\d+\])*$/;
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
export function split_path(path) {
|
||||
if (!path_regex.test(path)) {
|
||||
throw new Error(`Invalid path ${path}`);
|
||||
}
|
||||
|
||||
return path.split(/\.|\[|\]/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property key is dangerous and could lead to prototype pollution
|
||||
* @param {string} key
|
||||
*/
|
||||
function check_prototype_pollution(key) {
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
||||
throw new Error(
|
||||
`Invalid key "${key}"` +
|
||||
(DEV ? ': This key is not allowed to prevent prototype pollution.' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in a nested object using an array of keys, mutating the original object.
|
||||
* @param {Record<string, any>} object
|
||||
* @param {string[]} keys
|
||||
* @param {any} value
|
||||
*/
|
||||
export function deep_set(object, keys, value) {
|
||||
let current = object;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i += 1) {
|
||||
const key = keys[i];
|
||||
|
||||
check_prototype_pollution(key);
|
||||
|
||||
const is_array = /^\d+$/.test(keys[i + 1]);
|
||||
const exists = Object.hasOwn(current, key);
|
||||
const inner = current[key];
|
||||
|
||||
if (exists && is_array !== Array.isArray(inner)) {
|
||||
throw new Error(`Invalid array key ${keys[i + 1]}`);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
current[key] = is_array ? [] : {};
|
||||
}
|
||||
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
const final_key = keys[keys.length - 1];
|
||||
check_prototype_pollution(final_key);
|
||||
current[final_key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StandardSchemaV1.Issue} issue
|
||||
* @param {boolean} server Whether this issue came from server validation
|
||||
*/
|
||||
export function normalize_issue(issue, server = false) {
|
||||
/** @type {InternalRemoteFormIssue} */
|
||||
const normalized = { name: '', path: [], message: issue.message, server };
|
||||
|
||||
if (issue.path !== undefined) {
|
||||
let name = '';
|
||||
|
||||
for (const segment of issue.path) {
|
||||
const key = /** @type {string | number} */ (
|
||||
typeof segment === 'object' ? segment.key : segment
|
||||
);
|
||||
|
||||
normalized.path.push(key);
|
||||
|
||||
if (typeof key === 'number') {
|
||||
name += `[${key}]`;
|
||||
} else if (typeof key === 'string') {
|
||||
name += name === '' ? key : '.' + key;
|
||||
}
|
||||
}
|
||||
|
||||
normalized.name = name;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InternalRemoteFormIssue[]} issues
|
||||
*/
|
||||
export function flatten_issues(issues) {
|
||||
/** @type {Record<string, InternalRemoteFormIssue[]>} */
|
||||
const result = {};
|
||||
|
||||
for (const issue of issues) {
|
||||
(result.$ ??= []).push(issue);
|
||||
|
||||
let name = '';
|
||||
|
||||
if (issue.path !== undefined) {
|
||||
for (const key of issue.path) {
|
||||
if (typeof key === 'number') {
|
||||
name += `[${key}]`;
|
||||
} else if (typeof key === 'string') {
|
||||
name += name === '' ? key : '.' + key;
|
||||
}
|
||||
|
||||
(result[name] ??= []).push(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a nested value from an object using a path array
|
||||
* @param {Record<string, any>} object
|
||||
* @param {(string | number)[]} path
|
||||
* @returns {any}
|
||||
*/
|
||||
|
||||
export function deep_get(object, path) {
|
||||
let current = object;
|
||||
for (const key of path) {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return current;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a proxy-based field accessor for form data
|
||||
* @param {any} target - Function or empty POJO
|
||||
* @param {() => Record<string, any>} get_input - Function to get current input data
|
||||
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
|
||||
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
|
||||
* @param {(string | number)[]} path - Current access path
|
||||
* @returns {any} Proxy object with name(), value(), and issues() methods
|
||||
*/
|
||||
export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
|
||||
const get_value = () => {
|
||||
return deep_get(get_input(), path);
|
||||
};
|
||||
|
||||
return new Proxy(target, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'symbol') return target[prop];
|
||||
|
||||
// Handle array access like jobs[0]
|
||||
if (/^\d+$/.test(prop)) {
|
||||
return create_field_proxy({}, get_input, set_input, get_issues, [
|
||||
...path,
|
||||
parseInt(prop, 10)
|
||||
]);
|
||||
}
|
||||
|
||||
const key = build_path_string(path);
|
||||
|
||||
if (prop === 'set') {
|
||||
const set_func = function (/** @type {any} */ newValue) {
|
||||
set_input(path, newValue);
|
||||
return newValue;
|
||||
};
|
||||
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
|
||||
}
|
||||
|
||||
if (prop === 'value') {
|
||||
return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]);
|
||||
}
|
||||
|
||||
if (prop === 'issues' || prop === 'allIssues') {
|
||||
const issues_func = () => {
|
||||
const all_issues = get_issues()[key === '' ? '$' : key];
|
||||
|
||||
if (prop === 'allIssues') {
|
||||
return all_issues?.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message
|
||||
}));
|
||||
}
|
||||
|
||||
return all_issues
|
||||
?.filter((issue) => issue.name === key)
|
||||
?.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message
|
||||
}));
|
||||
};
|
||||
|
||||
return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
|
||||
}
|
||||
|
||||
if (prop === 'as') {
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {string} [input_value]
|
||||
*/
|
||||
const as_func = (type, input_value) => {
|
||||
const is_array =
|
||||
type === 'file multiple' ||
|
||||
type === 'select multiple' ||
|
||||
(type === 'checkbox' && typeof input_value === 'string');
|
||||
|
||||
const prefix =
|
||||
type === 'number' || type === 'range'
|
||||
? 'n:'
|
||||
: type === 'checkbox' && !is_array
|
||||
? 'b:'
|
||||
: '';
|
||||
|
||||
// Base properties for all input types
|
||||
/** @type {Record<string, any>} */
|
||||
const base_props = {
|
||||
name: prefix + key + (is_array ? '[]' : ''),
|
||||
get 'aria-invalid'() {
|
||||
const issues = get_issues();
|
||||
return key in issues ? 'true' : undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Add type attribute only for non-text inputs and non-select elements
|
||||
if (type !== 'text' && type !== 'select' && type !== 'select multiple') {
|
||||
base_props.type = type === 'file multiple' ? 'file' : type;
|
||||
}
|
||||
|
||||
// Handle submit and hidden inputs
|
||||
if (type === 'submit' || type === 'hidden') {
|
||||
if (DEV) {
|
||||
if (!input_value) {
|
||||
throw new Error(`\`${type}\` inputs must have a value`);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.defineProperties(base_props, {
|
||||
value: { value: input_value, enumerable: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle select inputs
|
||||
if (type === 'select' || type === 'select multiple') {
|
||||
return Object.defineProperties(base_props, {
|
||||
multiple: { value: is_array, enumerable: true },
|
||||
value: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
return get_value();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle checkbox inputs
|
||||
if (type === 'checkbox' || type === 'radio') {
|
||||
if (DEV) {
|
||||
if (type === 'radio' && !input_value) {
|
||||
throw new Error('Radio inputs must have a value');
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && is_array && !input_value) {
|
||||
throw new Error('Checkbox array inputs must have a value');
|
||||
}
|
||||
}
|
||||
|
||||
return Object.defineProperties(base_props, {
|
||||
value: { value: input_value ?? 'on', enumerable: true },
|
||||
checked: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
const value = get_value();
|
||||
|
||||
if (type === 'radio') {
|
||||
return value === input_value;
|
||||
}
|
||||
|
||||
if (is_array) {
|
||||
return (value ?? []).includes(input_value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle file inputs
|
||||
if (type === 'file' || type === 'file multiple') {
|
||||
return Object.defineProperties(base_props, {
|
||||
multiple: { value: is_array, enumerable: true },
|
||||
files: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
const value = get_value();
|
||||
|
||||
// Convert File/File[] to FileList-like object
|
||||
if (value instanceof File) {
|
||||
// In browsers, we can create a proper FileList using DataTransfer
|
||||
if (typeof DataTransfer !== 'undefined') {
|
||||
const fileList = new DataTransfer();
|
||||
fileList.items.add(value);
|
||||
return fileList.files;
|
||||
}
|
||||
// Fallback for environments without DataTransfer
|
||||
return { 0: value, length: 1 };
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.every((f) => f instanceof File)) {
|
||||
if (typeof DataTransfer !== 'undefined') {
|
||||
const fileList = new DataTransfer();
|
||||
value.forEach((file) => fileList.items.add(file));
|
||||
return fileList.files;
|
||||
}
|
||||
// Fallback for environments without DataTransfer
|
||||
/** @type {any} */
|
||||
const fileListLike = { length: value.length };
|
||||
value.forEach((file, index) => {
|
||||
fileListLike[index] = file;
|
||||
});
|
||||
return fileListLike;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle all other input types (text, number, etc.)
|
||||
return Object.defineProperties(base_props, {
|
||||
value: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
const value = get_value();
|
||||
return value != null ? String(value) : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
|
||||
}
|
||||
|
||||
// Handle property access (nested fields)
|
||||
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a path string from an array of path segments
|
||||
* @param {(string | number)[]} path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function build_path_string(path) {
|
||||
let result = '';
|
||||
|
||||
for (const segment of path) {
|
||||
if (typeof segment === 'number') {
|
||||
result += `[${segment}]`;
|
||||
} else {
|
||||
result += result === '' ? segment : '.' + segment;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RemoteForm<any, any>} instance
|
||||
* @deprecated remove in 3.0
|
||||
*/
|
||||
export function throw_on_old_property_access(instance) {
|
||||
Object.defineProperty(instance, 'field', {
|
||||
value: (/** @type {string} */ name) => {
|
||||
const new_name = name.endsWith('[]') ? name.slice(0, -2) : name;
|
||||
throw new Error(
|
||||
`\`form.field\` has been removed: Instead of \`<input name={form.field('${name}')} />\` do \`<input {...form.fields.${new_name}.as(type)} />\``
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const property of ['input', 'issues']) {
|
||||
Object.defineProperty(instance, property, {
|
||||
get() {
|
||||
const new_name = property === 'issues' ? 'issues' : 'value';
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
const prop_string = typeof prop === 'string' ? prop : String(prop);
|
||||
const old =
|
||||
prop_string.includes('[') || prop_string.includes('.')
|
||||
? `['${prop_string}']`
|
||||
: `.${prop_string}`;
|
||||
const replacement = `.${prop_string}.${new_name}()`;
|
||||
throw new Error(
|
||||
`\`form.${property}\` has been removed: Instead of \`form.${property}${old}\` write \`form.fields${replacement}\``
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
const DATA_SUFFIX = '/__data.json';
|
||||
const HTML_DATA_SUFFIX = '.html__data.json';
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function has_data_suffix(pathname) {
|
||||
return pathname.endsWith(DATA_SUFFIX) || pathname.endsWith(HTML_DATA_SUFFIX);
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function add_data_suffix(pathname) {
|
||||
if (pathname.endsWith('.html')) return pathname.replace(/\.html$/, HTML_DATA_SUFFIX);
|
||||
return pathname.replace(/\/$/, '') + DATA_SUFFIX;
|
||||
}
|
||||
|
||||
/** @param {string} pathname */
|
||||
export function strip_data_suffix(pathname) {
|
||||
if (pathname.endsWith(HTML_DATA_SUFFIX)) {
|
||||
return pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html';
|
||||
}
|
||||
|
||||
return pathname.slice(0, -DATA_SUFFIX.length);
|
||||
}
|
||||
|
||||
const ROUTE_SUFFIX = '/__route.js';
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function has_resolution_suffix(pathname) {
|
||||
return pathname.endsWith(ROUTE_SUFFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a regular URL to a route to send to SvelteKit's server-side route resolution endpoint
|
||||
* @param {string} pathname
|
||||
* @returns {string}
|
||||
*/
|
||||
export function add_resolution_suffix(pathname) {
|
||||
return pathname.replace(/\/$/, '') + ROUTE_SUFFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @returns {string}
|
||||
*/
|
||||
export function strip_resolution_suffix(pathname) {
|
||||
return pathname.slice(0, -ROUTE_SUFFIX.length);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module '__SERVER__/internal.js' {
|
||||
export const options: import('types').SSROptions;
|
||||
export const get_hooks: () => Promise<Partial<import('types').ServerHooks>>;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/** @type {{ decoders: Record<string, (data: any) => any> }} */
|
||||
export let app;
|
||||
|
||||
/**
|
||||
* @param {{ decoders: Record<string, (data: any) => any> }} value
|
||||
*/
|
||||
export function set_app(value) {
|
||||
app = value;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const NULL_BODY_STATUS = [101, 103, 204, 205, 304];
|
||||
|
||||
// eslint-disable-next-line n/prefer-global/process
|
||||
export const IN_WEBCONTAINER = !!globalThis.process?.versions?.webcontainer;
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
import { parse, serialize } from 'cookie';
|
||||
import { DEV } from 'esm-env';
|
||||
import { normalize_path, resolve } from '../../utils/url.js';
|
||||
import { add_data_suffix } from '../pathname.js';
|
||||
import { text_encoder } from '../utils.js';
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- control characters are invalid in cookie names
|
||||
const INVALID_COOKIE_CHARACTER_REGEX = /[\x00-\x1F\x7F()<>@,;:"/[\]?={} \t]/;
|
||||
|
||||
/**
|
||||
* Tracks all cookies set during dev mode so we can emit warnings
|
||||
* when we detect that there's likely cookie misusage due to wrong paths
|
||||
*
|
||||
* @type {Record<string, Set<string>>} */
|
||||
const cookie_paths = {};
|
||||
|
||||
/**
|
||||
* Cookies that are larger than this size (including the name and other
|
||||
* attributes) are discarded by browsers
|
||||
*/
|
||||
const MAX_COOKIE_SIZE = 4129;
|
||||
|
||||
// TODO 3.0 remove this check
|
||||
/** @param {import('./page/types.js').Cookie['options']} options */
|
||||
function validate_options(options) {
|
||||
if (options?.path === undefined) {
|
||||
throw new Error('You must specify a `path` when setting, deleting or serializing cookies');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique key for a cookie based on its domain, path, and name in
|
||||
* the format: `<domain>/<path>?<name>`.
|
||||
* If domain is undefined, it will be omitted.
|
||||
* For example: `/?name`, `example.com/foo?name`.
|
||||
*
|
||||
* @param {string | undefined} domain
|
||||
* @param {string} path
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function generate_cookie_key(domain, path, name) {
|
||||
return `${domain || ''}${path}?${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {URL} url
|
||||
*/
|
||||
export function get_cookies(request, url) {
|
||||
const header = request.headers.get('cookie') ?? '';
|
||||
const initial_cookies = parse(header, { decode: (value) => value });
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let normalized_url;
|
||||
|
||||
/** @type {Map<string, import('./page/types.js').Cookie>} */
|
||||
const new_cookies = new Map();
|
||||
|
||||
/** @type {import('cookie').CookieSerializeOptions} */
|
||||
const defaults = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: url.hostname === 'localhost' && url.protocol === 'http:' ? false : true
|
||||
};
|
||||
|
||||
/** @type {import('@sveltejs/kit').Cookies} */
|
||||
const cookies = {
|
||||
// The JSDoc param annotations appearing below for get, set and delete
|
||||
// are necessary to expose the `cookie` library types to
|
||||
// typescript users. `@type {import('@sveltejs/kit').Cookies}` above is not
|
||||
// sufficient to do so.
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {import('cookie').CookieParseOptions} [opts]
|
||||
*/
|
||||
get(name, opts) {
|
||||
// Look for the most specific matching cookie from new_cookies
|
||||
const best_match = Array.from(new_cookies.values())
|
||||
.filter((c) => {
|
||||
return (
|
||||
c.name === name &&
|
||||
domain_matches(url.hostname, c.options.domain) &&
|
||||
path_matches(url.pathname, c.options.path)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => b.options.path.length - a.options.path.length)[0];
|
||||
|
||||
if (best_match) {
|
||||
return best_match.options.maxAge === 0 ? undefined : best_match.value;
|
||||
}
|
||||
|
||||
const req_cookies = parse(header, { decode: opts?.decode });
|
||||
const cookie = req_cookies[name]; // the decoded string or undefined
|
||||
|
||||
// in development, if the cookie was set during this session with `cookies.set`,
|
||||
// but at a different path, warn the user. (ignore cookies from request headers,
|
||||
// since we don't know which path they were set at)
|
||||
if (DEV && !cookie) {
|
||||
const paths = Array.from(cookie_paths[name] ?? []).filter((path) => {
|
||||
// we only care about paths that are _more_ specific than the current path
|
||||
return path_matches(path, url.pathname) && path !== url.pathname;
|
||||
});
|
||||
|
||||
if (paths.length > 0) {
|
||||
console.warn(
|
||||
// prettier-ignore
|
||||
`'${name}' cookie does not exist for ${url.pathname}, but was previously set at ${conjoin([...paths])}. Did you mean to set its 'path' to '/' instead?`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return cookie;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import('cookie').CookieParseOptions} [opts]
|
||||
*/
|
||||
getAll(opts) {
|
||||
const cookies = parse(header, { decode: opts?.decode });
|
||||
|
||||
// Group cookies by name and find the most specific one for each name
|
||||
const lookup = new Map();
|
||||
|
||||
for (const c of new_cookies.values()) {
|
||||
if (
|
||||
domain_matches(url.hostname, c.options.domain) &&
|
||||
path_matches(url.pathname, c.options.path)
|
||||
) {
|
||||
const existing = lookup.get(c.name);
|
||||
|
||||
// If no existing cookie or this one has a more specific (longer) path, use this one
|
||||
if (!existing || c.options.path.length > existing.options.path.length) {
|
||||
lookup.set(c.name, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the most specific cookies to the result
|
||||
for (const c of lookup.values()) {
|
||||
cookies[c.name] = c.value;
|
||||
}
|
||||
|
||||
return Object.entries(cookies).map(([name, value]) => ({ name, value }));
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} value
|
||||
* @param {import('./page/types.js').Cookie['options']} options
|
||||
*/
|
||||
set(name, value, options) {
|
||||
// TODO: remove this check in 3.0
|
||||
const illegal_characters = name.match(INVALID_COOKIE_CHARACTER_REGEX);
|
||||
if (illegal_characters) {
|
||||
console.warn(
|
||||
`The cookie name "${name}" will be invalid in SvelteKit 3.0 as it contains ${illegal_characters.join(
|
||||
' and '
|
||||
)}. See RFC 2616 for more details https://datatracker.ietf.org/doc/html/rfc2616#section-2.2`
|
||||
);
|
||||
}
|
||||
|
||||
validate_options(options);
|
||||
set_internal(name, value, { ...defaults, ...options });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {import('./page/types.js').Cookie['options']} options
|
||||
*/
|
||||
delete(name, options) {
|
||||
validate_options(options);
|
||||
cookies.set(name, '', { ...options, maxAge: 0 });
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} value
|
||||
* @param {import('./page/types.js').Cookie['options']} options
|
||||
*/
|
||||
serialize(name, value, options) {
|
||||
validate_options(options);
|
||||
|
||||
let path = options.path;
|
||||
|
||||
if (!options.domain || options.domain === url.hostname) {
|
||||
if (!normalized_url) {
|
||||
throw new Error('Cannot serialize cookies until after the route is determined');
|
||||
}
|
||||
path = resolve(normalized_url, path);
|
||||
}
|
||||
|
||||
return serialize(name, value, { ...defaults, ...options, path });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {URL} destination
|
||||
* @param {string | null} header
|
||||
*/
|
||||
function get_cookie_header(destination, header) {
|
||||
/** @type {Record<string, string>} */
|
||||
const combined_cookies = {
|
||||
// cookies sent by the user agent have lowest precedence
|
||||
...initial_cookies
|
||||
};
|
||||
|
||||
// cookies previous set during this event with cookies.set have higher precedence
|
||||
for (const cookie of new_cookies.values()) {
|
||||
if (!domain_matches(destination.hostname, cookie.options.domain)) continue;
|
||||
if (!path_matches(destination.pathname, cookie.options.path)) continue;
|
||||
|
||||
const encoder = cookie.options.encode || encodeURIComponent;
|
||||
combined_cookies[cookie.name] = encoder(cookie.value);
|
||||
}
|
||||
|
||||
// explicit header has highest precedence
|
||||
if (header) {
|
||||
const parsed = parse(header, { decode: (value) => value });
|
||||
for (const name in parsed) {
|
||||
combined_cookies[name] = parsed[name];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(combined_cookies)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** @type {Array<() => void>} */
|
||||
const internal_queue = [];
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} value
|
||||
* @param {import('./page/types.js').Cookie['options']} options
|
||||
*/
|
||||
function set_internal(name, value, options) {
|
||||
if (!normalized_url) {
|
||||
internal_queue.push(() => set_internal(name, value, options));
|
||||
return;
|
||||
}
|
||||
|
||||
let path = options.path;
|
||||
|
||||
if (!options.domain || options.domain === url.hostname) {
|
||||
path = resolve(normalized_url, path);
|
||||
}
|
||||
|
||||
// Generate unique key for cookie storage
|
||||
const cookie_key = generate_cookie_key(options.domain, path, name);
|
||||
const cookie = { name, value, options: { ...options, path } };
|
||||
new_cookies.set(cookie_key, cookie);
|
||||
|
||||
if (DEV) {
|
||||
const serialized = serialize(name, value, cookie.options);
|
||||
if (text_encoder.encode(serialized).byteLength > MAX_COOKIE_SIZE) {
|
||||
throw new Error(`Cookie "${name}" is too large, and will be discarded by the browser`);
|
||||
}
|
||||
|
||||
cookie_paths[name] ??= new Set();
|
||||
|
||||
if (!value) {
|
||||
cookie_paths[name].delete(path);
|
||||
} else {
|
||||
cookie_paths[name].add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').TrailingSlash} trailing_slash
|
||||
*/
|
||||
function set_trailing_slash(trailing_slash) {
|
||||
normalized_url = normalize_path(url.pathname, trailing_slash);
|
||||
internal_queue.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
return { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @param {string} [constraint]
|
||||
*/
|
||||
export function domain_matches(hostname, constraint) {
|
||||
if (!constraint) return true;
|
||||
|
||||
const normalized = constraint[0] === '.' ? constraint.slice(1) : constraint;
|
||||
|
||||
if (hostname === normalized) return true;
|
||||
return hostname.endsWith('.' + normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} [constraint]
|
||||
*/
|
||||
export function path_matches(path, constraint) {
|
||||
if (!constraint) return true;
|
||||
|
||||
const normalized = constraint.endsWith('/') ? constraint.slice(0, -1) : constraint;
|
||||
|
||||
if (path === normalized) return true;
|
||||
return path.startsWith(normalized + '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Headers} headers
|
||||
* @param {MapIterator<import('./page/types.js').Cookie>} cookies
|
||||
*/
|
||||
export function add_cookies_to_headers(headers, cookies) {
|
||||
for (const new_cookie of cookies) {
|
||||
const { name, value, options } = new_cookie;
|
||||
headers.append('set-cookie', serialize(name, value, options));
|
||||
|
||||
// special case — for routes ending with .html, the route data lives in a sibling
|
||||
// `.html__data.json` file rather than a child `/__data.json` file, which means
|
||||
// we need to duplicate the cookie
|
||||
if (options.path.endsWith('.html')) {
|
||||
const path = add_data_suffix(options.path);
|
||||
headers.append('set-cookie', serialize(name, value, { ...options, path }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} array
|
||||
*/
|
||||
function conjoin(array) {
|
||||
if (array.length <= 2) return array.join(' and ');
|
||||
return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`;
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { text } from '@sveltejs/kit';
|
||||
import { HttpError, SvelteKitError, Redirect } from '@sveltejs/kit/internal';
|
||||
import { normalize_error } from '../../../utils/error.js';
|
||||
import { once } from '../../../utils/functions.js';
|
||||
import { server_data_serializer_json } from '../page/data_serializer.js';
|
||||
import { load_server_data } from '../page/load_data.js';
|
||||
import { handle_error_and_jsonify } from '../utils.js';
|
||||
import { normalize_path } from '../../../utils/url.js';
|
||||
import { text_encoder } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {import('types').SSRRoute} route
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @param {import('types').SSRState} state
|
||||
* @param {boolean[] | undefined} invalidated_data_nodes
|
||||
* @param {import('types').TrailingSlash} trailing_slash
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function render_data(
|
||||
event,
|
||||
event_state,
|
||||
route,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
invalidated_data_nodes,
|
||||
trailing_slash
|
||||
) {
|
||||
if (!route.page) {
|
||||
// requesting /__data.json should fail for a +server.js
|
||||
return new Response(undefined, {
|
||||
status: 404
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const node_ids = [...route.page.layouts, route.page.leaf];
|
||||
const invalidated = invalidated_data_nodes ?? node_ids.map(() => true);
|
||||
|
||||
let aborted = false;
|
||||
|
||||
const url = new URL(event.url);
|
||||
url.pathname = normalize_path(url.pathname, trailing_slash);
|
||||
|
||||
const new_event = { ...event, url };
|
||||
|
||||
const functions = node_ids.map((n, i) => {
|
||||
return once(async () => {
|
||||
try {
|
||||
if (aborted) {
|
||||
return /** @type {import('types').ServerDataSkippedNode} */ ({
|
||||
type: 'skip'
|
||||
});
|
||||
}
|
||||
|
||||
// == because it could be undefined (in dev) or null (in build, because of JSON.stringify)
|
||||
const node = n == undefined ? n : await manifest._.nodes[n]();
|
||||
// load this. for the child, return as is. for the final result, stream things
|
||||
return load_server_data({
|
||||
event: new_event,
|
||||
event_state,
|
||||
state,
|
||||
node,
|
||||
parent: async () => {
|
||||
/** @type {Record<string, any>} */
|
||||
const data = {};
|
||||
for (let j = 0; j < i; j += 1) {
|
||||
const parent = /** @type {import('types').ServerDataNode | null} */ (
|
||||
await functions[j]()
|
||||
);
|
||||
|
||||
if (parent) {
|
||||
Object.assign(data, parent.data);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
aborted = true;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promises = functions.map(async (fn, i) => {
|
||||
if (!invalidated[i]) {
|
||||
return /** @type {import('types').ServerDataSkippedNode} */ ({
|
||||
type: 'skip'
|
||||
});
|
||||
}
|
||||
|
||||
return fn();
|
||||
});
|
||||
|
||||
let length = promises.length;
|
||||
const nodes = await Promise.all(
|
||||
promises.map((p, i) =>
|
||||
p.catch(async (error) => {
|
||||
if (error instanceof Redirect) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Math.min because array isn't guaranteed to resolve in order
|
||||
length = Math.min(length, i + 1);
|
||||
|
||||
return /** @type {import('types').ServerErrorNode} */ ({
|
||||
type: 'error',
|
||||
error: await handle_error_and_jsonify(event, event_state, options, error),
|
||||
status:
|
||||
error instanceof HttpError || error instanceof SvelteKitError
|
||||
? error.status
|
||||
: undefined
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const data_serializer = server_data_serializer_json(event, event_state, options);
|
||||
for (let i = 0; i < nodes.length; i++) data_serializer.add_node(i, nodes[i]);
|
||||
const { data, chunks } = data_serializer.get_data();
|
||||
|
||||
if (!chunks) {
|
||||
// use a normal JSON response where possible, so we get `content-length`
|
||||
// and can use browser JSON devtools for easier inspecting
|
||||
return json_response(data);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(text_encoder.encode(data));
|
||||
for await (const chunk of chunks) {
|
||||
controller.enqueue(text_encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
|
||||
type: 'bytes'
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
// we use a proprietary content type to prevent buffering.
|
||||
// the `text` prefix makes it inspectable
|
||||
'content-type': 'text/sveltekit-data',
|
||||
'cache-control': 'private, no-store'
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const error = normalize_error(e);
|
||||
|
||||
if (error instanceof Redirect) {
|
||||
return redirect_json_response(error);
|
||||
} else {
|
||||
return json_response(await handle_error_and_jsonify(event, event_state, options, error), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any> | string} json
|
||||
* @param {number} [status]
|
||||
*/
|
||||
function json_response(json, status = 200) {
|
||||
return text(typeof json === 'string' ? json : JSON.stringify(json), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'cache-control': 'private, no-store'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Redirect} redirect
|
||||
*/
|
||||
export function redirect_json_response(redirect) {
|
||||
return json_response(
|
||||
/** @type {import('types').ServerRedirectNode} */ ({
|
||||
type: 'redirect',
|
||||
location: redirect.location
|
||||
})
|
||||
);
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { Redirect } from '@sveltejs/kit/internal';
|
||||
import { with_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js';
|
||||
import { negotiate } from '../../utils/http.js';
|
||||
import { method_not_allowed } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {import('types').SSREndpoint} mod
|
||||
* @param {import('types').SSRState} state
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function render_endpoint(event, event_state, mod, state) {
|
||||
const method = /** @type {import('types').HttpMethod} */ (event.request.method);
|
||||
|
||||
let handler = mod[method] || mod.fallback;
|
||||
|
||||
if (method === 'HEAD' && !mod.HEAD && mod.GET) {
|
||||
handler = mod.GET;
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
return method_not_allowed(mod, method);
|
||||
}
|
||||
|
||||
const prerender = mod.prerender ?? state.prerender_default;
|
||||
|
||||
if (prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE)) {
|
||||
throw new Error('Cannot prerender endpoints that have mutative methods');
|
||||
}
|
||||
|
||||
if (state.prerendering && !state.prerendering.inside_reroute && !prerender) {
|
||||
if (state.depth > 0) {
|
||||
// if request came from a prerendered page, bail
|
||||
throw new Error(`${event.route.id} is not prerenderable`);
|
||||
} else {
|
||||
// if request came direct from the crawler, signal that
|
||||
// this route cannot be prerendered, but don't bail
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
event_state.is_endpoint_request = true;
|
||||
|
||||
try {
|
||||
const response = await with_request_store({ event, state: event_state }, () =>
|
||||
handler(/** @type {import('@sveltejs/kit').RequestEvent<Record<string, any>>} */ (event))
|
||||
);
|
||||
|
||||
if (!(response instanceof Response)) {
|
||||
throw new Error(
|
||||
`Invalid response from route ${event.url.pathname}: handler should return a Response object`
|
||||
);
|
||||
}
|
||||
|
||||
if (state.prerendering && (!state.prerendering.inside_reroute || prerender)) {
|
||||
// The returned Response might have immutable Headers
|
||||
// so we should clone them before trying to mutate them.
|
||||
// We also need to clone the response body since it may be read twice during prerendering
|
||||
const cloned = new Response(response.clone().body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Headers(response.headers)
|
||||
});
|
||||
cloned.headers.set('x-sveltekit-prerender', String(prerender));
|
||||
|
||||
if (state.prerendering.inside_reroute && prerender) {
|
||||
// Without this, the route wouldn't be recorded as prerendered,
|
||||
// because there's nothing after reroute that would do that.
|
||||
cloned.headers.set(
|
||||
'x-sveltekit-routeid',
|
||||
encodeURI(/** @type {string} */ (event.route.id))
|
||||
);
|
||||
state.prerendering.dependencies.set(event.url.pathname, { response: cloned, body: null });
|
||||
} else {
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (e instanceof Redirect) {
|
||||
return new Response(undefined, {
|
||||
status: e.status,
|
||||
headers: { location: e.location }
|
||||
});
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
*/
|
||||
export function is_endpoint_request(event) {
|
||||
const { method, headers } = event.request;
|
||||
|
||||
// These methods exist exclusively for endpoints
|
||||
if (ENDPOINT_METHODS.includes(method) && !PAGE_METHODS.includes(method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// use:enhance uses a custom header to disambiguate
|
||||
if (method === 'POST' && headers.get('x-sveltekit-action') === 'true') return false;
|
||||
|
||||
// GET/POST requests may be for endpoints or pages. We prefer endpoints if this isn't a text/html request
|
||||
const accept = event.request.headers.get('accept') ?? '*/*';
|
||||
return negotiate(accept, ['*', 'text/html']) !== 'text/html';
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { public_env } from '../shared-server.js';
|
||||
|
||||
/** @type {string} */
|
||||
let body;
|
||||
|
||||
/** @type {string} */
|
||||
let etag;
|
||||
|
||||
/** @type {Headers} */
|
||||
let headers;
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @returns {Response}
|
||||
*/
|
||||
export function get_public_env(request) {
|
||||
body ??= `export const env=${JSON.stringify(public_env)}`;
|
||||
etag ??= `W/${Date.now()}`;
|
||||
headers ??= new Headers({
|
||||
'content-type': 'application/javascript; charset=utf-8',
|
||||
etag
|
||||
});
|
||||
|
||||
if (request.headers.get('if-none-match') === etag) {
|
||||
return new Response(undefined, { status: 304, headers });
|
||||
}
|
||||
|
||||
return new Response(body, { headers });
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import * as set_cookie_parser from 'set-cookie-parser';
|
||||
import { respond } from './respond.js';
|
||||
import * as paths from '$app/paths/internal/server';
|
||||
import { read_implementation } from '__sveltekit/server';
|
||||
import { has_prerendered_path } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* event: import('@sveltejs/kit').RequestEvent;
|
||||
* options: import('types').SSROptions;
|
||||
* manifest: import('@sveltejs/kit').SSRManifest;
|
||||
* state: import('types').SSRState;
|
||||
* get_cookie_header: (url: URL, header: string | null) => string;
|
||||
* set_internal: (name: string, value: string, opts: import('./page/types.js').Cookie['options']) => void;
|
||||
* }} opts
|
||||
* @returns {typeof fetch}
|
||||
*/
|
||||
export function create_fetch({ event, options, manifest, state, get_cookie_header, set_internal }) {
|
||||
/**
|
||||
* @type {typeof fetch}
|
||||
*/
|
||||
const server_fetch = async (info, init) => {
|
||||
const original_request = normalize_fetch_input(info, init, event.url);
|
||||
|
||||
// some runtimes (e.g. Cloudflare) error if you access `request.mode`,
|
||||
// annoyingly, so we need to read the value from the `init` object instead
|
||||
let mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors';
|
||||
let credentials =
|
||||
(info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin';
|
||||
|
||||
return options.hooks.handleFetch({
|
||||
event,
|
||||
request: original_request,
|
||||
fetch: async (info, init) => {
|
||||
const request = normalize_fetch_input(info, init, event.url);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (!request.headers.has('origin')) {
|
||||
request.headers.set('origin', event.url.origin);
|
||||
}
|
||||
|
||||
if (info !== original_request) {
|
||||
mode = (info instanceof Request ? info.mode : init?.mode) ?? 'cors';
|
||||
credentials =
|
||||
(info instanceof Request ? info.credentials : init?.credentials) ?? 'same-origin';
|
||||
}
|
||||
|
||||
// Remove Origin, according to https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin#description
|
||||
if (
|
||||
(request.method === 'GET' || request.method === 'HEAD') &&
|
||||
((mode === 'no-cors' && url.origin !== event.url.origin) ||
|
||||
url.origin === event.url.origin)
|
||||
) {
|
||||
request.headers.delete('origin');
|
||||
}
|
||||
|
||||
const decoded = decodeURIComponent(url.pathname);
|
||||
|
||||
if (
|
||||
url.origin !== event.url.origin ||
|
||||
(paths.base && decoded !== paths.base && !decoded.startsWith(`${paths.base}/`))
|
||||
) {
|
||||
// Allow cookie passthrough for "credentials: same-origin" and "credentials: include"
|
||||
// if SvelteKit is serving my.domain.com:
|
||||
// - domain.com WILL NOT receive cookies
|
||||
// - my.domain.com WILL receive cookies
|
||||
// - api.domain.dom WILL NOT receive cookies
|
||||
// - sub.my.domain.com WILL receive cookies
|
||||
// ports do not affect the resolution
|
||||
// leading dot prevents mydomain.com matching domain.com
|
||||
// Do not forward other cookies for "credentials: include" because we don't know
|
||||
// which cookie belongs to which domain (browser does not pass this info)
|
||||
if (`.${url.hostname}`.endsWith(`.${event.url.hostname}`) && credentials !== 'omit') {
|
||||
const cookie = get_cookie_header(url, request.headers.get('cookie'));
|
||||
if (cookie) request.headers.set('cookie', cookie);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// handle fetch requests for static assets. e.g. prebaked data, etc.
|
||||
// we need to support everything the browser's fetch supports
|
||||
const prefix = paths.assets || paths.base;
|
||||
const filename = (
|
||||
decoded.startsWith(prefix) ? decoded.slice(prefix.length) : decoded
|
||||
).slice(1);
|
||||
const filename_html = `${filename}/index.html`; // path may also match path/index.html
|
||||
|
||||
const is_asset = manifest.assets.has(filename) || filename in manifest._.server_assets;
|
||||
const is_asset_html =
|
||||
manifest.assets.has(filename_html) || filename_html in manifest._.server_assets;
|
||||
|
||||
if (is_asset || is_asset_html) {
|
||||
const file = is_asset ? filename : filename_html;
|
||||
|
||||
if (state.read) {
|
||||
const type = is_asset
|
||||
? manifest.mimeTypes[filename.slice(filename.lastIndexOf('.'))]
|
||||
: 'text/html';
|
||||
|
||||
return new Response(state.read(file), {
|
||||
headers: type ? { 'content-type': type } : {}
|
||||
});
|
||||
} else if (read_implementation && file in manifest._.server_assets) {
|
||||
const length = manifest._.server_assets[file];
|
||||
const type = manifest.mimeTypes[file.slice(file.lastIndexOf('.'))];
|
||||
|
||||
return new Response(read_implementation(file), {
|
||||
headers: {
|
||||
'Content-Length': '' + length,
|
||||
'Content-Type': type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await fetch(request);
|
||||
}
|
||||
|
||||
if (has_prerendered_path(manifest, paths.base + decoded)) {
|
||||
// The path of something prerendered could match a different route
|
||||
// that is still in the manifest, leading to the wrong route being loaded.
|
||||
// We therefore bail early here. The prerendered logic is different for
|
||||
// each adapter, (except maybe for prerendered redirects)
|
||||
// so we need to make an actual HTTP request.
|
||||
return await fetch(request);
|
||||
}
|
||||
|
||||
if (credentials !== 'omit') {
|
||||
const cookie = get_cookie_header(url, request.headers.get('cookie'));
|
||||
if (cookie) {
|
||||
request.headers.set('cookie', cookie);
|
||||
}
|
||||
|
||||
const authorization = event.request.headers.get('authorization');
|
||||
if (authorization && !request.headers.has('authorization')) {
|
||||
request.headers.set('authorization', authorization);
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.headers.has('accept')) {
|
||||
request.headers.set('accept', '*/*');
|
||||
}
|
||||
|
||||
if (!request.headers.has('accept-language')) {
|
||||
request.headers.set(
|
||||
'accept-language',
|
||||
/** @type {string} */ (event.request.headers.get('accept-language'))
|
||||
);
|
||||
}
|
||||
|
||||
const response = await internal_fetch(request, options, manifest, state);
|
||||
|
||||
const set_cookie = response.headers.get('set-cookie');
|
||||
if (set_cookie) {
|
||||
for (const str of set_cookie_parser.splitCookiesString(set_cookie)) {
|
||||
const { name, value, ...options } = set_cookie_parser.parseString(str, {
|
||||
decodeValues: false
|
||||
});
|
||||
|
||||
const path = options.path ?? (url.pathname.split('/').slice(0, -1).join('/') || '/');
|
||||
|
||||
// options.sameSite is string, something more specific is required - type cast is safe
|
||||
set_internal(name, value, {
|
||||
path,
|
||||
encode: (value) => value,
|
||||
.../** @type {import('cookie').CookieSerializeOptions} */ (options)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Don't make this function `async`! Otherwise, the user has to `catch` promises they use for streaming responses or else
|
||||
// it will be an unhandled rejection. Instead, we add a `.catch(() => {})` ourselves below to prevent this from happening.
|
||||
return (input, init) => {
|
||||
// See docs in fetch.js for why we need to do this
|
||||
const response = server_fetch(input, init);
|
||||
response.catch(() => {});
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestInfo | URL} info
|
||||
* @param {RequestInit | undefined} init
|
||||
* @param {URL} url
|
||||
*/
|
||||
function normalize_fetch_input(info, init, url) {
|
||||
if (info instanceof Request) {
|
||||
return info;
|
||||
}
|
||||
|
||||
return new Request(typeof info === 'string' ? new URL(info, url) : info, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @param {import('types').SSRState} state
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function internal_fetch(request, options, manifest, state) {
|
||||
if (request.signal) {
|
||||
if (request.signal.aborted) {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
let remove_abort_listener = () => {};
|
||||
/** @type {Promise<never>} */
|
||||
const abort_promise = new Promise((_, reject) => {
|
||||
const on_abort = () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
};
|
||||
request.signal.addEventListener('abort', on_abort, { once: true });
|
||||
remove_abort_listener = () => request.signal.removeEventListener('abort', on_abort);
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
respond(request, options, manifest, {
|
||||
...state,
|
||||
depth: state.depth + 1
|
||||
}),
|
||||
abort_promise
|
||||
]);
|
||||
remove_abort_listener();
|
||||
return result;
|
||||
} else {
|
||||
return await respond(request, options, manifest, {
|
||||
...state,
|
||||
depth: state.depth + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/** @import { PromiseWithResolvers } from '../../utils/promise.js' */
|
||||
import { with_resolvers } from '../../utils/promise.js';
|
||||
import { IN_WEBCONTAINER } from './constants.js';
|
||||
import { respond } from './respond.js';
|
||||
import { set_private_env, set_public_env } from '../shared-server.js';
|
||||
import { options, get_hooks } from '__SERVER__/internal.js';
|
||||
import { DEV } from 'esm-env';
|
||||
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';
|
||||
|
||||
/** @type {Promise<any>} */
|
||||
let init_promise;
|
||||
|
||||
/** @type {Promise<void> | null} */
|
||||
let current = null;
|
||||
|
||||
export class Server {
|
||||
/** @type {import('types').SSROptions} */
|
||||
#options;
|
||||
|
||||
/** @type {import('@sveltejs/kit').SSRManifest} */
|
||||
#manifest;
|
||||
|
||||
/** @param {import('@sveltejs/kit').SSRManifest} manifest */
|
||||
constructor(manifest) {
|
||||
/** @type {import('types').SSROptions} */
|
||||
this.#options = options;
|
||||
this.#manifest = manifest;
|
||||
|
||||
// Since AsyncLocalStorage is not working in webcontainers, we don't reset `sync_store`
|
||||
// in `src/exports/internal/event.js` and handle only one request at a time.
|
||||
if (IN_WEBCONTAINER) {
|
||||
const respond = this.respond.bind(this);
|
||||
|
||||
/** @type {typeof respond} */
|
||||
this.respond = async (...args) => {
|
||||
const { promise, resolve } = /** @type {PromiseWithResolvers<void>} */ (with_resolvers());
|
||||
|
||||
const previous = current;
|
||||
current = promise;
|
||||
|
||||
await previous;
|
||||
return respond(...args).finally(resolve);
|
||||
};
|
||||
}
|
||||
|
||||
set_manifest(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').ServerInitOptions} opts
|
||||
*/
|
||||
async init({ env, read }) {
|
||||
// Take care: Some adapters may have to call `Server.init` per-request to set env vars,
|
||||
// so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't
|
||||
// been done already.
|
||||
|
||||
// set env, in case it's used in initialisation
|
||||
const { env_public_prefix, env_private_prefix } = this.#options;
|
||||
|
||||
set_private_env(filter_env(env, env_private_prefix, env_public_prefix));
|
||||
set_public_env(filter_env(env, env_public_prefix, env_private_prefix));
|
||||
|
||||
if (read) {
|
||||
// Wrap the read function to handle MaybePromise<ReadableStream>
|
||||
// and ensure the public API stays synchronous
|
||||
/** @param {string} file */
|
||||
const wrapped_read = (file) => {
|
||||
const result = read(file);
|
||||
if (result instanceof ReadableStream) {
|
||||
return result;
|
||||
} else {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const stream = await Promise.resolve(result);
|
||||
if (!stream) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
set_read_implementation(wrapped_read);
|
||||
}
|
||||
|
||||
// During DEV and for some adapters this function might be called in quick succession,
|
||||
// so we need to make sure we're not invoking this logic (most notably the init hook) multiple times
|
||||
await (init_promise ??= (async () => {
|
||||
try {
|
||||
const module = await get_hooks();
|
||||
|
||||
this.#options.hooks = {
|
||||
handle: module.handle || (({ event, resolve }) => resolve(event)),
|
||||
handleError:
|
||||
module.handleError ||
|
||||
(({ status, error, event }) => {
|
||||
const error_message = format_server_error(
|
||||
status,
|
||||
/** @type {Error} */ (error),
|
||||
event
|
||||
);
|
||||
console.error(error_message);
|
||||
}),
|
||||
handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)),
|
||||
handleValidationError:
|
||||
module.handleValidationError ||
|
||||
(({ issues }) => {
|
||||
console.error('Remote function schema validation failed:', issues);
|
||||
return { message: 'Bad Request' };
|
||||
}),
|
||||
reroute: module.reroute || (() => {}),
|
||||
transport: module.transport || {}
|
||||
};
|
||||
|
||||
set_app({
|
||||
decoders: module.transport
|
||||
? Object.fromEntries(Object.entries(module.transport).map(([k, v]) => [k, v.decode]))
|
||||
: {}
|
||||
});
|
||||
|
||||
if (module.init) {
|
||||
await module.init();
|
||||
}
|
||||
} catch (e) {
|
||||
if (DEV) {
|
||||
this.#options.hooks = {
|
||||
handle: () => {
|
||||
throw e;
|
||||
},
|
||||
handleError: ({ error }) => console.error(error),
|
||||
handleFetch: ({ request, fetch }) => fetch(request),
|
||||
handleValidationError: () => {
|
||||
return { message: 'Bad Request' };
|
||||
},
|
||||
reroute: () => {},
|
||||
transport: {}
|
||||
};
|
||||
|
||||
set_app({
|
||||
decoders: {}
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {import('types').RequestOptions} options
|
||||
*/
|
||||
async respond(request, options) {
|
||||
return respond(request, this.#options, this.#manifest, {
|
||||
...options,
|
||||
error: false,
|
||||
depth: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
/** @import { RequestEvent, ActionResult, Actions } from '@sveltejs/kit' */
|
||||
/** @import { SSROptions, SSRNode, ServerNode, ServerHooks } from 'types' */
|
||||
import * as devalue from 'devalue';
|
||||
import { DEV } from 'esm-env';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/kit/internal';
|
||||
import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server';
|
||||
import { get_status, normalize_error } from '../../../utils/error.js';
|
||||
import { is_form_content_type, negotiate } from '../../../utils/http.js';
|
||||
import { handle_error_and_jsonify } from '../utils.js';
|
||||
import { record_span } from '../../telemetry/record_span.js';
|
||||
|
||||
/** @param {RequestEvent} event */
|
||||
export function is_action_json_request(event) {
|
||||
const accept = negotiate(event.request.headers.get('accept') ?? '*/*', [
|
||||
'application/json',
|
||||
'text/html'
|
||||
]);
|
||||
|
||||
return accept === 'application/json' && event.request.method === 'POST';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {SSROptions} options
|
||||
* @param {SSRNode['server'] | undefined} server
|
||||
*/
|
||||
export async function handle_action_json_request(event, event_state, options, server) {
|
||||
const actions = server?.actions;
|
||||
|
||||
if (!actions) {
|
||||
const no_actions_error = new SvelteKitError(
|
||||
405,
|
||||
'Method Not Allowed',
|
||||
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
|
||||
);
|
||||
|
||||
return action_json(
|
||||
{
|
||||
type: 'error',
|
||||
error: await handle_error_and_jsonify(event, event_state, options, no_actions_error)
|
||||
},
|
||||
{
|
||||
status: no_actions_error.status,
|
||||
headers: {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
|
||||
// "The server must generate an Allow header field in a 405 status code response"
|
||||
allow: 'GET'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
check_named_default_separate(actions);
|
||||
|
||||
try {
|
||||
const data = await call_action(event, event_state, actions);
|
||||
|
||||
if (DEV) {
|
||||
validate_action_return(data);
|
||||
}
|
||||
|
||||
if (data instanceof ActionFailure) {
|
||||
return action_json({
|
||||
type: 'failure',
|
||||
status: data.status,
|
||||
// @ts-expect-error we assign a string to what is supposed to be an object. That's ok
|
||||
// because we don't use the object outside, and this way we have better code navigation
|
||||
// through knowing where the related interface is used.
|
||||
data: stringify_action_response(
|
||||
data.data,
|
||||
/** @type {string} */ (event.route.id),
|
||||
options.hooks.transport
|
||||
)
|
||||
});
|
||||
} else {
|
||||
return action_json({
|
||||
type: 'success',
|
||||
status: data ? 200 : 204,
|
||||
// @ts-expect-error see comment above
|
||||
data: stringify_action_response(
|
||||
data,
|
||||
/** @type {string} */ (event.route.id),
|
||||
options.hooks.transport
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const err = normalize_error(e);
|
||||
|
||||
if (err instanceof Redirect) {
|
||||
return action_json_redirect(err);
|
||||
}
|
||||
|
||||
return action_json(
|
||||
{
|
||||
type: 'error',
|
||||
error: await handle_error_and_jsonify(
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
check_incorrect_fail_use(err)
|
||||
)
|
||||
},
|
||||
{
|
||||
status: get_status(err)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HttpError | Error} error
|
||||
*/
|
||||
export function check_incorrect_fail_use(error) {
|
||||
return error instanceof ActionFailure
|
||||
? new Error('Cannot "throw fail()". Use "return fail()"')
|
||||
: error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Redirect} redirect
|
||||
*/
|
||||
export function action_json_redirect(redirect) {
|
||||
return action_json({
|
||||
type: 'redirect',
|
||||
status: redirect.status,
|
||||
location: redirect.location
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActionResult} data
|
||||
* @param {ResponseInit} [init]
|
||||
*/
|
||||
function action_json(data, init) {
|
||||
return json(data, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
*/
|
||||
export function is_action_request(event) {
|
||||
return event.request.method === 'POST';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {SSRNode['server'] | undefined} server
|
||||
* @returns {Promise<ActionResult>}
|
||||
*/
|
||||
export async function handle_action_request(event, event_state, server) {
|
||||
const actions = server?.actions;
|
||||
|
||||
if (!actions) {
|
||||
// TODO should this be a different error altogether?
|
||||
event.setHeaders({
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
|
||||
// "The server must generate an Allow header field in a 405 status code response"
|
||||
allow: 'GET'
|
||||
});
|
||||
return {
|
||||
type: 'error',
|
||||
error: new SvelteKitError(
|
||||
405,
|
||||
'Method Not Allowed',
|
||||
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
check_named_default_separate(actions);
|
||||
|
||||
try {
|
||||
const data = await call_action(event, event_state, actions);
|
||||
|
||||
if (DEV) {
|
||||
validate_action_return(data);
|
||||
}
|
||||
|
||||
if (data instanceof ActionFailure) {
|
||||
return {
|
||||
type: 'failure',
|
||||
status: data.status,
|
||||
data: data.data
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'success',
|
||||
status: 200,
|
||||
// @ts-expect-error this will be removed upon serialization, so `undefined` is the same as omission
|
||||
data
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
const err = normalize_error(e);
|
||||
|
||||
if (err instanceof Redirect) {
|
||||
return {
|
||||
type: 'redirect',
|
||||
status: err.status,
|
||||
location: err.location
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
error: check_incorrect_fail_use(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actions} actions
|
||||
*/
|
||||
function check_named_default_separate(actions) {
|
||||
if (actions.default && Object.keys(actions).length > 1) {
|
||||
throw new Error(
|
||||
'When using named actions, the default action cannot be used. See the docs for more info: https://svelte.dev/docs/kit/form-actions#named-actions'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {NonNullable<ServerNode['actions']>} actions
|
||||
* @throws {Redirect | HttpError | SvelteKitError | Error}
|
||||
*/
|
||||
async function call_action(event, event_state, actions) {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
let name = 'default';
|
||||
for (const param of url.searchParams) {
|
||||
if (param[0].startsWith('/')) {
|
||||
name = param[0].slice(1);
|
||||
if (name === 'default') {
|
||||
throw new Error('Cannot use reserved action name "default"');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const action = actions[name];
|
||||
if (!action) {
|
||||
throw new SvelteKitError(404, 'Not Found', `No action with name '${name}' found`);
|
||||
}
|
||||
|
||||
if (!is_form_content_type(event.request)) {
|
||||
throw new SvelteKitError(
|
||||
415,
|
||||
'Unsupported Media Type',
|
||||
`Form actions expect form-encoded data — received ${event.request.headers.get(
|
||||
'content-type'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
return record_span({
|
||||
name: 'sveltekit.form_action',
|
||||
attributes: {
|
||||
'sveltekit.form_action.name': name,
|
||||
'http.route': event.route.id || 'unknown'
|
||||
},
|
||||
fn: async (current) => {
|
||||
const traced_event = merge_tracing(event, current);
|
||||
const result = await with_request_store({ event: traced_event, state: event_state }, () =>
|
||||
action(traced_event)
|
||||
);
|
||||
if (result instanceof ActionFailure) {
|
||||
current.setAttributes({
|
||||
'sveltekit.form_action.result.type': 'failure',
|
||||
'sveltekit.form_action.result.status': result.status
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {any} data */
|
||||
function validate_action_return(data) {
|
||||
if (data instanceof Redirect) {
|
||||
throw new Error('Cannot `return redirect(...)` — use `redirect(...)` instead');
|
||||
}
|
||||
|
||||
if (data instanceof HttpError) {
|
||||
throw new Error('Cannot `return error(...)` — use `error(...)` or `return fail(...)` instead');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context
|
||||
* @param {any} data
|
||||
* @param {string} route_id
|
||||
* @param {ServerHooks['transport']} transport
|
||||
*/
|
||||
export function uneval_action_response(data, route_id, transport) {
|
||||
const replacer = (/** @type {any} */ thing) => {
|
||||
for (const key in transport) {
|
||||
const encoded = transport[key].encode(thing);
|
||||
if (encoded) {
|
||||
return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return try_serialize(data, (value) => devalue.uneval(value, replacer), route_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context
|
||||
* @param {any} data
|
||||
* @param {string} route_id
|
||||
* @param {ServerHooks['transport']} transport
|
||||
*/
|
||||
function stringify_action_response(data, route_id, transport) {
|
||||
const encoders = Object.fromEntries(
|
||||
Object.entries(transport).map(([key, value]) => [key, value.encode])
|
||||
);
|
||||
|
||||
return try_serialize(data, (value) => devalue.stringify(value, encoders), route_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @param {(data: any) => string} fn
|
||||
* @param {string} route_id
|
||||
*/
|
||||
function try_serialize(data, fn, route_id) {
|
||||
try {
|
||||
return fn(data);
|
||||
} catch (e) {
|
||||
// If we're here, the data could not be serialized with devalue
|
||||
const error = /** @type {any} */ (e);
|
||||
|
||||
// if someone tries to use `json()` in their action
|
||||
if (data instanceof Response) {
|
||||
throw new Error(
|
||||
`Data returned from action inside ${route_id} is not serializable. Form actions need to return plain objects or fail(). E.g. return { success: true } or return fail(400, { message: "invalid" });`
|
||||
);
|
||||
}
|
||||
|
||||
// if devalue could not serialize a property on the object, etc.
|
||||
if ('path' in error) {
|
||||
let message = `Data returned from action inside ${route_id} is not serializable: ${error.message}`;
|
||||
if (error.path !== '') message += ` (data.${error.path})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { text_encoder } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl
|
||||
* modified and redistributed under BSD license
|
||||
* @param {string} data
|
||||
*/
|
||||
export function sha256(data) {
|
||||
if (!key[0]) precompute();
|
||||
|
||||
const out = init.slice(0);
|
||||
const array = encode(data);
|
||||
|
||||
for (let i = 0; i < array.length; i += 16) {
|
||||
const w = array.subarray(i, i + 16);
|
||||
|
||||
let tmp;
|
||||
let a;
|
||||
let b;
|
||||
|
||||
let out0 = out[0];
|
||||
let out1 = out[1];
|
||||
let out2 = out[2];
|
||||
let out3 = out[3];
|
||||
let out4 = out[4];
|
||||
let out5 = out[5];
|
||||
let out6 = out[6];
|
||||
let out7 = out[7];
|
||||
|
||||
/* Rationale for placement of |0 :
|
||||
* If a value can overflow is original 32 bits by a factor of more than a few
|
||||
* million (2^23 ish), there is a possibility that it might overflow the
|
||||
* 53-bit mantissa and lose precision.
|
||||
*
|
||||
* To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that
|
||||
* propagates around the loop, and on the hash state out[]. I don't believe
|
||||
* that the clamps on out4 and on out0 are strictly necessary, but it's close
|
||||
* (for out4 anyway), and better safe than sorry.
|
||||
*
|
||||
* The clamps on out[] are necessary for the output to be correct even in the
|
||||
* common case and for short inputs.
|
||||
*/
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
// load up the input word for this round
|
||||
|
||||
if (i < 16) {
|
||||
tmp = w[i];
|
||||
} else {
|
||||
a = w[(i + 1) & 15];
|
||||
|
||||
b = w[(i + 14) & 15];
|
||||
|
||||
tmp = w[i & 15] =
|
||||
(((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) +
|
||||
((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) +
|
||||
w[i & 15] +
|
||||
w[(i + 9) & 15]) |
|
||||
0;
|
||||
}
|
||||
|
||||
tmp =
|
||||
tmp +
|
||||
out7 +
|
||||
((out4 >>> 6) ^ (out4 >>> 11) ^ (out4 >>> 25) ^ (out4 << 26) ^ (out4 << 21) ^ (out4 << 7)) +
|
||||
(out6 ^ (out4 & (out5 ^ out6))) +
|
||||
key[i]; // | 0;
|
||||
|
||||
// shift register
|
||||
out7 = out6;
|
||||
out6 = out5;
|
||||
out5 = out4;
|
||||
|
||||
out4 = (out3 + tmp) | 0;
|
||||
|
||||
out3 = out2;
|
||||
out2 = out1;
|
||||
out1 = out0;
|
||||
|
||||
out0 =
|
||||
(tmp +
|
||||
((out1 & out2) ^ (out3 & (out1 ^ out2))) +
|
||||
((out1 >>> 2) ^
|
||||
(out1 >>> 13) ^
|
||||
(out1 >>> 22) ^
|
||||
(out1 << 30) ^
|
||||
(out1 << 19) ^
|
||||
(out1 << 10))) |
|
||||
0;
|
||||
}
|
||||
|
||||
out[0] = (out[0] + out0) | 0;
|
||||
out[1] = (out[1] + out1) | 0;
|
||||
out[2] = (out[2] + out2) | 0;
|
||||
out[3] = (out[3] + out3) | 0;
|
||||
out[4] = (out[4] + out4) | 0;
|
||||
out[5] = (out[5] + out5) | 0;
|
||||
out[6] = (out[6] + out6) | 0;
|
||||
out[7] = (out[7] + out7) | 0;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(out.buffer);
|
||||
reverse_endianness(bytes);
|
||||
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/** The SHA-256 initialization vector */
|
||||
const init = new Uint32Array(8);
|
||||
|
||||
/** The SHA-256 hash key */
|
||||
const key = new Uint32Array(64);
|
||||
|
||||
/** Function to precompute init and key. */
|
||||
function precompute() {
|
||||
/** @param {number} x */
|
||||
function frac(x) {
|
||||
return (x - Math.floor(x)) * 0x100000000;
|
||||
}
|
||||
|
||||
let prime = 2;
|
||||
|
||||
for (let i = 0; i < 64; prime++) {
|
||||
let is_prime = true;
|
||||
|
||||
for (let factor = 2; factor * factor <= prime; factor++) {
|
||||
if (prime % factor === 0) {
|
||||
is_prime = false;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_prime) {
|
||||
if (i < 8) {
|
||||
init[i] = frac(prime ** (1 / 2));
|
||||
}
|
||||
|
||||
key[i] = frac(prime ** (1 / 3));
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Uint8Array} bytes */
|
||||
function reverse_endianness(bytes) {
|
||||
for (let i = 0; i < bytes.length; i += 4) {
|
||||
const a = bytes[i + 0];
|
||||
const b = bytes[i + 1];
|
||||
const c = bytes[i + 2];
|
||||
const d = bytes[i + 3];
|
||||
|
||||
bytes[i + 0] = d;
|
||||
bytes[i + 1] = c;
|
||||
bytes[i + 2] = b;
|
||||
bytes[i + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} str */
|
||||
function encode(str) {
|
||||
const encoded = text_encoder.encode(str);
|
||||
const length = encoded.length * 8;
|
||||
|
||||
// result should be a multiple of 512 bits in length,
|
||||
// with room for a 1 (after the data) and two 32-bit
|
||||
// words containing the original input bit length
|
||||
const size = 512 * Math.ceil((length + 65) / 512);
|
||||
const bytes = new Uint8Array(size / 8);
|
||||
bytes.set(encoded);
|
||||
|
||||
// append a 1
|
||||
bytes[encoded.length] = 0b10000000;
|
||||
|
||||
reverse_endianness(bytes);
|
||||
|
||||
// add the input bit length
|
||||
const words = new Uint32Array(bytes.buffer);
|
||||
words[words.length - 2] = Math.floor(length / 0x100000000); // this will always be zero for us
|
||||
words[words.length - 1] = length;
|
||||
|
||||
return words;
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
import { DEV } from 'esm-env';
|
||||
import { escape_html } from '../../../utils/escape.js';
|
||||
import { sha256 } from './crypto.js';
|
||||
|
||||
const array = new Uint8Array(16);
|
||||
|
||||
function generate_nonce() {
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array));
|
||||
}
|
||||
|
||||
const quoted = new Set([
|
||||
'self',
|
||||
'unsafe-eval',
|
||||
'unsafe-hashes',
|
||||
'unsafe-inline',
|
||||
'none',
|
||||
'strict-dynamic',
|
||||
'report-sample',
|
||||
'wasm-unsafe-eval',
|
||||
'script'
|
||||
]);
|
||||
|
||||
const crypto_pattern = /^(nonce|sha\d\d\d)-/;
|
||||
|
||||
// CSP and CSP Report Only are extremely similar with a few caveats
|
||||
// the easiest/DRYest way to express this is with some private encapsulation
|
||||
class BaseProvider {
|
||||
/** @type {boolean} */
|
||||
#use_hashes;
|
||||
|
||||
/** @type {boolean} */
|
||||
#script_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#script_src_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#script_src_elem_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#style_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#style_src_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#style_src_attr_needs_csp;
|
||||
|
||||
/** @type {boolean} */
|
||||
#style_src_elem_needs_csp;
|
||||
|
||||
/** @type {import('types').CspDirectives} */
|
||||
#directives;
|
||||
|
||||
/** @type {Set<import('types').Csp.Source>} */
|
||||
#script_src;
|
||||
|
||||
/** @type {Set<import('types').Csp.Source>} */
|
||||
#script_src_elem;
|
||||
|
||||
/** @type {Set<import('types').Csp.Source>} */
|
||||
#style_src;
|
||||
|
||||
/** @type {Set<import('types').Csp.Source>} */
|
||||
#style_src_attr;
|
||||
|
||||
/** @type {Set<import('types').Csp.Source>} */
|
||||
#style_src_elem;
|
||||
|
||||
/** @type {boolean} */
|
||||
script_needs_nonce;
|
||||
|
||||
/** @type {boolean} */
|
||||
style_needs_nonce;
|
||||
|
||||
/** @type {boolean} */
|
||||
script_needs_hash;
|
||||
|
||||
/** @type {string} */
|
||||
#nonce;
|
||||
|
||||
/**
|
||||
* @param {boolean} use_hashes
|
||||
* @param {import('types').CspDirectives} directives
|
||||
* @param {string} nonce
|
||||
*/
|
||||
constructor(use_hashes, directives, nonce) {
|
||||
this.#use_hashes = use_hashes;
|
||||
this.#directives = DEV ? { ...directives } : directives; // clone in dev so we can safely mutate
|
||||
|
||||
const d = this.#directives;
|
||||
|
||||
this.#script_src = new Set();
|
||||
this.#script_src_elem = new Set();
|
||||
this.#style_src = new Set();
|
||||
this.#style_src_attr = new Set();
|
||||
this.#style_src_elem = new Set();
|
||||
|
||||
const effective_script_src = d['script-src'] || d['default-src'];
|
||||
const script_src_elem = d['script-src-elem'];
|
||||
const effective_style_src = d['style-src'] || d['default-src'];
|
||||
const style_src_attr = d['style-src-attr'];
|
||||
const style_src_elem = d['style-src-elem'];
|
||||
|
||||
if (DEV) {
|
||||
// remove strict-dynamic in dev...
|
||||
// TODO reinstate this if we can figure out how to make strict-dynamic work
|
||||
// if (d['default-src']) {
|
||||
// d['default-src'] = d['default-src'].filter((name) => name !== 'strict-dynamic');
|
||||
// if (d['default-src'].length === 0) delete d['default-src'];
|
||||
// }
|
||||
|
||||
// if (d['script-src']) {
|
||||
// d['script-src'] = d['script-src'].filter((name) => name !== 'strict-dynamic');
|
||||
// if (d['script-src'].length === 0) delete d['script-src'];
|
||||
// }
|
||||
|
||||
// ...and add unsafe-inline so we can inject <style> elements
|
||||
// Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list, so we remove those during dev when injecting unsafe-inline
|
||||
if (effective_style_src && !effective_style_src.includes('unsafe-inline')) {
|
||||
d['style-src'] = [
|
||||
...effective_style_src.filter(
|
||||
(value) => !(value.startsWith('sha256-') || value.startsWith('nonce-'))
|
||||
),
|
||||
'unsafe-inline'
|
||||
];
|
||||
}
|
||||
|
||||
if (style_src_attr && !style_src_attr.includes('unsafe-inline')) {
|
||||
d['style-src-attr'] = [
|
||||
...style_src_attr.filter(
|
||||
(value) => !(value.startsWith('sha256-') || value.startsWith('nonce-'))
|
||||
),
|
||||
'unsafe-inline'
|
||||
];
|
||||
}
|
||||
|
||||
if (style_src_elem && !style_src_elem.includes('unsafe-inline')) {
|
||||
d['style-src-elem'] = [
|
||||
...style_src_elem.filter(
|
||||
(value) => !(value.startsWith('sha256-') || value.startsWith('nonce-'))
|
||||
),
|
||||
'unsafe-inline'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {(import('types').Csp.Source | import('types').Csp.ActionSource)[] | undefined} directive */
|
||||
const style_needs_csp = (directive) =>
|
||||
!!directive && !directive.some((value) => value === 'unsafe-inline');
|
||||
|
||||
/** @param {(import('types').Csp.Source | import('types').Csp.ActionSource)[] | undefined} directive */
|
||||
const script_needs_csp = (directive) =>
|
||||
!!directive &&
|
||||
(!directive.some((value) => value === 'unsafe-inline') ||
|
||||
directive.some((value) => value === 'strict-dynamic'));
|
||||
|
||||
this.#script_src_needs_csp = script_needs_csp(effective_script_src);
|
||||
this.#script_src_elem_needs_csp = script_needs_csp(script_src_elem);
|
||||
this.#style_src_needs_csp = style_needs_csp(effective_style_src);
|
||||
this.#style_src_attr_needs_csp = style_needs_csp(style_src_attr);
|
||||
this.#style_src_elem_needs_csp = style_needs_csp(style_src_elem);
|
||||
|
||||
this.#script_needs_csp = this.#script_src_needs_csp || this.#script_src_elem_needs_csp;
|
||||
this.#style_needs_csp =
|
||||
!DEV &&
|
||||
(this.#style_src_needs_csp ||
|
||||
this.#style_src_attr_needs_csp ||
|
||||
this.#style_src_elem_needs_csp);
|
||||
|
||||
this.script_needs_nonce = this.#script_needs_csp && !this.#use_hashes;
|
||||
this.style_needs_nonce = this.#style_needs_csp && !this.#use_hashes;
|
||||
this.script_needs_hash = this.#script_needs_csp && this.#use_hashes;
|
||||
|
||||
this.#nonce = nonce;
|
||||
}
|
||||
|
||||
/** @param {string} content */
|
||||
add_script(content) {
|
||||
if (!this.#script_needs_csp) return;
|
||||
|
||||
/** @type {`nonce-${string}` | `sha256-${string}`} */
|
||||
const source = this.#use_hashes ? `sha256-${sha256(content)}` : `nonce-${this.#nonce}`;
|
||||
|
||||
if (this.#script_src_needs_csp) {
|
||||
this.#script_src.add(source);
|
||||
}
|
||||
|
||||
if (this.#script_src_elem_needs_csp) {
|
||||
this.#script_src_elem.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {`sha256-${string}`[]} hashes */
|
||||
add_script_hashes(hashes) {
|
||||
for (const hash of hashes) {
|
||||
if (this.#script_src_needs_csp) {
|
||||
this.#script_src.add(hash);
|
||||
}
|
||||
if (this.#script_src_elem_needs_csp) {
|
||||
this.#script_src_elem.add(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} content */
|
||||
add_style(content) {
|
||||
if (!this.#style_needs_csp) return;
|
||||
|
||||
/** @type {`nonce-${string}` | `sha256-${string}`} */
|
||||
const source = this.#use_hashes ? `sha256-${sha256(content)}` : `nonce-${this.#nonce}`;
|
||||
|
||||
if (this.#style_src_needs_csp) {
|
||||
this.#style_src.add(source);
|
||||
}
|
||||
|
||||
if (this.#style_src_attr_needs_csp) {
|
||||
this.#style_src_attr.add(source);
|
||||
}
|
||||
|
||||
if (this.#style_src_elem_needs_csp) {
|
||||
// this is the sha256 hash for the string "/* empty */"
|
||||
// adding it so that svelte does not break csp
|
||||
// see https://github.com/sveltejs/svelte/pull/7800
|
||||
const sha256_empty_comment_hash = 'sha256-9OlNO0DNEeaVzHL4RZwCLsBHA8WBQ8toBp/4F5XV2nc=';
|
||||
const d = this.#directives;
|
||||
|
||||
if (
|
||||
d['style-src-elem'] &&
|
||||
!d['style-src-elem'].includes(sha256_empty_comment_hash) &&
|
||||
!this.#style_src_elem.has(sha256_empty_comment_hash)
|
||||
) {
|
||||
this.#style_src_elem.add(sha256_empty_comment_hash);
|
||||
}
|
||||
|
||||
if (source !== sha256_empty_comment_hash) {
|
||||
this.#style_src_elem.add(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [is_meta]
|
||||
*/
|
||||
get_header(is_meta = false) {
|
||||
const header = [];
|
||||
|
||||
// due to browser inconsistencies, we can't append sources to default-src
|
||||
// (specifically, Firefox appears to not ignore nonce-{nonce} directives
|
||||
// on default-src), so we ensure that script-src and style-src exist
|
||||
|
||||
const directives = { ...this.#directives };
|
||||
|
||||
if (this.#style_src.size > 0) {
|
||||
directives['style-src'] = [
|
||||
...(directives['style-src'] || directives['default-src'] || []),
|
||||
...this.#style_src
|
||||
];
|
||||
}
|
||||
|
||||
if (this.#style_src_attr.size > 0) {
|
||||
directives['style-src-attr'] = [
|
||||
...(directives['style-src-attr'] || []),
|
||||
...this.#style_src_attr
|
||||
];
|
||||
}
|
||||
|
||||
if (this.#style_src_elem.size > 0) {
|
||||
directives['style-src-elem'] = [
|
||||
...(directives['style-src-elem'] || []),
|
||||
...this.#style_src_elem
|
||||
];
|
||||
}
|
||||
|
||||
if (this.#script_src.size > 0) {
|
||||
directives['script-src'] = [
|
||||
...(directives['script-src'] || directives['default-src'] || []),
|
||||
...this.#script_src
|
||||
];
|
||||
}
|
||||
|
||||
if (this.#script_src_elem.size > 0) {
|
||||
directives['script-src-elem'] = [
|
||||
...(directives['script-src-elem'] || []),
|
||||
...this.#script_src_elem
|
||||
];
|
||||
}
|
||||
|
||||
for (const key in directives) {
|
||||
if (is_meta && (key === 'frame-ancestors' || key === 'report-uri' || key === 'sandbox')) {
|
||||
// these values cannot be used with a <meta> tag
|
||||
// TODO warn?
|
||||
continue;
|
||||
}
|
||||
|
||||
// @ts-expect-error gimme a break typescript, `key` is obviously a member of internal_directives
|
||||
const value = /** @type {string[] | true} */ (directives[key]);
|
||||
|
||||
if (!value) continue;
|
||||
|
||||
const directive = [key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((value) => {
|
||||
if (quoted.has(value) || crypto_pattern.test(value)) {
|
||||
directive.push(`'${value}'`);
|
||||
} else {
|
||||
directive.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
header.push(directive.join(' '));
|
||||
}
|
||||
|
||||
return header.join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
class CspProvider extends BaseProvider {
|
||||
get_meta() {
|
||||
const content = this.get_header(true);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
return `<meta http-equiv="content-security-policy" content="${escape_html(content, true)}">`;
|
||||
}
|
||||
}
|
||||
|
||||
class CspReportOnlyProvider extends BaseProvider {
|
||||
/**
|
||||
* @param {boolean} use_hashes
|
||||
* @param {import('types').CspDirectives} directives
|
||||
* @param {string} nonce
|
||||
*/
|
||||
constructor(use_hashes, directives, nonce) {
|
||||
super(use_hashes, directives, nonce);
|
||||
|
||||
if (Object.values(directives).filter((v) => !!v).length > 0) {
|
||||
// If we're generating content-security-policy-report-only,
|
||||
// if there are any directives, we need a report-uri or report-to (or both)
|
||||
// else it's just an expensive noop.
|
||||
const has_report_to = directives['report-to']?.length ?? 0 > 0;
|
||||
const has_report_uri = directives['report-uri']?.length ?? 0 > 0;
|
||||
if (!has_report_to && !has_report_uri) {
|
||||
throw Error(
|
||||
'`content-security-policy-report-only` must be specified with either the `report-to` or `report-uri` directives, or both'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Csp {
|
||||
/** @readonly */
|
||||
nonce = generate_nonce();
|
||||
|
||||
/** @type {CspProvider} */
|
||||
csp_provider;
|
||||
|
||||
/** @type {CspReportOnlyProvider} */
|
||||
report_only_provider;
|
||||
|
||||
/**
|
||||
* @param {import('./types.js').CspConfig} config
|
||||
* @param {import('./types.js').CspOpts} opts
|
||||
*/
|
||||
constructor({ mode, directives, reportOnly }, { prerender }) {
|
||||
const use_hashes = mode === 'hash' || (mode === 'auto' && prerender);
|
||||
this.csp_provider = new CspProvider(use_hashes, directives, this.nonce);
|
||||
this.report_only_provider = new CspReportOnlyProvider(use_hashes, reportOnly, this.nonce);
|
||||
}
|
||||
|
||||
get script_needs_hash() {
|
||||
return this.csp_provider.script_needs_hash || this.report_only_provider.script_needs_hash;
|
||||
}
|
||||
|
||||
get script_needs_nonce() {
|
||||
return this.csp_provider.script_needs_nonce || this.report_only_provider.script_needs_nonce;
|
||||
}
|
||||
|
||||
get style_needs_nonce() {
|
||||
return this.csp_provider.style_needs_nonce || this.report_only_provider.style_needs_nonce;
|
||||
}
|
||||
|
||||
/** @param {string} content */
|
||||
add_script(content) {
|
||||
this.csp_provider.add_script(content);
|
||||
this.report_only_provider.add_script(content);
|
||||
}
|
||||
|
||||
/** @param {`sha256-${string}`[]} hashes */
|
||||
add_script_hashes(hashes) {
|
||||
this.csp_provider.add_script_hashes(hashes);
|
||||
this.report_only_provider.add_script_hashes(hashes);
|
||||
}
|
||||
|
||||
/** @param {string} content */
|
||||
add_style(content) {
|
||||
this.csp_provider.add_style(content);
|
||||
this.report_only_provider.add_style(content);
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import * as devalue from 'devalue';
|
||||
import { compact } from '../../../utils/array.js';
|
||||
import { create_async_iterator } from '../../../utils/streaming.js';
|
||||
import {
|
||||
clarify_devalue_error,
|
||||
get_global_name,
|
||||
handle_error_and_jsonify,
|
||||
serialize_uses
|
||||
} from '../utils.js';
|
||||
|
||||
/**
|
||||
* If the serialized data contains promises, `chunks` will be an
|
||||
* async iterable containing their resolutions
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {import('types').SSROptions} options
|
||||
* @returns {import('./types.js').ServerDataSerializer}
|
||||
*/
|
||||
export function server_data_serializer(event, event_state, options) {
|
||||
let promise_id = 1;
|
||||
let max_nodes = -1;
|
||||
|
||||
const iterator = create_async_iterator();
|
||||
const global = get_global_name(options);
|
||||
|
||||
/** @param {number} index */
|
||||
function get_replacer(index) {
|
||||
/** @param {any} thing */
|
||||
return function replacer(thing) {
|
||||
if (typeof thing?.then === 'function') {
|
||||
const id = promise_id++;
|
||||
|
||||
const promise = thing
|
||||
.then(/** @param {any} data */ (data) => ({ data }))
|
||||
.catch(
|
||||
/** @param {any} error */ async (error) => ({
|
||||
error: await handle_error_and_jsonify(event, event_state, options, error)
|
||||
})
|
||||
)
|
||||
.then(
|
||||
/**
|
||||
* @param {{data: any; error: any}} result
|
||||
*/
|
||||
async ({ data, error }) => {
|
||||
let str;
|
||||
try {
|
||||
str = devalue.uneval(error ? [, error] : [data], replacer);
|
||||
} catch {
|
||||
error = await handle_error_and_jsonify(
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
new Error(`Failed to serialize promise while rendering ${event.route.id}`)
|
||||
);
|
||||
data = undefined;
|
||||
str = devalue.uneval([, error], replacer);
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
str: `${global}.resolve(${id}, ${str.includes('app.decode') ? `(app) => ${str}` : `() => ${str}`})`
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
iterator.add(promise);
|
||||
|
||||
return `${global}.defer(${id})`;
|
||||
} else {
|
||||
for (const key in options.hooks.transport) {
|
||||
const encoded = options.hooks.transport[key].encode(thing);
|
||||
if (encoded) {
|
||||
return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const strings = /** @type {string[]} */ ([]);
|
||||
|
||||
return {
|
||||
set_max_nodes(i) {
|
||||
max_nodes = i;
|
||||
},
|
||||
|
||||
add_node(i, node) {
|
||||
try {
|
||||
if (!node) {
|
||||
strings[i] = 'null';
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
const payload = { type: 'data', data: node.data, uses: serialize_uses(node) };
|
||||
if (node.slash) payload.slash = node.slash;
|
||||
|
||||
strings[i] = devalue.uneval(payload, get_replacer(i));
|
||||
} catch (e) {
|
||||
// @ts-expect-error
|
||||
e.path = e.path.slice(1);
|
||||
throw new Error(clarify_devalue_error(event, /** @type {any} */ (e)));
|
||||
}
|
||||
},
|
||||
|
||||
get_data(csp) {
|
||||
const open = `<script${csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''}>`;
|
||||
const close = `</script>\n`;
|
||||
|
||||
return {
|
||||
data: `[${compact(max_nodes > -1 ? strings.slice(0, max_nodes) : strings).join(',')}]`,
|
||||
chunks:
|
||||
promise_id > 1
|
||||
? iterator.iterate(({ index, str }) => {
|
||||
if (max_nodes > -1 && index >= max_nodes) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return open + str + close;
|
||||
})
|
||||
: null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the serialized data contains promises, `chunks` will be an
|
||||
* async iterable containing their resolutions
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {import('types').SSROptions} options
|
||||
* @returns {import('./types.js').ServerDataSerializerJson}
|
||||
*/
|
||||
export function server_data_serializer_json(event, event_state, options) {
|
||||
let promise_id = 1;
|
||||
|
||||
const iterator = create_async_iterator();
|
||||
|
||||
const reducers = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(options.hooks.transport).map(([key, value]) => [key, value.encode])
|
||||
),
|
||||
/** @param {any} thing */
|
||||
Promise: (thing) => {
|
||||
if (typeof thing?.then !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = promise_id++;
|
||||
|
||||
/** @type {'data' | 'error'} */
|
||||
let key = 'data';
|
||||
|
||||
const promise = thing
|
||||
.catch(
|
||||
/** @param {any} e */ async (e) => {
|
||||
key = 'error';
|
||||
return handle_error_and_jsonify(event, event_state, options, /** @type {any} */ (e));
|
||||
}
|
||||
)
|
||||
.then(
|
||||
/** @param {any} value */
|
||||
async (value) => {
|
||||
let str;
|
||||
try {
|
||||
str = devalue.stringify(value, reducers);
|
||||
} catch {
|
||||
const error = await handle_error_and_jsonify(
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
new Error(`Failed to serialize promise while rendering ${event.route.id}`)
|
||||
);
|
||||
|
||||
key = 'error';
|
||||
str = devalue.stringify(error, reducers);
|
||||
}
|
||||
|
||||
return `{"type":"chunk","id":${id},"${key}":${str}}\n`;
|
||||
}
|
||||
);
|
||||
|
||||
iterator.add(promise);
|
||||
|
||||
return id;
|
||||
}
|
||||
};
|
||||
|
||||
const strings = /** @type {string[]} */ ([]);
|
||||
|
||||
return {
|
||||
add_node(i, node) {
|
||||
try {
|
||||
if (!node) {
|
||||
strings[i] = 'null';
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'error' || node.type === 'skip') {
|
||||
strings[i] = JSON.stringify(node);
|
||||
return;
|
||||
}
|
||||
|
||||
strings[i] =
|
||||
`{"type":"data","data":${devalue.stringify(node.data, reducers)},"uses":${JSON.stringify(
|
||||
serialize_uses(node)
|
||||
)}${node.slash ? `,"slash":${JSON.stringify(node.slash)}` : ''}}`;
|
||||
} catch (e) {
|
||||
// @ts-expect-error
|
||||
e.path = 'data' + e.path;
|
||||
throw new Error(clarify_devalue_error(event, /** @type {any} */ (e)));
|
||||
}
|
||||
},
|
||||
|
||||
get_data() {
|
||||
return {
|
||||
data: `{"type":"data","nodes":[${strings.join(',')}]}\n`,
|
||||
chunks: promise_id > 1 ? iterator.iterate() : null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
import { text } from '@sveltejs/kit';
|
||||
import { HttpError, Redirect } from '@sveltejs/kit/internal';
|
||||
import { compact } from '../../../utils/array.js';
|
||||
import { get_status, normalize_error } from '../../../utils/error.js';
|
||||
import { add_data_suffix } from '../../pathname.js';
|
||||
import { redirect_response, static_error_page, handle_error_and_jsonify } from '../utils.js';
|
||||
import {
|
||||
handle_action_json_request,
|
||||
handle_action_request,
|
||||
is_action_json_request,
|
||||
is_action_request
|
||||
} from './actions.js';
|
||||
import { server_data_serializer, server_data_serializer_json } from './data_serializer.js';
|
||||
import { load_data, load_server_data } from './load_data.js';
|
||||
import { render_response } from './render.js';
|
||||
import { respond_with_error } from './respond_with_error.js';
|
||||
import { DEV } from 'esm-env';
|
||||
import { get_remote_action, handle_remote_form_post } from '../remote.js';
|
||||
import { PageNodes } from '../../../utils/page_nodes.js';
|
||||
|
||||
/**
|
||||
* The maximum request depth permitted before assuming we're stuck in an infinite loop
|
||||
*/
|
||||
const MAX_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} event_state
|
||||
* @param {import('types').PageNodeIndexes} page
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @param {import('types').SSRState} state
|
||||
* @param {import('../../../utils/page_nodes.js').PageNodes} nodes
|
||||
* @param {import('types').RequiredResolveOptions} resolve_opts
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function render_page(
|
||||
event,
|
||||
event_state,
|
||||
page,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
nodes,
|
||||
resolve_opts
|
||||
) {
|
||||
if (state.depth > MAX_DEPTH) {
|
||||
// infinite request cycle detected
|
||||
return text(`Not found: ${event.url.pathname}`, {
|
||||
status: 404 // TODO in some cases this should be 500. not sure how to differentiate
|
||||
});
|
||||
}
|
||||
|
||||
if (is_action_json_request(event)) {
|
||||
const node = await manifest._.nodes[page.leaf]();
|
||||
return handle_action_json_request(event, event_state, options, node?.server);
|
||||
}
|
||||
|
||||
try {
|
||||
const leaf_node = /** @type {import('types').SSRNode} */ (nodes.page());
|
||||
|
||||
let status = 200;
|
||||
|
||||
/** @type {import('@sveltejs/kit').ActionResult | undefined} */
|
||||
let action_result = undefined;
|
||||
|
||||
if (is_action_request(event)) {
|
||||
const remote_id = get_remote_action(event.url);
|
||||
if (remote_id) {
|
||||
action_result = await handle_remote_form_post(event, event_state, manifest, remote_id);
|
||||
} else {
|
||||
// for action requests, first call handler in +page.server.js
|
||||
// (this also determines status code)
|
||||
action_result = await handle_action_request(event, event_state, leaf_node.server);
|
||||
}
|
||||
|
||||
if (action_result?.type === 'redirect') {
|
||||
return redirect_response(action_result.status, action_result.location);
|
||||
}
|
||||
if (action_result?.type === 'error') {
|
||||
status = get_status(action_result.error);
|
||||
}
|
||||
if (action_result?.type === 'failure') {
|
||||
status = action_result.status;
|
||||
}
|
||||
}
|
||||
|
||||
// it's crucial that we do this before returning the non-SSR response, otherwise
|
||||
// SvelteKit will erroneously believe that the path has been prerendered,
|
||||
// causing functions to be omitted from the manifest generated later
|
||||
const should_prerender = nodes.prerender();
|
||||
if (should_prerender) {
|
||||
const mod = leaf_node.server;
|
||||
if (mod?.actions) {
|
||||
throw new Error('Cannot prerender pages with actions');
|
||||
}
|
||||
} else if (state.prerendering) {
|
||||
// if the page isn't marked as prerenderable, then bail out at this point
|
||||
return new Response(undefined, {
|
||||
status: 204
|
||||
});
|
||||
}
|
||||
|
||||
// if we fetch any endpoints while loading data for this page, they should
|
||||
// inherit the prerender option of the page
|
||||
state.prerender_default = should_prerender;
|
||||
|
||||
const should_prerender_data = nodes.should_prerender_data();
|
||||
const data_pathname = add_data_suffix(event.url.pathname);
|
||||
|
||||
/** @type {import('./types.js').Fetched[]} */
|
||||
const fetched = [];
|
||||
|
||||
const ssr = nodes.ssr();
|
||||
const csr = nodes.csr();
|
||||
|
||||
// renders an empty 'shell' page if SSR is turned off and if there is
|
||||
// no server data to prerender. As a result, the load functions and rendering
|
||||
// only occur client-side.
|
||||
if (ssr === false && !(state.prerendering && should_prerender_data)) {
|
||||
// if the user makes a request through a non-enhanced form, the returned value is lost
|
||||
// because there is no SSR or client-side handling of the response
|
||||
if (DEV && action_result && !event.request.headers.has('x-sveltekit-action')) {
|
||||
if (action_result.type === 'error') {
|
||||
console.warn(
|
||||
"The form action returned an error, but +error.svelte wasn't rendered because SSR is off. To get the error page with CSR, enhance your form with `use:enhance`. See https://svelte.dev/docs/kit/form-actions#progressive-enhancement-use-enhance"
|
||||
);
|
||||
} else if (action_result.data) {
|
||||
/// case: lost data
|
||||
console.warn(
|
||||
"The form action returned a value, but it isn't available in `page.form`, because SSR is off. To handle the returned value in CSR, enhance your form with `use:enhance`. See https://svelte.dev/docs/kit/form-actions#progressive-enhancement-use-enhance"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await render_response({
|
||||
branch: [],
|
||||
fetched,
|
||||
page_config: {
|
||||
ssr: false,
|
||||
csr
|
||||
},
|
||||
status,
|
||||
error: null,
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
resolve_opts,
|
||||
data_serializer: server_data_serializer(event, event_state, options)
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Array<import('./types.js').Loaded | null>} */
|
||||
const branch = [];
|
||||
|
||||
/** @type {Error | null} */
|
||||
let load_error = null;
|
||||
|
||||
const data_serializer = server_data_serializer(event, event_state, options);
|
||||
const data_serializer_json =
|
||||
state.prerendering && should_prerender_data
|
||||
? server_data_serializer_json(event, event_state, options)
|
||||
: null;
|
||||
|
||||
/** @type {Array<Promise<import('types').ServerDataNode | null>>} */
|
||||
const server_promises = nodes.data.map((node, i) => {
|
||||
if (load_error) {
|
||||
// if an error happens immediately, don't bother with the rest of the nodes
|
||||
throw load_error;
|
||||
}
|
||||
|
||||
return Promise.resolve().then(async () => {
|
||||
try {
|
||||
if (node === leaf_node && action_result?.type === 'error') {
|
||||
// we wait until here to throw the error so that we can use
|
||||
// any nested +error.svelte components that were defined
|
||||
throw action_result.error;
|
||||
}
|
||||
|
||||
const server_data = await load_server_data({
|
||||
event,
|
||||
event_state,
|
||||
state,
|
||||
node,
|
||||
parent: async () => {
|
||||
/** @type {Record<string, any>} */
|
||||
const data = {};
|
||||
for (let j = 0; j < i; j += 1) {
|
||||
const parent = await server_promises[j];
|
||||
if (parent) Object.assign(data, parent.data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
if (node) {
|
||||
data_serializer.add_node(i, server_data);
|
||||
}
|
||||
|
||||
data_serializer_json?.add_node(i, server_data);
|
||||
|
||||
return server_data;
|
||||
} catch (e) {
|
||||
load_error = /** @type {Error} */ (e);
|
||||
throw load_error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** @type {Array<Promise<Record<string, any> | null>>} */
|
||||
const load_promises = nodes.data.map((node, i) => {
|
||||
if (load_error) throw load_error;
|
||||
return Promise.resolve().then(async () => {
|
||||
try {
|
||||
return await load_data({
|
||||
event,
|
||||
event_state,
|
||||
fetched,
|
||||
node,
|
||||
parent: async () => {
|
||||
const data = {};
|
||||
for (let j = 0; j < i; j += 1) {
|
||||
Object.assign(data, await load_promises[j]);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
resolve_opts,
|
||||
server_data_promise: server_promises[i],
|
||||
state,
|
||||
csr
|
||||
});
|
||||
} catch (e) {
|
||||
load_error = /** @type {Error} */ (e);
|
||||
throw load_error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// if we don't do this, rejections will be unhandled
|
||||
for (const p of server_promises) p.catch(() => {});
|
||||
for (const p of load_promises) p.catch(() => {});
|
||||
|
||||
for (let i = 0; i < nodes.data.length; i += 1) {
|
||||
const node = nodes.data[i];
|
||||
|
||||
if (node) {
|
||||
try {
|
||||
const server_data = await server_promises[i];
|
||||
const data = await load_promises[i];
|
||||
|
||||
branch.push({ node, server_data, data });
|
||||
} catch (e) {
|
||||
const err = normalize_error(e);
|
||||
|
||||
if (err instanceof Redirect) {
|
||||
if (state.prerendering && should_prerender_data) {
|
||||
const body = JSON.stringify({
|
||||
type: 'redirect',
|
||||
location: err.location
|
||||
});
|
||||
|
||||
state.prerendering.dependencies.set(data_pathname, {
|
||||
response: text(body),
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
return redirect_response(err.status, err.location);
|
||||
}
|
||||
|
||||
const status = get_status(err);
|
||||
const error = await handle_error_and_jsonify(event, event_state, options, err);
|
||||
|
||||
while (i--) {
|
||||
if (page.errors[i]) {
|
||||
const index = /** @type {number} */ (page.errors[i]);
|
||||
const node = await manifest._.nodes[index]();
|
||||
|
||||
let j = i;
|
||||
while (!branch[j]) j -= 1;
|
||||
|
||||
data_serializer.set_max_nodes(j + 1);
|
||||
|
||||
const layouts = compact(branch.slice(0, j + 1));
|
||||
const nodes = new PageNodes(layouts.map((layout) => layout.node));
|
||||
|
||||
return await render_response({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
resolve_opts,
|
||||
page_config: {
|
||||
ssr: nodes.ssr(),
|
||||
csr: nodes.csr()
|
||||
},
|
||||
status,
|
||||
error,
|
||||
branch: layouts.concat({
|
||||
node,
|
||||
data: null,
|
||||
server_data: null
|
||||
}),
|
||||
fetched,
|
||||
data_serializer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// if we're still here, it means the error happened in the root layout,
|
||||
// which means we have to fall back to error.html
|
||||
return static_error_page(options, status, error.message);
|
||||
}
|
||||
} else {
|
||||
// push an empty slot so we can rewind past gaps to the
|
||||
// layout that corresponds with an +error.svelte page
|
||||
branch.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.prerendering && data_serializer_json) {
|
||||
// ndjson format
|
||||
let { data, chunks } = data_serializer_json.get_data();
|
||||
|
||||
if (chunks) {
|
||||
for await (const chunk of chunks) {
|
||||
data += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
state.prerendering.dependencies.set(data_pathname, {
|
||||
response: text(data),
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
return await render_response({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
resolve_opts,
|
||||
page_config: {
|
||||
csr,
|
||||
ssr
|
||||
},
|
||||
status,
|
||||
error: null,
|
||||
branch: ssr === false ? [] : compact(branch),
|
||||
action_result,
|
||||
fetched,
|
||||
data_serializer:
|
||||
ssr === false ? server_data_serializer(event, event_state, options) : data_serializer
|
||||
});
|
||||
} catch (e) {
|
||||
// a remote function could have thrown a redirect during render
|
||||
if (e instanceof Redirect) {
|
||||
return redirect_response(e.status, e.location);
|
||||
}
|
||||
|
||||
// if we end up here, it means the data loaded successfully
|
||||
// but the page failed to render, or that a prerendering error occurred
|
||||
return await respond_with_error({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
status: e instanceof HttpError ? e.status : 500,
|
||||
error: e,
|
||||
resolve_opts
|
||||
});
|
||||
}
|
||||
}
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
import { DEV } from 'esm-env';
|
||||
import { disable_search, make_trackable } from '../../../utils/url.js';
|
||||
import { validate_depends, validate_load_response } from '../../shared.js';
|
||||
import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server';
|
||||
import { record_span } from '../../telemetry/record_span.js';
|
||||
import { base64_encode, text_decoder } from '../../utils.js';
|
||||
import { NULL_BODY_STATUS } from '../constants.js';
|
||||
import { get_node_type } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Calls the user's server `load` function.
|
||||
* @param {{
|
||||
* event: import('@sveltejs/kit').RequestEvent;
|
||||
* event_state: import('types').RequestState;
|
||||
* state: import('types').SSRState;
|
||||
* node: import('types').SSRNode | undefined;
|
||||
* parent: () => Promise<Record<string, any>>;
|
||||
* }} opts
|
||||
* @returns {Promise<import('types').ServerDataNode | null>}
|
||||
*/
|
||||
export async function load_server_data({ event, event_state, state, node, parent }) {
|
||||
if (!node?.server) return null;
|
||||
|
||||
let is_tracking = true;
|
||||
|
||||
const uses = {
|
||||
dependencies: new Set(),
|
||||
params: new Set(),
|
||||
parent: false,
|
||||
route: false,
|
||||
url: false,
|
||||
search_params: new Set()
|
||||
};
|
||||
|
||||
const load = node.server.load;
|
||||
// TODO: shouldn't this be calculated using PageNodes? there could be a trailingSlash option on a layout
|
||||
const slash = node.server.trailingSlash;
|
||||
|
||||
if (!load) {
|
||||
return { type: 'data', data: null, uses, slash };
|
||||
}
|
||||
|
||||
const url = make_trackable(
|
||||
event.url,
|
||||
() => {
|
||||
if (DEV && done && !uses.url) {
|
||||
console.warn(
|
||||
`${node.server_id}: Accessing URL properties in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the URL changes`
|
||||
);
|
||||
}
|
||||
|
||||
if (is_tracking) {
|
||||
uses.url = true;
|
||||
}
|
||||
},
|
||||
(param) => {
|
||||
if (DEV && done && !uses.search_params.has(param)) {
|
||||
console.warn(
|
||||
`${node.server_id}: Accessing URL properties in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the URL changes`
|
||||
);
|
||||
}
|
||||
|
||||
if (is_tracking) {
|
||||
uses.search_params.add(param);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (state.prerendering) {
|
||||
disable_search(url);
|
||||
}
|
||||
|
||||
let done = false;
|
||||
|
||||
const result = await record_span({
|
||||
name: 'sveltekit.load',
|
||||
attributes: {
|
||||
'sveltekit.load.node_id': node.server_id || 'unknown',
|
||||
'sveltekit.load.node_type': get_node_type(node.server_id),
|
||||
'sveltekit.load.environment': 'server',
|
||||
'http.route': event.route.id || 'unknown'
|
||||
},
|
||||
fn: async (current) => {
|
||||
const traced_event = merge_tracing(event, current);
|
||||
const result = await with_request_store({ event: traced_event, state: event_state }, () =>
|
||||
load.call(null, {
|
||||
...traced_event,
|
||||
fetch: (info, init) => {
|
||||
const url = new URL(info instanceof Request ? info.url : info, event.url);
|
||||
|
||||
if (DEV && done && !uses.dependencies.has(url.href)) {
|
||||
console.warn(
|
||||
`${node.server_id}: Calling \`event.fetch(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated`
|
||||
);
|
||||
}
|
||||
|
||||
// Note: server fetches are not added to uses.depends due to security concerns
|
||||
return event.fetch(info, init);
|
||||
},
|
||||
/** @param {string[]} deps */
|
||||
depends: (...deps) => {
|
||||
for (const dep of deps) {
|
||||
const { href } = new URL(dep, event.url);
|
||||
|
||||
if (DEV) {
|
||||
validate_depends(node.server_id || 'missing route ID', dep);
|
||||
|
||||
if (done && !uses.dependencies.has(href)) {
|
||||
console.warn(
|
||||
`${node.server_id}: Calling \`depends(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
uses.dependencies.add(href);
|
||||
}
|
||||
},
|
||||
params: new Proxy(event.params, {
|
||||
get: (target, key) => {
|
||||
if (DEV && done && typeof key === 'string' && !uses.params.has(key)) {
|
||||
console.warn(
|
||||
`${node.server_id}: Accessing \`params.${String(
|
||||
key
|
||||
)}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the param changes`
|
||||
);
|
||||
}
|
||||
|
||||
if (is_tracking) {
|
||||
uses.params.add(key);
|
||||
}
|
||||
return target[/** @type {string} */ (key)];
|
||||
}
|
||||
}),
|
||||
parent: async () => {
|
||||
if (DEV && done && !uses.parent) {
|
||||
console.warn(
|
||||
`${node.server_id}: Calling \`parent(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when parent data changes`
|
||||
);
|
||||
}
|
||||
|
||||
if (is_tracking) {
|
||||
uses.parent = true;
|
||||
}
|
||||
return parent();
|
||||
},
|
||||
route: new Proxy(event.route, {
|
||||
get: (target, key) => {
|
||||
if (DEV && done && typeof key === 'string' && !uses.route) {
|
||||
console.warn(
|
||||
`${node.server_id}: Accessing \`route.${String(
|
||||
key
|
||||
)}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the route changes`
|
||||
);
|
||||
}
|
||||
|
||||
if (is_tracking) {
|
||||
uses.route = true;
|
||||
}
|
||||
return target[/** @type {'id'} */ (key)];
|
||||
}
|
||||
}),
|
||||
url,
|
||||
untrack(fn) {
|
||||
is_tracking = false;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
is_tracking = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
if (DEV) {
|
||||
validate_load_response(result, `in ${node.server_id}`);
|
||||
}
|
||||
|
||||
done = true;
|
||||
|
||||
return {
|
||||
type: 'data',
|
||||
data: result ?? null,
|
||||
uses,
|
||||
slash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the user's `load` function.
|
||||
* @param {{
|
||||
* event: import('@sveltejs/kit').RequestEvent;
|
||||
* event_state: import('types').RequestState;
|
||||
* fetched: import('./types.js').Fetched[];
|
||||
* node: import('types').SSRNode | undefined;
|
||||
* parent: () => Promise<Record<string, any>>;
|
||||
* resolve_opts: import('types').RequiredResolveOptions;
|
||||
* server_data_promise: Promise<import('types').ServerDataNode | null>;
|
||||
* state: import('types').SSRState;
|
||||
* csr: boolean;
|
||||
* }} opts
|
||||
* @returns {Promise<Record<string, any | Promise<any>> | null>}
|
||||
*/
|
||||
export async function load_data({
|
||||
event,
|
||||
event_state,
|
||||
fetched,
|
||||
node,
|
||||
parent,
|
||||
server_data_promise,
|
||||
state,
|
||||
resolve_opts,
|
||||
csr
|
||||
}) {
|
||||
const server_data_node = await server_data_promise;
|
||||
|
||||
const load = node?.universal?.load;
|
||||
|
||||
if (!load) {
|
||||
return server_data_node?.data ?? null;
|
||||
}
|
||||
|
||||
const result = await record_span({
|
||||
name: 'sveltekit.load',
|
||||
attributes: {
|
||||
'sveltekit.load.node_id': node.universal_id || 'unknown',
|
||||
'sveltekit.load.node_type': get_node_type(node.universal_id),
|
||||
'sveltekit.load.environment': 'server',
|
||||
'http.route': event.route.id || 'unknown'
|
||||
},
|
||||
fn: async (current) => {
|
||||
const traced_event = merge_tracing(event, current);
|
||||
return await with_request_store({ event: traced_event, state: event_state }, () =>
|
||||
load.call(null, {
|
||||
url: event.url,
|
||||
params: event.params,
|
||||
data: server_data_node?.data ?? null,
|
||||
route: event.route,
|
||||
fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts),
|
||||
setHeaders: event.setHeaders,
|
||||
depends: () => {},
|
||||
parent,
|
||||
untrack: (fn) => fn(),
|
||||
tracing: traced_event.tracing
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (DEV) {
|
||||
validate_load_response(result, `in ${node.universal_id}`);
|
||||
}
|
||||
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Pick<import('@sveltejs/kit').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} event
|
||||
* @param {import('types').SSRState} state
|
||||
* @param {import('./types.js').Fetched[]} fetched
|
||||
* @param {boolean} csr
|
||||
* @param {Pick<Required<import('@sveltejs/kit').ResolveOptions>, 'filterSerializedResponseHeaders'>} resolve_opts
|
||||
* @returns {typeof fetch}
|
||||
*/
|
||||
export function create_universal_fetch(event, state, fetched, csr, resolve_opts) {
|
||||
/**
|
||||
* @param {URL | RequestInfo} input
|
||||
* @param {RequestInit} [init]
|
||||
*/
|
||||
const universal_fetch = async (input, init) => {
|
||||
const cloned_body = input instanceof Request && input.body ? input.clone().body : null;
|
||||
|
||||
const cloned_headers =
|
||||
input instanceof Request && [...input.headers].length
|
||||
? new Headers(input.headers)
|
||||
: init?.headers;
|
||||
|
||||
let response = await event.fetch(input, init);
|
||||
|
||||
const url = new URL(input instanceof Request ? input.url : input, event.url);
|
||||
const same_origin = url.origin === event.url.origin;
|
||||
|
||||
/** @type {import('types').PrerenderDependency} */
|
||||
let dependency;
|
||||
|
||||
if (same_origin) {
|
||||
if (state.prerendering) {
|
||||
dependency = { response, body: null };
|
||||
state.prerendering.dependencies.set(url.pathname, dependency);
|
||||
}
|
||||
} else if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||
// simulate CORS errors and "no access to body in no-cors mode" server-side for consistency with client-side behaviour
|
||||
const mode = input instanceof Request ? input.mode : (init?.mode ?? 'cors');
|
||||
if (mode === 'no-cors') {
|
||||
response = new Response('', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
} else {
|
||||
const acao = response.headers.get('access-control-allow-origin');
|
||||
if (!acao || (acao !== event.url.origin && acao !== '*')) {
|
||||
throw new Error(
|
||||
`CORS error: ${
|
||||
acao ? 'Incorrect' : 'No'
|
||||
} 'Access-Control-Allow-Origin' header is present on the requested resource`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {ReadableStream<Uint8Array>} */
|
||||
let teed_body;
|
||||
|
||||
const proxy = new Proxy(response, {
|
||||
get(response, key, receiver) {
|
||||
/**
|
||||
* @param {string | undefined} body
|
||||
* @param {boolean} is_b64
|
||||
*/
|
||||
async function push_fetched(body, is_b64) {
|
||||
const status_number = Number(response.status);
|
||||
if (isNaN(status_number)) {
|
||||
throw new Error(
|
||||
`response.status is not a number. value: "${
|
||||
response.status
|
||||
}" type: ${typeof response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
fetched.push({
|
||||
url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
|
||||
method: event.request.method,
|
||||
request_body: /** @type {string | ArrayBufferView | undefined} */ (
|
||||
input instanceof Request && cloned_body
|
||||
? await stream_to_string(cloned_body)
|
||||
: init?.body
|
||||
),
|
||||
request_headers: cloned_headers,
|
||||
response_body: body,
|
||||
response,
|
||||
is_b64
|
||||
});
|
||||
}
|
||||
|
||||
if (key === 'body') {
|
||||
if (response.body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (teed_body) {
|
||||
return teed_body;
|
||||
}
|
||||
|
||||
const [a, b] = response.body.tee();
|
||||
|
||||
void (async () => {
|
||||
let result = new Uint8Array();
|
||||
|
||||
for await (const chunk of a) {
|
||||
const combined = new Uint8Array(result.length + chunk.length);
|
||||
|
||||
combined.set(result, 0);
|
||||
combined.set(chunk, result.length);
|
||||
|
||||
result = combined;
|
||||
}
|
||||
|
||||
if (dependency) {
|
||||
dependency.body = new Uint8Array(result);
|
||||
}
|
||||
|
||||
void push_fetched(base64_encode(result), true);
|
||||
})();
|
||||
|
||||
return (teed_body = b);
|
||||
}
|
||||
|
||||
if (key === 'arrayBuffer') {
|
||||
return async () => {
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (dependency) {
|
||||
dependency.body = bytes;
|
||||
}
|
||||
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
await push_fetched(base64_encode(bytes), true);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
};
|
||||
}
|
||||
|
||||
async function text() {
|
||||
const body = await response.text();
|
||||
|
||||
if (body === '' && NULL_BODY_STATUS.includes(response.status)) {
|
||||
await push_fetched(undefined, false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!body || typeof body === 'string') {
|
||||
await push_fetched(body, false);
|
||||
}
|
||||
|
||||
if (dependency) {
|
||||
dependency.body = body;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
if (key === 'text') {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (key === 'json') {
|
||||
return async () => {
|
||||
const body = await text();
|
||||
return body ? JSON.parse(body) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const value = Reflect.get(response, key, response);
|
||||
|
||||
if (value instanceof Function) {
|
||||
// On Node v24+, the Response object has a private element #state – we
|
||||
// need to bind this function to the response in order to allow it to
|
||||
// access this private element. Defining the name and length ensure it
|
||||
// is identical to the original function when introspected.
|
||||
return Object.defineProperties(
|
||||
/**
|
||||
* @this {any}
|
||||
*/
|
||||
function () {
|
||||
return Reflect.apply(value, this === receiver ? response : this, arguments);
|
||||
},
|
||||
{
|
||||
name: { value: value.name },
|
||||
length: { value: value.length }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
if (csr) {
|
||||
// ensure that excluded headers can't be read
|
||||
const get = response.headers.get;
|
||||
response.headers.get = (key) => {
|
||||
const lower = key.toLowerCase();
|
||||
const value = get.call(response.headers, lower);
|
||||
if (value && !lower.startsWith('x-sveltekit-')) {
|
||||
const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
|
||||
if (!included) {
|
||||
throw new Error(
|
||||
`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://svelte.dev/docs/kit/hooks#Server-hooks-handle (at ${event.route.id})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
return proxy;
|
||||
};
|
||||
|
||||
// Don't make this function `async`! Otherwise, the user has to `catch` promises they use for streaming responses or else
|
||||
// it will be an unhandled rejection. Instead, we add a `.catch(() => {})` ourselves below to this from happening.
|
||||
return (input, init) => {
|
||||
// See docs in fetch.js for why we need to do this
|
||||
const response = universal_fetch(input, init);
|
||||
response.catch(() => {});
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReadableStream<Uint8Array>} stream
|
||||
*/
|
||||
async function stream_to_string(stream) {
|
||||
let result = '';
|
||||
const reader = stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
result += text_decoder.decode(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
import * as devalue from 'devalue';
|
||||
import { readable, writable } from 'svelte/store';
|
||||
import { DEV } from 'esm-env';
|
||||
import { text } from '@sveltejs/kit';
|
||||
import * as paths from '$app/paths/internal/server';
|
||||
import { hash } from '../../../utils/hash.js';
|
||||
import { serialize_data } from './serialize_data.js';
|
||||
import { s } from '../../../utils/misc.js';
|
||||
import { Csp } from './csp.js';
|
||||
import { uneval_action_response } from './actions.js';
|
||||
import { public_env } from '../../shared-server.js';
|
||||
import { SVELTE_KIT_ASSETS } from '../../../constants.js';
|
||||
import { SCHEME } from '../../../utils/url.js';
|
||||
import { create_server_routing_response, generate_route_object } from './server_routing.js';
|
||||
import { add_resolution_suffix } from '../../pathname.js';
|
||||
import { try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { text_encoder } from '../../utils.js';
|
||||
import { get_global_name } from '../utils.js';
|
||||
import { create_remote_key } from '../../shared.js';
|
||||
|
||||
// TODO rename this function/module
|
||||
|
||||
const updated = {
|
||||
...readable(false),
|
||||
check: () => false
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the HTML response.
|
||||
* @param {{
|
||||
* branch: Array<import('./types.js').Loaded>;
|
||||
* fetched: Array<import('./types.js').Fetched>;
|
||||
* options: import('types').SSROptions;
|
||||
* manifest: import('@sveltejs/kit').SSRManifest;
|
||||
* state: import('types').SSRState;
|
||||
* page_config: { ssr: boolean; csr: boolean };
|
||||
* status: number;
|
||||
* error: App.Error | null;
|
||||
* event: import('@sveltejs/kit').RequestEvent;
|
||||
* event_state: import('types').RequestState;
|
||||
* resolve_opts: import('types').RequiredResolveOptions;
|
||||
* action_result?: import('@sveltejs/kit').ActionResult;
|
||||
* data_serializer: import('./types.js').ServerDataSerializer
|
||||
* }} opts
|
||||
*/
|
||||
export async function render_response({
|
||||
branch,
|
||||
fetched,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
page_config,
|
||||
status,
|
||||
error = null,
|
||||
event,
|
||||
event_state,
|
||||
resolve_opts,
|
||||
action_result,
|
||||
data_serializer
|
||||
}) {
|
||||
if (state.prerendering) {
|
||||
if (options.csp.mode === 'nonce') {
|
||||
throw new Error('Cannot use prerendering if config.kit.csp.mode === "nonce"');
|
||||
}
|
||||
|
||||
if (options.app_template_contains_nonce) {
|
||||
throw new Error('Cannot use prerendering if page template contains %sveltekit.nonce%');
|
||||
}
|
||||
}
|
||||
|
||||
const { client } = manifest._;
|
||||
|
||||
const modulepreloads = new Set(client.imports);
|
||||
const stylesheets = new Set(client.stylesheets);
|
||||
const fonts = new Set(client.fonts);
|
||||
|
||||
/**
|
||||
* The value of the Link header that is added to the response when not prerendering
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const link_headers = new Set();
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
// TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold
|
||||
const inline_styles = new Map();
|
||||
|
||||
/** @type {ReturnType<typeof options.root.render>} */
|
||||
let rendered;
|
||||
|
||||
const form_value =
|
||||
action_result?.type === 'success' || action_result?.type === 'failure'
|
||||
? (action_result.data ?? null)
|
||||
: null;
|
||||
|
||||
/** @type {string} */
|
||||
let base = paths.base;
|
||||
|
||||
/** @type {string} */
|
||||
let assets = paths.assets;
|
||||
|
||||
/**
|
||||
* An expression that will evaluate in the client to determine the resolved base path.
|
||||
* We use a relative path when possible to support IPFS, the internet archive, etc.
|
||||
*/
|
||||
let base_expression = s(paths.base);
|
||||
|
||||
const csp = new Csp(options.csp, {
|
||||
prerender: !!state.prerendering
|
||||
});
|
||||
|
||||
// if appropriate, use relative paths for greater portability
|
||||
if (paths.relative) {
|
||||
if (!state.prerendering?.fallback) {
|
||||
const segments = event.url.pathname.slice(paths.base.length).split('/').slice(2);
|
||||
|
||||
base = segments.map(() => '..').join('/') || '.';
|
||||
|
||||
// resolve e.g. '../..' against current location, then remove trailing slash
|
||||
base_expression = `new URL(${s(base)}, location).pathname.slice(0, -1)`;
|
||||
|
||||
if (!paths.assets || (paths.assets[0] === '/' && paths.assets !== SVELTE_KIT_ASSETS)) {
|
||||
assets = base;
|
||||
}
|
||||
} else if (options.hash_routing) {
|
||||
// we have to assume that we're in the right place
|
||||
base_expression = "new URL('.', location).pathname.slice(0, -1)";
|
||||
}
|
||||
}
|
||||
|
||||
if (page_config.ssr) {
|
||||
/** @type {Record<string, any>} */
|
||||
const props = {
|
||||
stores: {
|
||||
page: writable(null),
|
||||
navigating: writable(null),
|
||||
updated
|
||||
},
|
||||
constructors: await Promise.all(
|
||||
branch.map(({ node }) => {
|
||||
if (!node.component) {
|
||||
// Can only be the leaf, layouts have a fallback component generated
|
||||
throw new Error(`Missing +page.svelte component for route ${event.route.id}`);
|
||||
}
|
||||
return node.component();
|
||||
})
|
||||
),
|
||||
form: form_value
|
||||
};
|
||||
|
||||
let data = {};
|
||||
|
||||
// props_n (instead of props[n]) makes it easy to avoid
|
||||
// unnecessary updates for layout components
|
||||
for (let i = 0; i < branch.length; i += 1) {
|
||||
data = { ...data, ...branch[i].data };
|
||||
props[`data_${i}`] = data;
|
||||
}
|
||||
|
||||
props.page = {
|
||||
error,
|
||||
params: /** @type {Record<string, any>} */ (event.params),
|
||||
route: event.route,
|
||||
status,
|
||||
url: event.url,
|
||||
data,
|
||||
form: form_value,
|
||||
state: {}
|
||||
};
|
||||
|
||||
const render_opts = {
|
||||
context: new Map([
|
||||
[
|
||||
'__request__',
|
||||
{
|
||||
page: props.page
|
||||
}
|
||||
]
|
||||
]),
|
||||
csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash }
|
||||
};
|
||||
|
||||
const fetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
if (DEV) {
|
||||
let warned = false;
|
||||
globalThis.fetch = (info, init) => {
|
||||
if (typeof info === 'string' && !SCHEME.test(info)) {
|
||||
throw new Error(
|
||||
`Cannot call \`fetch\` eagerly during server-side rendering with relative URL (${info}) — put your \`fetch\` calls inside \`onMount\` or a \`load\` function instead`
|
||||
);
|
||||
} else if (!warned && !try_get_request_store()?.state.is_in_remote_function) {
|
||||
console.warn(
|
||||
'Avoid calling `fetch` eagerly during server-side rendering — put your `fetch` calls inside `onMount` or a `load` function instead'
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
|
||||
return fetch(info, init);
|
||||
};
|
||||
}
|
||||
|
||||
rendered = await with_request_store({ event, state: event_state }, async () => {
|
||||
// use relative paths during rendering, so that the resulting HTML is as
|
||||
// portable as possible, but reset afterwards
|
||||
if (paths.relative) paths.override({ base, assets });
|
||||
|
||||
const maybe_promise = options.root.render(props, render_opts);
|
||||
// We have to invoke .then eagerly here in order to kick off rendering: it's only starting on access,
|
||||
// and `await maybe_promise` would eagerly access the .then property but call its function only after a tick, which is too late
|
||||
// for the paths.reset() below and for any eager getRequestEvent() calls during rendering without AsyncLocalStorage available.
|
||||
const rendered =
|
||||
options.async && 'then' in maybe_promise
|
||||
? /** @type {ReturnType<typeof options.root.render> & Promise<any>} */ (
|
||||
maybe_promise
|
||||
).then((r) => r)
|
||||
: maybe_promise;
|
||||
|
||||
// TODO 3.0 remove options.async
|
||||
if (options.async) {
|
||||
// we reset this synchronously, rather than after async rendering is complete,
|
||||
// to avoid cross-talk between requests. This is a breaking change for
|
||||
// anyone who opts into async SSR, since `base` and `assets` will no
|
||||
// longer be relative to the current pathname.
|
||||
// TODO 3.0 remove `base` and `assets` in favour of `resolve(...)` and `asset(...)`
|
||||
paths.reset();
|
||||
}
|
||||
|
||||
const { head, html, css, hashes } = /** @type {ReturnType<typeof options.root.render>} */ (
|
||||
options.async ? await rendered : rendered
|
||||
);
|
||||
|
||||
if (hashes) {
|
||||
csp.add_script_hashes(hashes.script);
|
||||
}
|
||||
|
||||
return { head, html, css, hashes };
|
||||
});
|
||||
} finally {
|
||||
if (DEV) {
|
||||
globalThis.fetch = fetch;
|
||||
}
|
||||
|
||||
paths.reset(); // just in case `options.root.render(...)` failed
|
||||
}
|
||||
|
||||
for (const { node } of branch) {
|
||||
for (const url of node.imports) modulepreloads.add(url);
|
||||
for (const url of node.stylesheets) stylesheets.add(url);
|
||||
for (const url of node.fonts) fonts.add(url);
|
||||
|
||||
if (node.inline_styles && !client.inline) {
|
||||
Object.entries(await node.inline_styles()).forEach(([filename, css]) => {
|
||||
if (typeof css === 'string') {
|
||||
inline_styles.set(filename, css);
|
||||
return;
|
||||
}
|
||||
|
||||
inline_styles.set(filename, css(`${assets}/${paths.app_dir}/immutable/assets`, assets));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rendered = { head: '', html: '', css: { code: '', map: null }, hashes: { script: [] } };
|
||||
}
|
||||
|
||||
const head = new Head(rendered.head, !!state.prerendering);
|
||||
let body = rendered.html;
|
||||
|
||||
/** @param {string} path */
|
||||
const prefixed = (path) => {
|
||||
if (path.startsWith('/')) {
|
||||
// Vite makes the start script available through the base path and without it.
|
||||
// We load it via the base path in order to support remote IDE environments which proxy
|
||||
// all URLs under the base path during development.
|
||||
return paths.base + path;
|
||||
}
|
||||
return `${assets}/${path}`;
|
||||
};
|
||||
|
||||
// inline styles can come from `bundleStrategy: 'inline'` or `inlineStyleThreshold`
|
||||
const style = client.inline
|
||||
? client.inline?.style
|
||||
: Array.from(inline_styles.values()).join('\n');
|
||||
|
||||
if (style) {
|
||||
const attributes = DEV ? ['data-sveltekit'] : [];
|
||||
if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`);
|
||||
csp.add_style(style);
|
||||
head.add_style(style, attributes);
|
||||
}
|
||||
|
||||
for (const dep of stylesheets) {
|
||||
const path = prefixed(dep);
|
||||
|
||||
const attributes = ['rel="stylesheet"'];
|
||||
|
||||
if (inline_styles.has(dep)) {
|
||||
// don't load stylesheets that are already inlined
|
||||
// include them in disabled state so that Vite can detect them and doesn't try to add them
|
||||
attributes.push('disabled', 'media="(max-width: 0)"');
|
||||
} else {
|
||||
if (resolve_opts.preload({ type: 'css', path })) {
|
||||
link_headers.add(`<${encodeURI(path)}>; rel="preload"; as="style"; nopush`);
|
||||
}
|
||||
}
|
||||
|
||||
head.add_stylesheet(path, attributes);
|
||||
}
|
||||
|
||||
for (const dep of fonts) {
|
||||
const path = prefixed(dep);
|
||||
|
||||
if (resolve_opts.preload({ type: 'font', path })) {
|
||||
const ext = dep.slice(dep.lastIndexOf('.') + 1);
|
||||
|
||||
head.add_link_tag(path, ['rel="preload"', 'as="font"', `type="font/${ext}"`, 'crossorigin']);
|
||||
|
||||
link_headers.add(
|
||||
`<${encodeURI(path)}>; rel="preload"; as="font"; type="font/${ext}"; crossorigin; nopush`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const global = get_global_name(options);
|
||||
const { data, chunks } = data_serializer.get_data(csp);
|
||||
|
||||
if (page_config.ssr && page_config.csr) {
|
||||
body += `\n\t\t\t${fetched
|
||||
.map((item) =>
|
||||
serialize_data(item, resolve_opts.filterSerializedResponseHeaders, !!state.prerendering)
|
||||
)
|
||||
.join('\n\t\t\t')}`;
|
||||
}
|
||||
|
||||
if (page_config.csr) {
|
||||
const route = manifest._.client.routes?.find((r) => r.id === event.route.id) ?? null;
|
||||
|
||||
if (client.uses_env_dynamic_public && state.prerendering) {
|
||||
modulepreloads.add(`${paths.app_dir}/env.js`);
|
||||
}
|
||||
|
||||
if (!client.inline) {
|
||||
const included_modulepreloads = Array.from(modulepreloads, (dep) => prefixed(dep)).filter(
|
||||
(path) => resolve_opts.preload({ type: 'js', path })
|
||||
);
|
||||
|
||||
for (const path of included_modulepreloads) {
|
||||
// see the kit.output.preloadStrategy option for details on why we have multiple options here
|
||||
link_headers.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`);
|
||||
|
||||
if (options.preload_strategy !== 'modulepreload') {
|
||||
head.add_script_preload(path);
|
||||
} else {
|
||||
head.add_link_tag(path, ['rel="modulepreload"']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prerender a `/path/to/page/__route.js` module
|
||||
if (manifest._.client.routes && state.prerendering && !state.prerendering.fallback) {
|
||||
const pathname = add_resolution_suffix(event.url.pathname);
|
||||
|
||||
state.prerendering.dependencies.set(
|
||||
pathname,
|
||||
create_server_routing_response(route, event.params, new URL(pathname, event.url), manifest)
|
||||
);
|
||||
}
|
||||
|
||||
const blocks = [];
|
||||
|
||||
// when serving a prerendered page in an app that uses $env/dynamic/public, we must
|
||||
// import the env.js module so that it evaluates before any user code can evaluate.
|
||||
// TODO revert to using top-level await once https://bugs.webkit.org/show_bug.cgi?id=242740 is fixed
|
||||
// https://github.com/sveltejs/kit/pull/11601
|
||||
const load_env_eagerly = client.uses_env_dynamic_public && state.prerendering;
|
||||
|
||||
const properties = [`base: ${base_expression}`];
|
||||
|
||||
if (paths.assets) {
|
||||
properties.push(`assets: ${s(paths.assets)}`);
|
||||
}
|
||||
|
||||
if (client.uses_env_dynamic_public) {
|
||||
properties.push(`env: ${load_env_eagerly ? 'null' : s(public_env)}`);
|
||||
}
|
||||
|
||||
if (chunks) {
|
||||
blocks.push('const deferred = new Map();');
|
||||
|
||||
properties.push(`defer: (id) => new Promise((fulfil, reject) => {
|
||||
deferred.set(id, { fulfil, reject });
|
||||
})`);
|
||||
|
||||
let app_declaration = '';
|
||||
|
||||
if (Object.keys(options.hooks.transport).length > 0) {
|
||||
if (client.inline) {
|
||||
app_declaration = `const app = __sveltekit_${options.version_hash}.app.app;`;
|
||||
} else if (client.app) {
|
||||
app_declaration = `const app = await import(${s(prefixed(client.app))});`;
|
||||
} else {
|
||||
app_declaration = `const { app } = await import(${s(prefixed(client.start))});`;
|
||||
}
|
||||
}
|
||||
|
||||
const prelude = app_declaration
|
||||
? `${app_declaration}
|
||||
const [data, error] = fn(app);`
|
||||
: `const [data, error] = fn();`;
|
||||
|
||||
// When resolving, the id might not yet be available due to the data
|
||||
// be evaluated upon init of kit, so we use a timeout to retry
|
||||
properties.push(`resolve: async (id, fn) => {
|
||||
${prelude}
|
||||
|
||||
const try_to_resolve = () => {
|
||||
if (!deferred.has(id)) {
|
||||
setTimeout(try_to_resolve, 0);
|
||||
return;
|
||||
}
|
||||
const { fulfil, reject } = deferred.get(id);
|
||||
deferred.delete(id);
|
||||
if (error) reject(error);
|
||||
else fulfil(data);
|
||||
}
|
||||
try_to_resolve();
|
||||
}`);
|
||||
}
|
||||
|
||||
// create this before declaring `data`, which may contain references to `${global}`
|
||||
blocks.push(`${global} = {
|
||||
${properties.join(',\n\t\t\t\t\t\t')}
|
||||
};`);
|
||||
|
||||
const args = ['element'];
|
||||
|
||||
blocks.push('const element = document.currentScript.parentElement;');
|
||||
|
||||
if (page_config.ssr) {
|
||||
const serialized = { form: 'null', error: 'null' };
|
||||
|
||||
if (form_value) {
|
||||
serialized.form = uneval_action_response(
|
||||
form_value,
|
||||
/** @type {string} */ (event.route.id),
|
||||
options.hooks.transport
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
serialized.error = devalue.uneval(error);
|
||||
}
|
||||
|
||||
const hydrate = [
|
||||
`node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`,
|
||||
`data: ${data}`,
|
||||
`form: ${serialized.form}`,
|
||||
`error: ${serialized.error}`
|
||||
];
|
||||
|
||||
if (status !== 200) {
|
||||
hydrate.push(`status: ${status}`);
|
||||
}
|
||||
|
||||
if (manifest._.client.routes) {
|
||||
if (route) {
|
||||
const stringified = generate_route_object(route, event.url, manifest).replaceAll(
|
||||
'\n',
|
||||
'\n\t\t\t\t\t\t\t'
|
||||
); // make output after it's put together with the rest more readable
|
||||
hydrate.push(`params: ${devalue.uneval(event.params)}`, `server_route: ${stringified}`);
|
||||
}
|
||||
} else if (options.embedded) {
|
||||
hydrate.push(`params: ${devalue.uneval(event.params)}`, `route: ${s(event.route)}`);
|
||||
}
|
||||
|
||||
const indent = '\t'.repeat(load_env_eagerly ? 7 : 6);
|
||||
args.push(`{\n${indent}\t${hydrate.join(`,\n${indent}\t`)}\n${indent}}`);
|
||||
}
|
||||
|
||||
const { remote_data: remote_cache } = event_state;
|
||||
|
||||
let serialized_remote_data = '';
|
||||
|
||||
if (remote_cache) {
|
||||
/** @type {Record<string, any>} */
|
||||
const remote = {};
|
||||
|
||||
for (const [info, cache] of remote_cache) {
|
||||
// remote functions without an `id` aren't exported, and thus
|
||||
// cannot be called from the client
|
||||
if (!info.id) continue;
|
||||
|
||||
for (const key in cache) {
|
||||
remote[create_remote_key(info.id, key)] = await cache[key];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO this is repeated in a few places — dedupe it
|
||||
const replacer = (/** @type {any} */ thing) => {
|
||||
for (const key in options.hooks.transport) {
|
||||
const encoded = options.hooks.transport[key].encode(thing);
|
||||
if (encoded) {
|
||||
return `app.decode('${key}', ${devalue.uneval(encoded, replacer)})`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
serialized_remote_data = `${global}.data = ${devalue.uneval(remote, replacer)};\n\n\t\t\t\t\t\t`;
|
||||
}
|
||||
|
||||
// `client.app` is a proxy for `bundleStrategy === 'split'`
|
||||
const boot = client.inline
|
||||
? `${client.inline.script}
|
||||
|
||||
${serialized_remote_data}${global}.app.start(${args.join(', ')});`
|
||||
: client.app
|
||||
? `Promise.all([
|
||||
import(${s(prefixed(client.start))}),
|
||||
import(${s(prefixed(client.app))})
|
||||
]).then(([kit, app]) => {
|
||||
${serialized_remote_data}kit.start(app, ${args.join(', ')});
|
||||
});`
|
||||
: `import(${s(prefixed(client.start))}).then((app) => {
|
||||
${serialized_remote_data}app.start(${args.join(', ')})
|
||||
});`;
|
||||
|
||||
if (load_env_eagerly) {
|
||||
blocks.push(`import(${s(`${base}/${paths.app_dir}/env.js`)}).then(({ env }) => {
|
||||
${global}.env = env;
|
||||
|
||||
${boot.replace(/\n/g, '\n\t')}
|
||||
});`);
|
||||
} else {
|
||||
blocks.push(boot);
|
||||
}
|
||||
|
||||
if (options.service_worker) {
|
||||
let opts = DEV ? ", { type: 'module' }" : '';
|
||||
if (options.service_worker_options != null) {
|
||||
const service_worker_options = { ...options.service_worker_options };
|
||||
if (DEV) {
|
||||
service_worker_options.type = 'module';
|
||||
}
|
||||
opts = `, ${s(service_worker_options)}`;
|
||||
}
|
||||
|
||||
// we use an anonymous function instead of an arrow function to support
|
||||
// older browsers (https://github.com/sveltejs/kit/pull/5417)
|
||||
blocks.push(`if ('serviceWorker' in navigator) {
|
||||
addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts});
|
||||
});
|
||||
}`);
|
||||
}
|
||||
|
||||
const init_app = `
|
||||
{
|
||||
${blocks.join('\n\n\t\t\t\t\t')}
|
||||
}
|
||||
`;
|
||||
csp.add_script(init_app);
|
||||
|
||||
body += `\n\t\t\t<script${
|
||||
csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''
|
||||
}>${init_app}</script>\n\t\t`;
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
'x-sveltekit-page': 'true',
|
||||
'content-type': 'text/html'
|
||||
});
|
||||
|
||||
if (state.prerendering) {
|
||||
// TODO read headers set with setHeaders and convert into http-equiv where possible
|
||||
const csp_headers = csp.csp_provider.get_meta();
|
||||
if (csp_headers) {
|
||||
head.add_http_equiv(csp_headers);
|
||||
}
|
||||
|
||||
if (state.prerendering.cache) {
|
||||
head.add_http_equiv(
|
||||
`<meta http-equiv="cache-control" content="${state.prerendering.cache}">`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const csp_header = csp.csp_provider.get_header();
|
||||
if (csp_header) {
|
||||
headers.set('content-security-policy', csp_header);
|
||||
}
|
||||
const report_only_header = csp.report_only_provider.get_header();
|
||||
if (report_only_header) {
|
||||
headers.set('content-security-policy-report-only', report_only_header);
|
||||
}
|
||||
|
||||
if (link_headers.size) {
|
||||
headers.set('link', Array.from(link_headers).join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
const html = options.templates.app({
|
||||
head: head.build(),
|
||||
body,
|
||||
assets,
|
||||
nonce: /** @type {string} */ (csp.nonce),
|
||||
env: public_env
|
||||
});
|
||||
|
||||
// TODO flush chunks as early as we can
|
||||
const transformed =
|
||||
(await resolve_opts.transformPageChunk({
|
||||
html,
|
||||
done: true
|
||||
})) || '';
|
||||
|
||||
if (!chunks) {
|
||||
headers.set('etag', `"${hash(transformed)}"`);
|
||||
}
|
||||
|
||||
if (DEV) {
|
||||
if (page_config.csr) {
|
||||
if (transformed.split('<!--').length < html.split('<!--').length) {
|
||||
// the \u001B stuff is ANSI codes, so that we don't need to add a library to the runtime
|
||||
// https://svelte.dev/playground/1b3f49696f0c44c881c34587f2537aa2?version=4.2.19
|
||||
console.warn(
|
||||
"\u001B[1m\u001B[31mRemoving comments in transformPageChunk can break Svelte's hydration\u001B[39m\u001B[22m"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (chunks) {
|
||||
console.warn(
|
||||
'\u001B[1m\u001B[31mReturning promises from server `load` functions will only work if `csr === true`\u001B[39m\u001B[22m'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !chunks
|
||||
? text(transformed, {
|
||||
status,
|
||||
headers
|
||||
})
|
||||
: new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(text_encoder.encode(transformed + '\n'));
|
||||
for await (const chunk of chunks) {
|
||||
if (chunk.length) controller.enqueue(text_encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
|
||||
type: 'bytes'
|
||||
}),
|
||||
{
|
||||
headers
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class Head {
|
||||
#rendered;
|
||||
#prerendering;
|
||||
/** @type {string[]} */
|
||||
#http_equiv = [];
|
||||
/** @type {string[]} */
|
||||
#link_tags = [];
|
||||
/** @type {string[]} */
|
||||
#script_preloads = [];
|
||||
/** @type {string[]} */
|
||||
#style_tags = [];
|
||||
/** @type {string[]} */
|
||||
#stylesheet_links = [];
|
||||
|
||||
/**
|
||||
* @param {string} rendered
|
||||
* @param {boolean} prerendering
|
||||
*/
|
||||
constructor(rendered, prerendering) {
|
||||
this.#rendered = rendered;
|
||||
this.#prerendering = prerendering;
|
||||
}
|
||||
|
||||
build() {
|
||||
return [
|
||||
...this.#http_equiv,
|
||||
...this.#link_tags,
|
||||
...this.#script_preloads,
|
||||
this.#rendered,
|
||||
...this.#style_tags,
|
||||
...this.#stylesheet_links
|
||||
].join('\n\t\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} style
|
||||
* @param {string[]} attributes
|
||||
*/
|
||||
add_style(style, attributes) {
|
||||
this.#style_tags.push(
|
||||
`<style${attributes.length ? ' ' + attributes.join(' ') : ''}>${style}</style>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} href
|
||||
* @param {string[]} attributes
|
||||
*/
|
||||
add_stylesheet(href, attributes) {
|
||||
this.#stylesheet_links.push(`<link href="${href}" ${attributes.join(' ')}>`);
|
||||
}
|
||||
|
||||
/** @param {string} href */
|
||||
add_script_preload(href) {
|
||||
this.#script_preloads.push(
|
||||
`<link rel="preload" as="script" crossorigin="anonymous" href="${href}">`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} href
|
||||
* @param {string[]} attributes
|
||||
*/
|
||||
add_link_tag(href, attributes) {
|
||||
if (!this.#prerendering) return;
|
||||
this.#link_tags.push(`<link href="${href}" ${attributes.join(' ')}>`);
|
||||
}
|
||||
|
||||
/** @param {string} tag */
|
||||
add_http_equiv(tag) {
|
||||
if (!this.#prerendering) return;
|
||||
this.#http_equiv.push(tag);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { Redirect } from '@sveltejs/kit/internal';
|
||||
import { render_response } from './render.js';
|
||||
import { load_data, load_server_data } from './load_data.js';
|
||||
import { handle_error_and_jsonify, static_error_page, redirect_response } from '../utils.js';
|
||||
import { get_status } from '../../../utils/error.js';
|
||||
import { PageNodes } from '../../../utils/page_nodes.js';
|
||||
import { server_data_serializer } from './data_serializer.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').Loaded} Loaded
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* event: import('@sveltejs/kit').RequestEvent;
|
||||
* event_state: import('types').RequestState;
|
||||
* options: import('types').SSROptions;
|
||||
* manifest: import('@sveltejs/kit').SSRManifest;
|
||||
* state: import('types').SSRState;
|
||||
* status: number;
|
||||
* error: unknown;
|
||||
* resolve_opts: import('types').RequiredResolveOptions;
|
||||
* }} opts
|
||||
*/
|
||||
export async function respond_with_error({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
status,
|
||||
error,
|
||||
resolve_opts
|
||||
}) {
|
||||
// reroute to the fallback page to prevent an infinite chain of requests.
|
||||
if (event.request.headers.get('x-sveltekit-error')) {
|
||||
return static_error_page(options, status, /** @type {Error} */ (error).message);
|
||||
}
|
||||
|
||||
/** @type {import('./types.js').Fetched[]} */
|
||||
const fetched = [];
|
||||
|
||||
try {
|
||||
const branch = [];
|
||||
const default_layout = await manifest._.nodes[0](); // 0 is always the root layout
|
||||
const nodes = new PageNodes([default_layout]);
|
||||
const ssr = nodes.ssr();
|
||||
const csr = nodes.csr();
|
||||
const data_serializer = server_data_serializer(event, event_state, options);
|
||||
|
||||
if (ssr) {
|
||||
state.error = true;
|
||||
|
||||
const server_data_promise = load_server_data({
|
||||
event,
|
||||
event_state,
|
||||
state,
|
||||
node: default_layout,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
parent: async () => ({})
|
||||
});
|
||||
|
||||
const server_data = await server_data_promise;
|
||||
data_serializer.add_node(0, server_data);
|
||||
|
||||
const data = await load_data({
|
||||
event,
|
||||
event_state,
|
||||
fetched,
|
||||
node: default_layout,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
parent: async () => ({}),
|
||||
resolve_opts,
|
||||
server_data_promise,
|
||||
state,
|
||||
csr
|
||||
});
|
||||
|
||||
branch.push(
|
||||
{
|
||||
node: default_layout,
|
||||
server_data,
|
||||
data
|
||||
},
|
||||
{
|
||||
node: await manifest._.nodes[1](), // 1 is always the root error
|
||||
data: null,
|
||||
server_data: null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return await render_response({
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
page_config: {
|
||||
ssr,
|
||||
csr
|
||||
},
|
||||
status,
|
||||
error: await handle_error_and_jsonify(event, event_state, options, error),
|
||||
branch,
|
||||
fetched,
|
||||
event,
|
||||
event_state,
|
||||
resolve_opts,
|
||||
data_serializer
|
||||
});
|
||||
} catch (e) {
|
||||
// Edge case: If route is a 404 and the user redirects to somewhere from the root layout,
|
||||
// we end up here.
|
||||
if (e instanceof Redirect) {
|
||||
return redirect_response(e.status, e.location);
|
||||
}
|
||||
|
||||
return static_error_page(
|
||||
options,
|
||||
get_status(e),
|
||||
(await handle_error_and_jsonify(event, event_state, options, e)).message
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { escape_html } from '../../../utils/escape.js';
|
||||
import { hash } from '../../../utils/hash.js';
|
||||
|
||||
/**
|
||||
* Inside a script element, only `</script` and `<!--` hold special meaning to the HTML parser.
|
||||
*
|
||||
* The first closes the script element, so everything after is treated as raw HTML.
|
||||
* The second disables further parsing until `-->`, so the script element might be unexpectedly
|
||||
* kept open until until an unrelated HTML comment in the page.
|
||||
*
|
||||
* U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are escaped for the sake of pre-2018
|
||||
* browsers.
|
||||
*
|
||||
* @see tests for unsafe parsing examples.
|
||||
* @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
|
||||
* @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
|
||||
* @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-state
|
||||
* @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state
|
||||
* @see https://github.com/tc39/proposal-json-superset
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
const replacements = {
|
||||
'<': '\\u003C',
|
||||
'\u2028': '\\u2028',
|
||||
'\u2029': '\\u2029'
|
||||
};
|
||||
|
||||
const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
|
||||
|
||||
/**
|
||||
* Generates a raw HTML string containing a safe script element carrying data and associated attributes.
|
||||
*
|
||||
* It escapes all the special characters needed to guarantee the element is unbroken, but care must
|
||||
* be taken to ensure it is inserted in the document at an acceptable position for a script element,
|
||||
* and that the resulting string isn't further modified.
|
||||
*
|
||||
* @param {import('./types.js').Fetched} fetched
|
||||
* @param {(name: string, value: string) => boolean} filter
|
||||
* @param {boolean} [prerendering]
|
||||
* @returns {string} The raw HTML of a script element carrying the JSON payload.
|
||||
* @example const html = serialize_data('/data.json', null, { foo: 'bar' });
|
||||
*/
|
||||
export function serialize_data(fetched, filter, prerendering = false) {
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {};
|
||||
|
||||
let cache_control = null;
|
||||
let age = null;
|
||||
let varyAny = false;
|
||||
|
||||
for (const [key, value] of fetched.response.headers) {
|
||||
if (filter(key, value)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
if (key === 'cache-control') cache_control = value;
|
||||
else if (key === 'age') age = value;
|
||||
else if (key === 'vary' && value.trim() === '*') varyAny = true;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
status: fetched.response.status,
|
||||
statusText: fetched.response.statusText,
|
||||
headers,
|
||||
body: fetched.response_body
|
||||
};
|
||||
|
||||
const safe_payload = JSON.stringify(payload).replace(pattern, (match) => replacements[match]);
|
||||
|
||||
const attrs = [
|
||||
'type="application/json"',
|
||||
'data-sveltekit-fetched',
|
||||
`data-url="${escape_html(fetched.url, true)}"`
|
||||
];
|
||||
|
||||
if (fetched.is_b64) {
|
||||
attrs.push('data-b64');
|
||||
}
|
||||
|
||||
if (fetched.request_headers || fetched.request_body) {
|
||||
/** @type {import('types').StrictBody[]} */
|
||||
const values = [];
|
||||
|
||||
if (fetched.request_headers) {
|
||||
values.push([...new Headers(fetched.request_headers)].join(','));
|
||||
}
|
||||
|
||||
if (fetched.request_body) {
|
||||
values.push(fetched.request_body);
|
||||
}
|
||||
|
||||
attrs.push(`data-hash="${hash(...values)}"`);
|
||||
}
|
||||
|
||||
// Compute the time the response should be cached, taking into account max-age and age.
|
||||
// Do not cache at all if a `Vary: *` header is present, as this indicates that the
|
||||
// cache is likely to get busted.
|
||||
if (!prerendering && fetched.method === 'GET' && cache_control && !varyAny) {
|
||||
const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
|
||||
if (match) {
|
||||
const ttl = +match[1] - +(age ?? '0');
|
||||
attrs.push(`data-ttl="${ttl}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return `<script ${attrs.join(' ')}>${safe_payload}</script>`;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { base, assets, relative } from '$app/paths/internal/server';
|
||||
import { text } from '@sveltejs/kit';
|
||||
import { s } from '../../../utils/misc.js';
|
||||
import { find_route } from '../../../utils/routing.js';
|
||||
import { get_relative_path } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* @param {import('types').SSRClientRoute} route
|
||||
* @param {URL} url
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generate_route_object(route, url, manifest) {
|
||||
const { errors, layouts, leaf } = route;
|
||||
|
||||
const nodes = [...errors, ...layouts.map((l) => l?.[1]), leaf[1]]
|
||||
.filter((n) => typeof n === 'number')
|
||||
.map((n) => `'${n}': () => ${create_client_import(manifest._.client.nodes?.[n], url)}`)
|
||||
.join(',\n\t\t');
|
||||
|
||||
// stringified version of
|
||||
/** @type {import('types').CSRRouteServer} */
|
||||
return [
|
||||
`{\n\tid: ${s(route.id)}`,
|
||||
`errors: ${s(route.errors)}`,
|
||||
`layouts: ${s(route.layouts)}`,
|
||||
`leaf: ${s(route.leaf)}`,
|
||||
`nodes: {\n\t\t${nodes}\n\t}\n}`
|
||||
].join(',\n\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} import_path
|
||||
* @param {URL} url
|
||||
*/
|
||||
function create_client_import(import_path, url) {
|
||||
if (!import_path) return 'Promise.resolve({})';
|
||||
|
||||
// During DEV, Vite will make the paths absolute (e.g. /@fs/...)
|
||||
if (import_path[0] === '/') {
|
||||
return `import('${import_path}')`;
|
||||
}
|
||||
|
||||
// During PROD, they're root-relative
|
||||
if (assets !== '') {
|
||||
return `import('${assets}/${import_path}')`;
|
||||
}
|
||||
|
||||
if (!relative) {
|
||||
return `import('${base}/${import_path}')`;
|
||||
}
|
||||
|
||||
// Else we make them relative to the server-side route resolution request
|
||||
// to support IPFS, the internet archive, etc.
|
||||
let path = get_relative_path(url.pathname, `${base}/${import_path}`);
|
||||
if (path[0] !== '.') path = `./${path}`;
|
||||
return `import('${path}')`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} resolved_path
|
||||
* @param {URL} url
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function resolve_route(resolved_path, url, manifest) {
|
||||
if (!manifest._.client.routes) {
|
||||
return text('Server-side route resolution disabled', { status: 400 });
|
||||
}
|
||||
|
||||
const matchers = await manifest._.matchers();
|
||||
const result = find_route(resolved_path, manifest._.client.routes, matchers);
|
||||
|
||||
return create_server_routing_response(result?.route ?? null, result?.params ?? {}, url, manifest)
|
||||
.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').SSRClientRoute | null} route
|
||||
* @param {Partial<Record<string, string>>} params
|
||||
* @param {URL} url
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @returns {{response: Response, body: string}}
|
||||
*/
|
||||
export function create_server_routing_response(route, params, url, manifest) {
|
||||
const headers = new Headers({
|
||||
'content-type': 'application/javascript; charset=utf-8'
|
||||
});
|
||||
|
||||
if (route) {
|
||||
const csr_route = generate_route_object(route, url, manifest);
|
||||
const body = `${create_css_import(route, url, manifest)}\nexport const route = ${csr_route}; export const params = ${JSON.stringify(params)};`;
|
||||
|
||||
return { response: text(body, { headers }), body };
|
||||
} else {
|
||||
return { response: text('', { headers }), body: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function generates the client-side import for the CSS files that are
|
||||
* associated with the current route. Vite takes care of that when using
|
||||
* client-side route resolution, but for server-side resolution it does
|
||||
* not know about the CSS files automatically.
|
||||
*
|
||||
* @param {import('types').SSRClientRoute} route
|
||||
* @param {URL} url
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @returns {string}
|
||||
*/
|
||||
function create_css_import(route, url, manifest) {
|
||||
const { errors, layouts, leaf } = route;
|
||||
|
||||
let css = '';
|
||||
|
||||
for (const node of [...errors, ...layouts.map((l) => l?.[1]), leaf[1]]) {
|
||||
if (typeof node !== 'number') continue;
|
||||
const node_css = manifest._.client.css?.[node];
|
||||
for (const css_path of node_css ?? []) {
|
||||
css += `'${assets || base}/${css_path}',`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!css) return '';
|
||||
|
||||
return `${create_client_import(/** @type {string} */ (manifest._.client.start), url)}.then(x => x.load_css([${css}]));`;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { CookieSerializeOptions } from 'cookie';
|
||||
import {
|
||||
CspDirectives,
|
||||
ServerDataNode,
|
||||
SSRNode,
|
||||
ServerDataSkippedNode,
|
||||
ServerErrorNode
|
||||
} from 'types';
|
||||
import { Csp } from './csp.js';
|
||||
|
||||
export interface Fetched {
|
||||
url: string;
|
||||
method: string;
|
||||
request_body?: string | ArrayBufferView | null;
|
||||
request_headers?: HeadersInit | undefined;
|
||||
response_body: string | undefined;
|
||||
response: Response;
|
||||
is_b64?: boolean;
|
||||
}
|
||||
|
||||
export type Loaded = {
|
||||
node: SSRNode;
|
||||
data: Record<string, any> | null;
|
||||
server_data: ServerDataNode | null;
|
||||
};
|
||||
|
||||
type CspMode = 'hash' | 'nonce' | 'auto';
|
||||
|
||||
export interface CspConfig {
|
||||
mode: CspMode;
|
||||
directives: CspDirectives;
|
||||
reportOnly: CspDirectives;
|
||||
}
|
||||
|
||||
export interface CspOpts {
|
||||
prerender: boolean;
|
||||
}
|
||||
|
||||
export interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
options: CookieSerializeOptions & { path: string };
|
||||
}
|
||||
|
||||
export type ServerDataSerializer = {
|
||||
add_node(i: number, node: ServerDataNode | null): void;
|
||||
get_data(csp: Csp): { data: string; chunks: AsyncIterable<string> | null };
|
||||
set_max_nodes(i: number): void;
|
||||
};
|
||||
|
||||
export type ServerDataSerializerJson = {
|
||||
add_node(
|
||||
i: number,
|
||||
node: ServerDataSkippedNode | ServerDataNode | ServerErrorNode | null | undefined
|
||||
): void;
|
||||
get_data(): { data: string; chunks: AsyncIterable<string> | null };
|
||||
};
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */
|
||||
/** @import { RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal';
|
||||
import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server';
|
||||
import { app_dir, base } from '$app/paths/internal/server';
|
||||
import { is_form_content_type } from '../../utils/http.js';
|
||||
import { parse_remote_arg, stringify } from '../shared.js';
|
||||
import { handle_error_and_jsonify } from './utils.js';
|
||||
import { normalize_error } from '../../utils/error.js';
|
||||
import { check_incorrect_fail_use } from './page/actions.js';
|
||||
import { DEV } from 'esm-env';
|
||||
import { record_span } from '../telemetry/record_span.js';
|
||||
import { deserialize_binary_form } from '../form-utils.js';
|
||||
|
||||
/** @type {typeof handle_remote_call_internal} */
|
||||
export async function handle_remote_call(event, state, options, manifest, id) {
|
||||
return record_span({
|
||||
name: 'sveltekit.remote.call',
|
||||
attributes: {
|
||||
'sveltekit.remote.call.id': id
|
||||
},
|
||||
fn: (current) => {
|
||||
const traced_event = merge_tracing(event, current);
|
||||
return with_request_store({ event: traced_event, state }, () =>
|
||||
handle_remote_call_internal(traced_event, state, options, manifest, id)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
* @param {RequestState} state
|
||||
* @param {SSROptions} options
|
||||
* @param {SSRManifest} manifest
|
||||
* @param {string} id
|
||||
*/
|
||||
async function handle_remote_call_internal(event, state, options, manifest, id) {
|
||||
const [hash, name, additional_args] = id.split('/');
|
||||
const remotes = manifest._.remotes;
|
||||
|
||||
if (!remotes[hash]) error(404);
|
||||
|
||||
const module = await remotes[hash]();
|
||||
const fn = module.default[name];
|
||||
|
||||
if (!fn) error(404);
|
||||
|
||||
/** @type {RemoteInfo} */
|
||||
const info = fn.__;
|
||||
const transport = options.hooks.transport;
|
||||
|
||||
event.tracing.current.setAttributes({
|
||||
'sveltekit.remote.call.type': info.type,
|
||||
'sveltekit.remote.call.name': info.name
|
||||
});
|
||||
|
||||
/** @type {string[] | undefined} */
|
||||
let form_client_refreshes;
|
||||
|
||||
try {
|
||||
if (info.type === '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, transport))
|
||||
);
|
||||
|
||||
const results = await with_request_store({ event, state }, () => info.run(args, options));
|
||||
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'result',
|
||||
result: stringify(results, transport)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === '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, meta, form_data } = await deserialize_binary_form(event.request);
|
||||
|
||||
// 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 data)) {
|
||||
data.id = JSON.parse(decodeURIComponent(additional_args));
|
||||
}
|
||||
|
||||
const fn = info.fn;
|
||||
const result = await with_request_store({ event, state }, () => fn(data, meta, form_data));
|
||||
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'result',
|
||||
result: stringify(result, transport),
|
||||
refreshes: result.issues ? undefined : await serialize_refreshes(meta.remote_refreshes)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === 'command') {
|
||||
/** @type {{ payload: string, refreshes: string[] }} */
|
||||
const { payload, refreshes } = await event.request.json();
|
||||
const arg = parse_remote_arg(payload, transport);
|
||||
const data = await with_request_store({ event, state }, () => fn(arg));
|
||||
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'result',
|
||||
result: stringify(data, transport),
|
||||
refreshes: await serialize_refreshes(refreshes)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const payload =
|
||||
info.type === 'prerender'
|
||||
? additional_args
|
||||
: /** @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')
|
||||
);
|
||||
|
||||
const data = await with_request_store({ event, state }, () =>
|
||||
fn(parse_remote_arg(payload, transport))
|
||||
);
|
||||
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'result',
|
||||
result: stringify(data, transport)
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Redirect) {
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'redirect',
|
||||
location: error.location,
|
||||
refreshes: await serialize_refreshes(form_client_refreshes)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const status =
|
||||
error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500;
|
||||
|
||||
return json(
|
||||
/** @type {RemoteFunctionResponse} */ ({
|
||||
type: 'error',
|
||||
error: await handle_error_and_jsonify(event, state, options, error),
|
||||
status
|
||||
}),
|
||||
{
|
||||
// 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 ? status : undefined,
|
||||
headers: {
|
||||
'cache-control': 'private, no-store'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]=} client_refreshes
|
||||
*/
|
||||
async function serialize_refreshes(client_refreshes) {
|
||||
const refreshes = state.refreshes ?? {};
|
||||
|
||||
if (client_refreshes) {
|
||||
for (const key of client_refreshes) {
|
||||
if (refreshes[key] !== undefined) continue;
|
||||
|
||||
const [hash, name, payload] = key.split('/');
|
||||
|
||||
const loader = manifest._.remotes[hash];
|
||||
const fn = (await loader?.())?.default?.[name];
|
||||
|
||||
if (!fn) error(400, 'Bad Request');
|
||||
|
||||
refreshes[key] = with_request_store({ event, state }, () =>
|
||||
fn(parse_remote_arg(payload, transport))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(refreshes).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stringify(
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(refreshes).map(async ([key, promise]) => [key, await promise])
|
||||
)
|
||||
),
|
||||
transport
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {typeof handle_remote_form_post_internal} */
|
||||
export async function handle_remote_form_post(event, state, manifest, 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, manifest, id)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestEvent} event
|
||||
* @param {RequestState} state
|
||||
* @param {SSRManifest} manifest
|
||||
* @param {string} id
|
||||
* @returns {Promise<ActionResult>}
|
||||
*/
|
||||
async function handle_remote_form_post_internal(event, state, manifest, id) {
|
||||
const [hash, name, action_id] = id.split('/');
|
||||
const remotes = manifest._.remotes;
|
||||
const module = await remotes[hash]?.();
|
||||
|
||||
let form = /** @type {RemoteForm<any, any>} */ (module?.default[name]);
|
||||
|
||||
if (!form) {
|
||||
event.setHeaders({
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
|
||||
// "The server must generate an Allow header field in a 405 status code response"
|
||||
allow: 'GET'
|
||||
});
|
||||
return {
|
||||
type: 'error',
|
||||
error: new SvelteKitError(
|
||||
405,
|
||||
'Method Not Allowed',
|
||||
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if (action_id) {
|
||||
// @ts-expect-error
|
||||
form = with_request_store({ event, state }, () => form.for(JSON.parse(action_id)));
|
||||
}
|
||||
|
||||
try {
|
||||
const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn;
|
||||
|
||||
const { data, meta, form_data } = await deserialize_binary_form(event.request);
|
||||
|
||||
if (action_id && !('id' in data)) {
|
||||
data.id = JSON.parse(decodeURIComponent(action_id));
|
||||
}
|
||||
|
||||
await with_request_store({ event, state }, () => 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
|
||||
};
|
||||
} catch (e) {
|
||||
const err = normalize_error(e);
|
||||
|
||||
if (err instanceof Redirect) {
|
||||
return {
|
||||
type: 'redirect',
|
||||
status: err.status,
|
||||
location: err.location
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
error: check_incorrect_fail_use(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {URL} url
|
||||
*/
|
||||
export function get_remote_id(url) {
|
||||
return (
|
||||
url.pathname.startsWith(`${base}/${app_dir}/remote/`) &&
|
||||
url.pathname.replace(`${base}/${app_dir}/remote/`, '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {URL} url
|
||||
*/
|
||||
export function get_remote_action(url) {
|
||||
return url.searchParams.get('/remote');
|
||||
}
|
||||
+750
@@ -0,0 +1,750 @@
|
||||
/** @import { RequestState } from 'types' */
|
||||
import { DEV } from 'esm-env';
|
||||
import { json, text } from '@sveltejs/kit';
|
||||
import { Redirect, SvelteKitError } from '@sveltejs/kit/internal';
|
||||
import { merge_tracing, with_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { base, app_dir } from '$app/paths/internal/server';
|
||||
import { is_endpoint_request, render_endpoint } from './endpoint.js';
|
||||
import { render_page } from './page/index.js';
|
||||
import { render_response } from './page/render.js';
|
||||
import { respond_with_error } from './page/respond_with_error.js';
|
||||
import { is_form_content_type } from '../../utils/http.js';
|
||||
import {
|
||||
handle_fatal_error,
|
||||
has_prerendered_path,
|
||||
method_not_allowed,
|
||||
redirect_response
|
||||
} from './utils.js';
|
||||
import { decode_pathname, disable_search, normalize_path } from '../../utils/url.js';
|
||||
import { find_route } from '../../utils/routing.js';
|
||||
import { redirect_json_response, render_data } from './data/index.js';
|
||||
import { add_cookies_to_headers, get_cookies } from './cookie.js';
|
||||
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 { get_public_env } from './env_module.js';
|
||||
import { resolve_route } from './page/server_routing.js';
|
||||
import { validateHeaders } from './validate-headers.js';
|
||||
import {
|
||||
add_data_suffix,
|
||||
add_resolution_suffix,
|
||||
has_data_suffix,
|
||||
has_resolution_suffix,
|
||||
strip_data_suffix,
|
||||
strip_resolution_suffix
|
||||
} from '../pathname.js';
|
||||
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';
|
||||
|
||||
/* global __SVELTEKIT_ADAPTER_NAME__ */
|
||||
|
||||
/** @type {import('types').RequiredResolveOptions['transformPageChunk']} */
|
||||
const default_transform = ({ html }) => html;
|
||||
|
||||
/** @type {import('types').RequiredResolveOptions['filterSerializedResponseHeaders']} */
|
||||
const default_filter = () => false;
|
||||
|
||||
/** @type {import('types').RequiredResolveOptions['preload']} */
|
||||
const default_preload = ({ type }) => type === 'js' || type === 'css';
|
||||
|
||||
const page_methods = new Set(['GET', 'HEAD', 'POST']);
|
||||
|
||||
const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
let warned_on_devtools_json_request = false;
|
||||
|
||||
export const respond = propagate_context(internal_respond);
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @param {import('types').SSRState} state
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function internal_respond(request, options, manifest, state) {
|
||||
/** URL but stripped from the potential `/__data.json` suffix and its search param */
|
||||
const url = new URL(request.url);
|
||||
|
||||
const is_route_resolution_request = has_resolution_suffix(url.pathname);
|
||||
const is_data_request = has_data_suffix(url.pathname);
|
||||
const remote_id = get_remote_id(url);
|
||||
|
||||
if (!DEV) {
|
||||
const request_origin = request.headers.get('origin');
|
||||
|
||||
if (remote_id) {
|
||||
if (request.method !== 'GET' && request_origin !== url.origin) {
|
||||
const message = 'Cross-site remote requests are forbidden';
|
||||
return json({ message }, { status: 403 });
|
||||
}
|
||||
} else if (options.csrf_check_origin) {
|
||||
const forbidden =
|
||||
is_form_content_type(request) &&
|
||||
(request.method === 'POST' ||
|
||||
request.method === 'PUT' ||
|
||||
request.method === 'PATCH' ||
|
||||
request.method === 'DELETE') &&
|
||||
request_origin !== url.origin &&
|
||||
(!request_origin || !options.csrf_trusted_origins.includes(request_origin));
|
||||
|
||||
if (forbidden) {
|
||||
const message = `Cross-site ${request.method} form submissions are forbidden`;
|
||||
const opts = { status: 403 };
|
||||
|
||||
if (request.headers.get('accept') === 'application/json') {
|
||||
return json({ message }, opts);
|
||||
}
|
||||
|
||||
return text(message, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.hash_routing && url.pathname !== base + '/' && url.pathname !== '/[fallback]') {
|
||||
return text('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
/** @type {boolean[] | undefined} */
|
||||
let invalidated_data_nodes;
|
||||
|
||||
if (is_route_resolution_request) {
|
||||
/**
|
||||
* If the request is for a route resolution, first modify the URL, then continue as normal
|
||||
* for path resolution, then return the route object as a JS file.
|
||||
*/
|
||||
url.pathname = strip_resolution_suffix(url.pathname);
|
||||
} else if (is_data_request) {
|
||||
url.pathname =
|
||||
strip_data_suffix(url.pathname) +
|
||||
(url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/';
|
||||
url.searchParams.delete(TRAILING_SLASH_PARAM);
|
||||
invalidated_data_nodes = url.searchParams
|
||||
.get(INVALIDATED_PARAM)
|
||||
?.split('')
|
||||
.map((node) => node === '1');
|
||||
url.searchParams.delete(INVALIDATED_PARAM);
|
||||
} else if (remote_id) {
|
||||
url.pathname = request.headers.get('x-sveltekit-pathname') ?? base;
|
||||
url.search = request.headers.get('x-sveltekit-search') ?? '';
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {};
|
||||
|
||||
const { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash } = get_cookies(
|
||||
request,
|
||||
url
|
||||
);
|
||||
|
||||
/** @type {RequestState} */
|
||||
const event_state = {
|
||||
prerendering: state.prerendering,
|
||||
transport: options.hooks.transport,
|
||||
handleValidationError: options.hooks.handleValidationError,
|
||||
tracing: {
|
||||
record_span
|
||||
},
|
||||
is_in_remote_function: false
|
||||
};
|
||||
|
||||
/** @type {import('@sveltejs/kit').RequestEvent} */
|
||||
const event = {
|
||||
cookies,
|
||||
// @ts-expect-error `fetch` needs to be created after the `event` itself
|
||||
fetch: null,
|
||||
getClientAddress:
|
||||
state.getClientAddress ||
|
||||
(() => {
|
||||
throw new Error(
|
||||
`${__SVELTEKIT_ADAPTER_NAME__} does not specify getClientAddress. Please raise an issue`
|
||||
);
|
||||
}),
|
||||
locals: {},
|
||||
params: {},
|
||||
platform: state.platform,
|
||||
request,
|
||||
route: { id: null },
|
||||
setHeaders: (new_headers) => {
|
||||
if (DEV) {
|
||||
validateHeaders(new_headers);
|
||||
}
|
||||
|
||||
for (const key in new_headers) {
|
||||
const lower = key.toLowerCase();
|
||||
const value = new_headers[key];
|
||||
|
||||
if (lower === 'set-cookie') {
|
||||
throw new Error(
|
||||
'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies'
|
||||
);
|
||||
} else if (lower in headers) {
|
||||
// appendHeaders-style for Server-Timing https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing
|
||||
if (lower === 'server-timing') {
|
||||
headers[lower] += ', ' + value;
|
||||
} else {
|
||||
throw new Error(`"${key}" header is already set`);
|
||||
}
|
||||
} else {
|
||||
headers[lower] = value;
|
||||
|
||||
if (state.prerendering && lower === 'cache-control') {
|
||||
state.prerendering.cache = /** @type {string} */ (value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
url,
|
||||
isDataRequest: is_data_request,
|
||||
isSubRequest: state.depth > 0,
|
||||
isRemoteRequest: !!remote_id
|
||||
};
|
||||
|
||||
event.fetch = create_fetch({
|
||||
event,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
get_cookie_header,
|
||||
set_internal
|
||||
});
|
||||
|
||||
if (state.emulator?.platform) {
|
||||
event.platform = await state.emulator.platform({
|
||||
config: {},
|
||||
prerender: !!state.prerendering?.fallback
|
||||
});
|
||||
}
|
||||
|
||||
let resolved_path = url.pathname;
|
||||
|
||||
if (!remote_id) {
|
||||
const prerendering_reroute_state = state.prerendering?.inside_reroute;
|
||||
try {
|
||||
// For the duration or a reroute, disable the prerendering state as reroute could call API endpoints
|
||||
// which would end up in the wrong logic path if not disabled.
|
||||
if (state.prerendering) state.prerendering.inside_reroute = true;
|
||||
|
||||
// reroute could alter the given URL, so we pass a copy
|
||||
resolved_path =
|
||||
(await options.hooks.reroute({ url: new URL(url), fetch: event.fetch })) ?? url.pathname;
|
||||
} catch {
|
||||
return text('Internal Server Error', {
|
||||
status: 500
|
||||
});
|
||||
} finally {
|
||||
if (state.prerendering) state.prerendering.inside_reroute = prerendering_reroute_state;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
resolved_path = decode_pathname(resolved_path);
|
||||
} catch {
|
||||
return text('Malformed URI', { status: 400 });
|
||||
}
|
||||
|
||||
// try to serve the rerouted prerendered resource if it exists
|
||||
if (
|
||||
// the resolved path has been decoded so it should be compared to the decoded url pathname
|
||||
resolved_path !== decode_pathname(url.pathname) &&
|
||||
!state.prerendering?.fallback &&
|
||||
has_prerendered_path(manifest, resolved_path)
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
url.pathname = is_data_request
|
||||
? add_data_suffix(resolved_path)
|
||||
: is_route_resolution_request
|
||||
? add_resolution_suffix(resolved_path)
|
||||
: resolved_path;
|
||||
|
||||
try {
|
||||
// `fetch` automatically decodes the body, so we need to delete the related headers to not break the response
|
||||
// Also see https://github.com/sveltejs/kit/issues/12197 for more info (we should fix this more generally at some point)
|
||||
const response = await fetch(url, request);
|
||||
const headers = new Headers(response.headers);
|
||||
if (headers.has('content-encoding')) {
|
||||
headers.delete('content-encoding');
|
||||
headers.delete('content-length');
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
} catch (error) {
|
||||
return await handle_fatal_error(event, event_state, options, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('types').SSRRoute | null} */
|
||||
let route = null;
|
||||
|
||||
if (base && !state.prerendering?.fallback) {
|
||||
if (!resolved_path.startsWith(base)) {
|
||||
return text('Not found', { status: 404 });
|
||||
}
|
||||
resolved_path = resolved_path.slice(base.length) || '/';
|
||||
}
|
||||
|
||||
if (is_route_resolution_request) {
|
||||
return resolve_route(resolved_path, new URL(request.url), manifest);
|
||||
}
|
||||
|
||||
if (resolved_path === `/${app_dir}/env.js`) {
|
||||
return get_public_env(request);
|
||||
}
|
||||
|
||||
if (!remote_id && resolved_path.startsWith(`/${app_dir}`)) {
|
||||
// Ensure that 404'd static assets are not cached - some adapters might apply caching by default
|
||||
const headers = new Headers();
|
||||
headers.set('cache-control', 'public, max-age=0, must-revalidate');
|
||||
return text('Not found', { status: 404, headers });
|
||||
}
|
||||
|
||||
if (!state.prerendering?.fallback) {
|
||||
// TODO this could theoretically break — should probably be inside a try-catch
|
||||
const matchers = await manifest._.matchers();
|
||||
const result = find_route(resolved_path, manifest._.routes, matchers);
|
||||
|
||||
if (result) {
|
||||
route = result.route;
|
||||
event.route = { id: route.id };
|
||||
event.params = result.params;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('types').RequiredResolveOptions} */
|
||||
let resolve_opts = {
|
||||
transformPageChunk: default_transform,
|
||||
filterSerializedResponseHeaders: default_filter,
|
||||
preload: default_preload
|
||||
};
|
||||
|
||||
/** @type {import('types').TrailingSlash} */
|
||||
let trailing_slash = 'never';
|
||||
|
||||
try {
|
||||
/** @type {PageNodes | undefined} */
|
||||
const page_nodes = route?.page
|
||||
? new PageNodes(await load_page_nodes(route.page, manifest))
|
||||
: undefined;
|
||||
|
||||
// determine whether we need to redirect to add/remove a trailing slash
|
||||
if (route && !remote_id) {
|
||||
// if `paths.base === '/a/b/c`, then the root route is `/a/b/c/`,
|
||||
// regardless of the `trailingSlash` route option
|
||||
if (url.pathname === base || url.pathname === base + '/') {
|
||||
trailing_slash = 'always';
|
||||
} else if (page_nodes) {
|
||||
if (DEV) {
|
||||
page_nodes.validate();
|
||||
}
|
||||
trailing_slash = page_nodes.trailing_slash();
|
||||
} else if (route.endpoint) {
|
||||
const node = await route.endpoint();
|
||||
trailing_slash = node.trailingSlash ?? 'never';
|
||||
if (DEV) {
|
||||
validate_server_exports(node, /** @type {string} */ (route.endpoint_id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_data_request) {
|
||||
const normalized = normalize_path(url.pathname, trailing_slash);
|
||||
|
||||
if (normalized !== url.pathname && !state.prerendering?.fallback) {
|
||||
return new Response(undefined, {
|
||||
status: 308,
|
||||
headers: {
|
||||
'x-sveltekit-normalize': '1',
|
||||
location:
|
||||
// ensure paths starting with '//' are not treated as protocol-relative
|
||||
(normalized.startsWith('//') ? url.origin + normalized : normalized) +
|
||||
(url.search === '?' ? '' : url.search)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (state.before_handle || state.emulator?.platform) {
|
||||
let config = {};
|
||||
|
||||
/** @type {import('types').PrerenderOption} */
|
||||
let prerender = false;
|
||||
|
||||
if (route.endpoint) {
|
||||
const node = await route.endpoint();
|
||||
config = node.config ?? config;
|
||||
prerender = node.prerender ?? prerender;
|
||||
} else if (page_nodes) {
|
||||
config = page_nodes.get_config() ?? config;
|
||||
prerender = page_nodes.prerender();
|
||||
}
|
||||
|
||||
if (state.before_handle) {
|
||||
state.before_handle(event, config, prerender);
|
||||
}
|
||||
|
||||
if (state.emulator?.platform) {
|
||||
event.platform = await state.emulator.platform({ config, prerender });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_trailing_slash(trailing_slash);
|
||||
|
||||
if (state.prerendering && !state.prerendering.fallback && !state.prerendering.inside_reroute) {
|
||||
disable_search(url);
|
||||
}
|
||||
|
||||
const response = await record_span({
|
||||
name: 'sveltekit.handle.root',
|
||||
attributes: {
|
||||
'http.route': event.route.id || 'unknown',
|
||||
'http.method': event.request.method,
|
||||
'http.url': event.url.href,
|
||||
'sveltekit.is_data_request': is_data_request,
|
||||
'sveltekit.is_sub_request': event.isSubRequest
|
||||
},
|
||||
fn: async (root_span) => {
|
||||
const traced_event = {
|
||||
...event,
|
||||
tracing: {
|
||||
enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__,
|
||||
root: root_span,
|
||||
current: root_span
|
||||
}
|
||||
};
|
||||
return await with_request_store({ event: traced_event, state: event_state }, () =>
|
||||
options.hooks.handle({
|
||||
event: traced_event,
|
||||
resolve: (event, opts) => {
|
||||
return record_span({
|
||||
name: 'sveltekit.resolve',
|
||||
attributes: {
|
||||
'http.route': event.route.id || 'unknown'
|
||||
},
|
||||
fn: (resolve_span) => {
|
||||
// counter-intuitively, we need to clear the event, so that it's not
|
||||
// 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) => {
|
||||
// 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) {
|
||||
const value = headers[key];
|
||||
response.headers.set(key, /** @type {string} */ (value));
|
||||
}
|
||||
|
||||
add_cookies_to_headers(response.headers, new_cookies.values());
|
||||
|
||||
if (state.prerendering && event.route.id !== null) {
|
||||
response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id));
|
||||
}
|
||||
|
||||
resolve_span.setAttributes({
|
||||
'http.response.status_code': response.status,
|
||||
'http.response.body.size':
|
||||
response.headers.get('content-length') || 'unknown'
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// respond with 304 if etag matches
|
||||
if (response.status === 200 && response.headers.has('etag')) {
|
||||
let if_none_match_value = request.headers.get('if-none-match');
|
||||
|
||||
// ignore W/ prefix https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#directives
|
||||
if (if_none_match_value?.startsWith('W/"')) {
|
||||
if_none_match_value = if_none_match_value.substring(2);
|
||||
}
|
||||
|
||||
const etag = /** @type {string} */ (response.headers.get('etag'));
|
||||
|
||||
if (if_none_match_value === etag) {
|
||||
const headers = new Headers({ etag });
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 + set-cookie
|
||||
for (const key of [
|
||||
'cache-control',
|
||||
'content-location',
|
||||
'date',
|
||||
'expires',
|
||||
'vary',
|
||||
'set-cookie'
|
||||
]) {
|
||||
const value = response.headers.get(key);
|
||||
if (value) headers.set(key, value);
|
||||
}
|
||||
|
||||
return new Response(undefined, {
|
||||
status: 304,
|
||||
headers
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case: If user does `return Response(30x)` in handle hook while processing a data request,
|
||||
// we need to transform the redirect response to a corresponding JSON response.
|
||||
if (is_data_request && response.status >= 300 && response.status <= 308) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
return redirect_json_response(new Redirect(/** @type {any} */ (response.status), location));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (e instanceof Redirect) {
|
||||
const response =
|
||||
is_data_request || remote_id
|
||||
? redirect_json_response(e)
|
||||
: route?.page && is_action_json_request(event)
|
||||
? action_json_redirect(e)
|
||||
: redirect_response(e.status, e.location);
|
||||
add_cookies_to_headers(response.headers, new_cookies.values());
|
||||
return response;
|
||||
}
|
||||
return await handle_fatal_error(event, event_state, options, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {PageNodes | undefined} page_nodes
|
||||
* @param {import('@sveltejs/kit').ResolveOptions} [opts]
|
||||
*/
|
||||
async function resolve(event, page_nodes, opts) {
|
||||
try {
|
||||
if (opts) {
|
||||
resolve_opts = {
|
||||
transformPageChunk: opts.transformPageChunk || default_transform,
|
||||
filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter,
|
||||
preload: opts.preload || default_preload
|
||||
};
|
||||
}
|
||||
|
||||
if (options.hash_routing || state.prerendering?.fallback) {
|
||||
return await render_response({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
page_config: { ssr: false, csr: true },
|
||||
status: 200,
|
||||
error: null,
|
||||
branch: [],
|
||||
fetched: [],
|
||||
resolve_opts,
|
||||
data_serializer: server_data_serializer(event, event_state, options)
|
||||
});
|
||||
}
|
||||
|
||||
if (remote_id) {
|
||||
return await handle_remote_call(event, event_state, options, manifest, remote_id);
|
||||
}
|
||||
|
||||
if (route) {
|
||||
const method = /** @type {import('types').HttpMethod} */ (event.request.method);
|
||||
|
||||
/** @type {Response} */
|
||||
let response;
|
||||
|
||||
if (is_data_request) {
|
||||
response = await render_data(
|
||||
event,
|
||||
event_state,
|
||||
route,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
invalidated_data_nodes,
|
||||
trailing_slash
|
||||
);
|
||||
} else if (route.endpoint && (!route.page || is_endpoint_request(event))) {
|
||||
response = await render_endpoint(event, event_state, await route.endpoint(), state);
|
||||
} else if (route.page) {
|
||||
if (!page_nodes) {
|
||||
throw new Error('page_nodes not found. This should never happen');
|
||||
} else if (page_methods.has(method)) {
|
||||
response = await render_page(
|
||||
event,
|
||||
event_state,
|
||||
route.page,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
page_nodes,
|
||||
resolve_opts
|
||||
);
|
||||
} else {
|
||||
const allowed_methods = new Set(allowed_page_methods);
|
||||
const node = await manifest._.nodes[route.page.leaf]();
|
||||
if (node?.server?.actions) {
|
||||
allowed_methods.add('POST');
|
||||
}
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
// This will deny CORS preflight requests implicitly because we don't
|
||||
// add the required CORS headers to the response.
|
||||
response = new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
allow: Array.from(allowed_methods.values()).join(', ')
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const mod = [...allowed_methods].reduce((acc, curr) => {
|
||||
acc[curr] = true;
|
||||
return acc;
|
||||
}, /** @type {Record<string, any>} */ ({}));
|
||||
response = method_not_allowed(mod, method);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// a route will always have a page or an endpoint, but TypeScript doesn't know that
|
||||
throw new Error('Route is neither page nor endpoint. This should never happen');
|
||||
}
|
||||
|
||||
// If the route contains a page and an endpoint, we need to add a
|
||||
// `Vary: Accept` header to the response because of browser caching
|
||||
if (request.method === 'GET' && route.page && route.endpoint) {
|
||||
const vary = response.headers
|
||||
.get('vary')
|
||||
?.split(',')
|
||||
?.map((v) => v.trim().toLowerCase());
|
||||
if (!(vary?.includes('accept') || vary?.includes('*'))) {
|
||||
// the returned response might have immutable headers,
|
||||
// so we have to clone them before trying to mutate them
|
||||
response = new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Headers(response.headers)
|
||||
});
|
||||
response.headers.append('Vary', 'Accept');
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
if (state.error && event.isSubRequest) {
|
||||
// avoid overwriting the headers. This could be a same origin fetch request
|
||||
// to an external service from the root layout while rendering an error page
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('x-sveltekit-error', 'true');
|
||||
return await fetch(request, { headers });
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return text('Internal Server Error', {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
|
||||
// if this request came direct from the user, rather than
|
||||
// via our own `fetch`, render a 404 page
|
||||
if (state.depth === 0) {
|
||||
// In local development, Chrome requests this file for its 'automatic workspace folders' feature,
|
||||
// causing console spam. If users want to serve this file they can install
|
||||
// https://svelte.dev/docs/cli/devtools-json
|
||||
if (DEV && event.url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') {
|
||||
if (!warned_on_devtools_json_request) {
|
||||
console.log(
|
||||
`\nGoogle Chrome is requesting ${event.url.pathname} to automatically configure devtools project settings. To learn why, and how to prevent this message, see https://svelte.dev/docs/cli/devtools-json\n`
|
||||
);
|
||||
|
||||
warned_on_devtools_json_request = true;
|
||||
}
|
||||
|
||||
return new Response(undefined, { status: 404 });
|
||||
}
|
||||
|
||||
return await respond_with_error({
|
||||
event,
|
||||
event_state,
|
||||
options,
|
||||
manifest,
|
||||
state,
|
||||
status: 404,
|
||||
error: new SvelteKitError(404, 'Not Found', `Not found: ${event.url.pathname}`),
|
||||
resolve_opts
|
||||
});
|
||||
}
|
||||
|
||||
if (state.prerendering) {
|
||||
return text('not found', { status: 404 });
|
||||
}
|
||||
|
||||
// we can't load the endpoint from our own manifest,
|
||||
// so we need to make an actual HTTP request
|
||||
const response = await fetch(request);
|
||||
|
||||
// clone the response so that headers are mutable (https://github.com/sveltejs/kit/issues/13857)
|
||||
return new Response(response.body, response);
|
||||
} catch (e) {
|
||||
// TODO if `e` is instead named `error`, some fucked up Vite transformation happens
|
||||
// and I don't even know how to describe it. need to investigate at some point
|
||||
|
||||
// HttpError from endpoint can end up here - TODO should it be handled there instead?
|
||||
return await handle_fatal_error(event, event_state, options, e);
|
||||
} finally {
|
||||
event.cookies.set = () => {
|
||||
throw new Error('Cannot use `cookies.set(...)` after the response has been generated');
|
||||
};
|
||||
|
||||
event.setHeaders = () => {
|
||||
throw new Error('Cannot use `setHeaders(...)` after the response has been generated');
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').PageNodeIndexes} page
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
*/
|
||||
export function load_page_nodes(page, manifest) {
|
||||
return Promise.all([
|
||||
// we use == here rather than === because [undefined] serializes as "[null]"
|
||||
...page.layouts.map((n) => (n == undefined ? n : manifest._.nodes[n]())),
|
||||
manifest._.nodes[page.leaf]()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* It's likely that, in a distributed system, there are spans starting outside the SvelteKit server -- eg.
|
||||
* started on the frontend client, or in a service that calls the SvelteKit server. There are standardized
|
||||
* ways to represent this context in HTTP headers, so we can extract that context and run our tracing inside of it
|
||||
* so that when our traces are exported, they are associated with the correct parent context.
|
||||
* @param {typeof internal_respond} fn
|
||||
* @returns {typeof internal_respond}
|
||||
*/
|
||||
function propagate_context(fn) {
|
||||
return async (req, ...rest) => {
|
||||
if (otel === null) {
|
||||
return fn(req, ...rest);
|
||||
}
|
||||
|
||||
const { propagation, context } = await otel;
|
||||
const c = propagation.extract(context.active(), Object.fromEntries(req.headers));
|
||||
return context.with(c, async () => {
|
||||
return await fn(req, ...rest);
|
||||
});
|
||||
};
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { DEV } from 'esm-env';
|
||||
import { json, text } from '@sveltejs/kit';
|
||||
import { HttpError } from '@sveltejs/kit/internal';
|
||||
import { with_request_store } from '@sveltejs/kit/internal/server';
|
||||
import { coalesce_to_error, get_message, get_status } from '../../utils/error.js';
|
||||
import { negotiate } from '../../utils/http.js';
|
||||
import { fix_stack_trace } from '../shared-server.js';
|
||||
import { ENDPOINT_METHODS } from '../../constants.js';
|
||||
import { escape_html } from '../../utils/escape.js';
|
||||
|
||||
/** @param {any} body */
|
||||
export function is_pojo(body) {
|
||||
if (typeof body !== 'object') return false;
|
||||
|
||||
if (body) {
|
||||
if (body instanceof Uint8Array) return false;
|
||||
if (body instanceof ReadableStream) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<Record<import('types').HttpMethod, any>>} mod
|
||||
* @param {import('types').HttpMethod} method
|
||||
*/
|
||||
export function method_not_allowed(mod, method) {
|
||||
return text(`${method} method not allowed`, {
|
||||
status: 405,
|
||||
headers: {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
|
||||
// "The server must generate an Allow header field in a 405 status code response"
|
||||
allow: allowed_methods(mod).join(', ')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {Partial<Record<import('types').HttpMethod, any>>} mod */
|
||||
export function allowed_methods(mod) {
|
||||
const allowed = ENDPOINT_METHODS.filter((method) => method in mod);
|
||||
|
||||
// if there's no HEAD handler, but we have a GET handler, we respond to
|
||||
// HEAD requests using the GET handler and omit the response body.
|
||||
if ('GET' in mod && !('HEAD' in mod)) {
|
||||
allowed.push('HEAD');
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').SSROptions} options
|
||||
*/
|
||||
export function get_global_name(options) {
|
||||
return DEV ? '__sveltekit_dev' : `__sveltekit_${options.version_hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return as a response that renders the error.html
|
||||
*
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {number} status
|
||||
* @param {string} message
|
||||
*/
|
||||
export function static_error_page(options, status, message) {
|
||||
let page = options.templates.error({ status, message: escape_html(message) });
|
||||
|
||||
if (DEV) {
|
||||
// inject Vite HMR client, for easier debugging
|
||||
page = page.replace('</head>', '<script type="module" src="/@vite/client"></script></head>');
|
||||
}
|
||||
|
||||
return text(page, {
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} state
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {unknown} error
|
||||
*/
|
||||
export async function handle_fatal_error(event, state, options, error) {
|
||||
error = error instanceof HttpError ? error : coalesce_to_error(error);
|
||||
const status = get_status(error);
|
||||
const body = await handle_error_and_jsonify(event, state, options, error);
|
||||
|
||||
// ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it
|
||||
const type = negotiate(event.request.headers.get('accept') || 'text/html', [
|
||||
'application/json',
|
||||
'text/html'
|
||||
]);
|
||||
|
||||
if (event.isDataRequest || type === 'application/json') {
|
||||
return json(body, {
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
return static_error_page(options, status, body.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {import('types').RequestState} state
|
||||
* @param {import('types').SSROptions} options
|
||||
* @param {any} error
|
||||
* @returns {Promise<App.Error>}
|
||||
*/
|
||||
export async function handle_error_and_jsonify(event, state, options, error) {
|
||||
if (error instanceof HttpError) {
|
||||
// @ts-expect-error custom user errors may not have a message field if App.Error is overwritten
|
||||
return { message: 'Unknown Error', ...error.body };
|
||||
}
|
||||
|
||||
if (DEV && typeof error == 'object') {
|
||||
fix_stack_trace(error);
|
||||
}
|
||||
|
||||
const status = get_status(error);
|
||||
const message = get_message(error);
|
||||
|
||||
return (
|
||||
(await with_request_store({ event, state }, () =>
|
||||
options.hooks.handleError({ error, event, status, message })
|
||||
)) ?? { message }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} status
|
||||
* @param {string} location
|
||||
*/
|
||||
export function redirect_response(status, location) {
|
||||
const response = new Response(undefined, {
|
||||
status,
|
||||
headers: { location }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
* @param {Error & { path: string }} error
|
||||
*/
|
||||
export function clarify_devalue_error(event, error) {
|
||||
if (error.path) {
|
||||
return (
|
||||
`Data returned from \`load\` while rendering ${event.route.id} is not serializable: ${error.message} (${error.path}). ` +
|
||||
`If you need to serialize/deserialize custom types, use transport hooks: https://svelte.dev/docs/kit/hooks#Universal-hooks-transport.`
|
||||
);
|
||||
}
|
||||
|
||||
if (error.path === '') {
|
||||
return `Data returned from \`load\` while rendering ${event.route.id} is not a plain object`;
|
||||
}
|
||||
|
||||
// belt and braces — this should never happen
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('types').ServerDataNode} node
|
||||
*/
|
||||
export function serialize_uses(node) {
|
||||
const uses = {};
|
||||
|
||||
if (node.uses && node.uses.dependencies.size > 0) {
|
||||
uses.dependencies = Array.from(node.uses.dependencies);
|
||||
}
|
||||
|
||||
if (node.uses && node.uses.search_params.size > 0) {
|
||||
uses.search_params = Array.from(node.uses.search_params);
|
||||
}
|
||||
|
||||
if (node.uses && node.uses.params.size > 0) {
|
||||
uses.params = Array.from(node.uses.params);
|
||||
}
|
||||
|
||||
if (node.uses?.parent) uses.parent = 1;
|
||||
if (node.uses?.route) uses.route = 1;
|
||||
if (node.uses?.url) uses.url = 1;
|
||||
|
||||
return uses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given path was prerendered
|
||||
* @param {import('@sveltejs/kit').SSRManifest} manifest
|
||||
* @param {string} pathname Should include the base and be decoded
|
||||
*/
|
||||
export function has_prerendered_path(manifest, pathname) {
|
||||
return (
|
||||
manifest._.prerendered_routes.has(pathname) ||
|
||||
(pathname.at(-1) === '/' && manifest._.prerendered_routes.has(pathname.slice(0, -1)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the error into a nice message with sanitized stack trace
|
||||
* @param {number} status
|
||||
* @param {Error} error
|
||||
* @param {import('@sveltejs/kit').RequestEvent} event
|
||||
*/
|
||||
export function format_server_error(status, error, event) {
|
||||
const formatted_text = `\n\x1b[1;31m[${status}] ${event.request.method} ${event.url.pathname}\x1b[0m`;
|
||||
|
||||
if (status === 404) {
|
||||
return formatted_text;
|
||||
}
|
||||
|
||||
return `${formatted_text}\n${DEV ? clean_up_stack_trace(error) : error.stack}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* In dev, tidy up stack traces by making paths relative to the current project directory
|
||||
* @param {string} file
|
||||
*/
|
||||
let relative = (file) => file;
|
||||
|
||||
if (DEV) {
|
||||
try {
|
||||
const path = await import('node:path');
|
||||
const process = await import('node:process');
|
||||
|
||||
relative = (file) => path.relative(process.cwd(), file);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a refined stack trace by excluding lines following the last occurrence of a line containing +page. +layout. or +server.
|
||||
* @param {Error} error
|
||||
*/
|
||||
export function clean_up_stack_trace(error) {
|
||||
const stack_trace = (error.stack?.split('\n') ?? []).map((line) => {
|
||||
return line.replace(/\((.+)(:\d+:\d+)\)$/, (_, file, loc) => `(${relative(file)}${loc})`);
|
||||
});
|
||||
|
||||
// progressive enhancement for people who haven't configured kit.files.src to something else
|
||||
const last_line_from_src_code = stack_trace.findLastIndex((line) => /\(src[\\/]/.test(line));
|
||||
|
||||
if (last_line_from_src_code === -1) {
|
||||
// default to the whole stack trace
|
||||
return error.stack;
|
||||
}
|
||||
|
||||
return stack_trace.slice(0, last_line_from_src_code + 1).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename without the extension. e.g., `+page.server`, `+page`, etc.
|
||||
* @param {string | undefined} node_id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function get_node_type(node_id) {
|
||||
const parts = node_id?.split('/');
|
||||
const filename = parts?.at(-1);
|
||||
if (!filename) return 'unknown';
|
||||
const dot_parts = filename.split('.');
|
||||
return dot_parts.slice(0, -1).join('.');
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/** @type {Set<string>} */
|
||||
const VALID_CACHE_CONTROL_DIRECTIVES = new Set([
|
||||
'max-age',
|
||||
'public',
|
||||
'private',
|
||||
'no-cache',
|
||||
'no-store',
|
||||
'must-revalidate',
|
||||
'proxy-revalidate',
|
||||
's-maxage',
|
||||
'immutable',
|
||||
'stale-while-revalidate',
|
||||
'stale-if-error',
|
||||
'no-transform',
|
||||
'only-if-cached',
|
||||
'max-stale',
|
||||
'min-fresh'
|
||||
]);
|
||||
|
||||
const CONTENT_TYPE_PATTERN =
|
||||
/^(application|audio|example|font|haptics|image|message|model|multipart|text|video|x-[a-z]+)\/[-+.\w]+$/i;
|
||||
|
||||
/** @type {Record<string, (value: string) => void>} */
|
||||
const HEADER_VALIDATORS = {
|
||||
'cache-control': (value) => {
|
||||
const error_suffix = `(While parsing "${value}".)`;
|
||||
const parts = value.split(',').map((part) => part.trim());
|
||||
if (parts.some((part) => !part)) {
|
||||
throw new Error(`\`cache-control\` header contains empty directives. ${error_suffix}`);
|
||||
}
|
||||
|
||||
const directives = parts.map((part) => part.split('=')[0].toLowerCase());
|
||||
const invalid = directives.find((directive) => !VALID_CACHE_CONTROL_DIRECTIVES.has(directive));
|
||||
if (invalid) {
|
||||
throw new Error(
|
||||
`Invalid cache-control directive "${invalid}". Did you mean one of: ${[...VALID_CACHE_CONTROL_DIRECTIVES].join(', ')}? ${error_suffix}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
'content-type': (value) => {
|
||||
const type = value.split(';')[0].trim();
|
||||
const error_suffix = `(While parsing "${value}".)`;
|
||||
if (!CONTENT_TYPE_PATTERN.test(type)) {
|
||||
throw new Error(`Invalid content-type value "${type}". ${error_suffix}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} headers
|
||||
*/
|
||||
export function validateHeaders(headers) {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const validator = HEADER_VALIDATORS[key.toLowerCase()];
|
||||
try {
|
||||
validator?.(value);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.warn(`[SvelteKit] ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* `$env/dynamic/private`
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export let private_env = {};
|
||||
|
||||
/**
|
||||
* `$env/dynamic/public`
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export let public_env = {};
|
||||
|
||||
/** @param {any} error */
|
||||
export let fix_stack_trace = (error) => error?.stack;
|
||||
|
||||
/** @type {(environment: Record<string, string>) => void} */
|
||||
export function set_private_env(environment) {
|
||||
private_env = environment;
|
||||
}
|
||||
|
||||
/** @type {(environment: Record<string, string>) => void} */
|
||||
export function set_public_env(environment) {
|
||||
public_env = environment;
|
||||
}
|
||||
|
||||
/** @param {(error: Error) => string} value */
|
||||
export function set_fix_stack_trace(value) {
|
||||
fix_stack_trace = value;
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/** @import { Transport } from '@sveltejs/kit' */
|
||||
import * as devalue from 'devalue';
|
||||
import { base64_decode, base64_encode, text_decoder } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {string} route_id
|
||||
* @param {string} dep
|
||||
*/
|
||||
export function validate_depends(route_id, dep) {
|
||||
const match = /^(moz-icon|view-source|jar):/.exec(dep);
|
||||
if (match) {
|
||||
console.warn(
|
||||
`${route_id}: Calling \`depends('${dep}')\` will throw an error in Firefox because \`${match[1]}\` is a special URI scheme`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const INVALIDATED_PARAM = 'x-sveltekit-invalidated';
|
||||
|
||||
export const TRAILING_SLASH_PARAM = 'x-sveltekit-trailing-slash';
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @param {string} [location_description]
|
||||
*/
|
||||
export function validate_load_response(data, location_description) {
|
||||
if (data != null && Object.getPrototypeOf(data) !== Object.prototype) {
|
||||
throw new Error(
|
||||
`a load function ${location_description} returned ${
|
||||
typeof data !== 'object'
|
||||
? `a ${typeof data}`
|
||||
: data instanceof Response
|
||||
? 'a Response object'
|
||||
: Array.isArray(data)
|
||||
? 'an array'
|
||||
: 'a non-plain object'
|
||||
}, but must return a plain object at the top level (i.e. \`return {...}\`)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to `devalue.stringify` the data object using the provided transport encoders.
|
||||
* @param {any} data
|
||||
* @param {Transport} transport
|
||||
*/
|
||||
export function stringify(data, transport) {
|
||||
const encoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.encode]));
|
||||
|
||||
return devalue.stringify(data, encoders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringifies the argument (if any) for a remote function in such a way that
|
||||
* it is both a valid URL and a valid file name (necessary for prerendering).
|
||||
* @param {any} value
|
||||
* @param {Transport} transport
|
||||
*/
|
||||
export function stringify_remote_arg(value, transport) {
|
||||
if (value === undefined) return '';
|
||||
|
||||
// If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size
|
||||
const json_string = stringify(value, transport);
|
||||
|
||||
const bytes = new TextEncoder().encode(json_string);
|
||||
return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the argument (if any) for a remote function
|
||||
* @param {string} string
|
||||
* @param {Transport} transport
|
||||
*/
|
||||
export function parse_remote_arg(string, transport) {
|
||||
if (!string) return undefined;
|
||||
|
||||
const json_string = text_decoder.decode(
|
||||
// no need to add back `=` characters, atob can handle it
|
||||
base64_decode(string.replaceAll('-', '+').replaceAll('_', '/'))
|
||||
);
|
||||
|
||||
const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode]));
|
||||
|
||||
return devalue.parse(json_string, decoders);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} payload
|
||||
*/
|
||||
export function create_remote_key(id, payload) {
|
||||
return id + '/' + payload;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/** @import { Tracer, Span, SpanContext } from '@opentelemetry/api' */
|
||||
|
||||
/**
|
||||
* Tracer implementation that does nothing (null object).
|
||||
* @type {Tracer}
|
||||
*/
|
||||
export const noop_tracer = {
|
||||
/**
|
||||
* @returns {Span}
|
||||
*/
|
||||
startSpan() {
|
||||
return noop_span;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {unknown} _name
|
||||
* @param {unknown} arg_1
|
||||
* @param {unknown} [arg_2]
|
||||
* @param {Function} [arg_3]
|
||||
* @returns {unknown}
|
||||
*/
|
||||
startActiveSpan(_name, arg_1, arg_2, arg_3) {
|
||||
if (typeof arg_1 === 'function') {
|
||||
return arg_1(noop_span);
|
||||
}
|
||||
if (typeof arg_2 === 'function') {
|
||||
return arg_2(noop_span);
|
||||
}
|
||||
if (typeof arg_3 === 'function') {
|
||||
return arg_3(noop_span);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Span}
|
||||
*/
|
||||
export const noop_span = {
|
||||
spanContext() {
|
||||
return noop_span_context;
|
||||
},
|
||||
setAttribute() {
|
||||
return this;
|
||||
},
|
||||
setAttributes() {
|
||||
return this;
|
||||
},
|
||||
addEvent() {
|
||||
return this;
|
||||
},
|
||||
setStatus() {
|
||||
return this;
|
||||
},
|
||||
updateName() {
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
isRecording() {
|
||||
return false;
|
||||
},
|
||||
recordException() {
|
||||
return this;
|
||||
},
|
||||
addLink() {
|
||||
return this;
|
||||
},
|
||||
addLinks() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {SpanContext}
|
||||
*/
|
||||
const noop_span_context = {
|
||||
traceId: '',
|
||||
spanId: '',
|
||||
traceFlags: 0
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/** @import { Tracer, SpanStatusCode, PropagationAPI, ContextAPI } from '@opentelemetry/api' */
|
||||
|
||||
/** @type {Promise<{ tracer: Tracer, SpanStatusCode: typeof SpanStatusCode, propagation: PropagationAPI, context: ContextAPI }> | null} */
|
||||
export let otel = null;
|
||||
|
||||
if (__SVELTEKIT_SERVER_TRACING_ENABLED__) {
|
||||
otel = import('@opentelemetry/api')
|
||||
.then((module) => {
|
||||
return {
|
||||
tracer: module.trace.getTracer('sveltekit'),
|
||||
propagation: module.propagation,
|
||||
context: module.context,
|
||||
SpanStatusCode: module.SpanStatusCode
|
||||
};
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error(
|
||||
'Tracing is enabled (see `config.kit.experimental.instrumentation.server` in your svelte.config.js), but `@opentelemetry/api` is not available. This error will likely resolve itself when you set up your tracing instrumentation in `instrumentation.server.js`. For more information, see https://svelte.dev/docs/kit/observability#opentelemetry-api'
|
||||
);
|
||||
});
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/** @import { RecordSpan } from 'types' */
|
||||
import { HttpError, Redirect } from '@sveltejs/kit/internal';
|
||||
import { noop_span } from './noop.js';
|
||||
import { otel } from './otel.js';
|
||||
|
||||
/** @type {RecordSpan} */
|
||||
export async function record_span({ name, attributes, fn }) {
|
||||
if (otel === null) {
|
||||
return fn(noop_span);
|
||||
}
|
||||
|
||||
const { SpanStatusCode, tracer } = await otel;
|
||||
|
||||
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
span.setAttributes({
|
||||
[`${name}.result.type`]: 'known_error',
|
||||
[`${name}.result.status`]: error.status,
|
||||
[`${name}.result.message`]: error.body.message
|
||||
});
|
||||
if (error.status >= 500) {
|
||||
span.recordException({
|
||||
name: 'HttpError',
|
||||
message: error.body.message
|
||||
});
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.body.message
|
||||
});
|
||||
}
|
||||
} else if (error instanceof Redirect) {
|
||||
span.setAttributes({
|
||||
[`${name}.result.type`]: 'redirect',
|
||||
[`${name}.result.status`]: error.status,
|
||||
[`${name}.result.location`]: error.location
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
span.setAttributes({
|
||||
[`${name}.result.type`]: 'unknown_error'
|
||||
});
|
||||
span.recordException({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
span.setAttributes({
|
||||
[`${name}.result.type`]: 'unknown_error'
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { BROWSER } from 'esm-env';
|
||||
|
||||
export const text_encoder = new TextEncoder();
|
||||
export const text_decoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Like node's path.relative, but without using node
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
*/
|
||||
export function get_relative_path(from, to) {
|
||||
const from_parts = from.split(/[/\\]/);
|
||||
const to_parts = to.split(/[/\\]/);
|
||||
from_parts.pop(); // get dirname
|
||||
|
||||
while (from_parts[0] === to_parts[0]) {
|
||||
from_parts.shift();
|
||||
to_parts.shift();
|
||||
}
|
||||
|
||||
let i = from_parts.length;
|
||||
while (i--) from_parts[i] = '..';
|
||||
|
||||
return from_parts.concat(to_parts).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function base64_encode(bytes) {
|
||||
// Using `Buffer` is faster than iterating
|
||||
if (!BROWSER && globalThis.Buffer) {
|
||||
return globalThis.Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} encoded
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function base64_decode(encoded) {
|
||||
// Using `Buffer` is faster than iterating
|
||||
if (!BROWSER && globalThis.Buffer) {
|
||||
const buffer = globalThis.Buffer.from(encoded, 'base64');
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
Reference in New Issue
Block a user