Files
sveltesociety.dev/.svelte/dev/runtime/internal/start.js
2021-06-24 17:24:42 +02:00

1022 lines
23 KiB
JavaScript

import Root from '../../generated/root.svelte';
import { routes, fallback } from '../../generated/manifest.js';
import { g as get_base_uri } from '../chunks/utils.js';
import { writable } from 'svelte/store';
import { init } from './singletons.js';
import { set_paths } from '../paths.js';
function scroll_state() {
return {
x: pageXOffset,
y: pageYOffset
};
}
/**
* @param {Node} node
* @returns {HTMLAnchorElement | SVGAElement}
*/
function find_anchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
return /** @type {HTMLAnchorElement | SVGAElement} */ (node);
}
class Router {
/** @param {{
* base: string;
* routes: import('types/internal').CSRRoute[];
* }} opts */
constructor({ base, routes }) {
this.base = base;
this.routes = routes;
}
/** @param {import('./renderer').Renderer} renderer */
init(renderer) {
/** @type {import('./renderer').Renderer} */
this.renderer = renderer;
renderer.router = this;
this.enabled = true;
if ('scrollRestoration' in history) {
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', () => {
history.scrollRestoration = 'auto';
});
// Setting scrollRestoration to manual again when returning to this page.
addEventListener('load', () => {
history.scrollRestoration = 'manual';
});
// There's no API to capture the scroll location right before the user
// hits the back/forward button, so we listen for scroll events
/** @type {NodeJS.Timeout} */
let scroll_timer;
addEventListener('scroll', () => {
clearTimeout(scroll_timer);
scroll_timer = setTimeout(() => {
// Store the scroll location in the history
// This will persist even if we navigate away from the site and come back
const new_state = {
...(history.state || {}),
'sveltekit:scroll': scroll_state()
};
history.replaceState(new_state, document.title, window.location.href);
}, 50);
});
/** @param {MouseEvent} event */
const trigger_prefetch = (event) => {
const a = find_anchor(/** @type {Node} */ (event.target));
if (a && a.href && a.hasAttribute('sveltekit:prefetch')) {
this.prefetch(new URL(/** @type {string} */ (a.href)));
}
};
/** @type {NodeJS.Timeout} */
let mousemove_timeout;
/** @param {MouseEvent} event */
const handle_mousemove = (event) => {
clearTimeout(mousemove_timeout);
mousemove_timeout = setTimeout(() => {
trigger_prefetch(event);
}, 20);
};
addEventListener('touchstart', trigger_prefetch);
addEventListener('mousemove', handle_mousemove);
/** @param {MouseEvent} event */
addEventListener('click', (event) => {
if (!this.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(/** @type {Node} */ (event.target));
if (!a) return;
if (!a.href) return;
// check if link is inside an svg
// in this case, both href and target are always inside an object
const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
const href = String(svg ? /** @type {SVGAElement} */ (a).href.baseVal : a.href);
if (href === location.href) {
if (!location.hash) event.preventDefault();
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 && rel.includes('external'))) {
return;
}
// Ignore if <a> has a target
if (svg ? /** @type {SVGAElement} */ (a).target.baseVal : a.target) return;
const url = new URL(href);
// Don't handle hash changes
if (url.pathname === location.pathname && url.search === location.search) return;
const noscroll = a.hasAttribute('sveltekit:noscroll');
history.pushState({}, '', url.href);
this._navigate(url, noscroll ? scroll_state() : null, [], url.hash);
event.preventDefault();
});
addEventListener('popstate', (event) => {
if (event.state && this.enabled) {
const url = new URL(location.href);
this._navigate(url, event.state['sveltekit:scroll'], []);
}
});
// make it possible to reset focus
document.body.setAttribute('tabindex', '-1');
// create initial history entry, so we can return here
history.replaceState(history.state || {}, '', location.href);
}
/**
* @param {URL} url
* @returns {import('./types').NavigationInfo}
*/
parse(url) {
if (url.origin !== location.origin) return null;
if (!url.pathname.startsWith(this.base)) return null;
const path = decodeURIComponent(url.pathname.slice(this.base.length) || '/');
const routes = this.routes.filter(([pattern]) => pattern.test(path));
const query = new URLSearchParams(url.search);
const id = `${path}?${query}`;
return { id, routes, path, query };
}
/**
* @param {string} href
* @param {{ noscroll?: boolean, replaceState?: boolean }} opts
* @param {string[]} chain
*/
async goto(href, { noscroll = false, replaceState = false } = {}, chain) {
if (this.enabled) {
const url = new URL(href, get_base_uri(document));
history[replaceState ? 'replaceState' : 'pushState']({}, '', href);
return this._navigate(url, noscroll ? scroll_state() : null, chain, url.hash);
}
location.href = href;
return new Promise(() => {
/* never resolves */
});
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
/**
* @param {URL} url
* @returns {Promise<import('./types').NavigationResult>}
*/
async prefetch(url) {
return this.renderer.load(this.parse(url));
}
/**
* @param {URL} url
* @param {{ x: number, y: number }} scroll
* @param {string[]} chain
* @param {string} [hash]
*/
async _navigate(url, scroll, chain, hash) {
const info = this.parse(url);
this.renderer.notify({
path: info.path,
query: info.query
});
// remove trailing slashes
if (location.pathname.endsWith('/') && location.pathname !== '/') {
history.replaceState({}, '', `${location.pathname.slice(0, -1)}${location.search}`);
}
await this.renderer.update(info, chain);
document.body.focus();
const deep_linked = hash && document.getElementById(hash.slice(1));
if (scroll) {
scrollTo(scroll.x, scroll.y);
} else if (deep_linked) {
// scroll is an element id (from a hash), we need to compute y
scrollTo(0, deep_linked.getBoundingClientRect().top + scrollY);
} else {
scrollTo(0, 0);
}
}
}
/**
* @param {import('types/page').LoadOutput} loaded
* @returns {import('types/page').LoadOutput}
*/
function normalize(loaded) {
// TODO should this behaviour be dev-only?
if (loaded.error) {
const error = typeof loaded.error === 'string' ? new Error(loaded.error) : loaded.error;
const status = loaded.status;
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) {
return {
status: 500,
error: new Error(
'"redirect" property returned from load() must be accompanied by a 3xx status code'
)
};
}
if (typeof loaded.redirect !== 'string') {
return {
status: 500,
error: new Error('"redirect" property returned from load() must be a string')
};
}
}
return loaded;
}
/** @param {any} value */
function page_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 };
}
/**
* @param {RequestInfo} resource
* @param {RequestInit} opts
*/
function initial_fetch(resource, opts) {
const url = typeof resource === 'string' ? resource : resource.url;
const script = document.querySelector(`script[type="svelte-data"][url="${url}"]`);
if (script) {
const { body, ...init } = JSON.parse(script.textContent);
return Promise.resolve(new Response(body, init));
}
return fetch(resource, opts);
}
/** @typedef {import('types/internal').CSRComponent} CSRComponent */
class Renderer {
/** @param {{
* Root: CSRComponent;
* fallback: [CSRComponent, CSRComponent];
* target: Node;
* session: any;
* host: string;
* }} opts */
constructor({ Root, fallback, target, session, host }) {
this.Root = Root;
this.fallback = fallback;
this.host = host;
/** @type {import('./router').Router} */
this.router = null;
this.target = target;
this.started = false;
this.session_id = 1;
/** @type {import('./types').NavigationState} */
this.current = {
page: null,
session_id: null,
branch: []
};
/** @type {Map<string, import('./types').NavigationResult>} */
this.cache = new Map();
this.loading = {
id: null,
promise: null
};
this.stores = {
page: page_store({}),
navigating: writable(null),
session: writable(session)
};
this.$session = null;
this.root = null;
let ready = false;
this.stores.session.subscribe(async (value) => {
this.$session = value;
if (!ready) return;
this.session_id += 1;
const info = this.router.parse(new URL(location.href));
this.update(info, []);
});
ready = true;
}
/**
* @param {{
* status: number;
* error: Error;
* nodes: Array<Promise<CSRComponent>>;
* page: import('types/page').Page;
* }} selected
*/
async start({ status, error, nodes, page }) {
/** @type {import('./types').BranchNode[]} */
const branch = [];
/** @type {Record<string, any>} */
let context = {};
/** @type {import('./types').NavigationResult} */
let result;
/** @type {number} */
let new_status;
/** @type {Error} new_error */
let new_error;
try {
for (let i = 0; i < nodes.length; i += 1) {
const is_leaf = i === nodes.length - 1;
const node = await this._load_node({
module: await nodes[i],
page,
context,
status: is_leaf && status,
error: is_leaf && error
});
branch.push(node);
if (node && node.loaded) {
if (node.loaded.error) {
if (error) throw node.loaded.error;
new_status = node.loaded.status;
new_error = node.loaded.error;
} else if (node.loaded.context) {
context = {
...context,
...node.loaded.context
};
}
}
}
result = await this._get_navigation_result_from_branch({ page, branch });
} catch (e) {
if (error) throw e;
new_status = 500;
new_error = e;
}
if (new_error) {
result = await this._load_error({
status: new_status,
error: new_error,
path: page.path,
query: page.query
});
}
if (result.redirect) {
// this is a real edge case — `load` would need to return
// a redirect but only in the browser
location.href = new URL(result.redirect, location.href).href;
return;
}
this._init(result);
}
/** @param {{ path: string, query: URLSearchParams }} destination */
notify({ path, query }) {
dispatchEvent(new CustomEvent('sveltekit:navigation-start'));
if (this.started) {
this.stores.navigating.set({
from: {
path: this.current.page.path,
query: this.current.page.query
},
to: {
path,
query
}
});
}
}
/**
* @param {import('./types').NavigationInfo} info
* @param {string[]} chain
*/
async update(info, chain) {
const token = (this.token = {});
let navigation_result = await this._get_navigation_result(info);
// abort if user navigated during update
if (token !== this.token) return;
if (navigation_result.redirect) {
if (chain.length > 10 || chain.includes(info.path)) {
navigation_result = await this._load_error({
status: 500,
error: new Error('Redirect loop'),
path: info.path,
query: info.query
});
} else {
if (this.router) {
this.router.goto(navigation_result.redirect, { replaceState: true }, [
...chain,
info.path
]);
} else {
location.href = new URL(navigation_result.redirect, location.href).href;
}
return;
}
}
if (navigation_result.reload) {
location.reload();
} else if (this.started) {
this.current = navigation_result.state;
this.root.$set(navigation_result.props);
this.stores.navigating.set(null);
await 0;
} else {
this._init(navigation_result);
}
dispatchEvent(new CustomEvent('sveltekit:navigation-end'));
this.loading.promise = null;
this.loading.id = null;
const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1];
if (leaf_node && leaf_node.module.router === false) {
this.router.disable();
} else {
this.router.enable();
}
}
/**
* @param {import('./types').NavigationInfo} info
* @returns {Promise<import('./types').NavigationResult>}
*/
load(info) {
this.loading.promise = this._get_navigation_result(info);
this.loading.id = info.id;
return this.loading.promise;
}
/** @param {import('./types').NavigationResult} result */
_init(result) {
this.current = result.state;
const style = document.querySelector('style[data-svelte]');
if (style) style.remove();
this.root = new this.Root({
target: this.target,
props: {
stores: this.stores,
...result.props
},
hydrate: true
});
this.started = true;
}
/**
* @param {import('./types').NavigationInfo} info
* @returns {Promise<import('./types').NavigationResult>}
*/
async _get_navigation_result(info) {
if (this.loading.id === info.id) {
return this.loading.promise;
}
for (let i = 0; i < info.routes.length; i += 1) {
const route = info.routes[i];
if (route.length === 1) {
return { reload: true };
}
// load code for subsequent routes immediately, if they are as
// likely to match the current path/query as the current one
let j = i + 1;
while (j < info.routes.length) {
const next = info.routes[j];
if (next[0].toString() === route[0].toString()) {
if (next.length !== 1) next[1].forEach((loader) => loader());
j += 1;
} else {
break;
}
}
const result = await this._load({ route, path: info.path, query: info.query });
if (result) return result;
}
return await this._load_error({
status: 404,
error: new Error(`Not found: ${info.path}`),
path: info.path,
query: info.query
});
}
/**
*
* @param {{
* page: import('types/page').Page;
* branch: import('./types').BranchNode[]
* }} opts
*/
async _get_navigation_result_from_branch({ page, branch }) {
const filtered = branch.filter(Boolean);
/** @type {import('./types').NavigationResult} */
const result = {
state: {
page,
branch,
session_id: this.session_id
},
props: {
components: filtered.map((node) => node.module.default)
}
};
for (let i = 0; i < filtered.length; i += 1) {
if (filtered[i].loaded) result.props[`props_${i}`] = await filtered[i].loaded.props;
}
if (
!this.current.page ||
page.path !== this.current.page.path ||
page.query.toString() !== this.current.page.query.toString()
) {
result.props.page = page;
}
const leaf = filtered[filtered.length - 1];
const maxage = leaf.loaded && leaf.loaded.maxage;
if (maxage) {
const hash = `${page.path}?${page.query}`;
let ready = false;
const clear = () => {
if (this.cache.get(hash) === result) {
this.cache.delete(hash);
}
unsubscribe();
clearTimeout(timeout);
};
const timeout = setTimeout(clear, maxage * 1000);
const unsubscribe = this.stores.session.subscribe(() => {
if (ready) clear();
});
ready = true;
this.cache.set(hash, result);
}
return result;
}
/**
*
* @param {{
* status?: number;
* error?: Error;
* module: CSRComponent;
* page: import('types/page').Page;
* context: Record<string, any>;
* }} options
* @returns
*/
async _load_node({ status, error, module, page, context }) {
/** @type {import('./types').BranchNode} */
const node = {
module,
uses: {
params: new Set(),
path: false,
query: false,
session: false,
context: false
},
loaded: null,
context
};
/** @type {Record<string, string>} */
const params = {};
for (const key in page.params) {
Object.defineProperty(params, key, {
get() {
node.uses.params.add(key);
return page.params[key];
},
enumerable: true
});
}
const session = this.$session;
if (module.load) {
/** @type {import('types/page').LoadInput | import('types/page').ErrorLoadInput} */
const load_input = {
page: {
host: page.host,
params,
get path() {
node.uses.path = true;
return page.path;
},
get query() {
node.uses.query = true;
return page.query;
}
},
get session() {
node.uses.session = true;
return session;
},
get context() {
node.uses.context = true;
return { ...context };
},
fetch: this.started ? fetch : initial_fetch
};
if (error) {
/** @type {import('types/page').ErrorLoadInput} */ (load_input).status = status;
/** @type {import('types/page').ErrorLoadInput} */ (load_input).error = error;
}
const loaded = await module.load.call(null, load_input);
// if the page component returns nothing from load, fall through
if (!loaded) return;
node.loaded = normalize(loaded);
if (node.loaded.context) node.context = node.loaded.context;
}
return node;
}
/**
* @param {import('./types').NavigationCandidate} selected
* @returns {Promise<import('./types').NavigationResult>}
*/
async _load({ route, path, query }) {
const hash = `${path}?${query}`;
if (this.cache.has(hash)) {
return this.cache.get(hash);
}
const [pattern, a, b, get_params] = route;
const params = get_params ? get_params(pattern.exec(path)) : {};
const changed = this.current.page && {
path: path !== this.current.page.path,
params: Object.keys(params).filter((key) => this.current.page.params[key] !== params[key]),
query: query.toString() !== this.current.page.query.toString(),
session: this.session_id !== this.current.session_id
};
/** @type {import('types/page').Page} */
const page = { host: this.host, path, query, params };
/** @type {import('./types').BranchNode[]} */
const branch = [];
/** @type {Record<string, any>} */
let context = {};
let context_changed = false;
/** @type {number} */
let status = 200;
/** @type {Error} */
let error = null;
// preload modules
a.forEach((loader) => loader());
load: for (let i = 0; i < a.length; i += 1) {
/** @type {import('./types').BranchNode} */
let node;
try {
if (!a[i]) continue;
const module = await a[i]();
const previous = this.current.branch[i];
const changed_since_last_render =
!previous ||
module !== previous.module ||
(changed.path && previous.uses.path) ||
changed.params.some((param) => previous.uses.params.has(param)) ||
(changed.query && previous.uses.query) ||
(changed.session && previous.uses.session) ||
(context_changed && previous.uses.context);
if (changed_since_last_render) {
node = await this._load_node({
module,
page,
context
});
const is_leaf = i === a.length - 1;
if (node && node.loaded) {
if (node.loaded.error) {
status = node.loaded.status;
error = node.loaded.error;
}
if (node.loaded.redirect) {
return {
redirect: node.loaded.redirect
};
}
if (node.loaded.context) {
context_changed = true;
}
} else if (is_leaf && module.load) {
// if the leaf node has a `load` function
// that returns nothing, fall through
return;
}
} else {
node = previous;
}
} catch (e) {
status = 500;
error = e;
}
if (error) {
while (i--) {
if (b[i]) {
let error_loaded;
/** @type {import('./types').BranchNode} */
let node_loaded;
let j = i;
while (!(node_loaded = branch[j])) {
j -= 1;
}
try {
error_loaded = await this._load_node({
status,
error,
module: await b[i](),
page,
context: node_loaded.context
});
if (error_loaded.loaded.error) {
continue;
}
branch.push(error_loaded);
break load;
} catch (e) {
continue;
}
}
}
return await this._load_error({
status,
error,
path,
query
});
} else {
if (node && node.loaded && node.loaded.context) {
context = {
...context,
...node.loaded.context
};
}
branch.push(node);
}
}
return await this._get_navigation_result_from_branch({ page, branch });
}
/**
* @param {{
* status: number;
* error: Error;
* path: string;
* query: URLSearchParams
* }} opts
*/
async _load_error({ status, error, path, query }) {
const page = {
host: this.host,
path,
query,
params: {}
};
const node = await this._load_node({
module: await this.fallback[0],
page,
context: {}
});
const branch = [
node,
await this._load_node({
status,
error,
module: await this.fallback[1],
page,
context: node && node.loaded && node.loaded.context
})
];
return await this._get_navigation_result_from_branch({ page, branch });
}
}
// @ts-ignore
/** @param {{
* paths: {
* assets: string;
* base: string;
* },
* target: Node;
* session: any;
* host: string;
* route: boolean;
* spa: boolean;
* hydrate: {
* status: number;
* error: Error;
* nodes: Array<Promise<import('types/internal').CSRComponent>>;
* page: import('types/page').Page;
* };
* }} opts */
async function start({ paths, target, session, host, route, spa, hydrate }) {
if (import.meta.env.DEV && !target) {
throw new Error('Missing target element. See https://kit.svelte.dev/docs#configuration-target');
}
const router =
route &&
new Router({
base: paths.base,
routes
});
const renderer = new Renderer({
Root,
fallback,
target,
session,
host
});
init(router);
set_paths(paths);
if (hydrate) await renderer.start(hydrate);
if (route) router.init(renderer);
if (spa) router.goto(location.href, { replaceState: true }, []);
dispatchEvent(new CustomEvent('sveltekit:start'));
}
if (import.meta.env.VITE_SVELTEKIT_SERVICE_WORKER) {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register(import.meta.env.VITE_SVELTEKIT_SERVICE_WORKER);
}
}
export { start };