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:
130
frontend/src/adapters/http/httpInvoker.ts
Normal file
130
frontend/src/adapters/http/httpInvoker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user