jules 57dc0455d1 realtime: bail in connect() when getToken returns no token
Without a JWT the Phoenix TenantSocket refuses the handshake
("REFUSED CONNECTION TO ArcadiaWeb.TenantSocket"), the client retries
forever, and both ends spam logs. Consumers like skyai-ecosystem set
enableRealtime unconditionally at mount, before login completes, which
is the common case for this noise.

Callers should invoke connect() again after login (e.g. on
crema:session-change) — the existing reconnect path covers that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:17:54 +10:00
2026-04-30 08:26:34 +10:00
2026-04-30 08:26:34 +10:00

@crema/arcadia-client

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 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

import {
  createArcadiaClient,
  ArcadiaProvider,
  useArcadia,
  useArcadiaClient,
  ArcadiaError,
} from "@crema/arcadia-client";

Usage in a Crema app

In app/root.tsx, wrap providers:

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:

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:

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:

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.

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.

<ArcadiaProvider
  baseUrl={ARCADIA_URL}
  initialTenantId={tenantId}
  userId={userId}
  enableRealtime
  getToken={() => sessionStorage.getItem("arcadia_access_token")}
>
  
</ArcadiaProvider>
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, plus social: social:notification, social:post, social:article. 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.

Social bindings

For the social surfaces (profiles, articles, discussion board, favourites, DNA-change subscriptions, notifications, search, @-mention search), there's a typed wrapper over the generic client:

import { createArcadiaClient, createSocialBindings } from "@crema/arcadia-client";

const client = createArcadiaClient({ baseUrl, getToken });
const social = createSocialBindings(client);

await social.createPost({ body_md: "hello" });
await social.search("agent design");
await social.addSubscription(agentId, { lastSeenDnaHash: currentHash });

In a React app, prefer the useSocial() hook pattern (see arcadia-aifirst-starter/app/lib/api/social.ts) so the bindings pick up the ArcadiaProvider's auth context automatically.

Realtime: the social write paths broadcast via Phoenix.PubSub → ArcadiaWeb.TenantChannel forwards them as social:notification / social:post / social:article channel events. Subscribe via useArcadiaSubscription.

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.
Description
No description provided
Readme 77 KiB
Languages
TypeScript 90.3%
JavaScript 9.7%