Expose la gestion des appareils appairés introduite en B1-B4, sur une surface unique partagée par le web et le desktop. - Écran Appareils : liste, renommage, révocation unitaire ou globale, activité formatée, panneau de code éphémère. - Le parcours d'appairage demande un nom d'appareil, pour qu'une révocation porte sur quelque chose d'identifiable par l'utilisateur. - Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port restant le seul contrat connu de la feature. Les erreurs sont mappées localement et le message du serveur n'est jamais affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour qui teste des codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.2 KiB
TypeScript
186 lines
7.2 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>;
|
|
}>;
|
|
|
|
/**
|
|
* 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<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;
|
|
// 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}.
|
|
*/
|
|
invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
|
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
|
|
}
|
|
|
|
/**
|
|
* Sends one authenticated REST request and maps the response exactly like
|
|
* {@link invoke} (401 routing + `ErrorDto` preservation).
|
|
*
|
|
* The device/auth surface of ticket #77 lives on dedicated routes rather than
|
|
* the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` —
|
|
* the RPC endpoint is itself auth-gated, so the surface that *manages* auth
|
|
* cannot ride on it. `what` names the call in fallback error messages.
|
|
*/
|
|
async request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
what = `${method} ${path}`,
|
|
): 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}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
// 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 ${what} 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, `${what} failed (HTTP ${res.status})`);
|
|
}
|
|
|
|
// A 204 / empty body maps to `undefined` (the void commands).
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = await res.json();
|
|
} catch {
|
|
parsed = undefined;
|
|
}
|
|
return parsed as T;
|
|
}
|
|
}
|