W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
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
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user