INIT
This commit is contained in:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user