@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 +91 -16
- package/API.md +360 -90
- package/README.md +192 -71
- package/dist/client/outbox.d.ts +51 -11
- package/dist/client/outbox.js +56 -18
- package/dist/client/rooms.d.ts +5 -5
- package/dist/client/rooms.js +1 -1
- package/dist/client/ws-client.d.ts +142 -16
- package/dist/client/ws-client.js +170 -37
- package/dist/mod.d.ts +23 -4
- package/dist/mod.js +22 -3
- package/dist/protocol/constants.d.ts +35 -16
- package/dist/protocol/constants.js +40 -18
- package/dist/protocol/errors.d.ts +21 -2
- package/dist/protocol/errors.js +24 -3
- package/dist/protocol/frames.d.ts +68 -16
- package/dist/protocol/frames.js +5 -0
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# @marianmeres/ws — Agent Guide
|
|
2
2
|
|
|
3
|
-
WebSocket client with
|
|
4
|
-
|
|
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 +
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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
|
|
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` —
|
|
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
|
-
| `
|
|
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.
|