@atproto/ws-client 0.0.3 → 0.1.0-next.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/CHANGELOG.md +25 -0
- package/LICENSE.txt +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -61
- package/dist/index.js.map +1 -1
- package/{jest.config.js → jest.config.cjs} +8 -2
- package/package.json +13 -8
- package/src/index.ts +26 -1
- package/tests/keepalive.test.ts +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.tests.tsbuildinfo +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @atproto/ws-client
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#4929](https://github.com/bluesky-social/atproto/pull/4929) [`bb7491c`](https://github.com/bluesky-social/atproto/commit/bb7491c29e06181e1d2f8cf6eb454f9bb8ab961b) Thanks [@devinivy](https://github.com/devinivy)! - **BREAKING:** Drop support for Node.js 18 and 20. Node.js 22 is now the minimum supported version. Docker images now use Node.js 24.
|
|
8
|
+
|
|
9
|
+
- [#4943](https://github.com/bluesky-social/atproto/pull/4943) [`07ae5d4`](https://github.com/bluesky-social/atproto/commit/07ae5d4452df51e045e0239da7a04cf0bc154028) Thanks [@devinivy](https://github.com/devinivy)! - **BREAKING:** Convert to pure ESM. All packages now ship `"type": "module"` with ES module output and Node16 module resolution.
|
|
10
|
+
|
|
11
|
+
Node.js 22's `require()` compatibility layer can still load these packages in CommonJS code.
|
|
12
|
+
|
|
13
|
+
- [#4930](https://github.com/bluesky-social/atproto/pull/4930) [`042df15`](https://github.com/bluesky-social/atproto/commit/042df15087c0e62cd1e715fcbf58852fab875af9) Thanks [@devinivy](https://github.com/devinivy)! - Build with TypeScript 6.0. Emitted `.d.ts` files now use TypeScript 6's stricter `Uint8Array<ArrayBuffer>` typing in places where Web/Node APIs require buffer-backed (not shared-memory) byte arrays. Consumers compiling against these types on older TypeScript should see no runtime impact, but may need to widen or cast in spots that previously relied on `Uint8Array` defaulting to `<ArrayBufferLike>`.
|
|
14
|
+
|
|
15
|
+
Internal: tsconfig `moduleResolution: "node"` is silenced via `ignoreDeprecations: "6.0"` for now; the proper migration to `node16`/`bundler` resolution is deferred.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [[`bb7491c`](https://github.com/bluesky-social/atproto/commit/bb7491c29e06181e1d2f8cf6eb454f9bb8ab961b), [`07ae5d4`](https://github.com/bluesky-social/atproto/commit/07ae5d4452df51e045e0239da7a04cf0bc154028), [`042df15`](https://github.com/bluesky-social/atproto/commit/042df15087c0e62cd1e715fcbf58852fab875af9)]:
|
|
20
|
+
- @atproto/common@0.6.0-next.0
|
|
21
|
+
|
|
22
|
+
## 0.0.4
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- [#4290](https://github.com/bluesky-social/atproto/pull/4290) [`b4a76ba`](https://github.com/bluesky-social/atproto/commit/b4a76bae7bef1189302488d43ce49a03fd61f957) Thanks [@dholms](https://github.com/dholms)! - Support sending data on websocket as well as an onReconnect callback
|
|
27
|
+
|
|
3
28
|
## 0.0.3
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
package/LICENSE.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Dual MIT/Apache-2.0 License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2022-
|
|
3
|
+
Copyright (c) 2022-2026 Bluesky Social PBC, and Contributors
|
|
4
4
|
|
|
5
5
|
Except as otherwise noted in individual files, this software is licensed under the MIT license (<http://opensource.org/licenses/MIT>), or the Apache License, Version 2.0 (<http://www.apache.org/licenses/LICENSE-2.0>).
|
|
6
6
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { ClientOptions
|
|
1
|
+
import type { ClientOptions } from 'ws';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
2
3
|
export declare class WebSocketKeepAlive {
|
|
3
4
|
opts: ClientOptions & {
|
|
4
5
|
getUrl: () => Promise<string>;
|
|
5
6
|
maxReconnectSeconds?: number;
|
|
6
7
|
signal?: AbortSignal;
|
|
7
8
|
heartbeatIntervalMs?: number;
|
|
9
|
+
onReconnect?: () => void;
|
|
8
10
|
onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void;
|
|
9
11
|
};
|
|
10
12
|
ws: WebSocket | null;
|
|
@@ -15,9 +17,12 @@ export declare class WebSocketKeepAlive {
|
|
|
15
17
|
maxReconnectSeconds?: number;
|
|
16
18
|
signal?: AbortSignal;
|
|
17
19
|
heartbeatIntervalMs?: number;
|
|
20
|
+
onReconnect?: () => void;
|
|
18
21
|
onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void;
|
|
19
22
|
});
|
|
20
23
|
[Symbol.asyncIterator](): AsyncGenerator<Uint8Array>;
|
|
24
|
+
send(data: string | Buffer): Promise<void>;
|
|
25
|
+
isConnected(): boolean;
|
|
21
26
|
startHeartbeat(ws: WebSocket): void;
|
|
22
27
|
}
|
|
23
28
|
export default WebSocketKeepAlive;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,SAAS,EAAyB,MAAM,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,IAAI,CAAA;AACvC,OAAO,EAAE,SAAS,EAAyB,MAAM,IAAI,CAAA;AAGrD,qBAAa,kBAAkB;IAMpB,IAAI,EAAE,aAAa,GAAG;QAC3B,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;QAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAA;QAC5B,MAAM,CAAC,EAAE,WAAW,CAAA;QACpB,mBAAmB,CAAC,EAAE,MAAM,CAAA;QAC5B,WAAW,CAAC,EAAE,MAAM,IAAI,CAAA;QACxB,gBAAgB,CAAC,EAAE,CACjB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,MAAM,EACT,YAAY,EAAE,OAAO,KAClB,IAAI,CAAA;KACV;IAhBI,EAAE,EAAE,SAAS,GAAG,IAAI,CAAO;IAC3B,YAAY,UAAO;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAO;gBAG9B,IAAI,EAAE,aAAa,GAAG;QAC3B,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;QAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAA;QAC5B,MAAM,CAAC,EAAE,WAAW,CAAA;QACpB,mBAAmB,CAAC,EAAE,MAAM,CAAA;QAC5B,WAAW,CAAC,EAAE,MAAM,IAAI,CAAA;QACxB,gBAAgB,CAAC,EAAE,CACjB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,MAAM,EACT,YAAY,EAAE,OAAO,KAClB,IAAI,CAAA;KACV;IAGI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,cAAc,CAAC,UAAU,CAAC;IAiE3D,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB1C,WAAW,IAAI,OAAO;IAItB,cAAc,CAAC,EAAE,EAAE,SAAS;CA4B7B;AAED,eAAe,kBAAkB,CAAA;AAMjC,qBAAa,eAAgB,SAAQ,KAAK;IAE/B,MAAM,EAAE,SAAS;IACjB,QAAQ,CAAC,EAAE,MAAM;gBADjB,MAAM,GAAE,SAA4B,EACpC,QAAQ,CAAC,EAAE,MAAM,YAAA;CAI3B;AAGD,oBAAY,SAAS;IACnB,MAAM,OAAO;IACb,QAAQ,OAAO;IACf,MAAM,OAAO;CACd"}
|
package/dist/index.js
CHANGED
|
@@ -1,34 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const ws_1 = require("ws");
|
|
5
|
-
const common_1 = require("@atproto/common");
|
|
6
|
-
class WebSocketKeepAlive {
|
|
1
|
+
import { WebSocket, createWebSocketStream } from 'ws';
|
|
2
|
+
import { SECOND, isErrnoException, wait } from '@atproto/common';
|
|
3
|
+
export class WebSocketKeepAlive {
|
|
7
4
|
constructor(opts) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
value: opts
|
|
13
|
-
});
|
|
14
|
-
Object.defineProperty(this, "ws", {
|
|
15
|
-
enumerable: true,
|
|
16
|
-
configurable: true,
|
|
17
|
-
writable: true,
|
|
18
|
-
value: null
|
|
19
|
-
});
|
|
20
|
-
Object.defineProperty(this, "initialSetup", {
|
|
21
|
-
enumerable: true,
|
|
22
|
-
configurable: true,
|
|
23
|
-
writable: true,
|
|
24
|
-
value: true
|
|
25
|
-
});
|
|
26
|
-
Object.defineProperty(this, "reconnects", {
|
|
27
|
-
enumerable: true,
|
|
28
|
-
configurable: true,
|
|
29
|
-
writable: true,
|
|
30
|
-
value: null
|
|
31
|
-
});
|
|
5
|
+
this.opts = opts;
|
|
6
|
+
this.ws = null;
|
|
7
|
+
this.initialSetup = true;
|
|
8
|
+
this.reconnects = null;
|
|
32
9
|
}
|
|
33
10
|
async *[Symbol.asyncIterator]() {
|
|
34
11
|
const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64);
|
|
@@ -37,15 +14,18 @@ class WebSocketKeepAlive {
|
|
|
37
14
|
const duration = this.initialSetup
|
|
38
15
|
? Math.min(1000, maxReconnectMs)
|
|
39
16
|
: backoffMs(this.reconnects++, maxReconnectMs);
|
|
40
|
-
await
|
|
17
|
+
await wait(duration);
|
|
41
18
|
}
|
|
42
19
|
const url = await this.opts.getUrl();
|
|
43
|
-
this.ws = new
|
|
20
|
+
this.ws = new WebSocket(url, this.opts);
|
|
44
21
|
const ac = new AbortController();
|
|
45
22
|
if (this.opts.signal) {
|
|
46
23
|
forwardSignal(this.opts.signal, ac);
|
|
47
24
|
}
|
|
48
25
|
this.ws.once('open', () => {
|
|
26
|
+
if (!this.initialSetup && this.opts.onReconnect) {
|
|
27
|
+
this.opts.onReconnect();
|
|
28
|
+
}
|
|
49
29
|
this.initialSetup = false;
|
|
50
30
|
this.reconnects = 0;
|
|
51
31
|
if (this.ws) {
|
|
@@ -59,7 +39,7 @@ class WebSocketKeepAlive {
|
|
|
59
39
|
}
|
|
60
40
|
});
|
|
61
41
|
try {
|
|
62
|
-
const wsStream =
|
|
42
|
+
const wsStream = createWebSocketStream(this.ws, {
|
|
63
43
|
signal: ac.signal,
|
|
64
44
|
readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together
|
|
65
45
|
});
|
|
@@ -68,7 +48,7 @@ class WebSocketKeepAlive {
|
|
|
68
48
|
}
|
|
69
49
|
}
|
|
70
50
|
catch (_err) {
|
|
71
|
-
const err =
|
|
51
|
+
const err = isErrnoException(_err) && _err.code === 'ABORT_ERR'
|
|
72
52
|
? _err.cause
|
|
73
53
|
: _err;
|
|
74
54
|
if (err instanceof DisconnectError) {
|
|
@@ -78,7 +58,7 @@ class WebSocketKeepAlive {
|
|
|
78
58
|
}
|
|
79
59
|
this.ws?.close(); // No-ops if already closed or closing
|
|
80
60
|
if (isReconnectable(err)) {
|
|
81
|
-
this.reconnects
|
|
61
|
+
this.reconnects ??= 0; // Never reconnect with a null
|
|
82
62
|
this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup);
|
|
83
63
|
continue;
|
|
84
64
|
}
|
|
@@ -89,6 +69,25 @@ class WebSocketKeepAlive {
|
|
|
89
69
|
break; // Other side cleanly ended stream and disconnected
|
|
90
70
|
}
|
|
91
71
|
}
|
|
72
|
+
send(data) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
if (!this.ws || this.ws.readyState !== 1 /* OPEN */) {
|
|
75
|
+
reject(new Error('WebSocket is not connected'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.ws.send(data, (err) => {
|
|
79
|
+
if (err) {
|
|
80
|
+
reject(err);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
resolve();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
isConnected() {
|
|
89
|
+
return this.ws !== null && this.ws.readyState === 1;
|
|
90
|
+
}
|
|
92
91
|
startHeartbeat(ws) {
|
|
93
92
|
let isAlive = true;
|
|
94
93
|
let heartbeatInterval = null;
|
|
@@ -100,7 +99,7 @@ class WebSocketKeepAlive {
|
|
|
100
99
|
ws.ping();
|
|
101
100
|
};
|
|
102
101
|
checkAlive();
|
|
103
|
-
heartbeatInterval = setInterval(checkAlive, this.opts.heartbeatIntervalMs ?? 10 *
|
|
102
|
+
heartbeatInterval = setInterval(checkAlive, this.opts.heartbeatIntervalMs ?? 10 * SECOND);
|
|
104
103
|
ws.on('pong', () => {
|
|
105
104
|
isAlive = true;
|
|
106
105
|
});
|
|
@@ -112,50 +111,33 @@ class WebSocketKeepAlive {
|
|
|
112
111
|
});
|
|
113
112
|
}
|
|
114
113
|
}
|
|
115
|
-
|
|
116
|
-
exports.default = WebSocketKeepAlive;
|
|
114
|
+
export default WebSocketKeepAlive;
|
|
117
115
|
class AbnormalCloseError extends Error {
|
|
118
116
|
constructor() {
|
|
119
117
|
super(...arguments);
|
|
120
|
-
|
|
121
|
-
enumerable: true,
|
|
122
|
-
configurable: true,
|
|
123
|
-
writable: true,
|
|
124
|
-
value: 'EWSABNORMALCLOSE'
|
|
125
|
-
});
|
|
118
|
+
this.code = 'EWSABNORMALCLOSE';
|
|
126
119
|
}
|
|
127
120
|
}
|
|
128
|
-
class DisconnectError extends Error {
|
|
121
|
+
export class DisconnectError extends Error {
|
|
129
122
|
constructor(wsCode = CloseCode.Policy, xrpcCode) {
|
|
130
123
|
super();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
configurable: true,
|
|
134
|
-
writable: true,
|
|
135
|
-
value: wsCode
|
|
136
|
-
});
|
|
137
|
-
Object.defineProperty(this, "xrpcCode", {
|
|
138
|
-
enumerable: true,
|
|
139
|
-
configurable: true,
|
|
140
|
-
writable: true,
|
|
141
|
-
value: xrpcCode
|
|
142
|
-
});
|
|
124
|
+
this.wsCode = wsCode;
|
|
125
|
+
this.xrpcCode = xrpcCode;
|
|
143
126
|
}
|
|
144
127
|
}
|
|
145
|
-
exports.DisconnectError = DisconnectError;
|
|
146
128
|
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
147
|
-
var CloseCode;
|
|
129
|
+
export var CloseCode;
|
|
148
130
|
(function (CloseCode) {
|
|
149
131
|
CloseCode[CloseCode["Normal"] = 1000] = "Normal";
|
|
150
132
|
CloseCode[CloseCode["Abnormal"] = 1006] = "Abnormal";
|
|
151
133
|
CloseCode[CloseCode["Policy"] = 1008] = "Policy";
|
|
152
|
-
})(CloseCode || (
|
|
134
|
+
})(CloseCode || (CloseCode = {}));
|
|
153
135
|
function isReconnectable(err) {
|
|
154
136
|
// Network errors are reconnectable.
|
|
155
137
|
// AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
|
|
156
138
|
// @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
|
|
157
139
|
// an invalid message is not current reconnectable, but the user can decide to skip them.
|
|
158
|
-
if (
|
|
140
|
+
if (isErrnoException(err) && typeof err.code === 'string') {
|
|
159
141
|
return networkErrorCodes.includes(err.code);
|
|
160
142
|
}
|
|
161
143
|
return false;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { ClientOptions, WebSocket, createWebSocketStream } from 'ws'\nimport { SECOND, isErrnoException, wait } from '@atproto/common'\n\nexport class WebSocketKeepAlive {\n public ws: WebSocket | null = null\n public initialSetup = true\n public reconnects: number | null = null\n\n constructor(\n public opts: ClientOptions & {\n getUrl: () => Promise<string>\n maxReconnectSeconds?: number\n signal?: AbortSignal\n heartbeatIntervalMs?: number\n onReconnectError?: (\n error: unknown,\n n: number,\n initialSetup: boolean,\n ) => void\n },\n ) {}\n\n async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {\n const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)\n while (true) {\n if (this.reconnects !== null) {\n const duration = this.initialSetup\n ? Math.min(1000, maxReconnectMs)\n : backoffMs(this.reconnects++, maxReconnectMs)\n await wait(duration)\n }\n const url = await this.opts.getUrl()\n this.ws = new WebSocket(url, this.opts)\n const ac = new AbortController()\n if (this.opts.signal) {\n forwardSignal(this.opts.signal, ac)\n }\n this.ws.once('open', () => {\n this.initialSetup = false\n this.reconnects = 0\n if (this.ws) {\n this.startHeartbeat(this.ws)\n }\n })\n this.ws.once('close', (code, reason) => {\n if (code === CloseCode.Abnormal) {\n // Forward into an error to distinguish from a clean close\n ac.abort(\n new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),\n )\n }\n })\n\n try {\n const wsStream = createWebSocketStream(this.ws, {\n signal: ac.signal,\n readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together\n })\n for await (const chunk of wsStream) {\n yield chunk\n }\n } catch (_err) {\n const err =\n isErrnoException(_err) && _err.code === 'ABORT_ERR'\n ? _err.cause\n : _err\n if (err instanceof DisconnectError) {\n // We cleanly end the connection\n this.ws?.close(err.wsCode)\n break\n }\n this.ws?.close() // No-ops if already closed or closing\n if (isReconnectable(err)) {\n this.reconnects ??= 0 // Never reconnect with a null\n this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup)\n continue\n } else {\n throw err\n }\n }\n break // Other side cleanly ended stream and disconnected\n }\n }\n\n startHeartbeat(ws: WebSocket) {\n let isAlive = true\n let heartbeatInterval: NodeJS.Timeout | null = null\n\n const checkAlive = () => {\n if (!isAlive) {\n return ws.terminate()\n }\n isAlive = false // expect websocket to no longer be alive unless we receive a \"pong\" within the interval\n ws.ping()\n }\n\n checkAlive()\n heartbeatInterval = setInterval(\n checkAlive,\n this.opts.heartbeatIntervalMs ?? 10 * SECOND,\n )\n\n ws.on('pong', () => {\n isAlive = true\n })\n ws.once('close', () => {\n if (heartbeatInterval) {\n clearInterval(heartbeatInterval)\n heartbeatInterval = null\n }\n })\n }\n}\n\nexport default WebSocketKeepAlive\n\nclass AbnormalCloseError extends Error {\n code = 'EWSABNORMALCLOSE'\n}\n\nexport class DisconnectError extends Error {\n constructor(\n public wsCode: CloseCode = CloseCode.Policy,\n public xrpcCode?: string,\n ) {\n super()\n }\n}\n\n// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1\nexport enum CloseCode {\n Normal = 1000,\n Abnormal = 1006,\n Policy = 1008,\n}\n\nfunction isReconnectable(err: unknown): boolean {\n // Network errors are reconnectable.\n // AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.\n // @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving\n // an invalid message is not current reconnectable, but the user can decide to skip them.\n if (isErrnoException(err) && typeof err.code === 'string') {\n return networkErrorCodes.includes(err.code)\n }\n return false\n}\n\nconst networkErrorCodes = [\n 'EWSABNORMALCLOSE',\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'ECONNABORTED',\n 'EPIPE',\n 'ETIMEDOUT',\n 'ECANCELED',\n]\n\nfunction backoffMs(n: number, maxMs: number) {\n const baseSec = Math.pow(2, n) // 1, 2, 4, ...\n const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds\n const ms = 1000 * (baseSec + randSec)\n return Math.min(ms, maxMs)\n}\n\nfunction forwardSignal(signal: AbortSignal, ac: AbortController) {\n if (signal.aborted) {\n return ac.abort(signal.reason)\n } else {\n signal.addEventListener('abort', () => ac.abort(signal.reason), {\n // @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625\n signal: ac.signal,\n })\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ClientOptions } from 'ws'\nimport { WebSocket, createWebSocketStream } from 'ws'\nimport { SECOND, isErrnoException, wait } from '@atproto/common'\n\nexport class WebSocketKeepAlive {\n public ws: WebSocket | null = null\n public initialSetup = true\n public reconnects: number | null = null\n\n constructor(\n public opts: ClientOptions & {\n getUrl: () => Promise<string>\n maxReconnectSeconds?: number\n signal?: AbortSignal\n heartbeatIntervalMs?: number\n onReconnect?: () => void\n onReconnectError?: (\n error: unknown,\n n: number,\n initialSetup: boolean,\n ) => void\n },\n ) {}\n\n async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {\n const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)\n while (true) {\n if (this.reconnects !== null) {\n const duration = this.initialSetup\n ? Math.min(1000, maxReconnectMs)\n : backoffMs(this.reconnects++, maxReconnectMs)\n await wait(duration)\n }\n const url = await this.opts.getUrl()\n this.ws = new WebSocket(url, this.opts)\n const ac = new AbortController()\n if (this.opts.signal) {\n forwardSignal(this.opts.signal, ac)\n }\n this.ws.once('open', () => {\n if (!this.initialSetup && this.opts.onReconnect) {\n this.opts.onReconnect()\n }\n this.initialSetup = false\n this.reconnects = 0\n if (this.ws) {\n this.startHeartbeat(this.ws)\n }\n })\n this.ws.once('close', (code, reason) => {\n if (code === CloseCode.Abnormal) {\n // Forward into an error to distinguish from a clean close\n ac.abort(\n new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),\n )\n }\n })\n\n try {\n const wsStream = createWebSocketStream(this.ws, {\n signal: ac.signal,\n readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together\n })\n for await (const chunk of wsStream) {\n yield chunk\n }\n } catch (_err) {\n const err =\n isErrnoException(_err) && _err.code === 'ABORT_ERR'\n ? _err.cause\n : _err\n if (err instanceof DisconnectError) {\n // We cleanly end the connection\n this.ws?.close(err.wsCode)\n break\n }\n this.ws?.close() // No-ops if already closed or closing\n if (isReconnectable(err)) {\n this.reconnects ??= 0 // Never reconnect with a null\n this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup)\n continue\n } else {\n throw err\n }\n }\n break // Other side cleanly ended stream and disconnected\n }\n }\n\n send(data: string | Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n if (!this.ws || this.ws.readyState !== 1 /* OPEN */) {\n reject(new Error('WebSocket is not connected'))\n return\n }\n this.ws.send(data, (err) => {\n if (err) {\n reject(err)\n } else {\n resolve()\n }\n })\n })\n }\n\n isConnected(): boolean {\n return this.ws !== null && this.ws.readyState === 1\n }\n\n startHeartbeat(ws: WebSocket) {\n let isAlive = true\n let heartbeatInterval: NodeJS.Timeout | null = null\n\n const checkAlive = () => {\n if (!isAlive) {\n return ws.terminate()\n }\n isAlive = false // expect websocket to no longer be alive unless we receive a \"pong\" within the interval\n ws.ping()\n }\n\n checkAlive()\n heartbeatInterval = setInterval(\n checkAlive,\n this.opts.heartbeatIntervalMs ?? 10 * SECOND,\n )\n\n ws.on('pong', () => {\n isAlive = true\n })\n ws.once('close', () => {\n if (heartbeatInterval) {\n clearInterval(heartbeatInterval)\n heartbeatInterval = null\n }\n })\n }\n}\n\nexport default WebSocketKeepAlive\n\nclass AbnormalCloseError extends Error {\n code = 'EWSABNORMALCLOSE'\n}\n\nexport class DisconnectError extends Error {\n constructor(\n public wsCode: CloseCode = CloseCode.Policy,\n public xrpcCode?: string,\n ) {\n super()\n }\n}\n\n// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1\nexport enum CloseCode {\n Normal = 1000,\n Abnormal = 1006,\n Policy = 1008,\n}\n\nfunction isReconnectable(err: unknown): boolean {\n // Network errors are reconnectable.\n // AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.\n // @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving\n // an invalid message is not current reconnectable, but the user can decide to skip them.\n if (isErrnoException(err) && typeof err.code === 'string') {\n return networkErrorCodes.includes(err.code)\n }\n return false\n}\n\nconst networkErrorCodes = [\n 'EWSABNORMALCLOSE',\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'ECONNABORTED',\n 'EPIPE',\n 'ETIMEDOUT',\n 'ECANCELED',\n]\n\nfunction backoffMs(n: number, maxMs: number) {\n const baseSec = Math.pow(2, n) // 1, 2, 4, ...\n const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds\n const ms = 1000 * (baseSec + randSec)\n return Math.min(ms, maxMs)\n}\n\nfunction forwardSignal(signal: AbortSignal, ac: AbortController) {\n if (signal.aborted) {\n return ac.abort(signal.reason)\n } else {\n signal.addEventListener('abort', () => ac.abort(signal.reason), {\n // @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625\n signal: ac.signal,\n })\n }\n}\n"]}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/** @type {import('jest').Config} */
|
|
2
2
|
module.exports = {
|
|
3
3
|
displayName: 'WebSocket Client',
|
|
4
|
-
transform: {
|
|
5
|
-
|
|
4
|
+
transform: {
|
|
5
|
+
'^.+\\.(t|j)s$': [
|
|
6
|
+
'@swc/jest',
|
|
7
|
+
{ jsc: { transform: {} }, module: { type: 'es6' } },
|
|
8
|
+
],
|
|
9
|
+
},
|
|
10
|
+
extensionsToTreatAsEsm: ['.ts'],
|
|
11
|
+
transformIgnorePatterns: [],
|
|
6
12
|
setupFiles: ['<rootDir>/../../jest.setup.ts'],
|
|
7
13
|
moduleNameMapper: { '^(\\.\\.?\\/.+)\\.js$': ['$1.ts', '$1.js'] },
|
|
8
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto/ws-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.1.0-next.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Websocket client library",
|
|
6
6
|
"keywords": [
|
|
@@ -14,22 +14,27 @@
|
|
|
14
14
|
"directory": "packages/ws-client"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
18
18
|
},
|
|
19
|
-
"main": "dist/index.js",
|
|
20
|
-
"types": "dist/index.d.ts",
|
|
21
19
|
"dependencies": {
|
|
22
20
|
"ws": "^8.12.0",
|
|
23
|
-
"@atproto/common": "^0.
|
|
21
|
+
"@atproto/common": "^0.6.0-next.0"
|
|
24
22
|
},
|
|
25
23
|
"devDependencies": {
|
|
26
24
|
"@types/ws": "^8.5.4",
|
|
27
25
|
"get-port": "^6.1.2",
|
|
28
|
-
"jest": "^
|
|
29
|
-
"typescript": "^
|
|
26
|
+
"jest": "^30.0.0",
|
|
27
|
+
"typescript": "^6.0.3"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
30
35
|
},
|
|
31
36
|
"scripts": {
|
|
32
|
-
"test": "jest",
|
|
37
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
33
38
|
"build": "tsc --build tsconfig.build.json"
|
|
34
39
|
}
|
|
35
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ClientOptions
|
|
1
|
+
import type { ClientOptions } from 'ws'
|
|
2
|
+
import { WebSocket, createWebSocketStream } from 'ws'
|
|
2
3
|
import { SECOND, isErrnoException, wait } from '@atproto/common'
|
|
3
4
|
|
|
4
5
|
export class WebSocketKeepAlive {
|
|
@@ -12,6 +13,7 @@ export class WebSocketKeepAlive {
|
|
|
12
13
|
maxReconnectSeconds?: number
|
|
13
14
|
signal?: AbortSignal
|
|
14
15
|
heartbeatIntervalMs?: number
|
|
16
|
+
onReconnect?: () => void
|
|
15
17
|
onReconnectError?: (
|
|
16
18
|
error: unknown,
|
|
17
19
|
n: number,
|
|
@@ -36,6 +38,9 @@ export class WebSocketKeepAlive {
|
|
|
36
38
|
forwardSignal(this.opts.signal, ac)
|
|
37
39
|
}
|
|
38
40
|
this.ws.once('open', () => {
|
|
41
|
+
if (!this.initialSetup && this.opts.onReconnect) {
|
|
42
|
+
this.opts.onReconnect()
|
|
43
|
+
}
|
|
39
44
|
this.initialSetup = false
|
|
40
45
|
this.reconnects = 0
|
|
41
46
|
if (this.ws) {
|
|
@@ -82,6 +87,26 @@ export class WebSocketKeepAlive {
|
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
send(data: string | Buffer): Promise<void> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
if (!this.ws || this.ws.readyState !== 1 /* OPEN */) {
|
|
93
|
+
reject(new Error('WebSocket is not connected'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
this.ws.send(data, (err) => {
|
|
97
|
+
if (err) {
|
|
98
|
+
reject(err)
|
|
99
|
+
} else {
|
|
100
|
+
resolve()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
isConnected(): boolean {
|
|
107
|
+
return this.ws !== null && this.ws.readyState === 1
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
startHeartbeat(ws: WebSocket) {
|
|
86
111
|
let isAlive = true
|
|
87
112
|
let heartbeatInterval: NodeJS.Timeout | null = null
|
package/tests/keepalive.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import getPort from 'get-port'
|
|
2
2
|
import { WebSocketServer } from 'ws'
|
|
3
3
|
import { wait } from '@atproto/common'
|
|
4
|
-
import { CloseCode, WebSocketKeepAlive } from '../src'
|
|
4
|
+
import { CloseCode, WebSocketKeepAlive } from '../src/index.js'
|
|
5
5
|
|
|
6
6
|
describe('WebSocketKeepAlive', () => {
|
|
7
7
|
it('uses a heartbeat to reconnect if a connection is dropped', async () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/index.ts"],"version":"
|
|
1
|
+
{"root":["./src/index.ts"],"version":"6.0.3"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"root":["./tests/keepalive.test.ts"],"version":"5.8.3"}
|