@firtoz/socka
Version:
Standard Schema–first WebSocket RPC for TypeScript — Bun, Hono, Node ws, Cloudflare Workers, Durable Objects
156 lines (106 loc) • 10.6 kB
Markdown
# Client
## Vanilla (`SockaSession`)
Outside React, construct **`SockaSession`** from **`@firtoz/socka/client`** with the same **`contract`** as the server and a **`url`** (or a prebuilt **`webSocket`**). **`wireFormat`** defaults to **`"json"`** and must match every server session that receives this connection.
| Option | Typical use |
|--------|-------------|
| **`contract`**, **`url`** / **`webSocket`** | Required pairing: open socket to your upgrade URL, or inject a socket for tests. |
| **`wireFormat`** | **`"json"`** (text frames) vs **`"msgpack"`** (binary); must match the **server** session config for this connection. |
| **`autoConnect: false`** | Defer opening until **`await session.connect()`** (or first **`send`**). |
| **`serializeJson` / `deserializeJson`** | Custom JSON handling for the **outer** frame in JSON mode (e.g. replacers). |
| **`onOpen` / `onClose` / `onError`** | Lifecycle and telemetry. |
| **`pushHandlers`** | Up-front subscriptions for contract **`pushes`** (same as **`session.subscribe.on`**). |
| **`reportError`** | Non-RPC client pipeline failures; defaults to **`console.error`**. |
Full list: **[Reference — Client configuration](./reference.md#client-configuration)**.
### Fire-and-forget
Calls that **omit** **`output`** resolve **`await session.send.*`** as soon as the request is sent; they do **not** wait for **`serverResponse`**. If the server returns **`serverError`**, handle it with **`reportError`** (see **[Reference — Errors](./reference.md#errors-and-observability)**) — there is no rejected promise for that call. Prefer **`output: z.void()`** when you still want **`await`** to track success or failure through the returned promise.
### Fire-and-forget observability
For **output-less** calls, the **`send`** method returns a **`Promise<void>`** that resolves when the **outbound request is queued**; it does **not** reject if the server later answers with **`serverError`**. Patterns like **`void send.sendCursor({ ... }).catch(() => undefined)`** therefore **do not** observe **server-side** handler failures, validation errors on the response path, or structured **`SockaError`** for that procedure.
- **Client** — pass **`reportError`** to **`SockaSession`** or **`useSockaSession`** / **`useSocka`** (and **`SockaSessionProvider`**, which forwards session options). Inspect **`event.kind`**; fire-and-forget server failures often surface as **`clientFireAndForgetRpcError`**. See **[Reference — Optional output (fire-and-forget)](./reference.md#optional-output-fire-and-forget)** and **[Reference — RPC handler errors](./reference.md#rpc-handler-errors)**.
- **Server** — use **`onHandlerError`** and **`onValidationError`** on **`SockaWebSocketSessionConfig`** / **`SockaDoSessionConfig`** for logs and metrics when a handler throws or inbound frames fail before your handler.
**Call names** — For literal `calls` objects, **`defineSocka`** rejects names that would make **`session.send`** Promise-like or clash with object shape (e.g. **`then`**, **`toString`**). If you use a wide **`Record<string, SockaProcedureDef>`**, TypeScript cannot apply that check; **`SockaSession`** still validates at construction (see **`RESERVED_SOCKA_PROCEDURE_NAMES`** in **`@firtoz/socka/core`**).
```ts
import { SockaSession } from "@firtoz/socka/client";
import { myContract } from "./contract";
const session = new SockaSession({ contract: myContract, url: "wss://example.com/ws" });
const rows = await session.send.list();
```
Use **`SockaWebSocketClient`** directly if you need **`onResponse` / `onServerError` / `onEvent`** frame hooks without **`SockaSession`**’s typed **`send`** / **`subscribe`**; most apps use **`SockaSession`**.
**Connection status** — **`SockaWebSocketClient`** and **`SockaSession`** expose **`status`** (`"idle" | "connecting" | "open" | "reconnecting" | "closed"`) and **`onStatusChange`** for UI (e.g. “Reconnecting…”). Same fields power the React hooks below.
## React
### `useSockaSession` — typed `send`
Use **`useSockaSession(contract, options, deps)`** when you want **`send.*`** RPC methods directly (same shape as **`session.send`**). **`ready`** flips to **`true`** after the socket opens; **`deps`** remount the connection when identity (e.g. room id) changes. Also returns **`status`**, **`reconnecting`**, and **`reconnectAttempt`** (see **`useSocka`**).
```ts
import { useSockaSession } from "@firtoz/socka/react";
import { myContract } from "./contract";
function App() {
const { ready, send } = useSockaSession(myContract, { url: "ws://..." }, []);
// After `ready`, call `send.list()` / `send.insert(...)` from effects or event handlers (not during render).
return null;
}
// Binary frames — set the same `wireFormat` on the server session
useSockaSession(myContract, { url: "wss://...", wireFormat: "msgpack" }, []);
```
Optional **`pushHandlers`** in the second argument matches **`Partial<InferSockaPushHandlers<typeof myContract>>`** — see **[Pushes — Typing `pushHandlers`](./pushes.md#typing-pushhandlers)**.
### SSR and WebSocket URLs
`useSockaSession` and **`SockaSessionProvider`** need a real **`url`** for **`new WebSocket(url)`**. In **React Router SSR** (or any **SSR** tree), `window` is missing on the server, and derived **`ws:` / `wss:`** URLs must not run during **render** with a **fake** placeholder like `ws://127.0.0.1/...` (it can connect to the wrong place or open before you know the page origin).
**Recommended pattern:** In the route module, set **`const [url, setUrl] = useState<string | null>(null)`**, and in **`useEffect`** (browser-only), build a **`wss://` / `ws://`** URL from **`window.location`** (protocol, host, path to your Worker’s WebSocket route). If **`url === null`**, render a **loading** shell; only render a child component that calls **`useSockaSession(myContract, { url, pushHandlers? }, [url])`** when **`url`** is set. Keep **`url`** in the hook **`deps`** so identity changes re-open the right socket.
**SSR checklist:**
1. **Never** call **`useSockaSession`** with a placeholder URL during SSR — the hook connects in **`useEffect`** and will throw if **`url`** is missing at runtime.
2. **Parent gates the child** — render **`ClientOnly`** / **`url === null ? <Loading /> : <ChatWithSocket url={url} />`** so the hook runs only in the browser with a real URL.
3. **Build the URL in `useEffect`** — derive **`wss:`** from **`window.location`**, room id from route params, and optional auth query params there (not during render).
4. **Pass `[url]` in hook deps** — room or tenant changes should remount the connection.
5. **`SockaSessionProvider`** follows the same rules — wrap only after **`url`** is known.
**React + Durable Object** end-to-end wiring: **[React + Durable Objects](./react-durable-objects.md)**.
### `useSocka` — hold a `SockaSession` ref
Use **`useSocka(options, deps)`** when you need the full **`SockaSession`** (e.g. **`session.subscribe`**, **`session.client`**, **`waitForPush`**, or passing the session into non-React helpers). It returns **`{ ready, sessionRef, status, reconnecting, reconnectAttempt }`** — read **`sessionRef.current`** in effects or callbacks (it is **`null`** until the effect runs). Use **`reconnecting`** or **`status === "reconnecting"`** for banners; **`reconnectAttempt`** counts backoff attempts.
| | **`useSockaSession`** | **`useSocka`** |
|--|------------------------|----------------|
| **Returns** | **`{ ready, send, status, reconnecting, reconnectAttempt }`** | **`{ ready, sessionRef, status, reconnecting, reconnectAttempt }`** |
| **Best for** | Most React UIs that only call RPCs | Subscriptions, low-level client access, imperative APIs |
```tsx
import { useEffect } from "react";
import { useSocka } from "@firtoz/socka/react";
import { myContract } from "./contract";
function App() {
const { ready, sessionRef } = useSocka({ contract: myContract, url: "ws://..." }, []);
useEffect(() => {
const s = sessionRef.current;
if (!ready || !s) return;
const onNotify = (p: { msg: string }) => console.log(p.msg);
s.subscribe.on("notify", onNotify);
return () => s.subscribe.off("notify", onNotify);
}, [ready, sessionRef]);
return null;
}
```
*(Example assumes your contract defines a **`notify`** push; use the real push names from **`myContract`**.)*
### `useSockaPresence` — snapshot + join/leave deltas
Call **`useSockaPresence(sessionRef, ready, options, deps)`** after **`useSocka`** / **`useSockaSession`**: it runs your **`snapshot`** RPC once, subscribes to **`joinPush`** / **`leavePush`**, and returns **`{ users, selfUserId, loading }`**. Pass the same **`deps`** as the connection when room identity changes.
### One WebSocket for the whole tree
If many components need **`send`**, avoid calling **`useSockaSession`** in each one (each call owns a connection). Mount a provider once and read the session from context:
```tsx
import { SockaSessionProvider, useSockaSessionContext } from "@firtoz/socka/react";
import { myContract } from "./contract";
function Layout({ roomId }: { roomId: string }) {
return (
<SockaSessionProvider
contract={myContract}
deps={[roomId]}
url={`wss://example.com/ws/${roomId}`}
>
<Child />
</SockaSessionProvider>
);
}
function Child() {
const { ready, send, status, reconnecting } = useSockaSessionContext(myContract);
// ...
}
```
Use the **same `contract` reference** on the provider and in **`useSockaSessionContext`** (checked at runtime).
## Deferred WebSocket connect
Use **`autoConnect: false`** on **`SockaWebSocketClient`** / **`SockaSession`** when you want to open the socket later (e.g. after user action). Call **`await session.connect()`** or **`await client.connect()`** before calls; each **`send`** call also awaits connect when the socket is not open yet.
## Client lifecycle
Treat each **`SockaSession`** / **`SockaWebSocketClient`** as bound to **one** underlying **`WebSocket`**. When the socket closes, pending calls reject on that instance unless you opt into client-side reconnect (see **[Reconnection](./reconnection.md)**). For a deliberate room/url change, construct a **new** client (in React, remount **`useSockaSession`** / **`SockaSessionProvider`** when **`url`** or identity **`deps`** change). Use **`ready`** / **`waitForOpen()`** before assuming the connection is usable; use **`onClose`** / **`onError`** (or **`reportError`**) for telemetry.
## Pushes
Server push and client subscriptions are covered in **[Pushes](./pushes.md)**.