Compare commits

...
5 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 281615a4b0 fix(validation): stop unrecognised boolean strings coercing to a silent false
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
checkField in packages/validation/src/index.ts (and its browser mirror in
runtime.ts) treated any string other than "true"/"on" as false with no
error, so typos like "treu" or values like "yes"/"1"/"TRUE" silently
passed as false.

Now:
- true/false booleans pass through unchanged
- recognised true strings (case-insensitive, trimmed): true, on, 1, yes
- recognised false strings: false, off, 0, no
- numeric 1/0 coerce (JSON payloads)
- undefined/null/"" still coerce to false (unchecked-checkbox semantics)
- anything else is now a type error (desc.typeMessage or "Must be true or
  false") instead of a silent false

Locked-in behaviours preserved: a required boolean given false still
errors, and parseEnv DEBUG: "true" coercion still works.

Added coverage for recognised strings, numeric 1/0, the type-error
regression guard, absent/empty handling, the required+false case, and a
client/server parity test driving both checkField and the browser runtime
through the same inputs.

Blast radius: searched packages/, examples/, services/ for v.boolean()
usage; all existing call sites (auth consent/rememberDevice, db 'active'
default, example consent checkboxes) feed true/false/'on'/absent values,
none of which change behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:26:20 +05:30
Clintchiz 3ae5d7cf97 fix(compiler): stop error {} from swallowing response {} bugs in api blocks
Client codegen chained .then().catch(), so a .catch() after .then()
caught exceptions thrown by the response body too. Switched to the
two-argument then(onFulfilled, onRejected) form, whose rejection
handler cannot see errors from the fulfilment handler.

SSR codegen wrapped both the transport call and the response-body eval
in the same try; only __wrnexusCallApi is now inside the try, and
__wrnexusEvalData runs after it, outside.

Also verifies (and locks in with a regression test) that GET query
numbers already coerce correctly through defineEndpoint + checkField,
and documents that in the typed-api-block spec.
2026-08-19 21:11:26 +05:30
ClintchizandClaude Opus 5 b5029889a5 fix: address all seven final-gate findings for typed api blocks
B1: qualify each generated __wrn_api_check_* assertion name with a short
hash of the page's path (relative to app/, for reproducibility across
checkouts) so two pages declaring a same-named block no longer collide
with an identical type alias (TS2300).

B2: skip assertion emission for any block that is not client-mode, or
that has zero declared request fields. ssr sectioned blocks can never
declare a request and always fell back to Record<string, never>, whose
keyof is `string` -- making the key-exactness arm of AssertAssignable
evaluate to false unconditionally (TS2344) on every ssr sectioned block
regardless of correctness. Chose to skip both non-client blocks and
zero-field client blocks, since neither has anything meaningful to
assert type-safety about.

B3: only resolve the endpoint's input (query params / ctx.req.json())
when the endpoint declares an input schema. Previously the router-set
fix accidentally read the request body unconditionally, so a handler
with no input schema that parses the request itself hit
ERR_BODY_ALREADY_USED.

B4: run response/error bodies in client-mode api blocks through
eraseFunctionTypes, matching every other browser-bound body in
client-codegen.ts, so a TypeScript-only construct inside one (e.g. an
annotated locally-declared function) doesn't reach the .mjs artifact.

B5: only exclude "api" from state/prop destructuring in the generated
browser module when the page actually has client-mode api blocks (i.e.
there is a real `api` binding to shadow). Previously "api" was always
excluded, so a page with `state api` and no api blocks got an
undeclared `api` reference (ReferenceError) in client code.

B6: prefix each emitted assertion with `export`, so it isn't flagged as
an unused local under a downstream project's noUnusedLocals (TS6196).

B7: wrnexusCallApi now resolves with undefined for an ok 204/205
response, or an ok response with an empty/unparseable body, instead of
rejecting with "Response was not valid JSON" -- matching the spec's
failure table (error path only for non-2xx, network failure, or an
actually unparseable body on a non-empty response).

