712 lines
20 KiB
TypeScript
712 lines
20 KiB
TypeScript
/**
|
|
* Client-side navigation runtime, served at `/__wrnexus/nav.js`.
|
|
*
|
|
* Progressive enhancement over normal links: intercepts same-origin `<a>`
|
|
* clicks, fetches the target page's HTML, swaps the `#app` container in place,
|
|
* updates history/title/scroll, ensures any framework runtimes the new page
|
|
* needs are present, and re-hydrates.
|
|
*
|
|
* Before replacing the current page, component lifecycle behaviors are
|
|
* explicitly disposed. This ensures `unmount` hooks and watcher cleanups run
|
|
* before the old DOM is removed.
|
|
*
|
|
* Programmatic navigation is exposed as:
|
|
*
|
|
* window.__wrnexusNavigate(url)
|
|
*/
|
|
|
|
export const NAV_RUNTIME = String.raw`
|
|
(function () {
|
|
if (
|
|
!window.history ||
|
|
!history.pushState ||
|
|
!window.fetch ||
|
|
!window.DOMParser
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (window.__wrnexusNavInstalled) {
|
|
return;
|
|
}
|
|
|
|
window.__wrnexusNavInstalled = true;
|
|
|
|
var APP_ID = "app";
|
|
var inFlight = null;
|
|
var memory = Object.create(null);
|
|
var keepAlive = Object.create(null);
|
|
var keepAliveOrder = [];
|
|
|
|
function keepAliveKey(node) {
|
|
return String(node && node.getAttribute("data-wrn-keepalive") || "");
|
|
}
|
|
|
|
function retainKeepAlive(root) {
|
|
Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) {
|
|
var key = keepAliveKey(node);
|
|
if (!key) return;
|
|
if (!keepAlive[key]) keepAliveOrder.push(key);
|
|
keepAlive[key] = node;
|
|
if (node.parentNode) node.parentNode.removeChild(node);
|
|
});
|
|
while (keepAliveOrder.length > 32) {
|
|
var expired = keepAliveOrder.shift();
|
|
if (expired && keepAlive[expired]) {
|
|
dispose(keepAlive[expired]);
|
|
delete keepAlive[expired];
|
|
}
|
|
}
|
|
}
|
|
|
|
function restoreKeepAlive(root) {
|
|
var placeholders = [];
|
|
if (root.matches && root.matches("[data-wrn-keepalive]")) placeholders.push(root);
|
|
Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) { placeholders.push(node); });
|
|
placeholders.forEach(function (placeholder) {
|
|
var saved = keepAlive[keepAliveKey(placeholder)];
|
|
if (saved && placeholder.parentNode) placeholder.parentNode.replaceChild(saved, placeholder);
|
|
});
|
|
}
|
|
|
|
function preservationPolicy(doc) {
|
|
var meta = doc.querySelector('meta[name="wrnexus-preserve"]');
|
|
var out = Object.create(null);
|
|
String(meta && meta.getAttribute("content") || "").split(",").forEach(function (name) {
|
|
if (name) out[name] = true;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function syncPreservationPolicy(nextDocument) {
|
|
var selector = 'meta[name="wrnexus-preserve"]';
|
|
var current = document.querySelector(selector);
|
|
var next = nextDocument.querySelector(selector);
|
|
if (!next) {
|
|
if (current) current.remove();
|
|
return;
|
|
}
|
|
if (!current) {
|
|
current = document.createElement("meta");
|
|
current.setAttribute("name", "wrnexus-preserve");
|
|
document.head.appendChild(current);
|
|
}
|
|
current.setAttribute("content", next.getAttribute("content") || "");
|
|
}
|
|
|
|
function stateKey(url) {
|
|
try {
|
|
var parsed = new URL(url, location.href);
|
|
return "wrnexus:navigation:" + parsed.pathname + parsed.search;
|
|
} catch (_) {
|
|
return "wrnexus:navigation:" + String(url);
|
|
}
|
|
}
|
|
|
|
function safeField(field) {
|
|
var type = String(field.type || "").toLowerCase();
|
|
var name = String(field.name || field.id || "").toLowerCase();
|
|
return type !== "password" && type !== "file" && type !== "hidden" &&
|
|
!field.hasAttribute("data-no-preserve") &&
|
|
!/(?:csrf|token|secret|password|credential)/.test(name);
|
|
}
|
|
|
|
function capturePage(url) {
|
|
var policy = preservationPolicy(document);
|
|
var state = { fields: Object.create(null), elements: Object.create(null) };
|
|
if (policy.scroll) state.scroll = [window.scrollX || 0, window.scrollY || 0];
|
|
if (policy.forms || policy.filters || policy.pagination || policy.workflow) {
|
|
Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) {
|
|
if (!safeField(field)) return;
|
|
var key = field.name || field.id || String(index);
|
|
state.fields[key] = { value: field.value, checked: !!field.checked, selectedIndex: field.selectedIndex };
|
|
});
|
|
}
|
|
if (policy.tabs || policy.expanded || policy.component) {
|
|
Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) {
|
|
var key = node.getAttribute("data-wrn-preserve") || node.id || String(index);
|
|
state.elements[key] = {
|
|
selected: node.getAttribute("aria-selected"),
|
|
expanded: node.getAttribute("aria-expanded"),
|
|
value: "value" in node ? node.value : null,
|
|
};
|
|
});
|
|
}
|
|
memory[stateKey(url)] = state;
|
|
try { sessionStorage.setItem(stateKey(url), JSON.stringify(state)); } catch (_) {}
|
|
}
|
|
|
|
function restorePage(url, isPop) {
|
|
var policy = preservationPolicy(document);
|
|
var state = memory[stateKey(url)];
|
|
if (!state) {
|
|
try { state = JSON.parse(sessionStorage.getItem(stateKey(url)) || "null"); } catch (_) {}
|
|
}
|
|
if (state && (policy.forms || policy.filters || policy.pagination || policy.workflow)) {
|
|
Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) {
|
|
if (!safeField(field)) return;
|
|
var saved = state.fields && state.fields[field.name || field.id || String(index)];
|
|
if (!saved) return;
|
|
if (field.type === "checkbox" || field.type === "radio") field.checked = !!saved.checked;
|
|
else field.value = saved.value;
|
|
if (field.tagName === "SELECT") field.selectedIndex = saved.selectedIndex;
|
|
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
if (state && (policy.tabs || policy.expanded || policy.component)) {
|
|
Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) {
|
|
var key = node.getAttribute("data-wrn-preserve") || node.id || String(index);
|
|
var saved = state.elements && state.elements[key];
|
|
if (!saved) return;
|
|
if (saved.selected != null) node.setAttribute("aria-selected", saved.selected);
|
|
if (saved.expanded != null) node.setAttribute("aria-expanded", saved.expanded);
|
|
if (saved.value != null && "value" in node) node.value = saved.value;
|
|
});
|
|
}
|
|
if (state && policy.scroll && state.scroll) window.scrollTo(state.scroll[0], state.scroll[1]);
|
|
else if (!isPop) window.scrollTo(0, 0);
|
|
}
|
|
|
|
function pathOf(src) {
|
|
return String(src).split("?")[0];
|
|
}
|
|
|
|
function isLocalLink(anchor) {
|
|
if (
|
|
!anchor ||
|
|
anchor.hasAttribute("download") ||
|
|
anchor.hasAttribute("data-no-nav")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (anchor.target && anchor.target !== "_self") {
|
|
return false;
|
|
}
|
|
|
|
if (anchor.origin !== location.origin) {
|
|
return false;
|
|
}
|
|
|
|
var href = anchor.getAttribute("href");
|
|
|
|
if (!href || href.charAt(0) === "#") {
|
|
return false;
|
|
}
|
|
|
|
var rel = (
|
|
anchor.getAttribute("rel") || ""
|
|
).toLowerCase();
|
|
|
|
return rel.indexOf("external") === -1;
|
|
}
|
|
|
|
function currentDocumentNonce() {
|
|
var node = document.querySelector("script[nonce],style[nonce]");
|
|
if (!node) return "";
|
|
return node.nonce || node.getAttribute("nonce") || "";
|
|
}
|
|
|
|
function syncWrnStyles(nextDocument) {
|
|
var current = Array.prototype.slice.call(
|
|
document.querySelectorAll("style[data-wrnexus-style-id]"),
|
|
);
|
|
var next = Array.prototype.slice.call(
|
|
nextDocument.querySelectorAll("style[data-wrnexus-style-id]"),
|
|
);
|
|
var currentById = Object.create(null);
|
|
var nextIds = Object.create(null);
|
|
var nonce = currentDocumentNonce();
|
|
|
|
current.forEach(function (style) {
|
|
var id = style.getAttribute("data-wrnexus-style-id");
|
|
if (id) currentById[id] = style;
|
|
});
|
|
|
|
next.forEach(function (nextStyle) {
|
|
var id = nextStyle.getAttribute("data-wrnexus-style-id");
|
|
if (!id) return;
|
|
nextIds[id] = true;
|
|
|
|
var currentStyle = currentById[id];
|
|
if (!currentStyle) {
|
|
currentStyle = document.createElement("style");
|
|
currentStyle.setAttribute("data-wrnexus-style-id", id);
|
|
}
|
|
|
|
["data-wrnexus-style-owner", "data-wrnexus-style-kind"].forEach(function (name) {
|
|
var value = nextStyle.getAttribute(name);
|
|
if (value == null) currentStyle.removeAttribute(name);
|
|
else currentStyle.setAttribute(name, value);
|
|
});
|
|
|
|
if (nonce) currentStyle.setAttribute("nonce", nonce);
|
|
if (currentStyle.textContent !== nextStyle.textContent) {
|
|
currentStyle.textContent = nextStyle.textContent;
|
|
}
|
|
|
|
document.head.appendChild(currentStyle);
|
|
});
|
|
|
|
current.forEach(function (style) {
|
|
var id = style.getAttribute("data-wrnexus-style-id");
|
|
if (id && !nextIds[id]) style.remove();
|
|
});
|
|
}
|
|
|
|
function loadedScriptPaths() {
|
|
var loaded = {};
|
|
|
|
document
|
|
.querySelectorAll("script[src]")
|
|
.forEach(function (script) {
|
|
var src = script.getAttribute("src");
|
|
|
|
if (src) {
|
|
loaded[pathOf(src)] = true;
|
|
}
|
|
});
|
|
|
|
return loaded;
|
|
}
|
|
|
|
function runtimeIds(root) {
|
|
var ids = {};
|
|
if (!root || !root.querySelectorAll) return ids;
|
|
var nodes = [];
|
|
if (root.matches && root.matches("[data-wrnexus-runtime]")) nodes.push(root);
|
|
root.querySelectorAll("[data-wrnexus-runtime]").forEach(function (node) { nodes.push(node); });
|
|
nodes.forEach(function (node) {
|
|
String(node.getAttribute("data-wrnexus-runtime") || "")
|
|
.split(/[\s,]+/)
|
|
.forEach(function (id) { if (id) ids[id] = true; });
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
function packageRuntimeRegistry() {
|
|
return window.__wrnexusRuntimes || {};
|
|
}
|
|
|
|
function mountPackageRuntimes(root) {
|
|
var ids = runtimeIds(root);
|
|
var registry = packageRuntimeRegistry();
|
|
Object.keys(ids).forEach(function (id) {
|
|
var runtime = registry[id];
|
|
if (!runtime || typeof runtime.mount !== "function") return;
|
|
try { runtime.mount(root); }
|
|
catch (error) { console.error("[wrnexus] failed to mount runtime", id, error); }
|
|
});
|
|
}
|
|
|
|
function unmountPackageRuntimes(root) {
|
|
var ids = runtimeIds(root);
|
|
var registry = packageRuntimeRegistry();
|
|
Object.keys(ids).forEach(function (id) {
|
|
var runtime = registry[id];
|
|
if (!runtime || typeof runtime.unmount !== "function") return;
|
|
try { runtime.unmount(root); }
|
|
catch (error) { console.error("[wrnexus] failed to unmount runtime", id, error); }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append framework runtimes declared by the incoming document but not
|
|
* currently loaded.
|
|
*/
|
|
function ensureScripts(doc) {
|
|
var loaded = loadedScriptPaths();
|
|
|
|
doc
|
|
.querySelectorAll("script[src]")
|
|
.forEach(function (script) {
|
|
var src = script.getAttribute("src");
|
|
|
|
if (!src || loaded[pathOf(src)]) {
|
|
return;
|
|
}
|
|
|
|
loaded[pathOf(src)] = true;
|
|
|
|
var element =
|
|
document.createElement("script");
|
|
|
|
Array.prototype.forEach.call(
|
|
script.attributes,
|
|
function (attribute) {
|
|
if (attribute.name === "src") return;
|
|
element.setAttribute(
|
|
attribute.name,
|
|
attribute.value,
|
|
);
|
|
},
|
|
);
|
|
element.src = src;
|
|
if (!script.hasAttribute("async")) {
|
|
element.async = false;
|
|
}
|
|
element.addEventListener("load", function () {
|
|
try {
|
|
mountPackageRuntimes(document);
|
|
window.dispatchEvent(
|
|
new CustomEvent(
|
|
"wrnexus:runtime-loaded",
|
|
{ detail: { src: src } },
|
|
),
|
|
);
|
|
} catch (_) {}
|
|
});
|
|
element.addEventListener("error", function () {
|
|
console.error(
|
|
"[wrnexus] failed to load client runtime",
|
|
src,
|
|
);
|
|
});
|
|
|
|
document.body.appendChild(element);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run explicit component cleanup before removing the existing page.
|
|
*
|
|
* The reactive runtime also observes removed DOM nodes, but explicit
|
|
* disposal here guarantees that unmount hooks run before replacement.
|
|
*/
|
|
function dispose(root) {
|
|
unmountPackageRuntimes(root);
|
|
try {
|
|
var stores = window.__wrnexusStoreContainer;
|
|
if (stores && typeof stores.disposePageStores === "function") {
|
|
Promise.resolve(stores.disposePageStores()).catch(function (error) {
|
|
console.error("[wrnexus] failed to dispose page stores", error);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("[wrnexus] failed to access page stores", error);
|
|
}
|
|
try {
|
|
if (
|
|
typeof window.__wrnexusDisposeBehaviors ===
|
|
"function"
|
|
) {
|
|
window.__wrnexusDisposeBehaviors(root);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to dispose page behaviors",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-hydrate already-loaded runtimes against newly inserted DOM.
|
|
*
|
|
* All framework hydration functions must remain idempotent.
|
|
*/
|
|
function rehydrate(root) {
|
|
mountPackageRuntimes(root);
|
|
try {
|
|
if (
|
|
typeof window.__wrnexusHydrateScopes ===
|
|
"function"
|
|
) {
|
|
window.__wrnexusHydrateScopes(root);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to hydrate reactive scopes",
|
|
error,
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (
|
|
typeof window.__wrnexusHydrateCsrFetches ===
|
|
"function"
|
|
) {
|
|
window.__wrnexusHydrateCsrFetches(root);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to hydrate CSR bindings",
|
|
error,
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (
|
|
window.__wireValidate &&
|
|
typeof window.__wireValidate.init ===
|
|
"function"
|
|
) {
|
|
window.__wireValidate.init(document);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to hydrate validation",
|
|
error,
|
|
);
|
|
}
|
|
|
|
try {
|
|
if (
|
|
window.wireTheme &&
|
|
typeof window.wireTheme.bind ===
|
|
"function"
|
|
) {
|
|
window.wireTheme.bind(document);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to hydrate theme bindings",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function dispatchNavigationEvent(url) {
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent("wrnexus:navigated", {
|
|
detail: {
|
|
url: url,
|
|
},
|
|
}),
|
|
);
|
|
} catch (_) {
|
|
// CustomEvent may not be available in very old browsers.
|
|
}
|
|
}
|
|
|
|
function hardNavigate(url) {
|
|
location.href = url;
|
|
}
|
|
|
|
function render(html, url, isPop) {
|
|
var doc;
|
|
|
|
try {
|
|
doc = new DOMParser().parseFromString(
|
|
html,
|
|
"text/html",
|
|
);
|
|
} catch (_) {
|
|
hardNavigate(url);
|
|
return;
|
|
}
|
|
|
|
capturePage(location.href);
|
|
|
|
var incomingApp =
|
|
doc.getElementById(APP_ID);
|
|
|
|
var currentApp =
|
|
document.getElementById(APP_ID);
|
|
|
|
if (!incomingApp || !currentApp) {
|
|
hardNavigate(url);
|
|
return;
|
|
}
|
|
|
|
if (doc.title) {
|
|
document.title = doc.title;
|
|
}
|
|
|
|
syncPreservationPolicy(doc);
|
|
|
|
syncWrnStyles(doc);
|
|
|
|
var importedNodes = [];
|
|
|
|
for (
|
|
var index = 0;
|
|
index < incomingApp.childNodes.length;
|
|
index++
|
|
) {
|
|
importedNodes.push(
|
|
document.importNode(
|
|
incomingApp.childNodes[index],
|
|
true,
|
|
),
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Run unmount hooks and watcher cleanup while the current DOM is still
|
|
* connected. This is important for components that remove window or
|
|
* document listeners during lifecycle.unmount.
|
|
*/
|
|
retainKeepAlive(currentApp);
|
|
dispose(currentApp);
|
|
|
|
try {
|
|
/*
|
|
* Avoid innerHTML so navigation works with strict Trusted Types and CSP
|
|
* configurations.
|
|
*/
|
|
currentApp.replaceChildren.apply(
|
|
currentApp,
|
|
importedNodes,
|
|
);
|
|
restoreKeepAlive(currentApp);
|
|
} catch (_) {
|
|
hardNavigate(url);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Load any runtime required only by the incoming page. Fresh scripts
|
|
* initialise themselves when loaded.
|
|
*/
|
|
ensureScripts(doc);
|
|
|
|
/*
|
|
* Hydrate runtimes that are already available. Hydration is scoped to
|
|
* #app instead of the entire document for less DOM traversal.
|
|
*/
|
|
rehydrate(currentApp);
|
|
|
|
if (!isPop) {
|
|
history.pushState(
|
|
{
|
|
wrnexusNav: true,
|
|
},
|
|
"",
|
|
url,
|
|
);
|
|
|
|
}
|
|
|
|
restorePage(url, isPop);
|
|
|
|
dispatchNavigationEvent(url);
|
|
}
|
|
|
|
function navigate(url, isPop) {
|
|
var token = {};
|
|
|
|
inFlight = token;
|
|
window.dispatchEvent(new CustomEvent("wrnexus:navigation-start", { detail: { url: url } }));
|
|
|
|
var pending = prefetched.get(url) || requestDocument(url, false);
|
|
prefetched.delete(url);
|
|
pending
|
|
.then(function (result) {
|
|
if (inFlight !== token) return null;
|
|
if (result.redirectedUrl) url = result.redirectedUrl;
|
|
if (result.contentType.indexOf("text/html") === -1) {
|
|
hardNavigate(url);
|
|
return null;
|
|
}
|
|
render(result.text, url, isPop);
|
|
return null;
|
|
})
|
|
.catch(function () {
|
|
if (inFlight === token) hardNavigate(url);
|
|
});
|
|
}
|
|
|
|
var prefetched = new Map();
|
|
function requestDocument(url, isPrefetch) {
|
|
return fetch(url, {
|
|
headers: {
|
|
"x-wrnexus-nav": "1",
|
|
...(isPrefetch ? { "x-wrnexus-prefetch": "1" } : {}),
|
|
accept: "text/html",
|
|
},
|
|
|
|
credentials: "same-origin",
|
|
}).then(function (response) {
|
|
var contentType = response.headers.get("content-type") || "";
|
|
return response.text().then(function (text) {
|
|
return {
|
|
text: text,
|
|
contentType: contentType,
|
|
redirectedUrl: response.redirected && response.url ? response.url : "",
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
function prefetch(anchor) {
|
|
if (!isLocalLink(anchor) || anchor.hasAttribute("data-no-prefetch")) return;
|
|
var url = anchor.href;
|
|
if (url === location.href || prefetched.has(url)) return;
|
|
prefetched.set(url, requestDocument(url, true));
|
|
while (prefetched.size > 20) prefetched.delete(prefetched.keys().next().value);
|
|
}
|
|
|
|
// Prefetch only on explicit pointer intent. Delegated pointerover fires when
|
|
// scrolling moves links underneath a stationary pointer, which can otherwise
|
|
// download every link on a long index page.
|
|
document.addEventListener("pointerdown", function (event) {
|
|
var anchor = event.target && event.target.closest ? event.target.closest("a") : null;
|
|
prefetch(anchor);
|
|
}, { passive: true });
|
|
document.addEventListener("focusin", function (event) {
|
|
var anchor = event.target && event.target.closest ? event.target.closest("a") : null;
|
|
prefetch(anchor);
|
|
});
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
function (event) {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.shiftKey ||
|
|
event.altKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var anchor =
|
|
event.target &&
|
|
event.target.closest
|
|
? event.target.closest("a")
|
|
: null;
|
|
|
|
if (!isLocalLink(anchor)) {
|
|
return;
|
|
}
|
|
|
|
if (anchor.href === location.href) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
navigate(anchor.href, false);
|
|
},
|
|
false,
|
|
);
|
|
|
|
window.addEventListener(
|
|
"popstate",
|
|
function () {
|
|
navigate(location.href, true);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Programmatic navigation for forms, actions and application code.
|
|
*/
|
|
window.__wrnexusNavigate = function (url) {
|
|
navigate(
|
|
new URL(url, location.href).href,
|
|
false,
|
|
);
|
|
};
|
|
|
|
/** Re-render the current route without adding a duplicate history entry. */
|
|
window.__wrnexusRefresh = function () {
|
|
navigate(location.href, true);
|
|
};
|
|
})();
|
|
`.trim();
|