release: WRNexusJS 0.8.0
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
||||
createCorsPreflightResponse,
|
||||
createRealtimeRegistry,
|
||||
csrfToken,
|
||||
verifyCsrf,
|
||||
escapeHtml,
|
||||
etag,
|
||||
isRoomDefinition,
|
||||
isWebSocketOriginAllowed,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
withSecurityHeaders,
|
||||
resolveRequestUrl,
|
||||
tenantMiddleware,
|
||||
tracingMiddleware,
|
||||
HealthRegistry,
|
||||
type Context,
|
||||
type Middleware,
|
||||
type Mode,
|
||||
@@ -38,13 +40,23 @@ import {
|
||||
} from "@wrnexus/core";
|
||||
import { requestHardening } from "@wrnexus/security";
|
||||
import {
|
||||
createLivenessHandler,
|
||||
createOtlpTraceExporter,
|
||||
createReadinessHandler,
|
||||
createWebVitalsHandler,
|
||||
defaultMetrics,
|
||||
metricsMiddleware,
|
||||
traceMiddleware,
|
||||
webVitalsClient,
|
||||
} from "@wrnexus/observability";
|
||||
import type { Router } from "@wrnexus/router";
|
||||
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
|
||||
import {
|
||||
partialPrerender,
|
||||
renderDocument,
|
||||
streamPartialDocument,
|
||||
type RenderScript,
|
||||
type ScriptAsset,
|
||||
} from "@wrnexus/ssr";
|
||||
import {
|
||||
disposeRequestStores,
|
||||
renderStoreHydration,
|
||||
@@ -52,6 +64,8 @@ import {
|
||||
} from "@wrnexus/ssr/store-context";
|
||||
import type { StoreDefinition } from "@wrnexus/store";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
import { CacheCoordinator } from "@wrnexus/cache";
|
||||
import { generateServiceWorker } from "@wrnexus/pwa";
|
||||
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
|
||||
import {
|
||||
ACCENT_COOKIE,
|
||||
@@ -67,8 +81,8 @@ import {
|
||||
type TenancyConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import {
|
||||
LANG_COOKIE,
|
||||
I18N_JS_HREF,
|
||||
renderI18nData,
|
||||
makeT,
|
||||
resolveLang,
|
||||
translateHtml,
|
||||
@@ -106,6 +120,12 @@ export type WsData =
|
||||
|
||||
type RouteModule = Record<string, unknown>;
|
||||
type ApiRegistry = Record<string, unknown>;
|
||||
interface ActionEntry {
|
||||
run: (input: unknown, ctx: Context) => unknown | Promise<unknown>;
|
||||
schema?: {
|
||||
parse(input: unknown): { ok: boolean; value: unknown; errors: Record<string, string> };
|
||||
};
|
||||
}
|
||||
interface CsrBinding {
|
||||
id: string;
|
||||
method?: string;
|
||||
@@ -161,6 +181,8 @@ export interface RuntimeDeps {
|
||||
security?: SecurityConfig;
|
||||
/** Built-in request tracing and Server-Timing policy. */
|
||||
observability?: ObservabilityConfig;
|
||||
/** Dependency health checks used by `/readyz` and `/__wrnexus/ready`. */
|
||||
health?: HealthRegistry;
|
||||
/** Built-in tenant identity resolution. */
|
||||
tenancy?: TenancyConfig;
|
||||
/** Max request body size in bytes (413 above this). Default 10 MB. */
|
||||
@@ -173,13 +195,17 @@ export interface RuntimeDeps {
|
||||
* bus (use the Redis pub/sub driver). Enables realtime across multiple apps.
|
||||
*/
|
||||
realtimeBus?: RealtimeBus;
|
||||
/** Shared first-class data/component/page caches. */
|
||||
cache?: CacheCoordinator;
|
||||
/** Final document transform supplied by the plugin render lifecycle. */
|
||||
renderHtml?: (html: string) => string | Promise<string>;
|
||||
|
||||
devToolbar?: {
|
||||
config: DevToolbarConfig;
|
||||
collector: DevToolbarCollector;
|
||||
root: string;
|
||||
platform?: DevToolbarPlatformSnapshot;
|
||||
panels?: DevToolbarPanel[];
|
||||
panels?: DevToolbarPanel[] | (() => DevToolbarPanel[] | Promise<DevToolbarPanel[]>);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,24 +256,34 @@ function frameworkMiddleware(deps: RuntimeDeps): Middleware[] {
|
||||
|
||||
if (deps.observability && deps.observability.enabled !== false) {
|
||||
middleware.push(metricsMiddleware({ registry: defaultMetrics, includePath: false }));
|
||||
const traceExporter =
|
||||
deps.observability.exporter === "otlp" && deps.observability.endpoint
|
||||
? createOtlpTraceExporter(deps.observability.endpoint, {
|
||||
serviceName: deps.observability.serviceName,
|
||||
})
|
||||
: undefined;
|
||||
middleware.push(
|
||||
tracingMiddleware(undefined, {
|
||||
traceMiddleware({
|
||||
serviceName: deps.observability.serviceName,
|
||||
sampleRate: deps.observability.sampleRate,
|
||||
serverTiming: deps.observability.serverTiming,
|
||||
onComplete:
|
||||
exporter: traceExporter,
|
||||
onSpan:
|
||||
deps.observability.exporter === "console"
|
||||
? (ctx, records) => {
|
||||
const total = records.find((record) => record.name === "http.request")?.durationMs;
|
||||
? (span) => {
|
||||
console.log(
|
||||
`[wrnexus:trace] ${ctx.req.method} ${ctx.url.pathname} ${total?.toFixed(2) ?? "0.00"}ms`,
|
||||
`[wrnexus:trace] ${span.name} ${span.durationMs.toFixed(2)}ms trace=${span.traceId}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
onExportError(error) {
|
||||
console.error("[wrnexus:trace] export failed", error);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (deps.tenancy && deps.tenancy.mode !== "custom") {
|
||||
if (deps.tenancy && Object.keys(deps.tenancy).length > 0 && deps.tenancy.mode !== "custom") {
|
||||
middleware.push(
|
||||
tenantMiddleware(tenantIdentityFromConfig(deps.tenancy), {
|
||||
required: deps.tenancy.required,
|
||||
@@ -277,30 +313,20 @@ export const PWA_CLIENT = `if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register(swUrl).catch(() => {}));
|
||||
}`;
|
||||
|
||||
export const PWA_DEV_CLEANUP_CLIENT = `if ("serviceWorker" in navigator && !sessionStorage.getItem("wrnexus-pwa-dev-cleaned")) {
|
||||
sessionStorage.setItem("wrnexus-pwa-dev-cleaned", "1");
|
||||
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
||||
return Promise.all(registrations.filter(function (registration) {
|
||||
return new URL(registration.active?.scriptURL || registration.installing?.scriptURL || registration.waiting?.scriptURL || location.origin, location.origin).pathname === "/sw.js";
|
||||
}).map(function (registration) { return registration.unregister(); }));
|
||||
}).catch(function () {});
|
||||
if (window.caches) caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.filter(function (key) { return key.indexOf("wrnexus-pwa-") === 0; }).map(function (key) { return caches.delete(key); }));
|
||||
}).catch(function () {});
|
||||
}`;
|
||||
|
||||
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))));
|
||||
});`;
|
||||
return generateServiceWorker(pwa);
|
||||
}
|
||||
|
||||
const DEFAULT_PWA_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
@@ -825,6 +851,20 @@ export interface Handlers {
|
||||
/** 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 cache =
|
||||
deps.cache ??
|
||||
new CacheCoordinator({
|
||||
onEvent: (event) => {
|
||||
if (mode === "development")
|
||||
console.debug(
|
||||
`[wrnexus:cache] ${event.layer} ${event.operation}${event.key ? ` ${event.key}` : ""}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
const initializeRequestCache = (ctx: Context): void => {
|
||||
ctx.locals.cache = cache;
|
||||
ctx.locals.requestCache ??= cache.request();
|
||||
};
|
||||
const builtInMiddleware = frameworkMiddleware(deps);
|
||||
const resolveMiddleware = async (): Promise<Middleware[]> => [
|
||||
...builtInMiddleware,
|
||||
@@ -867,11 +907,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
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 pwaServiceWorkerEnabled =
|
||||
mode === "production" && pwaEnabled && pwaConfig.serviceWorker !== false;
|
||||
const pwaDevCleanupEnabled = mode === "development";
|
||||
const webVitalsEnabled =
|
||||
deps.observability?.enabled !== false && deps.observability?.webVitals === true;
|
||||
const webVitalsEndpoint = deps.observability?.webVitalsEndpoint ?? "/__wrnexus/metrics/vitals";
|
||||
const webVitalsHandler = createWebVitalsHandler({ registry: defaultMetrics });
|
||||
const livenessHandler = createLivenessHandler();
|
||||
const readinessHandler = createReadinessHandler(deps.health ?? new HealthRegistry());
|
||||
|
||||
const configuredPermissions = deps.security?.permissionsPolicy;
|
||||
const runtimeSecurity: SecurityConfig | undefined =
|
||||
@@ -898,7 +942,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
// Health check — unauthenticated, skips the middleware pipeline.
|
||||
if (url.pathname === "/healthz" || url.pathname === "/__wrnexus/health") {
|
||||
return secure(Response.json({ status: "ok" }));
|
||||
return secure(await livenessHandler(req));
|
||||
}
|
||||
if (url.pathname === "/readyz" || url.pathname === "/__wrnexus/ready") {
|
||||
return secure(await readinessHandler(req));
|
||||
}
|
||||
if (webVitalsEnabled && url.pathname === webVitalsEndpoint) {
|
||||
return secure(await webVitalsHandler(req));
|
||||
@@ -995,6 +1042,16 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/__wrnexus/pwa-dev-cleanup.js" && pwaDevCleanupEnabled) {
|
||||
return secure(
|
||||
new Response(PWA_DEV_CLEANUP_CLIENT, {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/__wrnexus/mobile.js" && deps.mobile?.enabled !== false) {
|
||||
return secure(
|
||||
new Response(MOBILE_CLIENT, {
|
||||
@@ -1081,13 +1138,26 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
try {
|
||||
const ctx = createContext(req, url);
|
||||
initializeRequestCache(ctx);
|
||||
ctx.ip = server.requestIP?.(req)?.address ?? undefined;
|
||||
ctx.locals.cspNonce = nonce; // available to pages for their own inline scripts
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase())) {
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("form")) {
|
||||
try {
|
||||
const form = await req.clone().formData();
|
||||
const token = form.get("_csrf");
|
||||
if (typeof token === "string") ctx.locals._csrf = token;
|
||||
} catch {
|
||||
// The endpoint will return its normal malformed-input response.
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve the request language so both pages and API can translate.
|
||||
if (deps.i18n) {
|
||||
ctx.lang = resolveLang(
|
||||
deps.i18n,
|
||||
ctx.cookies.get(LANG_COOKIE),
|
||||
ctx.cookies.get(deps.i18n.cookie.name),
|
||||
req.headers.get("accept-language"),
|
||||
);
|
||||
ctx.t = makeT(deps.i18n, ctx.lang);
|
||||
@@ -1125,10 +1195,18 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
async function dispatch(ctx: Context): Promise<Response> {
|
||||
const { pathname } = ctx.url;
|
||||
|
||||
if (pathname === "/__wrnexus/cache") {
|
||||
if (mode !== "development") return new Response("Not Found", { status: 404 });
|
||||
return Response.json(cache.inspect(), { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
// Framework-owned assets (island chunks, reactive runtime, HMR stream).
|
||||
if (pathname === "/__wrnexus/csr") {
|
||||
return handleCsrBinding(ctx);
|
||||
}
|
||||
if (pathname === "/__wrnexus/client-load") {
|
||||
return handleClientLoad(ctx);
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/__wrnexus/")) {
|
||||
const res = await assets.serve(pathname);
|
||||
@@ -1186,6 +1264,40 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClientLoad(ctx: Context): Promise<Response> {
|
||||
const routePath = ctx.url.searchParams.get("route") ?? "";
|
||||
const name = ctx.url.searchParams.get("name") ?? "";
|
||||
if (
|
||||
!routePath.startsWith("/") ||
|
||||
routePath.startsWith("/__wrnexus/") ||
|
||||
!isSafeRequestPath(routePath) ||
|
||||
!/^[A-Za-z_$][\w$]{0,63}$/.test(name)
|
||||
) {
|
||||
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 load = pageModule.__wrnexusClientLoad;
|
||||
if (typeof load !== "function") return new Response("Not Found", { status: 404 });
|
||||
try {
|
||||
ctx.params = page.params;
|
||||
const values = await load(ctx);
|
||||
if (!values || typeof values !== "object" || !(name in values)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return Response.json(
|
||||
{ data: (values as Record<string, unknown>)[name] },
|
||||
{ headers: { "cache-control": "private, no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { code: "CLIENT_LOAD_FAILED", message: "Client data loading failed." } },
|
||||
{ status: 500, headers: { "cache-control": "private, no-store" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function callApiFromContext(ctx: Context, path: string, method = "GET"): Promise<unknown> {
|
||||
if (!isSafeApiPath(path)) {
|
||||
throw new Error("Unsafe framework API path");
|
||||
@@ -1283,6 +1395,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
async function renderComponents(
|
||||
body: string,
|
||||
translate: TFunction = (key) => key,
|
||||
language?: string,
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 15 || router.components.length === 0 || !body.includes("data-component=")) {
|
||||
@@ -1328,8 +1441,37 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
// 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);
|
||||
if (normalizedName === "languageswitcher" && deps.i18n) {
|
||||
props.locales ??= JSON.stringify(
|
||||
deps.i18n.langs.map((locale) => ({
|
||||
value: locale,
|
||||
label: deps.i18n?.labels[locale] ?? locale.toUpperCase(),
|
||||
shortLabel: locale.split("-")[0]!.toUpperCase(),
|
||||
})),
|
||||
);
|
||||
props.current ??=
|
||||
language && deps.i18n.langs.includes(language) ? language : deps.i18n.default;
|
||||
}
|
||||
const policy = (mod.__wrnexusCache ?? {}) as Record<string, string>;
|
||||
const strategy = policy.strategy?.toLowerCase();
|
||||
const renderComponent = () => fillSlots(String(render(props)), inner);
|
||||
const rendered =
|
||||
strategy && !["none", "no-store", "request"].includes(strategy)
|
||||
? await cache.getOrLoad(
|
||||
"component",
|
||||
`${normalizedName}:${language}:${JSON.stringify(props)}:${inner}`,
|
||||
renderComponent,
|
||||
{
|
||||
ttlMs: cacheDuration(policy.ttl, 60_000),
|
||||
staleWhileRevalidateMs:
|
||||
strategy === "stale-while-revalidate"
|
||||
? cacheDuration(policy.stale ?? policy.ttl, 60_000)
|
||||
: 0,
|
||||
tags: cacheList(policy.tags),
|
||||
},
|
||||
)
|
||||
: renderComponent();
|
||||
result += await renderComponents(rendered, translate, language, depth + 1);
|
||||
} catch (err) {
|
||||
console.error(`[wrnexus] component '${name}' failed to render`, err);
|
||||
deps.devToolbar?.collector.add(
|
||||
@@ -1347,6 +1489,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}
|
||||
|
||||
async function handlePage(ctx: Context): Promise<Response> {
|
||||
// Page rendering is also reached by HMR synchronization and internal
|
||||
// dispatches, so never rely exclusively on the public fetch initializer.
|
||||
initializeRequestCache(ctx);
|
||||
const isMobileRequest =
|
||||
ctx.req.headers.get("x-wrnexus-mobile") === "1" ||
|
||||
new RegExp(deps.mobile?.userAgent ?? "WrNexusMobile", "i").test(
|
||||
@@ -1378,10 +1523,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}
|
||||
|
||||
// Issue the CSRF token cookie so forms on this page can echo it back.
|
||||
csrfToken(ctx);
|
||||
const pageCsrf = csrfToken(ctx);
|
||||
const storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
|
||||
|
||||
const mod = await loadModule(matched.route.file);
|
||||
if (ctx.req.method.toUpperCase() === "POST") {
|
||||
const actionResponse = await handlePageAction(ctx, mod);
|
||||
if (actionResponse) return actionResponse;
|
||||
}
|
||||
const component = mod.default;
|
||||
if (typeof component !== "function") {
|
||||
throw new Error(`Page ${matched.route.file} has no default export`);
|
||||
@@ -1389,13 +1538,88 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
ctx.params = matched.params;
|
||||
const meta = (mod.meta ?? {}) as PageMeta;
|
||||
const pageNavigation = (mod.__wrnexusNavigation ?? {}) as { preserve?: string };
|
||||
const pageCache = (mod.__wrnexusCache ?? {}) as Record<string, string>;
|
||||
const fullPageEnabled = ["page", "full-page"].includes(pageCache.scope?.toLowerCase() ?? "");
|
||||
const fullPageKey = `page:${matched.route.raw}:${ctx.url.search}:${cacheIdentity(ctx, [
|
||||
"language",
|
||||
`cookie:${THEME_COOKIE}`,
|
||||
`cookie:${ACCENT_COOKIE}`,
|
||||
...cacheList(pageCache.vary),
|
||||
])}`;
|
||||
if (fullPageEnabled && ["GET", "HEAD"].includes(ctx.req.method.toUpperCase())) {
|
||||
const pageHit = cache.page.lookup(fullPageKey);
|
||||
if (pageHit.state === "fresh") {
|
||||
const cached = pageHit.entry.value as { html: string; etag: string; nonce: string };
|
||||
const restoredHtml = cached.nonce
|
||||
? cached.html.replaceAll(
|
||||
`nonce="${cached.nonce}"`,
|
||||
`nonce="${String(ctx.locals.cspNonce ?? "")}"`,
|
||||
)
|
||||
: cached.html;
|
||||
await disposeRequestStores(ctx.req);
|
||||
return new Response(ctx.req.method.toUpperCase() === "HEAD" ? null : restoredHtml, {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
etag: cached.etag,
|
||||
"cache-control": "public, max-age=0, must-revalidate",
|
||||
"x-wrnexus-page-cache": "HIT",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
let dataCacheState: "HIT" | "STALE" | "MISS" | "BYPASS" = "BYPASS";
|
||||
const preserve = (pageNavigation.preserve ?? "")
|
||||
.match(/(?:scroll|forms|tabs|expanded|filters|pagination|component|workflow)/g)
|
||||
?.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.join(",");
|
||||
const pageCtx = ctx as Context & {
|
||||
__wrnexusCallApi?: (path: string, method?: string) => Promise<unknown>;
|
||||
__wrnexusUseStore?: (definition: StoreDefinition<any, any, any>) => Promise<unknown>;
|
||||
};
|
||||
pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method);
|
||||
pageCtx.__wrnexusUseStore = (definition) => storeContainer.use(definition);
|
||||
let body = await renderComponents(String(await component(pageCtx)), ctx.t);
|
||||
const load = mod.__wrnexusLoad as ((ctx: Context) => Promise<unknown>) | undefined;
|
||||
if (typeof load === "function") {
|
||||
const strategy = pageCache.strategy?.toLowerCase();
|
||||
const cacheEnabled = Boolean(strategy && !["none", "no-store", "request"].includes(strategy));
|
||||
let data: unknown;
|
||||
if (cacheEnabled && ["GET", "HEAD"].includes(ctx.req.method.toUpperCase())) {
|
||||
const key = `route:${matched.route.raw}:${ctx.url.search}:${cacheIdentity(ctx, cacheList(pageCache.vary))}`;
|
||||
const lookup = cache.data.lookup(key);
|
||||
dataCacheState =
|
||||
lookup.state === "fresh" ? "HIT" : lookup.state === "stale" ? "STALE" : "MISS";
|
||||
data = await cache.getOrLoad("data", key, () => load(pageCtx), {
|
||||
ttlMs: cacheDuration(pageCache.ttl, 60_000),
|
||||
staleWhileRevalidateMs:
|
||||
strategy === "stale-while-revalidate"
|
||||
? cacheDuration(pageCache.stale ?? pageCache.ttl, 60_000)
|
||||
: 0,
|
||||
tags: cacheList(pageCache.tags),
|
||||
});
|
||||
} else {
|
||||
data = await (
|
||||
ctx.locals.requestCache as {
|
||||
getOrLoad<V>(key: string, loader: () => Promise<V>): Promise<V>;
|
||||
}
|
||||
).getOrLoad(`route:${matched.route.raw}`, () => load(pageCtx));
|
||||
}
|
||||
(pageCtx as Context & { data?: unknown }).data = data;
|
||||
if (data && typeof data === "object") Object.assign(pageCtx, data);
|
||||
}
|
||||
let body = await renderComponents(String(await component(pageCtx)), ctx.t, ctx.lang);
|
||||
const partial = (mod as { __wrnexusRender?: string }).__wrnexusRender === "partial-static";
|
||||
const precomputedShell = (mod as { __wrnexusStaticShell?: unknown }).__wrnexusStaticShell;
|
||||
const pagePartial = partial ? partialPrerender(body) : undefined;
|
||||
if (partial) {
|
||||
body = typeof precomputedShell === "string" ? precomputedShell : (pagePartial?.shell ?? body);
|
||||
}
|
||||
if (body.includes("data-wrn-action=")) {
|
||||
body = body.replace(
|
||||
/(<form\b[^>]*\bdata-wrn-action=(?:"[^"]+"|'[^']+')[^>]*>)/gi,
|
||||
`$1<input type="hidden" name="_csrf" value="${pageCsrf}">`,
|
||||
);
|
||||
}
|
||||
const resolvedTheme = deps.theme
|
||||
? resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)
|
||||
: "";
|
||||
@@ -1421,14 +1645,18 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
? undefined
|
||||
: router.layouts.find((l) => l.name === layoutName);
|
||||
if (importedLayout?.render) {
|
||||
body = await renderComponents(fillSlots(String(importedLayout.render({})), body), ctx.t);
|
||||
body = await renderComponents(
|
||||
fillSlots(String(importedLayout.render({})), body),
|
||||
ctx.t,
|
||||
ctx.lang,
|
||||
);
|
||||
} else 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);
|
||||
body = await renderComponents(fillSlots(String(layoutRender({})), body), ctx.t, ctx.lang);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wrnexus] layout '${layoutName}' failed to render`, err);
|
||||
@@ -1467,7 +1695,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
pathname: ctx.url.pathname,
|
||||
}),
|
||||
);
|
||||
documentTemplate = await renderComponents(fillSlots(rendered, body), ctx.t);
|
||||
documentTemplate = await renderComponents(fillSlots(rendered, body), ctx.t, ctx.lang);
|
||||
body = documentTemplate;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1498,6 +1726,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}
|
||||
if (pwaServiceWorkerEnabled)
|
||||
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
|
||||
if (pwaDevCleanupEnabled) scripts.push("/__wrnexus/pwa-dev-cleanup.js");
|
||||
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
|
||||
scripts.push(versionAssetUrl("/__wrnexus/mobile.js", deps.assetVersion));
|
||||
|
||||
@@ -1516,15 +1745,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}
|
||||
}
|
||||
attrs.push(`lang="${safeLanguageTag(language)}"`);
|
||||
if (deps.i18n) attrs.push(`dir="${deps.i18n.direction[language] ?? "ltr"}"`);
|
||||
const htmlAttrs = attrs.length ? ` ${attrs.join(" ")}` : undefined;
|
||||
|
||||
const html = renderDocument({
|
||||
const outerPartial = partial
|
||||
? partialPrerender(body, pagePartial?.regions.length ?? 0)
|
||||
: undefined;
|
||||
const partialRegions = [...(pagePartial?.regions ?? []), ...(outerPartial?.regions ?? [])];
|
||||
const renderedBody = outerPartial?.shell ?? body;
|
||||
if (partial && documentTemplate) documentTemplate = renderedBody;
|
||||
|
||||
let html = renderDocument({
|
||||
meta,
|
||||
seo: deps.seo,
|
||||
url: ctx.url,
|
||||
body,
|
||||
body: renderedBody,
|
||||
scripts,
|
||||
extraHead: [
|
||||
preserve ? `<meta name="wrnexus-preserve" content="${preserve}" />` : "",
|
||||
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" />` : "",
|
||||
@@ -1535,6 +1773,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
extraBody:
|
||||
[
|
||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||
deps.i18n
|
||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
||||
: "",
|
||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||
]
|
||||
@@ -1544,10 +1785,29 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
documentTemplate,
|
||||
styleNonce: (ctx.locals.cspNonce as string | undefined) ?? undefined,
|
||||
});
|
||||
if (deps.renderHtml) html = await deps.renderHtml(html);
|
||||
// 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}`);
|
||||
if (
|
||||
fullPageEnabled &&
|
||||
["GET", "HEAD"].includes(ctx.req.method.toUpperCase()) &&
|
||||
!/name=["']_csrf["']/.test(html)
|
||||
) {
|
||||
cache.page.set(
|
||||
fullPageKey,
|
||||
{ html, etag: tag, nonce: String(ctx.locals.cspNonce ?? "") },
|
||||
{
|
||||
ttlMs: cacheDuration(pageCache.ttl, 60_000),
|
||||
staleWhileRevalidateMs:
|
||||
pageCache.strategy?.toLowerCase() === "stale-while-revalidate"
|
||||
? cacheDuration(pageCache.stale ?? pageCache.ttl, 60_000)
|
||||
: 0,
|
||||
tags: cacheList(pageCache.tags),
|
||||
},
|
||||
);
|
||||
}
|
||||
await disposeRequestStores(ctx.req);
|
||||
const method = ctx.req.method.toUpperCase();
|
||||
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
|
||||
@@ -1556,18 +1816,132 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
return new Response(
|
||||
partial && method !== "HEAD"
|
||||
? streamPartialDocument(
|
||||
{ shell: html, regions: partialRegions },
|
||||
(ctx.locals.cspNonce as string | undefined) ?? undefined,
|
||||
)
|
||||
: html,
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
etag: tag,
|
||||
"cache-control": "private, no-cache",
|
||||
"x-wrnexus-data-cache": dataCacheState,
|
||||
...(partial ? { "x-wrnexus-render": "partial-static" } : {}),
|
||||
...(partial && typeof precomputedShell === "string"
|
||||
? { "x-wrnexus-static-shell": "build" }
|
||||
: {}),
|
||||
...(fullPageEnabled ? { "x-wrnexus-page-cache": "MISS" } : {}),
|
||||
...(shouldEnableDevToolbar(mode, deps)
|
||||
? {
|
||||
"x-wrnexus-dev-toolbar": "enabled",
|
||||
"x-wrnexus-route": matched.route.raw,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function actionInput(request: Request): Promise<{ name?: string; input: unknown }> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return {
|
||||
name: request.headers.get("x-wrnexus-action") ?? undefined,
|
||||
input: await request.json(),
|
||||
};
|
||||
}
|
||||
const form = await request.formData();
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const [key, value] of form) {
|
||||
if (key === "_wrnexus_action" || key === "_csrf") continue;
|
||||
if (!(key in input)) input[key] = value;
|
||||
else
|
||||
input[key] = Array.isArray(input[key])
|
||||
? [...(input[key] as unknown[]), value]
|
||||
: [input[key], value];
|
||||
}
|
||||
const submittedName = form.get("_wrnexus_action");
|
||||
return {
|
||||
name:
|
||||
request.headers.get("x-wrnexus-action") ??
|
||||
(typeof submittedName === "string" ? submittedName : undefined),
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePageAction(ctx: Context, mod: RouteModule): Promise<Response | null> {
|
||||
const actions = mod.__wrnexusActions as Record<string, ActionEntry> | undefined;
|
||||
if (!actions) return null;
|
||||
let submitted: Awaited<ReturnType<typeof actionInput>>;
|
||||
try {
|
||||
submitted = await actionInput(ctx.req);
|
||||
} catch {
|
||||
return Response.json({ error: "Malformed action input" }, { status: 400 });
|
||||
}
|
||||
if (!submitted.name) return null;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(submitted.name) || !actions[submitted.name]) {
|
||||
return Response.json({ error: "Unknown server action" }, { status: 404 });
|
||||
}
|
||||
const security = (mod.__wrnexusSecurity ?? {}) as Record<string, string>;
|
||||
if (/^(?:required|true)$/i.test(security.auth ?? "") && !ctx.user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
if (security.permission) {
|
||||
const permissions = ctx.locals.permissions;
|
||||
const allowed =
|
||||
typeof permissions === "function"
|
||||
? await permissions(security.permission, ctx)
|
||||
: Array.isArray(permissions) && permissions.includes(security.permission);
|
||||
if (!allowed) return Response.json({ error: "Permission denied" }, { status: 403 });
|
||||
}
|
||||
if (security.csrf !== "false" && !verifyCsrf(ctx)) {
|
||||
return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
|
||||
}
|
||||
const action = actions[submitted.name]!;
|
||||
let input = submitted.input;
|
||||
if (action.schema) {
|
||||
const parsed = action.schema.parse(input);
|
||||
if (!parsed.ok) {
|
||||
const acceptsJson = (ctx.req.headers.get("accept") ?? "").includes("application/json");
|
||||
if (acceptsJson)
|
||||
return Response.json(
|
||||
{ error: "Validation failed", errors: parsed.errors },
|
||||
{ status: 422 },
|
||||
);
|
||||
const errors = Object.entries(parsed.errors)
|
||||
.map(
|
||||
([field, message]) =>
|
||||
`<li><strong>${escapeHtml(field)}</strong>: ${escapeHtml(message)}</li>`,
|
||||
)
|
||||
.join("");
|
||||
return new Response(
|
||||
`<!doctype html><title>Validation failed</title><h1>Validation failed</h1><ul>${errors}</ul><a href="${escapeHtml(ctx.url.pathname)}">Go back</a>`,
|
||||
{
|
||||
status: 422,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
input = parsed.value;
|
||||
}
|
||||
const data = await action.run(input, ctx);
|
||||
const invalidated = [
|
||||
...new Set(
|
||||
Array.isArray(ctx.locals.__wrnexusInvalidatedTags)
|
||||
? (ctx.locals.__wrnexusInvalidatedTags as string[])
|
||||
: [],
|
||||
),
|
||||
];
|
||||
if (invalidated.length) cache.invalidateTags(invalidated);
|
||||
if ((ctx.req.headers.get("accept") ?? "").includes("application/json")) {
|
||||
return Response.json({ ok: true, data, invalidated });
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { location: ctx.url.pathname + ctx.url.search },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1605,11 +1979,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
headers.set("x-wrnexus-hmr", "1");
|
||||
const req = new Request(url, { headers });
|
||||
const ctx = createContext(req, url);
|
||||
initializeRequestCache(ctx);
|
||||
ctx.locals.cspNonce = randomNonce();
|
||||
if (deps.i18n) {
|
||||
ctx.lang = resolveLang(
|
||||
deps.i18n,
|
||||
ctx.cookies.get(LANG_COOKIE),
|
||||
ctx.cookies.get(deps.i18n.cookie.name),
|
||||
req.headers.get("accept-language"),
|
||||
);
|
||||
ctx.t = makeT(deps.i18n, ctx.lang);
|
||||
@@ -1908,9 +2283,15 @@ export function collectScripts(
|
||||
navigation: { mode?: "auto" | "client" | "document" } = {},
|
||||
): RenderScript[] {
|
||||
const scripts: RenderScript[] = [];
|
||||
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
|
||||
if (
|
||||
/\bdata-scope=/.test(body) ||
|
||||
/\bdata-wrnexus-csr=/.test(body) ||
|
||||
/\bdata-wrn-client-template=/.test(body) ||
|
||||
/\bdata-wrn-async=/.test(body)
|
||||
) {
|
||||
scripts.push("/__wrnexus/reactive.js");
|
||||
}
|
||||
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
|
||||
// The theme runtime is only needed when the page can switch themes.
|
||||
if (
|
||||
/\bdata-wire-theme-(toggle|set)\b/.test(body) ||
|
||||
@@ -1961,6 +2342,49 @@ function safeLanguageTag(value: string): string {
|
||||
return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en";
|
||||
}
|
||||
|
||||
function cacheDuration(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i.exec(value.trim());
|
||||
if (!match) return fallback;
|
||||
const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[
|
||||
(match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h" | "d"
|
||||
];
|
||||
return Math.max(0, Number(match[1]) * scale);
|
||||
}
|
||||
|
||||
function cacheList(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (Array.isArray(parsed))
|
||||
return parsed.filter((item): item is string => typeof item === "string");
|
||||
} catch {
|
||||
// Fall through to a convenient comma-separated form.
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function cacheIdentity(ctx: Context, vary: string[]): string {
|
||||
const user = ctx.user as { id?: unknown } | undefined;
|
||||
const values = new Map<string, string>();
|
||||
if (ctx.tenant?.id) values.set("tenant", String(ctx.tenant.id));
|
||||
if (user?.id !== undefined) values.set("user", String(user.id));
|
||||
for (const item of vary) {
|
||||
if (item === "tenant") values.set(item, String(ctx.tenant?.id ?? ""));
|
||||
else if (item === "user") values.set(item, String(user?.id ?? ""));
|
||||
else if (item === "language") values.set(item, ctx.lang);
|
||||
else if (item.startsWith("cookie:")) values.set(item, ctx.cookies.get(item.slice(7)) ?? "");
|
||||
else values.set(`header:${item}`, ctx.req.headers.get(item) ?? "");
|
||||
}
|
||||
return [...values.entries()]
|
||||
.sort()
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function versionAssetUrl(src: string, version?: string): string {
|
||||
if (!version) return src;
|
||||
return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
|
||||
|
||||
Reference in New Issue
Block a user