Spaces:
Runtime error
Runtime error
import { onMount, tick } from 'svelte'; | |
import { writable } from 'svelte/store'; | |
import { assets, set_paths } from '../paths.js'; | |
import Root from '__GENERATED__/root.svelte'; | |
import { components, dictionary, matchers } from '__GENERATED__/client-manifest.js'; | |
import { init } from './singletons.js'; | |
/** | |
* @param {unknown} err | |
* @return {Error} | |
*/ | |
function coalesce_to_error(err) { | |
return err instanceof Error || | |
(err && /** @type {any} */ (err).name && /** @type {any} */ (err).message) | |
? /** @type {Error} */ (err) | |
: new Error(JSON.stringify(err)); | |
} | |
/** | |
* @param {import('types').LoadOutput} loaded | |
* @returns {import('types').NormalizedLoadOutput} | |
*/ | |
function normalize(loaded) { | |
// TODO remove for 1.0 | |
// @ts-expect-error | |
if (loaded.fallthrough) { | |
throw new Error( | |
'fallthrough is no longer supported. Use matchers instead: https://kit.svelte.dev/docs/routing#advanced-routing-matching' | |
); | |
} | |
// TODO remove for 1.0 | |
if ('maxage' in loaded) { | |
throw new Error('maxage should be replaced with cache: { maxage }'); | |
} | |
const has_error_status = | |
loaded.status && loaded.status >= 400 && loaded.status <= 599 && !loaded.redirect; | |
if (loaded.error || has_error_status) { | |
const status = loaded.status; | |
if (!loaded.error && has_error_status) { | |
return { status: status || 500, error: new Error() }; | |
} | |
const error = typeof loaded.error === 'string' ? new Error(loaded.error) : loaded.error; | |
if (!(error instanceof Error)) { | |
return { | |
status: 500, | |
error: new Error( | |
`"error" property returned from load() must be a string or instance of Error, received type "${typeof error}"` | |
) | |
}; | |
} | |
if (!status || status < 400 || status > 599) { | |
console.warn('"error" returned from load() without a valid status code — defaulting to 500'); | |
return { status: 500, error }; | |
} | |
return { status, error }; | |
} | |
if (loaded.redirect) { | |
if (!loaded.status || Math.floor(loaded.status / 100) !== 3) { | |
throw new Error( | |
'"redirect" property returned from load() must be accompanied by a 3xx status code' | |
); | |
} | |
if (typeof loaded.redirect !== 'string') { | |
throw new Error('"redirect" property returned from load() must be a string'); | |
} | |
} | |
if (loaded.dependencies) { | |
if ( | |
!Array.isArray(loaded.dependencies) || | |
loaded.dependencies.some((dep) => typeof dep !== 'string') | |
) { | |
throw new Error('"dependencies" property returned from load() must be of type string[]'); | |
} | |
} | |
// TODO remove before 1.0 | |
if (/** @type {any} */ (loaded).context) { | |
throw new Error( | |
'You are returning "context" from a load function. ' + | |
'"context" was renamed to "stuff", please adjust your code accordingly.' | |
); | |
} | |
return /** @type {import('types').NormalizedLoadOutput} */ (loaded); | |
} | |
/** | |
* @param {string} path | |
* @param {import('types').TrailingSlash} trailing_slash | |
*/ | |
function normalize_path(path, trailing_slash) { | |
if (path === '/' || trailing_slash === 'ignore') return path; | |
if (trailing_slash === 'never') { | |
return path.endsWith('/') ? path.slice(0, -1) : path; | |
} else if (trailing_slash === 'always' && !path.endsWith('/')) { | |
return path + '/'; | |
} | |
return path; | |
} | |
class LoadURL extends URL { | |
/** @returns {string} */ | |
get hash() { | |
throw new Error( | |
'url.hash is inaccessible from load. Consider accessing hash from the page store within the script tag of your component.' | |
); | |
} | |
} | |
/** @param {HTMLDocument} doc */ | |
function get_base_uri(doc) { | |
let baseURI = doc.baseURI; | |
if (!baseURI) { | |
const baseTags = doc.getElementsByTagName('base'); | |
baseURI = baseTags.length ? baseTags[0].href : doc.URL; | |
} | |
return baseURI; | |
} | |
function scroll_state() { | |
return { | |
x: pageXOffset, | |
y: pageYOffset | |
}; | |
} | |
/** @param {Event} event */ | |
function find_anchor(event) { | |
const node = event | |
.composedPath() | |
.find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG <a> elements have a lowercase name | |
return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); | |
} | |
/** @param {HTMLAnchorElement | SVGAElement} node */ | |
function get_href(node) { | |
return node instanceof SVGAElement | |
? new URL(node.href.baseVal, document.baseURI) | |
: new URL(node.href); | |
} | |
/** @param {any} value */ | |
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 }; | |
} | |
function create_updated_store() { | |
const { set, subscribe } = writable(false); | |
const interval = +( | |
/** @type {string} */ (import.meta.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL) | |
); | |
const initial = import.meta.env.VITE_SVELTEKIT_APP_VERSION; | |
/** @type {NodeJS.Timeout} */ | |
let timeout; | |
async function check() { | |
if (import.meta.env.DEV || import.meta.env.SSR) return false; | |
clearTimeout(timeout); | |
if (interval) timeout = setTimeout(check, interval); | |
const file = import.meta.env.VITE_SVELTEKIT_APP_VERSION_FILE; | |
const res = await fetch(`${assets}/${file}`, { | |
headers: { | |
pragma: 'no-cache', | |
'cache-control': 'no-cache' | |
} | |
}); | |
if (res.ok) { | |
const { version } = await res.json(); | |
const updated = version !== initial; | |
if (updated) { | |
set(true); | |
clearTimeout(timeout); | |
} | |
return updated; | |
} else { | |
throw new Error(`Version check failed: ${res.status}`); | |
} | |
} | |
if (interval) timeout = setTimeout(check, interval); | |
return { | |
subscribe, | |
check | |
}; | |
} | |
/** | |
* Hash using djb2 | |
* @param {import('types').StrictBody} value | |
*/ | |
function hash(value) { | |
let hash = 5381; | |
let i = value.length; | |
if (typeof value === 'string') { | |
while (i) hash = (hash * 33) ^ value.charCodeAt(--i); | |
} else { | |
while (i) hash = (hash * 33) ^ value[--i]; | |
} | |
return (hash >>> 0).toString(36); | |
} | |
let loading = 0; | |
const native_fetch = window.fetch; | |
function lock_fetch() { | |
loading += 1; | |
} | |
function unlock_fetch() { | |
loading -= 1; | |
} | |
if (import.meta.env.DEV) { | |
let can_inspect_stack_trace = false; | |
const check_stack_trace = async () => { | |
const stack = /** @type {string} */ (new Error().stack); | |
can_inspect_stack_trace = stack.includes('check_stack_trace'); | |
}; | |
check_stack_trace(); | |
window.fetch = (input, init) => { | |
const url = input instanceof Request ? input.url : input.toString(); | |
const stack = /** @type {string} */ (new Error().stack); | |
const heuristic = can_inspect_stack_trace ? stack.includes('load_node') : loading; | |
if (heuristic) { | |
console.warn( | |
`Loading ${url} using \`window.fetch\`. For best results, use the \`fetch\` that is passed to your \`load\` function: https://kit.svelte.dev/docs/loading#input-fetch` | |
); | |
} | |
return native_fetch(input, init); | |
}; | |
} | |
/** | |
* @param {RequestInfo} resource | |
* @param {RequestInit} [opts] | |
*/ | |
function initial_fetch(resource, opts) { | |
const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url); | |
let selector = `script[sveltekit\\:data-type="data"][sveltekit\\:data-url=${url}]`; | |
if (opts && typeof opts.body === 'string') { | |
selector += `[sveltekit\\:data-body="${hash(opts.body)}"]`; | |
} | |
const script = document.querySelector(selector); | |
if (script && script.textContent) { | |
const { body, ...init } = JSON.parse(script.textContent); | |
return Promise.resolve(new Response(body, init)); | |
} | |
return native_fetch(resource, opts); | |
} | |
const param_pattern = /^(\.\.\.)?(\w+)(?:=(\w+))?$/; | |
/** @param {string} id */ | |
function parse_route_id(id) { | |
/** @type {string[]} */ | |
const names = []; | |
/** @type {string[]} */ | |
const types = []; | |
// `/foo` should get an optional trailing slash, `/foo.json` should not | |
// const add_trailing_slash = !/\.[a-z]+$/.test(key); | |
let add_trailing_slash = true; | |
const pattern = | |
id === '' | |
? /^\/$/ | |
: new RegExp( | |
`^${decodeURIComponent(id) | |
.split(/(?:@[a-zA-Z0-9_-]+)?(?:\/|$)/) | |
.map((segment, i, segments) => { | |
// special case — /[...rest]/ could contain zero segments | |
const match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment); | |
if (match) { | |
names.push(match[1]); | |
types.push(match[2]); | |
return '(?:/(.*))?'; | |
} | |
const is_last = i === segments.length - 1; | |
return ( | |
segment && | |
'/' + | |
segment | |
.split(/\[(.+?)\]/) | |
.map((content, i) => { | |
if (i % 2) { | |
const [, rest, name, type] = /** @type {RegExpMatchArray} */ ( | |
param_pattern.exec(content) | |
); | |
names.push(name); | |
types.push(type); | |
return rest ? '(.*?)' : '([^/]+?)'; | |
} | |
if (is_last && content.includes('.')) add_trailing_slash = false; | |
return ( | |
content // allow users to specify characters on the file system in an encoded manner | |
.normalize() | |
// We use [ and ] to denote parameters, so users must encode these on the file | |
// system to match against them. We don't decode all characters since others | |
// can already be epressed and so that '%' can be easily used directly in filenames | |
.replace(/%5[Bb]/g, '[') | |
.replace(/%5[Dd]/g, ']') | |
// '#', '/', and '?' can only appear in URL path segments in an encoded manner. | |
// They will not be touched by decodeURI so need to be encoded here, so | |
// that we can match against them. | |
// We skip '/' since you can't create a file with it on any OS | |
.replace(/#/g, '%23') | |
.replace(/\?/g, '%3F') | |
// escape characters that have special meaning in regex | |
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
); // TODO handle encoding | |
}) | |
.join('') | |
); | |
}) | |
.join('')}${add_trailing_slash ? '/?' : ''}$` | |
); | |
return { pattern, names, types }; | |
} | |
/** | |
* @param {RegExpMatchArray} match | |
* @param {string[]} names | |
* @param {string[]} types | |
* @param {Record<string, import('types').ParamMatcher>} matchers | |
*/ | |
function exec(match, names, types, matchers) { | |
/** @type {Record<string, string>} */ | |
const params = {}; | |
for (let i = 0; i < names.length; i += 1) { | |
const name = names[i]; | |
const type = types[i]; | |
const value = match[i + 1] || ''; | |
if (type) { | |
const matcher = matchers[type]; | |
if (!matcher) throw new Error(`Missing "${type}" param matcher`); // TODO do this ahead of time? | |
if (!matcher(value)) return; | |
} | |
params[name] = value; | |
} | |
return params; | |
} | |
/** | |
* @param {import('types').CSRComponentLoader[]} components | |
* @param {Record<string, [number[], number[], 1?]>} dictionary | |
* @param {Record<string, (param: string) => boolean>} matchers | |
* @returns {import('types').CSRRoute[]} | |
*/ | |
function parse(components, dictionary, matchers) { | |
const routes = Object.entries(dictionary).map(([id, [a, b, has_shadow]]) => { | |
const { pattern, names, types } = parse_route_id(id); | |
return { | |
id, | |
/** @param {string} path */ | |
exec: (path) => { | |
const match = pattern.exec(path); | |
if (match) return exec(match, names, types, matchers); | |
}, | |
a: a.map((n) => components[n]), | |
b: b.map((n) => components[n]), | |
has_shadow: !!has_shadow | |
}; | |
}); | |
return routes; | |
} | |
const SCROLL_KEY = 'sveltekit:scroll'; | |
const INDEX_KEY = 'sveltekit:index'; | |
const routes = parse(components, dictionary, matchers); | |
// we import the root layout/error components eagerly, so that | |
// connectivity errors after initialisation don't nuke the app | |
const default_layout = components[0](); | |
const default_error = components[1](); | |
const root_stuff = {}; | |
// We track the scroll position associated with each history entry in sessionStorage, | |
// rather than on history.state itself, because when navigation is driven by | |
// popstate it's too late to update the scroll position associated with the | |
// state we're navigating from | |
/** @typedef {{ x: number, y: number }} ScrollPosition */ | |
/** @type {Record<number, ScrollPosition>} */ | |
let scroll_positions = {}; | |
try { | |
scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]); | |
} catch { | |
// do nothing | |
} | |
/** @param {number} index */ | |
function update_scroll_positions(index) { | |
scroll_positions[index] = scroll_state(); | |
} | |
/** | |
* @param {{ | |
* target: Element; | |
* session: App.Session; | |
* base: string; | |
* trailing_slash: import('types').TrailingSlash; | |
* }} opts | |
* @returns {import('./types').Client} | |
*/ | |
function create_client({ target, session, base, trailing_slash }) { | |
/** @type {Map<string, import('./types').NavigationResult>} */ | |
const cache = new Map(); | |
/** @type {Array<((href: string) => boolean)>} */ | |
const invalidated = []; | |
const stores = { | |
url: notifiable_store({}), | |
page: notifiable_store({}), | |
navigating: writable(/** @type {import('types').Navigation | null} */ (null)), | |
session: writable(session), | |
updated: create_updated_store() | |
}; | |
/** @type {{id: string | null, promise: Promise<import('./types').NavigationResult | undefined> | null}} */ | |
const load_cache = { | |
id: null, | |
promise: null | |
}; | |
const callbacks = { | |
/** @type {Array<(opts: { from: URL, to: URL | null, cancel: () => void }) => void>} */ | |
before_navigate: [], | |
/** @type {Array<(opts: { from: URL | null, to: URL }) => void>} */ | |
after_navigate: [] | |
}; | |
/** @type {import('./types').NavigationState} */ | |
let current = { | |
branch: [], | |
error: null, | |
session_id: 0, | |
stuff: root_stuff, | |
// @ts-ignore - we need the initial value to be null | |
url: null | |
}; | |
let started = false; | |
let autoscroll = true; | |
let updating = false; | |
let session_id = 1; | |
/** @type {Promise<void> | null} */ | |
let invalidating = null; | |
/** @type {import('svelte').SvelteComponent} */ | |
let root; | |
/** @type {App.Session} */ | |
let $session; | |
let ready = false; | |
stores.session.subscribe(async (value) => { | |
$session = value; | |
if (!ready) return; | |
session_id += 1; | |
update(new URL(location.href), [], true); | |
}); | |
ready = true; | |
let router_enabled = true; | |
// keeping track of the history index in order to prevent popstate navigation events if needed | |
let current_history_index = history.state?.[INDEX_KEY]; | |
if (!current_history_index) { | |
// we use Date.now() as an offset so that cross-document navigations | |
// within the app don't result in data loss | |
current_history_index = Date.now(); | |
// create initial history entry, so we can return here | |
history.replaceState( | |
{ ...history.state, [INDEX_KEY]: current_history_index }, | |
'', | |
location.href | |
); | |
} | |
// if we reload the page, or Cmd-Shift-T back to it, | |
// recover scroll position | |
const scroll = scroll_positions[current_history_index]; | |
if (scroll) { | |
history.scrollRestoration = 'manual'; | |
scrollTo(scroll.x, scroll.y); | |
} | |
let hash_navigating = false; | |
/** @type {import('types').Page} */ | |
let page; | |
/** @type {{}} */ | |
let token; | |
/** | |
* @param {string | URL} url | |
* @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts | |
* @param {string[]} redirect_chain | |
*/ | |
async function goto( | |
url, | |
{ noscroll = false, replaceState = false, keepfocus = false, state = {} }, | |
redirect_chain | |
) { | |
if (typeof url === 'string') { | |
url = new URL(url, get_base_uri(document)); | |
} | |
if (router_enabled) { | |
return navigate({ | |
url, | |
scroll: noscroll ? scroll_state() : null, | |
keepfocus, | |
redirect_chain, | |
details: { | |
state, | |
replaceState | |
}, | |
accepted: () => {}, | |
blocked: () => {} | |
}); | |
} | |
await native_navigation(url); | |
} | |
/** @param {URL} url */ | |
async function prefetch(url) { | |
const intent = get_navigation_intent(url); | |
if (!intent) { | |
throw new Error('Attempted to prefetch a URL that does not belong to this app'); | |
} | |
load_cache.promise = load_route(intent, false); | |
load_cache.id = intent.id; | |
return load_cache.promise; | |
} | |
/** | |
* Returns `true` if update completes, `false` if it is aborted | |
* @param {URL} url | |
* @param {string[]} redirect_chain | |
* @param {boolean} no_cache | |
* @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean, details: { replaceState: boolean, state: any } | null}} [opts] | |
* @param {() => void} [callback] | |
*/ | |
async function update(url, redirect_chain, no_cache, opts, callback) { | |
const intent = get_navigation_intent(url); | |
const current_token = (token = {}); | |
let navigation_result = intent && (await load_route(intent, no_cache)); | |
if ( | |
!navigation_result && | |
url.origin === location.origin && | |
url.pathname === location.pathname | |
) { | |
// this could happen in SPA fallback mode if the user navigated to | |
// `/non-existent-page`. if we fall back to reloading the page, it | |
// will create an infinite loop. so whereas we normally handle | |
// unknown routes by going to the server, in this special case | |
// we render a client-side error page instead | |
navigation_result = await load_root_error_page({ | |
status: 404, | |
error: new Error(`Not found: ${url.pathname}`), | |
url, | |
routeId: null | |
}); | |
} | |
if (!navigation_result) { | |
await native_navigation(url); | |
return false; // unnecessary, but TypeScript prefers it this way | |
} | |
// abort if user navigated during update | |
if (token !== current_token) return false; | |
invalidated.length = 0; | |
if (navigation_result.redirect) { | |
if (redirect_chain.length > 10 || redirect_chain.includes(url.pathname)) { | |
navigation_result = await load_root_error_page({ | |
status: 500, | |
error: new Error('Redirect loop'), | |
url, | |
routeId: null | |
}); | |
} else { | |
if (router_enabled) { | |
goto(new URL(navigation_result.redirect, url).href, {}, [ | |
...redirect_chain, | |
url.pathname | |
]); | |
} else { | |
await native_navigation(new URL(navigation_result.redirect, location.href)); | |
} | |
return false; | |
} | |
} else if (navigation_result.props?.page?.status >= 400) { | |
const updated = await stores.updated.check(); | |
if (updated) { | |
await native_navigation(url); | |
} | |
} | |
updating = true; | |
if (opts && opts.details) { | |
const { details } = opts; | |
const change = details.replaceState ? 0 : 1; | |
details.state[INDEX_KEY] = current_history_index += change; | |
history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url); | |
} | |
if (started) { | |
current = navigation_result.state; | |
if (navigation_result.props.page) { | |
navigation_result.props.page.url = url; | |
} | |
root.$set(navigation_result.props); | |
} else { | |
initialize(navigation_result); | |
} | |
// opts must be passed if we're navigating | |
if (opts) { | |
const { scroll, keepfocus } = opts; | |
if (!keepfocus) { | |
// Reset page selection and focus | |
// We try to mimic browsers' behaviour as closely as possible by targeting the | |
// first scrollable region, but unfortunately it's not a perfect match — e.g. | |
// shift-tabbing won't immediately cycle up from the end of the page on Chromium | |
// See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area | |
const root = document.body; | |
const tabindex = root.getAttribute('tabindex'); | |
getSelection()?.removeAllRanges(); | |
root.tabIndex = -1; | |
root.focus({ preventScroll: true }); | |
// restore `tabindex` as to prevent `root` from stealing input from elements | |
if (tabindex !== null) { | |
root.setAttribute('tabindex', tabindex); | |
} else { | |
root.removeAttribute('tabindex'); | |
} | |
} | |
// need to render the DOM before we can scroll to the rendered elements | |
await tick(); | |
if (autoscroll) { | |
const deep_linked = url.hash && document.getElementById(url.hash.slice(1)); | |
if (scroll) { | |
scrollTo(scroll.x, scroll.y); | |
} else if (deep_linked) { | |
// Here we use `scrollIntoView` on the element instead of `scrollTo` | |
// because it natively supports the `scroll-margin` and `scroll-behavior` | |
// CSS properties. | |
deep_linked.scrollIntoView(); | |
} else { | |
scrollTo(0, 0); | |
} | |
} | |
} else { | |
// in this case we're simply invalidating | |
await tick(); | |
} | |
load_cache.promise = null; | |
load_cache.id = null; | |
autoscroll = true; | |
if (navigation_result.props.page) { | |
page = navigation_result.props.page; | |
} | |
const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1]; | |
router_enabled = leaf_node?.module.router !== false; | |
if (callback) callback(); | |
updating = false; | |
} | |
/** @param {import('./types').NavigationResult} result */ | |
function initialize(result) { | |
current = result.state; | |
const style = document.querySelector('style[data-sveltekit]'); | |
if (style) style.remove(); | |
page = result.props.page; | |
root = new Root({ | |
target, | |
props: { ...result.props, stores }, | |
hydrate: true | |
}); | |
if (router_enabled) { | |
const navigation = { from: null, to: new URL(location.href) }; | |
callbacks.after_navigate.forEach((fn) => fn(navigation)); | |
} | |
started = true; | |
} | |
/** | |
* | |
* @param {{ | |
* url: URL; | |
* params: Record<string, string>; | |
* stuff: Record<string, any>; | |
* branch: Array<import('./types').BranchNode | undefined>; | |
* status: number; | |
* error: Error | null; | |
* routeId: string | null; | |
* }} opts | |
*/ | |
async function get_navigation_result_from_branch({ | |
url, | |
params, | |
stuff, | |
branch, | |
status, | |
error, | |
routeId | |
}) { | |
const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean)); | |
const redirect = filtered.find((f) => f.loaded?.redirect); | |
/** @type {import('./types').NavigationResult} */ | |
const result = { | |
redirect: redirect?.loaded?.redirect, | |
state: { | |
url, | |
params, | |
branch, | |
error, | |
stuff, | |
session_id | |
}, | |
props: { | |
components: filtered.map((node) => node.module.default) | |
} | |
}; | |
for (let i = 0; i < filtered.length; i += 1) { | |
const loaded = filtered[i].loaded; | |
result.props[`props_${i}`] = loaded ? await loaded.props : null; | |
} | |
const page_changed = | |
!current.url || | |
url.href !== current.url.href || | |
current.error !== error || | |
current.stuff !== stuff; | |
if (page_changed) { | |
result.props.page = { error, params, routeId, status, stuff, url }; | |
// TODO remove this for 1.0 | |
/** | |
* @param {string} property | |
* @param {string} replacement | |
*/ | |
const print_error = (property, replacement) => { | |
Object.defineProperty(result.props.page, property, { | |
get: () => { | |
throw new Error(`$page.${property} has been replaced by $page.url.${replacement}`); | |
} | |
}); | |
}; | |
print_error('origin', 'origin'); | |
print_error('path', 'pathname'); | |
print_error('query', 'searchParams'); | |
} | |
const leaf = filtered[filtered.length - 1]; | |
const load_cache = leaf?.loaded?.cache; | |
if (load_cache) { | |
const key = url.pathname + url.search; // omit hash | |
let ready = false; | |
const clear = () => { | |
if (cache.get(key) === result) { | |
cache.delete(key); | |
} | |
unsubscribe(); | |
clearTimeout(timeout); | |
}; | |
const timeout = setTimeout(clear, load_cache.maxage * 1000); | |
const unsubscribe = stores.session.subscribe(() => { | |
if (ready) clear(); | |
}); | |
ready = true; | |
cache.set(key, result); | |
} | |
return result; | |
} | |
/** | |
* @param {{ | |
* status?: number; | |
* error?: Error; | |
* module: import('types').CSRComponent; | |
* url: URL; | |
* params: Record<string, string>; | |
* stuff: Record<string, any>; | |
* props?: Record<string, any>; | |
* routeId: string | null; | |
* }} options | |
*/ | |
async function load_node({ status, error, module, url, params, stuff, props, routeId }) { | |
/** @type {import('./types').BranchNode} */ | |
const node = { | |
module, | |
uses: { | |
params: new Set(), | |
url: false, | |
session: false, | |
stuff: false, | |
dependencies: new Set() | |
}, | |
loaded: null, | |
stuff | |
}; | |
/** @param dep {string} */ | |
function add_dependency(dep) { | |
const { href } = new URL(dep, url); | |
node.uses.dependencies.add(href); | |
} | |
if (props) { | |
// shadow endpoint props means we need to mark this URL as a dependency of itself | |
node.uses.dependencies.add(url.href); | |
} | |
/** @type {Record<string, string>} */ | |
const uses_params = {}; | |
for (const key in params) { | |
Object.defineProperty(uses_params, key, { | |
get() { | |
node.uses.params.add(key); | |
return params[key]; | |
}, | |
enumerable: true | |
}); | |
} | |
const session = $session; | |
const load_url = new LoadURL(url); | |
if (module.load) { | |
/** @type {import('types').LoadEvent} */ | |
const load_input = { | |
routeId, | |
params: uses_params, | |
props: props || {}, | |
get url() { | |
node.uses.url = true; | |
return load_url; | |
}, | |
get session() { | |
node.uses.session = true; | |
return session; | |
}, | |
get stuff() { | |
node.uses.stuff = true; | |
return { ...stuff }; | |
}, | |
async fetch(resource, init) { | |
let requested; | |
if (typeof resource === 'string') { | |
requested = resource; | |
} else { | |
requested = resource.url; | |
// we're not allowed to modify the received `Request` object, so in order | |
// to fixup relative urls we create a new equivalent `init` object instead | |
init = { | |
// the request body must be consumed in memory until browsers | |
// implement streaming request bodies and/or the body getter | |
body: | |
resource.method === 'GET' || resource.method === 'HEAD' | |
? undefined | |
: await resource.blob(), | |
cache: resource.cache, | |
credentials: resource.credentials, | |
headers: resource.headers, | |
integrity: resource.integrity, | |
keepalive: resource.keepalive, | |
method: resource.method, | |
mode: resource.mode, | |
redirect: resource.redirect, | |
referrer: resource.referrer, | |
referrerPolicy: resource.referrerPolicy, | |
signal: resource.signal, | |
...init | |
}; | |
} | |
// we must fixup relative urls so they are resolved from the target page | |
const normalized = new URL(requested, url).href; | |
add_dependency(normalized); | |
// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be normalized | |
return started ? native_fetch(normalized, init) : initial_fetch(requested, init); | |
}, | |
status: status ?? null, | |
error: error ?? null | |
}; | |
if (import.meta.env.DEV) { | |
// TODO remove this for 1.0 | |
Object.defineProperty(load_input, 'page', { | |
get: () => { | |
throw new Error('`page` in `load` functions has been replaced by `url` and `params`'); | |
} | |
}); | |
} | |
let loaded; | |
if (import.meta.env.DEV) { | |
try { | |
lock_fetch(); | |
loaded = await module.load.call(null, load_input); | |
} finally { | |
unlock_fetch(); | |
} | |
} else { | |
loaded = await module.load.call(null, load_input); | |
} | |
if (!loaded) { | |
throw new Error('load function must return a value'); | |
} | |
node.loaded = normalize(loaded); | |
if (node.loaded.stuff) node.stuff = node.loaded.stuff; | |
if (node.loaded.dependencies) { | |
node.loaded.dependencies.forEach(add_dependency); | |
} | |
} else if (props) { | |
node.loaded = normalize({ props }); | |
} | |
return node; | |
} | |
/** | |
* @param {import('./types').NavigationIntent} intent | |
* @param {boolean} no_cache | |
*/ | |
async function load_route({ id, url, params, route }, no_cache) { | |
if (load_cache.id === id && load_cache.promise) { | |
return load_cache.promise; | |
} | |
if (!no_cache) { | |
const cached = cache.get(id); | |
if (cached) return cached; | |
} | |
const { a, b, has_shadow } = route; | |
const changed = current.url && { | |
url: id !== current.url.pathname + current.url.search, | |
params: Object.keys(params).filter((key) => current.params[key] !== params[key]), | |
session: session_id !== current.session_id | |
}; | |
/** @type {Array<import('./types').BranchNode | undefined>} */ | |
let branch = []; | |
/** @type {Record<string, any>} */ | |
let stuff = root_stuff; | |
let stuff_changed = false; | |
/** @type {number | undefined} */ | |
let status = 200; | |
/** @type {Error | null} */ | |
let error = null; | |
// preload modules to avoid waterfall, but handle rejections | |
// so they don't get reported to Sentry et al (we don't need | |
// to act on the failures at this point) | |
a.forEach((loader) => loader().catch(() => {})); | |
load: for (let i = 0; i < a.length; i += 1) { | |
/** @type {import('./types').BranchNode | undefined} */ | |
let node; | |
try { | |
if (!a[i]) continue; | |
const module = await a[i](); | |
const previous = current.branch[i]; | |
const changed_since_last_render = | |
!previous || | |
module !== previous.module || | |
(changed.url && previous.uses.url) || | |
changed.params.some((param) => previous.uses.params.has(param)) || | |
(changed.session && previous.uses.session) || | |
Array.from(previous.uses.dependencies).some((dep) => invalidated.some((fn) => fn(dep))) || | |
(stuff_changed && previous.uses.stuff); | |
if (changed_since_last_render) { | |
/** @type {Record<string, any>} */ | |
let props = {}; | |
const is_shadow_page = has_shadow && i === a.length - 1; | |
if (is_shadow_page) { | |
const res = await native_fetch( | |
`${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`, | |
{ | |
headers: { | |
'x-sveltekit-load': 'true' | |
} | |
} | |
); | |
if (res.ok) { | |
const redirect = res.headers.get('x-sveltekit-location'); | |
if (redirect) { | |
return { | |
redirect, | |
props: {}, | |
state: current | |
}; | |
} | |
props = res.status === 204 ? {} : await res.json(); | |
} else { | |
status = res.status; | |
error = new Error('Failed to load data'); | |
} | |
} | |
if (!error) { | |
node = await load_node({ | |
module, | |
url, | |
params, | |
props, | |
stuff, | |
routeId: route.id | |
}); | |
} | |
if (node) { | |
if (is_shadow_page) { | |
node.uses.url = true; | |
} | |
if (node.loaded) { | |
if (node.loaded.error) { | |
status = node.loaded.status; | |
error = node.loaded.error; | |
} | |
if (node.loaded.redirect) { | |
return { | |
redirect: node.loaded.redirect, | |
props: {}, | |
state: current | |
}; | |
} | |
if (node.loaded.stuff) { | |
stuff_changed = true; | |
} | |
} | |
} | |
} else { | |
node = previous; | |
} | |
} catch (e) { | |
status = 500; | |
error = coalesce_to_error(e); | |
} | |
if (error) { | |
while (i--) { | |
if (b[i]) { | |
let error_loaded; | |
/** @type {import('./types').BranchNode | undefined} */ | |
let node_loaded; | |
let j = i; | |
while (!(node_loaded = branch[j])) { | |
j -= 1; | |
} | |
try { | |
error_loaded = await load_node({ | |
status, | |
error, | |
module: await b[i](), | |
url, | |
params, | |
stuff: node_loaded.stuff, | |
routeId: route.id | |
}); | |
if (error_loaded?.loaded?.error) { | |
continue; | |
} | |
if (error_loaded?.loaded?.stuff) { | |
stuff = { | |
...stuff, | |
...error_loaded.loaded.stuff | |
}; | |
} | |
branch = branch.slice(0, j + 1).concat(error_loaded); | |
break load; | |
} catch (e) { | |
continue; | |
} | |
} | |
} | |
return await load_root_error_page({ | |
status, | |
error, | |
url, | |
routeId: route.id | |
}); | |
} else { | |
if (node?.loaded?.stuff) { | |
stuff = { | |
...stuff, | |
...node.loaded.stuff | |
}; | |
} | |
branch.push(node); | |
} | |
} | |
return await get_navigation_result_from_branch({ | |
url, | |
params, | |
stuff, | |
branch, | |
status, | |
error, | |
routeId: route.id | |
}); | |
} | |
/** | |
* @param {{ | |
* status: number; | |
* error: Error; | |
* url: URL; | |
* routeId: string | null | |
* }} opts | |
*/ | |
async function load_root_error_page({ status, error, url, routeId }) { | |
/** @type {Record<string, string>} */ | |
const params = {}; // error page does not have params | |
const root_layout = await load_node({ | |
module: await default_layout, | |
url, | |
params, | |
stuff: {}, | |
routeId | |
}); | |
const root_error = await load_node({ | |
status, | |
error, | |
module: await default_error, | |
url, | |
params, | |
stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {}, | |
routeId | |
}); | |
return await get_navigation_result_from_branch({ | |
url, | |
params, | |
stuff: { | |
...root_layout?.loaded?.stuff, | |
...root_error?.loaded?.stuff | |
}, | |
branch: [root_layout, root_error], | |
status, | |
error, | |
routeId | |
}); | |
} | |
/** @param {URL} url */ | |
function get_navigation_intent(url) { | |
if (url.origin !== location.origin || !url.pathname.startsWith(base)) return; | |
const path = decodeURI(url.pathname.slice(base.length) || '/'); | |
for (const route of routes) { | |
const params = route.exec(path); | |
if (params) { | |
/** @type {import('./types').NavigationIntent} */ | |
const intent = { | |
id: url.pathname + url.search, | |
route, | |
params, | |
url | |
}; | |
return intent; | |
} | |
} | |
} | |
/** | |
* @param {{ | |
* url: URL; | |
* scroll: { x: number, y: number } | null; | |
* keepfocus: boolean; | |
* redirect_chain: string[]; | |
* details: { | |
* replaceState: boolean; | |
* state: any; | |
* } | null; | |
* accepted: () => void; | |
* blocked: () => void; | |
* }} opts | |
*/ | |
async function navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) { | |
const from = current.url; | |
let should_block = false; | |
const navigation = { | |
from, | |
to: url, | |
cancel: () => (should_block = true) | |
}; | |
callbacks.before_navigate.forEach((fn) => fn(navigation)); | |
if (should_block) { | |
blocked(); | |
return; | |
} | |
const pathname = normalize_path(url.pathname, trailing_slash); | |
const normalized = new URL(url.origin + pathname + url.search + url.hash); | |
update_scroll_positions(current_history_index); | |
accepted(); | |
if (started) { | |
stores.navigating.set({ | |
from: current.url, | |
to: normalized | |
}); | |
} | |
await update( | |
normalized, | |
redirect_chain, | |
false, | |
{ | |
scroll, | |
keepfocus, | |
details | |
}, | |
() => { | |
const navigation = { from, to: normalized }; | |
callbacks.after_navigate.forEach((fn) => fn(navigation)); | |
stores.navigating.set(null); | |
} | |
); | |
} | |
/** | |
* Loads `href` the old-fashioned way, with a full page reload. | |
* Returns a `Promise` that never resolves (to prevent any | |
* subsequent work, e.g. history manipulation, from happening) | |
* @param {URL} url | |
*/ | |
function native_navigation(url) { | |
location.href = url.href; | |
return new Promise(() => {}); | |
} | |
if (import.meta.hot) { | |
import.meta.hot.on('vite:beforeUpdate', () => { | |
if (current.error) location.reload(); | |
}); | |
} | |
return { | |
after_navigate: (fn) => { | |
onMount(() => { | |
callbacks.after_navigate.push(fn); | |
return () => { | |
const i = callbacks.after_navigate.indexOf(fn); | |
callbacks.after_navigate.splice(i, 1); | |
}; | |
}); | |
}, | |
before_navigate: (fn) => { | |
onMount(() => { | |
callbacks.before_navigate.push(fn); | |
return () => { | |
const i = callbacks.before_navigate.indexOf(fn); | |
callbacks.before_navigate.splice(i, 1); | |
}; | |
}); | |
}, | |
disable_scroll_handling: () => { | |
if (import.meta.env.DEV && started && !updating) { | |
throw new Error('Can only disable scroll handling during navigation'); | |
} | |
if (updating || !started) { | |
autoscroll = false; | |
} | |
}, | |
goto: (href, opts = {}) => goto(href, opts, []), | |
invalidate: (resource) => { | |
if (typeof resource === 'function') { | |
invalidated.push(resource); | |
} else { | |
const { href } = new URL(resource, location.href); | |
invalidated.push((dep) => dep === href); | |
} | |
if (!invalidating) { | |
invalidating = Promise.resolve().then(async () => { | |
await update(new URL(location.href), [], true); | |
invalidating = null; | |
}); | |
} | |
return invalidating; | |
}, | |
prefetch: async (href) => { | |
const url = new URL(href, get_base_uri(document)); | |
await prefetch(url); | |
}, | |
// TODO rethink this API | |
prefetch_routes: async (pathnames) => { | |
const matching = pathnames | |
? routes.filter((route) => pathnames.some((pathname) => route.exec(pathname))) | |
: routes; | |
const promises = matching.map((r) => Promise.all(r.a.map((load) => load()))); | |
await Promise.all(promises); | |
}, | |
_start_router: () => { | |
history.scrollRestoration = 'manual'; | |
// Adopted from Nuxt.js | |
// Reset scrollRestoration to auto when leaving page, allowing page reload | |
// and back-navigation from other pages to use the browser to restore the | |
// scrolling position. | |
addEventListener('beforeunload', (e) => { | |
let should_block = false; | |
const navigation = { | |
from: current.url, | |
to: null, | |
cancel: () => (should_block = true) | |
}; | |
callbacks.before_navigate.forEach((fn) => fn(navigation)); | |
if (should_block) { | |
e.preventDefault(); | |
e.returnValue = ''; | |
} else { | |
history.scrollRestoration = 'auto'; | |
} | |
}); | |
addEventListener('visibilitychange', () => { | |
if (document.visibilityState === 'hidden') { | |
update_scroll_positions(current_history_index); | |
try { | |
sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); | |
} catch { | |
// do nothing | |
} | |
} | |
}); | |
/** @param {Event} event */ | |
const trigger_prefetch = (event) => { | |
const a = find_anchor(event); | |
if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { | |
prefetch(get_href(a)); | |
} | |
}; | |
/** @type {NodeJS.Timeout} */ | |
let mousemove_timeout; | |
/** @param {MouseEvent|TouchEvent} event */ | |
const handle_mousemove = (event) => { | |
clearTimeout(mousemove_timeout); | |
mousemove_timeout = setTimeout(() => { | |
// event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout | |
// add a layer of indirection to address that | |
event.target?.dispatchEvent( | |
new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) | |
); | |
}, 20); | |
}; | |
addEventListener('touchstart', trigger_prefetch); | |
addEventListener('mousemove', handle_mousemove); | |
addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); | |
/** @param {MouseEvent} event */ | |
addEventListener('click', (event) => { | |
if (!router_enabled) return; | |
// Adapted from https://github.com/visionmedia/page.js | |
// MIT license https://github.com/visionmedia/page.js#license | |
if (event.button || event.which !== 1) return; | |
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; | |
if (event.defaultPrevented) return; | |
const a = find_anchor(event); | |
if (!a) return; | |
if (!a.href) return; | |
const is_svg_a_element = a instanceof SVGAElement; | |
const url = get_href(a); | |
// Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) | |
// MEMO: Without this condition, firefox will open mailer twice. | |
// See: https://github.com/sveltejs/kit/issues/4045 | |
if (!is_svg_a_element && url.origin === 'null') return; | |
// Ignore if tag has | |
// 1. 'download' attribute | |
// 2. 'rel' attribute includes external | |
const rel = (a.getAttribute('rel') || '').split(/\s+/); | |
if ( | |
a.hasAttribute('download') || | |
rel.includes('external') || | |
a.hasAttribute('sveltekit:reload') | |
) { | |
return; | |
} | |
// Ignore if <a> has a target | |
if (is_svg_a_element ? a.target.baseVal : a.target) return; | |
// Check if new url only differs by hash and use the browser default behavior in that case | |
// This will ensure the `hashchange` event is fired | |
// Removing the hash does a full page navigation in the browser, so make sure a hash is present | |
const [base, hash] = url.href.split('#'); | |
if (hash !== undefined && base === location.href.split('#')[0]) { | |
// set this flag to distinguish between navigations triggered by | |
// clicking a hash link and those triggered by popstate | |
hash_navigating = true; | |
update_scroll_positions(current_history_index); | |
stores.page.set({ ...page, url }); | |
stores.page.notify(); | |
return; | |
} | |
navigate({ | |
url, | |
scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, | |
keepfocus: false, | |
redirect_chain: [], | |
details: { | |
state: {}, | |
replaceState: url.href === location.href | |
}, | |
accepted: () => event.preventDefault(), | |
blocked: () => event.preventDefault() | |
}); | |
}); | |
addEventListener('popstate', (event) => { | |
if (event.state && router_enabled) { | |
// if a popstate-driven navigation is cancelled, we need to counteract it | |
// with history.go, which means we end up back here, hence this check | |
if (event.state[INDEX_KEY] === current_history_index) return; | |
navigate({ | |
url: new URL(location.href), | |
scroll: scroll_positions[event.state[INDEX_KEY]], | |
keepfocus: false, | |
redirect_chain: [], | |
details: null, | |
accepted: () => { | |
current_history_index = event.state[INDEX_KEY]; | |
}, | |
blocked: () => { | |
const delta = current_history_index - event.state[INDEX_KEY]; | |
history.go(delta); | |
} | |
}); | |
} | |
}); | |
addEventListener('hashchange', () => { | |
// if the hashchange happened as a result of clicking on a link, | |
// we need to update history, otherwise we have to leave it alone | |
if (hash_navigating) { | |
hash_navigating = false; | |
history.replaceState( | |
{ ...history.state, [INDEX_KEY]: ++current_history_index }, | |
'', | |
location.href | |
); | |
} | |
}); | |
}, | |
_hydrate: async ({ status, error, nodes, params, routeId }) => { | |
const url = new URL(location.href); | |
/** @type {Array<import('./types').BranchNode | undefined>} */ | |
const branch = []; | |
/** @type {Record<string, any>} */ | |
let stuff = {}; | |
/** @type {import('./types').NavigationResult | undefined} */ | |
let result; | |
let error_args; | |
try { | |
for (let i = 0; i < nodes.length; i += 1) { | |
const is_leaf = i === nodes.length - 1; | |
let props; | |
if (is_leaf) { | |
const serialized = document.querySelector('script[sveltekit\\:data-type="props"]'); | |
if (serialized) { | |
props = JSON.parse(/** @type {string} */ (serialized.textContent)); | |
} | |
} | |
const node = await load_node({ | |
module: await components[nodes[i]](), | |
url, | |
params, | |
stuff, | |
status: is_leaf ? status : undefined, | |
error: is_leaf ? error : undefined, | |
props, | |
routeId | |
}); | |
if (props) { | |
node.uses.dependencies.add(url.href); | |
node.uses.url = true; | |
} | |
branch.push(node); | |
if (node && node.loaded) { | |
if (node.loaded.error) { | |
if (error) throw node.loaded.error; | |
error_args = { | |
status: node.loaded.status, | |
error: node.loaded.error, | |
url, | |
routeId | |
}; | |
} else if (node.loaded.stuff) { | |
stuff = { | |
...stuff, | |
...node.loaded.stuff | |
}; | |
} | |
} | |
} | |
result = error_args | |
? await load_root_error_page(error_args) | |
: await get_navigation_result_from_branch({ | |
url, | |
params, | |
stuff, | |
branch, | |
status, | |
error, | |
routeId | |
}); | |
} catch (e) { | |
if (error) throw e; | |
result = await load_root_error_page({ | |
status: 500, | |
error: coalesce_to_error(e), | |
url, | |
routeId | |
}); | |
} | |
if (result.redirect) { | |
// this is a real edge case — `load` would need to return | |
// a redirect but only in the browser | |
await native_navigation(new URL(result.redirect, location.href)); | |
} | |
initialize(result); | |
} | |
}; | |
} | |
/** | |
* @param {{ | |
* paths: { | |
* assets: string; | |
* base: string; | |
* }, | |
* target: Element; | |
* session: any; | |
* route: boolean; | |
* spa: boolean; | |
* trailing_slash: import('types').TrailingSlash; | |
* hydrate: { | |
* status: number; | |
* error: Error; | |
* nodes: number[]; | |
* params: Record<string, string>; | |
* routeId: string | null; | |
* }; | |
* }} opts | |
*/ | |
async function start({ paths, target, session, route, spa, trailing_slash, hydrate }) { | |
const client = create_client({ | |
target, | |
session, | |
base: paths.base, | |
trailing_slash | |
}); | |
init({ client }); | |
set_paths(paths); | |
if (hydrate) { | |
await client._hydrate(hydrate); | |
} | |
if (route) { | |
if (spa) client.goto(location.href, { replaceState: true }); | |
client._start_router(); | |
} | |
dispatchEvent(new CustomEvent('sveltekit:start')); | |
} | |
export { start }; | |