Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f075df42c | ||
|
|
281615a4b0 | ||
|
|
3ae5d7cf97 | ||
|
|
b5029889a5 | ||
|
|
e9db4ca24d | ||
|
|
5318320c70 | ||
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 | ||
|
|
2cbc3e43e1 | ||
|
|
55fed2177a | ||
|
|
8c609edd32 | ||
|
|
e898929193 | ||
|
|
18a1c40118 | ||
|
|
a20f143acb | ||
|
|
ac248f2bb0 | ||
|
|
0904a4efaa | ||
|
|
5b11b937bb | ||
|
|
f199385204 | ||
|
|
c0c2fa4595 | ||
|
|
cfdcdd00ad | ||
|
|
03d5cb6aa6 | ||
|
|
701acd828c | ||
|
|
b28b79c370 | ||
|
|
fdd0c9f847 | ||
|
|
7ad336b4dd | ||
|
|
d70e89230b | ||
|
|
7e6d8c3bc8 | ||
|
|
92c0920c9b | ||
|
|
a8d8ac386f | ||
|
|
2c8841cc5f | ||
|
|
b344d2a70a | ||
|
|
e83f0366ef | ||
|
|
d9e8f5be82 | ||
|
|
bd0c1317ff | ||
|
|
97801287f9 | ||
|
|
d77638131b | ||
|
|
609224591c | ||
|
|
6074d19c43 | ||
|
|
7301849a7f | ||
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d | ||
|
|
e819c5739e | ||
|
|
ac164789bd | ||
|
|
88ec3a5cb6 | ||
|
|
28085b5a43 | ||
|
|
5d7bd81601 | ||
|
|
5e114d867f | ||
|
|
10421465df | ||
|
|
52cce2c628 | ||
|
|
afa2a8c093 |
@@ -25,3 +25,6 @@ tsconfig.focus.json
|
|||||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||||
# which requires the scaffold to live inside the repo tree).
|
# which requires the scaffold to live inside the repo tree).
|
||||||
**/test/.tmp-*/
|
**/test/.tmp-*/
|
||||||
|
|
||||||
|
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@wrnexus:registry=https://registry.npmjs.org/
|
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||||
audit=true
|
audit=true
|
||||||
fund=false
|
fund=false
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ bun.lockb
|
|||||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||||
**/*.gen.ts
|
**/*.gen.ts
|
||||||
**/*.generated.d.ts
|
**/*.generated.d.ts
|
||||||
|
**/*.generated.api-checks.ts
|
||||||
|
|
||||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||||
editors/vscode/src/compiler.cjs
|
editors/vscode/src/compiler.cjs
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
|
||||||
|
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
|
||||||
|
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
|
||||||
|
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
|
||||||
|
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
|
||||||
|
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
|
||||||
|
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
|
||||||
|
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
|
||||||
|
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
|
||||||
|
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
|
||||||
|
required boolean field given `false` still errors, as before (checkbox-required semantics
|
||||||
|
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
|
||||||
|
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
|
||||||
|
to start failing.
|
||||||
|
|
||||||
|
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
|
||||||
|
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
|
||||||
|
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
|
||||||
|
when no input is passed explicitly. Previously such endpoints silently validated
|
||||||
|
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
|
||||||
|
what was sent. **Behavior change for downstream apps:** a request that previously passed
|
||||||
|
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
|
||||||
|
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
|
||||||
|
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
|
||||||
|
priority over reading the request.
|
||||||
|
|
||||||
## 0.8.8
|
## 0.8.8
|
||||||
|
|
||||||
- Added the framework request context to `.wrn` language-server type environments.
|
- Added the framework request context to `.wrn` language-server type environments.
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.36",
|
"version": "0.8.46",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -287,6 +287,7 @@
|
|||||||
"@wrnexus/mcp": "workspace:*",
|
"@wrnexus/mcp": "workspace:*",
|
||||||
"@wrnexus/playground": "workspace:*",
|
"@wrnexus/playground": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/router": "workspace:*",
|
"@wrnexus/router": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
"@wrnexus/styles": "workspace:*",
|
"@wrnexus/styles": "workspace:*",
|
||||||
@@ -299,9 +300,8 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
@@ -318,18 +318,18 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@wrnexus/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.33",
|
"version": "0.8.41",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-toolbar": {
|
"packages/dev-toolbar": {
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.10",
|
"version": "0.8.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
@@ -451,13 +451,14 @@
|
|||||||
},
|
},
|
||||||
"packages/language-server": {
|
"packages/language-server": {
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus-language-server": "src/server.ts",
|
"wrnexus-language-server": "src/server.ts",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/mcp": {
|
"packages/mcp": {
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
},
|
},
|
||||||
"packages/react": {
|
"packages/react": {
|
||||||
"name": "@wrnexus/react",
|
"name": "@wrnexus/react",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -621,7 +622,7 @@
|
|||||||
},
|
},
|
||||||
"packages/syntax": {
|
"packages/syntax": {
|
||||||
"name": "@wrnexus/syntax",
|
"name": "@wrnexus/syntax",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
},
|
},
|
||||||
"packages/test": {
|
"packages/test": {
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
@@ -641,7 +642,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@wrnexus/ui",
|
"name": "@wrnexus/ui",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -662,7 +663,7 @@
|
|||||||
},
|
},
|
||||||
"packages/validation": {
|
"packages/validation": {
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
@@ -967,6 +968,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
||||||
|
|
||||||
|
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||||
|
|
||||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||||
|
|
||||||
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
||||||
@@ -1387,6 +1390,14 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ Supported view features include:
|
|||||||
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
||||||
- comments and scoped styles
|
- comments and scoped styles
|
||||||
|
|
||||||
|
`{#if}` and `{#each}` are rendered on the server for the initial response and
|
||||||
|
remain reactive after hydration. Browser state changes switch conditional
|
||||||
|
branches and rerender loop rows, including the `{:empty}` branch.
|
||||||
|
|
||||||
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
||||||
boundaries.
|
boundaries.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1040,7 +1040,8 @@
|
|||||||
"rpcManifest",
|
"rpcManifest",
|
||||||
"runtimeCapabilities",
|
"runtimeCapabilities",
|
||||||
"runtimeTypeOf",
|
"runtimeTypeOf",
|
||||||
"serializeIslandProps"
|
"serializeIslandProps",
|
||||||
|
"stripBrowserTypes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@wrnexus/content": {
|
"@wrnexus/content": {
|
||||||
@@ -1674,6 +1675,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1712,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
@@ -2851,7 +2854,10 @@
|
|||||||
"LexError",
|
"LexError",
|
||||||
"Lexer",
|
"Lexer",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenType"
|
"TokenType",
|
||||||
|
"isIdentPart",
|
||||||
|
"isIdentStart",
|
||||||
|
"skipLiteralOrComment"
|
||||||
],
|
],
|
||||||
"./types": [
|
"./types": [
|
||||||
"RuntimeType",
|
"RuntimeType",
|
||||||
|
|||||||
@@ -30,26 +30,26 @@
|
|||||||
|
|
||||||
**New package `packages/react/`:**
|
**New package `packages/react/`:**
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
| ------------------------ | ------------------------------------------------------------------ |
|
||||||
| `package.json` | Package manifest; optional peer deps |
|
| `package.json` | Package manifest; optional peer deps |
|
||||||
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
||||||
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
||||||
| `src/error-boundary.tsx` | Per-island React error boundary |
|
| `src/error-boundary.tsx` | Per-island React error boundary |
|
||||||
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
||||||
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
||||||
| `src/index.ts` | Public exports |
|
| `src/index.ts` | Public exports |
|
||||||
|
|
||||||
**Modified:**
|
**Modified:**
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
| ------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
||||||
| `packages/compiler/src/island-codegen.ts` *(new)* | Marker emission, props serialization, diagnostics |
|
| `packages/compiler/src/island-codegen.ts` _(new)_ | Marker emission, props serialization, diagnostics |
|
||||||
| `packages/compiler/src/island-bundle.ts` *(new)* | Island entry generation + `Bun.build` with shared React chunk |
|
| `packages/compiler/src/island-bundle.ts` _(new)_ | Island entry generation + `Bun.build` with shared React chunk |
|
||||||
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
||||||
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
||||||
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,11 +58,13 @@
|
|||||||
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/package.json`
|
- Create: `packages/react/package.json`
|
||||||
- Create: `packages/react/src/snapshot-cache.ts`
|
- Create: `packages/react/src/snapshot-cache.ts`
|
||||||
- Test: `packages/react/test/snapshot-cache.test.ts`
|
- Test: `packages/react/test/snapshot-cache.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing.
|
- Consumes: nothing.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
||||||
@@ -193,9 +195,7 @@ export interface SnapshotCache<S extends object> {
|
|||||||
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
||||||
* `readonlySnapshot` does by design.
|
* `readonlySnapshot` does by design.
|
||||||
*/
|
*/
|
||||||
export function createSnapshotCache<S extends object>(
|
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||||
source: SnapshotSource<S>,
|
|
||||||
): SnapshotCache<S> {
|
|
||||||
let cached: Readonly<S> | undefined;
|
let cached: Readonly<S> | undefined;
|
||||||
let dirty = true;
|
let dirty = true;
|
||||||
|
|
||||||
@@ -259,12 +259,14 @@ git commit -m "feat(react): add referentially-stable snapshot and selector cache
|
|||||||
### Task 2: Store bridge hook
|
### Task 2: Store bridge hook
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/store-bridge.ts`
|
- Create: `packages/react/src/store-bridge.ts`
|
||||||
- Create: `packages/react/src/index.ts`
|
- Create: `packages/react/src/index.ts`
|
||||||
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
||||||
- Test: `packages/react/test/store-bridge.test.ts`
|
- Test: `packages/react/test/store-bridge.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
||||||
@@ -436,10 +438,12 @@ git commit -m "feat(react): add useWrnStore bridge over useSyncExternalStore"
|
|||||||
### Task 3: Island marker codegen
|
### Task 3: Island marker codegen
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-codegen.ts`
|
- Create: `packages/compiler/src/island-codegen.ts`
|
||||||
- Test: `packages/compiler/test/island-codegen.test.ts`
|
- Test: `packages/compiler/test/island-codegen.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
||||||
@@ -596,10 +600,12 @@ git commit -m "feat(compiler): add island marker codegen and props contract"
|
|||||||
### Task 4: Resolve `.tsx` imports as islands
|
### Task 4: Resolve `.tsx` imports as islands
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/import-resolver.ts`
|
- Modify: `packages/compiler/src/import-resolver.ts`
|
||||||
- Test: `packages/compiler/test/island-resolution.test.ts`
|
- Test: `packages/compiler/test/island-resolution.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
||||||
|
|
||||||
@@ -705,12 +711,12 @@ export interface ResolvedImport {
|
|||||||
Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to:
|
Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (found) {
|
if (found) {
|
||||||
const resolved = realpathSync(found);
|
const resolved = realpathSync(found);
|
||||||
return resolved.endsWith(".tsx")
|
return resolved.endsWith(".tsx")
|
||||||
? { declaration, resolved, kind: "island" }
|
? { declaration, resolved, kind: "island" }
|
||||||
: { declaration, resolved };
|
: { declaration, resolved };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Run test to verify it passes**
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
@@ -735,10 +741,12 @@ git commit -m "feat(compiler): resolve .tsx imports and tag them as islands"
|
|||||||
### Task 5: Island error boundary
|
### Task 5: Island error boundary
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/error-boundary.tsx`
|
- Create: `packages/react/src/error-boundary.tsx`
|
||||||
- Test: `packages/react/test/error-boundary.test.tsx`
|
- Test: `packages/react/test/error-boundary.test.tsx`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
||||||
|
|
||||||
@@ -857,11 +865,13 @@ git commit -m "feat(react): add per-island error boundary"
|
|||||||
### Task 6: Island runtime — mount, strategies, unmount
|
### Task 6: Island runtime — mount, strategies, unmount
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/island-runtime.ts`
|
- Create: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-runtime.test.ts`
|
- Test: `packages/react/test/island-runtime.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
@@ -1070,10 +1080,12 @@ git commit -m "feat(react): add island mount strategies and navigation-safe unmo
|
|||||||
### Task 7: Island bundling with a shared React chunk
|
### Task 7: Island bundling with a shared React chunk
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-bundle.ts`
|
- Create: `packages/compiler/src/island-bundle.ts`
|
||||||
- Test: `packages/compiler/test/island-bundle.test.ts`
|
- Test: `packages/compiler/test/island-bundle.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
||||||
@@ -1226,6 +1238,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
### Task 8: Serve island assets in dev, prod, and static build
|
### Task 8: Serve island assets in dev, prod, and static build
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/runtime-source.ts`
|
- Create: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/dev-server/src/assets.ts`
|
- Modify: `packages/dev-server/src/assets.ts`
|
||||||
- Modify: `packages/dev-server/src/prod.ts`
|
- Modify: `packages/dev-server/src/prod.ts`
|
||||||
@@ -1233,6 +1246,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
- Test: `packages/react/test/runtime-source.test.ts`
|
- Test: `packages/react/test/runtime-source.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
||||||
|
|
||||||
@@ -1323,15 +1337,15 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
|
|||||||
Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:
|
Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`:
|
The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`.
|
where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`.
|
||||||
@@ -1341,8 +1355,8 @@ where `serveIslandArtifact` reads from the island build output directory produce
|
|||||||
In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route:
|
In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname === "/__wrnexus/islands.js")
|
if (pathname === "/__wrnexus/islands.js")
|
||||||
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory.
|
Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory.
|
||||||
@@ -1375,10 +1389,12 @@ git commit -m "feat(islands): serve the island runtime in dev, prod, and static
|
|||||||
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/analysis.ts`
|
- Modify: `packages/compiler/src/analysis.ts`
|
||||||
- Test: `packages/compiler/test/island-classification.test.ts`
|
- Test: `packages/compiler/test/island-classification.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `ResolvedImport.kind` (Task 4).
|
- Consumes: `ResolvedImport.kind` (Task 4).
|
||||||
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
||||||
|
|
||||||
@@ -1457,10 +1473,12 @@ git commit -m "feat(compiler): classify island routes as static-interactive"
|
|||||||
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
||||||
- Test: `packages/compiler/test/island-integration.test.ts`
|
- Test: `packages/compiler/test/island-integration.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
||||||
- Produces: nothing consumed downstream.
|
- Produces: nothing consumed downstream.
|
||||||
|
|
||||||
@@ -1493,10 +1511,7 @@ import { join, resolve } from "node:path";
|
|||||||
import { buildIslands } from "../src/island-bundle.ts";
|
import { buildIslands } from "../src/island-bundle.ts";
|
||||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||||
|
|
||||||
const COUNTER = resolve(
|
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||||
import.meta.dir,
|
|
||||||
"../../../examples/basic-app/app/islands/Counter.tsx",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||||
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
||||||
@@ -1522,8 +1537,8 @@ test("a page with multiple islands ships React exactly once", async () => {
|
|||||||
.filter((file) => file.endsWith(".js"))
|
.filter((file) => file.endsWith(".js"))
|
||||||
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
||||||
|
|
||||||
const withReactInternals = bundles.filter((source) =>
|
const withReactInternals = bundles.filter(
|
||||||
source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
(source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.assets).toHaveLength(2);
|
expect(result.assets).toHaveLength(2);
|
||||||
@@ -1564,12 +1579,14 @@ git commit -m "test(islands): guard zero-JS routes and single-React bundling"
|
|||||||
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/render-phase.ts`
|
- Create: `packages/react/src/render-phase.ts`
|
||||||
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/render-phase.test.ts`
|
- Test: `packages/react/test/render-phase.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `useWrnActions` (Task 2).
|
- Consumes: `useWrnActions` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `beginRenderPhase(): void`
|
- `beginRenderPhase(): void`
|
||||||
@@ -1730,12 +1747,14 @@ git commit -m "feat(react): fail loudly on store writes during island render"
|
|||||||
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/react/src/island-runtime.ts`
|
- Modify: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/runtime-source.ts`
|
- Modify: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-hmr.test.ts`
|
- Test: `packages/react/test/island-hmr.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
|
|
||||||
@@ -1751,8 +1770,7 @@ import { islandRootCount, mountIslands, remountIslands } from "../src/island-run
|
|||||||
|
|
||||||
test("remount replaces island output without leaking roots", async () => {
|
test("remount replaces island output without leaking roots", async () => {
|
||||||
const window = new Window();
|
const window = new Window();
|
||||||
window.document.body.innerHTML =
|
window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
||||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
|
||||||
data-wrn-island-props='{}'></div>`;
|
data-wrn-island-props='{}'></div>`;
|
||||||
(globalThis as any).window = window;
|
(globalThis as any).window = window;
|
||||||
(globalThis as any).document = window.document;
|
(globalThis as any).document = window.document;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,14 +28,14 @@ byte-for-byte unchanged, and a route with no islands must ship no React.
|
|||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
| Question | Decision |
|
| Question | Decision |
|
||||||
|---|---|
|
| ---------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| Purpose | npm ecosystem access |
|
| Purpose | npm ecosystem access |
|
||||||
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
||||||
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
||||||
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
||||||
| Bundling | Extend the existing Bun pipeline |
|
| Bundling | Extend the existing Bun pipeline |
|
||||||
| Packaging | New isolated package `@wrnexus/react` |
|
| Packaging | New isolated package `@wrnexus/react` |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ separate island hydration channel is introduced.
|
|||||||
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
||||||
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
||||||
referentially identical value when nothing has changed; otherwise React throws
|
referentially identical value when nothing has changed; otherwise React throws
|
||||||
*"The result of getSnapshot should be cached to avoid an infinite loop"* and spins.
|
_"The result of getSnapshot should be cached to avoid an infinite loop"_ and spins.
|
||||||
|
|
||||||
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
||||||
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
||||||
@@ -182,15 +182,15 @@ point.
|
|||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
| Condition | Behavior |
|
| Condition | Behavior |
|
||||||
|---|---|
|
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
||||||
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
||||||
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
||||||
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
|
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
|
||||||
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
|
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
|
||||||
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
|
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
|
||||||
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
||||||
|
|
||||||
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
|
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
|
||||||
the rest of the page working.
|
the rest of the page working.
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# HTML editing support for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-18
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
|
||||||
|
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
|
||||||
|
tag-level folding.
|
||||||
|
|
||||||
|
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html` → `html`), which is
|
||||||
|
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
|
||||||
|
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **HTML formatting.** See "Formatting is deliberately excluded" below.
|
||||||
|
- Editor support outside VS Code beyond what standard LSP gives for free.
|
||||||
|
- Changing `.wrn` syntax or the compiler.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
|
||||||
|
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
|
||||||
|
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
|
||||||
|
| Region detection | Tolerant scanner over a virtual document, not the AST |
|
||||||
|
| HTML knowledge | `vscode-html-languageservice` |
|
||||||
|
| Formatting | Excluded — `formatWrn` already owns markup formatting |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Virtual HTML document
|
||||||
|
|
||||||
|
New module: `packages/language-server/src/html-regions.ts`, exporting
|
||||||
|
`virtualHtmlDocument(document)`.
|
||||||
|
|
||||||
|
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
|
||||||
|
newlines preserved. The virtual document therefore has the same size and the same line/column
|
||||||
|
geometry as the source, so a position in the source _is_ the position in the virtual document.
|
||||||
|
No mapping table and no translation layer.
|
||||||
|
|
||||||
|
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
|
||||||
|
compacts code and carries line mappings back to source. Compaction is necessary there because
|
||||||
|
the output must be valid TypeScript. HTML has no such requirement, so the simpler
|
||||||
|
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
|
||||||
|
does not arise.
|
||||||
|
|
||||||
|
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
|
||||||
|
newlines at identical offsets. If this breaks, every feature reports positions off by some
|
||||||
|
amount rather than failing loudly.
|
||||||
|
|
||||||
|
### Region detection
|
||||||
|
|
||||||
|
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
|
||||||
|
while the document is being typed, which is exactly when it does not parse. The scanner finds
|
||||||
|
`view` followed by `{` and tracks brace depth to the matching close.
|
||||||
|
|
||||||
|
Two hazards it must handle, both of which defeat a naive implementation:
|
||||||
|
|
||||||
|
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
|
||||||
|
delimiter anywhere will consider the rest of the file one open string and lose every later
|
||||||
|
region. Quotes are tracked only inside attribute values, never in text nodes.
|
||||||
|
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
|
||||||
|
depth must be counted rather than scanning for the next `}`.
|
||||||
|
|
||||||
|
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
|
||||||
|
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
|
||||||
|
|
||||||
|
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
|
||||||
|
costs a single scan.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
### The server becomes the single authority inside view blocks
|
||||||
|
|
||||||
|
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
|
||||||
|
virtual document is non-blank there, which costs one character lookup.
|
||||||
|
|
||||||
|
**Inside a view block**, one list is assembled from two sources:
|
||||||
|
|
||||||
|
| Source | `sortText` prefix | Content |
|
||||||
|
| ------- | ----------------- | ------------------------------------------------------------------------ |
|
||||||
|
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
|
||||||
|
| HTML | `1` | Tags, attributes, attribute values |
|
||||||
|
|
||||||
|
`sortText` drives ordering independently of the label, so components rank above HTML tags
|
||||||
|
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
|
||||||
|
plus workspace items.
|
||||||
|
|
||||||
|
The server already indexes components, props, outputs, and slots
|
||||||
|
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
|
||||||
|
of the merge are already available to it.
|
||||||
|
|
||||||
|
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
|
||||||
|
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
|
||||||
|
resolves to the component.
|
||||||
|
|
||||||
|
### Trigger characters
|
||||||
|
|
||||||
|
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
|
||||||
|
`" "`, `"="`, `"\""`, and `"/"`.
|
||||||
|
|
||||||
|
### This fixes an existing bug
|
||||||
|
|
||||||
|
The extension's `completion.js` registers its own provider with `<` among its trigger
|
||||||
|
characters, and the language server answers `textDocument/completion` as well. VS Code
|
||||||
|
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
|
||||||
|
involved at all.
|
||||||
|
|
||||||
|
As part of this work the extension's provider returns nothing when the position is inside a view
|
||||||
|
block, and keeps its current behaviour elsewhere. One owner per context.
|
||||||
|
|
||||||
|
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
|
||||||
|
context, so future component-intelligence work belongs in the server rather than in
|
||||||
|
`completion.js`.
|
||||||
|
|
||||||
|
## Hover
|
||||||
|
|
||||||
|
`textDocument/hover` answers from the HTML service over the virtual document when the position
|
||||||
|
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
|
||||||
|
region, existing hover behaviour is unchanged.
|
||||||
|
|
||||||
|
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
|
||||||
|
any HTML entry of the same name, matching the completion precedence rule above.
|
||||||
|
|
||||||
|
## Tag handling
|
||||||
|
|
||||||
|
### Linked editing is standard LSP
|
||||||
|
|
||||||
|
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
|
||||||
|
it lives in the shared server like everything else.
|
||||||
|
|
||||||
|
### Auto-close on type is the one client-side piece
|
||||||
|
|
||||||
|
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
|
||||||
|
client-side, and this follows the same shape:
|
||||||
|
|
||||||
|
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
|
||||||
|
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
|
||||||
|
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
|
||||||
|
snippet or `null`.
|
||||||
|
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
|
||||||
|
|
||||||
|
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
|
||||||
|
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
|
||||||
|
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
|
||||||
|
|
||||||
|
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
|
||||||
|
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
|
||||||
|
|
||||||
|
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
|
||||||
|
`wrnexus.*` naming.
|
||||||
|
|
||||||
|
### Emmet
|
||||||
|
|
||||||
|
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
|
||||||
|
|
||||||
|
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
|
||||||
|
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
|
||||||
|
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
|
||||||
|
edge is real.
|
||||||
|
|
||||||
|
## Folding
|
||||||
|
|
||||||
|
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
|
||||||
|
the virtual document, filtered to view regions.
|
||||||
|
|
||||||
|
Today folding comes only from `language-configuration.json` markers, which work at block level
|
||||||
|
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
|
||||||
|
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
|
||||||
|
to work unchanged and tag folding appears inside markup.
|
||||||
|
|
||||||
|
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
|
||||||
|
outside a view region would let a fold swallow a brace boundary.
|
||||||
|
|
||||||
|
## Formatting is deliberately excluded
|
||||||
|
|
||||||
|
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
|
||||||
|
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
|
||||||
|
`printWidth`. It is a markup formatter that understands WRNexus syntax.
|
||||||
|
|
||||||
|
Adding HTML formatting would do two harmful things:
|
||||||
|
|
||||||
|
- **Two formatters would fight.** Output would depend on which ran last.
|
||||||
|
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
|
||||||
|
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
|
||||||
|
|
||||||
|
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
|
||||||
|
`editors/vscode`.
|
||||||
|
|
||||||
|
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
|
||||||
|
and passes other `require`s through to Node, so the package must be resolvable at runtime from
|
||||||
|
the extension. `editors/vscode` currently ships exactly one runtime dependency
|
||||||
|
(`vscode-languageclient`); this adds the second.
|
||||||
|
|
||||||
|
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
|
||||||
|
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Region scanner (`packages/language-server/test/`)
|
||||||
|
|
||||||
|
- **The invariant**, property-style across fixtures: virtual text length equals source length and
|
||||||
|
newlines sit at identical offsets.
|
||||||
|
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
|
||||||
|
found.
|
||||||
|
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
|
||||||
|
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
|
||||||
|
completion, not an edge case.
|
||||||
|
- **Multiple view blocks**, and files with none.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
|
||||||
|
- Outside a view block: response identical to current behaviour — the guard proving non-markup
|
||||||
|
contexts are undisturbed.
|
||||||
|
- Collision: a component named `Table` yields one entry, the component.
|
||||||
|
|
||||||
|
### Tag handling
|
||||||
|
|
||||||
|
- `<div>` → `</div>`; `<br>` → nothing; `<Card /` → `/>`; outside a view region → `null`.
|
||||||
|
- Linked editing returns ranges covering both the opening and closing tag names.
|
||||||
|
|
||||||
|
### Hover
|
||||||
|
|
||||||
|
- Inside a view region, a known tag returns HTML documentation.
|
||||||
|
- A component name returns the component detail, not an HTML entry of the same name.
|
||||||
|
|
||||||
|
### Folding
|
||||||
|
|
||||||
|
- Every returned range lies inside a view region.
|
||||||
|
- Block-level marker folding still works.
|
||||||
|
|
||||||
|
### Toolchain guards
|
||||||
|
|
||||||
|
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
|
||||||
|
- Manifest assertion that `emmet.includeLanguages` maps `wrn` → `html`, alongside the existing
|
||||||
|
marketplace checks in `editors/vscode/test`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- HTML formatting — see above; improve `formatWrn` instead.
|
||||||
|
- Moving the remaining `completion.js` component intelligence into the server. This design only
|
||||||
|
requires it to stand down inside view blocks; relocating the rest is follow-up work.
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Typed, callable `api` blocks for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** A sectioned `api` block that declares a typed request, transforms the response, and
|
||||||
|
handles failure — callable on demand from client code.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Calling this application's own API routes from a `.wrn` page should be declarative and
|
||||||
|
type-checked. Today it is neither: the `api` block takes no parameters at all, so anything
|
||||||
|
carrying a value from the page is written as a hand-rolled `fetch` — query-string assembly,
|
||||||
|
JSON headers, CSRF, status checks, and a `try/catch` repeated at every call site.
|
||||||
|
|
||||||
|
### What the current block cannot do
|
||||||
|
|
||||||
|
These are implementation facts, not gaps in documentation:
|
||||||
|
|
||||||
|
- **No query string.** `isSafeApiPath` (`packages/dev-server/src/runtime.ts`) rejects any path
|
||||||
|
containing `?` or `#`.
|
||||||
|
- **No interpolation.** `readPath()` reads until whitespace or `{`, so `/api/users?name={filter}`
|
||||||
|
ends the path at the brace and the remainder is parsed as the block body.
|
||||||
|
- **No request body.** The caller builds `new Request(apiUrl, { method, headers })` — there is no
|
||||||
|
parameter a payload could occupy, whatever method is named.
|
||||||
|
- **Fetch-once.** `setupCsrFetch` sets an `__wrnexusCsrFetch` flag and returns early on any later
|
||||||
|
pass, so a binding cannot be re-run.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- External or third-party APIs. Targets are restricted to this app's `/api/*` routes, preserving
|
||||||
|
the existing `isSafeApiPath` guarantee.
|
||||||
|
- Replacing `server function`. That remains the way to run arbitrary server logic over RPC.
|
||||||
|
- Author-settable headers. See "Why `headers` is excluded".
|
||||||
|
- Parameterised server-render fetching. See "The SSR boundary".
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| Trigger | `client {}` blocks are callable on demand; `ssr {}` stays render-time |
|
||||||
|
| Targets | This app's `/api/*` routes only |
|
||||||
|
| Execution | Decided by the enclosing mode, not a modifier |
|
||||||
|
| Request values | Declared fields, supplied at the call site |
|
||||||
|
| Type source | Route contract when available, declared types otherwise (with a warning) |
|
||||||
|
| Type enforcement | `tsc`, via assertions generated into `wrnexus.generated.api-checks.ts` |
|
||||||
|
| Failure | `error {}` converts a failure to a value; without it, the call rejects |
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
client {
|
||||||
|
api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
designation?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Called as `const users = await api.searchUsers({ name: nameFilter.trim() })`. The `api` namespace
|
||||||
|
joins those already in client scope (`server`, `output`, `props`, `refs`), so it reads the same way
|
||||||
|
as `server.searchUsers()`.
|
||||||
|
|
||||||
|
`GET` blocks declare `parameters` rather than `body`; the compiler appends them as a query string at
|
||||||
|
call time. The path in source stays a plain literal, so `isSafeApiPath` is satisfied without
|
||||||
|
relaxing it.
|
||||||
|
|
||||||
|
### Backward compatibility
|
||||||
|
|
||||||
|
A bare body keeps meaning "this is the response block", unchanged:
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users/ssr {
|
||||||
|
return users.map((user) => user.name).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule is **bare body = legacy untyped block; sections = typed block.**
|
||||||
|
|
||||||
|
The two forms reach the payload differently, and the reason is load-bearing rather than cosmetic.
|
||||||
|
The legacy form injects the response with `with ($data ?? {})`, which is why bare `users` resolves.
|
||||||
|
**`with` is untypeable** — TypeScript cannot see through it — so a typed `response` block is
|
||||||
|
impossible in that form. Sectioned blocks therefore bind the payload to `data`, specifically so
|
||||||
|
`tsc` can check `data.users` against the route's contract.
|
||||||
|
|
||||||
|
### Why `headers` is excluded
|
||||||
|
|
||||||
|
Own-route calls are same-origin, so cookies are already attached; `content-type` and `accept` follow
|
||||||
|
from whether the block has a body; and CSRF is attached by the runtime (below). What remains for an
|
||||||
|
author to set is mostly credentials, which do not belong in page source. Excluded from v1 pending a
|
||||||
|
concrete case.
|
||||||
|
|
||||||
|
## Type safety
|
||||||
|
|
||||||
|
### The constraint that shapes this
|
||||||
|
|
||||||
|
`examples/basic-app/tsconfig.json` uses `include: ["app"]` and excludes `.wrnexus-*`, and
|
||||||
|
`wrnexus build` never invokes `tsc`. **Generated build artifacts are not type-checked.** Compiling
|
||||||
|
the block into a typed client and expecting `tsc` to catch mismatches would therefore check nothing.
|
||||||
|
|
||||||
|
What _is_ type-checked is application source under `app/`. Enforcement goes there.
|
||||||
|
|
||||||
|
**Corrected 2026-08-19, during implementation.** This section originally placed the assertions in
|
||||||
|
`app/types/wrnexus.generated.d.ts`. That is inert: the root `tsconfig.json` sets
|
||||||
|
`skipLibCheck: true`, which exempts the _contents_ of every `.d.ts`, so an assertion written there
|
||||||
|
can never raise a `tsc` error. Proven by forcing `skipLibCheck: false`, under which the same
|
||||||
|
assertion fires as `TS2344`. The reasoning was right and the file was wrong. Per-block assertions
|
||||||
|
are emitted into a real `.ts` file instead — `app/types/wrnexus.generated.api-checks.ts` — which
|
||||||
|
`skipLibCheck` does not exempt and which `include: ["app"]` compiles. The helper types stay in the
|
||||||
|
`.d.ts`, where being declarations is correct.
|
||||||
|
|
||||||
|
### Three pieces
|
||||||
|
|
||||||
|
**1. Helper types**, extending what the generator already emits (`ApiRoute`, `ApiContracts`,
|
||||||
|
`ApiContract`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AssertAssignable<Actual, Expected> = Actual extends Expected ? true : never;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Per-block assertions**, generated into `app/types/wrnexus.generated.api-checks.ts`. `wrnexus generate types` already parses
|
||||||
|
`.wrn` sources to build the route list, so it can read each block's declared fields and emit:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type __wrn_check_searchUsers = AssertAssignable<
|
||||||
|
{ name?: string; age?: number },
|
||||||
|
ApiInput<"/api/users", "POST">
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what makes the safety real. It sits in a file the project's own `tsc` already compiles, so
|
||||||
|
`bun run typecheck` fails when a block sends a field the endpoint rejects. No bespoke type
|
||||||
|
comparison inside the WRNexus compiler, and no need to type-check build output. The language server
|
||||||
|
already runs TypeScript diagnostics on `.wrn` documents, so the same error appears inline.
|
||||||
|
|
||||||
|
**3. A generic runtime**, `callApi(path, method, input)`, typed by those contracts, so the fetch,
|
||||||
|
JSON handling, and failure branch live in one tested place instead of being re-emitted per block.
|
||||||
|
|
||||||
|
### Routes without a contract
|
||||||
|
|
||||||
|
A plain handler returning `Response.json` has no `defineEndpoint` contract, so `ApiInput` resolves
|
||||||
|
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
|
||||||
|
the route. Untyped endpoints stay visible rather than silently passing.
|
||||||
|
|
||||||
|
### GET parameters travel as strings
|
||||||
|
|
||||||
|
A `GET` block's `parameters` become a query string (see Request assembly below), and every
|
||||||
|
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
|
||||||
|
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
|
||||||
|
only because the endpoint's own schema coerces it back: `checkField` in
|
||||||
|
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
|
||||||
|
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
|
||||||
|
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
|
||||||
|
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
|
||||||
|
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
|
||||||
|
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
|
||||||
|
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
|
||||||
|
route also has no contract for the generator to check against, so it already falls under "Routes
|
||||||
|
without a contract" above and is flagged there.
|
||||||
|
|
||||||
|
### Staleness
|
||||||
|
|
||||||
|
Checking is only as current as the generated file, so this stays wired into the existing
|
||||||
|
`check:generated-types` gate, which already verifies those artifacts match their sources.
|
||||||
|
|
||||||
|
## Compilation and runtime
|
||||||
|
|
||||||
|
### Client mode
|
||||||
|
|
||||||
|
Each `client { api name ... }` becomes an entry on an `api` namespace in the generated browser
|
||||||
|
module, beside the existing `__wrnexusClientFunctions`, with `const api = context.api` added to
|
||||||
|
client scope exactly as `server` is today.
|
||||||
|
|
||||||
|
Declared field types are **type-only**. The generator uses them for the `.d.ts` assertions and
|
||||||
|
codegen drops them before emit. A client function body that carried TypeScript into a `.mjs`
|
||||||
|
artifact is a bug this repository has already shipped once (fixed 2026-08-19, `55fed217`); the same
|
||||||
|
discipline applies here.
|
||||||
|
|
||||||
|
`setupCsrFetch` is untouched. Callable blocks are a separate mechanism, so the existing render-time
|
||||||
|
binding needs no rework.
|
||||||
|
|
||||||
|
### Request assembly
|
||||||
|
|
||||||
|
`callApi` builds the request:
|
||||||
|
|
||||||
|
- **GET** — declared `parameters` become a query string; `undefined` fields are omitted, which
|
||||||
|
removes the `if (filter.trim())` ladder authors write by hand.
|
||||||
|
- **Everything else** — a JSON body with `content-type: application/json`.
|
||||||
|
- Always `credentials: "same-origin"` and `accept: application/json`.
|
||||||
|
- **Non-GET requests attach `x-csrf-token`**, read from the `wrn-csrf` cookie or the
|
||||||
|
`wrnexus-csrf` meta tag, reusing the logic already at `packages/csr/src/reactive-runtime.ts:4221`
|
||||||
|
for RPC. Hand-written `fetch` calls in application code generally omit this, so it is a
|
||||||
|
correctness gain rather than only less typing.
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
**`error {}` converts a failure into a value; without it, the call rejects.**
|
||||||
|
|
||||||
|
- 2xx — the JSON is parsed and bound as `data`, `response {}` runs, and its return value is the
|
||||||
|
call's result. With no `response` block, `data` is returned unchanged.
|
||||||
|
- Non-2xx, network failure, or an unparseable body — `error {}` runs with `status`, `message`, and
|
||||||
|
`data` in scope. `return []` yields an empty list and no exception.
|
||||||
|
- No `error {}` block — the promise rejects, so `try/catch` at the call site keeps working.
|
||||||
|
|
||||||
|
A block must never quietly return `undefined` on failure. Success-shaped failure is the defect class
|
||||||
|
this design is most concerned with, so the absence of an `error` block means throw, never swallow.
|
||||||
|
|
||||||
|
### The SSR boundary
|
||||||
|
|
||||||
|
`ssr {}` blocks accept `response {}` and `error {}`, but **not** `request {}`. There is no caller at
|
||||||
|
render time to supply arguments, and inferring an implicit source — page state, query parameters —
|
||||||
|
would be a guess. Parameterised requests are a client-mode feature; parameterised server-side
|
||||||
|
fetching stays with `server function`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Parser (`packages/syntax/test/`)
|
||||||
|
|
||||||
|
- A sectioned block parses into `request`/`response`/`error` parts.
|
||||||
|
- A bare body still parses as the response block (the backward-compatibility guarantee).
|
||||||
|
- `request` inside an `ssr {}` block is a parse error naming the restriction.
|
||||||
|
- A malformed section reports the offending offset rather than failing later in codegen.
|
||||||
|
|
||||||
|
### Type generation (`packages/cli/test/` or the types generator's suite)
|
||||||
|
|
||||||
|
- A block targeting a `defineEndpoint` route emits an assertion referencing that contract.
|
||||||
|
- A field the endpoint does not accept makes `bun run typecheck` fail — asserted by running `tsc`
|
||||||
|
over a fixture, not by string-matching the generated file.
|
||||||
|
- A block targeting a contract-less route emits the warning and falls back to declared types.
|
||||||
|
- `check:generated-types` still passes with blocks present.
|
||||||
|
|
||||||
|
### Codegen (`packages/compiler/test/`)
|
||||||
|
|
||||||
|
- A client-mode block emits an `api` namespace entry and valid JavaScript — no TypeScript survives
|
||||||
|
into the browser module (the guard for the `55fed217` defect class).
|
||||||
|
- Declared types do not appear in the emitted module.
|
||||||
|
- An `ssr` block's output is unchanged from today for a bare body.
|
||||||
|
|
||||||
|
### Runtime (`packages/csr/test/`)
|
||||||
|
|
||||||
|
- GET omits `undefined` parameters and includes the rest.
|
||||||
|
- Non-GET attaches `x-csrf-token` from cookie and from meta.
|
||||||
|
- 2xx runs `response`; its return value is the result.
|
||||||
|
- Non-2xx runs `error`; its return value is the result.
|
||||||
|
- With no `error` block, a non-2xx rejects rather than resolving to `undefined`.
|
||||||
|
|
||||||
|
### End to end (`examples/basic-app`)
|
||||||
|
|
||||||
|
A page calling a typed block against a real route, driven in a browser: the request carries the
|
||||||
|
declared fields, the response block's value reaches page state, and a deliberately failing call
|
||||||
|
takes the `error` path. Tests that pass while the feature does not work have been a recurring
|
||||||
|
failure in this repository, so browser verification is part of the definition of done.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- External and third-party API targets, with the allowlist and credential handling they require.
|
||||||
|
- Author-settable request headers.
|
||||||
|
- Re-runnable `ssr` bindings — `setupCsrFetch`'s fetch-once guard stays as it is.
|
||||||
|
- Parameterised server-render fetching.
|
||||||
|
- Response caching and request de-duplication.
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.8
|
||||||
|
|
||||||
|
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||||
|
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||||
|
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||||
|
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||||
|
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||||
|
stale asynchronous edits, and duplicate client-side suggestions.
|
||||||
|
|
||||||
## 0.8.3
|
## 0.8.3
|
||||||
|
|
||||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||||
@@ -16,8 +25,8 @@
|
|||||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||||
diagnostics in packaged extension environments.
|
diagnostics in packaged extension environments.
|
||||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||||
consistently in the repository and extension development environment.
|
consistently in the repository and extension development environment.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,11 @@
|
|||||||
"maximum": 240,
|
"maximum": 240,
|
||||||
"scope": "resource",
|
"scope": "resource",
|
||||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||||
|
},
|
||||||
|
"wrnexus.html.autoClosingTags": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,9 @@
|
|||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.wrn": "wrn"
|
"*.wrn": "wrn"
|
||||||
},
|
},
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"wrn": "html"
|
||||||
|
},
|
||||||
"[wrn]": {
|
"[wrn]": {
|
||||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
@@ -276,13 +284,14 @@
|
|||||||
"check": "bun run build && bun run test && bun run validate",
|
"check": "bun run build && bun run test && bun run validate",
|
||||||
"vscode:prepublish": "bun run check",
|
"vscode:prepublish": "bun run check",
|
||||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
"publish": "vsce publish --no-dependencies",
|
||||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/vsce": "^3.9.2"
|
"@vscode/vsce": "^3.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-languageclient": "^10.1.0"
|
"vscode-languageclient": "^10.1.0",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const vscode = require("vscode");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-close tags as they are typed.
|
||||||
|
*
|
||||||
|
* LSP has no request for this, so the client watches document changes and asks
|
||||||
|
* the server whether the tag should close. The server owns the decision because
|
||||||
|
* void elements and already-closed tags must not be closed.
|
||||||
|
*/
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn") return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||||
|
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length) return;
|
||||||
|
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/") return;
|
||||||
|
// Every cursor must have typed the same trigger. A replaced selection
|
||||||
|
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Positions come from the editor's selections, not from the changes.
|
||||||
|
*
|
||||||
|
* A change's `range` is in coordinates from before the whole event, so with
|
||||||
|
* several cursors on one line every range after the first is short by the
|
||||||
|
* insertions preceding it. The selections have already been adjusted for
|
||||||
|
* the edit, so they are where the carets actually are.
|
||||||
|
*/
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length) return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||||
|
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(
|
||||||
|
positions.map((position) =>
|
||||||
|
client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||||
|
/*
|
||||||
|
* One insertSnippet call carries one snippet, and it is the only form that
|
||||||
|
* keeps every caret: inserting sequentially would collapse the selection to
|
||||||
|
* the first snippet and invalidate the remaining positions. Cursors that
|
||||||
|
* want different closing tags are therefore declined rather than
|
||||||
|
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||||
|
* this is for, produces one snippet for all of them.
|
||||||
|
*/
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||||
|
|
||||||
|
// The user may have kept typing during the round-trip; re-validate everything the
|
||||||
|
// insertion depends on before touching the document, since a stale offset would
|
||||||
|
// silently corrupt it.
|
||||||
|
if (vscode.window.activeTextEditor !== editor) return;
|
||||||
|
if (editor.document !== event.document) return;
|
||||||
|
if (editor.document.version !== documentVersion) return;
|
||||||
|
if (editor.selections.length !== positions.length) return;
|
||||||
|
if (
|
||||||
|
!editor.selections.every(
|
||||||
|
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAutoCloseTags };
|
||||||
+445
-47
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
|
// WRN editor compiler source hash: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -285,6 +285,34 @@ function analyzeRuntimeRequirements(ast, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/compiler/src/browser-transpile.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Strip TypeScript from a generated browser module.
|
||||||
|
*
|
||||||
|
* A client function's body is emitted verbatim, so anything TypeScript-only
|
||||||
|
* inside one -- an annotated local, an `as` cast, a local interface -- reaches
|
||||||
|
* the browser module as TypeScript source. Codegen removes the types from the
|
||||||
|
* function's *signature*, which is what made this easy to miss: the emitted
|
||||||
|
* module looked transpiled, and only bodies carried types through.
|
||||||
|
*
|
||||||
|
* The artifact is written as `.mjs` and read back as plain JavaScript, so the
|
||||||
|
* failure surfaced as a syntax error pointing at generated code rather than at
|
||||||
|
* the `.wrn` line responsible.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.stripBrowserTypes = stripBrowserTypes;
|
||||||
|
let transpiler = null;
|
||||||
|
function stripBrowserTypes(code) {
|
||||||
|
const bun = globalThis.Bun;
|
||||||
|
if (!bun?.Transpiler) {
|
||||||
|
throw new Error("WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.");
|
||||||
|
}
|
||||||
|
transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" });
|
||||||
|
return transpiler.transformSync(code);
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/compiler/src/cache.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -483,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"payload",
|
||||||
]);
|
]);
|
||||||
@@ -683,6 +712,27 @@ function _functionEntry(ast, fn, availableFunctions) {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Client-mode api blocks become members of an `api` object in client scope.
|
||||||
|
*
|
||||||
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
|
*/
|
||||||
|
function apiBindings(ast) {
|
||||||
|
const members = ast.dataApis
|
||||||
|
.filter((block) => block.mode === "client" && block.sections)
|
||||||
|
.map((block) => {
|
||||||
|
const sections = block.sections;
|
||||||
|
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
|
||||||
|
const error = (0, syntax_1.eraseFunctionTypes)(sections.error).trim();
|
||||||
|
const failure = error
|
||||||
|
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||||
|
: `(error) => { throw error; }`;
|
||||||
|
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
|
||||||
|
});
|
||||||
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||||
|
}
|
||||||
function generateBrowserModule(ast) {
|
function generateBrowserModule(ast) {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||||
const functionNames = functions.map((fn) => fn.name);
|
const functionNames = functions.map((fn) => fn.name);
|
||||||
@@ -690,10 +740,19 @@ function generateBrowserModule(ast) {
|
|||||||
const selectedImports = selectedBrowserImports(ast, functions);
|
const selectedImports = selectedBrowserImports(ast, functions);
|
||||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||||
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
|
// `api` is only defined as a client-scope binding when the page actually has
|
||||||
|
// client-mode api blocks (see apiBindings below). A page that declares
|
||||||
|
// `state api` without any client api blocks must keep reading/writing that
|
||||||
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
|
// there is a real `api` binding to shadow it.
|
||||||
|
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||||
|
const localRuntimeBindings = hasClientApi
|
||||||
|
? RUNTIME_BINDINGS
|
||||||
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||||
|
const sharedState = state.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name));
|
||||||
const sharedProps = ast.props
|
const sharedProps = ast.props
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name));
|
.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name));
|
||||||
const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
|
const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
|
||||||
!RUNTIME_BINDINGS.has(name) &&
|
!RUNTIME_BINDINGS.has(name) &&
|
||||||
!sharedState.includes(name) &&
|
!sharedState.includes(name) &&
|
||||||
@@ -730,6 +789,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
@@ -1261,7 +1321,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition = node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
const inner = node.children
|
const inner = node.children
|
||||||
@@ -1447,6 +1521,7 @@ function renderBinding(binding) {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function hasClientBehavior(nodes) {
|
function hasClientBehavior(nodes) {
|
||||||
@@ -1503,18 +1578,28 @@ function apiBindingMap(ast, sharedHelpers) {
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
function ssrRuntimeSource() {
|
function ssrRuntimeSource() {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -1533,6 +1618,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
|
|||||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -1562,18 +1659,53 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -2004,13 +2136,16 @@ function generateInner(ast) {
|
|||||||
continue;
|
continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
||||||
continue;
|
continue;
|
||||||
loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`);
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
|
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
${storeDeclarations}
|
${storeDeclarations}
|
||||||
@@ -2425,6 +2560,76 @@ function compileAttrValue(raw, ctx) {
|
|||||||
}
|
}
|
||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes) {
|
||||||
|
const render = (node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}))))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean)
|
||||||
|
return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(JSON.stringify([attribute.name.slice("class:".length), expression]))}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([name, attribute.value]))}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static")
|
||||||
|
return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||||
|
return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
function encodeClientControl(value) {
|
||||||
|
return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
function renderComponentIfNode(node, ctx) {
|
function renderComponentIfNode(node, ctx) {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||||
@@ -2436,7 +2641,11 @@ function renderComponentIfNode(node, ctx) {
|
|||||||
? bodyExpression
|
? bodyExpression
|
||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})));
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function renderComponentEachNode(node, ctx) {
|
function renderComponentEachNode(node, ctx) {
|
||||||
const item = node.item;
|
const item = node.item;
|
||||||
@@ -2448,7 +2657,7 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
};
|
};
|
||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
return ("${(() => { const __wl = Array.isArray(" +
|
const serverBody = "${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
list +
|
list +
|
||||||
@@ -2460,7 +2669,16 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}");
|
"`; })()}";
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function serverLoopLocalsAttribute(ctx) {
|
function serverLoopLocalsAttribute(ctx) {
|
||||||
const locals = [...(ctx.serverLocals ?? [])];
|
const locals = [...(ctx.serverLocals ?? [])];
|
||||||
@@ -3242,7 +3460,7 @@ function resolveWrnImports(declarations, importer, options) {
|
|||||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.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.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.stripBrowserTypes = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||||
exports.compileNativeWrnFile = compileNativeWrnFile;
|
exports.compileNativeWrnFile = compileNativeWrnFile;
|
||||||
exports.compileWrnFile = compileWrnFile;
|
exports.compileWrnFile = compileWrnFile;
|
||||||
exports.compile = compile;
|
exports.compile = compile;
|
||||||
@@ -3264,6 +3482,8 @@ var targets_ts_1 = require("./targets.js");
|
|||||||
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
Object.defineProperty(exports, "generateTargets", { enumerable: true, get: function () { return targets_ts_1.generateTargets; } });
|
||||||
var client_codegen_ts_1 = require("./client-codegen.js");
|
var client_codegen_ts_1 = require("./client-codegen.js");
|
||||||
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
Object.defineProperty(exports, "generateBrowserModule", { enumerable: true, get: function () { return client_codegen_ts_1.generateBrowserModule; } });
|
||||||
|
var browser_transpile_ts_1 = require("./browser-transpile.js");
|
||||||
|
Object.defineProperty(exports, "stripBrowserTypes", { enumerable: true, get: function () { return browser_transpile_ts_1.stripBrowserTypes; } });
|
||||||
var server_codegen_ts_1 = require("./server-codegen.js");
|
var server_codegen_ts_1 = require("./server-codegen.js");
|
||||||
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
Object.defineProperty(exports, "generateServerFunctionsModule", { enumerable: true, get: function () { return server_codegen_ts_1.generateServerFunctionsModule; } });
|
||||||
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
Object.defineProperty(exports, "rpcManifest", { enumerable: true, get: function () { return server_codegen_ts_1.rpcManifest; } });
|
||||||
@@ -4500,6 +4720,151 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||||
__exportStar(require("@wrnexus/syntax/types"), exports);
|
__exportStar(require("@wrnexus/syntax/types"), exports);
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.parseApiSections = parseApiSections;
|
||||||
|
exports.hasRequestSection = hasRequestSection;
|
||||||
|
/**
|
||||||
|
* Parse the sectioned form of an `api` block body.
|
||||||
|
*
|
||||||
|
* Returns null when no section keyword is present, which is how the legacy
|
||||||
|
* bare-body form stays valid: the caller keeps treating the body as the
|
||||||
|
* response expression.
|
||||||
|
*
|
||||||
|
* Detection and slicing both drive the tokenizer's own string/comment-aware
|
||||||
|
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
|
||||||
|
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
|
||||||
|
* mentioned in a comment can't be mistaken for a real section.
|
||||||
|
*/
|
||||||
|
const tokenizer_ts_1 = require("./tokenizer.js");
|
||||||
|
const SECTION_NAMES = ["request", "response", "error"];
|
||||||
|
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
|
||||||
|
/**
|
||||||
|
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
|
||||||
|
* one of `names`. Strings, template literals, and comments are skipped via
|
||||||
|
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
|
||||||
|
* keyword mentioned inside a string or comment, or nested inside an unrelated
|
||||||
|
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
|
||||||
|
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
|
||||||
|
* not a reimplementation of it.
|
||||||
|
*/
|
||||||
|
function scanTopLevelBlocks(source, names) {
|
||||||
|
const found = new Map();
|
||||||
|
const lx = new tokenizer_ts_1.Lexer(source);
|
||||||
|
let depth = 0;
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
while (i < source.length) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === "\n") {
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
|
atLineStart = false;
|
||||||
|
if (depth === 0 && (0, tokenizer_ts_1.isIdentStart)(c)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < source.length && (0, tokenizer_ts_1.isIdentPart)(source[j]))
|
||||||
|
j++;
|
||||||
|
const word = source.slice(i, j);
|
||||||
|
// Skip trivia between the identifier and a possible '{' without
|
||||||
|
// treating anything in between as significant yet.
|
||||||
|
let k = j;
|
||||||
|
let lineStartAtK = false;
|
||||||
|
while (k < source.length) {
|
||||||
|
const kc = source[k];
|
||||||
|
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kc === "\n") {
|
||||||
|
lineStartAtK = true;
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kSkipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, k, lineStartAtK);
|
||||||
|
if (kSkipped !== null) {
|
||||||
|
k = kSkipped;
|
||||||
|
lineStartAtK = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (names.includes(word) && source[k] === "{") {
|
||||||
|
lx.pos = k;
|
||||||
|
const start = k + 1;
|
||||||
|
const text = lx.readBalancedBraces();
|
||||||
|
if (!found.has(word))
|
||||||
|
found.set(word, { text, start });
|
||||||
|
i = lx.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "{")
|
||||||
|
depth++;
|
||||||
|
else if (c === "}")
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
/** Rebase a span captured from `outer.text` back onto the original source. */
|
||||||
|
function absolutize(span, outer) {
|
||||||
|
if (!span)
|
||||||
|
return undefined;
|
||||||
|
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||||
|
}
|
||||||
|
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||||
|
function parseFields(span) {
|
||||||
|
if (!span)
|
||||||
|
return [];
|
||||||
|
const fields = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const rawLine of span.text.split("\n")) {
|
||||||
|
const lineOffset = span.start + cursor;
|
||||||
|
cursor += rawLine.length + 1;
|
||||||
|
const line = rawLine.trim().replace(/,$/, "");
|
||||||
|
if (!line || line.startsWith("//"))
|
||||||
|
continue;
|
||||||
|
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
throw new tokenizer_ts_1.LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`);
|
||||||
|
}
|
||||||
|
fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
function parseApiSections(source) {
|
||||||
|
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||||
|
if (top.size === 0)
|
||||||
|
return null;
|
||||||
|
const request = top.get("request");
|
||||||
|
const sub = request
|
||||||
|
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
|
||||||
|
: new Map();
|
||||||
|
return {
|
||||||
|
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||||
|
body: parseFields(absolutize(sub.get("body"), request)),
|
||||||
|
response: top.get("response")?.text ?? "",
|
||||||
|
error: top.get("error")?.text ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** True when the block declares a `request` section. */
|
||||||
|
function hasRequestSection(source) {
|
||||||
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -5848,6 +6213,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0;
|
|||||||
exports.parse = parse;
|
exports.parse = parse;
|
||||||
exports.parseHtmlView = parseHtmlView;
|
exports.parseHtmlView = parseHtmlView;
|
||||||
const spec_ts_1 = require("./spec.js");
|
const spec_ts_1 = require("./spec.js");
|
||||||
|
const api_sections_ts_1 = require("./api-sections.js");
|
||||||
/**
|
/**
|
||||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
*
|
*
|
||||||
@@ -6320,7 +6686,18 @@ function parse(source) {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name, method, path, body });
|
const sections = (0, api_sections_ts_1.parseApiSections)(body);
|
||||||
|
if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) {
|
||||||
|
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
|
||||||
|
}
|
||||||
|
dataApis.push({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: sections ? "" : body,
|
||||||
|
...(sections ? { sections } : {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
@@ -6955,13 +7332,55 @@ exports.WRN_DIAGNOSTIC_CODES = {
|
|||||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.Lexer = exports.LexError = void 0;
|
exports.Lexer = exports.isIdentPart = exports.isIdentStart = exports.LexError = void 0;
|
||||||
|
exports.skipLiteralOrComment = skipLiteralOrComment;
|
||||||
class LexError extends Error {
|
class LexError extends Error {
|
||||||
}
|
}
|
||||||
exports.LexError = LexError;
|
exports.LexError = LexError;
|
||||||
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||||
|
exports.isIdentStart = isIdentStart;
|
||||||
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
||||||
|
exports.isIdentPart = isIdentPart;
|
||||||
|
/**
|
||||||
|
* Skip over a string/template literal or comment starting at `src[i]`, using
|
||||||
|
* the exact rules `readBalancedBraces` needs to stay comment- and
|
||||||
|
* string-aware: `/* block *\/` comments anywhere, `//` line comments only at
|
||||||
|
* the start of a line (so a bare `https://…` in view text isn't mistaken for
|
||||||
|
* one), and `"`, `'`, `` ` `` strings with backslash escapes.
|
||||||
|
*
|
||||||
|
* Returns the index just past what it skipped, or `null` when `src[i]` isn't
|
||||||
|
* the start of one of those. Exported so any other raw-body scanner that
|
||||||
|
* needs to walk `.wrn` source without tripping over strings or comments
|
||||||
|
* (e.g. the `api` section scanner) shares this logic instead of
|
||||||
|
* reimplementing it — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces.
|
||||||
|
*/
|
||||||
|
function skipLiteralOrComment(src, i, atLineStart) {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === "/" && src[i + 1] === "*") {
|
||||||
|
const close = src.indexOf("*/", i + 2);
|
||||||
|
return close === -1 ? src.length : close + 2;
|
||||||
|
}
|
||||||
|
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||||
|
const newline = src.indexOf("\n", i + 2);
|
||||||
|
return newline === -1 ? src.length : newline;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length) {
|
||||||
|
if (src[j] === "\\") {
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (src[j] === c)
|
||||||
|
return j + 1;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return src.length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
class Lexer {
|
class Lexer {
|
||||||
src;
|
src;
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -7030,9 +7449,9 @@ class Lexer {
|
|||||||
case "'":
|
case "'":
|
||||||
return this.readString(c, pos);
|
return this.readString(c, pos);
|
||||||
}
|
}
|
||||||
if (isIdentStart(c)) {
|
if ((0, exports.isIdentStart)(c)) {
|
||||||
let v = "";
|
let v = "";
|
||||||
while (this.pos < src.length && isIdentPart(src[this.pos]))
|
while (this.pos < src.length && (0, exports.isIdentPart)(src[this.pos]))
|
||||||
v += src[this.pos++];
|
v += src[this.pos++];
|
||||||
return { type: "ident", value: v, pos };
|
return { type: "ident", value: v, pos };
|
||||||
}
|
}
|
||||||
@@ -7252,45 +7671,23 @@ class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str = null;
|
|
||||||
/** True while only whitespace has been seen since the last newline. */
|
/** True while only whitespace has been seen since the last newline. */
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (; i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i];
|
const c = src[i];
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str)
|
|
||||||
str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "\n") {
|
if (c === "\n") {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1)
|
i = skipped;
|
||||||
break; // unterminated: fall through to the error
|
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
|
||||||
const newline = src.indexOf("\n", i + 2);
|
|
||||||
if (newline === -1)
|
|
||||||
break;
|
|
||||||
i = newline - 1; // let the loop's own increment land on the newline
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c !== " " && c !== "\t" && c !== "\r")
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{")
|
if (c === "{")
|
||||||
depth++;
|
depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
@@ -7300,6 +7697,7 @@ class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
|
|||||||
return depth > 0;
|
return depth > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an offset sits inside a `view { }` block.
|
||||||
|
*
|
||||||
|
* The language server owns completion there and returns a merged list, so this
|
||||||
|
* provider stands down to avoid VS Code concatenating two independent lists.
|
||||||
|
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
|
||||||
|
* string that never closes.
|
||||||
|
*/
|
||||||
|
function isInsideViewBlock(text, offset) {
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote = null;
|
||||||
|
let index = start;
|
||||||
|
for (; index < text.length && depth > 0; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) quote = char;
|
||||||
|
else if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") depth -= 1;
|
||||||
|
}
|
||||||
|
if (offset >= start && offset <= index) return true;
|
||||||
|
pattern.lastIndex = index;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isAfterWatchKeyword(document, position) {
|
function isAfterWatchKeyword(document, position) {
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
|
||||||
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function provideCompletionItems(document, position) {
|
function provideCompletionItems(document, position) {
|
||||||
|
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
@@ -707,6 +744,7 @@ module.exports = {
|
|||||||
extractProps,
|
extractProps,
|
||||||
extractRouteParams,
|
extractRouteParams,
|
||||||
extractStates,
|
extractStates,
|
||||||
|
isInsideViewBlock,
|
||||||
provideCompletionItems,
|
provideCompletionItems,
|
||||||
registerCompletionProvider,
|
registerCompletionProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
// WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// editors/vscode/src/auto-close-tags.js
|
||||||
|
var require_auto_close_tags = __commonJS((exports2, module2) => {
|
||||||
|
var vscode = require("vscode");
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn")
|
||||||
|
return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
|
||||||
|
return;
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length)
|
||||||
|
return;
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/")
|
||||||
|
return;
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
|
||||||
|
return;
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty))
|
||||||
|
return;
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character }
|
||||||
|
})));
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
|
||||||
|
return;
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0]))
|
||||||
|
return;
|
||||||
|
if (vscode.window.activeTextEditor !== editor)
|
||||||
|
return;
|
||||||
|
if (editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
if (editor.document.version !== documentVersion)
|
||||||
|
return;
|
||||||
|
if (editor.selections.length !== positions.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
module2.exports = { registerAutoCloseTags };
|
||||||
|
});
|
||||||
|
|
||||||
// editors/vscode/src/extension.js
|
// editors/vscode/src/extension.js
|
||||||
var path = require("node:path");
|
var path = require("node:path");
|
||||||
var vscode = require("vscode");
|
var vscode = require("vscode");
|
||||||
var { LanguageClient, TransportKind } = require_main5();
|
var { LanguageClient, TransportKind } = require_main5();
|
||||||
|
var { registerAutoCloseTags } = require_auto_close_tags();
|
||||||
var WRN_LANGUAGE_ID = "wrn";
|
var WRN_LANGUAGE_ID = "wrn";
|
||||||
var client;
|
var client;
|
||||||
async function recoverWrnLanguage(document) {
|
async function recoverWrnLanguage(document) {
|
||||||
@@ -22730,6 +22783,7 @@ async function activate(context) {
|
|||||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
const running = client;
|
const running = client;
|
||||||
@@ -22737,4 +22791,4 @@ async function deactivate() {
|
|||||||
if (running)
|
if (running)
|
||||||
await running.stop();
|
await running.stop();
|
||||||
}
|
}
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vscode = require("vscode");
|
const vscode = require("vscode");
|
||||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||||
|
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||||
|
|
||||||
const WRN_LANGUAGE_ID = "wrn";
|
const WRN_LANGUAGE_ID = "wrn";
|
||||||
/** @type {LanguageClient | undefined} */
|
/** @type {LanguageClient | undefined} */
|
||||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
|||||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||||
);
|
);
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
|||||||
if (running) await running.stop();
|
if (running) await running.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
+22675
-34
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
translate(lineDelta, characterDelta) {
|
||||||
|
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||||
|
}
|
||||||
|
isEqual(other) {
|
||||||
|
return this.line === other.line && this.character === other.character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Selection {
|
||||||
|
constructor(active) {
|
||||||
|
this.active = active;
|
||||||
|
this.anchor = active;
|
||||||
|
this.isEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnippetString {
|
||||||
|
constructor(value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changeListener = null;
|
||||||
|
const host = {
|
||||||
|
Position,
|
||||||
|
Selection,
|
||||||
|
SnippetString,
|
||||||
|
workspace: {
|
||||||
|
onDidChangeTextDocument(listener) {
|
||||||
|
changeListener = listener;
|
||||||
|
return { dispose() {} };
|
||||||
|
},
|
||||||
|
getConfiguration() {
|
||||||
|
return { get: (_key, fallback) => fallback };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { activeTextEditor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost(host);
|
||||||
|
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the handler the way VS Code does: the document has already been
|
||||||
|
* updated and the carets moved by the time the change event fires.
|
||||||
|
*/
|
||||||
|
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||||
|
const inserted = [];
|
||||||
|
const asked = [];
|
||||||
|
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||||
|
const editor = {
|
||||||
|
document,
|
||||||
|
selections: carets.map((caret) => new Selection(caret)),
|
||||||
|
insertSnippet(snippet, positions) {
|
||||||
|
inserted.push({ value: snippet.value, positions });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
editor.selection = editor.selections[0];
|
||||||
|
host.window.activeTextEditor = editor;
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
sendRequest(_method, params) {
|
||||||
|
asked.push(params.position);
|
||||||
|
return Promise.resolve(snippetFor(params.position));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inserted,
|
||||||
|
asked,
|
||||||
|
fire: () =>
|
||||||
|
changeListener({
|
||||||
|
document,
|
||||||
|
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||||
|
contentChanges: carets.map(() => ({
|
||||||
|
text: typed,
|
||||||
|
rangeLength: 0,
|
||||||
|
range: { start: new Position(0, 0) },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("closes the tag at a single caret", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.equal(run.inserted[0].value, "$0</div>");
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[[1, 8]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the tag at every caret in one insertion", async () => {
|
||||||
|
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||||
|
// sequentially would collapse the selection to the first snippet.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||||
|
snippetFor: () => "$0</div>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.asked.length, 3);
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[1, 8],
|
||||||
|
[2, 8],
|
||||||
|
[3, 8],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||||
|
// Every contentChange above reports (0, 0). Using those would query and
|
||||||
|
// insert at the wrong offsets once more than one caret is on a line.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(4, 12), new Position(9, 3)],
|
||||||
|
snippetFor: () => "$0</p>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
run.asked.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[4, 12],
|
||||||
|
[9, 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when the carets want different closing tags", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when any caret has no tag to close", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a replaced selection", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await changeListener({
|
||||||
|
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||||
|
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost({
|
||||||
|
Position: class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Range: class Range {
|
||||||
|
constructor(start, end) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItem: class CompletionItem {
|
||||||
|
constructor(label, kind) {
|
||||||
|
this.label = label;
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItemKind: {
|
||||||
|
Event: 23,
|
||||||
|
Property: 10,
|
||||||
|
Function: 12,
|
||||||
|
Keyword: 14,
|
||||||
|
Variable: 13,
|
||||||
|
},
|
||||||
|
SnippetString: class SnippetString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MarkdownString: class MarkdownString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div>hello</div>
|
||||||
|
}
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a markup offset is inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a functions-block offset is not inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("<div");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: "<div>hello</div>" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 2, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert.equal(result.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("function go");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: " function go() {}" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 5, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||||
|
});
|
||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
// These extraction helpers are pure, but their module also registers VS Code
|
// These extraction helpers are pure, but their module also registers VS Code
|
||||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({});
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") return {};
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
test("extracts dynamic route params from filename", () => {
|
test("extracts dynamic route params from filename", () => {
|
||||||
const document = {
|
const document = {
|
||||||
|
|||||||
@@ -2,30 +2,24 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({
|
||||||
Module._load = function load(request, parent, isMain) {
|
Diagnostic: class Diagnostic {
|
||||||
if (request === "vscode") {
|
constructor(range, message, severity) {
|
||||||
return {
|
this.range = range;
|
||||||
Diagnostic: class Diagnostic {
|
this.message = message;
|
||||||
constructor(range, message, severity) {
|
this.severity = severity;
|
||||||
this.range = range;
|
}
|
||||||
this.message = message;
|
},
|
||||||
this.severity = severity;
|
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||||
}
|
Range: class Range {
|
||||||
},
|
constructor(start, end) {
|
||||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
this.start = start;
|
||||||
Range: class Range {
|
this.end = end;
|
||||||
constructor(start, end) {
|
}
|
||||||
this.start = start;
|
},
|
||||||
this.end = end;
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
findTopLevelDeclaration,
|
findTopLevelDeclaration,
|
||||||
maskLeadingTrivia,
|
maskLeadingTrivia,
|
||||||
@@ -34,7 +28,7 @@ const {
|
|||||||
validateLayoutUsage,
|
validateLayoutUsage,
|
||||||
validateRootMembers,
|
validateRootMembers,
|
||||||
} = require("../src/diagnostics");
|
} = require("../src/diagnostics");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
function mockDocument() {
|
function mockDocument() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ try {
|
|||||||
readFileSync(join(root, rel), "utf8");
|
readFileSync(join(root, rel), "utf8");
|
||||||
ok(`Marketplace document exists: ${rel}`);
|
ok(`Marketplace document exists: ${rel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||||
|
emmetLanguages?.wrn === "html"
|
||||||
|
? ok("Emmet is mapped for wrn documents")
|
||||||
|
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||||
|
|
||||||
|
const autoClose =
|
||||||
|
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||||
|
autoClose?.type === "boolean" && autoClose?.default === true
|
||||||
|
? ok("auto-closing tags setting is contributed")
|
||||||
|
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||||
|
|
||||||
|
manifest.dependencies?.["vscode-html-languageservice"]
|
||||||
|
? ok("HTML language service ships as a runtime dependency")
|
||||||
|
: bad("HTML language service ships as a runtime dependency");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bad("Marketplace metadata", e.message);
|
bad("Marketplace metadata", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||||
|
*
|
||||||
|
* These files run under `node --test` (see the package's test script), where
|
||||||
|
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||||
|
* root also picks them up by filename, and Bun resolves `require` through its
|
||||||
|
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||||
|
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||||
|
* module covers that case, leaving one shim that works under both runners.
|
||||||
|
*
|
||||||
|
* Returns a function restoring the original loader.
|
||||||
|
*/
|
||||||
|
function installVsCodeHost(stub) {
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
if (typeof Bun !== "undefined") {
|
||||||
|
require("bun").plugin({
|
||||||
|
name: "vscode-host-stub",
|
||||||
|
setup(build) {
|
||||||
|
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === "vscode") return stub;
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { installVsCodeHost };
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineEndpoint } from "@wrnexus/core";
|
||||||
|
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
|
||||||
|
|
||||||
|
interface DirectoryUser {
|
||||||
|
name: string;
|
||||||
|
designation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL: DirectoryUser[] = [
|
||||||
|
{ name: "Ajay", designation: "UI" },
|
||||||
|
{ name: "Asha", designation: "Backend" },
|
||||||
|
{ name: "Chen", designation: "UI" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-typed handler: input resolves to `{ name?: string }` from
|
||||||
|
* SearchDirectorySchema, so the block's request shape is checked for real
|
||||||
|
* (not against `unknown`, which the untyped-handler shape resolved to).
|
||||||
|
*/
|
||||||
|
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
|
||||||
|
input: SearchDirectorySchema,
|
||||||
|
description: "Case-insensitive substring search over the demo directory by name.",
|
||||||
|
handler(input) {
|
||||||
|
const needle = String(input.name ?? "").toLowerCase();
|
||||||
|
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||||
import type { Db, ExecResult } from "@wrnexus/db";
|
import type { Db, ExecResult } from "@wrnexus/db";
|
||||||
import { users } from "./schema.ts";
|
import { users } from "./schema.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
page ApiBlockDemo {
|
||||||
|
state nameFilter = "a"
|
||||||
|
state found = ""
|
||||||
|
state failed = ""
|
||||||
|
|
||||||
|
client {
|
||||||
|
api searchDirectory POST /api/directory {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function search(): Promise<void> {
|
||||||
|
const users = await api.searchDirectory({ name: nameFilter })
|
||||||
|
found = users.map((user) => user.name).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<button @click="search()">Search</button>
|
||||||
|
<p class="found" data-text="found">{found}</p>
|
||||||
|
<p class="failed" data-text="failed">{failed}</p>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
export interface Routes {
|
export interface Routes {
|
||||||
"/": Record<string, never>;
|
"/": Record<string, never>;
|
||||||
"/about": Record<string, never>;
|
"/about": Record<string, never>;
|
||||||
|
"/api-block-demo": Record<string, never>;
|
||||||
"/async-data": Record<string, never>;
|
"/async-data": Record<string, never>;
|
||||||
"/chat": Record<string, never>;
|
"/chat": Record<string, never>;
|
||||||
"/client-only": Record<string, never>;
|
"/client-only": Record<string, never>;
|
||||||
@@ -27,6 +28,7 @@ export interface Routes {
|
|||||||
export interface RouteNames {
|
export interface RouteNames {
|
||||||
"index": "/";
|
"index": "/";
|
||||||
"about": "/about";
|
"about": "/about";
|
||||||
|
"api.block.demo": "/api-block-demo";
|
||||||
"async.data": "/async-data";
|
"async.data": "/async-data";
|
||||||
"chat": "/chat";
|
"chat": "/chat";
|
||||||
"client.only": "/client-only";
|
"client.only": "/client-only";
|
||||||
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
|
|||||||
const paths: Record<RouteName, RoutePath> = {
|
const paths: Record<RouteName, RoutePath> = {
|
||||||
"index": "/",
|
"index": "/",
|
||||||
"about": "/about",
|
"about": "/about",
|
||||||
|
"api.block.demo": "/api-block-demo",
|
||||||
"async.data": "/async-data",
|
"async.data": "/async-data",
|
||||||
"chat": "/chat",
|
"chat": "/chat",
|
||||||
"client.only": "/client-only",
|
"client.only": "/client-only",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
|
||||||
|
export const SearchDirectorySchema = v.object({
|
||||||
|
name: v.string().trim().optional(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||||
|
//
|
||||||
|
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
|
||||||
|
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
|
||||||
|
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||||
|
export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
|
||||||
|
export {};
|
||||||
+13
-2
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
|||||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||||
|
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
|
||||||
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||||
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||||
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
|
|||||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||||
}
|
}
|
||||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/test": "workspace:*",
|
"@wrnexus/test": "workspace:*",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/pubsub": "0.8.6"
|
"@wrnexus/pubsub": "^0.8.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/captcha",
|
"name": "@wrnexus/captcha",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.36",
|
"version": "0.8.46",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@wrnexus/db": "workspace:*",
|
"@wrnexus/db": "workspace:*",
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
type DeploymentRuntime,
|
type DeploymentRuntime,
|
||||||
runtimeCapabilities,
|
runtimeCapabilities,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
import {
|
import {
|
||||||
loadAppConfig,
|
loadAppConfig,
|
||||||
@@ -324,7 +325,9 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(out, code, "utf8");
|
writeFileSync(out, code, "utf8");
|
||||||
writeFileSync(clientEntry, browserCode, "utf8");
|
// The entry is .mjs, so anything TypeScript left in a client function
|
||||||
|
// body would be read back as JavaScript and fail to parse.
|
||||||
|
writeFileSync(clientEntry, stripBrowserTypes(browserCode), "utf8");
|
||||||
const browserResult = await Bun.build({
|
const browserResult = await Bun.build({
|
||||||
entrypoints: [clientEntry],
|
entrypoints: [clientEntry],
|
||||||
target: "browser",
|
target: "browser",
|
||||||
@@ -620,10 +623,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
appDir,
|
appDir,
|
||||||
appRoot: root,
|
appRoot: root,
|
||||||
mode: "production",
|
mode: "production",
|
||||||
sources: [
|
// Component discovery is not style discovery. Built-in and plugin
|
||||||
...componentDirs,
|
// components own their CSS; scanning every available component makes
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
// Tailwind/Iconify generate rules for packages and components the app
|
||||||
],
|
// never renders. Packages that intentionally use app utilities opt in
|
||||||
|
// through an explicit styles.source contribution.
|
||||||
|
sources: pluginContributions.styles.flatMap((style) =>
|
||||||
|
style.source ? [style.source] : [],
|
||||||
|
),
|
||||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
config.styles,
|
config.styles,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
|||||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||||
// included in each published CLI bundle.
|
// included in each published CLI bundle.
|
||||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||||
import { parse } from "@wrnexus/syntax";
|
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||||
import { regenerateRoutes } from "./routes.ts";
|
import { regenerateRoutes } from "./routes.ts";
|
||||||
import { loadAppConfig } from "@wrnexus/styles";
|
import { loadAppConfig } from "@wrnexus/styles";
|
||||||
@@ -99,6 +99,78 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type assertions for sectioned api blocks.
|
||||||
|
*
|
||||||
|
* Enforcement lives here rather than in the compiler because this file is under
|
||||||
|
* `app/` and is therefore compiled by the project's own tsc, while generated
|
||||||
|
* build artifacts are not type-checked at all.
|
||||||
|
*/
|
||||||
|
function pageSlug(path: string): string {
|
||||||
|
// Block names are only unique within a single page (see apiBindingMap), so
|
||||||
|
// two pages each declaring e.g. `api search` is legal and would otherwise
|
||||||
|
// emit the identical `__wrn_api_check_search` type alias twice into this
|
||||||
|
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
|
||||||
|
// name with a short deterministic hash of the page's path (not the whole
|
||||||
|
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
|
||||||
|
// to keep names unique across the whole app while staying compact.
|
||||||
|
const normalized = path.replace(/\\/g, "/");
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
|
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiBlockAssertions(
|
||||||
|
pages: { path: string; ast: PageAst }[],
|
||||||
|
apiContracts: string,
|
||||||
|
appDir: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
// Hash the path relative to `app/`, not the absolute path: the absolute
|
||||||
|
// path varies with where the project checkout lives (e.g. a CI runner's
|
||||||
|
// temp clone vs. a developer's local path), which would make this file
|
||||||
|
// spuriously "stale" every time it's regenerated somewhere else.
|
||||||
|
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
|
||||||
|
for (const block of page.ast.dataApis) {
|
||||||
|
if (!block.sections) continue;
|
||||||
|
|
||||||
|
// ssr-mode sectioned blocks can never declare a `request` (they are
|
||||||
|
// render-time only), so they always fall back to the empty-shape
|
||||||
|
// `Record<string, never>` below. `keyof Record<string, never>` is
|
||||||
|
// `string`, which makes the key-exactness arm of AssertAssignable
|
||||||
|
// evaluate to `false` unconditionally and raises TS2344 on every such
|
||||||
|
// block regardless of whether the block author did anything wrong.
|
||||||
|
// We choose to skip emission for both (a) any non-client-mode block,
|
||||||
|
// since it structurally can never have a request to check, and (b) any
|
||||||
|
// block -- client included -- that has zero declared request fields,
|
||||||
|
// since there is nothing to assert type-safety about. This is more
|
||||||
|
// honest about intent than emitting a vacuous/always-failing check.
|
||||||
|
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||||
|
if (block.mode !== "client" || fields.length === 0) continue;
|
||||||
|
|
||||||
|
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||||
|
console.warn(
|
||||||
|
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
|
||||||
|
block.path,
|
||||||
|
)}, ${JSON.stringify(block.method)}>>>;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function generateApplicationTypes(
|
export function generateApplicationTypes(
|
||||||
appRoot: string,
|
appRoot: string,
|
||||||
pluginContributions?: PluginContributions,
|
pluginContributions?: PluginContributions,
|
||||||
@@ -145,6 +217,10 @@ export function generateApplicationTypes(
|
|||||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||||
|
path: file,
|
||||||
|
ast: parse(readFileSync(file, "utf8")),
|
||||||
|
}));
|
||||||
const typeDir = join(app, "types");
|
const typeDir = join(app, "types");
|
||||||
const apiContracts = router.api
|
const apiContracts = router.api
|
||||||
.map((route) => {
|
.map((route) => {
|
||||||
@@ -218,11 +294,33 @@ declare namespace WRNexusGenerated {
|
|||||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
mkdirSync(typeDir, { recursive: true });
|
mkdirSync(typeDir, { recursive: true });
|
||||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||||
writeFileSync(output, code, "utf8");
|
writeFileSync(output, code, "utf8");
|
||||||
|
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
|
||||||
|
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
|
||||||
|
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
|
||||||
|
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||||
|
//
|
||||||
|
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
|
||||||
|
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
|
||||||
|
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||||
|
${apiBlockAssertions(pageAsts, apiContracts, app)}
|
||||||
|
export {};
|
||||||
|
`;
|
||||||
|
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||||
writePluginArtifacts(root, pluginContributions);
|
writePluginArtifacts(root, pluginContributions);
|
||||||
return {
|
return {
|
||||||
file: relative(root, output).replace(/\\/g, "/"),
|
file: relative(root, output).replace(/\\/g, "/"),
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { generateApplicationTypes } from "../src/types.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Minimal app with one typed endpoint and one page that calls it. */
|
||||||
|
function fixture(block: string): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/search.wrn"),
|
||||||
|
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(generated).toContain("type AssertAssignable<");
|
||||||
|
expect(generated).toContain('type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"]');
|
||||||
|
expect(generated).toContain(
|
||||||
|
'type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The per-block assertions live in a plain .ts file, not the .d.ts: `skipLibCheck: true`
|
||||||
|
// (set repo-wide) exempts .d.ts *contents* from being checked at all, so a `.d.ts` can
|
||||||
|
// never actually enforce anything here. A real .ts file under app/ is compiled and
|
||||||
|
// checked normally.
|
||||||
|
test("emits one assertion per sectioned block, naming its route and method", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
|
||||||
|
expect(checks).toContain("name?: string");
|
||||||
|
expect(checks).toContain("age?: number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy bare-body block produces no assertion", () => {
|
||||||
|
const root = fixture(` api legacyUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the api-checks file has no runtime code and is a module", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).toContain("AUTO-GENERATED");
|
||||||
|
expect(checks.trim().endsWith("export {};")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B1: two pages each declaring a block with the same name do not collide", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/one.wrn"),
|
||||||
|
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/two.wrn"),
|
||||||
|
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
|
||||||
|
expect(names.length).toBe(2);
|
||||||
|
expect(new Set(names).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const GET = async () => Response.json({ users: [] });\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/ssr.wrn"),
|
||||||
|
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check");
|
||||||
|
expect(checks).not.toContain("loadUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B2: a client block with an empty request emits no assertion", () => {
|
||||||
|
const root = fixture(` api pingServer GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(checks).not.toContain("__wrn_api_check");
|
||||||
|
expect(checks).not.toContain("pingServer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
|
||||||
|
const root = fixture(BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
|
||||||
|
const assertionLine = checks
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
|
||||||
|
expect(assertionLine).toBeDefined();
|
||||||
|
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Real-compiler enforcement tests ---------------------------------------------
|
||||||
|
//
|
||||||
|
// Everything above only asserts on the emitted *text*. That proves nothing about
|
||||||
|
// whether the assertions actually make `tsc` fail — a build that reverted to the
|
||||||
|
// original inert `never`-based design, or one where `AssertAssignable` is merely
|
||||||
|
// one-directional (so it misses an *extra* declared field), would pass every test
|
||||||
|
// above unchanged. These tests instead run the real TypeScript compiler over the
|
||||||
|
// generated output and assert on its diagnostics.
|
||||||
|
//
|
||||||
|
// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback
|
||||||
|
// branch infers a real input type (`{ name: string; email: string }`) instead of
|
||||||
|
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
|
||||||
|
// could ever fail, which would make these tests meaningless.
|
||||||
|
function typedFixture(block: string): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/api/users.ts"),
|
||||||
|
`export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app/pages/search.wrn"),
|
||||||
|
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles the two generated files (and whatever they reference on disk) with the
|
||||||
|
* real TypeScript compiler and returns its stdout plus whether it reported any
|
||||||
|
* diagnostics.
|
||||||
|
*/
|
||||||
|
function typecheckGenerated(root: string): { ok: boolean; output: string } {
|
||||||
|
const dts = join(root, "app/types/wrnexus.generated.d.ts");
|
||||||
|
const checks = join(root, "app/types/wrnexus.generated.api-checks.ts");
|
||||||
|
const result = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"bunx",
|
||||||
|
"tsc",
|
||||||
|
"--noEmit",
|
||||||
|
"--strict",
|
||||||
|
"--skipLibCheck",
|
||||||
|
"--moduleResolution",
|
||||||
|
"bundler",
|
||||||
|
"--target",
|
||||||
|
"ES2022",
|
||||||
|
"--module",
|
||||||
|
"ESNext",
|
||||||
|
dts,
|
||||||
|
checks,
|
||||||
|
],
|
||||||
|
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||||
|
return { ok: result.exitCode === 0, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("tsc: a block whose fields match the contract has no diagnostics", () => {
|
||||||
|
const root = typedFixture(MATCHING_BLOCK);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(output.trim()).toBe("");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: number
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
extra: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a missing required field fails", () => {
|
||||||
|
const root = typedFixture(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
generateApplicationTypes(root);
|
||||||
|
const { ok, output } = typecheckGenerated(root);
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(output).toContain("wrnexus.generated.api-checks.ts");
|
||||||
|
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||||
|
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
|
||||||
|
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Strip TypeScript from a generated browser module.
|
||||||
|
*
|
||||||
|
* A client function's body is emitted verbatim, so anything TypeScript-only
|
||||||
|
* inside one -- an annotated local, an `as` cast, a local interface -- reaches
|
||||||
|
* the browser module as TypeScript source. Codegen removes the types from the
|
||||||
|
* function's *signature*, which is what made this easy to miss: the emitted
|
||||||
|
* module looked transpiled, and only bodies carried types through.
|
||||||
|
*
|
||||||
|
* The artifact is written as `.mjs` and read back as plain JavaScript, so the
|
||||||
|
* failure surfaced as a syntax error pointing at generated code rather than at
|
||||||
|
* the `.wrn` line responsible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let transpiler: { transformSync(code: string): string } | null = null;
|
||||||
|
|
||||||
|
export function stripBrowserTypes(code: string): string {
|
||||||
|
const bun = (
|
||||||
|
globalThis as unknown as {
|
||||||
|
Bun?: { Transpiler: new (options: unknown) => { transformSync(code: string): string } };
|
||||||
|
}
|
||||||
|
).Bun;
|
||||||
|
|
||||||
|
if (!bun?.Transpiler) {
|
||||||
|
throw new Error(
|
||||||
|
"WRN-CLIENT-TS: emitting a browser module needs the Bun transpiler to remove TypeScript from client function bodies.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
transpiler ??= new bun.Transpiler({ loader: "ts", target: "browser" });
|
||||||
|
|
||||||
|
return transpiler.transformSync(code);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"payload",
|
||||||
]);
|
]);
|
||||||
@@ -308,6 +309,32 @@ function _functionEntry(
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-mode api blocks become members of an `api` object in client scope.
|
||||||
|
*
|
||||||
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
|
*/
|
||||||
|
function apiBindings(ast: PageAst): string {
|
||||||
|
const members = ast.dataApis
|
||||||
|
.filter((block) => block.mode === "client" && block.sections)
|
||||||
|
.map((block) => {
|
||||||
|
const sections = block.sections!;
|
||||||
|
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
||||||
|
const error = eraseFunctionTypes(sections.error).trim();
|
||||||
|
const failure = error
|
||||||
|
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||||
|
: `(error) => { throw error; }`;
|
||||||
|
|
||||||
|
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
|
||||||
|
block.path,
|
||||||
|
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function generateBrowserModule(ast: PageAst): string {
|
export function generateBrowserModule(ast: PageAst): string {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||||
["legacy", "client", "shared"].includes(fn.runtime),
|
["legacy", "client", "shared"].includes(fn.runtime),
|
||||||
@@ -317,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
|
|||||||
const selectedImports = selectedBrowserImports(ast, functions);
|
const selectedImports = selectedBrowserImports(ast, functions);
|
||||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||||
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
||||||
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
|
// `api` is only defined as a client-scope binding when the page actually has
|
||||||
|
// client-mode api blocks (see apiBindings below). A page that declares
|
||||||
|
// `state api` without any client api blocks must keep reading/writing that
|
||||||
|
// state as before, so only exclude the "api" name from destructuring when
|
||||||
|
// there is a real `api` binding to shadow it.
|
||||||
|
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||||
|
const localRuntimeBindings = hasClientApi
|
||||||
|
? RUNTIME_BINDINGS
|
||||||
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||||
|
const sharedState = state.filter(
|
||||||
|
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
|
||||||
|
);
|
||||||
const sharedProps = ast.props
|
const sharedProps = ast.props
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.filter(
|
.filter(
|
||||||
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name),
|
(name) =>
|
||||||
|
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
|
||||||
);
|
);
|
||||||
const callableAliases = functionNames.filter(
|
const callableAliases = functionNames.filter(
|
||||||
(name) =>
|
(name) =>
|
||||||
@@ -365,6 +404,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ interface RenderBinding {
|
|||||||
path: string;
|
path: string;
|
||||||
body: string;
|
body: string;
|
||||||
helpers: string;
|
helpers: string;
|
||||||
|
// Present only for a sectioned ssr block with a non-empty `error {}` section.
|
||||||
|
// When set, a failed API call runs this body (with `status`/`message`/`data`
|
||||||
|
// bound) instead of propagating. Absent -> failures propagate, unchanged.
|
||||||
|
errorBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SsrBinding extends RenderBinding {
|
interface SsrBinding extends RenderBinding {
|
||||||
@@ -593,7 +597,22 @@ function renderNode(
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition =
|
||||||
|
node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
@@ -858,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -934,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ssrRuntimeSource(): string {
|
function ssrRuntimeSource(): string {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -953,6 +983,18 @@ function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __Wrn
|
|||||||
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -982,18 +1024,53 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
||||||
|
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -1502,8 +1579,11 @@ function generateInner(ast: PageAst): string {
|
|||||||
for (const [name, binding] of apiBindings) {
|
for (const [name, binding] of apiBindings) {
|
||||||
if (binding.mode !== "ssr") continue;
|
if (binding.mode !== "ssr") continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||||
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
loopConsts.push(
|
loopConsts.push(
|
||||||
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1511,7 +1591,9 @@ function generateInner(ast: PageAst): string {
|
|||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(
|
||||||
|
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
|
||||||
|
);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(
|
out.push(
|
||||||
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
@@ -2037,6 +2119,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
|
|||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes: ViewNode[]): string {
|
||||||
|
const render = (node: ViewNode): string => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression: string) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(
|
||||||
|
encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(
|
||||||
|
encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean) return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([attribute.name.slice("class:".length), expression]),
|
||||||
|
)}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([name, attribute.value]),
|
||||||
|
)}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static") return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeClientControl(value: unknown): string {
|
||||||
|
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
|
|
||||||
@@ -2051,7 +2214,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
|||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||||
@@ -2067,7 +2237,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
|
|
||||||
return (
|
const serverBody =
|
||||||
"${(() => { const __wl = Array.isArray(" +
|
"${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
@@ -2080,8 +2250,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}"
|
"`; })()}";
|
||||||
);
|
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
export { generate } from "./codegen.ts";
|
export { generate } from "./codegen.ts";
|
||||||
export { generateTargets } from "./targets.ts";
|
export { generateTargets } from "./targets.ts";
|
||||||
export { generateBrowserModule } from "./client-codegen.ts";
|
export { generateBrowserModule } from "./client-codegen.ts";
|
||||||
|
export { stripBrowserTypes } from "./browser-transpile.ts";
|
||||||
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
export { generateServerFunctionsModule, rpcManifest } from "./server-codegen.ts";
|
||||||
export { generateDeclarations } from "./type-codegen.ts";
|
export { generateDeclarations } from "./type-codegen.ts";
|
||||||
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen.ts";
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generateTargets } from "../src/targets.ts";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function browserModule(inner: string): string {
|
||||||
|
return generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
client {
|
||||||
|
${inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
console.log(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK = ` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
age?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
test("emits an api member that calls the transport with the block's path and method", () => {
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(generated).toContain("const api =");
|
||||||
|
expect(generated).toContain("searchUsers");
|
||||||
|
expect(generated).toContain('"/api/users"');
|
||||||
|
expect(generated).toContain('"POST"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declared field types never reach the browser module", () => {
|
||||||
|
// The artifact is written as .mjs and parsed as JavaScript.
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(generated).not.toContain("name?: string");
|
||||||
|
expect(generated).not.toContain("age?: number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the emitted module is valid JavaScript", () => {
|
||||||
|
const generated = browserModule(BLOCK);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a block without an error section still emits its response body", () => {
|
||||||
|
const generated = browserModule(` api plainUsers GET /api/users {
|
||||||
|
request {
|
||||||
|
parameters {
|
||||||
|
team: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("plainUsers");
|
||||||
|
expect(generated).toContain("data.users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("type annotations in response/error bodies are erased before emission (B4)", () => {
|
||||||
|
// Every other browser-bound body in the repo passes through eraseFunctionTypes
|
||||||
|
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
|
||||||
|
// store-codegen.ts); response/error bodies must too, for the same reason:
|
||||||
|
// eraseFunctionTypes strips function-signature annotations (params, return
|
||||||
|
// type, typed catch clauses) so a locally-declared helper function inside a
|
||||||
|
// response/error body no longer ships raw TypeScript into the .mjs artifact.
|
||||||
|
const generated = browserModule(` api searchUsers POST /api/users {
|
||||||
|
request {
|
||||||
|
body {
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response {
|
||||||
|
function pick(list: string[]): string[] { return list }
|
||||||
|
return pick(data.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
error {
|
||||||
|
function describe(e: unknown): string { return String(e) }
|
||||||
|
return describe(error)
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).not.toContain("list: string[]");
|
||||||
|
expect(generated).not.toContain("): string[] {");
|
||||||
|
expect(generated).not.toContain("e: unknown");
|
||||||
|
expect(generated).not.toContain("): string {");
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a page with state api and no client api blocks still reads that state (B5)", () => {
|
||||||
|
// "api" is normally excluded from state/prop destructuring because the
|
||||||
|
// emitted `const api = {...}` binding would shadow it -- but that binding
|
||||||
|
// only exists when the page has client-mode api blocks. Without one, the
|
||||||
|
// exclusion left `api` completely undeclared: a ReferenceError.
|
||||||
|
const generated = generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
state {
|
||||||
|
api = "hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client function run(): void {
|
||||||
|
console.log(api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
|
||||||
|
expect(generated).toContain("context.state");
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a browser module whose `run()` function calls api.searchUsers and
|
||||||
|
* reports the outcome through `output.report(...)` so the test can observe
|
||||||
|
* whether the call resolved or rejected without reaching into codegen
|
||||||
|
* internals.
|
||||||
|
*/
|
||||||
|
function reportingBrowserModule(apiBlock: string): string {
|
||||||
|
return generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
client {
|
||||||
|
${apiBlock}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs {
|
||||||
|
report(payload: any)
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
output.report({ ok: true, users })
|
||||||
|
} catch (e) {
|
||||||
|
output.report({ ok: false, message: String(e && e.message || e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBrowserModule(source: string): Promise<any> {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.mjs");
|
||||||
|
writeFileSync(file, source);
|
||||||
|
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a response body error is not swallowed by the error section (client)", async () => {
|
||||||
|
const mod = await importBrowserModule(
|
||||||
|
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||||
|
request { parameters { name: string } }
|
||||||
|
response {
|
||||||
|
return data.users.missing.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reports: unknown[] = [];
|
||||||
|
const context = {
|
||||||
|
state: {},
|
||||||
|
props: {},
|
||||||
|
output: { report: (value: unknown) => reports.push(value) },
|
||||||
|
server: {},
|
||||||
|
refs: {},
|
||||||
|
callApi: async () => ({ users: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.__wrnexusClientFunctions.run(context);
|
||||||
|
|
||||||
|
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
|
||||||
|
// The error section's own fallback ("[]" / an empty array) must not have
|
||||||
|
// been what the caller observed -- a bug in the response body is a
|
||||||
|
// rejection, not a silently-returned fallback value.
|
||||||
|
expect(reports[0]).not.toEqual({ ok: true, users: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
|
||||||
|
const mod = await importBrowserModule(
|
||||||
|
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||||
|
request { parameters { name: string } }
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reports: unknown[] = [];
|
||||||
|
const context = {
|
||||||
|
state: {},
|
||||||
|
props: {},
|
||||||
|
output: { report: (value: unknown) => reports.push(value) },
|
||||||
|
server: {},
|
||||||
|
refs: {},
|
||||||
|
callApi: async () => {
|
||||||
|
throw Object.assign(new Error("transport failed"), { status: 500 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.__wrnexusClientFunctions.run(context);
|
||||||
|
|
||||||
|
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a state field named api does not collide with the emitted api object", () => {
|
||||||
|
const generated = generateTargets(
|
||||||
|
parse(`page Repro {
|
||||||
|
state {
|
||||||
|
api = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
client {
|
||||||
|
${BLOCK}
|
||||||
|
}
|
||||||
|
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
const users = await api.searchUsers({ name: "Ajay" })
|
||||||
|
console.log(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><button @click="run()">go</button></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).browser;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generate } from "../src/codegen.ts";
|
||||||
|
|
||||||
|
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
|
||||||
|
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
|
||||||
|
// unrelated TypeScript version that doesn't understand this repo's tsconfig
|
||||||
|
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
|
||||||
|
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
|
||||||
|
// which uses this same local binary.
|
||||||
|
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
|
||||||
|
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
|
||||||
|
// file that's actually invoked (our temp one), not the base file — so the
|
||||||
|
// ambient `bun` types need an explicit path back to the repo's node_modules.
|
||||||
|
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the real TypeScript compiler over a generated server module. Proves
|
||||||
|
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
|
||||||
|
* module) actually type-checks — string-containment assertions alone can't
|
||||||
|
* catch a declared type that omits a field every emitted object literal has.
|
||||||
|
*/
|
||||||
|
function typecheckGenerated(source: string): { ok: boolean; output: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
|
||||||
|
roots.push(root);
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, source);
|
||||||
|
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
|
||||||
|
// checks the one file we care about instead of hand-duplicating the whole
|
||||||
|
// compiler configuration (and drifting from it over time).
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
extends: ROOT_TSCONFIG,
|
||||||
|
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
|
||||||
|
include: ["page.ts"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
|
||||||
|
cwd: root,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||||
|
return { ok: result.exitCode === 0, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverModule(inner: string): string {
|
||||||
|
return generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
${inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main><p api="ssrUsers">loading</p></main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a sectioned ssr block binds the payload to data", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("data.users.length");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy ssr block is unchanged", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
return users.length
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("users.length");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return message + status + data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).toContain('"errorBody"');
|
||||||
|
expect(generated).toContain("return message + status + data");
|
||||||
|
expect(generated).toContain("const status = $status");
|
||||||
|
expect(generated).toContain("const message = $message");
|
||||||
|
expect(generated).toContain("const data = $data");
|
||||||
|
expect(generated).toContain("__wrnexusEvalError");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block without an error section emits no catch entry for that binding", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(generated).not.toContain('"errorBody"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
|
||||||
|
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return message + status + data
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
const { ok, output } = typecheckGenerated(generated);
|
||||||
|
|
||||||
|
expect(output.trim()).toBe("");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
const html = await mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block's response body error is not swallowed by the error section", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.missing.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => ({ users: [] }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block still runs the error body on a genuine transport failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users.length
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
return ["fallback"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
const html = await mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
|
||||||
|
const generated = generate(
|
||||||
|
parse(`page Repro {
|
||||||
|
ssr {
|
||||||
|
api ssrUsers GET /api/users {
|
||||||
|
response {
|
||||||
|
return data.users
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
|
||||||
|
roots.push(root);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
const file = join(root, "page.ts");
|
||||||
|
writeFileSync(file, generated);
|
||||||
|
|
||||||
|
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mod.default({
|
||||||
|
req: { url: "http://localhost/", headers: new Headers() },
|
||||||
|
cookies: {},
|
||||||
|
session: {},
|
||||||
|
localStorage: {},
|
||||||
|
__wrnexusCallApi: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parse } from "@wrnexus/syntax";
|
||||||
|
import { generateTargets } from "../src/targets.ts";
|
||||||
|
import { stripBrowserTypes } from "../src/browser-transpile.ts";
|
||||||
|
|
||||||
|
/** Build the browser module for a page whose client function body is TypeScript. */
|
||||||
|
function browserModuleFor(body: string): string {
|
||||||
|
const source = `page Repro {
|
||||||
|
functions {
|
||||||
|
client async function run(): Promise<void> {
|
||||||
|
${body}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main><button @click="run()">go</button></main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return generateTargets(parse(source)).browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The artifact is written as .mjs, so this is how the runtime reads it back. */
|
||||||
|
function parsesAsJavaScript(code: string): boolean {
|
||||||
|
try {
|
||||||
|
new Function(code.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a client function body keeps its TypeScript in the generated module", () => {
|
||||||
|
// Codegen strips the signature's types but copies the body verbatim, which is
|
||||||
|
// what made this easy to miss. Guarding the premise the fix rests on.
|
||||||
|
const generated = browserModuleFor(` const requestBody: Record<string, unknown> = {}`);
|
||||||
|
|
||||||
|
expect(generated).toContain("const requestBody: Record<string, unknown>");
|
||||||
|
expect(parsesAsJavaScript(generated)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stripping types makes an annotated client function body valid JavaScript", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const requestBody: Record<string, unknown> = {}
|
||||||
|
requestBody.q = "x"`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).not.toContain("Record<string, unknown>");
|
||||||
|
expect(stripped).toContain("requestBody.q");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("casts, generics and local interfaces survive stripping", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` interface Local { a: string }
|
||||||
|
const names: string[] = ["a"]
|
||||||
|
const typed = { a: "x" } as Local
|
||||||
|
const total = (1 as number) + names.length
|
||||||
|
console.log(typed.a, total)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).toContain("console.log");
|
||||||
|
expect(stripped).not.toContain("interface Local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the module's exported bindings are preserved", () => {
|
||||||
|
// A transpile that dropped one of these would break hydration silently.
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const value: number = 1
|
||||||
|
console.log(value)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const binding of [
|
||||||
|
"__wrnexusClientFunctions",
|
||||||
|
"__wrnexusClientState",
|
||||||
|
"__wrnexusOutputs",
|
||||||
|
"__wrnexusImportedBindings",
|
||||||
|
"bindClientScope",
|
||||||
|
]) {
|
||||||
|
expect(stripped).toContain(binding);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body with no TypeScript is left working", () => {
|
||||||
|
const stripped = stripBrowserTypes(
|
||||||
|
browserModuleFor(` const plain = { a: 1 }
|
||||||
|
console.log(plain.a)`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsesAsJavaScript(stripped)).toBe(true);
|
||||||
|
expect(stripped).toContain("console.log");
|
||||||
|
});
|
||||||
@@ -1053,6 +1053,23 @@ component Banner {
|
|||||||
expect(output).toContain("Visible");
|
expect(output).toContain("Visible");
|
||||||
expect(output).toContain("Hidden");
|
expect(output).toContain("Hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("if and each blocks emit browser control metadata while preserving SSR", () => {
|
||||||
|
const output = generate(
|
||||||
|
parse(`component ClientBlocks {
|
||||||
|
state open = false
|
||||||
|
state items = ["a"]
|
||||||
|
view {
|
||||||
|
{#if open}<p>Open</p>{:else}<p>Closed</p>{/if}
|
||||||
|
{#each items as item}<span>{item}</span>{:empty}<i>Empty</i>{/each}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).toContain("data-wrn-if=");
|
||||||
|
expect(output).toContain("data-wrn-each=");
|
||||||
|
expect(output).toContain("Array.isArray(items)");
|
||||||
|
});
|
||||||
test("component array props support each blocks", () => {
|
test("component array props support each blocks", () => {
|
||||||
const output = generate(
|
const output = generate(
|
||||||
parse(`
|
parse(`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/content",
|
"name": "@wrnexus/content",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/core",
|
"name": "@wrnexus/core",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -96,7 +96,21 @@ export function defineEndpoint(
|
|||||||
if (definition.auth === "required" && !ctx.user) {
|
if (definition.auth === "required" && !ctx.user) {
|
||||||
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
||||||
}
|
}
|
||||||
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput;
|
// The real HTTP router invokes route handlers as `handler(ctx)` — it never
|
||||||
|
// supplies a second argument. Callers that already have a parsed payload
|
||||||
|
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
|
||||||
|
// that always wins. Otherwise, read the request ourselves: query params for
|
||||||
|
// GET/HEAD, JSON body for everything else.
|
||||||
|
let input: unknown = rawInput;
|
||||||
|
if (definition.input) {
|
||||||
|
const resolvedInput =
|
||||||
|
rawInput !== undefined
|
||||||
|
? rawInput
|
||||||
|
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
|
||||||
|
? Object.fromEntries(ctx.url.searchParams)
|
||||||
|
: await ctx.req.json().catch(() => ({}));
|
||||||
|
input = schemaValue(definition.input, resolvedInput);
|
||||||
|
}
|
||||||
const rawOutput = await definition.handler(input, ctx);
|
const rawOutput = await definition.handler(input, ctx);
|
||||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||||
return output instanceof Response ? output : json({ data: output });
|
return output instanceof Response ? output : json({ data: output });
|
||||||
|
|||||||
@@ -29,3 +29,108 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
|
|||||||
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
|
||||||
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
|
||||||
|
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
|
||||||
|
// passes rawInput explicitly, so it never exercises that calling convention. These
|
||||||
|
// tests call the endpoint with only a context, matching what actually happens in
|
||||||
|
// production, to guard against silently validating `undefined` again.
|
||||||
|
const search = v.object({ name: v.string().trim().optional() });
|
||||||
|
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
|
||||||
|
input: search,
|
||||||
|
handler(input) {
|
||||||
|
return { name: input.name ?? null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a GET request reads input from the URL's query string", async () => {
|
||||||
|
const request = new Request("https://example.test/api/search?name=Ada");
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
|
||||||
|
const request = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "Ada" }),
|
||||||
|
});
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Ada" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
|
||||||
|
const malformedRequest = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "{not json",
|
||||||
|
});
|
||||||
|
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
|
||||||
|
const malformedResponse = await searchEndpoint(malformedCtx);
|
||||||
|
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
|
||||||
|
// the point is that the malformed body did not throw an unhandled parse error.
|
||||||
|
expect(malformedResponse.status).toBe(200);
|
||||||
|
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
|
||||||
|
|
||||||
|
const requiredField = v.object({ name: v.string().min(1) });
|
||||||
|
const requiredEndpoint = defineEndpoint({
|
||||||
|
input: requiredField,
|
||||||
|
handler(input) {
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
|
||||||
|
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
|
||||||
|
const emptyResponse = await requiredEndpoint(emptyCtx);
|
||||||
|
// With no body at all, resolved input is {} — the schema's own required-field
|
||||||
|
// validation is what turns that into a 400, not a thrown parse error.
|
||||||
|
expect(emptyResponse.status).toBe(400);
|
||||||
|
expect(await emptyResponse.json()).toEqual({
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "Endpoint validation failed.",
|
||||||
|
details: { name: "Required" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET query strings travel as text (`URLSearchParams` values are always
|
||||||
|
// strings), so a `v.number()` field must come back as a real number, not the
|
||||||
|
// string the wire actually carried, or a page declaring `age?: number` on a
|
||||||
|
// GET api block would be lying about the type. checkField in
|
||||||
|
// @wrnexus/validation coerces via Number(pre) for both optional and required
|
||||||
|
// number fields (see packages/validation/src/index.ts); this locks that in
|
||||||
|
// end-to-end through defineEndpoint's own GET query-string resolution path.
|
||||||
|
test("a GET request coerces a v.number() query param to an actual number", async () => {
|
||||||
|
const ageSchema = v.object({ age: v.number() });
|
||||||
|
const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({
|
||||||
|
input: ageSchema,
|
||||||
|
handler(input) {
|
||||||
|
return { age: input.age, typeofAge: typeof input.age };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request("https://example.test/api/age?age=30");
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await ageEndpoint(ctx);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit rawInput argument still wins and the request is never read", async () => {
|
||||||
|
// A request whose body has already been consumed: if the endpoint tried to read it
|
||||||
|
// again (rather than trusting the explicit rawInput), this would throw.
|
||||||
|
const request = new Request("https://example.test/api/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "ignored-body" }),
|
||||||
|
});
|
||||||
|
await request.json(); // drain the body so a second .json() call would reject
|
||||||
|
const ctx = createContext(request, new URL(request.url));
|
||||||
|
const response = await searchEndpoint(ctx, { name: "Explicit" });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
|
|||||||
var emitPinInputEvent = bridge.emitPinInputEvent;
|
var emitPinInputEvent = bridge.emitPinInputEvent;
|
||||||
var parseScopeDecl = bridge.parseScopeDecl;
|
var parseScopeDecl = bridge.parseScopeDecl;
|
||||||
var warnOnce = bridge.warn || function () {};
|
var warnOnce = bridge.warn || function () {};
|
||||||
|
// The extracted sections use these the same way the core runtime does, and
|
||||||
|
// this bundle is a separate IIFE, so it needs its own copies.
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
${sections}
|
${sections}
|
||||||
function hydrate(root) {
|
function hydrate(root) {
|
||||||
var host = root || document;
|
var host = root || document;
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncI18n(nextDocument) {
|
function syncI18n(nextDocument) {
|
||||||
var script = Array.prototype.find.call(
|
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
nextDocument.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
|
||||||
if (!match) return;
|
|
||||||
try {
|
try {
|
||||||
var incoming = JSON.parse(match[1]);
|
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||||
var current = window.__wrnI18n || {};
|
var current = window.__wrnI18n || {};
|
||||||
var translator = current.t;
|
var translator = current.t;
|
||||||
var setter = current.set;
|
var setter = current.set;
|
||||||
|
|||||||
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var behaviorObserver;
|
var behaviorObserver;
|
||||||
var clientModuleCache = new Map();
|
var clientModuleCache = new Map();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two builtin chains the runtime reaches for constantly. Aliasing them is
|
||||||
|
* not only shorter: hasOwn keeps prototype keys from reading as data, and
|
||||||
|
* toArray is needed because a NodeList is not an Array.
|
||||||
|
*/
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* data-wrn-class-* and data-wrn-bind-* both carry a JSON ["name","expression"]
|
||||||
|
* pair. Malformed markup yields null so every caller bails the same way
|
||||||
|
* rather than each repeating the parse and the shape check.
|
||||||
|
*/
|
||||||
|
function pairBinding(value) {
|
||||||
|
var parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed && parsed.length === 2 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Globals the expression engine resolves for client code. Kept as explicit
|
* Globals the expression engine resolves for client code. Kept as explicit
|
||||||
* tables rather than falling through to window[name]: an implicit fallback
|
* lists rather than falling through to window[name]: an implicit fallback
|
||||||
* would let any expression reach every global on the page (and would make a
|
* would let any expression reach every global on the page (and would make a
|
||||||
* typo silently resolve to some unrelated window property) -- these lists
|
* typo silently resolve to some unrelated window property) -- these lists
|
||||||
* say exactly what client code may reach.
|
* say exactly what client code may reach.
|
||||||
*
|
*
|
||||||
* dialogGlobals must be bound to window or the browser throws
|
* Prototype-less so a name like "toString" or "constructor" is a miss
|
||||||
* "Illegal invocation" when they are called detached.
|
* rather than a hit on Object.prototype.
|
||||||
*/
|
*/
|
||||||
var dialogGlobals = {
|
function nameSet(names) {
|
||||||
alert: 1,
|
var set = Object.create(null);
|
||||||
confirm: 1,
|
|
||||||
prompt: 1,
|
|
||||||
fetch: 1,
|
|
||||||
print: 1,
|
|
||||||
open: 1,
|
|
||||||
scrollTo: 1,
|
|
||||||
scrollBy: 1,
|
|
||||||
matchMedia: 1,
|
|
||||||
getComputedStyle: 1,
|
|
||||||
structuredClone: 1,
|
|
||||||
queueMicrotask: 1,
|
|
||||||
btoa: 1,
|
|
||||||
atob: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Language builtins. Wrapped in thunks so referencing one that a given
|
names.split(" ").forEach(function (name) {
|
||||||
// engine lacks cannot throw at table-definition time.
|
set[name] = 1;
|
||||||
var jsGlobals = {
|
});
|
||||||
Object: function () { return Object; },
|
|
||||||
Boolean: function () { return Boolean; },
|
return set;
|
||||||
RegExp: function () { return RegExp; },
|
}
|
||||||
Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
|
|
||||||
Set: function () { return typeof Set === "undefined" ? undefined : Set; },
|
/*
|
||||||
Map: function () { return typeof Map === "undefined" ? undefined : Map; },
|
* Called with window as the receiver. Detached, the browser throws
|
||||||
Error: function () { return Error; },
|
* "Illegal invocation" for these.
|
||||||
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
|
*/
|
||||||
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
|
var boundWindowGlobals = nameSet(
|
||||||
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
|
"alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
|
||||||
parseInt: function () { return parseInt; },
|
" getComputedStyle structuredClone queueMicrotask btoa atob" +
|
||||||
parseFloat: function () { return parseFloat; },
|
" setTimeout clearTimeout setInterval clearInterval" +
|
||||||
isNaN: function () { return isNaN; },
|
" requestAnimationFrame cancelAnimationFrame",
|
||||||
isFinite: function () { return isFinite; },
|
);
|
||||||
encodeURIComponent: function () { return encodeURIComponent; },
|
|
||||||
decodeURIComponent: function () { return decodeURIComponent; },
|
/*
|
||||||
encodeURI: function () { return encodeURI; },
|
* Language builtins and other realm globals, read off globalThis. Naming
|
||||||
decodeURI: function () { return decodeURI; },
|
* them rather than referencing them directly means one an engine lacks
|
||||||
NaN: function () { return NaN; },
|
* resolves to undefined instead of throwing where the table is defined.
|
||||||
Infinity: function () { return Infinity; },
|
*/
|
||||||
undefined: function () { return undefined; },
|
var ambientGlobals = nameSet(
|
||||||
};
|
"Object Boolean RegExp Promise Set Map Error Symbol BigInt Intl parseInt" +
|
||||||
|
" parseFloat isNaN isFinite encodeURIComponent decodeURIComponent" +
|
||||||
|
" encodeURI decodeURI NaN Infinity undefined Array Number String Math" +
|
||||||
|
" JSON Date URL",
|
||||||
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* toast(...) -- raise a notification from any client expression.
|
* toast(...) -- raise a notification from any client expression.
|
||||||
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (!window.toast) window.toast = toastApi;
|
if (!window.toast) window.toast = toastApi;
|
||||||
|
|
||||||
// Read straight off window, no binding needed (objects, not functions).
|
// Read straight off window, no binding needed (objects, not functions).
|
||||||
var windowGlobals = {
|
var windowGlobals = nameSet(
|
||||||
localStorage: 1,
|
"localStorage sessionStorage screen performance crypto CustomEvent Event" +
|
||||||
sessionStorage: 1,
|
" FormData URLSearchParams AbortController Notification" +
|
||||||
screen: 1,
|
" IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
|
||||||
performance: 1,
|
" innerWidth innerHeight scrollX scrollY location history navigator",
|
||||||
crypto: 1,
|
);
|
||||||
CustomEvent: 1,
|
|
||||||
Event: 1,
|
|
||||||
FormData: 1,
|
|
||||||
URLSearchParams: 1,
|
|
||||||
AbortController: 1,
|
|
||||||
Notification: 1,
|
|
||||||
IntersectionObserver: 1,
|
|
||||||
ResizeObserver: 1,
|
|
||||||
MutationObserver: 1,
|
|
||||||
devicePixelRatio: 1,
|
|
||||||
innerWidth: 1,
|
|
||||||
innerHeight: 1,
|
|
||||||
scrollX: 1,
|
|
||||||
scrollY: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reportDiagnostic(code, message, element, detail) {
|
function reportDiagnostic(code, message, element, detail) {
|
||||||
var payload = {
|
var payload = {
|
||||||
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var serverProxy = new Proxy({}, {
|
var serverProxy = new Proxy({}, {
|
||||||
get: function (_target, property) {
|
get: function (_target, property) {
|
||||||
return function () {
|
return function () {
|
||||||
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
|
return callServerFunction(componentRpcName, String(property), toArray(arguments));
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1111,7 +1116,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "server") return serverProxy;
|
if (name === "server") return serverProxy;
|
||||||
if (name === "props") return propsProxy;
|
if (name === "props") return propsProxy;
|
||||||
if (name === "refs") return refsProxy;
|
if (name === "refs") return refsProxy;
|
||||||
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
|
if (hasOwn(moduleBindings, name)) return moduleBindings[name];
|
||||||
if (name === "$emit") {
|
if (name === "$emit") {
|
||||||
return function (eventName, detail) {
|
return function (eventName, detail) {
|
||||||
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
||||||
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "window") return window;
|
if (name === "window") return window;
|
||||||
if (name === "document") return document;
|
if (name === "document") return document;
|
||||||
if (name === "console") return console;
|
if (name === "console") return console;
|
||||||
if (name === "Array") return Array;
|
|
||||||
if (name === "Number") return Number;
|
|
||||||
if (name === "String") return String;
|
|
||||||
if (name === "Math") return Math;
|
|
||||||
if (name === "JSON") return JSON;
|
|
||||||
if (name === "Date") return Date;
|
|
||||||
if (name === "URL") return URL;
|
|
||||||
if (name === "location") return window.location;
|
|
||||||
if (name === "history") return window.history;
|
|
||||||
if (name === "navigator") return window.navigator;
|
|
||||||
if (name === "$route" || name === "route") {
|
if (name === "$route" || name === "route") {
|
||||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||||
return routeValue.get();
|
return routeValue.get();
|
||||||
}
|
}
|
||||||
if (name === "setTimeout") return window.setTimeout.bind(window);
|
|
||||||
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
|
||||||
if (name === "setInterval") return window.setInterval.bind(window);
|
|
||||||
if (name === "clearInterval") return window.clearInterval.bind(window);
|
|
||||||
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
|
|
||||||
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
|
|
||||||
/*
|
/*
|
||||||
* Ordinary browser and language globals.
|
* Ordinary browser and language globals.
|
||||||
*
|
*
|
||||||
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
* lacks one of these does not break the rest.
|
* lacks one of these does not break the rest.
|
||||||
*/
|
*/
|
||||||
if (name === "toast") return toastApi;
|
if (name === "toast") return toastApi;
|
||||||
if (dialogGlobals[name] && typeof window[name] === "function") {
|
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
||||||
return window[name].bind(window);
|
return window[name].bind(window);
|
||||||
}
|
}
|
||||||
if (jsGlobals[name]) {
|
if (ambientGlobals[name]) {
|
||||||
var builtin = jsGlobals[name]();
|
var builtin = globalThis[name];
|
||||||
if (builtin !== undefined) return builtin;
|
if (builtin !== undefined) return builtin;
|
||||||
}
|
}
|
||||||
if (windowGlobals[name]) {
|
if (windowGlobals[name]) {
|
||||||
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readScope(name) {
|
function readScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
if (computing.has(name)) {
|
if (computing.has(name)) {
|
||||||
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (currentRenderer) sig.subscribe(currentRenderer);
|
if (currentRenderer) sig.subscribe(currentRenderer);
|
||||||
return sig.get();
|
return sig.get();
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function peekScope(name) {
|
function peekScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
}
|
}
|
||||||
if (signals[name]) return signals[name].get();
|
if (signals[name]) return signals[name].get();
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function evalExpr(expr, locals) {
|
function evalExpr(expr, locals) {
|
||||||
return evaluateExpression(expr, function (name) {
|
return evaluateExpression(expr, function (name) {
|
||||||
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
|
if (locals && hasOwn(locals, name)) {
|
||||||
return locals[name];
|
return locals[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
source,
|
source,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
|
locals = locals || Object.create(null);
|
||||||
|
|
||||||
return batchUpdates(function () {
|
return batchUpdates(function () {
|
||||||
var statements =
|
var statements =
|
||||||
splitStatements(source);
|
splitStatements(source);
|
||||||
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name) {
|
function (name) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name, value) {
|
function (name, value) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals,
|
locals,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
function (name, value) {
|
||||||
|
locals[name] = value;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.returned) {
|
if (result.returned) {
|
||||||
@@ -1388,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
state: stateProxy,
|
state: stateProxy,
|
||||||
output: outputProxy,
|
output: outputProxy,
|
||||||
server: serverProxy,
|
server: serverProxy,
|
||||||
|
callApi: wrnexusCallApi,
|
||||||
props: propsProxy,
|
props: propsProxy,
|
||||||
refs: refsProxy,
|
refs: refsProxy,
|
||||||
};
|
};
|
||||||
@@ -1501,19 +1494,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
return type + ":" + String(value);
|
return type + ":" + String(value);
|
||||||
}
|
}
|
||||||
function fillMustache(str, itemEval) {
|
|
||||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
|
||||||
var e = (d || s).trim();
|
|
||||||
try { return String(itemEval(e)); } catch (err) { return ""; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function hydrateItem(
|
function hydrateItem(
|
||||||
root,
|
root,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
function localRead(name) {
|
function localRead(name) {
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(
|
hasOwn(
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1597,7 +1584,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (node !== root && insideNestedLoop(node)) return;
|
if (node !== root && insideNestedLoop(node)) return;
|
||||||
|
|
||||||
var attributes =
|
var attributes =
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
node.attributes,
|
node.attributes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1611,24 +1598,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (
|
if (
|
||||||
attribute.name === "data-text"
|
attribute.name === "data-text"
|
||||||
) {
|
) {
|
||||||
try {
|
(function (textNode, textExpression) {
|
||||||
var textValue =
|
var runText = reactive(function () {
|
||||||
itemEval(attribute.value);
|
try {
|
||||||
|
var textValue = itemEval(textExpression);
|
||||||
node.textContent =
|
textNode.textContent = textValue == null ? "" : String(textValue);
|
||||||
textValue == null
|
} catch (error) {
|
||||||
? ""
|
console.error(
|
||||||
: String(textValue);
|
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
|
||||||
} catch (error) {
|
error,
|
||||||
console.error(
|
);
|
||||||
"[wrnexus] data-for text binding failed for '" +
|
textNode.textContent = "";
|
||||||
attribute.value +
|
}
|
||||||
"'",
|
});
|
||||||
error,
|
runText();
|
||||||
);
|
})(node, attribute.value);
|
||||||
|
|
||||||
node.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1642,28 +1626,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
"data-wrn-class-",
|
"data-wrn-class-",
|
||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
var classBinding;
|
var classBinding = pairBinding(attribute.value);
|
||||||
|
|
||||||
try {
|
if (!classBinding) return;
|
||||||
classBinding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
var className = classBinding[0];
|
||||||
!classBinding ||
|
var classExpression = classBinding[1];
|
||||||
classBinding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className =
|
|
||||||
classBinding[0];
|
|
||||||
|
|
||||||
var classExpression =
|
|
||||||
classBinding[1];
|
|
||||||
|
|
||||||
var classEnabled = false;
|
var classEnabled = false;
|
||||||
|
|
||||||
@@ -1693,28 +1661,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
node.removeAttribute(attribute.name);
|
node.removeAttribute(attribute.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(attribute.value);
|
||||||
binding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attributeName =
|
var attributeName = binding[0];
|
||||||
binding[0];
|
var attributeTemplate = binding[1];
|
||||||
|
|
||||||
var attributeTemplate =
|
|
||||||
binding[1];
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Reactive, not resolved once. The expression can read component
|
* Reactive, not resolved once. The expression can read component
|
||||||
@@ -1782,7 +1735,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
eventLocals.event = event;
|
eventLocals.event = event;
|
||||||
eventLocals.$event = event;
|
eventLocals.$event = event;
|
||||||
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
eventLocals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -1934,8 +1887,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
// Hand every nested loop its own renderer, with this item in scope.
|
// Hand every nested loop its own renderer, with this item in scope.
|
||||||
if (root.querySelectorAll) {
|
if (root.querySelectorAll) {
|
||||||
Array.prototype.slice
|
toArray(root.querySelectorAll("[data-for]"))
|
||||||
.call(root.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (nested) {
|
.forEach(function (nested) {
|
||||||
// Only the outermost nested templates: deeper ones are set up by
|
// Only the outermost nested templates: deeper ones are set up by
|
||||||
// their own parent when it renders.
|
// their own parent when it renders.
|
||||||
@@ -1954,6 +1906,155 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compiled if/each blocks keep their SSR result in the custom
|
||||||
|
// element and carry an inert, base64-encoded template for later browser
|
||||||
|
// updates. The first reactive pass only subscribes to dependencies, so
|
||||||
|
// hydration does not throw away server DOM. Subsequent state changes
|
||||||
|
// materialize the appropriate branch/rows and hydrate their bindings.
|
||||||
|
function decodeControlDefinition(value) {
|
||||||
|
try {
|
||||||
|
var binary = window.atob(value || "");
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return JSON.parse(new TextDecoder("utf-8").decode(bytes));
|
||||||
|
} catch (error) {
|
||||||
|
reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupControlBlock(block, outerLocals) {
|
||||||
|
if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return;
|
||||||
|
block.__wrnexusControl = true;
|
||||||
|
var inherited = outerLocals || decodeLoopLocals(block);
|
||||||
|
var rangeEnd = null;
|
||||||
|
if (block.tagName && block.tagName.toLowerCase() === "template") {
|
||||||
|
var depth = 0;
|
||||||
|
for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) {
|
||||||
|
if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue;
|
||||||
|
if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++;
|
||||||
|
if (sibling.hasAttribute("data-wrn-control-end")) {
|
||||||
|
if (depth === 0) { rangeEnd = sibling; break; }
|
||||||
|
depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rangeEnd) return;
|
||||||
|
}
|
||||||
|
var ifDefinition = block.hasAttribute("data-wrn-if")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-if"))
|
||||||
|
: null;
|
||||||
|
var eachDefinition = block.hasAttribute("data-wrn-each")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-each"))
|
||||||
|
: null;
|
||||||
|
/*
|
||||||
|
* Skip the first reactive pass only when hydrating server DOM.
|
||||||
|
*
|
||||||
|
* A block that arrived with the server HTML is already rendered, so
|
||||||
|
* redrawing on the first pass would discard it. A block created later by
|
||||||
|
* an outer block's rerender has no server DOM — outerLocals is how it
|
||||||
|
* receives its enclosing loop's scope, and is only ever set on that path.
|
||||||
|
* Skipping its first pass leaves it permanently empty, because its
|
||||||
|
* dependencies never change again to trigger a second one.
|
||||||
|
*/
|
||||||
|
var firstRun = !outerLocals;
|
||||||
|
|
||||||
|
function controlRead(name) {
|
||||||
|
return hasOwn(inherited, name) ? inherited[name] : readScope(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlEval(expression, locals) {
|
||||||
|
return evaluateExpression(expression, function (name) {
|
||||||
|
return locals && hasOwn(locals, name)
|
||||||
|
? locals[name]
|
||||||
|
: controlRead(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearControlContent() {
|
||||||
|
if (!rangeEnd) { block.innerHTML = ""; return; }
|
||||||
|
while (block.nextSibling && block.nextSibling !== rangeEnd) {
|
||||||
|
block.parentNode.removeChild(block.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendControlContent(markup, locals) {
|
||||||
|
var template = document.createElement("template");
|
||||||
|
template.innerHTML = markup || "";
|
||||||
|
var fragment = template.content;
|
||||||
|
var elements = toArray(fragment.childNodes).filter(function (node) {
|
||||||
|
return node.nodeType === 1;
|
||||||
|
});
|
||||||
|
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
|
||||||
|
else block.appendChild(fragment);
|
||||||
|
elements.forEach(function (node) { hydrateItem(node, locals || inherited); });
|
||||||
|
elements.forEach(function (node) {
|
||||||
|
var controls = [];
|
||||||
|
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
|
||||||
|
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
|
||||||
|
controls.forEach(function (nested) {
|
||||||
|
if (nested.__wrnexusControl) return;
|
||||||
|
/*
|
||||||
|
* Run the new block now rather than waiting for a sweep.
|
||||||
|
*
|
||||||
|
* reactive() only registers an effect; effects execute when
|
||||||
|
* renderAll sweeps the list. A state change runs just the affected
|
||||||
|
* effects, so a block registered during that rerender is queued and
|
||||||
|
* never invoked -- it would stay empty for the life of the page.
|
||||||
|
*/
|
||||||
|
var runNested = setupControlBlock(nested, locals || inherited);
|
||||||
|
if (runNested) runNested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactive(function () {
|
||||||
|
/*
|
||||||
|
* A block removed from the DOM keeps its effect in the renderers list,
|
||||||
|
* so a later sweep would run it against a detached node and throw --
|
||||||
|
* aborting the sweep, leaving every later effect unrendered. Skip it.
|
||||||
|
*/
|
||||||
|
if (!block.parentNode) return;
|
||||||
|
if (ifDefinition) {
|
||||||
|
var selected = null;
|
||||||
|
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
|
||||||
|
var branch = ifDefinition[branchIndex];
|
||||||
|
if (branch.cond === null || !!controlEval(branch.cond, inherited)) {
|
||||||
|
selected = branch;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
appendControlContent(selected ? selected.body : "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eachDefinition) return;
|
||||||
|
var list = controlEval(eachDefinition.list, inherited);
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
if (list.length === 0) {
|
||||||
|
appendControlContent(eachDefinition.empty || "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var itemIndex = 0; itemIndex < list.length; itemIndex++) {
|
||||||
|
var rowLocals = {};
|
||||||
|
Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; });
|
||||||
|
rowLocals[eachDefinition.item] = list[itemIndex];
|
||||||
|
if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex;
|
||||||
|
appendControlContent(eachDefinition.body || "", rowLocals);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
|
||||||
|
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
|
||||||
|
setupControlBlock(block, null);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set up one [data-for] template. Extracted from an inline forEach so it
|
* Set up one [data-for] template. Extracted from an inline forEach so it
|
||||||
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
||||||
@@ -1981,7 +2082,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopRead(name) {
|
function loopRead(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
|
if (hasOwn(inherited, name)) {
|
||||||
return inherited[name];
|
return inherited[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -2154,7 +2255,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
rawKey = evaluateExpression(
|
rawKey = evaluateExpression(
|
||||||
keyExpression,
|
keyExpression,
|
||||||
function (name) {
|
function (name) {
|
||||||
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
return hasOwn(keyedLocals, name)
|
||||||
? keyedLocals[name]
|
? keyedLocals[name]
|
||||||
: loopRead(name);
|
: loopRead(name);
|
||||||
},
|
},
|
||||||
@@ -2227,8 +2328,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-for]"))
|
||||||
.call(el.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (tpl) {
|
.forEach(function (tpl) {
|
||||||
// Only top-level templates here; nested ones are connected by the item
|
// Only top-level templates here; nested ones are connected by the item
|
||||||
// that contains them, once it has values to give them.
|
// that contains them, once it has values to give them.
|
||||||
@@ -2324,24 +2424,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Conditional class bindings emitted as:
|
// Conditional class bindings emitted as:
|
||||||
// data-wrn-class-*='["class-name","expression"]'
|
// data-wrn-class-*='["class-name","expression"]'
|
||||||
var classBindNodes = [el].concat(
|
var classBindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(el.querySelectorAll("*")),
|
toArray(el.querySelectorAll("*")),
|
||||||
);
|
);
|
||||||
|
|
||||||
classBindNodes.forEach(function (node) {
|
classBindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
|
toArray(node.attributes).forEach(function (marker) {
|
||||||
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
||||||
|
|
||||||
var binding;
|
var binding = pairBinding(marker.value);
|
||||||
|
|
||||||
try {
|
if (!binding) return;
|
||||||
binding = JSON.parse(marker.value);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
|
|
||||||
var className = binding[0];
|
var className = binding[0];
|
||||||
var expression = binding[1];
|
var expression = binding[1];
|
||||||
@@ -2370,7 +2464,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
||||||
// state changes to update type, aria-*, class, href, and other attributes.
|
// state changes to update type, aria-*, class, href, and other attributes.
|
||||||
var bindNodes = [el].concat(
|
var bindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
el.querySelectorAll("*"),
|
el.querySelectorAll("*"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2378,8 +2472,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
bindNodes.forEach(function (node) {
|
bindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(node.attributes)
|
||||||
.call(node.attributes)
|
|
||||||
.forEach(function (marker) {
|
.forEach(function (marker) {
|
||||||
if (
|
if (
|
||||||
marker.name.indexOf(
|
marker.name.indexOf(
|
||||||
@@ -2390,22 +2483,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
node.removeAttribute(marker.name);
|
node.removeAttribute(marker.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(marker.value);
|
||||||
binding = JSON.parse(
|
|
||||||
marker.value,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = binding[0];
|
var name = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
@@ -2497,10 +2578,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event handlers on elements, window, and document.
|
// Event handlers on elements, window, and document.
|
||||||
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
var nodes = [el].concat(toArray(el.querySelectorAll("*")));
|
||||||
nodes.forEach(function (node) {
|
nodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-on-") !== 0) return;
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||||
|
|
||||||
var rawName = attr.name.slice("data-on-".length);
|
var rawName = attr.name.slice("data-on-".length);
|
||||||
@@ -2527,7 +2608,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
locals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -2576,8 +2657,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
||||||
// two cases stay distinguishable, and this scope claims every one that
|
// two cases stay distinguishable, and this scope claims every one that
|
||||||
// sits on a component it directly mounts.
|
// sits on a component it directly mounts.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-wrn-events]"))
|
||||||
.call(el.querySelectorAll("[data-wrn-events]"))
|
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
var componentRoot = closestScope(node);
|
var componentRoot = closestScope(node);
|
||||||
if (!componentRoot || componentRoot === el) return;
|
if (!componentRoot || componentRoot === el) return;
|
||||||
@@ -2594,7 +2674,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
node.__wrnexusOutputHandlers ||
|
node.__wrnexusOutputHandlers ||
|
||||||
(node.__wrnexusOutputHandlers = {});
|
(node.__wrnexusOutputHandlers = {});
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
||||||
|
|
||||||
var outName = attr.name.slice("data-wrn-out-".length);
|
var outName = attr.name.slice("data-wrn-out-".length);
|
||||||
@@ -2632,7 +2712,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload =
|
locals.payload =
|
||||||
event && Object.prototype.hasOwnProperty.call(event, "detail")
|
event && hasOwn(event, "detail")
|
||||||
? event.detail
|
? event.detail
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
@@ -2654,16 +2734,14 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Prop expressions belong to the parent that mounted the component. The
|
// Prop expressions belong to the parent that mounted the component. The
|
||||||
// server forwards these markers onto the rendered child root; evaluate
|
// server forwards these markers onto the rendered child root; evaluate
|
||||||
// them here and write changes into the child's prop signals.
|
// them here and write changes into the child's prop signals.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("*"))
|
||||||
.call(el.querySelectorAll("*"))
|
|
||||||
.filter(isScopeRoot)
|
.filter(isScopeRoot)
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
||||||
var binding;
|
var binding = pairBinding(attr.value);
|
||||||
try { binding = JSON.parse(attr.value); } catch (_) { return; }
|
if (!binding) return;
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
var propName = binding[0];
|
var propName = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
reactive(function () {
|
reactive(function () {
|
||||||
@@ -3017,8 +3095,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var host = root && root.querySelectorAll ? root : document;
|
var host = root && root.querySelectorAll ? root : document;
|
||||||
anchoredWriting = true;
|
anchoredWriting = true;
|
||||||
try {
|
try {
|
||||||
Array.prototype.slice
|
toArray(host.querySelectorAll(ANCHORED_SELECTOR))
|
||||||
.call(host.querySelectorAll(ANCHORED_SELECTOR))
|
|
||||||
.forEach(clampAnchored);
|
.forEach(clampAnchored);
|
||||||
} finally {
|
} finally {
|
||||||
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
||||||
@@ -3552,8 +3629,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// therefore no client-side binding to retain; consume its compiler markers
|
// therefore no client-side binding to retain; consume its compiler markers
|
||||||
// separately from component hydration.
|
// separately from component hydration.
|
||||||
if (host === document || host === document.documentElement) {
|
if (host === document || host === document.documentElement) {
|
||||||
Array.prototype.slice
|
toArray(document.documentElement.attributes)
|
||||||
.call(document.documentElement.attributes)
|
|
||||||
.forEach(function (attribute) {
|
.forEach(function (attribute) {
|
||||||
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
||||||
document.documentElement.removeAttribute(attribute.name);
|
document.documentElement.removeAttribute(attribute.name);
|
||||||
@@ -4171,6 +4247,72 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Transport for compiled api blocks.
|
||||||
|
*
|
||||||
|
* Only the request and the failure shape live here. A block's response and
|
||||||
|
* error bodies are page code, so they are emitted into the browser module
|
||||||
|
* and applied by the caller.
|
||||||
|
*/
|
||||||
|
function readCsrfToken() {
|
||||||
|
var meta = document.querySelector('meta[name="wrnexus-csrf"]');
|
||||||
|
if (meta) return meta.getAttribute("content") || "";
|
||||||
|
var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrnexusCallApi(path, method, input) {
|
||||||
|
var verb = String(method || "GET").toUpperCase();
|
||||||
|
var values = input || {};
|
||||||
|
var url = path;
|
||||||
|
var headers = { accept: "application/json" };
|
||||||
|
var init = { method: verb, credentials: "same-origin", headers: headers };
|
||||||
|
|
||||||
|
if (verb === "GET" || verb === "HEAD") {
|
||||||
|
var query = [];
|
||||||
|
Object.keys(values).forEach(function (key) {
|
||||||
|
var value = values[key];
|
||||||
|
// An omitted filter must not become "name=undefined".
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)));
|
||||||
|
});
|
||||||
|
if (query.length) url = path + "?" + query.join("&");
|
||||||
|
} else {
|
||||||
|
headers["content-type"] = "application/json";
|
||||||
|
headers["x-csrf-token"] = readCsrfToken();
|
||||||
|
init.body = JSON.stringify(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, init).then(function (response) {
|
||||||
|
// A 2xx with no body (204/205, or a genuinely empty response) is a
|
||||||
|
// success, not a parse failure -- the failure table only calls for the
|
||||||
|
// error path on non-2xx, network failure, or an unparseable body.
|
||||||
|
if (response.ok && (response.status === 204 || response.status === 205)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return response.json().then(
|
||||||
|
function (data) {
|
||||||
|
if (response.ok) return data;
|
||||||
|
var message =
|
||||||
|
data && data.error ? String(data.error) : "Request failed with " + response.status;
|
||||||
|
var failure = new Error(message);
|
||||||
|
failure.status = response.status;
|
||||||
|
failure.data = data;
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
if (response.ok) return undefined;
|
||||||
|
var failure = new Error("Response was not valid JSON");
|
||||||
|
failure.status = response.status;
|
||||||
|
failure.data = undefined;
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__wrnexusCallApi = wrnexusCallApi;
|
||||||
|
|
||||||
function dispatchComponentEvent(root, name, detail) {
|
function dispatchComponentEvent(root, name, detail) {
|
||||||
if (!root || !name) return null;
|
if (!root || !name) return null;
|
||||||
var EventConstructor =
|
var EventConstructor =
|
||||||
@@ -4195,7 +4337,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function emitPinInputEvent(root, name, extra) {
|
function emitPinInputEvent(root, name, extra) {
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var value = hidden ? hidden.value : "";
|
var value = hidden ? hidden.value : "";
|
||||||
var detail = {
|
var detail = {
|
||||||
component: "PinInput",
|
component: "PinInput",
|
||||||
@@ -4217,7 +4359,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
||||||
function setupPinInputController(root) {
|
function setupPinInputController(root) {
|
||||||
if (!root || root.__wrnexusPinInputController) return;
|
if (!root || root.__wrnexusPinInputController) return;
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var clearButton = root.querySelector("[data-pin-clear]");
|
var clearButton = root.querySelector("[data-pin-clear]");
|
||||||
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
||||||
@@ -4651,12 +4793,68 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse a while or for statement into its parts.
|
||||||
|
*
|
||||||
|
* Returns null for anything else so the caller falls through to the other
|
||||||
|
* statement forms. A for header is split on top-level semicolons only, so a
|
||||||
|
* semicolon inside a call argument or a string does not break it.
|
||||||
|
*/
|
||||||
|
function parseLoopStatement(source) {
|
||||||
|
source = String(source || "").trim();
|
||||||
|
|
||||||
|
var kind = null;
|
||||||
|
|
||||||
|
if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
|
||||||
|
kind = "while";
|
||||||
|
} else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
|
||||||
|
kind = "for";
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "(") return null;
|
||||||
|
|
||||||
|
var headerEnd = findClosingDelimiter(source, index, "(", ")");
|
||||||
|
|
||||||
|
if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
|
||||||
|
|
||||||
|
var header = source.slice(index + 1, headerEnd);
|
||||||
|
|
||||||
|
index = skipStatementWhitespace(source, headerEnd + 1);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
|
||||||
|
|
||||||
|
var bodyEnd = findClosingDelimiter(source, index, "{", "}");
|
||||||
|
|
||||||
|
if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
|
||||||
|
|
||||||
|
var body = source.slice(index + 1, bodyEnd);
|
||||||
|
|
||||||
|
if (kind === "while") {
|
||||||
|
return { init: null, condition: header.trim(), step: null, body: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = splitTopLevel(header, ";");
|
||||||
|
|
||||||
|
if (parts.length !== 3) throw new Error("A for header needs three parts");
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: parts[0].trim(),
|
||||||
|
condition: parts[1].trim(),
|
||||||
|
step: parts[2].trim(),
|
||||||
|
body: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
function runStatement(
|
function runStatement(
|
||||||
stmt,
|
stmt,
|
||||||
evalExpr,
|
evalExpr,
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
runBlock,
|
runBlock,
|
||||||
|
declare,
|
||||||
) {
|
) {
|
||||||
stmt = String(stmt || "").trim();
|
stmt = String(stmt || "").trim();
|
||||||
|
|
||||||
@@ -4707,6 +4905,60 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A loop body is author-written and runs in the browser, so a mistaken
|
||||||
|
* condition would freeze the tab. The cap keeps a runaway loop from
|
||||||
|
* hanging the page; it is far above any list a view renders.
|
||||||
|
*/
|
||||||
|
var loop = parseLoopStatement(stmt);
|
||||||
|
|
||||||
|
if (loop) {
|
||||||
|
var guard = 0;
|
||||||
|
|
||||||
|
if (loop.init) {
|
||||||
|
runStatement(loop.init, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!loop.condition || !!evalExpr(loop.condition)) {
|
||||||
|
if (++guard > 100000) break;
|
||||||
|
|
||||||
|
var outcome = runBlock(loop.body);
|
||||||
|
|
||||||
|
if (outcome && outcome.returned) return outcome;
|
||||||
|
|
||||||
|
if (loop.step) {
|
||||||
|
runStatement(loop.step, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A declaration binds a local, then falls through to the assignment
|
||||||
|
* branch below.
|
||||||
|
*
|
||||||
|
* Declaring first is what makes it local: an unknown name reaching
|
||||||
|
* writeScope becomes a signal and triggers a render sweep, so a var
|
||||||
|
* inside a shared function called during a render would loop forever.
|
||||||
|
* Once the name exists in locals, read and write both stay there.
|
||||||
|
*/
|
||||||
|
var declaration = stmt.match(
|
||||||
|
/^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (declaration) {
|
||||||
|
stmt = declaration[1].trim();
|
||||||
|
|
||||||
|
var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
|
||||||
|
|
||||||
|
if (declare) declare(declaredName, undefined);
|
||||||
|
|
||||||
|
if (!/=/.test(stmt)) {
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var increment = stmt.match(
|
var increment = stmt.match(
|
||||||
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
||||||
);
|
);
|
||||||
@@ -5699,7 +5951,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
||||||
if (element.__wrnDynamicMounted) return;
|
if (element.__wrnDynamicMounted) return;
|
||||||
element.__wrnDynamicMounted = true;
|
element.__wrnDynamicMounted = true;
|
||||||
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
|
var cases = toArray(element.children).filter(function (candidate) {
|
||||||
return candidate.hasAttribute("data-component-case");
|
return candidate.hasAttribute("data-component-case");
|
||||||
}).map(function (candidate) {
|
}).map(function (candidate) {
|
||||||
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { expect, test, beforeEach } from "bun:test";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
url: string;
|
||||||
|
init: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
|
||||||
|
function harness(response: { status: number; payload: unknown }) {
|
||||||
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||||
|
const calls: Call[] = [];
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).document = win.document;
|
||||||
|
(globalThis as Record<string, unknown>).location = win.location;
|
||||||
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||||
|
win as unknown as { NodeFilter: unknown }
|
||||||
|
).NodeFilter;
|
||||||
|
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: response.status >= 200 && response.status < 300,
|
||||||
|
status: response.status,
|
||||||
|
json: () => Promise.resolve(response.payload),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
(0, eval)(REACTIVE_RUNTIME);
|
||||||
|
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||||
|
.__wrnexusCallApi;
|
||||||
|
return { callApi, calls, win };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("GET builds a query string and omits undefined fields", async () => {
|
||||||
|
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
|
||||||
|
|
||||||
|
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
|
||||||
|
|
||||||
|
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
|
||||||
|
expect(calls[0]!.init.method).toBe("GET");
|
||||||
|
expect(calls[0]!.init.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST sends a JSON body", async () => {
|
||||||
|
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
|
||||||
|
|
||||||
|
await callApi("/api/users", "POST", { name: "Ajay" });
|
||||||
|
|
||||||
|
expect(calls[0]!.url).toBe("/api/users");
|
||||||
|
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||||
|
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||||
|
win.document.cookie = "wrn-csrf=token-123";
|
||||||
|
|
||||||
|
await callApi("/api/users", "POST", {});
|
||||||
|
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 2xx resolves to the parsed payload", async () => {
|
||||||
|
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
|
||||||
|
|
||||||
|
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
|
||||||
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).document = win.document;
|
||||||
|
(globalThis as Record<string, unknown>).location = win.location;
|
||||||
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||||
|
win as unknown as { NodeFilter: unknown }
|
||||||
|
).NodeFilter;
|
||||||
|
(globalThis as Record<string, unknown>).fetch = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||||
|
});
|
||||||
|
|
||||||
|
(0, eval)(REACTIVE_RUNTIME);
|
||||||
|
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||||
|
.__wrnexusCallApi;
|
||||||
|
|
||||||
|
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
|
||||||
|
const { callApi } = harness({ status: 200, payload: undefined });
|
||||||
|
(globalThis as Record<string, unknown>).fetch = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-2xx rejects with status, message and data", async () => {
|
||||||
|
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||||
|
|
||||||
|
const failure = await callApi("/api/users", "GET", {}).catch(
|
||||||
|
(error: Error & { status?: number; data?: unknown }) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure.status).toBe(400);
|
||||||
|
expect(failure.message).toContain("Bad filter");
|
||||||
|
expect(failure.data).toEqual({ error: "Bad filter" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterAll } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore globals a suite replaces, once the suite is done.
|
||||||
|
*
|
||||||
|
* These suites install a happy-dom window over the real globals and delete
|
||||||
|
* them before each test so every test starts clean. bun test loads and runs
|
||||||
|
* one file at a time rather than importing them all up front, so anything left
|
||||||
|
* deleted is still missing when the next suite runs -- which is how `bun test`
|
||||||
|
* with no argument came to fail unrelated files with "fetch is not a
|
||||||
|
* function". Names absent at capture time are deleted again rather than being
|
||||||
|
* restored as undefined, so a global that never existed does not gain a key.
|
||||||
|
*/
|
||||||
|
export function restoreGlobalsAfterAll(names: readonly string[]): void {
|
||||||
|
const captured = new Map<string, unknown>(
|
||||||
|
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const [name, value] of captured) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
} else {
|
||||||
|
(globalThis as Record<string, unknown>)[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
let win: any;
|
let win: any;
|
||||||
let fetchCalls: { url: string; opts: any }[];
|
let fetchCalls: { url: string; opts: any }[];
|
||||||
@@ -36,18 +37,22 @@ function install(bodyHtml: string): void {
|
|||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = [
|
||||||
|
"window",
|
||||||
|
"document",
|
||||||
|
"history",
|
||||||
|
"location",
|
||||||
|
"DOMParser",
|
||||||
|
"CustomEvent",
|
||||||
|
"Event",
|
||||||
|
"fetch",
|
||||||
|
];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const g = globalThis as any;
|
const g = globalThis as any;
|
||||||
for (const k of [
|
for (const k of REPLACED_GLOBALS) {
|
||||||
"window",
|
|
||||||
"document",
|
|
||||||
"history",
|
|
||||||
"location",
|
|
||||||
"DOMParser",
|
|
||||||
"CustomEvent",
|
|
||||||
"Event",
|
|
||||||
"fetch",
|
|
||||||
]) {
|
|
||||||
delete g[k];
|
delete g[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,7 +153,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
|||||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
@@ -180,7 +185,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
|||||||
};
|
};
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
|
|||||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
||||||
import { mountHtml } from "@wrnexus/test";
|
import { mountHtml } from "@wrnexus/test";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
// Fresh DOM per test, with the runtime's globals bound.
|
// Fresh DOM per test, with the runtime's globals bound.
|
||||||
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
||||||
@@ -34,12 +35,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete (globalThis as Record<string, unknown>).window;
|
for (const name of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>).document;
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
delete (globalThis as Record<string, unknown>).location;
|
}
|
||||||
delete (globalThis as Record<string, unknown>).fetch;
|
|
||||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("split runtime hydrates a controller only from the controller asset", () => {
|
test("split runtime hydrates a controller only from the controller asset", () => {
|
||||||
@@ -117,6 +120,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
|
|||||||
expect(btn.textContent).toBe("2");
|
expect(btn.textContent).toBe("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compiled if blocks switch branches after hydration", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
|
||||||
|
{ cond: null, body: '<p class="closed">Closed</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="open: false, count: 2">` +
|
||||||
|
`<button data-on-click="open = !open">toggle</button>` +
|
||||||
|
`<button data-on-click="count++">increment</button>` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".closed")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compiled each blocks rerender rows and their empty branch", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "items",
|
||||||
|
item: "item",
|
||||||
|
index: "index",
|
||||||
|
body: '<p class="row">{index}:{item}</p>',
|
||||||
|
empty: '<p class="empty">Empty</p>',
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="items: ['a']">` +
|
||||||
|
`<button data-on-click="items = ['b', 'c']">more</button>` +
|
||||||
|
`<button data-on-click="items = []">clear</button>` +
|
||||||
|
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelectorAll("button")[0]!.click();
|
||||||
|
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
|
||||||
|
["0:b", "1:c"],
|
||||||
|
);
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".row")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
|
||||||
|
});
|
||||||
|
|
||||||
test("component functions support formatted multiline assignments and ternaries", () => {
|
test("component functions support formatted multiline assignments and ternaries", () => {
|
||||||
const behavior = Buffer.from(
|
const behavior = Buffer.from(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -1582,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
|
|||||||
);
|
);
|
||||||
expect(seen).toEqual([60]);
|
expect(seen).toEqual([60]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("control blocks created by a client rerender render their own content", () => {
|
||||||
|
// A nested block that arrives with the server HTML is hydrated: its first
|
||||||
|
// reactive pass must NOT redraw, or it would throw away server DOM. A nested
|
||||||
|
// block created later by an outer rerender has no server DOM, so skipping its
|
||||||
|
// first pass leaves it permanently empty — its dependencies never change
|
||||||
|
// again to trigger a second one.
|
||||||
|
const inner = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "g.rows.length > 0", body: '<p class="has-rows">HAS</p>' },
|
||||||
|
{ cond: null, body: '<p class="no-rows">NONE</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const outer = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "groups",
|
||||||
|
item: "g",
|
||||||
|
body:
|
||||||
|
`<section class="group"><span data-text="g.name">{g.name}</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
|
||||||
|
`</section>`,
|
||||||
|
empty: "",
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
|
||||||
|
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
|
||||||
|
`<template data-wrn-each="${outer}"></template>` +
|
||||||
|
`<section class="group"><span data-text="g.name">g1</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
|
||||||
|
`<template data-wrn-control-end></template></section>` +
|
||||||
|
`<template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
// The outer each rerendered: the new group's heading is present.
|
||||||
|
expect(win.document.querySelector(".group span")?.textContent).toBe("g2");
|
||||||
|
// The nested if inside that new row must have rendered its else branch.
|
||||||
|
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
|
||||||
|
expect(win.document.querySelector(".has-rows")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a for loop with a declaration initialiser runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="total: 0">` +
|
||||||
|
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
|
||||||
|
`<span data-text="total">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a while loop runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 1">` +
|
||||||
|
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
|
||||||
|
`<span data-text="n">1</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("16");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration stays local instead of becoming reactive state", () => {
|
||||||
|
// An unknown name reaching writeScope becomes a signal and triggers a render
|
||||||
|
// sweep. A var inside a function called during a render would then loop
|
||||||
|
// forever, so declarations must bind locally.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span class="out" data-text="out">0</span>` +
|
||||||
|
`<span class="leak" data-text="step"></span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".out")?.textContent).toBe("6");
|
||||||
|
expect(win.document.querySelector(".leak")?.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a control block removed from the DOM does not abort later renders", () => {
|
||||||
|
// Its effect stays in the renderers list. Running it against a detached node
|
||||||
|
// throws, which would abort the sweep and leave every later effect stale.
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
|
||||||
|
`<button data-on-click="n = n + 1">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const block = win.document.querySelector("[data-wrn-if]")!;
|
||||||
|
block.parentNode!.removeChild(block);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration statement assigns into scope", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span data-text="out">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unbounded loop stops instead of hanging the page", () => {
|
||||||
|
// Handler source is author-controlled and runs in the browser. Without a cap
|
||||||
|
// a mistaken condition freezes the tab with no way back.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
const value = Number(win.document.querySelector("span")?.textContent);
|
||||||
|
expect(value).toBeGreaterThan(0);
|
||||||
|
expect(Number.isFinite(value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a class binding inside data-for follows state the row never mentions", () => {
|
||||||
|
// The row's own array is untouched, so nothing rebuilds the list. The
|
||||||
|
// binding has to be reactive in its own right to keep up.
|
||||||
|
const binding = JSON.stringify(["is-active", "selected === row.id"]);
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="rows: [{"id":1},{"id":2}], selected: 1">` +
|
||||||
|
`<button data-on-click="selected = 2">pick</button>` +
|
||||||
|
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const items = () => Array.from(win.document.querySelectorAll("li"));
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(true);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(false);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(false);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||||
let sockets: FakeWS[];
|
let sockets: FakeWS[];
|
||||||
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>)[k];
|
delete (globalThis as Record<string, unknown>)[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.14",
|
"version": "0.8.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,11 +226,16 @@ export function generateQueriesFile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imports = [
|
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
|
||||||
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
|
|
||||||
];
|
|
||||||
if (usedModels.size > 0) {
|
if (usedModels.size > 0) {
|
||||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
||||||
}
|
}
|
||||||
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
|
// The dialect is stamped into the header because it changes the emitted SQL:
|
||||||
|
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
|
||||||
|
// under a different profile therefore rewrites this committed file, and
|
||||||
|
// without the stamp the diff looks like unexplained churn.
|
||||||
|
return (
|
||||||
|
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
|
||||||
|
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ function additiveColumnTarget(sql: string): { table: string; column: string } |
|
|||||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||||
.replace(/--[^\r\n]*/g, " ")
|
.replace(/--[^\r\n]*/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
|
const match =
|
||||||
executable,
|
/^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
|
||||||
);
|
executable,
|
||||||
|
);
|
||||||
return match ? { table: match[1]!, column: match[2]! } : undefined;
|
return match ? { table: match[1]!, column: match[2]! } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
|
||||||
|
|
||||||
|
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
|
||||||
|
|
||||||
|
test("the generated header records the dialect it was built for", () => {
|
||||||
|
// The same command emits different SQL per dialect, so a build under another
|
||||||
|
// profile rewrites the committed file. The stamp makes that visible in the
|
||||||
|
// diff instead of looking like unexplained churn.
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placeholder style follows the dialect", () => {
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation is deterministic for a fixed dialect", () => {
|
||||||
|
const first = generateQueriesFile(queries, [], "postgres");
|
||||||
|
const second = generateQueriesFile(queries, [], "postgres");
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.33",
|
"version": "0.8.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type { AssetServer } from "./runtime.ts";
|
|||||||
import { servePublicAsset } from "./public.ts";
|
import { servePublicAsset } from "./public.ts";
|
||||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||||
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||||
|
import { HMR_CLIENT_HREF, HMR_CLIENT_JS } from "./runtime.ts";
|
||||||
|
|
||||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||||
export interface DevStyles {
|
export interface DevStyles {
|
||||||
@@ -99,6 +100,7 @@ export function createDevAssetServer(
|
|||||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (pathname === HMR_CLIENT_HREF) return jsResponse(HMR_CLIENT_JS);
|
||||||
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
||||||
if (pathname === "/__wrnexus/controllers.js")
|
if (pathname === "/__wrnexus/controllers.js")
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
|
|||||||
interface WsBridge {
|
interface WsBridge {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
backend?: WebSocket;
|
backend?: WebSocket;
|
||||||
queue: Array<string | ArrayBuffer>;
|
queue: Array<string | ArrayBuffer>;
|
||||||
maxMessageBytes: number;
|
maxMessageBytes: number;
|
||||||
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
|||||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayWebSocketOriginAllowed(
|
export function gatewayWebSocketOriginAllowed(
|
||||||
req: Request,
|
req: Request,
|
||||||
target: Target,
|
target: Target,
|
||||||
configured: string[],
|
configured: string[],
|
||||||
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
|||||||
}
|
}
|
||||||
if (configured.includes(origin)) return true;
|
if (configured.includes(origin)) return true;
|
||||||
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
||||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
// Compare hostnames, not hosts: configured domains carry no port, while the
|
||||||
|
// browser's Origin does. publicOrigin above only ever matches domains[0], so
|
||||||
|
// every other domain fell through to here and was denied purely on the port,
|
||||||
|
// which left the HMR socket reconnecting forever on those hosts.
|
||||||
|
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,6 +438,30 @@ export function gatewayProxyHeaders(
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward application identity to the child while leaving WebSocket framing to Bun. */
|
||||||
|
export function gatewayWebSocketBackendHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
headers.delete("upgrade");
|
||||||
|
headers.delete("accept-encoding");
|
||||||
|
for (const name of [...headers.keys()]) {
|
||||||
|
if (name.startsWith("sec-websocket-")) headers.delete(name);
|
||||||
|
}
|
||||||
|
// The public origin was validated at the gateway edge. The child receives a
|
||||||
|
// new, trusted same-origin connection from its private gateway listener.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
return Object.fromEntries(headers);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove headers that only a direct workspace-to-app request may supply. */
|
/** Remove headers that only a direct workspace-to-app request may supply. */
|
||||||
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||||
const sanitized = new Headers(headers);
|
const sanitized = new Headers(headers);
|
||||||
@@ -441,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reserved inter-app RPC namespace is refused at the gateway edge, before
|
* Private inter-app RPC routes are refused at the gateway edge. The exact
|
||||||
* any proxying — it is only ever mounted by a child app's own dev-server and
|
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
|
||||||
* must never be reachable from outside the workspace.
|
* intentionally proxied to the selected child app.
|
||||||
*/
|
*/
|
||||||
export function isRpcGatewayPath(pathname: string): boolean {
|
export function isRpcGatewayPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === RPC_PATH_PREFIX ||
|
|
||||||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
||||||
pathname === RPC_STREAM_PATH_PREFIX ||
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
||||||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the trusted internal hop for a browser server-function request. */
|
||||||
|
export function gatewayBrowserRpcHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Headers {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
// The public request already passed the gateway's host and fetch-metadata
|
||||||
|
// checks. Present the internal proxy hop as same-origin to the child while
|
||||||
|
// retaining the double-submit CSRF cookie and header.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
headers.delete("host");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot every app as a child process, then route by Host on one gateway port. */
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
||||||
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
||||||
const port = opts.port ?? 3000;
|
const port = opts.port ?? 3000;
|
||||||
@@ -468,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
);
|
);
|
||||||
// Loopback-only origins, computed up front (ports are assigned by index
|
// Loopback-only origins, computed up front (ports are assigned by index
|
||||||
// before any child spawns) so every child can reach every other child
|
// before any child spawns) so every child can reach every other child
|
||||||
// directly — bypassing the gateway, which 404s the RPC prefix by design.
|
// directly — bypassing the gateway, which 404s private nested RPC routes.
|
||||||
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
||||||
@@ -692,6 +739,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
data: {
|
data: {
|
||||||
origin: target.origin,
|
origin: target.origin,
|
||||||
path: url.pathname + url.search,
|
path: url.pathname + url.search,
|
||||||
|
headers: gatewayWebSocketBackendHeaders(
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
ip,
|
||||||
|
forwardedHeaders,
|
||||||
|
target.origin,
|
||||||
|
),
|
||||||
queue: [],
|
queue: [],
|
||||||
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
||||||
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
||||||
@@ -701,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
||||||
const headers = stripUntrustedInternalHeaders(
|
const headers =
|
||||||
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
url.pathname === RPC_PATH_PREFIX
|
||||||
);
|
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
|
||||||
|
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
|
||||||
const body =
|
const body =
|
||||||
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -753,7 +808,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
websocket: {
|
websocket: {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
||||||
const backend = new WebSocket(backendUrl);
|
const BackendWebSocket = WebSocket as unknown as new (
|
||||||
|
url: string,
|
||||||
|
options: Bun.WebSocketOptions,
|
||||||
|
) => WebSocket;
|
||||||
|
const backend = new BackendWebSocket(backendUrl, { headers: ws.data.headers });
|
||||||
ws.data.backend = backend;
|
ws.data.backend = backend;
|
||||||
backend.addEventListener("open", () => {
|
backend.addEventListener("open", () => {
|
||||||
for (const m of ws.data.queue) backend.send(m);
|
for (const m of ws.data.queue) backend.send(m);
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ import {
|
|||||||
wrnBrowserArtifactUrlAsync,
|
wrnBrowserArtifactUrlAsync,
|
||||||
} from "./pipeline.ts";
|
} from "./pipeline.ts";
|
||||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||||
|
import { createRecycleMonitor } from "./recycle.ts";
|
||||||
|
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
|
||||||
|
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
|
||||||
|
* session reaches, and far less than what makes the server crawl. Set
|
||||||
|
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
|
||||||
|
*/
|
||||||
|
const RECYCLE_REBUILD_THRESHOLD = (() => {
|
||||||
|
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
|
||||||
|
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
|
||||||
|
})();
|
||||||
|
/** Quiet period required first, so a recycle never interrupts a live request. */
|
||||||
|
const RECYCLE_IDLE_MS = 10_000;
|
||||||
|
const RECYCLE_CHECK_MS = 5_000;
|
||||||
import { createHandlers, type WsData } from "./runtime.ts";
|
import { createHandlers, type WsData } from "./runtime.ts";
|
||||||
import { createDevAssetServer } from "./assets.ts";
|
import { createDevAssetServer } from "./assets.ts";
|
||||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||||
@@ -440,10 +456,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
config: opts.stylesConfig,
|
config: opts.stylesConfig,
|
||||||
appRoot,
|
appRoot,
|
||||||
publicDir: join(appRoot, "public"),
|
publicDir: join(appRoot, "public"),
|
||||||
sources: [
|
// Component discovery is separate from utility-source discovery.
|
||||||
...componentDirs,
|
// Packages that need Tailwind scanning opt in via styles.source.
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||||
],
|
|
||||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
theme,
|
theme,
|
||||||
@@ -589,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
validateCsrf: validateRpcCsrf,
|
validateCsrf: validateRpcCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
|
||||||
|
* modules, so only dev needs to recycle.
|
||||||
|
*/
|
||||||
|
const recycle =
|
||||||
|
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
|
||||||
|
? createRecycleMonitor({
|
||||||
|
threshold: RECYCLE_REBUILD_THRESHOLD,
|
||||||
|
idleMs: RECYCLE_IDLE_MS,
|
||||||
|
onRecycle(reason) {
|
||||||
|
console.log(`[wrnexus] ${reason}`);
|
||||||
|
process.exit(RESTART_EXIT_CODE);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const server = Bun.serve<WsData>({
|
const server = Bun.serve<WsData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -596,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
maxRequestBodySize: 10 * 1024 * 1024,
|
maxRequestBodySize: 10 * 1024 * 1024,
|
||||||
...(opts.tls ? { tls: opts.tls } : {}),
|
...(opts.tls ? { tls: opts.tls } : {}),
|
||||||
fetch(request, server) {
|
fetch(request, server) {
|
||||||
|
recycle?.recordRequest(Date.now());
|
||||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||||
return handlers.fetch(request, server);
|
return handlers.fetch(request, server);
|
||||||
},
|
},
|
||||||
websocket: handlers.websocket,
|
websocket: handlers.websocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recycle) {
|
||||||
|
// unref so a pending check never keeps the process alive on its own.
|
||||||
|
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pluginRunner.hook("configureServer", {
|
await pluginRunner.hook("configureServer", {
|
||||||
server,
|
server,
|
||||||
@@ -627,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
||||||
|
recycle?.recordRebuild();
|
||||||
}
|
}
|
||||||
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
buildIslands,
|
buildIslands,
|
||||||
islandNamesFrom,
|
islandNamesFrom,
|
||||||
resolveWrnImports,
|
resolveWrnImports,
|
||||||
|
stripBrowserTypes,
|
||||||
type PageAst,
|
type PageAst,
|
||||||
type ViewNode,
|
type ViewNode,
|
||||||
} from "@wrnexus/compiler";
|
} from "@wrnexus/compiler";
|
||||||
@@ -58,9 +59,28 @@ export function runMiddleware(
|
|||||||
*/
|
*/
|
||||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||||
const moduleVersions = new Map<string, number>();
|
const moduleVersions = new Map<string, number>();
|
||||||
|
/*
|
||||||
|
* Artifact URLs carry a content hash, so a rebuild registers a new key and the
|
||||||
|
* previous one is never requested again -- left alone these grow for the life
|
||||||
|
* of the dev server. Bounded rather than cleared on rebuild because a page
|
||||||
|
* already mid-load may still ask for the URL it was served.
|
||||||
|
*/
|
||||||
|
const ARTIFACT_PATH_LIMIT = 512;
|
||||||
const browserArtifactPaths = new Map<string, string>();
|
const browserArtifactPaths = new Map<string, string>();
|
||||||
const islandArtifactPaths = new Map<string, string>();
|
const islandArtifactPaths = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberArtifact(paths: Map<string, string>, pathname: string, artifact: string): void {
|
||||||
|
// Re-insert so a key still in use is treated as recent.
|
||||||
|
paths.delete(pathname);
|
||||||
|
paths.set(pathname, artifact);
|
||||||
|
|
||||||
|
while (paths.size > ARTIFACT_PATH_LIMIT) {
|
||||||
|
const oldest = paths.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
paths.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||||
interface CompileImportOptions {
|
interface CompileImportOptions {
|
||||||
mode: ImportMode;
|
mode: ImportMode;
|
||||||
@@ -611,6 +631,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
||||||
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
||||||
if (target === "browser") {
|
if (target === "browser") {
|
||||||
|
transformed = stripBrowserTypes(transformed);
|
||||||
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
||||||
}
|
}
|
||||||
writeFileSync(artifacts[target], transformed, "utf8");
|
writeFileSync(artifacts[target], transformed, "utf8");
|
||||||
@@ -622,7 +643,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
);
|
);
|
||||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
compileMetrics.compilations++;
|
compileMetrics.compilations++;
|
||||||
return artifacts;
|
return artifacts;
|
||||||
})().finally(() => asyncCompileInProgress.delete(key));
|
})().finally(() => asyncCompileInProgress.delete(key));
|
||||||
@@ -673,7 +694,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
.map(([, path]) => path);
|
.map(([, path]) => path);
|
||||||
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
||||||
compileMetrics.hits++;
|
compileMetrics.hits++;
|
||||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||||
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
// 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.
|
// since, and after a restart with a warm cache nothing else would build them.
|
||||||
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
||||||
@@ -712,10 +733,10 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
writeFileSync(artifacts.main, mainCode, "utf8");
|
writeFileSync(artifacts.main, mainCode, "utf8");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.browser,
|
artifacts.browser,
|
||||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
stripBrowserTypes(rewriteArtifactImports(targets.browser, result.ast, file, "browser")),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.server,
|
artifacts.server,
|
||||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||||
@@ -775,7 +796,7 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
|||||||
|
|
||||||
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
||||||
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
||||||
islandArtifactPaths.set(pathname, artifact);
|
rememberArtifact(islandArtifactPaths, pathname, artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Recycle the dev server once hot rebuilds have piled up.
|
||||||
|
*
|
||||||
|
* Every rebuild of a `.wrn` file has to be given a new module identity,
|
||||||
|
* because Bun caches modules by path and would otherwise serve the old one.
|
||||||
|
* Bun has no API to unload a module, so each rebuild retains its predecessor
|
||||||
|
* for the life of the process -- measured at roughly 0.66 MB per rebuild,
|
||||||
|
* while edits that mint no new module (CSS) cost nothing. Over a long session
|
||||||
|
* that is the difference between a fast dev server and a stuck one.
|
||||||
|
*
|
||||||
|
* The process therefore recycles itself: the child exits with
|
||||||
|
* RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on
|
||||||
|
* their own because the HMR client already retries.
|
||||||
|
*
|
||||||
|
* Recycling is deferred until the server has been idle for a moment, so it
|
||||||
|
* never interrupts a request in flight. The cost is that in-memory state
|
||||||
|
* (realtime rooms, warmed caches) resets at that point, which is why the
|
||||||
|
* threshold is high enough that an ordinary editing session never reaches it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecycleMonitorOptions {
|
||||||
|
/** Retained rebuilds tolerated before a recycle is armed. */
|
||||||
|
threshold: number;
|
||||||
|
/** Quiet period required before recycling, in milliseconds. */
|
||||||
|
idleMs: number;
|
||||||
|
onRecycle: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleMonitor {
|
||||||
|
/** Count one rebuild that retained a module version. */
|
||||||
|
recordRebuild(): void;
|
||||||
|
/** Note that a request was served, at `now`. */
|
||||||
|
recordRequest(now: number): void;
|
||||||
|
/** Recycle if the threshold is passed and the server has gone quiet. */
|
||||||
|
tick(now: number): void;
|
||||||
|
retained(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor {
|
||||||
|
let rebuilds = 0;
|
||||||
|
let lastRequestAt: number | null = null;
|
||||||
|
let recycled = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordRebuild() {
|
||||||
|
rebuilds++;
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRequest(now: number) {
|
||||||
|
lastRequestAt = now;
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(now: number) {
|
||||||
|
if (recycled) return;
|
||||||
|
if (rebuilds < options.threshold) return;
|
||||||
|
// A server that has served nothing is idle by definition.
|
||||||
|
if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return;
|
||||||
|
|
||||||
|
recycled = true;
|
||||||
|
options.onRecycle(
|
||||||
|
`${rebuilds} hot rebuilds retained; restarting to release the memory they hold`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
retained() {
|
||||||
|
return rebuilds;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
var i18nScript = Array.prototype.find.call(
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
doc.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (i18nScript) {
|
if (i18nScript) {
|
||||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
try {
|
||||||
if (i18nMatch) {
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
try {
|
var existingI18n = window.__wrnI18n || {};
|
||||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
incomingI18n.t = existingI18n.t;
|
||||||
var existingI18n = window.__wrnI18n || {};
|
incomingI18n.set = existingI18n.set;
|
||||||
incomingI18n.t = existingI18n.t;
|
window.__wrnI18n = incomingI18n;
|
||||||
incomingI18n.set = existingI18n.set;
|
} catch (error) {
|
||||||
window.__wrnI18n = incomingI18n;
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
} catch (error) {
|
|
||||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,8 +872,19 @@ function randomNonce(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
||||||
function hmrClientTag(nonce: string): string {
|
/** Path the dev asset server publishes the HMR client on. */
|
||||||
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
|
export const HMR_CLIENT_HREF = "/__wrnexus/hmr-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HMR client is served as an external module rather than inlined.
|
||||||
|
*
|
||||||
|
* A document's CSP nonce is fixed at load, so an inline script arriving from a
|
||||||
|
* later response — which is exactly what an HMR reload produces — can never
|
||||||
|
* carry a nonce this document accepts. An external file is covered by
|
||||||
|
* script-src 'self' and needs no nonce at all.
|
||||||
|
*/
|
||||||
|
function hmrClientTag(_nonce: string): string {
|
||||||
|
return `<script src="${HMR_CLIENT_HREF}"></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 403 for a rejected cross-site WebSocket handshake. */
|
/** 403 for a rejected cross-site WebSocket handshake. */
|
||||||
@@ -1967,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
name: "web",
|
||||||
|
origin: "http://127.0.0.1:3101",
|
||||||
|
domains: ["localhost", "web.localhost"],
|
||||||
|
publicOrigin: "http://localhost:3000",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function upgrade(origin: string, host: string): Request {
|
||||||
|
return new Request("http://" + host + "/__wrnexus/hmr", {
|
||||||
|
headers: { origin, host, upgrade: "websocket" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows an upgrade from the app's primary domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an upgrade from a secondary domain on a non-default port", () => {
|
||||||
|
// publicOrigin is built from domains[0], so a browser on web.localhost falls
|
||||||
|
// through to the domain list — where the origin host still carries :3000 and
|
||||||
|
// the configured domain does not. That mismatch denied every HMR socket on
|
||||||
|
// any domain but the first, leaving the client reconnecting forever.
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://web.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies an unrelated origin", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://evil.example:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies a lookalike suffix domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
defaultGatewayHostname,
|
defaultGatewayHostname,
|
||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
|
gatewayBrowserRpcHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
stripUntrustedInternalHeaders,
|
||||||
gatewayRestartDelay,
|
gatewayRestartDelay,
|
||||||
internalError,
|
internalError,
|
||||||
@@ -37,6 +39,35 @@ test("gateway disables compression for its internal proxy hop", () => {
|
|||||||
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gateway WebSocket bridge forwards validated application identity", () => {
|
||||||
|
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "session=abc",
|
||||||
|
connection: "Upgrade",
|
||||||
|
upgrade: "websocket",
|
||||||
|
"sec-websocket-key": "test-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayWebSocketBackendHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.origin).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.cookie).toBe("session=abc");
|
||||||
|
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
|
||||||
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
||||||
|
expect(headers.host).toBeUndefined();
|
||||||
|
expect(headers.connection).toBeUndefined();
|
||||||
|
expect(headers.upgrade).toBeUndefined();
|
||||||
|
expect(headers["sec-websocket-key"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
||||||
const request = new Request("http://localhost:3000/path", {
|
const request = new Request("http://localhost:3000/path", {
|
||||||
headers: { "x-wrnexus-internal": "1" },
|
headers: { "x-wrnexus-internal": "1" },
|
||||||
@@ -146,13 +177,40 @@ test("nested SSO proxy keeps the protected app's original request headers", () =
|
|||||||
expect(proxied.get("x-original-uri")).toBe("/settings");
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
|
test("the gateway proxies browser server functions but refuses private RPC routes", () => {
|
||||||
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
|
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false);
|
||||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("browser RPC proxy preserves CSRF credentials and trusts only the internal hop", () => {
|
||||||
|
const request = new Request(`http://web.localhost:3000${RPC_PATH_PREFIX}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "wrn-csrf=token",
|
||||||
|
"x-csrf-token": "token",
|
||||||
|
[RPC_INTERNAL_HEADER]: "forged",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayBrowserRpcHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.get("origin")).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.get("cookie")).toBe("wrn-csrf=token");
|
||||||
|
expect(headers.get("x-csrf-token")).toBe("token");
|
||||||
|
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
|
||||||
|
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
|
||||||
|
expect(headers.has("host")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
RPC_INTERNAL_HEADER,
|
RPC_INTERNAL_HEADER,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRecycleMonitor } from "../src/recycle.ts";
|
||||||
|
|
||||||
|
/** Fresh monitor with a small threshold so tests stay readable. */
|
||||||
|
function monitor(overrides: Partial<Parameters<typeof createRecycleMonitor>[0]> = {}) {
|
||||||
|
const recycled: string[] = [];
|
||||||
|
const control = createRecycleMonitor({
|
||||||
|
threshold: 3,
|
||||||
|
idleMs: 1000,
|
||||||
|
onRecycle: (reason) => recycled.push(reason),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { control, recycled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stays quiet below the rebuild threshold", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
control.tick(10_000);
|
||||||
|
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles once rebuilds pass the threshold and the server goes idle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(1_500);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for the idle gap rather than cutting off active work", () => {
|
||||||
|
// Recycling mid-request would drop it. The gap is the whole point.
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(500);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.recordRequest(900);
|
||||||
|
control.tick(1_400);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.tick(2_000);
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles only once even if it keeps being ticked", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(5_000);
|
||||||
|
control.tick(6_000);
|
||||||
|
control.tick(7_000);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server that never served a request can still recycle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.tick(9_999);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports how many rebuilds are being retained", () => {
|
||||||
|
const { control } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
|
||||||
|
expect(control.retained()).toBe(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.9",
|
"version": "0.8.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
|||||||
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
|
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
|
||||||
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
|
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
|
||||||
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
|
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
|
||||||
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsBytes=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)).reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>150000)found.push(issue("performance/javascript-budget","javascript",jsBytes>300000?"error":"warning","JavaScript budget exceeded","JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB.",null,"Split routes and defer optional hydration."));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
|
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsResources=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)&&!/\/__wrnexus\/dev-toolbar\.js(?:\?|$)/.test(e.name));const jsBytes=jsResources.reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>300000)found.push(issue("performance/javascript-budget","javascript",jsBytes>600000?"error":"warning","Development JavaScript is large","Application JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB in development.",null,"Check the production build report before splitting routes; development modules are unminified.","medium",{javascriptBytes:jsBytes,mode:"development"}));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
|
||||||
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}if(unusedSelectors)found.push(issue("css/unused-selectors","css","suggestion","Potentially unused CSS",unusedSelectors+" of "+checkedSelectors+" inspected selectors do not match this page.",null,"Review across routes before removing selectors.","medium",{checkedSelectors,unusedSelectors}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
|
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){const href=sheet.href||"";if(/\/__wrnexus\/(?:ui|framework)\.css(?:\?|$)|\/__wrnexus\/theme\/[^/?]+\.css(?:\?|$)/.test(href))continue;let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}const unusedRatio=checkedSelectors?unusedSelectors/checkedSelectors:0;if(checkedSelectors>=20&&unusedRatio>=.8)found.push(issue("css/unused-selectors","css","suggestion","Low current-page CSS coverage",unusedSelectors+" of "+checkedSelectors+" inspected application selectors do not match this page.",null,"Review across routes before removing selectors. WRNexus UI and theme styles are excluded.","medium",{checkedSelectors,unusedSelectors,unusedRatio}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
|
||||||
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
|
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
|
||||||
const storeContainer=window.__wrnexusStoreContainer;
|
const storeContainer=window.__wrnexusStoreContainer;
|
||||||
if(storeContainer&&typeof storeContainer.inspect==="function"){
|
if(storeContainer&&typeof storeContainer.inspect==="function"){
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ test("exports usable development assets", () => {
|
|||||||
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
|
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
|
||||||
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
|
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
|
||||||
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
|
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("Low current-page CSS coverage");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("WRNexus UI and theme styles are excluded");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("checkedSelectors>=20&&unusedRatio>=.8");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("Development JavaScript is large");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("dev-toolbar\\.js");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("jsBytes>300000");
|
||||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
|
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
|
||||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
|
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/encryption",
|
"name": "@wrnexus/encryption",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*"
|
"@wrnexus/core": "workspace:*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.10",
|
"version": "0.8.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
|||||||
.replace(/\u2029/g, "\\u2029");
|
.replace(/\u2029/g, "\\u2029");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||||
|
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||||
|
*
|
||||||
|
* It ships inside a `type="application/json"` block, which the browser never
|
||||||
|
* executes, so `script-src` does not apply to it. As an inline executable
|
||||||
|
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||||
|
* from a different response, leaving window.__wrnI18n undefined.
|
||||||
|
*/
|
||||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||||
return `window.__wrnI18n=${safeJson({
|
return `${safeJson({
|
||||||
lang: active,
|
lang: active,
|
||||||
langs: i18n.langs,
|
langs: i18n.langs,
|
||||||
default: i18n.default,
|
default: i18n.default,
|
||||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
|||||||
directions: i18n.direction,
|
directions: i18n.direction,
|
||||||
labels: i18n.labels,
|
labels: i18n.labels,
|
||||||
cookie: i18n.cookie,
|
cookie: i18n.cookie,
|
||||||
})};`;
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full JSON block, including its script tag. */
|
||||||
|
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||||
|
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const I18N_RUNTIME = String.raw`
|
export const I18N_RUNTIME = String.raw`
|
||||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function state() { return window.__wrnI18n || {}; }
|
function readDataBlock() {
|
||||||
|
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function state() {
|
||||||
|
if (!window.__wrnI18n) {
|
||||||
|
var data = readDataBlock();
|
||||||
|
if (data) window.__wrnI18n = data;
|
||||||
|
}
|
||||||
|
return window.__wrnI18n || {};
|
||||||
|
}
|
||||||
function t(key, params) {
|
function t(key, params) {
|
||||||
var current = state();
|
var current = state();
|
||||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||||
|
|
||||||
|
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||||
|
|
||||||
|
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||||
|
const data = renderI18nData(i18n, "en");
|
||||||
|
expect(() => JSON.parse(data)).not.toThrow();
|
||||||
|
expect(data).not.toContain("window.__wrnI18n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data tag is a non-executable JSON block", () => {
|
||||||
|
// An executable inline script is subject to script-src and gets blocked
|
||||||
|
// whenever the document's CSP nonce came from a different response, which is
|
||||||
|
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||||
|
const tag = renderI18nDataTag(i18n, "es");
|
||||||
|
expect(tag).toContain('type="application/json"');
|
||||||
|
expect(tag).toContain("data-wrn-i18n");
|
||||||
|
expect(tag).not.toContain("nonce=");
|
||||||
|
expect(tag).toContain("Hola");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||||
|
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/image",
|
"name": "@wrnexus/image",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/jwt",
|
"name": "@wrnexus/jwt",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*"
|
"@wrnexus/core": "workspace:*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*"
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlRegion {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte ranges of the markup inside each `view { }` block.
|
||||||
|
*
|
||||||
|
* This is a tolerant scanner rather than the parser: completion fires while
|
||||||
|
* the document is being typed, which is exactly when it does not parse.
|
||||||
|
*/
|
||||||
|
export function viewRegions(text: string): HtmlRegion[] {
|
||||||
|
const regions: HtmlRegion[] = [];
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const end = matchingBrace(text, bodyStart);
|
||||||
|
regions.push({ start: bodyStart, end });
|
||||||
|
pattern.lastIndex = end;
|
||||||
|
}
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset of the brace closing the block that starts at `from`, or the end of
|
||||||
|
* the text when it is never closed (an unterminated block is normal mid-edit).
|
||||||
|
*
|
||||||
|
* Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
|
||||||
|
* would otherwise open a string that never closes and swallow the rest of the
|
||||||
|
* file.
|
||||||
|
*/
|
||||||
|
function matchingBrace(text: string, from: number): number {
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote: string | null = null;
|
||||||
|
|
||||||
|
for (let index = from; index < text.length; index += 1) {
|
||||||
|
const char = text[index]!;
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parallel document containing only the markup.
|
||||||
|
*
|
||||||
|
* Everything outside a view block becomes whitespace of the same length, and
|
||||||
|
* newlines are preserved, so an offset in the source is the same offset here.
|
||||||
|
* That removes the need for a mapping table entirely.
|
||||||
|
*/
|
||||||
|
export function virtualHtmlDocument(document: TextDocument): {
|
||||||
|
uri: string;
|
||||||
|
languageId: "html";
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
const source = document.text;
|
||||||
|
const keep = new Array<boolean>(source.length).fill(false);
|
||||||
|
for (const region of viewRegions(source)) {
|
||||||
|
for (let index = region.start; index < region.end; index += 1) keep[index] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
|
const char = source[index]!;
|
||||||
|
text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: `${document.uri}.html`, languageId: "html", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsideHtml(document: TextDocument, offset: number): boolean {
|
||||||
|
return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regions for a document, cached by uri and version.
|
||||||
|
*
|
||||||
|
* A single keystroke produces a burst of completion, hover, and tag-close
|
||||||
|
* requests; without this each one rescans the file.
|
||||||
|
*/
|
||||||
|
const regionCache = new Map<string, { version: number; regions: HtmlRegion[] }>();
|
||||||
|
|
||||||
|
function regionsFor(document: TextDocument): HtmlRegion[] {
|
||||||
|
// Documents without a version have no way to signal changes, so bypass cache.
|
||||||
|
if (document.version === undefined) {
|
||||||
|
return viewRegions(document.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = regionCache.get(document.uri);
|
||||||
|
if (cached && cached.version === document.version) return cached.regions;
|
||||||
|
|
||||||
|
const regions = viewRegions(document.text);
|
||||||
|
regionCache.set(document.uri, { version: document.version, regions });
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a document's cached regions. Call when a document closes. */
|
||||||
|
export function clearHtmlRegionCache(uri?: string): void {
|
||||||
|
if (uri) regionCache.delete(uri);
|
||||||
|
else regionCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// The default `main` entrypoint is a UMD bundle whose internal AMD-style
|
||||||
|
// `require("./parser/htmlScanner")` calls survive bundling literally instead
|
||||||
|
// of being inlined, so a bundled language server fails at runtime with
|
||||||
|
// "Cannot find module './parser/htmlScanner'". The ESM entrypoint bundles
|
||||||
|
// cleanly, so import it explicitly.
|
||||||
|
import {
|
||||||
|
getLanguageService,
|
||||||
|
TextDocument as HtmlTextDocument,
|
||||||
|
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
||||||
|
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
||||||
|
import { offsetAt, type Position, type TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlCompletionItem {
|
||||||
|
label: string;
|
||||||
|
kind: number;
|
||||||
|
detail?: string;
|
||||||
|
documentation?: string;
|
||||||
|
sortText?: string;
|
||||||
|
insertText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getLanguageService();
|
||||||
|
|
||||||
|
/** The virtual document as the HTML service's own document type. */
|
||||||
|
function htmlDocument(document: TextDocument) {
|
||||||
|
const virtual = virtualHtmlDocument(document);
|
||||||
|
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdown(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") return value || undefined;
|
||||||
|
if (value && typeof value === "object" && "value" in value) {
|
||||||
|
return String((value as { value: unknown }).value) || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML completions for a position inside a view block.
|
||||||
|
*
|
||||||
|
* Every item carries the `1` sortText prefix so the server can rank WRNexus
|
||||||
|
* entries above these without filtering either list.
|
||||||
|
*/
|
||||||
|
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const parsed = service.parseHTMLDocument(virtual);
|
||||||
|
const list = service.doComplete(virtual, position, parsed);
|
||||||
|
|
||||||
|
return list.items.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
kind: typeof item.kind === "number" ? item.kind : 1,
|
||||||
|
detail: item.detail,
|
||||||
|
documentation: markdown(item.documentation),
|
||||||
|
sortText: `1${item.sortText ?? item.label}`,
|
||||||
|
insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const contents = markdown(result.contents);
|
||||||
|
return contents ? { contents } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlFoldingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
): Array<{ startLine: number; endLine: number }> {
|
||||||
|
return service
|
||||||
|
.getFoldingRanges(htmlDocument(document))
|
||||||
|
.map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranges of the opening and closing tag names, so renaming one renames both. */
|
||||||
|
export function htmlLinkedEditingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
position: Position,
|
||||||
|
): Array<{ start: Position; end: Position }> | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const ranges = service.findLinkedEditingRanges(
|
||||||
|
virtual,
|
||||||
|
position,
|
||||||
|
service.parseHTMLDocument(virtual),
|
||||||
|
);
|
||||||
|
return ranges && ranges.length ? ranges : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects `<Name attr="x" /` just before `position` and completes the `>`.
|
||||||
|
*
|
||||||
|
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
|
||||||
|
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
|
||||||
|
* start tag, since plain HTML has no such elements outside its fixed void-element
|
||||||
|
* list. WRNexus components (`<Card />`) are exactly that case, so we complete
|
||||||
|
* it ourselves rather than relying on the library.
|
||||||
|
*
|
||||||
|
* The scan tracks quote state from the tag's opening `<` up to `offset` (quotes
|
||||||
|
* are only meaningful inside a tag) so a `/` inside an attribute value — e.g. the
|
||||||
|
* first slash of `href="https://..."` — never misfires as a self-close: the
|
||||||
|
* library already returns `null` there on purpose, because the cursor sits in an
|
||||||
|
* attribute value, not a tag-close position.
|
||||||
|
*/
|
||||||
|
function selfClosingTagCompletion(text: string, offset: number): string | null {
|
||||||
|
if (text.charAt(offset - 1) !== "/") return null;
|
||||||
|
if (text.charAt(offset) === ">") return null;
|
||||||
|
|
||||||
|
const tagStart = text.lastIndexOf("<", offset - 1);
|
||||||
|
if (tagStart < 0) return null;
|
||||||
|
if (!/^<[A-Za-z][\w-]*/.test(text.slice(tagStart))) return null;
|
||||||
|
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = tagStart + 1; i < offset - 1; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "<" || ch === ">") return null;
|
||||||
|
}
|
||||||
|
if (quote) return null;
|
||||||
|
|
||||||
|
return ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snippet that closes the tag being typed, or null.
|
||||||
|
*
|
||||||
|
* Void elements and already-closed tags return null, which is why this decision
|
||||||
|
* belongs here rather than in the editor client.
|
||||||
|
*/
|
||||||
|
export function htmlTagComplete(document: TextDocument, position: Position): string | null {
|
||||||
|
const offset = offsetAt(document.text, position);
|
||||||
|
if (!isInsideHtml(document, offset)) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
return selfClosingTagCompletion(document.text, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One completion list from both sources.
|
||||||
|
*
|
||||||
|
* WRNexus entries take the `0` sortText prefix so they rank above HTML without
|
||||||
|
* either list being filtered. An exact label collision resolves to the
|
||||||
|
* WRNexus entry: a component named `Table` is what the author meant.
|
||||||
|
*/
|
||||||
|
interface CompletionLike {
|
||||||
|
label: string;
|
||||||
|
kind?: number;
|
||||||
|
sortText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCompletions(
|
||||||
|
wrnexus: CompletionLike[],
|
||||||
|
html: CompletionLike[],
|
||||||
|
): CompletionLike[] {
|
||||||
|
const taken = new Set(wrnexus.map((item) => item.label));
|
||||||
|
return [
|
||||||
|
...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })),
|
||||||
|
...html.filter((item) => !taken.has(item.label)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ import {
|
|||||||
htmlToWrn,
|
htmlToWrn,
|
||||||
type TextDocument,
|
type TextDocument,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
|
import {
|
||||||
|
htmlCompletions,
|
||||||
|
htmlFoldingRanges,
|
||||||
|
htmlHover,
|
||||||
|
htmlLinkedEditingRanges,
|
||||||
|
htmlTagComplete,
|
||||||
|
mergeCompletions,
|
||||||
|
} from "./html-service.ts";
|
||||||
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||||
|
|
||||||
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
||||||
const documents = new Map<string, TextDocument>();
|
const documents = new Map<string, TextDocument>();
|
||||||
@@ -111,8 +120,12 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: { openClose: true, change: 1, save: true },
|
textDocumentSync: { openClose: true, change: 1, save: true },
|
||||||
documentFormattingProvider: true,
|
documentFormattingProvider: true,
|
||||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
completionProvider: {
|
||||||
|
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
||||||
|
},
|
||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
|
foldingRangeProvider: true,
|
||||||
|
linkedEditingRangeProvider: true,
|
||||||
definitionProvider: true,
|
definitionProvider: true,
|
||||||
referencesProvider: true,
|
referencesProvider: true,
|
||||||
renameProvider: { prepareProvider: true },
|
renameProvider: { prepareProvider: true },
|
||||||
@@ -172,6 +185,7 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
case "textDocument/didClose":
|
case "textDocument/didClose":
|
||||||
clearDiagnosticTimer(params.textDocument.uri);
|
clearDiagnosticTimer(params.textDocument.uri);
|
||||||
documents.delete(params.textDocument.uri);
|
documents.delete(params.textDocument.uri);
|
||||||
|
clearHtmlRegionCache(params.textDocument.uri);
|
||||||
send({
|
send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "textDocument/publishDiagnostics",
|
method: "textDocument/publishDiagnostics",
|
||||||
@@ -195,9 +209,13 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/completion":
|
case "textDocument/completion": {
|
||||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
|
||||||
|
const html = document ? htmlCompletions(document, params.position) : [];
|
||||||
|
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "textDocument/documentSymbol": {
|
case "textDocument/documentSymbol": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? documentSymbols(document) : []);
|
result(message.id, document ? documentSymbols(document) : []);
|
||||||
@@ -210,7 +228,24 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
}
|
}
|
||||||
case "textDocument/hover": {
|
case "textDocument/hover": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? hover(document, params.position) : null);
|
const html = document ? htmlHover(document, params.position) : null;
|
||||||
|
result(message.id, html ?? (document ? hover(document, params.position) : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/foldingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlFoldingRanges(document) : []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/linkedEditingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
|
||||||
|
result(message.id, ranges ? { ranges } : null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "wrn/tagComplete": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlTagComplete(document, params.position) : null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/definition": {
|
case "textDocument/definition": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
page HtmlEditingCheck {
|
||||||
|
seo {
|
||||||
|
title = "HTML editing check"
|
||||||
|
description = "Scratch page for verifying editor support inside view blocks."
|
||||||
|
canonical = "/html-editing-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<h1>Editor check</h1>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user