feat(core): share API request assembly between both transports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 06:43:42 +05:30
co-authored by Claude Opus 5
parent 768074ac0a
commit f32b33e3b6
4 changed files with 107 additions and 3 deletions
+36
View File
@@ -0,0 +1,36 @@
/**
* Assemble an API request from a block's declared input.
*
* Shared by both transports on purpose. The browser and the in-process server
* caller must send the same thing for the same call; two copies of these rules
* would drift, and the drift would be invisible because each side is tested
* separately.
*/
export interface BuiltApiRequest {
url: string;
body?: string;
contentType?: string;
}
export function buildApiRequest(
path: string,
method: string,
input: Record<string, unknown> | undefined,
): BuiltApiRequest {
const verb = String(method || "GET").toUpperCase();
const values = input ?? {};
if (verb === "GET" || verb === "HEAD") {
const query: string[] = [];
for (const [key, value] of Object.entries(values)) {
// 0 and false are legitimate values and must survive.
if (value === undefined || value === null || value === "") continue;
query.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return { url: query.length ? `${path}?${query.join("&")}` : path };
}
return { url: path, body: JSON.stringify(values), contentType: "application/json" };
}
+2
View File
@@ -17,6 +17,8 @@ export {
requireRequestContext,
} from "./request-context.ts";
export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts";
export { buildApiRequest } from "./api-request.ts";
export type { BuiltApiRequest } from "./api-request.ts";
export type {
ExecutionContext,
ExecutionContextInput,
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from "bun:test";
import { buildApiRequest } from "../src/api-request.ts";
import { REACTIVE_RUNTIME } from "../../csr/src/reactive-runtime.ts";
test("GET builds a query string", () => {
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).url).toBe("/api/users?name=Ajay");
});
test("GET omits undefined, null and empty string", () => {
const built = buildApiRequest("/api/users", "GET", {
name: "Ajay",
age: undefined,
team: null,
note: "",
});
expect(built.url).toBe("/api/users?name=Ajay");
});
test("GET keeps 0 and false", () => {
// A filter of 0 or false is a real value; dropping it silently would be a bug.
const built = buildApiRequest("/api/users", "GET", { count: 0, active: false });
expect(built.url).toContain("count=0");
expect(built.url).toContain("active=false");
});
test("GET has no body", () => {
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).body).toBeUndefined();
});
test("POST sends a JSON body and no query", () => {
const built = buildApiRequest("/api/users", "POST", { name: "Ajay" });
expect(built.url).toBe("/api/users");
expect(built.body).toBe(JSON.stringify({ name: "Ajay" }));
expect(built.contentType).toBe("application/json");
});
test("POST with no input sends an empty object", () => {
expect(buildApiRequest("/api/users", "POST", undefined).body).toBe("{}");
});
test("values are encoded", () => {
expect(buildApiRequest("/api/users", "GET", { name: "a b&c" }).url).toBe(
"/api/users?name=a%20b%26c",
);
});
test("the browser runtime and the shared builder agree", () => {
// Cheap structural guard: the runtime must apply the same omission rule.
// If someone changes one side's rules, this fails.
expect(REACTIVE_RUNTIME).toContain('value === ""');
expect(REACTIVE_RUNTIME).toContain("application/json");
});
+14 -3
View File
@@ -10,6 +10,7 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
import {
bridgeRealtime,
buildApiRequest,
createContext,
createCorsPreflightResponse,
createRealtimeRegistry,
@@ -1454,7 +1455,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}
}
async function callApiFromContext(ctx: Context, path: string, method = "GET"): Promise<unknown> {
async function callApiFromContext(
ctx: Context,
path: string,
method = "GET",
input?: Record<string, unknown>,
): Promise<unknown> {
if (!isSafeApiPath(path)) {
throw new Error("Unsafe framework API path");
}
@@ -1464,10 +1470,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
throw new Error(`Unsupported framework API method: ${normalizedMethod}`);
}
const apiUrl = new URL(path, ctx.req.url);
const built = buildApiRequest(path, normalizedMethod, input);
const apiUrl = new URL(built.url, ctx.req.url);
const headers = new Headers(ctx.req.headers);
if (built.contentType) headers.set("content-type", built.contentType);
const apiReq = new Request(apiUrl, {
method: normalizedMethod,
headers: ctx.req.headers,
headers,
...(built.body === undefined ? {} : { body: built.body }),
});
const apiCtx = createContext(apiReq, apiUrl);
apiCtx.locals = ctx.locals;