Compare commits
17
Commits
2cbc3e43e1
...
b3ed9689fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ed9689fa | ||
|
|
e5de7b54a5 | ||
|
|
3252b1b20e | ||
|
|
7601477f7d | ||
|
|
847b6dbe59 | ||
|
|
04be24ddd8 | ||
|
|
785e8a65bd | ||
|
|
a2698fb51a | ||
|
|
419614d9d1 | ||
|
|
70777e4a45 | ||
|
|
bd2f6ac5e3 | ||
|
|
b457ad1d54 | ||
|
|
323f57b32b | ||
|
|
028c2a6d64 | ||
|
|
2f0f82b29f | ||
|
|
50097ec4b4 | ||
|
|
f026415ba6 |
@@ -25,3 +25,6 @@ tsconfig.focus.json
|
|||||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||||
# which requires the scaffold to live inside the repo tree).
|
# which requires the scaffold to live inside the repo tree).
|
||||||
**/test/.tmp-*/
|
**/test/.tmp-*/
|
||||||
|
|
||||||
|
# Subagent-driven-development scratch (ledger, briefs, review packages)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ bun.lockb
|
|||||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||||
**/*.gen.ts
|
**/*.gen.ts
|
||||||
**/*.generated.d.ts
|
**/*.generated.d.ts
|
||||||
|
**/*.generated.api-checks.ts
|
||||||
|
|
||||||
# Bundled .wrn compiler for the VS Code extension (generated)
|
# Bundled .wrn compiler for the VS Code extension (generated)
|
||||||
editors/vscode/src/compiler.cjs
|
editors/vscode/src/compiler.cjs
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2854,7 +2854,10 @@
|
|||||||
"LexError",
|
"LexError",
|
||||||
"Lexer",
|
"Lexer",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenType"
|
"TokenType",
|
||||||
|
"isIdentPart",
|
||||||
|
"isIdentStart",
|
||||||
|
"skipLiteralOrComment"
|
||||||
],
|
],
|
||||||
"./types": [
|
"./types": [
|
||||||
"RuntimeType",
|
"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
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: f474a2710a8861a4e863af13954e202e41a94b160c43c20297a7dfdd94b9ea8b
|
// WRN editor compiler source hash: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -511,6 +511,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"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) {
|
function generateBrowserModule(ast) {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||||
const functionNames = functions.map((fn) => fn.name);
|
const functionNames = functions.map((fn) => fn.name);
|
||||||
@@ -758,6 +780,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
@@ -1489,6 +1512,7 @@ function renderBinding(binding) {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function hasClientBehavior(nodes) {
|
function hasClientBehavior(nodes) {
|
||||||
@@ -1545,18 +1569,28 @@ function apiBindingMap(ast, sharedHelpers) {
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
function ssrRuntimeSource() {
|
function ssrRuntimeSource() {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -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);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -1604,18 +1650,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
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> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -2046,13 +2126,16 @@ function generateInner(ast) {
|
|||||||
continue;
|
continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
||||||
continue;
|
continue;
|
||||||
loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`);
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
|
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
${storeDeclarations}
|
${storeDeclarations}
|
||||||
@@ -4627,6 +4710,151 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||||
__exportStar(require("@wrnexus/syntax/types"), exports);
|
__exportStar(require("@wrnexus/syntax/types"), exports);
|
||||||
|
|
||||||
|
},
|
||||||
|
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.parseApiSections = parseApiSections;
|
||||||
|
exports.hasRequestSection = hasRequestSection;
|
||||||
|
/**
|
||||||
|
* Parse the sectioned form of an `api` block body.
|
||||||
|
*
|
||||||
|
* Returns null when no section keyword is present, which is how the legacy
|
||||||
|
* bare-body form stays valid: the caller keeps treating the body as the
|
||||||
|
* response expression.
|
||||||
|
*
|
||||||
|
* Detection and slicing both drive the tokenizer's own string/comment-aware
|
||||||
|
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
|
||||||
|
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
|
||||||
|
* mentioned in a comment can't be mistaken for a real section.
|
||||||
|
*/
|
||||||
|
const tokenizer_ts_1 = require("./tokenizer.js");
|
||||||
|
const SECTION_NAMES = ["request", "response", "error"];
|
||||||
|
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"];
|
||||||
|
/**
|
||||||
|
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
|
||||||
|
* one of `names`. Strings, template literals, and comments are skipped via
|
||||||
|
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
|
||||||
|
* keyword mentioned inside a string or comment, or nested inside an unrelated
|
||||||
|
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
|
||||||
|
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
|
||||||
|
* not a reimplementation of it.
|
||||||
|
*/
|
||||||
|
function scanTopLevelBlocks(source, names) {
|
||||||
|
const found = new Map();
|
||||||
|
const lx = new tokenizer_ts_1.Lexer(source);
|
||||||
|
let depth = 0;
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
while (i < source.length) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === "\n") {
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
|
atLineStart = false;
|
||||||
|
if (depth === 0 && (0, tokenizer_ts_1.isIdentStart)(c)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < source.length && (0, tokenizer_ts_1.isIdentPart)(source[j]))
|
||||||
|
j++;
|
||||||
|
const word = source.slice(i, j);
|
||||||
|
// Skip trivia between the identifier and a possible '{' without
|
||||||
|
// treating anything in between as significant yet.
|
||||||
|
let k = j;
|
||||||
|
let lineStartAtK = false;
|
||||||
|
while (k < source.length) {
|
||||||
|
const kc = source[k];
|
||||||
|
if (kc === " " || kc === "\t" || kc === "\r") {
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kc === "\n") {
|
||||||
|
lineStartAtK = true;
|
||||||
|
k++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kSkipped = (0, tokenizer_ts_1.skipLiteralOrComment)(source, k, lineStartAtK);
|
||||||
|
if (kSkipped !== null) {
|
||||||
|
k = kSkipped;
|
||||||
|
lineStartAtK = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (names.includes(word) && source[k] === "{") {
|
||||||
|
lx.pos = k;
|
||||||
|
const start = k + 1;
|
||||||
|
const text = lx.readBalancedBraces();
|
||||||
|
if (!found.has(word))
|
||||||
|
found.set(word, { text, start });
|
||||||
|
i = lx.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === "{")
|
||||||
|
depth++;
|
||||||
|
else if (c === "}")
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
/** Rebase a span captured from `outer.text` back onto the original source. */
|
||||||
|
function absolutize(span, outer) {
|
||||||
|
if (!span)
|
||||||
|
return undefined;
|
||||||
|
return outer ? { text: span.text, start: outer.start + span.start } : span;
|
||||||
|
}
|
||||||
|
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
|
||||||
|
function parseFields(span) {
|
||||||
|
if (!span)
|
||||||
|
return [];
|
||||||
|
const fields = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const rawLine of span.text.split("\n")) {
|
||||||
|
const lineOffset = span.start + cursor;
|
||||||
|
cursor += rawLine.length + 1;
|
||||||
|
const line = rawLine.trim().replace(/,$/, "");
|
||||||
|
if (!line || line.startsWith("//"))
|
||||||
|
continue;
|
||||||
|
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
throw new tokenizer_ts_1.LexError(`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`);
|
||||||
|
}
|
||||||
|
fields.push({ name: match[1], optional: match[2] === "?", type: match[3].trim() });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
function parseApiSections(source) {
|
||||||
|
const top = scanTopLevelBlocks(source, SECTION_NAMES);
|
||||||
|
if (top.size === 0)
|
||||||
|
return null;
|
||||||
|
const request = top.get("request");
|
||||||
|
const sub = request
|
||||||
|
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
|
||||||
|
: new Map();
|
||||||
|
return {
|
||||||
|
parameters: parseFields(absolutize(sub.get("parameters"), request)),
|
||||||
|
body: parseFields(absolutize(sub.get("body"), request)),
|
||||||
|
response: top.get("response")?.text ?? "",
|
||||||
|
error: top.get("error")?.text ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** True when the block declares a `request` section. */
|
||||||
|
function hasRequestSection(source) {
|
||||||
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -5975,6 +6203,7 @@ exports.ParseError = exports.VOID_ELEMENTS = void 0;
|
|||||||
exports.parse = parse;
|
exports.parse = parse;
|
||||||
exports.parseHtmlView = parseHtmlView;
|
exports.parseHtmlView = parseHtmlView;
|
||||||
const spec_ts_1 = require("./spec.js");
|
const spec_ts_1 = require("./spec.js");
|
||||||
|
const api_sections_ts_1 = require("./api-sections.js");
|
||||||
/**
|
/**
|
||||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
*
|
*
|
||||||
@@ -6447,7 +6676,18 @@ function parse(source) {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name, method, path, body });
|
const sections = (0, api_sections_ts_1.parseApiSections)(body);
|
||||||
|
if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) {
|
||||||
|
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
|
||||||
|
}
|
||||||
|
dataApis.push({
|
||||||
|
mode,
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: sections ? "" : body,
|
||||||
|
...(sections ? { sections } : {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
@@ -7082,13 +7322,55 @@ exports.WRN_DIAGNOSTIC_CODES = {
|
|||||||
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
|
||||||
*/
|
*/
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.Lexer = exports.LexError = void 0;
|
exports.Lexer = exports.isIdentPart = exports.isIdentStart = exports.LexError = void 0;
|
||||||
|
exports.skipLiteralOrComment = skipLiteralOrComment;
|
||||||
class LexError extends Error {
|
class LexError extends Error {
|
||||||
}
|
}
|
||||||
exports.LexError = LexError;
|
exports.LexError = LexError;
|
||||||
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||||
|
exports.isIdentStart = isIdentStart;
|
||||||
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
|
||||||
|
exports.isIdentPart = isIdentPart;
|
||||||
|
/**
|
||||||
|
* Skip over a string/template literal or comment starting at `src[i]`, using
|
||||||
|
* the exact rules `readBalancedBraces` needs to stay comment- and
|
||||||
|
* string-aware: `/* block *\/` comments anywhere, `//` line comments only at
|
||||||
|
* the start of a line (so a bare `https://…` in view text isn't mistaken for
|
||||||
|
* one), and `"`, `'`, `` ` `` strings with backslash escapes.
|
||||||
|
*
|
||||||
|
* Returns the index just past what it skipped, or `null` when `src[i]` isn't
|
||||||
|
* the start of one of those. Exported so any other raw-body scanner that
|
||||||
|
* needs to walk `.wrn` source without tripping over strings or comments
|
||||||
|
* (e.g. the `api` section scanner) shares this logic instead of
|
||||||
|
* reimplementing it — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces.
|
||||||
|
*/
|
||||||
|
function skipLiteralOrComment(src, i, atLineStart) {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === "/" && src[i + 1] === "*") {
|
||||||
|
const close = src.indexOf("*/", i + 2);
|
||||||
|
return close === -1 ? src.length : close + 2;
|
||||||
|
}
|
||||||
|
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
||||||
|
const newline = src.indexOf("\n", i + 2);
|
||||||
|
return newline === -1 ? src.length : newline;
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'" || c === "`") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < src.length) {
|
||||||
|
if (src[j] === "\\") {
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (src[j] === c)
|
||||||
|
return j + 1;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return src.length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
class Lexer {
|
class Lexer {
|
||||||
src;
|
src;
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -7157,9 +7439,9 @@ class Lexer {
|
|||||||
case "'":
|
case "'":
|
||||||
return this.readString(c, pos);
|
return this.readString(c, pos);
|
||||||
}
|
}
|
||||||
if (isIdentStart(c)) {
|
if ((0, exports.isIdentStart)(c)) {
|
||||||
let v = "";
|
let v = "";
|
||||||
while (this.pos < src.length && isIdentPart(src[this.pos]))
|
while (this.pos < src.length && (0, exports.isIdentPart)(src[this.pos]))
|
||||||
v += src[this.pos++];
|
v += src[this.pos++];
|
||||||
return { type: "ident", value: v, pos };
|
return { type: "ident", value: v, pos };
|
||||||
}
|
}
|
||||||
@@ -7379,45 +7661,23 @@ class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str = null;
|
|
||||||
/** True while only whitespace has been seen since the last newline. */
|
/** True while only whitespace has been seen since the last newline. */
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (; i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i];
|
const c = src[i];
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str)
|
|
||||||
str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "\n") {
|
if (c === "\n") {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1)
|
i = skipped;
|
||||||
break; // unterminated: fall through to the error
|
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
|
||||||
const newline = src.indexOf("\n", i + 2);
|
|
||||||
if (newline === -1)
|
|
||||||
break;
|
|
||||||
i = newline - 1; // let the loop's own increment land on the newline
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c !== " " && c !== "\t" && c !== "\r")
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{")
|
if (c === "{")
|
||||||
depth++;
|
depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
@@ -7427,6 +7687,7 @@ class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 0a678785f34a594b64f06ca6e39cf7bea49127ddf344d585e52cc1b3eda9a82a
|
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||||
@@ -169631,6 +169631,32 @@ var isWs = (c) => c === " " || c === "\t" || c === `
|
|||||||
` || c === "\r";
|
` || c === "\r";
|
||||||
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
var isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
||||||
var isIdentPart = (c) => /[A-Za-z0-9_]/.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 {
|
class Lexer {
|
||||||
src;
|
src;
|
||||||
@@ -169892,46 +169918,23 @@ class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str = null;
|
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (;i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i];
|
const c = src[i];
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str)
|
|
||||||
str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === `
|
if (c === `
|
||||||
`) {
|
`) {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1)
|
i = skipped;
|
||||||
break;
|
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
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")
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{")
|
if (c === "{")
|
||||||
depth++;
|
depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
@@ -169941,6 +169944,7 @@ class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
@@ -170752,6 +170756,120 @@ var WRN_DIAGNOSTIC_CODES = {
|
|||||||
migration: "WRN-MIGRATION-001"
|
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
|
// packages/syntax/src/types.ts
|
||||||
function runtimeTypeOf(annotation) {
|
function runtimeTypeOf(annotation) {
|
||||||
if (!annotation)
|
if (!annotation)
|
||||||
@@ -171632,7 +171750,18 @@ function parse(source) {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name: 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;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
export interface Routes {
|
export interface Routes {
|
||||||
"/": Record<string, never>;
|
"/": Record<string, never>;
|
||||||
"/about": Record<string, never>;
|
"/about": Record<string, never>;
|
||||||
|
"/api-block-demo": Record<string, never>;
|
||||||
"/async-data": Record<string, never>;
|
"/async-data": Record<string, never>;
|
||||||
"/chat": Record<string, never>;
|
"/chat": Record<string, never>;
|
||||||
"/client-only": Record<string, never>;
|
"/client-only": Record<string, never>;
|
||||||
@@ -27,6 +28,7 @@ export interface Routes {
|
|||||||
export interface RouteNames {
|
export interface RouteNames {
|
||||||
"index": "/";
|
"index": "/";
|
||||||
"about": "/about";
|
"about": "/about";
|
||||||
|
"api.block.demo": "/api-block-demo";
|
||||||
"async.data": "/async-data";
|
"async.data": "/async-data";
|
||||||
"chat": "/chat";
|
"chat": "/chat";
|
||||||
"client.only": "/client-only";
|
"client.only": "/client-only";
|
||||||
@@ -119,6 +121,7 @@ export function route<N extends RouteName>(
|
|||||||
const paths: Record<RouteName, RoutePath> = {
|
const paths: Record<RouteName, RoutePath> = {
|
||||||
"index": "/",
|
"index": "/",
|
||||||
"about": "/about",
|
"about": "/about",
|
||||||
|
"api.block.demo": "/api-block-demo",
|
||||||
"async.data": "/async-data",
|
"async.data": "/async-data",
|
||||||
"chat": "/chat",
|
"chat": "/chat",
|
||||||
"client.only": "/client-only",
|
"client.only": "/client-only",
|
||||||
|
|||||||
@@ -0,0 +1,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
@@ -13,8 +13,8 @@ declare namespace WRNexusGenerated {
|
|||||||
: never;
|
: never;
|
||||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
type RouteName = "about" | "api.block.demo" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
type ApiRoute = "/api/accounts" | "/api/directory" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.layout" | "nav.navigation" | "nav.ui";
|
||||||
@@ -29,6 +29,7 @@ declare namespace WRNexusGenerated {
|
|||||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||||
|
"/api/directory": { POST: ApiContract<typeof import("../api/directory.ts")["POST"]> };
|
||||||
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
"/api/accounts": { GET: ApiContract<typeof import("../api/accounts.ts")["GET"]> };
|
||||||
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
"/api/invite": { POST: ApiContract<typeof import("../api/invite.ts")["POST"]> };
|
||||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||||
@@ -57,4 +58,14 @@ declare namespace WRNexusGenerated {
|
|||||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||||
}
|
}
|
||||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
|||||||
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
// Keep the CLI checker sourced from the package contract so typecheck fixes are
|
||||||
// included in each published CLI bundle.
|
// included in each published CLI bundle.
|
||||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||||
import { parse } from "@wrnexus/syntax";
|
import { parse, type PageAst } from "@wrnexus/syntax";
|
||||||
import { generate, generateTargets } from "@wrnexus/compiler";
|
import { generate, generateTargets } from "@wrnexus/compiler";
|
||||||
import { regenerateRoutes } from "./routes.ts";
|
import { regenerateRoutes } from "./routes.ts";
|
||||||
import { loadAppConfig } from "@wrnexus/styles";
|
import { loadAppConfig } from "@wrnexus/styles";
|
||||||
@@ -99,6 +99,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(
|
export function generateApplicationTypes(
|
||||||
appRoot: string,
|
appRoot: string,
|
||||||
pluginContributions?: PluginContributions,
|
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>"} };`;
|
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({
|
||||||
|
path: file,
|
||||||
|
ast: parse(readFileSync(file, "utf8")),
|
||||||
|
}));
|
||||||
const typeDir = join(app, "types");
|
const typeDir = join(app, "types");
|
||||||
const apiContracts = router.api
|
const apiContracts = router.api
|
||||||
.map((route) => {
|
.map((route) => {
|
||||||
@@ -218,11 +258,33 @@ declare namespace WRNexusGenerated {
|
|||||||
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
${generatedContractMap("RealtimeMessages", realtimeContracts)}
|
||||||
${generatedContractMap("QueuePayloads", queueContracts)}
|
${generatedContractMap("QueuePayloads", queueContracts)}
|
||||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||||
|
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||||
|
? true
|
||||||
|
: [Actual] extends [Expected]
|
||||||
|
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: false;
|
||||||
|
type __wrn_expect_true<T extends true> = T;
|
||||||
|
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||||
|
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
mkdirSync(typeDir, { recursive: true });
|
mkdirSync(typeDir, { recursive: true });
|
||||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||||
writeFileSync(output, code, "utf8");
|
writeFileSync(output, code, "utf8");
|
||||||
|
// `.d.ts` contents are exempt from checking under `skipLibCheck: true` (set in the
|
||||||
|
// repo/app tsconfig), so the per-block assertions are written into a real `.ts` file
|
||||||
|
// instead — only genuine `.ts`/`.tsx` sources are compiled and checked.
|
||||||
|
const apiChecksCode = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||||
|
//
|
||||||
|
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
|
||||||
|
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
|
||||||
|
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||||
|
${apiBlockAssertions(pageAsts, apiContracts)}
|
||||||
|
export {};
|
||||||
|
`;
|
||||||
|
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||||
writePluginArtifacts(root, pluginContributions);
|
writePluginArtifacts(root, pluginContributions);
|
||||||
return {
|
return {
|
||||||
file: relative(root, output).replace(/\\/g, "/"),
|
file: relative(root, output).replace(/\\/g, "/"),
|
||||||
|
|||||||
@@ -0,0 +1,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");
|
||||||
|
});
|
||||||
@@ -61,6 +61,7 @@ const RUNTIME_BINDINGS = new Set([
|
|||||||
"server",
|
"server",
|
||||||
"props",
|
"props",
|
||||||
"refs",
|
"refs",
|
||||||
|
"api",
|
||||||
"event",
|
"event",
|
||||||
"payload",
|
"payload",
|
||||||
]);
|
]);
|
||||||
@@ -308,6 +309,32 @@ function _functionEntry(
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-mode api blocks become members of an `api` object in client scope.
|
||||||
|
*
|
||||||
|
* Only the response and error bodies are emitted; the declared field types are
|
||||||
|
* type-only and are consumed by the types generator instead. Anything
|
||||||
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||||
|
*/
|
||||||
|
function apiBindings(ast: PageAst): string {
|
||||||
|
const members = ast.dataApis
|
||||||
|
.filter((block) => block.mode === "client" && block.sections)
|
||||||
|
.map((block) => {
|
||||||
|
const sections = block.sections!;
|
||||||
|
const response = 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 {
|
export function generateBrowserModule(ast: PageAst): string {
|
||||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||||
["legacy", "client", "shared"].includes(fn.runtime),
|
["legacy", "client", "shared"].includes(fn.runtime),
|
||||||
@@ -365,6 +392,7 @@ function __wrnexusCreateClientFunctions(context) {
|
|||||||
const server = context.server;
|
const server = context.server;
|
||||||
const props = context.props;
|
const props = context.props;
|
||||||
const refs = context.refs;
|
const refs = context.refs;
|
||||||
|
${apiBindings(ast)}
|
||||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ interface RenderBinding {
|
|||||||
path: string;
|
path: string;
|
||||||
body: string;
|
body: string;
|
||||||
helpers: string;
|
helpers: string;
|
||||||
|
// Present only for a sectioned ssr block with a non-empty `error {}` section.
|
||||||
|
// When set, a failed API call runs this body (with `status`/`message`/`data`
|
||||||
|
// bound) instead of propagating. Absent -> failures propagate, unchanged.
|
||||||
|
errorBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SsrBinding extends RenderBinding {
|
interface SsrBinding extends RenderBinding {
|
||||||
@@ -873,6 +877,7 @@ function renderBinding(binding: NamedDataBinding): RenderBinding {
|
|||||||
path: binding.path,
|
path: binding.path,
|
||||||
body: binding.body,
|
body: binding.body,
|
||||||
helpers: binding.helpers,
|
helpers: binding.helpers,
|
||||||
|
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,11 +941,21 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
if (bindings.has(block.name)) {
|
if (bindings.has(block.name)) {
|
||||||
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
throw new Error(`Duplicate .wrn api binding "${block.name}"`);
|
||||||
}
|
}
|
||||||
|
const sectioned = block.sections;
|
||||||
|
const errorSection = sectioned?.error.trim();
|
||||||
bindings.set(block.name, {
|
bindings.set(block.name, {
|
||||||
mode: block.mode,
|
mode: block.mode,
|
||||||
method: block.method,
|
method: block.method,
|
||||||
path: apiRoutePath(block.path),
|
path: apiRoutePath(block.path),
|
||||||
body: dataBody(block.body),
|
// A sectioned block binds the payload to `data`; the legacy form keeps
|
||||||
|
// the `with ($data)` injection, which cannot be typed.
|
||||||
|
body: sectioned
|
||||||
|
? `const data = $data; ${sectioned.response.trim() || "return data;"}`
|
||||||
|
: dataBody(block.body),
|
||||||
|
// Only a sectioned block with a non-empty `error {}` gets a fallback —
|
||||||
|
// legacy blocks and sectioned blocks without `error` keep failures
|
||||||
|
// propagating exactly as before.
|
||||||
|
...(errorSection ? { errorBody: errorSection } : {}),
|
||||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -949,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ssrRuntimeSource(): string {
|
function ssrRuntimeSource(): string {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch);
|
||||||
}
|
}
|
||||||
@@ -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);
|
return new Function("$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nwith ($data ?? {}) {\\n" + helpers + "\\n" + body + "\\n}")(data, adapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnexusEvalError(err: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown {
|
||||||
|
const adapters = {
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session,
|
||||||
|
localStorage: ctx.localStorage,
|
||||||
|
};
|
||||||
|
const status = (err as { status?: unknown } | null | undefined)?.status;
|
||||||
|
const data = (err as { data?: unknown } | null | undefined)?.data;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Function("$status", "$message", "$data", "$adapters", "const cookies = $adapters.cookies;\\nconst session = $adapters.session;\\nconst localStorage = $adapters.localStorage;\\nconst status = $status;\\nconst message = $message;\\nconst data = $data;\\n" + helpers + "\\n" + body)(status, message, data, adapters);
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnexusPropAttr(
|
function __wrnexusPropAttr(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -997,18 +1024,52 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont
|
|||||||
|
|
||||||
const url = new URL(path, ctx.req.url);
|
const url = new URL(path, ctx.req.url);
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||||
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(".wrn data API request failed with status " + res.status);
|
const data = type.includes("application/json")
|
||||||
|
? await res.json().catch(() => undefined)
|
||||||
|
: await res.text().catch(() => undefined);
|
||||||
|
throw Object.assign(new Error(".wrn data API request failed with status " + res.status), {
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = res.headers.get("content-type") || "";
|
|
||||||
return type.includes("application/json") ? await res.json() : await res.text();
|
return type.includes("application/json") ? await res.json() : await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type __WrnexusApiCall = {
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
helpers: string;
|
||||||
|
errorBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string };
|
||||||
|
|
||||||
|
// Shared by every ssr api-binding consumption site (marker replacement,
|
||||||
|
// #each loop consts, ...) so the narrow try/catch -- only active when the
|
||||||
|
// block declared an error section -- cannot drift between call sites.
|
||||||
|
async function __wrnexusResolveApiBinding(
|
||||||
|
binding: __WrnexusApiCall,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (binding.errorBody) {
|
||||||
|
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> {
|
async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
|
||||||
for (const binding of __wrnexusSsrBindings) {
|
for (const binding of __wrnexusSsrBindings) {
|
||||||
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
|
const value = await __wrnexusResolveApiBinding(binding, ctx);
|
||||||
const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
|
|
||||||
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
@@ -1517,8 +1578,11 @@ function generateInner(ast: PageAst): string {
|
|||||||
for (const [name, binding] of apiBindings) {
|
for (const [name, binding] of apiBindings) {
|
||||||
if (binding.mode !== "ssr") continue;
|
if (binding.mode !== "ssr") continue;
|
||||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||||
|
const errorBodyProp = binding.errorBody
|
||||||
|
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||||
|
: "";
|
||||||
loopConsts.push(
|
loopConsts.push(
|
||||||
` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`,
|
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1526,7 +1590,9 @@ function generateInner(ast: PageAst): string {
|
|||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(
|
||||||
|
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
|
||||||
|
);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(
|
out.push(
|
||||||
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -1380,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
state: stateProxy,
|
state: stateProxy,
|
||||||
output: outputProxy,
|
output: outputProxy,
|
||||||
server: serverProxy,
|
server: serverProxy,
|
||||||
|
callApi: wrnexusCallApi,
|
||||||
props: propsProxy,
|
props: propsProxy,
|
||||||
refs: refsProxy,
|
refs: refsProxy,
|
||||||
};
|
};
|
||||||
@@ -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) {
|
function dispatchComponentEvent(root, name, detail) {
|
||||||
if (!root || !name) return null;
|
if (!root || !name) return null;
|
||||||
var EventConstructor =
|
var EventConstructor =
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
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.
|
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||||
@@ -148,7 +149,10 @@ export interface DataApiBlock {
|
|||||||
name: string;
|
name: string;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Legacy bare body. Empty string when `sections` is set. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Present only for the sectioned, typed form. */
|
||||||
|
sections?: ApiSections;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModeFunctionsBlock {
|
export interface ModeFunctionsBlock {
|
||||||
@@ -696,7 +700,20 @@ export function parse(source: string): PageAst {
|
|||||||
const method = expect("ident").value.toUpperCase();
|
const method = expect("ident").value.toUpperCase();
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
dataApis.push({ mode, name, method, path, body });
|
const sections = 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;
|
break;
|
||||||
}
|
}
|
||||||
case "functions": {
|
case "functions": {
|
||||||
|
|||||||
@@ -31,8 +31,47 @@ export interface Token {
|
|||||||
export class LexError extends Error {}
|
export class LexError extends Error {}
|
||||||
|
|
||||||
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
export const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
|
||||||
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.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 {
|
export class Lexer {
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -312,46 +351,26 @@ export class Lexer {
|
|||||||
const start = this.pos + 1;
|
const start = this.pos + 1;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let i = this.pos;
|
let i = this.pos;
|
||||||
let str: string | null = null;
|
|
||||||
/** True while only whitespace has been seen since the last newline. */
|
/** True while only whitespace has been seen since the last newline. */
|
||||||
let atLineStart = false;
|
let atLineStart = false;
|
||||||
for (; i < src.length; i++) {
|
while (i < src.length) {
|
||||||
const c = src[i]!;
|
const c = src[i]!;
|
||||||
if (str) {
|
|
||||||
if (c === "\\") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === str) str = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === "\n") {
|
if (c === "\n") {
|
||||||
atLineStart = true;
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c === "/" && src[i + 1] === "*") {
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
const close = src.indexOf("*/", i + 2);
|
if (skipped !== null) {
|
||||||
if (close === -1) break; // unterminated: fall through to the error
|
i = skipped;
|
||||||
i = close + 1;
|
|
||||||
atLineStart = false;
|
atLineStart = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atLineStart && c === "/" && src[i + 1] === "/") {
|
|
||||||
const newline = src.indexOf("\n", i + 2);
|
|
||||||
if (newline === -1) break;
|
|
||||||
i = newline - 1; // let the loop's own increment land on the newline
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
||||||
|
|
||||||
if (c === '"' || c === "'" || c === "`") {
|
|
||||||
str = c;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{") depth++;
|
if (c === "{") depth++;
|
||||||
else if (c === "}") {
|
else if (c === "}") {
|
||||||
depth--;
|
depth--;
|
||||||
@@ -360,6 +379,7 @@ export class Lexer {
|
|||||||
return src.slice(start, i);
|
return src.slice(start, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }"',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ const scratchParent = join(root, ".wrnexus-type-check");
|
|||||||
const scratch = mkdtempSync(`${scratchParent}-`);
|
const scratch = mkdtempSync(`${scratchParent}-`);
|
||||||
const generated = [
|
const generated = [
|
||||||
join("app", "types", "wrnexus.generated.d.ts"),
|
join("app", "types", "wrnexus.generated.d.ts"),
|
||||||
|
join("app", "types", "wrnexus.generated.api-checks.ts"),
|
||||||
join("app", "types", "wrnexus.plugins.generated.d.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 app’s `/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).`);
|
||||||
@@ -181,7 +181,9 @@ const runtimeBudgets = {
|
|||||||
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
|
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
|
||||||
* cache.
|
* 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,
|
"component-controllers.ts": 24_100,
|
||||||
"nav-runtime.ts": 12_000,
|
"nav-runtime.ts": 12_000,
|
||||||
"realtime-runtime.ts": 8_000,
|
"realtime-runtime.ts": 8_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user