Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d | ||
|
|
e819c5739e | ||
|
|
ac164789bd | ||
|
|
88ec3a5cb6 | ||
|
|
28085b5a43 | ||
|
|
5d7bd81601 | ||
|
|
5e114d867f | ||
|
|
10421465df | ||
|
|
52cce2c628 | ||
|
|
afa2a8c093 | ||
|
|
4dd7681bf7 | ||
|
|
843db2815f | ||
|
|
17aa3b98eb | ||
|
|
442a3106ed | ||
|
|
3115277e9d | ||
|
|
02fdfa3aee | ||
|
|
5f66c8129c | ||
|
|
01a3b3b4e9 | ||
|
|
a184f1a3be | ||
|
|
06df9d66ae | ||
|
|
d269772a79 | ||
|
|
df2ee036eb | ||
|
|
b405025f37 | ||
|
|
cd07f0f8e2 | ||
|
|
ce67d0d1d2 | ||
|
|
468f63f378 | ||
|
|
a6570d7f68 | ||
|
|
4ef2ef14ff | ||
|
|
d78707be9f | ||
|
|
1d16ef1e82 | ||
|
|
12a1014db2 | ||
|
|
52b3e6d378 | ||
|
|
f88dd47408 | ||
|
|
f0447fddb0 |
@@ -1,3 +1,3 @@
|
||||
@wrnexus:registry=https://registry.npmjs.org/
|
||||
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||
audit=true
|
||||
fund=false
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
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,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);
|
||||
|
||||
+2334
-1607
File diff suppressed because it is too large
Load Diff
@@ -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,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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
+68
-11
@@ -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,7 +246,8 @@ 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(
|
||||
let code =
|
||||
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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("<");
|
||||
expect(html).toContain(""");
|
||||
});
|
||||
|
||||
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(""start":3");
|
||||
expect(out).toContain(""live":true");
|
||||
expect(out).toContain(""title":"Revenue"");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.22",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.29",
|
||||
"version": "0.8.38",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]/);
|
||||
}
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||
|
||||
/**
|
||||
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||
*
|
||||
* It ships inside a `type="application/json"` block, which the browser never
|
||||
* executes, so `script-src` does not apply to it. As an inline executable
|
||||
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||
* from a different response, leaving window.__wrnI18n undefined.
|
||||
*/
|
||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||
return `window.__wrnI18n=${safeJson({
|
||||
return `${safeJson({
|
||||
lang: active,
|
||||
langs: i18n.langs,
|
||||
default: i18n.default,
|
||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||
directions: i18n.direction,
|
||||
labels: i18n.labels,
|
||||
cookie: i18n.cookie,
|
||||
})};`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
/** The full JSON block, including its script tag. */
|
||||
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||
}
|
||||
|
||||
export const I18N_RUNTIME = String.raw`
|
||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||
});
|
||||
}
|
||||
function state() { return window.__wrnI18n || {}; }
|
||||
function readDataBlock() {
|
||||
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || "{}");
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function state() {
|
||||
if (!window.__wrnI18n) {
|
||||
var data = readDataBlock();
|
||||
if (data) window.__wrnI18n = data;
|
||||
}
|
||||
return window.__wrnI18n || {};
|
||||
}
|
||||
function t(key, params) {
|
||||
var current = state();
|
||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||
|
||||
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||
|
||||
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||
const data = renderI18nData(i18n, "en");
|
||||
expect(() => JSON.parse(data)).not.toThrow();
|
||||
expect(data).not.toContain("window.__wrnI18n");
|
||||
});
|
||||
|
||||
test("the data tag is a non-executable JSON block", () => {
|
||||
// An executable inline script is subject to script-src and gets blocked
|
||||
// whenever the document's CSP nonce came from a different response, which is
|
||||
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||
const tag = renderI18nDataTag(i18n, "es");
|
||||
expect(tag).toContain('type="application/json"');
|
||||
expect(tag).toContain("data-wrn-i18n");
|
||||
expect(tag).not.toContain("nonce=");
|
||||
expect(tag).toContain("Hola");
|
||||
});
|
||||
|
||||
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||
});
|
||||
@@ -1,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": {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user