Files
WRNexusJS/packages/dev-server/src/index.ts
T
ClintchizandClaude Opus 5 eeef2d79df fix(dev-server,security): repair two defects that only appear in a published build
The dev server shipped two entries, index and serve-entry, bundled
independently because the publish build set splitting:false. They share
pipeline.ts, which holds mutable module state -- compileCacheDir, set once
at startup by the bootstrap, and browserArtifactPaths, populated during
compilation and read when serving /__wrnexus/client/*. Duplicating the
module duplicated the state, so the writer and the reader addressed
different copies: every component client module 404'd and .wrn compilation
wrote nothing. It works from source, where there is one module instance,
which is why it reached a release. Emitting a shared chunk fixes it for
every package at once.

resetDevCache also ran several hundred lines after the plugin virtual
modules were written into the same directory, deleting them at every boot.
An app with no plugins never noticed; an app with one lost them every time.

Separately, secureCookieOptions spread ...options after its path default,
and setSecureCookie always forwards an explicit path key -- so omitting
path emitted a cookie with no Path at all, which the browser then scoped to
the request's directory.

Verified end to end against a real app installing the published packages:
17 artifacts written, client modules 200, and the sign-in form submits from
the UI and reaches /dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:04:12 +05:30

845 lines
30 KiB
TypeScript

/**
* @wrnexus/dev-server — the development HTTP + WebSocket server.
*
* Thin Bun.serve wrapper around the shared runtime (runtime.ts). Dynamic module
* loading and targeted cache invalidation keep page/component/API edits inside
* the running process while the HMR socket morphs fresh HTML into the browser.
*/
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve, dirname, isAbsolute, join } from "node:path";
import { type Middleware, type Mode, type SecurityConfig, type SeoConfig } from "@wrnexus/core";
import { buildRouter, type Router } from "@wrnexus/router";
import {
resolveThemeConfig,
resolveBrowserCookieOptions,
type BrowserCookiesConfig,
type StylesConfig,
type ThemeConfig,
type MobileConfig,
type PwaConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui/registry";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
import {
applyMigrations,
loadMigrations,
migrate,
setDb,
registerDb,
registerLazyDb,
getDbPerformanceSnapshot,
} from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
import { setAuthzCatalog, type AuthzCatalog, type AuthzModule } from "@wrnexus/authz";
import { loadAppAuthzCatalog } from "./authz-boot.ts";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import {
invalidateModule,
loadModule,
loadWrnServerModule,
setCompileCacheDir,
setCompileImportOptions,
rebuildChangedIslands,
setDevCompilerPipeline,
wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts";
import { createRpcHandler } from "@wrnexus/ssr/rpc";
import { createRecycleMonitor } from "./recycle.ts";
import { RESTART_EXIT_CODE } from "./restart.ts";
/*
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
* session reaches, and far less than what makes the server crawl. Set
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
*/
const RECYCLE_REBUILD_THRESHOLD = (() => {
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
})();
/** Quiet period required first, so a recycle never interrupts a live request. */
const RECYCLE_IDLE_MS = 10_000;
const RECYCLE_CHECK_MS = 5_000;
import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts";
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
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";
// Shared with prod.ts — kept in their own module to avoid a circular import
// (index.ts re-exports createProductionServer/createProductionHandlers from
// prod.ts, so prod.ts cannot import these back from index.ts).
import { validateRpcCsrf, withServerFnRequestContext } from "./rpc-shared.ts";
export { validateRpcCsrf, withServerFnRequestContext };
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
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;
appConfig?: Record<string, unknown>;
/** Resolved absolute path to the global CSS entry, or null. */
styleEntry?: string | null;
/** Custom styles config (e.g. a Tailwind/PostCSS processor). */
stylesConfig?: StylesConfig;
/** Raw HTML appended to every page head (from wrnexus.config.ts). */
head?: string;
/** Global SEO defaults. */
seo?: SeoConfig;
/** Framework security headers and CORS policy. */
security?: SecurityConfig;
/** Design-token theme config (merged over the built-in light/dark). */
theme?: ThemeConfig;
/** Shared browser-cookie defaults and preference-specific overrides. */
cookies?: BrowserCookiesConfig;
/** i18n config (default language + supported locales). */
i18n?: I18nConfig;
/** Default database connection (driver + url). Enables `getDb()` and dev auto-migrate. */
db?: { driver: string; url: string };
/** Named databases, reached with `getDb("<name>")`; migrations under app/db/<name>/. */
databases?: Record<string, { driver: string; url: string }>;
/** Realtime scaling: bridge room broadcasts over Redis across app processes. */
realtime?: { scale?: boolean; redisUrl?: string };
/** File-upload storage: named stores (local dir / S3), reached with `getStore()`. */
storage?: StorageConfig;
mobile?: MobileConfig;
pwa?: PwaConfig | false;
devToolbar?: boolean | DevToolbarConfig;
plugins?: PluginInput;
observability?: ObservabilityConfig;
tenancy?: TenancyConfig;
navigation?: NavigationConfig;
}
export interface RunningServer {
port: number;
hostname: string;
url: string;
router: Router;
stop(): void;
}
/** Build an invalidatable middleware loader for in-process hot updates. */
function middlewareLoader(router: Router): {
load: () => Promise<Middleware[]>;
invalidate: () => void;
} {
let cache: Middleware[] | null = null;
return {
async load() {
if (cache) return cache;
const out: Middleware[] = [];
for (const file of router.middlewareFiles) {
const mod = await loadModule(file);
if (typeof mod.default === "function") out.push(mod.default as Middleware);
else console.warn(`[wrnexus] middleware ${file} has no default export; skipped`);
}
cache = out;
return out;
},
invalidate() {
cache = null;
},
};
}
async function schemaRuntime(router: Router): Promise<string> {
const descriptors: Record<string, SchemaDescriptor> = {};
for (const schemaRef of router.schemas) {
try {
const mod = await loadModule(schemaRef.file);
const schema = mod.default as ObjectSchema | undefined;
if (schema && typeof schema.describe === "function") {
descriptors[schemaRef.name] = schema.describe();
}
} catch (error) {
console.warn(`[wrnexus] schema '${schemaRef.name}' failed to load`, error);
}
}
return renderSchemasScript(descriptors);
}
function resolveDevToolbarConfig(
mode: string,
value: ServeOptions["devToolbar"],
): DevToolbarConfig | null {
if (mode !== "development" || value === false || value === undefined) {
return null;
}
if (value === true) {
return {
enabled: true,
position: "bottom-center",
defaultOpen: false,
scanOnNavigation: true,
scanOnHmr: true,
openEditor: true,
};
}
if (value.enabled === false) {
return null;
}
return {
enabled: true,
position: "bottom-center",
defaultOpen: false,
scanOnNavigation: true,
scanOnHmr: true,
openEditor: true,
...value,
};
}
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, {
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, {
root: appRoot,
mode,
command: "dev",
metadata: new Map(),
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
/**
* Plugins must receive the complete
* wrnexus.config.ts object.
*
* ServeOptions only contains framework-known
* properties. Package-specific configuration,
* such as `auth`, is stored in appConfig.
*/
const pluginConfig: Record<string, unknown> = {
...(opts.appConfig ?? {}),
// Resolved runtime values take priority.
appDir,
port: opts.port,
hostname: opts.hostname,
mode,
hmr: opts.hmr,
styleEntry: opts.styleEntry,
styles: opts.stylesConfig ?? (opts.appConfig?.styles as StylesConfig | undefined),
head: opts.head,
seo: opts.seo,
security: opts.security,
theme: opts.theme,
i18n: opts.i18n,
db: opts.db,
databases: opts.databases,
realtime: opts.realtime,
storage: opts.storage,
mobile: opts.mobile,
pwa: opts.pwa,
devToolbar: opts.devToolbar,
plugins: configuredPlugins,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
};
await pluginRunner.configure(pluginConfig);
await pluginRunner.configResolved(
Object.freeze({
...pluginConfig,
}),
);
const pluginContributions = await pluginRunner.contributions();
const virtualModules = new Map<string, string>();
// Keep generated artifacts under the single framework state directory.
// Startup clears this cache and removes legacy PID-suffixed cache folders.
const cacheDir = join(appRoot, ".wrnexus", "cache");
// Clear the cache BEFORE anything writes into it. This used to run several
// hundred lines below, which deleted the plugin virtual modules written just
// after this point -- so an app with a plugin (auth, say) lost its generated
// modules at every boot, while an app with none never noticed.
resetDevCache({
rootDir: appRoot,
cacheDir,
enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1",
});
const virtualDir = join(cacheDir, "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: ${
pluginRunner.plugins.map((plugin) => plugin.name).join(", ") || "none"
}`,
);
console.log(`[wrnexus:plugin] contributed routes: ${pluginContributions.routes.length}`);
const pluginToolbarPanels = await pluginRunner.devToolbarPanels();
const bundledUiDir = uiComponentsDir();
const componentDirs = [
bundledUiDir,
join(bundledUiDir, "..", "styles"),
...pluginContributions.componentDirs,
];
const hmr = opts.hmr ?? mode === "development";
const port = opts.port ?? 3000;
const hostname = opts.hostname ?? "::";
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const router = buildRouter(appDir, {
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
});
const styleEntry = opts.styleEntry ?? null;
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
const devToolbarCollector: DevToolbarCollector | undefined = devToolbarConfig
? createDevToolbarCollector()
: undefined;
// Compile every `.wrn` into ONE cache dir at the project root, instead of a
// `.wrnexus/` next to each source file (and inside node_modules UI dirs).
setCompileCacheDir(cacheDir);
const theme = resolveThemeConfig(opts.theme, opts.cookies);
const uiStylesPath = uiCssPath();
const schemasJs = await schemaRuntime(router);
// Authorization: load and merge every app/authz/*.ts declaration, then stash
// it in the process-wide registry BEFORE middleware is resolved. App
// middleware (which registers authzMiddleware itself, with its own store —
// the framework never installs one) runs at request time and needs
// getAuthzCatalog() already populated by then. An app with no declarations
// gets an empty catalog; a genuine conflict between declarations throws and
// fails this boot loudly. Pass the already-built `router` (not `appDir`):
// it was just built above with the full componentDirs/externalRoutes/
// middlewareFiles options, so this avoids a second, redundant filesystem
// scan of the whole app/ tree on every dev boot.
const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(router);
setAuthzCatalog(authzCatalog);
// i18n is opt-in by the presence of app/locales/*.json.
const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict });
const i18nConfig = opts.i18n
? {
...opts.i18n,
cookie: {
...resolveBrowserCookieOptions(opts.cookies, "language"),
...(opts.i18n.cookie ?? {}),
},
}
: undefined;
const i18n = Object.keys(localeMessages).length
? resolveI18n(localeMessages, i18nConfig)
: undefined;
// Databases: configure the default (getDb()) + each named one (getDb("<name>")),
// and auto-migrate in dev so schemas are ready. The default's migrations live in
// app/db/migrations; a named db's in app/db/<name>/migrations. Prod runs
// migrations explicitly (files aren't in the bundle).
const connectAndMigrate = async (name: string | null, cfg: { driver: string; url: string }) => {
try {
const db = name
? registerDb(name, connectFromConfig(cfg, appRoot))
: setDb(connectFromConfig(cfg, appRoot));
const dir = name ? join(appDir, "db", name, "migrations") : join(appDir, "db", "migrations");
const appApplied = await migrate(db, dir);
const packageApplied = await applyMigrations(
db,
resolvePackageMigrations(pluginContributions.migrations, name ?? undefined),
);
const applied = [...appApplied, ...packageApplied];
if (applied.length) {
console.log(
`[wrnexus] applied ${applied.length} migration(s)${name ? ` to '${name}'` : ""}`,
);
}
} catch (err) {
const label = name ? `database '${name}'` : "database";
console.warn(`[wrnexus] ${label} setup failed:`, err instanceof Error ? err.message : err);
}
};
if (opts.db) await connectAndMigrate(null, opts.db);
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
const migrationDir = join(appDir, "db", name, "migrations");
const hasMigrations =
loadMigrations(migrationDir).length > 0 ||
resolvePackageMigrations(pluginContributions.migrations, name).length > 0;
if (hasMigrations) await connectAndMigrate(name, cfg);
else registerLazyDb(name, () => connectFromConfig(cfg, appRoot));
}
// File-upload storage: build a driver per configured store (local dir / S3).
// Relative local dirs resolve against the app root; served/served-back below.
configureStorage(opts.storage, appRoot);
const assets = createDevAssetServer(
appDir,
mode,
{
entry: styleEntry,
config: opts.stylesConfig,
appRoot,
publicDir: join(appRoot, "public"),
// Component discovery is separate from utility-source discovery.
// Packages that need Tailwind scanning opt in via styles.source.
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
theme,
() => readFileSync(uiStylesPath, "utf8"),
schemasJs,
pluginAssetsFromContributions(pluginContributions),
);
const hub = hmr ? new HmrHub() : undefined;
const unsubscribeDevToolbar =
devToolbarCollector && hub
? devToolbarCollector.subscribe((issues) => {
hub.broadcastJson({ channel: "toolbar", type: "toolbar:issues", issues });
})
: 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,
hmr,
router,
loadModule,
getMiddleware: middleware.load,
assets,
hasStyles: !!styleEntry || pluginContributions.styles.some((style) => !!style.entry),
hasUi: true,
theme,
i18n,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
pwa: opts.pwa,
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
clientRuntimes: pluginContributions.clientRuntimes,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
renderHtml: (html: string) => pluginRunner.render(html),
devToolbar:
devToolbarConfig && devToolbarCollector
? {
config: devToolbarConfig,
collector: devToolbarCollector,
root: appRoot,
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,
},
{
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: toolbarPlatform,
}
: 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: validateRpcCsrf,
});
const serverFnRpcHandler = withServerFnRequestContext(rpcHandler);
/*
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
* modules, so only dev needs to recycle.
*/
const recycle =
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
? createRecycleMonitor({
threshold: RECYCLE_REBUILD_THRESHOLD,
idleMs: RECYCLE_IDLE_MS,
onRecycle(reason) {
console.log(`[wrnexus] ${reason}`);
process.exit(RESTART_EXIT_CODE);
},
})
: null;
const server = Bun.serve<WsData>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 10 * 1024 * 1024,
...(opts.tls ? { tls: opts.tls } : {}),
fetch(request, server) {
recycle?.recordRequest(Date.now());
if (new URL(request.url).pathname === "/__wrnexus/rpc") return serverFnRpcHandler(request);
return handlers.fetch(request, server);
},
websocket: handlers.websocket,
});
if (recycle) {
// unref so a pending check never keeps the process alive on its own.
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
timer.unref?.();
}
try {
await pluginRunner.hook("configureServer", {
server,
router,
handlers,
assets,
devToolbarCollector,
pluginContributions,
});
} catch (error) {
server.stop();
throw error;
}
let watcher: ReturnType<typeof startWatcher>;
// In-process HMR: keep the server and socket alive, invalidate only changed
// modules, rescan file routes, and ask browsers to morph in fresh HTML.
if (hmr && hub) {
const hotUpdate = async (files: string[]): Promise<void> => {
// Allow VS Code/Bun to finish writing pasted content.
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
for (const file of files) {
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
recycle?.recordRebuild();
}
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
Object.assign(
router,
buildRouter(appDir, {
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
}),
);
middleware.invalidate();
const appFiles = files.filter((file) => !isAbsolute(file));
// Without this branch, editing app/authz/*.ts reloaded the page (watch.ts
// classifies any non-CSS change as "server") while the OLD catalog stayed
// authoritative — a false security signal: tightening or removing a
// permission LOOKS like it took effect but does not until a restart. A
// raw `import()` here would silently no-op: Bun caches local TS/JS
// modules by filesystem path and ignores query strings, so the edited
// file must be re-imported through `loadModule` (pipeline.ts), which
// copies it to a versioned sibling path specifically to defeat that
// cache — the same mechanism every other hot-reloaded module already
// uses. `router` was just rebuilt above, so this reuses it rather than
// re-scanning the filesystem a third time.
if (appFiles.some((file) => file === "authz" || file.startsWith("authz/"))) {
try {
const nextAuthzCatalog = await loadAppAuthzCatalog(
router,
(file) => loadModule(file) as Promise<{ default?: AuthzModule }>,
);
setAuthzCatalog(nextAuthzCatalog);
} catch (error) {
console.error(
"[wrnexus] authz hot update failed — the PREVIOUS catalog remains authoritative " +
"until this is fixed and the file saved again",
error,
);
}
}
if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
assets.updateSchemas(await schemaRuntime(router));
}
if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) {
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);
// Island .tsx sources are not .wrn files, so nothing below would rebuild
// them; page modules are cached, so no compile runs on the next request.
const islandFiles = files
.map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed)))
.filter((changed) => changed.endsWith(".tsx"));
if (islandFiles.length > 0) {
try {
await rebuildChangedIslands(islandFiles);
} catch (error) {
console.warn("[wrnexus] island rebuild failed", error);
}
}
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: await wrnBrowserArtifactUrlAsync(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",
reason: "source-change",
files,
});
};
const packageWatchDirs = [
dirname(uiStylesPath),
...componentDirs,
...pluginContributions.clientRuntimes.flatMap((runtime) =>
runtime.entry ? [dirname(runtime.entry)] : [],
),
...pluginContributions.assets.flatMap((asset) => (asset.entry ? [dirname(asset.entry)] : [])),
...pluginContributions.styles.flatMap((style) =>
[style.source, style.entry ? dirname(style.entry) : undefined].filter(
(value): value is string => !!value,
),
),
...pluginContributions.routes.map((route) => dirname(route.entry)),
...pluginContributions.middleware.map((file) => dirname(file)),
];
watcher = startWatcher({
appDir,
extraDirs: packageWatchDirs,
hub,
assets,
devToolbarCollector,
onHotChange: hotUpdate,
});
}
const boundPort = server.port ?? port;
return {
port: boundPort,
hostname,
url: `http://${displayHost}:${boundPort}`,
router,
stop: () => {
watcher?.close();
unsubscribeDevToolbar?.();
server.stop();
void pluginRunner.hook("shutdown");
rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
},
};
}
export { createHandlers } from "./runtime.ts";
export type { RuntimeDeps, AssetServer, WsData } from "./runtime.ts";
// Multi-app gateway: route multiple apps by domain behind one port.
export { startGateway } from "./gateway.ts";
export type {
GatewayApp,
GatewayOptions,
GatewayAuth,
GatewaySecurity,
RunningGateway,
} from "./gateway.ts";
// Deployment: the portable production handler + the node:http adapter.
export { createProductionServer, createProductionHandlers } from "./prod.ts";
// Internal: called only by the generated `.authz-setup.ts` module (see
// packages/cli/src/build.ts) to populate the authorization catalog before any
// other static import — including app middleware — evaluates. Not meant for
// direct use by application code.
export { applyAuthzManifestEarly } from "./prod.ts";
export type { AuthzManifestEntry } from "./prod.ts";
export { toRequest, writeResponse, nodeListener, serveNode } from "./adapters/node.ts";
export type { FetchHandler } from "./adapters/node.ts";