@marianmeres/ws 0.3.0 → 0.5.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/AGENTS.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # @marianmeres/ws — Agent Guide
2
2
 
3
- WebSocket client with namespaces, rooms, presence and reconnect, plus a
4
- demino-mountable reference server.
3
+ WebSocket client with reconnect, half-open detection and buffered sends, used
4
+ two ways: plain messages to/from the server (protocol **core**), or namespaces,
5
+ rooms and presence (the optional **rooms extension**). Plus a demino-mountable
6
+ reference server implementing both.
5
7
 
6
8
  ## Quick Reference
7
9
 
@@ -16,6 +18,8 @@ test: "deno task test"
16
18
  | Task | File | Key export |
17
19
  | ---------------------------- | ------------------------- | ------------------------------------- |
18
20
  | Client | `src/client/ws-client.ts` | `createWSClient`, `WSClient` |
21
+ | Messages (core), client | `src/client/ws-client.ts` | `WSClient.send`, `message` event |
22
+ | Messages (core), server | `src/server/service.ts` | `onMessage` option, `WSService.send` |
19
23
  | Reconnect curve | `src/client/backoff.ts` | `backoffDelay` |
20
24
  | Outbox + pending acks | `src/client/outbox.ts` | `Outbox` |
21
25
  | Room refcounting | `src/client/rooms.ts` | `RoomRegistry` |
@@ -33,7 +37,7 @@ src/
33
37
  ├── protocol.ts # ./protocol entry -> protocol/mod.ts
34
38
  ├── protocol/ # dependency-free; imported by BOTH sides
35
39
  │ ├── constants.ts # PROTOCOL_VERSION, FRAME, CLOSE, ERROR_CODE, PRESENCE
36
- │ ├── frames.ts # ClientFrame / ServerFrame unions, WSMessage
40
+ │ ├── frames.ts # ClientFrame / ServerFrame unions, WSMessage, WSRoomMessage
37
41
  │ └── errors.ts # typed errors
38
42
  ├── client/
39
43
  └── server/
@@ -43,11 +47,13 @@ example/ # standalone reference app — `deno task example`
43
47
  ├── server.ts # demino apps: /ws (createWSApp), /api, / (static)
44
48
  ├── history.ts # WSPubSubAdapter decorator -> in-memory backlog
45
49
  ├── shared.ts # the APPLICATION protocol (what goes in `payload`)
50
+ ├── build-styles.ts # generates public/theme.css, copies vui-base.css
46
51
  ├── client/ # @marianmeres/vanilla views + store, bundled by
47
52
  │ # @marianmeres/deno-build to public/dist/bundle.js
48
- └── public/ # index.html + design-tokens CSS (theme.css is
49
- # generated by build-theme.ts and IS committed;
50
- # dist/ is not)
53
+ └── public/ # index.html + CSS. theme.css (design tokens, prefix
54
+ # "vui-") and vui-base.css (@marianmeres/vanilla-ui's
55
+ # base style layer, copied verbatim) are generated by
56
+ # build-styles.ts and ARE committed; dist/ is not
51
57
  ```
52
58
 
53
59
  `src/server.ts` and `src/protocol.ts` exist because the npm build maps subpath
@@ -64,8 +70,34 @@ to compensate — do not reintroduce that.
64
70
  frame shape means changing it there, and bumping `PROTOCOL_VERSION` if the
65
71
  change is breaking.
66
72
 
73
+ **The protocol is two layers; keep them apart.** Core: `auth`/`hello`, `msg`
74
+ (both directions), `ack`/`nack`, `ping`/`pong`, `error`. Rooms extension: `sub`,
75
+ `unsub`, `pub`, `broadcast`, `presence`, and the routing fields (`room`,
76
+ `namespace`, `from`, `timestamp`) on a delivered `msg`. A core-only server
77
+ (PROTOCOL.md §8) must stay sufficient: never make a rooms concept — room,
78
+ namespace, `clientId` — required in the core. That is why `hello` identity is
79
+ optional, `auth` sends `clientId`/`namespace` only when the app set them, and
80
+ `WSMessage` has only `payload` required (`WSRoomMessage` is the room-delivery
81
+ refinement room handlers get). `tests/core.test.ts` runs the client against a
82
+ core-only server (`startCoreServer`) and fails if the core stops being enough.
83
+
84
+ **Every frame with an `id` gets exactly one answer.** A server answers an unknown
85
+ or unimplemented frame type with `unsupported` — `nack` when it has a string
86
+ `id`, `error` when not. Ignoring it would leave the client waiting out
87
+ `sendTimeout`; answering it is what makes the rooms extension safely optional.
88
+
89
+ **A `send()` without `{ ack: true }` completes when written.** It carries no
90
+ wire id; the outbox tracks it under a local key with `awaitAck: false` only so
91
+ it can be buffered and bounded offline, and `Outbox.transmitted()` resolves it.
92
+ It is therefore never "in flight" — `settleInFlight` never sees it. Its returned
93
+ promise is marked handled (`.catch(noop)` on it, then returned): fire-and-forget
94
+ invites not awaiting, and it can still reject (queue timeout, outbox drop,
95
+ terminal close). Removing that makes an ignored send fatal in Deno/Node; a
96
+ resilience test fails if you do.
97
+
67
98
  **Ordering on reconnect is load-bearing.** `#onHello` must re-subscribe _before_
