release: WRNexusJS 0.6.0
This commit is contained in:
@@ -25,6 +25,7 @@ import type { Mode } from "@wrnexus/core";
|
||||
import type { AssetServer } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||
import { serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||
|
||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||
export interface DevStyles {
|
||||
@@ -84,6 +85,9 @@ export function createDevAssetServer(
|
||||
},
|
||||
|
||||
async serve(pathname: string): Promise<Response | null> {
|
||||
if (pathname.startsWith("/__wrnexus/client/")) {
|
||||
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime());
|
||||
if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime());
|
||||
if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime());
|
||||
|
||||
@@ -32,7 +32,15 @@ import {
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
|
||||
import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
import { invalidateModule, loadModule, setCompileCacheDir } from "./pipeline.ts";
|
||||
import {
|
||||
invalidateModule,
|
||||
loadModule,
|
||||
loadWrnServerModule,
|
||||
setCompileCacheDir,
|
||||
setCompileImportOptions,
|
||||
wrnBrowserArtifactUrl,
|
||||
} from "./pipeline.ts";
|
||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||
import { createHandlers, type WsData } from "./runtime.ts";
|
||||
import { createDevAssetServer } from "./assets.ts";
|
||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||
@@ -173,6 +181,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const appDir = resolve(opts.appDir);
|
||||
const appRoot = dirname(appDir);
|
||||
const mode: Mode = opts.mode ?? "development";
|
||||
const importConfig = (opts.appConfig?.imports ?? {}) as {
|
||||
mode?: "legacy" | "compatible" | "explicit";
|
||||
autoImport?: boolean;
|
||||
aliases?: Record<string, string>;
|
||||
};
|
||||
setCompileImportOptions(appRoot, importConfig);
|
||||
const configuredPlugins = opts.plugins ?? (opts.appConfig?.plugins as PluginInput | undefined);
|
||||
|
||||
const discoveredPlugins = await discoverPlugins(appRoot, configuredPlugins, {
|
||||
@@ -384,8 +398,33 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
config: devToolbarConfig,
|
||||
collector: devToolbarCollector,
|
||||
root: appRoot,
|
||||
panels: pluginToolbarPanels,
|
||||
panels: [
|
||||
{
|
||||
id: "runtime",
|
||||
title: "Runtime",
|
||||
icon: "cpu",
|
||||
description:
|
||||
"Client, server, and shared functions, hydration modules, and runtime-boundary diagnostics.",
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "stores",
|
||||
title: "Stores",
|
||||
icon: "database",
|
||||
description:
|
||||
"Global/page stores, safe client state, computed values, actions, persistence, and hydration.",
|
||||
order: 20,
|
||||
},
|
||||
...pluginToolbarPanels,
|
||||
],
|
||||
platform: {
|
||||
wrnexus060: {
|
||||
clientFunctions: true,
|
||||
serverFunctions: true,
|
||||
sharedFunctions: true,
|
||||
typedOutputs: true,
|
||||
requestScopedStores: true,
|
||||
},
|
||||
plugins: pluginRunner.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
@@ -413,13 +452,59 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
: undefined,
|
||||
};
|
||||
const handlers = createHandlers(runtimeDeps);
|
||||
const rpcHandler = createRpcHandler({
|
||||
async resolve(rawComponent) {
|
||||
const componentName = rawComponent.split(":", 1)[0] ?? rawComponent;
|
||||
const preferred = router.components.find(
|
||||
(entry) => entry.name.toLowerCase() === componentName.toLowerCase(),
|
||||
);
|
||||
const candidates = [
|
||||
...(preferred ? [preferred.file] : []),
|
||||
...router.pages.filter((route) => route.file.endsWith(".wrn")).map((route) => route.file),
|
||||
...router.layouts.map((entry) => entry.file),
|
||||
...router.stores.map((entry) => entry.file),
|
||||
...router.components.filter((entry) => entry !== preferred).map((entry) => entry.file),
|
||||
];
|
||||
for (const file of new Set(candidates)) {
|
||||
const module = await loadWrnServerModule(file);
|
||||
const functions = module.__wrnexusServerFunctions;
|
||||
const manifest = module.__wrnexusRpcManifest;
|
||||
if (!functions || typeof functions !== "object" || !Array.isArray(manifest)) continue;
|
||||
const ownsComponent = manifest.some(
|
||||
(entry: any) =>
|
||||
String(entry?.component ?? "").toLowerCase() === componentName.toLowerCase(),
|
||||
);
|
||||
if (!ownsComponent) continue;
|
||||
return {
|
||||
functions: functions as Record<string, (...args: any[]) => any>,
|
||||
manifest: manifest as any,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
validateCsrf(request) {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && origin !== url.origin) return false;
|
||||
const cookieToken = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(
|
||||
request.headers.get("cookie") ?? "",
|
||||
)?.[1];
|
||||
return (
|
||||
!cookieToken ||
|
||||
decodeURIComponent(cookieToken) === (request.headers.get("x-wrnexus-csrf") ?? "")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const server = Bun.serve<WsData>({
|
||||
port,
|
||||
hostname,
|
||||
development: mode === "development",
|
||||
maxRequestBodySize: 10 * 1024 * 1024,
|
||||
fetch: handlers.fetch,
|
||||
fetch(request, server) {
|
||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||
return handlers.fetch(request, server);
|
||||
},
|
||||
websocket: handlers.websocket,
|
||||
});
|
||||
|
||||
@@ -473,7 +558,40 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
}
|
||||
|
||||
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
|
||||
hub.reload();
|
||||
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
|
||||
for (const changed of files) {
|
||||
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
||||
if (!absolute.endsWith(".wrn")) continue;
|
||||
try {
|
||||
const source = readFileSync(absolute, "utf8");
|
||||
const declaration = /\b(global|page)\s+store\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source);
|
||||
if (!declaration) continue;
|
||||
storeUpdates.push({
|
||||
name: declaration[2]!,
|
||||
kind: declaration[1]!,
|
||||
url: wrnBrowserArtifactUrl(absolute),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[wrnexus] failed to prepare store HMR for ${absolute}`, error);
|
||||
}
|
||||
}
|
||||
if (storeUpdates.length) {
|
||||
hub.broadcastJson({ type: "store-update", version: Date.now(), stores: storeUpdates });
|
||||
}
|
||||
const onlyStores =
|
||||
storeUpdates.length > 0 &&
|
||||
files.every((changed) => {
|
||||
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
|
||||
if (!absolute.endsWith(".wrn")) return false;
|
||||
try {
|
||||
return /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(
|
||||
readFileSync(absolute, "utf8"),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!onlyStores) hub.reload();
|
||||
hub.broadcastJson({
|
||||
channel: "toolbar",
|
||||
type: "toolbar:scan",
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
mkdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname } from "node:path";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import { dirname, join, basename, extname, resolve } from "node:path";
|
||||
import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler";
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
/**
|
||||
@@ -48,13 +49,105 @@ export function runMiddleware(
|
||||
*/
|
||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||
const moduleVersions = new Map<string, number>();
|
||||
const browserArtifactPaths = new Map<string, string>();
|
||||
|
||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||
interface CompileImportOptions {
|
||||
mode: ImportMode;
|
||||
aliases: Record<string, string>;
|
||||
autoImport: boolean;
|
||||
}
|
||||
const compileImportOptions = new Map<string, CompileImportOptions>();
|
||||
const warnedImportDiagnostics = new Set<string>();
|
||||
|
||||
export function setCompileImportOptions(
|
||||
appRoot: string,
|
||||
options: { mode?: ImportMode; aliases?: Record<string, string>; autoImport?: boolean } = {},
|
||||
): void {
|
||||
compileImportOptions.set(resolve(appRoot), {
|
||||
mode: options.mode ?? "compatible",
|
||||
aliases: { "@": "./app", ...(options.aliases ?? {}) },
|
||||
autoImport: options.autoImport ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const compileInProgress = new Map<string, WrnCompileArtifacts>();
|
||||
|
||||
function projectRootForFile(file: string): string {
|
||||
let current = dirname(resolve(file));
|
||||
while (true) {
|
||||
if (existsSync(join(current, "app"))) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return dirname(resolve(file));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteArtifactImports(
|
||||
code: string,
|
||||
ast: PageAst,
|
||||
importer: string,
|
||||
target: "main" | "server" | "browser",
|
||||
): string {
|
||||
if (!ast.structuredImports.length) return code;
|
||||
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,
|
||||
});
|
||||
let output = code;
|
||||
for (const entry of resolved) {
|
||||
if (entry.diagnostic) {
|
||||
const key = `${importer}:${entry.diagnostic.code}:${entry.declaration.source}`;
|
||||
if (entry.diagnostic.severity === "error") {
|
||||
throw new Error(`${entry.diagnostic.code}: ${entry.diagnostic.message}`);
|
||||
}
|
||||
if (!warnedImportDiagnostics.has(key)) {
|
||||
warnedImportDiagnostics.add(key);
|
||||
console.warn(`[wrnexus] ${entry.diagnostic.code}: ${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 (entry.resolved.endsWith(".wrn")) {
|
||||
const dependencySource = readFileSync(entry.resolved, "utf8");
|
||||
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
|
||||
const dependency = compileWireArtifacts(
|
||||
entry.resolved,
|
||||
moduleVersions.get(entry.resolved) ?? 0,
|
||||
);
|
||||
if (target === "browser") {
|
||||
if (!isStore) {
|
||||
// Components and layouts are compile-time dependencies in browser modules.
|
||||
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 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") ? compileWireToTs(file, version) : file;
|
||||
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
|
||||
@@ -116,31 +209,131 @@ function hashPath(s: string): string {
|
||||
* components) share one cache dir without colliding. Generated modules are
|
||||
* self-contained (no relative imports), so the cache location doesn't affect them.
|
||||
*/
|
||||
function compileWireToTs(file: string, version = 0): string {
|
||||
function importedValueBindings(ast: PageAst): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const entry of ast.structuredImports) {
|
||||
if (entry.typeOnly) continue;
|
||||
if (entry.defaultImport) names.add(entry.defaultImport);
|
||||
if (entry.namespaceImport) names.add(entry.namespaceImport);
|
||||
for (const item of entry.namedImports) if (!item.typeOnly) names.add(item.local);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function validateConfiguredImports(source: string, ast: PageAst, file: string): void {
|
||||
const root = projectRootForFile(file);
|
||||
const options = compileImportOptions.get(resolve(root));
|
||||
if (!options || options.mode === "legacy") return;
|
||||
const imported = importedValueBindings(ast);
|
||||
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));
|
||||
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
|
||||
if (!missing.length) return;
|
||||
const unique = [...new Set(missing)];
|
||||
const message = `WRN-IMPORT-IMPLICIT: ${file} uses ${unique.join(", ")} without explicit imports.`;
|
||||
if (options.mode === "explicit") throw new Error(message);
|
||||
const key = `${file}:WRN-IMPORT-IMPLICIT:${unique.join(",")}`;
|
||||
if (!warnedImportDiagnostics.has(key)) {
|
||||
warnedImportDiagnostics.add(key);
|
||||
console.warn(`[wrnexus] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WrnCompileArtifacts {
|
||||
main: string;
|
||||
browser: string;
|
||||
server: string;
|
||||
declarations: string;
|
||||
contract: string;
|
||||
rpc: string;
|
||||
}
|
||||
|
||||
export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts {
|
||||
const active = compileInProgress.get(file);
|
||||
if (active) return active;
|
||||
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
||||
const name = basename(file).replace(/\.wrn$/, "");
|
||||
const suffix = version ? `-hmr-${version}` : "";
|
||||
const source = readFileSync(file, "utf8");
|
||||
// Include the source contents in the cache identity. Package managers, git
|
||||
// checkouts, archive extraction, and linked dependencies can all replace a
|
||||
// file while preserving (or moving backwards) its mtime. An mtime-only cache
|
||||
// then serves an older compiled component even across a clean build.
|
||||
const out = join(
|
||||
cacheDir,
|
||||
`${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}.wrn.ts`,
|
||||
);
|
||||
|
||||
// The content hash makes this safe even when source timestamps are preserved.
|
||||
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${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`),
|
||||
};
|
||||
compileInProgress.set(file, artifacts);
|
||||
try {
|
||||
if (statSync(out).isFile()) return out;
|
||||
} catch {
|
||||
/* cache missing → compile below */
|
||||
try {
|
||||
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
||||
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;
|
||||
} finally {
|
||||
compileInProgress.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
const code = compileWireFile(source, file);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(out, code, "utf8");
|
||||
return out;
|
||||
export async function loadWrnServerModule(file: string): Promise<Record<string, unknown>> {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
const artifact = compileWireArtifacts(file, version).server;
|
||||
return import(pathToFileURL(artifact).href) as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function wrnBrowserArtifact(file: string): string {
|
||||
return compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
||||
}
|
||||
|
||||
export function wrnBrowserArtifactUrl(file: string): string {
|
||||
const artifact = compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
||||
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
}
|
||||
|
||||
export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
||||
const artifact = browserArtifactPaths.get(pathname);
|
||||
if (!artifact || !existsSync(artifact)) return null;
|
||||
return new Response(readFileSync(artifact, "utf8"), {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
||||
|
||||
@@ -203,6 +203,7 @@ function buildProdRouter(manifest: ProdManifest): {
|
||||
middlewareFiles: [],
|
||||
components: manifest.components.map((c) => ({ name: c.name, file: c.name })),
|
||||
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
|
||||
stores: [],
|
||||
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
|
||||
matchPage: (p) => matchRoute(pages, p),
|
||||
matchApi: (p) => matchRoute(api, p),
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
} from "@wrnexus/core";
|
||||
import type { Router } from "@wrnexus/router";
|
||||
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
|
||||
import {
|
||||
disposeRequestStores,
|
||||
renderStoreHydration,
|
||||
requestStoreContainer,
|
||||
} from "@wrnexus/ssr/store-context";
|
||||
import type { StoreDefinition } from "@wrnexus/store";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
|
||||
import {
|
||||
@@ -549,6 +555,8 @@ export const HMR_CLIENT_JS = `
|
||||
} else if (msg.type === "css") {
|
||||
swapCss(msg.version);
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: msg }));
|
||||
} else if (msg.type === "store-update") {
|
||||
applyStoreUpdates(msg);
|
||||
} else if (msg.type === "reload") requestSync();
|
||||
else if (msg.type === "html") applyHtml(msg.html);
|
||||
else if (msg.type === "error") console.error("[wrnexus] HMR update failed:", msg.message);
|
||||
@@ -563,6 +571,28 @@ export const HMR_CLIENT_JS = `
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
async function applyStoreUpdates(message) {
|
||||
var updates = Array.isArray(message.stores) ? message.stores : [];
|
||||
for (var i = 0; i < updates.length; i++) {
|
||||
var update = updates[i];
|
||||
try {
|
||||
var separator = String(update.url).indexOf("?") >= 0 ? "&" : "?";
|
||||
var module = await import(String(update.url) + separator + "hmr=" + encodeURIComponent(String(message.version || Date.now())));
|
||||
var definition = module[String(update.name) + "Definition"];
|
||||
if (!definition) throw new Error("Generated store module did not export its definition");
|
||||
if (typeof window.__wrnexusApplyStoreHotUpdate === "function") {
|
||||
var result = await window.__wrnexusApplyStoreHotUpdate(String(update.name), definition);
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:hmr-store-updated", { detail: { update: update, result: result } }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] store HMR update failed", update, error);
|
||||
requestSync();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestSync() {
|
||||
if (pendingSync) return;
|
||||
pendingSync = true;
|
||||
@@ -1311,6 +1341,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
// Issue the CSRF token cookie so forms on this page can echo it back.
|
||||
csrfToken(ctx);
|
||||
const storeContainer = requestStoreContainer(ctx.req, matched.route.raw);
|
||||
|
||||
const mod = await loadModule(matched.route.file);
|
||||
const component = mod.default;
|
||||
@@ -1322,8 +1353,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
const meta = (mod.meta ?? {}) as PageMeta;
|
||||
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 resolvedTheme = deps.theme
|
||||
? resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)
|
||||
@@ -1334,15 +1367,24 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
// Page layout: a page selects one by exporting `layout = "<name>"`
|
||||
// (app/layouts/<name>.wrn), else falls back to a `default` layout if one
|
||||
// exists. `layout = "none"` opts out. The layout wraps the body via <slot>.
|
||||
const importedLayout =
|
||||
mod.layout && typeof mod.layout === "object"
|
||||
? (mod.layout as { name?: string; render?: (props: Record<string, unknown>) => string })
|
||||
: undefined;
|
||||
const layoutName =
|
||||
(isMobileRequest && deps.mobile?.layout
|
||||
? deps.mobile.layout
|
||||
: typeof mod.layout === "string"
|
||||
? mod.layout
|
||||
: undefined) ?? "default";
|
||||
const layout =
|
||||
layoutName === "none" ? undefined : router.layouts.find((l) => l.name === layoutName);
|
||||
if (layout) {
|
||||
: importedLayout?.name) ?? "default";
|
||||
const layout = importedLayout
|
||||
? undefined
|
||||
: layoutName === "none"
|
||||
? undefined
|
||||
: router.layouts.find((l) => l.name === layoutName);
|
||||
if (importedLayout?.render) {
|
||||
body = await renderComponents(fillSlots(String(importedLayout.render({})), body), ctx.t);
|
||||
} else if (layout) {
|
||||
try {
|
||||
const layoutMod = await loadModule(layout.file);
|
||||
const layoutRender = (layoutMod as { render?: (p: Record<string, string>) => string })
|
||||
@@ -1451,6 +1493,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
.join("\n "),
|
||||
extraBody:
|
||||
[
|
||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||
]
|
||||
@@ -1464,6 +1507,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
// 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}`);
|
||||
await disposeRequestStores(ctx.req);
|
||||
const method = ctx.req.method.toUpperCase();
|
||||
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
|
||||
return new Response(null, {
|
||||
|
||||
Reference in New Issue
Block a user