Compare commits

...
Author SHA1 Message Date
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
ClintchizandClaude Opus 5 4dd7681bf7 fix(dev): repair the HMR client and keep islands alive across updates
The HMR client script was dead in the browser. HMR_CLIENT_JS is a
TypeScript template literal, so the regex [ \t\r\n] inside it was expanded
into real control characters, producing a regex literal containing a raw
newline — a syntax error that took the whole script down with "Invalid
regular expression: missing /". It now uses \s, and a test asserts the
emitted client parses and holds no control characters inside regex
literals; that test fails if the bug is reintroduced.

HMR also corrupted CSP nonces. A document's nonce is fixed at load, but
morph copied attributes from freshly fetched HTML, overwriting the live
nonce with one the browser will not honour. syncAttrs now leaves nonce
alone, and nodes moved across are re-stamped with the live nonce.

Islands vanished on every HMR update: morph puts the server placeholder
back over the mounted island. The island runtime now remounts on
wrnexus:hmr-updated. Remounting swaps the container for a bare clone —
re-rendering the existing root is a no-op once HMR has wiped the DOM
externally, and unmounting throws asynchronously because the nodes React
wants to remove are already gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:15:21 +05:30
ClintchizandClaude Opus 5 843db2815f fix(islands): rebuild on .tsx edits and support islands inside components
Three bugs found by driving the dev server rather than reading code:

1. An island used inside a .wrn component still emitted a component mount
   — only the page and nested-page render paths were covered.

2. Editing an island .tsx never rebuilt in dev. The bundle cache was keyed
   on source path alone, and page modules are cached after the first
   request so no compile runs to notice the change. The cache key now
   includes mtime, and the file watcher rebuilds islands whose .tsx
   changed.

3. A .wrn cache hit skipped island building entirely, so after a restart
   with a warm cache no island bundle was ever produced. Island inputs are
   now persisted beside the other artifacts and rebuilt on a cache hit.

