├── .gitignore ├── .npmignore ├── README.md ├── build.ts ├── package.json ├── src ├── core │ ├── constants.ts │ ├── main.ts │ ├── router │ │ ├── compiler │ │ │ ├── constants.ts │ │ │ ├── getHandler.ts │ │ │ ├── guard.ts │ │ │ ├── index.ts │ │ │ ├── node.ts │ │ │ ├── resolveArgs.ts │ │ │ ├── store.ts │ │ │ └── wrapper.ts │ │ ├── exports.ts │ │ ├── index.ts │ │ └── types.ts │ └── types.ts ├── index.ts └── plugins │ ├── group.ts │ ├── tester.ts │ └── ws.ts ├── tests ├── basic.test.ts ├── group.test.ts └── ws.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Bun 2 | bun.lockb 3 | node_modules 4 | 5 | # Build 6 | index.js 7 | types 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .gitignore 3 | .git 4 | 5 | # Packages 6 | node_modules 7 | bun.lockb 8 | 9 | # Test 10 | tests 11 | 12 | # Development 13 | src 14 | examples 15 | tsconfig.json 16 | microbench 17 | build.ts 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | An minimal, heavily-optimized router for Stric. 2 | 3 | ```typescript 4 | import { Router, macro } from '@stricjs/router'; 5 | 6 | // Create a router and serve using Bun 7 | export default new Router() 8 | // Handle GET request to `/` 9 | .get('/', macro('Hi')) 10 | // Handle POST request to `/json` 11 | .post('/json', ctx => ctx.data, { body: 'json', wrap: 'json' }) 12 | // Return 90 for requests to `/id/90` for instance 13 | .get('/id/:id', ctx => ctx.params.id, { wrap: true }) 14 | // Use the default 404 handler 15 | .use(404); 16 | ``` 17 | 18 | See the [docs](https://stricjs.netlify.app) for more details. 19 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { existsSync, rmSync } from 'fs'; 3 | 4 | // Generating types 5 | const dir = './types'; 6 | if (existsSync(dir)) 7 | rmSync(dir, { recursive: true }); 8 | 9 | Bun.build({ 10 | format: 'esm', 11 | target: 'bun', 12 | outdir: '.', 13 | //minify: true, 14 | entrypoints: ['./src/index.ts'] 15 | }); 16 | 17 | // Build type declarations 18 | Bun.spawn(['bun', 'x', 'tsc', '--outdir', dir], { stdout: 'inherit' }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stricjs/router", 3 | "version": "6.0.0-alpha", 4 | "repository": { 5 | "url": "https://github.com/bunsvr/router" 6 | }, 7 | "main": "index.js", 8 | "devDependencies": { 9 | "bun-types": "1.0.7", 10 | "mitata": "^0.1.6", 11 | "typescript": "^5.2.2" 12 | }, 13 | "description": "A minimal, heavily-optimized router for Stric", 14 | "scripts": { 15 | "build-test": "bun build.ts && bun test" 16 | }, 17 | "type": "module", 18 | "types": "types/index.d.ts", 19 | "keywords": [ 20 | "stric", 21 | "bun", 22 | "router" 23 | ], 24 | "sideEffects": false 25 | } 26 | -------------------------------------------------------------------------------- /src/core/constants.ts: -------------------------------------------------------------------------------- 1 | export const methods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH', 'ALL', 'GUARD', 'REJECT']; 2 | export const methodsLowerCase = methods.map(v => v.toLowerCase()); 3 | 4 | export function convert(path: string) { 5 | if (path.length < 2) return path; 6 | if (path.at(-1) === '/') return path.substring(0, path.length - 1); 7 | return path; 8 | } 9 | -------------------------------------------------------------------------------- /src/core/main.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'bun'; 2 | import type { 3 | RouterMethods, FetchMeta, Handler, ServeOptions, 4 | BodyHandler, ErrorHandler, RouterMeta, RouterPlugin, ResponseWrap 5 | } from './types'; 6 | import { wrap } from './types'; 7 | import Radx from './router'; 8 | import compileRouter from './router/compiler'; 9 | import { convert, methodsLowerCase as methods } from './constants'; 10 | import { 11 | requestObjectName, urlStartIndex, requestQueryIndex, 12 | serverErrorHandler, requestURL, appDetail, wsHandlerDataKey 13 | } from './router/compiler/constants'; 14 | 15 | export interface Router extends ServeOptions, RouterMethods<'/'> { }; 16 | 17 | /** 18 | * A Stric router 19 | * 20 | * Note: This will run *only* the first route found 21 | */ 22 | export class Router { 23 | /** 24 | * Internal dynamic path router 25 | */ 26 | router: Radx; 27 | private fn404: Handler; 28 | private fn400: Handler; 29 | private injects: Record; 30 | record: Record> = {}; 31 | 32 | /** 33 | * Create a router. 34 | * 35 | * If a `PORT` env is set, the port will be the value specified. 36 | */ 37 | constructor(opts: Partial = {}) { 38 | Object.assign(this, opts); 39 | 40 | let method: string; 41 | for (method of methods) { 42 | const METHOD = method.toUpperCase(); 43 | this[method] = (path: string, handler: Handler, opts: any) => { 44 | if (opts) for (const prop in opts) 45 | handler[prop] = opts[prop]; 46 | 47 | return this.use(METHOD, path, handler); 48 | } 49 | } 50 | 51 | // Automatically set port 52 | if (Bun.env.PORT) 53 | this.port = Number(Bun.env.PORT); 54 | else if (!('port' in this)) 55 | this.port = 3000; 56 | 57 | if (!('hostname' in this)) 58 | this.hostname = '127.0.0.1'; 59 | 60 | // Enable optimizer 61 | if (this.requestOptimization !== false) 62 | optimize(); 63 | } 64 | 65 | /** 66 | * Use the default response wrapper for a group of subroutes 67 | */ 68 | wrap(path: string): this; 69 | 70 | /** 71 | * Add a response wrapper 72 | */ 73 | wrap(path: string, handler: ResponseWrap): this; 74 | 75 | /** 76 | * Add a response wrapper for subroutes of path. 77 | * 78 | * Wrap will wrap reject responses 79 | */ 80 | wrap(path: string, handler: ResponseWrap = 'plain') { 81 | if (typeof handler === 'string') 82 | handler = wrap[handler]; 83 | 84 | // @ts-ignore 85 | this.use('WRAP', path, handler); 86 | return this; 87 | } 88 | 89 | /** 90 | * Set an alias for a path 91 | */ 92 | alias(name: string, origin: string): this { 93 | if (origin in this.record) { 94 | if (!(name in this.record)) this.record[name] = {}; 95 | 96 | let k: string; 97 | for (k in this.record[origin]) 98 | this.record[name][k] = this.record[origin][k]; 99 | 100 | return this; 101 | } 102 | throw new Error('Origin pathname not registered yet!'); 103 | } 104 | 105 | /** 106 | * Inject a variable into the fetch function scope 107 | */ 108 | inject(name: string, value: any, warning: boolean = true) { 109 | if (name.charCodeAt(0) === 95 && warning) 110 | console.warn('Name should not have prefix `_` to avoid collision with internal parameters!'); 111 | 112 | if (!this.injects) this.injects = {}; 113 | 114 | this.injects[name] = value; 115 | return this; 116 | } 117 | 118 | /** 119 | * Add a handler to the router 120 | * @param method 121 | * @param path 122 | * @param handler 123 | */ 124 | use(method: string | string[], path: T, handler: Handler): this; 125 | 126 | /** 127 | * Add a 404 handler to the router 128 | * @param type 129 | * @param handler 130 | */ 131 | use(type: 404, handler: Handler): this; 132 | 133 | /** 134 | * Add the default 404 handler to the router 135 | * @param type 136 | */ 137 | use(type: 404): this; 138 | 139 | /** 140 | * Add a 400 handler to the router when parsing body failed 141 | * @param type 142 | * @param handler 143 | */ 144 | use(type: 400, handler: BodyHandler): this; 145 | 146 | /** 147 | * Add the default 400 handler to the router when parsing body failed 148 | * @param type 149 | */ 150 | use(type: 400): this; 151 | 152 | /** 153 | * Add an error handler to the router 154 | * @param type 155 | * @param handler 156 | */ 157 | use(type: 500 | 'error', handler: ErrorHandler): this; 158 | 159 | /** 160 | * Add the default 500 handler to the router 161 | * @param type 162 | */ 163 | use(type: 500 | 'error'): this; 164 | 165 | use(...args: any[]) { 166 | switch (args[0]) { 167 | case 404: 168 | this.fn404 = args[1] || false; 169 | break; 170 | case 400: 171 | this.fn400 = args[1] || false; 172 | break; 173 | case 500: 174 | case 'error': 175 | this.error = args[1] || serverErrorHandler; 176 | break; 177 | default: 178 | // Normal parsing 179 | let [method, path, handler] = args, mth: string; 180 | path = convert(path); 181 | 182 | if (!Array.isArray(method)) 183 | method = [method]; 184 | 185 | if (!this.record[path]) this.record[path] = {}; 186 | 187 | for (mth of method) 188 | this.record[path][mth] = handler; 189 | 190 | break; 191 | } 192 | 193 | return this; 194 | } 195 | 196 | /** 197 | * Register this router as a plugin, which mount all routes, storage and injects (can be overritten) 198 | */ 199 | plugin(app: Router) { 200 | let k: string, k1: string; 201 | 202 | // Assign route records 203 | for (k in this.record) { 204 | if (k in app.record) 205 | for (k1 in this.record[k]) 206 | app.record[k][k1] = this.record[k][k1]; 207 | 208 | else app.record[k] = this.record[k]; 209 | } 210 | 211 | // Assign injects 212 | if (this.injects) { 213 | if (app.injects) 214 | for (k in this.injects) 215 | app.injects[k] = this.injects[k]; 216 | 217 | else for (k in this.injects) 218 | app.inject(k, this.injects[k]); 219 | } 220 | } 221 | 222 | /** 223 | * Mount another WinterCG compliant app to a path 224 | */ 225 | mount(path: string, app: { fetch: (request: any) => any }) { 226 | this.all(path, app.fetch as any); 227 | } 228 | 229 | /** 230 | * Set a property 231 | */ 232 | set(v: K, value: this[K]) { 233 | this[v] = value; 234 | return this; 235 | } 236 | 237 | /** 238 | * All resolving plugin. 239 | */ 240 | readonly resolvingPlugins: Promise[] = []; 241 | readonly afterListenPlugins: any[] = []; 242 | 243 | /** 244 | * Add plugins 245 | */ 246 | plug(...plugins: RouterPlugin[]) { 247 | let p: RouterPlugin, res: any; 248 | for (p of plugins) { 249 | if (!p) continue; 250 | 251 | // Put into a list to register later 252 | if (p.afterListen) { 253 | this.afterListenPlugins.push(p); 254 | continue; 255 | } 256 | 257 | res = typeof p === 'object' ? p.plugin(this) : p(this); 258 | if (res instanceof Promise) 259 | this.resolvingPlugins.push(res); 260 | } 261 | 262 | return this; 263 | } 264 | 265 | /** 266 | * Resolve all loading plugins. 267 | */ 268 | resolve() { 269 | return this.resolvingPlugins.length === 0 ? null 270 | : Promise.allSettled(this.resolvingPlugins); 271 | } 272 | 273 | /** 274 | * Get the literal, parameters and parameters value Stric uses to compose the fetch function. 275 | * 276 | * This method is intended for advanced usage. 277 | */ 278 | get meta(): FetchMeta { 279 | // Check whether a path handler does exists 280 | let key: string, hasRecord: boolean; 281 | for (key in this.record) { 282 | hasRecord = true; 283 | break; 284 | } 285 | 286 | // Assign records to the router 287 | if (!hasRecord) throw new Error('No route has been assigned yet'); 288 | this.router = new Radx; 289 | 290 | let store: any, method: string; 291 | for (key in this.record) { 292 | store = this.router.add(key); 293 | 294 | for (method in this.record[key]) 295 | store[method] = this.record[key][method]; 296 | } 297 | 298 | // Assign websocket 299 | this.websocket ||= { message: createWSHandler('message') }; 300 | this.websocket.open ||= createWSHandler('open'); 301 | this.websocket.drain ||= createWSHandler('drain'); 302 | this.websocket.close ||= createWSHandler('close'); 303 | 304 | // Compose the router 305 | const res = compileRouter( 306 | this.router, 307 | this.base ? this.base.length + 1 : urlStartIndex, 308 | this.fn400, this.fn404 309 | ); 310 | 311 | // People pls don't try to use this 312 | if (this.injects) for (key in this.injects) 313 | res.store[key] = this.injects[key]; 314 | 315 | // Store the ref of details 316 | res.store[appDetail] = this.details; 317 | 318 | return { 319 | params: Object.keys(res.store), 320 | body: `return ${requestObjectName}=>{${getPathParser(this) + res.fn}}`, 321 | values: Object.values(res.store) 322 | }; 323 | } 324 | 325 | /** 326 | * Fetch handler. 327 | * @param request Incoming request 328 | * @param server Current Bun server 329 | */ 330 | get fetch() { 331 | return buildFetch(this.meta); 332 | }; 333 | 334 | private async loadAfterListenPlugins() { 335 | let p: RouterPlugin, res: any; 336 | 337 | for (p of this.afterListenPlugins) { 338 | res = typeof p === 'object' ? p.plugin(this) : p(this); 339 | if (res instanceof Promise) res = await res; 340 | } 341 | } 342 | 343 | /** 344 | * Start an HTTP server at specified port (defaults to `3000`) and host (defaults to `127.0.0.1`). 345 | */ 346 | listen(gc: boolean = true) { 347 | if (gc) Bun.gc(true); 348 | 349 | const { fetch, ...rest } = this, s = Bun.serve({ ...rest, fetch }); 350 | 351 | // Additional details 352 | this.details.https = !!(this.tls || this.ca || this.key || this.cert); 353 | this.details.defaultPort = isDefaultPort(s); 354 | 355 | this.details.host = s.hostname + (this.details.defaultPort ? '' : ':' + s.port); 356 | this.details.base = 'http' + (this.details.https ? 's' : '') + '://' + this.details.host; 357 | 358 | this.details.dev = s.development; 359 | this.details.server = s; 360 | 361 | // @ts-ignore 362 | this.listening = true; 363 | 364 | if (this.afterListenPlugins.length !== 0) 365 | this.loadAfterListenPlugins(); 366 | 367 | // Log additional info 368 | console.info(`Started an HTTP server at ${this.details.base} in ${s.development ? 'development' : 'production'} mode`); 369 | return this; 370 | } 371 | 372 | // @ts-ignore Only available when `listen()` is used. 373 | details: RouterMeta = { 374 | https: false, 375 | defaultPort: false, 376 | host: '', 377 | base: '', 378 | dev: false, 379 | server: null, 380 | router: this 381 | }; 382 | 383 | /** 384 | * Check whether server is listening 385 | */ 386 | readonly listening = false; 387 | } 388 | 389 | export function isDefaultPort(s: Server) { 390 | switch (s.port) { 391 | case 80: 392 | case 443: 393 | return true; 394 | default: return false; 395 | } 396 | } 397 | 398 | export function macro(fn?: Handler | string | number | boolean | null | undefined | object): Handler { 399 | if (fn === null || fn === undefined) 400 | fn = Function('return()=>new Response')() as Handler; 401 | else if (typeof fn === 'string') 402 | fn = Function(`return()=>new Response('${fn}')`)() as Handler; 403 | else if (typeof fn !== 'function') 404 | fn = Function(`return()=>new Response('${JSON.stringify(fn)}')`)() as Handler; 405 | 406 | (fn as Handler).macro = true; 407 | return fn as Handler; 408 | } 409 | 410 | function createWSHandler(name: string) { 411 | const argsList = 'w' + (name === 'close' ? ',c,m' : ''); 412 | // Optimization: message handler should exist 413 | return Function(name === 'message' 414 | ? `return (w,m)=>{w.data.${wsHandlerDataKey}.message(w,m)}` 415 | : `return (${argsList})=>{if('${name}'in w.data.${wsHandlerDataKey})w.data.${wsHandlerDataKey}.${name}(${argsList})}` 416 | )(); 417 | } 418 | 419 | function getPathParser(app: Router) { 420 | return (typeof app.base === 'string' 421 | ? '' : `${urlStartIndex}=${requestURL}.indexOf('/',${app.uriLen ?? 12})+1;` 422 | ) + `${requestQueryIndex}=${requestURL}.indexOf('?',${typeof app.base === 'string' 423 | ? app.base.length + 1 : urlStartIndex 424 | });`; 425 | } 426 | 427 | /** 428 | * Build a fetch function from fetch metadata 429 | */ 430 | export function buildFetch(meta: FetchMeta): (req: Request) => any { 431 | return Function(...meta.params, meta.body)(...meta.values); 432 | } 433 | 434 | /** 435 | * Will work if Request proto is modifiable 436 | */ 437 | function optimize() { 438 | // @ts-ignore 439 | Request.prototype.path = 0; 440 | // @ts-ignore 441 | Request.prototype.query = 0; 442 | // @ts-ignore 443 | Request.prototype.params = null; 444 | // @ts-ignore 445 | Request.prototype.data = null; 446 | // @ts-ignore 447 | Request.prototype.set = null; 448 | } 449 | 450 | /** 451 | * Shorthand for `new Router().plug` 452 | */ 453 | export function router(...plugins: RouterPlugin[]) { 454 | return new Router().plug(...plugins); 455 | } 456 | 457 | export default Router; 458 | export { wrap }; 459 | export type * from './types'; 460 | 461 | // Export constants for AoT compiling 462 | export * from './router/exports'; 463 | -------------------------------------------------------------------------------- /src/core/router/compiler/constants.ts: -------------------------------------------------------------------------------- 1 | export const internalPrefix = '_', 2 | // Prefixes 3 | handlerPrefix = internalPrefix + 'c', 4 | rejectPrefix = internalPrefix + 'r', 5 | guardPrefix = internalPrefix + 'g', 6 | wrapperPrefix = internalPrefix + 'd', 7 | 8 | // Special handlers and props 9 | invalidBodyHandler = internalPrefix + 'i', 10 | prevParamIndex = internalPrefix + 't', 11 | currentParamIndex = internalPrefix + 'e', 12 | nfHandler = internalPrefix + 'n', 13 | appDetail = internalPrefix + 'a', 14 | debugServer = appDetail + '.' + 'server', 15 | 16 | // Request related 17 | requestObjectName = 'c', 18 | cachedMethod = 'method', 19 | wsHandlerDataKey = '_', 20 | 21 | // Request properties 22 | requestObjectPrefix = requestObjectName + '.', 23 | urlStartIndex = requestObjectPrefix + 'path', 24 | requestURL = requestObjectPrefix + 'url', 25 | requestQueryIndex = requestObjectPrefix + 'query', 26 | requestParams = requestObjectPrefix + 'params', 27 | requestParsedBody = requestObjectPrefix + 'data', 28 | 29 | // Predefined response options 30 | notFoundHeader = { status: 404 }, 31 | jsonHeader = { 32 | headers: { 'Content-Type': 'application/json' } 33 | }, 34 | badReqHeader = { status: 400 }, 35 | serverErrorHeader = { status: 500 }, 36 | 37 | // Predefined response function 38 | notFoundHandler = () => new Response(null, notFoundHeader), 39 | serverErrorHandler = () => new Response(null, serverErrorHeader), 40 | badRequestHandler = () => new Response(null, badReqHeader); 41 | -------------------------------------------------------------------------------- /src/core/router/compiler/getHandler.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '../../types'; 2 | 3 | /** 4 | * Get the function body of a macro 5 | */ 6 | export function getMacroHandler(handler: Handler) { 7 | let macro = handler.toString(); 8 | 9 | // Skip space to check for direct return 10 | macro = macro.substring(macro.indexOf(')') + 1).trimStart(); 11 | 12 | // If it is an arrow function 13 | if (macro.charCodeAt(0) !== 123) { 14 | // Remove arrow and trailing space 15 | macro = macro.substring(2).trimStart(); 16 | 17 | // If direct return 18 | if (macro.charCodeAt(0) !== 123) { 19 | if (macro.charCodeAt(macro.length - 1) !== 59) macro += ';'; 20 | macro = 'return ' + macro; 21 | } 22 | } 23 | 24 | return macro; 25 | } 26 | -------------------------------------------------------------------------------- /src/core/router/compiler/guard.ts: -------------------------------------------------------------------------------- 1 | import { HandlerDetails } from '../types'; 2 | import { Wrapper } from '../../types'; 3 | import { checkArgs } from './resolveArgs'; 4 | import { rejectPrefix, guardPrefix } from './constants'; 5 | import { checkWrap } from './wrapper'; 6 | 7 | /** 8 | * Handle GUARD and REJECT 9 | */ 10 | export function guardCheck(handlers: HandlerDetails, guard: any, reject: any, wrapper: Wrapper): [str: string, queue: string] { 11 | let methodCall = handlers.__defaultReturn, 12 | str = '', queue = '', caller = '', args: string; 13 | 14 | // Check if a reject does exists to customize handling 15 | if (reject) { 16 | args = checkArgs(reject, 0); 17 | 18 | // Add to the scope 19 | caller = rejectPrefix + handlers.__rejectIndex; 20 | ++handlers.__rejectIndex; 21 | handlers[caller] = reject; 22 | 23 | methodCall = `${caller}(${args})`; 24 | 25 | // Try assign a wrapper 26 | if (wrapper) 27 | methodCall = checkWrap(reject, wrapper, methodCall); 28 | 29 | methodCall = 'return ' + methodCall; 30 | } 31 | 32 | // Add guard 33 | caller = guardPrefix + handlers.__guardIndex; 34 | handlers[caller] = guard; 35 | ++handlers.__guardIndex; 36 | 37 | args = checkArgs(guard, 0); 38 | 39 | // Wrap the guard in async when needed 40 | if (guard.constructor.name === 'AsyncFunction') { 41 | str += `return ${caller}(${args}).then(_=>{if(_===null)${methodCall};`; 42 | queue = handlers.__defaultReturn + '});'; 43 | } else str += `if(${caller}(${args})===null)${methodCall};`; 44 | 45 | return [str, queue]; 46 | } 47 | -------------------------------------------------------------------------------- /src/core/router/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import Radx from '..'; 2 | import { 3 | invalidBodyHandler, requestQueryIndex, 4 | nfHandler, notFoundHeader, badRequestHandler, requestURL, cachedMethod, requestObjectName 5 | } from './constants'; 6 | 7 | import { HandlerDetails } from '../types'; 8 | import { checkArgs } from "./resolveArgs"; 9 | import { compileNode } from './node'; 10 | 11 | export default function compileRouter( 12 | router: Radx, startIndex: number | string, fn400: any, fn404: any 13 | ) { 14 | if (startIndex === 0) throw new Error('WTF'); 15 | 16 | // Store all states 17 | const handlersRec: HandlerDetails = { 18 | __index: 0, __defaultReturn: 'return', 19 | __pathStr: requestURL, __wrapperIndex: 0, 20 | __pathLen: requestQueryIndex, 21 | __rejectIndex: 0, __catchBody: '', 22 | __guardIndex: 0 23 | }; 24 | 25 | // Fn 400 modify the catch body 26 | if (fn400) { 27 | const args = checkArgs(fn400, 1); 28 | 29 | // Assign the catch body 30 | handlersRec[invalidBodyHandler] = fn400; 31 | handlersRec.__catchBody = args === '' 32 | ? `.catch(${invalidBodyHandler})` 33 | : `.catch(_=>${invalidBodyHandler}(_,${args}))`; 34 | } 35 | // Special 400 36 | else if (fn400 === false) { 37 | handlersRec[invalidBodyHandler] = badRequestHandler; 38 | handlersRec.__catchBody = `.catch(${invalidBodyHandler})`; 39 | } 40 | 41 | let composedBody = ''; 42 | 43 | // Fn 404 for default return 44 | if (fn404 || fn404 === false) { 45 | // Handle default and custom 404 46 | if (fn404 === false) { 47 | handlersRec.__defaultReturn += ` new Response(null,${nfHandler})`; 48 | handlersRec[nfHandler] = notFoundHeader; 49 | } else { 50 | handlersRec.__defaultReturn += ` ${nfHandler}(${checkArgs(fn404, 0)})`; 51 | handlersRec[nfHandler] = fn404; 52 | } 53 | 54 | composedBody = handlersRec.__defaultReturn; 55 | } 56 | 57 | // Composing nodes 58 | composedBody = compileNode( 59 | router.root, false, handlersRec, 60 | startIndex, false, false, null 61 | ) + composedBody; 62 | 63 | // Remove internals 64 | let key: string; 65 | for (key in handlersRec) 66 | if (key.startsWith('__')) 67 | delete handlersRec[key]; 68 | 69 | return { 70 | store: handlersRec, 71 | fn: `var{${cachedMethod}}=${requestObjectName};` 72 | + `if(${requestQueryIndex}===-1)${requestQueryIndex}=${requestURL}.length;` 73 | + composedBody 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/core/router/compiler/node.ts: -------------------------------------------------------------------------------- 1 | import { initWrapper } from './wrapper'; 2 | import { guardCheck } from './guard'; 3 | import { getStoreCall } from './store'; 4 | import { Wrapper } from '../../types'; 5 | import { 6 | currentParamIndex, prevParamIndex, urlStartIndex, 7 | requestParams, requestQueryIndex 8 | } from './constants'; 9 | import { Node, HandlerDetails } from '../types'; 10 | 11 | function plus(num: string | number, val: number) { 12 | if (val === 0) return num; 13 | if (typeof num === 'number') return num + val; 14 | 15 | let slices = num.split('+'), 16 | total = Number(slices[1]); 17 | 18 | if (isNaN(total)) total = 0; 19 | 20 | return slices[0] + '+' + (val + total); 21 | } 22 | 23 | export function checkPath( 24 | handlers: HandlerDetails, 25 | part: string, fullPartPrevLen: string | number, 26 | currentPathLen: string | number 27 | ) { 28 | if (part.length < 15) { 29 | let result = ''; 30 | 31 | for (var i = 0; i < part.length; ++i) { 32 | result += `if(${handlers.__pathStr}.charCodeAt(${fullPartPrevLen})===${part.charCodeAt(i)})`; 33 | fullPartPrevLen = plus(fullPartPrevLen, 1); 34 | } 35 | 36 | return result; 37 | } 38 | 39 | return `if(${handlers.__pathStr}.substring(${fullPartPrevLen},${currentPathLen})==='${part}')`; 40 | } 41 | 42 | export function compileNode( 43 | node: Node, 44 | isNormalInert: boolean, 45 | handlers: HandlerDetails, 46 | fullPartPrevLen: number | string, 47 | hasParams: boolean, 48 | backupParamIndexExists: boolean, 49 | wrapper: Wrapper 50 | ) { 51 | // Only fix inert 52 | if (isNormalInert && !('fixed' in node)) { 53 | node.part = node.part.substring(1); 54 | node.fixed = true; 55 | } 56 | 57 | const currentPathLen = plus(fullPartPrevLen, node.part.length); 58 | let str = node.part.length === 0 59 | ? '' : checkPath( 60 | handlers, node.part, 61 | fullPartPrevLen, currentPathLen 62 | ) + '{', queue = '', 63 | // For efficient storing 64 | iter: any, res: any; 65 | 66 | // Check store, inert, wilcard and params 67 | if (node.store !== null) { 68 | if (node.store.WRAP) { 69 | initWrapper(handlers, node.store.WRAP); 70 | wrapper = node.store.WRAP; 71 | } 72 | 73 | // Resolve guard 74 | if (node.store.GUARD) { 75 | res = guardCheck(handlers, node.store.GUARD, node.store.REJECT, wrapper); 76 | // Add to queue the needed string 77 | queue += res[1]; 78 | // Add to str the function body 79 | str += res[0]; 80 | } 81 | 82 | // Check if any other handler is provided other than GUARD and REJECT 83 | countMethod: 84 | for (iter in node.store) 85 | switch (iter as string) { 86 | case 'GUARD': 87 | case 'REJECT': 88 | case 'WRAP': 89 | continue countMethod; 90 | 91 | default: 92 | str += `if(${handlers.__pathLen}===${currentPathLen}){${getStoreCall( 93 | node.store, handlers, wrapper 94 | )}}`; 95 | break countMethod; 96 | } 97 | } 98 | 99 | if (node.inert !== null) { 100 | // The iterable instance 101 | res = node.inert.keys(); 102 | // Iterator for keys 103 | iter = res.next(); 104 | 105 | if (iter.done) 106 | str += `if(${handlers.__pathStr}.charCodeAt(${currentPathLen})===${iter.value}){${compileNode( 107 | node.inert.get(iter.value), true, 108 | handlers, plus(currentPathLen, 1), 109 | hasParams, backupParamIndexExists, wrapper 110 | )}}`; 111 | else { 112 | str += `switch(${handlers.__pathStr}.charCodeAt(${currentPathLen})){` 113 | 114 | // Skip checking if first done 115 | do { 116 | str += `case ${iter.value}:${compileNode( 117 | node.inert.get(iter.value), true, 118 | handlers, plus(currentPathLen, 1), 119 | hasParams, backupParamIndexExists, wrapper 120 | )}break;`; 121 | 122 | // Go to next item 123 | iter = res.next(); 124 | } while (!iter.done); 125 | 126 | str += '}'; 127 | } 128 | } 129 | 130 | if (node.params !== null) { 131 | // Whether path length is a number 132 | res = typeof currentPathLen === 'number'; 133 | 134 | const indexFrom = res ? currentPathLen : prevParamIndex, 135 | nextSlash = `${handlers.__pathStr}.indexOf('/',${indexFrom})`; 136 | 137 | if (!res) 138 | str += `${backupParamIndexExists ? '' : 'var '}${prevParamIndex}=${currentPathLen};`; 139 | 140 | if (node.params.inert !== null) 141 | str += `${hasParams ? '' : 'var '}${currentParamIndex}=${nextSlash};`; 142 | 143 | // End index here 144 | if (node.params.store !== null) { 145 | // Path substring 146 | iter = `${handlers.__pathStr}.substring(${indexFrom},${requestQueryIndex})`; 147 | 148 | str += `if(${node.params.inert !== null ? currentParamIndex : nextSlash}===-1){${requestParams}${hasParams 149 | ? `.${node.params.paramName}=${iter}` 150 | : `={${node.params.paramName}:${iter}}` 151 | };${getStoreCall( 152 | node.params.store, handlers, wrapper 153 | )}}`; 154 | } 155 | 156 | if (node.params.inert !== null) { 157 | // Path substring 158 | iter = `${handlers.__pathStr}.substring(${indexFrom},${currentParamIndex})`; 159 | 160 | const addParams = requestParams + (hasParams 161 | ? `.${node.params.paramName}=${iter}` 162 | : `={${node.params.paramName}:${iter}}` 163 | ); 164 | 165 | str += (node.params.store !== null 166 | ? addParams : `if(${currentParamIndex}===-1)${handlers.__defaultReturn};${addParams}` 167 | ) + ';' + compileNode( 168 | node.params.inert, false, handlers, 169 | // Check whether current path length includes the query index 170 | res || backupParamIndexExists 171 | || (currentPathLen as string).startsWith(urlStartIndex) 172 | ? plus(currentParamIndex, 1) : plus(currentPathLen, 1), 173 | true, !res, wrapper 174 | ); 175 | } 176 | } 177 | 178 | if (node.wildcardStore !== null) { 179 | res = `${handlers.__pathStr}.substring(${currentPathLen})`; 180 | 181 | str += requestParams + (hasParams ? `['*']=${res}` : `={'*':${res}}`) 182 | + `;${getStoreCall(node.wildcardStore, handlers, wrapper)}`; 183 | } 184 | 185 | if (node.part.length !== 0) queue += '}'; 186 | return str + queue; 187 | } 188 | 189 | -------------------------------------------------------------------------------- /src/core/router/compiler/resolveArgs.ts: -------------------------------------------------------------------------------- 1 | import { appDetail, requestObjectName } from './constants'; 2 | 3 | // Whether to pass `ctx` and `server` to args or not 4 | export function checkArgs(fn: Function, skips: number) { 5 | return fn.length > skips + 1 ? requestObjectName + ',' + appDetail : ( 6 | fn.length > skips ? requestObjectName : '' 7 | ); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/core/router/compiler/store.ts: -------------------------------------------------------------------------------- 1 | import { FunctionStore, HandlerDetails } from '../types'; 2 | import { Wrapper, Handler, wrap } from '../../types'; 3 | import { handlerPrefix, requestObjectPrefix, requestParsedBody, cachedMethod } from './constants'; 4 | import { initWrapper, checkWrap, wrapAsync } from './wrapper'; 5 | import { getMacroHandler } from './getHandler'; 6 | import { checkArgs } from './resolveArgs'; 7 | 8 | /** 9 | * Choose the best check for a method group 10 | */ 11 | export function methodSplit(store: FunctionStore, handlers: HandlerDetails, wrapper: Wrapper) { 12 | let method: string; 13 | const methods = []; 14 | 15 | // Ignore special methods 16 | for (method in store) 17 | switch (method) { 18 | case 'ALL': 19 | case 'GUARD': 20 | case 'REJECT': 21 | case 'WRAP': 22 | continue; 23 | 24 | default: 25 | methods.push(method); 26 | break; 27 | }; 28 | 29 | if (methods.length === 0) return ''; 30 | if (methods.length === 1) { 31 | method = methods[0]; 32 | return `if(${cachedMethod}==='${method}')${storeCheck(store[method], handlers, wrapper)}`; 33 | } 34 | 35 | // Multiple methods 36 | let str = `switch(${cachedMethod}){`; 37 | 38 | for (method of methods) 39 | str += `case'${method}':${storeCheck(store[method], handlers, wrapper)}`; 40 | 41 | return str + '}'; 42 | } 43 | 44 | /** 45 | * Checking methods and run the handler 46 | */ 47 | export function getStoreCall(store: FunctionStore, handlers: HandlerDetails, wrapper: Wrapper) { 48 | let str = methodSplit(store, handlers, wrapper); 49 | if ('ALL' in store) str += storeCheck(store['ALL'], handlers, wrapper); 50 | return str; 51 | } 52 | 53 | /** 54 | * Run the store 55 | */ 56 | export function storeCheck(fn: Handler, handlers: HandlerDetails, wrapper: Wrapper) { 57 | if (typeof fn === 'string') return fn; 58 | 59 | // Ignore wrappers for macros 60 | if (fn.macro) return getMacroHandler(fn); 61 | 62 | // Specific wrapper 63 | if (fn.wrap) { 64 | if (fn.wrap === true) 65 | fn.wrap = wrap.plain; 66 | else if (typeof fn.wrap === 'string') 67 | fn.wrap = wrap[fn.wrap]; 68 | 69 | initWrapper(handlers, fn.wrap); 70 | wrapper = fn.wrap; 71 | } else if (fn.wrap === false) 72 | wrapper = null; 73 | 74 | let methodName = handlerPrefix + handlers.__index, 75 | methodCall = `${methodName}(${checkArgs(fn, 0)})`, 76 | str = ''; 77 | 78 | // Add to handlers 79 | handlers[methodName] = fn; 80 | ++handlers.__index; 81 | 82 | // Check body parser 83 | if (fn.body && fn.body !== 'none') { 84 | str += 'return '; 85 | 86 | switch (fn.body) { 87 | case 'text': str += requestObjectPrefix + 'text'; break; 88 | case 'json': str += requestObjectPrefix + 'json'; break; 89 | case 'form': str += requestObjectPrefix + 'formData'; break; 90 | case 'blob': str += requestObjectPrefix + 'blob'; break; 91 | case 'buffer': str += requestObjectPrefix + 'arrayBuffer'; break; 92 | default: throw new Error('Invalid body parser specified: ' + fn.body); 93 | } 94 | 95 | // This wrap when response is truly async 96 | str += `().then(_=>{${requestParsedBody}=_;return ${methodCall}})`; 97 | 98 | // Can't put guards here cuz it will break response wrappers 99 | if (wrapper) str += wrapAsync(wrapper); 100 | str += handlers.__catchBody; 101 | } else { 102 | // Wrap response normally 103 | if (wrapper) 104 | methodCall = checkWrap(fn, wrapper, methodCall); 105 | 106 | str += 'return ' + methodCall; 107 | } 108 | 109 | return str + ';'; 110 | } 111 | -------------------------------------------------------------------------------- /src/core/router/compiler/wrapper.ts: -------------------------------------------------------------------------------- 1 | import { checkArgs } from './resolveArgs'; 2 | import { HandlerDetails } from '../types'; 3 | import { Wrapper, Handler } from '../../types'; 4 | import { wrapperPrefix } from './constants'; 5 | 6 | export function initWrapper(handlers: HandlerDetails, wrapper: Wrapper) { 7 | // Add it to the scope 8 | wrapper.callName = wrapperPrefix + handlers.__wrapperIndex; 9 | handlers[wrapper.callName] = wrapper; 10 | ++handlers.__wrapperIndex; 11 | 12 | // Initialize additional params when the wrapper cannot be passed to `then()` directly 13 | if (!('params' in wrapper)) { 14 | wrapper.params = checkArgs(wrapper, 1); 15 | wrapper.hasParams = wrapper.params !== ''; 16 | 17 | // Prepend with ',' for later concatenations 18 | if (wrapper.hasParams) 19 | wrapper.params = ',' + wrapper.params; 20 | } 21 | } 22 | 23 | export function checkWrap(fn: Handler, wrapper: Wrapper, methodCall: string) { 24 | return fn.chain || fn.constructor.name === 'AsyncFunction' 25 | ? methodCall + wrapAsync(wrapper) 26 | : wrapNormal(wrapper, methodCall); 27 | } 28 | 29 | export function wrapNormal(wrapper: Wrapper, methodCall: string) { 30 | return `${wrapper.callName}(${methodCall}${wrapper.params})`; 31 | } 32 | 33 | export function wrapAsync(wrapper: Wrapper) { 34 | return wrapper.hasParams 35 | ? `.then(_=>${wrapper.callName}(_${wrapper.params}))` 36 | : `.then(${wrapper.callName})`; 37 | } 38 | -------------------------------------------------------------------------------- /src/core/router/exports.ts: -------------------------------------------------------------------------------- 1 | import Radx from '.'; 2 | import * as constants from './compiler/constants'; 3 | import * as getHandler from './compiler/getHandler'; 4 | import * as guard from './compiler/guard'; 5 | import * as index from './compiler/index'; 6 | import * as node from './compiler/node'; 7 | import * as resolveArgs from './compiler/resolveArgs'; 8 | import * as store from './compiler/store'; 9 | import * as wrapper from './compiler/wrapper'; 10 | 11 | export const compiler = { 12 | constants, Radx, resolveArgs, getHandler, 13 | guard, index, node, store, wrapper 14 | } 15 | -------------------------------------------------------------------------------- /src/core/router/index.ts: -------------------------------------------------------------------------------- 1 | import { Node, ParamNode } from './types'; 2 | 3 | function createNode(part: string, inert?: Node[]): Node { 4 | return { 5 | part, 6 | store: null, 7 | inert: inert !== undefined ? new Map(inert.map( 8 | (child) => [child.part.charCodeAt(0), child]) 9 | ) : null, 10 | params: null, 11 | wildcardStore: null 12 | } 13 | } 14 | 15 | function cloneNode(node: Node, part: string): Node { 16 | return { 17 | part, 18 | store: node.store, 19 | inert: node.inert, 20 | params: node.params, 21 | wildcardStore: node.wildcardStore 22 | }; 23 | }; 24 | 25 | function createParamNode(paramName: string): ParamNode { 26 | return { paramName, store: null, inert: null }; 27 | }; 28 | 29 | /** 30 | * The base data structure for Stric router 31 | */ 32 | export class Radx { 33 | root: Node; 34 | 35 | private static regex = { 36 | static: /:.+?(?=\/|$)/, 37 | params: /:.+?(?=\/|$)/g 38 | }; 39 | 40 | add(path: string) { 41 | if (typeof path !== 'string') 42 | throw new TypeError('Route path must be a string'); 43 | 44 | if (path === '') path = '/'; 45 | else if (path[0] !== '/') path = `/${path}`; 46 | 47 | const isWildcard = path[path.length - 1] === '*'; 48 | if (isWildcard) 49 | // Slice off trailing '*' 50 | path = path.slice(0, -1); 51 | 52 | const inertParts = path.split(Radx.regex.static), 53 | paramParts = path.match(Radx.regex.params) || []; 54 | 55 | if (inertParts[inertParts.length - 1] === '') inertParts.pop(); 56 | 57 | let node: Node; 58 | 59 | if (!this.root) this.root = createNode(''); 60 | node = this.root; 61 | 62 | let paramPartsIndex = 0, part: string; 63 | for (let i = 0; i < inertParts.length; ++i) { 64 | part = inertParts[i].substring(1); 65 | 66 | if (i > 0) { 67 | // Set param on the node 68 | const param = paramParts[paramPartsIndex++].substring(1); 69 | 70 | if (node.params === null) node.params = createParamNode(param); 71 | else if (node.params.paramName !== param) 72 | throw new Error( 73 | `Cannot create route "${path}" with parameter "${param}" ` + 74 | 'because a route already exists with a different parameter name ' + 75 | `("${node.params.paramName}") in the same location` 76 | ); 77 | 78 | if (node.params.inert === null) { 79 | node = node.params.inert = createNode(part); 80 | continue; 81 | }; 82 | 83 | node = node.params.inert; 84 | } 85 | 86 | for (let j = 0; ;) { 87 | if (j === part.length) { 88 | if (j < node.part.length) { 89 | // Move the current node down 90 | const childNode = cloneNode(node, node.part.slice(j)) 91 | Object.assign(node, createNode(part, [childNode])) 92 | } 93 | break 94 | } 95 | 96 | if (j === node.part.length) { 97 | // Add static child 98 | if (node.inert === null) node.inert = new Map(); 99 | else if (node.inert.has(part.charCodeAt(j))) { 100 | // Re-run loop with existing static node 101 | node = node.inert.get(part.charCodeAt(j)); 102 | part = part.slice(j); 103 | j = 0; 104 | continue; 105 | } 106 | 107 | // Create new node 108 | const childNode = createNode(part.slice(j)); 109 | node.inert.set(part.charCodeAt(j), childNode); 110 | node = childNode; 111 | break; 112 | } 113 | if (part[j] !== node.part[j]) { 114 | // Split the node 115 | const existingChild = cloneNode(node, node.part.slice(j)), 116 | newChild = createNode(part.slice(j)); 117 | 118 | Object.assign( 119 | node, 120 | createNode(node.part.slice(0, j), [ 121 | existingChild, 122 | newChild 123 | ]) 124 | ) 125 | node = newChild; 126 | break; 127 | } 128 | ++j; 129 | } 130 | } 131 | 132 | if (paramPartsIndex < paramParts.length) { 133 | // The final part is a parameter 134 | const param = paramParts[paramPartsIndex], 135 | paramName = param.substring(1); 136 | 137 | if (node.params === null) node.params = createParamNode(paramName); 138 | else if (node.params.paramName !== paramName) 139 | throw new Error( 140 | `Cannot create route "${path}" with parameter "${paramName}" ` + 141 | 'because a route already exists with a different parameter name ' + 142 | `("${node.params.paramName}") in the same location` 143 | ); 144 | 145 | if (node.params.store === null) node.params.store = Object.create(null); 146 | return node.params.store; 147 | } 148 | 149 | if (isWildcard) { 150 | // The final part is a wildcard 151 | if (node.wildcardStore === null) node.wildcardStore = Object.create(null); 152 | return node.wildcardStore; 153 | } 154 | 155 | // The final part is static 156 | if (node.store === null) node.store = Object.create(null); 157 | return node.store; 158 | } 159 | } 160 | 161 | export default Radx; 162 | 163 | -------------------------------------------------------------------------------- /src/core/router/types.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from '../types'; 2 | 3 | export interface ParamNode { 4 | paramName: string 5 | store: T | null 6 | inert: Node | null 7 | } 8 | 9 | export interface Node { 10 | part: string 11 | store: T | null 12 | inert: Map> | null 13 | params: ParamNode | null 14 | wildcardStore: T | null 15 | fixed?: true 16 | } 17 | 18 | export interface HandlerDetails extends Dict { 19 | __index: number, 20 | __defaultReturn: string, 21 | __pathStr: string, 22 | __pathLen: string | null, 23 | __rejectIndex: number, 24 | __catchBody: string, 25 | __guardIndex: number, 26 | __wrapperIndex: number, 27 | } 28 | 29 | export type FunctionStore = Dict>; 30 | 31 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Server, Errorlike } from 'bun'; 2 | import { 3 | ServeOptions as BasicServeOptions, TLSServeOptions, 4 | TLSWebSocketServeOptions, WebSocketServeOptions 5 | } from 'bun'; 6 | import { jsonHeader } from './router/compiler/constants'; 7 | import type Router from './main'; 8 | 9 | const { stringify } = JSON, 10 | { file } = globalThis.Bun ?? {}, 11 | badReq = { status: 400 }, 12 | jsonSetHeaders = { 'Content-Type': 'application/json' }; 13 | 14 | function modify(set: ContextSet) { 15 | if ('headers' in set) { 16 | if (!('Content-Type' in set.headers)) 17 | set.headers['Content-Type'] = 'application/json'; 18 | } else set.headers = jsonSetHeaders; 19 | 20 | return set; 21 | } 22 | 23 | export const wrap = { 24 | /** 25 | * Send a file path 26 | */ 27 | path: (d: string | URL | null) => d === null ? null : new Response(file(d)), 28 | /** 29 | * Wrap the response 30 | */ 31 | plain: (d: ResponseBody) => new Response(d), 32 | /** 33 | * Wrap the JSON response with `Response.json` 34 | */ 35 | json: (d: any) => new Response(stringify(d), jsonHeader), 36 | /** 37 | * Send all info in ctx 38 | */ 39 | send: (d: ResponseBody, ctx: Context) => ctx.set === null 40 | ? new Response(d) 41 | : new Response(d, ctx.set), 42 | 43 | /** 44 | * Send all info in ctx and the response as json 45 | */ 46 | sendJSON: (d: any, ctx: Context) => d === null 47 | ? new Response(null, badReq) 48 | : (ctx.set === null 49 | ? new Response(stringify(d), jsonHeader) 50 | : new Response(stringify(d), modify(ctx.set)) 51 | ), 52 | }; 53 | 54 | type ExtractParams = T extends `${infer Segment}/${infer Rest}` 55 | ? (Segment extends `:${infer Param}` 56 | ? (Rest extends `*` ? { [K in Param]: string } : { [K in Param]: string } & ExtractParams) 57 | : {}) & ExtractParams 58 | : T extends `:${infer Param}` 59 | ? { [K in Param]: string } 60 | : T extends `*` 61 | ? { '*': string } 62 | : {}; 63 | 64 | /** 65 | * Infer params from string 66 | */ 67 | export type Params

