722 lines
24 KiB
TypeScript
722 lines
24 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 { realtimeBusFromConfig } from "./realtime-bus.ts";
|
|
import {
|
|
invalidateModule,
|
|
loadModule,
|
|
loadWrnServerModule,
|
|
setCompileCacheDir,
|
|
setCompileImportOptions,
|
|
setDevCompilerPipeline,
|
|
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";
|
|
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);
|
|
|
|
// 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));
|
|
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: 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",
|
|
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";
|
|
export { toRequest, writeResponse, nodeListener, serveNode } from "./adapters/node.ts";
|
|
export type { FetchHandler } from "./adapters/node.ts";
|