Compare commits

...
17 Commits
Author SHA1 Message Date
Clintchiz b3ed9689fa docs: add complete framework feature report
Quality / quality (ubuntu-latest) (push) Failing after 9m54s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-19 17:52:08 +05:30
ClintchizandClaude Opus 5 e5de7b54a5 feat(examples): worked example for typed api blocks
- Add examples/basic-app/app/api/directory.ts and app/pages/api-block-demo.wrn
  as the end-to-end worked example for typed api blocks (Task 6).
- Add **/*.generated.api-checks.ts to .prettierignore: this generated file
  must match the CLI's raw output byte-for-byte for check:generated-types,
  and prettier was reformatting it.
- Fix packages/csr/test/api-call.test.ts: no-unsafe-function-type lint error
  from the raw Function type, uncovered while running the full gate.
- Rebuild editors/vscode bundles (packages/compiler and packages/syntax
  changed in Tasks 1-5).
- Regenerate docs/public-api-0.8.json (additive: isIdentPart, isIdentStart,
  skipLiteralOrComment newly exported from packages/syntax).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:08:49 +05:30
ClintchizandClaude Opus 5 3252b1b20e fix(compiler): type-check the emitted ssr binding array and share error handling across call sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:42:38 +05:30
ClintchizandClaude Opus 5 7601477f7d feat(compiler): run error section on ssr api call failure
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:24:20 +05:30
ClintchizandClaude Opus 5 847b6dbe59 feat(compiler): support sections in ssr api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:19:27 +05:30
ClintchizandClaude Opus 5 04be24ddd8 fix(cli): close the extra-field gap in AssertAssignable, add tsc-based enforcement tests
AssertAssignable was one-directional ([Actual] extends [Expected]), so a
block declaring a field the contract doesn't accept passed silently
(TypeScript's excess-property check only applies to fresh object
literals, not conditional-type extends). Add a key-exactness check
(Exclude<keyof Actual, keyof Expected> extends never) alongside the
assignability check. Guard it with 'unknown extends Expected' so
untyped (no defineEndpoint contract) routes still only warn, per the
existing behaviour, instead of being forced to fail on every declared
field.

Add packages/cli/test/api-block-types.test.ts cases that regenerate a
fixture and run the real TypeScript compiler (via bunx tsc) over the
generated output, asserting on its diagnostics rather than on emitted
text: matching fields compile clean; a wrong-typed field, an extra
field (the Finding-A regression guard), and a missing required field
all fail, each pointing at the offending block's __wrn_api_check_*
line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:14:13 +05:30
ClintchizandClaude Opus 5 785e8a65bd fix(cli): move api-block assertions into a real .ts file
skipLibCheck exempts .d.ts contents from being checked, so assertions
written inside wrnexus.generated.d.ts were never evaluated by tsc.
Emit them into wrnexus.generated.api-checks.ts instead, referencing
the WRNexusGenerated namespace's helper types (which stay in the
.d.ts). Track the new generated file in check:generated-types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:01:07 +05:30
ClintchizandClaude Opus 5 a2698fb51a docs: correct the api block spec's type-enforcement mechanism
Assertions in a .d.ts are inert under skipLibCheck: true, which the root
tsconfig sets. Proven during implementation by forcing skipLibCheck: false,
where the same assertion fires as TS2344. They move to a generated .ts file,
which skipLibCheck does not exempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:58:05 +05:30
ClintchizandClaude Opus 5 419614d9d1 feat(cli): generate type assertions for api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:02 +05:30
ClintchizandClaude Opus 5 70777e4a45 fix(compiler): reserve api as a runtime binding to avoid client-scope collision
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:45:09 +05:30
ClintchizandClaude Opus 5 bd2f6ac5e3 feat(compiler): compile client api blocks into the browser module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:41:13 +05:30
Clintchiz b457ad1d54 feat(csr): add the api block transport 2026-08-19 15:37:23 +05:30
ClintchizandClaude Opus 5 323f57b32b fix(syntax): make api-section scanning string/comment-aware
Reuse tokenizer.readBalancedBraces string/comment skipping (extracted as
skipLiteralOrComment) for both section detection and slicing, instead of a
second hand-rolled brace counter. Fixes truncation on braces inside strings
and false-positive sectioned detection from keywords inside comments/strings.
Also switch api-sections.ts errors from plain Error to LexError so parser.ts
upgrades them to ParseError with an offset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:30:51 +05:30
ClintchizandClaude Opus 5 028c2a6d64 feat(syntax): parse sectioned api blocks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:21:10 +05:30
ClintchizandClaude Opus 5 2f0f82b29f chore: ignore the subagent-driven-development workspace
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:18:39 +05:30
ClintchizandClaude Opus 5 50097ec4b4 docs: implementation plan for typed api blocks
Six tasks: parse the sections, add the CSR transport, compile client-mode
blocks into the browser module, generate the tsc assertions, support
sections in ssr blocks, and verify end to end in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:15:02 +05:30
ClintchizandClaude Opus 5 f026415ba6 docs: design for typed, callable api blocks in .wrn files
A sectioned `api` block -- request / response / error -- callable on
demand from client code, typed against the API route contracts the types
generator already emits.

Records the constraints that shaped it: the current block cannot carry a
query string (isSafeApiPath rejects "?"), cannot interpolate (readPath
stops at "{"), has nowhere to put a body, and fetches once. And the one
that decides the type-safety mechanism -- generated build artifacts are
not type-checked, so enforcement goes into the generated .d.ts, which the
project's own tsc already compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:09:30 +05:30
29 changed files with 4838 additions and 112 deletions
+3
View File
@@ -25,3 +25,6 @@ tsconfig.focus.json
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
# which requires the scaffold to live inside the repo tree).
**/test/.tmp-*/
# Subagent-driven-development scratch (ledger, briefs, review packages)
.superpowers/
+1
View File
@@ -13,6 +13,7 @@ bun.lockb
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
**/*.gen.ts
**/*.generated.d.ts
**/*.generated.api-checks.ts
# Bundled .wrn compiler for the VS Code extension (generated)
editors/vscode/src/compiler.cjs
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -2854,7 +2854,10 @@
"LexError",
"Lexer",
"Token",
"TokenType"
"TokenType",
"isIdentPart",
"isIdentStart",
"skipLiteralOrComment"
],
"./types": [
"RuntimeType",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,260 @@
# 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.
### 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.
+301 -40
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b
// WRN editor compiler source hash: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -511,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([
"server",
"props",
"refs",
"api",
"event",
"payload",
]);
@@ -711,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 = sections.response.trim() || "return data;";
const error = 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} }).catch(${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
function generateBrowserModule(ast) {
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
const functionNames = functions.map((fn) => fn.name);
@@ -758,6 +780,7 @@ function __wrnexusCreateClientFunctions(context) {
const server = context.server;
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestore = () => { ${sharedRestore} };
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
@@ -1489,6 +1512,7 @@ function renderBinding(binding) {
path: binding.path,
body: binding.body,
helpers: binding.helpers,
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
};
}
function hasClientBehavior(nodes) {
@@ -1545,18 +1569,28 @@ function apiBindingMap(ast, sharedHelpers) {
if (bindings.has(block.name)) {
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
}
const sectioned = block.sections;
const errorSection = sectioned?.error.trim();
bindings.set(block.name, {
mode: block.mode,
method: block.method,
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),
});
}
return bindings;
}
function ssrRuntimeSource() {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
}
@@ -1575,6 +1609,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);
}
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(
value: unknown,
): string {
@@ -1604,18 +1650,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
const type = res.headers.get("content-type") || "";
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();
}
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) {
try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} catch (err) {
return __wrnexusEvalError(err, binding.errorBody, 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> {
for (const binding of __wrnexusSsrBindings) {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
const value = await __wrnexusResolveApiBinding(binding, ctx);
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
}
return html;
@@ -2046,13 +2126,16 @@ function generateInner(ast) {
continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
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;
if (needsSsrRuntime) {
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" : "";
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
${storeDeclarations}
@@ -4627,6 +4710,151 @@ Object.defineProperty(exports, "__esModule", { value: true });
/** @deprecated Import language type utilities from @wrnexus/syntax. */
__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) {
"use strict";
@@ -5975,6 +6203,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0;
exports.parse = parse;
exports.parseHtmlView = parseHtmlView;
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.
*
@@ -6447,7 +6676,18 @@ function parse(source) {
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
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;
}
case "functions": {
@@ -7082,13 +7322,55 @@ exports.WRN_DIAGNOSTIC_CODES = {
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
*/
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 {
}
exports.LexError = LexError;
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
exports.isIdentStart = isIdentStart;
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 {
src;
pos = 0;
@@ -7157,9 +7439,9 @@ class Lexer {
case "'":
return this.readString(c, pos);
}
if (isIdentStart(c)) {
if ((0, exports.isIdentStart)(c)) {
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++];
return { type: "ident", value: v, pos };
}
@@ -7379,45 +7661,23 @@ class Lexer {
const start = this.pos + 1;
let depth = 0;
let i = this.pos;
let str = null;
/** True while only whitespace has been seen since the last newline. */
let atLineStart = false;
for (; i < src.length; i++) {
while (i < src.length) {
const c = src[i];
if (str) {
if (c === "\\") {
i++;
continue;
}
if (c === str)
str = null;
continue;
}
if (c === "\n") {
atLineStart = true;
i++;
continue;
}
if (c === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
if (close === -1)
break; // unterminated: fall through to the error
i = close + 1;
const skipped = skipLiteralOrComment(src, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
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")
atLineStart = false;
if (c === '"' || c === "'" || c === "`") {
str = c;
continue;
}
if (c === "{")
depth++;
else if (c === "}") {
@@ -7427,6 +7687,7 @@ class Lexer {
return src.slice(start, i);
}
}
i++;
}
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+159 -30
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: afce4195f664e656dc8f38283bda8118637d977cd2eee8ba08c80906681510cd
// WRN editor language server source hash: cf98947fd66392200bdfb18524a13e8bbb77d4920ba65a4d724e1124152571a3
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -169631,6 +169631,32 @@ var isWs = (c) => c === " " || c === "\t" || c === `
` || c === "\r";
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
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(`
`, 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 {
src;
@@ -169892,46 +169918,23 @@ class Lexer {
const start = this.pos + 1;
let depth = 0;
let i = this.pos;
let str = null;
let atLineStart = false;
for (;i < src.length; i++) {
while (i < src.length) {
const c = src[i];
if (str) {
if (c === "\\") {
i++;
continue;
}
if (c === str)
str = null;
continue;
}
if (c === `
`) {
atLineStart = true;
i++;
continue;
}
if (c === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
if (close === -1)
break;
i = close + 1;
const skipped = skipLiteralOrComment(src, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
continue;
}
if (atLineStart && c === "/" && src[i + 1] === "/") {
const newline = src.indexOf(`
`, i + 2);
if (newline === -1)
break;
i = newline - 1;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r")
atLineStart = false;
if (c === '"' || c === "'" || c === "`") {
str = c;
continue;
}
if (c === "{")
depth++;
else if (c === "}") {
@@ -169941,6 +169944,7 @@ class Lexer {
return src.slice(start, i);
}
}
i++;
}
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
}
@@ -170752,6 +170756,120 @@ var WRN_DIAGNOSTIC_CODES = {
migration: "WRN-MIGRATION-001"
};
// packages/syntax/src/api-sections.ts
var SECTION_NAMES = ["request", "response", "error"];
var REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
function scanTopLevelBlocks(source, names) {
const found = new Map;
const lx = new Lexer(source);
let depth = 0;
let i = 0;
let atLineStart = true;
while (i < source.length) {
const c = source[i];
if (c === `
`) {
atLineStart = true;
i++;
continue;
}
const skipped = skipLiteralOrComment(source, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r")
atLineStart = false;
if (depth === 0 && isIdentStart(c)) {
let j = i + 1;
while (j < source.length && isIdentPart(source[j]))
j++;
const word = source.slice(i, j);
let k = j;
let lineStartAtK = false;
while (k < source.length) {
const kc = source[k];
if (kc === " " || kc === "\t" || kc === "\r") {
k++;
continue;
}
if (kc === `
`) {
lineStartAtK = true;
k++;
continue;
}
const kSkipped = 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;
}
function absolutize(span, outer) {
if (!span)
return;
return outer ? { text: span.text, start: outer.start + span.start } : span;
}
function parseFields(span) {
if (!span)
return [];
const fields = [];
let cursor = 0;
for (const rawLine of span.text.split(`
`)) {
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 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 ?? ""
};
}
function hasRequestSection(source) {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
}
// packages/syntax/src/types.ts
function runtimeTypeOf(annotation) {
if (!annotation)
@@ -171632,7 +171750,18 @@ function parse(source) {
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
dataApis.push({ mode, name: name2, method, path, body });
const sections = parseApiSections(body);
if (sections && mode !== "client" && 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: name2,
method,
path,
body: sections ? "" : body,
...sections ? { sections } : {}
});
break;
}
case "functions": {
+14
View File
@@ -0,0 +1,14 @@
import { defineEndpoint } from "@wrnexus/core";
import type { Context } from "@wrnexus/core";
const ALL = [
{ name: "Ajay", designation: "UI" },
{ name: "Asha", designation: "Backend" },
{ name: "Chen", designation: "UI" },
];
export const POST = async (ctx: Context) => {
const body = (await ctx.req.json().catch(() => ({}))) as { name?: string };
const needle = String(body.name ?? "").toLowerCase();
return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) });
};
@@ -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.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>
}
}
+3
View File
@@ -4,6 +4,7 @@
export interface Routes {
"/": Record<string, never>;
"/about": Record<string, never>;
"/api-block-demo": Record<string, never>;
"/async-data": Record<string, never>;
"/chat": Record<string, never>;
"/client-only": Record<string, never>;
@@ -27,6 +28,7 @@ export interface Routes {
export interface RouteNames {
"index": "/";
"about": "/about";
"api.block.demo": "/api-block-demo";
"async.data": "/async-data";
"chat": "/chat";
"client.only": "/client-only";
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
const paths: Record<RouteName, RoutePath> = {
"index": "/",
"about": "/about",
"api.block.demo": "/api-block-demo",
"async.data": "/async-data",
"chat": "/chat",
"client.only": "/client-only",
@@ -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.
type __wrn_api_check_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export {};
+13 -2
View File
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "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 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/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 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";
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
"/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/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/invite": { POST: ApiContract<typeof import("../api/invite.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"]>;
}
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"];
}
+63 -1
View File
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
// Keep the CLI checker sourced from the package contract so typecheck fixes are
// included in each published CLI bundle.
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 { regenerateRoutes } from "./routes.ts";
import { loadAppConfig } from "@wrnexus/styles";
@@ -99,6 +99,42 @@ 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 apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string {
const lines: string[] = [];
for (const page of pages) {
for (const block of page.ast.dataApis) {
if (!block.sections) 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 fields = [...block.sections.parameters, ...block.sections.body];
const shape = fields.length
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
: "Record<string, never>";
lines.push(
`type __wrn_api_check_${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(
appRoot: string,
pluginContributions?: PluginContributions,
@@ -145,6 +181,10 @@ export function generateApplicationTypes(
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
})
.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 apiContracts = router.api
.map((route) => {
@@ -218,11 +258,33 @@ declare namespace WRNexusGenerated {
${generatedContractMap("RealtimeMessages", realtimeContracts)}
${generatedContractMap("QueuePayloads", queueContracts)}
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 });
const output = join(typeDir, "wrnexus.generated.d.ts");
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)}
export {};
`;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
writePluginArtifacts(root, pluginContributions);
return {
file: relative(root, output).replace(/\\/g, "/"),
+236
View File
@@ -0,0 +1,236 @@
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).toContain("__wrn_api_check_searchUsers");
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);
});
// --- 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]).toContain("__wrn_api_check_searchUsers");
});
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]).toContain("__wrn_api_check_searchUsers");
});
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]).toContain("__wrn_api_check_searchUsers");
});
+28
View File
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
"server",
"props",
"refs",
"api",
"event",
"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 = sections.response.trim() || "return data;";
const error = 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} }).catch(${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
export function generateBrowserModule(ast: PageAst): string {
const functions = ast.runtimeFunctions.filter((fn) =>
["legacy", "client", "shared"].includes(fn.runtime),
@@ -365,6 +392,7 @@ function __wrnexusCreateClientFunctions(context) {
const server = context.server;
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestore = () => { ${sharedRestore} };
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
+74 -8
View File
@@ -35,6 +35,10 @@ interface RenderBinding {
path: string;
body: 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 {
@@ -873,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
path: binding.path,
body: binding.body,
helpers: binding.helpers,
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
};
}
@@ -936,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
if (bindings.has(block.name)) {
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
}
const sectioned = block.sections;
const errorSection = sectioned?.error.trim();
bindings.set(block.name, {
mode: block.mode,
method: block.method,
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),
});
}
@@ -949,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
}
function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
}
@@ -968,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);
}
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(
value: unknown,
): string {
@@ -997,18 +1024,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
const url = new URL(path, ctx.req.url);
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
const type = res.headers.get("content-type") || "";
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();
}
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) {
try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} catch (err) {
return __wrnexusEvalError(err, binding.errorBody, 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> {
for (const binding of __wrnexusSsrBindings) {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
const value = await __wrnexusResolveApiBinding(binding, ctx);
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
}
return html;
@@ -1517,8 +1578,11 @@ function generateInner(ast: PageAst): string {
for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr") continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
const errorBodyProp = binding.errorBody
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
: "";
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);`,
);
}
}
@@ -1526,7 +1590,9 @@ function generateInner(ast: PageAst): string {
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
if (needsSsrRuntime) {
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" : "";
out.push(
`export default async function ${ast.name}(ctx: __WrnexusContext) {
@@ -0,0 +1,110 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts";
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("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,205 @@
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 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");
});
+60
View File
@@ -1380,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
state: stateProxy,
output: outputProxy,
server: serverProxy,
callApi: wrnexusCallApi,
props: propsProxy,
refs: refsProxy,
};
@@ -4246,6 +4247,65 @@ 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) {
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 () {
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) {
if (!root || !name) return null;
var EventConstructor =
+92
View File
@@ -0,0 +1,92 @@
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 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" });
});
+173
View File
@@ -0,0 +1,173 @@
/**
* 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.
*/
import { Lexer, LexError, isIdentPart, isIdentStart, skipLiteralOrComment } from "./tokenizer.ts";
export interface ApiFieldDecl {
name: string;
optional: boolean;
type: string;
}
export interface ApiSections {
parameters: ApiFieldDecl[];
body: ApiFieldDecl[];
response: string;
error: string;
}
const SECTION_NAMES = ["request", "response", "error"] as const;
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"] as const;
interface Span {
text: string;
/** Offset of `text[0]` within the source that was scanned. */
start: number;
}
/**
* 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: string, names: readonly string[]): Map<string, Span> {
const found = new Map<string, Span>();
const lx = new 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 = skipLiteralOrComment(source, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
if (depth === 0 && isIdentStart(c)) {
let j = i + 1;
while (j < source.length && 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 = 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: Span | undefined, outer: Span | undefined): Span | undefined {
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: Span | undefined): ApiFieldDecl[] {
if (!span) return [];
const fields: ApiFieldDecl[] = [];
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 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;
}
export function parseApiSections(source: string): ApiSections | null {
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<string, Span>();
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. */
export function hasRequestSection(source: string): boolean {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
}
+18 -1
View File
@@ -1,4 +1,5 @@
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts";
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
@@ -148,7 +149,10 @@ export interface DataApiBlock {
name: string;
method: string;
path: string;
/** Legacy bare body. Empty string when `sections` is set. */
body: string;
/** Present only for the sectioned, typed form. */
sections?: ApiSections;
}
export interface ModeFunctionsBlock {
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
dataApis.push({ mode, name, method, path, body });
const sections = parseApiSections(body);
if (sections && mode !== "client" && 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;
}
case "functions": {
+47 -27
View File
@@ -31,8 +31,47 @@ export interface Token {
export class LexError extends Error {}
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
export const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
export const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
/**
* 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.
*/
export function skipLiteralOrComment(src: string, i: number, atLineStart: boolean): number | null {
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;
}
export class Lexer {
pos = 0;
@@ -312,46 +351,26 @@ export class Lexer {
const start = this.pos + 1;
let depth = 0;
let i = this.pos;
let str: string | null = null;
/** True while only whitespace has been seen since the last newline. */
let atLineStart = false;
for (; i < src.length; i++) {
while (i < src.length) {
const c = src[i]!;
if (str) {
if (c === "\\") {
i++;
continue;
}
if (c === str) str = null;
continue;
}
if (c === "\n") {
atLineStart = true;
i++;
continue;
}
if (c === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
if (close === -1) break; // unterminated: fall through to the error
i = close + 1;
const skipped = skipLiteralOrComment(src, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
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") atLineStart = false;
if (c === '"' || c === "'" || c === "`") {
str = c;
continue;
}
if (c === "{") depth++;
else if (c === "}") {
depth--;
@@ -360,6 +379,7 @@ export class Lexer {
return src.slice(start, i);
}
}
i++;
}
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
}
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test";
import { parse } from "../src/index.ts";
const page = (inner: string) => `page Repro {
client {
${inner}
}
view { <main>x</main> }
}
`;
test("parses a sectioned api block into request, response and error", () => {
const ast = parse(
page(` api searchUsers POST /api/users {
request {
body {
name?: string
age?: number
}
}
response {
return data.users
}
error {
return []
}
}`),
);
const block = ast.dataApis[0]!;
expect(block.name).toBe("searchUsers");
expect(block.method).toBe("POST");
expect(block.path).toBe("/api/users");
expect(block.sections?.body).toEqual([
{ name: "name", optional: true, type: "string" },
{ name: "age", optional: true, type: "number" },
]);
expect(block.sections?.response.trim()).toBe("return data.users");
expect(block.sections?.error.trim()).toBe("return []");
});
test("a bare body still parses as the legacy response body", () => {
const ast = parse(
page(` api legacyUsers GET /api/users {
return users.length
}`),
);
const block = ast.dataApis[0]!;
expect(block.sections).toBeUndefined();
expect(block.body.trim()).toBe("return users.length");
});
test("GET parameters are parsed as required when not marked optional", () => {
const ast = parse(
page(` api listUsers GET /api/users {
request {
parameters {
team: string
}
}
response {
return data.users
}
}`),
);
expect(ast.dataApis[0]!.sections?.parameters).toEqual([
{ name: "team", optional: false, type: "string" },
]);
});
test("request inside an ssr block is rejected with a message naming the restriction", () => {
const source = `page Repro {
ssr {
api ssrUsers GET /api/users {
request {
parameters {
team: string
}
}
response {
return data.users
}
}
}
view { <main>x</main> }
}
`;
expect(() => parse(source)).toThrow(/ssr[\s\S]*request/i);
});
test("a brace inside a string literal in the response body does not truncate the section", () => {
const ast = parse(
page(` api searchUsers POST /api/users {
request {
body {
name?: string
}
}
response {
return "a } weird string"
}
error {
return []
}
}`),
);
const block = ast.dataApis[0]!;
expect(block.sections?.response.trim()).toBe('return "a } weird string"');
expect(block.sections?.error.trim()).toBe("return []");
});
test("a legacy block whose comment or string mentions a section keyword stays legacy", () => {
const ast = parse(
page(` api legacyUsers GET /api/users {
// fall back to a manual request { } if this fails
return "response { not a section }"
}`),
);
const block = ast.dataApis[0]!;
expect(block.sections).toBeUndefined();
expect(block.body.trim()).toBe(
'// fall back to a manual request { } if this fails\n return "response { not a section }"',
);
});
+1
View File
@@ -8,6 +8,7 @@ const scratchParent = join(root, ".wrnexus-type-check");
const scratch = mkdtempSync(`${scratchParent}-`);
const generated = [
join("app", "types", "wrnexus.generated.d.ts"),
join("app", "types", "wrnexus.generated.api-checks.ts"),
join("app", "types", "wrnexus.plugins.generated.d.ts"),
];
@@ -0,0 +1,302 @@
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import console from "node:console";
import process from "node:process";
import { format } from "prettier";
const root = process.cwd();
const json = (path) => JSON.parse(readFileSync(join(root, path), "utf8"));
const rootPackage = json("package.json");
const publicApi = json("docs/public-api-0.8.json");
const uiReference = json("packages/ui/component-reference.json");
function walk(dir, extension, out = []) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
const info = statSync(path);
if (info.isDirectory() && !["node_modules", "dist", ".wrnexus"].includes(entry))
walk(path, extension, out);
else if (path.endsWith(extension)) out.push(path);
}
return out;
}
const packageRows = readdirSync(join(root, "packages"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const manifest = json(`packages/${entry.name}/package.json`);
return {
name: manifest.name,
version: manifest.version,
description: manifest.description ?? "—",
};
})
.sort((a, b) => a.name.localeCompare(b.name));
const wrnFiles = walk(join(root, "packages"), ".wrn").sort();
const packageBlocks = new Map();
for (const file of wrnFiles) {
const packageName = relative(join(root, "packages"), file).split(/[\\/]/)[0];
const list = packageBlocks.get(packageName) ?? [];
list.push(relative(root, file).replaceAll("\\", "/"));
packageBlocks.set(packageName, list);
}
const apiPackages = Object.entries(publicApi.packages);
const apiSymbolCount = apiPackages.reduce(
(total, [, entries]) =>
total + Object.values(entries).reduce((sum, symbols) => sum + symbols.length, 0),
0,
);
const lines = [];
const add = (...value) => lines.push(...value);
add(
"# WRNexusJS complete framework and `.wrn` report",
"",
`Generated for workspace version **${rootPackage.version}** from the checked-out source and generated references.`,
"",
"> Scope and source of truth: this report describes the checked-out implementation, not only the prose docs. The parser in `packages/syntax`, compiler/runtime code, package manifests, `docs/public-api-0.8.json`, and `packages/ui/component-reference.json` take precedence when older documents disagree.",
"",
"## 1. Executive summary",
"",
`- Workspace framework version: **${rootPackage.version}**. The 48 independently published packages have their own patch versions; see Appendix A.`,
"- Architecture: compiler-driven, SSR-first, Bun-native/full-stack, with Node-friendly selected tooling.",
"- Static routes retain the zero-framework-JavaScript goal; hydration is selective and islands are loaded only where declared.",
"- Current headline features include typed callable API blocks, reactive `if`/`each` control blocks, runtime-scoped state and functions, typed outputs, stores, React islands, HTML-aware editor tooling, package/plugin discovery, generated contracts, security gates, and multi-app RPC.",
`- Audited public API baseline: **${apiPackages.length} packages / ${apiSymbolCount} exported symbols** across root and subpath exports.`,
`- Audited packaged \`.wrn\` sources: **${wrnFiles.length}**; generated UI reference: **${uiReference.components.length} components**.`,
"",
"## 2. What a `.wrn` file is",
"",
"A `.wrn` file is a single compiler-owned source unit combining imports, a root declaration, typed data/state, runtime behavior, HTML view markup, styles, metadata, API handlers/bindings, and realtime handlers. It is parsed into the canonical AST owned by `@wrnexus/syntax`; `@wrnexus/compiler` turns that AST into SSR, browser, route, style, and metadata artifacts.",
"",
"Valid roots:",
"",
"| Root | Purpose |",
"| --- | --- |",
"| `page Name {}` | Routed page. |",
"| `component Name {}` | Reusable server-rendered component. |",
"| `layout Name {}` | Reusable page wrapper. |",
"| `global store Name {}` | Application-wide store. |",
"| `page store Name {}` | Page-lifetime store. |",
"",
"A file may start with static TypeScript imports. Component and layout symbols can be imported explicitly; compatibility discovery remains configurable for upgraded applications.",
"",
"## 3. Complete `.wrn` block and declaration catalog",
"",
"| Declaration/block | Shape | Meaning and current behavior |",
"| --- | --- | --- |",
"| `layout = LayoutSymbol` | root member | Preferred imported layout reference. String layout names remain a compatibility path. |",
'| `runtime = "…"` | root member | Targets: `server`, `client`, `universal`, `edge`, `worker`, `service-worker`. |',
'| `render = "…"` | root member | Modes: `static`, `server`, `hybrid`, `client`, `partial-static`. |',
'| `hydrate = "…"` | root member | `load`, `idle`, `visible`, `interaction`, `none`, or `media:<query>`; legacy `never` normalizes to `none`. |',
'| `client = "…"` | root member | Legacy alias for hydration configuration; `client {}` remains a different runtime-mode block. |',
"| `types {}` | raw TypeScript | Local type declarations emitted for checking. |",
"| `props {}` | typed declarations | Required without default; optional via `?`; defaults supported; legacy `@event name = function` is parsed for compatibility. |",
"| `outputs {}` | typed declarations | Canonical child-to-parent callable output contract, zero or one typed payload. |",
"| `state name = expr` / `state {}` | reactive data | Shared state; type annotation optional, initializer required. Arrays, objects, and multiline expressions are supported. |",
"| `server state {}` / `client state {}` | runtime-scoped data | State visible only in the declared runtime boundary. |",
"| `computed name = expr` / `computed {}` | derived data | Dependency-tracked cached values. |",
"| `effect {}` | reactive side effect | Runs after batched updates when referenced reactive values change. |",
"| `load server {}` / `load client {}` | loader | Runtime-specific loading; named/dependent/deferred forms are represented in the AST. |",
"| `action name(args) {}` | action | Named action, optionally schema-backed, exported for adapters. |",
"| `view {}` | HTML/template | HTML/component tree with expressions, events, directives, and reactive control blocks. |",
"| `style {}` | scoped CSS | Multiple blocks allowed; promoted into the document head with CSP/HMR/navigation support. |",
"| `seo {}` | metadata | Key/value SEO metadata. |",
"| `security {}` | policy metadata | Auth, CSRF, roles, rate-limit and organization-specific enforcement metadata. |",
"| `navigation {}` | navigation metadata | Page navigation policy/configuration consumed by runtime tooling. |",
"| `cache {}` | cache metadata | Declarative framework cache policy. |",
"| `functions {}` | shared helpers | Legacy/general shared helper body; runtime-specific function grammar is preferred where applicable. |",
"| `server { functions {} }` / `client { functions {} }` | mode helper block | Raw helpers scoped to SSR or browser execution. |",
"| `[async] server function name(args) {}` | callable function | Explicit server RPC boundary. |",
"| `[async] client function name(args) {}` | browser function | Explicit client callable function. |",
"| `[async] shared function name(args) {}` | universal helper | Explicit shared function. |",
"| `ssr { api … }` | render-time own-route data | Executes during render. Legacy bare response body is supported; sectioned `response`/`error` is supported; request parameters are intentionally forbidden. |",
"| `client { api … }` | callable own-route data | Sectioned form creates `api.name(input)` in browser scope; GET uses query parameters, other methods use JSON and CSRF. |",
"| `api METHOD /path {}` | route handler | Defines an application API endpoint/handler. Distinct from named data API bindings inside `ssr`/`client`. |",
"| `lifecycle { mount/update/unmount {} }` | component lifecycle | Hydrated lifecycle hooks. |",
"| `watch stateName {}` | watcher | Runs for changes to the named state. |",
"| `realtime name { on event(args) {} }` | websocket behavior | Declares named realtime handlers. |",
"| `persist {}` | store persistence | Storage (`memory`, `session`, `local`), included keys, version, migrations, and validation. |",
"| `lifecycle { serverInit/clientInit/hydrate/dispose {} }` | store lifecycle | Store-specific lifecycle form. |",
"",
"### View/template features",
"",
"- Standard HTML and custom/component tags; HTML void elements follow the platform list.",
"- Escaped `{expression}` interpolation. Raw HTML is an explicit security boundary.",
"- Browser event attributes: `@click`, `@window:scroll`, `@document:click`, and other event names.",
"- Conditional classes through `class:name='expression'`; visibility through `data-show`.",
'- Legacy loop attribute: `data-for="item, index in items key item.id"`, with optional `data-key`.',
"- Canonical control blocks: `{#if}`, `{:else if}`, `{:else}`, `{/if}` and `{#each list as item, index key expr}`, `{:empty}`, `{/each}`. Initial output is SSR and remains reactive after hydration.",
"- JSX-style expression props (`items={items}`, object/array expressions) are current; quoted expressions remain compatible. Literal HTML attributes remain quoted.",
"- React/TSX islands use imported `.tsx` components and `client:only`, `client:load`, `client:visible`, or `client:idle`. They are client-only in v1; island props must be JSON-serializable.",
"",
"### Typed callable API block (latest form)",
"",
"```wrn",
"client {",
" api searchUsers POST /api/users {",
" request { body { name?: string age?: number } }",
" response { return data.users }",
" error { return [] }",
" }",
"}",
"```",
"",
"Call it with `await api.searchUsers({ name })`. GET uses `request { parameters {} }`; non-GET uses `body {}`. Fields are type-only declarations checked against generated route contracts in `app/types/wrnexus.generated.api-checks.ts`. Success binds parsed JSON as `data`. `error {}` converts failure to its returned value; without it, non-2xx, network, and parse failures reject. Targets are restricted to the current apps `/api/*` routes. External APIs, custom headers, parameterized SSR calls, caching, and deduplication are deferred.",
"",
"Legacy API binding remains valid: `ssr { api users GET /api/users { return users } }`. Its bare body receives payload fields through the legacy dynamic scope. The sectioned form deliberately uses `data` so TypeScript can check it.",
"",
"## 4. Runtime, rendering, and data flow",
"",
"1. `@wrnexus/syntax` tokenizes/parses and emits stable diagnostics and AST nodes.",
"2. The compiler resolves imports/components/islands and generates SSR HTML functions, client modules, route/API exports, styles, metadata, and contracts.",
"3. Static pages ship no framework JS. Interactive pages receive only the required CSR runtime; island routes lazily receive React/island assets.",
"4. State changes batch, invalidate computed values, run effects/watchers, update expressions/classes/visibility, and rerender `if`/`each` regions.",
"5. Server functions use the RPC boundary; typed API blocks call same-app API routes; realtime blocks produce websocket handlers; stores bridge SSR and client state.",
"6. Generated types validate component props, outputs, functions, routes, and typed API-block request contracts during `tsc` and release checks.",
"",
"## 5. Framework feature inventory",
"",
"- Routing and rendering: filesystem pages/layouts, static/request/hybrid/client/partial-static rendering, route analysis, advanced routing, CSR navigation, layouts, streaming/SSR packages.",
"- Reactivity: state, computed values, effects, watchers, reactive attributes/events, SSR-to-client control blocks, loaders/actions, explicit hydration.",
"- Components/UI: application components, package-owned blocks, generated prop/output references, theming/tokens, 102 audited first-party UI components, app overrides/ejection.",
"- Data/backend: database drivers and migrations, repositories, cache, queue, pub/sub, realtime, GraphQL, route APIs, server functions, workspace RPC.",
"- Identity/security: auth, authorization, OAuth, JWT/JWKS, MFA/passkeys/recovery, CAPTCHA, encryption, SSRF defenses, CSP/CSRF, request limits, audit/security gates.",
"- Product capabilities: AI/RAG/provider adapters, content, i18n, image optimization, uploads, validation, PWA, native/mobile, analytics/tracking, observability.",
"- Developer experience: CLI create/dev/build/update/doctor/inspect/generate/eject/db/workspace operations, HMR, dev toolbar, language server, VS Code completion/HTML editing/formatting/diagnostics, playground, MCP, tests/benchmarks/release validation.",
"- Deployment: production builds, Docker and platform examples, migrations, package staging/integrity checks, SBOM and security/performance reports.",
"",
"## 6. Legacy-to-current migration map",
"",
"| Legacy/earlier approach | Current approach | Compatibility/status |",
"| --- | --- | --- |",
"| Compiler-owned/internal parsing imports | Canonical `@wrnexus/syntax` lexer/parser/AST/diagnostics | Compiler re-exports remain for compatibility; direct internals are deprecated. |",
"| Implicit component discovery everywhere | Explicit imports and generated contracts | Upgraded apps can retain `legacyComponentDiscovery`; unresolved symbols are reported rather than guessed. |",
'| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts remain behind compatibility configuration. |',
"| `@event changed = function` in props | `outputs { changed(payload: Type) }` | v0.6 migration converts declarations; ambiguous payloads become `unknown`. |",
'| `$emit("changed", value)` and `event.detail` | `output.changed(value)` and direct `payload` | Static cases auto-migrated; dynamic emit names require review. |',
"| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; compatibility default can preserve ambiguous behavior. |",
"| Manually copied CAPTCHA JS/script tags | Package-discovered client runtime/assets | v0.4 removes tags and archives old assets under `.wrnexus/legacy-assets/0.4.0`. |",
"| Package components/routes/assets wired manually | Automatic package/plugin discovery and contribution registry | Current CLI/build/dev server inspect and consume contributions. |",
"| Only scalar/quoted dynamic props | Native arrays/objects and JSX-style unquoted expressions | Quoted expression attributes remain supported. |",
"| `data-for` and older each forms | `{#each …}{:empty}{/each}` | Legacy loop forms remain supported; canonical blocks offer keyed/empty/reactive behavior. |",
"| Static server-only `if`/`each` after hydration | Reactive client rerendering of control blocks | Current runtime updates branches/rows after state changes. |",
"| Bare `api` binding bodies and hand-written `fetch` for inputs | Sectioned typed callable client API blocks | Bare body stays supported; new form adds inputs, route-contract checking, CSRF, response/error transforms. |",
"| Generated API assertions in `.d.ts` | Assertions in real `wrnexus.generated.api-checks.ts` | Changed because `skipLibCheck` made `.d.ts` assertions inert. |",
"| Client functions accidentally retaining TypeScript | Compiler strips type syntax before browser-module emission | Fixed and regression-tested. |",
"| Markup merely highlighted as embedded HTML | Virtual HTML document plus HTML language service | Current editor adds tag/attribute completion, auto-close/rename, hover, Emmet, and folding; WRN formatter still owns formatting. |",
"| Framework-only component ecosystem | Optional React `.tsx` islands | React is isolated and lazy; zero-JS routes remain unchanged; island SSR/Fast Refresh are deferred. |",
"| Per-component/global style placement inconsistencies | `style {}` promoted to document head | Current pipeline supports CSP, HMR and CSR navigation. |",
"| Manually maintained API/component knowledge | Generated public API and component references plus validation gates | `check:public-api`, generated-type checks, UI visual contract and package audits detect drift. |",
"",
"Compatibility flags visible in generated/upgraded config include `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime`. New projects default legacy flags off; migration-created configs may enable them to preserve behavior until source modernization is complete.",
"",
"## 7. Diagnostics, security, and correctness guarantees",
"",
"Stable diagnostics include parse/member/prop/state/hydration/runtime/accessibility codes and feature-specific diagnostics such as island prop or missing-React errors. Compiler, CLI doctor/build, type generation, and editor tooling share syntax ownership to reduce parser drift.",
"",
"Security properties include escaped output by default, CSP-aware styles/scripts, same-origin API restriction, CSRF on non-GET callable API requests, credential handling via same-origin cookies, safe serialization, SSRF policies, request limits, auth/authz metadata and middleware, secret/audit gates, and application-layer encryption where explicitly needed. Security metadata is declarative input; enforcement still belongs to installed middleware/plugins and route policy.",
"",
"## 8. Current limitations and deferred work",
"",
"- Typed API blocks do not target third-party URLs, accept custom author headers, parameterize SSR requests, or provide built-in request caching/deduplication.",
"- React islands are client-rendered in v1; island SSR/hydration and React Fast Refresh are deferred.",
"- Native compilation does not directly port data API blocks; native screens use generated backend helpers.",
"- HTML language features intentionally do not replace the WRN formatter.",
"- Generated type checks must stay fresh; `check:generated-types` is the enforcement gate.",
"",
"## 9. Documentation drift discovered by this audit",
"",
`- Root ` +
"`README.md`" +
` says 0.8.0, while the workspace manifest is ${rootPackage.version}.`,
`- ` +
"`packages/ui/README.md`" +
` says 85 components and ` +
"`docs/UI-COMPONENT-INVENTORY.md`" +
` says 891; the current generated component reference contains ${uiReference.components.length}.`,
"- `docs/WRN-LANGUAGE-SPEC-1.0.md` calls itself the 0.3.x contract and predates several implemented roots/members (stores, rendering modes, outputs, runtime-scoped state/functions, React islands, sectioned callable API blocks). Use it as historical baseline, not a complete 0.8.8 reference.",
"- Package patch versions are intentionally ahead of the workspace umbrella version in many packages. Consumers should use the actual package manifest/version selected by the release process.",
"",
"## 10. Recommended release verification",
"",
"Run `bun run check:production` for the complete production gate. Its chain covers workspace repair, generated types, public API, UI visual contract, 0.8 validation, framework/ASVS security, editor bundle freshness, typecheck, lint, component imports, package tests, formatting, and examples. Additional focused commands include `bun run test:all`, `bun run audit:packages`, `bun run test:package-kits`, `bun run validate:staging`, `bun run sbom`, and `bun run benchmark:framework`.",
"",
"## Appendix A — package/version inventory",
"",
"| Package | Version | Purpose |",
"| --- | --- | --- |",
);
for (const item of packageRows)
add(`| \`${item.name}\` | ${item.version} | ${item.description.replaceAll("|", "\\|")} |`);
add("", "## Appendix B — complete audited public export inventory", "");
for (const [packageName, entries] of apiPackages) {
const count = Object.values(entries).reduce((sum, symbols) => sum + symbols.length, 0);
add(`### \`${packageName}\` (${count} symbols)`, "");
for (const [subpath, symbols] of Object.entries(entries)) {
add(`- **${subpath}:** ${symbols.map((symbol) => `\`${symbol}\``).join(", ")}`);
}
add("");
}
add("## Appendix C — current UI component/block reference", "");
for (const component of uiReference.components) {
const props = component.props.length
? component.props
.map(
(prop) =>
`${prop.name}: ${prop.type}${prop.required ? " (required)" : ` = ${prop.default}`}`,
)
.join("; ")
: "none";
const outputs = component.outputs?.length
? component.outputs.map((output) => `${output.name}(${output.payloadType})`).join("; ")
: "none";
const slots = component.slots?.length ? component.slots.join(", ") : "none";
add(
`### \`${component.name}\``,
"",
`- Category: ${component.category}; mount: \`${component.mount}\`; source: \`${component.source}\`.`,
`- Purpose: ${component.purpose}`,
`- Props: ${props}`,
`- Outputs/events: ${outputs}`,
`- Slots: ${slots}`,
"",
);
}
add("## Appendix D — all other package-owned `.wrn` blocks", "");
for (const [packageName, files] of [...packageBlocks.entries()].sort(([a], [b]) =>
a.localeCompare(b),
)) {
if (packageName === "ui") continue;
add(
`### \`@wrnexus/${packageName}\` (${files.length})`,
"",
...files.map((file) => `- \`${file}\``),
"",
);
}
add(
"## Appendix E — authoritative files",
"",
"- Language parser/AST: `packages/syntax/src/parser.ts`, `v060.ts`, `api-sections.ts`, `spec.ts`.",
"- Compiler/runtime: `packages/compiler/src`, `packages/csr/src`, `packages/ssr/src`, `packages/dev-server/src`.",
"- Migration registry: `packages/cli/src/update.ts`.",
"- Public exports: `docs/public-api-0.8.json` (checked by `scripts/check-public-api.mjs`).",
"- UI blocks: `packages/ui/component-reference.json` and `packages/ui/COMPONENTS.md`.",
"- Latest feature designs: `docs/superpowers/specs/2026-08-19-typed-api-block-design.md`, `2026-08-18-react-islands-design.md`, and `2026-08-18-wrn-html-editing-design.md`.",
"",
"---",
"",
"This report is reproducible: run `node scripts/generate-complete-framework-report.mjs` after implementation or generated-reference changes.",
);
const report = await format(`${lines.join("\n")}\n`, { parser: "markdown" });
writeFileSync(join(root, "docs", "WRNEXUS-COMPLETE-FEATURE-REPORT.md"), report, "utf8");
console.log(`Wrote docs/WRNEXUS-COMPLETE-FEATURE-REPORT.md (${lines.length} lines).`);
+3 -1
View File
@@ -181,7 +181,9 @@ const runtimeBudgets = {
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
* cache.
*/
"reactive-runtime.ts": 50_500,
// Raised to 51_400: the callApi transport (query building, CSRF header,
// JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes.
"reactive-runtime.ts": 51_400,
"component-controllers.ts": 24_100,
"nav-runtime.ts": 12_000,
"realtime-runtime.ts": 8_000,