sleepy-serv 0.4.0 → 0.6.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.
package/src/socket.js ADDED
@@ -0,0 +1,542 @@
1
+ import Ajv from 'ajv'
2
+ import addFormats from 'ajv-formats'
3
+ import crypto from 'node:crypto'
4
+
5
+ import { toSegments, formatError, executeMiddlewareChain } from './utils'
6
+ import { TYPES, createMessage, validateMessage } from './messages'
7
+
8
+ import {
9
+ NotFoundError,
10
+ UnauthorizedError,
11
+ MethodNotAllowedError,
12
+ UnprocessableContentError,
13
+ InternalServerError,
14
+ ServiceUnavailableError,
15
+ } from './errors'
16
+
17
+ const ajv = new Ajv({
18
+ allErrors: true,
19
+ })
20
+
21
+ addFormats(ajv)
22
+
23
+ const validateNotMessage = ajv.compile({
24
+ type: 'object',
25
+ properties: {
26
+ clientId: {
27
+ type: 'string',
28
+ },
29
+ },
30
+ not: {
31
+ required: ['clientId'],
32
+ },
33
+ })
34
+
35
+ const createSocketValidator = ajv.compile({
36
+ type: 'object',
37
+ properties: {
38
+ clientId: {
39
+ type: 'string',
40
+ },
41
+ query: {
42
+ type: 'object',
43
+ properties: {
44
+ ticket: {
45
+ type: 'string',
46
+ },
47
+ },
48
+ required: ['ticket'],
49
+ },
50
+ server: {
51
+ type: 'object',
52
+ properties: {
53
+ upgrade: {},
54
+ },
55
+ required: ['upgrade'],
56
+ },
57
+ raw: {
58
+ type: 'object',
59
+ },
60
+ },
61
+ required: [
62
+ 'query',
63
+ 'server',
64
+ 'raw',
65
+ ],
66
+ not: {
67
+ required: ['clientId'],
68
+ },
69
+ })
70
+
71
+ const createTicketValidator = ajv.compile({
72
+ type: 'object',
73
+ properties: {
74
+ clientId: {
75
+ type: 'string',
76
+ },
77
+ },
78
+ not: {
79
+ required: ['clientId'],
80
+ },
81
+ })
82
+
83
+ const updateTicketValidator = ajv.compile({
84
+ type: 'object',
85
+ properties: {
86
+ clientId: {
87
+ type: 'string',
88
+ },
89
+ params: {
90
+ type: 'object',
91
+ properties: {
92
+ clientId: {
93
+ type: 'string',
94
+ },
95
+ },
96
+ required: ['clientId'],
97
+ },
98
+ headers: {
99
+ type: 'object',
100
+ properties: {
101
+ authorization: {
102
+ type: 'string',
103
+ pattern: '^Bearer .+$',
104
+ },
105
+ },
106
+ required: ['authorization'],
107
+ },
108
+ },
109
+ required: [
110
+ 'params',
111
+ 'headers',
112
+ ],
113
+ not: {
114
+ required: ['clientId'],
115
+ },
116
+ })
117
+
118
+ function isSessionActive (session) {
119
+ return !session.expiresAt || session.expiresAt > Date.now()
120
+ }
121
+
122
+ function randomToken (count) {
123
+ return crypto.randomBytes(count).toString('base64url')
124
+ }
125
+
126
+ function parseMessage (raw) {
127
+ try {
128
+ return JSON.parse(raw)
129
+ } catch (err) {
130
+ console.error(err)
131
+
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ function sweepInactiveSessions (state) {
137
+ for (const [key, session] of state.inactiveSessions) {
138
+ if (!isSessionActive(session)) {
139
+ state.inactiveSessions.delete(key)
140
+ }
141
+ }
142
+ }
143
+
144
+ function validateSchema (obj, validator) {
145
+ const headers = obj.headers
146
+ ? Object.fromEntries(obj.headers)
147
+ : undefined
148
+
149
+ const payload = {
150
+ ...obj,
151
+ headers,
152
+ }
153
+
154
+ if (!validateNotMessage(payload)) {
155
+ const errors = validateNotMessage.errors.map(item => formatError('', item))
156
+
157
+ throw new UnprocessableContentError(errors)
158
+ }
159
+
160
+ if (!validator(payload)) {
161
+ const errors = validator.errors.map(item => formatError('', item))
162
+
163
+ throw new UnprocessableContentError(errors)
164
+ }
165
+ }
166
+
167
+ function matchesSegments (patternSegments, requestSegments) {
168
+ if (patternSegments.length !== requestSegments.length) {
169
+ return false
170
+ }
171
+
172
+ return patternSegments.every((segment, index) =>
173
+ segment.startsWith(':') ||
174
+ segment === requestSegments[index],
175
+ )
176
+ }
177
+
178
+ function matchRoute (routes, message) {
179
+ const requestSegments = toSegments(message.route)
180
+
181
+ const matchingPaths = routes.filter(route =>
182
+ matchesSegments(route.segments, requestSegments),
183
+ )
184
+
185
+ if (!matchingPaths.length) {
186
+ throw new NotFoundError()
187
+ }
188
+
189
+ const route = matchingPaths.find(route => route.method === message.method)
190
+
191
+ if (!route) {
192
+ throw new MethodNotAllowedError()
193
+ }
194
+
195
+ return route
196
+ }
197
+
198
+ function buildParams (route, message) {
199
+ const requestSegments = toSegments(message.route)
200
+
201
+ return route.segments.reduce((accum, segment, index) =>
202
+ segment.startsWith(':') ? {
203
+ ...accum,
204
+ [segment.slice(1)]: requestSegments[index],
205
+ } : accum, {})
206
+ }
207
+
208
+ function buildRequest (params, message) {
209
+ const { id, clientId, method, route } = message
210
+ const headers = new Headers(message.headers ?? {})
211
+ const query = message.query ?? {}
212
+ const json = async () => message.body
213
+
214
+ return {
215
+ id,
216
+ clientId,
217
+ method,
218
+ route,
219
+ headers,
220
+ params,
221
+ query,
222
+ json,
223
+ }
224
+ }
225
+
226
+ async function buildOutgoingMessage (id, clientId, response) {
227
+ const text = await response.text()
228
+ const contentType = response.headers.get('content-type') ?? ''
229
+ const usingJson = contentType.includes('application/json')
230
+ const body = usingJson ? JSON.parse(text) : text
231
+
232
+ return createMessage(clientId, TYPES.RESPONSE, {
233
+ id,
234
+ status: response.status,
235
+ headers: response.headers,
236
+ body,
237
+ })
238
+ }
239
+
240
+ export function buildSocketState (opts = {}) {
241
+ return {
242
+ disconnectThreshold: opts.ws?.disconnectThreshold ?? 120_000,
243
+ heartbeatInterval: opts.ws?.heartbeatInterval ?? 30_000,
244
+ maxTickets: opts.ws?.maxTickets ?? 100_000,
245
+ reclaimTtl: opts.ws?.reclaimTtl ?? 300_000,
246
+ ticketTtl: opts.ws?.ticketTtl ?? 10_000,
247
+ tickets: new Map(),
248
+ activeSessions: new Map(),
249
+ inactiveSessions: new Map(),
250
+ }
251
+ }
252
+
253
+ export function buildSocketServer (routes, state) {
254
+ const {
255
+ disconnectThreshold,
256
+ heartbeatInterval,
257
+ reclaimTtl,
258
+ activeSessions,
259
+ inactiveSessions,
260
+ } = state
261
+
262
+ function armReaper (ws) {
263
+ clearTimeout(ws.data.reaperHandle)
264
+
265
+ ws.data.reaperHandle = setTimeout(() => {
266
+ ws.data.reaped = true
267
+
268
+ ws.close()
269
+ }, disconnectThreshold)
270
+ }
271
+
272
+ return {
273
+ open (ws) {
274
+ sweepInactiveSessions(state)
275
+
276
+ const token = randomToken(32)
277
+ const existingSession = activeSessions.get(ws.data.clientId)
278
+
279
+ if (existingSession) {
280
+ existingSession.ws.data.superseded = true
281
+
282
+ existingSession.ws.close()
283
+ }
284
+
285
+ inactiveSessions.delete(ws.data.clientId)
286
+
287
+ activeSessions.set(ws.data.clientId, {
288
+ token,
289
+ ws,
290
+ })
291
+
292
+ armReaper(ws)
293
+
294
+ const welcomeMessage = createMessage(
295
+ ws.data.clientId,
296
+ TYPES.WELCOME,
297
+ {
298
+ headers: {},
299
+ body: {
300
+ heartbeatInterval,
301
+ token,
302
+ },
303
+ },
304
+ )
305
+
306
+ ws.send(JSON.stringify(welcomeMessage))
307
+ },
308
+ close (ws, code) {
309
+ clearTimeout(ws.data.reaperHandle)
310
+
311
+ if (ws.data.superseded) {
312
+ return
313
+ }
314
+
315
+ const existingSession = activeSessions.get(ws.data.clientId)
316
+
317
+ if (!existingSession || existingSession.ws !== ws) {
318
+ return
319
+ }
320
+
321
+ activeSessions.delete(ws.data.clientId)
322
+
323
+ if (code !== 1000 || ws.data.reaped) {
324
+ inactiveSessions.set(ws.data.clientId, {
325
+ token: existingSession.token,
326
+ expiresAt: Date.now() + reclaimTtl,
327
+ })
328
+ }
329
+ },
330
+ async message (ws, raw) {
331
+ const incomingMsg = parseMessage(raw)
332
+
333
+ if (incomingMsg === undefined) {
334
+ return
335
+ }
336
+
337
+ armReaper(ws)
338
+
339
+ try {
340
+ validateMessage(incomingMsg)
341
+
342
+ if (incomingMsg.type === TYPES.HEARTBEAT) {
343
+ const { id, clientId } = incomingMsg
344
+ const ack = createMessage(clientId, TYPES.HEARTBEAT, { id })
345
+
346
+ ws.send(JSON.stringify(ack))
347
+
348
+ return
349
+ }
350
+
351
+ const { id, clientId } = incomingMsg
352
+ const route = matchRoute(routes, incomingMsg)
353
+ const params = buildParams(route, incomingMsg)
354
+ const req = buildRequest(params, incomingMsg)
355
+ const res = await executeMiddlewareChain(req, route.chain)
356
+ const outgoingMsg = await buildOutgoingMessage(id, clientId, res)
357
+
358
+ ws.send(JSON.stringify(outgoingMsg))
359
+ } catch (err) {
360
+ console.error(err)
361
+
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
+ })
376
+
377
+ ws.send(JSON.stringify(res))
378
+ }
379
+ },
380
+ }
381
+ }
382
+
383
+ export function buildSocketHandlers (state) {
384
+ const {
385
+ maxTickets,
386
+ ticketTtl,
387
+ tickets,
388
+ activeSessions,
389
+ inactiveSessions,
390
+ } = state
391
+
392
+ function bindTicket (clientId) {
393
+ for (const [key, entry] of tickets) {
394
+ if (entry.expiresAt > Date.now()) {
395
+ break
396
+ }
397
+
398
+ tickets.delete(key)
399
+ }
400
+
401
+ if (tickets.size >= maxTickets) {
402
+ throw new ServiceUnavailableError()
403
+ }
404
+
405
+ const ticket = randomToken(24)
406
+ const expiresAt = Date.now() + ticketTtl
407
+
408
+ tickets.set(ticket, {
409
+ clientId,
410
+ expiresAt,
411
+ })
412
+
413
+ return ticket
414
+ }
415
+
416
+ function redeemTicket (ticket) {
417
+ const entry = ticket ? tickets.get(ticket) : undefined
418
+
419
+ if (!entry) {
420
+ return undefined
421
+ }
422
+
423
+ tickets.delete(ticket)
424
+
425
+ if (entry.expiresAt <= Date.now()) {
426
+ return undefined
427
+ }
428
+
429
+ return entry.clientId
430
+ }
431
+
432
+ return [
433
+ {
434
+ method: 'GET',
435
+ path: '/ws',
436
+ handler (req, res) {
437
+ validateSchema(req, createSocketValidator)
438
+
439
+ if (typeof res !== 'object') {
440
+ throw new TypeError('Endpoint "res" must be an object')
441
+ }
442
+
443
+ const ctx = res ? { ...res } : {}
444
+
445
+ ctx.data = ctx.data ?? {}
446
+ ctx.data.clientId = redeemTicket(req.query.ticket)
447
+
448
+ if (!ctx.data.clientId) {
449
+ throw new NotFoundError()
450
+ }
451
+
452
+ const useSocket = req.server.upgrade(req.raw, ctx)
453
+
454
+ if (!useSocket) {
455
+ throw new NotFoundError()
456
+ }
457
+
458
+ return new Response()
459
+ },
460
+ },
461
+ {
462
+ method: 'POST',
463
+ path: '/ws',
464
+ handler (req, res) {
465
+ validateSchema(req, createTicketValidator)
466
+
467
+ const clientId = crypto.randomUUID()
468
+
469
+ return Response.json({
470
+ clientId,
471
+ ticket: bindTicket(clientId),
472
+ data: res,
473
+ }, { status: 201 })
474
+ },
475
+ },
476
+ {
477
+ method: 'PUT',
478
+ 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)
484
+
485
+ let session = activeSessions.get(req.params.clientId)
486
+
487
+ if (!session) {
488
+ const inactive = inactiveSessions.get(req.params.clientId)
489
+
490
+ if (inactive && !isSessionActive(inactive)) {
491
+ inactiveSessions.delete(req.params.clientId)
492
+ } else {
493
+ session = inactive
494
+ }
495
+ }
496
+
497
+ if (!session) {
498
+ throw new NotFoundError()
499
+ }
500
+
501
+ if (session.token !== token) {
502
+ throw new UnauthorizedError()
503
+ }
504
+
505
+ return Response.json({
506
+ clientId: req.params.clientId,
507
+ ticket: bindTicket(req.params.clientId),
508
+ data: res,
509
+ })
510
+ },
511
+ },
512
+ ]
513
+ }
514
+
515
+ export function buildSocketCommands (state) {
516
+ function sendToClient (clientId, event, body) {
517
+ const session = state.activeSessions.get(clientId)
518
+
519
+ if (!session) {
520
+ throw new ReferenceError(`No live socket for client: ${clientId}`)
521
+ }
522
+
523
+ const message = createMessage(clientId, TYPES.NOTIFICATION, {
524
+ event,
525
+ headers: {},
526
+ body,
527
+ })
528
+
529
+ session.ws.send(JSON.stringify(message))
530
+ }
531
+
532
+ return {
533
+ send (clientId, event, body) {
534
+ sendToClient(clientId, event, body)
535
+ },
536
+ broadcast (event, body) {
537
+ for (const clientId of state.activeSessions.keys()) {
538
+ sendToClient(clientId, event, body)
539
+ }
540
+ },
541
+ }
542
+ }
package/src/utils.js ADDED
@@ -0,0 +1,49 @@
1
+ export function toSegments (pathString) {
2
+ const [pathname] = String(pathString).split('?')
3
+ const segments = pathname.split('/')
4
+
5
+ if (pathname.startsWith('/')) {
6
+ segments.shift()
7
+ }
8
+
9
+ if (pathname.endsWith('/')) {
10
+ segments.pop()
11
+ }
12
+
13
+ return segments
14
+ }
15
+
16
+ export function formatError (prefix, input) {
17
+ const fixedPath = input.instancePath || '/'
18
+ const suffixPath = fixedPath.replace(/\//g, '.').replace('.', '')
19
+
20
+ return {
21
+ path: [prefix, suffixPath].filter(item => item).join('.'),
22
+ message: input.message,
23
+ }
24
+ }
25
+
26
+ export async function executeMiddlewareChain (req, chain) {
27
+ if (!chain.length) {
28
+ throw new RangeError('Middleware chain is empty')
29
+ }
30
+
31
+ const executeMiddleware = async (index, res) => {
32
+ const currentMiddleware = chain[index]
33
+ const isLastMiddleware = index === chain.length - 1
34
+
35
+ const next = !isLastMiddleware ?
36
+ (data) => executeMiddleware(index + 1, data)
37
+ : null
38
+
39
+ const result = await currentMiddleware(req, res, next)
40
+
41
+ if (result instanceof Response) {
42
+ return result
43
+ } else {
44
+ throw new TypeError('Handler does not return a Response object')
45
+ }
46
+ }
47
+
48
+ return executeMiddleware(0, null)
49
+ }