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:
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// 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
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -724,8 +724,8 @@ function apiBindings(ast) {
|
||||
.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 response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
|
||||
const error = (0, syntax_1.eraseFunctionTypes)(sections.error).trim();
|
||||
const failure = error
|
||||
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||
: `(error) => { throw error; }`;
|
||||
@@ -740,10 +740,19 @@ function generateBrowserModule(ast) {
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||
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
|
||||
.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) &&
|
||||
!RUNTIME_BINDINGS.has(name) &&
|
||||
!sharedState.includes(name) &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7
|
||||
// WRN editor extension source hash: bdf6f7f12c426181cad9a70f9ad1f3df2212bc487116ff3af97d2effd68b0c53
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
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
|
||||
// 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 type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
|
||||
export {};
|
||||
|
||||
@@ -106,26 +106,62 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
|
||||
* `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 {
|
||||
function pageSlug(path: string): string {
|
||||
// Block names are only unique within a single page (see apiBindingMap), so
|
||||
// two pages each declaring e.g. `api search` is legal and would otherwise
|
||||
// emit the identical `__wrn_api_check_search` type alias twice into this
|
||||
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
|
||||
// name with a short deterministic hash of the page's path (not the whole
|
||||
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
|
||||
// to keep names unique across the whole app while staying compact.
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
let hash = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function apiBlockAssertions(
|
||||
pages: { path: string; ast: PageAst }[],
|
||||
apiContracts: string,
|
||||
appDir: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// Hash the path relative to `app/`, not the absolute path: the absolute
|
||||
// path varies with where the project checkout lives (e.g. a CI runner's
|
||||
// temp clone vs. a developer's local path), which would make this file
|
||||
// spuriously "stale" every time it's regenerated somewhere else.
|
||||
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
|
||||
for (const block of page.ast.dataApis) {
|
||||
if (!block.sections) continue;
|
||||
|
||||
// ssr-mode sectioned blocks can never declare a `request` (they are
|
||||
// render-time only), so they always fall back to the empty-shape
|
||||
// `Record<string, never>` below. `keyof Record<string, never>` is
|
||||
// `string`, which makes the key-exactness arm of AssertAssignable
|
||||
// evaluate to `false` unconditionally and raises TS2344 on every such
|
||||
// block regardless of whether the block author did anything wrong.
|
||||
// We choose to skip emission for both (a) any non-client-mode block,
|
||||
// since it structurally can never have a request to check, and (b) any
|
||||
// block -- client included -- that has zero declared request fields,
|
||||
// since there is nothing to assert type-safety about. This is more
|
||||
// honest about intent than emitting a vacuous/always-failing check.
|
||||
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||
if (block.mode !== "client" || fields.length === 0) continue;
|
||||
|
||||
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||
console.warn(
|
||||
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||
const shape = fields.length
|
||||
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
|
||||
|
||||
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,
|
||||
)}, ${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
|
||||
// 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)}
|
||||
${apiBlockAssertions(pageAsts, apiContracts, app)}
|
||||
export {};
|
||||
`;
|
||||
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
|
||||
|
||||
@@ -60,7 +60,7 @@ test("emits one assertion per sectioned block, naming its route and method", ()
|
||||
generateApplicationTypes(root);
|
||||
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("name?: string");
|
||||
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);
|
||||
});
|
||||
|
||||
test("B1: two pages each declaring a block with the same name do not collide", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/one.wrn"),
|
||||
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/two.wrn"),
|
||||
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
|
||||
expect(names.length).toBe(2);
|
||||
expect(new Set(names).size).toBe(2);
|
||||
});
|
||||
|
||||
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const GET = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/ssr.wrn"),
|
||||
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("loadUsers");
|
||||
});
|
||||
|
||||
test("B2: a client block with an empty request emits no assertion", () => {
|
||||
const root = fixture(` api pingServer GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("pingServer");
|
||||
});
|
||||
|
||||
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
|
||||
const root = fixture(BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
const assertionLine = checks
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
|
||||
expect(assertionLine).toBeDefined();
|
||||
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
|
||||
});
|
||||
|
||||
// --- Real-compiler enforcement tests ---------------------------------------------
|
||||
//
|
||||
// Everything above only asserts on the emitted *text*. That proves nothing about
|
||||
@@ -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");
|
||||
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");
|
||||
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)", () => {
|
||||
@@ -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");
|
||||
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");
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
});
|
||||
|
||||
@@ -321,8 +321,8 @@ function apiBindings(ast: PageAst): string {
|
||||
.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 response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
||||
const error = eraseFunctionTypes(sections.error).trim();
|
||||
const failure = error
|
||||
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
||||
: `(error) => { throw error; }`;
|
||||
@@ -344,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
||||
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
|
||||
.map((entry) => entry.name)
|
||||
.filter(
|
||||
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name),
|
||||
(name) =>
|
||||
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
|
||||
);
|
||||
const callableAliases = functionNames.filter(
|
||||
(name) =>
|
||||
|
||||
@@ -81,6 +81,68 @@ test("a block without an error section still emits its response body", () => {
|
||||
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", () => {
|
||||
const generated = generateTargets(
|
||||
parse(`page Repro {
|
||||
|
||||
@@ -101,13 +101,16 @@ export function defineEndpoint(
|
||||
// (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(() => ({}));
|
||||
const input = definition.input ? schemaValue(definition.input, resolvedInput) : resolvedInput;
|
||||
input = schemaValue(definition.input, resolvedInput);
|
||||
}
|
||||
const rawOutput = await definition.handler(input, ctx);
|
||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||
return output instanceof Response ? output : json({ data: output });
|
||||
|
||||
@@ -4284,6 +4284,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
return fetch(url, init).then(function (response) {
|
||||
// A 2xx with no body (204/205, or a genuinely empty response) is a
|
||||
// success, not a parse failure -- the failure table only calls for the
|
||||
// error path on non-2xx, network failure, or an unparseable body.
|
||||
if (response.ok && (response.status === 204 || response.status === 205)) {
|
||||
return undefined;
|
||||
}
|
||||
return response.json().then(
|
||||
function (data) {
|
||||
if (response.ok) return data;
|
||||
@@ -4295,6 +4301,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
throw failure;
|
||||
},
|
||||
function () {
|
||||
if (response.ok) return undefined;
|
||||
var failure = new Error("Response was not valid JSON");
|
||||
failure.status = response.status;
|
||||
failure.data = undefined;
|
||||
|
||||
@@ -79,6 +79,42 @@ test("a 2xx resolves to the parsed payload", async () => {
|
||||
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||
});
|
||||
|
||||
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||
});
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
|
||||
.__wrnexusCallApi;
|
||||
|
||||
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
|
||||
const { callApi } = harness({ status: 200, payload: undefined });
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
|
||||
});
|
||||
|
||||
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("a non-2xx rejects with status, message and data", async () => {
|
||||
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user