release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 +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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user