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/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.js'
9
+ import {
10
+ buildSocketState,
11
+ buildSocketServer,
12
+ buildSocketHandlers,
13
+ buildSocketCommands,
14
+ } from './socket'
8
15
 
9
16
  import {
10
17
  NotFoundError,
@@ -13,20 +20,27 @@ import {
13
20
 
14
21
  export * from './errors'
15
22
 
16
- const ALLOWED_FILES_META = ['meta.js']
23
+ export {
24
+ parseJsonBody,
25
+ setValidationFormats,
26
+ validateSchemas,
27
+ } from './middleware'
28
+
29
+ const ALLOWED_FILES_META = ['meta.js', 'meta.ts']
17
30
 
18
31
  const ALLOWED_FILES_METHODS = [
19
32
  'head.js',
33
+ 'head.ts',
20
34
  'get.js',
35
+ 'get.ts',
21
36
  'put.js',
37
+ 'put.ts',
22
38
  'post.js',
39
+ 'post.ts',
23
40
  'patch.js',
41
+ 'patch.ts',
24
42
  'delete.js',
25
- ]
26
-
27
- const ALLOWED_FILES_ALL = [
28
- ...ALLOWED_FILES_META,
29
- ...ALLOWED_FILES_METHODS,
43
+ 'delete.ts',
30
44
  ]
31
45
 
32
46
  /* istanbul ignore if */
@@ -34,8 +48,6 @@ if (process.stdin.isTTY) {
34
48
  process.stdin.setRawMode(true)
35
49
  }
36
50
 
37
- export const middleware = _middleware
38
-
39
51
  const rl = readline.createInterface({
40
52
  input: stdin,
41
53
  output: stdout,
@@ -45,18 +57,32 @@ function methodNotAllowedHandler (_req) {
45
57
  throw new MethodNotAllowedError()
46
58
  }
47
59
 
48
- /* TODO: add whitelist support */
49
- function validateDirectoryIllegalFiles (targetPath, filenames) {
50
- // const hasInvalidFiles = filenames.some(filename =>
51
- // !ALLOWED_FILES_ALL.includes(filename)
52
- // )
53
-
54
- // if (hasInvalidFiles) {
55
- // throw new TypeError(`
56
- // Directory contains illegal files:
57
- // ${targetPath}
58
- // `.trim())
59
- // }
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
+ }
60
86
  }
61
87
 
62
88
  function validateLeafDirectory (targetPath, filenames, entries) {
@@ -64,7 +90,7 @@ function validateLeafDirectory (targetPath, filenames, entries) {
64
90
 
65
91
  if (!hasDirectories) {
66
92
  const hasMethodEntry = filenames.some(filename =>
67
- ALLOWED_FILES_METHODS.includes(filename)
93
+ ALLOWED_FILES_METHODS.includes(filename),
68
94
  )
69
95
 
70
96
  if (!hasMethodEntry) {
@@ -81,7 +107,6 @@ function validateDirectory (targetPath, entries) {
81
107
  .filter(entry => entry.stat.isFile())
82
108
  .map(entry => path.basename(entry.path))
83
109
 
84
- validateDirectoryIllegalFiles(targetPath, filenames)
85
110
  validateLeafDirectory(targetPath, filenames, entries)
86
111
  }
87
112
 
@@ -100,19 +125,19 @@ function getAllFilePathsRec (targetPath, paths) {
100
125
  validateDirectory(targetPath, children)
101
126
 
102
127
  return children.reduce((accum, curr) => {
103
- const result = curr.stat.isDirectory()
104
- ? getAllFilePathsRec(curr.path, paths)
105
- : [curr.path]
128
+ const result = curr.stat.isDirectory()
129
+ ? getAllFilePathsRec(curr.path, paths)
130
+ : [curr.path]
106
131
 
107
- return [...accum, ...result]
108
- }, [])
132
+ return [...accum, ...result]
133
+ }, [])
109
134
  }
110
135
 
111
136
  function getFilteredFilePaths (targetPath, allowedFiles) {
112
137
  const allPaths = getAllFilePathsRec(targetPath, [])
113
138
 
114
139
  return allPaths.filter(item =>
115
- allowedFiles.includes(path.basename(item))
140
+ allowedFiles.includes(path.basename(item)),
116
141
  )
117
142
  }
118
143
 
@@ -124,8 +149,24 @@ function getMetaFilePaths (targetPath) {
124
149
  return getFilteredFilePaths(targetPath, ALLOWED_FILES_META)
125
150
  }
126
151
 
127
- function buildRoutesPaths (rootPath, mountPath) {
128
- 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) {
129
170
  const paths = getMethodFilePaths(rootPath)
130
171
 
131
172
  return paths.map(modulePath => {
@@ -138,13 +179,7 @@ function buildRoutesPaths (rootPath, mountPath) {
138
179
  .filter(item => item)
139
180
  .join('') || '/'
140
181
 
141
- const metaMiddlewarePath = metadata
142
- .filter(metaPath => {
143
- const metaBasePath = path.dirname(metaPath)
144
-
145
- return modulePath.startsWith(metaBasePath)
146
- })
147
- .sort((a, b) => a.length - b.length)
182
+ const metaMiddlewarePath = selectMetaPaths(metadata, modulePath)
148
183
 
149
184
  return {
150
185
  method: segments[lastIndex].toUpperCase(),
@@ -155,19 +190,9 @@ function buildRoutesPaths (rootPath, mountPath) {
155
190
  })
156
191
  }
157
192
 
158
- async function buildHandlers (route, rootMiddleware) {
193
+ async function buildChain (route, rootMiddleware) {
159
194
  const module = await import(route.modulePath)
160
-
161
- const middlewareModules = await Promise.all(
162
- route.metaMiddlewarePath.map(item => import(item))
163
- )
164
-
165
- const metaMiddleware = middlewareModules
166
- .map(item => item.middleware)
167
- .reduce((accum, curr) => [
168
- ...accum,
169
- ...(curr || []),
170
- ], [])
195
+ const metaMiddleware = await resolveMetaMiddleware(route.metaMiddlewarePath)
171
196
 
172
197
  if (!module.default) {
173
198
  throw new ReferenceError(`
@@ -180,62 +205,87 @@ ${route.modulePath}
180
205
  ? module.default
181
206
  : [module.default]
182
207
 
183
- const middlewareChain = [
184
- ...rootMiddleware,
185
- ...metaMiddleware,
186
- ...baseChain,
187
- ]
208
+ return {
209
+ method: route.method,
210
+ path: route.path,
211
+ chain: [
212
+ ...rootMiddleware,
213
+ ...metaMiddleware,
214
+ ...baseChain,
215
+ ],
216
+ }
217
+ }
188
218
 
189
- const handler = async req => {
190
- const query = req.url.split('?')[1]
191
- const res = {}
219
+ function buildNormalRoutes (routePaths, rootMiddleware) {
220
+ return Promise.all(
221
+ routePaths.map(route => buildChain(route, rootMiddleware)),
222
+ )
223
+ }
192
224
 
193
- 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)
194
228
 
195
- const executeMiddleware = async (index) => {
196
- const currentMiddleware = middlewareChain[index]
197
- const isLastMiddleware = index === middlewareChain.length - 1
229
+ for (const socketRoute of socketRoutes) {
230
+ const mountedPath = `${mountPath}${socketRoute.path}`
198
231
 
199
- const next = !isLastMiddleware ?
200
- () => executeMiddleware(index + 1)
201
- : null
232
+ const targetItem = routePaths.find(item => (
233
+ item.method === socketRoute.method &&
234
+ item.path === mountedPath
235
+ ))
202
236
 
203
- const result = await currentMiddleware(req, res, next)
237
+ if (targetItem) {
238
+ targetItem.chain.push(socketRoute.handler)
204
239
 
205
- if (result instanceof Response) {
206
- return result
207
- } else {
208
- throw new TypeError('Handler does not return a Response object')
209
- }
240
+ continue
210
241
  }
211
242
 
212
- 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
+ })
213
257
  }
214
258
 
215
- return {
216
- method: route.method,
217
- path: route.path,
218
- handler,
219
- }
259
+ return routePaths
220
260
  }
221
261
 
222
- function buildModuleRoutes (routePaths, rootMiddleware) {
223
- return Promise.all(
224
- routePaths.map(route => buildHandlers(route, rootMiddleware))
225
- )
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
+ })
226
283
  }
227
284
 
228
285
  function buildServerRoutes (moduleRoutes) {
229
286
  return moduleRoutes.reduce((accum, curr) => {
230
287
  if (!accum[curr.path]) {
231
- accum[curr.path] = {
232
- HEAD: methodNotAllowedHandler,
233
- GET: methodNotAllowedHandler,
234
- PUT: methodNotAllowedHandler,
235
- POST: methodNotAllowedHandler,
236
- PATCH: methodNotAllowedHandler,
237
- DELETE: methodNotAllowedHandler,
238
- }
288
+ accum[curr.path] = defaultMethodMap()
239
289
  }
240
290
 
241
291
  accum[curr.path][curr.method] = curr.handler
@@ -253,43 +303,65 @@ function buildOutputRoutes (moduleRoutes) {
253
303
  }, {})
254
304
  }
255
305
 
256
- async function buildRoutes (rootPath, opts) {
306
+ async function buildRoutes (rootPath, state, opts) {
257
307
  const basePath = `${rootPath}/api`
258
308
  const mountPath = opts.mountPath || ''
259
- const rootMiddleware = opts.middleware || []
260
- const routePaths = buildRoutesPaths(basePath, mountPath)
261
- 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)
262
329
  const serverRoutes = buildServerRoutes(moduleRoutes)
263
330
  const outputRoutes = buildOutputRoutes(moduleRoutes)
264
331
 
265
332
  return {
266
333
  server: serverRoutes,
334
+ socket: socketRoutes,
267
335
  output: outputRoutes,
268
336
  }
269
337
  }
270
338
 
271
- function buildServer (port, routes, opts) {
339
+ function buildServer (port, routes, state, opts) {
272
340
  const hostname = opts.hostname || '0.0.0.0'
341
+ const websocketServer = buildSocketServer(routes.socket, state)
273
342
 
274
343
  return Bun.serve({
275
344
  port,
276
345
  hostname,
277
346
  routes: routes.server,
278
- fetch (_req) {
347
+ websocket: websocketServer,
348
+ async fetch (_req, _server) {
279
349
  throw new NotFoundError()
280
350
  },
281
351
  error (err) {
282
352
  console.error(err)
283
353
 
284
- return new Response(err.message, {
285
- status: err.constructor.statusCode || 500,
286
- })
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 })
287
359
  },
288
360
  })
289
361
  }
290
362
 
291
363
  function processIO (port, server, opts) {
292
- const onClose = opts.onClose || (() => {})
364
+ const onClose = opts.onClose || (() => { })
293
365
 
294
366
  console.info(`Running on port: ${port}`)
295
367
  console.info('')
@@ -305,13 +377,16 @@ function processIO (port, server, opts) {
305
377
  }
306
378
 
307
379
  export async function createApp (port, rootPath, opts = {}) {
308
- const routes = await buildRoutes(rootPath, opts)
309
- 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)
310
384
 
311
385
  processIO(port, server, opts)
312
386
 
313
387
  return {
314
388
  routes: routes.output,
315
389
  server,
390
+ commands,
316
391
  }
317
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
+ }