@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.
@@ -11,8 +11,13 @@
11
11
  *
12
12
  * It costs nothing today and it is the only thing that makes a breaking
13
13
  * protocol change survivable later.
14
+ *
15
+ * Version 2 split the protocol into a required **core** (handshake, messages
16
+ * in both directions, heartbeat) and an optional **rooms extension**
17
+ * (subscriptions, publishing, presence, broadcast), so a server that only
18
+ * needs a data channel implements the core and nothing else.
14
19
  */
15
- export const PROTOCOL_VERSION = 1;
20
+ export const PROTOCOL_VERSION = 2;
16
21
  /** Namespace used when the client does not specify one. */
17
22
  export const DEFAULT_NAMESPACE = "default";
18
23
  /**
@@ -21,36 +26,48 @@ export const DEFAULT_NAMESPACE = "default";
21
26
  * Note these are *protocol* types and have nothing to do with whatever the
22
27
  * application puts inside `payload` — the payload is opaque and is never
23
28
  * inspected nor mutated by this library.
29
+ *
30
+ * Every server implements the **core** frames. The **rooms extension**
31
+ * frames are sent only when the application uses rooms (`subscribe()`,
32
+ * `publish()`, `broadcast()`, presence), so a server that does not support
33
+ * them never sees one.
24
34
  */
25
35
  export const FRAME = {
26
- // client -> server
36
+ // core, client -> server
27
37
  /** Handshake. Always the first frame; carries the auth payload. */
28
38
  AUTH: "auth",
29
- /** Join one or more rooms, optionally with presence. */
30
- SUB: "sub",
31
- /** Leave one or more rooms. */
32
- UNSUB: "unsub",
33
- /** Publish into a room within the connection's own namespace. */
34
- PUB: "pub",
35
- /** Publish into a room across every namespace. Gated server-side. */
36
- BROADCAST: "broadcast",
37
39
  /** Liveness probe. Answered with `pong`. */
38
40
  PING: "ping",
39
- // server -> client
40
- /** Handshake accepted; carries the assigned id, namespace and version. */
41
+ // core, server -> client
42
+ /** Handshake accepted; carries the protocol version (and, with rooms, identity). */
41
43
  HELLO: "hello",
42
- /** Positive acknowledgement of a correlated request. */
44
+ /** Positive acknowledgement of a frame that carried an `id`; may carry a reply. */
43
45
  ACK: "ack",
44
46
  /** Negative acknowledgement; carries a `WSErrorInfo`. */
45
47
  NACK: "nack",
46
- /** A message delivered to a subscribed room. */
47
- MSG: "msg",
48
- /** A membership change in a presence-enabled room. */
49
- PRESENCE: "presence",
50
48
  /** Reply to `ping`. */
51
49
  PONG: "pong",
52
50
  /** Uncorrelated error — not tied to any request id. */
53
51
  ERROR: "error",
52
+ // core, both directions
53
+ /**
54
+ * A message. Client -> server it is addressed to the server itself;
55
+ * server -> client it is either a direct message (core) or a room delivery
56
+ * (rooms extension, recognisable by its `room` field).
57
+ */
58
+ MSG: "msg",
59
+ // rooms extension, client -> server
60
+ /** Join one or more rooms, optionally with presence. */
61
+ SUB: "sub",
62
+ /** Leave one or more rooms. */
63
+ UNSUB: "unsub",
64
+ /** Publish into a room within the connection's own namespace. */
65
+ PUB: "pub",
66
+ /** Publish into a room across every namespace. Gated server-side. */
67
+ BROADCAST: "broadcast",
68
+ // rooms extension, server -> client
69
+ /** A membership change in a presence-enabled room. */
70
+ PRESENCE: "presence",
54
71
  };
55
72
  /**
56
73
  * WebSocket close codes.
@@ -104,10 +121,15 @@ export const ERROR_CODE = {
104
121
  BAD_REQUEST: "bad_request",
105
122
  /** Frame rate cap exceeded. */
106
123
  RATE_LIMITED: "rate_limited",
124
+ /**
125
+ * A frame type this server does not implement — typically a rooms frame
126
+ * sent to a core-only server, or a `msg` to a server with no handler.
127
+ */
128
+ UNSUPPORTED: "unsupported",
107
129
  /** Unexpected server-side failure. */
