sleepy-serv 0.5.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/index.js CHANGED
@@ -2,9 +2,16 @@ import fs from 'fs'
2
2
  import path from 'path'
3
3
  import querystring from 'querystring'
4
4
  import readline from 'node:readline'
5
+
5
6
  import { stdin, stdout } from 'node:process'
7
+ import { toSegments, executeMiddlewareChain } from './utils'
6
8
 
7
- import * as _middleware from './middleware'
9
+ import {
10
+ buildSocketState,
11
+ buildSocketServer,
12
+ buildSocketHandlers,
13
+ buildSocketCommands,
14
+ } from './socket'
8
15
 
9
16
  import {
10
17
  NotFoundError,
@@ -13,10 +20,13 @@ import {
13
20
 
14
21
  export * from './errors'
15
22
 
16
- const ALLOWED_FILES_META = [
17
- 'meta.js',
18
- 'meta.ts',
19
- ]
23
+ export {
24
+ parseJsonBody,
25
+ setValidationFormats,
26
+ validateSchemas,
27
+ } from './middleware'
28
+
29
+ const ALLOWED_FILES_META = ['meta.js', 'meta.ts']
20
30
 
21
31
  const ALLOWED_FILES_METHODS = [
22
32
  'head.js',
@@ -33,18 +43,11 @@ const ALLOWED_FILES_METHODS = [
33
43
  'delete.ts',
34
44
  ]
35
45
 
36
- const ALLOWED_FILES_ALL = [
37
- ...ALLOWED_FILES_META,
38
- ...ALLOWED_FILES_METHODS,
39
- ]
40
-
41
46
  /* istanbul ignore if */
42
47
  if (process.stdin.isTTY) {
43
48
  process.stdin.setRawMode(true)
44
49
  }
45
50
 
46
- export const middleware = _middleware
47
-
48
51
  const rl = readline.createInterface({
49
52
  input: stdin,
50
53
  output: stdout,
@@ -54,18 +57,32 @@ function methodNotAllowedHandler (_req) {
54
57
  throw new MethodNotAllowedError()
55
58
  }
56
59
 
57
- /* TODO: add whitelist support */
58
- function validateDirectoryIllegalFiles (targetPath, filenames) {
59
- // const hasInvalidFiles = filenames.some(filename =>
60
- // !ALLOWED_FILES_ALL.includes(filename)
61
- // )
62
-
63
- // if (hasInvalidFiles) {
64
- // throw new TypeError(`
65
- // Directory contains illegal files:
66
- // ${targetPath}
67
- // `.trim())
68
- // }
60
+ function defaultMethodMap () {
61
+ return {
62
+ HEAD: methodNotAllowedHandler,
63
+ GET: methodNotAllowedHandler,
64
+ PUT: methodNotAllowedHandler,
65
+ POST: methodNotAllowedHandler,
66
+ PATCH: methodNotAllowedHandler,
67
+ DELETE: methodNotAllowedHandler,
68
+ }
69
+ }
70
+
71
+ function buildBunRequest (bunReq, server) {
72
+ const url = new URL(bunReq.url)
73
+ const qs = url.search.replace('?', '')
74
+ const json = () => bunReq.json()
75
+
76
+ return {
77
+ method: bunReq.method,
78
+ route: url.pathname,
79
+ headers: bunReq.headers,
80
+ params: bunReq.params ?? {},
81
+ query: querystring.parse(qs),
82
+ raw: bunReq,
83
+ server,
84
+ json,
85
+ }
69
86
  }
70
87
 
71
88
  function validateLeafDirectory (targetPath, filenames, entries) {
@@ -73,7 +90,7 @@ function validateLeafDirectory (targetPath, filenames, entries) {
73
90
 
74
91
  if (!hasDirectories) {
75
92
  const hasMethodEntry = filenames.some(filename =>
76
- ALLOWED_FILES_METHODS.includes(filename)
93
+ ALLOWED_FILES_METHODS.includes(filename),
77
94
  )
78
95
 
79
96
  if (!hasMethodEntry) {
@@ -90,7 +107,6 @@ function validateDirectory (targetPath, entries) {
90
107
  .filter(entry => entry.stat.isFile())
91
108
  .map(entry => path.basename(entry.path))
92
109
 
93
- validateDirectoryIllegalFiles(targetPath, filenames)
94
110
  validateLeafDirectory(targetPath, filenames, entries)
95
111
  }
96
112
 
@@ -109,19 +125,19 @@ function getAllFilePathsRec (targetPath, paths) {
109
125
  validateDirectory(targetPath, children)
110
126
 
111
127
  return children.reduce((accum, curr) => {
112
- const result = curr.stat.isDirectory()
113
- ? getAllFilePathsRec(curr.path, paths)
114
- : [curr.path]
128
+ const result = curr.stat.isDirectory()
129
+ ? getAllFilePathsRec(curr.path, paths)
130
+ : [curr.path]
115
131
 
116
- return [...accum, ...result]
117
- }, [])
132
+ return [...accum, ...result]
133
+ }, [])
118
134
  }
119
135
 
120
136
  function getFilteredFilePaths (targetPath, allowedFiles) {
121
137
  const allPaths = getAllFilePathsRec(targetPath, [])
122
138
 
123
139
  return allPaths.filter(item =>
124
- allowedFiles.includes(path.basename(item))
140
+ allowedFiles.includes(path.basename(item)),
125
141
  )
126
142
  }
127
143
 
@@ -133,8 +149,24 @@ function getMetaFilePaths (targetPath) {
133
149
  return getFilteredFilePaths(targetPath, ALLOWED_FILES_META)
134
150
  }
135
151
 
136
- function buildRoutesPaths (rootPath, mountPath) {
137
- const metadata = getMetaFilePaths(rootPath)
152
+ function selectMetaPaths (metadata, modulePath) {
153
+ return metadata
154
+ .filter(metaPath => modulePath.startsWith(path.dirname(metaPath)))
155
+ .sort((a, b) => a.length - b.length)
156
+ }
157
+
158
+ async function resolveMetaMiddleware (metaPaths) {
159
+ const modules = await Promise.all(metaPaths.map(item => import(item)))
160
+
161
+ return modules
162
+ .map(item => item.middleware)
163
+ .reduce((accum, curr) => [
164
+ ...accum,
165
+ ...(curr || []),
166
+ ], [])
167
+ }
168
+
169
+ function buildRoutePaths (rootPath, mountPath, metadata) {
138
170
  const paths = getMethodFilePaths(rootPath)
139
171
 
140
172
  return paths.map(modulePath => {
@@ -147,13 +179,7 @@ function buildRoutesPaths (rootPath, mountPath) {
147
179
  .filter(item => item)
148
180
  .join('') || '/'
149
181
 
150
- const metaMiddlewarePath = metadata
151
- .filter(metaPath => {
152
- const metaBasePath = path.dirname(metaPath)
153
-
154
- return modulePath.startsWith(metaBasePath)
155
- })
156
- .sort((a, b) => a.length - b.length)
182
+ const metaMiddlewarePath = selectMetaPaths(metadata, modulePath)
157
183
 
158
184
  return {
159
185
  method: segments[lastIndex].toUpperCase(),
@@ -164,19 +190,9 @@ function buildRoutesPaths (rootPath, mountPath) {
164
190
  })
165
191
  }
166
192
 
167
- async function buildHandlers (route, rootMiddleware) {
193
+ async function buildChain (route, rootMiddleware) {
168
194
  const module = await import(route.modulePath)
169
-
170
- const middlewareModules = await Promise.all(
171
- route.metaMiddlewarePath.map(item => import(item))
172
- )
173
-
174
- const metaMiddleware = middlewareModules
175
- .map(item => item.middleware)
176
- .reduce((accum, curr) => [
177
- ...accum,
178
- ...(curr || []),
179
- ], [])
195
+ const metaMiddleware = await resolveMetaMiddleware(route.metaMiddlewarePath)
180
196
 
181
197
  if (!module.default) {
182
198
  throw new ReferenceError(`
@@ -189,62 +205,87 @@ ${route.modulePath}
189
205
  ? module.default
190
206
  : [module.default]
191
207
 
192
- const middlewareChain = [
193
- ...rootMiddleware,
194
- ...metaMiddleware,
195
- ...baseChain,
196
- ]
208
+ return {
209
+ method: route.method,
210
+ path: route.path,
211
+ chain: [
212
+ ...rootMiddleware,
213
+ ...metaMiddleware,
214
+ ...baseChain,
215
+ ],
216
+ }
217
+ }
197
218
 
198
- const handler = async req => {
199
- const query = req.url.split('?')[1]
200
- const res = {}
219
+ function buildNormalRoutes (routePaths, rootMiddleware) {
220
+ return Promise.all(
221
+ routePaths.map(route => buildChain(route, rootMiddleware)),
222
+ )
223
+ }
201
224
 
202
- req.query = query ? querystring.parse(query) : {}
225
+ async function buildMergedRoutes (routePaths, middleware, state, opts) {
226
+ const { basePath, mountPath, metadata } = opts
227
+ const socketRoutes = buildSocketHandlers(state)
203
228
 
204
- const executeMiddleware = async (index) => {
205
- const currentMiddleware = middlewareChain[index]
206
- const isLastMiddleware = index === middlewareChain.length - 1
229
+ for (const socketRoute of socketRoutes) {
230
+ const mountedPath = `${mountPath}${socketRoute.path}`
207
231
 
208
- const next = !isLastMiddleware ?
209
- () => executeMiddleware(index + 1)
210
- : null
232
+ const targetItem = routePaths.find(item => (
233
+ item.method === socketRoute.method &&
234
+ item.path === mountedPath
235
+ ))
211
236
 
212
- const result = await currentMiddleware(req, res, next)
237
+ if (targetItem) {
238
+ targetItem.chain.push(socketRoute.handler)
213
239
 
214
- if (result instanceof Response) {
215
- return result
216
- } else {
217
- throw new TypeError('Handler does not return a Response object')
218
- }
240
+ continue
219
241
  }
220
242
 
221
- return executeMiddleware(0)
243
+ const method = socketRoute.method.toLowerCase()
244
+ const modulePath = path.join(basePath, socketRoute.path, `${method}.js`)
245
+ const metaPaths = selectMetaPaths(metadata, modulePath)
246
+ const metaMiddleware = await resolveMetaMiddleware(metaPaths)
247
+
248
+ routePaths.push({
249
+ method: socketRoute.method,
250
+ path: mountedPath,
251
+ chain: [
252
+ ...middleware,
253
+ ...metaMiddleware,
254
+ socketRoute.handler,
255
+ ],
256
+ })
222
257
  }
223
258
 
224
- return {
225
- method: route.method,
226
- path: route.path,
227
- handler,
228
- }
259
+ return routePaths
229
260
  }
230
261
 
231
- function buildModuleRoutes (routePaths, rootMiddleware) {
232
- return Promise.all(
233
- routePaths.map(route => buildHandlers(route, rootMiddleware))
234
- )
262
+ function buildSocketRoutes (mergedRoutes) {
263
+ return mergedRoutes.map(route => ({
264
+ ...route,
265
+ segments: toSegments(route.path),
266
+ }))
267
+ }
268
+
269
+ function buildModuleRoutes (routePaths) {
270
+ return routePaths.map(route => {
271
+ const handler = async (bunReq, server) => {
272
+ const req = buildBunRequest(bunReq, server)
273
+
274
+ return executeMiddlewareChain(req, route.chain)
275
+ }
276
+
277
+ return {
278
+ method: route.method,
279
+ path: route.path,
280
+ handler,
281
+ }
282
+ })
235
283
  }
236
284
 
237
285
  function buildServerRoutes (moduleRoutes) {
238
286
  return moduleRoutes.reduce((accum, curr) => {
239
287
  if (!accum[curr.path]) {
240
- accum[curr.path] = {
241
- HEAD: methodNotAllowedHandler,
242
- GET: methodNotAllowedHandler,
243
- PUT: methodNotAllowedHandler,
244
- POST: methodNotAllowedHandler,
245
- PATCH: methodNotAllowedHandler,
246
- DELETE: methodNotAllowedHandler,
247
- }
288
+ accum[curr.path] = defaultMethodMap()
248
289
  }
249
290
 
250
291
  accum[curr.path][curr.method] = curr.handler
@@ -262,43 +303,65 @@ function buildOutputRoutes (moduleRoutes) {
262
303
  }, {})
263
304
  }
264
305
 
265
- async function buildRoutes (rootPath, opts) {
306
+ async function buildRoutes (rootPath, state, opts) {
266
307
  const basePath = `${rootPath}/api`
267
308
  const mountPath = opts.mountPath || ''
268
- const rootMiddleware = opts.middleware || []
269
- const routePaths = buildRoutesPaths(basePath, mountPath)
270
- const moduleRoutes = await buildModuleRoutes(routePaths, rootMiddleware)
309
+ const middleware = opts.middleware || []
310
+ const metadata = getMetaFilePaths(basePath)
311
+ const routePaths = buildRoutePaths(basePath, mountPath, metadata)
312
+ const normalRoutes = await buildNormalRoutes(routePaths, middleware)
313
+
314
+ const routingOpts = {
315
+ basePath,
316
+ mountPath,
317
+ metadata,
318
+ }
319
+
320
+ const mergedRoutes = await buildMergedRoutes(
321
+ normalRoutes,
322
+ middleware,
323
+ state,
324
+ routingOpts,
325
+ )
326
+
327
+ const socketRoutes = buildSocketRoutes(mergedRoutes)
328
+ const moduleRoutes = buildModuleRoutes(socketRoutes)
271
329
  const serverRoutes = buildServerRoutes(moduleRoutes)
272
330
  const outputRoutes = buildOutputRoutes(moduleRoutes)
273
331
 
274
332
  return {
275
333
  server: serverRoutes,
334
+ socket: socketRoutes,
276
335
  output: outputRoutes,
277
336
  }
278
337
  }
279
338
 
280
- function buildServer (port, routes, opts) {
339
+ function buildServer (port, routes, state, opts) {
281
340
  const hostname = opts.hostname || '0.0.0.0'
341
+ const websocketServer = buildSocketServer(routes.socket, state)
282
342
 
283
343
  return Bun.serve({
284
344
  port,
285
345
  hostname,
286
346
  routes: routes.server,
287
- fetch (_req) {
347
+ websocket: websocketServer,
348
+ async fetch (_req, _server) {
288
349
  throw new NotFoundError()
289
350
  },
290
351
  error (err) {
291
352
  console.error(err)
292
353
 
293
- return new Response(err.message, {
294
- status: err.constructor.statusCode || 500,
295
- })
354
+ const status = err.constructor.status ?? 500
355
+
356
+ return err.output !== undefined
357
+ ? Response.json(err.output, { status })
358
+ : new Response(err.message, { status })
296
359
  },
297
360
  })
298
361
  }
299
362
 
300
363
  function processIO (port, server, opts) {
301
- const onClose = opts.onClose || (() => {})
364
+ const onClose = opts.onClose || (() => { })
302
365
 
303
366
  console.info(`Running on port: ${port}`)
304
367
  console.info('')
@@ -314,13 +377,16 @@ function processIO (port, server, opts) {
314
377
  }
315
378
 
316
379
  export async function createApp (port, rootPath, opts = {}) {
317
- const routes = await buildRoutes(rootPath, opts)
318
- const server = buildServer(port, routes, opts)
380
+ const state = buildSocketState(opts)
381
+ const routes = await buildRoutes(rootPath, state, opts)
382
+ const server = buildServer(port, routes, state, opts)
383
+ const commands = buildSocketCommands(state)
319
384
 
320
385
  processIO(port, server, opts)
321
386
 
322
387
  return {
323
388
  routes: routes.output,
324
389
  server,
390
+ commands,
325
391
  }
326
392
  }
@@ -0,0 +1,164 @@
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
+ export const TYPES = {
8
+ REQUEST: 'request',
9
+ RESPONSE: 'response',
10
+ WELCOME: 'welcome',
11
+ HEARTBEAT: 'heartbeat',
12
+ NOTIFICATION: 'notification',
13
+ }
14
+
15
+ export const TYPES_RECEIVED = [
16
+ TYPES.HEARTBEAT,
17
+ TYPES.REQUEST,
18
+ ]
19
+
20
+ const ajv = new Ajv({
21
+ allErrors: true,
22
+ removeAdditional: 'all',
23
+ })
24
+
25
+ addFormats(ajv)
26
+
27
+ const SCHEMA_BASE = {
28
+ type: 'object',
29
+ properties: {
30
+ id: {
31
+ type: 'string',
32
+ format: 'uuid',
33
+ },
34
+ clientId: {
35
+ type: 'string',
36
+ format: 'uuid',
37
+ },
38
+ type: {
39
+ type: 'string',
40
+ enum: TYPES_RECEIVED,
41
+ },
42
+ timestamp: {
43
+ type: 'string',
44
+ format: 'date-time',
45
+ },
46
+ },
47
+ required: [
48
+ 'id',
49
+ 'clientId',
50
+ 'type',
51
+ 'timestamp',
52
+ ],
53
+ }
54
+
55
+ const validateHeartbeat = ajv.compile({
56
+ type: 'object',
57
+ properties: {
58
+ ...SCHEMA_BASE.properties,
59
+ type: {
60
+ type: 'string',
61
+ const: TYPES.HEARTBEAT,
62
+ },
63
+ },
64
+ required: SCHEMA_BASE.required,
65
+ })
66
+
67
+ const validateRequest = ajv.compile({
68
+ type: 'object',
69
+ properties: {
70
+ ...SCHEMA_BASE.properties,
71
+ type: {
72
+ type: 'string',
73
+ const: TYPES.REQUEST,
74
+ },
75
+ method: {
76
+ type: 'string',
77
+ enum: [
78
+ 'HEAD',
79
+ 'GET',
80
+ 'PUT',
81
+ 'POST',
82
+ 'PATCH',
83
+ 'DELETE',
84
+ ],
85
+ },
86
+ route: {
87
+ type: 'string',
88
+ format: 'uri-reference',
89
+ },
90
+ headers: {
91
+ type: 'object',
92
+ },
93
+ query: {
94
+ type: 'object',
95
+ },
96
+ body: {
97
+ type: [
98
+ 'boolean',
99
+ 'number',
100
+ 'string',
101
+ 'object',
102
+ 'array',
103
+ 'null',
104
+ ],
105
+ },
106
+ },
107
+ required: [
108
+ ...SCHEMA_BASE.required,
109
+ 'method',
110
+ 'route',
111
+ 'headers',
112
+ 'query',
113
+ 'body',
114
+ ],
115
+ })
116
+
117
+ const TYPE_VALIDATORS = {
118
+ [TYPES.HEARTBEAT]: validateHeartbeat,
119
+ [TYPES.REQUEST]: validateRequest,
120
+ }
121
+
122
+ export function createMessage (clientId, type, opts = {}) {
123
+ const timestamp = new Date().toISOString()
124
+
125
+ const base = {
126
+ id: opts.id ?? crypto.randomUUID(),
127
+ clientId,
128
+ type,
129
+ timestamp,
130
+ }
131
+
132
+ return {
133
+ ...opts,
134
+ ...base,
135
+ }
136
+ }
137
+
138
+ export function validateMessage (message) {
139
+ const validate = TYPE_VALIDATORS[message.type]
140
+
141
+ if (message.type === undefined) {
142
+ throw new UnprocessableContentError([
143
+ {
144
+ path: '',
145
+ message: `must have required property 'type'`,
146
+ },
147
+ ])
148
+ }
149
+
150
+ if (!TYPES_RECEIVED.includes(message.type)) {
151
+ throw new UnprocessableContentError([
152
+ {
153
+ path: 'type',
154
+ message: `must be one of: ${TYPES_RECEIVED}`,
155
+ },
156
+ ])
157
+ }
158
+
159
+ if (!validate(message)) {
160
+ const errors = validate.errors.map(item => formatError('', item))
161
+
162
+ throw new UnprocessableContentError(errors)
163
+ }
164
+ }