release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.3.6",
"version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
+18 -2
View File
@@ -24,6 +24,7 @@ import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from
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";
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
export interface DevStyles {
@@ -31,6 +32,8 @@ export interface DevStyles {
config?: StylesConfig;
appRoot: string;
publicDir?: string;
sources?: string[];
entries?: string[];
}
/** A dev asset server also supports invalidating its caches in-process. */
@@ -64,6 +67,7 @@ export function createDevAssetServer(
theme?: ResolvedTheme,
uiCss?: string,
schemasJs?: string,
pluginAssets: readonly ServedPluginAsset[] = [],
): DevAssetServer {
let cssCache: string | null = null;
let schemasCode = schemasJs ?? "window.__wireSchemas={};";
@@ -107,16 +111,28 @@ export function createDevAssetServer(
}
if (pathname === "/__wrnexus/styles.css") {
if (!styles?.entry) return new Response("Not Found", { status: 404 });
if (!styles?.entry && !styles?.entries?.length) {
return new Response("Not Found", { status: 404 });
}
if (cssCache === null) {
cssCache = await renderStyles(
{ entryPath: styles.entry, appDir, appRoot: styles.appRoot, mode },
{
entryPath: styles.entry,
appDir,
appRoot: styles.appRoot,
mode,
sources: styles.sources,
entries: styles.entries,
},
styles.config,
);
}
return cssResponse(cssCache);
}
const pluginAsset = await servePluginAsset(pluginAssets, pathname, mode);
if (pluginAsset) return pluginAsset;
return servePublicAsset(styles?.publicDir, pathname, mode);
},
};
+83 -12
View File
@@ -6,7 +6,7 @@
* the running process while the HMR socket morphs fresh HTML into the browser.
*/
import { resolve, dirname, join } from "node:path";
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 {
@@ -19,20 +19,22 @@ import {
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
import { migrate, setDb, registerDb } from "@wrnexus/db";
import { applyMigrations, migrate, setDb, registerDb } 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, setCompileCacheDir } from "./pipeline.ts";
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";
import { resetDevCache } from "./cache.ts";
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
import { createPluginRunner, type PluginInput } from "@wrnexus/plugin";
import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
@@ -160,7 +162,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 pluginRunner = createPluginRunner(opts.plugins, {
const discoveredPlugins = await discoverPlugins(appRoot, opts.plugins, {
includeDevDependencies: true,
strict: true,
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
const pluginRunner = createPluginRunner(discoveredPlugins, {
root: appRoot,
mode,
command: "dev",
@@ -171,13 +178,20 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
await pluginRunner.configResolved(
Object.freeze({ ...opts }) as Readonly<Record<string, unknown>>,
);
const pluginContributions = await pluginRunner.contributions();
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: [uiComponentsDir()] });
const router = buildRouter(appDir, {
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
});
const styleEntry = opts.styleEntry ?? null;
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
@@ -215,7 +229,12 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
? registerDb(name, connectFromConfig(cfg, appRoot))
: setDb(connectFromConfig(cfg, appRoot));
const dir = name ? join(appDir, "db", name, "migrations") : join(appDir, "db", "migrations");
const applied = await migrate(db, dir);
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}'` : ""}`,
@@ -242,10 +261,16 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
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,
uiStyles,
schemasJs,
pluginAssetsFromContributions(pluginContributions),
);
const hub = hmr ? new HmrHub() : undefined;
@@ -264,7 +289,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
loadModule,
getMiddleware: middleware.load,
assets,
hasStyles: !!styleEntry,
hasStyles: !!styleEntry || pluginContributions.styles.some((style) => !!style.entry),
hasUi: true,
theme,
i18n,
@@ -275,6 +300,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
clientRuntimes: pluginContributions.clientRuntimes,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
devToolbar:
@@ -283,6 +309,31 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
config: devToolbarConfig,
collector: devToolbarCollector,
root: appRoot,
panels: pluginToolbarPanels,
platform: {
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,
},
},
}
: undefined,
};
@@ -304,6 +355,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
handlers,
assets,
devToolbarCollector,
pluginContributions,
});
} catch (error) {
server.stop();
@@ -319,22 +371,26 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
// Allow VS Code/Bun to finish writing pasted content.
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
for (const relative of files) {
invalidateModule(resolve(appDir, relative));
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: [uiComponentsDir()],
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
}),
);
middleware.invalidate();
if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
const appFiles = files.filter((file) => !isAbsolute(file));
if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
assets.updateSchemas(await schemaRuntime(router));
}
if (files.some((file) => file === "locales" || file.startsWith("locales/"))) {
if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) {
const messages = loadLocales(join(appDir, "locales"));
runtimeDeps.i18n = Object.keys(messages).length
? resolveI18n(messages, opts.i18n)
@@ -350,8 +406,23 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
files,
});
};
const packageWatchDirs = [
...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,
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from "node:fs/promises";
import type {
ClientRuntimeDefinition,
PackageAssetDefinition,
PluginContributions,
} from "@wrnexus/plugin";
import { contentTypeForPath, normalizeClientRuntime, normalizePackageAsset } from "@wrnexus/plugin";
import type { ScriptAsset } from "@wrnexus/ssr";
export interface ServedPluginAsset {
id: string;
publicPath: string;
entry?: string;
source?: string | Uint8Array;
contentType: string;
immutable: boolean;
runtime?: boolean;
runtimeType?: "module" | "script";
bundle?: boolean;
}
export interface ClientRuntimeAsset extends ClientRuntimeDefinition {
publicPath: string;
}
function toBody(value: Uint8Array): ArrayBuffer {
return Uint8Array.from(value).buffer;
}
export function pluginAssetsFromContributions(
contributions: PluginContributions,
): ServedPluginAsset[] {
const assets: ServedPluginAsset[] = [];
for (const input of contributions.clientRuntimes) {
const runtime = normalizeClientRuntime(input);
assets.push({
id: `runtime:${runtime.id}`,
publicPath: runtime.publicPath!,
entry: runtime.entry,
source: runtime.source,
contentType: "text/javascript; charset=utf-8",
immutable: false,
runtime: true,
runtimeType: runtime.type,
bundle: runtime.bundle,
});
}
for (const input of contributions.assets) {
const asset = normalizePackageAsset(input);
assets.push({
id: `asset:${asset.id}`,
publicPath: asset.publicPath!,
entry: asset.entry,
source: asset.source,
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath!),
immutable: asset.immutable ?? false,
});
}
return assets;
}
export async function readPluginAsset(asset: ServedPluginAsset): Promise<string | ArrayBuffer> {
if (typeof asset.source === "string") return asset.source;
if (asset.source instanceof Uint8Array) return toBody(asset.source);
if (!asset.entry) throw new Error(`Plugin asset '${asset.id}' has no source.`);
const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?tsx?$/.test(asset.entry));
if (!shouldBundle) return toBody(await readFile(asset.entry));
const result = await Bun.build({
entrypoints: [asset.entry],
target: "browser",
format: asset.runtimeType === "script" ? "iife" : "esm",
minify: false,
sourcemap: "inline",
});
if (!result.success || !result.outputs[0]) {
throw new Error(
`Plugin runtime '${asset.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
);
}
return toBody(new Uint8Array(await result.outputs[0].arrayBuffer()));
}
export async function servePluginAsset(
assets: readonly ServedPluginAsset[],
pathname: string,
mode: "development" | "production",
): Promise<Response | null> {
const asset = assets.find((entry) => entry.publicPath === pathname);
if (!asset) return null;
try {
const body = await readPluginAsset(asset);
return new Response(body, {
headers: {
"content-type": asset.contentType,
"cache-control":
mode === "production" && asset.immutable
? "public, max-age=31536000, immutable"
: "no-cache",
"x-content-type-options": "nosniff",
},
});
} catch (error) {
console.error(`[wrnexus] failed to serve plugin asset ${asset.publicPath}`, error);
return new Response("Plugin asset unavailable", { status: 503 });
}
}
export function runtimeScript(input: ClientRuntimeDefinition): ScriptAsset {
const runtime = normalizeClientRuntime(input);
return {
src: runtime.publicPath!,
type: runtime.type === "script" ? "classic" : "module",
async: runtime.load === "eager" ? false : undefined,
defer: runtime.load !== "eager",
integrity: runtime.integrity,
crossOrigin: runtime.crossOrigin,
attributes: {
"data-wrnexus-runtime-src": runtime.id,
...(runtime.attributes ?? {}),
},
};
}
export function runtimeIdsFromMarkup(body: string): Set<string> {
const ids = new Set<string>();
for (const match of body.matchAll(/\bdata-wrnexus-runtime\s*=\s*["']([^"']+)["']/gi)) {
for (const id of match[1]!.split(/[\s,]+/)) {
if (id) ids.add(id);
}
}
return ids;
}
export function runtimeScriptsForMarkup(
body: string,
runtimes: readonly ClientRuntimeDefinition[] = [],
): ScriptAsset[] {
const ids = runtimeIdsFromMarkup(body);
return runtimes.filter((runtime) => ids.has(runtime.id)).map(runtimeScript);
}
export function mergePluginAssets(
runtimes: readonly ClientRuntimeDefinition[],
assets: readonly PackageAssetDefinition[],
): ServedPluginAsset[] {
return pluginAssetsFromContributions({
componentDirs: [],
clientRuntimes: [...runtimes],
assets: [...assets],
styles: [],
routes: [],
middleware: [],
migrations: [],
});
}
@@ -0,0 +1,56 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { basename } from "node:path";
import { loadMigrations, parseMigration, type Migration } from "@wrnexus/db";
import type { PackageMigrationDefinition } from "@wrnexus/plugin";
function migrationName(definition: PackageMigrationDefinition, name?: string): string {
const prefix = definition.id.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
const suffix = name?.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
return suffix ? `${prefix}__${suffix}` : prefix;
}
function databaseMatches(definition: PackageMigrationDefinition, database?: string): boolean {
const target = definition.database?.trim() || "default";
return target === (database?.trim() || "default");
}
/** Resolve package-owned migrations into the same ordered contract as app migrations. */
export function resolvePackageMigrations(
definitions: readonly PackageMigrationDefinition[],
database?: string,
): Migration[] {
const output: Migration[] = [];
for (const definition of definitions) {
if (!databaseMatches(definition, database)) continue;
if (definition.source !== undefined) {
const parsed = parseMigration(migrationName(definition), definition.source);
output.push(parsed);
continue;
}
const entry = definition.entry;
if (!entry || !existsSync(entry)) {
throw new Error(
`WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} points to ${entry ?? "<empty>"}.`,
);
}
const stat = statSync(entry);
if (stat.isDirectory()) {
for (const migration of loadMigrations(entry)) {
output.push({ ...migration, name: migrationName(definition, migration.name) });
}
continue;
}
if (!stat.isFile() || !entry.endsWith(".sql")) {
throw new Error(
`WRN-PLUGIN-MIGRATION-ENTRY: ${definition.id} must be a .sql file or directory.`,
);
}
output.push(
parseMigration(
migrationName(definition, basename(entry, ".sql")),
readFileSync(entry, "utf8"),
),
);
}
return output;
}
+21
View File
@@ -41,6 +41,7 @@ import {
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
type RouteModule = Record<string, unknown>;
@@ -62,6 +63,12 @@ export interface ProdManifest {
layouts: { name: string; mod: RouteModule }[];
}
export interface ProductionPluginAsset {
path: string;
contentType: string;
immutable?: boolean;
}
export interface ProdOptions {
/** Absolute path to the pre-built global stylesheet, if any. */
stylesPath?: string;
@@ -108,6 +115,10 @@ export interface ProdOptions {
storage?: StorageConfig;
/** Cache-busting version appended to framework asset URLs. */
assetVersion?: string;
/** Package browser runtimes already emitted by the production build. */
clientRuntimes?: ClientRuntimeDefinition[];
/** Public URL to emitted package asset metadata. */
pluginAssets?: Record<string, ProductionPluginAsset>;
/** Absolute path to copied public assets, if any. */
publicDir?: string;
/** Raw HTML appended to every page head. */
@@ -238,6 +249,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
if (pathname === "/__wrnexus/framework.css")
return serveFile(opts.frameworkCssPath, CSS_HEADERS);
if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS);
const pluginAsset = opts.pluginAssets?.[pathname];
if (pluginAsset) {
return serveFile(pluginAsset.path, {
"content-type": pluginAsset.contentType,
"cache-control":
pluginAsset.immutable === false ? "no-cache" : "public, max-age=31536000, immutable",
"x-content-type-options": "nosniff",
});
}
return servePublicAsset(opts.publicDir, pathname, MODE);
},
};
@@ -306,6 +326,7 @@ export function createProductionHandlers(
inlineStyles: opts.inlineStyles,
stylesIncludeFramework: opts.stylesIncludeFramework,
assetVersion: opts.assetVersion,
clientRuntimes: opts.clientRuntimes,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
+36 -7
View File
@@ -36,7 +36,9 @@ import {
type TFunction,
} from "@wrnexus/core";
import type { Router } from "@wrnexus/router";
import { renderDocument } from "@wrnexus/ssr";
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
import {
THEME_COOKIE,
THEME_CSS_HREF,
@@ -58,7 +60,11 @@ import {
} from "@wrnexus/i18n";
import { runMiddleware } from "./pipeline.ts";
import type { HmrHub } from "./hmr.ts";
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
import type {
DevToolbarConfig,
DevToolbarPanel,
DevToolbarPlatformSnapshot,
} from "@wrnexus/dev-toolbar/types";
import {
createServerIssue,
@@ -125,6 +131,8 @@ export interface RuntimeDeps {
inlineStyles?: string;
/** Production cache-busting version appended to framework asset URLs. */
assetVersion?: string;
/** Package browser runtimes resolved by the plugin system. */
clientRuntimes?: ClientRuntimeDefinition[];
/** Raw HTML appended to every page head (e.g. CDN framework links). */
head?: string;
/** Global SEO defaults. */
@@ -152,6 +160,8 @@ export interface RuntimeDeps {
config: DevToolbarConfig;
collector: DevToolbarCollector;
root: string;
platform?: DevToolbarPlatformSnapshot;
panels?: DevToolbarPanel[];
};
}
@@ -835,6 +845,8 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
collector: deps.devToolbar.collector,
editor: deps.devToolbar.config.editor,
allowOpenEditor: deps.devToolbar.config.openEditor !== false,
platform: deps.devToolbar.platform,
panels: deps.devToolbar.panels,
});
if (toolbarResponse) return secure(toolbarResponse);
}
@@ -1111,7 +1123,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
i = end;
const component = router.components.find((c) => c.name === name);
const normalizedName = name.toLowerCase();
const component = router.components.find(
(candidate) => candidate.name.toLowerCase() === normalizedName,
);
if (!component) {
console.warn(`[wrnexus] no component registered for '${name}'`);
result += body.slice(tagStart, end);
@@ -1280,7 +1295,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
if (deps.i18n) body = translateHtml(body, ctx.t);
// Point 3: only ship the JS this page actually uses.
const scripts = collectScripts(body).map((src) => versionAssetUrl(src, deps.assetVersion));
const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
versionRenderScript(script, deps.assetVersion),
);
if (pwaServiceWorkerEnabled)
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
@@ -1319,7 +1336,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
// 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${scripts.join(",")}\n${body}`);
const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`);
const method = ctx.req.method.toUpperCase();
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
return new Response(null, {
@@ -1640,10 +1657,13 @@ export function resolveTProps(
* server-rendered into the HTML; the only script is the reactive runtime, and
* only when the page actually contains a scope or a browser-side API fetch.
*/
export function collectScripts(body: string): string[] {
export function collectScripts(
body: string,
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
): RenderScript[] {
// Client-side navigation is an app-wide progressive enhancement: it must load
// on every page (you navigate *from* any page), and degrades to full loads.
const scripts: string[] = ["/__wrnexus/nav.js"];
const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
scripts.push("/__wrnexus/reactive.js");
}
@@ -1667,6 +1687,10 @@ export function collectScripts(body: string): string[] {
if (/\bdata-uploader\b/.test(body)) {
scripts.push("/__wrnexus/uploader.js");
}
// Package runtimes are declarative. Components mark the rendered HTML with
// `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded
// once, without requiring application-authored script tags or public copies.
scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes));
return scripts;
}
@@ -1686,6 +1710,11 @@ function versionAssetUrl(src: string, version?: string): string {
return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
}
function versionRenderScript(script: RenderScript, version?: string): RenderScript {
if (typeof script === "string") return versionAssetUrl(script, version);
return { ...script, src: versionAssetUrl(script.src, version) } satisfies ScriptAsset;
}
function escapeStyleContent(css: string): string {
return css.replace(/<\/style/gi, "<\\/style");
}
+50 -16
View File
@@ -8,13 +8,16 @@
* process and HMR socket stay alive.
*/
import { watch, type FSWatcher } from "node:fs";
import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { HmrHub } from "./hmr.ts";
import type { DevAssetServer } from "./assets.ts";
import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
export interface WatchOptions {
appDir: string;
/** Additional package component/runtime/style directories watched for HMR. */
extraDirs?: string[];
hub: HmrHub;
assets: DevAssetServer;
devToolbarCollector?: DevToolbarCollector;
@@ -39,8 +42,12 @@ function classify(rel: string): Kind {
return "server";
}
/** Returns the watcher so the running server can close it during shutdown. */
export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
export interface WatchHandle {
close(): void;
}
/** Returns a composite watcher so the running server can close every source root. */
export function startWatcher(opts: WatchOptions): WatchHandle | undefined {
const { appDir, hub, assets } = opts;
const pending = new Set<Kind>();
const pendingFiles = new Set<string>();
@@ -72,18 +79,45 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
pendingFiles.clear();
};
try {
return watch(appDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const rel = filename.toString().replace(/\\/g, "/");
if (isIgnored(rel)) return;
pendingFiles.add(rel);
pending.add(classify(rel));
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 200); // debounce editor write bursts
});
} catch (err) {
console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
return undefined;
const appRoot = resolve(appDir);
const candidates = [appRoot, ...(opts.extraDirs ?? []).map((dir) => resolve(dir))];
const roots = [...new Set(candidates)]
.filter((dir) => existsSync(dir) && statSync(dir).isDirectory())
.filter(
(dir, index, values) =>
!values.some((other, otherIndex) => {
if (otherIndex >= index) return false;
const nested = relative(other, dir);
return nested === "" || (!nested.startsWith("..") && !isAbsolute(nested));
}),
);
const watchers: FSWatcher[] = [];
for (const root of roots) {
try {
const external = root !== appRoot;
watchers.push(
watch(root, { recursive: true }, (_event, filename) => {
if (!filename) return;
const relativeFile = filename.toString().replace(/\\/g, "/");
if (isIgnored(relativeFile)) return;
const file = external ? join(root, relativeFile).replace(/\\/g, "/") : relativeFile;
pendingFiles.add(file);
pending.add(classify(file));
if (timer) clearTimeout(timer);
timer = setTimeout(flush, 200); // debounce editor write bursts
}),
);
} catch (error) {
console.warn(`[wrnexus] file watching unavailable for ${root}`, error);
}
}
if (!watchers.length) return undefined;
return {
close() {
if (timer) clearTimeout(timer);
for (const watcher of watchers) watcher.close();
},
};
}
@@ -0,0 +1,67 @@
import { expect, test } from "bun:test";
import {
mergePluginAssets,
runtimeIdsFromMarkup,
runtimeScriptsForMarkup,
servePluginAsset,
} from "../src/plugin-assets.ts";
import { resolvePackageMigrations } from "../src/plugin-migrations.ts";
test("injects only runtimes referenced by rendered markup", () => {
const runtimes = [
{ id: "captcha", source: "window.captcha = true" },
{ id: "editor", source: "window.editor = true" },
];
const body = '<div data-wrnexus-runtime="captcha captcha"></div>';
expect([...runtimeIdsFromMarkup(body)]).toEqual(["captcha"]);
const scripts = runtimeScriptsForMarkup(body, runtimes);
expect(scripts).toHaveLength(1);
expect(scripts[0]).toMatchObject({
src: "/__wrnexus/assets/captcha.js",
type: "module",
defer: true,
});
});
test("serves package assets with a safe content type", async () => {
const assets = mergePluginAssets(
[{ id: "captcha", source: "window.captcha = true", type: "script" }],
[],
);
const response = await servePluginAsset(assets, "/__wrnexus/assets/captcha.js", "development");
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response?.headers.get("x-content-type-options")).toBe("nosniff");
expect(await response?.text()).toContain("window.captcha");
});
test("resolves inline package migrations for the requested database", () => {
const migrations = resolvePackageMigrations(
[
{ id: "default-schema", source: "-- +up\nCREATE TABLE one(id INTEGER);" },
{
id: "analytics-schema",
database: "analytics",
source: "-- +up\nCREATE TABLE events(id INTEGER);",
},
],
"analytics",
);
expect(migrations).toHaveLength(1);
expect(migrations[0]?.name).toBe("analytics-schema");
expect(migrations[0]?.up).toContain("CREATE TABLE events");
});
import { collectScripts } from "../src/runtime.ts";
test("collectScripts automatically adds a referenced package runtime once", () => {
const scripts = collectScripts(
'<main><div data-wrnexus-runtime="captcha"></div><div data-wrnexus-runtime="captcha"></div></main>',
[{ id: "captcha", source: "window.captcha = true", type: "script" }],
);
expect(
scripts.filter(
(script) => typeof script !== "string" && script.src === "/__wrnexus/assets/captcha.js",
),
).toHaveLength(1);
});