release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+4
View File
@@ -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());
+122 -4
View File
@@ -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",
+214 -21
View File
@@ -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. */
+1
View File
@@ -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),
+48 -4
View File
@@ -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, {