fix(compiler): type-check the emitted ssr binding array and share error handling across call sites

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:42:38 +05:30
co-authored by Claude Opus 5
parent 7601477f7d
commit 3252b1b20e
2 changed files with 180 additions and 16 deletions
+36 -15
View File
@@ -964,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
} }
function ssrRuntimeSource(): string { function ssrRuntimeSource(): string {
return `const __wrnexusHtmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" }; return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" };
function __wrnexusEscapeHtml(value: unknown): string { function __wrnexusEscapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); 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(); 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<unknown> {
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<string> { async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise<string> {
for (const binding of __wrnexusSsrBindings) { for (const binding of __wrnexusSsrBindings) {
let value: unknown; const value = await __wrnexusResolveApiBinding(binding, ctx);
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);
}
html = html.replace(binding.marker, __wrnexusEscapeHtml(value)); html = html.replace(binding.marker, __wrnexusEscapeHtml(value));
} }
return html; return html;
@@ -1560,8 +1578,11 @@ function generateInner(ast: PageAst): string {
for (const [name, binding] of apiBindings) { for (const [name, binding] of apiBindings) {
if (binding.mode !== "ssr") continue; if (binding.mode !== "ssr") continue;
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue; if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
const errorBodyProp = binding.errorBody
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
: "";
loopConsts.push( 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) { if (needsSsrRuntime) {
out.push(ssrRuntimeSource()); out.push(ssrRuntimeSource());
out.push( 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" : ""; const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
out.push( out.push(
+144 -1
View File
@@ -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 { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts"; 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 { function serverModule(inner: string): string {
return generate( return generate(
parse(`page Repro { 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"'); 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 { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
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 { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
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");
});