init: initial commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
134
README.md
Normal file
134
README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# @crema/arcadia-client
|
||||
|
||||
Typed HTTP client + React bindings for the [arcadia](https://git.sky-ai.com/CremaUIStudio/arcadia-app) Phoenix API. Wraps the OpenAPI-spec'd surface at `/api/v1` with auth (Bearer JWT and/or service-account API key), tenant context (`X-Tenant-ID`), idempotency keys, error normalization, and rate-limit-aware retry.
|
||||
|
||||
The client is **stateless about how the JWT is obtained** — callers pass `getToken` so token refresh and storage stay app-owned.
|
||||
|
||||
## Public API
|
||||
|
||||
```ts
|
||||
import {
|
||||
createArcadiaClient,
|
||||
ArcadiaProvider,
|
||||
useArcadia,
|
||||
useArcadiaClient,
|
||||
ArcadiaError,
|
||||
} from "@crema/arcadia-client";
|
||||
```
|
||||
|
||||
## Usage in a Crema app
|
||||
|
||||
In `app/root.tsx`, wrap providers:
|
||||
|
||||
```tsx
|
||||
import { ArcadiaProvider } from "@crema/arcadia-client";
|
||||
|
||||
<ArcadiaProvider
|
||||
baseUrl={import.meta.env.VITE_ARCADIA_URL}
|
||||
initialTenantId={tenantId}
|
||||
getToken={() => sessionStorage.getItem("arcadia_token")}
|
||||
onUnauthorized={() => navigate("/login")}
|
||||
>
|
||||
{children}
|
||||
</ArcadiaProvider>;
|
||||
```
|
||||
|
||||
In any component:
|
||||
|
||||
```tsx
|
||||
import { useArcadiaClient, ArcadiaError } from "@crema/arcadia-client";
|
||||
|
||||
function ResourceList() {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
arcadia
|
||||
.GET<{ data: Resource[] }>("/api/v1/digital_objects", { params: { page: 1 } })
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: ArcadiaError) => {
|
||||
if (err.isRateLimited) showToast("Too many requests, slow down.");
|
||||
});
|
||||
}, [arcadia]);
|
||||
}
|
||||
```
|
||||
|
||||
## Generated types
|
||||
|
||||
Endpoint request/response types come from arcadia's live OpenAPI spec. Regenerate with:
|
||||
|
||||
```bash
|
||||
ARCADIA_OPENAPI_URL=http://localhost:4000/api/openapi \
|
||||
node ../lib-arcadia-client/scripts/sync-spec.mjs
|
||||
```
|
||||
|
||||
Requires `openapi-typescript` in the consuming app's devDeps:
|
||||
|
||||
```bash
|
||||
npm i -D openapi-typescript
|
||||
```
|
||||
|
||||
The generated file lives at `src/generated/openapi.d.ts`. Until it's been generated at least once, the file is a stub and only the hand-written types in `src/types.ts` are useful.
|
||||
|
||||
## Two surfaces: generic + typed
|
||||
|
||||
The client gives you both a generic-string API and a fully typed (OpenAPI-driven) API. They share the same auth/retry/error plumbing.
|
||||
|
||||
```ts
|
||||
const arcadia = useArcadiaClient();
|
||||
|
||||
// Generic — accepts any string path. Use when the spec is incomplete or
|
||||
// when calling endpoints outside the spec.
|
||||
const res = await arcadia.GET<{ data: Resource[] }>(
|
||||
"/api/v1/digital_objects",
|
||||
{ params: { page: 1 } },
|
||||
);
|
||||
|
||||
// Typed — paths, params, and responses inferred from the generated spec.
|
||||
// Throws ArcadiaError on non-2xx.
|
||||
const { data } = await arcadia.typed.GET("/api/v1/digital_objects", {
|
||||
params: { query: { page: 1 } },
|
||||
});
|
||||
```
|
||||
|
||||
## Realtime
|
||||
|
||||
Phoenix Channels at `/socket/tenant`, opt-in via the provider. The socket auto-connects when `enableRealtime` is on, joins `tenant:<id>` (and optionally `tenant:<id>:user:<userId>`), and disconnects on unmount.
|
||||
|
||||
```tsx
|
||||
<ArcadiaProvider
|
||||
baseUrl={ARCADIA_URL}
|
||||
initialTenantId={tenantId}
|
||||
userId={userId}
|
||||
enableRealtime
|
||||
getToken={() => sessionStorage.getItem("arcadia_access_token")}
|
||||
>
|
||||
…
|
||||
</ArcadiaProvider>
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useArcadiaSubscription } from "@crema/arcadia-client";
|
||||
|
||||
function NotificationToasts() {
|
||||
useArcadiaSubscription("notification", (n) => {
|
||||
toast(n.title, n.body);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Known events on `TenantEventMap`: `notification`, `digital_object`, `announcement`, `status_update`, `event`. The map is open-ended — apps can subscribe to any string event arcadia emits; payload type defaults to `Record<string, unknown>`.
|
||||
|
||||
For user-scoped events (those filtered to the current user), pass `{ scope: "user" }` and ensure `userId` was provided to the provider.
|
||||
|
||||
## What's not in here yet
|
||||
|
||||
- **TanStack Query helpers** — opt-in. Vibespace doesn't use Query today; we can layer it via a sub-export later.
|
||||
- **Token refresh helper** — the client surfaces 401 via `onUnauthorized`; the app decides whether to refresh + retry. A reference refresh helper may land later.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Inline imports only — no own `package.json` (lib lives by the consuming app's deps).
|
||||
- Path-aliased into apps via `tsconfig.json` `paths`: `@crema/arcadia-client` → `../lib-arcadia-client/src/index.tsx`.
|
||||
- Tailwind doesn't scan this lib — no UI; nothing to scan.
|
||||
99
scripts/sync-spec.mjs
Normal file
99
scripts/sync-spec.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
// Fetches the arcadia OpenAPI spec and regenerates ../src/generated/openapi.d.ts.
|
||||
//
|
||||
// Usage (from a consuming app, with arcadia reachable):
|
||||
// node ../lib-arcadia-client/scripts/sync-spec.mjs
|
||||
//
|
||||
// Configurable via env:
|
||||
// ARCADIA_OPENAPI_URL default: http://localhost:4000/api/openapi
|
||||
// ARCADIA_BEARER_TOKEN optional, sent as Authorization if the spec route
|
||||
// is gated in your deployment
|
||||
//
|
||||
// Requires `openapi-typescript` to be available (in the consuming app's
|
||||
// node_modules, or globally). Run from a consuming app so the dep resolves:
|
||||
// npm i -D openapi-typescript
|
||||
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = resolve(here, "..", "src", "generated");
|
||||
const outFile = resolve(outDir, "openapi.d.ts");
|
||||
const specUrl = process.env.ARCADIA_OPENAPI_URL ?? "http://localhost:4000/api/openapi";
|
||||
|
||||
async function main() {
|
||||
console.log(`→ fetching ${specUrl}`);
|
||||
const headers = {};
|
||||
if (process.env.ARCADIA_BEARER_TOKEN) {
|
||||
headers["Authorization"] = `Bearer ${process.env.ARCADIA_BEARER_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(specUrl, { headers });
|
||||
if (!res.ok) {
|
||||
console.error(`spec fetch failed: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
|
||||
// Resolve openapi-typescript from the consuming app's node_modules, since
|
||||
// the lib has no package.json of its own.
|
||||
let openapiTs;
|
||||
let astToString;
|
||||
try {
|
||||
const requireFromCwd = createRequire(resolve(process.cwd(), "package.json"));
|
||||
const resolved = requireFromCwd.resolve("openapi-typescript");
|
||||
const mod = await import(pathToFileURL(resolved).href);
|
||||
// Handle both ESM (mod.default is the function) and CJS-via-import
|
||||
// (mod.default is the namespace, mod.default.default is the function).
|
||||
openapiTs = typeof mod.default === "function" ? mod.default : mod.default?.default;
|
||||
astToString = mod.astToString ?? mod.default?.astToString;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`openapi-typescript could not be resolved from ${process.cwd()}.\n` +
|
||||
`Run from your consuming app's directory after installing it:\n` +
|
||||
` cd <app> && npm i -D openapi-typescript\n` +
|
||||
`Original error: ${err.message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Scrub malformed operation entries. Arcadia's spec contains some
|
||||
// endpoints whose operation value is the bare string "ok" (a controller
|
||||
// placeholder that never got replaced with a real OperationObject).
|
||||
// openapi-typescript can't transform them — strip them and report so
|
||||
// the generation succeeds for the rest. Fix is on the arcadia side.
|
||||
const skipped = [];
|
||||
if (spec.paths) {
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
for (const [verb, op] of Object.entries(item)) {
|
||||
if (typeof op !== "object" || op === null) {
|
||||
skipped.push(`${verb.toUpperCase()} ${path}`);
|
||||
delete item[verb];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.warn(`⚠ skipped ${skipped.length} malformed operations (arcadia spec bug):`);
|
||||
for (const s of skipped.slice(0, 10)) console.warn(` - ${s}`);
|
||||
if (skipped.length > 10) console.warn(` …and ${skipped.length - 10} more`);
|
||||
}
|
||||
|
||||
console.log(`→ generating types`);
|
||||
const ast = await openapiTs(spec);
|
||||
const dts = astToString(ast);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const banner =
|
||||
"// AUTO-GENERATED — do not edit by hand. Regenerate with sync-spec.mjs.\n" +
|
||||
`// Source: ${specUrl}\n` +
|
||||
`// Generated: ${new Date().toISOString()}\n\n`;
|
||||
await writeFile(outFile, banner + dts, "utf8");
|
||||
console.log(`✓ wrote ${outFile}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
209
src/client.ts
Normal file
209
src/client.ts
Normal 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
77
src/errors.ts
Normal 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
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
30
src/index.tsx
Normal 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
143
src/provider.tsx
Normal 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
163
src/realtime.ts
Normal 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
47
src/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user