2ae65a5a9f
Build Kvitt images / build (push) Failing after 1h14m5s
web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC §14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css (light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
123 lines
4.8 KiB
TypeScript
123 lines
4.8 KiB
TypeScript
/**
|
|
* The typed API client (openapi-fetch over the generated `schema.d.ts`). Same origin as the SPA (`/me`, `/sync`, …).
|
|
* Every call carries the headers of SPEC §14.3.1: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version`,
|
|
* `X-Kvitt-App-Version-Name`, `Accept-Language`, and `Authorization: Bearer <token>` while signed in. A 401 signs the
|
|
* session out (`unauthorized` or `device_revoked`), which sends the router to /app/login.
|
|
*/
|
|
import createClient, { type Client } from 'openapi-fetch';
|
|
import { BUILD } from '../app/buildInfo';
|
|
import type { Language } from '../i18n/language';
|
|
import type { paths } from './schema';
|
|
import type { SessionStore } from './session';
|
|
import type { ErrorBody, ErrorCode } from './types';
|
|
|
|
/** The protocol version this client speaks (SPEC §14.4). */
|
|
export const PROTOCOL_VERSION = '1';
|
|
|
|
/** The operations' header parameters are set by the middleware below, so callers never pass them. */
|
|
type WithoutHeaderParams<Operation> = Operation extends { parameters: infer Params }
|
|
? Omit<Operation, 'parameters'> & { parameters: Omit<Params, 'header'> & { header?: never } }
|
|
: Operation;
|
|
export type ApiPaths = {
|
|
[Path in keyof paths]: { [Method in keyof paths[Path]]: WithoutHeaderParams<paths[Path][Method]> };
|
|
};
|
|
export type Api = Client<ApiPaths>;
|
|
|
|
export interface ApiOptions {
|
|
/** The origin the SPA is served from; the API lives at its root (OpenAPI `servers`). */
|
|
baseUrl: string;
|
|
session: SessionStore;
|
|
language: () => Language;
|
|
fetch?: (request: Request) => Promise<Response>;
|
|
}
|
|
|
|
/** An API failure: an error body of SPEC §14.3.2, or `network` when the server could not be reached. */
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: ErrorCode | 'network' | 'unexpected',
|
|
readonly field: string | null,
|
|
message: string,
|
|
readonly retryAfterSeconds: number | null = null,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
function isErrorBody(value: unknown): value is ErrorBody {
|
|
return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'string';
|
|
}
|
|
|
|
async function errorCodeOf(response: Response): Promise<string | null> {
|
|
try {
|
|
const body: unknown = await response.clone().json();
|
|
return isErrorBody(body) ? body.code : null;
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError || error instanceof TypeError) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function createApi(options: ApiOptions): Api {
|
|
const client = createClient<ApiPaths>({ baseUrl: options.baseUrl, fetch: options.fetch });
|
|
client.use({
|
|
onRequest({ request }) {
|
|
request.headers.set('X-Kvitt-Protocol', PROTOCOL_VERSION);
|
|
request.headers.set('X-Kvitt-App-Version', String(BUILD.versionCode));
|
|
request.headers.set('X-Kvitt-App-Version-Name', BUILD.versionName);
|
|
request.headers.set('Accept-Language', options.language());
|
|
const session = options.session.get();
|
|
if (session !== null) request.headers.set('Authorization', `Bearer ${session.token}`);
|
|
return request;
|
|
},
|
|
async onResponse({ request, response }) {
|
|
if (response.status === 401 && request.headers.has('Authorization')) {
|
|
const code = await errorCodeOf(response);
|
|
options.session.signOut(code === 'device_revoked' ? 'device_revoked' : 'unauthorized');
|
|
}
|
|
return response;
|
|
},
|
|
});
|
|
return client;
|
|
}
|
|
|
|
interface Outcome<D> {
|
|
data?: D;
|
|
error?: unknown;
|
|
response: Response;
|
|
}
|
|
|
|
async function settle<D>(pending: Promise<Outcome<D>>): Promise<Outcome<D>> {
|
|
try {
|
|
return await pending;
|
|
} catch (cause) {
|
|
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
|
|
throw new ApiError(0, 'network', null, cause instanceof Error ? cause.message : 'network error');
|
|
}
|
|
}
|
|
|
|
function failure(outcome: Outcome<unknown>): ApiError {
|
|
const { response, error } = outcome;
|
|
const retryAfter = response.headers.get('Retry-After');
|
|
const retryAfterSeconds = retryAfter === null ? null : Number(retryAfter);
|
|
if (isErrorBody(error)) {
|
|
return new ApiError(response.status, error.code, error.field, error.message, retryAfterSeconds);
|
|
}
|
|
return new ApiError(response.status, 'unexpected', null, `HTTP ${response.status}`, retryAfterSeconds);
|
|
}
|
|
|
|
/** The response body of a successful call; throws ApiError otherwise. */
|
|
export async function unwrap<D>(pending: Promise<Outcome<D>>): Promise<D> {
|
|
const outcome = await settle(pending);
|
|
if (!outcome.response.ok) throw failure(outcome);
|
|
if (outcome.data === undefined) throw new ApiError(outcome.response.status, 'unexpected', null, 'empty body');
|
|
return outcome.data;
|
|
}
|
|
|
|
/** For calls answered with 204 No Content; throws ApiError on failure. */
|
|
export async function unwrapEmpty(pending: Promise<Outcome<unknown>>): Promise<void> {
|
|
const outcome = await settle(pending);
|
|
if (!outcome.response.ok) throw failure(outcome);
|
|
}
|