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:
@@ -964,7 +964,7 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ssrRuntimeSource(): string {
|
function ssrRuntimeSource(): string {
|
||||||
return `const __wrnexusHtmlEscapes = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user