createProductionHandlers called setAuthzCatalog unconditionally, so a caller using client.ts's documented escape hatch (setAuthzCatalog(catalog) before importing anything that reads it) had that catalog silently wiped to empty whenever opts.authz was omitted. Now only sets when opts.authz has entries to contribute, or when nothing has been set yet; a non-empty opts.authz still always sets and still throws on a genuine conflict. Also removes RuntimeDeps.authz: nothing read it, and its doc comment described a consumer that doesn't exist. The real wiring is getAuthzCatalog()/setAuthzCatalog(), including the HMR hot-update path, which is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
769 lines
27 KiB
TypeScript
769 lines
27 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, 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";
|
|
import {
|
|
resolveThemeConfig,
|
|
type StylesConfig,
|
|
type ThemeConfig,
|
|
type MobileConfig,
|
|
type PwaConfig,
|
|
type NavigationConfig,
|
|
} from "@wrnexus/styles";
|
|
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui";
|
|
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,
|
|
setDevCompilerPipeline,
|
|
wrnBrowserArtifactUrlAsync,
|
|
} 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";
|
|
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 {
|
|
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;
|
|
/** 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>();
|
|
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: ${
|
|
pluginRunner.plugins.map((plugin) => plugin.name).join(", ") || "none"
|
|
}`,
|
|
);
|
|
|
|
console.log(`[wrnexus:plugin] contributed routes: ${pluginContributions.routes.length}`);
|
|
|
|
const pluginToolbarPanels = await pluginRunner.devToolbarPanels();
|
|
const componentDirs = [uiComponentsDir(), ...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;
|
|
|
|
resetDevCache({
|
|
rootDir: appRoot,
|
|
enabled: process.env.WRNEXUS_PRESERVE_CACHE !== "1",
|
|
});
|
|
|
|
// 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(join(appRoot, ".wrnexus"));
|
|
const theme = resolveThemeConfig(opts.theme);
|
|
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 i18n = Object.keys(localeMessages).length
|
|
? resolveI18n(localeMessages, opts.i18n)
|
|
: 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"),
|
|
sources: [
|
|
...componentDirs,
|
|
...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(request) {
|
|
const url = new URL(request.url);
|
|
const origin = request.headers.get("origin");
|
|
if (origin && origin !== url.origin) return false;
|
|
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
const cookieToken =
|
|
/(?:^|;\s*)wire-csrf=([^;]+)/.exec(cookieHeader)?.[1] ??
|
|
/(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1];
|
|
const headerToken =
|
|
request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf") ?? "";
|
|
return !cookieToken || decodeURIComponent(cookieToken) === headerToken;
|
|
},
|
|
});
|
|
|
|
const server = Bun.serve<WsData>({
|
|
port,
|
|
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);
|
|
},
|
|
websocket: handlers.websocket,
|
|
});
|
|
|
|
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));
|
|
}
|
|
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);
|
|
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");
|
|
},
|
|
};
|
|
}
|
|
|
|
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";
|