sleepy-socket 0.0.1 → 0.6.1
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/README.md +280 -1
- package/package.json +1 -1
- package/src/index.js +537 -1
- package/src/messages.js +21 -0
- package/src/utils.js +19 -0
package/README.md
CHANGED
|
@@ -1,3 +1,282 @@
|
|
|
1
1
|
# `sleepy-socket`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A WebSocket client for talking to `sleepy-serv` servers
|
|
4
|
+
|
|
5
|
+
## Important Notes
|
|
6
|
+
|
|
7
|
+
- This package has zero dependencies, and runs in browsers as well as in [`bun.sh`](https://bun.sh).
|
|
8
|
+
- This package is the client half of `sleepy-serv`. It expects a `sleepy-serv` server on the other end.
|
|
9
|
+
- Requests are made over a single WebSocket connection, but they're modeled as REST-ful calls with methods, routes, headers, and status codes.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add sleepy-socket
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Getting Started
|
|
18
|
+
|
|
19
|
+
Here's a minimalist example on how to connect and make a request:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import SleepySocketClient from 'sleepy-socket'
|
|
23
|
+
|
|
24
|
+
const client = await SleepySocketClient.connect('localhost', 3000)
|
|
25
|
+
const res = await client.get('/users')
|
|
26
|
+
|
|
27
|
+
console.log(res.status) // 200
|
|
28
|
+
console.log(res.body) // the parsed response body
|
|
29
|
+
|
|
30
|
+
await client.close()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`connect()` is the only supported way to create a client. It's `async` because it doesn't resolve until the connection is fully established: it requests a ticket over HTTP, opens the WebSocket, and waits for the server's `welcome` message. Once it resolves, the client is ready to make requests.
|
|
34
|
+
|
|
35
|
+
### Making Requests
|
|
36
|
+
|
|
37
|
+
There's one method per HTTP verb: `head()`, `get()`, `post()`, `put()`, `patch()`, and `delete()`. They all take the same two parameters:
|
|
38
|
+
- `route`: the route to call, such as `/users` or `/users/123`
|
|
39
|
+
- `opts`: an optional object containing `headers`, `query`, and `body`
|
|
40
|
+
|
|
41
|
+
Each one returns a promise that resolves to the full _response message_, not just the body:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const res = await client.get('/users/123')
|
|
45
|
+
|
|
46
|
+
console.log(res)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That gives you an object shaped like this:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
{
|
|
53
|
+
id: '2b1f...', // uuid, matches the request that produced it
|
|
54
|
+
clientId: '9c4e...', // same as `client.id`
|
|
55
|
+
type: 'response',
|
|
56
|
+
status: 200,
|
|
57
|
+
timestamp: '2026-07-19T...',
|
|
58
|
+
headers: { 'content-type': 'application/json;charset=utf-8' },
|
|
59
|
+
body: { name: 'ada' },
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Note that a failing status does _not_ reject the promise. A _404 NotFound_ or _500 InternalServerError_ resolves normally, with the status on `res.status`. Only transport-level problems reject, such as a timeout or the socket closing mid-flight. This means you check `res.status` rather than wrapping calls in `try`/`catch`:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const res = await client.get('/users/123')
|
|
67
|
+
|
|
68
|
+
if (res.status === 404) {
|
|
69
|
+
console.log('no such user')
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Request Options
|
|
74
|
+
|
|
75
|
+
The second parameter to any request method can contain these optional properties:
|
|
76
|
+
- `headers`: a `Headers` instance. Passing anything else throws a `TypeError`.
|
|
77
|
+
- `query`: a plain object of query string values
|
|
78
|
+
- `body`: the request body, which can be any JSON-serializable value
|
|
79
|
+
|
|
80
|
+
Here's an example that uses all three:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
const res = await client.post('/users', {
|
|
84
|
+
headers: new Headers({
|
|
85
|
+
authorization: `Bearer ${token}`,
|
|
86
|
+
}),
|
|
87
|
+
query: { dryRun: true },
|
|
88
|
+
body: {
|
|
89
|
+
name: 'ada',
|
|
90
|
+
count: 3,
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
When `body` is a non-null object, the client sets `content-type` to `application/json;charset=utf-8` for you, unless you already set a `content-type` header yourself.
|
|
96
|
+
|
|
97
|
+
### Notifications
|
|
98
|
+
|
|
99
|
+
Servers can push messages that aren't replies to anything. Those arrive as notifications, and you subscribe to them with `on()`:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
client.on('notification', message => {
|
|
103
|
+
console.log(message.event) // 'state_changed'
|
|
104
|
+
console.log(message.body) // { score: 1 }
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
There's one thing worth pointing out here: `'notification'` is the only event name the client emits. The server's own event name lives on the message's `event` property, so you branch on that inside your handler:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
client.on('notification', message => {
|
|
112
|
+
switch (message.event) {
|
|
113
|
+
case 'state_changed':
|
|
114
|
+
return applyState(message.body)
|
|
115
|
+
|
|
116
|
+
case 'user_joined':
|
|
117
|
+
return addUser(message.body)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If one of your handlers throws, the error is caught and logged, and the remaining handlers still receive the message.
|
|
123
|
+
|
|
124
|
+
### Reconnection
|
|
125
|
+
|
|
126
|
+
The client reconnects automatically when the socket drops. It reclaims its previous session, so `client.id` stays the same across a reconnect and you don't need to re-establish application state.
|
|
127
|
+
|
|
128
|
+
You can tune the backoff:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const client = await SleepySocketClient.connect('localhost', 3000, {
|
|
132
|
+
reconnect: {
|
|
133
|
+
minDelay: 1_000,
|
|
134
|
+
maxDelay: 10_000,
|
|
135
|
+
factor: 2,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Set `reconnect` to `false` to turn it off entirely:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
const client = await SleepySocketClient.connect('localhost', 3000, {
|
|
144
|
+
reconnect: false,
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Note that only the literal value `false` disables reconnection. Any other value falls back to the defaults.
|
|
149
|
+
|
|
150
|
+
### Response Queueing
|
|
151
|
+
|
|
152
|
+
Requests are sent over one socket, so responses can come back in a different order than they were sent. The `queue` option controls how the client hands those responses back to you.
|
|
153
|
+
|
|
154
|
+
For example, if you fire three requests that take 300ms, 100ms, and 200ms:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
import SleepySocketClient, { QUEUE } from 'sleepy-socket'
|
|
158
|
+
|
|
159
|
+
const client = await SleepySocketClient.connect('localhost', 3000, {
|
|
160
|
+
queue: QUEUE.FIFO,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const results = []
|
|
164
|
+
|
|
165
|
+
await Promise.all([
|
|
166
|
+
client.get('/', { query: { delay: 300 } }).then(() => results.push(1)),
|
|
167
|
+
client.get('/', { query: { delay: 100 } }).then(() => results.push(2)),
|
|
168
|
+
client.get('/', { query: { delay: 200 } }).then(() => results.push(3)),
|
|
169
|
+
])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The three queue types resolve those promises differently:
|
|
173
|
+
- `QUEUE.NONE`: each promise resolves the moment its response arrives, so `results` is `[2, 3, 1]`. This is the default.
|
|
174
|
+
- `QUEUE.FIFO`: responses are held back until every earlier request has resolved, so `results` is `[1, 2, 3]`, matching the order you sent them.
|
|
175
|
+
- `QUEUE.LIFO`: responses drain from the most recent request backwards, so `results` is `[3, 2, 1]`.
|
|
176
|
+
|
|
177
|
+
`QUEUE.NONE` is the right choice most of the time. `QUEUE.FIFO` is useful when responses have to be applied in the order they were requested.
|
|
178
|
+
|
|
179
|
+
### Mount Paths
|
|
180
|
+
|
|
181
|
+
If the server was created with a `mountPath`, give the client the same value:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const client = await SleepySocketClient.connect('localhost', 3000, {
|
|
185
|
+
mountPath: '/api/v2',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const res = await client.get('/users')
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The routes you pass to request methods stay mount-relative. The client joins the prefix on internally, so `/users` above is sent as `/api/v2/users`.
|
|
192
|
+
|
|
193
|
+
## API
|
|
194
|
+
|
|
195
|
+
### `SleepySocketClient.connect(host, port, opts)`
|
|
196
|
+
|
|
197
|
+
This static method creates a client, connects it, and resolves once the server has acknowledged the connection. It's the only supported way to construct a client.
|
|
198
|
+
|
|
199
|
+
The parameters are:
|
|
200
|
+
- `host`: the hostname, without a scheme, such as `'localhost'`
|
|
201
|
+
- `port`: the port number
|
|
202
|
+
- `opts`: an optional options object
|
|
203
|
+
|
|
204
|
+
The `opts` object can contain these optional properties:
|
|
205
|
+
- `queue`: how responses are handed back, one of `QUEUE.NONE`, `QUEUE.FIFO`, or `QUEUE.LIFO`. Defaults to `QUEUE.NONE`. An unrecognized value throws a `RangeError`.
|
|
206
|
+
- `secure`: set to `true` to use `https` and `wss` instead of `http` and `ws`. Defaults to `false`.
|
|
207
|
+
- `timeout`: how long to wait, in milliseconds, both for the initial connection and for each individual request. Defaults to `30_000`.
|
|
208
|
+
- `serverTimeout`: how long the client tolerates silence from the server, in milliseconds, before it considers the connection dead and closes it. Defaults to `120_000`.
|
|
209
|
+
- `mountPath`: the server's mount path prefix. Defaults to `''`.
|
|
210
|
+
- `reconnect`: an options object for reconnection behavior, or `false` to disable it
|
|
211
|
+
|
|
212
|
+
The `reconnect` object can contain these optional properties:
|
|
213
|
+
- `minDelay`: the starting backoff delay in milliseconds. Defaults to `500`.
|
|
214
|
+
- `maxDelay`: the maximum backoff delay in milliseconds. Defaults to `30_000`.
|
|
215
|
+
- `factor`: the exponential multiplier applied to the delay after each failed attempt. Defaults to `2`.
|
|
216
|
+
- `random`: the jitter source. Defaults to `Math.random`.
|
|
217
|
+
|
|
218
|
+
### Request Methods
|
|
219
|
+
|
|
220
|
+
`head(route, opts)`, `get(route, opts)`, `post(route, opts)`, `put(route, opts)`, `patch(route, opts)`, and `delete(route, opts)` all send a request and return a promise resolving to the response message.
|
|
221
|
+
|
|
222
|
+
They throw synchronously if the client isn't connected, and their promises reject on timeout or if the socket closes before the response arrives.
|
|
223
|
+
|
|
224
|
+
### `on(event, handler)`
|
|
225
|
+
|
|
226
|
+
Registers a handler for an event. The only event emitted is `'notification'`. Registering the same function twice is a no-op, since handlers are stored in a set.
|
|
227
|
+
|
|
228
|
+
### `off(event, handler)`
|
|
229
|
+
|
|
230
|
+
Removes a previously registered handler. It's safe to call with a handler that was never registered.
|
|
231
|
+
|
|
232
|
+
### `close()`
|
|
233
|
+
|
|
234
|
+
Closes the connection and rejects any in-flight requests. It returns a promise, so it's worth awaiting before your process exits.
|
|
235
|
+
|
|
236
|
+
Note that closing is permanent. There's no reopen, and calling `close()` a second time throws. If you're calling it in a `finally` block, guard it with `isConnected`:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
try {
|
|
240
|
+
await doWork(client)
|
|
241
|
+
} finally {
|
|
242
|
+
if (client.isConnected) {
|
|
243
|
+
await client.close()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Properties
|
|
249
|
+
|
|
250
|
+
All of these are read-only:
|
|
251
|
+
- `id`: the server-assigned client id, which survives reconnects
|
|
252
|
+
- `isConnected`: whether the client is currently connected and ready for requests
|
|
253
|
+
- `socket`: the underlying `WebSocket`, or `null` while disconnected
|
|
254
|
+
- `connectionData`: whatever payload the server attached when the connection was established. This is where application data such as an auth token shows up.
|
|
255
|
+
- `token`: the reclaim token used internally to restore the session after a drop. This is not an application auth token; that would be on `connectionData`.
|
|
256
|
+
- `queueType`: the configured queue type
|
|
257
|
+
- `secure`: whether the connection uses `wss`
|
|
258
|
+
- `timeout`: the configured request timeout
|
|
259
|
+
- `serverTimeout`: the configured server silence timeout
|
|
260
|
+
- `heartbeatInterval`: how often the client sends heartbeats. This is dictated by the server, not configured by you.
|
|
261
|
+
- `mountPath`: the configured mount path
|
|
262
|
+
|
|
263
|
+
### `QUEUE`
|
|
264
|
+
|
|
265
|
+
Contains the valid values for the `queue` option: `QUEUE.NONE`, `QUEUE.FIFO`, and `QUEUE.LIFO`.
|
|
266
|
+
|
|
267
|
+
### `TYPES`
|
|
268
|
+
|
|
269
|
+
Contains the message type names used on the wire: `TYPES.WELCOME`, `TYPES.HEARTBEAT`, `TYPES.REQUEST`, `TYPES.RESPONSE`, and `TYPES.NOTIFICATION`. A response message's `type` is always `TYPES.RESPONSE`, and a notification's is always `TYPES.NOTIFICATION`.
|
|
270
|
+
|
|
271
|
+
## Errors
|
|
272
|
+
|
|
273
|
+
Most failures surface as thrown errors or rejected promises:
|
|
274
|
+
- `Invalid queue type: <value>`: a `RangeError` thrown by `connect()` when `queue` isn't a valid `QUEUE` value. This is thrown before any network call is made.
|
|
275
|
+
- `Connection failed.`: the connection couldn't be established
|
|
276
|
+
- `Connection timed out.`: the connection wasn't established within `timeout` milliseconds
|
|
277
|
+
- `opts.headers must be a Headers instance`: a `TypeError` thrown when a request's `headers` option isn't a `Headers` object
|
|
278
|
+
- `Socket is closed`: thrown when you call a request method while disconnected, or when you call `close()` more than once
|
|
279
|
+
- `Request timed out.`: a request didn't get a response within `timeout` milliseconds
|
|
280
|
+
- `Socket closed.`: the socket closed while requests were still in flight. Every pending request rejects with this.
|
|
281
|
+
|
|
282
|
+
Remember that these cover transport failures only. An error _response_ from the server, such as a _404 NotFound_, resolves normally with the status on `res.status`.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1 +1,537 @@
|
|
|
1
|
-
|
|
1
|
+
import { TYPES, createMessage } from './messages'
|
|
2
|
+
import { joinRoute } from './utils'
|
|
3
|
+
|
|
4
|
+
export * from './messages'
|
|
5
|
+
export * from './utils'
|
|
6
|
+
|
|
7
|
+
export const QUEUE = {
|
|
8
|
+
NONE: 'none',
|
|
9
|
+
FIFO: 'fifo',
|
|
10
|
+
LIFO: 'lifo',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RECONNECT_JITTER = 0.5
|
|
14
|
+
const JSON_CONTENT_TYPE = 'application/json;charset=utf-8'
|
|
15
|
+
|
|
16
|
+
export default class SleepySocketClient {
|
|
17
|
+
#id = null
|
|
18
|
+
#queueType = QUEUE.NONE
|
|
19
|
+
#ready = false
|
|
20
|
+
#closing = false
|
|
21
|
+
#secure = false
|
|
22
|
+
#timeout = 30_000
|
|
23
|
+
#serverTimeout = 120_000
|
|
24
|
+
#heartbeatInterval = 30_000
|
|
25
|
+
#mountPath = ''
|
|
26
|
+
#host = null
|
|
27
|
+
#port = null
|
|
28
|
+
#token = null
|
|
29
|
+
#socket = null
|
|
30
|
+
#random = Math.random
|
|
31
|
+
#livenessTimer = null
|
|
32
|
+
#heartbeatTimer = null
|
|
33
|
+
#reconnectTimer = null
|
|
34
|
+
#reconnectConfig = null
|
|
35
|
+
#connectionData = null
|
|
36
|
+
#listeners = new Map()
|
|
37
|
+
#dispatchedMessages = []
|
|
38
|
+
|
|
39
|
+
get id () {
|
|
40
|
+
return this.#id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get isConnected () {
|
|
44
|
+
return this.#ready
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get queueType () {
|
|
48
|
+
return this.#queueType
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get secure () {
|
|
52
|
+
return this.#secure
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get timeout () {
|
|
56
|
+
return this.#timeout
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get heartbeatInterval () {
|
|
60
|
+
return this.#heartbeatInterval
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get serverTimeout () {
|
|
64
|
+
return this.#serverTimeout
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get token () {
|
|
68
|
+
return this.#token
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get mountPath () {
|
|
72
|
+
return this.#mountPath
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get socket () {
|
|
76
|
+
return this.#socket
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get connectionData () {
|
|
80
|
+
return this.#connectionData
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async connect (host, port, opts = {}) {
|
|
84
|
+
if (opts.queue && !Object.values(QUEUE).includes(opts.queue)) {
|
|
85
|
+
throw new RangeError(`Invalid queue type: ${opts.queue}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const client = new this()
|
|
89
|
+
const hasReconnect = opts.reconnect && typeof opts.reconnect === 'object'
|
|
90
|
+
const reconnect = hasReconnect ? opts.reconnect : {}
|
|
91
|
+
|
|
92
|
+
client.#host = host
|
|
93
|
+
client.#port = port
|
|
94
|
+
client.#queueType = opts.queue ?? QUEUE.NONE
|
|
95
|
+
client.#secure = opts.secure ?? false
|
|
96
|
+
client.#timeout = opts.timeout ?? 30_000
|
|
97
|
+
client.#serverTimeout = opts.serverTimeout ?? 120_000
|
|
98
|
+
client.#mountPath = opts.mountPath ?? ''
|
|
99
|
+
|
|
100
|
+
if (opts.reconnect !== false) {
|
|
101
|
+
client.#reconnectConfig = {
|
|
102
|
+
minDelay: reconnect.minDelay ?? 500,
|
|
103
|
+
maxDelay: reconnect.maxDelay ?? 30_000,
|
|
104
|
+
factor: reconnect.factor ?? 2,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
client.#random = reconnect.random ?? Math.random
|
|
109
|
+
|
|
110
|
+
await client.#establish()
|
|
111
|
+
|
|
112
|
+
return client
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#baseUrl () {
|
|
116
|
+
const protocol = this.#secure ? 'https' : 'http'
|
|
117
|
+
|
|
118
|
+
return `${protocol}://${this.#host}:${this.#port}${this.#mountPath}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #createTicket () {
|
|
122
|
+
const response = await fetch(`${this.#baseUrl()}/ws`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return response.json()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #reclaimTicket () {
|
|
130
|
+
const url = `${this.#baseUrl()}/ws/${this.#id}`
|
|
131
|
+
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
headers: {
|
|
135
|
+
authorization: `Bearer ${this.#token}`,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return response.json()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async #requestTicket () {
|
|
147
|
+
if (this.#id && this.#token) {
|
|
148
|
+
const reclaimed = await this.#reclaimTicket()
|
|
149
|
+
|
|
150
|
+
if (reclaimed) {
|
|
151
|
+
return reclaimed
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return this.#createTicket()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#socketUrl (ticket) {
|
|
159
|
+
const protocol = this.#secure ? 'wss' : 'ws'
|
|
160
|
+
const authority = `${this.#host}:${this.#port}${this.#mountPath}`
|
|
161
|
+
|
|
162
|
+
return `${protocol}://${authority}/ws?ticket=${ticket}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#openSocket (res, succeed, fail) {
|
|
166
|
+
this.#socket = new WebSocket(this.#socketUrl(res.ticket))
|
|
167
|
+
this.#connectionData = res.data
|
|
168
|
+
|
|
169
|
+
const onError = () => fail('Connection failed.')
|
|
170
|
+
|
|
171
|
+
const onWelcome = event => {
|
|
172
|
+
const message = JSON.parse(event.data)
|
|
173
|
+
|
|
174
|
+
this.#socket.removeEventListener('message', onWelcome)
|
|
175
|
+
|
|
176
|
+
if (message.type !== TYPES.WELCOME) {
|
|
177
|
+
fail('Expected a welcome message.')
|
|
178
|
+
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#id = message.clientId
|
|
183
|
+
this.#token = message.body.token
|
|
184
|
+
this.#heartbeatInterval = message.body.heartbeatInterval
|
|
185
|
+
|
|
186
|
+
this.#socket.addEventListener('message', ev => this.#handleMessage(ev))
|
|
187
|
+
this.#socket.addEventListener('close', ev => this.#handleClose(ev))
|
|
188
|
+
|
|
189
|
+
this.#startHeartbeat()
|
|
190
|
+
this.#armLiveness()
|
|
191
|
+
|
|
192
|
+
this.#ready = true
|
|
193
|
+
|
|
194
|
+
succeed()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.#socket.addEventListener('open', () => {
|
|
198
|
+
this.#socket.removeEventListener('error', onError)
|
|
199
|
+
this.#socket.addEventListener('message', onWelcome)
|
|
200
|
+
}, { once: true })
|
|
201
|
+
|
|
202
|
+
this.#socket.addEventListener('error', onError)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#establish () {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
let settled = false
|
|
208
|
+
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
if (settled) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
settled = true
|
|
215
|
+
|
|
216
|
+
this.#socket?.close()
|
|
217
|
+
reject(new Error('Connection timed out.'))
|
|
218
|
+
}, this.#timeout)
|
|
219
|
+
|
|
220
|
+
const fail = message => {
|
|
221
|
+
if (settled) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
settled = true
|
|
226
|
+
|
|
227
|
+
clearTimeout(timer)
|
|
228
|
+
reject(new Error(message))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const succeed = () => {
|
|
232
|
+
if (settled) {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
settled = true
|
|
237
|
+
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
resolve()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.#requestTicket()
|
|
243
|
+
.then(ticketData => this.#openSocket(ticketData, succeed, fail))
|
|
244
|
+
.catch(() => fail('Connection failed.'))
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#armLiveness () {
|
|
249
|
+
clearTimeout(this.#livenessTimer)
|
|
250
|
+
|
|
251
|
+
this.#livenessTimer = setTimeout(() => {
|
|
252
|
+
this.#socket?.close()
|
|
253
|
+
}, this.serverTimeout)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#scheduleReconnect (attempt) {
|
|
257
|
+
const { minDelay, maxDelay, factor } = this.#reconnectConfig
|
|
258
|
+
const base = Math.min(minDelay * factor ** attempt, maxDelay)
|
|
259
|
+
const delay = base * (1 + this.#random() * RECONNECT_JITTER)
|
|
260
|
+
|
|
261
|
+
this.#reconnectTimer = setTimeout(async () => {
|
|
262
|
+
this.#reconnectTimer = null
|
|
263
|
+
|
|
264
|
+
if (this.#closing) {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
await this.#establish()
|
|
270
|
+
} catch {
|
|
271
|
+
if (this.#closing) {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.#scheduleReconnect(attempt + 1)
|
|
276
|
+
}
|
|
277
|
+
}, delay)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#startHeartbeat () {
|
|
281
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
282
|
+
const message = createMessage(this.#id, TYPES.HEARTBEAT)
|
|
283
|
+
|
|
284
|
+
this.#socket.send(JSON.stringify(message))
|
|
285
|
+
}, this.#heartbeatInterval)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#stopHeartbeat () {
|
|
289
|
+
clearInterval(this.#heartbeatTimer)
|
|
290
|
+
|
|
291
|
+
this.#heartbeatTimer = null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#normalizeRequestOpts (opts) {
|
|
295
|
+
if (opts.headers !== undefined && !(opts.headers instanceof Headers)) {
|
|
296
|
+
throw new TypeError('opts.headers must be a Headers instance')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const headers = opts.headers ?? new Headers()
|
|
300
|
+
const query = opts.query ?? {}
|
|
301
|
+
const body = opts.body ?? null
|
|
302
|
+
|
|
303
|
+
const isJsonBody = body !== null && typeof body === 'object'
|
|
304
|
+
|
|
305
|
+
if (isJsonBody && !headers.has('content-type')) {
|
|
306
|
+
headers.set('content-type', JSON_CONTENT_TYPE)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
headers,
|
|
311
|
+
query,
|
|
312
|
+
body,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#sendRequest (method, route, opts = {}) {
|
|
317
|
+
if (!this.#ready) {
|
|
318
|
+
throw new Error('Socket is closed')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const { headers, query, body } = this.#normalizeRequestOpts(opts)
|
|
322
|
+
const fullRoute = joinRoute(this.#mountPath, route)
|
|
323
|
+
|
|
324
|
+
const message = createMessage(this.#id, TYPES.REQUEST, {
|
|
325
|
+
method,
|
|
326
|
+
query,
|
|
327
|
+
body,
|
|
328
|
+
route: fullRoute,
|
|
329
|
+
headers: Object.fromEntries(headers),
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
const timer = setTimeout(() => {
|
|
334
|
+
const index = this.#dispatchedMessages.findIndex(item =>
|
|
335
|
+
item.id === message.id,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if (index !== -1) {
|
|
339
|
+
this.#dispatchedMessages.splice(index, 1)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
reject(new Error('Request timed out.'))
|
|
343
|
+
}, this.#timeout)
|
|
344
|
+
|
|
345
|
+
this.#dispatchedMessages.push({
|
|
346
|
+
id: message.id,
|
|
347
|
+
resolve,
|
|
348
|
+
reject,
|
|
349
|
+
timer,
|
|
350
|
+
ready: false,
|
|
351
|
+
response: null,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
this.#socket.send(JSON.stringify(message))
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#handleClose (event) {
|
|
359
|
+
this.#ready = false
|
|
360
|
+
|
|
361
|
+
this.#stopHeartbeat()
|
|
362
|
+
clearTimeout(this.#livenessTimer)
|
|
363
|
+
|
|
364
|
+
for (const entry of this.#dispatchedMessages) {
|
|
365
|
+
clearTimeout(entry.timer)
|
|
366
|
+
entry.reject(new Error('Socket closed.'))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.#dispatchedMessages = []
|
|
370
|
+
this.#socket = null
|
|
371
|
+
|
|
372
|
+
if (this.#closing) {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!this.#reconnectConfig) {
|
|
377
|
+
if (!event.wasClean) {
|
|
378
|
+
throw new Error(`Socket closed unexpectedly (code: ${event.code}).`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.#scheduleReconnect(0)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#handleRequest (data) {
|
|
388
|
+
const entry = this.#dispatchedMessages.find(item => item.id === data.id)
|
|
389
|
+
|
|
390
|
+
if (!entry) {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
clearTimeout(entry.timer)
|
|
395
|
+
|
|
396
|
+
entry.response = data
|
|
397
|
+
entry.ready = true
|
|
398
|
+
|
|
399
|
+
this.#drain()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#handleNotification (data) {
|
|
403
|
+
this.#emit('notification', data)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#handleMessage (event) {
|
|
407
|
+
this.#armLiveness()
|
|
408
|
+
|
|
409
|
+
const data = JSON.parse(event.data)
|
|
410
|
+
|
|
411
|
+
switch (data.type) {
|
|
412
|
+
case TYPES.HEARTBEAT:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
case TYPES.RESPONSE:
|
|
416
|
+
return this.#handleRequest(data)
|
|
417
|
+
|
|
418
|
+
case TYPES.NOTIFICATION:
|
|
419
|
+
return this.#handleNotification(data)
|
|
420
|
+
|
|
421
|
+
default:
|
|
422
|
+
throw new RangeError(`Unknown message type: "${data.type}"`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#processNone () {
|
|
427
|
+
this.#dispatchedMessages = this.#dispatchedMessages.filter(entry => {
|
|
428
|
+
if (entry.ready) {
|
|
429
|
+
entry.resolve(entry.response)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return !entry.ready
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#processFifo () {
|
|
437
|
+
while (this.#dispatchedMessages[0]?.ready) {
|
|
438
|
+
const [entry] = this.#dispatchedMessages.splice(0, 1)
|
|
439
|
+
|
|
440
|
+
entry.resolve(entry.response)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#processLifo () {
|
|
445
|
+
while (this.#dispatchedMessages.at(-1)?.ready) {
|
|
446
|
+
const entry = this.#dispatchedMessages.pop()
|
|
447
|
+
|
|
448
|
+
entry.resolve(entry.response)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#drain () {
|
|
453
|
+
switch (this.#queueType) {
|
|
454
|
+
case QUEUE.NONE:
|
|
455
|
+
return this.#processNone()
|
|
456
|
+
|
|
457
|
+
case QUEUE.FIFO:
|
|
458
|
+
return this.#processFifo()
|
|
459
|
+
|
|
460
|
+
case QUEUE.LIFO:
|
|
461
|
+
return this.#processLifo()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#emit (event, payload) {
|
|
466
|
+
const handlers = this.#listeners.get(event)
|
|
467
|
+
|
|
468
|
+
if (!handlers) {
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const handler of handlers) {
|
|
473
|
+
try {
|
|
474
|
+
handler(payload)
|
|
475
|
+
} catch (err) {
|
|
476
|
+
console.error(err)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
on (event, handler) {
|
|
482
|
+
if (!this.#listeners.has(event)) {
|
|
483
|
+
this.#listeners.set(event, new Set())
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this.#listeners.get(event).add(handler)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
off (event, handler) {
|
|
490
|
+
this.#listeners.get(event)?.delete(handler)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async close () {
|
|
494
|
+
if (this.#closing) {
|
|
495
|
+
throw new Error('Socket is closed')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.#closing = true
|
|
499
|
+
this.#ready = false
|
|
500
|
+
|
|
501
|
+
this.#stopHeartbeat()
|
|
502
|
+
clearTimeout(this.#livenessTimer)
|
|
503
|
+
clearTimeout(this.#reconnectTimer)
|
|
504
|
+
|
|
505
|
+
this.#reconnectTimer = null
|
|
506
|
+
|
|
507
|
+
if (this.#socket) {
|
|
508
|
+
await this.#socket.close()
|
|
509
|
+
|
|
510
|
+
this.#socket = null
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
head (route, opts = {}) {
|
|
515
|
+
return this.#sendRequest('HEAD', route, opts)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
get (route, opts = {}) {
|
|
519
|
+
return this.#sendRequest('GET', route, opts)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
post (route, opts = {}) {
|
|
523
|
+
return this.#sendRequest('POST', route, opts)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
put (route, opts = {}) {
|
|
527
|
+
return this.#sendRequest('PUT', route, opts)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
patch (route, opts = {}) {
|
|
531
|
+
return this.#sendRequest('PATCH', route, opts)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
delete (route, opts = {}) {
|
|
535
|
+
return this.#sendRequest('DELETE', route, opts)
|
|
536
|
+
}
|
|
537
|
+
}
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { id } from './utils'
|
|
2
|
+
|
|
3
|
+
export const TYPES = {
|
|
4
|
+
WELCOME: 'welcome',
|
|
5
|
+
HEARTBEAT: 'heartbeat',
|
|
6
|
+
REQUEST: 'request',
|
|
7
|
+
RESPONSE: 'response',
|
|
8
|
+
NOTIFICATION: 'notification',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createMessage (clientId, type, opts = {}) {
|
|
12
|
+
return {
|
|
13
|
+
...opts,
|
|
14
|
+
id: opts.id ?? id(),
|
|
15
|
+
clientId,
|
|
16
|
+
type,
|
|
17
|
+
timestamp: new Date().toISOString(),
|
|
18
|
+
headers: opts.headers ?? new Headers(),
|
|
19
|
+
body: opts.body ?? null,
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let _uuidFn = () => crypto.randomUUID()
|
|
2
|
+
|
|
3
|
+
export function joinRoute (...segments) {
|
|
4
|
+
const joined = segments
|
|
5
|
+
.filter(Boolean)
|
|
6
|
+
.join('/')
|
|
7
|
+
.replace(/\/{2,}/g, '/')
|
|
8
|
+
|
|
9
|
+
return joined.startsWith('/') ? joined : `/${joined}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export function id () {
|
|
14
|
+
return _uuidFn()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setIdGenerator (fn) {
|
|
18
|
+
_uuidFn = fn
|
|
19
|
+
}
|