release: WRNexusJS 0.8.0
This commit is contained in:
@@ -10,7 +10,12 @@
|
||||
* invalidated in-process by the file watcher so edits show without a restart.
|
||||
*/
|
||||
|
||||
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
|
||||
import {
|
||||
getActionRuntime,
|
||||
getReactiveRuntime,
|
||||
getNavRuntime,
|
||||
getRealtimeRuntime,
|
||||
} from "@wrnexus/csr";
|
||||
import {
|
||||
renderStyles,
|
||||
renderThemeCss,
|
||||
@@ -91,6 +96,7 @@ export function createDevAssetServer(
|
||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime());
|
||||
if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime());
|
||||
if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime());
|
||||
if (pathname === "/__wrnexus/actions.js") return jsResponse(getActionRuntime());
|
||||
if (pathname === "/__wrnexus/validate.js") return jsResponse(VALIDATE_RUNTIME);
|
||||
if (pathname === "/__wrnexus/i18n.js") return jsResponse(I18N_RUNTIME);
|
||||
if (pathname === UPLOAD_JS_HREF) return jsResponse(UPLOAD_RUNTIME);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the running process while the HMR socket morphs fresh HTML into the browser.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname, isAbsolute, join } from "node:path";
|
||||
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
import { buildRouter, type Router } from "@wrnexus/router";
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
setDb,
|
||||
registerDb,
|
||||
registerLazyDb,
|
||||
getDbPerformanceSnapshot,
|
||||
} from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
loadWrnServerModule,
|
||||
setCompileCacheDir,
|
||||
setCompileImportOptions,
|
||||
setDevCompilerPipeline,
|
||||
wrnBrowserArtifactUrl,
|
||||
} from "./pipeline.ts";
|
||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||
@@ -48,18 +50,27 @@ import { resolvePackageMigrations } from "./plugin-migrations.ts";
|
||||
import { HmrHub } from "./hmr.ts";
|
||||
import { startWatcher } from "./watch.ts";
|
||||
export { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
export { expandStaticComponents, precomputePartialStaticShell } from "./partial-build.ts";
|
||||
export { getWrnCompileMetrics, resetWrnCompileMetrics } from "./pipeline.ts";
|
||||
export type { WrnCompileMetrics } from "./pipeline.ts";
|
||||
import { resetDevCache } from "./cache.ts";
|
||||
|
||||
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
|
||||
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
|
||||
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
|
||||
|
||||
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
|
||||
import {
|
||||
builtinDevToolbarPanels,
|
||||
createDevToolbarCollector,
|
||||
type DevToolbarCollector,
|
||||
} from "@wrnexus/dev-toolbar/server";
|
||||
|
||||
export interface ServeOptions {
|
||||
appDir: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
/** Development TLS material. Production TLS is normally terminated by the deployment proxy. */
|
||||
tls?: { cert: string; key: string };
|
||||
mode?: Mode;
|
||||
/** Inject the live-reload client (defaults to true in development). */
|
||||
hmr?: boolean;
|
||||
@@ -193,6 +204,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
enforcePermissions: (opts.appConfig?.pluginPermissions as { enforce?: boolean } | undefined)
|
||||
?.enforce,
|
||||
grantedPermissions: (
|
||||
opts.appConfig?.pluginPermissions as
|
||||
{ grants?: Record<string, import("@wrnexus/plugin").PluginPermission[]> } | undefined
|
||||
)?.grants,
|
||||
});
|
||||
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
@@ -252,6 +269,30 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
);
|
||||
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const virtualModules = new Map<string, string>();
|
||||
const virtualDir = join(appRoot, ".wrnexus", "virtual");
|
||||
mkdirSync(virtualDir, { recursive: true });
|
||||
for (const [index, module] of pluginContributions.virtualModules.entries()) {
|
||||
const output = join(virtualDir, `plugin-${index}.ts`);
|
||||
writeFileSync(
|
||||
output,
|
||||
await module.load({
|
||||
root: appRoot,
|
||||
mode,
|
||||
command: "dev",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
virtualModules.set(module.id, output);
|
||||
}
|
||||
setDevCompilerPipeline({
|
||||
transformAst: (ast, file) => pluginRunner.transformAst(ast, file),
|
||||
transformCode: (code, file) => pluginRunner.transformCode(code, file),
|
||||
virtualModules,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[wrnexus:plugin] discovered: ${
|
||||
@@ -296,7 +337,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const schemasJs = await schemaRuntime(router);
|
||||
|
||||
// i18n is opt-in by the presence of app/locales/*.json.
|
||||
const localeMessages = loadLocales(join(appDir, "locales"));
|
||||
const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict });
|
||||
const i18n = Object.keys(localeMessages).length
|
||||
? resolveI18n(localeMessages, opts.i18n)
|
||||
: undefined;
|
||||
@@ -369,6 +410,34 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
})
|
||||
: undefined;
|
||||
const middleware = middlewareLoader(router);
|
||||
const toolbarPlatform = {
|
||||
wrnexus060: {
|
||||
clientFunctions: true,
|
||||
serverFunctions: true,
|
||||
sharedFunctions: true,
|
||||
typedOutputs: true,
|
||||
requestScopedStores: true,
|
||||
},
|
||||
plugins: pluginRunner.plugins.map((plugin) => ({ name: plugin.name, version: plugin.version })),
|
||||
runtimes: pluginContributions.clientRuntimes.map((runtime) => ({
|
||||
id: runtime.id,
|
||||
publicPath: runtime.publicPath,
|
||||
type: runtime.type,
|
||||
load: runtime.load,
|
||||
})),
|
||||
assets: pluginContributions.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
publicPath: asset.publicPath,
|
||||
contentType: asset.contentType,
|
||||
})),
|
||||
componentDirs,
|
||||
styles: pluginContributions.styles,
|
||||
routes: {
|
||||
pages: router.pages.length,
|
||||
api: router.api.length,
|
||||
realtime: router.realtime.length,
|
||||
},
|
||||
};
|
||||
|
||||
const runtimeDeps = {
|
||||
mode,
|
||||
@@ -392,13 +461,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
clientRuntimes: pluginContributions.clientRuntimes,
|
||||
hub,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
renderHtml: (html: string) => pluginRunner.render(html),
|
||||
devToolbar:
|
||||
devToolbarConfig && devToolbarCollector
|
||||
? {
|
||||
config: devToolbarConfig,
|
||||
collector: devToolbarCollector,
|
||||
root: appRoot,
|
||||
panels: [
|
||||
panels: () => [
|
||||
{
|
||||
id: "runtime",
|
||||
title: "Runtime",
|
||||
@@ -415,39 +485,23 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
"Global/page stores, safe client state, computed values, actions, persistence, and hydration.",
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "cache",
|
||||
title: "Cache",
|
||||
icon: "layers",
|
||||
description:
|
||||
"Request, data, component, and page cache entries plus hit/miss history.",
|
||||
order: 30,
|
||||
},
|
||||
...builtinDevToolbarPanels({
|
||||
root: appRoot,
|
||||
platform: toolbarPlatform,
|
||||
database: getDbPerformanceSnapshot(),
|
||||
version: { current: "0.8.0" },
|
||||
}),
|
||||
...pluginToolbarPanels,
|
||||
],
|
||||
platform: {
|
||||
wrnexus060: {
|
||||
clientFunctions: true,
|
||||
serverFunctions: true,
|
||||
sharedFunctions: true,
|
||||
typedOutputs: true,
|
||||
requestScopedStores: true,
|
||||
},
|
||||
plugins: pluginRunner.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
})),
|
||||
runtimes: pluginContributions.clientRuntimes.map((runtime) => ({
|
||||
id: runtime.id,
|
||||
publicPath: runtime.publicPath,
|
||||
type: runtime.type,
|
||||
load: runtime.load,
|
||||
})),
|
||||
assets: pluginContributions.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
publicPath: asset.publicPath,
|
||||
contentType: asset.contentType,
|
||||
})),
|
||||
componentDirs,
|
||||
styles: pluginContributions.styles,
|
||||
routes: {
|
||||
pages: router.pages.length,
|
||||
api: router.api.length,
|
||||
realtime: router.realtime.length,
|
||||
},
|
||||
},
|
||||
platform: toolbarPlatform,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -501,6 +555,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
hostname,
|
||||
development: mode === "development",
|
||||
maxRequestBodySize: 10 * 1024 * 1024,
|
||||
...(opts.tls ? { tls: opts.tls } : {}),
|
||||
fetch(request, server) {
|
||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||
return handlers.fetch(request, server);
|
||||
@@ -551,13 +606,21 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
assets.updateSchemas(await schemaRuntime(router));
|
||||
}
|
||||
if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) {
|
||||
const messages = loadLocales(join(appDir, "locales"));
|
||||
runtimeDeps.i18n = Object.keys(messages).length
|
||||
? resolveI18n(messages, opts.i18n)
|
||||
: undefined;
|
||||
try {
|
||||
const messages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict });
|
||||
runtimeDeps.i18n = Object.keys(messages).length
|
||||
? resolveI18n(messages, opts.i18n)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[wrnexus] locale hot update was incomplete; keeping the last valid bundle",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||
await pluginRunner.hook("hmrUpdate", files);
|
||||
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
|
||||
for (const changed of files) {
|
||||
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
||||
@@ -634,6 +697,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
watcher?.close();
|
||||
unsubscribeDevToolbar?.();
|
||||
server.stop();
|
||||
void pluginRunner.hook("shutdown");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { partialPrerender } from "@wrnexus/ssr";
|
||||
import {
|
||||
fillSlots,
|
||||
normalizeComponentName,
|
||||
parseComponentProps,
|
||||
readElementBody,
|
||||
} from "./runtime.ts";
|
||||
|
||||
export interface PartialBuildModule {
|
||||
default?: unknown;
|
||||
render?: (props?: Record<string, unknown>) => string | Promise<string>;
|
||||
layout?: string | { name?: string; render?: (props?: Record<string, unknown>) => string };
|
||||
__wrnexusBuildStaticShell?: (ctx?: Record<string, unknown>) => string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface PartialBuildEntry {
|
||||
name: string;
|
||||
mod: PartialBuildModule;
|
||||
}
|
||||
|
||||
const MOUNT_OPEN_RE =
|
||||
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-component="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/;
|
||||
|
||||
/** Expand compiler component mounts at build time using only their pure render exports. */
|
||||
export async function expandStaticComponents(
|
||||
html: string,
|
||||
components: readonly PartialBuildEntry[],
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 15) throw new Error("WRN-PARTIAL-STATIC-DEPTH: component nesting exceeds 15");
|
||||
if (!html.includes("data-component=")) return html;
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const match = MOUNT_OPEN_RE.exec(html.slice(cursor));
|
||||
if (!match) return output + html.slice(cursor);
|
||||
const start = cursor + match.index;
|
||||
output += html.slice(cursor, start);
|
||||
const [open, tag, attributes, name, selfClosing] = match;
|
||||
const openEnd = start + open.length;
|
||||
const body =
|
||||
selfClosing === "/" ? { inner: "", end: openEnd } : readElementBody(html, tag!, openEnd);
|
||||
const component = components.find(
|
||||
(entry) => normalizeComponentName(entry.name) === normalizeComponentName(name!),
|
||||
);
|
||||
if (!component || typeof component.mod.render !== "function") {
|
||||
throw new Error(`WRN-PARTIAL-STATIC-COMPONENT: '${name}' has no build-time renderer`);
|
||||
}
|
||||
const rendered = await component.mod.render(parseComponentProps(attributes!));
|
||||
output += await expandStaticComponents(
|
||||
fillSlots(String(rendered), body.inner),
|
||||
components,
|
||||
depth + 1,
|
||||
);
|
||||
cursor = body.end;
|
||||
}
|
||||
}
|
||||
|
||||
/** Produce the body shell stored in dist; dynamic region bodies are never evaluated here. */
|
||||
export async function precomputePartialStaticShell(
|
||||
page: PartialBuildModule,
|
||||
components: readonly PartialBuildEntry[],
|
||||
): Promise<{ shell: string; regions: number }> {
|
||||
if (typeof page.__wrnexusBuildStaticShell !== "function") {
|
||||
throw new Error("WRN-PARTIAL-STATIC-EXPORT: compiler did not emit a static-shell renderer");
|
||||
}
|
||||
const body = await expandStaticComponents(
|
||||
String(await page.__wrnexusBuildStaticShell({})),
|
||||
components,
|
||||
);
|
||||
const result = partialPrerender(body);
|
||||
return { shell: result.shell, regions: result.regions.length };
|
||||
}
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname, resolve } from "node:path";
|
||||
import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler";
|
||||
import {
|
||||
compile,
|
||||
generate,
|
||||
generateTargets,
|
||||
resolveWrnImports,
|
||||
type PageAst,
|
||||
} from "@wrnexus/compiler";
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
/**
|
||||
@@ -60,6 +66,18 @@ interface CompileImportOptions {
|
||||
const compileImportOptions = new Map<string, CompileImportOptions>();
|
||||
const warnedImportDiagnostics = new Set<string>();
|
||||
|
||||
interface DevCompilerPipeline {
|
||||
transformAst(ast: PageAst, file: string): Promise<PageAst>;
|
||||
transformCode(code: string, file: string): Promise<string>;
|
||||
virtualModules: Map<string, string>;
|
||||
}
|
||||
let devCompilerPipeline: DevCompilerPipeline | null = null;
|
||||
|
||||
/** Install the configured plugin compiler pipeline for development compilation. */
|
||||
export function setDevCompilerPipeline(pipeline: DevCompilerPipeline | null): void {
|
||||
devCompilerPipeline = pipeline;
|
||||
}
|
||||
|
||||
export function setCompileImportOptions(
|
||||
appRoot: string,
|
||||
options: { mode?: ImportMode; aliases?: Record<string, string>; autoImport?: boolean } = {},
|
||||
@@ -142,34 +160,93 @@ function rewriteArtifactImports(
|
||||
return output;
|
||||
}
|
||||
|
||||
export function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
async function rewriteArtifactImportsAsync(
|
||||
code: string,
|
||||
ast: PageAst,
|
||||
importer: string,
|
||||
target: "main" | "server" | "browser",
|
||||
): Promise<string> {
|
||||
let output = code;
|
||||
for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) {
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
output = output.replace(
|
||||
new RegExp(`(["'])${escaped}\\1`, "g"),
|
||||
JSON.stringify(pathToFileURL(replacement).href),
|
||||
);
|
||||
}
|
||||
if (!ast.structuredImports.length) return output;
|
||||
const root = projectRootForFile(importer);
|
||||
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
||||
mode: "compatible" as const,
|
||||
aliases: { "@": "./app" },
|
||||
autoImport: true,
|
||||
};
|
||||
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
||||
appRoot: root,
|
||||
mode: importOptions.mode,
|
||||
aliases: importOptions.aliases,
|
||||
});
|
||||
for (const entry of resolved) {
|
||||
if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message);
|
||||
if (!entry.resolved || !entry.declaration.source) continue;
|
||||
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
||||
continue;
|
||||
let replacement = entry.resolved;
|
||||
if (replacement.endsWith(".wrn")) {
|
||||
const dependencySource = readFileSync(replacement, "utf8");
|
||||
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
|
||||
const dependency = await compileWireArtifactsAsync(
|
||||
replacement,
|
||||
moduleVersions.get(replacement) ?? 0,
|
||||
);
|
||||
if (target === "browser") {
|
||||
if (!isStore) {
|
||||
output = output.replace(entry.declaration.raw, "");
|
||||
continue;
|
||||
}
|
||||
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
} else replacement = target === "server" ? dependency.server : dependency.main;
|
||||
}
|
||||
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
||||
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
let mod = moduleCache.get(file);
|
||||
if (!mod) {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
// `.wrn` files are compiled to TypeScript first, then imported.
|
||||
let target = file.endsWith(".wrn") ? compileWireArtifacts(file, version).main : file;
|
||||
let temporary = false;
|
||||
// Bun intentionally caches local TS/JS modules by filesystem path and ignores
|
||||
// URL query strings. A short-lived versioned sibling keeps relative imports
|
||||
// correct while giving the changed module a genuinely new import identity.
|
||||
if (version && !file.endsWith(".wrn")) {
|
||||
const extension = extname(file);
|
||||
const stem = basename(file, extension);
|
||||
target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`);
|
||||
copyFileSync(file, target);
|
||||
temporary = true;
|
||||
}
|
||||
// pathToFileURL handles Windows drive letters and spaces correctly.
|
||||
mod = import(pathToFileURL(target).href) as Promise<Record<string, unknown>>;
|
||||
if (temporary) {
|
||||
mod = mod.finally(() => {
|
||||
try {
|
||||
unlinkSync(target);
|
||||
} catch {
|
||||
/* best-effort cleanup after Bun has loaded the module */
|
||||
}
|
||||
});
|
||||
}
|
||||
mod = (async () => {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
// `.wrn` files are compiled to TypeScript first, then imported.
|
||||
let target = file.endsWith(".wrn")
|
||||
? (await compileWireArtifactsAsync(file, version)).main
|
||||
: file;
|
||||
let temporary = false;
|
||||
// Bun intentionally caches local TS/JS modules by filesystem path and ignores
|
||||
// URL query strings. A short-lived versioned sibling keeps relative imports
|
||||
// correct while giving the changed module a genuinely new import identity.
|
||||
if (version && !file.endsWith(".wrn")) {
|
||||
const extension = extname(file);
|
||||
const stem = basename(file, extension);
|
||||
target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`);
|
||||
copyFileSync(file, target);
|
||||
temporary = true;
|
||||
}
|
||||
// pathToFileURL handles Windows drive letters and spaces correctly.
|
||||
let imported = import(pathToFileURL(target).href) as Promise<Record<string, unknown>>;
|
||||
if (temporary) {
|
||||
imported = imported.finally(() => {
|
||||
try {
|
||||
unlinkSync(target);
|
||||
} catch {
|
||||
/* best-effort cleanup after Bun has loaded the module */
|
||||
}
|
||||
});
|
||||
}
|
||||
return imported;
|
||||
})();
|
||||
moduleCache.set(file, mod);
|
||||
}
|
||||
return mod;
|
||||
@@ -228,7 +305,18 @@ function validateConfiguredImports(source: string, ast: PageAst, file: string):
|
||||
const usedComponents = new Set(
|
||||
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
|
||||
);
|
||||
const missing = [...usedComponents].filter((name) => !imported.has(name));
|
||||
const compilerBuiltins = new Set([
|
||||
"Async",
|
||||
"Component",
|
||||
"Error",
|
||||
"Loading",
|
||||
"Portal",
|
||||
"Success",
|
||||
"Transition",
|
||||
]);
|
||||
const missing = [...usedComponents].filter(
|
||||
(name) => !compilerBuiltins.has(name) && !imported.has(name),
|
||||
);
|
||||
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
|
||||
if (!missing.length) return;
|
||||
const unique = [...new Set(missing)];
|
||||
@@ -250,6 +338,98 @@ export interface WrnCompileArtifacts {
|
||||
rpc: string;
|
||||
}
|
||||
|
||||
export interface WrnCompileMetrics {
|
||||
hits: number;
|
||||
misses: number;
|
||||
compilations: number;
|
||||
errors: number;
|
||||
totalDurationMs: number;
|
||||
lastDurationMs: number;
|
||||
}
|
||||
|
||||
const compileMetrics: WrnCompileMetrics = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
compilations: 0,
|
||||
errors: 0,
|
||||
totalDurationMs: 0,
|
||||
lastDurationMs: 0,
|
||||
};
|
||||
const asyncCompileInProgress = new Map<string, Promise<WrnCompileArtifacts>>();
|
||||
|
||||
export function compileWireArtifactsAsync(file: string, version = 0): Promise<WrnCompileArtifacts> {
|
||||
if (!devCompilerPipeline) return Promise.resolve(compileWireArtifacts(file, version));
|
||||
const key = `${file}:${version}`;
|
||||
const active = asyncCompileInProgress.get(key);
|
||||
if (active) return active;
|
||||
const task = (async () => {
|
||||
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const source = readFileSync(file, "utf8");
|
||||
// Plugin output affects the artifact, so use a separate cache generation.
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}${suffix}`;
|
||||
const artifacts: WrnCompileArtifacts = {
|
||||
main: join(cacheDir, `${stem}.wrn.ts`),
|
||||
browser: join(cacheDir, `${stem}.client.mjs`),
|
||||
server: join(cacheDir, `${stem}.server.ts`),
|
||||
declarations: join(cacheDir, `${stem}.d.ts`),
|
||||
contract: join(cacheDir, `${stem}.contract.json`),
|
||||
rpc: join(cacheDir, `${stem}.rpc.json`),
|
||||
};
|
||||
const result = compile(source, file);
|
||||
validateConfiguredImports(source, result.ast, file);
|
||||
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
||||
const targets = generateTargets(ast);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||
const outputs = {
|
||||
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
browserPath,
|
||||
),
|
||||
browser: targets.browser,
|
||||
server: targets.server,
|
||||
declarations: targets.declarations,
|
||||
};
|
||||
for (const target of ["main", "browser", "server"] as const) {
|
||||
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
||||
writeFileSync(
|
||||
artifacts[target],
|
||||
await devCompilerPipeline!.transformCode(rewritten, file),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
artifacts.declarations,
|
||||
await devCompilerPipeline!.transformCode(outputs.declarations, file),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
||||
compileMetrics.compilations++;
|
||||
return artifacts;
|
||||
})().finally(() => asyncCompileInProgress.delete(key));
|
||||
asyncCompileInProgress.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export function getWrnCompileMetrics(): Readonly<WrnCompileMetrics> {
|
||||
return { ...compileMetrics };
|
||||
}
|
||||
|
||||
export function resetWrnCompileMetrics(): void {
|
||||
Object.assign(compileMetrics, {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
compilations: 0,
|
||||
errors: 0,
|
||||
totalDurationMs: 0,
|
||||
lastDurationMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts {
|
||||
const active = compileInProgress.get(file);
|
||||
if (active) return active;
|
||||
@@ -270,39 +450,52 @@ export function compileWireArtifacts(file: string, version = 0): WrnCompileArtif
|
||||
try {
|
||||
try {
|
||||
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
||||
compileMetrics.hits++;
|
||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||
return artifacts;
|
||||
}
|
||||
} catch {
|
||||
// Compile missing artifact set below.
|
||||
}
|
||||
const result = compile(source, file);
|
||||
validateConfiguredImports(source, result.ast, file);
|
||||
const targets = generateTargets(result.ast);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||
const mainCode = rewriteArtifactImports(
|
||||
result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath),
|
||||
result.ast,
|
||||
file,
|
||||
"main",
|
||||
);
|
||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||
writeFileSync(
|
||||
artifacts.browser,
|
||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
||||
"utf8",
|
||||
);
|
||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
||||
writeFileSync(
|
||||
artifacts.server,
|
||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(artifacts.declarations, targets.declarations, "utf8");
|
||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||
return artifacts;
|
||||
compileMetrics.misses++;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const result = compile(source, file);
|
||||
validateConfiguredImports(source, result.ast, file);
|
||||
const targets = generateTargets(result.ast);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
||||
const mainCode = rewriteArtifactImports(
|
||||
result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath),
|
||||
result.ast,
|
||||
file,
|
||||
"main",
|
||||
);
|
||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||
writeFileSync(
|
||||
artifacts.browser,
|
||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
||||
"utf8",
|
||||
);
|
||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
||||
writeFileSync(
|
||||
artifacts.server,
|
||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(artifacts.declarations, targets.declarations, "utf8");
|
||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||
compileMetrics.compilations++;
|
||||
return artifacts;
|
||||
} catch (error) {
|
||||
compileMetrics.errors++;
|
||||
throw error;
|
||||
} finally {
|
||||
const duration = performance.now() - started;
|
||||
compileMetrics.lastDurationMs = duration;
|
||||
compileMetrics.totalDurationMs += duration;
|
||||
}
|
||||
} finally {
|
||||
compileInProgress.delete(file);
|
||||
}
|
||||
|
||||
@@ -153,5 +153,12 @@ export function mergePluginAssets(
|
||||
routes: [],
|
||||
middleware: [],
|
||||
migrations: [],
|
||||
directives: [],
|
||||
cliCommands: [],
|
||||
virtualModules: [],
|
||||
deploymentAdapters: [],
|
||||
configSchemas: [],
|
||||
documentation: [],
|
||||
typeDefinitions: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* `wrnexus build` generates an entry that statically imports every route and
|
||||
* component module and hands them here as a manifest. We rebuild the (cheap)
|
||||
* route-matching tables from the raw patterns and run the exact same request
|
||||
* runtime as dev — just with production error pages and no live-reload client.
|
||||
* runtime as dev. Normal preview/deploy output has no live-reload client; the
|
||||
* supervised `dev --production-runtime` mode can explicitly enable it.
|
||||
*/
|
||||
|
||||
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
|
||||
@@ -16,7 +17,12 @@ import {
|
||||
type Route,
|
||||
type Router,
|
||||
} from "@wrnexus/router";
|
||||
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
|
||||
import {
|
||||
getActionRuntime,
|
||||
getReactiveRuntime,
|
||||
getNavRuntime,
|
||||
getRealtimeRuntime,
|
||||
} from "@wrnexus/csr";
|
||||
import {
|
||||
loadEnv,
|
||||
resolveProfile,
|
||||
@@ -43,6 +49,7 @@ import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
import { HmrHub } from "./hmr.ts";
|
||||
|
||||
type RouteModule = Record<string, unknown>;
|
||||
|
||||
@@ -51,6 +58,8 @@ export interface ManifestRoute {
|
||||
raw: string;
|
||||
/** The statically-imported route module. */
|
||||
mod: RouteModule;
|
||||
/** Body shell precomputed by `wrnexus build` for a partial-static page. */
|
||||
staticShell?: string;
|
||||
}
|
||||
|
||||
export interface ProdManifest {
|
||||
@@ -139,6 +148,8 @@ export interface ProdOptions {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
maxBodyBytes?: number;
|
||||
/** Enable only for the CLI's supervised exact-production development mode. */
|
||||
developmentRuntime?: boolean;
|
||||
}
|
||||
|
||||
const MODE: Mode = "production";
|
||||
@@ -181,7 +192,10 @@ function buildProdRouter(manifest: ProdManifest): {
|
||||
const routes = entries.map((e): Route => {
|
||||
const { regex, paramNames } = compileRoutePattern(e.raw);
|
||||
// Use the raw pattern as a stable module key.
|
||||
modules.set(e.raw, e.mod);
|
||||
modules.set(
|
||||
e.raw,
|
||||
e.staticShell === undefined ? e.mod : { ...e.mod, __wrnexusStaticShell: e.staticShell },
|
||||
);
|
||||
return { raw: e.raw, file: e.raw, regex, paramNames };
|
||||
});
|
||||
return sortRoutes(routes);
|
||||
@@ -236,6 +250,8 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
|
||||
return new Response(getNavRuntime(), { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/realtime.js")
|
||||
return new Response(getRealtimeRuntime(), { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/actions.js")
|
||||
return new Response(getActionRuntime(), { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/validate.js")
|
||||
return new Response(VALIDATE_RUNTIME, { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/i18n.js")
|
||||
@@ -308,9 +324,11 @@ export function createProductionHandlers(
|
||||
return mod;
|
||||
};
|
||||
|
||||
const productionHmr = opts.developmentRuntime === true;
|
||||
const handlers = createHandlers({
|
||||
mode: MODE,
|
||||
hmr: false,
|
||||
hmr: productionHmr,
|
||||
hub: productionHmr ? new HmrHub() : undefined,
|
||||
router,
|
||||
loadModule,
|
||||
getMiddleware,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
* the supervisor delivers live reload of edited server code.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { Mode } from "@wrnexus/core";
|
||||
import { findStyleEntry, headToString, loadAppConfig, renderFontHead } from "@wrnexus/styles";
|
||||
import { startServer } from "./index.ts";
|
||||
|
||||
const [appDir, portStr, modeStr, hostname, hmrStr] = process.argv.slice(2);
|
||||
const [appDir, portStr, modeStr, hostname, hmrStr, certFile, keyFile] = process.argv.slice(2);
|
||||
|
||||
const mode = (modeStr as Mode) || "development";
|
||||
|
||||
@@ -38,6 +39,10 @@ const server = await startServer({
|
||||
|
||||
port,
|
||||
hostname,
|
||||
tls:
|
||||
certFile && keyFile
|
||||
? { cert: readFileSync(certFile, "utf8"), key: readFileSync(keyFile, "utf8") }
|
||||
: undefined,
|
||||
mode,
|
||||
hmr: hmrStr === undefined ? undefined : hmrStr === "true",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user