The islands manifest is deliberately excluded from the artifact
completeness check: only the async compile path writes it, so requiring it
made the sync path miss the cache on every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:26:58 +05:30
ClintchizandClaude Opus 5 17aa3b98eb feat(islands): wire islands end to end
The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:16:03 +05:30
81 changed files with 1450 additions and 299 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
+5 -5
View File
@@ -272,7 +272,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.36", "version": "0.8.41",
"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:*",
@@ -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.37",
"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.12",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+26 -1
View File
@@ -973,6 +973,10 @@
"EffectBlock", "EffectBlock",
"EventDecl", "EventDecl",
"FormatWrnOptions", "FormatWrnOptions",
"IslandBuildResult",
"IslandDiagnostic",
"IslandInput",
"IslandStrategy",
"LexError", "LexError",
"Lexer", "Lexer",
"LoadBlock", "LoadBlock",
@@ -999,7 +1003,9 @@
"analyzeOptimizations", "analyzeOptimizations",
"analyzeRuntimeImports", "analyzeRuntimeImports",
"analyzeRuntimeRequirements", "analyzeRuntimeRequirements",
"assertReactAvailable",
"assertValidAst", "assertValidAst",
"buildIslands",
"compilationKey", "compilationKey",
"compile", "compile",
"compileNativeWrnFile", "compileNativeWrnFile",
@@ -1015,19 +1021,26 @@
"generate", "generate",
"generateBrowserModule", "generateBrowserModule",
"generateDeclarations", "generateDeclarations",
"generateIslandEntry",
"generateNative", "generateNative",
"generateServerFunctionsModule", "generateServerFunctionsModule",
"generateStoreBrowserModule", "generateStoreBrowserModule",
"generateStoreModule", "generateStoreModule",
"generateTargets", "generateTargets",
"inferredRuntimeType", "inferredRuntimeType",
"islandNamesFrom",
"islandPropValue",
"optimizeAst", "optimizeAst",
"parse", "parse",
"parseIslandStrategy",
"renderIslandMarker",
"resolveWrnImport", "resolveWrnImport",
"resolveWrnImports", "resolveWrnImports",
"routeNeedsIslands",
"rpcManifest", "rpcManifest",
"runtimeCapabilities", "runtimeCapabilities",
"runtimeTypeOf" "runtimeTypeOf",
"serializeIslandProps"
] ]
}, },
"@wrnexus/content": { "@wrnexus/content": {
@@ -2271,6 +2284,18 @@
"StoreResolver", "StoreResolver",
"createSelectorCache", "createSelectorCache",
"createSnapshotCache", "createSnapshotCache",
"discardDetachedRoots",
"islandRootCount",
"mountIslands",
"remountIslands",
"setStoreResolver",
"unmountIslands",
"useWrnActions",
"useWrnStore"
],
"./browser": [
"MountOptions",
"discardDetachedRoots",
"islandRootCount", "islandRootCount",
"mountIslands", "mountIslands",
"remountIslands", "remountIslands",
@@ -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;
@@ -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.
+337 -4
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. // Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f // WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3 // Generated with TypeScript: 6.0.3
const __nodeRequire = require; const __nodeRequire = require;
const __path = __nodeRequire("node:path"); const __path = __nodeRequire("node:path");
@@ -789,7 +789,46 @@ const types_ts_1 = require("./types.js");
const syntax_1 = require("@wrnexus/syntax"); const syntax_1 = require("@wrnexus/syntax");
const store_codegen_ts_1 = require("./store-codegen.js"); const store_codegen_ts_1 = require("./store-codegen.js");
const analysis_ts_1 = require("./analysis.js"); const analysis_ts_1 = require("./analysis.js");
const island_codegen_ts_1 = require("./island-codegen.js");
const client_codegen_ts_1 = require("./client-codegen.js"); const client_codegen_ts_1 = require("./client-codegen.js");
/**
* Island names for the file currently being generated.
*
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
* an extra parameter through every render function. Always reset in generate().
*/
let currentIslands = new Set();
/**
* Builds the island placeholder for a component tag that was imported from a
* .tsx file. Returns null for ordinary .wrn components.
*/
function islandMarkerFor(node) {
if (!currentIslands.has(node.tag))
return null;
const directives = node.attrs
.map((attr) => attr.name)
.filter((name) => name.startsWith("client:"));
const props = {};
for (const attr of node.attrs) {
if (attr.name.startsWith("client:"))
continue;
const parsed = (0, island_codegen_ts_1.islandPropValue)(attr.value);
if ("dynamic" in parsed) {
throw new Error(`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
`Island props are serialized at build time, so they must be literal values ` +
`(for example start={3} or title="Revenue"), not ${attr.value}.`);
}
props[attr.name] = parsed.value;
}
const serialized = (0, island_codegen_ts_1.serializeIslandProps)(node.tag, props);
if ("diagnostic" in serialized)
throw new Error(serialized.diagnostic.message);
return (0, island_codegen_ts_1.renderIslandMarker)({
name: node.tag,
strategy: (0, island_codegen_ts_1.parseIslandStrategy)(directives),
propsJson: serialized.json,
});
}
function isComponentTag(tag) { function isComponentTag(tag) {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
} }
@@ -1137,6 +1176,9 @@ function renderLoopBody(node) {
escLit("</div>")); escLit("</div>"));
} }
if (componentTag) { if (componentTag) {
const island = islandMarkerFor(node);
if (island)
return escLit(island);
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) + return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs + attrs +
escLit(">") + escLit(">") +
@@ -1330,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`; return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
} }
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) { function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
const island = islandMarkerFor(node);
if (island)
return island;
const attrs = node.attrs const attrs = node.attrs
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops)) .map((attr) => renderPageComponentAttr(attr, loops))
@@ -1340,6 +1385,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`; return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
} }
function renderNestedComponentInvocation(node, ctx) { function renderNestedComponentInvocation(node, ctx) {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island)
return island;
let bindIndex = 0; let bindIndex = 0;
const attrs = node.attrs const attrs = node.attrs
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
@@ -1796,7 +1845,16 @@ function markServerAsyncBoundaries(nodes, serverLoads) {
markServerAsyncBoundaries(node.children, serverLoads); markServerAsyncBoundaries(node.children, serverLoads);
} }
} }
function generate(ast) { function generate(ast, options = {}) {
currentIslands = options.islands ?? new Set();
try {
return generateInner(ast);
}
finally {
currentIslands = new Set();
}
}
function generateInner(ast) {
ast = (0, analysis_ts_1.optimizeAst)(ast).ast; ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store") if (ast.kind === "global-store" || ast.kind === "page-store")
return (0, store_codegen_ts_1.generateStoreModule)(ast); return (0, store_codegen_ts_1.generateStoreModule)(ast);
@@ -3184,7 +3242,7 @@ function resolveWrnImports(declarations, importer, options) {
* `@wrnexus/syntax` package. This package owns platform-specific codegen. * `@wrnexus/syntax` package. This package owns platform-specific codegen.
*/ */
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0; exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
exports.compileNativeWrnFile = compileNativeWrnFile; exports.compileNativeWrnFile = compileNativeWrnFile;
exports.compileWrnFile = compileWrnFile; exports.compileWrnFile = compileWrnFile;
exports.compile = compile; exports.compile = compile;
@@ -3280,6 +3338,281 @@ var cache_ts_1 = require("./cache.js");
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } }); Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } }); Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } }); Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
var island_codegen_ts_1 = require("./island-codegen.js");
Object.defineProperty(exports, "islandNamesFrom", { enumerable: true, get: function () { return island_codegen_ts_1.islandNamesFrom; } });
Object.defineProperty(exports, "islandPropValue", { enumerable: true, get: function () { return island_codegen_ts_1.islandPropValue; } });
Object.defineProperty(exports, "parseIslandStrategy", { enumerable: true, get: function () { return island_codegen_ts_1.parseIslandStrategy; } });
Object.defineProperty(exports, "renderIslandMarker", { enumerable: true, get: function () { return island_codegen_ts_1.renderIslandMarker; } });
Object.defineProperty(exports, "serializeIslandProps", { enumerable: true, get: function () { return island_codegen_ts_1.serializeIslandProps; } });
var island_bundle_ts_1 = require("./island-bundle.js");
Object.defineProperty(exports, "assertReactAvailable", { enumerable: true, get: function () { return island_bundle_ts_1.assertReactAvailable; } });
Object.defineProperty(exports, "buildIslands", { enumerable: true, get: function () { return island_bundle_ts_1.buildIslands; } });
Object.defineProperty(exports, "generateIslandEntry", { enumerable: true, get: function () { return island_bundle_ts_1.generateIslandEntry; } });
var analysis_ts_2 = require("./analysis.js");
Object.defineProperty(exports, "routeNeedsIslands", { enumerable: true, get: function () { return analysis_ts_2.routeNeedsIslands; } });
},
"packages/compiler/src/island-bundle.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateIslandEntry = generateIslandEntry;
exports.reactJsxPlugin = reactJsxPlugin;
exports.assertReactAvailable = assertReactAvailable;
exports.buildIslands = buildIslands;
const node_crypto_1 = require("node:crypto");
const node_fs_1 = require("node:fs");
const node_module_1 = require("node:module");
const node_path_1 = require("node:path");
/**
* Generates the per-island browser entry.
*
* Never imports react-dom/server islands are client-only.
*/
function generateIslandEntry(input) {
return [
"/** @jsxImportSource react */",
`import Component from ${JSON.stringify(input.sourcePath)};`,
`export const name = ${JSON.stringify(input.name)};`,
`export default Component;`,
"",
].join("\n");
}
/**
* Compiles every island `.tsx` against React's JSX runtime.
*
* The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
* island would otherwise compile to WRNexus's HTML-string renderer and
* silently never mount. A `@jsxImportSource` pragma applies only to the file
* that carries it, so putting one in the generated entry does nothing for the
* author's own component the injection has to happen per source file, which
* is what this plugin does. App-authored islands stay plain `.tsx`.
*/
function reactJsxPlugin() {
return {
name: "wrnexus-island-jsx",
setup(build) {
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
// Only first-party island sources need the pragma. Third-party .tsx
// under node_modules is left alone so Bun's own handling is untouched.
if (args.path.includes("node_modules"))
return undefined;
return {
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
loader: "tsx",
};
});
},
};
}
function assertReactAvailable(appRoot) {
const require = (0, node_module_1.createRequire)((0, node_path_1.join)(appRoot, "package.json"));
try {
require.resolve("react");
require.resolve("react-dom");
return null;
}
catch {
return {
code: "WRN-ISLAND-REACT-MISSING",
severity: "error",
message: "This app imports a .tsx island but react and react-dom are not installed. " +
"Run: bun add react react-dom",
};
}
}
/**
* Bundles island entries. `splitting: true` is required so React is emitted
* once as a shared chunk rather than duplicated into every island.
*/
async function buildIslands(input) {
if (input.islands.length === 0)
return { assets: [], sharedChunks: [] };
// Each island gets its own generated entry file named after the island.
// Passing the component sources directly would dedupe two islands that share
// a source file, and output order is not guaranteed to match input order —
// both of which silently mismatch island names to bundles.
//
// The entries live inside outDir so `react` resolves from the app that
// installed it, exactly as the island's own imports do.
const entryDir = (0, node_path_1.join)(input.outDir, ".entries");
(0, node_fs_1.mkdirSync)(entryDir, { recursive: true });
const islandNames = new Set(input.islands.map((island) => island.name));
for (const island of input.islands) {
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
}
const entrypoints = input.islands.map((island) => (0, node_path_1.join)(entryDir, `${island.name}.tsx`));
if (input.appRoot) {
const resolveFrom = (0, node_module_1.createRequire)((0, node_path_1.join)(input.appRoot, "package.json"));
const runtimeEntry = (0, node_path_1.join)(entryDir, "runtime.ts");
(0, node_fs_1.writeFileSync)(runtimeEntry, `export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
`, "utf8");
entrypoints.push(runtimeEntry);
}
try {
const result = await Bun.build({
entrypoints,
outdir: input.outDir,
target: "browser",
format: "esm",
splitting: true,
minify: true,
plugins: [reactJsxPlugin()],
});
if (!result.success) {
throw new AggregateError(result.logs, "Island bundling failed");
}
const assets = [];
const sharedChunks = [];
for (const output of result.outputs) {
if (output.kind === "entry-point") {
// Bun names an entry's output after its entry file, so the basename
// identifies the island unambiguously.
const stem = (0, node_path_1.basename)(output.path).replace(/\.js$/, "");
if (stem === "runtime")
continue;
if (!islandNames.has(stem))
continue;
assets.push({
name: stem,
hash: (0, node_crypto_1.createHash)("sha256").update(output.path).digest("hex").slice(0, 16),
path: output.path,
});
}
else if (output.kind === "chunk") {
sharedChunks.push(output.path);
}
}
return { assets, sharedChunks };
}
finally {
(0, node_fs_1.rmSync)(entryDir, { recursive: true, force: true });
}
}
},
"packages/compiler/src/island-codegen.ts": function (module, exports, require, __filename, __dirname) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseIslandStrategy = parseIslandStrategy;
exports.islandPropValue = islandPropValue;
exports.serializeIslandProps = serializeIslandProps;
exports.renderIslandMarker = renderIslandMarker;
exports.islandNamesFrom = islandNamesFrom;
// Implemented locally rather than imported from @wrnexus/core: compiler modules
// are bundled into the Node-only VS Code extension, which contains no other
// packages, so a runtime import of core would break the editor compiler.
const HTML_ESCAPES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
}
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
function isSafeIslandName(name) {
return SAFE_ISLAND_NAME.test(name);
}
const STRATEGIES = {
"client:only": "only",
"client:load": "load",
"client:visible": "visible",
"client:idle": "idle",
};
function parseIslandStrategy(directives) {
for (const directive of directives) {
const match = STRATEGIES[directive];
if (match)
return match;
}
return "only";
}
function unsupportedProp(value) {
const type = typeof value;
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
return true;
}
if (value === null || type !== "object")
return false;
if (Array.isArray(value))
return value.some(unsupportedProp);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null)
return true;
return Object.values(value).some(unsupportedProp);
}
/**
* Interprets an island attribute value with JSX semantics.
*
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
* `true`. Without this every prop arrives as a string, so `start={3}` would be
* `"3"` and arithmetic in the island silently concatenates.
*
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
* evaluated at runtime and cannot cross the serialization boundary.
*/
function islandPropValue(raw) {
if (raw === undefined || raw === "")
return { value: true };
const expression = /^\{([\s\S]*)\}$/.exec(raw);
if (!expression)
return { value: raw };
const inner = expression[1].trim();
try {
return { value: JSON.parse(inner) };
}
catch {
return { dynamic: inner };
}
}
function serializeIslandProps(componentName, props) {
const offenders = Object.entries(props)
.filter(([, value]) => unsupportedProp(value))
.map(([key]) => key);
if (offenders.length > 0) {
return {
diagnostic: {
code: "WRN-ISLAND-PROPS",
severity: "error",
message: `Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
`Island props cross a serialization boundary and must be JSON-safe ` +
`(no functions, symbols, bigints, undefined, or class instances).`,
},
};
}
return { json: JSON.stringify(props) };
}
function renderIslandMarker(input) {
// The name becomes a path segment when the browser fetches
// /__wrnexus/island/<name>.js, so reuse the framework's conservative charset
// rather than relying on escaping alone.
if (!isSafeIslandName(input.name)) {
throw new Error(`Island name '${input.name}' is not a safe identifier. ` +
`Island names may only contain letters, digits, underscores, and hyphens.`);
}
return (`<div data-wrn-island="${escapeHtml(input.name)}"` +
` data-wrn-island-strategy="${input.strategy}"` +
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`);
}
/**
* Local binding names introduced by island imports.
*
* Codegen sees only component tag names, so it needs the set of names that came
* from `.tsx` imports to tell an island apart from a `.wrn` component.
*/
function islandNamesFrom(imports) {
const names = new Set();
for (const entry of imports) {
if (entry.kind !== "island")
continue;
const local = entry.declaration.defaultImport;
if (local)
names.add(local);
}
return names;
}
}, },
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) { "packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116 // WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict"; "use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+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"
} }
} }
@@ -0,0 +1,21 @@
import Counter from "../islands/Counter"
// React island demo. Route: /island-demo
//
// Counter.tsx is a plain React component: the compiler emits an island
// placeholder here instead of a server-rendered component mount, and the
// browser mounts it with createRoot.
page IslandDemo {
seo {
title = "Island demo"
description = "Mounts a React island inside a server-rendered WRNexus page."
canonical = "/island-demo"
}
view {
<main>
<h1>React island</h1>
<Counter start={3} client:visible />
</main>
}
}
+3
View File
@@ -9,6 +9,7 @@ export interface Routes {
"/client-only": Record<string, never>; "/client-only": Record<string, never>;
"/dashboard": Record<string, never>; "/dashboard": Record<string, never>;
"/hello": Record<string, never>; "/hello": Record<string, never>;
"/island-demo": Record<string, never>;
"/language-tools": Record<string, never>; "/language-tools": Record<string, never>;
"/layout": Record<string, never>; "/layout": Record<string, never>;
"/login": Record<string, never>; "/login": Record<string, never>;
@@ -31,6 +32,7 @@ export interface RouteNames {
"client.only": "/client-only"; "client.only": "/client-only";
"dashboard": "/dashboard"; "dashboard": "/dashboard";
"hello": "/hello"; "hello": "/hello";
"island.demo": "/island-demo";
"language.tools": "/language-tools"; "language.tools": "/language-tools";
"layout": "/layout"; "layout": "/layout";
"login": "/login"; "login": "/login";
@@ -122,6 +124,7 @@ export function route<N extends RouteName>(
"client.only": "/client-only", "client.only": "/client-only",
"dashboard": "/dashboard", "dashboard": "/dashboard",
"hello": "/hello", "hello": "/hello",
"island.demo": "/island-demo",
"language.tools": "/language-tools", "language.tools": "/language-tools",
"layout": "/layout", "layout": "/layout",
"login": "/login", "login": "/login",
+1 -1
View File
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never; : never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown; type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown; type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
+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.34", "version": "0.8.41",
"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:*",
+59 -14
View File
@@ -28,6 +28,9 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
import { import {
analyzeRuntimeImports, analyzeRuntimeImports,
analyzeRuntimeRequirements, analyzeRuntimeRequirements,
assertReactAvailable,
buildIslands,
islandNamesFrom,
assertValidAst, assertValidAst,
generate, generate,
generateTargets, generateTargets,
@@ -185,6 +188,8 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientFiles = new Map<string, string>(); const clientFiles = new Map<string, string>();
const runtimeAnalysis = new Map<string, RuntimeRequirements>(); const runtimeAnalysis = new Map<string, RuntimeRequirements>();
const partialStaticFiles = new Set<string>(); const partialStaticFiles = new Set<string>();
/** Island component name -> resolved .tsx source, collected across all routes. */
const discoveredIslands = new Map<string, string>();
const compileWrn = async (file: string): Promise<void> => { const compileWrn = async (file: string): Promise<void> => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return; if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8"); const source = readFileSync(file, "utf8");
@@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise<void> {
assertValidAst(ast, { file, accessibility: true }); assertValidAst(ast, { file, accessibility: true });
ast = await pluginRunner.transformAst(ast, file); ast = await pluginRunner.transformAst(ast, file);
if (ast.renderMode === "partial-static") partialStaticFiles.add(file); if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
// Islands must be known before codegen (to emit markers instead of
// component mounts) and before route analysis (an island route ships JS).
const fileImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const fileIslands = islandNamesFrom(fileImports);
for (const imported of fileImports) {
if (imported.kind === "island" && imported.resolved) {
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
}
}
runtimeAnalysis.set(
file,
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
);
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file); const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error"); const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) { for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
@@ -223,10 +246,11 @@ export async function runBuild(appRoot: string): Promise<void> {
try { try {
const targets = generateTargets(ast); const targets = generateTargets(ast);
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll( let code =
"__WRNEXUS_CLIENT_MODULE__", `// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
clientUrl, "__WRNEXUS_CLIENT_MODULE__",
); clientUrl,
);
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`; let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
code = await pluginRunner.transformCode(code, file); code = await pluginRunner.transformCode(code, file);
browserCode = await pluginRunner.transformCode(browserCode, file); browserCode = await pluginRunner.transformCode(browserCode, file);
@@ -249,11 +273,7 @@ export async function runBuild(appRoot: string): Promise<void> {
); );
} }
const resolvedImports = resolveWrnImports(ast.structuredImports, file, { const resolvedImports = fileImports;
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
for (const imported of resolvedImports) { for (const imported of resolvedImports) {
if (imported.diagnostic?.severity === "error") { if (imported.diagnostic?.severity === "error") {
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`); throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
@@ -505,6 +525,26 @@ export async function runBuild(appRoot: string): Promise<void> {
assetHash.update(islandCode); assetHash.update(islandCode);
console.log(`✓ Islands: ${islandsPath}`); console.log(`✓ Islands: ${islandsPath}`);
// Island bundles are emitted only when a route actually imported a .tsx, so a
// build with no islands produces no React and no island assets at all.
const islandsDir = join(distDir, "island");
if (discoveredIslands.size > 0) {
const missingReact = assertReactAvailable(root);
if (missingReact) {
throw new Error(`${missingReact.code}: ${missingReact.message}`);
}
mkdirSync(islandsDir, { recursive: true });
const islandBuild = await buildIslands({
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
outDir: islandsDir,
appRoot: root,
});
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
console.log(
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
);
}
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark). // 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
const theme = resolveThemeConfig(config.theme, config.cookies); const theme = resolveThemeConfig(config.theme, config.cookies);
const themeCss = renderThemeCss(theme); const themeCss = renderThemeCss(theme);
@@ -580,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,
@@ -747,6 +791,7 @@ await createProductionServer(
reactivePath: join(import.meta.dir, "reactive.js"), reactivePath: join(import.meta.dir, "reactive.js"),
controllersPath: join(import.meta.dir, "controllers.js"), controllersPath: join(import.meta.dir, "controllers.js"),
clientModulesDir: join(import.meta.dir, "client"), clientModulesDir: join(import.meta.dir, "client"),
islandsDir: join(import.meta.dir, "island"),
themePath: join(import.meta.dir, "theme.css"), themePath: join(import.meta.dir, "theme.css"),
themeAssetsDir: join(import.meta.dir, "theme"), themeAssetsDir: join(import.meta.dir, "theme"),
themeJsPath: join(import.meta.dir, "theme.js"), themeJsPath: join(import.meta.dir, "theme.js"),
+9 -9
View File
@@ -121,16 +121,16 @@ Thumbs.db
}, },
"devDependencies": { "devDependencies": {
"@wrnexus/cli": "${cliVersion}", "@wrnexus/cli": "${cliVersion}",
"@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": "latest", "@types/bun": "^1.3.14",
"eslint": "^9.0.0", "eslint": "^10.8.1",
"prettier": "latest", "prettier": "^3.9.6",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "^5.5.0", "typescript": "^6.0.3",
"typescript-eslint": "latest" "typescript-eslint": "^8.67.0"
} }
} }
`, `,
+2 -2
View File
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
devDependencies: { devDependencies: {
"@capacitor/cli": "^8.0.0", "@capacitor/cli": "^8.0.0",
"@capacitor/assets": "^3.0.0", "@capacitor/assets": "^3.0.0",
typescript: "^5.5.0", typescript: "^6.0.3",
}, },
}; };
@@ -237,7 +237,7 @@ function generateNativeMobile(
"react-native-safe-area-context": "^5.6.0", "react-native-safe-area-context": "^5.6.0",
"react-native-screens": "^4.23.0", "react-native-screens": "^4.23.0",
}, },
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" }, devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" },
}; };
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`; const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`; const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
+1 -1
View File
@@ -53,7 +53,7 @@ export function generateSystem(rootDir: string, input: string): string[] {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*", "@wrnexus/plugin": "workspace:*",
}, },
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" }, devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
wrnexus: { wrnexus: {
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true }, plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
}, },
+8 -8
View File
@@ -13,7 +13,7 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts"; import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts"; import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server"; import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
}, },
"devDependencies": { "devDependencies": {
"@wrnexus/cli": "${frameworkVersion}", "@wrnexus/cli": "${frameworkVersion}",
"@eslint/js": "^9.0.0", "@eslint/js": "^10.0.1",
"@types/bun": "latest", "@types/bun": "^1.3.14",
"eslint": "^9.0.0", "eslint": "^10.8.1",
"prettier": "latest", "prettier": "^3.9.6",
"typescript": "^5.5.0", "typescript": "^6.0.3",
"typescript-eslint": "latest" "typescript-eslint": "^8.67.0"
} }
} }
`, `,
@@ -250,7 +250,7 @@ export default tseslint.config(
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@wrnexus/pubsub": "${frameworkVersion}" "@wrnexus/pubsub": "${scaffoldFrameworkRange}"
} }
} }
`, `,
+1 -1
View File
@@ -147,7 +147,7 @@ test("scaffoldApp uses compatible framework packages and pins the current CLI",
} }
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version); expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3"); expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118"); expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.123");
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8"); const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";'); expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"'); expect(globalCss).toContain('"Plus Jakarta Sans"');
@@ -69,7 +69,9 @@ export default {
} }
if (port === undefined) { if (port === undefined) {
throw new Error(`production server failed to start: ${await new Response(proc.stderr).text()}`); throw new Error(
`production server failed to start: ${await new Response(proc.stderr).text()}`,
);
} }
const page = await fetch(`http://127.0.0.1:${port}/`); const page = await fetch(`http://127.0.0.1:${port}/`);
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { currentCliVersion } from "../src/update-notifier.ts"; import { currentCliVersion } from "../src/update-notifier.ts";
import { scaffoldFrameworkRange } from "../src/create.ts";
import { import {
addWorkspaceApp, addWorkspaceApp,
insertWorkspaceApp, insertWorkspaceApp,
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com"); expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com");
}); });
test("workspace templates pin the running framework release", () => { test("workspace templates pin the CLI and use compatible independent package ranges", () => {
const files = workspaceFiles("acme"); const files = workspaceFiles("acme");
const rootPackage = JSON.parse(files["package.json"]!); const rootPackage = JSON.parse(files["package.json"]!);
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!); const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
const version = currentCliVersion(); const version = currentCliVersion();
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version); expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version); expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(scaffoldFrameworkRange);
expect(files["README.md"]).toContain("http://127.0.0.1:3000"); expect(files["README.md"]).toContain("http://127.0.0.1:3000");
expect(files["README.md"]).toContain("internal gateway targets"); expect(files["README.md"]).toContain("internal gateway targets");
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production"); expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
+1 -2
View File
@@ -1,13 +1,12 @@
{ {
"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": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*", "@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
+72 -1
View File
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax"; import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts"; import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts"; import { optimizeAst } from "./analysis.ts";
import {
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
import { browserModuleRequired } from "./client-codegen.ts"; import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding { interface RenderBinding {
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
mode: DataMode; mode: DataMode;
} }
/**
* Island names for the file currently being generated.
*
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
* an extra parameter through every render function. Always reset in generate().
*/
let currentIslands: ReadonlySet<string> = new Set<string>();
/**
* Builds the island placeholder for a component tag that was imported from a
* .tsx file. Returns null for ordinary .wrn components.
*/
function islandMarkerFor(node: {
tag: string;
attrs: Array<{ name: string; value?: string }>;
}): string | null {
if (!currentIslands.has(node.tag)) return null;
const directives = node.attrs
.map((attr) => attr.name)
.filter((name) => name.startsWith("client:"));
const props: Record<string, unknown> = {};
for (const attr of node.attrs) {
if (attr.name.startsWith("client:")) continue;
const parsed = islandPropValue(attr.value);
if ("dynamic" in parsed) {
throw new Error(
`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
`Island props are serialized at build time, so they must be literal values ` +
`(for example start={3} or title="Revenue"), not ${attr.value}.`,
);
}
props[attr.name] = parsed.value;
}
const serialized = serializeIslandProps(node.tag, props);
if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message);
return renderIslandMarker({
name: node.tag,
strategy: parseIslandStrategy(directives),
propsJson: serialized.json,
});
}
function isComponentTag(tag: string): boolean { function isComponentTag(tag: string): boolean {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
} }
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
} }
if (componentTag) { if (componentTag) {
const island = islandMarkerFor(node);
if (island) return escLit(island);
return ( return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) + escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs + attrs +
@@ -704,6 +754,9 @@ function renderPageComponentInvocation(
loops: string[], loops: string[],
reactive: PageReactive | null, reactive: PageReactive | null,
): string { ): string {
const island = islandMarkerFor(node);
if (island) return island;
const attrs = node.attrs const attrs = node.attrs
.filter((attr) => attr.name !== "data-component") .filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops)) .map((attr) => renderPageComponentAttr(attr, loops))
@@ -720,6 +773,10 @@ function renderNestedComponentInvocation(
node: Extract<ViewNode, { type: "element" }>, node: Extract<ViewNode, { type: "element" }>,
ctx: CompCtx, ctx: CompCtx,
): string { ): string {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island) return island;
let bindIndex = 0; let bindIndex = 0;
const attrs = node.attrs const attrs = node.attrs
@@ -1255,7 +1312,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<s
} }
} }
export function generate(ast: PageAst): string { export interface GenerateOptions {
/** Local names bound to .tsx island imports in this file. */
islands?: ReadonlySet<string>;
}
export function generate(ast: PageAst, options: GenerateOptions = {}): string {
currentIslands = options.islands ?? new Set<string>();
try {
return generateInner(ast);
} finally {
currentIslands = new Set<string>();
}
}
function generateInner(ast: PageAst): string {
ast = optimizeAst(ast).ast; ast = optimizeAst(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast); if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") { if (ast.kind === "component" || ast.kind === "layout") {
+12
View File
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
} }
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts"; export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts"; export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
export {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts";
export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts";
export type { IslandBuildResult, IslandInput } from "./island-bundle.ts";
export { routeNeedsIslands } from "./analysis.ts";
+25 -1
View File
@@ -82,6 +82,16 @@ export function assertReactAvailable(
export async function buildIslands(input: { export async function buildIslands(input: {
islands: IslandInput[]; islands: IslandInput[];
outDir: string; outDir: string;
/**
* App root used to resolve the island mount runtime. When given, the runtime
* is emitted as `runtime.js` in the SAME build as the islands.
*
* This is not a convenience: building the runtime separately gives it its own
* copy of React, and a component rendered by one copy while importing hooks
* from another fails with "Cannot read properties of null (reading
* 'useState')". One build with splitting keeps React in a single shared chunk.
*/
appRoot?: string;
}): Promise<IslandBuildResult> { }): Promise<IslandBuildResult> {
if (input.islands.length === 0) return { assets: [], sharedChunks: [] }; if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
@@ -100,9 +110,22 @@ export async function buildIslands(input: {
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8"); writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
} }
const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`));
if (input.appRoot) {
const resolveFrom = createRequire(join(input.appRoot, "package.json"));
const runtimeEntry = join(entryDir, "runtime.ts");
writeFileSync(
runtimeEntry,
`export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
`,
"utf8",
);
entrypoints.push(runtimeEntry);
}
try { try {
const result = await Bun.build({ const result = await Bun.build({
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)), entrypoints,
outdir: input.outDir, outdir: input.outDir,
target: "browser", target: "browser",
format: "esm", format: "esm",
@@ -123,6 +146,7 @@ export async function buildIslands(input: {
// Bun names an entry's output after its entry file, so the basename // Bun names an entry's output after its entry file, so the basename
// identifies the island unambiguously. // identifies the island unambiguously.
const stem = basename(output.path).replace(/\.js$/, ""); const stem = basename(output.path).replace(/\.js$/, "");
if (stem === "runtime") continue;
if (!islandNames.has(stem)) continue; if (!islandNames.has(stem)) continue;
assets.push({ assets.push({
name: stem, name: stem,
+60 -1
View File
@@ -1,4 +1,23 @@
import { escapeHtml, isSafeIslandName } from "@wrnexus/core"; // Implemented locally rather than imported from @wrnexus/core: compiler modules
// are bundled into the Node-only VS Code extension, which contains no other
// packages, so a runtime import of core would break the editor compiler.
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!);
}
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
function isSafeIslandName(name: string): boolean {
return SAFE_ISLAND_NAME.test(name);
}
export type IslandStrategy = "only" | "load" | "visible" | "idle"; export type IslandStrategy = "only" | "load" | "visible" | "idle";
@@ -35,6 +54,28 @@ function unsupportedProp(value: unknown): boolean {
return Object.values(value as Record<string, unknown>).some(unsupportedProp); return Object.values(value as Record<string, unknown>).some(unsupportedProp);
} }
/**
* Interprets an island attribute value with JSX semantics.
*
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
* `true`. Without this every prop arrives as a string, so `start={3}` would be
* `"3"` and arithmetic in the island silently concatenates.
*
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
* evaluated at runtime and cannot cross the serialization boundary.
*/
export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } {
if (raw === undefined || raw === "") return { value: true };
const expression = /^\{([\s\S]*)\}$/.exec(raw);
if (!expression) return { value: raw };
const inner = expression[1]!.trim();
try {
return { value: JSON.parse(inner) as unknown };
} catch {
return { dynamic: inner };
}
}
export function serializeIslandProps( export function serializeIslandProps(
componentName: string, componentName: string,
props: Record<string, unknown>, props: Record<string, unknown>,
@@ -80,3 +121,21 @@ export function renderIslandMarker(input: {
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>` ` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
); );
} }
/**
* Local binding names introduced by island imports.
*
* Codegen sees only component tag names, so it needs the set of names that came
* from `.tsx` imports to tell an island apart from a `.wrn` component.
*/
export function islandNamesFrom(
imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>,
): Set<string> {
const names = new Set<string>();
for (const entry of imports) {
if (entry.kind !== "island") continue;
const local = entry.declaration.defaultImport;
if (local) names.add(local);
}
return names;
}
@@ -12,9 +12,9 @@ test("a route with an island import needs client JavaScript", () => {
}); });
test("a route with no island imports stays zero-JS", () => { test("a route with no island imports stays zero-JS", () => {
expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe( expect(
false, routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
); ).toBe(false);
}); });
test("an empty import list stays zero-JS", () => { test("an empty import list stays zero-JS", () => {
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy, parseIslandStrategy,
renderIslandMarker, renderIslandMarker,
serializeIslandProps, serializeIslandProps,
@@ -71,3 +73,28 @@ test("renders a marker with escaped props", () => {
expect(html).toContain("&lt;"); expect(html).toContain("&lt;");
expect(html).toContain("&quot;"); expect(html).toContain("&quot;");
}); });
test("collects local binding names from island imports only", () => {
const names = islandNamesFrom([
{ kind: "island", declaration: { defaultImport: "Chart" } },
{ declaration: { defaultImport: "Card" } },
{ kind: "island", declaration: {} },
]);
expect([...names]).toEqual(["Chart"]);
});
test("island prop values follow JSX semantics, not raw attribute strings", () => {
// Without this, start={3} arrives as the string "3" and arithmetic inside the
// island concatenates: 3 -> "31" -> "311".
expect(islandPropValue("{3}")).toEqual({ value: 3 });
expect(islandPropValue("{true}")).toEqual({ value: true });
expect(islandPropValue("{[1,2]}")).toEqual({ value: [1, 2] });
expect(islandPropValue('{"a"}')).toEqual({ value: "a" });
expect(islandPropValue("Revenue")).toEqual({ value: "Revenue" });
expect(islandPropValue(undefined)).toEqual({ value: true });
});
test("a runtime expression prop is reported as dynamic", () => {
expect(islandPropValue("{someVariable}")).toEqual({ dynamic: "someVariable" });
expect(islandPropValue("{fn()}")).toEqual({ dynamic: "fn()" });
});
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const SOURCE = `page Home {
view {
<Chart title="Revenue" client:visible />
<Card>plain</Card>
}
}`;
test("an island tag emits an island marker instead of a component mount", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).toContain("Chart");
expect(out).toContain('data-wrn-island-strategy="visible"');
// Non-island components still mount the normal way.
expect(out).toContain('data-component="Card"');
});
test("without the island set the same tag stays a normal component", () => {
const out = generate(parse(SOURCE));
expect(out).not.toContain("data-wrn-island=");
expect(out).toContain('data-component="Chart"');
});
test("island props are serialized into the marker", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("Revenue");
});
test("numeric and boolean island props keep their types through the marker", () => {
const source = `page Home {
view { <Chart start={3} live={true} title="Revenue" /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("&quot;start&quot;:3");
expect(out).toContain("&quot;live&quot;:true");
expect(out).toContain("&quot;title&quot;:&quot;Revenue&quot;");
});
test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
const source = `page Home {
state count = 1
view { <Chart value={count} /> }
}`;
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
/WRN-ISLAND-PROPS/,
);
});
test("an island inside a .wrn component also emits a marker", () => {
const source = `component Panel {
view { <Chart start={1} /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).not.toContain('data-component="Chart"');
});
+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"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.8.14", "version": "0.8.15",
"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"
} }
} }
+1 -3
View File
@@ -226,9 +226,7 @@ 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";`);
} }
+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;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.32", "version": "0.8.37",
"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")
+37 -1
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;
@@ -433,6 +434,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 +717,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 +785,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);
+16 -4
View File
@@ -43,6 +43,7 @@ import {
loadWrnServerModule, loadWrnServerModule,
setCompileCacheDir, setCompileCacheDir,
setCompileImportOptions, setCompileImportOptions,
rebuildChangedIslands,
setDevCompilerPipeline, setDevCompilerPipeline,
wrnBrowserArtifactUrlAsync, wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts"; } from "./pipeline.ts";
@@ -439,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,
@@ -685,6 +685,18 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
console.log(`[wrnexus] hot update — ${files.join(", ")}`); console.log(`[wrnexus] hot update — ${files.join(", ")}`);
await pluginRunner.hook("hmrUpdate", files); await pluginRunner.hook("hmrUpdate", files);
// Island .tsx sources are not .wrn files, so nothing below would rebuild
// them; page modules are cached, so no compile runs on the next request.
const islandFiles = files
.map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed)))
.filter((changed) => changed.endsWith(".tsx"));
if (islandFiles.length > 0) {
try {
await rebuildChangedIslands(islandFiles);
} catch (error) {
console.warn("[wrnexus] island rebuild failed", error);
}
}
const storeUpdates: Array<{ name: string; url: string; kind: string }> = []; const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
for (const changed of files) { for (const changed of files) {
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed); const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
+130 -2
View File
@@ -19,6 +19,8 @@ import {
compile, compile,
generate, generate,
generateTargets, generateTargets,
buildIslands,
islandNamesFrom,
resolveWrnImports, resolveWrnImports,
type PageAst, type PageAst,
type ViewNode, type ViewNode,
@@ -126,6 +128,102 @@ function importOptionsHash(file: string): string {
return hashPath(JSON.stringify({ ...options, aliases })); return hashPath(JSON.stringify({ ...options, aliases }));
} }
/** Island bundles already built this dev session, keyed by resolved source. */
const builtIslands = new Map<string, string>();
/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */
const islandSources = new Map<string, string>();
let islandAppRoot: string | null = null;
let islandRuntimeBuilt = false;
/**
* Builds island bundles on demand in dev and registers them under
* /__wrnexus/island/. Without this the browser bootstrap 404s and no island
* ever mounts.
*/
async function ensureIslandArtifacts(
islands: Array<{ name: string; sourcePath: string }>,
appRoot: string,
): Promise<void> {
if (islands.length === 0) return;
const outDir = join(appRoot, ".wrnexus", "island");
islandAppRoot = appRoot;
for (const island of islands) islandSources.set(island.name, island.sourcePath);
// Keyed by source AND mtime: keying on the path alone serves a stale bundle
// forever once an island .tsx is edited.
const stamp = (island: { name: string; sourcePath: string }) => {
let mtime: number;
try {
mtime = statSync(island.sourcePath).mtimeMs;
} catch {
mtime = 0;
}
return `${island.sourcePath}:${mtime}`;
};
const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island));
if (pending.length === 0 && islandRuntimeBuilt) return;
// The runtime is built alongside the islands so they share one React copy.
const result = await buildIslands({
islands: pending.length ? pending : islands,
outDir,
appRoot,
});
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
islandRuntimeBuilt = true;
for (const asset of result.assets) {
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
const rebuilt = pending.find((island) => island.name === asset.name);
if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt));
}
for (const chunk of result.sharedChunks) {
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
}
}
/**
* Island imports in this file: names so codegen emits placeholders instead of
* component mounts, and sources so the bundles can be built.
*/
/**
* Rebuilds islands whose .tsx source changed.
*
* Page modules are cached after the first request, so no compile runs on a
* later request and nothing else would notice an island edit.
*/
export async function rebuildChangedIslands(changed: string[]): Promise<boolean> {
if (!islandAppRoot) return false;
const touched = new Set(changed.map((file) => resolve(file)));
const affected = [...islandSources]
.filter(([, sourcePath]) => touched.has(resolve(sourcePath)))
.map(([name, sourcePath]) => ({ name, sourcePath }));
if (affected.length === 0) return false;
await ensureIslandArtifacts(affected, islandAppRoot);
return true;
}
function islandsForFile(
ast: PageAst,
importer: string,
): { names: Set<string>; inputs: Array<{ name: string; sourcePath: string }> } {
if (!ast.structuredImports.length) return { names: new Set(), inputs: [] };
const root = projectRootForFile(importer);
const importOptions = compileImportOptions.get(resolve(root)) ?? {
mode: "compatible" as const,
aliases: { "@": "./app" },
autoImport: true,
};
const resolved = resolveWrnImports(ast.structuredImports, importer, {
appRoot: root,
mode: importOptions.mode,
aliases: importOptions.aliases,
});
const inputs = resolved
.filter((entry) => entry.kind === "island" && entry.resolved && entry.declaration.defaultImport)
.map((entry) => ({ name: entry.declaration.defaultImport!, sourcePath: entry.resolved! }));
return { names: islandNamesFrom(resolved), inputs };
}
function rewriteArtifactImports( function rewriteArtifactImports(
code: string, code: string,
ast: PageAst, ast: PageAst,
@@ -396,6 +494,8 @@ export interface WrnCompileArtifacts {
declarations: string; declarations: string;
contract: string; contract: string;
rpc: string; rpc: string;
/** Island inputs for this file, so a cache hit can still build islands. */
islands: string;
} }
export interface WrnCompileMetrics { export interface WrnCompileMetrics {
@@ -486,15 +586,20 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
declarations: join(cacheDir, `${stem}.d.ts`), declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`), contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`), rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
}; };
const result = compile(source, file); const result = compile(source, file);
validateConfiguredImports(source, result.ast, file); validateConfiguredImports(source, result.ast, file);
const ast = await devCompilerPipeline!.transformAst(result.ast, file); const ast = await devCompilerPipeline!.transformAst(result.ast, file);
const { names: islands, inputs: islandInputs } = islandsForFile(ast, file);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(artifacts.islands, JSON.stringify(islandInputs), "utf8");
await ensureIslandArtifacts(islandInputs, projectRootForFile(file));
const targets = generateTargets(ast); const targets = generateTargets(ast);
mkdirSync(cacheDir, { recursive: true }); mkdirSync(cacheDir, { recursive: true });
const browserPath = `/__wrnexus/client/${stem}.mjs`; const browserPath = `/__wrnexus/client/${stem}.mjs`;
const outputs = { const outputs = {
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll( main: `// compiled from .wrn\n${generate(ast, { islands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__", "__WRNEXUS_CLIENT_MODULE__",
browserPath, browserPath,
), ),
@@ -555,13 +660,36 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
declarations: join(cacheDir, `${stem}.d.ts`), declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`), contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`), rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
}; };
compileInProgress.set(file, artifacts); compileInProgress.set(file, artifacts);
try { try {
try { try {
if (Object.values(artifacts).every((path) => statSync(path).isFile())) { // The islands manifest is written only by the async compile path, so it
// is not part of the completeness check — a missing manifest means "no
// islands known for this file", not a stale cache.
const requiredArtifacts = Object.entries(artifacts)
.filter(([key]) => key !== "islands")
.map(([, path]) => path);
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
compileMetrics.hits++; compileMetrics.hits++;
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser); browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
// A cached .wrn still needs its island bundles: the .tsx may have changed
// since, and after a restart with a warm cache nothing else would build them.
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
try {
cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{
name: string;
sourcePath: string;
}>;
} catch {
cachedIslands = [];
}
// This variant is synchronous, so the rebuild is kicked off rather than
// awaited. The async compile path awaits it before serving a page.
void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => {
console.warn("[wrnexus] island rebuild failed", error);
});
return artifacts; return artifacts;
} }
} catch { } catch {
+46 -9
View File
@@ -674,10 +674,10 @@ export const HMR_CLIENT_JS = `
var i18nScript = Array.prototype.find.call( var i18nScript = Array.prototype.find.call(
doc.querySelectorAll("script:not([src])"), doc.querySelectorAll("script:not([src])"),
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); }, function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
); );
if (i18nScript) { if (i18nScript) {
var i18nMatch = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(i18nScript.textContent || "").trim()); var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
if (i18nMatch) { if (i18nMatch) {
try { try {
var incomingI18n = JSON.parse(i18nMatch[1]); var incomingI18n = JSON.parse(i18nMatch[1]);
@@ -805,6 +805,30 @@ export const HMR_CLIENT_JS = `
// Preserve a hydrated subtree only while its server hydration signature and // Preserve a hydrated subtree only while its server hydration signature and
// behavior are unchanged. Component edits must replace and re-hydrate the // behavior are unchanged. Component edits must replace and re-hydrate the
// old subtree or HMR will keep stale markup indefinitely. // old subtree or HMR will keep stale markup indefinitely.
// HMR fetches fresh HTML whose inline scripts carry a NEW server nonce, but a
// document's CSP nonce is fixed at load and cannot be updated. Any node moved
// across therefore has to be re-stamped with the live document's nonce or the
// browser blocks it.
function adoptNonce(node) {
var nonce = currentDocumentNonce();
if (!nonce || !node || node.nodeType !== 1) return node;
var stamp = function (element) {
if (element.getAttribute("src")) return;
element.setAttribute("nonce", nonce);
try {
element.nonce = nonce;
} catch (error) {
// Read-only in some engines; the attribute above is what CSP checks.
}
};
if (node.nodeName === "SCRIPT" || node.nodeName === "STYLE") stamp(node);
if (node.querySelectorAll) {
var nested = node.querySelectorAll("script,style");
for (var i = 0; i < nested.length; i++) stamp(nested[i]);
}
return node;
}
function morph(from, to) { function morph(from, to) {
if (from.__wrnexusHydrated) { if (from.__wrnexusHydrated) {
var sameHydration = var sameHydration =
@@ -813,16 +837,16 @@ export const HMR_CLIENT_JS = `
from.getAttribute("data-scope") === to.getAttribute("data-scope"); from.getAttribute("data-scope") === to.getAttribute("data-scope");
if (sameHydration) return; if (sameHydration) return;
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from); if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
from.replaceWith(to.cloneNode(true)); from.replaceWith(adoptNonce(to.cloneNode(true)));
return; return;
} }
syncAttrs(from, to); syncAttrs(from, to);
var fc = from.childNodes, tc = to.childNodes, i; var fc = from.childNodes, tc = to.childNodes, i;
for (i = 0; i < tc.length; i++) { for (i = 0; i < tc.length; i++) {
var t = tc[i], f = fc[i]; var t = tc[i], f = fc[i];
if (!f) { from.appendChild(t.cloneNode(true)); continue; } if (!f) { from.appendChild(adoptNonce(t.cloneNode(true))); continue; }
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) { if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
from.replaceChild(t.cloneNode(true), f); continue; from.replaceChild(adoptNonce(t.cloneNode(true)), f); continue;
} }
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; } if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
if (f.nodeType === 1) morph(f, t); if (f.nodeType === 1) morph(f, t);
@@ -831,8 +855,10 @@ export const HMR_CLIENT_JS = `
} }
function syncAttrs(from, to) { function syncAttrs(from, to) {
var ta = to.attributes, fa = from.attributes, i; var ta = to.attributes, fa = from.attributes, i;
for (i = 0; i < ta.length; i++) if (from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value); // Never copy the incoming nonce: it belongs to the fetched document and
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name); // would replace the live nonce this document's CSP actually allows.
for (i = 0; i < ta.length; i++) if (ta[i].name !== "nonce" && from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
for (i = fa.length - 1; i >= 0; i--) if (fa[i].name !== "nonce" && !to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
} }
// Exposed for tests; harmless (the client is injected only in dev). // Exposed for tests; harmless (the client is injected only in dev).
@@ -852,8 +878,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. */
@@ -19,6 +19,9 @@ export function collectScripts(
) { ) {
scripts.push("/__wrnexus/reactive.js"); scripts.push("/__wrnexus/reactive.js");
} }
// Islands ship their own bootstrap, not the reactive runtime — a page whose
// only interactivity is an island must not pull in WRNexus's client runtime.
if (/\bdata-wrn-island=/.test(body)) scripts.push("/__wrnexus/islands.js");
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js"); if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
if ( if (
/\bdata-wrn-theme-(toggle|set)\b/.test(body) || /\bdata-wrn-theme-(toggle|set)\b/.test(body) ||
+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" },
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test";
import { HMR_CLIENT_JS } from "../src/runtime.ts";
test("the HMR client script is syntactically valid JavaScript", () => {
// HMR_CLIENT_JS is a TypeScript template literal, so an escape like \t or \n
// written unescaped is expanded by TypeScript into a real control character.
// Inside a regex literal that produces a raw newline, which is a syntax error
// that silently kills the whole HMR client in the browser.
expect(() => new Function(HMR_CLIENT_JS)).not.toThrow();
});
test("the HMR client contains no raw control characters inside regex literals", () => {
const regexLiterals = HMR_CLIENT_JS.match(/\/(?![/*])(?:\.|\[[^\]]*\]|[^/\n\r])+\//g) ?? [];
expect(regexLiterals.length).toBeGreaterThan(0);
for (const literal of regexLiterals) {
expect(literal).not.toMatch(/[\n\r\t]/);
}
});
+2 -1
View File
@@ -14,5 +14,6 @@ test("HMR replaces hydrated components when their server signature changes", ()
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")'); expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")');
expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")'); expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")');
expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)"); expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)");
expect(HMR_CLIENT_JS).toContain("from.replaceWith(to.cloneNode(true))"); // Cloned nodes are re-stamped with the live document nonce before insertion.
expect(HMR_CLIENT_JS).toContain("from.replaceWith(adoptNonce(to.cloneNode(true)))");
}); });
@@ -0,0 +1,73 @@
import { afterAll, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { compile, generate, islandNamesFrom, resolveWrnImports } from "@wrnexus/compiler";
import { collectScripts } from "../src/script-selection.ts";
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
function app() {
const root = mkdtempSync(join(process.cwd(), ".island-e2e-"));
created.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "app", "Chart.tsx"),
`export default function Chart({ title }: { title: string }) { return <div>{title}</div>; }`,
);
writeFileSync(join(root, "app", "Card.wrn"), `component Card { view { <div>card</div> } }`);
return root;
}
test("a .wrn importing a .tsx emits an island marker and requests the bootstrap", () => {
const root = app();
const page = join(root, "app", "page.wrn");
const source = [
'import Chart from "./Chart"',
'import Card from "./Card"',
"page Home {",
" view {",
' <Chart title="Revenue" client:visible />',
" <Card />",
" }",
"}",
].join("\n");
writeFileSync(page, source);
const ast = compile(source, page).ast;
const islands = islandNamesFrom(
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
);
// Only the .tsx import is an island; the .wrn component is not.
expect([...islands]).toEqual(["Chart"]);
const out = generate(ast, { islands });
expect(out).toContain('data-wrn-island="Chart"');
expect(out).toContain('data-wrn-island-strategy="visible"');
expect(out).toContain('data-component="Card"');
// Rendered island markup must pull in the island bootstrap.
expect(collectScripts('<div data-wrn-island="Chart"></div>')).toContain("/__wrnexus/islands.js");
});
test("a page with no .tsx imports emits no island markup and no island script", () => {
const root = app();
const page = join(root, "app", "plain.wrn");
const source = ['import Card from "./Card"', "page Plain {", " view { <Card /> }", "}"].join(
"\n",
);
writeFileSync(page, source);
const ast = compile(source, page).ast;
const islands = islandNamesFrom(
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
);
expect(islands.size).toBe(0);
const out = generate(ast, { islands });
expect(out).not.toContain("data-wrn-island");
expect(collectScripts(out)).not.toContain("/__wrnexus/islands.js");
});
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { collectScripts } from "../src/script-selection.ts";
test("island markup pulls in the island bootstrap", () => {
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
expect(scripts).toContain("/__wrnexus/islands.js");
});
test("markup without islands ships no island bootstrap", () => {
expect(collectScripts(`<p>plain server html</p>`)).toEqual([]);
});
test("islands alone do not pull in the reactive runtime", () => {
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
expect(scripts).not.toContain("/__wrnexus/reactive.js");
});
@@ -22,7 +22,10 @@ function customNotFoundRuntime() {
router: buildRouter(app), router: buildRouter(app),
loadModule: async (file) => loadModule: async (file) =>
file.includes(`${join("api", "404")}.ts`) file.includes(`${join("api", "404")}.ts`)
? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) } ? {
GET: () =>
Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }),
}
: { default: () => "<main><h1>That page is gone</h1></main>" }, : { default: () => "<main><h1>That page is gone</h1></main>" },
getMiddleware: async () => [], getMiddleware: async () => [],
assets: { serve: async () => null }, assets: { serve: async () => null },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.8.9", "version": "0.8.12",
"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.11",
"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/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"
} }
} }
+8 -3
View File
@@ -5,15 +5,20 @@
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./runtime": "./src/runtime-source.ts" "./runtime": "./src/runtime-source.ts",
"./browser": "./src/browser.ts"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react": { "optional": true }, "react": {
"react-dom": { "optional": true } "optional": true
},
"react-dom": {
"optional": true
}
}, },
"dependencies": { "dependencies": {
"@wrnexus/store": "workspace:*" "@wrnexus/store": "workspace:*"
+14
View File
@@ -0,0 +1,14 @@
/**
* Browser entry for the island mount runtime, served at
* `/__wrnexus/island/runtime.js` and imported by the bootstrap only when a
* `data-wrn-island` marker is present.
*/
export {
discardDetachedRoots,
islandRootCount,
mountIslands,
remountIslands,
unmountIslands,
} from "./island-runtime.ts";
export type { MountOptions } from "./island-runtime.ts";
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
+1 -1
View File
@@ -41,4 +41,4 @@ export class IslandErrorBoundary extends Component<
</div> </div>
); );
} }
} }
+1 -1
View File
@@ -6,4 +6,4 @@ export { IslandErrorBoundary } from "./error-boundary.tsx";
export type { IslandErrorBoundaryProps } from "./error-boundary.tsx"; export type { IslandErrorBoundaryProps } from "./error-boundary.tsx";
export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts"; export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts";
export type { MountOptions } from "./island-runtime.ts"; export type { MountOptions } from "./island-runtime.ts";
export { remountIslands } from "./island-runtime.ts"; export { discardDetachedRoots, remountIslands } from "./island-runtime.ts";
+46 -9
View File
@@ -5,6 +5,14 @@ import { IslandErrorBoundary } from "./error-boundary.tsx";
export interface MountOptions { export interface MountOptions {
loader: (name: string) => Promise<{ default: ComponentType<any> }>; loader: (name: string) => Promise<{ default: ComponentType<any> }>;
development?: boolean; development?: boolean;
/**
* Re-render islands that are already mounted instead of skipping them.
*
* Used by HMR: the container element usually survives the morph, and React
* refuses a second `createRoot` on the same container, so the existing root
* has to be re-rendered rather than replaced.
*/
remount?: boolean;
} }
const roots = new Map<Element, Root>(); const roots = new Map<Element, Root>();
@@ -43,7 +51,7 @@ function whenReady(element: Element, strategy: string): Promise<void> {
} }
async function mountOne(element: Element, options: MountOptions): Promise<void> { async function mountOne(element: Element, options: MountOptions): Promise<void> {
if (roots.has(element)) return; if (roots.has(element) && !options.remount) return;
const name = element.getAttribute("data-wrn-island"); const name = element.getAttribute("data-wrn-island");
if (!name) return; if (!name) return;
@@ -53,7 +61,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
// Re-check: an await point means a concurrent mount may have claimed this // Re-check: an await point means a concurrent mount may have claimed this
// element while the strategy was resolving. // element while the strategy was resolving.
if (roots.has(element)) return; if (roots.has(element) && !options.remount) return;
let Component: ComponentType<any>; let Component: ComponentType<any>;
try { try {
@@ -63,9 +71,11 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
return; return;
} }
if (roots.has(element)) return; if (roots.has(element) && !options.remount) return;
const root = createRoot(element); // Reuse an existing root: React rejects a second createRoot on the same
// container, and HMR keeps the container across a morph.
const root = roots.get(element) ?? createRoot(element);
roots.set(element, root); roots.set(element, root);
root.render( root.render(
createElement( createElement(
@@ -102,14 +112,41 @@ export function unmountIslands(root: ParentNode): void {
} }
/** /**
* Dev-only: dispose and re-create island roots after a source change. * Dev-only: re-render islands after a source change.
* *
* Island state resets by design Fast Refresh needs a Babel/SWC transform plus * Island state resets by design Fast Refresh needs a Babel/SWC transform plus
* a runtime and is out of scope. `unmountIslands` clears each element from the * a runtime and is out of scope.
* root registry, so the following `mountIslands` is not short-circuited by the
* already-mounted guard.
*/ */
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> { export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
unmountIslands(root); // Every mounted container is swapped for a bare clone before remounting.
//
// Re-rendering the existing root is not enough: HMR wipes the container's
// children externally, and React — whose virtual tree is unchanged — treats
// the re-render as a no-op and leaves the island blank. Unmounting instead
// throws asynchronously, because the DOM it wants to remove is already gone.
// A fresh container sidesteps both, and React accepts createRoot on a node it
// has never seen.
for (const [element] of [...roots]) {
if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) {
continue;
}
roots.delete(element);
if (element.isConnected) element.replaceWith(element.cloneNode(false));
}
discardDetachedRoots();
await mountIslands(root, options); await mountIslands(root, options);
} }
/**
* Forgets roots whose container left the document.
*
* HMR morphs server markup over the mounted island, so React's DOM is already
* gone by the time we get here; calling unmount then throws asynchronously with
* "The node to be removed is not a child of this node". Navigation still uses
* `unmountIslands`, where the DOM is intact and cleanup must actually run.
*/
export function discardDetachedRoots(): void {
for (const element of [...roots.keys()]) {
if (!element.isConnected) roots.delete(element);
}
}
+5
View File
@@ -25,6 +25,11 @@ export function getIslandRuntime(development = false): string {
development: ${development} development: ${development}
}); });
}; };
// HMR morphs the server placeholder back over the mounted island, which
// discards the React tree. Remount once the DOM has settled.
window.addEventListener("wrnexus:hmr-updated", function () {
window.__wrnexusRemountIslands();
});
}).catch(function (error) { }).catch(function (error) {
console.error("[wrnexus] failed to load the island runtime", error); console.error("[wrnexus] failed to load the island runtime", error);
}); });
+28 -1
View File
@@ -1,7 +1,12 @@
import { afterEach, expect, test } from "bun:test"; import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import { act, createElement } from "react"; import { act, createElement } from "react";
import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts"; import {
islandRootCount,
mountIslands,
remountIslands,
unmountIslands,
} from "../src/island-runtime.ts";
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -65,3 +70,25 @@ test("repeated remounts stay at one root", async () => {
expect(islandRootCount()).toBe(1); expect(islandRootCount()).toBe(1);
}); });
test("remount re-renders in place instead of creating a second root", async () => {
const window = domWith(marker);
const loader = async () => ({ default: () => createElement("span", null, "v1") });
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
// Simulate HMR morphing server markup back over the mounted island: React's
// rendered DOM is gone, so unmounting it would throw.
const el = window.document.querySelector("[data-wrn-island]")!;
el.innerHTML = "";
await act(async () => {
await remountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
expect(window.document.body.textContent).toContain("v1");
});
@@ -23,3 +23,11 @@ test("threads the development flag into the mount options", () => {
test("encodes the island name before using it as a URL path segment", () => { test("encodes the island name before using it as a URL path segment", () => {
expect(getIslandRuntime(false)).toContain("encodeURIComponent"); expect(getIslandRuntime(false)).toContain("encodeURIComponent");
}); });
test("the runtime remounts islands after an HMR update", () => {
// HMR morphs the server placeholder over the mounted island, discarding the
// React tree; without this the island silently disappears on every edit.
const source = getIslandRuntime(true);
expect(source).toContain("wrnexus:hmr-updated");
expect(source).toContain("__wrnexusRemountIslands");
});
+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": {
+1 -16
View File
@@ -34,28 +34,13 @@ function loadTypeScript() {
const ts = loadTypeScript(); const ts = loadTypeScript();
// React island modules are not used by the editor: island-bundle.ts calls
// Bun.build and island-codegen.ts imports @wrnexus/core, neither of which
// exists in this Node-only bundle. They are unreachable from the editor entry,
// so excluding them keeps Bun-only code out of the extension entirely.
const EDITOR_EXCLUDED = ["island-bundle.ts", "island-codegen.ts"];
function isEditorExcluded(path) {
return EDITOR_EXCLUDED.some((name) => path.endsWith(name));
}
function walk(dir) { function walk(dir) {
const files = []; const files = [];
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
const path = join(dir, entry); const path = join(dir, entry);
const stat = statSync(path); const stat = statSync(path);
if (stat.isDirectory()) files.push(...walk(path)); if (stat.isDirectory()) files.push(...walk(path));
else if ( else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path);
stat.isFile() &&
path.endsWith(".ts") &&
!path.endsWith(".test.ts") &&
!isEditorExcluded(path)
)
files.push(path);
} }
return files; return files;
} }
+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
@@ -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
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,
@@ -20,6 +21,7 @@
"@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"], "@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"],
"@wrnexus/react": ["./packages/react/src/index.ts"], "@wrnexus/react": ["./packages/react/src/index.ts"],
"@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"], "@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"],
"@wrnexus/react/browser": ["./packages/react/src/browser.ts"],
"@wrnexus/reactive": ["./packages/reactive/src/index.ts"], "@wrnexus/reactive": ["./packages/reactive/src/index.ts"],
"@wrnexus/graphql": ["./packages/graphql/src/index.ts"], "@wrnexus/graphql": ["./packages/graphql/src/index.ts"],
"@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"], "@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],