diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 02510787..aac540f3 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -1380,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw` state: stateProxy, output: outputProxy, server: serverProxy, + callApi: wrnexusCallApi, props: propsProxy, refs: refsProxy, }; @@ -4246,6 +4247,65 @@ export const REACTIVE_RUNTIME = String.raw` }); } + /* + * Transport for compiled api blocks. + * + * Only the request and the failure shape live here. A block's response and + * error bodies are page code, so they are emitted into the browser module + * and applied by the caller. + */ + function readCsrfToken() { + var meta = document.querySelector('meta[name="wrnexus-csrf"]'); + if (meta) return meta.getAttribute("content") || ""; + var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || ""); + return match ? decodeURIComponent(match[1]) : ""; + } + + function wrnexusCallApi(path, method, input) { + var verb = String(method || "GET").toUpperCase(); + var values = input || {}; + var url = path; + var headers = { accept: "application/json" }; + var init = { method: verb, credentials: "same-origin", headers: headers }; + + if (verb === "GET" || verb === "HEAD") { + var query = []; + Object.keys(values).forEach(function (key) { + var value = values[key]; + // An omitted filter must not become "name=undefined". + if (value === undefined || value === null || value === "") return; + query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value))); + }); + if (query.length) url = path + "?" + query.join("&"); + } else { + headers["content-type"] = "application/json"; + headers["x-csrf-token"] = readCsrfToken(); + init.body = JSON.stringify(values); + } + + return fetch(url, init).then(function (response) { + return response.json().then( + function (data) { + if (response.ok) return data; + var message = + data && data.error ? String(data.error) : "Request failed with " + response.status; + var failure = new Error(message); + failure.status = response.status; + failure.data = data; + throw failure; + }, + function () { + var failure = new Error("Response was not valid JSON"); + failure.status = response.status; + failure.data = undefined; + throw failure; + }, + ); + }); + } + + window.__wrnexusCallApi = wrnexusCallApi; + function dispatchComponentEvent(root, name, detail) { if (!root || !name) return null; var EventConstructor = diff --git a/packages/csr/test/api-call.test.ts b/packages/csr/test/api-call.test.ts new file mode 100644 index 00000000..18c83a63 --- /dev/null +++ b/packages/csr/test/api-call.test.ts @@ -0,0 +1,91 @@ +import { expect, test, beforeEach } from "bun:test"; +import { Window } from "happy-dom"; +import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts"; +import { restoreGlobalsAfterAll } from "./global-restore.ts"; + +const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"]; +restoreGlobalsAfterAll(REPLACED_GLOBALS); + +beforeEach(() => { + for (const name of REPLACED_GLOBALS) delete (globalThis as Record)[name]; +}); + +interface Call { + url: string; + init: RequestInit; +} + +/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */ +function harness(response: { status: number; payload: unknown }) { + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = `
`; + const calls: Call[] = []; + + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).NodeFilter = ( + win as unknown as { NodeFilter: unknown } + ).NodeFilter; + (globalThis as Record).fetch = (url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve({ + ok: response.status >= 200 && response.status < 300, + status: response.status, + json: () => Promise.resolve(response.payload), + }); + }; + + (0, eval)(REACTIVE_RUNTIME); + const callApi = (win as unknown as { __wrnexusCallApi: Function }).__wrnexusCallApi; + return { callApi, calls, win }; +} + +test("GET builds a query string and omits undefined fields", async () => { + const { callApi, calls } = harness({ status: 200, payload: { users: [] } }); + + await callApi("/api/users", "GET", { name: "Ajay", age: undefined }); + + expect(calls[0]!.url).toBe("/api/users?name=Ajay"); + expect(calls[0]!.init.method).toBe("GET"); + expect(calls[0]!.init.body).toBeUndefined(); +}); + +test("POST sends a JSON body", async () => { + const { callApi, calls } = harness({ status: 200, payload: { ok: true } }); + + await callApi("/api/users", "POST", { name: "Ajay" }); + + expect(calls[0]!.url).toBe("/api/users"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" })); + expect((calls[0]!.init.headers as Record)["content-type"]).toBe( + "application/json", + ); +}); + +test("a non-GET request carries the CSRF token from the cookie", async () => { + const { callApi, calls, win } = harness({ status: 200, payload: {} }); + win.document.cookie = "wrn-csrf=token-123"; + + await callApi("/api/users", "POST", {}); + + expect((calls[0]!.init.headers as Record)["x-csrf-token"]).toBe("token-123"); +}); + +test("a 2xx resolves to the parsed payload", async () => { + const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } }); + + expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] }); +}); + +test("a non-2xx rejects with status, message and data", async () => { + const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } }); + + const failure = await callApi("/api/users", "GET", {}).catch( + (error: Error & { status?: number; data?: unknown }) => error, + ); + + expect(failure.status).toBe(400); + expect(failure.message).toContain("Bad filter"); + expect(failure.data).toEqual({ error: "Bad filter" }); +}); diff --git a/scripts/security-performance-audit.mjs b/scripts/security-performance-audit.mjs index f70e8682..c5ca4514 100644 --- a/scripts/security-performance-audit.mjs +++ b/scripts/security-performance-audit.mjs @@ -181,7 +181,9 @@ const runtimeBudgets = { * 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long * cache. */ - "reactive-runtime.ts": 50_500, + // Raised to 51_400: the callApi transport (query building, CSRF header, + // JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes. + "reactive-runtime.ts": 51_400, "component-controllers.ts": 24_100, "nav-runtime.ts": 12_000, "realtime-runtime.ts": 8_000,