Compare commits

...
Author SHA1 Message Date
ClintchizandClaude Opus 5 7d481df652 docs(html-editing): add implementation plan
Eight TDD tasks: the view-block scanner and virtual document, the HTML
service wrapper, merging HTML into completion and hover, folding and
linked editing, auto-close on type, standing down the duplicate client
provider, manifest guards, and a manual editor check.

Task 1 comes first because everything reads positions through it: its
length-and-newline invariant is what removes position mapping, and a
break there would misreport positions everywhere rather than fail.

The last task is manual verification in an Extension Development Host.
Unit tests cannot show that completions actually appear in an editor, and
a green suite has hidden non-functional features in this repo before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:30:34 +05:30
ClintchizandClaude Opus 5 5477f5436d docs(html-editing): add design spec for HTML support in .wrn files
Markup in a .wrn file highlights but has no tag or attribute completion,
no tag closing, and no tag-level folding: the grammar's embeddedLanguages
mapping only affects tokenization, and VS Code's HTML language service
never runs on these documents.

The design extracts view blocks into a virtual HTML document where
everything outside them is blanked to whitespace of identical length, so
source positions and virtual positions are the same and no mapping table
is needed. Region detection is a tolerant scanner rather than the parser,
because completion fires while the document is mid-edit and unparseable.

Completion merges WRNexus and HTML entries into one list ranked by
sortText, which also fixes an existing bug: the extension and the server
both answer completion on '<' today, so VS Code concatenates two lists.

Two decisions worth review:

- HTML formatting is excluded. formatWrn already formats markup, knows
  WRNexus syntax, and would fight a second formatter that is free to
  rewrite spacing inside @click={...} and client:visible.
- Only auto-close-on-type is client-side. Linked editing is standard LSP
  and lives in the shared server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:23:08 +05:30
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
ClintchizandClaude Opus 5 e819c5739e Revert "chore(example): regenerate basic-app queries"
Quality / quality (ubuntu-latest) (push) Failing after 12m38s
Quality / quality (windows-latest) (push) Canceled after 0s
The generator's placeholder style is not deterministic across runs: the
same command emitted $1 once and ? the next time, depending on the
database dialect active in the environment. Restoring the committed
output and reverting my earlier regeneration, which was environment
churn rather than an intended change.

Worth a look on its own: a generator whose output depends on ambient
environment makes check:generated-types environment-sensitive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:26:07 +05:30
ClintchizandClaude Opus 5 ac164789bd fix(dev): serve the HMR client as an external script
A document's CSP nonce is fixed at load, so an inline script delivered by
a later response can never carry a nonce that document accepts. The HMR
client is now served at /__wrnexus/hmr-client.js, which script-src 'self'
already covers and which needs no nonce at all.

This removes one of the two inline scripts CSP was blocking in
development. The i18n data script is still blocked and needs the same
treatment; it is shared with the CSR navigation and HMR parsers, so
moving it spans @wrnexus/i18n, csr, and dev-server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:25:40 +05:30
ClintchizandClaude Opus 5 88ec3a5cb6 chore(example): regenerate basic-app queries
Regenerated output for the committed query generator: positional
placeholders now render as $1 rather than ?.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:15 +05:30
ClintchizandClaude Opus 5 28085b5a43 chore(db,scripts): pending migration and packaging tweaks
Pre-existing working-tree changes to migration SQL parsing, query
generation, and the packaging scripts. Committed as-is rather than
authored here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:02 +05:30
ClintchizandClaude Opus 5 5d7bd81601 fix(dev-toolbar): report development-appropriate budgets
Excludes the toolbar's own bundle from the JavaScript budget, raises the
development thresholds, and skips WRNexus UI and theme stylesheets when
measuring CSS coverage, so unminified development modules and framework
styles stop reading as application problems.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:17:01 +05:30
ClintchizandClaude Opus 5 5e114d867f feat(gateway): forward application identity on WebSocket upgrades
Adds gatewayWebSocketBackendHeaders so proxied upgrades carry application
identity while Bun keeps ownership of WebSocket framing.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:54 +05:30
ClintchizandClaude Opus 5 10421465df fix(styles): stop scanning every component for utility sources
Component discovery is not utility-source discovery: scanning all
built-in and plugin component directories made Tailwind/Iconify generate
rules for components the app never renders. Packages that need scanning
opt in through styles.source.

Pre-existing working-tree change, committed as-is rather than authored
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:53 +05:30
ClintchizandClaude Opus 5 52cce2c628 style(docs): apply Prettier to the React islands spec and plan
Formatting only; no content change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:45 +05:30
ClintchizandClaude Opus 5 afa2a8c093 chore(deps): move packages to TypeScript 6.0.3
Raises the typescript devDependency across the workspace, bumps package
versions, re-adds ignoreDeprecations, and repoints the @wrnexus registry.

