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.
@@ -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
- /* istanbul ignore if */
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 buildBunRequest (bunReq, server) {
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 (targetPath, filenames, entries) {
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 (targetPath, allowedFiles) {
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 (metaPaths) {
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 (rootPath, mountPath, metadata) {
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 (route, rootMiddleware) {
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 (routePaths, rootMiddleware) {
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 (routePaths, middleware, state, opts) {
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 buildSocketRoutes (mergedRoutes) {
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 (routePaths) {
270
- return routePaths.map(route => {
271
- const handler = async (bunReq, server) => {
272
- const req = buildBunRequest(bunReq, server)
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((accum, curr) => {
287
- if (!accum[curr.path]) {
288
- accum[curr.path] = defaultMethodMap()
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
- accum[curr.path][curr.method] = curr.handler
401
+ accum[curr.path][curr.method] = curr.handler
292
402
 
293
- return accum
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 (rootPath, state, opts) {
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 (port, routes, state, opts) {
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
- const status = err.constructor.status ?? 500
473
+ if (err instanceof RequestError) {
474
+ const ctor = err.constructor as typeof RequestError
355
475
 
356
- return err.output !== undefined
357
- ? Response.json(err.output, { status })
358
- : new Response(err.message, { status })
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 (port, server, opts) {
364
- const onClose = opts.onClose || (() => { })
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
- /* istanbul ignore next */
372
- rl.on('close', async () => {
373
- await server.stop()
498
+ const shutdown = async (force = false): Promise<void> => {
499
+ await server.stop(force)
374
500
  await onClose()
375
- process.exit(0)
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 (port, rootPath, opts = {}) {
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
  }
@@ -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
+ }