= ExtractParams

& E; 68 | 69 | export type ParserType = B extends 'text' ? string : ( 70 | B extends 'json' ? Record : ( 71 | B extends 'form' ? FormData : ( 72 | B extends 'buffer' ? ArrayBuffer : ( 73 | B extends 'blob' ? Blob : any 74 | ) 75 | ) 76 | ) 77 | ); 78 | 79 | /** 80 | * WebSocket data 81 | */ 82 | export interface WSContext

{ 83 | /** 84 | * The current context 85 | */ 86 | ctx: Context<'none', P>; 87 | /** 88 | * The router meta 89 | */ 90 | meta: RouterMeta; 91 | } 92 | 93 | /** 94 | * All common headers name 95 | */ 96 | export type CommonHeader = "Content-Type" | "Authorization" | "User-Agent" 97 | | "Access-Control-Allow-Origin" | "Access-Control-Max-Age" | "Access-Control-Allow-Headers" 98 | | "Access-Control-Allow-Credentials" | "Access-Control-Expose-Headers" | "Vary" | "Accept" 99 | | "Accept-Encoding" | "Accept-Language" | "Connection" | "Cache-Control" | "Set-Cookie" | "Cookie" 100 | | "Referer" | "Content-Length" | "Date" | "Expect" | "Server" | "Location" | "If-Modified-Since" | "ETag" 101 | | "X-XSS-Protection" | "X-Content-Type-Options" | "Referrer-Policy" | "Expect-CT" | "Content-Security-Policy" 102 | | "Cross-Origin-Opener-Policy" | "Cross-Origin-Embedder-Policy" | "Cross-Origin-Resource-Policy" 103 | | "Permissions-Policy" | "X-Powered-By" | "X-DNS-Prefetch-Control" | "Public-Key-Pins" 104 | | "X-Frame-Options" | "Strict-Transport-Security"; 105 | 106 | export type CommonHeaders = { 107 | [head in CommonHeader]?: string; 108 | } 109 | 110 | /** 111 | * Represent a `head` object 112 | */ 113 | export interface ContextHeaders extends CommonHeaders, Dict { }; 114 | 115 | /** 116 | * Represent a request context 117 | */ 118 | export interface Context extends Request { 119 | /** 120 | * Parsed request body 121 | */ 122 | data: D; 123 | /** 124 | * Parsed request parameter with additional properties if specified 125 | */ 126 | params: Params

& Dict; 127 | /** 128 | * Request query start index (include `?`). 129 | */ 130 | query: number; 131 | /** 132 | * Request path start index (skip first `/`). 133 | * This field only exists only if `base` is not specified 134 | */ 135 | path: number; 136 | /** 137 | * Use to set response 138 | */ 139 | set: ContextSet; 140 | } 141 | 142 | /** 143 | * Common status code 144 | */ 145 | export type StatusCode = 146 | // 1xx 147 | 100 | 101 | 102 | 103 148 | // 2xx 149 | | 200 | 201 | 202 | 203 | 204 | 205 | 206 150 | | 207 | 208 | 226 151 | // 3xx 152 | | 300 | 301 | 302 | 303 | 304 | 307 | 308 153 | // 4xx 154 | | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 155 | | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 156 | | 416 | 417 | 418 | 422 | 423 | 424 | 425 157 | | 426 | 428 | 429 | 431 | 451 158 | // 5xx 159 | | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 160 | | 508 | 510 | 511 161 | // Other 162 | | (number & {}) | (bigint & {}); 163 | 164 | export interface ContextSet extends ResponseInit { 165 | headers?: ContextHeaders; 166 | status?: StatusCode; 167 | } 168 | 169 | /** 170 | * Create a context set object 171 | */ 172 | export function ContextSet() { } 173 | ContextSet.prototype = Object.create(null); 174 | ContextSet.prototype.headers = null; 175 | ContextSet.prototype.status = null; 176 | ContextSet.prototype.statusText = null; 177 | 178 | /** 179 | * Blob part 180 | */ 181 | export type BlobPart = string | Blob | BufferSource; 182 | 183 | /** 184 | * A Response body 185 | */ 186 | export type ResponseBody = ReadableStream | BlobPart | BlobPart[] | FormData | URLSearchParams; 187 | 188 | /** 189 | * A route handler function 190 | */ 191 | export interface Handler extends RouteOptions { 192 | (ctx: Context, T>, meta: RouterMeta): any; 193 | } 194 | 195 | export interface RouterMeta { 196 | /** 197 | * Whether the server is using HTTPS 198 | */ 199 | https: boolean; 200 | /** 201 | * The base URL with protocol and base host 202 | */ 203 | base: string; 204 | /** 205 | * The base host 206 | */ 207 | host: string; 208 | /** 209 | * Whether the server is using default port 210 | */ 211 | defaultPort: boolean; 212 | /** 213 | * The debug server 214 | */ 215 | server: Server; 216 | /** 217 | * The router 218 | */ 219 | router: Router; 220 | /** 221 | * Whether server is in dev mode 222 | */ 223 | dev: boolean; 224 | } 225 | 226 | 227 | /** 228 | * Builtin body parser 229 | * - 'json': req.json() 230 | * - 'text': req.text() 231 | * - 'form': req.formData() 232 | * - 'blob': req.blob() 233 | * - 'buffer': req.arrayBuffer() 234 | */ 235 | export type BodyParser = 'json' | 'text' | 'form' | 'blob' | 'buffer' | 'none'; 236 | 237 | type TrimEndPath

