class NoxidRouteQueryError extends Error {
constructor(code, route, field, type, values) {
super(`${code}: query field ${field} on ${route}`);
this.name = "NoxidRouteQueryError";
this.code = code;
this.route = route;
this.field = field;
this.type = type;
this.values = Object.freeze([...values]);
}
}
function normalizeBasePath(value) {
if (!value || value === "/") return "/";
return value.endsWith("/") ? value.slice(0, -1) : value;
}
function isWithinBase(pathname, basePath) {
return basePath === "/" || pathname === basePath || pathname.startsWith(`${basePath}/`);
}
function stripBasePath(pathname, basePath) {
if (basePath === "/") return pathname;
if (pathname === basePath) return "/";
return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : null;
}
function resolveNavigation(value, basePath) {
const raw = typeof value === "string" ? value : value.href;
const url = new URL(raw, location.href);
if (url.origin !== location.origin || isWithinBase(url.pathname, basePath)) return url;
if (typeof raw === "string" && raw.startsWith("/") && !raw.startsWith("//")) {
url.pathname = `${basePath}${url.pathname}`;
}
return url;
}
function pathSegments(pathname) {
const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
if (normalized === "/") return [];
return normalized.slice(1).split("/").map((segment) => decodeURIComponent(segment));
}
function patternSegments(pattern) {
if (pattern === "/") return [];
return pattern.slice(1).split("/");
}
function convertScalar(value, type) {
if (type === "String") return value;
if (type === "Int") {
if (!/^-?\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
if (type === "Boolean") {
if (value === "true") return true;
if (value === "false") return false;
}
return null;
}
export function parseRouteQuery(route, source) {
const search = source instanceof URL ? source.searchParams : source;
const query = {};
for (const field of route.query ?? []) {
const values = search.getAll(field.name);
if (values.length === 0) {
if (field.required) {
throw new NoxidRouteQueryError("ROUTE_QUERY_REQUIRED", route.id, field.name, field.type, values);
}
query[field.name] = null;
continue;
}
if (values.length > 1) {
throw new NoxidRouteQueryError("ROUTE_QUERY_MULTIPLE", route.id, field.name, field.type, values);
}
const converted = convertScalar(values[0], field.type);
if (converted === null) {
throw new NoxidRouteQueryError("ROUTE_QUERY_INVALID", route.id, field.name, field.type, values);
}
query[field.name] = converted;
}
return Object.freeze(query);
}
export function matchRoute(route, pathname) {
const actual = pathSegments(pathname);
if (actual.some((segment) => segment === "." || segment === ".." || /[\u0000-\u001f\u007f]/.test(segment))) return null;
const expected = patternSegments(route.pattern);
const catchAllIndex = expected.findIndex(
(segment) => segment.startsWith("{*") && segment.endsWith("}"),
);
if (catchAllIndex === -1 && actual.length !== expected.length) return null;
if (catchAllIndex !== -1 && (catchAllIndex !== expected.length - 1 || actual.length < expected.length)) {
return null;
}
const parameters = new Map((route.parameters ?? []).map((parameter) => [parameter.name, parameter]));
const params = Object.create(null);
for (let index = 0; index < expected.length; index += 1) {
const segment = expected[index];
if (segment.startsWith("{*") && segment.endsWith("}")) {
const name = segment.slice(2, -1);
const parameter = parameters.get(name);
if (!parameter?.catchAll) return null;
params[name] = Object.freeze(actual.slice(index));
break;
}
if (!segment.startsWith("{") || !segment.endsWith("}")) {
if (segment !== actual[index]) return null;
continue;
}
const name = segment.slice(1, -1);
const parameter = parameters.get(name);
if (!parameter || parameter.catchAll) return null;
const converted = convertScalar(actual[index], parameter.type);
if (converted === null) return null;
params[name] = converted;
}
return Object.freeze(params);
}
function attachStyle(target, revision = "") {
if (!target?.css) return { link: null, loaded: Promise.resolve() };
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `${target.css}${revision}`;
link.dataset.noxidRouteStyle = target.component;
const loaded = new Promise((resolve, reject) => {
link.addEventListener("load", resolve, { once: true });
link.addEventListener("error", () => reject(new Error(`ROUTE_STYLE_LOAD_FAILED: ${target.css}`)), { once: true });
});
document.head.appendChild(link);
return { link, loaded };
}
async function loadTargets(targets, signal, revision = "") {
const styles = targets.map((target) => attachStyle(target, revision));
try {
const modules = await Promise.all(targets.map((target) => target.load()));
await Promise.all(styles.map((style) => style.loaded));
if (signal.aborted) throw signal.reason;
return {
loaded: targets.map((target, index) => ({ target, module: modules[index] })),
styles: styles.flatMap((style) => (style.link ? [style.link] : [])),
};
} catch (error) {
styles.forEach((style) => style.link?.remove());
throw error;
}
}
function renderFailure(root, code, message) {
const section = document.createElement("section");
section.setAttribute("role", "alert");
section.dataset.noxidRouteError = code;
const heading = document.createElement("h1");
heading.textContent = code === "NOT_FOUND" ? "Page not found" : "Page failed to load";
const detail = document.createElement("p");
detail.textContent = message;
section.append(heading, detail);
root.replaceChildren(section);
}
function applyRouteMetadata(metadata, defaultTitle) {
document.title = metadata?.title ?? defaultTitle;
let description = document.head.querySelector("meta[data-noxid-route-description]");
if (metadata?.description) {
if (!description) {
description = document.createElement("meta");
description.name = "description";
description.dataset.noxidRouteDescription = "";
document.head.appendChild(description);
}
description.content = metadata.description;
} else {
description?.remove();
}
}
function disposeView(view) {
view?.instance?.dispose();
view?.styles?.forEach((link) => link.remove());
}
function runtimeOptionsFor(route, params, query, url, path, basePath, streams, prefetchRoute, host) {
return Object.freeze({
...(host.runtimeOptions ?? {}),
route: Object.freeze({ id: route.id, pattern: route.pattern, path, urlPath: url.pathname, basePath, params, query }),
middleware: Object.freeze({ applied: Object.freeze([]) }),
streams,
executeBoundary: typeof host.executeBoundary === "function"
? host.executeBoundary.bind(host)
: host.runtimeOptions?.executeBoundary,
prefetchRoute,
});
}
async function acquirePrefetchLoaders(targets, initialProps, runtimeOptions, signal) {
const props = { ...initialProps };
const loaders = targets.flatMap((target) => (target.loaders ?? []).map((loader) => ({ target, loader })));
if (loaders.length > 0 && typeof runtimeOptions.executeBoundary !== "function") {
throw Object.assign(new Error("Route prefetch loaders require an execution boundary"), { code: "ROUTE_PREFETCH_LOADER_UNAVAILABLE" });
}
const stages = [...new Set(loaders.map(({ loader }) => loader.stage ?? 0))].sort((left, right) => left - right);
for (const stage of stages) {
const results = await Promise.all(loaders.filter(({ loader }) => (loader.stage ?? 0) === stage).map(async ({ target, loader }) => {
if (signal.aborted) throw signal.reason ?? new DOMException("Route prefetch aborted", "AbortError");
const inputs = (loader.arguments ?? []).map((argument) => Object.freeze({
id: argument.id,
name: argument.name,
type: argument.type,
value: props[argument.sourceName],
}));
const value = await runtimeOptions.executeBoundary(Object.freeze({
id: loader.action,
loader: loader.id,
kind: "route-loader",
component: target.component,
action: loader.actionName,
execution: loader.execution,
arguments: Object.freeze(inputs),
result: Object.freeze(loader.result),
signal,
}));
return Object.freeze({ name: loader.name, value });
}));
for (const result of results) props[result.name] = result.value;
}
return Object.freeze(props);
}
function mountLoadedTargets(root, loaded, props, runtimeOptions) {
let targetRoot = root;
let parentOwner = null;
let rootInstance = null;
try {
for (const { target, module } of loaded) {
const mount = module?.[`mount${target.component}`];
if (typeof mount !== "function") throw new Error(`ROUTE_MOUNT_MISSING: ${target.component}`);
const instance = mount(targetRoot, props, {}, parentOwner, runtimeOptions);
rootInstance ??= instance;
parentOwner = instance.owner;
if (target.layout) {
const outlet = targetRoot.querySelector("outlet");
if (!outlet) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
targetRoot = outlet;
}
}
return rootInstance;
} catch (error) {
rootInstance?.dispose();
throw error;
}
}
export function startRouter({ root, routes, host = {}, streams = Object.freeze({}), basePath: configuredBasePath = "/", defaultTitle: configuredDefaultTitle = document.title }) {
const basePath = normalizeBasePath(configuredBasePath);
const defaultTitle = configuredDefaultTitle || document.title;
let current = null;
let transition = null;
let pending = null;
let sequence = 0;
const prefetches = new Map();
function commitHistory(url, options) {
if (!options.popstate) history[options.replace ? "replaceState" : "pushState"]({}, "", url);
}
function prefetchFailure(code, message, route = null, cause = null) {
return Object.assign(new Error(message), {
code,
semanticId: route?.id ?? null,
route: route?.id ?? null,
cause,
});
}
function prefetchRoute(value) {
let url;
try {
url = resolveNavigation(value, basePath);
} catch (cause) {
return Promise.reject(prefetchFailure("ROUTE_PREFETCH_URL_INVALID", "Route prefetch requires a valid URL", null, cause));
}
if (url.origin !== location.origin) {
return Promise.reject(prefetchFailure("ROUTE_PREFETCH_EXTERNAL", "Route prefetch only supports same-origin URLs"));
}
if (!isWithinBase(url.pathname, basePath)) {
return Promise.reject(prefetchFailure("ROUTE_PREFETCH_OUTSIDE_BASE", `Route prefetch URL is outside ${basePath}`));
}
url.hash = "";
const key = `${url.pathname}${url.search}`;
const existing = prefetches.get(key);
if (existing) return existing.promise;
const controller = new AbortController();
const work = (async () => {
const applicationPath = stripBasePath(url.pathname, basePath);
const matched = routes
.map((route) => ({ route, params: matchRoute(route, applicationPath) }))
.find((item) => item.params !== null);
if (!matched) throw prefetchFailure("ROUTE_PREFETCH_NOT_FOUND", `No route matches ${applicationPath}`);
let query;
try { query = parseRouteQuery(matched.route, url); }
catch (cause) {
throw prefetchFailure("ROUTE_PREFETCH_QUERY_INVALID", `Route prefetch query is invalid for ${matched.route.id}`, matched.route, cause);
}
let modules;
try { modules = await Promise.all(matched.route.targets.map((target) => target.load())); }
catch (cause) {
throw prefetchFailure("ROUTE_PREFETCH_MODULE_FAILED", `Route prefetch could not load modules for ${matched.route.id}`, matched.route, cause);
}
const runtimeOptions = runtimeOptionsFor(matched.route, matched.params, query, url, applicationPath, basePath, streams, prefetchRoute, host);
let props = Object.freeze({ ...matched.params, ...query });
try { props = await acquirePrefetchLoaders(matched.route.targets, props, runtimeOptions, controller.signal); }
catch (cause) {
if (cause?.code === "ROUTE_PREFETCH_LOADER_UNAVAILABLE") throw cause;
throw prefetchFailure("ROUTE_PREFETCH_LOADER_FAILED", `Route prefetch loaders failed for ${matched.route.id}`, matched.route, cause);
}
try {
for (let index = 0; index < matched.route.targets.length; index += 1) {
const target = matched.route.targets[index];
const prefetch = modules[index]?.[`__noxidPrefetch${target.component}`];
if (typeof prefetch === "function") await prefetch(props, runtimeOptions);
}
} catch (cause) {
throw prefetchFailure("ROUTE_PREFETCH_RESOURCE_FAILED", `Route resource prefetch failed for ${matched.route.id}`, matched.route, cause);
}
return Object.freeze({ routeId: matched.route.id, url: key });
})();
const promise = work.catch((cause) => {
if (typeof cause?.code === "string" && cause.code.startsWith("ROUTE_PREFETCH_")) throw cause;
throw prefetchFailure("ROUTE_PREFETCH_FAILED", "Route prefetch failed", null, cause);
});
prefetches.set(key, { promise, controller });
void promise.finally(() => {
if (prefetches.get(key)?.promise === promise) prefetches.delete(key);
}).catch(() => {});
return promise;
}
async function showRouteError(error, context, runtimeOptions, options) {
if (context.signal.aborted) return false;
const code = typeof error?.code === "string" ? error.code : "ROUTE_NAVIGATION_FAILED";
const message = typeof error?.message === "string" ? error.message : String(error);
commitHistory(context.url, options);
if (context.route.error) {
try {
const boundary = await loadTargets([context.route.error], context.signal, options.revision);
const instance = mountLoadedTargets(root, boundary.loaded, { code, message }, runtimeOptions);
current = { route: context.route, instance, styles: boundary.styles, url: context.url };
pending = null;
return false;
} catch (boundaryError) {
if (context.signal.aborted) return false;
renderFailure(root, "ROUTE_ERROR_BOUNDARY_FAILED", String(boundaryError?.message ?? boundaryError));
pending = null;
return false;
}
}
renderFailure(root, code, message);
pending = null;
return false;
}
async function navigate(value, options = {}) {
const url = resolveNavigation(value, basePath);
if (url.origin !== location.origin || !isWithinBase(url.pathname, basePath)) {
location.assign(url.href);
return false;
}
const applicationPath = stripBasePath(url.pathname, basePath);
pending?.abort(new DOMException("Navigation superseded", "AbortError"));
disposeView(transition);
transition = null;
const controller = new AbortController();
pending = controller;
const navigation = ++sequence;
const matched = routes
.map((route) => ({ route, params: matchRoute(route, applicationPath) }))
.find((item) => item.params !== null);
if (!matched) {
applyRouteMetadata(null, defaultTitle);
commitHistory(url, options);
disposeView(current);
current = null;
renderFailure(root, "NOT_FOUND", `No route matches ${applicationPath}`);
pending = null;
return false;
}
const baseContext = { route: matched.route, params: matched.params, url, path: applicationPath, basePath, signal: controller.signal };
let query;
try {
query = parseRouteQuery(matched.route, url);
} catch (error) {
if (controller.signal.aborted) return false;
const emptyQuery = Object.freeze({});
const context = { ...baseContext, query: emptyQuery, props: matched.params };
const runtimeOptions = runtimeOptionsFor(matched.route, matched.params, emptyQuery, url, applicationPath, basePath, streams, prefetchRoute, host);
applyRouteMetadata(null, defaultTitle);
disposeView(current);
current = null;
return showRouteError(error, context, runtimeOptions, options);
}
const routeProps = Object.freeze({ ...matched.params, ...query });
const context = { ...baseContext, query, props: routeProps };
const runtimeOptions = runtimeOptionsFor(matched.route, matched.params, query, url, applicationPath, basePath, streams, prefetchRoute, host);
applyRouteMetadata(matched.route.metadata, defaultTitle);
let loadingView = null;
let routeStyles = [];
let routeInstance = null;
try {
if (matched.route.loading) {
disposeView(current);
current = null;
const loading = await loadTargets([matched.route.loading], controller.signal, options.revision);
if (navigation !== sequence || controller.signal.aborted) {
loading.styles.forEach((link) => link.remove());
return false;
}
loadingView = { instance: mountLoadedTargets(root, loading.loaded, {}, runtimeOptions), styles: loading.styles };
transition = loadingView;
}
const routeAssets = await loadTargets(matched.route.targets, controller.signal, options.revision);
routeStyles = routeAssets.styles;
if (navigation !== sequence || controller.signal.aborted) {
if (loadingView && transition === loadingView) { disposeView(loadingView); transition = null; }
routeStyles.forEach((link) => link.remove());
return false;
}
if (loadingView) {
disposeView(loadingView);
if (transition === loadingView) transition = null;
} else disposeView(current);
current = null;
routeInstance = mountLoadedTargets(root, routeAssets.loaded, routeProps, runtimeOptions);
current = { route: matched.route, instance: routeInstance, styles: routeStyles, url };
commitHistory(url, options);
pending = null;
return true;
} catch (error) {
routeInstance?.dispose();
routeStyles.forEach((link) => link.remove());
if (loadingView && transition === loadingView) { disposeView(loadingView); transition = null; }
if (navigation !== sequence || controller.signal.aborted) return false;
disposeView(current);
current = null;
return showRouteError(error, context, runtimeOptions, options);
}
}
function onClick(event) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target.closest?.("a[href]");
if (!anchor || anchor.target || anchor.hasAttribute("download")) return;
const url = new URL(anchor.href, location.href);
if (url.origin !== location.origin || !isWithinBase(url.pathname, basePath)) return;
event.preventDefault();
void navigate(anchor.getAttribute("href"));
}
const onPopState = () => void navigate(location.href, { popstate: true, replace: true });
document.addEventListener("click", onClick);
window.addEventListener("popstate", onPopState);
void navigate(location.href, { popstate: true, replace: true });
return Object.freeze({
navigate,
refresh(revision) {
return navigate(location.href, { popstate: true, replace: true, revision: `?noxid-hmr=${encodeURIComponent(revision)}` });
},
basePath,
current: () => current?.route ?? null,
dispose() {
pending?.abort(new DOMException("Router disposed", "AbortError"));
for (const entry of prefetches.values()) entry.controller.abort(new DOMException("Router disposed", "AbortError"));
prefetches.clear();
document.removeEventListener("click", onClick);
window.removeEventListener("popstate", onPopState);
disposeView(transition);
transition = null;
disposeView(current);
current = null;
applyRouteMetadata(null, defaultTitle);
},
});
}