@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 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
+ [![NPM](https://img.shields.io/npm/v/@atproto/ws-client)](https://www.npmjs.com/package/@atproto/ws-client)
6
+ [![Github CI Status](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml/badge.svg)](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.
@@ -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":"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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"]}
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,9 @@
1
+ {
2
+ "extends": "../../tsconfig/node.json",
3
+ "compilerOptions": {
4
+ "noImplicitAny": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist"
7
+ },
8
+ "include": ["./src"]
9
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/index.ts"],"version":"5.8.3"}
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "include": [],
3
+ "references": [
4
+ { "path": "./tsconfig.build.json" },
5
+ { "path": "./tsconfig.tests.json" }
6
+ ]
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig/tests.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["./tests"]
7
+ }
@@ -0,0 +1 @@
1
+ {"root":["./tests/keepalive.test.ts"],"version":"5.8.3"}