= P extends `${infer C}/` ? C : P; 238 | type AddStartPath

= P extends `/${infer C}` ? `/${C}` : `/${P}`; 239 | 240 | /** 241 | * Normalize a path 242 | */ 243 | export type Normalize

= TrimEndPath> extends '' ? '/' : TrimEndPath>; 244 | 245 | /** 246 | * Concat path 247 | */ 248 | export type ConcatPath = Normalize<`${Normalize}${Normalize}`>; 249 | 250 | /** 251 | * Fetch metadatas 252 | */ 253 | export interface FetchMeta { 254 | /** 255 | * Parameters to pass into fetch scope 256 | */ 257 | params: string[]; 258 | 259 | /** 260 | * The body of the fetch function 261 | */ 262 | body: string; 263 | 264 | /** 265 | * All values corresponding to the parameters 266 | */ 267 | values: any[]; 268 | } 269 | 270 | interface AllOptions extends BasicServeOptions, TLSServeOptions, WebSocketServeOptions, TLSWebSocketServeOptions { } 271 | 272 | export interface ServeOptions extends Partial { 273 | /** 274 | * Enable inspect mode 275 | */ 276 | inspector?: boolean; 277 | 278 | /** 279 | * Should be set to something like `http://localhost:3000` 280 | * This enables optimizations for path parsing but does not work with subdomain 281 | */ 282 | base?: string; 283 | 284 | /** 285 | * The minimum length of the request domain. 286 | * 287 | * Use this instead of `base` to work with subdomain 288 | */ 289 | uriLen?: number; 290 | 291 | /** 292 | * If the value is not set or set to any other value than `false`, 293 | * this will change the prototype of `Request` to include properties 294 | * frequently used by Stric, which improves performance 295 | */ 296 | requestOptimization?: boolean; 297 | } 298 | 299 | /** 300 | * An error handler 301 | */ 302 | export interface ErrorHandler { 303 | (this: Server, err: Errorlike): any 304 | } 305 | 306 | /** 307 | * Handle body parsing error 308 | */ 309 | export interface BodyHandler { 310 | (err: any, ...args: Parameters): any; 311 | } 312 | 313 | export interface RouteOptions { 314 | /** 315 | * Select a body parser 316 | */ 317 | body?: BodyParser; 318 | 319 | /** 320 | * Whether to use the handler as macro 321 | */ 322 | macro?: boolean; 323 | 324 | /** 325 | * Specify a wrapper. 326 | * If set to false, the parent wrapper will be disabled 327 | */ 328 | wrap?: ResponseWrap; 329 | 330 | /** 331 | * Whether to force chain wrap with `then` 332 | */ 333 | chain?: boolean; 334 | } 335 | 336 | export type ResponseWrap = keyof typeof wrap | Wrapper | true | false; 337 | 338 | // Behave like a post middleware 339 | export interface Wrapper { 340 | (response: any, ...args: Parameters): any; 341 | 342 | // Private props for modifying at compile time 343 | callName?: string; 344 | params?: string; 345 | hasParams?: boolean; 346 | } 347 | 348 | export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | 'patch' | 'all' | 'guard' | 'reject'; 349 | export type RouterMethods = { 350 | [K in HttpMethod]: ( 351 | path: T, handler: O extends { body: infer B } 352 | ? ( 353 | B extends BodyParser 354 | ? Handler, B> 355 | : Handler> 356 | ) : Handler>, 357 | options?: O 358 | ) => Router; 359 | }; 360 | 361 | /** 362 | * Specific plugin for router 363 | */ 364 | export interface Plugin { 365 | (app: Router): Router | void | Promise; 366 | 367 | /** 368 | * Only register the plugin after start listening 369 | */ 370 | afterListen?: boolean; 371 | } 372 | 373 | /** 374 | * An object as a plugin 375 | */ 376 | export interface PluginObject { 377 | plugin: Plugin; 378 | 379 | /** 380 | * Only register the plugin after start listening 381 | */ 382 | afterListen?: boolean; 383 | } 384 | 385 | export type RouterPlugin = Plugin | PluginObject; 386 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core/main'; 2 | export * from './plugins/group'; 3 | export * from './plugins/tester'; 4 | export * from './plugins/ws'; 5 | -------------------------------------------------------------------------------- /src/plugins/group.ts: -------------------------------------------------------------------------------- 1 | import { Router, wrap } from "../core/main"; 2 | import type { 3 | ConcatPath, Handler, ResponseWrap, RouterPlugin, 4 | HttpMethod, BodyParser, RouteOptions 5 | } from "../core/types"; 6 | import { convert, methodsLowerCase as methods } from "../core/constants"; 7 | 8 | export type GroupMethods = { 9 | [K in HttpMethod]: ( 10 | path: T, handler: O extends { body: infer B } 11 | ? ( 12 | B extends BodyParser 13 | ? Handler, B> 14 | : Handler> 15 | ) : Handler>, 16 | options?: O 17 | ) => Group; 18 | }; 19 | 20 | export interface Group extends GroupMethods { } 21 | 22 | // @ts-ignore Shorthand 23 | export const route: GroupMethods<'/'> = {}; 24 | for (const method of methods) 25 | route[method] = (...args: any[]) => new Group()[method](...args); 26 | 27 | /** 28 | * A routes group. Can be used as a plugin 29 | */ 30 | export class Group { 31 | record: any[][] = []; 32 | plugins: any[] = []; 33 | 34 | /** 35 | * Create a new routes group 36 | * @param root 37 | */ 38 | // @ts-ignore 39 | constructor(public root: Root = '/') { 40 | if (root !== '/' && root.endsWith('/')) 41 | // @ts-ignore 42 | root = root.slice(0, -1); 43 | this.root = root; 44 | 45 | for (const method of methods) this[method] = (path: string, handler: Handler, opts: any) => { 46 | // Special cases 47 | path = convert(path); 48 | 49 | const args = [method, path, handler]; 50 | if (opts) args.push(opts); 51 | 52 | this.record.push(args); 53 | return this; 54 | } 55 | } 56 | 57 | /** 58 | * Use the default response wrapper for a group of subroutes 59 | */ 60 | wrap(path: string): this; 61 | 62 | /** 63 | * Wrap the response 64 | */ 65 | wrap(path: string, handler: ResponseWrap = 'plain') { 66 | if (typeof handler === 'string') 67 | handler = wrap[handler]; 68 | 69 | if (this.root !== '/') 70 | path = this.root + path; 71 | path = convert(path); 72 | 73 | this.record.push(['wrap', path, handler]); 74 | return this; 75 | } 76 | 77 | /** 78 | * Add a plugin 79 | * @param plugin 80 | */ 81 | plug(...plugins: RouterPlugin[]) { 82 | this.plugins.push(...plugins); 83 | return this; 84 | } 85 | 86 | private fixPath(p: string) { 87 | return this.root === '/' ? p : this.root + p; 88 | } 89 | 90 | /** 91 | * Get the plugin 92 | */ 93 | plugin(app: Router) { 94 | let item: any; 95 | 96 | for (item of this.plugins) { 97 | // Set the correct root 98 | if (item instanceof Group) { 99 | if (item.root === '/') 100 | item.root = this.root; 101 | else if (this.root !== '/') 102 | // @ts-ignore 103 | item.root = this.root + item.root; 104 | } 105 | 106 | app.plug(item); 107 | } 108 | 109 | for (item of this.record) app[item[0]]( 110 | this.fixPath(item[1]), ...item.slice(2) 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/plugins/tester.ts: -------------------------------------------------------------------------------- 1 | import Router from "../core/main"; 2 | 3 | class ResponseStatus { 4 | constructor(public code: number, public text: string) { } 5 | } 6 | 7 | const toTxT = (r: Response) => r.text(), 8 | toJSON = (r: Response) => r.json(), 9 | toBlob = (r: Response) => r.blob(), 10 | toForm = (r: Response) => r.formData(), 11 | toBuffer = (r: Response) => r.arrayBuffer(), 12 | getStatus = (r: Response) => new ResponseStatus(r.status, r.statusText), 13 | getStatusCode = (r: Response) => r.status, 14 | getStatusText = (r: Response) => r.statusText, 15 | getHeaders = (r: Response) => r.headers, 16 | responseIsOk = (r: Response) => r.ok; 17 | 18 | type Params = [url: string, init?: Omit & { body?: BodyInit | Dict }]; 19 | 20 | export interface MockOptions { 21 | /** 22 | * Represent the log level 23 | * `0`: No logging. This is the default value 24 | * `1`: Log only path 25 | */ 26 | logLevel?: 0 | 1; 27 | } 28 | 29 | /** 30 | * Create a tester for the current router 31 | */ 32 | export function mock(app: Router, opts: MockOptions = {}) { 33 | if (!app.listening) app.listen(); 34 | const { logLevel: logLvl = 0 } = opts, base = app.details.base; 35 | 36 | return { 37 | /** 38 | * Create a WS client based on the path 39 | */ 40 | ws(path: string | URL, opts?: ConstructorParameters[1]) { 41 | path = base + path; 42 | return new WebSocket(path, opts); 43 | }, 44 | /** 45 | * Mock the current fetch handler. 46 | * 47 | * If a non-response object is returned, an empty 404 response is returned instead 48 | */ 49 | async fetch(...args: Params): Promise { 50 | if (logLvl >= 1) console.info('Testing', '`' + args[0] + '`'); 51 | args[0] = base + args[0]; 52 | 53 | // Automatically stringify the body if body is JSON 54 | if (args[1]?.body) { 55 | const b = args[1].body as any; 56 | if (typeof b === 'object') 57 | if (b.toString === Object.prototype.toString) 58 | // @ts-ignore 59 | args[1].body = JSON.stringify(b); 60 | } 61 | 62 | // @ts-ignore Save microticks 63 | return await fetch(new Request(...args)); 64 | }, 65 | 66 | /** 67 | * Mock a request and return the status message 68 | */ 69 | async head(...args: Params): Promise { 70 | return this.fetch(...args).then(getHeaders); 71 | }, 72 | 73 | /** 74 | * Mock a request and convert the response to an ArrayBuffer 75 | */ 76 | async ok(...args: Params): Promise { 77 | return this.fetch(...args).then(responseIsOk); 78 | }, 79 | 80 | /** 81 | * Mock a request and return the status message 82 | */ 83 | async msg(...args: Params): Promise { 84 | return this.fetch(...args).then(getStatusText); 85 | }, 86 | 87 | /** 88 | * Mock a request and get the status code and message 89 | */ 90 | async stat(...args: Params): Promise { 91 | return this.fetch(...args).then(getStatus); 92 | }, 93 | 94 | /** 95 | * Mock a request and get the status code 96 | */ 97 | async code(...args: Params): Promise { 98 | return this.fetch(...args).then(getStatusCode); 99 | }, 100 | 101 | /** 102 | * Mock a request and convert the response to string 103 | */ 104 | async text(...args: Params): Promise { 105 | return this.fetch(...args).then(toTxT); 106 | }, 107 | 108 | /** 109 | * Mock a request and convert the response to JSON 110 | */ 111 | async json(...args: Params): Promise { 112 | return this.fetch(...args).then(toJSON); 113 | }, 114 | 115 | /** 116 | * Mock a request and convert the response to Blob 117 | */ 118 | async blob(...args: Params): Promise { 119 | return this.fetch(...args).then(toBlob); 120 | }, 121 | 122 | /** 123 | * Mock a request and convert the response to form data 124 | */ 125 | async form(...args: Params): Promise { 126 | return this.fetch(...args).then(toForm); 127 | }, 128 | 129 | /** 130 | * Mock a request and convert the response to an ArrayBuffer 131 | */ 132 | async buf(...args: Params): Promise { 133 | return this.fetch(...args).then(toBuffer); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/plugins/ws.ts: -------------------------------------------------------------------------------- 1 | import type { Server, WebSocketHandler } from 'bun'; 2 | import type { PluginObject, Router, RouterMeta } from '..'; 3 | import { wsHandlerDataKey } from '../core/router/compiler/constants'; 4 | 5 | export namespace ws { 6 | /** 7 | * Create a dynamic websocket route. 8 | */ 9 | export function route(handler: WebSocketHandler, noOptions: boolean = false) { 10 | return new Route(handler, noOptions); 11 | } 12 | 13 | export interface Route { 14 | /** 15 | * Upgrade the connection to a WebSocket connection. 16 | * User after attaching the route to a server 17 | */ 18 | upgrade(c: Request, opts?: { 19 | /** 20 | * Send any additional headers while upgrading, like cookies 21 | */ 22 | headers?: HeadersInit; 23 | 24 | /** 25 | * This value is passed to the {@link ServerWebSocket.data} property 26 | */ 27 | data?: T; 28 | }): boolean; 29 | 30 | /** 31 | * The attached server 32 | */ 33 | readonly server: Server; 34 | 35 | /** 36 | * The attached meta. Only works with Stric plugins 37 | */ 38 | readonly meta: RouterMeta; 39 | } 40 | 41 | export class Route implements PluginObject { 42 | constructor(public readonly handler: WebSocketHandler, public noOptions: boolean = false) { } 43 | 44 | /** 45 | * Attach this route to a server 46 | */ 47 | attach(server: Server) { 48 | const defOpts = { 49 | data: { [wsHandlerDataKey]: this.handler } 50 | }; 51 | 52 | this.upgrade = this.noOptions 53 | ? (c: Request) => server.upgrade(c, defOpts) 54 | : Function('k', 's', `var i=k.data,h=i.${wsHandlerDataKey};` 55 | + `return (c,o)=>{` 56 | + `if(o===undefined)o=k;` 57 | + `else if('data'in o)o.data.${wsHandlerDataKey}=h;` 58 | + `else o.data=i;` 59 | + `return s.upgrade(c,o)}` 60 | )(defOpts, server); 61 | 62 | // @ts-ignore 63 | this.server = server; 64 | 65 | return this; 66 | } 67 | 68 | /** 69 | * This plugin runs after listening 70 | */ 71 | plugin(app: Router) { 72 | if (app.details.server === null) 73 | throw new Error('This plugin needs to be registered after the server started!'); 74 | 75 | this.attach(app.details.server); 76 | // @ts-ignore 77 | this.meta = app.details; 78 | return app; 79 | } 80 | 81 | // This plugin is registered after listening 82 | afterListen = true; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/basic.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { test, expect } from 'bun:test'; 3 | import { macro, mock, router, wrap } from '..'; 4 | 5 | const predefinedBody = { hi: 'there' }, invalidBody = { status: 400 }; 6 | 7 | // Create the function; 8 | const app = router() 9 | .set('port', 3000) 10 | .get('/', macro('Hi')) 11 | 12 | .get('/id/:id', c => new Response(c.params.id)) 13 | .get('/:name/dashboard/:cat', c => new Response(c.params.name + ' ' + c.params.cat)) 14 | 15 | .post('/json', c => wrap.json(c.data), { body: 'json' }) 16 | .all('/json', () => wrap.json(predefinedBody)) 17 | 18 | .get('/api/v1/hi', () => 'Hi') 19 | 20 | .guard('/api/v1', async c => c.method === 'GET' ? null : true) 21 | .reject('/api/v1', () => 'No enter!') 22 | .wrap('/api/v1') 23 | 24 | .all('/json/*', c => new Response(c.params['*'])) 25 | 26 | .get('/str/1', () => 'Hello') 27 | .get('/str/2', async () => 'Hi') 28 | .get('/str/3', (_, meta) => meta.server.port) 29 | 30 | .get('/str/4', c => { 31 | c.set = { status: 418 }; 32 | return 'I\'m a teapot'; 33 | }) 34 | 35 | .get('/str/5', macro(10)) 36 | .wrap('/str', 'send') 37 | 38 | .use(404) 39 | .use(400, (e, c) => new Response(c.url + ': ' + e, invalidBody)); 40 | 41 | // Tracking time 42 | console.time('Build fetch'); 43 | console.log(app.meta); 44 | 45 | // Report process memory usage and build time 46 | console.timeEnd('Build fetch'); 47 | 48 | const tester = mock(app, { logLevel: 1 }); 49 | console.log(process.memoryUsage()); 50 | 51 | // GET / should returns 'Hi' 52 | test('GET /', async () => { 53 | const res = await tester.text('/'); 54 | expect(res).toBe('Hi'); 55 | }); 56 | 57 | // Dynamic path test 58 | test('GET /id/:id', async () => { 59 | const randomNum = String(Math.round(Math.random() * 101)), 60 | res = await tester.text(`/id/${randomNum}?param`); 61 | 62 | expect(res).toBe(randomNum); 63 | }); 64 | 65 | // Edge case test 66 | test('GET /:name/dashboard/:cat', async () => { 67 | const randomNum = String(Math.round(Math.random() * 101)), 68 | res = await tester.text(`/${randomNum}/dashboard/main`); 69 | 70 | expect(res).toBe(randomNum + ' main'); 71 | }); 72 | 73 | // JSON test 74 | test('POST /json', async () => { 75 | const rnd = { value: Math.round(Math.random()) }, 76 | res = await tester.json('/json', { 77 | method: 'POST', 78 | body: rnd 79 | }); 80 | 81 | expect(res).toStrictEqual(rnd); 82 | }); 83 | 84 | test('404', async () => { 85 | let res: any = await tester.code('/path/that/does/not/exists'); 86 | expect(res).toBe(404); 87 | 88 | res = await tester.text('/json/any', { method: 'PUT' }); 89 | expect(res).toBe('any'); 90 | 91 | res = await tester.text('/api/v1/hi'); 92 | expect(res).toBe('No enter!'); 93 | }); 94 | 95 | test('400', async () => { 96 | const res = await tester.fetch('/json', { method: 'POST' }); 97 | 98 | expect(res.status).toBe(400); 99 | console.log(await res.text()); 100 | }); 101 | 102 | test('Wrapper', async () => { 103 | let res: any = await tester.text('/str/1'); 104 | expect(res).toBe('Hello'); 105 | 106 | res = await tester.text('/str/2'); 107 | expect(res).toBe('Hi'); 108 | 109 | res = await tester.text('/str/3'); 110 | expect(res).toBe('3000'); 111 | 112 | res = await tester.fetch('/str/4'); 113 | expect(await res.text()).toBe(`I'm a teapot`); 114 | expect(res.status).toBe(418); 115 | 116 | res = await tester.text('/str/5'); 117 | expect(res).toBe('10'); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/group.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Group, mock, router, route } from ".."; 3 | 4 | const a = route.get('/a', () => 'Hi', { wrap: 'send' }), 5 | b = new Group('/b').plug(a), 6 | c = new Group('/c').plug(b), 7 | app = router(c).set('port', 3001).use(404); 8 | 9 | const client = mock(app, { logLevel: 1 }); 10 | 11 | test('Nested group', async () => { 12 | const res = await client.text('/c/b/a'); 13 | expect(res).toBe('Hi'); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/ws.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, router, ws } from '..'; 2 | import { test, expect } from 'bun:test'; 3 | 4 | const route = ws.route({ 5 | message(ws) { 6 | ws.send('Hi'); 7 | } 8 | }, true), app = router(route) 9 | .set('port', 3002) 10 | .all('/', c => route.upgrade(c)) 11 | .listen(); 12 | 13 | const client = mock(app); 14 | 15 | test('Dynamic WS', done => { 16 | const socket = client.ws('/'); 17 | 18 | socket.onopen = () => socket.send(''); 19 | socket.onmessage = m => { 20 | expect(m.data).toBe('Hi'); 21 | done(); 22 | }; 23 | }); 24 | 25 | 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "types": [ 8 | "bun-types" 9 | ], 10 | "esModuleInterop": true, 11 | "target": "ESNext", 12 | "skipDefaultLibCheck": true, 13 | "moduleResolution": "node", 14 | "declaration": true, 15 | "emitDeclarationOnly": true 16 | }, 17 | "include": [ 18 | "src" 19 | ] 20 | } 21 | --------------------------------------------------------------------------------