1721 lines
67 KiB
TypeScript
1721 lines
67 KiB
TypeScript
/**
|
|
* Shared request runtime used by BOTH the dev server and the production server.
|
|
*
|
|
* It owns the HTTP/WebSocket dispatch and the SSR document assembly, but knows
|
|
* nothing about *how* modules or assets are produced — those come in via
|
|
* `RuntimeDeps`. Dev wires in dynamic module loading + on-the-fly bundling;
|
|
* prod wires in a static manifest + pre-built chunks on disk.
|
|
*/
|
|
|
|
import {
|
|
bridgeRealtime,
|
|
createContext,
|
|
createCorsPreflightResponse,
|
|
createRealtimeRegistry,
|
|
csrfToken,
|
|
etag,
|
|
isRoomDefinition,
|
|
isWebSocketOriginAllowed,
|
|
notModified,
|
|
isSafeRequestPath,
|
|
renderError,
|
|
renderNotFound,
|
|
withContextHeaders,
|
|
withSecurityHeaders,
|
|
resolveRequestUrl,
|
|
tenantMiddleware,
|
|
tracingMiddleware,
|
|
type Context,
|
|
type Middleware,
|
|
type Mode,
|
|
type PageMeta,
|
|
type RealtimeBus,
|
|
type RealtimeConnectMeta,
|
|
type SecurityConfig,
|
|
type SeoConfig,
|
|
type TFunction,
|
|
} from "@wrnexus/core";
|
|
import type { Router } from "@wrnexus/router";
|
|
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
|
|
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
|
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
|
|
import {
|
|
THEME_COOKIE,
|
|
THEME_CSS_HREF,
|
|
THEME_JS_HREF,
|
|
resolveThemeName,
|
|
type ResolvedTheme,
|
|
type MobileConfig,
|
|
type PwaConfig,
|
|
type ObservabilityConfig,
|
|
type TenancyConfig,
|
|
} from "@wrnexus/styles";
|
|
import {
|
|
LANG_COOKIE,
|
|
I18N_JS_HREF,
|
|
makeT,
|
|
resolveLang,
|
|
translateHtml,
|
|
type ResolvedI18n,
|
|
} from "@wrnexus/i18n";
|
|
import { runMiddleware } from "./pipeline.ts";
|
|
import type { HmrHub } from "./hmr.ts";
|
|
import type {
|
|
DevToolbarConfig,
|
|
DevToolbarPanel,
|
|
DevToolbarPlatformSnapshot,
|
|
} from "@wrnexus/dev-toolbar/types";
|
|
|
|
import {
|
|
createServerIssue,
|
|
handleDevToolbarRoute,
|
|
issueFromError,
|
|
type DevToolbarCollector,
|
|
} from "@wrnexus/dev-toolbar/server";
|
|
|
|
/** HTTP methods we recognize as API handler exports. */
|
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
|
|
|
|
/** A realtime module's `websocket` export: a bag of optional lifecycle hooks. */
|
|
export type WsHandler = Record<string, (...args: any[]) => unknown>;
|
|
|
|
/**
|
|
* Per-connection socket data. A socket is either an app realtime connection or
|
|
* an internal HMR connection — discriminated by `kind`.
|
|
*/
|
|
export type WsData =
|
|
| { kind: "realtime"; handler: WsHandler } // legacy raw `websocket` export
|
|
| { kind: "room"; meta: RealtimeConnectMeta } // `defineRoom` default export
|
|
| { kind: "hmr"; baseUrl: string; headers: [string, string][] };
|
|
|
|
type RouteModule = Record<string, unknown>;
|
|
type ApiRegistry = Record<string, unknown>;
|
|
interface CsrBinding {
|
|
id: string;
|
|
method?: string;
|
|
path: string;
|
|
body?: string;
|
|
expr?: string;
|
|
helpers?: string;
|
|
}
|
|
|
|
/** Serves framework-owned assets under `/__wrnexus/*` (islands, reactive, hmr). */
|
|
export interface AssetServer {
|
|
serve(pathname: string): Promise<Response | null>;
|
|
}
|
|
|
|
export interface RuntimeDeps {
|
|
mode: Mode;
|
|
/** When true, inject the live-reload client into rendered pages. */
|
|
hmr: boolean;
|
|
router: Router;
|
|
/** Load a route module by absolute path (dev: dynamic import; prod: manifest). */
|
|
loadModule(file: string): Promise<RouteModule>;
|
|
/** Resolve the ordered middleware chain. */
|
|
getMiddleware(): Promise<Middleware[]>;
|
|
/** Serve `/__wrnexus/*` assets. */
|
|
assets: AssetServer;
|
|
/** When true, inject the global stylesheet link into every page head. */
|
|
hasStyles?: boolean;
|
|
/** When true, inject the Wire UI stylesheet link (`/__wrnexus/ui.css`). */
|
|
hasUi?: boolean;
|
|
/** Production build combined theme + UI stylesheet. */
|
|
hasFrameworkStyles?: boolean;
|
|
/** App stylesheet already contains theme + UI CSS and is the only CSS request needed. */
|
|
stylesIncludeFramework?: boolean;
|
|
/** Resolved theme config: enables `/__wrnexus/theme.css` + `<html data-theme>`. */
|
|
theme?: ResolvedTheme;
|
|
/** Resolved i18n bundle: enables `ctx.t`, `<html lang>`, and `{t:key}` markers. */
|
|
i18n?: ResolvedI18n;
|
|
/** Small production stylesheets can be inlined to avoid a render-blocking request. */
|
|
inlineStyles?: string;
|
|
/** Production cache-busting version appended to framework asset URLs. */
|
|
assetVersion?: string;
|
|
/** Package browser runtimes resolved by the plugin system. */
|
|
clientRuntimes?: ClientRuntimeDefinition[];
|
|
/** Raw HTML appended to every page head (e.g. CDN framework links). */
|
|
head?: string;
|
|
/** Global SEO defaults. */
|
|
seo?: SeoConfig;
|
|
mobile?: MobileConfig;
|
|
pwa?: PwaConfig | false;
|
|
/** Framework security headers and CORS policy. */
|
|
security?: SecurityConfig;
|
|
/** Built-in request tracing and Server-Timing policy. */
|
|
observability?: ObservabilityConfig;
|
|
/** Built-in tenant identity resolution. */
|
|
tenancy?: TenancyConfig;
|
|
/** Max request body size in bytes (413 above this). Default 10 MB. */
|
|
maxBodyBytes?: number;
|
|
/** HMR hub for browser live-update sockets (dev only). */
|
|
hub?: HmrHub;
|
|
/**
|
|
* Cross-process realtime bus. When provided, room broadcasts/`toUser` sends are
|
|
* bridged to it so they reach clients on every app process/instance sharing the
|
|
* bus (use the Redis pub/sub driver). Enables realtime across multiple apps.
|
|
*/
|
|
realtimeBus?: RealtimeBus;
|
|
|
|
devToolbar?: {
|
|
config: DevToolbarConfig;
|
|
collector: DevToolbarCollector;
|
|
root: string;
|
|
platform?: DevToolbarPlatformSnapshot;
|
|
panels?: DevToolbarPanel[];
|
|
};
|
|
}
|
|
|
|
const DEV_TOOLBAR_SCRIPT =
|
|
'<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-dev-toolbar></script>';
|
|
|
|
function shouldEnableDevToolbar(mode: string, deps: RuntimeDeps): boolean {
|
|
return (
|
|
mode === "development" &&
|
|
deps.devToolbar !== undefined &&
|
|
deps.devToolbar.config.enabled !== false
|
|
);
|
|
}
|
|
|
|
function tenantIdentityFromConfig(
|
|
config: TenancyConfig,
|
|
): (ctx: Context) => Promise<{ id: string; slug?: string } | null> {
|
|
return async (ctx) => {
|
|
const host = ctx.url.hostname.toLowerCase();
|
|
|
|
if (config.mode === "domain") return host ? { id: host, slug: host } : null;
|
|
|
|
if (config.mode === "path") {
|
|
const segments = ctx.url.pathname.split("/").filter(Boolean);
|
|
const prefix = config.pathPrefix?.replace(/^\/+|\/+$/g, "");
|
|
const slug = prefix ? (segments[0] === prefix ? segments[1] : undefined) : segments[0];
|
|
return slug ? { id: slug, slug } : null;
|
|
}
|
|
|
|
if (config.mode === "subdomain" || config.mode === undefined) {
|
|
const roots = config.rootDomains?.map((domain) => domain.toLowerCase()) ?? [];
|
|
const root = roots.find((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0];
|
|
if (!slug || slug === host || slug === "www" || slug === "localhost") return null;
|
|
return { id: slug, slug };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
}
|
|
|
|
function frameworkMiddleware(deps: RuntimeDeps): Middleware[] {
|
|
const middleware: Middleware[] = [];
|
|
|
|
if (deps.observability && deps.observability.enabled !== false) {
|
|
middleware.push(
|
|
tracingMiddleware(undefined, {
|
|
sampleRate: deps.observability.sampleRate,
|
|
serverTiming: deps.observability.serverTiming,
|
|
onComplete:
|
|
deps.observability.exporter === "console"
|
|
? (ctx, records) => {
|
|
const total = records.find((record) => record.name === "http.request")?.durationMs;
|
|
console.log(
|
|
`[wrnexus:trace] ${ctx.req.method} ${ctx.url.pathname} ${total?.toFixed(2) ?? "0.00"}ms`,
|
|
);
|
|
}
|
|
: undefined,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (deps.tenancy && deps.tenancy.mode !== "custom") {
|
|
middleware.push(
|
|
tenantMiddleware(tenantIdentityFromConfig(deps.tenancy), {
|
|
required: deps.tenancy.required,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return middleware;
|
|
}
|
|
|
|
function shouldReportNotFound(pathname: string): boolean {
|
|
return !(
|
|
pathname.startsWith("/__wrnexus/") ||
|
|
pathname.startsWith("/.well-known/") ||
|
|
pathname === "/favicon.ico" ||
|
|
pathname.endsWith(".map")
|
|
);
|
|
}
|
|
|
|
export const PWA_CLIENT = `if ("serviceWorker" in navigator) {
|
|
var swUrl = "/sw.js";
|
|
if (window.trustedTypes) {
|
|
try {
|
|
swUrl = window.trustedTypes.createPolicy("wrnexus-pwa", { createScriptURL: function(value) { return value; } }).createScriptURL(swUrl);
|
|
} catch (_) {}
|
|
}
|
|
addEventListener("load", () => navigator.serviceWorker.register(swUrl).catch(() => {}));
|
|
}`;
|
|
|
|
function renderPwaServiceWorker(pwa: PwaConfig): string {
|
|
const offlineUrl = pwa.offlineUrl ?? pwa.startUrl ?? "/";
|
|
const cacheUrls = [...new Set([offlineUrl, ...(pwa.cacheUrls ?? [])])];
|
|
return `const CACHE = ${JSON.stringify(pwa.cacheName ?? "wrnexus-pwa-v1")};
|
|
const OFFLINE_URL = ${JSON.stringify(offlineUrl)};
|
|
const PRECACHE_URLS = ${JSON.stringify(cacheUrls)};
|
|
self.addEventListener("install", event => {
|
|
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(PRECACHE_URLS)).catch(() => {}));
|
|
self.skipWaiting();
|
|
});
|
|
self.addEventListener("activate", event => event.waitUntil(
|
|
caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith("wrnexus-pwa-") && key !== CACHE).map(key => caches.delete(key))))
|
|
.then(() => self.clients.claim())
|
|
));
|
|
self.addEventListener("fetch", event => {
|
|
if (event.request.method !== "GET" || event.request.mode !== "navigate") return;
|
|
event.respondWith(fetch(event.request).then(response => {
|
|
if (response.ok) {
|
|
const copy = response.clone();
|
|
caches.open(CACHE).then(cache => cache.put(event.request, copy));
|
|
}
|
|
return response;
|
|
}).catch(() => caches.match(event.request).then(hit => hit || caches.match(OFFLINE_URL))));
|
|
});`;
|
|
}
|
|
|
|
const DEFAULT_PWA_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
<rect width="512" height="512" rx="112" fill="#6366f1"/>
|
|
<path d="M112 144h64l42 168 38-128h48l38 128 42-168h64l-72 224h-60l-36-117-36 117h-60z" fill="white"/>
|
|
</svg>`;
|
|
|
|
export const MOBILE_CLIENT = `const isNativeApp = window.Capacitor?.isNativePlatform?.() === true ||
|
|
["android", "ios"].includes(window.Capacitor?.getPlatform?.());
|
|
const nativeTarget = isNativeApp ? "mobile" : "browser";
|
|
const nativeCapabilities = new Map();
|
|
const registerNative = (name, adapters) => nativeCapabilities.set(name, adapters);
|
|
const unregisterNative = name => nativeCapabilities.delete(name);
|
|
const nativeAdapter = name => nativeCapabilities.get(name)?.[nativeTarget];
|
|
const nativeSupports = name => {
|
|
const adapter = nativeAdapter(name);
|
|
return !!adapter && (adapter.supported?.() ?? true);
|
|
};
|
|
const nativeRun = async (name, options) => {
|
|
const adapter = nativeAdapter(name);
|
|
if (!adapter || !(adapter.supported?.() ?? true)) throw new Error('Native capability "' + name + '" is unavailable on ' + nativeTarget);
|
|
return adapter.run(options);
|
|
};
|
|
const cap = (plugin, method) => ({
|
|
supported: () => typeof window.Capacitor?.Plugins?.[plugin]?.[method] === "function",
|
|
run: options => window.Capacitor.Plugins[plugin][method](options)
|
|
});
|
|
const capWith = (plugin, method, transform) => ({
|
|
supported: () => typeof window.Capacitor?.Plugins?.[plugin]?.[method] === "function",
|
|
run: options => window.Capacitor.Plugins[plugin][method](transform(options))
|
|
});
|
|
registerNative("clipboard.write", {
|
|
browser: { supported: () => !!navigator.clipboard?.writeText, run: o => navigator.clipboard.writeText(String(o?.text ?? "")) },
|
|
mobile: capWith("Clipboard", "write", o => ({ string: String(o?.text ?? o?.string ?? "") }))
|
|
});
|
|
registerNative("share", {
|
|
browser: { supported: () => typeof navigator.share === "function", run: o => navigator.share(o) },
|
|
mobile: cap("Share", "share")
|
|
});
|
|
registerNative("geolocation", {
|
|
browser: { supported: () => !!navigator.geolocation, run: o => new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, o)) },
|
|
mobile: cap("Geolocation", "getCurrentPosition")
|
|
});
|
|
registerNative("network", {
|
|
browser: { run: () => ({ connected: navigator.onLine }) }, mobile: cap("Network", "getStatus")
|
|
});
|
|
registerNative("camera", {
|
|
browser: { supported: () => !!navigator.mediaDevices?.getUserMedia, run: o => navigator.mediaDevices.getUserMedia({ video: o ?? true, audio: false }) },
|
|
mobile: cap("Camera", "getPhoto")
|
|
});
|
|
registerNative("haptics", { mobile: cap("Haptics", "impact") });
|
|
nativeCapabilities.get("haptics").browser = { supported: () => typeof navigator.vibrate === "function", run: o => navigator.vibrate(o?.duration ?? 20) };
|
|
registerNative("storage.get", { browser: { run: o => ({ value: localStorage.getItem(String(o?.key ?? "")) }) }, mobile: cap("Preferences", "get") });
|
|
registerNative("storage.set", { browser: { run: o => localStorage.setItem(String(o?.key ?? ""), String(o?.value ?? "")) }, mobile: cap("Preferences", "set") });
|
|
registerNative("filesystem.read", {
|
|
browser: { supported: () => "showOpenFilePicker" in window, run: async () => { const [handle] = await window.showOpenFilePicker(); return handle?.getFile(); } },
|
|
mobile: cap("Filesystem", "readFile")
|
|
});
|
|
registerNative("filesystem.write", {
|
|
browser: { supported: () => "showSaveFilePicker" in window, run: async o => { const handle = await window.showSaveFilePicker(); const writable = await handle.createWritable(); await writable.write(o?.data ?? ""); await writable.close(); } },
|
|
mobile: cap("Filesystem", "writeFile")
|
|
});
|
|
registerNative("notifications.schedule", {
|
|
browser: { supported: () => typeof Notification !== "undefined", run: async o => { if (Notification.permission === "default") await Notification.requestPermission(); if (Notification.permission !== "granted") throw new Error("Notification permission was not granted"); return new Notification(o?.title ?? "Notification", { body: o?.body }); } },
|
|
mobile: capWith("LocalNotifications", "schedule", o => o?.notifications ? o : ({ notifications: [{ id: o?.id ?? Date.now() % 2147483647, title: o?.title ?? "Notification", body: o?.body ?? "", schedule: o?.schedule }] }))
|
|
});
|
|
registerNative("device.info", { browser: { run: () => ({ platform: "web", userAgent: navigator.userAgent, language: navigator.language }) }, mobile: cap("Device", "getInfo") });
|
|
window.__WrNexusNativePending?.forEach((adapters, name) => registerNative(name, adapters));
|
|
delete window.__WrNexusNativePending;
|
|
window.WrNexusNative = { target: nativeTarget, isMobile: isNativeApp, register: registerNative, unregister: unregisterNative, supports: nativeSupports, run: nativeRun };
|
|
const revealMobileOnly = root => {
|
|
const elements = [];
|
|
if (root instanceof Element) elements.push(root);
|
|
root.querySelectorAll?.("[data-mobile-only],[data-native-only],[data-native-requires],[data-native-unsupported]").forEach(element => elements.push(element));
|
|
elements.forEach(element => {
|
|
if (element.matches("[data-mobile-only]")) element.hidden = !isNativeApp;
|
|
const only = element.getAttribute("data-native-only");
|
|
if (only) element.hidden = !((only === "mobile" && isNativeApp) || (["browser", "web"].includes(only) && !isNativeApp) || only === window.Capacitor?.getPlatform?.());
|
|
const required = element.getAttribute("data-native-requires");
|
|
if (required) element.hidden = !nativeSupports(required);
|
|
const unsupported = element.getAttribute("data-native-unsupported");
|
|
if (unsupported) element.hidden = nativeSupports(unsupported);
|
|
});
|
|
};
|
|
revealMobileOnly(document);
|
|
new MutationObserver(records => {
|
|
records.forEach(record => record.addedNodes.forEach(node => {
|
|
revealMobileOnly(node);
|
|
installNativeEvents(node);
|
|
}));
|
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
|
|
const handleNativeEvent = async event => {
|
|
const eventSelector = "[data-on-wrnexus-browser-" + event.type + "],[data-on-wrnexus-mobile-" + event.type + "]";
|
|
const actionSelector = event.type === "click" ? ",[data-native-browser],[data-native-mobile]" : "";
|
|
const element = event.target.closest?.(eventSelector + actionSelector);
|
|
if (!element) return;
|
|
const platformEvent = "wrnexus-" + nativeTarget + "-" + event.type;
|
|
if (element.hasAttribute("data-on-" + platformEvent)) element.dispatchEvent(new CustomEvent(platformEvent));
|
|
if (event.type !== "click") return;
|
|
const capability = element.getAttribute("data-native-" + nativeTarget);
|
|
if (!capability) return;
|
|
let options = {};
|
|
const raw = element.getAttribute("data-native-options");
|
|
if (raw) try { options = JSON.parse(raw); } catch (error) {
|
|
element.dispatchEvent(new CustomEvent("wrnexus:native-error", { detail: error, bubbles: true }));
|
|
return;
|
|
}
|
|
try {
|
|
const result = await nativeRun(capability, options);
|
|
element.dispatchEvent(new CustomEvent("wrnexus:native-success", { detail: { capability, result }, bubbles: true }));
|
|
} catch (error) {
|
|
element.dispatchEvent(new CustomEvent("wrnexus:native-error", { detail: { capability, error }, bubbles: true }));
|
|
}
|
|
};
|
|
const nativeEventTypes = new Set();
|
|
const listenNativeEvent = type => {
|
|
if (nativeEventTypes.has(type)) return;
|
|
nativeEventTypes.add(type);
|
|
document.addEventListener(type, handleNativeEvent, true);
|
|
};
|
|
const installNativeEvents = root => {
|
|
const elements = [];
|
|
if (root instanceof Element) elements.push(root);
|
|
root.querySelectorAll?.("*").forEach(element => elements.push(element));
|
|
elements.forEach(element => Array.from(element.attributes).forEach(attribute => {
|
|
const match = /^data-on-wrnexus-(?:browser|mobile)-(.+)$/.exec(attribute.name);
|
|
if (match) listenNativeEvent(match[1]);
|
|
}));
|
|
};
|
|
listenNativeEvent("click");
|
|
installNativeEvents(document);
|
|
|
|
const openWebCamera = async image => {
|
|
if (!navigator.mediaDevices?.getUserMedia) return false;
|
|
let stream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: "environment" } },
|
|
audio: false
|
|
});
|
|
const overlay = document.createElement("div");
|
|
Object.assign(overlay.style, {
|
|
position: "fixed", inset: "0", zIndex: "2147483647", display: "grid",
|
|
placeItems: "center", padding: "24px", background: "rgba(2,6,23,.92)"
|
|
});
|
|
const panel = document.createElement("div");
|
|
Object.assign(panel.style, { width: "min(720px,100%)", color: "white" });
|
|
const video = document.createElement("video");
|
|
video.autoplay = true;
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.srcObject = stream;
|
|
Object.assign(video.style, {
|
|
display: "block", width: "100%", maxHeight: "70vh", objectFit: "contain",
|
|
borderRadius: "16px", background: "black"
|
|
});
|
|
const controls = document.createElement("div");
|
|
Object.assign(controls.style, { display: "flex", gap: "12px", marginTop: "16px" });
|
|
const capture = document.createElement("button");
|
|
capture.type = "button";
|
|
capture.textContent = "Capture photo";
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.textContent = "Cancel";
|
|
[capture, cancel].forEach(control => Object.assign(control.style, {
|
|
flex: "1", padding: "14px", border: "0", borderRadius: "12px",
|
|
fontWeight: "700", cursor: "pointer"
|
|
}));
|
|
capture.style.background = "#6366f1";
|
|
capture.style.color = "white";
|
|
const close = () => {
|
|
stream.getTracks().forEach(track => track.stop());
|
|
overlay.remove();
|
|
};
|
|
capture.onclick = () => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth || 1280;
|
|
canvas.height = video.videoHeight || 720;
|
|
canvas.getContext("2d")?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
if (image) image.src = canvas.toDataURL("image/jpeg", 0.92);
|
|
close();
|
|
};
|
|
cancel.onclick = close;
|
|
controls.append(capture, cancel);
|
|
panel.append(video, controls);
|
|
overlay.append(panel);
|
|
document.body.append(overlay);
|
|
await video.play();
|
|
return true;
|
|
} catch (error) {
|
|
stream?.getTracks().forEach(track => track.stop());
|
|
return false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener("click", async event => {
|
|
const button = event.target.closest?.("[data-mobile-camera]");
|
|
if (!button) return;
|
|
const selector = button.getAttribute("data-mobile-camera") || "[data-mobile-photo]";
|
|
const image = document.querySelector(selector);
|
|
try {
|
|
const camera = window.Capacitor?.Plugins?.Camera;
|
|
if (isNativeApp && camera) {
|
|
const photo = await camera.getPhoto({ quality: 90, resultType: "uri", source: "camera" });
|
|
if (image) image.src = photo.webPath || photo.path;
|
|
return;
|
|
}
|
|
if (await openWebCamera(image)) return;
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.setAttribute("capture", "environment");
|
|
input.hidden = true;
|
|
input.onchange = () => {
|
|
if (image && input.files?.[0]) image.src = URL.createObjectURL(input.files[0]);
|
|
input.remove();
|
|
};
|
|
document.body.append(input);
|
|
input.click();
|
|
} catch (error) {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:camera-error", { detail: error }));
|
|
}
|
|
});
|
|
|
|
const nativeApp = window.Capacitor?.Plugins?.App;
|
|
nativeApp?.addListener?.("backButton", ({ canGoBack }) => {
|
|
if (canGoBack || history.length > 1) history.back();
|
|
// At the root screen intentionally do nothing instead of closing the app.
|
|
});`;
|
|
|
|
function revealMobileOnlyHtml(html: string): string {
|
|
return html.replace(/<[^>]*\bdata-mobile-only\b[^>]*>/gi, (tag) =>
|
|
tag.replace(/\s+hidden(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/i, ""),
|
|
);
|
|
}
|
|
|
|
/** The global stylesheet URL (served by the asset server when styles exist). */
|
|
export const STYLES_HREF = "/__wrnexus/styles.css";
|
|
|
|
/**
|
|
* Inline HMR client (WebSocket). Goals:
|
|
* - CSS change -> hot-swap the stylesheet, zero reload, zero flash.
|
|
* - markup/page -> ask over the existing HMR WebSocket for fresh HTML and
|
|
* MORPH the live DOM in place, without restarting the server.
|
|
* - island code -> same WebSocket sync path; no location.reload().
|
|
*/
|
|
export const HMR_CLIENT_JS = `
|
|
(function () {
|
|
if (!("WebSocket" in window)) return;
|
|
var proto = location.protocol === "https:" ? "wss" : "ws";
|
|
var url = proto + "://" + location.host + "/__wrnexus/hmr";
|
|
var ws, openedBefore = false, timer, pendingSync = false;
|
|
|
|
function connect() {
|
|
ws = new WebSocket(url);
|
|
ws.onopen = function () {
|
|
if (openedBefore) requestSync(); // reconnect == server restarted
|
|
openedBefore = true;
|
|
};
|
|
ws.onmessage = function (e) {
|
|
var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
|
|
if (msg.channel === "toolbar") {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:toolbar-message", { detail: msg }));
|
|
} else if (msg.type === "css") {
|
|
swapCss(msg.version);
|
|
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: msg }));
|
|
} else if (msg.type === "reload") requestSync();
|
|
else if (msg.type === "html") applyHtml(msg.html);
|
|
else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message);
|
|
};
|
|
ws.onclose = function () { clearTimeout(timer); timer = setTimeout(connect, 400); };
|
|
ws.onerror = function () { try { ws.close(); } catch (_) {} };
|
|
}
|
|
|
|
function send(message) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
ws.send(JSON.stringify(message));
|
|
return true;
|
|
}
|
|
|
|
function requestSync() {
|
|
if (pendingSync) return;
|
|
pendingSync = true;
|
|
if (!send({ type: "sync", path: location.pathname + location.search })) {
|
|
pendingSync = false;
|
|
}
|
|
}
|
|
|
|
function swapCss(version) {
|
|
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
for (var i = 0; i < links.length; i++) (function (link) {
|
|
var href = link.getAttribute("href");
|
|
if (!href || href.charAt(0) !== "/") return; // skip CDN links
|
|
var next = href.split("?")[0] + "?hmr=" + encodeURIComponent(String(version || "css"));
|
|
var copy = link.cloneNode(false);
|
|
copy.setAttribute("href", next);
|
|
copy.addEventListener("load", function () {
|
|
if (link.parentNode) link.parentNode.removeChild(link);
|
|
});
|
|
link.parentNode.insertBefore(copy, link.nextSibling);
|
|
})(links[i]);
|
|
}
|
|
|
|
function applyHtml(html) {
|
|
pendingSync = false;
|
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
if (doc.title) document.title = doc.title;
|
|
var from = document.getElementById("app");
|
|
var to = doc.getElementById("app");
|
|
if (from && to) morph(from, to);
|
|
swapCss("html");
|
|
// Components are re-rendered server-side into the new HTML; re-hydrate
|
|
// their reactive scopes (and any browser-side API fetches) in place.
|
|
if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document);
|
|
if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document);
|
|
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: { type: "html" } }));
|
|
}
|
|
|
|
// Minimal index-based DOM morph: preserve matching nodes (keeps state/focus),
|
|
// patch text and attributes, clone genuinely new nodes, drop removed ones.
|
|
// Hydrated subtrees (reactive scopes) are CLIENT-OWNED and left untouched,
|
|
// so live state (e.g. a counter at 5) is never reset to the SSR 0.
|
|
function morph(from, to) {
|
|
if (from.__wrnexusHydrated) return;
|
|
syncAttrs(from, to);
|
|
var fc = from.childNodes, tc = to.childNodes, i;
|
|
for (i = 0; i < tc.length; i++) {
|
|
var t = tc[i], f = fc[i];
|
|
if (!f) { from.appendChild(t.cloneNode(true)); continue; }
|
|
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
|
|
from.replaceChild(t.cloneNode(true), f); continue;
|
|
}
|
|
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
|
|
if (f.nodeType === 1) morph(f, t);
|
|
}
|
|
while (from.childNodes.length > tc.length) from.removeChild(from.lastChild);
|
|
}
|
|
function syncAttrs(from, to) {
|
|
var ta = to.attributes, fa = from.attributes, i;
|
|
for (i = 0; i < ta.length; i++) if (from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
|
|
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
|
|
}
|
|
|
|
// Exposed for tests; harmless (the client is injected only in dev).
|
|
window.__wrnexusHmr = { morph: morph, swapCss: swapCss, requestSync: requestSync, applyHtml: applyHtml };
|
|
|
|
connect();
|
|
})();
|
|
`;
|
|
|
|
/** A base64 CSP nonce for this request's inline scripts. */
|
|
function randomNonce(): string {
|
|
const bytes = new Uint8Array(16);
|
|
crypto.getRandomValues(bytes);
|
|
let bin = "";
|
|
for (const b of bytes) bin += String.fromCharCode(b);
|
|
return btoa(bin);
|
|
}
|
|
|
|
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
|
function hmrClientTag(nonce: string): string {
|
|
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
|
|
}
|
|
|
|
/** 403 for a rejected cross-site WebSocket handshake. */
|
|
function forbiddenOrigin(): Response {
|
|
return new Response("Forbidden WebSocket origin", { status: 403 });
|
|
}
|
|
|
|
export interface UpgradeServer {
|
|
upgrade(req: Request, opts: { data: WsData }): boolean;
|
|
/** Bun's per-request socket peer address (used for the non-spoofable client IP). */
|
|
requestIP?(req: Request): { address: string } | null;
|
|
}
|
|
|
|
/** The subset of Bun's ServerWebSocket the runtime touches. */
|
|
export interface Ws {
|
|
data: WsData;
|
|
send(data: string | Uint8Array): unknown;
|
|
close(code?: number, reason?: string): void;
|
|
}
|
|
|
|
export interface Handlers {
|
|
fetch(req: Request, server: UpgradeServer): Promise<Response | undefined>;
|
|
websocket: {
|
|
open(ws: Ws): void;
|
|
message(ws: Ws, message: string | Uint8Array): void;
|
|
close(ws: Ws, code?: number, reason?: string): void;
|
|
drain(ws: Ws): void;
|
|
};
|
|
}
|
|
|
|
/** Build the fetch + websocket handlers from a set of dependencies. */
|
|
export function createHandlers(deps: RuntimeDeps): Handlers {
|
|
const { mode, hmr, router, loadModule, getMiddleware, assets } = deps;
|
|
const builtInMiddleware = frameworkMiddleware(deps);
|
|
const resolveMiddleware = async (): Promise<Middleware[]> => [
|
|
...builtInMiddleware,
|
|
...(await getMiddleware()),
|
|
];
|
|
const maxBodyBytes = deps.maxBodyBytes ?? 10 * 1024 * 1024; // 10 MB default
|
|
|
|
// Server-side realtime room manager (shared by every `defineRoom` connection).
|
|
const realtime = createRealtimeRegistry();
|
|
// Bridge across processes/instances when a pub/sub bus is provided, so room
|
|
// broadcasts reach clients on every app run sharing the bus (Redis in prod).
|
|
if (deps.realtimeBus) bridgeRealtime(realtime, deps.realtimeBus);
|
|
|
|
// Global <head> additions, identical on every page (styles are global).
|
|
// Theme tokens load first so global.css and component styles can override them.
|
|
// Order: theme tokens, then Wire UI, then the app stylesheet — so the app's
|
|
// own CSS (loaded last) can override both the tokens and the UI classes.
|
|
const headParts: string[] = [];
|
|
if (deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
|
headParts.push(
|
|
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/framework.css", deps.assetVersion)}" />`,
|
|
);
|
|
} else if (deps.theme && !deps.stylesIncludeFramework) {
|
|
const themeHref = versionAssetUrl(THEME_CSS_HREF, deps.assetVersion);
|
|
headParts.push(`<link rel="stylesheet" href="${themeHref}" />`);
|
|
}
|
|
if (deps.hasUi && !deps.hasFrameworkStyles && !deps.stylesIncludeFramework) {
|
|
headParts.push(
|
|
`<link rel="stylesheet" href="${versionAssetUrl("/__wrnexus/ui.css", deps.assetVersion)}" />`,
|
|
);
|
|
}
|
|
if (deps.inlineStyles) {
|
|
headParts.push(`<style data-wrnexus-global>${escapeStyleContent(deps.inlineStyles)}</style>`);
|
|
} else if (deps.hasStyles) {
|
|
const stylesHref = versionAssetUrl(STYLES_HREF, deps.assetVersion);
|
|
headParts.push(`<link rel="preload" href="${stylesHref}" as="style" />`);
|
|
headParts.push(`<link rel="stylesheet" href="${stylesHref}" />`);
|
|
}
|
|
if (deps.head) headParts.push(deps.head);
|
|
const extraHead = headParts.join("\n ") || undefined;
|
|
const pwaEnabled = deps.pwa !== false && deps.pwa?.enabled !== false;
|
|
const pwaConfig: PwaConfig = deps.pwa && typeof deps.pwa === "object" ? deps.pwa : {};
|
|
const pwaServiceWorkerEnabled = pwaEnabled && pwaConfig.serviceWorker !== false;
|
|
|
|
const configuredPermissions = deps.security?.permissionsPolicy;
|
|
const runtimeSecurity: SecurityConfig | undefined =
|
|
deps.mobile?.enabled === false
|
|
? deps.security
|
|
: {
|
|
...deps.security,
|
|
permissionsPolicy:
|
|
configuredPermissions === false
|
|
? false
|
|
: { camera: ["self"], ...(configuredPermissions ?? {}) },
|
|
};
|
|
|
|
async function fetchHandler(req: Request, server: UpgradeServer): Promise<Response | undefined> {
|
|
// Behind a trusted proxy (nginx / gateway), honor X-Forwarded-Proto/Host so
|
|
// ctx.url reflects the external HTTPS scheme — makes CSRF/session cookies Secure.
|
|
const url = resolveRequestUrl(req, deps.security?.trustProxy);
|
|
const nonce = randomNonce();
|
|
const secure = (res: Response): Response =>
|
|
withSecurityHeaders(req, res, mode, runtimeSecurity, nonce);
|
|
|
|
const preflight = createCorsPreflightResponse(req, deps.security);
|
|
if (preflight) return secure(preflight);
|
|
|
|
// Health check — unauthenticated, skips the middleware pipeline.
|
|
if (url.pathname === "/healthz" || url.pathname === "/__wrnexus/health") {
|
|
return secure(Response.json({ status: "ok" }));
|
|
}
|
|
|
|
if (url.pathname === "/site.webmanifest" && pwaEnabled) {
|
|
const pwa = pwaConfig;
|
|
return secure(
|
|
Response.json(
|
|
{
|
|
id: pwa.id ?? pwa.startUrl ?? "/",
|
|
name: pwa.name ?? deps.seo?.title ?? "WrNexus App",
|
|
short_name: pwa.shortName ?? pwa.name ?? deps.seo?.title ?? "WrNexus",
|
|
description: pwa.description ?? deps.seo?.description,
|
|
start_url: pwa.startUrl ?? "/",
|
|
scope: pwa.scope ?? "/",
|
|
lang: pwa.lang ?? "en",
|
|
display: pwa.display ?? "standalone",
|
|
orientation: pwa.orientation ?? "any",
|
|
theme_color: pwa.themeColor ?? deps.seo?.themeColor ?? "#6c8cff",
|
|
background_color: pwa.backgroundColor ?? "#0f172a",
|
|
icons: pwa.icons ?? [
|
|
{
|
|
src: "/__wrnexus/pwa-icon.svg",
|
|
sizes: "any",
|
|
type: "image/svg+xml",
|
|
purpose: "any maskable",
|
|
},
|
|
],
|
|
categories: pwa.categories,
|
|
screenshots: pwa.screenshots?.map((screenshot) => ({
|
|
src: screenshot.src,
|
|
sizes: screenshot.sizes,
|
|
type: screenshot.type,
|
|
form_factor: screenshot.formFactor,
|
|
label: screenshot.label,
|
|
})),
|
|
shortcuts: pwa.shortcuts?.map((shortcut) => ({
|
|
name: shortcut.name,
|
|
short_name: shortcut.shortName,
|
|
description: shortcut.description,
|
|
url: shortcut.url,
|
|
icons: shortcut.icons,
|
|
})),
|
|
},
|
|
{ headers: { "content-type": "application/manifest+json", "cache-control": "no-cache" } },
|
|
),
|
|
);
|
|
}
|
|
if (url.pathname === "/__wrnexus/pwa-icon.svg" && pwaEnabled) {
|
|
return secure(
|
|
new Response(DEFAULT_PWA_ICON, {
|
|
headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" },
|
|
}),
|
|
);
|
|
}
|
|
if (url.pathname === "/sw.js" && pwaServiceWorkerEnabled) {
|
|
return secure(
|
|
new Response(renderPwaServiceWorker(pwaConfig), {
|
|
headers: {
|
|
"content-type": "text/javascript; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
if (url.pathname === "/__wrnexus/pwa.js" && pwaServiceWorkerEnabled) {
|
|
return secure(
|
|
new Response(PWA_CLIENT, {
|
|
headers: {
|
|
"content-type": "text/javascript; charset=utf-8",
|
|
"cache-control": url.searchParams.has("v")
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-cache",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
if (url.pathname === "/__wrnexus/mobile.js" && deps.mobile?.enabled !== false) {
|
|
return secure(
|
|
new Response(MOBILE_CLIENT, {
|
|
headers: {
|
|
"content-type": "text/javascript; charset=utf-8",
|
|
"cache-control": url.searchParams.has("v")
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-cache",
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Reject oversized request bodies early (DoS guard).
|
|
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
|
if (contentLength > maxBodyBytes) {
|
|
return secure(new Response("Payload Too Large", { status: 413 }));
|
|
}
|
|
|
|
if (deps.devToolbar) {
|
|
const toolbarResponse = await handleDevToolbarRoute(req, {
|
|
mode,
|
|
root: deps.devToolbar.root,
|
|
collector: deps.devToolbar.collector,
|
|
editor: deps.devToolbar.config.editor,
|
|
allowOpenEditor: deps.devToolbar.config.openEditor !== false,
|
|
platform: deps.devToolbar.platform,
|
|
panels: deps.devToolbar.panels,
|
|
});
|
|
if (toolbarResponse) return secure(toolbarResponse);
|
|
}
|
|
|
|
// --- HMR socket (dev only): upgrade before anything else. ---
|
|
if (hmr && url.pathname === "/__wrnexus/hmr") {
|
|
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
|
|
const headers = Array.from(req.headers.entries()).filter(
|
|
([name]) => !/^sec-websocket-|^connection$|^upgrade$/i.test(name),
|
|
);
|
|
if (server.upgrade(req, { data: { kind: "hmr", baseUrl: url.origin, headers } })) {
|
|
return undefined;
|
|
}
|
|
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
|
}
|
|
|
|
// --- Realtime: try to upgrade BEFORE the normal HTTP pipeline. ---
|
|
const rt = router.matchRealtime(url.pathname);
|
|
if (rt) {
|
|
// Reject cross-site WebSocket handshakes (CSWSH) before doing any work.
|
|
if (!isWebSocketOriginAllowed(req, deps.security)) return secure(forbiddenOrigin());
|
|
const mod = await loadModule(rt.route.file);
|
|
let data: WsData;
|
|
if (isRoomDefinition(mod.default)) {
|
|
// Identify the connection: an authenticated session user id, else ?user=.
|
|
const wsCtx = createContext(req, url);
|
|
const sessionUser = wsCtx.session.get<{ id?: unknown }>("user");
|
|
const user =
|
|
sessionUser && typeof sessionUser === "object" && sessionUser.id != null
|
|
? String(sessionUser.id)
|
|
: (url.searchParams.get("user") ?? undefined);
|
|
const query = Object.fromEntries(url.searchParams);
|
|
// Room-level gate (e.g. require auth) — reject before accepting the socket.
|
|
const allowed = await mod.default.handlers.authorize?.({
|
|
user,
|
|
query,
|
|
headers: req.headers,
|
|
});
|
|
if (allowed === false) {
|
|
return secure(new Response("Forbidden", { status: 403 }));
|
|
}
|
|
data = { kind: "room", meta: { room: url.pathname, def: mod.default, query, user } };
|
|
} else if (mod.websocket && typeof mod.websocket === "object") {
|
|
data = { kind: "realtime", handler: mod.websocket as WsHandler };
|
|
} else {
|
|
return secure(
|
|
new Response("Realtime route needs a default defineRoom() or a `websocket` export", {
|
|
status: 500,
|
|
}),
|
|
);
|
|
}
|
|
const upgraded = server.upgrade(req, { data });
|
|
if (upgraded) return undefined; // Bun takes over the socket.
|
|
return secure(new Response("Expected a WebSocket upgrade request", { status: 426 }));
|
|
}
|
|
|
|
try {
|
|
const ctx = createContext(req, url);
|
|
ctx.ip = server.requestIP?.(req)?.address ?? undefined;
|
|
ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts
|
|
// Resolve the request language so both pages and API can translate.
|
|
if (deps.i18n) {
|
|
ctx.lang = resolveLang(
|
|
deps.i18n,
|
|
ctx.cookies.get(LANG_COOKIE),
|
|
req.headers.get("accept-language"),
|
|
);
|
|
ctx.t = makeT(deps.i18n, ctx.lang);
|
|
}
|
|
const mws = await resolveMiddleware();
|
|
const res = secure(
|
|
withContextHeaders(ctx, await runMiddleware(mws, ctx, () => dispatch(ctx))),
|
|
);
|
|
return compressResponse(req, res);
|
|
} catch (err) {
|
|
const app = process.env.WRNEXUS_APP_NAME ?? "app";
|
|
const detail =
|
|
err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
|
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
console.error(
|
|
`[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`,
|
|
);
|
|
deps.devToolbar?.collector.add(
|
|
issueFromError(err, {
|
|
ruleId: "server/request-error",
|
|
category: "server",
|
|
title: "Request processing failed",
|
|
pathname: url.pathname,
|
|
}),
|
|
);
|
|
const response = secure(renderError(err, mode));
|
|
// Gateway-managed production apps bind to loopback. Carry a bounded,
|
|
// encoded diagnostic to the parent gateway so centralized log collectors
|
|
// can explain child failures; the gateway always strips this header.
|
|
response.headers.set("x-wrnexus-internal-error", encodeURIComponent(message.slice(0, 500)));
|
|
return compressResponse(req, response);
|
|
}
|
|
}
|
|
|
|
async function dispatch(ctx: Context): Promise<Response> {
|
|
const { pathname } = ctx.url;
|
|
|
|
// Framework-owned assets (island chunks, reactive runtime, HMR stream).
|
|
if (pathname === "/__wrnexus/csr") {
|
|
return handleCsrBinding(ctx);
|
|
}
|
|
|
|
if (pathname.startsWith("/__wrnexus/")) {
|
|
const res = await assets.serve(pathname);
|
|
return res ?? new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
return handleApi(ctx);
|
|
}
|
|
|
|
const publicAsset = await assets.serve(pathname);
|
|
if (publicAsset) return publicAsset;
|
|
|
|
return handlePage(ctx);
|
|
}
|
|
|
|
async function handleCsrBinding(ctx: Context): Promise<Response> {
|
|
const routePath = ctx.url.searchParams.get("route") ?? "";
|
|
const id = ctx.url.searchParams.get("id") ?? "";
|
|
|
|
if (
|
|
!routePath.startsWith("/") ||
|
|
routePath.startsWith("/__wrnexus/") ||
|
|
!isSafeRequestPath(routePath) ||
|
|
!/^[A-Za-z0-9_-]+$/.test(id)
|
|
) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const page = router.matchPage(routePath);
|
|
if (!page) return new Response("Not Found", { status: 404 });
|
|
|
|
const pageModule = await loadModule(page.route.file);
|
|
const bindings = pageModule.__wrnexusCsr;
|
|
if (!Array.isArray(bindings)) return new Response("Not Found", { status: 404 });
|
|
|
|
const binding = bindings.find((item): item is CsrBinding => {
|
|
if (!item || typeof item !== "object") return false;
|
|
const candidate = item as Partial<CsrBinding>;
|
|
return (
|
|
candidate.id === id &&
|
|
typeof candidate.path === "string" &&
|
|
(candidate.method === undefined || typeof candidate.method === "string") &&
|
|
(typeof candidate.body === "string" || typeof candidate.expr === "string") &&
|
|
(candidate.helpers === undefined || typeof candidate.helpers === "string")
|
|
);
|
|
});
|
|
if (!binding || !isSafeApiPath(binding.path)) return new Response("Not Found", { status: 404 });
|
|
|
|
const data = await callApiFromContext(ctx, binding.path, binding.method ?? "GET");
|
|
const value = evalData(ctx, data, binding.body ?? binding.expr ?? "", binding.helpers ?? "");
|
|
|
|
return new Response(String(value), {
|
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
async function callApiFromContext(ctx: Context, path: string, method = "GET"): Promise<unknown> {
|
|
if (!isSafeApiPath(path)) {
|
|
throw new Error("Unsafe framework API path");
|
|
}
|
|
|
|
const normalizedMethod = method.toUpperCase();
|
|
if (!HTTP_METHODS.includes(normalizedMethod as (typeof HTTP_METHODS)[number])) {
|
|
throw new Error(`Unsupported framework API method: ${normalizedMethod}`);
|
|
}
|
|
|
|
const apiUrl = new URL(path, ctx.req.url);
|
|
const apiReq = new Request(apiUrl, {
|
|
method: normalizedMethod,
|
|
headers: ctx.req.headers,
|
|
});
|
|
const apiCtx = createContext(apiReq, apiUrl);
|
|
apiCtx.locals = ctx.locals;
|
|
apiCtx.cookies = ctx.cookies;
|
|
apiCtx.session = ctx.session;
|
|
apiCtx.localStorage = ctx.localStorage;
|
|
|
|
const apiRes = await handleApi(apiCtx);
|
|
if (!apiRes.ok) {
|
|
throw new Error(`API route ${apiUrl.pathname} returned ${apiRes.status}`);
|
|
}
|
|
|
|
const contentType = apiRes.headers.get("content-type") ?? "";
|
|
return contentType.includes("application/json") ? await apiRes.json() : await apiRes.text();
|
|
}
|
|
|
|
function isSafeApiPath(pathname: string): boolean {
|
|
return (
|
|
(pathname === "/api" || pathname.startsWith("/api/")) &&
|
|
isSafeRequestPath(pathname) &&
|
|
!pathname.includes("?") &&
|
|
!pathname.includes("#")
|
|
);
|
|
}
|
|
|
|
function evalData(ctx: Context, data: unknown, body: string, helpers = ""): unknown {
|
|
const adapters = {
|
|
cookies: ctx.cookies,
|
|
session: ctx.session,
|
|
localStorage: ctx.localStorage,
|
|
};
|
|
return new Function(
|
|
"$data",
|
|
"$adapters",
|
|
"const cookies = $adapters.cookies;\nconst session = $adapters.session;\nconst localStorage = $adapters.localStorage;\nwith ($data ?? {}) {\n" +
|
|
helpers +
|
|
"\n" +
|
|
body +
|
|
"\n}",
|
|
)(data, adapters);
|
|
}
|
|
|
|
async function handleApi(ctx: Context): Promise<Response> {
|
|
const matched = router.matchApi(ctx.url.pathname);
|
|
if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
|
|
|
|
const mod = await loadModule(matched.route.file);
|
|
const method = ctx.req.method.toUpperCase();
|
|
const embeddedApi = mod.__wrnexusApi as ApiRegistry | undefined;
|
|
const handler = embeddedApi?.[`${method} ${matched.route.raw}`] ?? mod[method];
|
|
|
|
if (typeof handler !== "function") {
|
|
const allowed = HTTP_METHODS.filter(
|
|
(m) =>
|
|
typeof mod[m] === "function" ||
|
|
typeof embeddedApi?.[`${m} ${matched.route.raw}`] === "function",
|
|
);
|
|
return new Response("Method Not Allowed", {
|
|
status: 405,
|
|
headers: { Allow: allowed.join(", ") },
|
|
});
|
|
}
|
|
|
|
ctx.params = matched.params;
|
|
return (await handler(ctx)) as Response;
|
|
}
|
|
|
|
/**
|
|
* Resolve `<tag data-component="name" ...props></tag>` mounts by rendering the
|
|
* matching `.wrn` component on the server and splicing its HTML in place.
|
|
* Runs before script collection so the injected `data-scope` is visible.
|
|
* Recurses so components can mount other components.
|
|
*/
|
|
async function renderComponents(
|
|
body: string,
|
|
translate: TFunction = (key) => key,
|
|
depth = 0,
|
|
): Promise<string> {
|
|
if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) {
|
|
return body;
|
|
}
|
|
|
|
let result = "";
|
|
let i = 0;
|
|
for (;;) {
|
|
const m = MOUNT_OPEN_RE.exec(body.slice(i));
|
|
if (!m) {
|
|
result += body.slice(i);
|
|
break;
|
|
}
|
|
const tagStart = i + m.index;
|
|
result += body.slice(i, tagStart);
|
|
|
|
const [open, tag, attrStr, name, selfClose] = m;
|
|
const openEnd = tagStart + open.length;
|
|
const { inner, end } =
|
|
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
|
|
i = end;
|
|
|
|
const normalizedName = name.toLowerCase();
|
|
const component = router.components.find(
|
|
(candidate) => candidate.name.toLowerCase() === normalizedName,
|
|
);
|
|
if (!component) {
|
|
console.warn(`[wrnexus] no component registered for '${name}'`);
|
|
result += body.slice(tagStart, end);
|
|
continue;
|
|
}
|
|
try {
|
|
const mod = await loadModule(component.file);
|
|
const render = (mod as { render?: (props: Record<string, string>) => string }).render;
|
|
if (typeof render !== "function") {
|
|
console.warn(`[wrnexus] component '${name}' has no render() export`);
|
|
result += body.slice(tagStart, end);
|
|
continue;
|
|
}
|
|
// Render, drop the mount's children into the component's <slot>, then
|
|
// recurse so components-in-the-view and components-in-the-children resolve.
|
|
// Props may carry `{t:key}` i18n markers — resolve them for the active
|
|
// language before handing them to the component.
|
|
const props = resolveTProps(parseComponentProps(attrStr!), translate);
|
|
const rendered = fillSlots(String(render(props)), inner);
|
|
result += await renderComponents(rendered, translate, depth + 1);
|
|
} catch (err) {
|
|
console.error(`[wrnexus] component '${name}' failed to render`, err);
|
|
deps.devToolbar?.collector.add(
|
|
issueFromError(err, {
|
|
ruleId: "server/component-render-error",
|
|
category: "server",
|
|
title: `Component '${name}' failed to render`,
|
|
source: { file: component.file, component: name },
|
|
}),
|
|
);
|
|
result += body.slice(tagStart, end);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function handlePage(ctx: Context): Promise<Response> {
|
|
const isMobileRequest =
|
|
ctx.req.headers.get("x-wrnexus-mobile") === "1" ||
|
|
new RegExp(deps.mobile?.userAgent ?? "WrNexusMobile", "i").test(
|
|
ctx.req.headers.get("user-agent") ?? "",
|
|
);
|
|
const matched = router.matchPage(ctx.url.pathname);
|
|
if (!matched) {
|
|
if (deps.devToolbar && shouldReportNotFound(ctx.url.pathname)) {
|
|
deps.devToolbar.collector.add(
|
|
createServerIssue({
|
|
ruleId: "routing/not-found",
|
|
category: "routing",
|
|
severity: "warning",
|
|
title: "Route not found",
|
|
message: `No page route matched ${ctx.url.pathname}.`,
|
|
pathname: ctx.url.pathname,
|
|
recommendation:
|
|
"Verify the URL, page filename, dynamic route parameters, and route casing.",
|
|
}),
|
|
);
|
|
}
|
|
const response = renderNotFound();
|
|
if (!isMobileRequest) return response;
|
|
const headers = new Headers(response.headers);
|
|
headers.set("x-wrnexus-original-status", "404");
|
|
// Android WebView may replace a main-frame HTTP error with its own error
|
|
// surface. Keep the WrNexus 404 document renderable inside Capacitor.
|
|
return new Response(response.body, { status: 200, headers });
|
|
}
|
|
|
|
// Issue the CSRF token cookie so forms on this page can echo it back.
|
|
csrfToken(ctx);
|
|
|
|
const mod = await loadModule(matched.route.file);
|
|
const component = mod.default;
|
|
if (typeof component !== "function") {
|
|
throw new Error(`Page ${matched.route.file} has no default export`);
|
|
}
|
|
|
|
ctx.params = matched.params;
|
|
const meta = (mod.meta ?? {}) as PageMeta;
|
|
const pageCtx = ctx as Context & {
|
|
__wrnexusCallApi?: (path: string, method?: string) => Promise<unknown>;
|
|
};
|
|
pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method);
|
|
let body = await renderComponents(String(await component(pageCtx)), ctx.t);
|
|
const resolvedTheme = deps.theme
|
|
? resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)
|
|
: "";
|
|
const language = deps.i18n && ctx.lang ? ctx.lang : (meta.lang ?? deps.seo?.lang ?? "en");
|
|
let documentTemplate: string | undefined;
|
|
|
|
// Page layout: a page selects one by exporting `layout = "<name>"`
|
|
// (app/layouts/<name>.wrn), else falls back to a `default` layout if one
|
|
// exists. `layout = "none"` opts out. The layout wraps the body via <slot>.
|
|
const layoutName =
|
|
(isMobileRequest && deps.mobile?.layout
|
|
? deps.mobile.layout
|
|
: typeof mod.layout === "string"
|
|
? mod.layout
|
|
: undefined) ?? "default";
|
|
const layout =
|
|
layoutName === "none" ? undefined : router.layouts.find((l) => l.name === layoutName);
|
|
if (layout) {
|
|
try {
|
|
const layoutMod = await loadModule(layout.file);
|
|
const layoutRender = (layoutMod as { render?: (p: Record<string, string>) => string })
|
|
.render;
|
|
if (typeof layoutRender === "function") {
|
|
body = await renderComponents(fillSlots(String(layoutRender({})), body), ctx.t);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[wrnexus] layout '${layoutName}' failed to render`, err);
|
|
deps.devToolbar?.collector.add(
|
|
issueFromError(err, {
|
|
ruleId: "server/layout-render-error",
|
|
category: "server",
|
|
title: `Layout '${layoutName}' failed to render`,
|
|
pathname: ctx.url.pathname,
|
|
source: { file: layout.file },
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// A conventional app/layouts/document.wrn is a request-aware, top-level
|
|
// document shell. It wraps the selected page layout and may own html, head,
|
|
// body, and #app. The SSR renderer still merges framework metadata/assets.
|
|
const documentLayout =
|
|
layoutName === "document"
|
|
? undefined
|
|
: router.layouts.find((item) => item.name === "document");
|
|
if (documentLayout) {
|
|
try {
|
|
const documentMod = await loadModule(documentLayout.file);
|
|
const documentRender = (
|
|
documentMod as { render?: (props: Record<string, unknown>) => string }
|
|
).render;
|
|
if (typeof documentRender === "function") {
|
|
const rendered = String(
|
|
documentRender({
|
|
cookies: ctx.cookies.getAll(),
|
|
theme: resolvedTheme,
|
|
language,
|
|
url: ctx.url.toString(),
|
|
pathname: ctx.url.pathname,
|
|
}),
|
|
);
|
|
documentTemplate = await renderComponents(fillSlots(rendered, body), ctx.t);
|
|
body = documentTemplate;
|
|
}
|
|
} catch (err) {
|
|
console.error("[wrnexus] document layout failed to render", err);
|
|
deps.devToolbar?.collector.add(
|
|
issueFromError(err, {
|
|
ruleId: "server/document-layout-render-error",
|
|
category: "server",
|
|
title: "Document layout failed to render",
|
|
pathname: ctx.url.pathname,
|
|
source: { file: documentLayout.file },
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isMobileRequest) body = revealMobileOnlyHtml(body);
|
|
|
|
// i18n: resolve `{t:key}` / `t:attr` markers against the request language.
|
|
if (deps.i18n) body = translateHtml(body, ctx.t);
|
|
|
|
// Point 3: only ship the JS this page actually uses.
|
|
const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
|
|
versionRenderScript(script, deps.assetVersion),
|
|
);
|
|
if (pwaServiceWorkerEnabled)
|
|
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
|
|
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
|
|
scripts.push(versionAssetUrl("/__wrnexus/mobile.js", deps.assetVersion));
|
|
|
|
// <html> attributes: no-flash theme (from cookie, validated) + active lang.
|
|
const attrs: string[] = [];
|
|
if (deps.theme) attrs.push(`data-theme="${resolvedTheme}"`);
|
|
attrs.push(`lang="${safeLanguageTag(language)}"`);
|
|
const htmlAttrs = attrs.length ? ` ${attrs.join(" ")}` : undefined;
|
|
|
|
const html = renderDocument({
|
|
meta,
|
|
seo: deps.seo,
|
|
url: ctx.url,
|
|
body,
|
|
scripts,
|
|
extraHead: [
|
|
pwaEnabled ? `<link rel="manifest" href="/site.webmanifest" />` : "",
|
|
pwaEnabled ? `<meta name="mobile-web-app-capable" content="yes" />` : "",
|
|
pwaEnabled ? `<meta name="apple-mobile-web-app-capable" content="yes" />` : "",
|
|
extraHead,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n "),
|
|
extraBody:
|
|
[
|
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n") || undefined,
|
|
htmlAttrs,
|
|
documentTemplate,
|
|
});
|
|
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
|
|
// the shell carries a per-request CSP nonce in dev, which would otherwise make
|
|
// the ETag change every request. Same content → same ETag → 304 on revalidate.
|
|
const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`);
|
|
const method = ctx.req.method.toUpperCase();
|
|
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
|
|
return new Response(null, {
|
|
status: 304,
|
|
headers: { etag: tag, "cache-control": "private, no-cache" },
|
|
});
|
|
}
|
|
return new Response(html, {
|
|
headers: {
|
|
"content-type": "text/html; charset=utf-8",
|
|
etag: tag,
|
|
"cache-control": "private, no-cache",
|
|
...(shouldEnableDevToolbar(mode, deps)
|
|
? {
|
|
"x-wrnexus-dev-toolbar": "enabled",
|
|
"x-wrnexus-route": matched.route.raw,
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
const hub = deps.hub;
|
|
|
|
async function handleHmrMessage(ws: Ws, message: string | Uint8Array): Promise<void> {
|
|
let msg: { type?: unknown; path?: unknown };
|
|
try {
|
|
msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
} catch {
|
|
return;
|
|
}
|
|
if (msg.type !== "sync") return;
|
|
const path = typeof msg.path === "string" ? msg.path : "/";
|
|
let url: URL;
|
|
try {
|
|
url = new URL(path, ws.data.kind === "hmr" ? ws.data.baseUrl : "http://localhost");
|
|
} catch {
|
|
ws.send(JSON.stringify({ type: "error", message: "Invalid HMR sync path" }));
|
|
return;
|
|
}
|
|
if (
|
|
url.origin !== (ws.data.kind === "hmr" ? ws.data.baseUrl : url.origin) ||
|
|
!url.pathname.startsWith("/") ||
|
|
url.pathname.startsWith("/__wrnexus/") ||
|
|
!isSafeRequestPath(url.pathname)
|
|
) {
|
|
ws.send(JSON.stringify({ type: "error", message: "Unsafe HMR sync path" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const headers = new Headers(ws.data.kind === "hmr" ? ws.data.headers : undefined);
|
|
headers.set("accept", "text/html");
|
|
headers.set("x-wrnexus-hmr", "1");
|
|
const req = new Request(url, { headers });
|
|
const ctx = createContext(req, url);
|
|
ctx.locals.cspNonce = randomNonce();
|
|
if (deps.i18n) {
|
|
ctx.lang = resolveLang(
|
|
deps.i18n,
|
|
ctx.cookies.get(LANG_COOKIE),
|
|
req.headers.get("accept-language"),
|
|
);
|
|
ctx.t = makeT(deps.i18n, ctx.lang);
|
|
}
|
|
const res = withContextHeaders(
|
|
ctx,
|
|
await runMiddleware(await resolveMiddleware(), ctx, () => dispatch(ctx)),
|
|
);
|
|
const html = await res.text();
|
|
ws.send(JSON.stringify({ type: "html", html }));
|
|
} catch (err) {
|
|
if (mode === "development") console.error(err);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
message: err instanceof Error ? err.message : "Unknown HMR sync error",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
fetch: fetchHandler,
|
|
websocket: {
|
|
open(ws) {
|
|
if (ws.data?.kind === "hmr") hub?.add(ws);
|
|
else if (ws.data?.kind === "room") void realtime.open(ws, ws.data.meta);
|
|
else ws.data?.handler?.open?.(ws);
|
|
},
|
|
message(ws, message) {
|
|
if (ws.data?.kind === "hmr") {
|
|
void handleHmrMessage(ws, message);
|
|
return;
|
|
}
|
|
if (ws.data?.kind === "room") void realtime.message(ws, message);
|
|
else ws.data?.handler?.message?.(ws, message);
|
|
},
|
|
close(ws, code, reason) {
|
|
if (ws.data?.kind === "hmr") hub?.remove(ws);
|
|
else if (ws.data?.kind === "room") void realtime.close(ws);
|
|
else ws.data?.handler?.close?.(ws, code, reason);
|
|
},
|
|
drain(ws) {
|
|
if (ws.data?.kind === "hmr" || ws.data?.kind === "room") return;
|
|
ws.data?.handler?.drain?.(ws);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decode the handful of HTML entities the page compiler emits when escaping
|
|
* attribute values, so component props arrive as the author wrote them.
|
|
*/
|
|
function decodeHtmlEntities(value: string): string {
|
|
let decoded = value;
|
|
// Static page attributes can be escaped by their author/generator and then
|
|
// escaped again by the page compiler. Decode both layers so structured
|
|
// component props arrive as valid JSON instead of `"` text.
|
|
for (let pass = 0; pass < 2; pass += 1) {
|
|
const next = decoded
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&/g, "&");
|
|
if (next === decoded) break;
|
|
decoded = next;
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
/** Content types worth gzipping (text + text-like application types). */
|
|
const COMPRESSIBLE_TYPE =
|
|
/^(?:text\/|application\/(?:json|xml|javascript|manifest\+json)|image\/svg\+xml)/i;
|
|
/** Below this size gzip's overhead isn't worth it. */
|
|
const COMPRESS_MIN_BYTES = 1024;
|
|
|
|
/**
|
|
* Gzip a response when the client accepts it and the body is a compressible,
|
|
* buffered (non-streaming) payload. Streaming/SSE responses opt out via
|
|
* `Cache-Control: no-transform`, so they are never buffered here.
|
|
*/
|
|
async function compressResponse(req: Request, res: Response): Promise<Response> {
|
|
const accept = req.headers.get("accept-encoding") ?? "";
|
|
if (!accept.toLowerCase().includes("gzip")) return res;
|
|
if (res.headers.get("content-encoding")) return res;
|
|
if (res.status === 204 || res.status === 304) return res;
|
|
if (!COMPRESSIBLE_TYPE.test(res.headers.get("content-type") ?? "")) return res;
|
|
if ((res.headers.get("cache-control") ?? "").includes("no-transform")) return res;
|
|
|
|
const body = new Uint8Array(await res.arrayBuffer());
|
|
if (body.length < COMPRESS_MIN_BYTES) {
|
|
return new Response(body, {
|
|
status: res.status,
|
|
statusText: res.statusText,
|
|
headers: res.headers,
|
|
});
|
|
}
|
|
const gzipped = Bun.gzipSync(body);
|
|
const headers = new Headers(res.headers);
|
|
headers.set("content-encoding", "gzip");
|
|
headers.set("content-length", String(gzipped.length));
|
|
const vary = headers.get("Vary");
|
|
if (!vary) headers.set("Vary", "Accept-Encoding");
|
|
else if (!/\baccept-encoding\b/i.test(vary)) headers.set("Vary", `${vary}, Accept-Encoding`);
|
|
return new Response(gzipped, { status: res.status, statusText: res.statusText, headers });
|
|
}
|
|
|
|
/** Opening tag of a component mount: captures tag, attrs, name, self-close. */
|
|
const MOUNT_OPEN_RE =
|
|
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-component="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/;
|
|
|
|
/**
|
|
* From `from` (just past a mount's opening `>`), return the element's inner HTML
|
|
* and the index just past its matching close tag. Counts same-tag nesting so a
|
|
* `<div data-component>` may contain other `<div>`s.
|
|
*/
|
|
/** Cache of per-tag "open tag" regexes so we don't recompile one per call. */
|
|
const OPEN_TAG_RE = new Map<string, RegExp>();
|
|
function openTagRe(tag: string): RegExp {
|
|
let re = OPEN_TAG_RE.get(tag);
|
|
if (!re) {
|
|
re = new RegExp(`<${tag}\\b`, "g");
|
|
OPEN_TAG_RE.set(tag, re);
|
|
}
|
|
return re;
|
|
}
|
|
|
|
export function readElementBody(
|
|
html: string,
|
|
tag: string,
|
|
from: number,
|
|
): { inner: string; end: number } {
|
|
const openRe = openTagRe(tag);
|
|
const closeTag = `</${tag}>`;
|
|
let depth = 1;
|
|
let i = from;
|
|
while (i < html.length) {
|
|
const nextClose = html.indexOf(closeTag, i);
|
|
if (nextClose === -1) return { inner: html.slice(from), end: html.length };
|
|
openRe.lastIndex = i;
|
|
const openMatch = openRe.exec(html);
|
|
if (openMatch && openMatch.index < nextClose) {
|
|
const gt = html.indexOf(">", openMatch.index);
|
|
if (gt !== -1 && html[gt - 1] === "/") {
|
|
i = gt + 1; // self-closing child: no depth change
|
|
} else {
|
|
depth++;
|
|
i = gt === -1 ? html.length : gt + 1;
|
|
}
|
|
} else {
|
|
depth--;
|
|
if (depth === 0)
|
|
return { inner: html.slice(from, nextClose), end: nextClose + closeTag.length };
|
|
i = nextClose + closeTag.length;
|
|
}
|
|
}
|
|
return { inner: html.slice(from), end: html.length };
|
|
}
|
|
|
|
/**
|
|
* Split a mount's children into named slots and the default slot. Content inside
|
|
* `<tag data-slot="name">…</tag>` goes to that named slot (the wrapper element is
|
|
* dropped); everything else is the default slot.
|
|
*/
|
|
function extractSlots(inner: string): { named: Record<string, string>; def: string } {
|
|
const named: Record<string, string> = {};
|
|
if (!inner.includes("data-slot=")) return { named, def: inner };
|
|
|
|
const SLOT_MOUNT_RE =
|
|
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-slot="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/;
|
|
let def = "";
|
|
let i = 0;
|
|
for (;;) {
|
|
const m = SLOT_MOUNT_RE.exec(inner.slice(i));
|
|
if (!m) {
|
|
def += inner.slice(i);
|
|
break;
|
|
}
|
|
const tagStart = i + m.index;
|
|
def += inner.slice(i, tagStart);
|
|
const [open, tag, , name, selfClose] = m;
|
|
const openEnd = tagStart + open.length;
|
|
const { inner: content, end } =
|
|
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(inner, tag!, openEnd);
|
|
named[name!] = content;
|
|
i = end;
|
|
}
|
|
return { named, def };
|
|
}
|
|
|
|
/**
|
|
* Replace `<slot>` elements in a component's output with the mount's children:
|
|
* <slot name="x">…</slot> ← content from `data-slot="x"` on the mount
|
|
* <slot>…</slot> ← everything else (the default slot)
|
|
* A `<slot>fallback</slot>` keeps its fallback when nothing is provided.
|
|
*/
|
|
export function fillSlots(html: string, inner: string): string {
|
|
if (!/<slot\b/.test(html)) return html;
|
|
const { named, def } = extractSlots(inner);
|
|
const defTrimmed = def.trim();
|
|
|
|
return html
|
|
.replace(
|
|
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?\/>/g,
|
|
(_m, name: string) => named[name] ?? "",
|
|
)
|
|
.replace(
|
|
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?>([\s\S]*?)<\/slot>/g,
|
|
(_m, name: string, fallback: string) =>
|
|
named[name] != null && named[name]!.trim() ? named[name]! : fallback,
|
|
)
|
|
.replace(/<slot\s*\/>/g, defTrimmed ? def : "")
|
|
.replace(/<slot\b[^>]*>([\s\S]*?)<\/slot>/g, (_m, fallback: string) =>
|
|
defTrimmed ? def : fallback,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract props from a `data-component` mount's attribute string. Every quoted
|
|
* attribute other than `data-component` becomes a (string) prop; the component's
|
|
* declared defaults coerce them to their final types.
|
|
*/
|
|
export function parseComponentProps(attrStr: string): Record<string, string> {
|
|
const props: Record<string, string> = {};
|
|
for (const m of attrStr.matchAll(/([A-Za-z_][\w-]*)(?:="([^"]*)")?/g)) {
|
|
const key = m[1]!;
|
|
if (key === "data-component") continue;
|
|
props[key] = decodeHtmlEntities(m[2] ?? "");
|
|
}
|
|
return props;
|
|
}
|
|
|
|
/**
|
|
* Resolve `{t:key}` i18n markers inside component prop values against the active
|
|
* language, so authors can pass localized text into a component:
|
|
* `<div data-component="badge" label="{t:status.new}">`.
|
|
*/
|
|
export function resolveTProps(
|
|
props: Record<string, string>,
|
|
translate: TFunction,
|
|
): Record<string, string> {
|
|
for (const key of Object.keys(props)) {
|
|
const value = props[key]!;
|
|
if (value.includes("{t:")) {
|
|
props[key] = value.replace(/\{t:\s*([^}]+?)\s*\}/g, (_m, tkey: string) =>
|
|
translate(tkey.trim()),
|
|
);
|
|
}
|
|
}
|
|
return props;
|
|
}
|
|
|
|
/**
|
|
* Decide which framework scripts a rendered page needs. Components are already
|
|
* server-rendered into the HTML; the only script is the reactive runtime, and
|
|
* only when the page actually contains a scope or a browser-side API fetch.
|
|
*/
|
|
export function collectScripts(
|
|
body: string,
|
|
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
|
|
): RenderScript[] {
|
|
// Client-side navigation is an app-wide progressive enhancement: it must load
|
|
// on every page (you navigate *from* any page), and degrades to full loads.
|
|
const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
|
|
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
|
|
scripts.push("/__wrnexus/reactive.js");
|
|
}
|
|
// The theme runtime is only needed when the page can switch themes.
|
|
if (/\bdata-wire-theme-(toggle|set)\b/.test(body)) {
|
|
scripts.push(THEME_JS_HREF);
|
|
}
|
|
// Validation: schema descriptors + the generic validator, only for pages with a form.
|
|
if (/\bdata-schema="[A-Za-z0-9_-]+"/.test(body)) {
|
|
scripts.push("/__wrnexus/schemas.js", "/__wrnexus/validate.js");
|
|
}
|
|
// Language switcher runtime, only when the page has one.
|
|
if (/\bdata-wire-lang-set\b/.test(body) || /\bselect[^>]*\bdata-wire-lang\b/.test(body)) {
|
|
scripts.push(I18N_JS_HREF);
|
|
}
|
|
// Realtime client, only when the page declares a room.
|
|
if (/\bdata-room=/.test(body)) {
|
|
scripts.push("/__wrnexus/realtime.js");
|
|
}
|
|
// File-upload runtime (drag-drop + progress), only when a page has an uploader.
|
|
if (/\bdata-uploader\b/.test(body)) {
|
|
scripts.push("/__wrnexus/uploader.js");
|
|
}
|
|
// Package runtimes are declarative. Components mark the rendered HTML with
|
|
// `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded
|
|
// once, without requiring application-authored script tags or public copies.
|
|
scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes));
|
|
return scripts;
|
|
}
|
|
|
|
/** Whether rendered markup needs the Capacitor/native browser bridge. */
|
|
export function usesMobileRuntime(body: string): boolean {
|
|
return /\b(?:data-mobile-[\w-]+|data-native-(?:browser|mobile|only|requires|unsupported|options)|data-on-wrnexus-(?:browser|mobile)-[\w-]+)\b/.test(
|
|
body,
|
|
);
|
|
}
|
|
|
|
function safeLanguageTag(value: string): string {
|
|
return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en";
|
|
}
|
|
|
|
function versionAssetUrl(src: string, version?: string): string {
|
|
if (!version) return src;
|
|
return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
|
|
}
|
|
|
|
function versionRenderScript(script: RenderScript, version?: string): RenderScript {
|
|
if (typeof script === "string") return versionAssetUrl(script, version);
|
|
return { ...script, src: versionAssetUrl(script.src, version) } satisfies ScriptAsset;
|
|
}
|
|
|
|
function escapeStyleContent(css: string): string {
|
|
return css.replace(/<\/style/gi, "<\\/style");
|
|
}
|