@firtoz/socka 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -40
- package/dist/{SockaWebSocketSession-Bru8yFcK.d.ts → SockaWebSocketSession-Cza7Fti-.d.ts} +87 -5
- package/dist/bun/index.d.ts +28 -3
- package/dist/bun/index.js +28 -5
- package/dist/bun/index.js.map +1 -1
- package/dist/{chunk-MZCQHJXY.js → chunk-2FNWVCP3.js} +27 -8
- package/dist/chunk-2FNWVCP3.js.map +1 -0
- package/dist/{chunk-AM7PB26G.js → chunk-H3S3435J.js} +125 -3
- package/dist/chunk-H3S3435J.js.map +1 -0
- package/dist/{chunk-45D4T232.js → chunk-JVLUA3Q5.js} +64 -6
- package/dist/chunk-JVLUA3Q5.js.map +1 -0
- package/dist/chunk-KQO5AVKA.js +8 -0
- package/dist/chunk-KQO5AVKA.js.map +1 -0
- package/dist/client/index.d.ts +59 -3
- package/dist/client/index.js +2 -2
- package/dist/core/index.d.ts +5 -1
- package/dist/core/index.js +1 -1
- package/dist/do/index.d.ts +20 -2
- package/dist/do/index.js +35 -2
- package/dist/do/index.js.map +1 -1
- package/dist/hono/cloudflare-workers.d.ts +2 -2
- package/dist/hono/cloudflare-workers.js +4 -3
- package/dist/hono/cloudflare-workers.js.map +1 -1
- package/dist/hono/index.d.ts +20 -4
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/react/index.d.ts +43 -4
- package/dist/react/index.js +103 -9
- package/dist/react/index.js.map +1 -1
- package/dist/server/index.d.ts +17 -4
- package/dist/server/index.js +24 -4
- package/dist/server/index.js.map +1 -1
- package/dist/{socka-report-error-DzFI2Tr7.d.ts → socka-report-error-ixTynx4w.d.ts} +8 -1
- package/dist/test/index.d.ts +11 -0
- package/dist/test/index.js +84 -0
- package/dist/test/index.js.map +1 -0
- package/docs/README.md +15 -6
- package/docs/auth.md +27 -0
- package/docs/backpressure.md +16 -0
- package/docs/client.md +44 -3
- package/docs/comparison.md +1 -1
- package/docs/durable-objects.md +2 -2
- package/docs/getting-started.md +143 -84
- package/docs/history.md +26 -0
- package/docs/internals.md +56 -0
- package/docs/lifecycle.md +3 -3
- package/docs/multi-room.md +10 -8
- package/docs/peers.md +11 -7
- package/docs/presence.md +43 -0
- package/docs/{events.md → pushes.md} +1 -1
- package/docs/recipes.md +78 -0
- package/docs/reconnection.md +44 -0
- package/docs/reference.md +21 -30
- package/docs/server.md +14 -1
- package/docs/testing.md +20 -0
- package/docs/wire-format.md +25 -0
- package/examples/minimal-socka.ts +56 -3
- package/package.json +14 -10
- package/dist/chunk-45D4T232.js.map +0 -1
- package/dist/chunk-AM7PB26G.js.map +0 -1
- package/dist/chunk-MZCQHJXY.js.map +0 -1
package/docs/presence.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Presence (who is online)
|
|
2
|
+
|
|
3
|
+
“Presence” is usually modeled as:
|
|
4
|
+
|
|
5
|
+
1. **Snapshot** — an RPC (e.g. **`listPresence`**) returns the current set of users.
|
|
6
|
+
2. **Pushes** — **`userJoined`** / **`userLeft`** (or similar) update the UI when peers attach or detach.
|
|
7
|
+
|
|
8
|
+
## Server: `listPeers` / `listPeersWith`
|
|
9
|
+
|
|
10
|
+
On **`SockaWebSocketSession`** (and **`SockaDoSession`**), **`session.listPeers()`** returns **`TData[]`** for every connection in the same **`sessions`** map (same room), in **insert order**. Use **`listPeers({ excludeSelf: true })`** to omit the calling socket.
|
|
11
|
+
|
|
12
|
+
**`session.peerCount()`** / **`session.hasPeers()`** are cheap alternatives to **`listPeers().length`** when you only need a count or existence check.
|
|
13
|
+
|
|
14
|
+
**`session.listPeersWith((s) => …)`** maps each **peer session** (not just **`data`**) — useful if you need fields beyond **`TData`**.
|
|
15
|
+
|
|
16
|
+
Map that list to whatever your RPC output needs:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
listPresence: async (_input, session) => {
|
|
20
|
+
const users = session.listPeers().map((d) => ({
|
|
21
|
+
userId: d.userId,
|
|
22
|
+
displayName: d.displayName,
|
|
23
|
+
}));
|
|
24
|
+
users.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
25
|
+
return { selfUserId: session.data.userId, users };
|
|
26
|
+
},
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Pushes
|
|
30
|
+
|
|
31
|
+
In **`onAttached`**, broadcast **`userJoined`**; in **`handleClose`**, broadcast **`userLeft`** so other clients update incrementally. Ordering relative to **`listPresence`** is not guaranteed across reconnects — clients should call **`listPresence`** (or equivalent) after connect and treat pushes as deltas.
|
|
32
|
+
|
|
33
|
+
## Client
|
|
34
|
+
|
|
35
|
+
After **`waitForOpen()`** or in **`onOpen`**, fetch **`listPresence`** once, then apply **`userJoined`** / **`userLeft`** from **`session.subscribe`** for live updates.
|
|
36
|
+
|
|
37
|
+
React: **`useSockaPresence`** — see **[Client](./client.md)**.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## See also
|
|
41
|
+
|
|
42
|
+
- **[Getting started](./getting-started.md)** — chat tutorial.
|
|
43
|
+
- **[Pushes](./pushes.md)** — **`broadcastPush`**, **`subscribe`**.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Contracts can declare **`pushes`** alongside **`calls`**. Each push name maps to a **Standard Schema** payload. The server validates payloads **before** sending; the client decodes and validates **before** your listeners run—so **`InferSockaPushPayload`** stays honest end to end.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Pushes use the same **`wireFormat`** as RPCs for that session (default JSON). **Details:** **[Reference](./reference.md#wire-encoding-json-and-msgpack)** · **[Internals](./internals.md)**.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
export const myContract = defineSocka({
|
package/docs/recipes.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Recipes (copy-paste)
|
|
2
|
+
|
|
3
|
+
Minimal wiring per runtime. Each assumes a **`defineSocka`** contract and matching client **`SockaSession`**. Full apps: **[chatroom-bun](../../examples/chatroom-bun)** (Bun SQLite), **[chatroom-hono](../../examples/chatroom-hono)** (Hono JSON), **[chatroom-do](../../examples/chatroom-do)** (Durable Object SQLite).
|
|
4
|
+
|
|
5
|
+
## Multi-room Bun (`Bun.serve`)
|
|
6
|
+
|
|
7
|
+
Use **`createSockaRoomRegistry`** + **`createSockaBunWebSocketHandlers({ resolveScope })`** — see **[chatroom-bun](../../examples/chatroom-bun/src/server.ts)**.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createSockaBunWebSocketHandlers, sockaBunUpgrade } from "@firtoz/socka/bun";
|
|
11
|
+
import { createSockaRoomRegistry } from "@firtoz/socka/server";
|
|
12
|
+
|
|
13
|
+
const rooms = createSockaRoomRegistry((roomId, _sessionMap) => ({
|
|
14
|
+
contract: myContract,
|
|
15
|
+
strictUpgradeRequest: true,
|
|
16
|
+
createData: (init) => { /* parse init.request */ return { roomId: "…" }; },
|
|
17
|
+
handlers: { /* … */ },
|
|
18
|
+
handleClose: async () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { websocket } = createSockaBunWebSocketHandlers({
|
|
22
|
+
resolveScope(ws) {
|
|
23
|
+
const room = rooms.get(ws.data.roomId);
|
|
24
|
+
return { sessionMap: room.sessionMap, config: room.config };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
Bun.serve({
|
|
29
|
+
fetch(req, srv) {
|
|
30
|
+
if (req.url.includes("/ws/")) {
|
|
31
|
+
const roomId = "…";
|
|
32
|
+
if (sockaBunUpgrade(srv, req, { roomId })) return undefined;
|
|
33
|
+
return new Response("upgrade failed", { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
return new Response("OK");
|
|
36
|
+
},
|
|
37
|
+
websocket,
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Single-room Bun
|
|
42
|
+
|
|
43
|
+
One **`sessionMap`** and one **`config`** — use **`createSockaBunWebSocketHandlers(myConfig)`** without **`resolveScope`**.
|
|
44
|
+
|
|
45
|
+
## Hono on Node (`@hono/node-ws`)
|
|
46
|
+
|
|
47
|
+
**`createNodeWebSocket`** + **`sockaHonoNodeWs`** — see **[chatroom-hono](../../examples/chatroom-hono/src/server.ts)**.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { sockaHonoNodeWs } from "@firtoz/socka/hono";
|
|
51
|
+
import { createSockaRoomRegistry } from "@firtoz/socka/server";
|
|
52
|
+
|
|
53
|
+
const rooms = createSockaRoomRegistry((roomId) => ({ /* config */ }));
|
|
54
|
+
|
|
55
|
+
app.get("/ws/:roomId", upgradeWebSocket((c) => {
|
|
56
|
+
const room = rooms.get(c.req.param("roomId") ?? "default");
|
|
57
|
+
return sockaHonoNodeWs(room.config, { sessions: room.sessionMap })(c);
|
|
58
|
+
}));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Hono on Cloudflare Workers
|
|
62
|
+
|
|
63
|
+
**`upgradeWebSocket`** from **`hono/cloudflare-workers`** + **`sockaHonoCloudflare`** — session often starts on first **`onMessage`**; see **[Server](./server.md#firtoz-socka-hono-cloudflare-workers)**.
|
|
64
|
+
|
|
65
|
+
## Durable Objects
|
|
66
|
+
|
|
67
|
+
Subclass **`SockaWebSocketDO`**, implement **`createSockaSession`** returning **`SockaDoSession`** — see **[Durable Objects](./durable-objects.md)** and **[chatroom-do](../../examples/chatroom-do/src/do.ts)**.
|
|
68
|
+
|
|
69
|
+
## Client (browser)
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { SockaSession } from "@firtoz/socka/client";
|
|
73
|
+
|
|
74
|
+
const session = new SockaSession({ contract: myContract, url: "wss://…/ws/room" });
|
|
75
|
+
await session.send.list();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
React: **`useSockaSession`** / **`useSocka`** / **`useSockaPresence`** — see **[Client](./client.md)**.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Reconnection
|
|
2
|
+
|
|
3
|
+
**`SockaWebSocketClient`** and **`SockaSession`** share the same reconnect options (sessions forward them to the client).
|
|
4
|
+
|
|
5
|
+
## Defaults
|
|
6
|
+
|
|
7
|
+
- **`url` mode** — Reconnect is **on** with exponential backoff + jitter, **infinite** attempts, and **pause while `document.hidden`** (when **`document`** exists).
|
|
8
|
+
- **Injected `webSocket`** — Reconnect is **off** by default (typical for tests). Pass an explicit **`reconnect`** object to enable it for a mocked socket.
|
|
9
|
+
|
|
10
|
+
Set **`reconnect: false`** to disable entirely.
|
|
11
|
+
|
|
12
|
+
## Options (`SockaReconnectConfig`)
|
|
13
|
+
|
|
14
|
+
| Field | Default | Notes |
|
|
15
|
+
|-------|---------|--------|
|
|
16
|
+
| **`initialDelayMs`** | `1000` | First delay after a close that triggers reconnect. |
|
|
17
|
+
| **`maxDelayMs`** | `30000` | Cap for the backoff curve. |
|
|
18
|
+
| **`jitter`** | `0.2` | Fraction of the delay to randomize (0–1). |
|
|
19
|
+
| **`maxAttempts`** | *omitted* | Omit for **infinite** attempts. |
|
|
20
|
+
| **`pauseWhenHidden`** | `true` | Wait until the tab is visible again before reconnecting (browser). |
|
|
21
|
+
|
|
22
|
+
Delay grows exponentially from **`initialDelayMs`** up to **`maxDelayMs`**, then jitter is applied.
|
|
23
|
+
|
|
24
|
+
## Lifecycle callbacks
|
|
25
|
+
|
|
26
|
+
| Callback | When |
|
|
27
|
+
|----------|------|
|
|
28
|
+
| **`onReconnecting`** | Before a delayed attempt is scheduled (**`attempt`**, **`delayMs`**). |
|
|
29
|
+
| **`onReconnected`** | After a **new** socket reaches **`open`** following a reconnect (**`attempt`**). |
|
|
30
|
+
|
|
31
|
+
Use **`onReconnected`** to **re-hydrate** client state: call **`listHistory`**, **`listPresence`**, or your own snapshot RPCs — in-memory UI state may be stale across a new socket.
|
|
32
|
+
|
|
33
|
+
## Stopping the loop
|
|
34
|
+
|
|
35
|
+
**`session.close()`** / **`client.close()`** performs a **manual** close: the client sets an internal flag so **abnormal-close reconnect** does not run afterward. Use this for user-driven “disconnect” or cleanup.
|
|
36
|
+
|
|
37
|
+
## Pending RPCs
|
|
38
|
+
|
|
39
|
+
Pending calls at disconnect time **reject** (same as without reconnect). There is no built-in queue across disconnects; re-issue work after **`onReconnected`** or **`waitForOpen()`** if needed.
|
|
40
|
+
|
|
41
|
+
## See also
|
|
42
|
+
|
|
43
|
+
- **[Client](./client.md)** — lifecycle and React remounting when changing URL/room.
|
|
44
|
+
- **[Testing](./testing.md)** — injected **`WebSocket`** fakes in tests.
|
package/docs/reference.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Reference
|
|
2
2
|
|
|
3
|
+
User-facing **API** and **configuration**. For **wire protocol details** (frame kinds, transport layers, `decodeSockaWire`), see **[Internals](./internals.md)**.
|
|
4
|
+
|
|
3
5
|
## Type inference
|
|
4
6
|
|
|
5
7
|
```ts
|
|
@@ -24,42 +26,25 @@ type Handlers = InferSockaHandlers<
|
|
|
24
26
|
|
|
25
27
|
Each **`event`** is **`SockaReportError`**: one discriminated union (`kind` narrows context; **`error`** is the thrown/rejected value; **`eventName`** / **`adapter`** where relevant). Export: **`@firtoz/socka/core`** (`defaultReportError`, `reportSockaError`). If you omit **`reportError`**, socka uses **`console.error`** with the same **`socka:`**-prefixed messages as before.
|
|
26
28
|
|
|
27
|
-
## TypeScript: Durable Objects and session types
|
|
28
|
-
|
|
29
|
-
`@firtoz/socka/do` **erases** the contract slot on **`SockaWebSocketDO`** so concrete `defineSocka(...)` contracts stay strict under TypeScript. If a generic base class rejects your session type, keep using **your** contract type from the module where you called **`defineSocka`**—do not expect an unconstrained `SockaContract<SockaContractConfig>` to accept every concrete contract without that erasure.
|
|
30
|
-
|
|
31
29
|
## Wire encoding: JSON and msgpack
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|--------------|-----------------|-------------------|
|
|
37
|
-
| **`"json"`** (default) | **Text** — `send(string)` | UTF-8 JSON of the **whole** envelope (one JSON object per frame). Uses **`serializeJson`** / **`deserializeJson`** when set, otherwise `JSON.stringify` / `JSON.parse`. |
|
|
38
|
-
| **`"msgpack"`** | **Binary** — `send(ArrayBuffer)` | [msgpack](https://msgpack.org/) of the same envelope object graph. In the browser, **`SockaWebSocketClient`** sets **`binaryType = "arraybuffer"`** so binary frames decode correctly. |
|
|
39
|
-
|
|
40
|
-
**Rules**
|
|
31
|
+
- Set **`wireFormat`** to the **same value** on the **client** and on **every server session** for that connection. Default is **`"json"`** (UTF-8 **text** WebSocket frames).
|
|
32
|
+
- **`"msgpack"`** uses **binary** frames; use it only when **both** ends opt in.
|
|
33
|
+
- **RPCs and typed pushes** share one encoding — there is no separate “push encoding.”
|
|
41
34
|
|
|
42
|
-
|
|
43
|
-
- **RPCs and contract pushes** share one encoding: `clientRequest` / `serverResponse` / `serverError` / `serverEvent` are all wrapped the same way.
|
|
44
|
-
- If you use **`dispatchSockaInboundMessage`** manually, pass the same **`wireFormat`** as the peer used to **encode** the frame.
|
|
45
|
-
- Optional **`serializeJson`** / **`deserializeJson`** on client or server config only affect **JSON mode** (the outer envelope). Call **`body`** and push **`body`** values are still whatever your **Standard Schema** accepts after JSON/msgpack decode.
|
|
35
|
+
Tradeoffs (bundle size, CPU, debuggability): **[Wire format](./wire-format.md)**.
|
|
46
36
|
|
|
47
|
-
|
|
37
|
+
Tables, logical frame kinds (`clientRequest`, `serverResponse`, …), and **`dispatchSockaInboundMessage`** details: **[Internals](./internals.md)**.
|
|
48
38
|
|
|
49
|
-
|
|
39
|
+
## RPC handler errors
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|------|------|
|
|
53
|
-
| `clientRequest` | Client → server RPC (`id`, `rpc`, `body`) |
|
|
54
|
-
| `serverResponse` | Success reply (correlated by `id`) |
|
|
55
|
-
| `serverError` | Correlated failure (`id`, `error` message string) |
|
|
56
|
-
| `serverEvent` | Server push (`event`, `body`) — **not** tied to an RPC `id` |
|
|
41
|
+
Throw **`SockaError`** from handlers when you control the **message** sent on the **`serverError`** frame. Pass optional **`code`** and **`data`** so clients can branch without parsing **`message`**:
|
|
57
42
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
```ts
|
|
44
|
+
throw new SockaError("Not allowed", { code: "FORBIDDEN", data: { reason: "…" } });
|
|
45
|
+
```
|
|
61
46
|
|
|
62
|
-
|
|
47
|
+
Any other thrown value is wrapped in **`SockaError`** using the original **`Error.message`** when possible, otherwise **`"Handler failed"`**. The client rejects the matching RPC with **`SockaError`**; the wire carries **`error`** (string) plus optional **`code`** and **`data`**. Older peers that only read **`error`** are unchanged.
|
|
63
48
|
|
|
64
49
|
## Server session configuration
|
|
65
50
|
|
|
@@ -71,7 +56,8 @@ Throw **`SockaError`** from handlers when you control the **message** sent on th
|
|
|
71
56
|
| **`wireFormat`** | `"json"` (default) or `"msgpack"` — must match clients. |
|
|
72
57
|
| **`handlers`** | Typed call implementations; arity follows input schema (see [Getting started](./getting-started.md)). |
|
|
73
58
|
| **`handleClose`** | Async per-socket teardown; runs **before** removal from `sessions` (see [Lifecycle](./lifecycle.md)). |
|
|
74
|
-
| **`createData`** | Builds **`session.data`**. **`SockaWebSocketSession`**: **`(init: SockaWebSocketInit) => T`** (
|
|
59
|
+
| **`createData`** | Builds **`session.data`**. **`SockaWebSocketSession`**: **`(init: SockaWebSocketInit) => T`** or, with **`strictUpgradeRequest: true`**, **`(init: SockaStrictWebSocketInit) => T`** so **`init.request`** is always set — see **[Server](./server.md)**. **`SockaDoSession`**: **`(ctx: Context) => T`** — see **[Durable Objects](./durable-objects.md)**. |
|
|
60
|
+
| **`strictUpgradeRequest`** | When **`true`**, **`createData`** receives **`SockaStrictWebSocketInit`** ( **`init.request` required** ). Omitted = **`SockaWebSocketInit`** with optional **`request`**. See **[Server — Strict upgrade request](./server.md#strict-upgrade-request)**. |
|
|
75
61
|
| **`onAttached`** | Optional: after registration in `sessions` (safe for broadcasts). |
|
|
76
62
|
| **`onHandlerError`** | Observes thrown errors in handlers (after optional `SockaError` wrapping for the wire). |
|
|
77
63
|
| **`onValidationError`** | Inbound frame failed schema / wire decode before your handler. |
|
|
@@ -89,12 +75,16 @@ Throw **`SockaError`** from handlers when you control the **message** sent on th
|
|
|
89
75
|
| **`autoConnect`** | Default **`true`**. If **`false`**, call **`connect()`** before **`session.send`** (or rely on implicit open from **`send`**). |
|
|
90
76
|
| **`serializeJson` / `deserializeJson`** | Same as server — JSON wire mode only. |
|
|
91
77
|
| **`onOpen` / `onClose` / `onError`** | WebSocket lifecycle. |
|
|
78
|
+
| **`reconnect`** | **`false`** or backoff options — default **on** for **`url`**, **off** for injected **`webSocket`** unless overridden. See **[Reconnection](./reconnection.md)**. |
|
|
79
|
+
| **`onReconnecting` / `onReconnected`** | Reconnect lifecycle (**`SockaWebSocketClient`** / **`SockaSession`**). |
|
|
92
80
|
| **`onValidationError`** | Inbound frame failed validation (**`SockaWebSocketClient`**). |
|
|
93
81
|
| **`pushHandlers`** | Initial **`session.subscribe`** subscriptions (**`SockaSession`** only). |
|
|
94
82
|
| **`reportError`** | Client pipeline failures (listeners, validation); see **Errors and observability**. |
|
|
95
83
|
|
|
96
84
|
**`SockaSession`** passes unrecognized options through to **`SockaWebSocketClient`** except **`pushHandlers`** and **`reportError`** (handled at the session layer). React hooks mirror these options — see **[Client](./client.md)**.
|
|
97
85
|
|
|
86
|
+
**`SockaSession`** / **`SockaWebSocketClient`** also expose **`status`** and **`onStatusChange`** (connection lifecycle, reconnect UI).
|
|
87
|
+
|
|
98
88
|
## Schema libraries
|
|
99
89
|
|
|
100
90
|
Anything that implements **Standard Schema v1** works — **Zod**, **Valibot**, **ArkType**, or a custom implementation. Pass schemas straight into **`defineSocka`**; no adapter helpers required.
|
|
@@ -115,7 +105,8 @@ Anything that implements **Standard Schema v1** works — **Zod**, **Valibot**,
|
|
|
115
105
|
| `@firtoz/socka` | Same as **`@firtoz/socka/core`** — `defineSocka`, wire helpers, errors, types (prefer explicit **`/core`** in examples) |
|
|
116
106
|
| `@firtoz/socka/core` | `defineSocka`, wire helpers, `SockaError`, `SockaReportError`, `reportSockaError`, types |
|
|
117
107
|
| `@firtoz/socka/client` | `SockaSession`, `SockaWebSocketClient` (also re-exports `SockaReportError`, `reportSockaError`) |
|
|
118
|
-
| `@firtoz/socka/
|
|
108
|
+
| `@firtoz/socka/test` | `createFakeWebSocket` for unit tests — see **[Testing](./testing.md)** |
|
|
109
|
+
| `@firtoz/socka/react` | `useSocka`, `useSockaSession`, `useSockaPresence`, provider + context |
|
|
119
110
|
| `@firtoz/socka/do` | `SockaDoSession`, `SockaWebSocketDO` |
|
|
120
111
|
| `@firtoz/socka/server` | `SockaWebSocketSession`, `attachSockaWebSocket`, `dispatchSockaInboundMessage`, `broadcastSockaEventToPeers` |
|
|
121
112
|
| `@firtoz/socka/bun` | `createSockaBunWebSocketHandlers` for **`Bun.serve`** |
|
package/docs/server.md
CHANGED
|
@@ -42,7 +42,7 @@ attachSockaWebSocket(
|
|
|
42
42
|
|
|
43
43
|
Optional fourth argument **`{ request }`** is passed to **`createData`** when you define per-connection state. Use **`InferSockaHandlers<typeof myContract, SockaWebSocketSession<typeof myContract, YourData>>`** (or omit the second generic and let inference fill it from your handlers).
|
|
44
44
|
|
|
45
|
-
**Inbound frames without `attachSockaWebSocket`** — use **`dispatchSockaInboundMessage(session, wireFormat, data)`** with the same `data` shape as a DOM **`MessageEvent
|
|
45
|
+
**Inbound frames without `attachSockaWebSocket`** — use **`dispatchSockaInboundMessage(session, wireFormat, data)`** with the same `data` shape as a DOM **`MessageEvent`**. See **[Internals](./internals.md)** for how this fits the wire pipeline.
|
|
46
46
|
|
|
47
47
|
## `createData` and session-only state
|
|
48
48
|
|
|
@@ -51,6 +51,17 @@ Optional fourth argument **`{ request }`** is passed to **`createData`** when yo
|
|
|
51
51
|
| **`createData`** runs in the **`SockaWebSocketSession`** constructor. | You receive **`SockaWebSocketInit`** (e.g. **`{ request }`** from **`attachSockaWebSocket`**). |
|
|
52
52
|
| **Result** is stored in **`session.data`**. | Lives in **process memory** unless you persist it yourself. |
|
|
53
53
|
|
|
54
|
+
## Strict upgrade request
|
|
55
|
+
|
|
56
|
+
**`strictUpgradeRequest`** is an optional field on **`SockaWebSocketSessionConfig`**. It does not change the wire protocol — only how **`createData`** is typed and what happens at runtime if the upgrade **`Request`** is missing.
|
|
57
|
+
|
|
58
|
+
| Mode | Type passed to **`createData`** | When to use it |
|
|
59
|
+
|------|----------------------------------|----------------|
|
|
60
|
+
| **Omitted** (default) | **`SockaWebSocketInit`** — **`init.request` may be `undefined`** | Custom **`attachSockaWebSocket`** call sites, tests, or any adapter that might not attach an HTTP **`Request`**. You handle a missing URL yourself (optional chaining, fallback URL). |
|
|
61
|
+
| **`true`** | **`SockaStrictWebSocketInit`** — **`init.request` is always a `Request`** | Normal **Bun** / **Hono** upgrades where you always have the incoming request and want to read query params, cookies, or path without `init.request?.url ?? "http://_/"` placeholders. TypeScript catches mistakes; if the adapter omits **`request`**, socka throws a clear error at session construction. |
|
|
62
|
+
|
|
63
|
+
**Typical wiring:** Bun stores **`request`** on **`ServerWebSocket` `data`**; use **`sockaBunInitFromWsData`** with **`strictUpgradeRequest: true`**. Hono **`sockaHonoNodeWs`** can omit **`sockaInit`** — the default builds a **`Request`** from the Hono context. See JSDoc on **`SockaWebSocketSessionConfig`**, **`SockaWebSocketInit`**, and **`SockaStrictWebSocketInit`** in **`@firtoz/socka/server`**.
|
|
64
|
+
|
|
54
65
|
Calls **with** an input schema use **`(input, session) => output`**. Calls **without** input use **`(session) => output`** only (no `undefined` first argument). The **`session`** argument is the **`SockaWebSocketSession`** instance: read **`session.data`**, call **`await session.emitPush`**, **`await session.broadcastPush`** (payloads are validated against the contract **`pushes`** schemas before send).
|
|
55
66
|
|
|
56
67
|
**`onAttached`** — optional. Runs after the session is registered in the shared **`sessions`** map (safe to broadcast to peers).
|
|
@@ -111,6 +122,8 @@ Bun.serve({ fetch, websocket });
|
|
|
111
122
|
|
|
112
123
|
**Multi-room** — use the overload **`createSockaBunWebSocketHandlers({ resolveScope })`** so each **`ServerWebSocket`** picks the correct **`sessionMap`** and shared **`config`** (see [Multi-room](./multi-room.md) and the tic-tac-toe example).
|
|
113
124
|
|
|
125
|
+
**Upgrade query params** — `createData` only sees **`init.request`** when the adapter passes it. For Bun, merge the upgrade **`Request`** into **`ServerWebSocket` `data`** so **`?name=`** and other query params are available. Prefer **`sockaBunUpgrade(server, req, { roomId })`** from **`@firtoz/socka/bun`**, which sets **`data: { …extras, request: req }`**. Alternatively call **`server.upgrade(req, { data: { roomId, request: req } })`** yourself. **`sockaBunInitFromWsData`** reads **`data.request`** and builds **`SockaWebSocketInit`** for the session constructor.
|
|
126
|
+
|
|
114
127
|
## `@firtoz/socka/hono` (Node — `@hono/node-ws`)
|
|
115
128
|
|
|
116
129
|
Use **`createNodeWebSocket`** from [**`@hono/node-ws`**](https://github.com/honojs/middleware/tree/main/packages/node-ws) with **`serve`** from **`@hono/node-server`**, then **`upgradeWebSocket(sockaHonoNodeWs({ contract, handlers, handleClose }))`**. **`sockaHonoNodeWs`** returns the callback Hono expects for **`upgradeWebSocket`**.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Client: inject a `WebSocket`
|
|
4
|
+
|
|
5
|
+
In tests, pass **`webSocket`** into **`SockaSession`** or **`SockaWebSocketClient`** with a **fake implementation** of the **`WebSocket`** API (no real network). Import **`createFakeWebSocket`** from **`@firtoz/socka/test`** (same helper the package tests use). Drive **`send`** / **`subscribe`** by delivering **encoded socka frames** (JSON or msgpack per **`wireFormat`**) through the fake’s **`onmessage`** path.
|
|
6
|
+
|
|
7
|
+
**Reconnect:** With an injected socket, reconnect defaults to **off**; set **`reconnect`** explicitly if you need to test backoff behavior.
|
|
8
|
+
|
|
9
|
+
## Server: `SockaWebSocketSession` in isolation
|
|
10
|
+
|
|
11
|
+
Construct **`SockaWebSocketSession`** with a **`SockaWebSocketSessionConfig`** and call **`handleRawMessage`** / **`dispatchSockaInboundMessage`** with encoded frames (see package tests under **`packages/socka/src/server/`**).
|
|
12
|
+
|
|
13
|
+
## Integration-style
|
|
14
|
+
|
|
15
|
+
The monorepo may include **`tests/socka-server-test`** (or similar) for end-to-end handler checks against a real upgrade path — run the workspace test target that applies to your change.
|
|
16
|
+
|
|
17
|
+
## See also
|
|
18
|
+
|
|
19
|
+
- **[Reference](./reference.md)** — client/server configuration tables.
|
|
20
|
+
- **[Internals](./internals.md)** — frame shapes for manual encoding.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Wire format tradeoffs (JSON vs msgpack)
|
|
2
|
+
|
|
3
|
+
Socka uses one **`wireFormat`** per connection for **both** RPCs and pushes (**default `"json"`**).
|
|
4
|
+
|
|
5
|
+
## JSON (`"json"`)
|
|
6
|
+
|
|
7
|
+
- **Pros** — Easy to debug in DevTools; text frames; no extra binary codec in the browser.
|
|
8
|
+
- **Cons** — Larger payloads than msgpack for repetitive objects; UTF-8 string overhead.
|
|
9
|
+
|
|
10
|
+
## MessagePack (`"msgpack"`)
|
|
11
|
+
|
|
12
|
+
Implemented with **msgpackr** (bundled dependency size ~tens of kb — see your bundler report).
|
|
13
|
+
|
|
14
|
+
- **Pros** — Smaller frames for structured data; binary **`ArrayBuffer`** WebSocket frames.
|
|
15
|
+
- **Cons** — Harder to read in network tabs; must set **`wireFormat: "msgpack"`** on **client and server** for that connection.
|
|
16
|
+
|
|
17
|
+
## Binary payloads
|
|
18
|
+
|
|
19
|
+
If you send **raw binary** application data, msgpack mode is a natural fit; keep contract fields as **byte arrays** or **base64** depending on how you want to validate (Standard Schema still applies to decoded values).
|
|
20
|
+
|
|
21
|
+
## Switching
|
|
22
|
+
|
|
23
|
+
Set **`wireFormat: "msgpack"`** on **`SockaSession`** / **`SockaWebSocketClient`** and on **`SockaWebSocketSessionConfig`** / **`SockaDoSessionConfig`** for every session that speaks to that client.
|
|
24
|
+
|
|
25
|
+
**Details:** **[Reference — Wire encoding](./reference.md#wire-encoding-json-and-msgpack)** · **[Internals](./internals.md)**.
|
|
@@ -8,12 +8,59 @@ import {
|
|
|
8
8
|
type InferSockaHandlers,
|
|
9
9
|
} from "../src/core/contract";
|
|
10
10
|
|
|
11
|
+
const messageRow = z.object({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
ts: z.number(),
|
|
14
|
+
userId: z.string(),
|
|
15
|
+
displayName: z.string(),
|
|
16
|
+
text: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const onlineUser = z.object({
|
|
20
|
+
userId: z.string(),
|
|
21
|
+
displayName: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
11
24
|
const contract = defineSocka({
|
|
12
25
|
calls: {
|
|
13
|
-
|
|
26
|
+
sendMessage: {
|
|
14
27
|
input: z.object({ text: z.string() }),
|
|
15
|
-
output: z.object({
|
|
28
|
+
output: z.object({ ok: z.literal(true) }),
|
|
16
29
|
},
|
|
30
|
+
listHistory: {
|
|
31
|
+
input: z.object({ limit: z.number().optional() }),
|
|
32
|
+
output: z.object({ messages: z.array(messageRow) }),
|
|
33
|
+
},
|
|
34
|
+
listPresence: {
|
|
35
|
+
input: z.object({}).optional(),
|
|
36
|
+
output: z.object({
|
|
37
|
+
selfUserId: z.string(),
|
|
38
|
+
users: z.array(onlineUser),
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
clearHistory: {
|
|
42
|
+
input: z.object({}).optional(),
|
|
43
|
+
output: z.object({ ok: z.literal(true) }),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
pushes: {
|
|
47
|
+
userJoined: z.object({ userId: z.string(), displayName: z.string() }),
|
|
48
|
+
userLeft: z.object({
|
|
49
|
+
userId: z.string(),
|
|
50
|
+
displayName: z.string(),
|
|
51
|
+
}),
|
|
52
|
+
roomMessage: z.object({
|
|
53
|
+
id: z.string(),
|
|
54
|
+
ts: z.number(),
|
|
55
|
+
userId: z.string(),
|
|
56
|
+
displayName: z.string(),
|
|
57
|
+
text: z.string(),
|
|
58
|
+
}),
|
|
59
|
+
historyCleared: z.object({
|
|
60
|
+
ts: z.number(),
|
|
61
|
+
clearedByUserId: z.string(),
|
|
62
|
+
clearedByDisplayName: z.string(),
|
|
63
|
+
}),
|
|
17
64
|
},
|
|
18
65
|
});
|
|
19
66
|
|
|
@@ -23,7 +70,13 @@ type Handlers = InferSockaHandlers<typeof contract, unknown>;
|
|
|
23
70
|
void (async () => {
|
|
24
71
|
const _send: Send = {} as Send;
|
|
25
72
|
const _handlers: Handlers = {
|
|
26
|
-
|
|
73
|
+
sendMessage: async () => ({ ok: true }),
|
|
74
|
+
listHistory: async () => ({ messages: [] }),
|
|
75
|
+
listPresence: async () => ({
|
|
76
|
+
selfUserId: "demo-user",
|
|
77
|
+
users: [{ userId: "demo-user", displayName: "Demo" }],
|
|
78
|
+
}),
|
|
79
|
+
clearHistory: async () => ({ ok: true }),
|
|
27
80
|
};
|
|
28
81
|
void _send;
|
|
29
82
|
void _handlers;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/socka",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Standard Schema–first WebSocket RPC for TypeScript — Bun, Hono, Node ws, Cloudflare Workers, Durable Objects",
|
|
6
6
|
"main": "./dist/core/index.js",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"./hono/cloudflare": {
|
|
43
43
|
"types": "./dist/hono/cloudflare-workers.d.ts",
|
|
44
44
|
"import": "./dist/hono/cloudflare-workers.js"
|
|
45
|
+
},
|
|
46
|
+
"./test": {
|
|
47
|
+
"types": "./dist/test/index.d.ts",
|
|
48
|
+
"import": "./dist/test/index.js"
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"files": [
|
|
@@ -92,17 +96,17 @@
|
|
|
92
96
|
"access": "public"
|
|
93
97
|
},
|
|
94
98
|
"dependencies": {
|
|
95
|
-
"@firtoz/maybe-error": "^1.6.
|
|
99
|
+
"@firtoz/maybe-error": "^1.6.1",
|
|
96
100
|
"@standard-schema/spec": "^1.1.0",
|
|
97
101
|
"msgpackr": "^1.11.9"
|
|
98
102
|
},
|
|
99
103
|
"peerDependencies": {
|
|
100
|
-
"@cloudflare/workers-types": "^4.
|
|
104
|
+
"@cloudflare/workers-types": "^4.20260416.2",
|
|
101
105
|
"@firtoz/websocket-do": "^13.0.0",
|
|
102
106
|
"@hono/node-server": "^1.19.2",
|
|
103
107
|
"@hono/node-ws": "^1.3.0",
|
|
104
108
|
"hono": "^4.12.9",
|
|
105
|
-
"react": "^19.2.
|
|
109
|
+
"react": "^19.2.5",
|
|
106
110
|
"ws": "^8.18.0"
|
|
107
111
|
},
|
|
108
112
|
"peerDependenciesMeta": {
|
|
@@ -129,17 +133,17 @@
|
|
|
129
133
|
}
|
|
130
134
|
},
|
|
131
135
|
"devDependencies": {
|
|
132
|
-
"@cloudflare/workers-types": "^4.
|
|
133
|
-
"@happy-dom/global-registrator": "^20.
|
|
134
|
-
"@hono/node-server": "^1.19.
|
|
136
|
+
"@cloudflare/workers-types": "^4.20260416.2",
|
|
137
|
+
"@happy-dom/global-registrator": "^20.9.0",
|
|
138
|
+
"@hono/node-server": "^1.19.14",
|
|
135
139
|
"@hono/node-ws": "^1.3.0",
|
|
136
140
|
"@tanstack/intent": "^0.0.29",
|
|
137
141
|
"@testing-library/react": "^16.3.2",
|
|
138
142
|
"@types/react": "^19.2.14",
|
|
139
143
|
"@types/ws": "^8.18.1",
|
|
140
|
-
"bun-types": "^1.3.
|
|
141
|
-
"happy-dom": "^20.
|
|
142
|
-
"react-dom": "19.2.
|
|
144
|
+
"bun-types": "^1.3.12",
|
|
145
|
+
"happy-dom": "^20.9.0",
|
|
146
|
+
"react-dom": "19.2.5",
|
|
143
147
|
"tsup": "^8.5.1",
|
|
144
148
|
"typescript": "^6.0.2",
|
|
145
149
|
"valibot": "^1.3.1",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/SockaWebSocketSession.ts"],"names":[],"mappings":"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","file":"chunk-45D4T232.js","sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport { exhaustiveGuard } from \"@firtoz/maybe-error\";\nimport type {\n\tInferSockaPushPayload,\n\tSockaContract,\n\tSockaContractConfig,\n} from \"../core/contract\";\nimport {\n\tSockaWireError,\n\tdecodeSockaWire,\n\tencodeServerResponse,\n\tencodeServerError,\n\tencodeServerEvent,\n\ttype SockaClientRequestFrame,\n\ttype SockaWireFrame,\n} from \"../core/envelope\";\nimport {\n\tencodeSockaWire,\n\tparseWirePayload,\n\ttype SockaWireFormat,\n} from \"../core/wire-codec\";\nimport { reportSockaError } from \"../core/socka-report-error\";\nimport { parseStandardSchema } from \"../core/validate\";\nimport { SockaError } from \"../core/socka-error\";\nimport type {\n\tSockaWebSocketInit,\n\tSockaWebSocketSessionConfig,\n} from \"./SockaWebSocketSessionConfig\";\n\n/** Session data with no fields — `createData` may be omitted (defaults to `{}`). */\ntype EmptySockaSessionData = Record<string, never>;\n\nexport type { SockaWebSocketInit, SockaWebSocketSessionConfig };\n\n/** Session that can send a wire-level server event (already validated). */\nexport type SockaEmitCapable = {\n\temitWireEvent(event: string, body: unknown): void;\n};\n\n/**\n * Contract-typed session surface for handlers that push to clients.\n */\nexport interface SockaPushSession<\n\tTContract extends SockaContract<SockaContractConfig>,\n> {\n\temitPush<K extends keyof TContract[\"pushes\"] & string>(\n\t\tname: K,\n\t\tbody: InferSockaPushPayload<TContract, K>,\n\t): Promise<void>;\n\tbroadcastPush<K extends keyof TContract[\"pushes\"] & string>(\n\t\tname: K,\n\t\tbody: InferSockaPushPayload<TContract, K>,\n\t\texcludeSelf?: boolean,\n\t): Promise<void>;\n}\n\n/**\n * Broadcast a socka server event to every session in the map (optionally\n * excluding the caller). Payload must already be contract-validated.\n *\n * Exclusion uses the **WebSocket** identity (`self.websocket`), not the session\n * object reference, so the same `sessions` map can hold `SockaDoSession` while\n * `broadcastPush` runs on `this.socka` (inner {@link SockaWebSocketSession}).\n */\nexport function broadcastSockaEventToPeers(\n\tsessions: Map<WebSocket, SockaEmitCapable>,\n\tself: SockaEmitCapable & { readonly websocket: WebSocket },\n\tevent: string,\n\tbody: unknown,\n\texcludeSelf = false,\n): void {\n\tfor (const [ws, session] of sessions) {\n\t\tif (excludeSelf && ws === self.websocket) continue;\n\t\tsession.emitWireEvent(event, body);\n\t}\n}\n\n/**\n * Runtime-agnostic socka server session: standard {@link WebSocket} wire\n * dispatch without Cloudflare Durable Object APIs.\n */\nexport class SockaWebSocketSession<\n\tTContract extends SockaContract<SockaContractConfig>,\n\tTData = EmptySockaSessionData,\n> implements SockaPushSession<TContract>\n{\n\tprivate readonly config: SockaWebSocketSessionConfig<TContract, TData>;\n\tprivate readonly wireFormat: SockaWireFormat;\n\tprivate _data!: TData;\n\n\tpublic constructor(\n\t\tpublic readonly websocket: WebSocket,\n\t\tprotected readonly sessions: Map<\n\t\t\tWebSocket,\n\t\t\tSockaWebSocketSession<TContract, TData>\n\t\t>,\n\t\tconfig: SockaWebSocketSessionConfig<TContract, TData>,\n\t\tinit?: SockaWebSocketInit,\n\t) {\n\t\tthis.config = config;\n\t\tthis.wireFormat = config.wireFormat ?? \"json\";\n\t\tconst create =\n\t\t\tconfig.createData ?? ((_i: SockaWebSocketInit) => ({}) as TData);\n\t\tthis._data = create(init ?? {});\n\t}\n\n\tpublic get data(): TData {\n\t\treturn this._data;\n\t}\n\n\t/**\n\t * Invokes the user {@link typeof SockaWebSocketSessionConfig.handleClose} callback.\n\t * Server adapters should call this when the WebSocket closes, **before** deleting\n\t * this session from the shared `sessions` map.\n\t */\n\tpublic async invokeHandleClose(): Promise<void> {\n\t\tawait this.config.handleClose(this);\n\t}\n\n\tpublic async handleRawMessage(rawMessage: string): Promise<void> {\n\t\tif (this.wireFormat !== \"json\") {\n\t\t\tawait this.reportValidationError(\n\t\t\t\tnew Error(\"socka: unexpected JSON frame in msgpack mode\"),\n\t\t\t\trawMessage,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tconst deserialize = this.config.deserializeJson ?? JSON.parse;\n\t\tlet parsed: unknown;\n\t\ttry {\n\t\t\tparsed = deserialize(rawMessage);\n\t\t} catch {\n\t\t\tawait this.reportValidationError(\n\t\t\t\tnew Error(\"socka: invalid JSON\"),\n\t\t\t\trawMessage,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tawait this.dispatchAfterParsed(parsed, rawMessage);\n\t}\n\n\tpublic async handleBinaryMessage(buffer: ArrayBuffer): Promise<void> {\n\t\tif (this.wireFormat !== \"msgpack\") {\n\t\t\tawait this.reportValidationError(\n\t\t\t\tnew Error(\"socka: unexpected binary frame in JSON mode\"),\n\t\t\t\tbuffer,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tlet parsed: unknown;\n\t\ttry {\n\t\t\tparsed = parseWirePayload(buffer, \"msgpack\");\n\t\t} catch (err) {\n\t\t\tawait this.reportValidationError(\n\t\t\t\terr instanceof Error ? err : new Error(\"socka: msgpack decode failed\"),\n\t\t\t\tbuffer,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tawait this.dispatchAfterParsed(parsed, buffer);\n\t}\n\n\tprivate async dispatchAfterParsed(\n\t\tparsed: unknown,\n\t\toriginalWire: unknown,\n\t): Promise<void> {\n\t\tlet decoded: ReturnType<typeof decodeSockaWire>;\n\t\ttry {\n\t\t\tdecoded = decodeSockaWire(parsed);\n\t\t} catch (err) {\n\t\t\tif (err instanceof SockaWireError) {\n\t\t\t\tawait this.reportValidationError(err, originalWire);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\n\t\tswitch (decoded.kind) {\n\t\t\tcase \"clientRequest\":\n\t\t\t\tawait this.dispatchClientRequest(decoded.frame, originalWire);\n\t\t\t\treturn;\n\t\t\tcase \"serverResponse\":\n\t\t\tcase \"serverError\":\n\t\t\tcase \"serverEvent\":\n\t\t\t\tawait this.reportValidationError(\n\t\t\t\t\tnew Error(\"socka: unexpected server-originated frame from client\"),\n\t\t\t\t\tparsed,\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\tdefault:\n\t\t\t\texhaustiveGuard(decoded);\n\t\t}\n\t}\n\n\tprivate async dispatchClientRequest(\n\t\tframe: SockaClientRequestFrame,\n\t\t_originalWire: unknown,\n\t): Promise<void> {\n\t\tconst rpcName = frame.rpc;\n\t\tconst procedure = this.config.contract.calls[rpcName];\n\n\t\tif (!procedure) {\n\t\t\tconst errorFrame = encodeServerError(\n\t\t\t\tframe.id,\n\t\t\t\t`Unknown call: ${rpcName}`,\n\t\t\t);\n\t\t\tthis.sendWireFrame(errorFrame);\n\t\t\treturn;\n\t\t}\n\n\t\tlet validatedInput: unknown;\n\t\tif (procedure.input) {\n\t\t\ttry {\n\t\t\t\tvalidatedInput = await parseStandardSchema(procedure.input, frame.body);\n\t\t\t} catch (err) {\n\t\t\t\tconst msg =\n\t\t\t\t\terr instanceof Error ? err.message : \"Input validation failed\";\n\t\t\t\tconst errorFrame = encodeServerError(frame.id, msg);\n\t\t\t\tthis.sendWireFrame(errorFrame);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tlet result: unknown;\n\t\ttry {\n\t\t\tif (procedure.input) {\n\t\t\t\tconst handler = this.config.handlers[rpcName] as (\n\t\t\t\t\tinput: unknown,\n\t\t\t\t\ts: SockaWebSocketSession<TContract, TData>,\n\t\t\t\t) => unknown | Promise<unknown>;\n\t\t\t\tresult = await handler(validatedInput, this);\n\t\t\t} else {\n\t\t\t\tconst handler = this.config.handlers[rpcName] as (\n\t\t\t\t\ts: SockaWebSocketSession<TContract, TData>,\n\t\t\t\t) => unknown | Promise<unknown>;\n\t\t\t\tresult = await handler(this);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tthis.config.onHandlerError?.(err, rpcName, validatedInput, this);\n\t\t\tconst sockaErr =\n\t\t\t\terr instanceof SockaError\n\t\t\t\t\t? err\n\t\t\t\t\t: new SockaError(\n\t\t\t\t\t\t\terr instanceof Error ? err.message : \"Handler failed\",\n\t\t\t\t\t\t);\n\t\t\tconst errorFrame = encodeServerError(frame.id, sockaErr.message);\n\t\t\tthis.sendWireFrame(errorFrame);\n\t\t\treturn;\n\t\t}\n\n\t\tlet validatedOutput: unknown;\n\t\ttry {\n\t\t\tvalidatedOutput = await parseStandardSchema(procedure.output, result);\n\t\t} catch (err) {\n\t\t\tconst msg =\n\t\t\t\terr instanceof Error ? err.message : \"Output validation failed\";\n\t\t\tconst errorFrame = encodeServerError(frame.id, msg);\n\t\t\tthis.sendWireFrame(errorFrame);\n\t\t\treturn;\n\t\t}\n\n\t\tconst responseFrame = encodeServerResponse(\n\t\t\tframe.id,\n\t\t\trpcName,\n\t\t\tvalidatedOutput,\n\t\t);\n\t\tthis.sendWireFrame(responseFrame);\n\t}\n\n\tprivate encodeOutgoing(frame: SockaWireFrame): string | Uint8Array {\n\t\treturn encodeSockaWire(\n\t\t\tframe,\n\t\t\tthis.wireFormat,\n\t\t\tthis.config.serializeJson ?? JSON.stringify,\n\t\t);\n\t}\n\n\tprivate sendWireFrame(frame: SockaWireFrame): void {\n\t\tif (this.websocket.readyState !== WebSocket.OPEN) {\n\t\t\treturn;\n\t\t}\n\t\tconst encoded = this.encodeOutgoing(frame);\n\t\tif (typeof encoded === \"string\") {\n\t\t\tthis.websocket.send(encoded);\n\t\t\treturn;\n\t\t}\n\t\tconst copy = new Uint8Array(encoded.byteLength);\n\t\tcopy.set(encoded);\n\t\tthis.websocket.send(copy.buffer);\n\t}\n\n\t/**\n\t * Send a server event frame (wire). Prefer {@link emitPush} so\n\t * payloads are validated against the contract.\n\t */\n\tpublic emitWireEvent(event: string, body: unknown): void {\n\t\tconst frame = encodeServerEvent(event, body);\n\t\tthis.sendWireFrame(frame);\n\t}\n\n\tpublic async emitPush<K extends keyof TContract[\"pushes\"] & string>(\n\t\tname: K,\n\t\tbody: InferSockaPushPayload<TContract, K>,\n\t): Promise<void> {\n\t\tconst schema = this.config.contract.pushes[name];\n\t\tif (!schema) {\n\t\t\tthrow new Error(`socka: unknown push ${String(name)}`);\n\t\t}\n\t\tconst validated = await parseStandardSchema(\n\t\t\tschema as StandardSchemaV1<unknown, InferSockaPushPayload<TContract, K>>,\n\t\t\tbody,\n\t\t);\n\t\tthis.emitWireEvent(name, validated);\n\t}\n\n\tpublic async broadcastPush<K extends keyof TContract[\"pushes\"] & string>(\n\t\tname: K,\n\t\tbody: InferSockaPushPayload<TContract, K>,\n\t\texcludeSelf = false,\n\t): Promise<void> {\n\t\tconst schema = this.config.contract.pushes[name];\n\t\tif (!schema) {\n\t\t\tthrow new Error(`socka: unknown push ${String(name)}`);\n\t\t}\n\t\tconst validated = await parseStandardSchema(\n\t\t\tschema as StandardSchemaV1<unknown, InferSockaPushPayload<TContract, K>>,\n\t\t\tbody,\n\t\t);\n\t\tbroadcastSockaEventToPeers(\n\t\t\tthis.sessions,\n\t\t\tthis,\n\t\t\tname,\n\t\t\tvalidated,\n\t\t\texcludeSelf,\n\t\t);\n\t}\n\n\tprivate async reportValidationError(\n\t\terror: unknown,\n\t\toriginalMessage: unknown,\n\t): Promise<void> {\n\t\tif (this.config.onValidationError) {\n\t\t\tawait this.config.onValidationError(error, originalMessage);\n\t\t} else {\n\t\t\tconsole.error(\"socka: validation error:\", error, originalMessage);\n\t\t}\n\t}\n}\n\n/**\n * Invoke {@link SockaWebSocketSessionConfig.onAttached} after the session is\n * registered in the shared map.\n */\nexport function runSockaSessionOnAttached<\n\tTContract extends SockaContract<SockaContractConfig>,\n\tTData,\n>(\n\tconfig: SockaWebSocketSessionConfig<TContract, TData>,\n\tsession: SockaWebSocketSession<TContract, TData>,\n): void {\n\tconst cb = config.onAttached;\n\tif (!cb) return;\n\ttry {\n\t\tconst result = cb(session);\n\t\tvoid Promise.resolve(result).catch((error: unknown) => {\n\t\t\treportSockaError(config.reportError, {\n\t\t\t\tkind: \"serverOnAttached\",\n\t\t\t\terror,\n\t\t\t});\n\t\t});\n\t} catch (error) {\n\t\treportSockaError(config.reportError, { kind: \"serverOnAttached\", error });\n\t}\n}\n"]}
|