sleepy-serv 0.6.2 → 0.8.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/README.md +20 -4
- package/dist/errors.d.ts +173 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/messages.d.ts +63 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/middleware.d.ts +19 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/socket.d.ts +50 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/utils.d.ts +125 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +17 -3
- package/src/{errors.js → errors.ts} +171 -80
- package/src/{index.js → index.ts} +215 -59
- package/src/messages.ts +237 -0
- package/src/middleware.ts +209 -0
- package/src/{socket.js → socket.ts} +247 -86
- package/src/utils.ts +204 -0
- package/src/messages.js +0 -164
- package/src/meta.js +0 -65
- package/src/middleware.js +0 -138
- package/src/utils.js +0 -49
|
@@ -4,7 +4,7 @@ import querystring from 'querystring'
|
|
|
4
4
|
import readline from 'node:readline'
|
|
5
5
|
|
|
6
6
|
import { stdin, stdout } from 'node:process'
|
|
7
|
-
import { toSegments, executeMiddlewareChain } from './utils'
|
|
7
|
+
import { StatusCode, toSegments, executeMiddlewareChain } from './utils'
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
buildSocketState,
|
|
@@ -14,10 +14,27 @@ import {
|
|
|
14
14
|
} from './socket'
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
+
RequestError,
|
|
17
18
|
NotFoundError,
|
|
18
19
|
MethodNotAllowedError,
|
|
19
20
|
} from './errors'
|
|
20
21
|
|
|
22
|
+
import type { BunRequest } from 'bun'
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
HttpMethod,
|
|
26
|
+
Middleware,
|
|
27
|
+
EndpointRequest,
|
|
28
|
+
AppOptions,
|
|
29
|
+
Server,
|
|
30
|
+
} from './utils'
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
SocketRoute,
|
|
34
|
+
SocketState,
|
|
35
|
+
SocketCommands,
|
|
36
|
+
} from './socket'
|
|
37
|
+
|
|
21
38
|
export * from './errors'
|
|
22
39
|
|
|
23
40
|
export {
|
|
@@ -26,6 +43,81 @@ export {
|
|
|
26
43
|
validateSchemas,
|
|
27
44
|
} from './middleware'
|
|
28
45
|
|
|
46
|
+
export { HttpMethod, StatusCode } from './utils'
|
|
47
|
+
|
|
48
|
+
export type {
|
|
49
|
+
AppOptions,
|
|
50
|
+
EndpointRequest,
|
|
51
|
+
FormattedError,
|
|
52
|
+
Middleware,
|
|
53
|
+
NextFn,
|
|
54
|
+
Request,
|
|
55
|
+
Server,
|
|
56
|
+
SocketOptions,
|
|
57
|
+
WebSocketRequest,
|
|
58
|
+
} from './utils'
|
|
59
|
+
|
|
60
|
+
export type {
|
|
61
|
+
FormatterField,
|
|
62
|
+
FormatterSchema,
|
|
63
|
+
ValidationSchemas,
|
|
64
|
+
} from './middleware'
|
|
65
|
+
|
|
66
|
+
export type { SocketCommands } from './socket'
|
|
67
|
+
|
|
68
|
+
type OutputRoutes = Record<string, string[]>
|
|
69
|
+
type ServerRoutes = Record<string, Record<string, EndpointHandler>>
|
|
70
|
+
|
|
71
|
+
type EndpointHandler = (
|
|
72
|
+
bunReq: BunRequest,
|
|
73
|
+
server: Server,
|
|
74
|
+
) => Promise<Response>
|
|
75
|
+
|
|
76
|
+
type DirEntry = {
|
|
77
|
+
path: string
|
|
78
|
+
stat: fs.Stats
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type RoutePath = {
|
|
82
|
+
method: HttpMethod
|
|
83
|
+
path: string
|
|
84
|
+
metaMiddlewarePath: string[]
|
|
85
|
+
modulePath: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type ChainRoute = {
|
|
89
|
+
method: HttpMethod
|
|
90
|
+
path: string
|
|
91
|
+
chain: Middleware[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type ModuleRoute = {
|
|
95
|
+
method: HttpMethod
|
|
96
|
+
path: string
|
|
97
|
+
handler: EndpointHandler
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type RoutingOptions = {
|
|
101
|
+
basePath: string
|
|
102
|
+
mountPath: string
|
|
103
|
+
metadata: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type AppRoutes = {
|
|
107
|
+
server: ServerRoutes
|
|
108
|
+
output: OutputRoutes
|
|
109
|
+
socket: SocketRoute[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type CloseFn = (force?: boolean) => Promise<void>
|
|
113
|
+
|
|
114
|
+
export type App = {
|
|
115
|
+
server: Server
|
|
116
|
+
commands: SocketCommands
|
|
117
|
+
routes: OutputRoutes
|
|
118
|
+
close: CloseFn
|
|
119
|
+
}
|
|
120
|
+
|
|
29
121
|
const ALLOWED_FILES_META = ['meta.js', 'meta.ts']
|
|
30
122
|
|
|
31
123
|
const ALLOWED_FILES_METHODS = [
|
|
@@ -43,21 +135,11 @@ const ALLOWED_FILES_METHODS = [
|
|
|
43
135
|
'delete.ts',
|
|
44
136
|
]
|
|
45
137
|
|
|
46
|
-
|
|
47
|
-
if (process.stdin.isTTY) {
|
|
48
|
-
process.stdin.setRawMode(true)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const rl = readline.createInterface({
|
|
52
|
-
input: stdin,
|
|
53
|
-
output: stdout,
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
function methodNotAllowedHandler (_req) {
|
|
138
|
+
function methodNotAllowedHandler (_req: unknown): never {
|
|
57
139
|
throw new MethodNotAllowedError()
|
|
58
140
|
}
|
|
59
141
|
|
|
60
|
-
function defaultMethodMap () {
|
|
142
|
+
function defaultMethodMap (): Record<string, EndpointHandler> {
|
|
61
143
|
return {
|
|
62
144
|
HEAD: methodNotAllowedHandler,
|
|
63
145
|
GET: methodNotAllowedHandler,
|
|
@@ -68,13 +150,16 @@ function defaultMethodMap () {
|
|
|
68
150
|
}
|
|
69
151
|
}
|
|
70
152
|
|
|
71
|
-
function
|
|
153
|
+
function buildEndpointRequest (
|
|
154
|
+
bunReq: BunRequest,
|
|
155
|
+
server: Server,
|
|
156
|
+
): EndpointRequest {
|
|
72
157
|
const url = new URL(bunReq.url)
|
|
73
158
|
const qs = url.search.replace('?', '')
|
|
74
159
|
const json = () => bunReq.json()
|
|
75
160
|
|
|
76
161
|
return {
|
|
77
|
-
method: bunReq.method,
|
|
162
|
+
method: bunReq.method as HttpMethod,
|
|
78
163
|
route: url.pathname,
|
|
79
164
|
headers: bunReq.headers,
|
|
80
165
|
params: bunReq.params ?? {},
|
|
@@ -85,7 +170,11 @@ function buildBunRequest (bunReq, server) {
|
|
|
85
170
|
}
|
|
86
171
|
}
|
|
87
172
|
|
|
88
|
-
function validateLeafDirectory (
|
|
173
|
+
function validateLeafDirectory (
|
|
174
|
+
targetPath: string,
|
|
175
|
+
filenames: string[],
|
|
176
|
+
entries: DirEntry[],
|
|
177
|
+
): void {
|
|
89
178
|
const hasDirectories = entries.some(entry => entry.stat.isDirectory())
|
|
90
179
|
|
|
91
180
|
if (!hasDirectories) {
|
|
@@ -102,7 +191,7 @@ ${targetPath}
|
|
|
102
191
|
}
|
|
103
192
|
}
|
|
104
193
|
|
|
105
|
-
function validateDirectory (targetPath, entries) {
|
|
194
|
+
function validateDirectory (targetPath: string, entries: DirEntry[]): void {
|
|
106
195
|
const filenames = entries
|
|
107
196
|
.filter(entry => entry.stat.isFile())
|
|
108
197
|
.map(entry => path.basename(entry.path))
|
|
@@ -110,7 +199,7 @@ function validateDirectory (targetPath, entries) {
|
|
|
110
199
|
validateLeafDirectory(targetPath, filenames, entries)
|
|
111
200
|
}
|
|
112
201
|
|
|
113
|
-
function getAllFilePathsRec (targetPath, paths) {
|
|
202
|
+
function getAllFilePathsRec (targetPath: string, paths: string[]): string[] {
|
|
114
203
|
const entries = fs.readdirSync(targetPath)
|
|
115
204
|
|
|
116
205
|
const children = entries.map(item => {
|
|
@@ -124,7 +213,7 @@ function getAllFilePathsRec (targetPath, paths) {
|
|
|
124
213
|
|
|
125
214
|
validateDirectory(targetPath, children)
|
|
126
215
|
|
|
127
|
-
return children.reduce((accum, curr) => {
|
|
216
|
+
return children.reduce<string[]>((accum, curr) => {
|
|
128
217
|
const result = curr.stat.isDirectory()
|
|
129
218
|
? getAllFilePathsRec(curr.path, paths)
|
|
130
219
|
: [curr.path]
|
|
@@ -133,7 +222,10 @@ function getAllFilePathsRec (targetPath, paths) {
|
|
|
133
222
|
}, [])
|
|
134
223
|
}
|
|
135
224
|
|
|
136
|
-
function getFilteredFilePaths (
|
|
225
|
+
function getFilteredFilePaths (
|
|
226
|
+
targetPath: string,
|
|
227
|
+
allowedFiles: string[],
|
|
228
|
+
): string[] {
|
|
137
229
|
const allPaths = getAllFilePathsRec(targetPath, [])
|
|
138
230
|
|
|
139
231
|
return allPaths.filter(item =>
|
|
@@ -141,21 +233,23 @@ function getFilteredFilePaths (targetPath, allowedFiles) {
|
|
|
141
233
|
)
|
|
142
234
|
}
|
|
143
235
|
|
|
144
|
-
function getMethodFilePaths (targetPath) {
|
|
236
|
+
function getMethodFilePaths (targetPath: string): string[] {
|
|
145
237
|
return getFilteredFilePaths(targetPath, ALLOWED_FILES_METHODS)
|
|
146
238
|
}
|
|
147
239
|
|
|
148
|
-
function getMetaFilePaths (targetPath) {
|
|
240
|
+
function getMetaFilePaths (targetPath: string): string[] {
|
|
149
241
|
return getFilteredFilePaths(targetPath, ALLOWED_FILES_META)
|
|
150
242
|
}
|
|
151
243
|
|
|
152
|
-
function selectMetaPaths (metadata, modulePath) {
|
|
244
|
+
function selectMetaPaths (metadata: string[], modulePath: string): string[] {
|
|
153
245
|
return metadata
|
|
154
246
|
.filter(metaPath => modulePath.startsWith(path.dirname(metaPath)))
|
|
155
247
|
.sort((a, b) => a.length - b.length)
|
|
156
248
|
}
|
|
157
249
|
|
|
158
|
-
async function resolveMetaMiddleware (
|
|
250
|
+
async function resolveMetaMiddleware (
|
|
251
|
+
metaPaths: string[],
|
|
252
|
+
): Promise<Middleware[]> {
|
|
159
253
|
const modules = await Promise.all(metaPaths.map(item => import(item)))
|
|
160
254
|
|
|
161
255
|
return modules
|
|
@@ -166,7 +260,11 @@ async function resolveMetaMiddleware (metaPaths) {
|
|
|
166
260
|
], [])
|
|
167
261
|
}
|
|
168
262
|
|
|
169
|
-
function buildRoutePaths (
|
|
263
|
+
function buildRoutePaths (
|
|
264
|
+
rootPath: string,
|
|
265
|
+
mountPath: string,
|
|
266
|
+
metadata: string[],
|
|
267
|
+
): RoutePath[] {
|
|
170
268
|
const paths = getMethodFilePaths(rootPath)
|
|
171
269
|
|
|
172
270
|
return paths.map(modulePath => {
|
|
@@ -182,7 +280,7 @@ function buildRoutePaths (rootPath, mountPath, metadata) {
|
|
|
182
280
|
const metaMiddlewarePath = selectMetaPaths(metadata, modulePath)
|
|
183
281
|
|
|
184
282
|
return {
|
|
185
|
-
method: segments[lastIndex].toUpperCase(),
|
|
283
|
+
method: segments[lastIndex].toUpperCase() as HttpMethod,
|
|
186
284
|
path: joinedPath,
|
|
187
285
|
metaMiddlewarePath,
|
|
188
286
|
modulePath,
|
|
@@ -190,7 +288,10 @@ function buildRoutePaths (rootPath, mountPath, metadata) {
|
|
|
190
288
|
})
|
|
191
289
|
}
|
|
192
290
|
|
|
193
|
-
async function buildChain (
|
|
291
|
+
async function buildChain (
|
|
292
|
+
route: RoutePath,
|
|
293
|
+
rootMiddleware: Middleware[],
|
|
294
|
+
): Promise<ChainRoute> {
|
|
194
295
|
const module = await import(route.modulePath)
|
|
195
296
|
const metaMiddleware = await resolveMetaMiddleware(route.metaMiddlewarePath)
|
|
196
297
|
|
|
@@ -216,13 +317,21 @@ ${route.modulePath}
|
|
|
216
317
|
}
|
|
217
318
|
}
|
|
218
319
|
|
|
219
|
-
function buildNormalRoutes (
|
|
320
|
+
function buildNormalRoutes (
|
|
321
|
+
routePaths: RoutePath[],
|
|
322
|
+
rootMiddleware: Middleware[],
|
|
323
|
+
): Promise<ChainRoute[]> {
|
|
220
324
|
return Promise.all(
|
|
221
325
|
routePaths.map(route => buildChain(route, rootMiddleware)),
|
|
222
326
|
)
|
|
223
327
|
}
|
|
224
328
|
|
|
225
|
-
async function buildMergedRoutes (
|
|
329
|
+
async function buildMergedRoutes (
|
|
330
|
+
routePaths: ChainRoute[],
|
|
331
|
+
middleware: Middleware[],
|
|
332
|
+
state: SocketState,
|
|
333
|
+
opts: RoutingOptions,
|
|
334
|
+
): Promise<ChainRoute[]> {
|
|
226
335
|
const { basePath, mountPath, metadata } = opts
|
|
227
336
|
const socketRoutes = buildSocketHandlers(state)
|
|
228
337
|
|
|
@@ -259,17 +368,17 @@ async function buildMergedRoutes (routePaths, middleware, state, opts) {
|
|
|
259
368
|
return routePaths
|
|
260
369
|
}
|
|
261
370
|
|
|
262
|
-
function
|
|
371
|
+
function buildSocketRoutes (mergedRoutes: ChainRoute[]): SocketRoute[] {
|
|
263
372
|
return mergedRoutes.map(route => ({
|
|
264
373
|
...route,
|
|
265
374
|
segments: toSegments(route.path),
|
|
266
375
|
}))
|
|
267
376
|
}
|
|
268
377
|
|
|
269
|
-
function buildModuleRoutes (
|
|
270
|
-
return
|
|
271
|
-
const handler = async (bunReq, server) => {
|
|
272
|
-
const req =
|
|
378
|
+
function buildModuleRoutes (socketRoutes: SocketRoute[]): ModuleRoute[] {
|
|
379
|
+
return socketRoutes.map(route => {
|
|
380
|
+
const handler: EndpointHandler = async (bunReq, server) => {
|
|
381
|
+
const req = buildEndpointRequest(bunReq, server)
|
|
273
382
|
|
|
274
383
|
return executeMiddlewareChain(req, route.chain)
|
|
275
384
|
}
|
|
@@ -282,20 +391,21 @@ function buildModuleRoutes (routePaths) {
|
|
|
282
391
|
})
|
|
283
392
|
}
|
|
284
393
|
|
|
285
|
-
function buildServerRoutes (moduleRoutes) {
|
|
286
|
-
return moduleRoutes.reduce(
|
|
287
|
-
|
|
288
|
-
accum[curr.path]
|
|
289
|
-
|
|
394
|
+
function buildServerRoutes (moduleRoutes: ModuleRoute[]): ServerRoutes {
|
|
395
|
+
return moduleRoutes.reduce<ServerRoutes>(
|
|
396
|
+
(accum, curr) => {
|
|
397
|
+
if (!accum[curr.path]) {
|
|
398
|
+
accum[curr.path] = defaultMethodMap()
|
|
399
|
+
}
|
|
290
400
|
|
|
291
|
-
|
|
401
|
+
accum[curr.path][curr.method] = curr.handler
|
|
292
402
|
|
|
293
|
-
|
|
294
|
-
|
|
403
|
+
return accum
|
|
404
|
+
}, {})
|
|
295
405
|
}
|
|
296
406
|
|
|
297
|
-
function buildOutputRoutes (moduleRoutes) {
|
|
298
|
-
return moduleRoutes.reduce((accum, curr) => {
|
|
407
|
+
function buildOutputRoutes (moduleRoutes: ModuleRoute[]): OutputRoutes {
|
|
408
|
+
return moduleRoutes.reduce<OutputRoutes>((accum, curr) => {
|
|
299
409
|
accum[curr.path] = accum[curr.path] || []
|
|
300
410
|
accum[curr.path].push(curr.method)
|
|
301
411
|
|
|
@@ -303,7 +413,11 @@ function buildOutputRoutes (moduleRoutes) {
|
|
|
303
413
|
}, {})
|
|
304
414
|
}
|
|
305
415
|
|
|
306
|
-
async function buildRoutes (
|
|
416
|
+
async function buildRoutes (
|
|
417
|
+
rootPath: string,
|
|
418
|
+
state: SocketState,
|
|
419
|
+
opts: AppOptions,
|
|
420
|
+
): Promise<AppRoutes> {
|
|
307
421
|
const basePath = `${rootPath}/api`
|
|
308
422
|
const mountPath = opts.mountPath || ''
|
|
309
423
|
const middleware = opts.middleware || []
|
|
@@ -336,7 +450,12 @@ async function buildRoutes (rootPath, state, opts) {
|
|
|
336
450
|
}
|
|
337
451
|
}
|
|
338
452
|
|
|
339
|
-
function buildServer (
|
|
453
|
+
function buildServer (
|
|
454
|
+
port: number,
|
|
455
|
+
routes: AppRoutes,
|
|
456
|
+
state: SocketState,
|
|
457
|
+
opts: AppOptions,
|
|
458
|
+
): Server {
|
|
340
459
|
const hostname = opts.hostname || '0.0.0.0'
|
|
341
460
|
const websocketServer = buildSocketServer(routes.socket, state)
|
|
342
461
|
|
|
@@ -351,42 +470,79 @@ function buildServer (port, routes, state, opts) {
|
|
|
351
470
|
error (err) {
|
|
352
471
|
console.error(err)
|
|
353
472
|
|
|
354
|
-
|
|
473
|
+
if (err instanceof RequestError) {
|
|
474
|
+
const ctor = err.constructor as typeof RequestError
|
|
355
475
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
476
|
+
return Response.json(err.output, { status: ctor.status })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return new Response(err.message, {
|
|
480
|
+
status: StatusCode.InternalServerError,
|
|
481
|
+
})
|
|
359
482
|
},
|
|
360
483
|
})
|
|
361
484
|
}
|
|
362
485
|
|
|
363
|
-
function processIO (
|
|
364
|
-
|
|
486
|
+
function processIO (
|
|
487
|
+
port: number,
|
|
488
|
+
server: Server,
|
|
489
|
+
opts: AppOptions,
|
|
490
|
+
): CloseFn {
|
|
491
|
+
const onClose = opts.onClose || (() => {})
|
|
365
492
|
|
|
366
493
|
console.info(`Running on port: ${port}`)
|
|
367
494
|
console.info('')
|
|
368
495
|
console.info('Press Ctrl+D to gracefully shutdown')
|
|
369
496
|
console.info('')
|
|
370
497
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
await server.stop()
|
|
498
|
+
const shutdown = async (force = false): Promise<void> => {
|
|
499
|
+
await server.stop(force)
|
|
374
500
|
await onClose()
|
|
375
|
-
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!stdin.isTTY) {
|
|
504
|
+
return shutdown
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
stdin.setRawMode(true)
|
|
508
|
+
|
|
509
|
+
const rl = readline.createInterface({
|
|
510
|
+
input: stdin,
|
|
511
|
+
output: stdout,
|
|
376
512
|
})
|
|
513
|
+
|
|
514
|
+
/* istanbul ignore next */
|
|
515
|
+
const handleClose = async (): Promise<void> => {
|
|
516
|
+
await shutdown()
|
|
517
|
+
process.exit(0)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
rl.on('close', handleClose)
|
|
521
|
+
|
|
522
|
+
return async (force = false) => {
|
|
523
|
+
rl.off('close', handleClose)
|
|
524
|
+
rl.close()
|
|
525
|
+
stdin.setRawMode(false)
|
|
526
|
+
|
|
527
|
+
await shutdown(force)
|
|
528
|
+
}
|
|
377
529
|
}
|
|
378
530
|
|
|
379
|
-
export async function createApp (
|
|
531
|
+
export async function createApp (
|
|
532
|
+
port: number,
|
|
533
|
+
rootPath: string,
|
|
534
|
+
opts: AppOptions = {},
|
|
535
|
+
): Promise<App> {
|
|
380
536
|
const state = buildSocketState(opts)
|
|
381
537
|
const routes = await buildRoutes(rootPath, state, opts)
|
|
382
538
|
const server = buildServer(port, routes, state, opts)
|
|
383
539
|
const commands = buildSocketCommands(state)
|
|
384
|
-
|
|
385
|
-
processIO(port, server, opts)
|
|
540
|
+
const close = processIO(port, server, opts)
|
|
386
541
|
|
|
387
542
|
return {
|
|
388
543
|
routes: routes.output,
|
|
389
544
|
server,
|
|
390
545
|
commands,
|
|
546
|
+
close,
|
|
391
547
|
}
|
|
392
548
|
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import Ajv from 'ajv'
|
|
2
|
+
import addFormats from 'ajv-formats'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { formatError } from './utils'
|
|
5
|
+
import { UnprocessableContentError } from './errors'
|
|
6
|
+
|
|
7
|
+
import type { ValidateFunction } from 'ajv'
|
|
8
|
+
import type { HttpMethod } from './utils'
|
|
9
|
+
|
|
10
|
+
export const MessageType = {
|
|
11
|
+
Request: 'request',
|
|
12
|
+
Response: 'response',
|
|
13
|
+
Welcome: 'welcome',
|
|
14
|
+
Heartbeat: 'heartbeat',
|
|
15
|
+
Notification: 'notification',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export type MessageType = typeof MessageType[keyof typeof MessageType]
|
|
19
|
+
|
|
20
|
+
export const RECEIVED_MESSAGE_TYPES: string[] = [
|
|
21
|
+
MessageType.Heartbeat,
|
|
22
|
+
MessageType.Request,
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export type BaseMessage = {
|
|
26
|
+
id: string
|
|
27
|
+
clientId: string
|
|
28
|
+
type: MessageType
|
|
29
|
+
timestamp: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HeartbeatMessage = BaseMessage & {
|
|
33
|
+
type: typeof MessageType.Heartbeat
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RequestMessage = BaseMessage & {
|
|
37
|
+
type: typeof MessageType.Request
|
|
38
|
+
method: HttpMethod
|
|
39
|
+
route: string
|
|
40
|
+
headers: Bun.HeadersInit
|
|
41
|
+
query: Record<string, unknown>
|
|
42
|
+
body: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ResponseMessage = BaseMessage & {
|
|
46
|
+
type: typeof MessageType.Response
|
|
47
|
+
status: number
|
|
48
|
+
headers: Headers
|
|
49
|
+
body: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type WelcomeMessage = BaseMessage & {
|
|
53
|
+
type: typeof MessageType.Welcome
|
|
54
|
+
headers: Headers
|
|
55
|
+
body: {
|
|
56
|
+
heartbeatInterval: number
|
|
57
|
+
token: string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type NotificationMessage = BaseMessage & {
|
|
62
|
+
type: typeof MessageType.Notification
|
|
63
|
+
event: string
|
|
64
|
+
headers: Headers
|
|
65
|
+
body: unknown
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Message =
|
|
69
|
+
| HeartbeatMessage
|
|
70
|
+
| RequestMessage
|
|
71
|
+
| ResponseMessage
|
|
72
|
+
| WelcomeMessage
|
|
73
|
+
| NotificationMessage
|
|
74
|
+
|
|
75
|
+
export type RawMessage = {
|
|
76
|
+
type?: string
|
|
77
|
+
[key: string]: unknown
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type IncomingMessage = HeartbeatMessage | RequestMessage
|
|
81
|
+
|
|
82
|
+
export type MessageContent<T extends MessageType = MessageType> =
|
|
83
|
+
Partial<Pick<Extract<Message, { type: T }>, 'id'>>
|
|
84
|
+
& Omit<Extract<Message, { type: T }>, keyof BaseMessage>
|
|
85
|
+
|
|
86
|
+
const ajv = new Ajv({
|
|
87
|
+
allErrors: true,
|
|
88
|
+
removeAdditional: 'all',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
addFormats(ajv)
|
|
92
|
+
|
|
93
|
+
const SCHEMA_BASE = {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
id: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
format: 'uuid',
|
|
99
|
+
},
|
|
100
|
+
clientId: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
format: 'uuid',
|
|
103
|
+
},
|
|
104
|
+
type: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
enum: RECEIVED_MESSAGE_TYPES,
|
|
107
|
+
},
|
|
108
|
+
timestamp: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
format: 'date-time',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: [
|
|
114
|
+
'id',
|
|
115
|
+
'clientId',
|
|
116
|
+
'type',
|
|
117
|
+
'timestamp',
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const validateHeartbeat = ajv.compile({
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
...SCHEMA_BASE.properties,
|
|
125
|
+
type: {
|
|
126
|
+
type: 'string',
|
|
127
|
+
const: MessageType.Heartbeat,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
required: SCHEMA_BASE.required,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const validateRequest = ajv.compile({
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
...SCHEMA_BASE.properties,
|
|
137
|
+
type: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
const: MessageType.Request,
|
|
140
|
+
},
|
|
141
|
+
method: {
|
|
142
|
+
type: 'string',
|
|
143
|
+
enum: [
|
|
144
|
+
'HEAD',
|
|
145
|
+
'GET',
|
|
146
|
+
'PUT',
|
|
147
|
+
'POST',
|
|
148
|
+
'PATCH',
|
|
149
|
+
'DELETE',
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
route: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
format: 'uri-reference',
|
|
155
|
+
},
|
|
156
|
+
headers: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
},
|
|
159
|
+
query: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
},
|
|
162
|
+
body: {
|
|
163
|
+
type: [
|
|
164
|
+
'boolean',
|
|
165
|
+
'number',
|
|
166
|
+
'string',
|
|
167
|
+
'object',
|
|
168
|
+
'array',
|
|
169
|
+
'null',
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
required: [
|
|
174
|
+
...SCHEMA_BASE.required,
|
|
175
|
+
'method',
|
|
176
|
+
'route',
|
|
177
|
+
'headers',
|
|
178
|
+
'query',
|
|
179
|
+
'body',
|
|
180
|
+
],
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const TYPE_VALIDATORS: Record<string, ValidateFunction> = {
|
|
184
|
+
[MessageType.Heartbeat]: validateHeartbeat,
|
|
185
|
+
[MessageType.Request]: validateRequest,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createMessage<T extends MessageType> (
|
|
189
|
+
clientId: string,
|
|
190
|
+
type: T,
|
|
191
|
+
content: MessageContent<T>,
|
|
192
|
+
): Extract<Message, { type: T }> {
|
|
193
|
+
const id = content.id ?? crypto.randomUUID()
|
|
194
|
+
const timestamp = new Date().toISOString()
|
|
195
|
+
|
|
196
|
+
const base = {
|
|
197
|
+
id,
|
|
198
|
+
clientId,
|
|
199
|
+
type,
|
|
200
|
+
timestamp,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
...content,
|
|
205
|
+
...base,
|
|
206
|
+
} as Extract<Message, { type: T }>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function validateMessage (message: RawMessage): IncomingMessage {
|
|
210
|
+
if (message.type === undefined) {
|
|
211
|
+
throw new UnprocessableContentError([
|
|
212
|
+
{
|
|
213
|
+
path: '',
|
|
214
|
+
message: `must have required property 'type'`,
|
|
215
|
+
},
|
|
216
|
+
])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!RECEIVED_MESSAGE_TYPES.includes(message.type)) {
|
|
220
|
+
throw new UnprocessableContentError([
|
|
221
|
+
{
|
|
222
|
+
path: 'type',
|
|
223
|
+
message: `must be one of: ${RECEIVED_MESSAGE_TYPES}`,
|
|
224
|
+
},
|
|
225
|
+
])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const validate = TYPE_VALIDATORS[message.type]
|
|
229
|
+
|
|
230
|
+
if (!validate(message)) {
|
|
231
|
+
const errors = validate.errors!.map(item => formatError('', item))
|
|
232
|
+
|
|
233
|
+
throw new UnprocessableContentError(errors)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return message as IncomingMessage
|
|
237
|
+
}
|