diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index d88f3604..e9077d77 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -964,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map": ">", "\\"": """, "'": "'" }; + return `const __wrnexusHtmlEscapes: Record = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } @@ -1038,20 +1038,38 @@ async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusCont return type.includes("application/json") ? await res.json() : await res.text(); } +type __WrnexusApiCall = { + path: string; + method: string; + body: string; + helpers: string; + errorBody?: string; +}; + +type __WrnexusSsrBinding = __WrnexusApiCall & { marker: string }; + +// Shared by every ssr api-binding consumption site (marker replacement, +// #each loop consts, ...) so the narrow try/catch -- only active when the +// block declared an error section -- cannot drift between call sites. +async function __wrnexusResolveApiBinding( + binding: __WrnexusApiCall, + ctx: __WrnexusContext, +): Promise { + if (binding.errorBody) { + try { + const data = await __wrnexusCallApi(binding.path, binding.method, ctx); + return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); + } catch (err) { + return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx); + } + } + const data = await __wrnexusCallApi(binding.path, binding.method, ctx); + return __wrnexusEvalData(data, binding.body, binding.helpers, ctx); +} + async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise { for (const binding of __wrnexusSsrBindings) { - let value: unknown; - if (binding.errorBody) { - try { - const data = await __wrnexusCallApi(binding.path, binding.method, ctx); - value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); - } catch (err) { - value = __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx); - } - } else { - const data = await __wrnexusCallApi(binding.path, binding.method, ctx); - value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); - } + const value = await __wrnexusResolveApiBinding(binding, ctx); html = html.replace(binding.marker, __wrnexusEscapeHtml(value)); } return html; @@ -1560,8 +1578,11 @@ function generateInner(ast: PageAst): string { for (const [name, binding] of apiBindings) { if (binding.mode !== "ssr") continue; if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue; + const errorBodyProp = binding.errorBody + ? `, errorBody: ${JSON.stringify(binding.errorBody)}` + : ""; loopConsts.push( - ` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`, + ` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`, ); } } @@ -1570,7 +1591,7 @@ function generateInner(ast: PageAst): string { if (needsSsrRuntime) { out.push(ssrRuntimeSource()); out.push( - `const __wrnexusSsrBindings: Array<{ method: string; path: string; body: string; helpers: string; errorBody?: string }> = ${JSON.stringify(ssrBindings, null, 2)};`, + `const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`, ); const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; out.push( diff --git a/packages/compiler/test/api-block-ssr.test.ts b/packages/compiler/test/api-block-ssr.test.ts index 5d51cc3f..ecdc4bd8 100644 --- a/packages/compiler/test/api-block-ssr.test.ts +++ b/packages/compiler/test/api-block-ssr.test.ts @@ -1,7 +1,58 @@ -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 { generate } from "../src/codegen.ts"; +const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/"); +// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an +// unrelated TypeScript version that doesn't understand this repo's tsconfig +// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't +// find the `bun` type-definition entry point), unlike `bun run typecheck`, +// which uses this same local binary. +const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/"); +// `types`/`typeRoots` in an extended tsconfig resolve relative to the config +// file that's actually invoked (our temp one), not the base file — so the +// ambient `bun` types need an explicit path back to the repo's node_modules. +const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/"); + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** + * Runs the real TypeScript compiler over a generated server module. Proves + * the emitted `__wrnexusSsrBindings` annotation (and everything else in the + * module) actually type-checks — string-containment assertions alone can't + * catch a declared type that omits a field every emitted object literal has. + */ +function typecheckGenerated(source: string): { ok: boolean; output: string } { + const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-")); + roots.push(root); + const file = join(root, "page.ts"); + writeFileSync(file, source); + // Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only + // checks the one file we care about instead of hand-duplicating the whole + // compiler configuration (and drifting from it over time). + writeFileSync( + join(root, "tsconfig.json"), + JSON.stringify({ + extends: ROOT_TSCONFIG, + compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] }, + include: ["page.ts"], + }), + ); + const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`; + return { ok: result.exitCode === 0, output }; +} + function serverModule(inner: string): string { return generate( parse(`page Repro { @@ -60,3 +111,95 @@ test("an ssr block without an error section emits no catch entry for that bindin expect(generated).not.toContain('"errorBody"'); }); + +test("tsc: a sectioned ssr block's generated module has no diagnostics", () => { + const generated = serverModule(` api ssrUsers GET /api/users { + response { + return data.users.length + } + error { + return message + status + data + } + }`); + + const { ok, output } = typecheckGenerated(generated); + + expect(output.trim()).toBe(""); + expect(ok).toBe(true); +}); + +test("an ssr block used in {#each} with an error section runs the error body on failure", async () => { + const generated = generate( + parse(`page Repro { + ssr { + api ssrUsers GET /api/users { + response { + return data.users + } + error { + return ["fallback"] + } + } + } + + view {
{#each ssrUsers as u}

{u}

{/each}
} +} +`), + ); + + const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-")); + roots.push(root); + mkdirSync(root, { recursive: true }); + const file = join(root, "page.ts"); + writeFileSync(file, generated); + + const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`); + const html = await mod.default({ + req: { url: "http://localhost/", headers: new Headers() }, + cookies: {}, + session: {}, + localStorage: {}, + __wrnexusCallApi: async () => { + throw new Error("boom"); + }, + }); + + expect(html).toContain("fallback"); +}); + +test("an ssr block used in {#each} without an error section still propagates a failure", async () => { + const generated = generate( + parse(`page Repro { + ssr { + api ssrUsers GET /api/users { + response { + return data.users + } + } + } + + view {
{#each ssrUsers as u}

{u}

{/each}
} +} +`), + ); + + const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-")); + roots.push(root); + mkdirSync(root, { recursive: true }); + const file = join(root, "page.ts"); + writeFileSync(file, generated); + + const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`); + + await expect( + mod.default({ + req: { url: "http://localhost/", headers: new Headers() }, + cookies: {}, + session: {}, + localStorage: {}, + __wrnexusCallApi: async () => { + throw new Error("boom"); + }, + }), + ).rejects.toThrow("boom"); +});