const MAX_REDIRECTS = 16;
const ROUTER_SUPPORTS_HYDRATION = true;
const DOM_REGION_MARKER_SCHEMA_VERSION = 2;
let hydrationRuntimeSequence = 0;
let workerRequestSequence = 0;
const workerHosts = new Map();
function workerHost(basePath) {
let host = workerHosts.get(basePath);
if (host) return host;
const prefix = basePath === "/" ? "" : basePath;
const worker = new Worker(`${prefix}/assets/noxid-worker.js`, { type: "module", name: "noxid-actions" });
const pending = new Map();
worker.addEventListener("message", (event) => {
const message = event.data;
const request = pending.get(message?.id);
if (!request) return;
pending.delete(message.id);
request.cleanup();
if (message.ok) request.resolve(message.value);
else {
const error = new Error(message.error?.message ?? "Worker action failed");
error.code = message.error?.code ?? "WORKER_EXECUTION_FAILED";
request.reject(error);
}
});
worker.addEventListener("error", (event) => {
for (const request of pending.values()) {
request.cleanup();
request.reject(Object.assign(new Error(event.message || "Worker host failed"), { code: "WORKER_HOST_FAILED" }));
}
pending.clear();
workerHosts.delete(basePath);
});
host = { worker, pending };
workerHosts.set(basePath, host);
return host;
}
function executeWorkerBoundary(basePath, route, descriptor) {
const { worker, pending } = workerHost(basePath);
const id = ++workerRequestSequence;
return new Promise((resolve, reject) => {
const abort = () => {
worker.postMessage({ type: "cancel", id });
pending.delete(id);
reject(descriptor.signal.reason ?? new DOMException("Worker action cancelled", "AbortError"));
};
const cleanup = () => descriptor.signal?.removeEventListener("abort", abort);
if (descriptor.signal?.aborted) { abort(); return; }
descriptor.signal?.addEventListener("abort", abort, { once: true });
pending.set(id, { resolve, reject, cleanup });
try {
worker.postMessage({ type: "execute", id, action: descriptor.id, route: route.id, arguments: descriptor.arguments ?? [] });
} catch (error) {
pending.delete(id);
cleanup();
reject(error);
}
});
}
export class NoxidMiddlewareError extends Error {
constructor(code, middleware, route, detail = null) {
super(`${code}: ${middleware ?? route}`);
this.name = "NoxidMiddlewareError";
this.code = code;
this.middleware = middleware;
this.route = route;
this.detail = detail;
}
}
export 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 convertParameter(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 = convertParameter(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 = convertParameter(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 = "", options = {}) {
const styles = targets.map((target) => {
if (!options.reuseStyles) return attachStyle(target, revision);
const existing = [...document.head.querySelectorAll("link[data-noxid-route-style]")]
.find((link) => link.dataset.noxidRouteStyle === target.component);
return existing ? { link: existing, loaded: Promise.resolve() } : attachStyle(target, revision);
});
try {
const modules = await Promise.all(targets.map((target) => {
if (target.renderMode === "server") return Promise.resolve(null);
if (options.deferHydration && target.hydration !== "eager") return Promise.resolve(null);
return target.load(revision);
}));
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) {
if (!options.reuseStyles) styles.forEach((style) => style.link?.remove());
throw error;
}
}
async function executeMiddleware(names, loaders, context) {
const applied = [];
for (const name of names) {
if (context.signal.aborted) throw context.signal.reason;
const load = loaders[name];
if (typeof load !== "function") {
throw new NoxidMiddlewareError("MIDDLEWARE_MODULE_MISSING", name, context.route.id);
}
const module = await load();
const handle = module.default ?? module.handle;
if (typeof handle !== "function") {
throw new NoxidMiddlewareError("MIDDLEWARE_HANDLER_MISSING", name, context.route.id);
}
const result = await handle(Object.freeze({ ...context, middleware: name }));
if (result === false || result?.allow === false) {
return { denied: true, name, result, applied };
}
if (typeof result?.redirect === "string") {
return { redirect: result.redirect, replace: result.replace !== false, name, applied };
}
applied.push(name);
}
return { applied };
}
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"
: code === "ACCESS_DENIED"
? "Access denied"
: "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 boundaryTransport(basePath, route) {
return async (descriptor) => {
if (descriptor.execution === "worker") {
return executeWorkerBoundary(basePath, route, descriptor);
}
const argumentsObject = Object.create(null);
for (const argument of descriptor.arguments ?? []) argumentsObject[argument.name] = argument.value;
const prefix = basePath === "/" ? "" : basePath;
const response = await fetch(`${prefix}/_noxid/actions/${encodeURIComponent(descriptor.id)}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-noxid-route-id": route.id,
},
body: JSON.stringify({ arguments: argumentsObject }),
credentials: "same-origin",
signal: descriptor.signal,
});
let result;
try { result = await response.json(); }
catch {
const error = new Error(`Noxid action ${descriptor.id} returned a non-JSON response`);
error.code = "BOUNDARY_RESPONSE_INVALID";
error.semanticId = descriptor.id;
throw error;
}
if (!response.ok || result?.ok !== true) {
const detail = result?.error ?? {};
const error = new Error(detail.message ?? `Noxid action ${descriptor.id} failed with HTTP ${response.status}`);
error.code = detail.code ?? "BOUNDARY_REQUEST_FAILED";
error.semanticId = detail.semanticId ?? descriptor.id;
error.details = detail.details ?? null;
throw error;
}
return result.value;
};
}
export async function acquireRouteLoaders(targets, initialProps, runtimeOptions, signal) {
const props = { ...initialProps };
const loaders = targets.flatMap((target) => (target.loaders ?? []).map((loader) => ({ target, loader })));
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("Navigation 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 runtimeOptionsFor(host, route, params, query, url, path, basePath, applied, endpointStreams, prefetchRoute) {
return {
...(host.runtimeOptions ?? {}),
streams: Object.freeze({
...(host.runtimeOptions?.streams ?? {}),
...endpointStreams,
}),
route: Object.freeze({
id: route.id,
pattern: route.pattern,
path,
urlPath: url.pathname,
basePath,
params,
query,
}),
middleware: Object.freeze({ applied: Object.freeze([...applied]) }),
authorizeComponent: typeof host.authorizeComponent === "function"
? host.authorizeComponent.bind(host)
: host.runtimeOptions?.authorizeComponent,
executeBoundary: typeof host.executeBoundary === "function"
? host.executeBoundary.bind(host)
: host.runtimeOptions?.executeBoundary ?? boundaryTransport(basePath, route),
prefetchRoute,
};
}
function layoutOutlet(root) {
return [...root.querySelectorAll("outlet")].find((candidate) => candidate !== root) ?? null;
}
function serverShellBoundary(route) {
if (route.render?.mode !== "client") return -1;
for (let index = route.targets.length - 1; index >= 0; index -= 1) {
if (route.targets[index].renderMode === "server") return index;
}
return -1;
}
function activateLoadedTargets(root, loaded, props, runtimeOptions, hydrating = false) {
let targetRoot = root;
let parentOwner = null;
let rootInstance = null;
try {
for (const { target, module } of loaded) {
if (target.renderMode === "server") {
if (target.layout) {
const outlet = layoutOutlet(targetRoot);
if (!outlet) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
targetRoot = outlet;
}
continue;
}
const activation = hydrating && target.renderMode === "universal"
? module?.[`hydrate${target.component}`]
: module?.[`mount${target.component}`];
if (typeof activation !== "function") {
const kind = hydrating ? "HYDRATE" : "MOUNT";
throw new Error(`ROUTE_${kind}_MISSING: ${target.component}`);
}
const instance = activation(targetRoot, props, {}, parentOwner, runtimeOptions);
rootInstance ??= instance;
parentOwner = instance.owner;
if (target.layout) {
const outlet = layoutOutlet(targetRoot);
if (!outlet) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
targetRoot = outlet;
}
}
return rootInstance;
} catch (error) {
rootInstance?.dispose();
throw error;
}
}
function hydrationScope(root) {
if (root?.nodeType === Node.ELEMENT_NODE) return root;
return root?.parentElement ?? document.documentElement;
}
function hydrationVisibleElement(root) {
if (root?.nodeType === Node.COMMENT_NODE) {
let cursor = root.nextSibling;
while (cursor) {
if (cursor.nodeType === Node.ELEMENT_NODE) return cursor;
cursor = cursor.nextSibling;
}
}
const scope = hydrationScope(root);
return scope?.firstElementChild ?? scope;
}
function hydrationIdentity(component, scope, policy) {
hydrationRuntimeSequence += 1;
return {
semanticId: `component:${component}`,
runtimeId: `hydration:${hydrationRuntimeSequence}`,
ownerId: null,
component,
scope,
policy,
};
}
function emitHydration(kind, identity, payload = {}) {
emitRuntimeEvent(`hydration.${kind}`, {
semanticId: identity.semanticId,
runtimeId: identity.runtimeId,
ownerId: identity.ownerId,
payload: { component: identity.component, scope: identity.scope, policy: identity.policy, ...payload },
});
}
function captureControlState(target) {
if (!target || typeof target !== "object") return null;
const state = {};
if ("value" in target) state.value = target.value;
if ("checked" in target) state.checked = target.checked;
if ("selectionStart" in target) state.selectionStart = target.selectionStart;
if ("selectionEnd" in target) state.selectionEnd = target.selectionEnd;
return Object.freeze(state);
}
function restoreControlState(target, state) {
if (!state || !target?.isConnected) return;
if ("value" in state) target.value = state.value;
if ("checked" in state) target.checked = state.checked;
if ("selectionStart" in state && typeof target.setSelectionRange === "function") {
try { target.setSelectionRange(state.selectionStart, state.selectionEnd); } catch {}
}
}
function replayInteractionEvent(record, identity) {
const { event, target, control } = record;
if (!target?.isConnected) return;
restoreControlState(target, control);
if (event.type === "click" && typeof target.click === "function") {
target.click();
emitHydration("event.replayed", identity, { event: event.type });
return;
}
if (event.type === "submit" && typeof target.requestSubmit === "function") {
target.requestSubmit(event.submitter ?? undefined);
emitHydration("event.replayed", identity, { event: event.type });
return;
}
let replay;
const common = { bubbles: true, cancelable: event.cancelable, composed: event.composed };
if (event.type === "click" && typeof MouseEvent === "function") {
replay = new MouseEvent("click", {
...common,
button: event.button,
buttons: event.buttons,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
});
} else if (event.type === "keydown" && typeof KeyboardEvent === "function") {
replay = new KeyboardEvent("keydown", {
...common,
key: event.key,
code: event.code,
repeat: event.repeat,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
});
} else if (event.type === "input" && typeof InputEvent === "function") {
replay = new InputEvent("input", { ...common, data: event.data, inputType: event.inputType });
} else replay = new Event(event.type, common);
Object.defineProperty(replay, "__noxidReplayed", { value: true });
target.dispatchEvent(replay);
emitHydration("event.replayed", identity, { event: event.type });
}
export function scheduleHydration(root, policy, activate, identity) {
let cancelled = false;
let finished = false;
let cleanup = () => {};
let activation = null;
const run = (trigger = policy) => {
if (cancelled) return Promise.resolve();
if (activation) return activation;
emitHydration("triggered", identity, { trigger });
activation = Promise.resolve()
.then(activate)
.then(() => {
finished = true;
cleanup();
cleanup = () => {};
})
.catch((error) => { throw error; });
return activation;
};
emitHydration("scheduled", identity);
if (policy === "idle") {
if (typeof requestIdleCallback === "function") {
const handle = requestIdleCallback(() => {
cleanup();
cleanup = () => {};
void run("idle").catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
}, { timeout: 2000 });
cleanup = () => cancelIdleCallback(handle);
} else {
const handle = setTimeout(() => {
cleanup();
cleanup = () => {};
void run("idle-fallback").catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
}, 1);
cleanup = () => clearTimeout(handle);
}
} else if (policy === "visible" && typeof IntersectionObserver === "function") {
const target = hydrationVisibleElement(root);
if (!target) {
queueMicrotask(() => {
void run("visible-fallback").catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
});
}
else {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
cleanup();
cleanup = () => {};
void run("visible").catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
}
}, { rootMargin: "200px" });
observer.observe(target);
cleanup = () => observer.disconnect();
}
} else if (policy === "interaction") {
const scope = root?.nodeType === Node.COMMENT_NODE
? hydrationVisibleElement(root)
: hydrationScope(root);
const intentEvents = ["pointerover", "focusin", "touchstart"];
const replayEvents = ["click", "submit", "keydown", "input", "change"];
const onIntent = (event) => {
if (event.__noxidReplayed) return;
void run(event.type).catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
};
const onReplayable = (event) => {
if (event.__noxidReplayed || finished) return;
if (event.cancelable) event.preventDefault();
event.stopImmediatePropagation();
const record = Object.freeze({ event, target: event.target, control: captureControlState(event.target) });
emitHydration("event.captured", identity, { event: event.type });
void run(event.type)
.then(() => replayInteractionEvent(record, identity))
.catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
};
for (const event of intentEvents) scope.addEventListener(event, onIntent, { capture: true, passive: true });
for (const event of replayEvents) scope.addEventListener(event, onReplayable, { capture: true });
cleanup = () => {
for (const event of intentEvents) scope.removeEventListener(event, onIntent, { capture: true });
for (const event of replayEvents) scope.removeEventListener(event, onReplayable, { capture: true });
};
} else {
queueMicrotask(() => {
void run("fallback").catch((error) => console.error("NOXID_DEFERRED_HYDRATION_FAILED", error));
});
}
return () => {
if (cancelled) return;
cancelled = true;
cleanup();
if (!finished) emitHydration("cancelled", identity);
};
}
async function hydrateTargetsByPolicy(root, loaded, props, runtimeOptions, signal, shellBoundary = loaded.length - 1) {
let disposed = false;
const instances = [];
const cancelSchedules = [];
const dispose = () => {
if (disposed) return;
disposed = true;
for (let index = cancelSchedules.length - 1; index >= 0; index -= 1) cancelSchedules[index]();
for (let index = instances.length - 1; index >= 0; index -= 1) instances[index]?.dispose?.();
};
const activateFrom = async (index, targetRoot, parentOwner) => {
if (disposed || signal?.aborted || index >= loaded.length) return;
const entry = loaded[index];
const { target } = entry;
if (target.renderMode === "server") {
const childRoot = target.layout
? layoutOutlet(targetRoot)
: targetRoot;
if (!childRoot) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
return activateFrom(index + 1, childRoot, parentOwner);
}
if (index > shellBoundary || target.renderMode === "client") {
if (!entry.module) entry.module = await target.load();
const mount = entry.module?.[`mount${target.component}`];
if (typeof mount !== "function") throw new Error(`ROUTE_MOUNT_MISSING: ${target.component}`);
const instance = mount(targetRoot, props, {}, parentOwner, runtimeOptions);
instances.push(instance);
const childRoot = target.layout
? layoutOutlet(targetRoot)
: targetRoot;
if (!childRoot) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
return activateFrom(index + 1, childRoot, instance.owner);
}
if (target.hydration === "never") {
const childRoot = target.layout
? layoutOutlet(targetRoot)
: targetRoot;
if (!childRoot) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
return activateFrom(index + 1, childRoot, parentOwner);
}
const identity = hydrationIdentity(target.component, "route", target.hydration);
const activate = async () => {
if (disposed || signal?.aborted) return;
try {
if (!entry.module) {
emitHydration("chunk.requested", identity);
entry.module = await target.load();
emitHydration("chunk.loaded", identity);
}
if (disposed || signal?.aborted) return;
emitHydration("started", identity);
const hydrate = entry.module?.[`hydrate${target.component}`];
if (typeof hydrate !== "function") throw new Error(`ROUTE_HYDRATE_MISSING: ${target.component}`);
const instance = hydrate(targetRoot, props, {}, parentOwner, runtimeOptions);
identity.ownerId = instance.owner?.id ?? null;
instances.push(instance);
emitHydration("completed", identity, { adopted: true });
const childRoot = target.layout
? layoutOutlet(targetRoot)
: targetRoot;
if (!childRoot) throw new Error(`ROUTE_LAYOUT_OUTLET_MISSING: ${target.component}`);
await activateFrom(index + 1, childRoot, instance.owner);
} catch (error) {
emitHydration("failed", identity, { code: error?.code ?? null, message: error?.message ?? String(error) });
throw error;
}
};
if (target.hydration === "eager") {
emitHydration("scheduled", identity);
emitHydration("triggered", identity, { trigger: "eager" });
await activate();
} else cancelSchedules.push(scheduleHydration(targetRoot, target.hydration, activate, identity));
};
try {
await activateFrom(0, root, null);
return Object.freeze({
get owner() { return instances[0]?.owner ?? null; },
dispose,
});
} catch (error) {
dispose();
throw error;
}
}
function combineRouteInstances(routeInstance, islandInstances) {
if (islandInstances.length === 0) return routeInstance;
let disposed = false;
return Object.freeze({
get owner() { return routeInstance?.owner ?? null; },
dispose() {
if (disposed) return;
disposed = true;
for (let index = islandInstances.length - 1; index >= 0; index -= 1) islandInstances[index]?.dispose?.();
routeInstance?.dispose?.();
},
});
}
async function hydrateIndependentIslands(root, entries, loaders, runtimeOptions, signal) {
if (!Array.isArray(entries) || entries.length === 0) return [];
const seen = new Set();
const instances = [];
try {
for (const entry of entries) {
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
if (!entry || typeof entry.id !== "string" || typeof entry.component !== "string" || (entry.mode !== "universal" && entry.mode !== "client")) {
throw new Error("SSR_ISLAND_PAYLOAD_INVALID");
}
if (seen.has(entry.id)) throw new Error(`SSR_ISLAND_ID_DUPLICATE: ${entry.id}`);
seen.add(entry.id);
if (!["eager", "idle", "visible", "interaction"].includes(entry.hydration)) {
throw new Error(`SSR_ISLAND_HYDRATION_INVALID: ${entry.component}`);
}
const load = loaders[entry.component];
if (typeof load !== "function") throw new Error(`SSR_ISLAND_MODULE_MISSING: ${entry.component}`);
const marker = findMarker(root, `noxid-island:${entry.id}`);
let instance = null;
let disposed = false;
const identity = hydrationIdentity(entry.component, `island:${entry.id}`, entry.hydration);
const activate = async () => {
if (disposed || signal?.aborted) return;
try {
emitHydration("chunk.requested", identity);
const module = await load();
emitHydration("chunk.loaded", identity);
if (disposed || signal?.aborted) return;
const hydrate = module?.[`hydrate${entry.component}`];
const mount = module?.[`mount${entry.component}`];
if (typeof hydrate !== "function" || typeof mount !== "function") {
throw new Error(`SSR_ISLAND_ACTIVATION_MISSING: ${entry.component}`);
}
emitHydration("started", identity);
instance = hydrateComponent(
marker,
`noxid-island-end:${entry.id}`,
hydrate,
mount,
Object.freeze({ ...(entry.props ?? {}) }),
{},
null,
`island:${entry.component}:${entry.id}`,
runtimeOptions,
);
identity.ownerId = instance.owner?.id ?? null;
emitHydration("completed", identity, { adopted: entry.mode === "universal" });
} catch (error) {
emitHydration("failed", identity, { code: error?.code ?? null, message: error?.message ?? String(error) });
throw error;
}
};
const cancel = entry.hydration === "eager"
? null
: scheduleHydration(marker, entry.hydration, activate, identity);
if (entry.hydration === "eager") {
emitHydration("scheduled", identity);
emitHydration("triggered", identity, { trigger: "eager" });
await activate();
}
instances.push(Object.freeze({
get owner() { return instance?.owner ?? null; },
dispose() {
if (disposed) return;
disposed = true;
cancel?.();
instance?.dispose();
marker.remove();
},
}));
}
return instances;
} catch (error) {
for (let index = instances.length - 1; index >= 0; index -= 1) instances[index]?.dispose?.();
throw error;
}
}
function mountLoadedTargets(root, loaded, props, runtimeOptions) {
return activateLoadedTargets(root, loaded, props, runtimeOptions, false);
}
function hydrateLoadedTargets(root, loaded, props, runtimeOptions, signal) {
return hydrateTargetsByPolicy(root, loaded, props, runtimeOptions, signal);
}
function resourceSnapshotParameterKey(value) {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(resourceSnapshotParameterKey).join(",")}]`;
return `{${Object.keys(value).sort().map((name) => `${JSON.stringify(name)}:${resourceSnapshotParameterKey(value[name])}`).join(",")}}`;
}
export function startRouter({
root,
routes,
middleware: middlewareLoaders = {},
islands: islandLoaders = {},
host = {},
streams: endpointStreams = Object.freeze({}),
basePath: configuredBasePath = "/",
defaultTitle: configuredDefaultTitle = document.title,
hydration = null,
}) {
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 props = Object.freeze({ ...matched.params, ...query });
const context = Object.freeze({
route: matched.route,
params: matched.params,
query,
props,
url,
path: applicationPath,
basePath,
signal: controller.signal,
host: host.context ?? null,
});
let middlewareResult;
try { middlewareResult = await executeMiddleware(matched.route.middleware, middlewareLoaders, context); }
catch (cause) {
throw prefetchFailure("ROUTE_PREFETCH_MIDDLEWARE_FAILED", `Route prefetch middleware failed for ${matched.route.id}`, matched.route, cause);
}
if (middlewareResult.redirect) throw prefetchFailure("ROUTE_PREFETCH_REDIRECTED", `Route prefetch middleware redirected ${matched.route.id}`, matched.route);
if (middlewareResult.denied) throw prefetchFailure("ROUTE_PREFETCH_DENIED", `Route prefetch middleware denied ${matched.route.id}`, matched.route);
let modules;
try {
modules = await Promise.all(matched.route.targets.map((target) => (
typeof target.load === "function" ? target.load() : null
)));
} catch (cause) {
throw prefetchFailure("ROUTE_PREFETCH_MODULE_FAILED", `Route prefetch could not load modules for ${matched.route.id}`, matched.route, cause);
}
const runtimeOptions = runtimeOptionsFor(
host,
matched.route,
matched.params,
query,
url,
applicationPath,
basePath,
middlewareResult.applied,
endpointStreams,
prefetchRoute,
);
try {
props = await acquireRouteLoaders(matched.route.targets, props, runtimeOptions, controller.signal);
} catch (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;
if (typeof host.routeError === "function") {
await host.routeError(boundaryError, { ...context, originalError: error, root });
pending = null;
return false;
}
renderFailure(root, "ROUTE_ERROR_BOUNDARY_FAILED", String(boundaryError?.message ?? boundaryError));
pending = null;
return false;
}
}
if (typeof host.routeError === "function") {
await host.routeError(error, { ...context, root });
} else {
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);
const redirectDepth = options.redirectDepth ?? 0;
if (redirectDepth > MAX_REDIRECTS) {
throw new NoxidMiddlewareError("MIDDLEWARE_REDIRECT_LIMIT", null, url.pathname);
}
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;
if (typeof host.notFound === "function") await host.notFound({ root, url, signal: controller.signal });
else renderFailure(root, "NOT_FOUND", `No route matches ${applicationPath}`);
pending = null;
return false;
}
const shellBoundary = serverShellBoundary(matched.route);
if (current && matched.route.targets.some((target) => target.renderMode === "server")) {
location.assign(url.href);
pending = null;
return false;
}
const baseContext = {
route: matched.route,
params: matched.params,
url,
path: applicationPath,
basePath,
signal: controller.signal,
host: host.context ?? null,
};
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(
host,
matched.route,
matched.params,
emptyQuery,
url,
applicationPath,
basePath,
[],
endpointStreams,
prefetchRoute,
);
applyRouteMetadata(null, defaultTitle);
disposeView(current);
current = null;
return showRouteError(error, context, runtimeOptions, options);
}
let routeProps = Object.freeze({ ...matched.params, ...query });
const context = {
...baseContext,
query,
props: routeProps,
};
let middlewareResult;
try {
middlewareResult = await executeMiddleware(matched.route.middleware, middlewareLoaders, context);
} catch (error) {
if (controller.signal.aborted) return false;
applyRouteMetadata(null, defaultTitle);
const runtimeOptions = runtimeOptionsFor(
host,
matched.route,
matched.params,
query,
url,
applicationPath,
basePath,
[],
endpointStreams,
prefetchRoute,
);
disposeView(current);
current = null;
return showRouteError(error, context, runtimeOptions, options);
}
if (navigation !== sequence || controller.signal.aborted) return false;
if (middlewareResult.redirect) {
const redirect = resolveNavigation(middlewareResult.redirect, basePath);
if (redirect.href === url.href) {
throw new NoxidMiddlewareError("MIDDLEWARE_REDIRECT_LOOP", middlewareResult.name, matched.route.id);
}
return navigate(redirect, {
replace: middlewareResult.replace,
redirectDepth: redirectDepth + 1,
});
}
if (middlewareResult.denied) {
applyRouteMetadata(null, defaultTitle);
disposeView(current);
current = null;
if (typeof host.denied === "function") await host.denied({ ...context, result: middlewareResult.result });
else renderFailure(root, "ACCESS_DENIED", `Middleware ${middlewareResult.name} denied this route.`);
pending = null;
return false;
}
const runtimeOptions = runtimeOptionsFor(
host,
matched.route,
matched.params,
query,
url,
applicationPath,
basePath,
middlewareResult.applied,
endpointStreams,
prefetchRoute,
);
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;
}
routeProps = await acquireRouteLoaders(
matched.route.targets,
routeProps,
runtimeOptions,
controller.signal,
);
context.props = routeProps;
if (navigation !== sequence || controller.signal.aborted) {
if (loadingView && transition === loadingView) {
disposeView(loadingView);
transition = null;
}
return false;
}
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 = shellBoundary >= 0
? await hydrateTargetsByPolicy(
root,
routeAssets.loaded,
routeProps,
runtimeOptions,
controller.signal,
shellBoundary,
)
: 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);
}
}
async function hydrateInitial(payload) {
if (!payload || payload.schemaVersion !== 1 || payload.domMarkerSchemaVersion !== DOM_REGION_MARKER_SCHEMA_VERSION || typeof payload.routeId !== "string") return false;
const route = routes.find((candidate) => candidate.id === payload.routeId);
if (Array.isArray(payload.islands) && payload.islands.length > 0 && payload.islandSchemaVersion !== 2) return false;
if (!route) return false;
const shellBoundary = serverShellBoundary(route);
const serverShell = shellBoundary >= 0;
if (route.render?.mode === "client" && !serverShell) return false;
const url = new URL(location.href);
if (payload.url !== `${url.pathname}${url.search}`) return false;
const applicationPath = stripBasePath(url.pathname, basePath);
const params = applicationPath === null ? null : matchRoute(route, applicationPath);
if (params === null) return false;
const query = parseRouteQuery(route, url);
const controller = new AbortController();
pending = controller;
sequence += 1;
const props = Object.freeze({ ...(payload.props ?? {}) });
const applied = Array.isArray(payload.middleware) ? payload.middleware : [];
const runtimeOptions = runtimeOptionsFor(
host,
route,
params,
query,
url,
applicationPath,
basePath,
applied,
endpointStreams,
prefetchRoute,
);
if (payload.resourceSnapshotSchemaVersion !== 1 || !Array.isArray(payload.resources)) return false;
const resourceSnapshots = Object.create(null);
for (const snapshot of payload.resources) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return false;
const keys = Object.keys(snapshot).sort();
if (keys.length !== 5 || keys.join(",") !== "acquisitionId,data,parameters,renderedAt,resourceId") return false;
if (typeof snapshot.acquisitionId !== "string" || snapshot.acquisitionId.length === 0) return false;
if (typeof snapshot.resourceId !== "string" || snapshot.resourceId.length === 0) return false;
if (!snapshot.parameters || typeof snapshot.parameters !== "object" || Array.isArray(snapshot.parameters)) return false;
if (!Object.prototype.hasOwnProperty.call(snapshot, "data")) return false;
if (typeof snapshot.renderedAt !== "number" || !Number.isFinite(snapshot.renderedAt) || snapshot.renderedAt < 0) return false;
const trustedSnapshot = Object.freeze({
acquisitionId: snapshot.acquisitionId,
resourceId: snapshot.resourceId,
parameters: Object.freeze({ ...snapshot.parameters }),
data: snapshot.data,
renderedAt: snapshot.renderedAt,
});
const existing = resourceSnapshots[snapshot.acquisitionId];
if (existing === undefined) {
resourceSnapshots[snapshot.acquisitionId] = trustedSnapshot;
} else {
const snapshots = Array.isArray(existing) ? existing : [existing];
const parameterKey = resourceSnapshotParameterKey(trustedSnapshot.parameters);
if (snapshots.some((candidate) => resourceSnapshotParameterKey(candidate.parameters) === parameterKey)) return false;
resourceSnapshots[snapshot.acquisitionId] = [...snapshots, trustedSnapshot];
}
}
for (const acquisitionId of Object.keys(resourceSnapshots)) {
if (Array.isArray(resourceSnapshots[acquisitionId])) {
resourceSnapshots[acquisitionId] = Object.freeze(resourceSnapshots[acquisitionId]);
}
}
runtimeOptions.resourceSnapshots = Object.freeze(resourceSnapshots);
if (Array.isArray(payload.streams)) {
if (payload.streamSnapshotSchemaVersion !== 1) return false;
const snapshots = Object.create(null);
for (const snapshot of payload.streams) {
if (!snapshot || typeof snapshot.acquisitionId !== "string" || typeof snapshot.streamId !== "string" || !snapshot.event || typeof snapshot.event.tag !== "string") return false;
if (snapshots[snapshot.acquisitionId]) return false;
snapshots[snapshot.acquisitionId] = Object.freeze({ ...snapshot });
}
runtimeOptions.streamSnapshots = Object.freeze(snapshots);
}
let routeAssets = null;
let routeInstance = null;
try {
applyRouteMetadata(route.metadata, defaultTitle);
routeAssets = await loadTargets(route.targets, controller.signal, "", { reuseStyles: true, deferHydration: true });
routeInstance = serverShell
? await hydrateTargetsByPolicy(
root,
routeAssets.loaded,
props,
runtimeOptions,
controller.signal,
shellBoundary,
)
: await hydrateLoadedTargets(root, routeAssets.loaded, props, runtimeOptions, controller.signal);
const islandInstances = await hydrateIndependentIslands(root, payload.islands, islandLoaders, runtimeOptions, controller.signal);
const instance = combineRouteInstances(routeInstance, islandInstances);
current = { route, instance, styles: routeAssets.styles, url };
pending = null;
return true;
} catch (error) {
routeInstance?.dispose?.();
routeAssets?.styles?.forEach((link) => link.remove());
pending = null;
console.error("NOXID_SSR_HYDRATION_FAILED", error);
return false;
}
}
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) return;
const applicationPath = stripBasePath(url.pathname, basePath);
const destination = applicationPath === null
? null
: routes.find((route) => matchRoute(route, applicationPath) !== null);
if (destination?.render?.mode === "prerender") 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);
if (ROUTER_SUPPORTS_HYDRATION && hydration) {
void hydrateInitial(hydration).then((hydrated) => {
if (!hydrated) void navigate(location.href, { popstate: true, replace: true });
});
} else {
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);
},
});
}