68
- flushing the outbox, and the server must handle `sub`/`unsub` **synchronously**.
99
+ flushing the outbox (which holds `pub`, `broadcast` and `msg` frames alike), and
100
+ the server must handle `sub`/`unsub` **synchronously**.
69
101
  Together these guarantee a buffered publish cannot land in a room the server has
70
102
  not registered yet. There is a test that fails if you break it
71
103
  (`buffered publishes flush *after* re-subscribe`).
@@ -73,9 +105,33 @@ not registered yet. There is a test that fails if you break it
73
105
  **`sub`/`unsub` bypass the outbox.** They are replayed wholesale by the
74
106
  re-subscribe step, so buffering them too would apply them twice.
75
107
 
108
+ **A control-frame promise the client does not await must carry a `.catch()`.**
109
+ `#sendControl` tracks the frame in the outbox, so its promise rejects on dispose,
110
+ on a terminal close and on the send timeout. An unawaited one — the unsubscriber's
111
+ `unsub`, the `#onHello` re-subscribe — is an uncaught rejection that exits a Deno
112
+ or Node process.
113
+
114
+ **The server validates every frame field before it uses it, and wraps the whole
115
+ dispatch.** A malformed but parseable frame is a `nack`/`error` `bad_request`
116
+ and the socket stays open; an unexpected throw is `error` `internal` and a 1011
117
+ close. Both paths, including the async one, end in a `.catch()` — a handler that
118
+ throws must never be able to take the process down. The one exception is the
119
+ application's `onMessage`: its throw is an application failure, not broken
120
+ bookkeeping, so it is answered (`WSRemoteError` → its own `code`/`message`,
121
+ anything else → `internal` with no text) and the socket stays open.
122
+
123
+ **`onMessage` is not serialized.** It is called in arrival order but not awaited
124
+ before the next frame, like every other async path in the service. The Python
125
+ servers in PROTOCOL.md do serialize (a per-connection worker) — both are
126
+ documented as such; do not "fix" either to match the other without updating
127
+ PROTOCOL.md §3.2.
128
+
76
129
  **Every send carries one deadline** spanning queue + flight + ack — not an
77
130
  ack-only timeout. Combining acks with infinite retry otherwise produces promises
78
- that pend forever.
131
+ that pend forever. And a socket close settles in-flight frames at once, rather
132
+ than waiting out a deadline for an answer that can no longer arrive: `sub` and
133
+ `unsub` resolve, everything else rejects with `WSConnectionLostError`. Queued
134
+ frames are untouched — they are still waiting for a connection, not an ack.
79
135
 
80
136
  **The pong deadline is not restarted by later pings.** It measures time since
