Compare commits

..
Author SHA1 Message Date
ClintchizandClaude Opus 5 b646ec8d00 chore(release): patch-bump packages changed since the last publish
Quality / quality (ubuntu-latest) (push) Failing after 12m48s
Quality / quality (windows-latest) (push) Canceled after 0s
cli 0.8.42, csr 0.8.22, db 0.8.16, dev-server 0.8.38,
dev-toolbar 0.8.13, i18n 0.8.12.

Every previous version was already on the registry, so the HMR client
repair, the i18n JSON data block, the gateway WebSocket origin fix, and
the generated-dialect stamp were not reachable by consumers.

compiler and react are unchanged since their last publish and are not
bumped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:01:01 +05:30
ClintchizandClaude Opus 5 e66d2425aa fix(gateway): allow HMR sockets on every configured domain
Quality / quality (ubuntu-latest) (push) Failing after 13m52s
Quality / quality (windows-latest) (push) Canceled after 0s
The WebSocket origin check compared the browser's Origin host, which
carries the port, against configured domains, which do not. publicOrigin
only ever matches domains[0], so every other domain fell through to that
comparison and was denied purely on the port: web.localhost:3000 never
matched web.localhost.

The result was a 403 on the HMR upgrade and a client reconnecting
forever, while the page itself loaded fine because HTTP routing resolves
the Host separately.

Compares hostnames now. Unrelated and lookalike-suffix origins are still
denied, and both cases are covered by tests.

Verified through a real gateway: the HMR socket opens on both localhost
and web.localhost, and a live edit reaches the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:53:27 +05:30
ClintchizandClaude Opus 5 b3b65dddd8 fix(db): stamp the dialect into generated query files
Quality / quality (ubuntu-latest) (push) Failing after 12m46s
Quality / quality (windows-latest) (push) Canceled after 0s
The same generate command emitted ? one run and $1 the next, which looked
like non-determinism. It is not: postgres uses $1 placeholders where
sqlite and mysql use ?, and the driver comes from the active profile, so
building under a different profile rewrites this committed file.

The header now records the dialect it was generated for, making the flip
visible in the diff and explaining check:generated-types failures instead
of leaving them looking like random churn.

Worth deciding separately: a committed artifact whose contents depend on
the active profile will keep drifting. Either generate per dialect, or
stop committing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:44:36 +05:30
ClintchizandClaude Opus 5 5dbcc5b85d fix(i18n): ship i18n data as a JSON block so CSP cannot block it
window.__wrnI18n was undefined in development: the payload shipped as an
executable inline script, and a document's CSP nonce is fixed at load, so
any such script arriving from a later response is blocked. Client
translations and language switching silently had no data.

The payload is now a type="application/json" block, which the browser
never executes and script-src therefore never applies to. The i18n
runtime, CSR navigation, and HMR all read the block instead of matching
window.__wrnI18n= with a regex.

