feat(compiler): emit the server-side api object
Server module now declares `const api = { ... }` for apis {} blocks in
mode "any", dispatching in-process via requireRequestContext + the
existing __wrnexusCallApi transport helper. The try wraps only the
transport call; the response body runs after it, outside the try, so
a bug in the author's response code surfaces rather than being
mistaken for a request failure. A block with no error {} section
rethrows instead of resolving undefined.
Also closes the pageCtx.__wrnexusCallApi wiring gap in
dev-server/runtime.ts: it now forwards input through to
callApiFromContext instead of dropping it.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// 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
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -1596,6 +1596,37 @@ function apiBindingMap(ast, sharedHelpers) {
|
|||||||
}
|
}
|
||||||
return bindings;
|
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() {
|
function ssrRuntimeSource() {
|
||||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
@@ -1603,7 +1634,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type __WrnexusContext = import("@wrnexus/core").Context & {
|
type __WrnexusContext = import("@wrnexus/core").Context & {
|
||||||
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
|
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
|
||||||
localStorage?: unknown;
|
localStorage?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1650,13 +1681,40 @@ function __wrnexusPropAttr(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
|
async function __wrnexusCallApi(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
input?: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
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 verb = String(method || "GET").toUpperCase();
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const values = (input ?? {}) as Record<string, unknown>;
|
||||||
|
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") || "";
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = type.includes("application/json")
|
const data = type.includes("application/json")
|
||||||
@@ -1999,6 +2057,10 @@ function generateInner(ast) {
|
|||||||
}
|
}
|
||||||
if (ast.imports.length > 0)
|
if (ast.imports.length > 0)
|
||||||
out.push(generatedImports(ast).join("\n"));
|
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 ssrBindings = [];
|
||||||
const csrBindings = [];
|
const csrBindings = [];
|
||||||
const helpers = targetFunctions(ast, "server");
|
const helpers = targetFunctions(ast, "server");
|
||||||
@@ -2139,9 +2201,15 @@ function generateInner(ast) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
|
||||||
|
if (needsRuntimeHelpers) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
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" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
${storeDeclarations}
|
${storeDeclarations}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: eca15cff8b9c2ae1842cf584ab58b70c1d23327a30dd7aa36e9d0c50f1fe2ba9
|
// WRN editor extension source hash: ae7dab4aff1ce30f0d01fa0b9172651e526408916351862e2644a85c25df03ac
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -970,6 +970,40 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
|||||||
return bindings;
|
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: PageAst): string {
|
||||||
|
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(): string {
|
function ssrRuntimeSource(): string {
|
||||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||||
function __wrnexusEscapeHtml(value: unknown): string {
|
function __wrnexusEscapeHtml(value: unknown): string {
|
||||||
@@ -977,7 +1011,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type __WrnexusContext = import("@wrnexus/core").Context & {
|
type __WrnexusContext = import("@wrnexus/core").Context & {
|
||||||
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
|
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
|
||||||
localStorage?: unknown;
|
localStorage?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1024,13 +1058,40 @@ function __wrnexusPropAttr(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
|
async function __wrnexusCallApi(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
ctx: __WrnexusContext,
|
||||||
|
input?: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
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 verb = String(method || "GET").toUpperCase();
|
||||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
const values = (input ?? {}) as Record<string, unknown>;
|
||||||
|
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") || "";
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = type.includes("application/json")
|
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"));
|
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 ssrBindings: SsrBinding[] = [];
|
||||||
const csrBindings: CsrBinding[] = [];
|
const csrBindings: CsrBinding[] = [];
|
||||||
const helpers = targetFunctions(ast, "server");
|
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;
|
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||||
if (needsSsrRuntime) {
|
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
|
||||||
|
if (needsRuntimeHelpers) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(
|
out.push(
|
||||||
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
|
`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" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(
|
out.push(
|
||||||
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||||
|
|||||||
@@ -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 { <main>x</main> }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 { <main>x</main> }
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(generated).toContain("throw");
|
||||||
|
});
|
||||||
@@ -1773,10 +1773,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
?.filter((value, index, values) => values.indexOf(value) === index)
|
?.filter((value, index, values) => values.indexOf(value) === index)
|
||||||
.join(",");
|
.join(",");
|
||||||
const pageCtx = ctx as Context & {
|
const pageCtx = ctx as Context & {
|
||||||
__wrnexusCallApi?: (path: string, method?: string) => Promise<unknown>;
|
__wrnexusCallApi?: (
|
||||||
|
path: string,
|
||||||
|
method?: string,
|
||||||
|
input?: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
__wrnexusUseStore?: (definition: StoreDefinition<any, any, any>) => Promise<unknown>;
|
__wrnexusUseStore?: (definition: StoreDefinition<any, any, any>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
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);
|
pageCtx.__wrnexusUseStore = (definition) => storeContainer.use(definition);
|
||||||
const load = mod.__wrnexusLoad as ((ctx: Context) => Promise<unknown>) | undefined;
|
const load = mod.__wrnexusLoad as ((ctx: Context) => Promise<unknown>) | undefined;
|
||||||
if (typeof load === "function") {
|
if (typeof load === "function") {
|
||||||
|
|||||||
Reference in New Issue
Block a user