Regenerated examples/basic-app's generated types and editor bundles to
match. Confirmed the example's type gate still fails when an
unaccepted field is added to a request body, and passes cleanly
otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:38:22 +05:30
ClintchizandClaude Opus 5 e9db4ca24d test(core): cover defineEndpoint's no-second-argument request-parsing path
Every existing test in endpoint-schema.test.ts passed rawInput
explicitly, so the branch added to endpoint.ts's fix (GET/HEAD query
parsing, JSON body parsing, malformed/absent body fallback) was
exercised by nothing but a manual curl. Add coverage that calls the
endpoint with only a context, matching the real router's calling
convention:

- GET with query parameters populates input from ctx.url.searchParams.
- POST with a JSON body populates input from the parsed body.
- POST with a malformed or absent body does not throw; the schema's
  own validation decides the outcome (asserted on the real response).
- An explicit rawInput argument still wins and the request is never
  read (the body is drained first, so a second .json() call would
  reject if the endpoint tried to read it again) -- the regression
  guard for the branch intentionally left untouched.

Confirmed the GET and POST-body tests fail against the pre-fix
endpoint.ts (input resolves as undefined/null instead of the sent
value); the malformed/absent-body test does not distinguish pre- and
post-fix, because in that specific edge case both normalize to an
effectively empty input -- noted in the report rather than forced.

Added a CHANGELOG entry documenting the behavior change for
downstream apps: a request that previously passed vacuous validation
on a defineEndpoint route can now legitimately fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:16:32 +05:30
ClintchizandClaude Opus 5 5318320c70 fix(examples): make the api-block demo endpoint actually type-check
directory.ts previously exported a plain (ctx: Context) => ... handler.
With that shape ApiInput<> resolved to unknown, so the generated
__wrn_api_check assertion for the demo page passed trivially even with
a field the endpoint does not accept -- the worked example did not
demonstrate the type safety it exists to demonstrate.

Rewrite directory.ts to use defineEndpoint with a schema (matching
typed-user.ts), which gives the generated assertion a real input type
to check against. Confirmed: adding an unaccepted field to the block's
request body now fails typecheck naming
__wrn_api_check_searchDirectory; removing it passes with zero net
diff.

Fix a real bug this surfaced: packages/core/src/endpoint.ts only read
its input from a second 'rawInput' argument, but the actual HTTP
router (packages/dev-server/src/runtime.ts handleApi) invokes route
handlers as handler(ctx) with no second argument. Every
defineEndpoint-based route -- including the pre-existing typed-user.ts
example -- silently received an empty/undefined input through the
real router (confirmed via curl: valid typed-user payloads were
rejected as 'Required'; directory's name filter matched every record
regardless of query). Fixed by having the endpoint wrapper parse the
request itself (query params for GET/HEAD, JSON body otherwise) when
no rawInput is explicitly supplied, while still honoring an explicit
rawInput for direct/unit-test callers.

Also update api-block-demo.wrn's response section: defineEndpoint
wraps handler output as { data: ... }, so the block's raw response
body is now { data: { users: [...] } } -- response reads
data.data.users instead of data.users.

