init: initial commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-04-30 08:26:34 +10:00
commit 262a56c2e5
10 changed files with 2914 additions and 0 deletions

209
src/client.ts Normal file
View File

@@ -0,0 +1,209 @@
// Core HTTP client. Hand-rolled around fetch with hooks for auth, tenant
// context, idempotency, and rate-limit-aware retry.
//
// Two surfaces:
// - client.GET/POST/PUT/PATCH/DELETE — generic, accepts any string path.
// Returns parsed JSON or throws ArcadiaError. Use this for paths that
// aren't in the OpenAPI spec, or when the spec is incomplete (arcadia
// has some "ok"-placeholder operations that don't generate operation
// types — see scripts/sync-spec.mjs).
// - client.typed.GET/POST/... — openapi-fetch-backed, fully typed against
// the generated `paths`. Same auth/retry plumbing. Returns the parsed
// `data` or throws ArcadiaError.
//
// The client is stateless about how the JWT is obtained — callers pass
// `getToken` so refresh and storage stay app-owned.
import createOpenapiClient, { type Client as OpenapiClient } from "openapi-fetch";
import { normalizeErrorResponse, ArcadiaError } from "./errors";
import type { ArcadiaTenantId } from "./types";
import type { paths } from "./generated/openapi";
export interface ArcadiaClientOptions {
baseUrl: string;
getToken?: () => string | null | Promise<string | null>;
apiKey?: string;
apiKeyMode?: "fallback" | "always";
tenantId?: ArcadiaTenantId;
onUnauthorized?: (err: ArcadiaError) => void;
/** Number of retries on 429 / 503. Default 2. Honors Retry-After. */
maxRetries?: number;
fetch?: typeof fetch;
}
export interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/** Query params (URL search params). null/undefined are dropped. */
params?: Record<string, string | number | boolean | null | undefined>;
/** Request body. Plain object → JSON. FormData / Blob → sent as-is. */
body?: unknown;
/** Idempotency key for safe retries on POST/PUT/PATCH/DELETE. */
idempotencyKey?: string;
headers?: Record<string, string>;
signal?: AbortSignal;
}
/** openapi-fetch's typed client, wrapped so methods return data and throw
* ArcadiaError on failure (matching the rest of the API). */
export type TypedArcadiaClient = OpenapiClient<paths>;
export interface ArcadiaClient {
request<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
GET<T = unknown>(path: string, opts?: Omit<RequestOptions, "method" | "body">): Promise<T>;
POST<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
PUT<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
PATCH<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
DELETE<T = unknown>(path: string, opts?: Omit<RequestOptions, "method">): Promise<T>;
/** Fully typed client for paths in the OpenAPI spec. Same auth/retry
* plumbing; throws ArcadiaError on non-2xx. */
typed: TypedArcadiaClient;
getTenantId(): ArcadiaTenantId | undefined;
setTenantId(id: ArcadiaTenantId | undefined): void;
}
export function createArcadiaClient(opts: ArcadiaClientOptions): ArcadiaClient {
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
const realFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
const apiKeyMode = opts.apiKeyMode ?? "fallback";
const maxRetries = opts.maxRetries ?? 2;
let tenantId = opts.tenantId;
// Shared fetch: header injection + retry. Used both by `request()` and as
// the underlying transport for openapi-fetch (`typed`).
const transportFetch: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Accept", headers.get("Accept") ?? "application/json");
const token = opts.getToken ? await opts.getToken() : null;
if (token && !headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`);
if (opts.apiKey && (apiKeyMode === "always" || !token) && !headers.has("X-API-Key")) {
headers.set("X-API-Key", opts.apiKey);
}
if (tenantId && !headers.has("X-Tenant-ID")) headers.set("X-Tenant-ID", tenantId);
let attempt = 0;
while (true) {
const res = await realFetch(input, { ...init, headers });
if ((res.status === 429 || res.status === 503) && attempt < maxRetries) {
attempt += 1;
const wait = parseRetryAfter(res.headers.get("Retry-After")) ?? backoff(attempt);
await sleep(wait);
continue;
}
return res;
}
};
// Build the openapi-fetch-backed typed client. We then wrap each verb so
// it throws ArcadiaError on non-2xx instead of returning `{ data, error }`.
const oa = createOpenapiClient<paths>({ baseUrl, fetch: transportFetch });
const typed = wrapOpenapi(oa, opts.onUnauthorized);
async function request<T>(path: string, ro: RequestOptions = {}): Promise<T> {
const method = ro.method ?? "GET";
const url = buildUrl(baseUrl, path, ro.params);
const headers: Record<string, string> = { ...(ro.headers ?? {}) };
if (ro.idempotencyKey && method !== "GET") headers["X-Idempotency-Key"] = ro.idempotencyKey;
let body: BodyInit | undefined;
if (ro.body !== undefined && ro.body !== null) {
if (ro.body instanceof FormData || ro.body instanceof Blob || typeof ro.body === "string") {
body = ro.body as BodyInit;
} else {
headers["Content-Type"] = "application/json";
body = JSON.stringify(ro.body);
}
}
const res = await transportFetch(url, { method, headers, body, signal: ro.signal });
if (res.ok) {
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
}
const err = await normalizeErrorResponse(res);
if (err.isAuth) opts.onUnauthorized?.(err);
throw err;
}
return {
request,
GET: (p, o) => request(p, { ...o, method: "GET" }),
POST: (p, o) => request(p, { ...o, method: "POST" }),
PUT: (p, o) => request(p, { ...o, method: "PUT" }),
PATCH: (p, o) => request(p, { ...o, method: "PATCH" }),
DELETE: (p, o) => request(p, { ...o, method: "DELETE" }),
typed,
getTenantId: () => tenantId,
setTenantId: (id) => {
tenantId = id;
},
};
}
/** Wrap openapi-fetch so each verb throws ArcadiaError on non-2xx instead
* of returning `{ data, error }`. Preserves full type inference because
* we forward the original method's signature. */
function wrapOpenapi(
oa: OpenapiClient<paths>,
onUnauthorized?: (err: ArcadiaError) => void,
): TypedArcadiaClient {
const verbs = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"] as const;
const out: Record<string, unknown> = { ...oa };
for (const verb of verbs) {
const orig = (oa as unknown as Record<string, (...args: unknown[]) => unknown>)[verb];
if (typeof orig !== "function") continue;
out[verb] = async (...args: unknown[]) => {
const result = (await orig.apply(oa, args)) as { data?: unknown; error?: unknown; response: Response };
if (result.error !== undefined && !result.response.ok) {
const err = await normalizeErrorResponse(result.response);
if (err.isAuth) onUnauthorized?.(err);
throw err;
}
return result;
};
}
return out as unknown as TypedArcadiaClient;
}
function buildUrl(
baseUrl: string,
path: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string {
const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
if (!params) return url;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === null || v === undefined) continue;
qs.append(k, String(v));
}
const s = qs.toString();
return s ? `${url}${url.includes("?") ? "&" : "?"}${s}` : url;
}
function parseRetryAfter(h: string | null): number | null {
if (!h) return null;
const seconds = Number(h);
if (Number.isFinite(seconds)) return seconds * 1000;
const date = Date.parse(h);
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
return null;
}
function backoff(attempt: number): number {
return Math.min(4000, 250 * Math.pow(4, attempt - 1));
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

77
src/errors.ts Normal file
View File

@@ -0,0 +1,77 @@
// Error normalization for the arcadia API.
//
// Arcadia returns errors as JSON in the shape:
// { error: string, message: string, details?: object, request_id?: string }
// with appropriate HTTP status. ArcadiaError preserves all of that plus the
// status code so callers can branch on either code (`error === "rate_limited"`)
// or status (`status === 429`).
export interface ArcadiaErrorBody {
error: string;
message: string;
details?: Record<string, unknown>;
request_id?: string;
}
export class ArcadiaError extends Error {
readonly status: number;
readonly code: string;
readonly details?: Record<string, unknown>;
readonly requestId?: string;
readonly raw?: unknown;
constructor(opts: {
status: number;
code: string;
message: string;
details?: Record<string, unknown>;
requestId?: string;
raw?: unknown;
}) {
super(opts.message);
this.name = "ArcadiaError";
this.status = opts.status;
this.code = opts.code;
this.details = opts.details;
this.requestId = opts.requestId;
this.raw = opts.raw;
}
get isAuth(): boolean {
return this.status === 401;
}
get isForbidden(): boolean {
return this.status === 403;
}
get isNotFound(): boolean {
return this.status === 404;
}
get isValidation(): boolean {
return this.status === 422;
}
get isRateLimited(): boolean {
return this.status === 429;
}
get isServer(): boolean {
return this.status >= 500;
}
}
export async function normalizeErrorResponse(res: Response): Promise<ArcadiaError> {
let body: Partial<ArcadiaErrorBody> = {};
let raw: unknown;
try {
raw = await res.clone().json();
if (raw && typeof raw === "object") body = raw as ArcadiaErrorBody;
} catch {
// Non-JSON error (e.g. HTML 502 from a proxy). Fall through.
}
return new ArcadiaError({
status: res.status,
code: body.error ?? `http_${res.status}`,
message: body.message ?? res.statusText ?? `Request failed with ${res.status}`,
details: body.details,
requestId: body.request_id,
raw,
});
}

2006
src/generated/openapi.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

30
src/index.tsx Normal file
View File

@@ -0,0 +1,30 @@
// PURPOSE: Typed HTTP client + React bindings for the arcadia Phoenix API.
// Wraps the OpenAPI-spec'd surface at /api/v1 with auth (Bearer JWT
// and/or X-API-Key), tenant context (X-Tenant-ID), idempotency
// keys, error normalization, and rate-limit-aware retry. Realtime
// (Phoenix Channels at /socket/tenant) lives alongside in the same
// provider so apps can subscribe to notification / digital_object /
// announcement / status_update / event topics with the same auth.
//
// The client is stateless about how the JWT is obtained — callers
// pass `getToken` so refresh and storage stay app-owned. Generated
// types under ./generated/openapi.d.ts are produced by the
// sync-spec script (see ../scripts/sync-spec.mjs).
// ===========================================================================
// EXPORTS
// Client: createArcadiaClient, type ArcadiaClient, type TypedArcadiaClient,
// type ArcadiaClientOptions
// Errors: ArcadiaError, type ArcadiaErrorBody
// Provider: ArcadiaProvider, useArcadia, useArcadiaClient,
// useArcadiaSubscription
// Realtime: createArcadiaRealtime, socketUrlFromBaseUrl,
// type ArcadiaRealtime, type TenantEventMap, type RealtimeStatus
// Types: re-exported subset from ./types and ./generated/openapi
// ===========================================================================
"use client";
export * from "./client";
export * from "./errors";
export * from "./provider";
export * from "./realtime";
export * from "./types";

143
src/provider.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createArcadiaClient, type ArcadiaClient, type ArcadiaClientOptions } from "./client";
import {
createArcadiaRealtime,
socketUrlFromBaseUrl,
type ArcadiaRealtime,
type RealtimeScope,
type RealtimeStatus,
type TenantEventMap,
} from "./realtime";
interface ArcadiaContextValue {
client: ArcadiaClient;
/** Current tenant id (state, so consumers re-render on switch). */
tenantId: string | undefined;
setTenantId: (id: string | undefined) => void;
/** Realtime instance (if `enableRealtime` was set on the provider). */
realtime: ArcadiaRealtime | null;
/** Realtime status, kept in state for ergonomic UI. */
realtimeStatus: RealtimeStatus;
}
const Ctx = createContext<ArcadiaContextValue | null>(null);
export interface ArcadiaProviderProps extends Omit<ArcadiaClientOptions, "tenantId"> {
/** Initial tenant id. Use `setTenantId` from the hook to switch at runtime. */
initialTenantId?: string;
/** User id for user-scoped realtime subscriptions. Optional. */
userId?: string;
/** Enable Phoenix Channels realtime. Default false. When true and
* tenantId is set, the provider auto-connects on mount. */
enableRealtime?: boolean;
/** Override socket URL. Defaults to socketUrlFromBaseUrl(baseUrl). */
socketUrl?: string;
children: ReactNode;
}
export function ArcadiaProvider({
initialTenantId,
userId,
enableRealtime = false,
socketUrl,
children,
...clientOpts
}: ArcadiaProviderProps) {
const [tenantId, setTenantIdState] = useState<string | undefined>(initialTenantId);
const [realtimeStatus, setRealtimeStatus] = useState<RealtimeStatus>("idle");
// Keep callable refs so we don't rebuild the client on every render.
const optsRef = useRef(clientOpts);
optsRef.current = clientOpts;
const client = useMemo(
() =>
createArcadiaClient({
...clientOpts,
tenantId: initialTenantId,
getToken: () => optsRef.current.getToken?.() ?? null,
onUnauthorized: (err) => optsRef.current.onUnauthorized?.(err),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clientOpts.baseUrl],
);
// Keep client tenant in lockstep with React state.
useEffect(() => {
client.setTenantId(tenantId);
}, [client, tenantId]);
// Realtime lifecycle. Recreated when tenantId, userId, or socket URL change.
const realtime = useMemo<ArcadiaRealtime | null>(() => {
if (!enableRealtime || !tenantId) return null;
if (typeof window === "undefined") return null; // SSR-safe
return createArcadiaRealtime({
socketUrl: socketUrl ?? socketUrlFromBaseUrl(clientOpts.baseUrl),
tenantId,
userId,
getToken: () => optsRef.current.getToken?.() ?? null,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableRealtime, tenantId, userId, socketUrl, clientOpts.baseUrl]);
useEffect(() => {
if (!realtime) return;
const off = realtime.onStatusChange(setRealtimeStatus);
realtime.connect();
return () => {
off();
realtime.disconnect();
};
}, [realtime]);
const value = useMemo<ArcadiaContextValue>(
() => ({ client, tenantId, setTenantId: setTenantIdState, realtime, realtimeStatus }),
[client, tenantId, realtime, realtimeStatus],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useArcadia(): ArcadiaContextValue {
const v = useContext(Ctx);
if (!v) throw new Error("useArcadia must be used inside <ArcadiaProvider>");
return v;
}
/** Convenience: most consumers only need the client. */
export function useArcadiaClient(): ArcadiaClient {
return useArcadia().client;
}
/** Subscribe to a Phoenix Channels event for as long as the component is
* mounted. No-op if the provider was created without `enableRealtime`. The
* callback is captured fresh on every render via a ref so consumers don't
* need to memoize it. */
export function useArcadiaSubscription<E extends keyof TenantEventMap>(
event: E,
callback: (payload: TenantEventMap[E]) => void,
opts?: { scope?: RealtimeScope; enabled?: boolean },
): void {
const { realtime, realtimeStatus } = useArcadia();
const cbRef = useRef(callback);
cbRef.current = callback;
const enabled = opts?.enabled ?? true;
const scope = opts?.scope ?? "tenant";
useEffect(() => {
if (!realtime || !enabled || realtimeStatus !== "open") return;
const unsub = realtime.on(event, ((payload) => cbRef.current(payload)) as (p: TenantEventMap[E]) => void, scope);
return unsub;
}, [realtime, realtimeStatus, event, enabled, scope]);
}

163
src/realtime.ts Normal file
View File

@@ -0,0 +1,163 @@
// Realtime over Phoenix Channels at /socket/tenant.
//
// Arcadia exposes a tenant-scoped socket; clients authenticate by passing
// the JWT in connect params. After joining `tenant:<tenant_id>` the server
// pushes events: notification, digital_object, announcement, status_update,
// event. A user-scoped sub-topic `tenant:<id>:user:<user_id>` carries the
// same shape filtered to the current user.
//
// SSR-safe: no WebSocket reference is created until connect() is called.
import { Socket, Channel } from "phoenix";
import type { ArcadiaTenantId } from "./types";
/** Known event payloads. Open-ended — arcadia may add more, and apps can
* augment via module declaration merging or just use the indexed access. */
export interface TenantEventMap {
notification: { id: string; level?: "info" | "warning" | "error"; title: string; body?: string; [k: string]: unknown };
digital_object: { id: string; action: "created" | "updated" | "deleted" | "restored"; [k: string]: unknown };
announcement: { id: string; title: string; body?: string; [k: string]: unknown };
status_update: { id: string; component?: string; status?: string; [k: string]: unknown };
event: { type: string; payload?: unknown; [k: string]: unknown };
[key: string]: Record<string, unknown>;
}
export type RealtimeStatus = "idle" | "connecting" | "open" | "closed" | "error";
export type RealtimeScope = "tenant" | "user";
export interface ArcadiaRealtimeOptions {
/** Full URL to the Phoenix socket endpoint. Example:
* "ws://localhost:4000/socket/tenant" or "wss://api.example.com/socket/tenant".
* If you only have an HTTP base URL, see `socketUrlFromBaseUrl`. */
socketUrl: string;
/** Tenant id whose topic we'll join (e.g. UUID or slug). */
tenantId: ArcadiaTenantId;
/** Optional user id; required if you want user-scoped subscriptions. */
userId?: string;
/** Returns the JWT to send as Phoenix connect param `token`. Called
* once on connect; reconnect re-evaluates so token rotation works. */
getToken: () => string | null | Promise<string | null>;
/** Heartbeat ms. Phoenix default is 30000. */
heartbeatIntervalMs?: number;
/** Called for any unexpected error (auth failure, channel join refused). */
onError?: (err: Error) => void;
/** Custom Socket factory (for tests / mocks). */
socketFactory?: (endpoint: string, opts: ConstructorParameters<typeof Socket>[1]) => Socket;
}
export interface ArcadiaRealtime {
/** Open the socket and join tenant + (optionally) user channels. Idempotent. */
connect(): Promise<void>;
/** Tear everything down. */
disconnect(): void;
/** Subscribe to an event. Returns an unsubscribe function.
* scope="tenant" (default) targets `tenant:<id>`.
* scope="user" targets `tenant:<id>:user:<userId>` (requires userId). */
on<E extends keyof TenantEventMap>(
event: E,
callback: (payload: TenantEventMap[E]) => void,
scope?: RealtimeScope,
): () => void;
/** Current status. */
status: RealtimeStatus;
/** Subscribe to status changes. Returns an unsubscribe fn. */
onStatusChange(cb: (status: RealtimeStatus) => void): () => void;
}
/** Convert an http(s) base URL into the matching ws(s) socket URL. */
export function socketUrlFromBaseUrl(baseUrl: string, path = "/socket/tenant"): string {
const u = new URL(baseUrl);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}${path}`;
}
export function createArcadiaRealtime(opts: ArcadiaRealtimeOptions): ArcadiaRealtime {
let socket: Socket | null = null;
let tenantChannel: Channel | null = null;
let userChannel: Channel | null = null;
let _status: RealtimeStatus = "idle";
const statusListeners = new Set<(s: RealtimeStatus) => void>();
function setStatus(next: RealtimeStatus) {
if (_status === next) return;
_status = next;
for (const cb of statusListeners) cb(next);
}
async function connect(): Promise<void> {
if (socket) return;
setStatus("connecting");
const token = await opts.getToken();
const factory =
opts.socketFactory ?? ((endpoint, o) => new Socket(endpoint, o));
socket = factory(opts.socketUrl, {
params: () => ({ token }),
heartbeatIntervalMs: opts.heartbeatIntervalMs ?? 30000,
});
socket.onOpen(() => setStatus("open"));
socket.onClose(() => setStatus("closed"));
socket.onError((err) => {
setStatus("error");
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
});
socket.connect();
tenantChannel = socket.channel(`tenant:${opts.tenantId}`, {});
tenantChannel
.join()
.receive("error", (resp) => opts.onError?.(new Error(`tenant channel join failed: ${JSON.stringify(resp)}`)));
if (opts.userId) {
userChannel = socket.channel(`tenant:${opts.tenantId}:user:${opts.userId}`, {});
userChannel
.join()
.receive("error", (resp) =>
opts.onError?.(new Error(`user channel join failed: ${JSON.stringify(resp)}`)),
);
}
}
function disconnect() {
tenantChannel?.leave();
userChannel?.leave();
socket?.disconnect();
tenantChannel = null;
userChannel = null;
socket = null;
setStatus("idle");
}
function on<E extends keyof TenantEventMap>(
event: E,
callback: (payload: TenantEventMap[E]) => void,
scope: RealtimeScope = "tenant",
): () => void {
const channel = scope === "user" ? userChannel : tenantChannel;
if (!channel) {
// Connection not established yet — return a no-op unsubscribe; caller
// should retry after connect() resolves. The React hook handles this.
return () => {};
}
const ref = channel.on(event as string, callback as (payload: unknown) => void);
return () => {
channel.off(event as string, ref);
};
}
function onStatusChange(cb: (status: RealtimeStatus) => void): () => void {
statusListeners.add(cb);
return () => statusListeners.delete(cb);
}
return {
connect,
disconnect,
on,
get status() {
return _status;
},
onStatusChange,
};
}

47
src/types.ts Normal file
View File

@@ -0,0 +1,47 @@
// Hand-written shared types.
//
// Per-endpoint request/response types come from the generated OpenAPI types
// (see ./generated/openapi.d.ts after running `sync-spec`). The shapes here
// are the ones that exist *around* every request — wrappers, pagination,
// auth payloads — and don't belong to any one endpoint.
/** Successful response envelope used by most v1 endpoints. */
export interface ArcadiaEnvelope<T> {
data: T;
meta?: ArcadiaMeta;
}
/** Optional metadata block on list responses (pagination, totals, etc). */
export interface ArcadiaMeta {
page?: number;
per_page?: number;
total?: number;
total_pages?: number;
request_id?: string;
[key: string]: unknown;
}
/** Auth-token pair returned by /auth/login, /auth/refresh, OAuth callbacks.
* `expires_in` is seconds-from-now (Phoenix Guardian default); compute the
* absolute expiry on the client if needed. */
export interface ArcadiaAuthTokens {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: "Bearer";
}
/** Minimal user shape used by useArcadia / auth-ui until generated types
* cover the whole User schema. Apps should prefer generated types when
* available. */
export interface ArcadiaUser {
id: string;
email: string;
name?: string;
tenant_id?: string;
roles?: string[];
}
/** Tenant identifier — either a UUID string, or a slug if the deployment
* uses friendly tenant slugs. The client doesn't enforce a format. */
export type ArcadiaTenantId = string;