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>
This commit is contained in:
2026-08-19 20:38:22 +05:30
co-authored by Claude Opus 5
parent e9db4ca24d
commit b5029889a5
10 changed files with 267 additions and 29 deletions
+14 -5
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: fde135d610b595588974c7b88dc73ddf10c1da99e106a2c0a009be2707fc13a8
// 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,8 +724,8 @@ 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; }`;
@@ -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) &&
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7 // WRN editor extension source hash: bdf6f7f12c426181cad9a70f9ad1f3df2212bc487116ff3af97d2effd68b0c53
// 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);
@@ -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/);
}); });
+16 -4
View File
@@ -321,8 +321,8 @@ 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; }`;
@@ -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) =>
@@ -81,6 +81,68 @@ 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();
});
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 {
+4 -1
View File
@@ -101,13 +101,16 @@ export function defineEndpoint(
// (unit tests, internal RPC-style calls) may still pass one explicitly, and // (unit tests, internal RPC-style calls) may still pass one explicitly, and
// that always wins. Otherwise, read the request ourselves: query params for // that always wins. Otherwise, read the request ourselves: query params for
// GET/HEAD, JSON body for everything else. // GET/HEAD, JSON body for everything else.
let input: unknown = rawInput;
if (definition.input) {
const resolvedInput = const resolvedInput =
rawInput !== undefined rawInput !== undefined
? rawInput ? rawInput
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD" : ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
? Object.fromEntries(ctx.url.searchParams) ? Object.fromEntries(ctx.url.searchParams)
: await ctx.req.json().catch(() => ({})); : await ctx.req.json().catch(() => ({}));
const input = definition.input ? schemaValue(definition.input, resolvedInput) : resolvedInput; 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 });
+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" } });