108
130
  INTERNAL: "internal",
109
131
  };
110
- /** Presence event kinds. */
132
+ /** Presence event kinds (rooms extension). */
111
133
  export const PRESENCE = {
112
134
  /** Full membership snapshot, sent on every (re)subscribe. */
113
135
  SYNC: "sync",
@@ -58,7 +58,12 @@ export declare class WSConnectTimeoutError extends WSError {
58
58
  */
59
59
  constructor(ms: number);
60
60
  }
61
- /** `sendTimeout` elapsed while queued, in flight, or awaiting an ack. */
61
+ /**
62
+ * `sendTimeout` elapsed while queued, in flight, or awaiting an ack.
63
+ *
64
+ * A send that awaits no ack — `send()` without `{ ack: true }` — can only hit
65
+ * this while still queued, i.e. when no connection came up in time.
66
+ */
62
67
  export declare class WSTimeoutError extends WSError {
63
68
  /**
64
69
  * Reports the deadline that was exceeded.
@@ -71,7 +76,21 @@ export declare class WSTimeoutError extends WSError {
71
76
  export declare class WSOutboxDropError extends WSError {
72
77
  constructor();
73
78
  }
74
- /** The server rejected the operation with a `nack`. */
79
+ /**
80
+ * The socket closed while the frame was in flight.
81
+ *
82
+ * Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
83
+ * connected", that one means "the server is not answering".
84
+ */
85
+ export declare class WSConnectionLostError extends WSError {
86
+ constructor();
87
+ }
88
+ /**
89
+ * The server rejected the operation with a `nack`.
90
+ *
91
+ * Also the way a server-side `onMessage` hook rejects a message on purpose:
92
+ * throw one, and its `code` and `message` travel back in the `nack` unchanged.
93
+ */
75
94
  export declare class WSRemoteError extends WSError {
76
95
  /** Machine-readable code from the server — see `ERROR_CODE`. */
77
96
  readonly code: string;
@@ -64,7 +64,12 @@ export class WSConnectTimeoutError extends WSError {
64
64
  super(`Not connected within ${ms}ms (still retrying in background)`);
65
65
  }
66
66
  }
67
- /** `sendTimeout` elapsed while queued, in flight, or awaiting an ack. */
67
+ /**
68
+ * `sendTimeout` elapsed while queued, in flight, or awaiting an ack.
69
+ *
70
+ * A send that awaits no ack — `send()` without `{ ack: true }` — can only hit
71
+ * this while still queued, i.e. when no connection came up in time.
72
+ */
68
73
  export class WSTimeoutError extends WSError {
69
74
  /**
70
75
  * Reports the deadline that was exceeded.
@@ -72,7 +77,7 @@ export class WSTimeoutError extends WSError {
72
77
  * @param ms - the elapsed `sendTimeout`
73
78
  */
74
79
  constructor(ms) {
75
- super(`No acknowledgement within ${ms}ms`);
80
+ super(`Send not completed within ${ms}ms`);
76
81
  }
77
82
  }
78
83
  /** The frame was evicted from a full outbox before it could be sent. */
@@ -81,7 +86,23 @@ export class WSOutboxDropError extends WSError {
81
86
  super("Dropped from outbox (capacity reached while disconnected)");
82
87
  }
83
88
  }
84
- /** The server rejected the operation with a `nack`. */
89
+ /**
90
+ * The socket closed while the frame was in flight.
91
+ *
92
+ * Distinct from {@link WSTimeoutError} on purpose: this one means "retry when
93
+ * connected", that one means "the server is not answering".
94
+ */
95
+ export class WSConnectionLostError extends WSError {
96
+ constructor() {
97
+ super("The connection closed before the server acknowledged the frame; it was not resent");
98
+ }
99
+ }
100
+ /**
101
+ * The server rejected the operation with a `nack`.
102
+ *
103
+ * Also the way a server-side `onMessage` hook rejects a message on purpose:
104
+ * throw one, and its `code` and `message` travel back in the `nack` unchanged.
105
+ */
85
106
  export class WSRemoteError extends WSError {
86
107
  /** Machine-readable code from the server — see `ERROR_CODE`. */
87
108
  code;
@@ -7,6 +7,11 @@
7
7
  * application. Your payload may contain its own `type` field and nothing will
8
8
  * collide.
9
9
  *
10
+ * The frames come in two layers. The **core** — `auth`/`hello`, `msg` in both
11
+ * directions, `ack`/`nack`, `ping`/`pong`, `error` — is all a server must
12
+ * implement. The **rooms extension** — `sub`, `unsub`, `pub`, `broadcast`,
13
+ * `presence`, and the routing fields on a delivered `msg` — is opt-in.
14
+ *
10
15
  * @module
11
16
  */
12
17
  import type { FRAME, PRESENCE } from "./constants.js";
@@ -18,23 +23,45 @@ export interface WSErrorInfo {
18
23
  message: string;
19
24
  }
20
25
  /**
21
- * A message as delivered to application code.
26
+ * A message as delivered to application code — the `message` event fires
27
+ * with one for every inbound `msg` frame.
22
28
  *
23
29
  * A `msg` frame minus its `type` field *is* a `WSMessage` — there is no
24
30
  * translation layer and no divergence between wire names and API names.
31
+ *
32
+ * Only `payload` is guaranteed. A direct message from the server (core) has
33
+ * nothing else; a message delivered through a room (rooms extension) carries
34
+ * every routing field — see {@link WSRoomMessage}. `room` tells them apart.
25
35
  */
26
36
  export interface WSMessage<T = unknown> {
37
+ /** Opaque application payload. */
38
+ payload: T;
39
+ /** Room the message was published to. Absent on a direct message. */
40
+ room?: string;
41
+ /** Namespace the message belongs to. Absent on a direct message. */
42
+ namespace?: string;
43
+ /**
44
+ * Originating client id, `null` when injected server-side. Absent on a
45
+ * direct message, which always comes from the server.
46
+ */
47
+ from?: string | null;
48
+ /** Server-assigned epoch milliseconds. Absent on a direct message. */
49
+ timestamp?: number;
50
+ }
51
+ /**
52
+ * A message delivered through a room (rooms extension). This is what room
53
+ * handlers receive: every routing field is present.
54
+ */
55
+ export interface WSRoomMessage<T = unknown> extends WSMessage<T> {
27
56
  /** Room the message was published to. */
28
57
  room: string;
29
- /** Namespace the message belongs to. */
58
+ /** Namespace the message belongs to — always the receiver's own. */
30
59
  namespace: string;
31
60
  /**
32
61
  * Originating client id, or `null` when the message was injected
33
62
  * server-side (via the service API or the HTTP routes).
34
63
  */
35
64
  from: string | null;
36
- /** Opaque application payload. */
37
- payload: T;
38
65
  /** Server-assigned epoch milliseconds. */
39
66
  timestamp: number;
40
67
  }
@@ -42,7 +69,7 @@ export interface WSMessage<T = unknown> {
42
69
  export type PresenceEventType = (typeof PRESENCE)[keyof typeof PRESENCE];
43
70
  /**
44
71
  * A membership change in a room the client subscribed to with presence
45
- * enabled.
72
+ * enabled (rooms extension).
46
73
  *
47
74
  * `sync` carries the full snapshot and is emitted on every (re)subscribe —
48
75
  * crucially including after a reconnect, when membership may have changed
@@ -62,7 +89,7 @@ export interface WSPresenceEvent {
62
89
  /** Server-assigned epoch milliseconds. */
63
90
  timestamp: number;
64
91
  }
65
- /** A single room subscription request. */
92
+ /** A single room subscription request (rooms extension). */
66
93
  export interface SubRequest {
67
94
  /** Room name to join. */
68
95
  room: string;
@@ -72,12 +99,24 @@ export interface SubRequest {
72
99
  /** Frames sent by the client. */
73
100
  export type ClientFrame = {
74
101
  type: typeof FRAME.AUTH;
75
- id: string;
102
+ /**
103
+ * Not sent since protocol 2 and never acknowledged. Kept optional so a
104
+ * server keeps accepting a protocol-1 client that still sends it.
105
+ */
106
+ id?: string;
76
107
  protocol: number;
77
108
  payload: unknown;
78
109
  /** Preferred client id — the server may override it. */
79
110
  clientId?: string;
111
+ /** Requested namespace — sent only when the application chose one. */
80
112
  namespace?: string;
113
+ } | {
114
+ type: typeof FRAME.MSG;
115
+ /** Present only when the sender awaits an `ack`/`nack`. */
116
+ id?: string;
117
+ payload: unknown;
118
+ } | {
119
+ type: typeof FRAME.PING;
81
120
  } | {
82
121
  type: typeof FRAME.SUB;
83
122
  id: string;
@@ -97,36 +136,39 @@ export type ClientFrame = {
97
136
  id: string;
98
137
  room: string;
99
138
  payload: unknown;
100
- } | {
101
- type: typeof FRAME.PING;
102
139
  };
103
140
  /** Frames sent by the server. */
104
141
  export type ServerFrame = {
105
142
  type: typeof FRAME.HELLO;
106
- clientId: string;
107
- namespace: string;
108
143
  protocol: number;
144
+ /** Assigned client id. Optional in the core; always sent with rooms. */
145
+ clientId?: string;
146
+ /** Assigned namespace. Optional in the core; always sent with rooms. */
147
+ namespace?: string;
109
148
  } | {
110
149
  type: typeof FRAME.ACK;
111
150
  id: string;
151
+ /** Delivery count, on `pub`/`broadcast` acks only. */
112
152
  recipients?: number;
153
+ /** The server's reply, on `msg` acks only. Any JSON value. */
154
+ payload?: unknown;
113
155
  } | {
114
156
  type: typeof FRAME.NACK;
115
157
  id: string;
116
158
  error: WSErrorInfo;
117
159
  } | ({
118
160
  type: typeof FRAME.MSG;
119
- } & WSMessage) | ({
120
- type: typeof FRAME.PRESENCE;
121
- } & WSPresenceEvent) | {
161
+ } & WSMessage) | {
122
162
  type: typeof FRAME.PONG;
123
163
  } | {
124
164
  type: typeof FRAME.ERROR;
125
165
  error: WSErrorInfo;
126
- };
166
+ } | ({
167
+ type: typeof FRAME.PRESENCE;
168
+ } & WSPresenceEvent);
127
169
  /** Any frame, in either direction. */
128
170
  export type WSFrame = ClientFrame | ServerFrame;
129
- /** Result of a successful `publish()` / `broadcast()`. */
171
+ /** Result of a successful `publish()` / `broadcast()` (rooms extension). */
130
172
  export interface WSPublishResult {
131
173
  /**
132
174
  * Sockets the message was handed to **on the receiving server instance**.
@@ -136,6 +178,16 @@ export interface WSPublishResult {
136
178
  */
137
179
  recipients: number;
138
180
  }
181
+ /**
182
+ * What the client proposed in its `auth` frame. Hints, not facts — they are
183
+ * whatever the socket sent, so validate them before honouring them.
184
+ */
185
+ export interface WSRequestedIdentity {
186
+ /** The claimed client id, when the frame carried a usable one. */
187
+ clientId?: string;
188
+ /** The requested namespace, or the default when the frame carried none. */
189
+ namespace: string;
190
+ }
139
191
  /** Outcome of the server's `verify()` hook. */
140
192
  export interface AuthResult {
141
193
  /** Assign a specific client id. Defaults to a generated one. */
@@ -7,6 +7,11 @@
7
7
  * application. Your payload may contain its own `type` field and nothing will
8
8
  * collide.
9
9
  *
10
+ * The frames come in two layers. The **core** — `auth`/`hello`, `msg` in both
11
+ * directions, `ack`/`nack`, `ping`/`pong`, `error` — is all a server must
12
+ * implement. The **rooms extension** — `sub`, `unsub`, `pub`, `broadcast`,
13
+ * `presence`, and the routing fields on a delivered `msg` — is opt-in.
14
+ *
10
15
  * @module
11
16
  */
12
17
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/ws",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -27,7 +27,7 @@
27
27
  "@marianmeres/clog": "^3.21.0",
28
28
  "@marianmeres/pubsub": "^3.0.0",
29
29
  "@marianmeres/ticker": "^1.17.1",
30
- "@marianmeres/uid": "^1.1.0"
30
+ "@marianmeres/uid": "^1.2.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",