@atproto/ws-client 0.0.2
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 +7 -0
- package/LICENSE.txt +7 -0
- package/README.md +15 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +35 -0
- package/src/index.ts +174 -0
- package/tests/keepalive.test.ts +50 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.tests.json +7 -0
- package/tsconfig.tests.tsbuildinfo +1 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @atproto/ws-client
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#4348](https://github.com/bluesky-social/atproto/pull/4348) [`1dd20d3a8`](https://github.com/bluesky-social/atproto/commit/1dd20d3a81cda29392d8d63d13082254ec5f68a8) Thanks [@dholms](https://github.com/dholms)! - Move WebSocketKeepAlive into its own package
|
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Dual MIT/Apache-2.0 License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @atproto/ws-client: WebSocket Client Library
|
|
2
|
+
|
|
3
|
+
Shared Typescript library for managing a long-lived WebSocket client connection, including a heartbeat mechanism to ensure the connection remains active.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@atproto/ws-client)
|
|
6
|
+
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)
|
|
7
|
+
|
|
8
|
+
## License
|
|
9
|
+
|
|
10
|
+
This project is dual-licensed under MIT and Apache 2.0 terms:
|
|
11
|
+
|
|
12
|
+
- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
|
|
13
|
+
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)
|
|
14
|
+
|
|
15
|
+
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ClientOptions, WebSocket } from 'ws';
|
|
2
|
+
export declare class WebSocketKeepAlive {
|
|
3
|
+
opts: ClientOptions & {
|
|
4
|
+
getUrl: () => Promise<string>;
|
|
5
|
+
maxReconnectSeconds?: number;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
heartbeatIntervalMs?: number;
|
|
8
|
+
onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void;
|
|
9
|
+
};
|
|
10
|
+
ws: WebSocket | null;
|
|
11
|
+
initialSetup: boolean;
|
|
12
|
+
reconnects: number | null;
|
|
13
|
+
constructor(opts: ClientOptions & {
|
|
14
|
+
getUrl: () => Promise<string>;
|
|
15
|
+
maxReconnectSeconds?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
heartbeatIntervalMs?: number;
|
|
18
|
+
onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void;
|
|
19
|
+
});
|
|
20
|
+
[Symbol.asyncIterator](): AsyncGenerator<Uint8Array>;
|
|
21
|
+
startHeartbeat(ws: WebSocket): void;
|
|
22
|
+
}
|
|
23
|
+
export default WebSocketKeepAlive;
|
|
24
|
+
export declare class DisconnectError extends Error {
|
|
25
|
+
wsCode: CloseCode;
|
|
26
|
+
xrpcCode?: string | undefined;
|
|
27
|
+
constructor(wsCode?: CloseCode, xrpcCode?: string | undefined);
|
|
28
|
+
}
|
|
29
|
+
export declare enum CloseCode {
|
|
30
|
+
Normal = 1000,
|
|
31
|
+
Abnormal = 1006,
|
|
32
|
+
Policy = 1008
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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;AAGpE,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,gBAAgB,CAAC,EAAE,CACjB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,MAAM,EACT,YAAY,EAAE,OAAO,KAClB,IAAI,CAAA;KACV;IAfI,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,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;IA8D3D,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
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CloseCode = exports.DisconnectError = exports.WebSocketKeepAlive = void 0;
|
|
4
|
+
const ws_1 = require("ws");
|
|
5
|
+
const common_1 = require("@atproto/common");
|
|
6
|
+
class WebSocketKeepAlive {
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
Object.defineProperty(this, "opts", {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
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
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async *[Symbol.asyncIterator]() {
|
|
34
|
+
const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64);
|
|
35
|
+
while (true) {
|
|
36
|
+
if (this.reconnects !== null) {
|
|
37
|
+
const duration = this.initialSetup
|
|
38
|
+
? Math.min(1000, maxReconnectMs)
|
|
39
|
+
: backoffMs(this.reconnects++, maxReconnectMs);
|
|
40
|
+
await (0, common_1.wait)(duration);
|
|
41
|
+
}
|
|
42
|
+
const url = await this.opts.getUrl();
|
|
43
|
+
this.ws = new ws_1.WebSocket(url, this.opts);
|
|
44
|
+
const ac = new AbortController();
|
|
45
|
+
if (this.opts.signal) {
|
|
46
|
+
forwardSignal(this.opts.signal, ac);
|
|
47
|
+
}
|
|
48
|
+
this.ws.once('open', () => {
|
|
49
|
+
this.initialSetup = false;
|
|
50
|
+
this.reconnects = 0;
|
|
51
|
+
if (this.ws) {
|
|
52
|
+
this.startHeartbeat(this.ws);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
this.ws.once('close', (code, reason) => {
|
|
56
|
+
if (code === CloseCode.Abnormal) {
|
|
57
|
+
// Forward into an error to distinguish from a clean close
|
|
58
|
+
ac.abort(new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const wsStream = (0, ws_1.createWebSocketStream)(this.ws, {
|
|
63
|
+
signal: ac.signal,
|
|
64
|
+
readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together
|
|
65
|
+
});
|
|
66
|
+
for await (const chunk of wsStream) {
|
|
67
|
+
yield chunk;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (_err) {
|
|
71
|
+
const err = (0, common_1.isErrnoException)(_err) && _err.code === 'ABORT_ERR'
|
|
72
|
+
? _err.cause
|
|
73
|
+
: _err;
|
|
74
|
+
if (err instanceof DisconnectError) {
|
|
75
|
+
// We cleanly end the connection
|
|
76
|
+
this.ws?.close(err.wsCode);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
this.ws?.close(); // No-ops if already closed or closing
|
|
80
|
+
if (isReconnectable(err)) {
|
|
81
|
+
this.reconnects ?? (this.reconnects = 0); // Never reconnect with a null
|
|
82
|
+
this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
break; // Other side cleanly ended stream and disconnected
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
startHeartbeat(ws) {
|
|
93
|
+
let isAlive = true;
|
|
94
|
+
let heartbeatInterval = null;
|
|
95
|
+
const checkAlive = () => {
|
|
96
|
+
if (!isAlive) {
|
|
97
|
+
return ws.terminate();
|
|
98
|
+
}
|
|
99
|
+
isAlive = false; // expect websocket to no longer be alive unless we receive a "pong" within the interval
|
|
100
|
+
ws.ping();
|
|
101
|
+
};
|
|
102
|
+
checkAlive();
|
|
103
|
+
heartbeatInterval = setInterval(checkAlive, this.opts.heartbeatIntervalMs ?? 10 * common_1.SECOND);
|
|
104
|
+
ws.on('pong', () => {
|
|
105
|
+
isAlive = true;
|
|
106
|
+
});
|
|
107
|
+
ws.once('close', () => {
|
|
108
|
+
if (heartbeatInterval) {
|
|
109
|
+
clearInterval(heartbeatInterval);
|
|
110
|
+
heartbeatInterval = null;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.WebSocketKeepAlive = WebSocketKeepAlive;
|
|
116
|
+
exports.default = WebSocketKeepAlive;
|
|
117
|
+
class AbnormalCloseError extends Error {
|
|
118
|
+
constructor() {
|
|
119
|
+
super(...arguments);
|
|
120
|
+
Object.defineProperty(this, "code", {
|
|
121
|
+
enumerable: true,
|
|
122
|
+
configurable: true,
|
|
123
|
+
writable: true,
|
|
124
|
+
value: 'EWSABNORMALCLOSE'
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
class DisconnectError extends Error {
|
|
129
|
+
constructor(wsCode = CloseCode.Policy, xrpcCode) {
|
|
130
|
+
super();
|
|
131
|
+
Object.defineProperty(this, "wsCode", {
|
|
132
|
+
enumerable: true,
|
|
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
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.DisconnectError = DisconnectError;
|
|
146
|
+
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
147
|
+
var CloseCode;
|
|
148
|
+
(function (CloseCode) {
|
|
149
|
+
CloseCode[CloseCode["Normal"] = 1000] = "Normal";
|
|
150
|
+
CloseCode[CloseCode["Abnormal"] = 1006] = "Abnormal";
|
|
151
|
+
CloseCode[CloseCode["Policy"] = 1008] = "Policy";
|
|
152
|
+
})(CloseCode || (exports.CloseCode = CloseCode = {}));
|
|
153
|
+
function isReconnectable(err) {
|
|
154
|
+
// Network errors are reconnectable.
|
|
155
|
+
// AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
|
|
156
|
+
// @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
|
|
157
|
+
// an invalid message is not current reconnectable, but the user can decide to skip them.
|
|
158
|
+
if ((0, common_1.isErrnoException)(err) && typeof err.code === 'string') {
|
|
159
|
+
return networkErrorCodes.includes(err.code);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const networkErrorCodes = [
|
|
164
|
+
'EWSABNORMALCLOSE',
|
|
165
|
+
'ECONNRESET',
|
|
166
|
+
'ECONNREFUSED',
|
|
167
|
+
'ECONNABORTED',
|
|
168
|
+
'EPIPE',
|
|
169
|
+
'ETIMEDOUT',
|
|
170
|
+
'ECANCELED',
|
|
171
|
+
];
|
|
172
|
+
function backoffMs(n, maxMs) {
|
|
173
|
+
const baseSec = Math.pow(2, n); // 1, 2, 4, ...
|
|
174
|
+
const randSec = Math.random() - 0.5; // Random jitter between -.5 and .5 seconds
|
|
175
|
+
const ms = 1000 * (baseSec + randSec);
|
|
176
|
+
return Math.min(ms, maxMs);
|
|
177
|
+
}
|
|
178
|
+
function forwardSignal(signal, ac) {
|
|
179
|
+
if (signal.aborted) {
|
|
180
|
+
return ac.abort(signal.reason);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
signal.addEventListener('abort', () => ac.abort(signal.reason), {
|
|
184
|
+
// @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625
|
|
185
|
+
signal: ac.signal,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,2BAAoE;AACpE,4CAAgE;AAEhE,MAAa,kBAAkB;IAK7B,YACS,IAUN;QAVD;;;;mBAAO,IAAI;WAUV;QAfI;;;;mBAAuB,IAAI;WAAA;QAC3B;;;;mBAAe,IAAI;WAAA;QACnB;;;;mBAA4B,IAAI;WAAA;IAcpC,CAAC;IAEJ,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC;QAC3B,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,mBAAmB,IAAI,EAAE,CAAC,CAAA;QACnE,OAAO,IAAI,EAAE,CAAC;YACZ,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;gBAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY;oBAChC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,cAAc,CAAC;oBAChC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,cAAc,CAAC,CAAA;gBAChD,MAAM,IAAA,aAAI,EAAC,QAAQ,CAAC,CAAA;YACtB,CAAC;YACD,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAA;YACpC,IAAI,CAAC,EAAE,GAAG,IAAI,cAAS,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;YACvC,MAAM,EAAE,GAAG,IAAI,eAAe,EAAE,CAAA;YAChC,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;gBACrB,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;YACrC,CAAC;YACD,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;gBACxB,IAAI,CAAC,YAAY,GAAG,KAAK,CAAA;gBACzB,IAAI,CAAC,UAAU,GAAG,CAAC,CAAA;gBACnB,IAAI,IAAI,CAAC,EAAE,EAAE,CAAC;oBACZ,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;gBAC9B,CAAC;YACH,CAAC,CAAC,CAAA;YACF,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;gBACrC,IAAI,IAAI,KAAK,SAAS,CAAC,QAAQ,EAAE,CAAC;oBAChC,0DAA0D;oBAC1D,EAAE,CAAC,KAAK,CACN,IAAI,kBAAkB,CAAC,sBAAsB,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,CAClE,CAAA;gBACH,CAAC;YACH,CAAC,CAAC,CAAA;YAEF,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,IAAA,0BAAqB,EAAC,IAAI,CAAC,EAAE,EAAE;oBAC9C,MAAM,EAAE,EAAE,CAAC,MAAM;oBACjB,kBAAkB,EAAE,IAAI,EAAE,2DAA2D;iBACtF,CAAC,CAAA;gBACF,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,QAAQ,EAAE,CAAC;oBACnC,MAAM,KAAK,CAAA;gBACb,CAAC;YACH,CAAC;YAAC,OAAO,IAAI,EAAE,CAAC;gBACd,MAAM,GAAG,GACP,IAAA,yBAAgB,EAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,KAAK,WAAW;oBACjD,CAAC,CAAC,IAAI,CAAC,KAAK;oBACZ,CAAC,CAAC,IAAI,CAAA;gBACV,IAAI,GAAG,YAAY,eAAe,EAAE,CAAC;oBACnC,gCAAgC;oBAChC,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;oBAC1B,MAAK;gBACP,CAAC;gBACD,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,CAAA,CAAC,sCAAsC;gBACvD,IAAI,eAAe,CAAC,GAAG,CAAC,EAAE,CAAC;oBACzB,IAAI,CAAC,UAAU,KAAf,IAAI,CAAC,UAAU,GAAK,CAAC,EAAA,CAAC,8BAA8B;oBACpD,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,YAAY,CAAC,CAAA;oBACrE,SAAQ;gBACV,CAAC;qBAAM,CAAC;oBACN,MAAM,GAAG,CAAA;gBACX,CAAC;YACH,CAAC;YACD,MAAK,CAAC,mDAAmD;QAC3D,CAAC;IACH,CAAC;IAED,cAAc,CAAC,EAAa;QAC1B,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,IAAI,iBAAiB,GAA0B,IAAI,CAAA;QAEnD,MAAM,UAAU,GAAG,GAAG,EAAE;YACtB,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,EAAE,CAAC,SAAS,EAAE,CAAA;YACvB,CAAC;YACD,OAAO,GAAG,KAAK,CAAA,CAAC,wFAAwF;YACxG,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,CAAA;QAED,UAAU,EAAE,CAAA;QACZ,iBAAiB,GAAG,WAAW,CAC7B,UAAU,EACV,IAAI,CAAC,IAAI,CAAC,mBAAmB,IAAI,EAAE,GAAG,eAAM,CAC7C,CAAA;QAED,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE;YACjB,OAAO,GAAG,IAAI,CAAA;QAChB,CAAC,CAAC,CAAA;QACF,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE;YACpB,IAAI,iBAAiB,EAAE,CAAC;gBACtB,aAAa,CAAC,iBAAiB,CAAC,CAAA;gBAChC,iBAAiB,GAAG,IAAI,CAAA;YAC1B,CAAC;QACH,CAAC,CAAC,CAAA;IACJ,CAAC;CACF;AA7GD,gDA6GC;AAED,kBAAe,kBAAkB,CAAA;AAEjC,MAAM,kBAAmB,SAAQ,KAAK;IAAtC;;QACE;;;;mBAAO,kBAAkB;WAAA;IAC3B,CAAC;CAAA;AAED,MAAa,eAAgB,SAAQ,KAAK;IACxC,YACS,SAAoB,SAAS,CAAC,MAAM,EACpC,QAAiB;QAExB,KAAK,EAAE,CAAA;QAHP;;;;mBAAO,MAAM;WAA8B;QAC3C;;;;mBAAO,QAAQ;WAAS;IAG1B,CAAC;CACF;AAPD,0CAOC;AAED,uDAAuD;AACvD,IAAY,SAIX;AAJD,WAAY,SAAS;IACnB,gDAAa,CAAA;IACb,oDAAe,CAAA;IACf,gDAAa,CAAA;AACf,CAAC,EAJW,SAAS,yBAAT,SAAS,QAIpB;AAED,SAAS,eAAe,CAAC,GAAY;IACnC,oCAAoC;IACpC,8EAA8E;IAC9E,qFAAqF;IACrF,yFAAyF;IACzF,IAAI,IAAA,yBAAgB,EAAC,GAAG,CAAC,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC1D,OAAO,iBAAiB,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IAC7C,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,MAAM,iBAAiB,GAAG;IACxB,kBAAkB;IAClB,YAAY;IACZ,cAAc;IACd,cAAc;IACd,OAAO;IACP,WAAW;IACX,WAAW;CACZ,CAAA;AAED,SAAS,SAAS,CAAC,CAAS,EAAE,KAAa;IACzC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA,CAAC,eAAe;IAC9C,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAA,CAAC,2CAA2C;IAC/E,MAAM,EAAE,GAAG,IAAI,GAAG,CAAC,OAAO,GAAG,OAAO,CAAC,CAAA;IACrC,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,CAAA;AAC5B,CAAC;AAED,SAAS,aAAa,CAAC,MAAmB,EAAE,EAAmB;IAC7D,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAChC,CAAC;SAAM,CAAC;QACN,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE;YAC9D,2EAA2E;YAC3E,MAAM,EAAE,EAAE,CAAC,MAAM;SAClB,CAAC,CAAA;IACJ,CAAC;AACH,CAAC","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"]}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
displayName: 'WebSocket Client',
|
|
4
|
+
transform: { '^.+\\.(j|t)s$': '@swc/jest' },
|
|
5
|
+
transformIgnorePatterns: ['/node_modules/.pnpm/(?!(get-port)@)'],
|
|
6
|
+
setupFiles: ['<rootDir>/../../jest.setup.ts'],
|
|
7
|
+
moduleNameMapper: { '^(\\.\\.?\\/.+)\\.js$': ['$1.ts', '$1.js'] },
|
|
8
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atproto/ws-client",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Websocket client library",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"atproto",
|
|
8
|
+
"xrpc"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://atproto.com",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/bluesky-social/atproto",
|
|
14
|
+
"directory": "packages/ws-client"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.7.0"
|
|
18
|
+
},
|
|
19
|
+
"main": "dist/index.js",
|
|
20
|
+
"types": "dist/index.d.ts",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.12.0",
|
|
23
|
+
"@atproto/common": "^0.4.12"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/ws": "^8.5.4",
|
|
27
|
+
"get-port": "^6.1.2",
|
|
28
|
+
"jest": "^28.1.2",
|
|
29
|
+
"typescript": "^5.6.3"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "jest",
|
|
33
|
+
"build": "tsc --build tsconfig.build.json"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { ClientOptions, WebSocket, createWebSocketStream } from 'ws'
|
|
2
|
+
import { SECOND, isErrnoException, wait } from '@atproto/common'
|
|
3
|
+
|
|
4
|
+
export class WebSocketKeepAlive {
|
|
5
|
+
public ws: WebSocket | null = null
|
|
6
|
+
public initialSetup = true
|
|
7
|
+
public reconnects: number | null = null
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
public opts: ClientOptions & {
|
|
11
|
+
getUrl: () => Promise<string>
|
|
12
|
+
maxReconnectSeconds?: number
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
heartbeatIntervalMs?: number
|
|
15
|
+
onReconnectError?: (
|
|
16
|
+
error: unknown,
|
|
17
|
+
n: number,
|
|
18
|
+
initialSetup: boolean,
|
|
19
|
+
) => void
|
|
20
|
+
},
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
|
|
24
|
+
const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)
|
|
25
|
+
while (true) {
|
|
26
|
+
if (this.reconnects !== null) {
|
|
27
|
+
const duration = this.initialSetup
|
|
28
|
+
? Math.min(1000, maxReconnectMs)
|
|
29
|
+
: backoffMs(this.reconnects++, maxReconnectMs)
|
|
30
|
+
await wait(duration)
|
|
31
|
+
}
|
|
32
|
+
const url = await this.opts.getUrl()
|
|
33
|
+
this.ws = new WebSocket(url, this.opts)
|
|
34
|
+
const ac = new AbortController()
|
|
35
|
+
if (this.opts.signal) {
|
|
36
|
+
forwardSignal(this.opts.signal, ac)
|
|
37
|
+
}
|
|
38
|
+
this.ws.once('open', () => {
|
|
39
|
+
this.initialSetup = false
|
|
40
|
+
this.reconnects = 0
|
|
41
|
+
if (this.ws) {
|
|
42
|
+
this.startHeartbeat(this.ws)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
this.ws.once('close', (code, reason) => {
|
|
46
|
+
if (code === CloseCode.Abnormal) {
|
|
47
|
+
// Forward into an error to distinguish from a clean close
|
|
48
|
+
ac.abort(
|
|
49
|
+
new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const wsStream = createWebSocketStream(this.ws, {
|
|
56
|
+
signal: ac.signal,
|
|
57
|
+
readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together
|
|
58
|
+
})
|
|
59
|
+
for await (const chunk of wsStream) {
|
|
60
|
+
yield chunk
|
|
61
|
+
}
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
const err =
|
|
64
|
+
isErrnoException(_err) && _err.code === 'ABORT_ERR'
|
|
65
|
+
? _err.cause
|
|
66
|
+
: _err
|
|
67
|
+
if (err instanceof DisconnectError) {
|
|
68
|
+
// We cleanly end the connection
|
|
69
|
+
this.ws?.close(err.wsCode)
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
this.ws?.close() // No-ops if already closed or closing
|
|
73
|
+
if (isReconnectable(err)) {
|
|
74
|
+
this.reconnects ??= 0 // Never reconnect with a null
|
|
75
|
+
this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup)
|
|
76
|
+
continue
|
|
77
|
+
} else {
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
break // Other side cleanly ended stream and disconnected
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
startHeartbeat(ws: WebSocket) {
|
|
86
|
+
let isAlive = true
|
|
87
|
+
let heartbeatInterval: NodeJS.Timeout | null = null
|
|
88
|
+
|
|
89
|
+
const checkAlive = () => {
|
|
90
|
+
if (!isAlive) {
|
|
91
|
+
return ws.terminate()
|
|
92
|
+
}
|
|
93
|
+
isAlive = false // expect websocket to no longer be alive unless we receive a "pong" within the interval
|
|
94
|
+
ws.ping()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
checkAlive()
|
|
98
|
+
heartbeatInterval = setInterval(
|
|
99
|
+
checkAlive,
|
|
100
|
+
this.opts.heartbeatIntervalMs ?? 10 * SECOND,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
ws.on('pong', () => {
|
|
104
|
+
isAlive = true
|
|
105
|
+
})
|
|
106
|
+
ws.once('close', () => {
|
|
107
|
+
if (heartbeatInterval) {
|
|
108
|
+
clearInterval(heartbeatInterval)
|
|
109
|
+
heartbeatInterval = null
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default WebSocketKeepAlive
|
|
116
|
+
|
|
117
|
+
class AbnormalCloseError extends Error {
|
|
118
|
+
code = 'EWSABNORMALCLOSE'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class DisconnectError extends Error {
|
|
122
|
+
constructor(
|
|
123
|
+
public wsCode: CloseCode = CloseCode.Policy,
|
|
124
|
+
public xrpcCode?: string,
|
|
125
|
+
) {
|
|
126
|
+
super()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
131
|
+
export enum CloseCode {
|
|
132
|
+
Normal = 1000,
|
|
133
|
+
Abnormal = 1006,
|
|
134
|
+
Policy = 1008,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isReconnectable(err: unknown): boolean {
|
|
138
|
+
// Network errors are reconnectable.
|
|
139
|
+
// AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
|
|
140
|
+
// @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
|
|
141
|
+
// an invalid message is not current reconnectable, but the user can decide to skip them.
|
|
142
|
+
if (isErrnoException(err) && typeof err.code === 'string') {
|
|
143
|
+
return networkErrorCodes.includes(err.code)
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const networkErrorCodes = [
|
|
149
|
+
'EWSABNORMALCLOSE',
|
|
150
|
+
'ECONNRESET',
|
|
151
|
+
'ECONNREFUSED',
|
|
152
|
+
'ECONNABORTED',
|
|
153
|
+
'EPIPE',
|
|
154
|
+
'ETIMEDOUT',
|
|
155
|
+
'ECANCELED',
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
function backoffMs(n: number, maxMs: number) {
|
|
159
|
+
const baseSec = Math.pow(2, n) // 1, 2, 4, ...
|
|
160
|
+
const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds
|
|
161
|
+
const ms = 1000 * (baseSec + randSec)
|
|
162
|
+
return Math.min(ms, maxMs)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function forwardSignal(signal: AbortSignal, ac: AbortController) {
|
|
166
|
+
if (signal.aborted) {
|
|
167
|
+
return ac.abort(signal.reason)
|
|
168
|
+
} else {
|
|
169
|
+
signal.addEventListener('abort', () => ac.abort(signal.reason), {
|
|
170
|
+
// @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625
|
|
171
|
+
signal: ac.signal,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import getPort from 'get-port'
|
|
2
|
+
import { WebSocketServer } from 'ws'
|
|
3
|
+
import { wait } from '@atproto/common'
|
|
4
|
+
import { CloseCode, WebSocketKeepAlive } from '../src'
|
|
5
|
+
|
|
6
|
+
describe('WebSocketKeepAlive', () => {
|
|
7
|
+
it('uses a heartbeat to reconnect if a connection is dropped', async () => {
|
|
8
|
+
// we run a server that, on first connection, pauses for longer than the heartbeat interval (doesn't return "pong"s)
|
|
9
|
+
// on second connection, it sends a message and then closes
|
|
10
|
+
const port = await getPort()
|
|
11
|
+
const server = new WebSocketServer({ port })
|
|
12
|
+
let firstConnection = true
|
|
13
|
+
let firstWasClosed = false
|
|
14
|
+
server.on('connection', async (socket) => {
|
|
15
|
+
if (firstConnection === true) {
|
|
16
|
+
firstConnection = false
|
|
17
|
+
socket.pause()
|
|
18
|
+
await wait(600)
|
|
19
|
+
// shouldn't send this message because the socket would be closed
|
|
20
|
+
socket.send(Buffer.from('error message'), (err) => {
|
|
21
|
+
if (err) throw err
|
|
22
|
+
socket.close(CloseCode.Normal)
|
|
23
|
+
})
|
|
24
|
+
socket.on('close', () => {
|
|
25
|
+
firstWasClosed = true
|
|
26
|
+
})
|
|
27
|
+
} else {
|
|
28
|
+
socket.send(Buffer.from('test message'), (err) => {
|
|
29
|
+
if (err) throw err
|
|
30
|
+
socket.close(CloseCode.Normal)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const wsKeepAlive = new WebSocketKeepAlive({
|
|
36
|
+
getUrl: async () => `ws://localhost:${port}`,
|
|
37
|
+
heartbeatIntervalMs: 500,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const messages: Uint8Array[] = []
|
|
41
|
+
for await (const msg of wsKeepAlive) {
|
|
42
|
+
messages.push(msg)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect(messages).toHaveLength(1)
|
|
46
|
+
expect(Buffer.from(messages[0]).toString()).toBe('test message')
|
|
47
|
+
expect(firstWasClosed).toBe(true)
|
|
48
|
+
server.close()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/index.ts"],"version":"5.8.3"}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./tests/keepalive.test.ts"],"version":"5.8.3"}
|