81
137
  the _oldest_ unanswered ping. Restarting it means any `pingInterval <=
@@ -87,7 +143,13 @@ checks its captured generation. Without it a slow `onclose` from a dead socket
87
143
  cancels the reconnect that replaced it.
88
144
 
89
145
  **Safe defaults are deny.** `allowBroadcast` denies; HTTP injection routes are
90
- not mounted without `httpAuth`. Do not "helpfully" relax either.
146
+ not mounted without `httpAuth`. Do not "helpfully" relax either. The documented
147
+ exception: `clientId` and `namespace` fall back to what the client asked for
148
+ (assigned → requested → generated), so a multi-tenant `verify` must return both.
149
+ Its third argument carries the client's proposals so they can be validated there
150
+ instead of being duplicated into the auth payload. `allowedOrigins` is the other
151
+ exception — opt-in, because a default allow-list would break every non-browser
152
+ deployment; unset means no check at all.
91
153
 
92
154
  **Delivery is at-most-once.** Transmitted-but-unacked sends are never resent.
93
155
  If that changes, the server needs deduplication first.
@@ -122,11 +184,20 @@ run `deno publish` then the npm build.
122
184
 
123
185
  ## Before Making Changes
124
186
 
125
- 1. `deno task test` — 39 tests, all real sockets against a real server
187
+ 1. `deno task test` — 83 tests, mostly real sockets against a real server:
188
+ `unit`, `integration`, `resilience`, `core` (messages: a core-only server,
189
+ `onMessage`, `service.send`), `protocol` (server input hardening, raw
190
+ sockets) and `codec` (custom encode/decode, binary frames)
126
191
  2. `deno lint && deno fmt --check && deno check src/mod.ts src/server.ts src/protocol.ts`
127
192
  3. Touched a public signature? `deno doc --lint src/mod.ts src/server.ts
128
193
  src/protocol.ts` **and** `deno publish --dry-run --allow-dirty`
129
- 4. Touching the wire? Update `src/protocol/` first, then both sides
194
+ 4. Touching the wire? Update `src/protocol/` first, then both sides, then
195
+ PROTOCOL.md — including its two Python servers and the Appendix A script.
196
+ Re-verify them, don't eyeball them: copy each code block out, point the
197
+ script's `jsr:@marianmeres/ws` import at `src/mod.ts`, run each server with
198
+ `WS_AUTH_TIMEOUT=1 WS_IDLE_TIMEOUT=2` (Python 3.10+, `websockets>=13`, in a
199
+ venv) and run the script — core server without `WS_ROOMS`, full one with
200
+ `WS_ROOMS=1`
130
201
  5. Touching reconnect, outbox or heartbeat? Read `tests/resilience.test.ts`
131
202
  first — those tests encode the failure modes the design exists to handle
132
203
  6. `deno task npm:build` if packaging changed (npm ships the **client only**;
@@ -142,18 +213,22 @@ run `deno publish` then the npm build.
142
213
  - No server-side replay, so no at-least-once delivery
143
214
  - `recipients` counts are instance-local; they stay that way under a
144
215
  distributed adapter
216
+ - Direct messages (`WSService.send`) are instance-local; the adapter carries
217
+ room messages only
145
218
  - Only `WSPubSubLocal` exists — Redis/Deno-KV adapters are an unimplemented seam
146
219
  - Presence is scoped to `(room, namespace)`; broadcast crosses namespaces but
147
220
  presence does not
148
221
  - Node/Bun cannot run the server (`Deno.upgradeWebSocket`)
222
+ - Origin checking is opt-in via `allowedOrigins`; unset means no check
149
223
 
150
224
  ## Documentation Index
151
225
 
152
- | Document | Purpose |
153
- | ------------------- | ------------------------------------------------ |
154
- | `README.md` | Human-facing overview and usage |
155
- | `API.md` | Complete API reference — every public export |
156
- | `example/README.md` | The reference app: what it demonstrates, and how |
226
+ | Document | Purpose |
227
+ | ------------------- | ----------------------------------------------------------------------------------- |
228
+ | `README.md` | Human-facing overview and usage |
229
+ | `API.md` | Complete API reference — every public export |
230
+ | `PROTOCOL.md` | Wire protocol spec (core + rooms extension), two Python servers, conformance script |
231
+ | `example/README.md` | The reference app: what it demonstrates, and how |
157
232
 
158
233
  `tmp/spec.md` (design spec and decision log) is referenced in some commits but
159
234
  is **untracked** — `tmp/*` is gitignored, so it does not exist in a fresh clone.