diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index c7c081e0..753aceed 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -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) && diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 3c21629e..ccbe17f2 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -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); diff --git a/examples/basic-app/app/types/wrnexus.generated.api-checks.ts b/examples/basic-app/app/types/wrnexus.generated.api-checks.ts index a74302f6..96f0461d 100644 --- a/examples/basic-app/app/types/wrnexus.generated.api-checks.ts +++ b/examples/basic-app/app/types/wrnexus.generated.api-checks.ts @@ -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>>; +export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true>>; export {}; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 31d06b2c..c56b10a0 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -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` below. `keyof Record` 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"; + const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`; lines.push( - `type __wrn_api_check_${block.name} = WRNexusGenerated.__wrn_expect_true>>;`, ); @@ -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"); diff --git a/packages/cli/test/api-block-types.test.ts b/packages/cli/test/api-block-types.test.ts index 2cb4d28c..216ef3aa 100644 --- a/packages/cli/test/api-block-types.test.ts +++ b/packages/cli/test/api-block-types.test.ts @@ -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 {
x
}\n}\n`, + ); + writeFileSync( + join(root, "app/pages/two.wrn"), + `page Two {\n client {\n${BLOCK}\n }\n\n view {
x
}\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 {
x
}\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/); }); diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index 7d476288..c46a4e9e 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -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) => diff --git a/packages/compiler/test/api-block-codegen.test.ts b/packages/compiler/test/api-block-codegen.test.ts index fa18f11e..bdd70a56 100644 --- a/packages/compiler/test/api-block-codegen.test.ts +++ b/packages/compiler/test/api-block-codegen.test.ts @@ -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 {
} +} +`), + ).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 { diff --git a/packages/core/src/endpoint.ts b/packages/core/src/endpoint.ts index 118f4367..17835174 100644 --- a/packages/core/src/endpoint.ts +++ b/packages/core/src/endpoint.ts @@ -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. - 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; + 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 output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput; return output instanceof Response ? output : json({ data: output }); diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index aac540f3..5b8c915b 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -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; diff --git a/packages/csr/test/api-call.test.ts b/packages/csr/test/api-call.test.ts index 006f2556..c97f9abd 100644 --- a/packages/csr/test/api-call.test.ts +++ b/packages/csr/test/api-call.test.ts @@ -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; + win.document.body.innerHTML = `
`; + + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).NodeFilter = ( + win as unknown as { NodeFilter: unknown } + ).NodeFilter; + (globalThis as Record).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 }) + .__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).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" } });