/** * HTTP transport primitive for the web (client/server) adapter set — ticket #13, * lot F1. This is the HTTP analogue of Tauri's `invoke`: it forwards one backend * *command* + its camelCase argument envelope to the shared backend core over * HTTP, and preserves the exact `ErrorDto` shape ({@link GatewayError}) on * failure. * * **Contract choice (F1):** rather than a per-command REST resource tree (the * first-draft routes in `docs/ticket13-b0-backend-transport-inventory.md`), F1 * uses a single generic RPC endpoint `POST {baseUrl}/api/invoke` with body * `{ command, args }`. This mirrors the Tauri `invoke(command, args)` seam * one-for-one, so **every** request/response gateway reuses the *identical* * command names and `{ request: { … } }` envelopes already frozen for the Tauri * adapter — no DTO divergence. Switching to REST later (if the backend picks that * in B3/B4) only touches this file + the gateway wiring, never a component. * This divergence from the B0 REST sketch is flagged for DevBackend to confirm. * * Only `src/adapters/**` may own transport code (CI guard * `no-direct-invoke.test.ts`); this file lives there and touches no * `@tauri-apps/api`. */ import type { GatewayError } from "@/domain"; /** The `fetch` surface this invoker needs; injectable so tests pass a stub. */ export type FetchLike = ( input: string, init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal; /** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */ credentials?: "same-origin" | "include" | "omit"; }, ) => Promise<{ ok: boolean; status: number; /** Parsed JSON body (may reject/return undefined for empty bodies). */ json(): Promise; text(): Promise; }>; /** * Resolves the default `fetch`, **bound to `globalThis`**. * * WHATWG `fetch` is a method of the global object and checks its receiver: once * stored in a field and called as `this.fetchImpl(…)`, its `this` is the owning * adapter instance, not `window`. A real browser rejects that — Firefox with * `TypeError: 'fetch' called on an object that does not implement interface * Window`, Chrome with "Illegal invocation". jsdom does not enforce the receiver, * so the unit tests never caught it; the bind is what keeps the fallback callable * from a field. An *injected* `fetchImpl` is left untouched (a test stub is a * plain function and needs no receiver). * * When there is no global `fetch` (SSR / bare Node), the value is returned as-is * so the failure still happens at call time, exactly as before, rather than * throwing during construction. */ export function defaultFetch(): FetchLike { const globalFetch = globalThis.fetch; return ( typeof globalFetch === "function" ? globalFetch.bind(globalThis) : globalFetch ) as unknown as FetchLike; } /** Configuration for the HTTP invoker. */ export interface HttpInvokerConfig { /** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */ baseUrl: string; /** Bearer token obtained from pairing (ticket #13 auth); sent as `Authorization`. */ token?: string; /** Injected fetch (defaults to global `fetch`). */ fetchImpl?: FetchLike; /** * Called when the backend answers `401` (the session cookie is missing or * expired). F2 wires this to the web session so the app routes back to the * pairing screen. The `GatewayError` is still thrown to the caller. */ onUnauthorized?: () => void; } /** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */ function toGatewayError(value: unknown, fallbackMessage: string): GatewayError { if (value && typeof value === "object") { const rec = value as Record; const code = typeof rec.code === "string" ? rec.code : undefined; const message = typeof rec.message === "string" ? rec.message : undefined; if (code || message) { return { code: code ?? "ERROR", message: message ?? fallbackMessage }; } } return { code: "TRANSPORT_ERROR", message: fallbackMessage }; } /** * Sends backend commands over HTTP. Mirrors the Tauri `invoke` signature so the * web gateways can reuse the exact command + argument envelopes of their Tauri * siblings. */ export class HttpInvoker { private readonly baseUrl: string; private readonly token?: string; private readonly fetchImpl: FetchLike; private readonly onUnauthorized?: () => void; constructor(config: HttpInvokerConfig) { this.baseUrl = config.baseUrl.replace(/\/+$/, ""); this.token = config.token; this.onUnauthorized = config.onUnauthorized; // The global fallback must stay bound to `globalThis` — see `defaultFetch`. this.fetchImpl = config.fetchImpl ?? defaultFetch(); } /** * Invokes a backend command. `args` is the same camelCase argument object the * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the * parsed result, or rejects with a {@link GatewayError}. */ async invoke(command: string, args: Record = {}): Promise { const headers: Record = { "Content-Type": "application/json", }; if (this.token) headers.Authorization = `Bearer ${this.token}`; let res: Awaited>; try { res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { method: "POST", headers, body: JSON.stringify({ command, args }), // Same-origin so the HttpOnly session cookie is sent automatically // (ticket #13: no secret in the URL/headers from JS). credentials: "same-origin", }); } catch (networkError) { const err: GatewayError = { code: "TRANSPORT_ERROR", message: `HTTP request for '${command}' failed: ${String(networkError)}`, }; throw err; } if (!res.ok) { // A 401 means the session cookie is missing/expired: signal the app to // route back to pairing (the error is still thrown to the caller). if (res.status === 401) this.onUnauthorized?.(); // Preserve the backend `ErrorDto` when present; fall back to the status. let parsed: unknown; try { parsed = await res.json(); } catch { parsed = undefined; } throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`); } // A 204 / empty body maps to `undefined` (the void commands). let body: unknown; try { body = await res.json(); } catch { body = undefined; } return body as T; } }