release: WRNexusJS 0.5.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -65,7 +65,7 @@ export function createDevAssetServer(
|
||||
mode: Mode,
|
||||
styles?: DevStyles,
|
||||
theme?: ResolvedTheme,
|
||||
uiCss?: string,
|
||||
uiCss?: string | (() => string),
|
||||
schemasJs?: string,
|
||||
pluginAssets: readonly ServedPluginAsset[] = [],
|
||||
): DevAssetServer {
|
||||
@@ -96,7 +96,10 @@ export function createDevAssetServer(
|
||||
if (pathname === "/__wrnexus/schemas.js") return jsResponse(schemasCode);
|
||||
|
||||
if (pathname === "/__wrnexus/ui.css") {
|
||||
return uiCss ? cssResponse(uiCss) : new Response("Not Found", { status: 404 });
|
||||
const currentUiCss = typeof uiCss === "function" ? uiCss() : uiCss;
|
||||
return currentUiCss
|
||||
? cssResponse(currentUiCss)
|
||||
: new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
if (pathname === "/__wrnexus/theme.css") {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the running process while the HMR socket morphs fresh HTML into the browser.
|
||||
*/
|
||||
|
||||
import { readFileSync } 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";
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
type MobileConfig,
|
||||
type PwaConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
||||
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, migrate, setDb, registerDb } from "@wrnexus/db";
|
||||
@@ -46,6 +47,7 @@ export interface ServeOptions {
|
||||
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). */
|
||||
@@ -162,11 +164,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const appDir = resolve(opts.appDir);
|
||||
const appRoot = dirname(appDir);
|
||||
const mode: Mode = opts.mode ?? "development";
|
||||
const discoveredPlugins = await discoverPlugins(appRoot, opts.plugins, {
|
||||
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}`),
|
||||
});
|
||||
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root: appRoot,
|
||||
mode,
|
||||
@@ -174,11 +179,64 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await pluginRunner.configure(opts as unknown as Record<string, unknown>);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
await pluginRunner.configure(pluginConfig);
|
||||
|
||||
await pluginRunner.configResolved(
|
||||
Object.freeze({ ...opts }) as Readonly<Record<string, unknown>>,
|
||||
Object.freeze({
|
||||
...pluginConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
|
||||
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];
|
||||
|
||||
@@ -209,7 +267,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
// `.wrnexus/` next to each source file (and inside node_modules UI dirs).
|
||||
setCompileCacheDir(join(appRoot, ".wrnexus"));
|
||||
const theme = resolveThemeConfig(opts.theme);
|
||||
const uiStyles = uiCss();
|
||||
const uiStylesPath = uiCssPath();
|
||||
|
||||
const schemasJs = await schemaRuntime(router);
|
||||
|
||||
@@ -268,7 +326,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||
},
|
||||
theme,
|
||||
uiStyles,
|
||||
() => readFileSync(uiStylesPath, "utf8"),
|
||||
schemasJs,
|
||||
pluginAssetsFromContributions(pluginContributions),
|
||||
);
|
||||
@@ -407,6 +465,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
});
|
||||
};
|
||||
const packageWatchDirs = [
|
||||
dirname(uiStylesPath),
|
||||
...componentDirs,
|
||||
...pluginContributions.clientRuntimes.flatMap((runtime) =>
|
||||
runtime.entry ? [dirname(runtime.entry)] : [],
|
||||
|
||||
@@ -87,6 +87,7 @@ export function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
* When unset, compilation falls back to a sibling `.wrnexus/` next to each file.
|
||||
*/
|
||||
let compileCacheDir: string | null = null;
|
||||
const WRN_COMPILE_CACHE_VERSION = "v2";
|
||||
|
||||
/**
|
||||
* Point all `.wrn` compilation at ONE cache dir (typically `<appRoot>/.wrnexus`)
|
||||
@@ -124,7 +125,10 @@ function compileWireToTs(file: string, version = 0): string {
|
||||
// checkouts, archive extraction, and linked dependencies can all replace a
|
||||
// file while preserving (or moving backwards) its mtime. An mtime-only cache
|
||||
// then serves an older compiled component even across a clean build.
|
||||
const out = join(cacheDir, `${name}-${hashPath(file)}-${hashPath(source)}${suffix}.wrn.ts`);
|
||||
const out = join(
|
||||
cacheDir,
|
||||
`${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}${suffix}.wrn.ts`,
|
||||
);
|
||||
|
||||
// The content hash makes this safe even when source timestamps are preserved.
|
||||
try {
|
||||
|
||||
@@ -599,10 +599,20 @@ export const HMR_CLIENT_JS = `
|
||||
|
||||
// Minimal index-based DOM morph: preserve matching nodes (keeps state/focus),
|
||||
// patch text and attributes, clone genuinely new nodes, drop removed ones.
|
||||
// Hydrated subtrees (reactive scopes) are CLIENT-OWNED and left untouched,
|
||||
// so live state (e.g. a counter at 5) is never reset to the SSR 0.
|
||||
// Preserve a hydrated subtree only while its server hydration signature and
|
||||
// behavior are unchanged. Component edits must replace and re-hydrate the
|
||||
// old subtree or HMR will keep stale markup indefinitely.
|
||||
function morph(from, to) {
|
||||
if (from.__wrnexusHydrated) return;
|
||||
if (from.__wrnexusHydrated) {
|
||||
var sameHydration =
|
||||
from.getAttribute("data-wrn-hydration") === to.getAttribute("data-wrn-hydration") &&
|
||||
from.getAttribute("data-wrn-behavior") === to.getAttribute("data-wrn-behavior") &&
|
||||
from.getAttribute("data-scope") === to.getAttribute("data-scope");
|
||||
if (sameHydration) return;
|
||||
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
|
||||
from.replaceWith(to.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
syncAttrs(from, to);
|
||||
var fc = from.childNodes, tc = to.childNodes, i;
|
||||
for (i = 0; i < tc.length; i++) {
|
||||
@@ -1070,6 +1080,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
const matched = router.matchApi(ctx.url.pathname);
|
||||
if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
|
||||
|
||||
// Expose the canonical matched route to package dispatchers. A package may
|
||||
// contribute several URL paths from one module, and request URLs can be
|
||||
// rewritten by gateways or internal framework calls. The router match is
|
||||
// the authoritative route identity.
|
||||
ctx.params = matched.params;
|
||||
ctx.locals.__wrnexusRoute = matched.route.raw;
|
||||
ctx.locals.__wrnexusRouteKind = "api";
|
||||
|
||||
const mod = await loadModule(matched.route.file);
|
||||
const method = ctx.req.method.toUpperCase();
|
||||
const embeddedApi = mod.__wrnexusApi as ApiRegistry | undefined;
|
||||
@@ -1087,7 +1105,6 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
});
|
||||
}
|
||||
|
||||
ctx.params = matched.params;
|
||||
return (await handler(ctx)) as Response;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,29 +9,43 @@
|
||||
*/
|
||||
|
||||
import { dirname } from "node:path";
|
||||
import { startServer } from "./index.ts";
|
||||
import { loadAppConfig, headToString, findStyleEntry, renderFontHead } from "@wrnexus/styles";
|
||||
import type { Mode } from "@wrnexus/core";
|
||||
import { findStyleEntry, headToString, loadAppConfig, renderFontHead } from "@wrnexus/styles";
|
||||
import { startServer } from "./index.ts";
|
||||
|
||||
const [appDir, portStr, modeStr, hostname, hmrStr] = process.argv.slice(2);
|
||||
|
||||
const mode = (modeStr as Mode) || "development";
|
||||
|
||||
const port = Number(portStr) || 3000;
|
||||
|
||||
// Load optional wrnexus.config.ts (sits next to the app/ dir) + resolve styles.
|
||||
const appRoot = dirname(appDir!);
|
||||
if (!appDir) {
|
||||
throw new Error("WRN-DEV-APP-DIR: app directory argument is required.");
|
||||
}
|
||||
|
||||
// Load optional wrnexus.config.ts next to the app directory.
|
||||
const appRoot = dirname(appDir);
|
||||
|
||||
const config = await loadAppConfig(appRoot);
|
||||
const styleEntry = findStyleEntry(appDir!, appRoot, config.styles?.entry);
|
||||
|
||||
const styleEntry = findStyleEntry(appDir, appRoot, config.styles?.entry);
|
||||
|
||||
const server = await startServer({
|
||||
appDir: appDir!,
|
||||
appConfig: {
|
||||
...config,
|
||||
},
|
||||
|
||||
port,
|
||||
hostname,
|
||||
mode,
|
||||
hmr: hmrStr === undefined ? undefined : hmrStr === "true",
|
||||
|
||||
styleEntry,
|
||||
stylesConfig: config.styles,
|
||||
|
||||
head: [renderFontHead(config.fonts), headToString(config.head)].filter(Boolean).join("\n "),
|
||||
|
||||
seo: config.seo,
|
||||
security: config.security,
|
||||
theme: config.theme,
|
||||
@@ -47,20 +61,44 @@ const server = await startServer({
|
||||
observability: config.observability,
|
||||
tenancy: config.tenancy,
|
||||
});
|
||||
const r = server.router;
|
||||
|
||||
const group = (label: string, items: { raw: string }[]) => {
|
||||
if (!items.length) return;
|
||||
console.log(` ${label}`);
|
||||
for (const it of items) console.log(` ${it.raw}`);
|
||||
};
|
||||
const router = server.router;
|
||||
|
||||
interface PrintableRoute {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function printRouteGroup(label: string, items: readonly PrintableRoute[]): void {
|
||||
const routes = Array.from(new Set(items.map((item) => item.raw.trim()).filter(Boolean))).sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
|
||||
console.log(` ${label}: ${routes.length}`);
|
||||
|
||||
if (routes.length === 0) {
|
||||
console.warn(` ⚠ No ${label.toLowerCase()} registered`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
console.log(` ${route}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ⚡ WrNexus — ${server.url}\n`);
|
||||
group("Pages", r.pages);
|
||||
group("API", r.api);
|
||||
group("Realtime", r.realtime);
|
||||
if (r.components.length) {
|
||||
console.log(" Components");
|
||||
for (const c of r.components) console.log(` ${c.name}`);
|
||||
}
|
||||
|
||||
printRouteGroup("Pages", router.pages);
|
||||
|
||||
console.log("");
|
||||
|
||||
printRouteGroup("API Routes", router.api);
|
||||
|
||||
if (router.realtime.length > 0) {
|
||||
console.log("");
|
||||
|
||||
printRouteGroup("Realtime Routes", router.realtime);
|
||||
}
|
||||
|
||||
console.log(`\n Components: ${router.components.length}`);
|
||||
|
||||
console.log("");
|
||||
|
||||
@@ -8,3 +8,11 @@ test("HMR client syncs fresh HTML over the websocket", () => {
|
||||
expect(HMR_CLIENT_JS).not.toContain("fetch(location.href");
|
||||
expect(HMR_CLIENT_JS).not.toContain("location.reload()");
|
||||
});
|
||||
|
||||
test("HMR replaces hydrated components when their server signature changes", () => {
|
||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-hydration")');
|
||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")');
|
||||
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")');
|
||||
expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)");
|
||||
expect(HMR_CLIENT_JS).toContain("from.replaceWith(to.cloneNode(true))");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { get } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
import { startServer } from "../src/index.ts";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-config-"));
|
||||
const appDir = join(root, "app");
|
||||
const routeFile = join(root, "config-probe.ts");
|
||||
const runtimeKey = `@wrnexus/dev-server:test:plugin-config:${crypto.randomUUID()}`;
|
||||
|
||||
mkdirSync(appDir, { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "plugin-config-lifecycle-test" }));
|
||||
writeFileSync(
|
||||
routeFile,
|
||||
`
|
||||
export function GET() {
|
||||
return Response.json({
|
||||
value: globalThis[Symbol.for(${JSON.stringify(runtimeKey)})],
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
delete (globalThis as Record<PropertyKey, unknown>)[Symbol.for(runtimeKey)];
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function getJson(url: string): Promise<{ status: number | undefined; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = get(url, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => {
|
||||
try {
|
||||
resolve({
|
||||
status: response.statusCode,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("startServer configures plugins once with the complete application config", async () => {
|
||||
let configureCalls = 0;
|
||||
const plugin = definePlugin({
|
||||
name: "plugin-config-lifecycle-test",
|
||||
configure(config) {
|
||||
configureCalls += 1;
|
||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for(runtimeKey)] =
|
||||
config.lifecycleSentinel;
|
||||
},
|
||||
routeEntries: [{ kind: "api", path: "/api/config-probe", entry: routeFile }],
|
||||
});
|
||||
|
||||
const server = await startServer({
|
||||
appDir,
|
||||
appConfig: { lifecycleSentinel: "configured" },
|
||||
plugins: plugin,
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
mode: "development",
|
||||
hmr: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await getJson(`${server.url}/api/config-probe`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ value: "configured" });
|
||||
expect(configureCalls).toBe(1);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createDevAssetServer } from "../src/assets.ts";
|
||||
|
||||
test("UI CSS is read from its live source after an HMR change", async () => {
|
||||
let css = ".wire-card { color: red; }";
|
||||
const assets = createDevAssetServer(
|
||||
process.cwd(),
|
||||
"development",
|
||||
undefined,
|
||||
undefined,
|
||||
() => css,
|
||||
);
|
||||
|
||||
const first = await assets.serve("/__wrnexus/ui.css");
|
||||
expect(await first?.text()).toContain("color: red");
|
||||
|
||||
css = ".wire-card { color: blue; }";
|
||||
assets.invalidateCss();
|
||||
|
||||
const second = await assets.serve("/__wrnexus/ui.css");
|
||||
expect(await second?.text()).toContain("color: blue");
|
||||
expect(second?.headers.get("cache-control")).toBe("no-cache");
|
||||
});
|
||||
Reference in New Issue
Block a user