sleepy-serv 0.18.0 → 0.20.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.
Files changed (52) hide show
  1. package/README.md +113 -17
  2. package/dist/cli/build.d.ts +2 -0
  3. package/dist/cli/build.d.ts.map +1 -0
  4. package/dist/cli/dev.d.ts +2 -0
  5. package/dist/cli/dev.d.ts.map +1 -0
  6. package/dist/cli/index.d.ts +3 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli/init.d.ts +2 -0
  9. package/dist/cli/init.d.ts.map +1 -0
  10. package/dist/core/errors.d.ts.map +1 -0
  11. package/dist/core/index.d.ts +17 -0
  12. package/dist/core/index.d.ts.map +1 -0
  13. package/dist/core/messages.d.ts.map +1 -0
  14. package/dist/core/middleware.d.ts.map +1 -0
  15. package/dist/core/socket.d.ts.map +1 -0
  16. package/dist/{utils.d.ts → core/utils.d.ts} +15 -2
  17. package/dist/core/utils.d.ts.map +1 -0
  18. package/dist/index.d.ts +3 -16
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/plugin/codegen.d.ts +3 -0
  21. package/dist/plugin/codegen.d.ts.map +1 -0
  22. package/dist/plugin/config.d.ts +14 -0
  23. package/dist/plugin/config.d.ts.map +1 -0
  24. package/dist/plugin/index.d.ts +2 -0
  25. package/dist/plugin/index.d.ts.map +1 -0
  26. package/dist/plugin/scanner.d.ts +15 -0
  27. package/dist/plugin/scanner.d.ts.map +1 -0
  28. package/package.json +8 -1
  29. package/src/cli/build.ts +105 -0
  30. package/src/cli/dev.ts +27 -0
  31. package/src/cli/index.ts +53 -0
  32. package/src/cli/init.ts +108 -0
  33. package/src/core/index.ts +406 -0
  34. package/src/{socket.ts → core/socket.ts} +18 -18
  35. package/src/{utils.ts → core/utils.ts} +18 -2
  36. package/src/index.ts +5 -571
  37. package/src/plugin/codegen.ts +101 -0
  38. package/src/plugin/config.ts +34 -0
  39. package/src/plugin/index.ts +30 -0
  40. package/src/plugin/scanner.ts +148 -0
  41. package/dist/errors.d.ts.map +0 -1
  42. package/dist/messages.d.ts.map +0 -1
  43. package/dist/middleware.d.ts.map +0 -1
  44. package/dist/socket.d.ts.map +0 -1
  45. package/dist/utils.d.ts.map +0 -1
  46. /package/dist/{errors.d.ts → core/errors.d.ts} +0 -0
  47. /package/dist/{messages.d.ts → core/messages.d.ts} +0 -0
  48. /package/dist/{middleware.d.ts → core/middleware.d.ts} +0 -0
  49. /package/dist/{socket.d.ts → core/socket.d.ts} +0 -0
  50. /package/src/{errors.ts → core/errors.ts} +0 -0
  51. /package/src/{messages.ts → core/messages.ts} +0 -0
  52. /package/src/{middleware.ts → core/middleware.ts} +0 -0
