@atproto/ws-client 0.1.3 → 0.1.4
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 +13 -0
- package/package.json +14 -9
- package/jest.config.cjs +0 -21
- package/src/index.ts +0 -199
- package/tests/keepalive.test.ts +0 -62
- package/tsconfig.build.json +0 -9
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -7
- package/tsconfig.tests.json +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @atproto/ws-client
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#5099](https://github.com/bluesky-social/atproto/pull/5099) [`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Update TypeScript build to rely on references to composite internal projects
|
|
8
|
+
|
|
9
|
+
- [#5099](https://github.com/bluesky-social/atproto/pull/5099) [`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Bundle only necessary files in the NPM tarball, including the `CHANGELOG.md` and `README.md` files (if present).
|
|
10
|
+
|
|
11
|
+
- [#5099](https://github.com/bluesky-social/atproto/pull/5099) [`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Build with `noImplicitAny` enabled
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [[`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07), [`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07), [`b43ec31`](https://github.com/bluesky-social/atproto/commit/b43ec31f247f4461725b01226885f88bd430ca07)]:
|
|
14
|
+
- @atproto/common@0.6.5
|
|
15
|
+
|
|
3
16
|
## 0.1.3
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto/ws-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Websocket client library",
|
|
6
6
|
"keywords": [
|
|
@@ -13,12 +13,24 @@
|
|
|
13
13
|
"url": "https://github.com/bluesky-social/atproto",
|
|
14
14
|
"directory": "packages/ws-client"
|
|
15
15
|
},
|
|
16
|
+
"files": [
|
|
17
|
+
"./dist",
|
|
18
|
+
"./README.md",
|
|
19
|
+
"./CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
16
28
|
"engines": {
|
|
17
29
|
"node": ">=22"
|
|
18
30
|
},
|
|
19
31
|
"dependencies": {
|
|
20
32
|
"ws": "^8.12.0",
|
|
21
|
-
"@atproto/common": "^0.6.
|
|
33
|
+
"@atproto/common": "^0.6.5"
|
|
22
34
|
},
|
|
23
35
|
"devDependencies": {
|
|
24
36
|
"@types/ws": "^8.5.4",
|
|
@@ -26,13 +38,6 @@
|
|
|
26
38
|
"http-terminator": "^3.2.0",
|
|
27
39
|
"jest": "^30.0.0"
|
|
28
40
|
},
|
|
29
|
-
"type": "module",
|
|
30
|
-
"exports": {
|
|
31
|
-
".": {
|
|
32
|
-
"types": "./dist/index.d.ts",
|
|
33
|
-
"default": "./dist/index.js"
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
41
|
"scripts": {
|
|
37
42
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
38
43
|
"build": "tsgo --build tsconfig.build.json"
|
package/jest.config.cjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/** @type {import('jest').Config} */
|
|
2
|
-
module.exports = {
|
|
3
|
-
displayName: 'WebSocket Client',
|
|
4
|
-
transform: {
|
|
5
|
-
'^.+\\.(t|j)s$': [
|
|
6
|
-
'@swc/jest',
|
|
7
|
-
{
|
|
8
|
-
jsc: {
|
|
9
|
-
parser: { syntax: 'typescript', importAttributes: true },
|
|
10
|
-
experimental: { keepImportAttributes: true },
|
|
11
|
-
transform: {},
|
|
12
|
-
},
|
|
13
|
-
module: { type: 'es6' },
|
|
14
|
-
},
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
extensionsToTreatAsEsm: ['.ts'],
|
|
18
|
-
transformIgnorePatterns: [],
|
|
19
|
-
setupFiles: ['<rootDir>/../../test.setup.ts'],
|
|
20
|
-
moduleNameMapper: { '^(\\.\\.?\\/.+)\\.js$': ['$1.ts', '$1.js'] },
|
|
21
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import type { ClientOptions } from 'ws'
|
|
2
|
-
import { WebSocket, createWebSocketStream } from 'ws'
|
|
3
|
-
import { SECOND, isErrnoException, wait } from '@atproto/common'
|
|
4
|
-
|
|
5
|
-
export class WebSocketKeepAlive {
|
|
6
|
-
public ws: WebSocket | null = null
|
|
7
|
-
public initialSetup = true
|
|
8
|
-
public reconnects: number | null = null
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
public opts: ClientOptions & {
|
|
12
|
-
getUrl: () => Promise<string>
|
|
13
|
-
maxReconnectSeconds?: number
|
|
14
|
-
signal?: AbortSignal
|
|
15
|
-
heartbeatIntervalMs?: number
|
|
16
|
-
onReconnect?: () => void
|
|
17
|
-
onReconnectError?: (
|
|
18
|
-
error: unknown,
|
|
19
|
-
n: number,
|
|
20
|
-
initialSetup: boolean,
|
|
21
|
-
) => void
|
|
22
|
-
},
|
|
23
|
-
) {}
|
|
24
|
-
|
|
25
|
-
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
|
|
26
|
-
const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)
|
|
27
|
-
while (true) {
|
|
28
|
-
if (this.reconnects !== null) {
|
|
29
|
-
const duration = this.initialSetup
|
|
30
|
-
? Math.min(1000, maxReconnectMs)
|
|
31
|
-
: backoffMs(this.reconnects++, maxReconnectMs)
|
|
32
|
-
await wait(duration)
|
|
33
|
-
}
|
|
34
|
-
const url = await this.opts.getUrl()
|
|
35
|
-
this.ws = new WebSocket(url, this.opts)
|
|
36
|
-
const ac = new AbortController()
|
|
37
|
-
if (this.opts.signal) {
|
|
38
|
-
forwardSignal(this.opts.signal, ac)
|
|
39
|
-
}
|
|
40
|
-
this.ws.once('open', () => {
|
|
41
|
-
if (!this.initialSetup && this.opts.onReconnect) {
|
|
42
|
-
this.opts.onReconnect()
|
|
43
|
-
}
|
|
44
|
-
this.initialSetup = false
|
|
45
|
-
this.reconnects = 0
|
|
46
|
-
if (this.ws) {
|
|
47
|
-
this.startHeartbeat(this.ws)
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
this.ws.once('close', (code, reason) => {
|
|
51
|
-
if (code === CloseCode.Abnormal) {
|
|
52
|
-
// Forward into an error to distinguish from a clean close
|
|
53
|
-
ac.abort(
|
|
54
|
-
new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const wsStream = createWebSocketStream(this.ws, {
|
|
61
|
-
signal: ac.signal,
|
|
62
|
-
readableObjectMode: true, // Ensures frame bytes don't get buffered/combined together
|
|
63
|
-
})
|
|
64
|
-
for await (const chunk of wsStream) {
|
|
65
|
-
yield chunk
|
|
66
|
-
}
|
|
67
|
-
} catch (_err) {
|
|
68
|
-
const err =
|
|
69
|
-
isErrnoException(_err) && _err.code === 'ABORT_ERR'
|
|
70
|
-
? _err.cause
|
|
71
|
-
: _err
|
|
72
|
-
if (err instanceof DisconnectError) {
|
|
73
|
-
// We cleanly end the connection
|
|
74
|
-
this.ws?.close(err.wsCode)
|
|
75
|
-
break
|
|
76
|
-
}
|
|
77
|
-
this.ws?.close() // No-ops if already closed or closing
|
|
78
|
-
if (isReconnectable(err)) {
|
|
79
|
-
this.reconnects ??= 0 // Never reconnect with a null
|
|
80
|
-
this.opts.onReconnectError?.(err, this.reconnects, this.initialSetup)
|
|
81
|
-
continue
|
|
82
|
-
} else {
|
|
83
|
-
throw err
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
break // Other side cleanly ended stream and disconnected
|
|
87
|
-
}
|
|
88
|
-
}
|
|
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
|
-
|
|
110
|
-
startHeartbeat(ws: WebSocket) {
|
|
111
|
-
let isAlive = true
|
|
112
|
-
let heartbeatInterval: NodeJS.Timeout | null = null
|
|
113
|
-
|
|
114
|
-
const checkAlive = () => {
|
|
115
|
-
if (!isAlive) {
|
|
116
|
-
return ws.terminate()
|
|
117
|
-
}
|
|
118
|
-
isAlive = false // expect websocket to no longer be alive unless we receive a "pong" within the interval
|
|
119
|
-
ws.ping()
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
checkAlive()
|
|
123
|
-
heartbeatInterval = setInterval(
|
|
124
|
-
checkAlive,
|
|
125
|
-
this.opts.heartbeatIntervalMs ?? 10 * SECOND,
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
ws.on('pong', () => {
|
|
129
|
-
isAlive = true
|
|
130
|
-
})
|
|
131
|
-
ws.once('close', () => {
|
|
132
|
-
if (heartbeatInterval) {
|
|
133
|
-
clearInterval(heartbeatInterval)
|
|
134
|
-
heartbeatInterval = null
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export default WebSocketKeepAlive
|
|
141
|
-
|
|
142
|
-
class AbnormalCloseError extends Error {
|
|
143
|
-
code = 'EWSABNORMALCLOSE'
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export class DisconnectError extends Error {
|
|
147
|
-
constructor(
|
|
148
|
-
public wsCode: CloseCode = CloseCode.Policy,
|
|
149
|
-
public xrpcCode?: string,
|
|
150
|
-
) {
|
|
151
|
-
super()
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
156
|
-
export enum CloseCode {
|
|
157
|
-
Normal = 1000,
|
|
158
|
-
Abnormal = 1006,
|
|
159
|
-
Policy = 1008,
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function isReconnectable(err: unknown): boolean {
|
|
163
|
-
// Network errors are reconnectable.
|
|
164
|
-
// AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
|
|
165
|
-
// @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
|
|
166
|
-
// an invalid message is not current reconnectable, but the user can decide to skip them.
|
|
167
|
-
if (isErrnoException(err) && typeof err.code === 'string') {
|
|
168
|
-
return networkErrorCodes.includes(err.code)
|
|
169
|
-
}
|
|
170
|
-
return false
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const networkErrorCodes = [
|
|
174
|
-
'EWSABNORMALCLOSE',
|
|
175
|
-
'ECONNRESET',
|
|
176
|
-
'ECONNREFUSED',
|
|
177
|
-
'ECONNABORTED',
|
|
178
|
-
'EPIPE',
|
|
179
|
-
'ETIMEDOUT',
|
|
180
|
-
'ECANCELED',
|
|
181
|
-
]
|
|
182
|
-
|
|
183
|
-
function backoffMs(n: number, maxMs: number) {
|
|
184
|
-
const baseSec = Math.pow(2, n) // 1, 2, 4, ...
|
|
185
|
-
const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds
|
|
186
|
-
const ms = 1000 * (baseSec + randSec)
|
|
187
|
-
return Math.min(ms, maxMs)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function forwardSignal(signal: AbortSignal, ac: AbortController) {
|
|
191
|
-
if (signal.aborted) {
|
|
192
|
-
return ac.abort(signal.reason)
|
|
193
|
-
} else {
|
|
194
|
-
signal.addEventListener('abort', () => ac.abort(signal.reason), {
|
|
195
|
-
// @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68625
|
|
196
|
-
signal: ac.signal,
|
|
197
|
-
})
|
|
198
|
-
}
|
|
199
|
-
}
|
package/tests/keepalive.test.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { once } from 'node:events'
|
|
2
|
-
import { createServer } from 'node:http'
|
|
3
|
-
import { AddressInfo } from 'node:net'
|
|
4
|
-
// eslint-disable-next-line import/default
|
|
5
|
-
import httpTerminator from 'http-terminator'
|
|
6
|
-
import { WebSocketServer } from 'ws'
|
|
7
|
-
import { wait } from '@atproto/common'
|
|
8
|
-
import { CloseCode, WebSocketKeepAlive } from '../src/index.js'
|
|
9
|
-
|
|
10
|
-
describe('WebSocketKeepAlive', () => {
|
|
11
|
-
it('uses a heartbeat to reconnect if a connection is dropped', async () => {
|
|
12
|
-
// we run a server that, on first connection, pauses for longer than the heartbeat interval (doesn't return "pong"s)
|
|
13
|
-
// on second connection, it sends a message and then closes
|
|
14
|
-
|
|
15
|
-
const server = createServer()
|
|
16
|
-
|
|
17
|
-
// make sure to always close the server (even in case of test failure)
|
|
18
|
-
const { terminate } = httpTerminator.createHttpTerminator({ server })
|
|
19
|
-
await using _ = { [Symbol.asyncDispose]: async () => terminate() }
|
|
20
|
-
|
|
21
|
-
const wsServer = new WebSocketServer({ server })
|
|
22
|
-
let firstConnection = true
|
|
23
|
-
let firstWasClosed = false
|
|
24
|
-
wsServer.on('connection', async (socket) => {
|
|
25
|
-
if (firstConnection === true) {
|
|
26
|
-
firstConnection = false
|
|
27
|
-
socket.pause()
|
|
28
|
-
await wait(600)
|
|
29
|
-
// shouldn't send this message because the socket would be closed
|
|
30
|
-
socket.send(Buffer.from('error message'), (err) => {
|
|
31
|
-
if (err) throw err
|
|
32
|
-
socket.close(CloseCode.Normal)
|
|
33
|
-
})
|
|
34
|
-
socket.on('close', () => {
|
|
35
|
-
firstWasClosed = true
|
|
36
|
-
})
|
|
37
|
-
} else {
|
|
38
|
-
socket.send(Buffer.from('test message'), (err) => {
|
|
39
|
-
if (err) throw err
|
|
40
|
-
socket.close(CloseCode.Normal)
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
await once(server.listen(0), 'listening')
|
|
46
|
-
const port = (server.address() as AddressInfo).port
|
|
47
|
-
|
|
48
|
-
const wsKeepAlive = new WebSocketKeepAlive({
|
|
49
|
-
getUrl: async () => `ws://localhost:${port}`,
|
|
50
|
-
heartbeatIntervalMs: 500,
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const messages: Uint8Array[] = []
|
|
54
|
-
for await (const msg of wsKeepAlive) {
|
|
55
|
-
messages.push(msg)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
expect(messages).toHaveLength(1)
|
|
59
|
-
expect(Buffer.from(messages[0]).toString()).toBe('test message')
|
|
60
|
-
expect(firstWasClosed).toBe(true)
|
|
61
|
-
})
|
|
62
|
-
})
|
package/tsconfig.build.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":"7.0.0-dev.20260614.1","root":["./src/index.ts"]}
|
package/tsconfig.json
DELETED