Compare commits

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

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

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

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

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

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

Two decisions worth review:

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:23:08 +05:30
ClintchizandClaude Opus 5 b646ec8d00 chore(release): patch-bump packages changed since the last publish
Quality / quality (ubuntu-latest) (push) Failing after 12m48s
Quality / quality (windows-latest) (push) Canceled after 0s
cli 0.8.42, csr 0.8.22, db 0.8.16, dev-server 0.8.38,
dev-toolbar 0.8.13, i18n 0.8.12.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:16:34 +05:30
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
ClintchizandClaude Opus 5 442a3106ed test(islands): guard zero-JS routes and single-React bundling
Two guards protect the core promise: a route with no islands emits no
assets at all, and a page with several islands keeps React in one shared
chunk.

buildIslands now writes a generated entry per island instead of passing
component sources directly. Two islands sharing a source deduped to a
single entrypoint, and output order is not guaranteed to match input
order, so island names could bind to the wrong bundle.

Island modules are excluded from the editor compiler bundle: it globs
packages/compiler/src, and island-bundle.ts calls Bun.build while
island-codegen.ts imports @wrnexus/core — neither belongs in a Node-only
VS Code artifact.

Integration assertions share one build. bun test interferes with
Bun.build's module reads after several build calls in one process, while
the same calls succeed repeatedly outside the runner; production is
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:50:26 +05:30
ClintchizandClaude Opus 5 3115277e9d chore(ui): refresh the stale UI visual contract baseline
Card, Carousel, Footer, Navbar, input, select, and textarea last changed
in d78707be, but the baseline was last regenerated several commits
earlier, so check:ui-visual already failed on main.

Unrelated to the React islands work; committed separately so the feature
changeset does not absorb it. Only source hashes changed — no UI source
was modified here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:44:27 +05:30
ClintchizandClaude Opus 5 02fdfa3aee feat(react): remount islands on hot module replacement
Disposes and re-creates island roots after a source change. Island state
resets by design; Fast Refresh needs a Babel/SWC transform plus a
runtime and is out of scope for v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:34:36 +05:30
ClintchizandClaude Opus 5 5f66c8129c docs(react-islands): drop the write-during-render guard
Implemented and removed. A render-phase flag cleared on a microtask is
still set when React runs effects, so islands writing from an effect —
the documented correct pattern — would throw. The flag also cannot be
set for an island's own re-renders, so real violations pass silently.

Detecting React's render phase reliably needs React internals, which is
not acceptable in a shipped framework. React already reports the real
hazard, and the getSnapshot caching requirement covers the loop case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:33:48 +05:30
ClintchizandClaude Opus 5 01a3b3b4e9 feat(compiler): classify island routes as static-interactive
A route mounting an island ships JavaScript, so reporting it as zero-JS
static would make the framework's performance accounting wrong.

Adds a separate needsIslandRuntime flag rather than reusing
needsClientRuntime: an island needs the island runtime, not WRNexus's
reactive runtime, and conflating them would ship the wrong bundle.

analyzeRuntimeRequirements takes island presence as an optional second
argument, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:30:19 +05:30
ClintchizandClaude Opus 5 a184f1a3be feat(islands): serve the island runtime in dev, prod, and static builds
Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/
prefix (mount runtime, island bundles, shared chunks) to all three
serving paths.

Dev reuses the browserArtifactPaths registry pattern from pipeline.ts.
Prod mirrors the clientModulesDir handler, including its filename
allowlist, so island names cannot escape the output directory.

The bootstrap is inert without a data-wrn-island marker, so island-free
pages still download nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:28:10 +05:30
ClintchizandClaude Opus 5 06df9d66ae feat(compiler): bundle islands with a shared React chunk
splitting:true keeps React in one shared chunk so a page with several
islands does not ship react-dom repeatedly.

Island .tsx is compiled against React's JSX runtime via a Bun onLoad
plugin. The repo's root tsconfig sets jsxImportSource to @wrnexus/core,
so islands would otherwise compile to the HTML-string renderer and never
mount. A @jsxImportSource pragma only affects the file carrying it, so
injecting one into the generated entry is not enough — the injection has
to happen per source file. App-authored islands stay plain .tsx.

The JSX test asserts built output rather than generated entry text,
because the entry-text assertion passed while the mechanism did not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:22:58 +05:30
ClintchizandClaude Opus 5 d269772a79 feat(react): add island mount strategies and navigation-safe unmount
Mounts markers with client:only/load/visible/idle, and disposes roots on
route change so React roots, detached DOM, and store subscriptions do
not leak across client-side navigation.

Bundle load failures and malformed props JSON degrade to a warning and
leave the server markup intact rather than taking down the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:20:05 +05:30
ClintchizandClaude Opus 5 df2ee036eb feat(react): add per-island error boundary
A crashed island renders its error in dev and nothing in prod, leaving
the surrounding server-rendered page intact.

Island .tsx sources carry an explicit @jsxImportSource react pragma: the
repo's root tsconfig points jsxImportSource at @wrnexus/core, so without
it island JSX compiles to WRNexus's string renderer instead of React
elements.

Tests render on the client via createRoot rather than a server renderer,
because React error boundaries do not engage during SSR — and islands
are client-only regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:16:42 +05:30
ClintchizandClaude Opus 5 b405025f37 feat(compiler): resolve .tsx imports and tag them as islands
.wrn keeps resolution priority so existing components are unaffected
when a .tsx file shares their name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:15:08 +05:30
ClintchizandClaude Opus 5 cd07f0f8e2 feat(compiler): add island marker codegen and props contract
Emits the data-wrn-island placeholder, parses client:* strategies, and
rejects non-serializable props at compile time via WRN-ISLAND-PROPS so
the serialization boundary fails where it is cheapest to fix.

Island names become URL path segments when the browser fetches the
island bundle, so they are validated with core's existing
isSafeIslandName rather than relying on escaping alone. This adds
@wrnexus/core to the compiler's dependencies; core has no dependencies
of its own, so no cycle is introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:13:56 +05:30
ClintchizandClaude Opus 5 ce67d0d1d2 feat(react): add useWrnStore bridge over useSyncExternalStore
Resolves WRNexus stores from inside islands, reading through the cached
snapshot so React sees a stable reference. Unknown store names throw
with the list of registered stores rather than failing opaquely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:12:33 +05:30
ClintchizandClaude Opus 5 468f63f378 feat(react): add referentially-stable snapshot and selector caches
useSyncExternalStore requires getSnapshot to return an identical
reference when unchanged; @wrnexus/store's readonlySnapshot returns a
fresh clone per call. The cache lives here rather than in the store
package so existing consumers are untouched.

createSelectorCache takes an optional equality function: the Object.is
default can never stabilize a selector that allocates, which is the
usual source of infinite re-renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:11:22 +05:30
ClintchizandClaude Opus 5 a6570d7f68 docs(react-islands): add implementation plan
Twelve TDD tasks covering the approved spec: snapshot cache, store
bridge, marker codegen, .tsx resolution, error boundary, island runtime,
bundling with a shared React chunk, asset serving, route classification,
integration guards, the write-during-render guard, and HMR remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:07:32 +05:30
ClintchizandClaude Opus 5 4ef2ef14ff docs(react-islands): add design spec for opt-in React islands
Adds the approved design for consuming npm React components as
client-only islands inside WRNexus, without changing the SSR-first
rendering model.

Key decisions:
- New isolated @wrnexus/react package; react/react-dom as optional peers
- Client-only by default, SSR deferred to v2
- Islands declared via .tsx imports in .wrn frontmatter
- Store access via useSyncExternalStore, with the snapshot cache in the
  adapter so @wrnexus/store stays untouched