@@ -0,0 +1,108 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ const TEMPLATES: Record<string, string> = {
5
+ 'sleepy.config.ts': 'export default {}',
6
+
7
+ 'tsconfig.json': JSON.stringify({
8
+ compilerOptions: {
9
+ strict: true,
10
+ skipLibCheck: true,
11
+ target: 'ESNext',
12
+ module: 'ESNext',
13
+ moduleResolution: 'bundler',
14
+ types: ['bun-types'],
15
+ },
16
+ }, null, 2) + '\n',
17
+
18
+ 'src/index.ts': `
19
+ import { createApp } from \'sleepy-serv\'
20
+
21
+ createApp(3000)
22
+ `.trim().concat('\n'),
23
+
24
+ 'src/api/get.ts': `
25
+ export default function handler (): Response {
26
+ return Response.json({ ok: true })
27
+ }
28
+ `.trim().concat('\n'),
29
+ }
30
+
31
+ function writeIfMissing (filePath: string, content: string): boolean {
32
+ if (fs.existsSync(filePath)) {
33
+ console.warn(` skip: ${filePath} (already exists)`)
34
+
35
+ return false
36
+ }
37
+
38
+ const dir = path.dirname(filePath)
39
+
40
+ fs.mkdirSync(dir, { recursive: true })
41
+ fs.writeFileSync(filePath, content)
42
+ console.log(` create: ${filePath}`)
43
+
44
+ return true
45
+ }
46
+
47
+ function writePackageJson (): void {
48
+ const filePath = 'package.json'
49
+ const name = path.basename(process.cwd())
50
+
51
+ const scripts = {
52
+ dev: 'sleepy dev',
53
+ build: 'sleepy build',
54
+ }
55
+
56
+ if (fs.existsSync(filePath)) {
57
+ const raw = fs.readFileSync(filePath, 'utf-8')
58
+ const existing = JSON.parse(raw)
59
+
60
+ existing.scripts = {
61
+ ...existing.scripts,
62
+ ...scripts,
63
+ }
64
+
65
+ fs.writeFileSync(
66
+ filePath,
67
+ JSON.stringify(existing, null, 2) + '\n',
68
+ )
69
+
70
+ console.log(` update: ${filePath}`)
71
+
72
+ return
73
+ }
74
+
75
+ const pkg = {
76
+ name,
77
+ version: '0.0.0',
78
+ type: 'module',
79
+ scripts,
80
+ dependencies: {
81
+ 'sleepy-serv': 'latest',
82
+ },
83
+ }
84
+
85
+ fs.writeFileSync(
86
+ filePath,
87
+ JSON.stringify(pkg, null, 2) + '\n',
88
+ )
89
+
90
+ console.log(` create: ${filePath}`)
91
+ }
92
+
93
+ export async function init (): Promise<void> {
94
+ console.log('')
95
+
96
+ writePackageJson()
97
+
98
+ for (const [relPath, content] of Object.entries(TEMPLATES)) {
99
+ writeIfMissing(relPath, content)
100
+ }
101
+
102
+ console.log('')
103
+ console.log('Created sleepy-serv project in ./')
104
+ console.log('')
105
+ console.log(' bun install')
106
+ console.log(' sleepy dev')
107
+ console.log('')
108
+ }
@@ -0,0 +1,406 @@
1
+ import querystring from 'querystring'
2
+ import readline from 'node:readline'
3
+
4
+ import { stdin, stdout } from 'node:process'
5
+
6
+ import {
7
+ toSegments,
8
+ executeMiddlewareChain,
9
+ } from './utils'
10
+
11
+ import {
12
+ buildSocketState,
13
+ buildSocketServer,
14
+ buildSocketHandlers,
15
+ buildSocketCommands,
16
+ } from './socket'
17
+
18
+ import {
19
+ RequestError,
20
+ NotFoundError,
21
+ MethodNotAllowedError,
22
+ InternalServerError,
23
+ } from './errors'
24
+
25
+ import type { BunRequest } from 'bun'
26
+
27
+ import type {
28
+ AsyncHandlerResult,
29
+ HttpMethod,
30
+ Middleware,
31
+ MiddlewareChain,
32
+ EndpointRequest,
33
+ MetaEntry,
34
+ RouteConfig,
35
+ RouteDefinition,
36
+ SocketCommands,
37
+ AppOptions,
38
+ Server,
39
+ } from './utils'
40
+
41
+ import type {
42
+ SocketRoute,
43
+ SocketState,
44
+ } from './socket'
45
+
46
+ export * from './errors'
47
+ export { StatusCode, CloseCode, CloseReason, HttpMethod } from './utils'
48
+
49
+ export {
50
+ parseJsonBody,
51
+ setValidationFormats,
52
+ validateSchemas,
53
+ } from './middleware'
54
+
55
+ export type { FilterFn, SessionEntry, SocketCommands } from './utils'
56
+
57
+ export type {
58
+ AppOptions,
59
+ AsyncHandlerResult,
60
+ BaseRequest,
61
+ EndpointRequest,
62
+ FormattedError,
63
+ Handler,
64
+ MetaEntry,
65
+ Middleware,
66
+ MiddlewareChain,
67
+ NextFn,
68
+ HandlerResult,
69
+ Request,
70
+ RouteConfig,
71
+ RouteDefinition,
72
+ Server,
73
+ SocketConnection,
74
+ SocketOptions,
75
+ WebSocketRequest,
76
+ } from './utils'
77
+
78
+ export type {
79
+ FormatterField,
80
+ FormatterSchema,
81
+ ValidationSchemas,
82
+ } from './middleware'
83
+
84
+ type OutputRoutes = Record<string, string[]>
85
+ type ServerRoutes = Record<string, Record<string, EndpointHandler>>
86
+
87
+ type EndpointHandler = (
88
+ bunReq: BunRequest,
89
+ server: Server,
90
+ ) => AsyncHandlerResult
91
+
92
+ type ChainRoute = {
93
+ method: HttpMethod
94
+ path: string
95
+ chain: MiddlewareChain
96
+ }
97
+
98
+ type ModuleRoute = {
99
+ method: HttpMethod
100
+ path: string
101
+ handler: EndpointHandler
102
+ }
103
+
104
+ type AppRoutes = {
105
+ server: ServerRoutes
106
+ output: OutputRoutes
107
+ socket: SocketRoute[]
108
+ }
109
+
110
+ type CloseFn = (force?: boolean) => Promise<void>
111
+
112
+ export type App = {
113
+ server: Server
114
+ routes: OutputRoutes
115
+ ws: SocketCommands
116
+ close: CloseFn
117
+ }
118
+
119
+ function methodNotAllowedHandler (_req: unknown): never {
120
+ throw new MethodNotAllowedError()
121
+ }
122
+
123
+ function defaultMethodMap (): Record<string, EndpointHandler> {
124
+ return {
125
+ HEAD: methodNotAllowedHandler,
126
+ GET: methodNotAllowedHandler,
127
+ PUT: methodNotAllowedHandler,
128
+ POST: methodNotAllowedHandler,
129
+ PATCH: methodNotAllowedHandler,
130
+ DELETE: methodNotAllowedHandler,
131
+ }
132
+ }
133
+
134
+ function buildEndpointRequest (
135
+ bunReq: BunRequest,
136
+ server: Server,
137
+ ws: SocketCommands,
138
+ ): EndpointRequest {
139
+ const url = new URL(bunReq.url)
140
+ const qs = url.search.replace('?', '')
141
+
142
+ let bodyPromise: Promise<unknown> | null = null
143
+
144
+ const json = (): Promise<unknown> => {
145
+ if (!bodyPromise) {
146
+ bodyPromise = bunReq.json()
147
+ }
148
+
149
+ return bodyPromise
150
+ }
151
+
152
+ return {
153
+ method: bunReq.method as HttpMethod,
154
+ route: url.pathname,
155
+ headers: bunReq.headers,
156
+ params: bunReq.params ?? {},
157
+ query: querystring.parse(qs),
158
+ raw: bunReq,
159
+ server,
160
+ json,
161
+ ws,
162
+ }
163
+ }
164
+
165
+ function normalizeChain (
166
+ route: RouteDefinition,
167
+ ): ChainRoute {
168
+ const chain = Array.isArray(route.chain)
169
+ ? route.chain
170
+ : [route.chain]
171
+
172
+ return {
173
+ method: route.method,
174
+ path: route.path,
175
+ chain,
176
+ }
177
+ }
178
+
179
+ function buildMergedRoutes (
180
+ routePaths: ChainRoute[],
181
+ middleware: Middleware[],
182
+ meta: MetaEntry[],
183
+ state: SocketState,
184
+ mountPath: string,
185
+ ): ChainRoute[] {
186
+ const socketRoutes = buildSocketHandlers(state)
187
+
188
+ for (const socketRoute of socketRoutes) {
189
+ const mountedPath = `${mountPath}${socketRoute.path}`
190
+
191
+ const targetItem = routePaths.find(item => (
192
+ item.method === socketRoute.method &&
193
+ item.path === mountedPath
194
+ ))
195
+
196
+ if (targetItem) {
197
+ targetItem.chain.push(socketRoute.handler)
198
+
199
+ continue
200
+ }
201
+
202
+ const metaMiddleware = meta
203
+ .filter(entry => socketRoute.path.startsWith(entry.path))
204
+ .sort((a, b) => a.path.length - b.path.length)
205
+ .flatMap(entry => entry.middleware)
206
+
207
+ routePaths.push({
208
+ method: socketRoute.method,
209
+ path: mountedPath,
210
+ chain: [
211
+ ...middleware,
212
+ ...metaMiddleware,
213
+ socketRoute.handler,
214
+ ],
215
+ })
216
+ }
217
+
218
+ return routePaths
219
+ }
220
+
221
+ function buildSocketRoutes (mergedRoutes: ChainRoute[]): SocketRoute[] {
222
+ return mergedRoutes.map(route => ({
223
+ ...route,
224
+ segments: toSegments(route.path),
225
+ }))
226
+ }
227
+
228
+ function buildModuleRoutes (
229
+ socketRoutes: SocketRoute[],
230
+ ws: SocketCommands,
231
+ ): ModuleRoute[] {
232
+ return socketRoutes.map(route => {
233
+ const handler: EndpointHandler = async (bunReq, server) => {
234
+ const req = buildEndpointRequest(bunReq, server, ws)
235
+
236
+ return executeMiddlewareChain(req, route.chain)
237
+ }
238
+
239
+ return {
240
+ method: route.method,
241
+ path: route.path,
242
+ handler,
243
+ }
244
+ })
245
+ }
246
+
247
+ function buildServerRoutes (moduleRoutes: ModuleRoute[]): ServerRoutes {
248
+ return moduleRoutes.reduce<ServerRoutes>(
249
+ (accum, curr) => {
250
+ if (!accum[curr.path]) {
251
+ accum[curr.path] = defaultMethodMap()
252
+ }
253
+
254
+ accum[curr.path][curr.method] = curr.handler
255
+
256
+ return accum
257
+ }, {})
258
+ }
259
+
260
+ function buildOutputRoutes (moduleRoutes: ModuleRoute[]): OutputRoutes {
261
+ return moduleRoutes.reduce<OutputRoutes>((accum, curr) => {
262
+ accum[curr.path] = accum[curr.path] || []
263
+ accum[curr.path].push(curr.method)
264
+
265
+ return accum
266
+ }, {})
267
+ }
268
+
269
+ function buildRoutes (
270
+ config: RouteConfig,
271
+ state: SocketState,
272
+ ws: SocketCommands,
273
+ opts: AppOptions,
274
+ ): AppRoutes {
275
+ const mountPath = opts.mountPath || ''
276
+ const middleware = opts.middleware || []
277
+ const meta = config.meta || []
278
+
279
+ const normalRoutes = config.routes.map(route => {
280
+ const normalized = normalizeChain(route)
281
+ const routePath = normalized.path === '/' ? '' : normalized.path
282
+
283
+ const joinedPath = [mountPath, routePath]
284
+ .filter(item => item)
285
+ .join('') || '/'
286
+
287
+ return {
288
+ method: normalized.method,
289
+ path: joinedPath,
290
+ chain: [
291
+ ...middleware,
292
+ ...normalized.chain,
293
+ ],
294
+ }
295
+ })
296
+
297
+ const mergedRoutes = buildMergedRoutes(
298
+ normalRoutes,
299
+ middleware,
300
+ meta,
301
+ state,
302
+ mountPath,
303
+ )
304
+
305
+ const socketRoutes = buildSocketRoutes(mergedRoutes)
306
+ const moduleRoutes = buildModuleRoutes(socketRoutes, ws)
307
+ const serverRoutes = buildServerRoutes(moduleRoutes)
308
+ const outputRoutes = buildOutputRoutes(moduleRoutes)
309
+
310
+ return {
311
+ server: serverRoutes,
312
+ socket: socketRoutes,
313
+ output: outputRoutes,
314
+ }
315
+ }
316
+
317
+ function buildServer (
318
+ port: number,
319
+ routes: AppRoutes,
320
+ state: SocketState,
321
+ ws: SocketCommands,
322
+ opts: AppOptions,
323
+ ): Server {
324
+ const hostname = opts.hostname || '0.0.0.0'
325
+
326
+ const websocketServer = buildSocketServer(
327
+ routes.socket,
328
+ state,
329
+ ws,
330
+ )
331
+
332
+ return Bun.serve({
333
+ port,
334
+ hostname,
335
+ routes: routes.server,
336
+ websocket: websocketServer,
337
+ async fetch (_req, _server) {
338
+ throw new NotFoundError()
339
+ },
340
+ error (err) {
341
+ console.error(err)
342
+
343
+ const isRequestError = !(err instanceof RequestError)
344
+ const httpError = isRequestError ? new InternalServerError() : err
345
+ const { status } = httpError.constructor as typeof RequestError
346
+
347
+ return Response.json(httpError.output, { status })
348
+ },
349
+ })
350
+ }
351
+
352
+ function processIO (server: Server, opts: AppOptions): CloseFn {
353
+ const onClose = opts.onClose || (() => {})
354
+
355
+ console.info(`Running on port: ${server.port}`)
356
+ console.info('')
357
+ console.info('Press Ctrl+D to gracefully shutdown')
358
+ console.info('')
359
+
360
+ const shutdown = async (force = false): Promise<void> => {
361
+ await server.stop(force)
362
+ await onClose()
363
+ }
364
+
365
+ if (!stdin.isTTY) {
366
+ return shutdown
367
+ }
368
+
369
+ const rl = readline.createInterface({
370
+ input: stdin,
371
+ output: stdout,
372
+ })
373
+
374
+ const handleClose = async (): Promise<void> => {
375
+ await shutdown()
376
+ process.exit(0)
377
+ }
378
+
379
+ rl.on('close', handleClose)
380
+
381
+ return async (force = false) => {
382
+ rl.off('close', handleClose)
383
+ rl.close()
384
+
385
+ await shutdown(force)
386
+ }
387
+ }
388
+
389
+ export function createApp (
390
+ port: number,
391
+ config: RouteConfig,
392
+ opts: AppOptions = {},
393
+ ): App {
394
+ const state = buildSocketState(opts)
395
+ const ws = buildSocketCommands(state)
396
+ const routes = buildRoutes(config, state, ws, opts)
397
+ const server = buildServer(port, routes, state, ws, opts)
398
+ const close = processIO(server, opts)
399
+
400
+ return {
401
+ routes: routes.output,
402
+ server,
403
+ ws,
404
+ close,
405
+ }
406
+ }
@@ -746,50 +746,50 @@ export function buildSocketCommands (state: SocketState): SocketCommands {
746
746
  }
