release: WRNexusJS 0.5.10

This commit is contained in:
2026-07-30 13:36:29 +05:30
parent 8fc6f15402
commit d1b0c55b53
159 changed files with 7509 additions and 604 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.1",
"version": "0.5.10",
"type": "module",
"main": "src/index.ts",
"exports": {
+40 -8
View File
@@ -10,7 +10,8 @@
*/
import { spawn, type ChildProcess } from "node:child_process";
import { join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { RESTART_EXIT_CODE } from "./restart.ts";
@@ -150,11 +151,15 @@ export function stripInternalError(res: Response): Response {
}
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
export function forwardAuthFailure(
res: Response,
verifierUrl: string,
verifierPublicOrigin = verifierUrl,
): Response {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
try {
const redirect = new URL(location, verifierUrl);
const redirect = new URL(location, verifierPublicOrigin);
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
return new Response(null, { status: res.status, headers: { location: redirect.href } });
}
@@ -197,6 +202,7 @@ async function checkAuth(
req: Request,
ip: string,
internalOrigins: Readonly<Record<string, string>>,
publicOrigins: Readonly<Record<string, string>>,
publicOrigin?: string,
): Promise<Response | null> {
if (!auth) return null;
@@ -226,6 +232,10 @@ async function checkAuth(
if (auth.forward) {
const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
const verifierPublicOrigin =
typeof auth.forward.app === "string"
? publicOrigins[auth.forward.app]
: new URL(auth.forward.url).origin;
try {
const res = await fetch(verifyUrl, {
@@ -240,7 +250,7 @@ async function checkAuth(
`[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? `${diagnostic}` : ""}`,
);
}
return forwardAuthFailure(res, verifyUrl);
return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin);
}
} catch (error) {
console.error(
@@ -351,11 +361,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
const environment = opts.environment ?? (mode === "production" ? "production" : "development");
const workspaceOrigins = Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
const workspaceOrigins: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
),
);
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
// When the CLI is executed directly from a framework checkout, keep child
// apps on that same source tree. Resolving the package name from an external
// workspace can otherwise select its older installed release on restart.
const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url));
const usesSourceServeEntry = existsSync(sourceServeEntry);
const serveEntry = usesSourceServeEntry
? sourceServeEntry
: fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
const frameworkRoot = resolve(dirname(sourceServeEntry), "../../..");
let stopping = false;
const targets: Target[] = opts.apps.map((app, i) => {
@@ -397,6 +417,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
],
{
stdio: "inherit",
// Bun can otherwise resolve bare @wrnexus imports against the
// external application's node_modules after an HMR restart.
// Keep source-checkout children anchored to the same framework
// checkout as the gateway command.
cwd: usesSourceServeEntry ? frameworkRoot : dir,
env: {
...process.env,
WRNEXUS_ENV: environment,
@@ -496,7 +521,14 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
}
// Per-app access control (basic auth / IP allowlist / forward-auth).
const denied = await checkAuth(target.auth, req, ip, internalOrigins, target.publicOrigin);
const denied = await checkAuth(
target.auth,
req,
ip,
internalOrigins,
workspaceOrigins,
target.publicOrigin,
);
if (denied) {
if (sec.accessLog)
console.log(
+20 -3
View File
@@ -16,11 +16,19 @@ import {
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, migrate, setDb, registerDb } from "@wrnexus/db";
import {
applyMigrations,
loadMigrations,
migrate,
setDb,
registerDb,
registerLazyDb,
} from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
@@ -76,6 +84,7 @@ export interface ServeOptions {
plugins?: PluginInput;
observability?: ObservabilityConfig;
tenancy?: TenancyConfig;
navigation?: NavigationConfig;
}
export interface RunningServer {
@@ -217,6 +226,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
plugins: configuredPlugins,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
};
await pluginRunner.configure(pluginConfig);
@@ -304,8 +314,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
}
};
if (opts.db) await connectAndMigrate(null, opts.db);
for (const [name, cfg] of Object.entries(opts.databases ?? {}))
await connectAndMigrate(name, cfg);
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.
@@ -358,6 +374,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
clientRuntimes: pluginContributions.clientRuntimes,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
+6 -9
View File
@@ -25,10 +25,11 @@ import {
type PwaConfig,
type ObservabilityConfig,
type TenancyConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import {
configureStorage,
@@ -133,6 +134,8 @@ export interface ProdOptions {
observability?: ObservabilityConfig;
/** Built-in tenant identity resolution. */
tenancy?: TenancyConfig;
/** Page navigation strategy. */
navigation?: NavigationConfig;
port?: number;
hostname?: string;
maxBodyBytes?: number;
@@ -287,14 +290,7 @@ export function createProductionHandlers(
}
}
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
try {
registerDb(name, connectFromConfig(cfg));
} catch (err) {
console.warn(
`[wrnexus] database '${name}' setup failed:`,
err instanceof Error ? err.message : err,
);
}
registerLazyDb(name, () => connectFromConfig(cfg));
}
// File-upload storage. Relative local dirs resolve against the deployment cwd
@@ -334,6 +330,7 @@ export function createProductionHandlers(
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
navigation: opts.navigation,
maxBodyBytes: opts.maxBodyBytes,
realtimeBus: realtimeBusFromConfig(opts.realtime),
});
+20 -6
View File
@@ -133,6 +133,8 @@ export interface RuntimeDeps {
assetVersion?: string;
/** Package browser runtimes resolved by the plugin system. */
clientRuntimes?: ClientRuntimeDefinition[];
/** Page navigation strategy. `document` disables same-origin link interception. */
navigation?: { mode?: "client" | "document" };
/** Raw HTML appended to every page head (e.g. CDN framework links). */
head?: string;
/** Global SEO defaults. */
@@ -1140,9 +1142,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
i = end;
const normalizedName = name.toLowerCase();
const normalizedName = normalizeComponentName(name);
const component = router.components.find(
(candidate) => candidate.name.toLowerCase() === normalizedName,
(candidate) => normalizeComponentName(candidate.name) === normalizedName,
);
if (!component) {
console.warn(`[wrnexus] no component registered for '${name}'`);
@@ -1312,7 +1314,7 @@ 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, deps.clientRuntimes).map((script) =>
const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) =>
versionRenderScript(script, deps.assetVersion),
);
if (pwaServiceWorkerEnabled)
@@ -1669,6 +1671,17 @@ export function resolveTProps(
return props;
}
/**
* Normalize component identifiers for registry lookup.
*
* WRN declarations use PascalCase (`PublicHeader`) while explicit
* `data-component` mounts commonly use kebab-case (`public-header`) or
* snake_case (`public_header`). All three forms identify the same component.
*/
export function normalizeComponentName(name: string): string {
return name.toLowerCase().replace(/[-_]/g, "");
}
/**
* Decide which framework scripts a rendered page needs. Components are already
* server-rendered into the HTML; the only script is the reactive runtime, and
@@ -1677,10 +1690,11 @@ export function resolveTProps(
export function collectScripts(
body: string,
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
navigation: { mode?: "client" | "document" } = {},
): 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: RenderScript[] = ["/__wrnexus/nav.js"];
// Client navigation is optional. In document mode, links retain native browser
// behavior and each route receives a fresh server-rendered HTML document.
const scripts: RenderScript[] = navigation.mode === "document" ? [] : ["/__wrnexus/nav.js"];
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
scripts.push("/__wrnexus/reactive.js");
}
+1
View File
@@ -60,6 +60,7 @@ const server = await startServer({
plugins: config.plugins,
observability: config.observability,
tenancy: config.tenancy,
navigation: config.navigation,
});
const router = server.router;
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";
import { normalizeComponentName } from "../src/runtime.ts";
test("component lookup treats PascalCase, kebab-case, and snake_case as equivalent", () => {
expect(normalizeComponentName("PublicHeader")).toBe("publicheader");
expect(normalizeComponentName("public-header")).toBe("publicheader");
expect(normalizeComponentName("public_header")).toBe("publicheader");
});
test("component lookup preserves meaningful alphanumeric differences", () => {
expect(normalizeComponentName("Card2")).not.toBe(normalizeComponentName("Card"));
expect(normalizeComponentName("PublicFooter")).not.toBe(normalizeComponentName("PublicHeader"));
});
+13
View File
@@ -49,6 +49,19 @@ test("forward auth preserves intentional verifier redirects", () => {
expect(denied.headers.has("location")).toBe(false);
});
test("forward auth exposes the verifier public origin instead of its internal app port", () => {
const redirected = forwardAuthFailure(
new Response(null, {
status: 302,
headers: { location: "/sign-in?returnTo=%2Fadmin" },
}),
"http://127.0.0.1:85/api/verify",
"http://sso.localhost",
);
expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin");
});
test("gateway reads and strips internal app diagnostics", async () => {
const response = new Response("safe public error", {
status: 500,
@@ -65,3 +65,11 @@ test("collectScripts automatically adds a referenced package runtime once", () =
),
).toHaveLength(1);
});
test("collectScripts omits client navigation in document mode", () => {
const scripts = collectScripts("<main>Server rendered</main>", [], {
mode: "document",
});
expect(scripts).not.toContain("/__wrnexus/nav.js");
});