feat(frontend): adapter web HTTP+WebSocket derrière les ports (#13)

Lot F1 du chantier server/client mode : nouvel adaptateur web branché
derrière les ports d'invocation et de flux live, permettant au frontend de
dialoguer avec le backend via HTTP + WebSocket en mode client/serveur. Le
mode desktop (Tauri IPC) reste inchangé.

- frontend/src/adapters/http : invoker HTTP, client live WebSocket, gateways
  request/response et stream, frames, garde unsupported (7 fichiers + 2 tests).
- frontend/src/app : câblage DI (di.tsx) et son test, typage vite-env.d.ts.

Validé : build vert, garde no-direct-invoke verte, 724 tests verts,
desktop inchangé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:50:38 +02:00
parent c8fef2a76a
commit e0cdb4aa56
12 changed files with 1783 additions and 5 deletions

View File

@ -0,0 +1,130 @@
/**
* 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;
},
) => 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;
}
/** 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;
constructor(config: HttpInvokerConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.token = config.token;
// `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 }),
});
} catch (networkError) {
const err: GatewayError = {
code: "TRANSPORT_ERROR",
message: `HTTP request for '${command}' failed: ${String(networkError)}`,
};
throw err;
}
if (!res.ok) {
// 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;
}
}