sleepy-serv 0.6.2 → 0.7.0

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.
@@ -2,10 +2,17 @@ import Ajv from 'ajv'
2
2
  import addFormats from 'ajv-formats'
3
3
  import crypto from 'node:crypto'
4
4
 
5
- import { toSegments, formatError, executeMiddlewareChain } from './utils'
6
- import { TYPES, createMessage, validateMessage } from './messages'
5
+ import { MessageType, createMessage, validateMessage } from './messages'
7
6
 
8
7
  import {
8
+ StatusCode,
9
+ toSegments,
10
+ formatError,
11
+ executeMiddlewareChain,
12
+ } from './utils'
13
+
14
+ import {
15
+ RequestError,
9
16
  NotFoundError,
10
17
  UnauthorizedError,
11
18
  MethodNotAllowedError,
@@ -14,6 +21,106 @@ import {
14
21
  ServiceUnavailableError,
15
22
  } from './errors'
16
23
 
24
+ import type { WebSocketHandler } from 'bun'
25
+ import type { ValidateFunction } from 'ajv'
26
+
27
+ import type {
28
+ HttpMethod,
29
+ Request,
30
+ WebSocketRequest,
31
+ Middleware,
32
+ SocketData,
33
+ AppOptions,
34
+ } from './utils'
35
+
36
+ import type {
37
+ BaseMessage,
38
+ RawMessage,
39
+ RequestMessage,
40
+ ResponseMessage,
41
+ } from './messages'
42
+
43
+ export type Ticket = {
44
+ clientId: string
45
+ expiresAt: number
46
+ }
47
+
48
+ export type SocketConnection = {
49
+ data: SocketData
50
+ send: (data: string) => unknown
51
+ close: () => void
52
+ }
53
+
54
+ export type ActiveSession = {
55
+ token: string
56
+ ws: SocketConnection
57
+ }
58
+
59
+ export type InactiveSession = {
60
+ token: string
61
+ expiresAt: number
62
+ }
63
+
64
+ export type Session = ActiveSession | InactiveSession
65
+
66
+ export type SocketState = {
67
+ disconnectThreshold: number
68
+ heartbeatInterval: number
69
+ maxTickets: number
70
+ reclaimTtl: number
71
+ ticketTtl: number
72
+ tickets: Map<string, Ticket>
73
+ activeSessions: Map<string, ActiveSession>
74
+ inactiveSessions: Map<string, InactiveSession>
75
+ }
76
+
77
+ export type SocketRoute = {
78
+ method: HttpMethod
79
+ path: string
80
+ segments: string[]
81
+ chain: Middleware[]
82
+ }
83
+
84
+ export type SocketEndpoint = {
85
+ method: HttpMethod
86
+ path: string
87
+ handler: (req: Request, res: unknown) => Response
88
+ }
89
+
90
+ export type SocketCommands = {
91
+ send: (clientId: string, event: string, body: unknown) => void
92
+ broadcast: (event: string, body: unknown) => void
93
+ }
94
+
95
+ type UpgradeData = {
96
+ clientId?: string
97
+ [key: string]: unknown
98
+ }
99
+
100
+ type UpgradeContext = {
101
+ data?: UpgradeData
102
+ [key: string]: unknown
103
+ }
104
+
105
+ type CreateSocketRequest = {
106
+ query: {
107
+ ticket: string
108
+ }
109
+ server: {
110
+ upgrade: (raw: unknown, ctx: UpgradeContext) => boolean
111
+ }
112
+ raw: unknown
113
+ }
114
+
115
+ type CreateTicketRequest = Record<string, unknown>
116
+
117
+ type UpdateTicketRequest = {
118
+ headers: Headers
119
+ params: {
120
+ clientId: string
121
+ }
122
+ }
123
+
17
124
  const ajv = new Ajv({
18
125
  allErrors: true,
19
126
  })
@@ -32,7 +139,7 @@ const validateNotMessage = ajv.compile({
32
139
  },
33
140
  })
34
141
 
