From f32b33e3b6b631674d13fe339cf5508df1f65a17 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 06:43:42 +0530 Subject: [PATCH] feat(core): share API request assembly between both transports Co-Authored-By: Claude Opus 5 --- packages/core/src/api-request.ts | 36 +++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/test/api-request.test.ts | 55 ++++++++++++++++++++++++++ packages/dev-server/src/runtime.ts | 17 ++++++-- 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api-request.ts create mode 100644 packages/core/test/api-request.test.ts diff --git a/packages/core/src/api-request.ts b/packages/core/src/api-request.ts new file mode 100644 index 00000000..16eadb6c --- /dev/null +++ b/packages/core/src/api-request.ts @@ -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 | 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" }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4fd7c34b..1ffcc917 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/test/api-request.test.ts b/packages/core/test/api-request.test.ts new file mode 100644 index 00000000..e2a94b27 --- /dev/null +++ b/packages/core/test/api-request.test.ts @@ -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"); +}); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 567b1631..9759ba56 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -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 { + async function callApiFromContext( + ctx: Context, + path: string, + method = "GET", + input?: Record, + ): Promise { 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;