diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 6dc89204..8bf41486 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: d23177f63433ab92bc7d40d5d5967ed8eae783843224be091cc48be13747df6e +// WRN editor compiler source hash: 03f56cb1555bb66c503f2ec4caf75aa149dd7142637593a07c78d1f1baab8e2b // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -1596,6 +1596,37 @@ function apiBindingMap(ast, sharedHelpers) { } return bindings; } +/** + * Server-side `api` object. + * + * The transport dispatches in-process, so a call from a load block or an action + * costs a function call rather than a network round trip. The request context + * comes from AsyncLocalStorage because `ctx` is not in scope everywhere server + * code runs. + */ +function serverApiBindings(ast) { + const members = ast.dataApis + .filter((block) => block.mode === "any") + .map((block) => { + const sections = block.sections; + const response = sections.response.trim() || "return data;"; + const error = sections.error.trim(); + const failure = error + ? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}` + : `throw err;`; + return ` ${JSON.stringify(block.name)}: async (input?: unknown) => { + const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext; + let data: any; + try { + data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input); + } catch (err) { + ${failure} + } + ${response} + }`; + }); + return members.length ? `const api = {\n${members.join(",\n")}\n};` : ""; +} function ssrRuntimeSource() { return `const __wrnexusHtmlEscapes: Record = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; function __wrnexusEscapeHtml(value: unknown): string { @@ -1603,7 +1634,7 @@ function __wrnexusEscapeHtml(value: unknown): string { } type __WrnexusContext = import("@wrnexus/core").Context & { - __wrnexusCallApi?: (path: string, method: string) => Promise; + __wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise; localStorage?: unknown; }; @@ -1650,13 +1681,40 @@ function __wrnexusPropAttr( ); } -async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise { +async function __wrnexusCallApi( + path: string, + method: string, + ctx: __WrnexusContext, + input?: unknown, +): Promise { if (typeof ctx.__wrnexusCallApi === "function") { - return await ctx.__wrnexusCallApi(path, method); + return await ctx.__wrnexusCallApi(path, method, input); } - const url = new URL(path, ctx.req.url); - const res = await fetch(new Request(url, { method, headers: ctx.req.headers })); + const verb = String(method || "GET").toUpperCase(); + const values = (input ?? {}) as Record; + const headers = new Headers(ctx.req.headers); + let requestPath = path; + let body: string | undefined; + if (verb === "GET" || verb === "HEAD") { + const query: string[] = []; + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === null || value === "") continue; + query.push(\`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`); + } + if (query.length) requestPath = \`\${path}?\${query.join("&")}\`; + } else { + body = JSON.stringify(values); + headers.set("content-type", "application/json"); + } + const url = new URL(requestPath, ctx.req.url); + const res = await fetch( + new Request(url, { + method, + headers, + ...(body === undefined ? {} : { body }), + }), + ); const type = res.headers.get("content-type") || ""; if (!res.ok) { const data = type.includes("application/json") @@ -1999,6 +2057,10 @@ function generateInner(ast) { } if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n")); + const hasServerApis = ast.dataApis.some((block) => block.mode === "any"); + if (hasServerApis) { + out.push(`import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`); + } const ssrBindings = []; const csrBindings = []; const helpers = targetFunctions(ast, "server"); @@ -2139,9 +2201,15 @@ function generateInner(ast) { } } const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; - if (needsSsrRuntime) { + const needsRuntimeHelpers = needsSsrRuntime || hasServerApis; + if (needsRuntimeHelpers) { out.push(ssrRuntimeSource()); out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`); + } + if (hasServerApis) { + out.push(serverApiBindings(ast)); + } + if (needsSsrRuntime) { const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) { ${storeDeclarations} diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index b2fac7fa..f727f2f2 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: eca15cff8b9c2ae1842cf584ab58b70c1d23327a30dd7aa36e9d0c50f1fe2ba9 +// WRN editor extension source hash: ae7dab4aff1ce30f0d01fa0b9172651e526408916351862e2644a85c25df03ac // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 6b8b940b..3321ce22 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -970,6 +970,40 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map block.mode === "any") + .map((block) => { + const sections = block.sections!; + const response = sections.response.trim() || "return data;"; + const error = sections.error.trim(); + const failure = error + ? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}` + : `throw err;`; + + return ` ${JSON.stringify(block.name)}: async (input?: unknown) => { + const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext; + let data: any; + try { + data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input); + } catch (err) { + ${failure} + } + ${response} + }`; + }); + + return members.length ? `const api = {\n${members.join(",\n")}\n};` : ""; +} + function ssrRuntimeSource(): string { return `const __wrnexusHtmlEscapes: Record = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }; function __wrnexusEscapeHtml(value: unknown): string { @@ -977,7 +1011,7 @@ function __wrnexusEscapeHtml(value: unknown): string { } type __WrnexusContext = import("@wrnexus/core").Context & { - __wrnexusCallApi?: (path: string, method: string) => Promise; + __wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise; localStorage?: unknown; }; @@ -1024,13 +1058,40 @@ function __wrnexusPropAttr( ); } -async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise { +async function __wrnexusCallApi( + path: string, + method: string, + ctx: __WrnexusContext, + input?: unknown, +): Promise { if (typeof ctx.__wrnexusCallApi === "function") { - return await ctx.__wrnexusCallApi(path, method); + return await ctx.__wrnexusCallApi(path, method, input); } - const url = new URL(path, ctx.req.url); - const res = await fetch(new Request(url, { method, headers: ctx.req.headers })); + const verb = String(method || "GET").toUpperCase(); + const values = (input ?? {}) as Record; + const headers = new Headers(ctx.req.headers); + let requestPath = path; + let body: string | undefined; + if (verb === "GET" || verb === "HEAD") { + const query: string[] = []; + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === null || value === "") continue; + query.push(\`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`); + } + if (query.length) requestPath = \`\${path}?\${query.join("&")}\`; + } else { + body = JSON.stringify(values); + headers.set("content-type", "application/json"); + } + const url = new URL(requestPath, ctx.req.url); + const res = await fetch( + new Request(url, { + method, + headers, + ...(body === undefined ? {} : { body }), + }), + ); const type = res.headers.get("content-type") || ""; if (!res.ok) { const data = type.includes("application/json") @@ -1431,6 +1492,12 @@ function generateInner(ast: PageAst): string { ); } if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n")); + const hasServerApis = ast.dataApis.some((block) => block.mode === "any"); + if (hasServerApis) { + out.push( + `import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`, + ); + } const ssrBindings: SsrBinding[] = []; const csrBindings: CsrBinding[] = []; const helpers = targetFunctions(ast, "server"); @@ -1594,11 +1661,17 @@ function generateInner(ast: PageAst): string { } const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; - if (needsSsrRuntime) { + const needsRuntimeHelpers = needsSsrRuntime || hasServerApis; + if (needsRuntimeHelpers) { out.push(ssrRuntimeSource()); out.push( `const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`, ); + } + if (hasServerApis) { + out.push(serverApiBindings(ast)); + } + if (needsSsrRuntime) { const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; out.push( `export default async function ${ast.name}(ctx: __WrnexusContext) { diff --git a/packages/compiler/test/apis-server-emit.test.ts b/packages/compiler/test/apis-server-emit.test.ts new file mode 100644 index 00000000..5cff5929 --- /dev/null +++ b/packages/compiler/test/apis-server-emit.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generate } from "../src/codegen.ts"; + +const SOURCE = `page Probe { + apis { + searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + error { return [] } + } + } + + load server directory { + return await api.searchUsers({ name: "a" }) + } + + view {
x
} +} +`; + +test("the server module declares an api object with the block's path and method", () => { + const generated = generate(parse(SOURCE)); + + expect(generated).toContain("const api ="); + expect(generated).toContain("searchUsers"); + expect(generated).toContain('"/api/users"'); + expect(generated).toContain('"POST"'); +}); + +test("the response body is spliced in", () => { + expect(generate(parse(SOURCE))).toContain("data.users"); +}); + +test("a block with no error section rethrows rather than resolving undefined", () => { + const generated = generate( + parse(`page P { + apis { a GET /api/a { response { return data } } } + load server x { return await api.a() } + view {
x
} +} +`), + ); + + expect(generated).toContain("throw"); +}); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 9759ba56..c9a99420 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1773,10 +1773,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ?.filter((value, index, values) => values.indexOf(value) === index) .join(","); const pageCtx = ctx as Context & { - __wrnexusCallApi?: (path: string, method?: string) => Promise; + __wrnexusCallApi?: ( + path: string, + method?: string, + input?: Record, + ) => Promise; __wrnexusUseStore?: (definition: StoreDefinition) => Promise; }; - pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method); + pageCtx.__wrnexusCallApi = (path, method = "GET", input) => + callApiFromContext(ctx, path, method, input); pageCtx.__wrnexusUseStore = (definition) => storeContainer.use(definition); const load = mod.__wrnexusLoad as ((ctx: Context) => Promise) | undefined; if (typeof load === "function") {