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.
@@ -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,78 @@ 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
+ export type App = {
113
+ server: Server
114
+ commands: SocketCommands
115
+ routes: OutputRoutes
116
+ }
117
+
29
118
  const ALLOWED_FILES_META = ['meta.js', 'meta.ts']
30
119
 
31
120
  const ALLOWED_FILES_METHODS = [
@@ -53,11 +142,11 @@ const rl = readline.createInterface({
53
142
  output: stdout,
54
143
  })
55
144
 
56
- function methodNotAllowedHandler (_req) {
145
+ function methodNotAllowedHandler (_req: unknown): never {
57
146
  throw new MethodNotAllowedError()
58
147
  }
59
148
 
60
- function defaultMethodMap () {
149
+ function defaultMethodMap (): Record<string, EndpointHandler> {
61
150
  return {
62
151
  HEAD: methodNotAllowedHandler,
63
152
  GET: methodNotAllowedHandler,
@@ -68,13 +157,16 @@ function defaultMethodMap () {
68
157
  }
69
158
  }
70
159
 
71
- function buildBunRequest (bunReq, server) {
160
+ function buildEndpointRequest (
161
+ bunReq: BunRequest,
162
+ server: Server,
163
+ ): EndpointRequest {
72
164
  const url = new URL(bunReq.url)
73
165
  const qs = url.search.replace('?', '')
74
166
  const json = () => bunReq.json()
75
167
 
76
168
  return {
77
- method: bunReq.method,
169
+ method: bunReq.method as HttpMethod,
78
170
  route: url.pathname,
79
171
  headers: bunReq.headers,
80
172
  params: bunReq.params ?? {},
@@ -85,7 +177,11 @@ function buildBunRequest (bunReq, server) {
85
177
  }
86
178
  }
87
179
 
88
- function validateLeafDirectory (targetPath, filenames, entries) {
180
+ function validateLeafDirectory (
181
+ targetPath: string,
182
+ filenames: string[],
183
+ entries: DirEntry[],
184
+ ): void {
89
185
  const hasDirectories = entries.some(entry => entry.stat.isDirectory())
90
186
 
91
187
  if (!hasDirectories) {
@@ -102,7 +198,7 @@ ${targetPath}
102
198
  }
103
199
  }
104
200
 
105
- function validateDirectory (targetPath, entries) {
201
+ function validateDirectory (targetPath: string, entries: DirEntry[]): void {
106
202
  const filenames = entries
107
203
  .filter(entry => entry.stat.isFile())
108
204
  .map(entry => path.basename(entry.path))
@@ -110,7 +206,7 @@ function validateDirectory (targetPath, entries) {
110
206
  validateLeafDirectory(targetPath, filenames, entries)
111
207
  }
112
208
 
113
- function getAllFilePathsRec (targetPath, paths) {
209
+ function getAllFilePathsRec (targetPath: string, paths: string[]): string[] {
114
210
  const entries = fs.readdirSync(targetPath)
115
211
 
116
212
  const children = entries.map(item => {
@@ -124,7 +220,7 @@ function getAllFilePathsRec (targetPath, paths) {
124
220
 
125
221
  validateDirectory(targetPath, children)
126
222
 
127
- return children.reduce((accum, curr) => {
223
+ return children.reduce<string[]>((accum, curr) => {
128
224
  const result = curr.stat.isDirectory()
129
225
  ? getAllFilePathsRec(curr.path, paths)
130
226
  : [curr.path]
@@ -133,7 +229,10 @@ function getAllFilePathsRec (targetPath, paths) {
133
229
  }, [])
134
230
  }
135
231
 
136
- function getFilteredFilePaths (targetPath, allowedFiles) {
232
+ function getFilteredFilePaths (
233
+ targetPath: string,
234
+ allowedFiles: string[],
235
+ ): string[] {
137
236
  const allPaths = getAllFilePathsRec(targetPath, [])
138
237
 
139
238
  return allPaths.filter(item =>
@@ -141,21 +240,23 @@ function getFilteredFilePaths (targetPath, allowedFiles) {
141
240
  )
142
241
  }
143
242
 
144
- function getMethodFilePaths (targetPath) {
243
+ function getMethodFilePaths (targetPath: string): string[] {
145
244
  return getFilteredFilePaths(targetPath, ALLOWED_FILES_METHODS)
146
245
  }
147
246
 
148
- function getMetaFilePaths (targetPath) {
247
+ function getMetaFilePaths (targetPath: string): string[] {
149
248
  return getFilteredFilePaths(targetPath, ALLOWED_FILES_META)
150
249
  }
151
250
 
152
- function selectMetaPaths (metadata, modulePath) {
251
+ function selectMetaPaths (metadata: string[], modulePath: string): string[] {
153
252
  return metadata
154
253
  .filter(metaPath => modulePath.startsWith(path.dirname(metaPath)))
155
254
  .sort((a, b) => a.length - b.length)
156
255
  }
157
256
 
158
- async function resolveMetaMiddleware (metaPaths) {
257
+ async function resolveMetaMiddleware (
258
+ metaPaths: string[],
259
+ ): Promise<Middleware[]> {
159
260
  const modules = await Promise.all(metaPaths.map(item => import(item)))
160
261
 
161
262
  return modules
@@ -166,7 +267,11 @@ async function resolveMetaMiddleware (metaPaths) {
166
267
  ], [])
167
268
  }
168
269
 
169
- function buildRoutePaths (rootPath, mountPath, metadata) {
270
+ function buildRoutePaths (
271
+ rootPath: string,
272
+ mountPath: string,
273
+ metadata: string[],
274
+ ): RoutePath[] {
170
275
  const paths = getMethodFilePaths(rootPath)
171
276
 
172
277
  return paths.map(modulePath => {
@@ -182,7 +287,7 @@ function buildRoutePaths (rootPath, mountPath, metadata) {
182
287
  const metaMiddlewarePath = selectMetaPaths(metadata, modulePath)
183
288
 
184
289
  return {
185
- method: segments[lastIndex].toUpperCase(),
290
+ method: segments[lastIndex].toUpperCase() as HttpMethod,
186
291
  path: joinedPath,
187
292
  metaMiddlewarePath,
188
293
  modulePath,
@@ -190,7 +295,10 @@ function buildRoutePaths (rootPath, mountPath, metadata) {
190
295
  })
191
296
  }
192
297
 
193
- async function buildChain (route, rootMiddleware) {
298
+ async function buildChain (
299
+ route: RoutePath,
300
+ rootMiddleware: Middleware[],
301
+ ): Promise<ChainRoute> {
194
302
  const module = await import(route.modulePath)
195
303
  const metaMiddleware = await resolveMetaMiddleware(route.metaMiddlewarePath)
196
304
 
@@ -216,13 +324,21 @@ ${route.modulePath}
216
324
  }
217
325
  }
218
326
 
219
- function buildNormalRoutes (routePaths, rootMiddleware) {
327
+ function buildNormalRoutes (
328
+ routePaths: RoutePath[],
329
+ rootMiddleware: Middleware[],
330
+ ): Promise<ChainRoute[]> {
220
331
  return Promise.all(
221
332
  routePaths.map(route => buildChain(route, rootMiddleware)),
222
333
  )
223
334
  }
224
335
 
225
- async function buildMergedRoutes (routePaths, middleware, state, opts) {
336
+ async function buildMergedRoutes (
337
+ routePaths: ChainRoute[],
338
+ middleware: Middleware[],
339
+ state: SocketState,
340
+ opts: RoutingOptions,
341
+ ): Promise<ChainRoute[]> {
226
342
  const { basePath, mountPath, metadata } = opts
227
343
  const socketRoutes = buildSocketHandlers(state)
228
344
 
@@ -259,17 +375,17 @@ async function buildMergedRoutes (routePaths, middleware, state, opts) {
259
375
  return routePaths
260
376
  }
261
377
 
262
- function buildSocketRoutes (mergedRoutes) {
378
+ function buildSocketRoutes (mergedRoutes: ChainRoute[]): SocketRoute[] {
263
379
  return mergedRoutes.map(route => ({
264
380
  ...route,
265
381
  segments: toSegments(route.path),
266
382
  }))
267
383
  }
268
384
 
269
- function buildModuleRoutes (routePaths) {
385
+ function buildModuleRoutes (routePaths: SocketRoute[]): ModuleRoute[] {
270
386
  return routePaths.map(route => {
271
- const handler = async (bunReq, server) => {
272
- const req = buildBunRequest(bunReq, server)
387
+ const handler: EndpointHandler = async (bunReq, server) => {
388
+ const req = buildEndpointRequest(bunReq, server)
273
389
 
274
390
  return executeMiddlewareChain(req, route.chain)
275
391
  }
@@ -282,20 +398,21 @@ function buildModuleRoutes (routePaths) {
282
398
  })
283
399
  }
284
400
 
285
- function buildServerRoutes (moduleRoutes) {
286
- return moduleRoutes.reduce((accum, curr) => {
287
- if (!accum[curr.path]) {
288
- accum[curr.path] = defaultMethodMap()
289
- }
401
+ function buildServerRoutes (moduleRoutes: ModuleRoute[]): ServerRoutes {
402
+ return moduleRoutes.reduce<ServerRoutes>(
403
+ (accum, curr) => {
404
+ if (!accum[curr.path]) {
405
+ accum[curr.path] = defaultMethodMap()
406
+ }
290
407
 
291
- accum[curr.path][curr.method] = curr.handler
408
+ accum[curr.path][curr.method] = curr.handler
292
409
 
293
- return accum
294
- }, {})
410
+ return accum
411
+ }, {})
295
412
  }
296
413
 
297
- function buildOutputRoutes (moduleRoutes) {
298
- return moduleRoutes.reduce((accum, curr) => {
414
+ function buildOutputRoutes (moduleRoutes: ModuleRoute[]): OutputRoutes {
415
+ return moduleRoutes.reduce<OutputRoutes>((accum, curr) => {
299
416
  accum[curr.path] = accum[curr.path] || []
300
417
  accum[curr.path].push(curr.method)
301
418
 
@@ -303,7 +420,11 @@ function buildOutputRoutes (moduleRoutes) {
303
420
  }, {})
304
421
  }
305
422
 
306
- async function buildRoutes (rootPath, state, opts) {
423
+ async function buildRoutes (
424
+ rootPath: string,
425
+ state: SocketState,
426
+ opts: AppOptions,
427
+ ): Promise<AppRoutes> {
307
428
  const basePath = `${rootPath}/api`
308
429
  const mountPath = opts.mountPath || ''
309
430
  const middleware = opts.middleware || []
@@ -336,7 +457,12 @@ async function buildRoutes (rootPath, state, opts) {
336
457
  }
337
458
  }
338
459
 
339
- function buildServer (port, routes, state, opts) {
460
+ function buildServer (
461
+ port: number,
462
+ routes: AppRoutes,
463
+ state: SocketState,
464
+ opts: AppOptions,
465
+ ): Server {
340
466
  const hostname = opts.hostname || '0.0.0.0'
341
467
  const websocketServer = buildSocketServer(routes.socket, state)
342
468
 
@@ -351,17 +477,25 @@ function buildServer (port, routes, state, opts) {
351
477
  error (err) {
352
478
  console.error(err)
353
479
 
354
- const status = err.constructor.status ?? 500
480
+ if (err instanceof RequestError) {
481
+ const ctor = err.constructor as typeof RequestError
482
+
483
+ return Response.json(err.output, { status: ctor.status })
484
+ }
355
485
 
356
- return err.output !== undefined
357
- ? Response.json(err.output, { status })
358
- : new Response(err.message, { status })
486
+ return new Response(err.message, {
487
+ status: StatusCode.InternalServerError,
488
+ })
359
489
  },
360
490
  })
361
491
  }
362
492
 
363
- function processIO (port, server, opts) {
364
- const onClose = opts.onClose || (() => { })
493
+ function processIO (
494
+ port: number,
495
+ server: Server,
496
+ opts: AppOptions,
497
+ ): void {
498
+ const onClose = opts.onClose || (() => {})
365
499
 
366
500
  console.info(`Running on port: ${port}`)
367
501
  console.info('')
@@ -376,7 +510,11 @@ function processIO (port, server, opts) {
376
510
  })
377
511
  }
378
512
 
379
- export async function createApp (port, rootPath, opts = {}) {
513
+ export async function createApp (
514
+ port: number,
515
+ rootPath: string,
516
+ opts: AppOptions = {},
517
+ ): Promise<App> {
380
518
  const state = buildSocketState(opts)
381
519
  const routes = await buildRoutes(rootPath, state, opts)
382
520
  const server = buildServer(port, routes, state, opts)
@@ -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
+ }