@dojo-ng/websocket 0.1.1 → 0.1.3
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 +78 -75
- package/dist/index.js +1 -1
- package/dist/queue.d.ts +1 -1
- package/dist/queue.js +4 -6
- package/dist/wsdispatcher.js +14 -24
- package/dist/wsocket.js +7 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# @dojo-ng/websocket
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
behavior below is in the header comments of `src/wsocket.ts` and `src/wsdispatcher.ts`.
|
|
3
|
+
A WebSocket client that reconnects, queues messages while offline, and matches each response to
|
|
4
|
+
its request. Events flow through a [`@dojo-ng/pubsub`](https://www.npmjs.com/package/@dojo-ng/pubsub)
|
|
5
|
+
bus in both directions.
|
|
7
6
|
|
|
8
|
-
Part of the [Dojo NG framework](
|
|
9
|
-
|
|
7
|
+
Part of the [Dojo NG framework](https://foss.heptapod.net/dojo-ng/framework) packages: plain ESM
|
|
8
|
+
and no Lit. BSD-3-Clause.
|
|
10
9
|
|
|
11
10
|
## Install
|
|
12
11
|
|
|
@@ -14,97 +13,101 @@ Part of the [Dojo NG framework](../../README.md) monorepo (the non-Lit runtime t
|
|
|
14
13
|
npm install @dojo-ng/websocket
|
|
15
14
|
```
|
|
16
15
|
|
|
17
|
-
##
|
|
16
|
+
## Usage
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
listener teardown before close (the reconnect-storm fix), and `open(force)` semantics.
|
|
18
|
+
```js
|
|
19
|
+
import { createPubSub } from "@dojo-ng/pubsub";
|
|
20
|
+
import { WSDispatcher } from "@dojo-ng/websocket";
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const bus = createPubSub();
|
|
23
|
+
const server = new WSDispatcher({
|
|
24
|
+
url: "wss://example.com/ws",
|
|
25
|
+
pubsub: bus, // share the app's bus; without this you get a private one
|
|
26
|
+
onConnect: () => [{ action: "event", id: 0, data: { event: "hello", params: { user: 7 } } }],
|
|
27
|
+
});
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
ws.addEventListener("open", () => console.log("open"));
|
|
29
|
-
ws.addEventListener("message", (e) => console.log(e.detail.data));
|
|
30
|
-
ws.open();
|
|
29
|
+
server.open();
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// Request and response. Sent now if the socket is open, otherwise queued.
|
|
32
|
+
const user = await server.call({ method: "getUser", args: [7] });
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
because it's a complete, independently useful primitive on its own, not because most callers need
|
|
38
|
-
to reach for it.
|
|
34
|
+
// Events from the server arrive on the bus under their own name.
|
|
35
|
+
bus.subscribe("chat:message", (msg) => console.log(msg), { replay: false });
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
// Publishing to the outbound topic sends an event to the server.
|
|
38
|
+
bus.publish("notify:event", { event: "chat:message", params: { text: "Hi" } });
|
|
39
|
+
```
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
## What WSDispatcher handles
|
|
42
|
+
|
|
43
|
+
- `onConnect` returns messages to send first on every connect and reconnect, before the queue.
|
|
44
|
+
Use it to tell the server who the user is.
|
|
45
|
+
- Messages sent while the connection is down wait in a queue. A message leaves the queue only
|
|
46
|
+
after it is sent, so a failed send does not lose it.
|
|
47
|
+
- After a lost connection, the client waits longer before each new try (Fibonacci backoff). A
|
|
48
|
+
successful connect resets the delay. After `close()`, it does not reconnect.
|
|
49
|
+
- When the server sends `ping` messages, the client expects one at least every 40 seconds. If
|
|
50
|
+
none arrives, it closes and reconnects through the same backoff. A server that never sends a
|
|
51
|
+
ping turns this check off.
|
|
52
|
+
- `call()` has no timeout. If the server never answers, the promise never settles. Use
|
|
53
|
+
`Promise.race` with a timer if you need a limit.
|
|
54
|
+
- Publishing to `outboundTopic` sends an event to the server. The default topic is
|
|
55
|
+
`"notify:event"`.
|
|
56
|
+
- `simulate("disconnect")` and `simulate("connect")` let you test your reconnect handling without
|
|
57
|
+
a real outage.
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
url: "wss://example.test/ws",
|
|
47
|
-
// Sent on every (re)connect, before the offline queue flushes. The app owns what identity
|
|
48
|
-
// means here — this package has no opinion beyond "send these frames first."
|
|
49
|
-
onConnect: () => [{ action: "event", id: 0, data: { event: "identify", params: { sessionId } } }],
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
dispatcher.open();
|
|
59
|
+
## The wire format
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
Every message is one JSON object with `action`, `id`, and `data`. A `call` response sends back the
|
|
62
|
+
same `id`, and its `data` becomes the value of the promise. An `event` carries the topic name and
|
|
63
|
+
the payload.
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
dispatcher.pubsub.subscribe("chat:message", (payload) => render(payload));
|
|
62
|
-
dispatcher.pubsub.publish("notify:event", { event: "chat:message", params: { text: "hi" } });
|
|
65
|
+
```json
|
|
66
|
+
{ "action": "call", "id": 12, "data": { "method": "getUser", "args": [7] } }
|
|
67
|
+
{ "action": "event", "id": 0, "data": { "event": "chat:message", "params": { "text": "Hi" } } }
|
|
68
|
+
{ "action": "ping", "id": 0, "data": null }
|
|
63
69
|
```
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
watchdog that closes and reopens — through the same backoff path, not a bypass — if the server
|
|
70
|
-
goes quiet for 40s. `dispatcher.simulate("disconnect" | "connect")` is a deliberate testing
|
|
71
|
-
affordance; the playground (below) uses it.
|
|
71
|
+
The full rules are in
|
|
72
|
+
[the JSON Schema](https://foss.heptapod.net/dojo-ng/framework/-/blob/branch/default/packages/websocket/schema/envelope.schema.json).
|
|
73
|
+
The object form was chosen after measuring: with compression, a typical session is only about
|
|
74
|
+
3% larger than with a positional array.
|
|
72
75
|
|
|
73
|
-
##
|
|
76
|
+
## WSocket, the thinner layer
|
|
74
77
|
|
|
75
|
-
`
|
|
76
|
-
`WSDispatcher` sends and receives, independently of the TypeScript `Envelope` type that binds it
|
|
77
|
-
— the schema is the source of truth, the type is one binding of it. The object form was chosen
|
|
78
|
-
over a positional-array form (`[action, id, data]`) by measurement, not argument: compressed (raw
|
|
79
|
-
DEFLATE, context carried across a session — the realistic case), the object form is only 3.4%
|
|
80
|
-
larger than positional over a representative session. `spike/measure-envelope.mjs` reproduces
|
|
81
|
-
the measurement.
|
|
78
|
+
Most apps want `WSDispatcher`. If you only want a thin wrapper, the package also exports `WSocket`.
|
|
82
79
|
|
|
83
|
-
|
|
80
|
+
- It smooths over differences between WebSocket implementations.
|
|
81
|
+
- `send()` returns a `SendState`: `OKAY`, `NOT_READY`, `BUFFERING`, or `SEND_ERROR`. It reports
|
|
82
|
+
`BUFFERING` instead of sending while the browser still has unsent data.
|
|
83
|
+
- It removes its listeners before closing, so a reconnect does not stack old handlers.
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
a real client can't perform on itself (the server dropping the connection with no handshake;
|
|
88
|
-
the server going silent without closing the socket), because `simulate("disconnect")` alone
|
|
89
|
-
proves the opposite case: an explicit close schedules no reconnect at all.
|
|
85
|
+
```js
|
|
86
|
+
import { WSocket, SendState } from "@dojo-ng/websocket";
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
const ws = new WSocket("wss://example.com");
|
|
89
|
+
ws.addEventListener("open", () => console.log("open"));
|
|
90
|
+
ws.addEventListener("message", (e) => console.log(e.detail.data));
|
|
91
|
+
ws.open();
|
|
92
|
+
|
|
93
|
+
const state = ws.send("hello"); // SendState.OKAY, NOT_READY, BUFFERING, or SEND_ERROR
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
## Learn more
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
The [framework runtime guide](https://dojo-ng.com/docs/framework/) shows the client together with
|
|
99
|
+
[`@dojo-ng/framework`](https://www.npmjs.com/package/@dojo-ng/framework) and
|
|
100
|
+
[`@dojo-ng/pubsub`](https://www.npmjs.com/package/@dojo-ng/pubsub).
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|---|---|
|
|
102
|
-
| `npm test` | `node --test` over `test/*.test.mjs` — `Queue`, `WSocket`, `WSDispatcher`, and the envelope schema, against fake sockets and (for the reconnect/heartbeat cases) `node:test`'s built-in fake timers |
|
|
103
|
-
| `npm run typecheck` | `tsc --noEmit` |
|
|
102
|
+
## Development
|
|
104
103
|
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
- `npm test` runs the unit tests with `node --test`, against a fake socket and fake timers. No
|
|
105
|
+
test uses the network.
|
|
106
|
+
- `npm run typecheck` runs `tsc --noEmit`.
|
|
107
|
+
- `npm run playground` starts a local echo server and a demo page at
|
|
108
|
+
`http://localhost:8901/packages/websocket/playground/index.html`. Its buttons show the offline
|
|
109
|
+
queue, a real reconnect with backoff, and the heartbeat check.
|
|
107
110
|
|
|
108
111
|
## License
|
|
109
112
|
|
|
110
|
-
BSD-3-Clause. See [LICENSE](
|
|
113
|
+
BSD-3-Clause. See [LICENSE](https://foss.heptapod.net/dojo-ng/framework/-/blob/branch/default/LICENSE).
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Public entry point for @dojo-ng/websocket. `queue.ts`'s Queue is deliberately not re-exported
|
|
2
|
-
// from this file
|
|
2
|
+
// from this file — everything else built here is meant to be used directly.
|
|
3
3
|
export { WSocket, ReadyState, SendState } from "./wsocket.js";
|
|
4
4
|
export { WSDispatcher } from "./wsdispatcher.js";
|
package/dist/queue.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare class Queue<T> {
|
|
|
5
5
|
private length;
|
|
6
6
|
/** Inserts the element at the end of the queue. Returns false if `elem` is undefined. */
|
|
7
7
|
enqueue(elem: T): boolean;
|
|
8
|
-
/** Same as `enqueue
|
|
8
|
+
/** Same as `enqueue`. */
|
|
9
9
|
add(elem: T): boolean;
|
|
10
10
|
/** Inserts the element at the front of the queue. Returns false if `elem` is undefined. */
|
|
11
11
|
pushfront(elem: T): boolean;
|
package/dist/queue.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// Internal module
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// of app3's collections module (lodash-es included). Never re-export this module from index.ts:
|
|
5
|
-
// that is the whole of the T2 decision (framework-monorepo-spec.md B3, websocket-spec.md T2).
|
|
1
|
+
// Internal module: a self-contained singly linked list, with only what the dispatcher's send
|
|
2
|
+
// queue needs and no dependencies. Never re-export this module from index.ts: it is an
|
|
3
|
+
// implementation detail, not public API.
|
|
6
4
|
/** A FIFO queue backed by a singly linked list: enqueue, dequeue, and pushfront are all O(1). */
|
|
7
5
|
export class Queue {
|
|
8
6
|
constructor() {
|
|
@@ -14,7 +12,7 @@ export class Queue {
|
|
|
14
12
|
enqueue(elem) {
|
|
15
13
|
return this.add(elem);
|
|
16
14
|
}
|
|
17
|
-
/** Same as `enqueue
|
|
15
|
+
/** Same as `enqueue`. */
|
|
18
16
|
add(elem) {
|
|
19
17
|
if (elem === undefined)
|
|
20
18
|
return false;
|
package/dist/wsdispatcher.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// that carries the value — everything in the proposal's carry list lands here except the
|
|
3
|
-
// transport/envelope registries (Phase 2, deliberately unwritten until Phase 1 meets a real
|
|
4
|
-
// backend). Carried behaviors, each with its own reason:
|
|
1
|
+
// WebSocket message dispatch on top of `WSocket`. Behaviors, each with its own reason:
|
|
5
2
|
//
|
|
6
3
|
// - Request/response correlation (`call`): `#nextId++` into a callback map, resolved by id on the
|
|
7
|
-
// matching inbound frame.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// matching inbound frame. There is no timeout/reject path, so a pending call can wait forever
|
|
5
|
+
// on a connection that never responds. This is deliberate and documented; a caller that needs a
|
|
6
|
+
// limit races the promise against a timer.
|
|
10
7
|
// - Offline send queue (`#drain`): peeks and sends, dequeuing only on a confirmed OKAY send, so a
|
|
11
8
|
// failed send never drops the message. BUFFERING retries at 50ms; a confirmed send immediately
|
|
12
9
|
// tries the next one.
|
|
@@ -17,30 +14,23 @@
|
|
|
17
14
|
// not a bypass. `TIMEOUT_STEP`'s reset-only-on-success rule means a heartbeat failure right after
|
|
18
15
|
// a fresh open still reopens promptly, but a string of them backs off like any other flapping
|
|
19
16
|
// connection.
|
|
20
|
-
// - Identity handshake (`onConnect`):
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// this package doesn't know what "who this is" means for any given backend.
|
|
17
|
+
// - Identity handshake (`onConnect`): a caller-supplied hook returning frames to send first on
|
|
18
|
+
// every (re)connect, before the queue flush. The app owns what identity means; this package
|
|
19
|
+
// does not know what "who this is" means for any given backend.
|
|
24
20
|
// - Events over pub/sub, not a bespoke API: an inbound "event" frame publishes under its own name;
|
|
25
21
|
// publishing to `outboundTopic` sends an "event" frame. No new subscription concept invented —
|
|
26
22
|
// pub/sub is the whole app-facing surface, in both directions.
|
|
27
23
|
// - `simulate("disconnect" | "connect")`: a deliberate testing affordance. Reconnect logic is
|
|
28
24
|
// otherwise nearly untestable against a real backend.
|
|
29
25
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// `createDefer` (→ `Promise.withResolvers()`), the `WeakMap` private-props pattern (→ `#private`
|
|
35
|
-
// fields), `Evented`/`ws.on()` (→ `WSocket`'s `EventTarget`, from T3), lodash-es `defer`/`delay` (→
|
|
36
|
-
// native `setTimeout`), all `window.hcstatic.*` coupling, and the MANHOLE/CDPPUSH actions
|
|
37
|
-
// (Holmes-specific). JSON-RPC's header/response-object wrapping is also dropped — that's `core/rpc`
|
|
38
|
-
// territory, out of scope for this package per the proposal's layering.
|
|
26
|
+
// There is no lock around the send queue. `Queue`'s operations are synchronous, so in
|
|
27
|
+
// single-threaded JS every method here runs to completion before the next scheduled one starts,
|
|
28
|
+
// and there is no reentrancy hazard to guard against. Request/response wrapping such as JSON-RPC
|
|
29
|
+
// headers is out of scope; that belongs in a layer above this package.
|
|
39
30
|
//
|
|
40
|
-
// The envelope shape
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// the tests here operate on.
|
|
31
|
+
// The envelope shape (`{ action, id, data }`) is specified in schema/envelope.schema.json. It was
|
|
32
|
+
// chosen over a positional array by measuring compressed bytes on the wire
|
|
33
|
+
// (spike/measure-envelope.mjs).
|
|
44
34
|
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
45
35
|
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
46
36
|
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
package/dist/wsocket.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
// `
|
|
3
|
-
// `MozWebSocket` fallback dropped entirely (dead for over a decade). `WebSocket` is read as the
|
|
1
|
+
// A thin wrapper over the browser `WebSocket`, exposed as an `EventTarget`. There is no
|
|
2
|
+
// `MozWebSocket` fallback (dead for over a decade). `WebSocket` is read as the
|
|
4
3
|
// global, at call time rather than a module-scope import — that's also what lets a test substitute
|
|
5
4
|
// a fake one via `globalThis.WebSocket` with no constructor injection seam to design around.
|
|
6
5
|
//
|
|
7
6
|
// Four behaviors carried forward on purpose. Each is a real bug someone would otherwise
|
|
8
7
|
// reintroduce by "simplifying" this file once it looks like it's just wrapping `new WebSocket()`:
|
|
9
8
|
//
|
|
10
|
-
// - Out-of-sync detection (`send`, below):
|
|
11
|
-
//
|
|
12
|
-
// already
|
|
13
|
-
//
|
|
9
|
+
// - Out-of-sync detection (`send`, below): if `isOpen()` re-checked `socket.readyState` on every
|
|
10
|
+
// call, the out-of-sync check in `send()` would be unreachable dead code — `isOpen` would
|
|
11
|
+
// already return false for the same reason before `send()` got there. So `isOpen()` reflects
|
|
12
|
+
// only OUR tracked state (set on `onopen`, cleared by `#handleClose`),
|
|
14
13
|
// so `send()`'s live re-check of `socket.readyState` is the one thing that actually catches a
|
|
15
14
|
// socket whose `readyState` moved to CLOSING/CLOSED before its own `onclose`/`onerror` fired —
|
|
16
15
|
// which is legal per the WebSocket spec and is exactly the state this check exists to catch.
|
|
@@ -23,7 +22,7 @@
|
|
|
23
22
|
// - `open(force)` semantics and handler wiring inside `onopen`: with no existing socket, or with
|
|
24
23
|
// `force: true`, any prior socket is torn down and a fresh one opened. `message`/`error`/`close`
|
|
25
24
|
// handlers are wired only once `onopen` fires, which is also why a pre-open error goes straight
|
|
26
|
-
// to `#handleClose` with no `error` event —
|
|
25
|
+
// to `#handleClose` with no `error` event — an asymmetry kept on purpose, because a caller
|
|
27
26
|
// distinguishing "never connected" from "connected, then errored" depends on it.
|
|
28
27
|
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
29
28
|
if (kind === "m") throw new TypeError("Private method is not writable");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dojo-ng/websocket",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Dojo NG
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Dojo NG WebSocket client that reconnects, queues messages while offline, and matches responses to requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"license": "BSD-3-Clause",
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@dojo-ng/pubsub": "^0.1.
|
|
15
|
+
"@dojo-ng/pubsub": "^0.1.3"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"ajv": "^8.20.0",
|