@atproto/ws-client 0.1.2 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
16
+ ## 0.1.3
17
+
18
+ ### Patch Changes
19
+
20
+ - [#5151](https://github.com/bluesky-social/atproto/pull/5151) [`a51c45d`](https://github.com/bluesky-social/atproto/commit/a51c45d38f6bd7b8765f640e564cf921d52162e7) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Update dependencies
21
+
22
+ - Updated dependencies [[`f2cf8f7`](https://github.com/bluesky-social/atproto/commit/f2cf8f7fc5f3a10847f2e6d785e5fa2244ee8cfb), [`a51c45d`](https://github.com/bluesky-social/atproto/commit/a51c45d38f6bd7b8765f640e564cf921d52162e7), [`f2cf8f7`](https://github.com/bluesky-social/atproto/commit/f2cf8f7fc5f3a10847f2e6d785e5fa2244ee8cfb)]:
23
+ - @atproto/common@0.6.4
24
+
3
25
  ## 0.1.2
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/ws-client",
3
- "version": "0.1.2",
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.3"
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
- }
@@ -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
- })
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig/node.json",
3
- "compilerOptions": {
4
- "noImplicitAny": true,
5
- "rootDir": "./src",
6
- "outDir": "./dist",
7
- },
8
- "include": ["./src"],
9
- }
@@ -1 +0,0 @@
1
- {"version":"7.0.0-dev.20260614.1","root":["./src/index.ts"]}
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "include": [],
3
- "references": [
4
- { "path": "./tsconfig.build.json" },
5
- { "path": "./tsconfig.tests.json" },
6
- ],
7
- }
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig/tests.json",
3
- "compilerOptions": {
4
- "rootDir": ".",
5
- },
6
- "include": ["./tests"],
7
- }