These were pre-existing working-tree changes, committed as-is rather than
authored here. The .npmrc change redirects @wrnexus publishes from
registry.npmjs.org to registry.workroot.in — confirm that is intended
before publishing from this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:34 +05:30
57 changed files with 1922 additions and 249 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
@wrnexus:registry=https://registry.npmjs.org/ @wrnexus:registry=https://registry.workroot.in/repository/npm/
audit=true audit=true
fund=false fund=false
+8 -8
View File
@@ -272,7 +272,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.36", "version": "0.8.42",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -287,6 +287,7 @@
"@wrnexus/mcp": "workspace:*", "@wrnexus/mcp": "workspace:*",
"@wrnexus/playground": "workspace:*", "@wrnexus/playground": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
"@wrnexus/react": "workspace:*",
"@wrnexus/router": "workspace:*", "@wrnexus/router": "workspace:*",
"@wrnexus/security": "workspace:*", "@wrnexus/security": "workspace:*",
"@wrnexus/styles": "workspace:*", "@wrnexus/styles": "workspace:*",
@@ -299,9 +300,8 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.10", "version": "0.8.11",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*", "@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
@@ -322,14 +322,14 @@
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.21", "version": "0.8.22",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
}, },
"packages/db": { "packages/db": {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.8.15", "version": "0.8.16",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -337,7 +337,7 @@
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.33", "version": "0.8.38",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*", "@wrnexus/cache": "workspace:*",
@@ -364,7 +364,7 @@
}, },
"packages/dev-toolbar": { "packages/dev-toolbar": {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.8.10", "version": "0.8.13",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -397,7 +397,7 @@
}, },
"packages/i18n": { "packages/i18n": {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.8.11", "version": "0.8.12",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
+2
View File
@@ -1674,6 +1674,7 @@
"@wrnexus/i18n": { "@wrnexus/i18n": {
".": [ ".": [
"ExtractedTranslationKey", "ExtractedTranslationKey",
"I18N_DATA_ATTRIBUTE",
"I18N_JS_HREF", "I18N_JS_HREF",
"I18N_RUNTIME", "I18N_RUNTIME",
"I18nConfig", "I18nConfig",
@@ -1710,6 +1711,7 @@
"plural", "plural",
"pseudoLocalize", "pseudoLocalize",
"renderI18nData", "renderI18nData",
"renderI18nDataTag",
"resolveI18n", "resolveI18n",
"resolveLang", "resolveLang",
"translateHtml", "translateHtml",
@@ -30,26 +30,26 @@
**New package `packages/react/`:** **New package `packages/react/`:**
| File | Responsibility | | File | Responsibility |
|---|---| | ------------------------ | ------------------------------------------------------------------ |
| `package.json` | Package manifest; optional peer deps | | `package.json` | Package manifest; optional peer deps |
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. | | `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` | | `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
| `src/error-boundary.tsx` | Per-island React error boundary | | `src/error-boundary.tsx` | Per-island React error boundary |
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry | | `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) | | `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
| `src/index.ts` | Public exports | | `src/index.ts` | Public exports |
**Modified:** **Modified:**
| File | Change | | File | Change |
|---|---| | ------------------------------------------------- | ------------------------------------------------------------- |
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` | | `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
| `packages/compiler/src/island-codegen.ts` *(new)* | Marker emission, props serialization, diagnostics | | `packages/compiler/src/island-codegen.ts` _(new)_ | Marker emission, props serialization, diagnostics |
| `packages/compiler/src/island-bundle.ts` *(new)* | Island entry generation + `Bun.build` with shared React chunk | | `packages/compiler/src/island-bundle.ts` _(new)_ | Island entry generation + `Bun.build` with shared React chunk |
| `packages/dev-server/src/assets.ts` | Serve island routes in dev | | `packages/dev-server/src/assets.ts` | Serve island routes in dev |
| `packages/dev-server/src/prod.ts` | Serve island routes in prod | | `packages/dev-server/src/prod.ts` | Serve island routes in prod |
| `packages/cli/src/build.ts` | Emit island assets in static build | | `packages/cli/src/build.ts` | Emit island assets in static build |
--- ---
@@ -58,11 +58,13 @@
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation. The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
**Files:** **Files:**
- Create: `packages/react/package.json` - Create: `packages/react/package.json`
- Create: `packages/react/src/snapshot-cache.ts` - Create: `packages/react/src/snapshot-cache.ts`
- Test: `packages/react/test/snapshot-cache.test.ts` - Test: `packages/react/test/snapshot-cache.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: nothing. - Consumes: nothing.
- Produces: - Produces:
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>` - `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
@@ -193,9 +195,7 @@ export interface SnapshotCache<S extends object> {
* and spins if given a fresh object each call, which `@wrnexus/store`'s * and spins if given a fresh object each call, which `@wrnexus/store`'s
* `readonlySnapshot` does by design. * `readonlySnapshot` does by design.
*/ */
export function createSnapshotCache<S extends object>( export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
source: SnapshotSource<S>,
): SnapshotCache<S> {
let cached: Readonly<S> | undefined; let cached: Readonly<S> | undefined;
let dirty = true; let dirty = true;
@@ -259,12 +259,14 @@ git commit -m "feat(react): add referentially-stable snapshot and selector cache
### Task 2: Store bridge hook ### Task 2: Store bridge hook
**Files:** **Files:**
- Create: `packages/react/src/store-bridge.ts` - Create: `packages/react/src/store-bridge.ts`
- Create: `packages/react/src/index.ts` - Create: `packages/react/src/index.ts`
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests - Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
- Test: `packages/react/test/store-bridge.test.ts` - Test: `packages/react/test/store-bridge.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1. - Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
- Produces: - Produces:
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R` - `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
@@ -436,10 +438,12 @@ git commit -m "feat(react): add useWrnStore bridge over useSyncExternalStore"
### Task 3: Island marker codegen ### Task 3: Island marker codegen
**Files:** **Files:**
- Create: `packages/compiler/src/island-codegen.ts` - Create: `packages/compiler/src/island-codegen.ts`
- Test: `packages/compiler/test/island-codegen.test.ts` - Test: `packages/compiler/test/island-codegen.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`). - Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
- Produces: - Produces:
- `type IslandStrategy = "only" | "load" | "visible" | "idle"` - `type IslandStrategy = "only" | "load" | "visible" | "idle"`
@@ -596,10 +600,12 @@ git commit -m "feat(compiler): add island marker codegen and props contract"
### Task 4: Resolve `.tsx` imports as islands ### Task 4: Resolve `.tsx` imports as islands
**Files:** **Files:**
- Modify: `packages/compiler/src/import-resolver.ts` - Modify: `packages/compiler/src/import-resolver.ts`
- Test: `packages/compiler/test/island-resolution.test.ts` - Test: `packages/compiler/test/island-resolution.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: nothing from prior tasks. - Consumes: nothing from prior tasks.
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`. - Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
@@ -705,12 +711,12 @@ export interface ResolvedImport {
Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to: Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to:
```ts ```ts
if (found) { if (found) {
const resolved = realpathSync(found); const resolved = realpathSync(found);
return resolved.endsWith(".tsx") return resolved.endsWith(".tsx")
? { declaration, resolved, kind: "island" } ? { declaration, resolved, kind: "island" }
: { declaration, resolved }; : { declaration, resolved };
} }
``` ```
- [ ] **Step 5: Run test to verify it passes** - [ ] **Step 5: Run test to verify it passes**
@@ -735,10 +741,12 @@ git commit -m "feat(compiler): resolve .tsx imports and tag them as islands"
### Task 5: Island error boundary ### Task 5: Island error boundary
**Files:** **Files:**
- Create: `packages/react/src/error-boundary.tsx` - Create: `packages/react/src/error-boundary.tsx`
- Test: `packages/react/test/error-boundary.test.tsx` - Test: `packages/react/test/error-boundary.test.tsx`
**Interfaces:** **Interfaces:**
- Consumes: nothing from prior tasks. - Consumes: nothing from prior tasks.
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`. - Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
@@ -857,11 +865,13 @@ git commit -m "feat(react): add per-island error boundary"
### Task 6: Island runtime — mount, strategies, unmount ### Task 6: Island runtime — mount, strategies, unmount
**Files:** **Files:**
- Create: `packages/react/src/island-runtime.ts` - Create: `packages/react/src/island-runtime.ts`
- Modify: `packages/react/src/index.ts` - Modify: `packages/react/src/index.ts`
- Test: `packages/react/test/island-runtime.test.ts` - Test: `packages/react/test/island-runtime.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2). - Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
- Produces: - Produces:
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>` - `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
@@ -1070,10 +1080,12 @@ git commit -m "feat(react): add island mount strategies and navigation-safe unmo
### Task 7: Island bundling with a shared React chunk ### Task 7: Island bundling with a shared React chunk
**Files:** **Files:**
- Create: `packages/compiler/src/island-bundle.ts` - Create: `packages/compiler/src/island-bundle.ts`
- Test: `packages/compiler/test/island-bundle.test.ts` - Test: `packages/compiler/test/island-bundle.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: nothing from prior tasks. - Consumes: nothing from prior tasks.
- Produces: - Produces:
- `generateIslandEntry(input: { name: string; sourcePath: string }): string` - `generateIslandEntry(input: { name: string; sourcePath: string }): string`
@@ -1226,6 +1238,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
### Task 8: Serve island assets in dev, prod, and static build ### Task 8: Serve island assets in dev, prod, and static build
**Files:** **Files:**
- Create: `packages/react/src/runtime-source.ts` - Create: `packages/react/src/runtime-source.ts`
- Modify: `packages/dev-server/src/assets.ts` - Modify: `packages/dev-server/src/assets.ts`
- Modify: `packages/dev-server/src/prod.ts` - Modify: `packages/dev-server/src/prod.ts`
@@ -1233,6 +1246,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
- Test: `packages/react/test/runtime-source.test.ts` - Test: `packages/react/test/runtime-source.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `mountIslands`, `unmountIslands` (Task 6). - Consumes: `mountIslands`, `unmountIslands` (Task 6).
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`. - Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
@@ -1323,15 +1337,15 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines: Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:
```ts ```ts
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true)); if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
``` ```
The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`: The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`:
```ts ```ts
if (pathname.startsWith("/__wrnexus/island/")) { if (pathname.startsWith("/__wrnexus/island/")) {
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 }); return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
} }
``` ```
where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`. where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`.
@@ -1341,8 +1355,8 @@ where `serveIslandArtifact` reads from the island build output directory produce
In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route: In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route:
```ts ```ts
if (pathname === "/__wrnexus/islands.js") if (pathname === "/__wrnexus/islands.js")
return new Response(getIslandRuntime(), { headers: JS_HEADERS }); return new Response(getIslandRuntime(), { headers: JS_HEADERS });
``` ```
Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory. Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory.
@@ -1375,10 +1389,12 @@ git commit -m "feat(islands): serve the island runtime in dev, prod, and static
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong. A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
**Files:** **Files:**
- Modify: `packages/compiler/src/analysis.ts` - Modify: `packages/compiler/src/analysis.ts`
- Test: `packages/compiler/test/island-classification.test.ts` - Test: `packages/compiler/test/island-classification.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `ResolvedImport.kind` (Task 4). - Consumes: `ResolvedImport.kind` (Task 4).
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`. - Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
@@ -1457,10 +1473,12 @@ git commit -m "feat(compiler): classify island routes as static-interactive"
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them. Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
**Files:** **Files:**
- Create: `examples/basic-app/app/islands/Counter.tsx` - Create: `examples/basic-app/app/islands/Counter.tsx`
- Test: `packages/compiler/test/island-integration.test.ts` - Test: `packages/compiler/test/island-integration.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9). - Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
- Produces: nothing consumed downstream. - Produces: nothing consumed downstream.
@@ -1493,10 +1511,7 @@ import { join, resolve } from "node:path";
import { buildIslands } from "../src/island-bundle.ts"; import { buildIslands } from "../src/island-bundle.ts";
import { routeNeedsIslands } from "../src/analysis.ts"; import { routeNeedsIslands } from "../src/analysis.ts";
const COUNTER = resolve( const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
import.meta.dir,
"../../../examples/basic-app/app/islands/Counter.tsx",
);
test("a route with no islands ships zero framework JavaScript", async () => { test("a route with no islands ships zero framework JavaScript", async () => {
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-")); const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
@@ -1522,8 +1537,8 @@ test("a page with multiple islands ships React exactly once", async () => {
.filter((file) => file.endsWith(".js")) .filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(outDir, file), "utf8")); .map((file) => readFileSync(join(outDir, file), "utf8"));
const withReactInternals = bundles.filter((source) => const withReactInternals = bundles.filter(
source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"), (source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
); );
expect(result.assets).toHaveLength(2); expect(result.assets).toHaveLength(2);
@@ -1564,12 +1579,14 @@ git commit -m "test(islands): guard zero-JS routes and single-React bundling"
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly. The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
**Files:** **Files:**
- Create: `packages/react/src/render-phase.ts` - Create: `packages/react/src/render-phase.ts`
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`) - Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
- Modify: `packages/react/src/index.ts` - Modify: `packages/react/src/index.ts`
- Test: `packages/react/test/render-phase.test.ts` - Test: `packages/react/test/render-phase.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `useWrnActions` (Task 2). - Consumes: `useWrnActions` (Task 2).
- Produces: - Produces:
- `beginRenderPhase(): void` - `beginRenderPhase(): void`
@@ -1730,12 +1747,14 @@ git commit -m "feat(react): fail loudly on store writes during island render"
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later. On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
**Files:** **Files:**
- Modify: `packages/react/src/island-runtime.ts` - Modify: `packages/react/src/island-runtime.ts`
- Modify: `packages/react/src/runtime-source.ts` - Modify: `packages/react/src/runtime-source.ts`
- Modify: `packages/react/src/index.ts` - Modify: `packages/react/src/index.ts`
- Test: `packages/react/test/island-hmr.test.ts` - Test: `packages/react/test/island-hmr.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `mountIslands`, `unmountIslands` (Task 6). - Consumes: `mountIslands`, `unmountIslands` (Task 6).
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>` - Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
@@ -1751,8 +1770,7 @@ import { islandRootCount, mountIslands, remountIslands } from "../src/island-run
test("remount replaces island output without leaking roots", async () => { test("remount replaces island output without leaking roots", async () => {
const window = new Window(); const window = new Window();
window.document.body.innerHTML = window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
data-wrn-island-props='{}'></div>`; data-wrn-island-props='{}'></div>`;
(globalThis as any).window = window; (globalThis as any).window = window;
(globalThis as any).document = window.document; (globalThis as any).document = window.document;
File diff suppressed because it is too large Load Diff
@@ -28,14 +28,14 @@ byte-for-byte unchanged, and a route with no islands must ship no React.
## Decisions ## Decisions
| Question | Decision | | Question | Decision |
|---|---| | ---------------- | ---------------------------------------------------------------------------------------- |
| Purpose | npm ecosystem access | | Purpose | npm ecosystem access |
| Server rendering | Client-only by default; SSR opt-in deferred to v2 | | Server rendering | Client-only by default; SSR opt-in deferred to v2 |
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` | | Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) | | Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
| Bundling | Extend the existing Bun pipeline | | Bundling | Extend the existing Bun pipeline |
| Packaging | New isolated package `@wrnexus/react` | | Packaging | New isolated package `@wrnexus/react` |
## Architecture ## Architecture
@@ -100,7 +100,7 @@ separate island hydration channel is introduced.
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a `readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a **new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
referentially identical value when nothing has changed; otherwise React throws referentially identical value when nothing has changed; otherwise React throws
*"The result of getSnapshot should be cached to avoid an infinite loop"* and spins. _"The result of getSnapshot should be cached to avoid an infinite loop"_ and spins.
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds **The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
one cached snapshot per store instance, returns the same reference until the store's `subscribe` one cached snapshot per store instance, returns the same reference until the store's `subscribe`
@@ -182,15 +182,15 @@ point.
## Error handling ## Error handling
| Condition | Behavior | | Condition | Behavior |
|---|---| | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure | | `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact | | Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered | | Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` | | Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined | | Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. | | Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
| Cleanup throws on unmount | Caught and logged; navigation must not break | | Cleanup throws on unmount | Caught and logged; navigation must not break |
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
the rest of the page working. the rest of the page working.
@@ -0,0 +1,252 @@
# HTML editing support for `.wrn` files — Design
**Date:** 2026-08-18
**Status:** Approved for implementation
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
## Goal
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
tag-level folding.
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html``html`), which is
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
### Non-goals
- **HTML formatting.** See "Formatting is deliberately excluded" below.
- Editor support outside VS Code beyond what standard LSP gives for free.
- Changing `.wrn` syntax or the compiler.
## Decisions
| Question | Decision |
| ------------------- | ---------------------------------------------------------------------------- |
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
| Region detection | Tolerant scanner over a virtual document, not the AST |
| HTML knowledge | `vscode-html-languageservice` |
| Formatting | Excluded — `formatWrn` already owns markup formatting |
## Architecture
### Virtual HTML document
New module: `packages/language-server/src/html-regions.ts`, exporting
`virtualHtmlDocument(document)`.
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
newlines preserved. The virtual document therefore has the same size and the same line/column
geometry as the source, so a position in the source _is_ the position in the virtual document.
No mapping table and no translation layer.
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
compacts code and carries line mappings back to source. Compaction is necessary there because
the output must be valid TypeScript. HTML has no such requirement, so the simpler
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
does not arise.
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
newlines at identical offsets. If this breaks, every feature reports positions off by some
amount rather than failing loudly.
### Region detection
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
while the document is being typed, which is exactly when it does not parse. The scanner finds
`view` followed by `{` and tracks brace depth to the matching close.
Two hazards it must handle, both of which defeat a naive implementation:
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
delimiter anywhere will consider the rest of the file one open string and lose every later
region. Quotes are tracked only inside attribute values, never in text nodes.
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
depth must be counted rather than scanning for the next `}`.
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
costs a single scan.
## Completion
### The server becomes the single authority inside view blocks
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
virtual document is non-blank there, which costs one character lookup.
**Inside a view block**, one list is assembled from two sources:
| Source | `sortText` prefix | Content |
| ------- | ----------------- | ------------------------------------------------------------------------ |
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
| HTML | `1` | Tags, attributes, attribute values |
`sortText` drives ordering independently of the label, so components rank above HTML tags
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
plus workspace items.
The server already indexes components, props, outputs, and slots
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
of the merge are already available to it.
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
resolves to the component.
### Trigger characters
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
`" "`, `"="`, `"\""`, and `"/"`.
### This fixes an existing bug
The extension's `completion.js` registers its own provider with `<` among its trigger
characters, and the language server answers `textDocument/completion` as well. VS Code
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
involved at all.
As part of this work the extension's provider returns nothing when the position is inside a view
block, and keeps its current behaviour elsewhere. One owner per context.
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
context, so future component-intelligence work belongs in the server rather than in
`completion.js`.
## Hover
`textDocument/hover` answers from the HTML service over the virtual document when the position
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
region, existing hover behaviour is unchanged.
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
any HTML entry of the same name, matching the completion precedence rule above.
## Tag handling
### Linked editing is standard LSP
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
it lives in the shared server like everything else.
### Auto-close on type is the one client-side piece
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
client-side, and this follows the same shape:
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
snippet or `null`.
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
`wrnexus.*` naming.
### Emmet
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
edge is real.
## Folding
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
the virtual document, filtered to view regions.
Today folding comes only from `language-configuration.json` markers, which work at block level
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
to work unchanged and tag folding appears inside markup.
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
outside a view region would let a fold swallow a brace boundary.
## Formatting is deliberately excluded
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
`printWidth`. It is a markup formatter that understands WRNexus syntax.
Adding HTML formatting would do two harmful things:
- **Two formatters would fight.** Output would depend on which ran last.
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
## Dependencies
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
`editors/vscode`.
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
and passes other `require`s through to Node, so the package must be resolvable at runtime from
the extension. `editors/vscode` currently ships exactly one runtime dependency
(`vscode-languageclient`); this adds the second.
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
## Testing
### Region scanner (`packages/language-server/test/`)
- **The invariant**, property-style across fixtures: virtual text length equals source length and
newlines sit at identical offsets.
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
found.
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
completion, not an edge case.
- **Multiple view blocks**, and files with none.
### Completion
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
- Outside a view block: response identical to current behaviour — the guard proving non-markup
contexts are undisturbed.
- Collision: a component named `Table` yields one entry, the component.
### Tag handling
- `<div>``</div>`; `<br>` → nothing; `<Card /``/>`; outside a view region → `null`.
- Linked editing returns ranges covering both the opening and closing tag names.
### Hover
- Inside a view region, a known tag returns HTML documentation.
- A component name returns the component detail, not an HTML entry of the same name.
### Folding
- Every returned range lies inside a view region.
- Block-level marker folding still works.
### Toolchain guards
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
- Manifest assertion that `emmet.includeLanguages` maps `wrn``html`, alongside the existing
marketplace checks in `editors/vscode/test`.
## Deferred
- HTML formatting — see above; improve `formatWrn` instead.
- Moving the remaining `completion.js` component intelligence into the server. This design only
requires it to stand down inside view blocks; relocating the rest is follow-up work.
+5 -5
View File
@@ -19,10 +19,10 @@
"@wrnexus/validation": "workspace:*" "@wrnexus/validation": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.0", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.0.0", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+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 type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts"; import { users } from "./schema.ts";
+6 -6
View File
@@ -23,12 +23,12 @@
"devDependencies": { "devDependencies": {
"@wrnexus/test": "workspace:*", "@wrnexus/test": "workspace:*",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.118", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"eslint": "^10.8.0", "eslint": "^10.8.1",
"prettier": "^3.9.4", "prettier": "^3.9.6",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript-eslint": "^8.65.0" "typescript-eslint": "^8.67.0"
} }
} }
+5 -5
View File
@@ -16,10 +16,10 @@
"@wrnexus/validation": "workspace:*" "@wrnexus/validation": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.0", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.0.0", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+4 -4
View File
@@ -18,10 +18,10 @@
"@wrnexus/ui": "workspace:*" "@wrnexus/ui": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.118", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.5.0" "typescript": "^6.0.3"
} }
} }
+4 -4
View File
@@ -16,10 +16,10 @@
"@wrnexus/ui": "workspace:*" "@wrnexus/ui": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.118", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check" "check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
}, },
"dependencies": { "dependencies": {
"@wrnexus/ai": "0.8.6", "@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "0.8.6", "@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "0.8.6", "@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "0.8.6", "@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "0.8.6", "@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "0.8.6", "@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "0.8.6", "@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "0.8.6", "@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "0.8.6", "@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "0.8.6", "@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "0.8.6", "@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "0.8.6", "@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "0.8.6", "@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "0.8.6", "@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "0.8.6", "@wrnexus/security": "^0.8.8",
"@wrnexus/store": "0.8.6", "@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "0.8.6", "@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "0.8.6", "@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "0.8.6", "@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "0.8.6", "@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "0.8.6", "@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "0.8.6" "@wrnexus/authz": "^0.8.9"
}, },
"devDependencies": { "devDependencies": {
"@wrnexus/cli": "0.8.6", "@wrnexus/cli": "^0.8.36",
"@eslint/js": "^9.0.0", "@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.118", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"eslint": "^9.0.0", "eslint": "^10.8.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.5.0", "typescript": "^6.0.3",
"typescript-eslint": "^8.65.0" "typescript-eslint": "^8.67.0"
} }
} }
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check" "check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
}, },
"dependencies": { "dependencies": {
"@wrnexus/ai": "0.8.6", "@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "0.8.6", "@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "0.8.6", "@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "0.8.6", "@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "0.8.6", "@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "0.8.6", "@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "0.8.6", "@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "0.8.6", "@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "0.8.6", "@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "0.8.6", "@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "0.8.6", "@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "0.8.6", "@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "0.8.6", "@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "0.8.6", "@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "0.8.6", "@wrnexus/security": "^0.8.8",
"@wrnexus/store": "0.8.6", "@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "0.8.6", "@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "0.8.6", "@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "0.8.6", "@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "0.8.6", "@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "0.8.6", "@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "0.8.6" "@wrnexus/authz": "^0.8.9"
}, },
"devDependencies": { "devDependencies": {
"@wrnexus/cli": "0.8.6", "@wrnexus/cli": "^0.8.36",
"@eslint/js": "^9.0.0", "@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.118", "@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0", "@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"eslint": "^9.0.0", "eslint": "^10.8.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.5.0", "typescript": "^6.0.3",
"typescript-eslint": "^8.65.0" "typescript-eslint": "^8.67.0"
} }
} }
+5 -5
View File
@@ -21,12 +21,12 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check" "check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
}, },
"devDependencies": { "devDependencies": {
"@wrnexus/cli": "0.8.6", "@wrnexus/cli": "^0.8.36",
"@eslint/js": "^9.0.0", "@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"eslint": "^9.0.0", "eslint": "^10.8.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^5.5.0", "typescript": "^6.0.3",
"typescript-eslint": "^8.65.0" "typescript-eslint": "^8.67.0"
} }
} }
@@ -12,6 +12,6 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@wrnexus/pubsub": "0.8.6" "@wrnexus/pubsub": "^0.8.9"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/auth", "name": "@wrnexus/auth",
"version": "0.8.11", "version": "0.8.12",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@@ -54,7 +54,7 @@
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2" "typescript": "^6.0.3"
}, },
"wrnexus": { "wrnexus": {
"plugin": { "plugin": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/captcha", "name": "@wrnexus/captcha",
"version": "0.8.10", "version": "0.8.11",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.", "description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@@ -48,7 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*" "@wrnexus/syntax": "workspace:*"
}, },
"wrnexus": { "wrnexus": {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.36", "version": "0.8.42",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
@@ -28,6 +28,7 @@
"@wrnexus/db": "workspace:*", "@wrnexus/db": "workspace:*",
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
"@wrnexus/react": "workspace:*",
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*", "@wrnexus/typecheck": "workspace:*",
"@wrnexus/security": "workspace:*", "@wrnexus/security": "workspace:*",
+8 -4
View File
@@ -620,10 +620,14 @@ export async function runBuild(appRoot: string): Promise<void> {
appDir, appDir,
appRoot: root, appRoot: root,
mode: "production", mode: "production",
sources: [ // Component discovery is not style discovery. Built-in and plugin
...componentDirs, // components own their CSS; scanning every available component makes
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])), // Tailwind/Iconify generate rules for packages and components the app
], // never renders. Packages that intentionally use app utilities opt in
// through an explicit styles.source contribution.
sources: pluginContributions.styles.flatMap((style) =>
style.source ? [style.source] : [],
),
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])), entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
}, },
config.styles, config.styles,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.10", "version": "0.8.11",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/content", "name": "@wrnexus/content",
"version": "0.8.8", "version": "0.8.9",
"type": "module", "type": "module",
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.", "description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -14,6 +14,6 @@
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.21", "version": "0.8.22",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+2 -7
View File
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
} }
function syncI18n(nextDocument) { function syncI18n(nextDocument) {
var script = Array.prototype.find.call( var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
nextDocument.querySelectorAll("script:not([src])"),
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
if (!script) return; if (!script) return;
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
if (!match) return;
try { try {
var incoming = JSON.parse(match[1]); var incoming = JSON.parse(String(script.textContent || "{}"));
var current = window.__wrnI18n || {}; var current = window.__wrnI18n || {};
var translator = current.t; var translator = current.t;
var setter = current.set; 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) }; win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
nextHtml = nextHtml =
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` + `<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>`; `</body></html>`;
win.document.getElementById("lnk").click(); win.document.getElementById("lnk").click();
@@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i
}; };
nextHtml = nextHtml =
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` + `<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>`; `</body></html>`;
win.document.getElementById("lnk").click(); win.document.getElementById("lnk").click();
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.8.14", "version": "0.8.16",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -26,6 +26,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+9 -4
View File
@@ -226,11 +226,16 @@ export function generateQueriesFile(
); );
} }
const imports = [ const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
];
if (usedModels.size > 0) { if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`); 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`
);
} }
+4 -3
View File
@@ -91,9 +91,10 @@ function additiveColumnTarget(sql: string): { table: string; column: string } |
.replace(/\/\*[\s\S]*?\*\//g, " ") .replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\r\n]*/g, " ") .replace(/--[^\r\n]*/g, " ")
.trim(); .trim();
const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec( const match =
executable, /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
); executable,
);
return match ? { table: match[1]!, column: match[2]! } : undefined; return match ? { table: match[1]!, column: match[2]! } : undefined;
} }
+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", "name": "@wrnexus/dev-server",
"version": "0.8.33", "version": "0.8.38",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+2
View File
@@ -34,6 +34,7 @@ import type { AssetServer } from "./runtime.ts";
import { servePublicAsset } from "./public.ts"; import { servePublicAsset } from "./public.ts";
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts"; import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts"; import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
import { HMR_CLIENT_HREF, HMR_CLIENT_JS } from "./runtime.ts";
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */ /** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
export interface DevStyles { export interface DevStyles {
@@ -99,6 +100,7 @@ export function createDevAssetServer(
if (pathname.startsWith("/__wrnexus/island/")) { if (pathname.startsWith("/__wrnexus/island/")) {
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 }); return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
} }
if (pathname === HMR_CLIENT_HREF) return jsResponse(HMR_CLIENT_JS);
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true)); if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true)); if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
if (pathname === "/__wrnexus/controllers.js") if (pathname === "/__wrnexus/controllers.js")
+43 -3
View File
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
interface WsBridge { interface WsBridge {
origin: string; origin: string;
path: string; path: string;
headers: Record<string, string>;
backend?: WebSocket; backend?: WebSocket;
queue: Array<string | ArrayBuffer>; queue: Array<string | ArrayBuffer>;
maxMessageBytes: number; maxMessageBytes: number;
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength; return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
} }
function gatewayWebSocketOriginAllowed( export function gatewayWebSocketOriginAllowed(
req: Request, req: Request,
target: Target, target: Target,
configured: string[], configured: string[],
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
} }
if (configured.includes(origin)) return true; if (configured.includes(origin)) return true;
if (target.publicOrigin && origin === new URL(target.publicOrigin).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());
} }
/** /**
@@ -433,6 +438,30 @@ export function gatewayProxyHeaders(
return headers; return headers;
} }
/** Forward application identity to the child while leaving WebSocket framing to Bun. */
export function gatewayWebSocketBackendHeaders(
req: Request,
url: URL,
ip: string,
forwardedHeaders: boolean,
backendOrigin: string,
): Record<string, string> {
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
);
headers.delete("host");
headers.delete("connection");
headers.delete("upgrade");
headers.delete("accept-encoding");
for (const name of [...headers.keys()]) {
if (name.startsWith("sec-websocket-")) headers.delete(name);
}
// The public origin was validated at the gateway edge. The child receives a
// new, trusted same-origin connection from its private gateway listener.
headers.set("origin", backendOrigin);
return Object.fromEntries(headers);
}
/** Remove headers that only a direct workspace-to-app request may supply. */ /** Remove headers that only a direct workspace-to-app request may supply. */
export function stripUntrustedInternalHeaders(headers: Headers): Headers { export function stripUntrustedInternalHeaders(headers: Headers): Headers {
const sanitized = new Headers(headers); const sanitized = new Headers(headers);
@@ -692,6 +721,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
data: { data: {
origin: target.origin, origin: target.origin,
path: url.pathname + url.search, path: url.pathname + url.search,
headers: gatewayWebSocketBackendHeaders(
req,
url,
ip,
forwardedHeaders,
target.origin,
),
queue: [], queue: [],
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024, maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100, maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
@@ -753,7 +789,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
websocket: { websocket: {
open(ws) { open(ws) {
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path; const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
const backend = new WebSocket(backendUrl); const BackendWebSocket = WebSocket as unknown as new (
url: string,
options: Bun.WebSocketOptions,
) => WebSocket;
const backend = new BackendWebSocket(backendUrl, { headers: ws.data.headers });
ws.data.backend = backend; ws.data.backend = backend;
backend.addEventListener("open", () => { backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m); for (const m of ws.data.queue) backend.send(m);
+3 -4
View File
@@ -440,10 +440,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
config: opts.stylesConfig, config: opts.stylesConfig,
appRoot, appRoot,
publicDir: join(appRoot, "public"), publicDir: join(appRoot, "public"),
sources: [ // Component discovery is separate from utility-source discovery.
...componentDirs, // Packages that need Tailwind scanning opt in via styles.source.
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])), sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])), entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
}, },
theme, theme,
+24 -21
View File
@@ -81,7 +81,7 @@ import {
type TenancyConfig, type TenancyConfig,
} from "@wrnexus/styles"; } from "@wrnexus/styles";
import { import {
renderI18nData, renderI18nDataTag,
makeT, makeT,
resolveLang, resolveLang,
translateHtml, translateHtml,
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
pendingSync = false; pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html"); var doc = new DOMParser().parseFromString(html, "text/html");
var i18nScript = Array.prototype.find.call( var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
doc.querySelectorAll("script:not([src])"),
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
if (i18nScript) { if (i18nScript) {
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim()); try {
if (i18nMatch) { var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
try { var existingI18n = window.__wrnI18n || {};
var incomingI18n = JSON.parse(i18nMatch[1]); incomingI18n.t = existingI18n.t;
var existingI18n = window.__wrnI18n || {}; incomingI18n.set = existingI18n.set;
incomingI18n.t = existingI18n.t; window.__wrnI18n = incomingI18n;
incomingI18n.set = existingI18n.set; } catch (error) {
window.__wrnI18n = incomingI18n; console.error("[wrnexus] failed to synchronize i18n HMR data", error);
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
}
} }
} }
@@ -878,8 +872,19 @@ function randomNonce(): string {
} }
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */ /** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
function hmrClientTag(nonce: string): string { /** Path the dev asset server publishes the HMR client on. */
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`; export const HMR_CLIENT_HREF = "/__wrnexus/hmr-client.js";
/**
* The HMR client is served as an external module rather than inlined.
*
* A document's CSP nonce is fixed at load, so an inline script arriving from a
* later response — which is exactly what an HMR reload produces — can never
* carry a nonce this document accepts. An external file is covered by
* script-src 'self' and needs no nonce at all.
*/
function hmrClientTag(_nonce: string): string {
return `<script src="${HMR_CLIENT_HREF}"></script>`;
} }
/** 403 for a rejected cross-site WebSocket handshake. */ /** 403 for a rejected cross-site WebSocket handshake. */
@@ -1967,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
extraBody: extraBody:
[ [
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined), renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
deps.i18n deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
: "",
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "", hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "", 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);
});
+30
View File
@@ -5,6 +5,7 @@ import {
forwardAuthFailure, forwardAuthFailure,
forwardAuthHeaders, forwardAuthHeaders,
gatewayProxyHeaders, gatewayProxyHeaders,
gatewayWebSocketBackendHeaders,
stripUntrustedInternalHeaders, stripUntrustedInternalHeaders,
gatewayRestartDelay, gatewayRestartDelay,
internalError, internalError,
@@ -37,6 +38,35 @@ test("gateway disables compression for its internal proxy hop", () => {
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1"); expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
}); });
test("gateway WebSocket bridge forwards validated application identity", () => {
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
headers: {
host: "web.localhost:3000",
origin: "http://web.localhost:3000",
cookie: "session=abc",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-key": "test-key",
},
});
const headers = gatewayWebSocketBackendHeaders(
request,
new URL(request.url),
"127.0.0.1",
true,
"http://127.0.0.1:3001",
);
expect(headers.origin).toBe("http://127.0.0.1:3001");
expect(headers.cookie).toBe("session=abc");
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
expect(headers["x-forwarded-proto"]).toBe("http");
expect(headers.host).toBeUndefined();
expect(headers.connection).toBeUndefined();
expect(headers.upgrade).toBeUndefined();
expect(headers["sec-websocket-key"]).toBeUndefined();
});
test("gateway proxy headers do not preserve the RPC internal marker", () => { test("gateway proxy headers do not preserve the RPC internal marker", () => {
const request = new Request("http://localhost:3000/path", { const request = new Request("http://localhost:3000/path", {
headers: { "x-wrnexus-internal": "1" }, headers: { "x-wrnexus-internal": "1" },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.8.9", "version": "0.8.13",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@@ -20,6 +20,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -43,8 +43,8 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{} try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media.")); if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));}); q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsBytes=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)).reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>150000)found.push(issue("performance/javascript-budget","javascript",jsBytes>300000?"error":"warning","JavaScript budget exceeded","JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB.",null,"Split routes and defer optional hydration."));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)})); const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsResources=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)&&!/\/__wrnexus\/dev-toolbar\.js(?:\?|$)/.test(e.name));const jsBytes=jsResources.reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>300000)found.push(issue("performance/javascript-budget","javascript",jsBytes>600000?"error":"warning","Development JavaScript is large","Application JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB in development.",null,"Check the production build report before splitting routes; development modules are unminified.","medium",{javascriptBytes:jsBytes,mode:"development"}));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}if(unusedSelectors)found.push(issue("css/unused-selectors","css","suggestion","Potentially unused CSS",unusedSelectors+" of "+checkedSelectors+" inspected selectors do not match this page.",null,"Review across routes before removing selectors.","medium",{checkedSelectors,unusedSelectors}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration.")); let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){const href=sheet.href||"";if(/\/__wrnexus\/(?:ui|framework)\.css(?:\?|$)|\/__wrnexus\/theme\/[^/?]+\.css(?:\?|$)/.test(href))continue;let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}const unusedRatio=checkedSelectors?unusedSelectors/checkedSelectors:0;if(checkedSelectors>=20&&unusedRatio>=.8)found.push(issue("css/unused-selectors","css","suggestion","Low current-page CSS coverage",unusedSelectors+" of "+checkedSelectors+" inspected application selectors do not match this page.",null,"Review across routes before removing selectors. WRNexus UI and theme styles are excluded.","medium",{checkedSelectors,unusedSelectors,unusedRatio}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")}))); q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
const storeContainer=window.__wrnexusStoreContainer; const storeContainer=window.__wrnexusStoreContainer;
if(storeContainer&&typeof storeContainer.inspect==="function"){ if(storeContainer&&typeof storeContainer.inspect==="function"){
@@ -9,6 +9,12 @@ test("exports usable development assets", () => {
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"'); expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id'); expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)"); expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
expect(DEV_TOOLBAR_RUNTIME).toContain("Low current-page CSS coverage");
expect(DEV_TOOLBAR_RUNTIME).toContain("WRNexus UI and theme styles are excluded");
expect(DEV_TOOLBAR_RUNTIME).toContain("checkedSelectors>=20&&unusedRatio>=.8");
expect(DEV_TOOLBAR_RUNTIME).toContain("Development JavaScript is large");
expect(DEV_TOOLBAR_RUNTIME).toContain("dev-toolbar\\.js");
expect(DEV_TOOLBAR_RUNTIME).toContain("jsBytes>300000");
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel"); expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel"); expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.8.8", "version": "0.8.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -20,7 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*" "@wrnexus/core": "workspace:*"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.8.10", "version": "0.8.12",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -23,7 +23,7 @@
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*" "@wrnexus/syntax": "workspace:*"
}, },
"wrnexus": { "wrnexus": {
+35 -3
View File
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
.replace(/\u2029/g, "\\u2029"); .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 { export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
const active = i18n.langs.includes(lang) ? lang : i18n.default; const active = i18n.langs.includes(lang) ? lang : i18n.default;
return `window.__wrnI18n=${safeJson({ return `${safeJson({
lang: active, lang: active,
langs: i18n.langs, langs: i18n.langs,
default: i18n.default, default: i18n.default,
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
directions: i18n.direction, directions: i18n.direction,
labels: i18n.labels, labels: i18n.labels,
cookie: i18n.cookie, 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` 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 + "}"; 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) { function t(key, params) {
var current = state(); var current = state();
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params); 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]');
});
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/image", "name": "@wrnexus/image",
"version": "0.8.9", "version": "0.8.10",
"type": "module", "type": "module",
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.", "description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -22,7 +22,7 @@
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*" "@wrnexus/syntax": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.8.8", "version": "0.8.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -20,7 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*" "@wrnexus/core": "workspace:*"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/pwa", "name": "@wrnexus/pwa",
"version": "0.8.8", "version": "0.8.9",
"type": "module", "type": "module",
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.", "description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -14,6 +14,6 @@
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/realtime", "name": "@wrnexus/realtime",
"version": "0.8.9", "version": "0.8.10",
"description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.", "description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@@ -29,7 +29,7 @@
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2" "typescript": "^6.0.3"
}, },
"wrnexus": { "wrnexus": {
"plugin": { "plugin": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/rpc", "name": "@wrnexus/rpc",
"version": "0.8.9", "version": "0.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -26,6 +26,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/typecheck", "name": "@wrnexus/typecheck",
"version": "0.8.9", "version": "0.8.10",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
@@ -10,6 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"typescript": "^5.5.0" "typescript": "^6.0.3"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/uploader", "name": "@wrnexus/uploader",
"version": "0.8.9", "version": "0.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -23,7 +23,7 @@
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*" "@wrnexus/syntax": "workspace:*"
}, },
"wrnexus": { "wrnexus": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/validation", "name": "@wrnexus/validation",
"version": "0.8.9", "version": "0.8.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -23,7 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2", "typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*" "@wrnexus/syntax": "workspace:*"
}, },
"wrnexus": { "wrnexus": {
+2 -1
View File
@@ -48,7 +48,8 @@ export function validateStagedManifest(stage: string, manifest: Record<string, u
} }
if (!/^@wrnexus\/[a-z0-9-]+$/.test(name)) throw new Error(`${name} has an invalid package name.`); if (!/^@wrnexus\/[a-z0-9-]+$/.test(name)) throw new Error(`${name} has an invalid package name.`);
if (manifest.private !== undefined) throw new Error(`${name} staged manifest must omit private.`); if (manifest.private !== undefined) throw new Error(`${name} staged manifest must omit private.`);
if ((manifest.publishConfig as any)?.registry !== "https://registry.npmjs.org/") { const expectedRegistry = process.env.WRNEXUS_NPM_REGISTRY ?? "https://registry.npmjs.org/";
if ((manifest.publishConfig as any)?.registry !== expectedRegistry) {
throw new Error(`${name} staged manifest has an unexpected registry.`); throw new Error(`${name} staged manifest has an unexpected registry.`);
} }
if (!["restricted", "public"].includes(String((manifest.publishConfig as any)?.access))) { if (!["restricted", "public"].includes(String((manifest.publishConfig as any)?.access))) {
+1 -1
View File
@@ -32,7 +32,7 @@ import { validateAndHashStage, type StagedPackageIntegrity } from "./lib/package
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const packagesDir = join(repoRoot, "packages"); const packagesDir = join(repoRoot, "packages");
const stageRoot = join(repoRoot, ".publish"); const stageRoot = join(repoRoot, ".publish");
const REGISTRY = "https://registry.npmjs.org/"; const REGISTRY = process.env.WRNEXUS_NPM_REGISTRY ?? "https://registry.npmjs.org/";
// First release is published PRIVATE ("restricted") so we can test before the // First release is published PRIVATE ("restricted") so we can test before the
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set // world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
// WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus` // WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus`
+1 -1
View File
@@ -145,7 +145,7 @@ check(
); );
check( check(
"i18n runtime data is injected into rendered documents", "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"), has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
); );
check( check(
+1 -1
View File
@@ -14,6 +14,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^5.9.2" "typescript": "^6.0.3"
} }
} }
+1
View File
@@ -6,6 +6,7 @@
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun"], "types": ["bun"],
"strict": true, "strict": true,
"ignoreDeprecations": "6.0",
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,