747
747
 
748
748
  return {
749
- query (fn) {
750
- const results: SessionEntry[] = []
749
+ broadcast (event, body) {
750
+ for (const clientId of state.activeSessions.keys()) {
751
+ sendToClient(clientId, event, body)
752
+ }
753
+ },
754
+ send (event, body, fn) {
751
755
  let index = 0
752
756
 
757
+ /* TODO: look into concurrency at some point */
753
758
  for (const [clientId, session] of state.activeSessions) {
754
759
  if (fn(clientId, session.ws.data, index)) {
755
- results.push({
756
- clientId,
757
- app: session.ws.data.app,
758
- })
760
+ sendToClient(clientId, event, body)
759
761
  }
760
762
 
761
763
  index += 1
762
764
  }
763
-
764
- return results
765
765
  },
766
- send (fn, event, body) {
766
+ drop (fn, code, reason) {
767
767
  let index = 0
768
768
 
769
- /* TODO: look into concurrency at some point */
770
769
  for (const [clientId, session] of state.activeSessions) {
771
770
  if (fn(clientId, session.ws.data, index)) {
772
- sendToClient(clientId, event, body)
771
+ session.ws.close(code, reason)
773
772
  }
774
773
 
775
774
  index += 1
776
775
  }
777
776
  },
778
- broadcast (event, body) {
779
- for (const clientId of state.activeSessions.keys()) {
780
- sendToClient(clientId, event, body)
781
- }
782
- },
783
- drop (fn, code, reason) {
777
+ query (fn) {
778
+ const results: SessionEntry[] = []
784
779
  let index = 0
785
780
 
786
781
  for (const [clientId, session] of state.activeSessions) {
787
782
  if (fn(clientId, session.ws.data, index)) {
788
- session.ws.close(code, reason)
783
+ results.push({
784
+ clientId,
785
+ app: session.ws.data.app,
786
+ })
789
787
  }
790
788
 
791
789
  index += 1
792
790
  }
791
+
792
+ return results
793
793
  },
794
794
  }
795
795
  }
@@ -133,10 +133,10 @@ export type FilterFn = (
133
133
  ) => boolean
134
134
 
135
135
  export type SocketCommands = {
136
- query: (fn: FilterFn) => SessionEntry[]
137
- send: (fn: FilterFn, event: string, body: unknown) => void
138
136
  broadcast: (event: string, body: unknown) => void
137
+ send: (event: string, body: unknown, fn: FilterFn) => void
139
138
  drop: (fn: FilterFn, code?: number, reason?: string) => void
139
+ query: (fn: FilterFn) => SessionEntry[]
140
140
  }
141
141
 
142
142
  export type BaseRequest = {
@@ -204,6 +204,22 @@ export type SocketConnection = {
204
204
 
205
205
  export type Server = BunServer<SocketData>
206
206
 
207
+ export type RouteDefinition = {
208
+ method: HttpMethod
209
+ path: string
210
+ chain: Handler | MiddlewareChain
211
+ }
212
+
213
+ export type MetaEntry = {
214
+ path: string
215
+ middleware: Middleware[]
216
+ }
217
+
218
+ export type RouteConfig = {
219
+ routes: RouteDefinition[]
220
+ meta?: MetaEntry[]
221
+ }
222
+
207
223
  export type AppOptions = {
208
224
  hostname?: string
209
225
  mountPath?: string