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 CHANGED
@@ -1,3 +1,282 @@
1
1
  # `sleepy-socket`
2
2
 
3
- Placeholder for the `sleepy-socket` package.
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
@@ -2,7 +2,7 @@
2
2
  "name": "sleepy-socket",
3
3
  "description": "A dependency-free WebSocket client for sleepy-serv",
4
4
  "author": "Travis J True",
5
- "version": "0.0.1",
5
+ "version": "0.6.1",
6
6
  "exports": "./src/index.js",
7
7
  "type": "module",
8
8
  "repository": {
package/src/index.js CHANGED
@@ -1 +1,537 @@
1
- console.log('This is a placeholder for the sleepy-socket client')
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
+ }
@@ -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
+ }