Re-verified in a real browser after the endpoint rewrite and the
router fix: search returns exactly "Ajay, Asha", exactly one
POST /api/directory carrying x-csrf-token, and the error section
still runs cleanly (no exception, empty result) on a missing route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:10 +05:30
21 changed files with 824 additions and 40 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## Unreleased
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
required boolean field given `false` still errors, as before (checkbox-required semantics
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
to start failing.
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
when no input is passed explicitly. Previously such endpoints silently validated
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
what was sent. **Behavior change for downstream apps:** a request that previously passed
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
priority over reading the request.
## 0.8.8 ## 0.8.8
- Added the framework request context to `.wrn` language-server type environments. - Added the framework request context to `.wrn` language-server type environments.
@@ -158,6 +158,22 @@ A plain handler returning `Response.json` has no `defineEndpoint` contract, so `
to `unknown`. The block's declared types are used directly and the generator emits a warning naming 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. the route. Untyped endpoints stay visible rather than silently passing.
### GET parameters travel as strings
A `GET` block's `parameters` become a query string (see Request assembly below), and every
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
only because the endpoint's own schema coerces it back: `checkField` in
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
route also has no contract for the generator to check against, so it already falls under "Routes
without a contract" above and is flagged there.
### Staleness ### Staleness
Checking is only as current as the generated file, so this stays wired into the existing Checking is only as current as the generated file, so this stays wired into the existing
+18 -8
View File
@@ -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: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e // WRN editor compiler source hash: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3 // Generated with TypeScript: 6.0.3
const __nodeRequire = require; const __nodeRequire = require;
@@ -724,12 +724,12 @@ function apiBindings(ast) {
.filter((block) => block.mode === "client" && block.sections) .filter((block) => block.mode === "client" && block.sections)
.map((block) => { .map((block) => {
const sections = block.sections; const sections = block.sections;
const response = sections.response.trim() || "return data;"; const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
const error = sections.error.trim(); const error = (0, syntax_1.eraseFunctionTypes)(sections.error).trim();
const failure = error const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }` ? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw 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 ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
}); });
return members.length ? `const api = {\n${members.join(",\n")}\n };` : ""; return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
} }
@@ -740,10 +740,19 @@ function generateBrowserModule(ast) {
const selectedImports = selectedBrowserImports(ast, functions); const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n"); const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name)); // `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name));
const sharedProps = ast.props const sharedProps = ast.props
.map((entry) => entry.name) .map((entry) => entry.name)
.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name)); .filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name));
const callableAliases = functionNames.filter((name) => safeIdentifier(name) && const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
!RUNTIME_BINDINGS.has(name) && !RUNTIME_BINDINGS.has(name) &&
!sharedState.includes(name) && !sharedState.includes(name) &&
@@ -1682,12 +1691,13 @@ async function __wrnexusResolveApiBinding(
ctx: __WrnexusContext, ctx: __WrnexusContext,
): Promise<unknown> { ): Promise<unknown> {
if (binding.errorBody) { if (binding.errorBody) {
let data: unknown;
try { try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx); data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} catch (err) { } catch (err) {
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx); return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
} }
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} }
const data = await __wrnexusCallApi(binding.path, binding.method, ctx); const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7 // WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict"; "use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+20 -7
View File
@@ -1,14 +1,27 @@
import { defineEndpoint } from "@wrnexus/core"; import { defineEndpoint } from "@wrnexus/core";
import type { Context } from "@wrnexus/core"; import { SearchDirectorySchema } from "../schemas/search-directory.ts";
const ALL = [ interface DirectoryUser {
name: string;
designation: string;
}
const ALL: DirectoryUser[] = [
{ name: "Ajay", designation: "UI" }, { name: "Ajay", designation: "UI" },
{ name: "Asha", designation: "Backend" }, { name: "Asha", designation: "Backend" },
{ name: "Chen", designation: "UI" }, { name: "Chen", designation: "UI" },
]; ];
export const POST = async (ctx: Context) => { /**
const body = (await ctx.req.json().catch(() => ({}))) as { name?: string }; * Schema-typed handler: input resolves to `{ name?: string }` from
const needle = String(body.name ?? "").toLowerCase(); * SearchDirectorySchema, so the block's request shape is checked for real
return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) }); * (not against `unknown`, which the untyped-handler shape resolved to).
}; */
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
input: SearchDirectorySchema,
description: "Case-insensitive substring search over the demo directory by name.",
handler(input) {
const needle = String(input.name ?? "").toLowerCase();
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
},
});
@@ -12,7 +12,7 @@ page ApiBlockDemo {
} }
response { response {
return data.users return data.data.users
} }
error { error {
@@ -0,0 +1,5 @@
import { v } from "@wrnexus/validation";
export const SearchDirectorySchema = v.object({
name: v.string().trim().optional(),
});
@@ -3,5 +3,5 @@
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not // 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 // 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. // 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 type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export {}; export {};
+43 -7
View File
@@ -106,26 +106,62 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
* `app/` and is therefore compiled by the project's own tsc, while generated * `app/` and is therefore compiled by the project's own tsc, while generated
* build artifacts are not type-checked at all. * build artifacts are not type-checked at all.
*/ */
function apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string { function pageSlug(path: string): string {
// Block names are only unique within a single page (see apiBindingMap), so
// two pages each declaring e.g. `api search` is legal and would otherwise
// emit the identical `__wrn_api_check_search` type alias twice into this
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
// name with a short deterministic hash of the page's path (not the whole
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
// to keep names unique across the whole app while staying compact.
const normalized = path.replace(/\\/g, "/");
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
function apiBlockAssertions(
pages: { path: string; ast: PageAst }[],
apiContracts: string,
appDir: string,
): string {
const lines: string[] = []; const lines: string[] = [];
for (const page of pages) { for (const page of pages) {
// Hash the path relative to `app/`, not the absolute path: the absolute
// path varies with where the project checkout lives (e.g. a CI runner's
// temp clone vs. a developer's local path), which would make this file
// spuriously "stale" every time it's regenerated somewhere else.
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
for (const block of page.ast.dataApis) { for (const block of page.ast.dataApis) {
if (!block.sections) continue; if (!block.sections) continue;
// ssr-mode sectioned blocks can never declare a `request` (they are
// render-time only), so they always fall back to the empty-shape
// `Record<string, never>` below. `keyof Record<string, never>` is
// `string`, which makes the key-exactness arm of AssertAssignable
// evaluate to `false` unconditionally and raises TS2344 on every such
// block regardless of whether the block author did anything wrong.
// We choose to skip emission for both (a) any non-client-mode block,
// since it structurally can never have a request to check, and (b) any
// block -- client included -- that has zero declared request fields,
// since there is nothing to assert type-safety about. This is more
// honest about intent than emitting a vacuous/always-failing check.
const fields = [...block.sections.parameters, ...block.sections.body];
if (block.mode !== "client" || fields.length === 0) continue;
if (!apiContracts.includes(JSON.stringify(block.path))) { if (!apiContracts.includes(JSON.stringify(block.path))) {
console.warn( console.warn(
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`, `[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.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
const shape = fields.length
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
: "Record<string, never>";
lines.push( lines.push(
`type __wrn_api_check_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify( `export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
block.path, block.path,
)}, ${JSON.stringify(block.method)}>>>;`, )}, ${JSON.stringify(block.method)}>>>;`,
); );
@@ -281,7 +317,7 @@ declare namespace WRNexusGenerated {
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not // 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 // 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. // checked; this file is compiled and checked normally by the project's own tsc.
${apiBlockAssertions(pageAsts, apiContracts)} ${apiBlockAssertions(pageAsts, apiContracts, app)}
export {}; export {};
`; `;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8"); writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
+77 -4
View File
@@ -60,7 +60,7 @@ test("emits one assertion per sectioned block, naming its route and method", ()
generateApplicationTypes(root); generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).toContain("__wrn_api_check_searchUsers"); expect(checks).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">'); expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
expect(checks).toContain("name?: string"); expect(checks).toContain("name?: string");
expect(checks).toContain("age?: number"); expect(checks).toContain("age?: number");
@@ -85,6 +85,79 @@ test("the api-checks file has no runtime code and is a module", () => {
expect(checks.trim().endsWith("export {};")).toBe(true); expect(checks.trim().endsWith("export {};")).toBe(true);
}); });
test("B1: two pages each declaring a block with the same name do not collide", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/one.wrn"),
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
);
writeFileSync(
join(root, "app/pages/two.wrn"),
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
expect(names.length).toBe(2);
expect(new Set(names).size).toBe(2);
});
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const GET = async () => Response.json({ users: [] });\n`,
);
writeFileSync(
join(root, "app/pages/ssr.wrn"),
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("loadUsers");
});
test("B2: a client block with an empty request emits no assertion", () => {
const root = fixture(` api pingServer GET /api/users {
response {
return data.users
}
}`);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
expect(checks).not.toContain("__wrn_api_check");
expect(checks).not.toContain("pingServer");
});
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
const root = fixture(BLOCK);
generateApplicationTypes(root);
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const assertionLine = checks
.split(/\r?\n/)
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
expect(assertionLine).toBeDefined();
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
});
// --- Real-compiler enforcement tests --------------------------------------------- // --- Real-compiler enforcement tests ---------------------------------------------
// //
// Everything above only asserts on the emitted *text*. That proves nothing about // Everything above only asserts on the emitted *text*. That proves nothing about
@@ -186,7 +259,7 @@ test("tsc: a field with the wrong type fails, naming the block's assertion", ()
expect(output).toContain("wrnexus.generated.api-checks.ts"); expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
}); });
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => { test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
@@ -210,7 +283,7 @@ test("tsc: an extra field the contract does not accept fails (Finding A regressi
expect(output).toContain("wrnexus.generated.api-checks.ts"); expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
}); });
test("tsc: a missing required field fails", () => { test("tsc: a missing required field fails", () => {
@@ -232,5 +305,5 @@ test("tsc: a missing required field fails", () => {
expect(output).toContain("wrnexus.generated.api-checks.ts"); expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
}); });
+17 -5
View File
@@ -321,15 +321,15 @@ function apiBindings(ast: PageAst): string {
.filter((block) => block.mode === "client" && block.sections) .filter((block) => block.mode === "client" && block.sections)
.map((block) => { .map((block) => {
const sections = block.sections!; const sections = block.sections!;
const response = sections.response.trim() || "return data;"; const response = eraseFunctionTypes(sections.response).trim() || "return data;";
const error = sections.error.trim(); const error = eraseFunctionTypes(sections.error).trim();
const failure = error const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }` ? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw error; }`; : `(error) => { throw error; }`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify( return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
block.path, block.path,
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`; )}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
}); });
return members.length ? `const api = {\n${members.join(",\n")}\n };` : ""; return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
@@ -344,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
const selectedImports = selectedBrowserImports(ast, functions); const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n"); const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name)); // `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter(
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
);
const sharedProps = ast.props const sharedProps = ast.props
.map((entry) => entry.name) .map((entry) => entry.name)
.filter( .filter(
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name), (name) =>
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
); );
const callableAliases = functionNames.filter( const callableAliases = functionNames.filter(
(name) => (name) =>
+3 -2
View File
@@ -1056,12 +1056,13 @@ async function __wrnexusResolveApiBinding(
ctx: __WrnexusContext, ctx: __WrnexusContext,
): Promise<unknown> { ): Promise<unknown> {
if (binding.errorBody) { if (binding.errorBody) {
let data: unknown;
try { try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx); data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} catch (err) { } catch (err) {
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx); return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
} }
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
} }
const data = await __wrnexusCallApi(binding.path, binding.method, ctx); const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
@@ -1,7 +1,15 @@
import { expect, test } from "bun:test"; 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 { parse } from "@wrnexus/syntax";
import { generateTargets } from "../src/targets.ts"; import { generateTargets } from "../src/targets.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function browserModule(inner: string): string { function browserModule(inner: string): string {
return generateTargets( return generateTargets(
parse(`page Repro { parse(`page Repro {
@@ -81,6 +89,173 @@ test("a block without an error section still emits its response body", () => {
expect(generated).toContain("data.users"); expect(generated).toContain("data.users");
}); });
test("type annotations in response/error bodies are erased before emission (B4)", () => {
// Every other browser-bound body in the repo passes through eraseFunctionTypes
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
// store-codegen.ts); response/error bodies must too, for the same reason:
// eraseFunctionTypes strips function-signature annotations (params, return
// type, typed catch clauses) so a locally-declared helper function inside a
// response/error body no longer ships raw TypeScript into the .mjs artifact.
const generated = browserModule(` api searchUsers POST /api/users {
request {
body {
name?: string
}
}
response {
function pick(list: string[]): string[] { return list }
return pick(data.users)
}
error {
function describe(e: unknown): string { return String(e) }
return describe(error)
}
}`);
expect(generated).not.toContain("list: string[]");
expect(generated).not.toContain("): string[] {");
expect(generated).not.toContain("e: unknown");
expect(generated).not.toContain("): string {");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a page with state api and no client api blocks still reads that state (B5)", () => {
// "api" is normally excluded from state/prop destructuring because the
// emitted `const api = {...}` binding would shadow it -- but that binding
// only exists when the page has client-mode api blocks. Without one, the
// exclusion left `api` completely undeclared: a ReferenceError.
const generated = generateTargets(
parse(`page Repro {
state {
api = "hello"
}
functions {
client function run(): void {
console.log(api)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(generated).toContain("context.state");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
/**
* Builds a browser module whose `run()` function calls api.searchUsers and
* reports the outcome through `output.report(...)` so the test can observe
* whether the call resolved or rejected without reaching into codegen
* internals.
*/
function reportingBrowserModule(apiBlock: string): string {
return generateTargets(
parse(`page Repro {
client {
${apiBlock}
}
outputs {
report(payload: any)
}
functions {
client async function run(): Promise<void> {
try {
const users = await api.searchUsers({ name: "Ajay" })
output.report({ ok: true, users })
} catch (e) {
output.report({ ok: false, message: String(e && e.message || e) })
}
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
async function importBrowserModule(source: string): Promise<any> {
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.mjs");
writeFileSync(file, source);
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
}
test("a response body error is not swallowed by the error section (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users.missing.length
}
error {
return []
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => ({ users: [] }),
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
// The error section's own fallback ("[]" / an empty array) must not have
// been what the caller observed -- a bug in the response body is a
// rejection, not a silently-returned fallback value.
expect(reports[0]).not.toEqual({ ok: true, users: [] });
});
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users
}
error {
return ["fallback"]
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => {
throw Object.assign(new Error("transport failed"), { status: 500 });
},
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
});
test("a state field named api does not collide with the emitted api object", () => { test("a state field named api does not collide with the emitted api object", () => {
const generated = generateTargets( const generated = generateTargets(
parse(`page Repro { parse(`page Repro {
@@ -167,6 +167,83 @@ test("an ssr block used in {#each} with an error section runs the error body on
expect(html).toContain("fallback"); expect(html).toContain("fallback");
}); });
test("an ssr block's response body error is not swallowed by the error section", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.missing.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => ({ users: [] }),
}),
).rejects.toThrow();
});
test("an ssr block still runs the error body on a genuine transport failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block used in {#each} without an error section still propagates a failure", async () => { test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
const generated = generate( const generated = generate(
parse(`page Repro { parse(`page Repro {
+15 -1
View File
@@ -96,7 +96,21 @@ export function defineEndpoint(
if (definition.auth === "required" && !ctx.user) { if (definition.auth === "required" && !ctx.user) {
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required."); throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
} }
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput; // The real HTTP router invokes route handlers as `handler(ctx)` — it never
// supplies a second argument. Callers that already have a parsed payload
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
// that always wins. Otherwise, read the request ourselves: query params for
// GET/HEAD, JSON body for everything else.
let input: unknown = rawInput;
if (definition.input) {
const resolvedInput =
rawInput !== undefined
? rawInput
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
? Object.fromEntries(ctx.url.searchParams)
: await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput);
}
const rawOutput = await definition.handler(input, ctx); const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output }); return output instanceof Response ? output : json({ data: output });
+105
View File
@@ -29,3 +29,108 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" }); const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } }); expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
}); });
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
// passes rawInput explicitly, so it never exercises that calling convention. These
// tests call the endpoint with only a context, matching what actually happens in
// production, to guard against silently validating `undefined` again.
const search = v.object({ name: v.string().trim().optional() });
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
input: search,
handler(input) {
return { name: input.name ?? null };
},
});
test("with no second argument, a GET request reads input from the URL's query string", async () => {
const request = new Request("https://example.test/api/search?name=Ada");
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
const malformedRequest = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
});
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
const malformedResponse = await searchEndpoint(malformedCtx);
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
// the point is that the malformed body did not throw an unhandled parse error.
expect(malformedResponse.status).toBe(200);
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
const requiredField = v.object({ name: v.string().min(1) });
const requiredEndpoint = defineEndpoint({
input: requiredField,
handler(input) {
return input;
},
});
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
const emptyResponse = await requiredEndpoint(emptyCtx);
// With no body at all, resolved input is {} — the schema's own required-field
// validation is what turns that into a 400, not a thrown parse error.
expect(emptyResponse.status).toBe(400);
expect(await emptyResponse.json()).toEqual({
error: {
code: "VALIDATION_ERROR",
message: "Endpoint validation failed.",
details: { name: "Required" },
},
});
});
// GET query strings travel as text (`URLSearchParams` values are always
// strings), so a `v.number()` field must come back as a real number, not the
// string the wire actually carried, or a page declaring `age?: number` on a
// GET api block would be lying about the type. checkField in
// @wrnexus/validation coerces via Number(pre) for both optional and required
// number fields (see packages/validation/src/index.ts); this locks that in
// end-to-end through defineEndpoint's own GET query-string resolution path.
test("a GET request coerces a v.number() query param to an actual number", async () => {
const ageSchema = v.object({ age: v.number() });
const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({
input: ageSchema,
handler(input) {
return { age: input.age, typeofAge: typeof input.age };
},
});
const request = new Request("https://example.test/api/age?age=30");
const ctx = createContext(request, new URL(request.url));
const response = await ageEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } });
});
test("an explicit rawInput argument still wins and the request is never read", async () => {
// A request whose body has already been consumed: if the endpoint tried to read it
// again (rather than trusting the explicit rawInput), this would throw.
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "ignored-body" }),
});
await request.json(); // drain the body so a second .json() call would reject
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx, { name: "Explicit" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
});
+7
View File
@@ -4284,6 +4284,12 @@ export const REACTIVE_RUNTIME = String.raw`
} }
return fetch(url, init).then(function (response) { return fetch(url, init).then(function (response) {
// A 2xx with no body (204/205, or a genuinely empty response) is a
// success, not a parse failure -- the failure table only calls for the
// error path on non-2xx, network failure, or an unparseable body.
if (response.ok && (response.status === 204 || response.status === 205)) {
return undefined;
}
return response.json().then( return response.json().then(
function (data) { function (data) {
if (response.ok) return data; if (response.ok) return data;
@@ -4295,6 +4301,7 @@ export const REACTIVE_RUNTIME = String.raw`
throw failure; throw failure;
}, },
function () { function () {
if (response.ok) return undefined;
var failure = new Error("Response was not valid JSON"); var failure = new Error("Response was not valid JSON");
failure.status = response.status; failure.status = response.status;
failure.data = undefined; failure.data = undefined;
+36
View File
@@ -79,6 +79,42 @@ test("a 2xx resolves to the parsed payload", async () => {
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] }); expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
}); });
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 204,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
(0, eval)(REACTIVE_RUNTIME);
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
.__wrnexusCallApi;
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
});
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
const { callApi } = harness({ status: 200, payload: undefined });
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
});
test("a non-2xx rejects with status, message and data", async () => { test("a non-2xx rejects with status, message and data", async () => {
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } }); const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
+22 -1
View File
@@ -114,7 +114,28 @@ export function checkField(
} }
if (desc.type === "boolean") { if (desc.type === "boolean") {
const value = raw === true || raw === "true" || raw === "on"; const empty = raw === undefined || raw === null || raw === "";
let value: boolean;
if (typeof raw === "boolean") {
value = raw;
} else if (empty) {
value = false;
} else if (raw === 1) {
value = true;
} else if (raw === 0) {
value = false;
} else if (typeof raw === "string") {
const norm = raw.trim().toLowerCase();
if (norm === "true" || norm === "on" || norm === "1" || norm === "yes") {
value = true;
} else if (norm === "false" || norm === "off" || norm === "0" || norm === "no") {
value = false;
} else {
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
}
} else {
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
}
if (!desc.optional && !value) { if (!desc.optional && !value) {
return { value, error: desc.requiredMessage || "Required" }; return { value, error: desc.requiredMessage || "Required" };
} }
+22 -1
View File
@@ -45,7 +45,28 @@ export const VALIDATE_RUNTIME = String.raw`
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null; return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
} }
if (desc.type === "boolean") { if (desc.type === "boolean") {
var b = raw === true || raw === "true" || raw === "on"; var bEmpty = raw === undefined || raw === null || raw === "";
var b;
if (typeof raw === "boolean") {
b = raw;
} else if (bEmpty) {
b = false;
} else if (raw === 1) {
b = true;
} else if (raw === 0) {
b = false;
} else if (typeof raw === "string") {
var bNorm = raw.trim().toLowerCase();
if (bNorm === "true" || bNorm === "on" || bNorm === "1" || bNorm === "yes") {
b = true;
} else if (bNorm === "false" || bNorm === "off" || bNorm === "0" || bNorm === "no") {
b = false;
} else {
return desc.typeMessage || "Must be true or false";
}
} else {
return desc.typeMessage || "Must be true or false";
}
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null; return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
} }
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw; var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
+134
View File
@@ -114,6 +114,140 @@ test("checkField coerces and applies rules", () => {
).toBe("Email is required"); ).toBe("Email is required");
}); });
test("checkField coerces recognised true/false boolean strings, case-insensitively and trimmed", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
for (const raw of [
"true",
"TRUE",
" True ",
"on",
"ON",
" on ",
"1",
" 1 ",
"yes",
"YES",
" Yes ",
]) {
expect(checkField(desc, raw)).toEqual({ value: true, error: null });
}
for (const raw of [
"false",
"FALSE",
" False ",
"off",
"OFF",
" off ",
"0",
" 0 ",
"no",
"NO",
" No ",
]) {
expect(checkField(desc, raw)).toEqual({ value: false, error: null });
}
});
test("checkField coerces numeric 1/0 booleans (common in JSON payloads)", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
expect(checkField(desc, 1)).toEqual({ value: true, error: null });
expect(checkField(desc, 0)).toEqual({ value: false, error: null });
expect(checkField(desc, true)).toEqual({ value: true, error: null });
expect(checkField(desc, false)).toEqual({ value: false, error: null });
});
test("checkField rejects unrecognised boolean strings/values as a type error, not a silent false", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
for (const raw of ["yes please", "maybe", "treu", "TRUE!", "2", { a: 1 }, [1, 2]]) {
const result = checkField(desc, raw);
expect(result.error).toBe("Must be true or false");
}
const custom = checkField(
{ type: "boolean" as const, optional: true, typeMessage: "Pick yes or no", rules: [] },
"maybe",
);
expect(custom.error).toBe("Pick yes or no");
});
test("checkField treats absent/empty boolean input as false, erroring only when required", () => {
const optionalDesc = { type: "boolean" as const, optional: true, rules: [] };
expect(checkField(optionalDesc, undefined)).toEqual({ value: false, error: null });
expect(checkField(optionalDesc, null)).toEqual({ value: false, error: null });
expect(checkField(optionalDesc, "")).toEqual({ value: false, error: null });
const requiredDesc = {
type: "boolean" as const,
requiredMessage: "Required",
rules: [],
};
expect(checkField(requiredDesc, undefined).error).toBe("Required");
expect(checkField(requiredDesc, "").error).toBe("Required");
});
test("checkField still errors when a required boolean is explicitly false (checkbox semantics locked in)", () => {
expect(
checkField(
{ type: "boolean" as const, requiredMessage: "Accept the terms to continue", rules: [] },
false,
),
).toEqual({ value: false, error: "Accept the terms to continue" });
});
test("client/server boolean parity: checkField and the browser runtime agree on every case", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="parity">
<input name="field">
<span data-error="field"></span>
</form>`;
const schema = v.object({ field: v.boolean().optional() }).describe();
win.__wrnSchemas = { parity: schema };
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
try {
(0, eval)(VALIDATE_RUNTIME);
const runtime = win.__wrnValidate as { init(root: Document): void };
runtime.init(win.document as unknown as Document);
const input = win.document.querySelector("input") as HappyDOMHTMLInputElement;
const error = win.document.querySelector("[data-error=field]") as HappyDOMHTMLElement;
const cases = [
"true",
"TRUE",
" True ",
"on",
"ON",
"1",
" 1 ",
"yes",
"YES",
"false",
"FALSE",
"off",
"OFF",
"0",
"no",
"NO",
"",
"yes please",
"maybe",
"treu",
];
for (const raw of cases) {
const serverResult = checkField(schema.fields.field, raw);
input.value = raw;
input.dispatchEvent(windowEvent(win, "blur", { bubbles: true }));
const clientRejected = error.textContent !== "";
expect(clientRejected).toBe(serverResult.error !== null);
}
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("invalid() returns a 400 with errors", async () => { test("invalid() returns a 400 with errors", async () => {
const res = invalid({ email: "bad" }); const res = invalid({ email: "bad" });
expect(res.status).toBe(400); expect(res.status).toBe(400);