35
- const createSocketValidator = ajv.compile({
142
+ const createSocketValidator = ajv.compile<CreateSocketRequest>({
36
143
  type: 'object',
37
144
  properties: {
38
145
  clientId: {
@@ -68,7 +175,7 @@ const createSocketValidator = ajv.compile({
68
175
  },
69
176
  })
70
177
 
71
- const createTicketValidator = ajv.compile({
178
+ const createTicketValidator = ajv.compile<CreateTicketRequest>({
72
179
  type: 'object',
73
180
  properties: {
74
181
  clientId: {
@@ -80,7 +187,7 @@ const createTicketValidator = ajv.compile({
80
187
  },
81
188
  })
82
189
 
83
- const updateTicketValidator = ajv.compile({
190
+ const updateTicketValidator = ajv.compile<UpdateTicketRequest>({
84
191
  type: 'object',
85
192
  properties: {
86
193
  clientId: {
@@ -115,17 +222,21 @@ const updateTicketValidator = ajv.compile({
115
222
  },
116
223
  })
117
224
 
118
- function isSessionActive (session) {
225
+ function isSessionActive (session: InactiveSession): boolean {
119
226
  return !session.expiresAt || session.expiresAt > Date.now()
120
227
  }
121
228
 
122
- function randomToken (count) {
123
- return crypto.randomBytes(count).toString('base64url')
229
+ function randomTicket (): string {
230
+ return crypto.randomBytes(24).toString('base64url')
124
231
  }
125
232
 
126
- function parseMessage (raw) {
233
+ function randomToken (): string {
234
+ return crypto.randomBytes(32).toString('base64url')
235
+ }
236
+
237
+ function parseMessage (raw: string | Buffer): RawMessage | undefined {
127
238
  try {
128
- return JSON.parse(raw)
239
+ return JSON.parse(String(raw))
129
240
  } catch (err) {
130
241
  console.error(err)
131
242
 
@@ -133,7 +244,7 @@ function parseMessage (raw) {
133
244
  }
134
245
  }
135
246
 
136
- function sweepInactiveSessions (state) {
247
+ function sweepInactiveSessions (state: SocketState): void {
137
248
  for (const [key, session] of state.inactiveSessions) {
138
249
  if (!isSessionActive(session)) {
139
250
  state.inactiveSessions.delete(key)
@@ -141,8 +252,11 @@ function sweepInactiveSessions (state) {
141
252
  }
142
253
  }
143
254
 
144
- function validateSchema (obj, validator) {
145
- const headers = obj.headers
255
+ function validateSchema<T extends Record<string, unknown>> (
256
+ obj: Record<string, unknown>,
257
+ validator: ValidateFunction<T>,
258
+ ): T {
259
+ const headers = obj.headers instanceof Headers
146
260
  ? Object.fromEntries(obj.headers)
147
261
  : undefined
148
262
 
@@ -152,19 +266,24 @@ function validateSchema (obj, validator) {
152
266
  }
153
267
 
154
268
  if (!validateNotMessage(payload)) {
155
- const errors = validateNotMessage.errors.map(item => formatError('', item))
269
+ const errors = validateNotMessage.errors!.map(item => formatError('', item))
156
270
 
157
271
  throw new UnprocessableContentError(errors)
158
272
  }
159
273
 
160
274
  if (!validator(payload)) {
161
- const errors = validator.errors.map(item => formatError('', item))
275
+ const errors = validator.errors!.map(item => formatError('', item))
162
276
 
163
277
  throw new UnprocessableContentError(errors)
164
278
  }
279
+
280
+ return obj as T
165
281
  }
166
282
 
167
- function matchesSegments (patternSegments, requestSegments) {
283
+ function matchesSegments (
284
+ patternSegments: string[],
285
+ requestSegments: string[],
286
+ ): boolean {
168
287
  if (patternSegments.length !== requestSegments.length) {
169
288
  return false
170
289
  }
@@ -175,7 +294,10 @@ function matchesSegments (patternSegments, requestSegments) {
175
294
  )
176
295
  }
177
296
 
178
- function matchRoute (routes, message) {
297
+ function matchRoute (
298
+ routes: SocketRoute[],
299
+ message: RequestMessage,
300
+ ): SocketRoute {
179
301
  const requestSegments = toSegments(message.route)
180
302
 
181
303
  const matchingPaths = routes.filter(route =>
@@ -195,17 +317,24 @@ function matchRoute (routes, message) {
195
317
  return route
196
318
  }
197
319
 
198
- function buildParams (route, message) {
320
+ function buildParams (
321
+ route: SocketRoute,
322
+ message: RequestMessage,
323
+ ): Record<string, string> {
199
324
  const requestSegments = toSegments(message.route)
200
325
 
201
- return route.segments.reduce((accum, segment, index) =>
202
- segment.startsWith(':') ? {
203
- ...accum,
204
- [segment.slice(1)]: requestSegments[index],
205
- } : accum, {})
326
+ return route.segments.reduce<Record<string, string>>(
327
+ (accum, segment, index) =>
328
+ segment.startsWith(':') ? {
329
+ ...accum,
330
+ [segment.slice(1)]: requestSegments[index],
331
+ } : accum, {})
206
332
  }
207
333
 
208
- function buildRequest (params, message) {
334
+ function buildRequest (
335
+ params: Record<string, string>,
336
+ message: RequestMessage,
337
+ ): WebSocketRequest {
209
338
  const { id, clientId, method, route } = message
210
339
  const headers = new Headers(message.headers ?? {})
211
340
  const query = message.query ?? {}
@@ -223,13 +352,17 @@ function buildRequest (params, message) {
223
352
  }
224
353
  }
225
354
 
226
- async function buildOutgoingMessage (id, clientId, response) {
355
+ async function buildOutgoingMessage (
356
+ id: string,
357
+ clientId: string,
358
+ response: Response,
359
+ ): Promise<ResponseMessage> {
227
360
  const text = await response.text()
228
361
  const contentType = response.headers.get('content-type') ?? ''
229
362
  const usingJson = contentType.includes('application/json')
230
363
  const body = usingJson ? JSON.parse(text) : text
231
364
 
232
- return createMessage(clientId, TYPES.RESPONSE, {
365
+ return createMessage(clientId, MessageType.Response, {
233
366
  id,
234
367
  status: response.status,
235
368
  headers: response.headers,
@@ -237,7 +370,34 @@ async function buildOutgoingMessage (id, clientId, response) {
237
370
  })
238
371
  }
239
372
 
240
- export function buildSocketState (opts = {}) {
373
+ function buildErrorMessage (
374
+ message: RawMessage,
375
+ err: unknown,
376
+ ): ResponseMessage {
377
+ const { id, clientId } = message as Pick<BaseMessage, 'id' | 'clientId'>
378
+
379
+ if (err instanceof RequestError) {
380
+ const ctor = err.constructor as typeof RequestError
381
+
382
+ return createMessage(clientId, MessageType.Response, {
383
+ id,
384
+ status: ctor.status,
385
+ headers: new Headers({
386
+ 'content-type': 'application/json;charset=utf-8',
387
+ }),
388
+ body: err.output,
389
+ })
390
+ }
391
+
392
+ return createMessage(clientId, MessageType.Response, {
393
+ id,
394
+ status: InternalServerError.status,
395
+ headers: new Headers(),
396
+ body: err instanceof Error ? err.message : undefined,
397
+ })
398
+ }
399
+
400
+ export function buildSocketState (opts: AppOptions = {}): SocketState {
241
401
  return {
242
402
  disconnectThreshold: opts.ws?.disconnectThreshold ?? 120_000,
243
403
  heartbeatInterval: opts.ws?.heartbeatInterval ?? 30_000,
@@ -250,7 +410,10 @@ export function buildSocketState (opts = {}) {
250
410
  }
251
411
  }
252
412
 
253
- export function buildSocketServer (routes, state) {
413
+ export function buildSocketServer (
414
+ routes: SocketRoute[],
415
+ state: SocketState,
416
+ ): WebSocketHandler<SocketData> {
254
417
  const {
255
418
  disconnectThreshold,
256
419
  heartbeatInterval,
@@ -259,8 +422,10 @@ export function buildSocketServer (routes, state) {
259
422
  inactiveSessions,
260
423
  } = state
261
424
 
262
- function armReaper (ws) {
263
- clearTimeout(ws.data.reaperHandle)
425
+ function armReaper (ws: SocketConnection): void {
426
+ if (ws.data.reaperHandle) {
427
+ clearTimeout(ws.data.reaperHandle)
428
+ }
264
429
 
265
430
  ws.data.reaperHandle = setTimeout(() => {
266
431
  ws.data.reaped = true
@@ -270,10 +435,10 @@ export function buildSocketServer (routes, state) {
270
435
  }
271
436
 
272
437
  return {
273
- open (ws) {
438
+ open (ws: SocketConnection): void {
274
439
  sweepInactiveSessions(state)
275
440
 
276
- const token = randomToken(32)
441
+ const token = randomToken()
277
442
  const existingSession = activeSessions.get(ws.data.clientId)
278
443
 
279
444
  if (existingSession) {
@@ -293,9 +458,9 @@ export function buildSocketServer (routes, state) {
293
458
 
294
459
  const welcomeMessage = createMessage(
295
460
  ws.data.clientId,
296
- TYPES.WELCOME,
461
+ MessageType.Welcome,
297
462
  {
298
- headers: {},
463
+ headers: new Headers(),
299
464
  body: {
300
465
  heartbeatInterval,
301
466
  token,
@@ -305,8 +470,10 @@ export function buildSocketServer (routes, state) {
305
470
 
306
471
  ws.send(JSON.stringify(welcomeMessage))
307
472
  },
308
- close (ws, code) {
309
- clearTimeout(ws.data.reaperHandle)
473
+ close (ws: SocketConnection, code: number): void {
474
+ if (ws.data.reaperHandle) {
475
+ clearTimeout(ws.data.reaperHandle)
476
+ }
310
477
 
311
478
  if (ws.data.superseded) {
312
479
  return
@@ -327,7 +494,7 @@ export function buildSocketServer (routes, state) {
327
494
  })
328
495
  }
329
496
  },
330
- async message (ws, raw) {
497
+ async message (ws: SocketConnection, raw: string | Buffer): Promise<void> {
331
498
  const incomingMsg = parseMessage(raw)
332
499
 
333
500
  if (incomingMsg === undefined) {
@@ -337,21 +504,21 @@ export function buildSocketServer (routes, state) {
337
504
  armReaper(ws)
338
505
 
339
506
  try {
340
- validateMessage(incomingMsg)
507
+ const message = validateMessage(incomingMsg)
341
508
 
342
- if (incomingMsg.type === TYPES.HEARTBEAT) {
343
- const { id, clientId } = incomingMsg
344
- const ack = createMessage(clientId, TYPES.HEARTBEAT, { id })
509
+ if (message.type === MessageType.Heartbeat) {
510
+ const { id, clientId } = message
511
+ const ack = createMessage(clientId, MessageType.Heartbeat, { id })
345
512
 
346
513
  ws.send(JSON.stringify(ack))
347
514
 
348
515
  return
349
516
  }
350
517
 
351
- const { id, clientId } = incomingMsg
352
- const route = matchRoute(routes, incomingMsg)
353
- const params = buildParams(route, incomingMsg)
354
- const req = buildRequest(params, incomingMsg)
518
+ const { id, clientId } = message
519
+ const route = matchRoute(routes, message)
520
+ const params = buildParams(route, message)
521
+ const req = buildRequest(params, message)
355
522
  const res = await executeMiddlewareChain(req, route.chain)
356
523
  const outgoingMsg = await buildOutgoingMessage(id, clientId, res)
357
524
 
@@ -359,20 +526,7 @@ export function buildSocketServer (routes, state) {
359
526
  } catch (err) {
360
527
  console.error(err)
361
528
 
362
- const { id, clientId } = incomingMsg
363
- const status = err.constructor.status ?? InternalServerError.status
364
- const body = err.output !== undefined ? err.output : err.message
365
-
366
- const headers = err.output !== undefined
367
- ? { 'content-type': 'application/json;charset=utf-8' }
368
- : {}
369
-
370
- const res = createMessage(clientId, TYPES.RESPONSE, {
371
- id,
372
- status,
373
- headers,
374
- body,
375
- })
529
+ const res = buildErrorMessage(incomingMsg, err)
376
530
 
377
531
  ws.send(JSON.stringify(res))
378
532
  }
@@ -380,7 +534,7 @@ export function buildSocketServer (routes, state) {
380
534
  }
381
535
  }
382
536
 
383
- export function buildSocketHandlers (state) {
537
+ export function buildSocketHandlers (state: SocketState): SocketEndpoint[] {
384
538
  const {
385
539
  maxTickets,
386
540
  ticketTtl,
@@ -389,7 +543,7 @@ export function buildSocketHandlers (state) {
389
543
  inactiveSessions,
390
544
  } = state
391
545
 
392
- function bindTicket (clientId) {
546
+ function bindTicket (clientId: string): string {
393
547
  for (const [key, entry] of tickets) {
394
548
  if (entry.expiresAt > Date.now()) {
395
549
  break
@@ -399,10 +553,10 @@ export function buildSocketHandlers (state) {
399
553
  }
400
554
 
401
555
  if (tickets.size >= maxTickets) {
402
- throw new ServiceUnavailableError()
556
+ throw new ServiceUnavailableError('Unable to issue ticket')
403
557
  }
404
558
 
405
- const ticket = randomToken(24)
559
+ const ticket = randomTicket()
406
560
  const expiresAt = Date.now() + ticketTtl
407
561
 
408
562
  tickets.set(ticket, {
@@ -413,7 +567,7 @@ export function buildSocketHandlers (state) {
413
567
  return ticket
414
568
  }
415
569
 
416
- function redeemTicket (ticket) {
570
+ function redeemTicket (ticket: string): string | undefined {
417
571
  const entry = ticket ? tickets.get(ticket) : undefined
418
572
 
419
573
  if (!entry) {
@@ -433,23 +587,26 @@ export function buildSocketHandlers (state) {
433
587
  {
434
588
  method: 'GET',
435
589
  path: '/ws',
436
- handler (req, res) {
437
- validateSchema(req, createSocketValidator)
590
+ handler (req: Record<string, unknown>, res: unknown): Response {
591
+ const validReq = validateSchema(req, createSocketValidator)
438
592
 
439
593
  if (typeof res !== 'object') {
440
594
  throw new TypeError('Endpoint "res" must be an object')
441
595
  }
442
596
 
443
- const ctx = res ? { ...res } : {}
597
+ const ctx: UpgradeContext = res ? { ...res } : {}
444
598
 
445
599
  ctx.data = ctx.data ?? {}
446
- ctx.data.clientId = redeemTicket(req.query.ticket)
600
+ ctx.data.clientId = redeemTicket(validReq.query.ticket)
601
+ ctx.data.superseded = false
602
+ ctx.data.reaped = false
603
+ ctx.data.reaperHandle = null
447
604
 
448
605
  if (!ctx.data.clientId) {
449
606
  throw new NotFoundError()
450
607
  }
451
608
 
452
- const useSocket = req.server.upgrade(req.raw, ctx)
609
+ const useSocket = validReq.server.upgrade(validReq.raw, ctx)
453
610
 
454
611
  if (!useSocket) {
455
612
  throw new NotFoundError()
@@ -461,7 +618,7 @@ export function buildSocketHandlers (state) {
461
618
  {
462
619
  method: 'POST',
463
620
  path: '/ws',
464
- handler (req, res) {
621
+ handler (req: Record<string, unknown>, res: unknown): Response {
465
622
  validateSchema(req, createTicketValidator)
466
623
 
467
624
  const clientId = crypto.randomUUID()
@@ -470,25 +627,25 @@ export function buildSocketHandlers (state) {
470
627
  clientId,
471
628
  ticket: bindTicket(clientId),
472
629
  data: res,
473
- }, { status: 201 })
630
+ }, { status: StatusCode.Created })
474
631
  },
475
632
  },
476
633
  {
477
634
  method: 'PUT',
478
635
  path: '/ws/:clientId',
479
- handler (req, res) {
480
- validateSchema(req, updateTicketValidator)
481
-
482
- const authHeader = req.headers.get('authorization')
483
- const token = authHeader.slice('Bearer '.length)
636
+ handler (req: Record<string, unknown>, res: unknown): Response {
637
+ const validReq = validateSchema(req, updateTicketValidator)
638
+ const authHeader = validReq.headers.get('authorization')
639
+ const token = authHeader!.slice('Bearer '.length)
484
640
 
485
- let session = activeSessions.get(req.params.clientId)
641
+ let session: Session | undefined =
642
+ activeSessions.get(validReq.params.clientId)
486
643
 
487
644
  if (!session) {
488
- const inactive = inactiveSessions.get(req.params.clientId)
645
+ const inactive = inactiveSessions.get(validReq.params.clientId)
489
646
 
490
647
  if (inactive && !isSessionActive(inactive)) {
491
- inactiveSessions.delete(req.params.clientId)
648
+ inactiveSessions.delete(validReq.params.clientId)
492
649
  } else {
493
650
  session = inactive
494
651
  }
@@ -499,12 +656,12 @@ export function buildSocketHandlers (state) {
499
656
  }
500
657
 
501
658
  if (session.token !== token) {
502
- throw new UnauthorizedError()
659
+ throw new UnauthorizedError('Invalid token')
503
660
  }
504
661
 
505
662
  return Response.json({
506
- clientId: req.params.clientId,
507
- ticket: bindTicket(req.params.clientId),
663
+ clientId: validReq.params.clientId,
664
+ ticket: bindTicket(validReq.params.clientId),
508
665
  data: res,
509
666
  })
510
667
  },
@@ -512,17 +669,21 @@ export function buildSocketHandlers (state) {
512
669
  ]
513
670
  }
514
671
 
515
- export function buildSocketCommands (state) {
516
- function sendToClient (clientId, event, body) {
672
+ export function buildSocketCommands (state: SocketState): SocketCommands {
673
+ function sendToClient (
674
+ clientId: string,
675
+ event: string,
676
+ body: unknown,
677
+ ): void {
517
678
  const session = state.activeSessions.get(clientId)
518
679
 
519
680
  if (!session) {
520
681
  throw new ReferenceError(`No live socket for client: ${clientId}`)
521
682
  }
522
683
 
523
- const message = createMessage(clientId, TYPES.NOTIFICATION, {
684
+ const message = createMessage(clientId, MessageType.Notification, {
524
685
  event,
525
- headers: {},
686
+ headers: new Headers(),
526
687
  body,
527
688
  })
528
689