Pages now render zero executable inline scripts, so an inline script-src
violation is structurally impossible rather than merely unobserved. Zero
framework JavaScript on island-free routes is unaffected: the block is
inert data, and nothing loads to read it unless the page needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:44:28 +05:30
19 changed files with 183 additions and 48 deletions
+6 -6
View File
@@ -272,7 +272,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.8.41",
"version": "0.8.42",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -322,14 +322,14 @@
},
"packages/csr": {
"name": "@wrnexus/csr",
"version": "0.8.21",
"version": "0.8.22",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.8.15",
"version": "0.8.16",
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3",
@@ -337,7 +337,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.37",
"version": "0.8.38",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -364,7 +364,7 @@
},
"packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar",
"version": "0.8.12",
"version": "0.8.13",
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3",
@@ -397,7 +397,7 @@
},
"packages/i18n": {
"name": "@wrnexus/i18n",
"version": "0.8.11",
"version": "0.8.12",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
+2
View File
@@ -1674,6 +1674,7 @@
"@wrnexus/i18n": {
".": [
"ExtractedTranslationKey",
"I18N_DATA_ATTRIBUTE",
"I18N_JS_HREF",
"I18N_RUNTIME",
"I18nConfig",
@@ -1710,6 +1711,7 @@
"plural",
"pseudoLocalize",
"renderI18nData",
"renderI18nDataTag",
"resolveI18n",
"resolveLang",
"translateHtml",
+1 -1
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
import type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.41",
"version": "0.8.42",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.21",
"version": "0.8.22",
"type": "module",
"main": "src/index.ts",
"exports": {
+2 -7
View File
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
}
function syncI18n(nextDocument) {
var script = Array.prototype.find.call(
nextDocument.querySelectorAll("script:not([src])"),
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
if (!script) return;
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
if (!match) return;
try {
var incoming = JSON.parse(match[1]);
var incoming = JSON.parse(String(script.textContent || "{}"));
var current = window.__wrnI18n || {};
var translator = current.t;
var setter = current.set;
+2 -2
View File
@@ -148,7 +148,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
nextHtml =
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
@@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i
};
nextHtml =
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.15",
"version": "0.8.16",
"private": true,
"type": "module",
"main": "./src/index.ts",
+8 -1
View File
@@ -230,5 +230,12 @@ export function generateQueriesFile(
if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
}
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
// The dialect is stamped into the header because it changes the emitted SQL:
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
// under a different profile therefore rewrites this committed file, and
// without the stamp the diff looks like unexplained churn.
return (
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
);
}
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
test("the generated header records the dialect it was built for", () => {
// The same command emits different SQL per dialect, so a build under another
// profile rewrites the committed file. The stamp makes that visible in the
// diff instead of looking like unexplained churn.
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
});
test("placeholder style follows the dialect", () => {
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
});
test("generation is deterministic for a fixed dialect", () => {
const first = generateQueriesFile(queries, [], "postgres");
const second = generateQueriesFile(queries, [], "postgres");
expect(first).toBe(second);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.37",
"version": "0.8.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+6 -2
View File
@@ -159,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
}
function gatewayWebSocketOriginAllowed(
export function gatewayWebSocketOriginAllowed(
req: Request,
target: Target,
configured: string[],
@@ -174,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
}
if (configured.includes(origin)) return true;
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
// Compare hostnames, not hosts: configured domains carry no port, while the
// browser's Origin does. publicOrigin above only ever matches domains[0], so
// every other domain fell through to here and was denied purely on the port,
// which left the HMR socket reconnecting forever on those hosts.
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
}
/**
+11 -19
View File
@@ -81,7 +81,7 @@ import {
type TenancyConfig,
} from "@wrnexus/styles";
import {
renderI18nData,
renderI18nDataTag,
makeT,
resolveLang,
translateHtml,
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html");
var i18nScript = Array.prototype.find.call(
doc.querySelectorAll("script:not([src])"),
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
if (i18nScript) {
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
if (i18nMatch) {
try {
var incomingI18n = JSON.parse(i18nMatch[1]);
var existingI18n = window.__wrnI18n || {};
incomingI18n.t = existingI18n.t;
incomingI18n.set = existingI18n.set;
window.__wrnI18n = incomingI18n;
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
}
try {
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
var existingI18n = window.__wrnI18n || {};
incomingI18n.t = existingI18n.t;
incomingI18n.set = existingI18n.set;
window.__wrnI18n = incomingI18n;
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
}
}
@@ -1978,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
extraBody:
[
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
deps.i18n
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
: "",
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
]
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
const target = {
name: "web",
origin: "http://127.0.0.1:3101",
domains: ["localhost", "web.localhost"],
publicOrigin: "http://localhost:3000",
} as any;
function upgrade(origin: string, host: string): Request {
return new Request("http://" + host + "/__wrnexus/hmr", {
headers: { origin, host, upgrade: "websocket" },
});
}
test("allows an upgrade from the app's primary domain", () => {
expect(
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
).toBe(true);
});
test("allows an upgrade from a secondary domain on a non-default port", () => {
// publicOrigin is built from domains[0], so a browser on web.localhost falls
// through to the domain list — where the origin host still carries :3000 and
// the configured domain does not. That mismatch denied every HMR socket on
// any domain but the first, leaving the client reconnecting forever.
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://web.localhost:3000", "web.localhost:3000"),
target,
[],
),
).toBe(true);
});
test("still denies an unrelated origin", () => {
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://evil.example:3000", "web.localhost:3000"),
target,
[],
),
).toBe(false);
});
test("still denies a lookalike suffix domain", () => {
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
target,
[],
),
).toBe(false);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.8.12",
"version": "0.8.13",
"private": true,
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.8.11",
"version": "0.8.12",
"private": true,
"type": "module",
"main": "./src/index.ts",
+35 -3
View File
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
.replace(/\u2029/g, "\\u2029");
}
/** Attribute marking the JSON block that carries per-request i18n data. */
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
/**
* The i18n payload, emitted as JSON rather than as an assignment.
*
* It ships inside a `type="application/json"` block, which the browser never
* executes, so `script-src` does not apply to it. As an inline executable
* script it was blocked whenever the surrounding document's CSP nonce came
* from a different response, leaving window.__wrnI18n undefined.
*/
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
const active = i18n.langs.includes(lang) ? lang : i18n.default;
return `window.__wrnI18n=${safeJson({
return `${safeJson({
lang: active,
langs: i18n.langs,
default: i18n.default,
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
directions: i18n.direction,
labels: i18n.labels,
cookie: i18n.cookie,
})};`;
})}`;
}
/** The full JSON block, including its script tag. */
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
}
export const I18N_RUNTIME = String.raw`
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
});
}
function state() { return window.__wrnI18n || {}; }
function readDataBlock() {
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
if (!node) return null;
try {
return JSON.parse(node.textContent || "{}");
} catch (error) {
console.error("[wrnexus] i18n data block was not valid JSON", error);
return null;
}
}
function state() {
if (!window.__wrnI18n) {
var data = readDataBlock();
if (data) window.__wrnI18n = data;
}
return window.__wrnI18n || {};
}
function t(key, params) {
var current = state();
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
test("the i18n payload is plain JSON, not an assignment", () => {
const data = renderI18nData(i18n, "en");
expect(() => JSON.parse(data)).not.toThrow();
expect(data).not.toContain("window.__wrnI18n");
});
test("the data tag is a non-executable JSON block", () => {
// An executable inline script is subject to script-src and gets blocked
// whenever the document's CSP nonce came from a different response, which is
// what left window.__wrnI18n undefined. A JSON block is never executed.
const tag = renderI18nDataTag(i18n, "es");
expect(tag).toContain('type="application/json"');
expect(tag).toContain("data-wrn-i18n");
expect(tag).not.toContain("nonce=");
expect(tag).toContain("Hola");
});
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
});
+1 -1
View File
@@ -145,7 +145,7 @@ check(
);
check(
"i18n runtime data is injected into rendered documents",
has("packages/dev-server/src/runtime.ts", "renderI18nData(deps.i18n, language)") &&
has("packages/dev-server/src/runtime.ts", "renderI18nDataTag(deps.i18n, language)") &&
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
);
check(