Files
IdeaSDK/frontend/src/adapters/http/httpInvoker.ts
Blomios e500e31663 feat(frontend): client web read-only pairing + snapshot état (#13)
Lot F2 du chantier server/client mode : client web read-only complétant le
premier incrément livrable — pairing, liste des projets, ouverture et
snapshot de l'état, sans PTY.

- frontend/src/adapters/http/webSession.ts : session web (pairing/cookie).
- frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index.
- Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401).
- Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans
  httpInvoker.test.ts.

Validé : frontend 736 tests verts, build vert, garde no-direct-invoke
verte, contrat B4↔F2 aligné, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:21:35 +02:00

147 lines
5.5 KiB
TypeScript

/**
* 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<string, string>;
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<unknown>;
text(): Promise<string>;
}>;
/** 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<string, unknown>;
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;
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
// to the minimal shape used here.
this.fetchImpl =
config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
}
/**
* 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<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
let res: Awaited<ReturnType<FetchLike>>;
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;
}
}