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>
147 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
}
|