@vyckr/tachyon 0.1.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/Tach.ts ADDED
@@ -0,0 +1,602 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { watch } from "node:fs";
3
+ import { exists } from "node:fs/promises";
4
+ import Silo from "@vyckr/byos";
5
+ import { Glob, Server } from "bun";
6
+
7
+ const Tach = {
8
+
9
+ indexedRoutes: new Map<string, Map<string, Function>>(),
10
+
11
+ routeSlugs: new Map<string, Map<string, number>>(),
12
+
13
+ allMethods: process.env.ALLOW_METHODS ? process.env.ALLOW_METHODS.split(',') : ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
14
+
15
+ inDevelopment: process.env.PRODUCTION ? false : true,
16
+
17
+ headers: {
18
+ "Access-Control-Allow-Headers": process.env.ALLOW_HEADERS || "",
19
+ "Access-Control-Allow-Origin": process.env.ALLLOW_ORGINS || "",
20
+ "Access-Control-Allow-Credential": process.env.ALLOW_CREDENTIALS || "false",
21
+ "Access-Control-Expose-Headers": process.env.ALLOW_EXPOSE_HEADERS || "",
22
+ "Access-Control-Max-Age": process.env.ALLOW_MAX_AGE || "",
23
+ "Access-Control-Allow-Methods": process.env.ALLOW_METHODS || ""
24
+ },
25
+
26
+ dbPath: process.env.DB_DIR,
27
+
28
+ saveStats: process.env.SAVE_STATS === 'true',
29
+ saveRequests: process.env.SAVE_REQUESTS === 'true',
30
+ saveErrors: process.env.SAVE_ERRORS === 'true',
31
+ saveLogs: process.env.SAVE_LOGS === 'true',
32
+
33
+ logsTableName: "_logs",
34
+ errorsTableName: "_errors",
35
+ requestTableName: "_requests",
36
+ statsTableName: "_stats",
37
+
38
+ context: new AsyncLocalStorage<_log[]>(),
39
+
40
+ routesPath: process.env.LAMBDA_TASK_ROOT ? `${process.env.LAMBDA_TASK_ROOT}/routes` : `${process.cwd()}/routes`,
41
+
42
+ hasMiddleware: await exists(`${process.env.LAMBDA_TASK_ROOT || process.cwd()}/routes/_middleware.ts`) || await exists(`${process.env.LAMBDA_TASK_ROOT || process.cwd()}/routes/_middleware.js`) ,
43
+
44
+ pathsMatch(routeSegs: string[], pathSegs: string[]) {
45
+
46
+ if (routeSegs.length !== pathSegs.length) {
47
+ return false;
48
+ }
49
+
50
+ const slugs = Tach.routeSlugs.get(`${routeSegs.join('/')}.ts`) || Tach.routeSlugs.get(`${routeSegs.join('/')}.js`) || new Map<string, number>()
51
+
52
+ for (let i = 0; i < routeSegs.length; i++) {
53
+ if (!slugs.has(routeSegs[i]) && routeSegs[i].replace('.ts', '').replace('.js', '') !== pathSegs[i]) {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ return true;
59
+ },
60
+
61
+ getHandler(request: Request) {
62
+
63
+ const url = new URL(request.url);
64
+
65
+ let handler;
66
+ let params: string[] = [];
67
+ const paths = url.pathname.split('/').slice(1);
68
+ const allowedMethods: string[] = [];
69
+
70
+ let slugs = new Map<string, string>()
71
+
72
+ let bestMatchKey = '';
73
+ let bestMatchLength = -1;
74
+
75
+ for (const [routeKey] of Tach.indexedRoutes) {
76
+ const routeSegs = routeKey.split('/').map(seg => seg.replace('.ts', '').replace('.js', ''));
77
+ const isMatch = Tach.pathsMatch(routeSegs, paths.slice(0, routeSegs.length));
78
+
79
+ if (isMatch && routeSegs.length > bestMatchLength) {
80
+ bestMatchKey = routeKey;
81
+ bestMatchLength = routeSegs.length;
82
+ }
83
+ }
84
+
85
+ if (bestMatchKey) {
86
+ const routeMap = Tach.indexedRoutes.get(bestMatchKey)!
87
+ handler = routeMap.get(request.method);
88
+
89
+ for (const [key] of routeMap) {
90
+ if (Tach.allMethods.includes(key)) allowedMethods.push(key);
91
+ }
92
+
93
+ params = paths.slice(bestMatchLength);
94
+
95
+ const slugMap = Tach.routeSlugs.get(bestMatchKey) ?? new Map<string, number>()
96
+
97
+ slugMap.forEach((idx, key) => slugs.set(key, paths[idx]))
98
+ }
99
+
100
+ Tach.headers = { ...Tach.headers, "Access-Control-Allow-Methods": allowedMethods.join(',') };
101
+
102
+ if (!handler) throw new Error(`Route ${request.method} ${url.pathname} not found`, { cause: 404 });
103
+
104
+ return { handler, params: Tach.parseParams(params), slugs }
105
+ },
106
+
107
+ formatDate() {
108
+ return new Date().toISOString().replace('T', ' ').replace('Z', '')
109
+ },
110
+
111
+ formatMsg(...msg: any[]) {
112
+
113
+ if(msg instanceof Set) return "\n" + JSON.stringify(Array.from(msg), null, 2)
114
+
115
+ else if(msg instanceof Map) return "\n" + JSON.stringify(Object.fromEntries(msg), null, 2)
116
+
117
+ else if(msg instanceof FormData) {
118
+ const formEntries: Record<string, any> = {}
119
+ msg.forEach((val, key) => formEntries[key] = val)
120
+ return "\n" + JSON.stringify(formEntries, null, 2)
121
+ }
122
+
123
+ else if(Array.isArray(msg)
124
+ || (typeof msg === 'object' && !Array.isArray(msg))
125
+ || (typeof msg === 'object' && msg !== null)) return "\n" + JSON.stringify(msg, null, 2)
126
+
127
+ return msg
128
+ },
129
+
130
+ configLogger() {
131
+
132
+ const logger = console.log
133
+
134
+ function log(...args: any[]): void {
135
+
136
+ if (!args.length) {
137
+ return;
138
+ }
139
+
140
+ const messages = args.map(arg => Bun.inspect(arg).replace(/\n/g, "\r"));
141
+
142
+ logger(...messages);
143
+ }
144
+
145
+ const reset = '\x1b[0m'
146
+
147
+ console.info = (...args: any[]) => {
148
+ const info = `[${Tach.formatDate()}]\x1b[32m INFO${reset} (${process.pid}) ${Tach.formatMsg(...args)}`
149
+ log(info)
150
+ if(Tach.context.getStore()) {
151
+ const logWriter = Tach.context.getStore()
152
+ if(logWriter && Tach.dbPath && Tach.saveLogs) logWriter.push({ date: Date.now(), msg: `${info.replace(reset, '').replace('\x1b[32m', '')}\n`, type: "info" })
153
+ }
154
+ }
155
+
156
+ console.error = (...args: any[]) => {
157
+ const err = `[${Tach.formatDate()}]\x1b[31m ERROR${reset} (${process.pid}) ${Tach.formatMsg(...args)}`
158
+ log(err)
159
+ if(Tach.context.getStore()) {
160
+ const logWriter = Tach.context.getStore()
161
+ if(logWriter && Tach.dbPath && Tach.saveLogs) logWriter.push({ date: Date.now(), msg: `${err.replace(reset, '').replace('\x1b[31m', '')}\n`, type: "error" })
162
+ }
163
+ }
164
+
165
+ console.debug = (...args: any[]) => {
166
+ const bug = `[${Tach.formatDate()}]\x1b[36m DEBUG${reset} (${process.pid}) ${Tach.formatMsg(...args)}`
167
+ log(bug)
168
+ if(Tach.context.getStore()) {
169
+ const logWriter = Tach.context.getStore()
170
+ if(logWriter && Tach.dbPath && Tach.saveLogs) logWriter.push({ date: Date.now(), msg: `${bug.replace(reset, '').replace('\x1b[36m', '')}\n`, type: "debug" })
171
+ }
172
+ }
173
+
174
+ console.warn = (...args: any[]) => {
175
+ const warn = `[${Tach.formatDate()}]\x1b[33m WARN${reset} (${process.pid}) ${Tach.formatMsg(...args)}`
176
+ log(warn)
177
+ if(Tach.context.getStore()) {
178
+ const logWriter = Tach.context.getStore()
179
+ if(logWriter && Tach.dbPath && Tach.saveLogs) logWriter.push({ date: Date.now(), msg: `${warn.replace(reset, '').replace('\x1b[33m', '')}\n`, type: "warn" })
180
+ }
181
+ }
182
+
183
+ console.trace = (...args: any[]) => {
184
+ const trace = `[${Tach.formatDate()}]\x1b[35m TRACE${reset} (${process.pid}) ${Tach.formatMsg(...args)}`
185
+ log(trace)
186
+ if(Tach.context.getStore()) {
187
+ const logWriter = Tach.context.getStore()
188
+ if(logWriter && Tach.dbPath && Tach.saveLogs) logWriter.push({ date: Date.now(), msg: `${trace.replace(reset, '').replace('\x1b[35m', '')}\n`, type: "trace" })
189
+ }
190
+ }
191
+ },
192
+
193
+ async logRequest(request: Request, status: number, context: _HTTPContext, data: any = null) {
194
+
195
+ if(Tach.dbPath && Tach.saveRequests) {
196
+
197
+ const url = new URL(request.url)
198
+ const date = Date.now()
199
+ const duration = date - (context.requestTime ?? 0)
200
+
201
+ await Silo.putData(Tach.requestTableName, { ipAddress: context.ipAddress, url: `${url.pathname}${url.search}`, method: request.method, status, duration, date, size: data ? String(data).length : 0, data })
202
+ }
203
+ },
204
+
205
+ async processRequest(request: Request, context: _HTTPContext) {
206
+
207
+ const { handler, params, slugs } = Tach.getHandler(request)
208
+
209
+ if(slugs.size > 0) context.slugs = slugs
210
+
211
+ const body = await request.blob()
212
+
213
+ let data: Blob | Record<string, any> | undefined
214
+
215
+ if(body.size > 0) {
216
+
217
+ if(body.type.includes('form')) data = Tach.parseKVParams(await body.formData())
218
+ else {
219
+ try {
220
+ data = await body.json()
221
+ } catch {
222
+ data = body
223
+ }
224
+ }
225
+ }
226
+
227
+ const searchParams = new URL(request.url).searchParams
228
+
229
+ let queryParams: Record<string, any> | undefined;
230
+
231
+ if(searchParams.size > 0) queryParams = Tach.parseKVParams(searchParams)
232
+
233
+ const middlewarePath = await exists(`${Tach.routesPath}/_middleware.ts`) ? `${Tach.routesPath}/_middleware.ts` : `${Tach.routesPath}/_middleware.js`
234
+
235
+ if(params.length > 0 && !queryParams && !data) {
236
+
237
+ let res = undefined
238
+
239
+ if(Tach.hasMiddleware) {
240
+
241
+ const middleware = (await import(middlewarePath)).default
242
+
243
+ res = await middleware(async () => handler(...params, context))
244
+
245
+ } else res = await handler(...params, context)
246
+
247
+ await Tach.logRequest(request, 200, context)
248
+
249
+ return res
250
+
251
+ } else if(params.length === 0 && queryParams && !data) {
252
+
253
+ let res = undefined
254
+
255
+ if(Tach.hasMiddleware) {
256
+
257
+ const middleware = (await import(middlewarePath)).default
258
+
259
+ res = await middleware(async () => handler(queryParams, context))
260
+
261
+ } else res = await handler(queryParams, context)
262
+
263
+ await Tach.logRequest(request, 200, context)
264
+
265
+ return res
266
+
267
+ } else if(params.length === 0 && !queryParams && data) {
268
+
269
+ let res = undefined
270
+
271
+ if(Tach.hasMiddleware) {
272
+
273
+ const middleware = (await import(middlewarePath)).default
274
+
275
+ res = await middleware(async () => handler(data, context))
276
+
277
+ } else res = await handler(data, context)
278
+
279
+ await Tach.logRequest(request, 200, context, await body.text())
280
+
281
+ return res
282
+
283
+ } else if(params.length > 0 && queryParams && !data) {
284
+
285
+ let res = undefined
286
+
287
+ if(Tach.hasMiddleware) {
288
+
289
+ const middleware = (await import(middlewarePath)).default
290
+
291
+ res = await middleware(async () => handler(...params, queryParams, context))
292
+
293
+ } else res = await handler(...params, queryParams, context)
294
+
295
+ await Tach.logRequest(request, 200, context)
296
+
297
+ return res
298
+
299
+ } else if(params.length > 0 && !queryParams && data) {
300
+
301
+ let res = undefined
302
+
303
+ if(Tach.hasMiddleware) {
304
+
305
+ const middleware = (await import(middlewarePath)).default
306
+
307
+ res = await middleware(async () => handler(...params, data, context))
308
+
309
+ } else res = await handler(...params, data, context)
310
+
311
+ await Tach.logRequest(request, 200, context, await body.text())
312
+
313
+ return res
314
+
315
+ } else if(params.length === 0 && data && queryParams) {
316
+
317
+ let res = undefined
318
+
319
+ if(Tach.hasMiddleware) {
320
+
321
+ const middleware = (await import(middlewarePath)).default
322
+
323
+ res = await middleware(async () => handler(queryParams, data, context))
324
+
325
+ } else res = await handler(queryParams, data, context)
326
+
327
+ await Tach.logRequest(request, 200, context, await body.text())
328
+
329
+ return res
330
+
331
+ } else if(params.length > 0 && data && queryParams) {
332
+
333
+ let res = undefined
334
+
335
+ if(Tach.hasMiddleware) {
336
+
337
+ const middleware = (await import(middlewarePath)).default
338
+
339
+ res = await middleware(async () => handler(...params, queryParams, data, context))
340
+
341
+ } else res = await handler(...params, queryParams, data, context)
342
+
343
+ await Tach.logRequest(request, 200, context, await body.text())
344
+
345
+ return res
346
+
347
+ } else {
348
+
349
+ let res = undefined
350
+
351
+ if(Tach.hasMiddleware) {
352
+
353
+ const middleware = (await import(middlewarePath)).default
354
+
355
+ res = await middleware(async () => handler(context))
356
+
357
+ } else res = await handler(context)
358
+
359
+ await Tach.logRequest(request, 200, context)
360
+
361
+ return res
362
+ }
363
+ },
364
+
365
+ isAsyncIterator(data: any) {
366
+ return typeof data === "object" && Object.hasOwn(data, Symbol.asyncIterator)
367
+ },
368
+
369
+ hasFunctions(data: any) {
370
+ return typeof data === "object" && (Object.keys(data).some((elem) => typeof elem === "function") || Object.values(data).some((elem) => typeof elem === "function"))
371
+ },
372
+
373
+ processResponse(status: number, data?: any) {
374
+
375
+ const headers = Tach.headers
376
+
377
+ if(data instanceof Set) return Response.json(Array.from(data), { status, headers })
378
+
379
+ if(data instanceof Map) return Response.json(Object.fromEntries(data), { status, headers })
380
+
381
+ if(data instanceof FormData || data instanceof Blob) return new Response(data, { status, headers })
382
+
383
+ if(typeof data === "object" && !Array.isArray(data) && !Tach.isAsyncIterator(data) && !Tach.hasFunctions(data)) return Response.json(data, { status, headers })
384
+
385
+ if((typeof data === "object" && Array.isArray(data)) || data instanceof Array) return Response.json(data, { status, headers })
386
+
387
+ if(typeof data === "number" || typeof data === "boolean") return Response.json(data, { status, headers })
388
+
389
+ return new Response(data, { status, headers })
390
+ },
391
+
392
+ async logError(e: Error, ipAddress: string, url: URL, method: string, logs: _log[], startTime?: number) {
393
+
394
+ const path = url.pathname
395
+
396
+ if(logs.length > 0 && Tach.saveLogs && Tach.dbPath) await Promise.all(logs.map(log => {
397
+ return Silo.putData(Tach.logsTableName, { ipAddress, path, method, ...log })
398
+ }))
399
+
400
+ if(Tach.dbPath && Tach.saveErrors) await Silo.putData(Tach.errorsTableName, { ipAddress, date: Date.now(),path, method, error: e.message })
401
+
402
+ console.error(`"${method} ${path}" ${e.cause as number ?? 500} ${startTime ? `- ${Date.now() - startTime}ms` : ''} - ${e.message.length} byte(s)`)
403
+ },
404
+
405
+ watchFiles() {
406
+
407
+ if(Tach.inDevelopment) {
408
+
409
+ watch(Tach.routesPath, { recursive: true }, async (ev, filename) => {
410
+ delete import.meta.require.cache[`${Tach.routesPath}/${filename}`]
411
+ if(!filename?.split('/').some((path) => path.startsWith('_'))) await Tach.validateRoutes(filename!)
412
+ })
413
+ }
414
+ },
415
+
416
+ async fetch(req: Request, server: Server) {
417
+
418
+ const request = req.clone()
419
+
420
+ const logs: _log[] = []
421
+
422
+ const url = new URL(req.url)
423
+
424
+ const startTime = Date.now()
425
+
426
+ const ipAddress = server.requestIP ? server.requestIP(req)!.address : '0.0.0.0'
427
+
428
+ return await Tach.context.run(logs, async () => {
429
+
430
+ let res: Response
431
+
432
+ try {
433
+
434
+ const data = await Tach.processRequest(req, { ipAddress, request: req, requestTime: startTime, logs, slugs: new Map<string, any>() })
435
+
436
+ res = Tach.processResponse(200, data)
437
+
438
+ if(logs.length > 0 && Tach.saveLogs && Tach.dbPath) await Promise.all(logs.map(log => {
439
+ return Silo.putData(Tach.logsTableName, { ipAddress, path: url.pathname, method: req.method, ...log })
440
+ }))
441
+
442
+ if(!Tach.isAsyncIterator(data)) {
443
+
444
+ const status = res.status
445
+ const response_size = typeof data !== "undefined" ? String(data).length : 0
446
+ const url = new URL(req.url)
447
+ const method = req.method
448
+ const date = Date.now()
449
+ const duration = date - startTime
450
+
451
+ console.info(`"${method} ${url.pathname}" ${status} - ${duration}ms - ${response_size} byte(s)`)
452
+
453
+ if(Tach.dbPath && Tach.saveStats) await Silo.putData(Tach.statsTableName, { ipAddress, cpu: process.cpuUsage(), memory: process.memoryUsage(), date: Date.now() })
454
+ }
455
+
456
+ } catch(e) {
457
+
458
+ const method = request.method
459
+
460
+ await Tach.logError(e as Error, ipAddress, url, method, logs, startTime)
461
+
462
+ if(Tach.dbPath && Tach.saveStats) await Silo.putData(Tach.statsTableName, { ipAddress, cpu: process.cpuUsage(), memory: process.memoryUsage(), date: Date.now() })
463
+
464
+ res = Response.json({ detail: (e as Error).message }, { status: (e as Error).cause as number ?? 500, headers: Tach.headers })
465
+ }
466
+
467
+ return res
468
+ })
469
+ },
470
+
471
+ async validateRoutes(route?: string) {
472
+
473
+ const staticPaths: string[] = []
474
+
475
+ const validateRoute = async (route: string) => {
476
+
477
+ const paths = route.split('/')
478
+
479
+ const pattern = /[<>|\[\]]/
480
+
481
+ const slugs = new Map<string, number>()
482
+
483
+ paths.forEach((path, idx) => {
484
+
485
+ if(pattern.test(path) && (idx % 2 === 0 || paths[idx].includes('.ts') || paths[idx].includes('.js'))) {
486
+ throw new Error(`Invalid route ${route}`)
487
+ }
488
+
489
+ if(pattern.test(path)) slugs.set(path, idx)
490
+ })
491
+
492
+ const idx = paths.findIndex((path) => pattern.test(path))
493
+
494
+ if(idx > -1 && (idx % 2 === 0 || paths[idx].includes('.ts') || paths[idx].includes('.js'))) throw new Error(`Invalid route ${route}`)
495
+
496
+ const staticPath = paths.filter((path) => !pattern.test(path)).join(',')
497
+
498
+ if(staticPaths.includes(staticPath)) throw new Error(`Duplicate route ${route}`)
499
+
500
+ staticPaths.push(staticPath)
501
+
502
+ const module = await import(`${Tach.routesPath}/${route}`)
503
+
504
+ const controller = (new module.default() as any).constructor
505
+
506
+ const methodFuncs = new Map<string, Function>()
507
+
508
+ for(const method of Tach.allMethods) {
509
+
510
+ if(controller[method]) {
511
+
512
+ methodFuncs.set(method, controller[method])
513
+ }
514
+ }
515
+
516
+ Tach.indexedRoutes.set(route, methodFuncs)
517
+
518
+ if(slugs.size > 0) Tach.routeSlugs.set(route, slugs)
519
+ }
520
+
521
+ if(route) return await validateRoute(route)
522
+
523
+ const files = Array.from(new Glob(`**/*.{ts,js}`).scanSync({ cwd: Tach.routesPath }))
524
+
525
+ const routes = files.filter((route) => !route.split('/').some((path) => path.startsWith('_')))
526
+
527
+ for(const route of routes) await validateRoute(route)
528
+ },
529
+
530
+ parseParams(input: string[]) {
531
+
532
+ const params: (string | boolean | number | null | undefined)[] = []
533
+
534
+ for(const param of input) {
535
+
536
+ const num = Number(param)
537
+
538
+ if(!Number.isNaN(num)) params.push(num)
539
+
540
+ else if(param === 'true') params.push(true)
541
+
542
+ else if(param === 'false') params.push(false)
543
+
544
+ else if(param === 'null') params.push(null)
545
+
546
+ else if(param === 'undefined') params.push(undefined)
547
+
548
+ else params.push(param)
549
+ }
550
+
551
+ return params
552
+ },
553
+
554
+ parseKVParams(input: URLSearchParams | FormData) {
555
+
556
+ const params: Record<string, any> = {}
557
+
558
+ for(const [key, val] of input) {
559
+
560
+ if(typeof val === "string") {
561
+
562
+ try {
563
+
564
+ params[key] = JSON.parse(val)
565
+
566
+ } catch {
567
+
568
+ const num = Number(val)
569
+
570
+ if(!Number.isNaN(num)) params[key] = num
571
+
572
+ else if(val === 'true') params[key] = true
573
+
574
+ else if(val === 'false') params[key] = false
575
+
576
+ else if(typeof val === "string" && val.includes(',')) params[key] = Tach.parseParams(val.split(','))
577
+
578
+ else if(val === 'null') params[key] = null
579
+
580
+ if(params[key] === undefined) params[key] = val
581
+ }
582
+
583
+ } else params[key] = val
584
+ }
585
+
586
+ return params
587
+ }
588
+ }
589
+
590
+ try {
591
+
592
+ await Tach.validateRoutes()
593
+
594
+ Tach.watchFiles()
595
+
596
+ Tach.configLogger()
597
+
598
+ } catch(e) {
599
+ console.log(`Tach.ts --> ${e}`)
600
+ }
601
+
602
+ export default Tach
package/src/Yon.ts ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bun
2
+ import Tach from "./Tach.js"
3
+ import Silo from "@vyckr/byos";
4
+
5
+ try {
6
+
7
+ const start = Date.now()
8
+
9
+ const server = Bun.serve({ fetch: Tach.fetch, async error(req) {
10
+
11
+ if(Tach.dbPath && Tach.saveStats) await Silo.putData(Tach.statsTableName, { cpu: process.cpuUsage(), memory: process.memoryUsage(), date: Date.now() })
12
+
13
+ return Response.json({ detail: req.message }, { status: req.cause as number ?? 500, headers: Tach.headers })
14
+ },
15
+ development: Tach.inDevelopment,
16
+ port: process.env.PORT || 8000
17
+ })
18
+
19
+ process.on('SIGINT', () => process.exit(0))
20
+
21
+ console.info(`Live Server is running on http://${server.hostname}:${server.port} (Press CTRL+C to quit) - StartUp Time: ${Date.now() - start}ms`)
22
+
23
+ } catch(e) {
24
+ if(e instanceof Error) console.error(e.message)
25
+ }