- Bundling extends the existing Bun pipeline; React as a shared chunk
- Routes with no islands must still ship zero framework JavaScript

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:01:58 +05:30
Clintchiz d78707be9f fix(forms): surface validation and recover schema drift
Quality / quality (ubuntu-latest) (push) Failing after 10m23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 13:01:10 +05:30
Clintchiz 1d16ef1e82 chore(release): refresh ui consumers
Quality / quality (ubuntu-latest) (push) Failing after 9m50s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:33:40 +05:30
Clintchiz 12a1014db2 fix(ui): bind textarea values without markup
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:30:46 +05:30
Clintchiz 52b3e6d378 fix(csr): preserve translations across navigation
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:26:01 +05:30
Clintchiz f88dd47408 fix(runtime): stabilize navigation and custom errors
Quality / quality (ubuntu-latest) (push) Failing after 23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 10:44:06 +05:30
Clintchiz f0447fddb0 fix(db): share registry across bundled copies
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-13 19:10:51 +05:30
123 changed files with 9650 additions and 2679 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
fund=false
+200 -738
View File
File diff suppressed because it is too large Load Diff
+52 -1
View File
@@ -973,6 +973,10 @@
"EffectBlock",
"EventDecl",
"FormatWrnOptions",
"IslandBuildResult",
"IslandDiagnostic",
"IslandInput",
"IslandStrategy",
"LexError",
"Lexer",
"LoadBlock",
@@ -999,7 +1003,9 @@
"analyzeOptimizations",
"analyzeRuntimeImports",
"analyzeRuntimeRequirements",
"assertReactAvailable",
"assertValidAst",
"buildIslands",
"compilationKey",
"compile",
"compileNativeWrnFile",
@@ -1015,19 +1021,26 @@
"generate",
"generateBrowserModule",
"generateDeclarations",
"generateIslandEntry",
"generateNative",
"generateServerFunctionsModule",
"generateStoreBrowserModule",
"generateStoreModule",
"generateTargets",
"inferredRuntimeType",
"islandNamesFrom",
"islandPropValue",
"optimizeAst",
"parse",
"parseIslandStrategy",
"renderIslandMarker",
"resolveWrnImport",
"resolveWrnImports",
"routeNeedsIslands",
"rpcManifest",
"runtimeCapabilities",
"runtimeTypeOf"
"runtimeTypeOf",
"serializeIslandProps"
]
},
"@wrnexus/content": {
@@ -1661,6 +1674,7 @@
"@wrnexus/i18n": {
".": [
"ExtractedTranslationKey",
"I18N_DATA_ATTRIBUTE",
"I18N_JS_HREF",
"I18N_RUNTIME",
"I18nConfig",
@@ -1697,6 +1711,7 @@
"plural",
"pseudoLocalize",
"renderI18nData",
"renderI18nDataTag",
"resolveI18n",
"resolveLang",
"translateHtml",
@@ -2259,6 +2274,42 @@
"subjectQueue"
]
},
"@wrnexus/react": {
".": [
"BoundStore",
"IslandErrorBoundary",
"IslandErrorBoundaryProps",
"IslandStore",
"MountOptions",
"SnapshotCache",
"SnapshotSource",
"StoreResolver",
"createSelectorCache",
"createSnapshotCache",
"discardDetachedRoots",
"islandRootCount",
"mountIslands",
"remountIslands",
"setStoreResolver",
"unmountIslands",
"useWrnActions",
"useWrnStore"
],
"./browser": [
"MountOptions",
"discardDetachedRoots",
"islandRootCount",
"mountIslands",
"remountIslands",
"setStoreResolver",
"unmountIslands",
"useWrnActions",
"useWrnStore"
],
"./runtime": [
"getIslandRuntime"
]
},
"@wrnexus/reactive": {
".": [
"AnimationTimeline",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
# React Islands for WRNexus — Design
**Date:** 2026-08-18
**Status:** Approved for implementation
**Scope:** Add opt-in React islands to WRNexus without altering the existing SSR-first rendering model.
## Goal
Give WRNexus authors access to the npm React ecosystem — charts, editors, date pickers,
drag-and-drop, maps — without rebuilding those components natively and without adopting React
as the framework's rendering model.
This is explicitly **not** a migration path toward React, and not a replacement for `.wrn`
components. Islands are a consumption path for third-party components.
### Non-goals
- Server-rendering islands (deferred; see "Deferred to v2").
- React Fast Refresh.
- `bind:` syntax sugar for store binding.
- Replacing the `.wrn` authoring format.
## Guiding constraint
WRNexus's differentiator is that non-interactive routes ship **zero** framework JavaScript.
Every decision below is subordinate to preserving that. An app that uses no islands must be
byte-for-byte unchanged, and a route with no islands must ship no React.
## Decisions
| Question | Decision |
| ---------------- | ---------------------------------------------------------------------------------------- |
| Purpose | npm ecosystem access |
| 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 />` |
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
| Bundling | Extend the existing Bun pipeline |
| Packaging | New isolated package `@wrnexus/react` |
## Architecture
### Package boundary
All React-specific code lives in a new package, `@wrnexus/react`. `react` and `react-dom` are
declared as **optional peer dependencies**, so both the bundle cost and the dependency itself
land only on apps that import a `.tsx` island.
This boundary is deliberate: React concerns must never leak into `core`, `csr`, `store`, or
`compiler` beyond the narrow, explicitly enumerated hooks below. If islands do not earn their
keep, the feature is removed by deleting one package and reverting a small number of tagged
integration points.
### Island lifecycle
The compiler emits a placeholder element carrying `data-wrn-island` (name, strategy, serialized
props) alongside the existing `data-wrn-scope` marker.
The island runtime is lazy-loaded using the same marker-presence pattern as
`loadComponentControllers` in `packages/csr/src/index.ts` — fetched only if a `data-wrn-island`
marker exists in the document. A page with no islands downloads nothing, including React.
Mount strategies:
- `client:only` (default) — mount via `createRoot` once the bundle arrives.
- `client:load` — mount on document load.
- `client:visible` — mount via `IntersectionObserver`.
- `client:idle` — mount on `requestIdleCallback`.
**Unmount is mandatory.** WRNexus has client-side navigation (`packages/csr/src/nav-runtime.ts`).
Island roots are tracked per scope and explicitly `root.unmount()`-ed on route change. Omitting
this leaks React roots, detached DOM, and store subscriptions on every navigation.
### Asset serving
Two additions following the existing `/__wrnexus/*` convention:
- `/__wrnexus/islands.js` — the mount runtime.
- `/__wrnexus/island/<hash>.js` — per-island bundles, content-hashed.
Registered in the three existing locations:
- `packages/dev-server/src/assets.ts` (dev)
- `packages/dev-server/src/prod.ts` (prod)
- `packages/cli/src/build.ts` (static build)
## Store bridge
### The hook
`useWrnStore(name, selector?)`, built on `useSyncExternalStore`.
`StoreInstanceCore` (`packages/store/src/types.ts`) already provides both halves of React's
external-store contract — `subscribe(listener) => unsubscribe` and `snapshot()` — so the
adapter is thin. Islands resolve the browser container via `browserStoreContainer()` from
`packages/store/src/client.ts`, reading the store already hydrated from the server render. No
separate island hydration channel is introduced.
### Snapshot caching (load-bearing)
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
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 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`
callback fires, then recomputes.
Rationale: changing `readonlySnapshot` would alter semantics for all existing consumers and the
current test suite in order to serve one new caller. Keeping the cache in the adapter leaves the
store package untouched and confines this feature's blast radius to the new package.
### Selectors
`snapshot()` returns whole state, so without a selector any mutation re-renders every island
bound to that store. `useWrnStore("cart", s => s.itemCount)` caches the selected value and
compares with `Object.is`, re-rendering only on actual change.
### Writes
Writes go through `instance.actions.*`, never direct state assignment. The `mutableState` Proxy
would technically accept a raw write, but that bypasses action naming and the `StoreMutation`
record that subscribers and devtools depend on.
### The one author-facing rule
Write → action → store notifies → snapshot changes → island re-renders. This terminates cleanly
**provided writes never occur during render**. Writes belong in event handlers or effects.
This rule is documented rather than machine-enforced — see "Dropped: the write-during-render
guard" below. It is also the reason this shape was chosen over generated `bind:` sugar: the
cycle stays visible in the author's own code rather than being hidden in generated glue.
## Compiler and bundler changes
### Detection
`candidates()` in `packages/compiler/src/import-resolver.ts` currently resolves `.wrn`, `.ts`,
`.d.ts` and index variants. Add `.tsx` and `index.tsx`, and tag `ResolvedImport` with
`kind: "island"` when the resolved path ends in `.tsx`.
Because authoring uses an explicit frontmatter import, detection requires no heuristics and no
configuration.
### Server codegen
Where a `.wrn` component import generates a server render call, an island import instead emits
the placeholder marker with name, strategy, and props serialized as JSON through the existing
`escapeHtml`. Since islands are client-only in v1, the server never imports React.
### Props contract
Island props must be JSON-serializable. Passing a function, symbol, or class instance is a
compile-time diagnostic (`WRN-ISLAND-PROPS`) rather than a runtime failure. This makes the
serialization boundary explicit at the point where it is cheapest to correct.
### Bundling
Each island gets a generated entry (component + mount runtime), bundled via `Bun.build`, with
content-hashed output.
**React must be emitted as a shared chunk.** Five islands on one page must not ship five copies
of `react-dom`. This is a day-one splitting requirement, not a later optimization, because
getting it wrong fails silently and multiplies bundle size.
### Route classification
The compiler already classifies routes (static, static-interactive, request SSR, and so on), and
that classification determines whether a route ships JavaScript. A route containing an island is
no longer zero-JS static — it is static-interactive. Islands must feed into that existing
classifier so the framework's performance reporting stays accurate.
### HMR
On island source change: unmount the root and re-mount with the new bundle. Correct and simple;
the cost is that component state resets on edit.
React Fast Refresh requires a Babel/SWC transform plus a runtime and is out of scope. If authors
report that state-preserving edits matter, that is the concrete evidence that would justify
introducing Vite or esbuild for island bundling — and the bundler interface is the intended swap
point.
## Error handling
| Condition | Behavior |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 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` |
| 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. |
| 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
the rest of the page working.
## Dropped: the write-during-render guard
The design originally called for a dev-only guard that threw when an island
called a store action during render. It was implemented, then removed: the
mechanism is unreliable in both directions.
- **False positives.** React runs effects before a queued microtask drains, so a
render-phase flag cleared on a microtask is still set inside `useEffect`. An
island writing from an effect — the documented correct pattern — would throw.
- **False negatives.** The flag can only be set from the error boundary`s
render. When an island updates its own state, only the island re-renders, so
the flag is never set and a genuine write-during-render passes silently.
There is no reliable public API for detecting React`s render phase; doing it
properly requires React internals, which is not acceptable in a shipped
framework.
React already covers the real hazard: writing during render that notifies
subscribers produces "Cannot update a component while rendering a different
component", and the infinite-loop case is caught by the `getSnapshot` caching
requirement handled in the store bridge. The custom guard added false positives
without covering anything React misses.
The author-facing rule still stands and is still documented — it is simply not
machine-enforced.
## Testing
### Compiler unit tests
- `.tsx` resolution through `candidates()`
- Marker emission with correct name, strategy, and props
- JSON serialization and escaping of props
- `WRN-ISLAND-PROPS` diagnostic for non-serializable props
- `WRN-ISLAND-REACT-MISSING` diagnostic
- Route reclassification from static to static-interactive when an island is present
### Store bridge unit tests
- **`getSnapshot()` returns a referentially identical value across repeated calls with no
mutation, and a new one after a mutation.** This single test stands between the
implementation and an infinite render loop.
- Selector memoization and `Object.is` change detection
- `subscribe`/`unsubscribe` symmetry
### Island runtime tests (`happy-dom`, already a dev dependency)
- Mount per strategy: `only`, `load`, `visible`, `idle`
- Error boundary containment
- **Unmount on navigation** — roots disposed, store subscriptions released; subscription counts
stay flat across repeated simulated navigations
### Integration guards
Both protect the core promise:
1. A route with no islands ships **zero** framework JavaScript.
2. A page with multiple islands ships React exactly **once**.
All of the above run under the existing `bun test packages` and `test:examples`, so
`check:production` covers islands from day one.
## Deferred to v2
- **SSR opt-in** — `renderToString` + `hydrateRoot` for libraries that support it. The marker and
bundling design already accommodate this; only the server codegen path and a hydration
strategy are missing.
- **`bind:` sugar** — generated two-way binding in `.wrn` markup, layered over the v1 store
bridge as pure syntax. Add only if authors ask.
- **React Fast Refresh** — see HMR above.
@@ -0,0 +1,252 @@
# HTML editing support for `.wrn` files — Design
**Date:** 2026-08-18
**Status:** Approved for implementation
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
## Goal
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
tag-level folding.
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html``html`), which is
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
### Non-goals
- **HTML formatting.** See "Formatting is deliberately excluded" below.
- Editor support outside VS Code beyond what standard LSP gives for free.
- Changing `.wrn` syntax or the compiler.
## Decisions
| Question | Decision |
| ------------------- | ---------------------------------------------------------------------------- |
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
| Region detection | Tolerant scanner over a virtual document, not the AST |
| HTML knowledge | `vscode-html-languageservice` |
| Formatting | Excluded — `formatWrn` already owns markup formatting |
## Architecture
### Virtual HTML document
New module: `packages/language-server/src/html-regions.ts`, exporting
`virtualHtmlDocument(document)`.
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
newlines preserved. The virtual document therefore has the same size and the same line/column
geometry as the source, so a position in the source _is_ the position in the virtual document.
No mapping table and no translation layer.
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
compacts code and carries line mappings back to source. Compaction is necessary there because
the output must be valid TypeScript. HTML has no such requirement, so the simpler
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
does not arise.
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
newlines at identical offsets. If this breaks, every feature reports positions off by some
amount rather than failing loudly.
### Region detection
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
while the document is being typed, which is exactly when it does not parse. The scanner finds
`view` followed by `{` and tracks brace depth to the matching close.
Two hazards it must handle, both of which defeat a naive implementation:
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
delimiter anywhere will consider the rest of the file one open string and lose every later
region. Quotes are tracked only inside attribute values, never in text nodes.
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
depth must be counted rather than scanning for the next `}`.
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
costs a single scan.
## Completion
### The server becomes the single authority inside view blocks
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
virtual document is non-blank there, which costs one character lookup.
**Inside a view block**, one list is assembled from two sources:
| Source | `sortText` prefix | Content |
| ------- | ----------------- | ------------------------------------------------------------------------ |
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
| HTML | `1` | Tags, attributes, attribute values |
`sortText` drives ordering independently of the label, so components rank above HTML tags
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
plus workspace items.
The server already indexes components, props, outputs, and slots
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
of the merge are already available to it.
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
resolves to the component.
### Trigger characters
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
`" "`, `"="`, `"\""`, and `"/"`.
### This fixes an existing bug
The extension's `completion.js` registers its own provider with `<` among its trigger
characters, and the language server answers `textDocument/completion` as well. VS Code
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
involved at all.
As part of this work the extension's provider returns nothing when the position is inside a view
block, and keeps its current behaviour elsewhere. One owner per context.
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
context, so future component-intelligence work belongs in the server rather than in
`completion.js`.
## Hover
`textDocument/hover` answers from the HTML service over the virtual document when the position
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
region, existing hover behaviour is unchanged.
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
any HTML entry of the same name, matching the completion precedence rule above.
## Tag handling
### Linked editing is standard LSP
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
it lives in the shared server like everything else.
### Auto-close on type is the one client-side piece
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
client-side, and this follows the same shape:
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
snippet or `null`.
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
`wrnexus.*` naming.
### Emmet
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
edge is real.
## Folding
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
the virtual document, filtered to view regions.
Today folding comes only from `language-configuration.json` markers, which work at block level
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
to work unchanged and tag folding appears inside markup.
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
outside a view region would let a fold swallow a brace boundary.
## Formatting is deliberately excluded
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
`printWidth`. It is a markup formatter that understands WRNexus syntax.
Adding HTML formatting would do two harmful things:
- **Two formatters would fight.** Output would depend on which ran last.
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
## Dependencies
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
`editors/vscode`.
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
and passes other `require`s through to Node, so the package must be resolvable at runtime from
the extension. `editors/vscode` currently ships exactly one runtime dependency
(`vscode-languageclient`); this adds the second.
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
## Testing
### Region scanner (`packages/language-server/test/`)
- **The invariant**, property-style across fixtures: virtual text length equals source length and
newlines sit at identical offsets.
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
found.
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
completion, not an edge case.
- **Multiple view blocks**, and files with none.
### Completion
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
- Outside a view block: response identical to current behaviour — the guard proving non-markup
contexts are undisturbed.
- Collision: a component named `Table` yields one entry, the component.
### Tag handling
- `<div>``</div>`; `<br>` → nothing; `<Card /``/>`; outside a view region → `null`.
- Linked editing returns ranges covering both the opening and closing tag names.
### Hover
- Inside a view region, a known tag returns HTML documentation.
- A component name returns the component detail, not an HTML entry of the same name.
### Folding
- Every returned range lies inside a view region.
- Block-level marker folding still works.
### Toolchain guards
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
- Manifest assertion that `emmet.includeLanguages` maps `wrn``html`, alongside the existing
marketplace checks in `editors/vscode/test`.
## Deferred
- HTML formatting — see above; improve `formatWrn` instead.
- Moving the remaining `completion.js` component intelligence into the server. This design only
requires it to stand down inside view blocks; relocating the rest is follow-up work.
+7 -7
View File
@@ -13,8 +13,8 @@
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
"packages/ui/components/Card.wrn": "255912df0668ebcf45b6cc3ffe99a4a3e0bf87376a5d199e3adcdf3610ea79e3",
"packages/ui/components/Carousel.wrn": "7ab122bc0ce772346c665b43d62e73c9865a13b97c02ccf5b03d1e3eaea757d6",
"packages/ui/components/Card.wrn": "3cc2ff238bda279c169026a462236144ad11ec0046deef00ab00e001a07e8a4c",
"packages/ui/components/Carousel.wrn": "e7d921f802aa19210f3f415b59b70750a2c8d78e30684ad334809741c70a6bf9",
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
@@ -39,7 +39,7 @@
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
"packages/ui/components/Footer.wrn": "c498820a160c1286331a423a4498054e7852d2f1a9eb6e81eb5b008b1693efc4",
"packages/ui/components/Footer.wrn": "f7c5a77064a09e244f689d42475c4c20143f4c8859cac451c10a50c14652db33",
"packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
@@ -60,7 +60,7 @@
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
"packages/ui/components/Navbar.wrn": "ed3bb1974b93d52c480af468bdb1c0b253cb9196f3a00602614d3ce1e271a669",
"packages/ui/components/Navbar.wrn": "01903230210cd2e5e6d9325a0708f85af9623d4118e3a53f1adb770fd545425d",
"packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
@@ -95,14 +95,14 @@
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
"packages/ui/components/input.wrn": "a576e5ee2c6d0da963ebb14e8809ddf3ec7333b1eae095d5a4b7fce3ceb1fb55",
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
"packages/ui/components/radio.wrn": "09425a78358de5bbd2f47482f313e80065135335b969ce5ccdd5c7cc3ea5232c",
"packages/ui/components/select.wrn": "b107b259d228201e9071701370ad1c912ac9a8131f9e0a55834bcc84ef0dd417",
"packages/ui/components/select.wrn": "1d8d47a74d5ab9ed58d0ba11f46910897233b96652d17f5962f5dda60bd4cf8a",
"packages/ui/components/skeleton.wrn": "4fc5e0846eeefd7830c038e1789be995c4f9d833aa913ff079eb4863baa65648",
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
"packages/ui/components/switch.wrn": "874504e4828e9db6a570d78984c4d3a76d0ceb39d70baa877649cb3798c82c85",
"packages/ui/components/textarea.wrn": "76d432179f2b7790ab9cdd644752928c259e1f496ad89c4db79981b74bbd226f",
"packages/ui/components/textarea.wrn": "9870d37664471a103d44435a3977bf40b087a743acd76ee4d77df9b56cccbb2b",
"packages/ui/components/tooltip.wrn": "f3dfdfa5cd9661fe5e95ef3580eef4c7437c069726421370c2d7328fbe7d840d",
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
+363 -8
View File
@@ -1,8 +1,8 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: c0e3e8c72c68cb3c182e2de84c13ef0b8921579d9b081550002b8cdbe4af3397
// WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18
// Generated with TypeScript: 5.9.3
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
const __path = __nodeRequire("node:path");
const __modules = {
@@ -11,6 +11,7 @@ const __modules = {
Object.defineProperty(exports, "__esModule", { value: true });
exports.optimizeAst = optimizeAst;
exports.analyzeOptimizations = analyzeOptimizations;
exports.routeNeedsIslands = routeNeedsIslands;
exports.analyzeRuntimeRequirements = analyzeRuntimeRequirements;
function identifiers(value) {
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
@@ -191,8 +192,18 @@ function hasEvent(nodes) {
}
return false;
}
function analyzeRuntimeRequirements(ast) {
/**
* A route containing a React island ships JavaScript and can no longer be
* classified as zero-JS static, so island presence must reach the classifier.
*/
function routeNeedsIslands(imports) {
return imports.some((entry) => entry.kind === "island");
}
function analyzeRuntimeRequirements(ast, options = {}) {
const hasIslands = options.hasIslands ?? false;
const reasons = [];
if (hasIslands)
reasons.push("react island");
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
const clientState = ast.states.some((state) => state.runtime !== "server");
const interactive = clientFunctions ||
@@ -246,11 +257,16 @@ function analyzeRuntimeRequirements(ast) {
kind = "streaming-ssr";
reasons.push("partial-static shell with streamed dynamic regions");
}
// An island ships JavaScript, so a would-be zero-JS static route must be
// reported as static-interactive. Explicit render modes still win above.
if (hasIslands && kind === "static")
kind = "static-interactive";
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
const serverDisabled = ast.renderMode === "client";
return {
kind,
canPrerender: kind === "static" || kind === "static-interactive",
needsIslandRuntime: hasIslands,
needsClientRuntime: !clientDisabled &&
(interactive || ast.renderMode === "client") &&
ast.hydrate !== "none" &&
@@ -773,7 +789,46 @@ const types_ts_1 = require("./types.js");
const syntax_1 = require("@wrnexus/syntax");
const store_codegen_ts_1 = require("./store-codegen.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");
/**
* 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) {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -1121,6 +1176,9 @@ function renderLoopBody(node) {
escLit("</div>"));
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island)
return escLit(island);
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
escLit(">") +
@@ -1314,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
}
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
const island = islandMarkerFor(node);
if (island)
return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -1324,6 +1385,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
}
function renderNestedComponentInvocation(node, ctx) {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island)
return island;
let bindIndex = 0;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
@@ -1780,7 +1845,16 @@ function markServerAsyncBoundaries(nodes, 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;
if (ast.kind === "global-store" || ast.kind === "page-store")
return (0, store_codegen_ts_1.generateStoreModule)(ast);
@@ -3107,9 +3181,11 @@ function candidates(path) {
path,
`${path}.wrn`,
`${path}.ts`,
`${path}.tsx`,
`${path}.d.ts`,
(0, node_path_1.join)(path, "index.wrn"),
(0, node_path_1.join)(path, "index.ts"),
(0, node_path_1.join)(path, "index.tsx"),
];
}
function resolveWrnImport(declaration, importer, options) {
@@ -3136,8 +3212,12 @@ function resolveWrnImport(declaration, importer, options) {
return false;
}
});
if (found)
return { declaration, resolved: (0, node_fs_1.realpathSync)(found) };
if (found) {
const resolved = (0, node_fs_1.realpathSync)(found);
return resolved.endsWith(".tsx")
? { declaration, resolved, kind: "island" }
: { declaration, resolved };
}
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
return {
declaration,
@@ -3162,7 +3242,7 @@ function resolveWrnImports(declarations, importer, options) {
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
*/
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.compileWrnFile = compileWrnFile;
exports.compile = compile;
@@ -3258,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, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
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) {
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 28a3937b948e6affb33150753d537162c6702786551dd90ab0968ef9166f21ac
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -19,10 +19,10 @@
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
import type { Db, ExecResult } from "@wrnexus/db";
import { users } from "./schema.ts";
@@ -0,0 +1,10 @@
import { useState } from "react";
export default function Counter({ start = 0 }: { start?: number }) {
const [count, setCount] = useState(start);
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
{`clicked ${count}`}
</button>
);
}
@@ -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>;
"/dashboard": Record<string, never>;
"/hello": Record<string, never>;
"/island-demo": Record<string, never>;
"/language-tools": Record<string, never>;
"/layout": Record<string, never>;
"/login": Record<string, never>;
@@ -31,6 +32,7 @@ export interface RouteNames {
"client.only": "/client-only";
"dashboard": "/dashboard";
"hello": "/hello";
"island.demo": "/island-demo";
"language.tools": "/language-tools";
"layout": "/layout";
"login": "/login";
@@ -122,6 +124,7 @@ export function route<N extends RouteName>(
"client.only": "/client-only",
"dashboard": "/dashboard",
"hello": "/hello",
"island.demo": "/island-demo",
"language.tools": "/language-tools",
"layout": "/layout",
"login": "/login",
+1 -1
View File
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never;
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 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 RealtimeRoute = "/realtime/chat" | "/realtime/hello";
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": {
"@wrnexus/test": "workspace:*",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"eslint": "^10.8.0",
"prettier": "^3.9.4",
"tailwindcss": "^4.0.0",
"typescript-eslint": "^8.65.0"
"@tailwindcss/cli": "^4.3.3",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript-eslint": "^8.67.0"
}
}
+5 -5
View File
@@ -16,10 +16,10 @@
"@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
"@iconify/tailwind4": "^1.0.0",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+4 -4
View File
@@ -18,10 +18,10 @@
"@wrnexus/ui": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+4 -4
View File
@@ -16,10 +16,10 @@
"@wrnexus/ui": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2"
"@tailwindcss/cli": "^4.3.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
"@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "^0.8.8",
"@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "^0.8.9"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
@@ -24,40 +24,40 @@
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "0.8.6",
"@wrnexus/auth": "0.8.6",
"@wrnexus/captcha": "0.8.6",
"@wrnexus/core": "0.8.6",
"@wrnexus/csr": "0.8.6",
"@wrnexus/db": "0.8.6",
"@wrnexus/dev-server": "0.8.6",
"@wrnexus/encryption": "0.8.6",
"@wrnexus/helpers": "0.8.6",
"@wrnexus/i18n": "0.8.6",
"@wrnexus/image": "0.8.6",
"@wrnexus/jwt": "0.8.6",
"@wrnexus/observability": "0.8.6",
"@wrnexus/realtime": "0.8.6",
"@wrnexus/security": "0.8.6",
"@wrnexus/store": "0.8.6",
"@wrnexus/styles": "0.8.6",
"@wrnexus/tracking": "0.8.6",
"@wrnexus/ui": "0.8.6",
"@wrnexus/uploader": "0.8.6",
"@wrnexus/validation": "0.8.6",
"@wrnexus/authz": "0.8.6"
"@wrnexus/ai": "^0.8.9",
"@wrnexus/auth": "^0.8.12",
"@wrnexus/captcha": "^0.8.11",
"@wrnexus/core": "^0.8.9",
"@wrnexus/csr": "^0.8.21",
"@wrnexus/db": "^0.8.15",
"@wrnexus/dev-server": "^0.8.33",
"@wrnexus/encryption": "^0.8.9",
"@wrnexus/helpers": "^0.8.8",
"@wrnexus/i18n": "^0.8.11",
"@wrnexus/image": "^0.8.10",
"@wrnexus/jwt": "^0.8.9",
"@wrnexus/observability": "^0.8.8",
"@wrnexus/realtime": "^0.8.10",
"@wrnexus/security": "^0.8.8",
"@wrnexus/store": "^0.8.8",
"@wrnexus/styles": "^0.8.15",
"@wrnexus/tracking": "^0.8.8",
"@wrnexus/ui": "^0.8.19",
"@wrnexus/uploader": "^0.8.10",
"@wrnexus/validation": "^0.8.10",
"@wrnexus/authz": "^0.8.9"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"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"
},
"devDependencies": {
"@wrnexus/cli": "0.8.6",
"@eslint/js": "^9.0.0",
"@wrnexus/cli": "^0.8.36",
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^9.0.0",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"typescript": "^5.5.0",
"typescript-eslint": "^8.65.0"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
@@ -12,6 +12,6 @@
"test": "bun test"
},
"dependencies": {
"@wrnexus/pubsub": "0.8.6"
"@wrnexus/pubsub": "^0.8.9"
}
}
+8 -4
View File
@@ -71,12 +71,16 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^10.8.0",
"happy-dom": "^20.11.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^10.8.1",
"happy-dom": "^20.11.2",
"prettier": "^3.9.6",
"react": "^19",
"react-dom": "^19",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
},
"engines": {
"bun": ">=1.3.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"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.",
"type": "module",
"sideEffects": false,
@@ -54,7 +54,7 @@
"devDependencies": {
"@types/bun": "^1.3.14",
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
},
"wrnexus": {
"plugin": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.8.10",
"version": "0.8.11",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -48,7 +48,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2",
"typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*"
},
"wrnexus": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.32",
"version": "0.8.42",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -28,6 +28,7 @@
"@wrnexus/db": "workspace:*",
"@wrnexus/authz": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/react": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*",
"@wrnexus/security": "workspace:*",
+71 -14
View File
@@ -24,9 +24,13 @@ import {
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
import { getIslandRuntime } from "@wrnexus/react/runtime";
import {
analyzeRuntimeImports,
analyzeRuntimeRequirements,
assertReactAvailable,
buildIslands,
islandNamesFrom,
assertValidAst,
generate,
generateTargets,
@@ -112,6 +116,7 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientModulesDir = join(distDir, "client");
const reactivePath = join(distDir, "reactive.js");
const controllersPath = join(distDir, "controllers.js");
const islandsPath = join(distDir, "islands.js");
const publicDir = join(root, "public");
const distPublicDir = join(distDir, "public");
const config = await loadAppConfig(root);
@@ -183,6 +188,8 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientFiles = new Map<string, string>();
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
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> => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8");
@@ -207,7 +214,25 @@ export async function runBuild(appRoot: string): Promise<void> {
assertValidAst(ast, { file, accessibility: true });
ast = await pluginRunner.transformAst(ast, 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 errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
@@ -221,10 +246,11 @@ export async function runBuild(appRoot: string): Promise<void> {
try {
const targets = generateTargets(ast);
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let code =
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
code = await pluginRunner.transformCode(code, file);
browserCode = await pluginRunner.transformCode(browserCode, file);
@@ -247,11 +273,7 @@ export async function runBuild(appRoot: string): Promise<void> {
);
}
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const resolvedImports = fileImports;
for (const imported of resolvedImports) {
if (imported.diagnostic?.severity === "error") {
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
@@ -493,6 +515,36 @@ export async function runBuild(appRoot: string): Promise<void> {
assetHash.update(controllerCode);
console.log(`✓ Controllers: ${controllersPath}`);
// Island bootstrap: emitted unconditionally but inert without markers, so a
// build with no islands still ships no React.
const islandCode = await buildBrowserRuntime(
getIslandRuntime(),
islandsPath,
join(compiledDir, "islands.entry.js"),
);
assetHash.update(islandCode);
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).
const theme = resolveThemeConfig(config.theme, config.cookies);
const themeCss = renderThemeCss(theme);
@@ -568,10 +620,14 @@ export async function runBuild(appRoot: string): Promise<void> {
appDir,
appRoot: root,
mode: "production",
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
// Component discovery is not style discovery. Built-in and plugin
// components own their CSS; scanning every available component makes
// 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] : [])),
},
config.styles,
@@ -735,6 +791,7 @@ await createProductionServer(
reactivePath: join(import.meta.dir, "reactive.js"),
controllersPath: join(import.meta.dir, "controllers.js"),
clientModulesDir: join(import.meta.dir, "client"),
islandsDir: join(import.meta.dir, "island"),
themePath: join(import.meta.dir, "theme.css"),
themeAssetsDir: join(import.meta.dir, "theme"),
themeJsPath: join(import.meta.dir, "theme.js"),
+9 -9
View File
@@ -121,16 +121,16 @@ Thumbs.db
},
"devDependencies": {
"@wrnexus/cli": "${cliVersion}",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
+2 -2
View File
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
devDependencies: {
"@capacitor/cli": "^8.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-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 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/plugin": "workspace:*",
},
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
wrnexus: {
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 { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
},
"devDependencies": {
"@wrnexus/cli": "${frameworkVersion}",
"@eslint/js": "^9.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
@@ -250,7 +250,7 @@ export default tseslint.config(
"test": "bun test"
},
"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["@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");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"');
@@ -69,7 +69,9 @@ export default {
}
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}/`);
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { currentCliVersion } from "../src/update-notifier.ts";
import { scaffoldFrameworkRange } from "../src/create.ts";
import {
addWorkspaceApp,
insertWorkspaceApp,
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
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 rootPackage = JSON.parse(files["package.json"]!);
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
const version = currentCliVersion();
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("internal gateway targets");
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.10",
"version": "0.8.11",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -8,8 +8,8 @@
},
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*",
"@wrnexus/validation": "workspace:*"
}
}
+22 -1
View File
@@ -1,4 +1,5 @@
import type { PageAst, ViewNode } from "@wrnexus/syntax";
import type { ResolvedImport } from "./import-resolver.ts";
export type RouteExecutionKind =
| "static"
@@ -12,6 +13,8 @@ export interface RuntimeRequirements {
kind: RouteExecutionKind;
canPrerender: boolean;
needsClientRuntime: boolean;
/** True when the route mounts a React island and must ship the island runtime. */
needsIslandRuntime: boolean;
needsServerRuntime: boolean;
hydrationStrategy: string | null;
reasons: string[];
@@ -210,8 +213,21 @@ function hasEvent(nodes: ViewNode[]): boolean {
return false;
}
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
/**
* A route containing a React island ships JavaScript and can no longer be
* classified as zero-JS static, so island presence must reach the classifier.
*/
export function routeNeedsIslands(imports: ResolvedImport[]): boolean {
return imports.some((entry) => entry.kind === "island");
}
export function analyzeRuntimeRequirements(
ast: PageAst,
options: { hasIslands?: boolean } = {},
): RuntimeRequirements {
const hasIslands = options.hasIslands ?? false;
const reasons: string[] = [];
if (hasIslands) reasons.push("react island");
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
const clientState = ast.states.some((state) => state.runtime !== "server");
const interactive =
@@ -260,12 +276,17 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
reasons.push("partial-static shell with streamed dynamic regions");
}
// An island ships JavaScript, so a would-be zero-JS static route must be
// reported as static-interactive. Explicit render modes still win above.
if (hasIslands && kind === "static") kind = "static-interactive";
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
const serverDisabled = ast.renderMode === "client";
return {
kind,
canPrerender: kind === "static" || kind === "static-interactive",
needsIslandRuntime: hasIslands,
needsClientRuntime:
!clientDisabled &&
(interactive || ast.renderMode === "client") &&
+72 -1
View File
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import {
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding {
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
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 {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island) return escLit(island);
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
@@ -704,6 +754,9 @@ function renderPageComponentInvocation(
loops: string[],
reactive: PageReactive | null,
): string {
const island = islandMarkerFor(node);
if (island) return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -720,6 +773,10 @@ function renderNestedComponentInvocation(
node: Extract<ViewNode, { type: "element" }>,
ctx: CompCtx,
): string {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island) return island;
let bindIndex = 0;
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;
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") {
+10 -1
View File
@@ -11,6 +11,8 @@ export interface ImportResolverOptions {
export interface ResolvedImport {
declaration: StructuredImportDecl;
resolved?: string;
/** `.tsx` imports are React islands, not `.wrn` components. */
kind?: "island";
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
}
@@ -21,9 +23,11 @@ function candidates(path: string): string[] {
path,
`${path}.wrn`,
`${path}.ts`,
`${path}.tsx`,
`${path}.d.ts`,
join(path, "index.wrn"),
join(path, "index.ts"),
join(path, "index.tsx"),
];
}
@@ -56,7 +60,12 @@ export function resolveWrnImport(
return false;
}
});
if (found) return { declaration, resolved: realpathSync(found) };
if (found) {
const resolved = realpathSync(found);
return resolved.endsWith(".tsx")
? { declaration, resolved, kind: "island" as const }
: { declaration, resolved };
}
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
return {
declaration,
+12
View File
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
}
export { compilationKey, createCompilationCache, DependencyGraph } 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";
+165
View File
@@ -0,0 +1,165 @@
import type { BunPlugin } from "bun";
import { createHash } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { basename, join } from "node:path";
export interface IslandInput {
name: string;
sourcePath: string;
}
export interface IslandBuildResult {
assets: Array<{ name: string; hash: string; path: string }>;
sharedChunks: string[];
}
/**
* Generates the per-island browser entry.
*
* Never imports react-dom/server islands are client-only.
*/
export function generateIslandEntry(input: IslandInput): string {
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`.
*/
export function reactJsxPlugin(): BunPlugin {
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" as const,
};
});
},
};
}
export function assertReactAvailable(
appRoot: string,
): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null {
const require = createRequire(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.
*/
export async function buildIslands(input: {
islands: IslandInput[];
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> {
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 = join(input.outDir, ".entries");
mkdirSync(entryDir, { recursive: true });
const islandNames = new Set(input.islands.map((island) => island.name));
for (const island of input.islands) {
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 {
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: IslandBuildResult["assets"] = [];
const sharedChunks: string[] = [];
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 = basename(output.path).replace(/\.js$/, "");
if (stem === "runtime") continue;
if (!islandNames.has(stem)) continue;
assets.push({
name: stem,
hash: 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 {
rmSync(entryDir, { recursive: true, force: true });
}
}
+141
View File
@@ -0,0 +1,141 @@
// 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 interface IslandDiagnostic {
code: "WRN-ISLAND-PROPS";
message: string;
severity: "error";
}
const STRATEGIES: Record<string, IslandStrategy> = {
"client:only": "only",
"client:load": "load",
"client:visible": "visible",
"client:idle": "idle",
};
export function parseIslandStrategy(directives: string[]): IslandStrategy {
for (const directive of directives) {
const match = STRATEGIES[directive];
if (match) return match;
}
return "only";
}
function unsupportedProp(value: unknown): boolean {
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 as unknown[]).some(unsupportedProp);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return true;
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(
componentName: string,
props: Record<string, unknown>,
): { json: string } | { diagnostic: IslandDiagnostic } {
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) };
}
export function renderIslandMarker(input: {
name: string;
strategy: IslandStrategy;
propsJson: string;
}): string {
// 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.
*/
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;
}
@@ -0,0 +1,61 @@
import { afterAll, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { assertReactAvailable, buildIslands, generateIslandEntry } from "../src/island-bundle.ts";
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
test("generates an entry that re-exports the island component", () => {
const entry = generateIslandEntry({ name: "Chart", sourcePath: "/app/Chart.tsx" });
expect(entry).toContain("/app/Chart.tsx");
expect(entry).toContain("Chart");
expect(entry).not.toContain("react-dom/server");
});
test("island .tsx compiles against React's JSX runtime, not WRNexus's", async () => {
// The root tsconfig points jsxImportSource at @wrnexus/core, so an island
// would otherwise compile to WRNexus's HTML-string renderer and never mount.
// A pragma applies only to the file carrying it, so this asserts the built
// output rather than the generated entry text.
// The fixture must live inside the repo: Bun resolves `react` from the
// importing file's location, exactly as a real island resolves it from the
// app that installed react.
const root = mkdtempSync(join(process.cwd(), ".island-jsx-test-"));
created.push(root);
const source = join(root, "Chart.tsx");
writeFileSync(
source,
`export default function Chart({ title }: { title: string }) {
return <div className="chart">{title}</div>;
}`,
);
const outDir = join(root, "out");
await buildIslands({ islands: [{ name: "Chart", sourcePath: source }], outDir });
const built = readdirSync(outDir)
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(outDir, file), "utf8"))
.join("\n");
expect(built).not.toContain("wrnexus");
expect(built).toMatch(/react\/jsx|jsxDEV|jsx_runtime/);
});
test("reports WRN-ISLAND-REACT-MISSING when react is not installed", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-noreact-"));
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
const diagnostic = assertReactAvailable(root);
expect(diagnostic?.code).toBe("WRN-ISLAND-REACT-MISSING");
expect(diagnostic?.message).toContain("bun add react react-dom");
});
test("returns null when react resolves", () => {
expect(assertReactAvailable(process.cwd())).toBeNull();
});
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { analyzeRuntimeRequirements, routeNeedsIslands } from "../src/analysis.ts";
test("a route with an island import needs client JavaScript", () => {
expect(
routeNeedsIslands([
{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" },
{ declaration: { source: "./Chart" } as any, resolved: "/app/Chart.tsx", kind: "island" },
]),
).toBe(true);
});
test("a route with no island imports stays zero-JS", () => {
expect(
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
).toBe(false);
});
test("an empty import list stays zero-JS", () => {
expect(routeNeedsIslands([])).toBe(false);
});
test("an unresolved import does not count as an island", () => {
expect(
routeNeedsIslands([
{
declaration: { source: "./missing" } as any,
diagnostic: { code: "WRN-IMPORT-NOT-FOUND", message: "nope", severity: "warning" },
},
]),
).toBe(false);
});
test("an island promotes a static route to static-interactive", () => {
const source = `page Home { view { <div>hello</div> } }`;
const ast = parse(source);
const plain = analyzeRuntimeRequirements(ast);
expect(plain.kind).toBe("static");
expect(plain.needsIslandRuntime).toBe(false);
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
expect(withIsland.kind).toBe("static-interactive");
expect(withIsland.needsIslandRuntime).toBe(true);
expect(withIsland.reasons).toContain("react island");
});
test("an island does not turn on the WRNexus reactive runtime", () => {
const ast = parse(`page Home { view { <div>hello</div> } }`);
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
// Islands ship the island runtime, not WRNexus's own client runtime.
expect(withIsland.needsClientRuntime).toBe(false);
expect(withIsland.needsIslandRuntime).toBe(true);
});
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test";
import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "../src/island-codegen.ts";
test("defaults to the client-only strategy", () => {
expect(parseIslandStrategy([])).toBe("only");
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
expect(parseIslandStrategy(["client:load"])).toBe("load");
});
test("serializes JSON-safe props", () => {
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
});
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
const result = serializeIslandProps("Chart", { onClick: () => {} });
expect(result).toHaveProperty("diagnostic");
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
expect(diagnostic.message).toContain("Chart");
expect(diagnostic.message).toContain("onClick");
});
test("rejects class instances and nested offenders", () => {
class Point {
constructor(public x = 1) {}
}
expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic");
expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty(
"diagnostic",
);
expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic");
});
test("accepts null and nested plain data", () => {
const result = serializeIslandProps("Chart", {
empty: null,
nested: { rows: [{ id: 1 }], flag: false },
});
expect(result).toHaveProperty("json");
});
test("rejects island names that are unsafe as URL path segments", () => {
// The name is fetched as /__wrnexus/island/<name>.js, so traversal and
// separators must be refused rather than merely escaped.
expect(() =>
renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }),
).toThrow(/not a safe identifier/);
expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow(
/not a safe identifier/,
);
expect(() =>
renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }),
).not.toThrow();
});
test("renders a marker with escaped props", () => {
const html = renderIslandMarker({
name: "Chart",
strategy: "visible",
propsJson: '{"title":"a<b\\"c"}',
});
expect(html).toContain('data-wrn-island="Chart"');
expect(html).toContain('data-wrn-island-strategy="visible"');
expect(html).not.toContain('title":"a<b"c');
expect(html).toContain("&lt;");
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"');
});
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { routeNeedsIslands } from "../src/analysis.ts";
import { buildIslands, type IslandBuildResult } from "../src/island-bundle.ts";
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
function outDir(label: string): string {
const dir = mkdtempSync(join(process.cwd(), `.island-int-${label}-`));
created.push(dir);
return dir;
}
// Every assertion that needs a real bundle shares this one build.
//
// Not just for speed: `bun test` interferes with Bun.build's module reads once
// several build calls have run across test files in the same process, while the
// same calls succeed repeatedly outside the runner. Production is unaffected —
// the dev server's rebuild loop was verified separately — but tests must keep
// their build count low to stay reliable in the full suite.
let dir: string;
let result: IslandBuildResult;
let bundles: string[];
beforeAll(async () => {
dir = outDir("shared");
result = await buildIslands({
islands: [
{ name: "CounterA", sourcePath: COUNTER },
{ name: "CounterB", sourcePath: COUNTER },
],
outDir: dir,
});
bundles = readdirSync(dir)
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(dir, file), "utf8"));
});
test("a route with no islands ships zero framework JavaScript", async () => {
const empty = outDir("nojs");
const none = await buildIslands({ islands: [], outDir: empty });
expect(none.assets).toHaveLength(0);
expect(none.sharedChunks).toHaveLength(0);
expect(readdirSync(empty)).toHaveLength(0);
expect(routeNeedsIslands([])).toBe(false);
});
test("a page with multiple islands ships React exactly once", () => {
// React's internals must appear in at most one emitted file — the shared
// chunk. If splitting regresses, every island inlines its own copy.
const withReactInternals = bundles.filter(
(source) => source.includes("REACT_ELEMENT_TYPE") || source.includes("react.development"),
);
expect(result.assets).toHaveLength(2);
expect(withReactInternals.length).toBeLessThanOrEqual(1);
});
test("two islands sharing one source get distinct, correctly named assets", () => {
// Passing component sources as entrypoints deduped them, so the second island
// silently lost its bundle and names could bind to the wrong output.
expect(result.assets.map((asset) => asset.name).sort()).toEqual(["CounterA", "CounterB"]);
expect(new Set(result.assets.map((asset) => asset.path)).size).toBe(2);
});
test("a real island builds against React and never pulls in the WRNexus renderer", () => {
const built = bundles.join("\n");
expect(built).not.toContain("wrnexus");
expect(built).not.toContain("react-dom/server");
expect(built).toContain("useState");
});
test("the generated entry directory is not left behind in the output", () => {
expect(readdirSync(dir)).not.toContain(".entries");
});
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveWrnImport } from "../src/import-resolver.ts";
function appWith(files: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), "wrnexus-island-"));
mkdirSync(join(root, "app"), { recursive: true });
for (const [name, contents] of Object.entries(files)) {
writeFileSync(join(root, "app", name), contents);
}
return root;
}
test("resolves a .tsx import and tags it as an island", () => {
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
const result = resolveWrnImport(
{ source: "./Chart", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Chart.tsx");
expect(result.kind).toBe("island");
});
test("does not tag a .ts import as an island", () => {
const root = appWith({ "helper.ts": "export const value = 1;" });
const result = resolveWrnImport(
{ source: "./helper", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("helper.ts");
expect(result.kind).toBeUndefined();
});
test("prefers .wrn over .tsx when both exist", () => {
const root = appWith({
"Widget.wrn": "<template></template>",
"Widget.tsx": "export default function Widget() { return null; }",
});
const result = resolveWrnImport(
{ source: "./Widget", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.resolved).toContain("Widget.wrn");
expect(result.kind).toBeUndefined();
});
test("resolves an explicit .tsx extension as an island", () => {
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
const result = resolveWrnImport(
{ source: "./Chart.tsx", specifiers: [] } as any,
join(root, "app", "page.wrn"),
{ appRoot: root },
);
expect(result.kind).toBe("island");
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/content",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
"main": "./src/index.ts",
@@ -14,6 +14,6 @@
],
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.19",
"version": "0.8.22",
"type": "module",
"main": "src/index.ts",
"exports": {
+43
View File
@@ -367,6 +367,43 @@ export const NAV_RUNTIME = String.raw`
});
}
function syncI18n(nextDocument) {
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
if (!script) return;
try {
var incoming = JSON.parse(String(script.textContent || "{}"));
var current = window.__wrnI18n || {};
var translator = current.t;
var setter = current.set;
function mergeCatalog(base, update) {
var output = {};
Object.keys(base && typeof base === "object" ? base : {}).forEach(function (key) {
var value = base[key];
output[key] = value && typeof value === "object" && !Array.isArray(value)
? mergeCatalog(value, {})
: value;
});
Object.keys(update && typeof update === "object" ? update : {}).forEach(function (key) {
var left = output[key];
var right = update[key];
output[key] = left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)
? mergeCatalog(left, right)
: right;
});
return output;
}
if (current.lang && current.lang === incoming.lang) {
incoming.messages = mergeCatalog(current.messages, incoming.messages);
incoming.fallbackMessages = mergeCatalog(current.fallbackMessages, incoming.fallbackMessages);
}
window.__wrnI18n = incoming;
if (translator) window.__wrnI18n.t = translator;
if (setter) window.__wrnI18n.set = setter;
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n data", error);
}
}
/**
* Run explicit component cleanup before removing the existing page.
*
@@ -516,6 +553,8 @@ export const NAV_RUNTIME = String.raw`
syncPreservationPolicy(doc);
syncI18n(doc);
syncWrnStyles(doc);
var importedNodes = [];
@@ -568,6 +607,10 @@ export const NAV_RUNTIME = String.raw`
*/
rehydrate(currentApp);
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
window.__wrnLang.bind(currentApp);
}
if (!isPop) {
history.pushState(
{
+52
View File
@@ -139,6 +139,58 @@ test("rebinds theme controls after swapping the page", async () => {
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull();
});
test("synchronizes and rebinds i18n data during client navigation", async () => {
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
let boundRoot: unknown;
const translate = () => "translated";
const setLanguage = () => true;
win.__wrnI18n = { lang: "en", messages: { old: "Old" }, t: translate, set: setLanguage };
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
nextHtml =
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
await flush();
expect(win.__wrnI18n.lang).toBe("mr");
expect(win.__wrnI18n.messages.home.title).toBe("नवीन");
expect(win.__wrnI18n.t).toBe(translate);
expect(win.__wrnI18n.set).toBe(setLanguage);
expect(boundRoot).toBe(win.document.getElementById("app"));
});
test("preserves same-language translations when an incoming navigation catalog is partial", async () => {
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
win.__wrnI18n = {
lang: "en",
messages: { navigation: { home: "Home" }, footer: { contact: "Contact" } },
fallbackMessages: {},
};
win.__wrnLang = {
bind: (root: ParentNode) => {
root.querySelectorAll("[data-t]").forEach((node) => {
const parts = String(node.getAttribute("data-t") || "").split(".");
let value: any = win.__wrnI18n.messages;
for (const part of parts) value = value?.[part];
node.textContent = typeof value === "string" ? value : node.getAttribute("data-t");
});
},
};
nextHtml =
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
await flush();
expect(win.__wrnI18n.messages.navigation.home).toBe("Home");
expect(win.__wrnI18n.messages.footer.contact).toBe("Contact");
expect(win.document.querySelector("[data-t]")?.textContent).toBe("Home");
});
test("unmounts and remounts package runtimes during client navigation", async () => {
install(
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.12",
"version": "0.8.16",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -26,6 +26,6 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
}
}
+18 -6
View File
@@ -14,7 +14,18 @@ import type { Db } from "./driver.ts";
const DEFAULT = "default";
type DbFactory = () => Db;
type RegistryEntry = { db?: Db; factory?: DbFactory };
const registry = new Map<string, RegistryEntry>();
const REGISTRY_KEY = Symbol.for("@wrnexus/db:registry:v1");
type RegistryGlobal = typeof globalThis & { [REGISTRY_KEY]?: Map<string, RegistryEntry> };
// Production bundlers can include @wrnexus/db more than once when an app and
// the server runtime resolve compatible but distinct package installations.
// A module-local Map splits configuration from consumers in that case. Store
// the registry on globalThis under a stable symbol so every bundled copy in
// the process observes the same default and named connections.
function databaseRegistry(): Map<string, RegistryEntry> {
const scope = globalThis as RegistryGlobal;
return (scope[REGISTRY_KEY] ??= new Map<string, RegistryEntry>());
}
/** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db;
@@ -23,7 +34,7 @@ export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a;
registry.set(name, { db });
databaseRegistry().set(name, { db });
return db;
}
@@ -37,12 +48,12 @@ export function registerDb(name: string, db: Db): Db {
* `getDb(name)` call creates and caches the connection.
*/
export function registerLazyDb(name: string, factory: DbFactory): void {
registry.set(name, { factory });
databaseRegistry().set(name, { factory });
}
/** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db {
const entry = registry.get(name);
const entry = databaseRegistry().get(name);
if (!entry) {
throw new Error(
name === DEFAULT
@@ -60,16 +71,17 @@ export function getDb(name = DEFAULT): Db {
/** Whether the default (or a named) database has been configured. */
export function hasDb(name = DEFAULT): boolean {
return registry.has(name);
return databaseRegistry().has(name);
}
/** Names of all configured databases (the default appears as "default"). */
export function databaseNames(): string[] {
return [...registry.keys()];
return [...databaseRegistry().keys()];
}
/** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> {
const registry = databaseRegistry();
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
registry.clear();
const results = await Promise.allSettled(databases.map((db) => db.close()));
+9 -4
View File
@@ -226,11 +226,16 @@ export function generateQueriesFile(
);
}
const imports = [
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
];
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
}
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
// The dialect is stamped into the header because it changes the emitted SQL:
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
// under a different profile therefore rewrites this committed file, and
// without the stamp the diff looks like unexplained churn.
return (
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
);
}
+41 -1
View File
@@ -86,6 +86,41 @@ function hasExecutableSql(sql: string): boolean {
return false;
}
function additiveColumnTarget(sql: string): { table: string; column: string } | undefined {
const executable = sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\r\n]*/g, " ")
.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(
executable,
);
return match ? { table: match[1]!, column: match[2]! } : undefined;
}
async function additiveColumnAlreadyExists(db: Db, sql: string): Promise<boolean> {
const target = additiveColumnTarget(sql);
if (!target) return false;
if (db.driver.dialect === "sqlite") {
const columns = await db.all<{ name: string }>(`PRAGMA table_info(${target.table})`);
return columns.some(({ name }) => name.toLowerCase() === target.column.toLowerCase());
}
if (db.driver.dialect === "postgres") {
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
/** Load and parse all migration files in a directory, sorted by filename. */
export function loadMigrations(dir: string): Migration[] {
if (!existsSync(dir)) return [];
@@ -171,7 +206,12 @@ export async function applyMigrations(
for (const migration of pending.filter(({ name }) => !current.has(name))) {
throwIfAborted(options.signal);
await db.tx(async (tx) => {
if (hasExecutableSql(migration.up)) await tx.exec(migration.up);
if (
hasExecutableSql(migration.up) &&
!(await additiveColumnAlreadyExists(tx, migration.up))
) {
await tx.exec(migration.up);
}
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
});
done.push(migration.name);
+18
View File
@@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async (
await db.close();
});
test("add-column migrations recover when the column exists but the migration record does not", async () => {
const db = createDb(sqlite());
await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)");
const migrations = [
{
name: "0002_otp_purpose",
up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';",
down: "ALTER TABLE otp_challenges DROP COLUMN purpose;",
},
];
expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]);
expect(await appliedMigrations(db)).toContain("0002_otp_purpose");
const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)");
expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1);
await db.close();
});
test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
const db = createDb(sqlite());
const migrations = [
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
test("the generated header records the dialect it was built for", () => {
// The same command emits different SQL per dialect, so a build under another
// profile rewrites the committed file. The stamp makes that visible in the
// diff instead of looking like unexplained churn.
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
});
test("placeholder style follows the dialect", () => {
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
});
test("generation is deterministic for a fixed dialect", () => {
const first = generateQueriesFile(queries, [], "postgres");
const second = generateQueriesFile(queries, [], "postgres");
expect(first).toBe(second);
});
+13
View File
@@ -92,3 +92,16 @@ test("registry closes every database and clears itself when one close fails", as
expect(secondClosed).toBe(true);
expect(databaseNames()).toEqual([]);
});
test("separately evaluated package copies share the process-wide registry", async () => {
await closeDatabases();
const secondCopy = await import(`../src/client.ts?copy=${crypto.randomUUID()}`);
const main = createDb(sqlite(":memory:"));
setDb(main);
expect(secondCopy.hasDb()).toBe(true);
expect(secondCopy.getDb()).toBe(main);
await secondCopy.closeDatabases();
expect(hasDb()).toBe(false);
});
+8
View File
@@ -66,6 +66,14 @@ interface RunningServer {
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
### Custom not-found handlers
Add `app/pages/404.wrn` to customize unmatched frontend routes. The rendered
page keeps the requested response's HTTP `404` status. Add `app/api/404.ts`
with normal HTTP method exports to customize unmatched backend/API responses;
its response body and headers are preserved and its status is normalized to
`404`.
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.29",
"version": "0.8.38",
"type": "module",
"main": "src/index.ts",
"exports": {
+8 -1
View File
@@ -25,6 +25,7 @@ import {
type ResolvedTheme,
type StylesConfig,
} from "@wrnexus/styles";
import { getIslandRuntime } from "@wrnexus/react/runtime";
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
import { I18N_RUNTIME } from "@wrnexus/i18n";
import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader";
@@ -32,7 +33,8 @@ import type { Mode } from "@wrnexus/core";
import type { AssetServer } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
import { 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`. */
export interface DevStyles {
@@ -95,6 +97,11 @@ export function createDevAssetServer(
if (pathname.startsWith("/__wrnexus/client/")) {
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
}
if (pathname.startsWith("/__wrnexus/island/")) {
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/reactive.js") return jsResponse(getReactiveRuntime(true));
if (pathname === "/__wrnexus/controllers.js")
return jsResponse(getComponentControllerRuntime(true));
+43 -3
View File
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
interface WsBridge {
origin: string;
path: string;
headers: Record<string, string>;
backend?: WebSocket;
queue: Array<string | ArrayBuffer>;
maxMessageBytes: number;
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
}
function gatewayWebSocketOriginAllowed(
export function gatewayWebSocketOriginAllowed(
req: Request,
target: Target,
configured: string[],
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
}
if (configured.includes(origin)) return true;
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
// Compare hostnames, not hosts: configured domains carry no port, while the
// browser's Origin does. publicOrigin above only ever matches domains[0], so
// every other domain fell through to here and was denied purely on the port,
// which left the HMR socket reconnecting forever on those hosts.
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
}
/**
@@ -433,6 +438,30 @@ export function gatewayProxyHeaders(
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. */
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
const sanitized = new Headers(headers);
@@ -692,6 +721,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
data: {
origin: target.origin,
path: url.pathname + url.search,
headers: gatewayWebSocketBackendHeaders(
req,
url,
ip,
forwardedHeaders,
target.origin,
),
queue: [],
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
@@ -753,7 +789,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
websocket: {
open(ws) {
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;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);
+16 -4
View File
@@ -43,6 +43,7 @@ import {
loadWrnServerModule,
setCompileCacheDir,
setCompileImportOptions,
rebuildChangedIslands,
setDevCompilerPipeline,
wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts";
@@ -439,10 +440,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
config: opts.stylesConfig,
appRoot,
publicDir: join(appRoot, "public"),
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
// Component discovery is separate from utility-source discovery.
// Packages that need Tailwind scanning opt in via styles.source.
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
theme,
@@ -685,6 +685,18 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
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 }> = [];
for (const changed of files) {
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
+150 -2
View File
@@ -19,6 +19,8 @@ import {
compile,
generate,
generateTargets,
buildIslands,
islandNamesFrom,
resolveWrnImports,
type PageAst,
type ViewNode,
@@ -57,6 +59,7 @@ export function runMiddleware(
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
const moduleVersions = new Map<string, number>();
const browserArtifactPaths = new Map<string, string>();
const islandArtifactPaths = new Map<string, string>();
type ImportMode = "legacy" | "compatible" | "explicit";
interface CompileImportOptions {
@@ -125,6 +128,102 @@ function importOptionsHash(file: string): string {
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(
code: string,
ast: PageAst,
@@ -395,6 +494,8 @@ export interface WrnCompileArtifacts {
declarations: string;
contract: string;
rpc: string;
/** Island inputs for this file, so a cache hit can still build islands. */
islands: string;
}
export interface WrnCompileMetrics {
@@ -485,15 +586,20 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
};
const result = compile(source, file);
validateConfiguredImports(source, 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);
mkdirSync(cacheDir, { recursive: true });
const browserPath = `/__wrnexus/client/${stem}.mjs`;
const outputs = {
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
main: `// compiled from .wrn\n${generate(ast, { islands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
browserPath,
),
@@ -554,13 +660,36 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
};
compileInProgress.set(file, artifacts);
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++;
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;
}
} catch {
@@ -644,6 +773,25 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
});
}
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
export function registerIslandArtifact(pathname: string, artifact: string): void {
islandArtifactPaths.set(pathname, artifact);
}
/** Serves a built island bundle, chunk, or the island mount runtime. */
export function serveIslandArtifact(pathname: string): Response | null {
const artifact = islandArtifactPaths.get(pathname);
if (!artifact || !existsSync(artifact)) return null;
return new Response(readFileSync(artifact, "utf8"), {
headers: {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-store, max-age=0",
pragma: "no-cache",
expires: "0",
},
});
}
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
export function invalidateModule(file: string): void {
file = resolve(file);
+11
View File
@@ -25,6 +25,7 @@ import {
getNavRuntime,
getRealtimeRuntime,
} from "@wrnexus/csr";
import { getIslandRuntime } from "@wrnexus/react/runtime";
import {
loadEnv,
resolveProfile,
@@ -99,6 +100,7 @@ export interface ProdOptions {
controllersPath?: string;
/** Absolute directory containing bundled per-WRN browser modules. */
clientModulesDir?: string;
islandsDir?: string;
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
themePath?: string;
/** Pre-built active theme/accent stylesheets, loaded on demand. */
@@ -341,6 +343,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
}
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
}
if (pathname.startsWith("/__wrnexus/island/")) {
const name = pathname.slice("/__wrnexus/island/".length);
if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) {
return new Response("Not Found", { status: 404 });
}
return serveFile(join(opts.islandsDir, name), JS_HEADERS);
}
if (pathname === "/__wrnexus/islands.js")
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
if (pathname === "/__wrnexus/reactive.js") {
if (opts.reactivePath) {
const file = Bun.file(opts.reactivePath);
+97 -13
View File
@@ -81,7 +81,7 @@ import {
type TenancyConfig,
} from "@wrnexus/styles";
import {
renderI18nData,
renderI18nDataTag,
makeT,
resolveLang,
translateHtml,
@@ -672,6 +672,19 @@ export const HMR_CLIENT_JS = `
pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html");
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
if (i18nScript) {
try {
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
var existingI18n = window.__wrnI18n || {};
incomingI18n.t = existingI18n.t;
incomingI18n.set = existingI18n.set;
window.__wrnI18n = incomingI18n;
} catch (error) {
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
}
}
if (doc.title) {
document.title = doc.title;
}
@@ -704,6 +717,10 @@ export const HMR_CLIENT_JS = `
window.__wrnexusHydrateCsrFetches(document);
}
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
window.__wrnLang.bind(document);
}
if (
window.wrnTheme &&
typeof window.wrnTheme.bind === "function"
@@ -782,6 +799,30 @@ export const HMR_CLIENT_JS = `
// Preserve a hydrated subtree only while its server hydration signature and
// behavior are unchanged. Component edits must replace and re-hydrate the
// old subtree or HMR will keep stale markup indefinitely.
// 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) {
if (from.__wrnexusHydrated) {
var sameHydration =
@@ -790,16 +831,16 @@ export const HMR_CLIENT_JS = `
from.getAttribute("data-scope") === to.getAttribute("data-scope");
if (sameHydration) return;
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
from.replaceWith(to.cloneNode(true));
from.replaceWith(adoptNonce(to.cloneNode(true)));
return;
}
syncAttrs(from, to);
var fc = from.childNodes, tc = to.childNodes, i;
for (i = 0; i < tc.length; 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)) {
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 === 1) morph(f, t);
@@ -808,8 +849,10 @@ export const HMR_CLIENT_JS = `
}
function syncAttrs(from, to) {
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);
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
// Never copy the incoming nonce: it belongs to the fetched document and
// 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).
@@ -829,8 +872,19 @@ function randomNonce(): string {
}
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
function hmrClientTag(nonce: string): string {
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
/** Path the dev asset server publishes the HMR client on. */
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. */
@@ -1449,7 +1503,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
async function handleApi(ctx: Context): Promise<Response> {
const matched = router.matchApi(ctx.url.pathname);
if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
if (!matched) {
const fallback = router.matchApi("/api/404");
if (fallback && ctx.url.pathname !== "/api/404") {
const originalPath = ctx.url.pathname;
ctx.url.pathname = "/api/404";
try {
const response = await handleApi(ctx);
return new Response(response.body, {
status: 404,
headers: response.headers,
});
} finally {
ctx.url.pathname = originalPath;
}
}
return Response.json({ error: "Not Found" }, { status: 404 });
}
// Expose the canonical matched route to package dispatchers. A package may
// contribute several URL paths from one module, and request URLs can be
@@ -1606,7 +1676,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}),
);
}
const response = renderNotFound();
let response: Response;
const fallback = router.matchPage("/404");
if (fallback && ctx.url.pathname !== "/404") {
const originalPath = ctx.url.pathname;
ctx.url.pathname = "/404";
try {
const rendered = await handlePage(ctx);
response = new Response(rendered.body, {
status: 404,
headers: rendered.headers,
});
} finally {
ctx.url.pathname = originalPath;
}
} else {
response = renderNotFound();
}
if (!isMobileRequest) return response;
const headers = new Headers(response.headers);
headers.set("x-wrnexus-original-status", "404");
@@ -1886,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
extraBody:
[
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
deps.i18n
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
: "",
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
]
@@ -19,6 +19,9 @@ export function collectScripts(
) {
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-theme-(toggle|set)\b/.test(body) ||
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
const target = {
name: "web",
origin: "http://127.0.0.1:3101",
domains: ["localhost", "web.localhost"],
publicOrigin: "http://localhost:3000",
} as any;
function upgrade(origin: string, host: string): Request {
return new Request("http://" + host + "/__wrnexus/hmr", {
headers: { origin, host, upgrade: "websocket" },
});
}
test("allows an upgrade from the app's primary domain", () => {
expect(
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
).toBe(true);
});
test("allows an upgrade from a secondary domain on a non-default port", () => {
// publicOrigin is built from domains[0], so a browser on web.localhost falls
// through to the domain list — where the origin host still carries :3000 and
// the configured domain does not. That mismatch denied every HMR socket on
// any domain but the first, leaving the client reconnecting forever.
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://web.localhost:3000", "web.localhost:3000"),
target,
[],
),
).toBe(true);
});
test("still denies an unrelated origin", () => {
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://evil.example:3000", "web.localhost:3000"),
target,
[],
),
).toBe(false);
});
test("still denies a lookalike suffix domain", () => {
expect(
gatewayWebSocketOriginAllowed(
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
target,
[],
),
).toBe(false);
});
+30
View File
@@ -5,6 +5,7 @@ import {
forwardAuthFailure,
forwardAuthHeaders,
gatewayProxyHeaders,
gatewayWebSocketBackendHeaders,
stripUntrustedInternalHeaders,
gatewayRestartDelay,
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");
});
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", () => {
const request = new Request("http://localhost:3000/path", {
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-scope")');
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");
});
@@ -0,0 +1,54 @@
import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { buildRouter } from "@wrnexus/router";
import { createHandlers, type RuntimeDeps } from "../src/runtime.ts";
const roots: string[] = [];
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
function customNotFoundRuntime() {
const root = mkdtempSync(join(tmpdir(), "wrnexus-not-found-"));
roots.push(root);
const app = join(root, "app");
mkdirSync(join(app, "pages"), { recursive: true });
mkdirSync(join(app, "api"), { recursive: true });
writeFileSync(join(app, "pages/404.ts"), "export default () => '';");
writeFileSync(join(app, "api/404.ts"), "export const GET = () => null;");
return createHandlers({
mode: "production",
hmr: false,
router: buildRouter(app),
loadModule: async (file) =>
file.includes(`${join("api", "404")}.ts`)
? {
GET: () =>
Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }),
}
: { default: () => "<main><h1>That page is gone</h1></main>" },
getMiddleware: async () => [],
assets: { serve: async () => null },
} satisfies RuntimeDeps);
}
const server = { upgrade: () => false };
test("renders app/pages/404 with an HTTP 404 status", async () => {
const response = await customNotFoundRuntime().fetch(
new Request("https://example.test/missing"),
server,
);
expect(response?.status).toBe(404);
expect(await response?.text()).toContain("That page is gone");
});
test("uses app/api/404 for unmatched API routes and preserves headers", async () => {
const response = await customNotFoundRuntime().fetch(
new Request("https://example.test/api/missing"),
server,
);
expect(response?.status).toBe(404);
expect(response?.headers.get("x-custom")).toBe("yes");
expect(await response?.json()).toEqual({ code: "CUSTOM_NOT_FOUND" });
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.8.9",
"version": "0.8.13",
"private": true,
"type": "module",
"sideEffects": false,
@@ -20,6 +20,6 @@
},
"devDependencies": {
"@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{}
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."));});
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)}));
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."));
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]){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")})));
const storeContainer=window.__wrnexusStoreContainer;
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('issue("plugin/"+app.id');
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-plugin-panel");
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -20,7 +20,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
},
"dependencies": {
"@wrnexus/core": "workspace:*"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.8.10",
"version": "0.8.12",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -23,7 +23,7 @@
],
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2",
"typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*"
},
"wrnexus": {
+35 -3
View File
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
.replace(/\u2029/g, "\\u2029");
}
/** Attribute marking the JSON block that carries per-request i18n data. */
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
/**
* The i18n payload, emitted as JSON rather than as an assignment.
*
* It ships inside a `type="application/json"` block, which the browser never
* executes, so `script-src` does not apply to it. As an inline executable
* script it was blocked whenever the surrounding document's CSP nonce came
* from a different response, leaving window.__wrnI18n undefined.
*/
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
const active = i18n.langs.includes(lang) ? lang : i18n.default;
return `window.__wrnI18n=${safeJson({
return `${safeJson({
lang: active,
langs: i18n.langs,
default: i18n.default,
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
directions: i18n.direction,
labels: i18n.labels,
cookie: i18n.cookie,
})};`;
})}`;
}
/** The full JSON block, including its script tag. */
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
}
export const I18N_RUNTIME = String.raw`
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
});
}
function state() { return window.__wrnI18n || {}; }
function readDataBlock() {
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
if (!node) return null;
try {
return JSON.parse(node.textContent || "{}");
} catch (error) {
console.error("[wrnexus] i18n data block was not valid JSON", error);
return null;
}
}
function state() {
if (!window.__wrnI18n) {
var data = readDataBlock();
if (data) window.__wrnI18n = data;
}
return window.__wrnI18n || {};
}
function t(key, params) {
var current = state();
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
test("the i18n payload is plain JSON, not an assignment", () => {
const data = renderI18nData(i18n, "en");
expect(() => JSON.parse(data)).not.toThrow();
expect(data).not.toContain("window.__wrnI18n");
});
test("the data tag is a non-executable JSON block", () => {
// An executable inline script is subject to script-src and gets blocked
// whenever the document's CSP nonce came from a different response, which is
// what left window.__wrnI18n undefined. A JSON block is never executed.
const tag = renderI18nDataTag(i18n, "es");
expect(tag).toContain('type="application/json"');
expect(tag).toContain("data-wrn-i18n");
expect(tag).not.toContain("nonce=");
expect(tag).toContain("Hola");
});
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/image",
"version": "0.8.9",
"version": "0.8.10",
"type": "module",
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
"main": "./src/index.ts",
@@ -22,7 +22,7 @@
],
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2",
"typescript": "^6.0.3",
"@wrnexus/syntax": "workspace:*"
},
"peerDependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -20,7 +20,7 @@
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
},
"dependencies": {
"@wrnexus/core": "workspace:*"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pwa",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
"main": "./src/index.ts",
@@ -14,6 +14,6 @@
],
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
"typescript": "^6.0.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
# @wrnexus/react
Opt-in React islands for WRNexusJS: mount npm React components inside server-rendered `.wrn` pages without adopting React as the framework's rendering model.
Import a `.tsx` component in a `.wrn` script block and use it as an element. The compiler emits a `data-wrn-island` placeholder instead of a server render, and this package's runtime mounts it in the browser with `createRoot`. Islands are client-only, each mounts inside its own error boundary, and roots are disposed on client-side navigation.
`react` and `react-dom` are optional peer dependencies, so apps that use no islands ship no React. A route with no islands still ships zero framework JavaScript.
Use `useWrnStore(name, selector?)` to read a WRNexus store from inside an island and `useWrnActions(name)` to write to it. Writes belong in event handlers or effects, never during render.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@wrnexus/react",
"version": "0.8.8",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./runtime": "./src/runtime-source.ts",
"./browser": "./src/browser.ts"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"dependencies": {
"@wrnexus/store": "workspace:*"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3"
}
}
+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";
+44
View File
@@ -0,0 +1,44 @@
/** @jsxImportSource react */
import { Component, type ErrorInfo, type ReactNode } from "react";
export interface IslandErrorBoundaryProps {
name: string;
development: boolean;
/** Optional so createElement(Boundary, props, child) typechecks. */
children?: ReactNode;
}
interface IslandErrorBoundaryState {
error: Error | null;
}
/**
* Contains island failures locally: a crashed island must never blank the
* surrounding server-rendered page.
*/
export class IslandErrorBoundary extends Component<
IslandErrorBoundaryProps,
IslandErrorBoundaryState
> {
override state: IslandErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): IslandErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(`[wrnexus] island '${this.props.name}' failed to render`, error, info);
}
override render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
if (!this.props.development) return null;
return (
<div data-wrn-island-error={this.props.name} style={{ padding: "0.75rem" }}>
<strong>{`Island '${this.props.name}' failed`}</strong>
<pre>{error.stack ?? error.message}</pre>
</div>
);
}
}
+9
View File
@@ -0,0 +1,9 @@
export { createSelectorCache, createSnapshotCache } from "./snapshot-cache.ts";
export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts";
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
export type { BoundStore, IslandStore, StoreResolver } from "./store-bridge.ts";
export { IslandErrorBoundary } from "./error-boundary.tsx";
export type { IslandErrorBoundaryProps } from "./error-boundary.tsx";
export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts";
export type { MountOptions } from "./island-runtime.ts";
export { discardDetachedRoots, remountIslands } from "./island-runtime.ts";
+152
View File
@@ -0,0 +1,152 @@
import { createElement, type ComponentType } from "react";
import { createRoot, type Root } from "react-dom/client";
import { IslandErrorBoundary } from "./error-boundary.tsx";
export interface MountOptions {
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
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>();
export function islandRootCount(): number {
return roots.size;
}
function readProps(element: Element): Record<string, unknown> {
const raw = element.getAttribute("data-wrn-island-props");
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch (error) {
console.error("[wrnexus] island props were not valid JSON", error);
return {};
}
}
function whenReady(element: Element, strategy: string): Promise<void> {
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
return new Promise((resolve) => {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
resolve();
}
});
observer.observe(element);
});
}
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
return new Promise((resolve) => requestIdleCallback(() => resolve()));
}
return Promise.resolve();
}
async function mountOne(element: Element, options: MountOptions): Promise<void> {
if (roots.has(element) && !options.remount) return;
const name = element.getAttribute("data-wrn-island");
if (!name) return;
const strategy = element.getAttribute("data-wrn-island-strategy") ?? "only";
await whenReady(element, strategy);
// Re-check: an await point means a concurrent mount may have claimed this
// element while the strategy was resolving.
if (roots.has(element) && !options.remount) return;
let Component: ComponentType<any>;
try {
Component = (await options.loader(name)).default;
} catch (error) {
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
return;
}
if (roots.has(element) && !options.remount) return;
// 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);
root.render(
createElement(
IslandErrorBoundary,
{ name, development: options.development ?? false },
createElement(Component, readProps(element)),
),
);
}
/** Mounts every island marker under `root`. No-op when the page has none. */
export async function mountIslands(root: ParentNode, options: MountOptions): Promise<void> {
const markers = Array.from(root.querySelectorAll("[data-wrn-island]"));
if (markers.length === 0) return;
await Promise.all(markers.map((element) => mountOne(element, options)));
}
/**
* Disposes island roots under `root`. Must run on client-side navigation or
* React roots, detached DOM, and store subscriptions leak on every route change.
*/
export function unmountIslands(root: ParentNode): void {
for (const [element, reactRoot] of [...roots]) {
if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) {
continue;
}
try {
reactRoot.unmount();
} catch (error) {
console.error("[wrnexus] island failed to unmount cleanly", error);
}
roots.delete(element);
}
}
/**
* Dev-only: re-render islands after a source change.
*
* Island state resets by design Fast Refresh needs a Babel/SWC transform plus
* a runtime and is out of scope.
*/
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
// 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);
}
/**
* 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);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* The island bootstrap served at `/__wrnexus/islands.js`.
*
* Mirrors the `@wrnexus/csr` pattern: this file is only ever fetched when a
* `data-wrn-island` marker is present, so island-free pages download nothing
* including React.
*/
export function getIslandRuntime(development = false): string {
return `
(function () {
function loader(name) {
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
}
function boot() {
if (!document.querySelector("[data-wrn-island]")) return;
import("/__wrnexus/island/runtime.js").then(function (runtime) {
runtime.mountIslands(document, { loader: loader, development: ${development} });
window.__wrnexusUnmountIslands = function (root) {
runtime.unmountIslands(root || document);
};
window.__wrnexusRemountIslands = function (root) {
return runtime.remountIslands(root || document, {
loader: loader,
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) {
console.error("[wrnexus] failed to load the island runtime", error);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
`;
}
+62
View File
@@ -0,0 +1,62 @@
export interface SnapshotSource<S extends object> {
snapshot(): Readonly<S>;
subscribe(listener: () => void): () => void;
}
export interface SnapshotCache<S extends object> {
getSnapshot(): Readonly<S>;
dispose(): void;
}
/**
* Wraps a store instance so repeated `getSnapshot()` calls return the same
* reference until the store notifies a change. `useSyncExternalStore` throws
* and spins if given a fresh object each call, which `@wrnexus/store`'s
* `readonlySnapshot` does by design.
*/
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
let cached: Readonly<S> | undefined;
let dirty = true;
const unsubscribe = source.subscribe(() => {
dirty = true;
});
return {
getSnapshot() {
if (dirty || cached === undefined) {
cached = source.snapshot();
dirty = false;
}
return cached;
},
dispose() {
unsubscribe();
},
};
}
/**
* Memoizes a selector over a cached snapshot. Without this, any mutation
* re-renders every island bound to the store, because snapshots are whole-state.
*/
export function createSelectorCache<S extends object, R>(
getSnapshot: () => Readonly<S>,
selector: (state: Readonly<S>) => R,
isEqual: (a: R, b: R) => boolean = Object.is,
): () => R {
let lastSnapshot: Readonly<S> | undefined;
let lastResult: R;
let initialized = false;
return () => {
const snapshot = getSnapshot();
if (!initialized || snapshot !== lastSnapshot) {
const next = selector(snapshot);
if (!initialized || !isEqual(next, lastResult)) lastResult = next;
lastSnapshot = snapshot;
initialized = true;
}
return lastResult;
};
}
+74
View File
@@ -0,0 +1,74 @@
import { useDebugValue, useMemo, useSyncExternalStore } from "react";
import { createSelectorCache, createSnapshotCache, type SnapshotCache } from "./snapshot-cache.ts";
export interface IslandStore<S extends object> {
snapshot(): Readonly<S>;
subscribe(listener: () => void): () => void;
actions: Record<string, (...args: any[]) => unknown>;
}
export type StoreResolver = (name: string) => IslandStore<any> | undefined;
let resolver: StoreResolver | null = null;
let knownNames: string[] = [];
/** Registers how island stores are looked up. Set by the island runtime at mount. */
export function setStoreResolver(next: StoreResolver | null, names: string[] = []): void {
resolver = next;
knownNames = names;
}
export interface BoundStore<S extends object> {
store: IslandStore<S>;
getSnapshot: () => Readonly<S>;
cache: SnapshotCache<S>;
}
function resolveStore<S extends object>(name: string): BoundStore<S> {
if (!resolver) {
throw new Error(
`useWrnStore("${name}") was called before the island runtime registered any stores.`,
);
}
const store = resolver(name) as IslandStore<S> | undefined;
if (!store) {
const available = knownNames.length > 0 ? knownNames.join(", ") : "(none registered)";
throw new Error(`Unknown WRNexus store "${name}". Available stores: ${available}`);
}
const cache = createSnapshotCache<S>(store);
return { store, cache, getSnapshot: cache.getSnapshot };
}
/** Test seam — exercises resolution and caching without rendering React. */
export function getStoreForTest<S extends object>(name: string): BoundStore<S> {
return resolveStore<S>(name);
}
/**
* Reads a WRNexus store from inside a React island.
*
* Writes must go through `useWrnActions` from an event handler or effect
* never during render, which would loop.
*/
export function useWrnStore<S extends object, R = Readonly<S>>(
name: string,
selector?: (state: Readonly<S>) => R,
isEqual?: (a: R, b: R) => boolean,
): R {
const bound = useMemo(() => resolveStore<S>(name), [name]);
const read = useMemo(
() =>
selector
? createSelectorCache<S, R>(bound.getSnapshot, selector, isEqual)
: (bound.getSnapshot as unknown as () => R),
[bound, selector, isEqual],
);
const value = useSyncExternalStore(bound.store.subscribe, read, read);
useDebugValue(value);
return value;
}
/** Returns the action map for a store, for writes from handlers and effects. */
export function useWrnActions(name: string): Record<string, (...args: any[]) => unknown> {
return useMemo(() => resolveStore(name).store.actions, [name]);
}
+104
View File
@@ -0,0 +1,104 @@
/** @jsxImportSource react */
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { IslandErrorBoundary } from "../src/error-boundary.tsx";
// React error boundaries only engage during client rendering — the server
// renderers rethrow. Islands are client-only, so this is also how they run.
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function Boom(): never {
throw new Error("chart exploded");
}
function mountHost() {
const window = new Window();
(globalThis as any).window = window;
(globalThis as any).document = window.document;
const container = window.document.createElement("div");
window.document.body.appendChild(container);
return { window, container: container as unknown as HTMLElement };
}
const silenced: Array<() => void> = [];
afterEach(() => {
for (const restore of silenced.splice(0)) restore();
});
function silenceExpectedErrors() {
const original = console.error;
console.error = () => {};
silenced.push(() => {
console.error = original;
});
}
test("renders children when nothing throws", async () => {
const { container } = mountHost();
const root = createRoot(container);
await act(async () => {
root.render(
<IslandErrorBoundary name="Chart" development={false}>
<p>ok</p>
</IslandErrorBoundary>,
);
});
expect(container.innerHTML).toBe("<p>ok</p>");
});
test("contains a thrown error and shows details in development", async () => {
silenceExpectedErrors();
const { container } = mountHost();
const root = createRoot(container);
await act(async () => {
root.render(
<IslandErrorBoundary name="Chart" development>
<Boom />
</IslandErrorBoundary>,
);
});
expect(container.innerHTML).toContain("Chart");
expect(container.innerHTML).toContain("chart exploded");
});
test("renders nothing in production when an island throws", async () => {
silenceExpectedErrors();
const { container } = mountHost();
const root = createRoot(container);
await act(async () => {
root.render(
<IslandErrorBoundary name="Chart" development={false}>
<Boom />
</IslandErrorBoundary>,
);
});
expect(container.innerHTML).toBe("");
});
test("a crashed island does not remove sibling server markup", async () => {
silenceExpectedErrors();
const { window, container } = mountHost();
const sibling = window.document.createElement("p");
sibling.textContent = "server rendered";
window.document.body.appendChild(sibling);
const root = createRoot(container);
await act(async () => {
root.render(
<IslandErrorBoundary name="Chart" development={false}>
<Boom />
</IslandErrorBoundary>,
);
});
expect(window.document.body.textContent).toContain("server rendered");
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, createElement } from "react";
import {
islandRootCount,
mountIslands,
remountIslands,
unmountIslands,
} from "../src/island-runtime.ts";
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function host(window: Window): ParentNode {
return window.document.body as unknown as ParentNode;
}
function domWith(html: string) {
const window = new Window();
window.document.body.innerHTML = html;
(globalThis as any).window = window;
(globalThis as any).document = window.document;
return window;
}
afterEach(() => {
const doc = (globalThis as any).document;
if (!doc) return;
act(() => {
unmountIslands(doc);
});
});
const marker =
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"` +
` data-wrn-island-props='{}'></div>`;
test("remount replaces island output without leaking roots", async () => {
const window = domWith(marker);
const first = async () => ({ default: () => createElement("span", null, "v1") });
const second = async () => ({ default: () => createElement("span", null, "v2") });
await act(async () => {
await mountIslands(host(window), { loader: first });
});
expect(window.document.body.textContent).toContain("v1");
expect(islandRootCount()).toBe(1);
await act(async () => {
await remountIslands(host(window), { loader: second });
});
expect(window.document.body.textContent).toContain("v2");
expect(window.document.body.textContent).not.toContain("v1");
expect(islandRootCount()).toBe(1);
});
test("repeated remounts stay at one root", async () => {
const window = domWith(marker);
const loader = async () => ({ default: () => createElement("span", null, "x") });
await act(async () => {
await mountIslands(host(window), { loader });
});
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await remountIslands(host(window), { loader });
});
}
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");
});
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, createElement } from "react";
import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts";
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// happy-dom's element types do not structurally match lib.dom's ParentNode;
// this cast is a test-environment concern, not a runtime one.
function host(window: Window): ParentNode {
return window.document.body as unknown as ParentNode;
}
function domWith(html: string) {
const window = new Window();
window.document.body.innerHTML = html;
(globalThis as any).window = window;
(globalThis as any).document = window.document;
return window;
}
const loader = async () => ({
default: (props: { title?: string }) => createElement("span", null, props.title ?? "none"),
});
afterEach(() => {
const doc = (globalThis as any).document;
if (!doc) return;
act(() => {
unmountIslands(doc);
});
});
function marker(props = "{}", strategy = "only") {
return (
`<div data-wrn-island="Chart" data-wrn-island-strategy="${strategy}"` +
` data-wrn-island-props='${props}'></div>`
);
}
test("mounts an island and passes deserialized props", async () => {
const window = domWith(marker('{"title":"Revenue"}'));
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(window.document.body.textContent).toContain("Revenue");
expect(islandRootCount()).toBe(1);
});
test("unmounts roots and leaves no leaked roots behind", async () => {
const window = domWith(marker('{"title":"A"}'));
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
act(() => {
unmountIslands(host(window));
});
expect(islandRootCount()).toBe(0);
});
test("repeated mount/unmount cycles do not accumulate roots", async () => {
const window = domWith(marker());
for (let i = 0; i < 5; i += 1) {
await act(async () => {
await mountIslands(host(window), { loader });
});
act(() => {
unmountIslands(host(window));
});
}
expect(islandRootCount()).toBe(0);
});
test("does nothing when no island markers are present", async () => {
const window = domWith(`<p>plain server html</p>`);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(0);
});
test("mounting twice does not create a second root for the same element", async () => {
const window = domWith(marker());
await act(async () => {
await mountIslands(host(window), { loader });
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
});
test("a failing bundle load leaves the placeholder and mounts no root", async () => {
const window = domWith(marker());
const original = console.error;
console.error = () => {};
await act(async () => {
await mountIslands(host(window), {
loader: async () => {
throw new Error("network down");
},
});
});
console.error = original;
expect(islandRootCount()).toBe(0);
expect(window.document.querySelector("[data-wrn-island]")).not.toBeNull();
});
test("malformed props JSON falls back to empty props instead of throwing", async () => {
const window = domWith(marker("not-json"));
const original = console.error;
console.error = () => {};
await act(async () => {
await mountIslands(host(window), { loader });
});
console.error = original;
expect(window.document.body.textContent).toContain("none");
expect(islandRootCount()).toBe(1);
});
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { getIslandRuntime } from "../src/runtime-source.ts";
test("emits a runtime that bails out when no island markers exist", () => {
const source = getIslandRuntime(false);
expect(source).toContain("data-wrn-island");
expect(source).toContain("/__wrnexus/island/");
});
test("registers a navigation hook so islands unmount on route change", () => {
expect(getIslandRuntime(false)).toContain("__wrnexusUnmountIslands");
});
test("never references react-dom/server", () => {
expect(getIslandRuntime(true)).not.toContain("react-dom/server");
});
test("threads the development flag into the mount options", () => {
expect(getIslandRuntime(true)).toContain("development: true");
expect(getIslandRuntime(false)).toContain("development: false");
});
test("encodes the island name before using it as a URL path segment", () => {
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");
});

Some files were not shown because too many files have changed in this diff Show More