├── .gitignore ├── .npmignore ├── README.md ├── bench ├── async │ └── index.js ├── fetch │ ├── index.js │ └── test.js ├── jsconfig.json ├── micro │ └── stringify.js ├── package.json ├── query │ ├── index.js │ └── singleProp.js └── startup │ ├── index.js │ ├── lib.js │ └── routers.js ├── build.ts ├── package.json ├── src ├── core │ ├── client │ │ ├── client.ts │ │ ├── index.ts │ │ ├── types │ │ │ ├── requestProps.ts │ │ │ └── route.ts │ │ └── utils │ │ │ ├── pathInject.ts │ │ │ ├── serialize.ts │ │ │ └── stringifyQuery.ts │ ├── index.ts │ ├── server │ │ ├── index.ts │ │ ├── route.ts │ │ ├── types │ │ │ ├── handler.ts │ │ │ ├── plugin.ts │ │ │ └── responseInit.ts │ │ └── utils │ │ │ ├── macro.ts │ │ │ └── responses.ts │ └── utils │ │ ├── methods.ts │ │ └── types.ts ├── index.ts ├── plugins │ ├── index.ts │ └── server │ │ ├── cors.ts │ │ ├── csrf.ts │ │ ├── decodeURI.ts │ │ ├── form.ts │ │ └── query.ts └── utils │ └── defaultOptions.ts ├── tests ├── app.ts ├── bun │ ├── cors.spec.ts │ ├── csrf.spec.ts │ ├── defers.spec.ts │ ├── fetch.spec.ts │ ├── index.spec.ts │ ├── set.spec.ts │ └── validator.spec.ts ├── tsconfig.json └── utils │ ├── form.spec.ts │ └── query.spec.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | # Lock 178 | bun.lockb 179 | 180 | # Output files 181 | /types 182 | /*.js 183 | 184 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bun.lockb 2 | node_modules 3 | 4 | .git 5 | .gitignore 6 | 7 | tsconfig.json 8 | 9 | src 10 | tests 11 | examples 12 | 13 | build.ts 14 | 15 | bench 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Byte 2 | 3 | A simple, performance-focused web framework that works on Bun, Deno, and browsers. 4 | 5 | ```ts 6 | import { Byte } from "@bit-js/byte"; 7 | 8 | export default new Byte().get("/", (ctx) => ctx.body("Hi")); 9 | ``` 10 | 11 | ## Features 12 | 13 | - **Fast**: Internally use [`Blitz`](//www.npmjs.com/package/@bit-js/blitz), the fastest router in the JS ecosystem. 14 | - **Multi-runtime**: Works on all non-edge JS runtimes without any adapters. 15 | 16 | ## Benchmarks 17 | 18 | Byte starts up faster than the `hono/quick` preset with LinearRouter. 19 | 20 | ``` 21 | [535.66ms] Byte: Build 1000 routes 22 | [687.44ms] Hono: Build 1000 routes 23 | ``` 24 | 25 | Byte matches routes 6x faster than Hono with RegExpRouter. 26 | 27 | ``` 28 | "/user": 29 | - Hono: 23416ns 30 | - Byte: 4463ns 31 | 32 | "/user/comments": 33 | - Hono: 26255ns 34 | - Byte: 4454ns 35 | 36 | "/user/avatar": 37 | - Hono: 31863ns 38 | - Byte: 4991ns 39 | 40 | "/event/:id": 41 | - Hono: 33113ns 42 | - Byte: 7072ns 43 | 44 | "/event/:id/comments": 45 | - Hono: 34888ns 46 | - Byte: 8257ns 47 | 48 | "/status": 49 | - Hono: 26211ns 50 | - Byte: 4195ns 51 | 52 | "/deeply/nested/route/for/testing": 53 | - Hono: 22171ns 54 | - Byte: 3981ns 55 | ``` 56 | 57 | See [benchmarks](//github.com/bit-js/byte/tree/main/bench) for more details. 58 | 59 | ## Docs 60 | 61 | See the docs at [bytejs.pages.dev](https://bytejs.pages.dev). 62 | -------------------------------------------------------------------------------- /bench/async/index.js: -------------------------------------------------------------------------------- 1 | import { group, run, bench } from 'mitata'; 2 | 3 | for (let i = 0; i < 15; ++i) bench('noop', () => { }); 4 | 5 | const thenFn = a => a + Math.random(); 6 | 7 | const f1 = () => Promise.resolve(0).then(thenFn); 8 | 9 | const f2 = async () => thenFn(await Promise.resolve(0)); 10 | 11 | group('Async function testing', () => { 12 | bench('No async', async () => { 13 | await f1(); 14 | }); 15 | 16 | bench('Async await', async () => { 17 | await f2(); 18 | }); 19 | }); 20 | 21 | run(); 22 | -------------------------------------------------------------------------------- /bench/fetch/index.js: -------------------------------------------------------------------------------- 1 | // Byte 2 | import { Byte, send } from '../..'; 3 | 4 | function createByte() { 5 | const app = new Byte() 6 | .get('/user', send.body('User')) 7 | .get('/user/comments', send.body('User comments')) 8 | .get('/user/avatar', send.body('User avatar')) 9 | .get('/event/:id', (ctx) => ctx.body(`Event ${ctx.params.id}`)) 10 | .get('/event/:id/comments', (ctx) => ctx.body(`Event ${ctx.params.id} comments`)) 11 | .get('/status', send.body('Status')) 12 | .get('/deeply/nested/route/for/testing', send.body('Deeply nested route for testing')); 13 | 14 | app.fetch(new Request('http://localhost:3000')); 15 | return app.fetch; 16 | } 17 | 18 | // Elysia 19 | import { Elysia } from 'elysia'; 20 | 21 | function createElysia() { 22 | const app = new Elysia() 23 | .get('/user', 'User') 24 | .get('/user/comments', 'User comments') 25 | .get('/user/avatar', 'User avatar') 26 | .get('/event/:id', (ctx) => `Event ${ctx.params.id}`) 27 | .get('/event/:id/comments', (ctx) => `Event ${ctx.params.id} comments`) 28 | .get('/status', 'Status') 29 | .get('/deeply/nested/route/for/testing', 'Deeply nested route for testing'); 30 | 31 | app.fetch(new Request('http://localhost:3000')); 32 | return app.fetch; 33 | } 34 | 35 | // Hono 36 | import { Hono } from 'hono'; 37 | import { RegExpRouter } from 'hono/router/reg-exp-router'; 38 | 39 | function createHono() { 40 | const app = new Hono({ router: new RegExpRouter() }) 41 | .get('/user', (ctx) => ctx.body('User')) 42 | .get('/user/comments', (ctx) => ctx.body('User comments')) 43 | .get('/user/avatar', (ctx) => ctx.body('User avatar')) 44 | .get('/event/:id', (ctx) => ctx.body(`Event ${ctx.req.param('id')}`)) 45 | .get('/event/:id/comments', (ctx) => ctx.body(`Event ${ctx.req.param('id')} comments`)) 46 | .get('/status', (ctx) => ctx.body('Status')) 47 | .get('/deeply/nested/route/for/testing', (ctx) => ctx.body('Deeply nested route for testing')); 48 | 49 | app.fetch(new Request('http://localhost:3000')); 50 | return app.fetch; 51 | } 52 | 53 | // Main testing 54 | import test from './test'; 55 | 56 | console.log('Benchmarking...'); 57 | const { benchmarks } = await test({ 58 | Hono: createHono(), 59 | Elysia: createElysia(), 60 | Byte: createByte(), 61 | }); 62 | 63 | const groupResult = {}; 64 | 65 | for (let i = 0, { length } = benchmarks; i < length; ++i) { 66 | const result = benchmarks[i], { group } = result; 67 | if (group === null) continue; 68 | 69 | groupResult[group] ??= []; 70 | groupResult[group].push(`- ${result.name}: ${Math.round(result.stats.avg)}ns\n`); 71 | } 72 | 73 | for (const group in groupResult) 74 | console.log(`"${group}":\n${groupResult[group].join('')}`); 75 | -------------------------------------------------------------------------------- /bench/fetch/test.js: -------------------------------------------------------------------------------- 1 | import { group, run, bench } from 'mitata'; 2 | 3 | const routes = { 4 | '/user': () => 'User', 5 | '/user/comments': () => 'User comments', 6 | '/user/avatar': () => 'User avatar', 7 | '/event/:id': (params) => `Event ${params.id}`, 8 | '/event/:id/comments': (params) => `Event ${params.id} comments`, 9 | '/status': () => 'Status', 10 | '/deeply/nested/route/for/testing': () => 'Deeply nested route for testing' 11 | }; 12 | 13 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 14 | const charactersLength = characters.length; 15 | 16 | function randomValue() { 17 | const result = new Array(10); 18 | 19 | for (let i = 0; i < 10; ++i) 20 | result[i] = characters[Math.floor(Math.random() * charactersLength)]; 21 | 22 | return result.join(''); 23 | } 24 | 25 | // Generate random params and inject into the path 26 | function buildPath(path) { 27 | const parts = [], params = {}; 28 | 29 | let paramIdx = path.indexOf(':'), start = 0; 30 | while (paramIdx !== -1) { 31 | if (paramIdx !== start) 32 | parts.push(path.substring(start, paramIdx)); 33 | 34 | ++paramIdx; 35 | start = path.indexOf('/', paramIdx); 36 | 37 | const value = randomValue(); 38 | parts.push(value); 39 | 40 | if (start === -1) { 41 | params[path.substring(paramIdx)] = value; 42 | return { path: parts.join(''), params }; 43 | } 44 | 45 | params[path.substring(paramIdx, start)] = value; 46 | paramIdx = path.indexOf(':', start + 1); 47 | }; 48 | 49 | parts.push(path.substring(start)); 50 | return { path: parts.join(''), params }; 51 | } 52 | 53 | export async function check(res, expect) { 54 | if (await (await res).text() !== expect) throw new Error('A framework failed the test'); 55 | } 56 | 57 | const built = {}; 58 | for (const path in routes) 59 | built[path] = buildPath(path); 60 | 61 | export default function test(frameworks) { 62 | for (let i = 0; i < 15; ++i) bench('noop', () => { }); 63 | 64 | for (const path in routes) { 65 | const buildResult = built[path]; 66 | const req = new Request('http://localhost' + buildResult.path); 67 | 68 | group(path, () => { 69 | for (const label in frameworks) { 70 | const fn = frameworks[label]; 71 | 72 | check(fn(req), routes[path](buildResult.params)); 73 | console.log(fn.toString()); 74 | 75 | bench(label, () => fn(req)); 76 | } 77 | }); 78 | } 79 | 80 | return run({ 81 | silent: true, 82 | json: true 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /bench/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "jsx": "react-jsx", 11 | "allowJs": true, 12 | // Bundler mode 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "verbatimModuleSyntax": true, 16 | "noEmit": true, 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // Some stricter flags (disabled by default) 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noPropertyAccessFromIndexSignature": false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /bench/micro/stringify.js: -------------------------------------------------------------------------------- 1 | import { group, run, bench } from 'mitata'; 2 | 3 | const list = ['str', Math.round(Math.random() * 16), false, true]; 4 | 5 | group('Stringify', () => { 6 | const toStr = (v) => v.toString(); 7 | bench('toString', () => list.map(toStr)); 8 | 9 | const templateStr = (v) => `${v}`; 10 | bench('Template string', () => list.map(templateStr)); 11 | }); 12 | 13 | run(); 14 | 15 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch", 3 | "type": "module", 4 | "devDependencies": { 5 | "@types/bun": "latest" 6 | }, 7 | "peerDependencies": { 8 | "mitata": "^0.1.11" 9 | }, 10 | "dependencies": { 11 | "blitz-new": "npm:@bit-js/blitz@latest", 12 | "blitz-old": "npm:@bit-js/blitz@1.0.19", 13 | "elysia": "latest", 14 | "hono": "latest" 15 | }, 16 | "scripts": { 17 | "jit": "BUN_JSC_jitPolicyScale=0.0 BUN_JSC_thresholdForOptimizeSoon=0.0 BUN_JSC_thresholdForJITSoon=0.0 bun run", 18 | "jitless": "BUN_JSC_UseJit=0 bun run", 19 | "startup": "bun run ./startup/index.js", 20 | "fetch": "bun jitless ./fetch/index.js" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bench/query/index.js: -------------------------------------------------------------------------------- 1 | import { group, run, bench } from 'mitata'; 2 | import { query, Context } from '../..'; 3 | 4 | const params = 'items=1&items=2&name=Reve&age=16&admin'; 5 | const ctx = new Context(new Request('http://localhost:3000/?' + params)); 6 | 7 | const parse = query.schema({ 8 | items: { type: 'number', maxItems: 10 }, 9 | name: { type: 'string' }, 10 | age: { type: 'number' }, 11 | admin: { type: 'bool' } 12 | }); 13 | console.log(parse.toString()); 14 | 15 | group('Query parsing', () => { 16 | bench('Schema', () => { 17 | const o = parse(ctx); 18 | return `${o.items.join()} ${o.name} ${o.age} ${o.admin}`; 19 | }); 20 | 21 | bench('URLSearchParams', () => { 22 | const params = new URLSearchParams(ctx.req.url.substring(ctx.pathEnd + 1)); 23 | return `${params.getAll('items').join()} ${params.get('name')} ${+params.get('age')} ${params.has('admin')}`; 24 | }); 25 | }); 26 | 27 | run(); 28 | -------------------------------------------------------------------------------- /bench/query/singleProp.js: -------------------------------------------------------------------------------- 1 | import { group, run, bench } from 'mitata'; 2 | import { query, Context } from '../..'; 3 | import { optimizeNextInvocation } from 'bun:jsc'; 4 | 5 | const params = 'id=1&name=Reve&age=16&admin'; 6 | const ctx = new Context(new Request('http://localhost:3000/?' + params)); 7 | const search = new URLSearchParams(ctx.req.url.substring(ctx.pathEnd + 1)); 8 | 9 | const parse = query.get('name'); 10 | console.log(parse.toString()); 11 | 12 | parse(ctx); 13 | optimizeNextInvocation(parse); 14 | 15 | group('Query parsing', () => { 16 | bench('Schema', () => parse(ctx)); 17 | bench('URLSearchParams', () => search.get('name')); 18 | }); 19 | 20 | run(); 21 | 22 | -------------------------------------------------------------------------------- /bench/startup/index.js: -------------------------------------------------------------------------------- 1 | import { exec } from './lib'; 2 | 3 | exec('Byte', [ 4 | 'import { Byte, send } from "../.."', 5 | 'const app = new Byte()' 6 | ], (route) => `\t.get('${route.part}', send.body(${route.value}))`); 7 | 8 | exec('Hono', [ 9 | 'import { Hono } from "hono/quick"', 10 | 'import { LinearRouter as Router } from "hono/router/linear-router"', 11 | 'const app = new Hono({ router: new Router() })' 12 | ], (route) => `\t.get('${route.part}', (ctx) => ctx.body(${route.value}))`); 13 | 14 | exec('Elysia', [ 15 | 'import { Elysia } from "elysia"', 16 | 'const app = new Elysia()' 17 | ], (route) => `\t.get('${route.part}', ${route.value})`); 18 | -------------------------------------------------------------------------------- /bench/startup/lib.js: -------------------------------------------------------------------------------- 1 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_'; 2 | const charactersLength = characters.length; 3 | export const routesCount = 1e3; 4 | 5 | // Make everything as random as possible 6 | function makePart() { 7 | const result = []; 8 | const length = 2 + Math.round(Math.random() * 16); 9 | 10 | for (let cnt = 0; cnt < length; ++cnt) 11 | result.push(characters[Math.floor(Math.random() * charactersLength)]); 12 | 13 | return `/${result.join('')}`; 14 | } 15 | 16 | export function makePath(idx) { 17 | const parts = new Array(routesCount); 18 | for (let i = 0; i < routesCount; ++i) 19 | parts[i] = makePart(); 20 | 21 | // Put URL params randomly to force the paths to be registered on the radix tree 22 | parts[idx] = `/:${parts[idx].substring(1)}`; 23 | return parts.join(''); 24 | } 25 | 26 | const routes = new Array(routesCount); 27 | for (let i = 0; i < routesCount; ++i) 28 | routes[i] = { part: makePath(i), value: `"${Math.random()}"` }; 29 | 30 | export async function exec(name, content, chain, build) { 31 | const path = `./dist/${name}.js`; 32 | 33 | if (process.argv[2] !== 'test') { 34 | content.unshift(`console.time("${name}: Build ${routesCount} routes")`); 35 | 36 | for (let i = 0; i < routesCount; ++i) 37 | content.push(chain(routes[i])); 38 | 39 | if (build === undefined) content.push('app.fetch(new Request("http://localhost:3000"))'); 40 | else content.push(build); 41 | 42 | content.push(`console.timeEnd("${name}: Build ${routesCount} routes")`); 43 | await Bun.write(path, content.join('\n')); 44 | } 45 | 46 | Bun.spawn(['bun', 'run', path], { 47 | stdout: 'inherit' 48 | }); 49 | } 50 | 51 | -------------------------------------------------------------------------------- /bench/startup/routers.js: -------------------------------------------------------------------------------- 1 | import { exec } from './lib'; 2 | 3 | exec('blitz-edge', [ 4 | 'import { EdgeRouter } from "blitz-new"', 5 | 'const app = new EdgeRouter()' 6 | ], (route) => `app.put('GET', '${route.part}', () => new Response('${route.value}'))`, 'app.build()'); 7 | 8 | exec('blitz-new', [ 9 | 'import Blitz from "blitz-new"', 10 | 'const app = new Blitz()' 11 | ], (route) => `app.put('GET', '${route.part}', () => new Response('${route.value}'))`, 'app.build()'); 12 | 13 | exec('blitz-old', [ 14 | 'import Blitz from "blitz-old"', 15 | 'const app = new Blitz()' 16 | ], (route) => `app.put('GET', '${route.part}', () => new Response('${route.value}'))`, 'app.build()'); 17 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { existsSync, rmSync } from 'fs'; 3 | import pkg from './package.json'; 4 | 5 | // Generating types 6 | const dir = './types'; 7 | if (existsSync(dir)) rmSync(dir, { recursive: true }); 8 | 9 | Bun.spawn(['bun', 'x', 'tsc'], { 10 | stdout: 'inherit', 11 | stderr: 'inherit' 12 | }); 13 | 14 | Bun.build({ 15 | format: 'esm', 16 | target: 'bun', 17 | outdir: '.', 18 | entrypoints: ['./src/index.ts'], 19 | minify: { 20 | whitespace: true 21 | }, 22 | external: Object.keys(pkg.dependencies) 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bit-js/byte", 3 | "version": "2.0.0", 4 | "module": "index.js", 5 | "type": "module", 6 | "types": "types/index.d.ts", 7 | "dependencies": { 8 | "@bit-js/blitz": "^1.4.0" 9 | }, 10 | "scripts": { 11 | "build-test": "bun build.ts && bun test && tsc --noEmit -p ./tests/tsconfig.json" 12 | }, 13 | "keywords": [ 14 | "framework", 15 | "backend", 16 | "minimal", 17 | "fast" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/core/client/client.ts: -------------------------------------------------------------------------------- 1 | import type { BaseByte } from '../server'; 2 | 3 | import serialize from './utils/serialize'; 4 | import getInjectFn from './utils/pathInject'; 5 | import stringifyQuery from './utils/stringifyQuery'; 6 | 7 | import type { UnionToIntersection } from '../utils/types'; 8 | 9 | import type { InferRoutes } from './types/route'; 10 | import { emptyObj } from '../../utils/defaultOptions'; 11 | 12 | import type { ProtoSchema } from '../utils/methods'; 13 | 14 | /** 15 | * Infer client type 16 | */ 17 | export type InferClient = UnionToIntersection< 18 | InferRoutes< 19 | T['__infer']['routes'], 20 | T['__infer']['fallbackResponse'] 21 | > 22 | >; 23 | 24 | /** 25 | * Customize client 26 | */ 27 | export interface ClientOptions { 28 | fetch?: (req: Request) => Promise; 29 | init?: RequestInit; 30 | } 31 | 32 | const fetchFn = globalThis.fetch.bind(globalThis); 33 | 34 | // Bit client prototype 35 | export class BitClient implements ProtoSchema { 36 | /** 37 | * Base URL 38 | */ 39 | readonly url: string; 40 | 41 | /** 42 | * Fetch function 43 | */ 44 | readonly fetch: ClientOptions['fetch'] & {}; 45 | 46 | /** 47 | * Default response init 48 | */ 49 | readonly defaultInit: ClientOptions['init'] & {}; 50 | 51 | constructor(url: string, options?: ClientOptions) { 52 | if (typeof options === 'undefined') { 53 | this.fetch = fetchFn; 54 | this.defaultInit = emptyObj; 55 | } else { 56 | this.fetch = options.fetch ?? fetchFn; 57 | this.defaultInit = options.init ?? emptyObj; 58 | } 59 | 60 | // Normalize URL 61 | const lastIdx = url.length - 1; 62 | this.url = url.charCodeAt(lastIdx) === 47 ? url.substring(0, lastIdx) : url; 63 | } 64 | 65 | $(path: string, init?: any) { 66 | const { defaultInit } = this; 67 | if (typeof init === 'undefined') 68 | return this.fetch(new Request(this.url + path, defaultInit)); 69 | 70 | if (defaultInit !== emptyObj) 71 | for (const key in defaultInit) 72 | // @ts-expect-error Set new keys to init 73 | init[key] ??= defaultInit[key]; 74 | 75 | const { params, body, query } = init; 76 | if (typeof body !== 'undefined') 77 | init.body = serialize(body); 78 | 79 | return this.fetch( 80 | new Request( 81 | // Cast URL parameters 82 | `${this.url}${typeof params === 'undefined' ? path : getInjectFn(path)(params)}${stringifyQuery(query)}`, 83 | init 84 | ) 85 | ); 86 | } 87 | 88 | /** @internal */ 89 | get(path: string, init?: any) { 90 | if (typeof init === 'undefined') 91 | return this.$(path, getInit); 92 | 93 | init.method = 'GET'; 94 | return this.$(path, init); 95 | } 96 | 97 | /** @internal */ 98 | head(path: string, init?: any) { 99 | if (typeof init === 'undefined') 100 | return this.$(path, headInit); 101 | 102 | init.method = 'HEAD'; 103 | return this.$(path, init); 104 | } 105 | 106 | /** @internal */ 107 | post(path: string, init?: any) { 108 | if (typeof init === 'undefined') 109 | return this.$(path, postInit); 110 | 111 | init.method = 'POST'; 112 | return this.$(path, init); 113 | } 114 | 115 | /** @internal */ 116 | put(path: string, init?: any) { 117 | if (typeof init === 'undefined') 118 | return this.$(path, putInit); 119 | 120 | init.method = 'PUT'; 121 | return this.$(path, init); 122 | } 123 | 124 | /** @internal */ 125 | delete(path: string, init?: any) { 126 | if (typeof init === 'undefined') 127 | return this.$(path, deleteInit); 128 | 129 | init.method = 'DELETE'; 130 | return this.$(path, init); 131 | } 132 | 133 | /** @internal */ 134 | options(path: string, init?: any) { 135 | if (typeof init === 'undefined') 136 | return this.$(path, optionsInit); 137 | 138 | init.method = 'OPTIONS'; 139 | return this.$(path, init); 140 | } 141 | /** @internal */ 142 | patch(path: string, init?: any) { 143 | if (typeof init === 'undefined') 144 | return this.$(path, patchInit); 145 | 146 | init.method = 'PATCH'; 147 | return this.$(path, init); 148 | } 149 | 150 | /** @internal */ 151 | connect(path: string, init?: any) { 152 | if (typeof init === 'undefined') 153 | return this.$(path, connectInit); 154 | 155 | init.method = 'CONNECT'; 156 | return this.$(path, init); 157 | } 158 | /** @internal */ 159 | trace(path: string, init?: any) { 160 | if (typeof init === 'undefined') 161 | return this.$(path, traceInit); 162 | 163 | init.method = 'TRACE'; 164 | return this.$(path, init); 165 | } 166 | 167 | /** @internal */ 168 | any(path: string, init?: any) { 169 | return typeof init === 'undefined' ? this.$(path) : this.$(path, init); 170 | } 171 | } 172 | 173 | // Default request init objects 174 | const getInit = { method: 'GET' }; 175 | const headInit = { method: 'HEAD' }; 176 | const postInit = { method: 'POST' }; 177 | const putInit = { method: 'PUT' }; 178 | const deleteInit = { method: 'DELETE' }; 179 | const optionsInit = { method: 'OPTIONS' }; 180 | const patchInit = { method: 'PATCH' }; 181 | const connectInit = { method: 'CONNECT' }; 182 | const traceInit = { method: 'TRACE' }; 183 | 184 | export type Client = InferClient & BitClient; 185 | 186 | -------------------------------------------------------------------------------- /src/core/client/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseByte } from '../server'; 2 | import { BitClient, type Client, type ClientOptions } from './client'; 3 | 4 | /** 5 | * A type safe client 6 | */ 7 | export function bit(url: string, options?: ClientOptions): Client { 8 | return typeof options === 'undefined' ? new BitClient(url) : new BitClient(url, options) as any; 9 | } 10 | 11 | // Types 12 | export * from './types/route'; 13 | export * from './types/requestProps'; 14 | 15 | // Client internals 16 | export * from './client'; 17 | export { default as stringifyQuery } from './utils/stringifyQuery'; 18 | export { default as serialize } from './utils/serialize'; 19 | export { default as getInjectFn } from './utils/pathInject'; 20 | 21 | -------------------------------------------------------------------------------- /src/core/client/types/requestProps.ts: -------------------------------------------------------------------------------- 1 | import type { ParamsKey } from '@bit-js/blitz'; 2 | import type { BaseRoute } from '../../server'; 3 | 4 | // Parameter types 5 | type ParamValue = string | number | boolean; 6 | type SetParams = ParamsKey extends never ? {} : { 7 | /** 8 | * Rest parameter ('$') must start with a slash 9 | */ 10 | params: { [K in ParamsKey]: ParamValue } 11 | }; 12 | 13 | // Main types 14 | export interface QueryParams extends Record { } 15 | 16 | export interface RequestBaseProps extends RequestInit { 17 | query?: QueryParams; 18 | body?: any; 19 | } 20 | 21 | export type RequestProps = RequestBaseProps & SetParams; 22 | -------------------------------------------------------------------------------- /src/core/client/types/route.ts: -------------------------------------------------------------------------------- 1 | import type { BaseRoute, RoutesRecord } from '../../server'; 2 | import type { Promisify, RequiredKeys, AwaitedReturn } from '../../utils/types'; 3 | import type { RequestBaseProps, RequestProps } from './requestProps'; 4 | 5 | // Infer a single route 6 | type RouteFunc = 7 | // Force to provide additional fields if exists 8 | RequiredKeys extends never 9 | ? (path: Path, init?: RequestBaseProps) => Return 10 | : (path: Path, init: Init) => Return; 11 | 12 | type InferReturn = Promisify>; 13 | 14 | export type InferRoute = { 15 | [K in T['method']]: RouteFunc< 16 | T['path'], 17 | RequestProps, 18 | FallbackResponse | InferReturn 19 | >; 20 | }; 21 | 22 | export type InferRoutes = T extends [infer Route extends BaseRoute, ...infer Rest extends RoutesRecord] 23 | ? InferRoute | InferRoutes : {}; 24 | -------------------------------------------------------------------------------- /src/core/client/utils/pathInject.ts: -------------------------------------------------------------------------------- 1 | type PathInjectFunction = (params: Record) => string; 2 | const injectPath: Record = {}; 3 | 4 | // Inject parameter to the path 5 | // Small string builders can utilize rope string 6 | function buildPathInject(path: string) { 7 | let parts = ''; 8 | 9 | let paramIdx = path.indexOf(':'); 10 | let start = 0; 11 | 12 | while (paramIdx !== -1) { 13 | if (paramIdx !== start) parts += path.substring(start, paramIdx); 14 | 15 | ++paramIdx; 16 | start = path.indexOf('/', paramIdx); 17 | 18 | if (start === -1) { 19 | parts += `\${p.${path.substring(paramIdx)}}`; 20 | return Function(`return (p)=>\`${parts}\``)(); 21 | } 22 | 23 | parts += `\${p.${path.substring(paramIdx, start)}}`; 24 | paramIdx = path.indexOf(':', start + 1); 25 | } 26 | 27 | // Wildcard check 28 | parts += path.charCodeAt(path.length - 1) === 42 29 | ? `${path.substring(start, path.length - 2)}\${p.$}` 30 | : path.substring(start); 31 | 32 | return Function(`return (p)=>\`${parts}\``)(); 33 | } 34 | 35 | export default function getInjectFn(path: string) { 36 | return injectPath[path] ??= buildPathInject(path); 37 | } 38 | -------------------------------------------------------------------------------- /src/core/client/utils/serialize.ts: -------------------------------------------------------------------------------- 1 | export default function serialize(input: any) { 2 | switch (typeof input) { 3 | case 'string': return input; 4 | case 'object': 5 | if (input === null) return null; 6 | 7 | const { constructor } = input; 8 | if (constructor === Object) 9 | return JSON.stringify(input); 10 | if (constructor === Promise) 11 | return input.then(serialize); 12 | if (constructor === Map) 13 | return JSON.stringify(Object.fromEntries(input)); 14 | 15 | return input; 16 | 17 | case 'number': return `${input}`; 18 | case 'bigint': return `${input}`; 19 | case 'boolean': return `${input}`; 20 | 21 | default: return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/core/client/utils/stringifyQuery.ts: -------------------------------------------------------------------------------- 1 | import type { QueryParams } from '../types/requestProps'; 2 | 3 | export default function stringifyQuery(query?: QueryParams) { 4 | if (typeof query === 'undefined') return ''; 5 | 6 | const parts = []; 7 | 8 | for (const key in query) { 9 | const value = query[key]; 10 | 11 | switch (typeof value) { 12 | case 'boolean': 13 | if (value) parts.push(encodeURIComponent(key)); 14 | continue; 15 | 16 | case 'object': 17 | const encodedKey = encodeURIComponent(key); 18 | for (let i = 0, { length } = value; i < length; ++i) parts.push(`${encodedKey}=${encodeURIComponent(`${value[i]}`)}`); 19 | continue; 20 | 21 | default: 22 | parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(`${value}`)}`); 23 | continue; 24 | } 25 | } 26 | 27 | return `?${parts.join('&')}`; 28 | } 29 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './server'; 3 | -------------------------------------------------------------------------------- /src/core/server/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseRouter } from '@bit-js/blitz'; 2 | import Blitz from '@bit-js/blitz'; 3 | 4 | import type { ProtoSchema, RequestMethod } from '../utils/methods'; 5 | 6 | import { Route, type RoutesRecord, type ActionList } from './route'; 7 | import { Context, type BaseHandler, type DeferFn, type Fn } from './types/handler'; 8 | 9 | import { bit } from '../client'; 10 | import { default404, emptyList } from '../../utils/defaultOptions'; 11 | import type { AwaitedReturn } from '../utils/types'; 12 | import type { ExcludeResponse, ExtractResponse } from './utils/responses'; 13 | import type { BasePlugin, InferPluginState } from './types/plugin'; 14 | 15 | // Methods to register request handlers 16 | interface Register { 17 | < 18 | const Path extends string, 19 | const Handler extends BaseHandler 20 | >( 21 | path: Path, 22 | handler: Handler 23 | ): Byte<[...T, Route], State>; 24 | 25 | < 26 | const Path extends string, 27 | const Handler extends BaseHandler 28 | >( 29 | path: Path, 30 | handlers: Handler 31 | ): Byte<[...T, Route], State, FallbackResponse>; 32 | } 33 | 34 | type HandlerRegisters = { 35 | [Method in RequestMethod | 'any']: Register; 36 | }; 37 | 38 | /** 39 | * Create a Byte app 40 | */ 41 | export class Byte implements ProtoSchema { 42 | readonly actions: ActionList = []; 43 | readonly defers: DeferFn[] = []; 44 | 45 | /** 46 | * Register middlewares that doesn't require validations 47 | */ 48 | prepare(fn: Fn) { 49 | this.actions.push([1, fn]); 50 | return this; 51 | } 52 | 53 | /** 54 | * Register middlewares 55 | */ 56 | use>(fn: Middleware) { 57 | this.actions.push([2, fn]); 58 | return this as Byte< 59 | Rec, State, FallbackResponse | ExtractResponse> 60 | >; 61 | } 62 | 63 | /** 64 | * Bind a prop to the context 65 | */ 66 | set>(name: Name, fn: Getter) { 67 | this.actions.push([3, fn, name]); 68 | return this as Byte< 69 | Rec, State & { [K in Name]: AwaitedReturn }, FallbackResponse 70 | >; 71 | } 72 | 73 | /** 74 | * Bind a prop to the context 75 | */ 76 | state>(name: Name, fn: Getter) { 77 | this.actions.push([4, fn, name]); 78 | return this as Byte< 79 | Rec, 80 | State & { [K in Name]: ExcludeResponse> }, 81 | FallbackResponse | ExtractResponse> 82 | >; 83 | } 84 | 85 | /** 86 | * Run after response handler 87 | */ 88 | defer>(fn: Defer) { 89 | this.defers.push(fn); 90 | return this as Byte< 91 | Rec, State, 92 | FallbackResponse | ExtractResponse> 93 | >; 94 | } 95 | 96 | /** 97 | * Register plugins 98 | */ 99 | register(...plugins: Plugins) { 100 | for (let i = 0, { length } = plugins; i < length; ++i) 101 | // @ts-expect-error 102 | plugins[i].plug(this); 103 | 104 | return this as Byte, FallbackResponse>; 105 | } 106 | 107 | /** 108 | * Routes record. Only use this to infer types 109 | */ 110 | readonly routes: RoutesRecord = []; 111 | 112 | /** 113 | * Register sub-routes 114 | */ 115 | route(base: string, { routes }: T) { 116 | const currentRoutes = this.routes; 117 | const { actions, defers } = this; 118 | 119 | for (let i = 0, { length } = routes; i < length; ++i) currentRoutes.push(routes[i].clone(base, actions, defers)); 120 | 121 | return this; 122 | } 123 | 124 | #fetch?: any; 125 | 126 | /** 127 | * Build the fetch function 128 | */ 129 | build(router: BaseRouter = new Blitz()) { 130 | const { routes } = this; 131 | router.fallback = default404; 132 | 133 | for (let i = 0, { length } = routes; i < length; ++i) routes[i].register(router); 134 | 135 | return this.#fetch = router.build(Context); 136 | } 137 | 138 | /** 139 | * Get the fetch function for use 140 | */ 141 | get fetch(): (req: Request) => any { 142 | return this.#fetch ??= this.build(); 143 | } 144 | 145 | /** 146 | * Create a test client 147 | */ 148 | client() { 149 | return bit('http://127.0.0.1', this); 150 | } 151 | 152 | /** 153 | * Create a handler 154 | */ 155 | static handle>(fn: T) { 156 | return fn; 157 | } 158 | 159 | /** 160 | * Create an defer handler 161 | */ 162 | static defer>(fn: T) { 163 | return fn; 164 | } 165 | 166 | /** 167 | * Create a plugin 168 | */ 169 | static plugin(plugin: Plugin) { 170 | return plugin; 171 | } 172 | 173 | /** 174 | * Shorthand for registering subroutes 175 | */ 176 | static route(base: string, app: T) { 177 | return new Byte().route(base, app); 178 | } 179 | 180 | /** 181 | * Register a handler 182 | */ 183 | handle(method: string, path: string, ...args: any[]) { 184 | // Load necessary actions 185 | const { actions, defers } = this; 186 | 187 | // Push new route 188 | this.routes.push(new Route( 189 | method, path, 190 | // Check for validator 191 | args[0], 192 | // Load the actions and alters 193 | actions.length === 0 ? emptyList : [actions], defers.length === 0 ? emptyList : [defers] 194 | )); 195 | 196 | return this; 197 | } 198 | 199 | /** @internal */ 200 | get(...args: any[]): any { 201 | // @ts-expect-error 202 | return this.handle('GET', ...args); 203 | } 204 | 205 | /** @internal */ 206 | head(...args: any[]): any { 207 | // @ts-expect-error 208 | return this.handle('HEAD', ...args); 209 | } 210 | 211 | /** @internal */ 212 | post(...args: any[]): any { 213 | // @ts-expect-error 214 | return this.handle('POST', ...args); 215 | } 216 | 217 | /** @internal */ 218 | put(...args: any[]): any { 219 | // @ts-expect-error 220 | return this.handle('PUT', ...args); 221 | } 222 | 223 | /** @internal */ 224 | delete(...args: any[]): any { 225 | // @ts-expect-error 226 | return this.handle('DELETE', ...args); 227 | } 228 | 229 | /** @internal */ 230 | options(...args: any[]): any { 231 | // @ts-expect-error 232 | return this.handle('OPTIONS', ...args); 233 | } 234 | 235 | /** @internal */ 236 | patch(...args: any[]): any { 237 | // @ts-expect-error 238 | return this.handle('PATCH', ...args); 239 | } 240 | 241 | /** @internal */ 242 | connect(...args: any[]): any { 243 | // @ts-expect-error 244 | return this.handle('CONNECT', ...args); 245 | } 246 | 247 | /** @internal */ 248 | trace(...args: any[]): any { 249 | // @ts-expect-error 250 | return this.handle('TRACE', ...args); 251 | } 252 | 253 | /** @internal */ 254 | any(...args: any[]): any { 255 | // @ts-expect-error 256 | return this.handle(null, ...args); 257 | } 258 | } 259 | 260 | export interface Byte extends HandlerRegisters { 261 | __infer: { 262 | routes: Rec, 263 | state: State, 264 | fallbackResponse: FallbackResponse 265 | }; 266 | } 267 | 268 | export type BaseByte = Byte; 269 | 270 | // Real stuff 271 | export * from './route'; 272 | 273 | // Types 274 | export * from './types/plugin'; 275 | export * from './types/handler'; 276 | export * from './types/responseInit'; 277 | 278 | // Internals and utils 279 | export * from './utils/responses'; 280 | export * from './utils/macro'; 281 | -------------------------------------------------------------------------------- /src/core/server/route.ts: -------------------------------------------------------------------------------- 1 | import type { BaseRouter } from '@bit-js/blitz'; 2 | import type { DeferFn, Fn } from './types/handler'; 3 | 4 | import { isAsync } from './utils/macro'; 5 | 6 | // Action 7 | export interface Initializer { 8 | 0: 1; 9 | 1: Fn; 10 | } 11 | 12 | export interface Middleware { 13 | 0: 2; 14 | 1: Fn; 15 | } 16 | 17 | export interface Setter { 18 | 0: 3; 19 | 1: Fn; 20 | 2: string; 21 | } 22 | 23 | export interface StateSetter { 24 | 0: 4; 25 | 1: Fn; 26 | 2: string; 27 | } 28 | 29 | export type ActionList = (Initializer | Middleware | Setter | StateSetter)[]; 30 | 31 | /** 32 | * Represent a route 33 | */ 34 | export class Route< 35 | Method extends string, 36 | Path extends string, 37 | Handler extends Fn 38 | > { 39 | /** 40 | * Create a route procedure 41 | */ 42 | constructor( 43 | readonly method: Method, 44 | readonly path: Path, 45 | readonly handler: Handler, 46 | readonly actions: ActionList[], 47 | readonly defers: DeferFn[][] 48 | ) { } 49 | 50 | /** 51 | * Clone the route with a new base path 52 | */ 53 | clone(base: string, otherAppActions: ActionList, otherAppDefers: DeferFn[]) { 54 | const { path } = this; 55 | 56 | return new Route( 57 | this.method, 58 | // Merge pathname 59 | base.length === 1 ? path : (path.length === 1 ? base : base + path) as Path, 60 | // Copy other props 61 | this.handler, 62 | // Push other stuff 63 | otherAppActions.length === 0 ? this.actions : [otherAppActions, ...this.actions], 64 | otherAppDefers.length === 0 ? this.defers : [...this.defers, otherAppDefers] 65 | ); 66 | } 67 | 68 | /** 69 | * Register the handler to the underlying router 70 | */ 71 | register(router: BaseRouter) { 72 | if (this.method === null) 73 | router.handle(this.path, this.compile()); 74 | else 75 | router.on(this.method, this.path, this.compile()); 76 | } 77 | 78 | /** 79 | * Compile the route into a single function 80 | * 81 | */ 82 | compile() { 83 | const { handler, actions, defers } = this; 84 | 85 | // Conditions 86 | const noActions = actions.length === 0; 87 | const noDefers = defers.length === 0; 88 | 89 | if (noActions && noDefers) return handler; 90 | 91 | const keys: string[] = []; 92 | const statements: string[] = []; 93 | const values: (Fn | DeferFn)[] = []; 94 | 95 | let hasAsync = false; 96 | let noContext = true; 97 | let idx = 0; 98 | 99 | // Compile actions and check result 100 | if (!noActions) 101 | // Loop in reverse each app action 102 | { 103 | for (let i = 0, lI = actions.length; i < lI; ++i) { 104 | const list = actions[i]; 105 | 106 | for (let j = 0, lJ = list.length; j < lJ; ++j, ++idx) { 107 | const action = list[j]; 108 | 109 | const fn = action[1]; 110 | const fnKey = `f${idx}`; 111 | 112 | keys.push(fnKey); 113 | values.push(fn); 114 | 115 | const fnAsync = isAsync(fn); 116 | hasAsync ||= fnAsync; 117 | 118 | const fnNoContext = fn.length === 0; 119 | noContext &&= fnNoContext; 120 | 121 | switch (action[0]) { 122 | case 1: 123 | statements.push(`${fnAsync ? 'await ' : ''}${fnKey}(${noContext ? '' : 'c'})`); 124 | continue; 125 | 126 | case 2: 127 | statements.push(`const c${idx}=${fnAsync ? 'await ' : ''}${fnKey}(${noContext ? '' : 'c'});if(c${idx} instanceof Response)return c${idx}`); 128 | continue; 129 | 130 | case 3: 131 | statements.push(`c.${action[2]}=${fnAsync ? 'await ' : ''}${fnKey}(${noContext ? '' : 'c'})`); 132 | continue; 133 | 134 | case 4: 135 | statements.push(`const c${idx}=${fnAsync ? 'await ' : ''}${fnKey}(${noContext ? '' : 'c'});if(c${idx} instanceof Response)return c${idx};c.${action[2]}=c${idx}`); 136 | continue; 137 | } 138 | } 139 | } 140 | } 141 | 142 | // Restricted variable for the main handler 143 | keys.push('$'); 144 | values.push(handler); 145 | 146 | const handlerNoContext = handler.length === 0; 147 | noContext &&= handlerNoContext; 148 | 149 | // Check for alters 150 | if (noDefers) 151 | // Save some milliseconds if the function is async 152 | statements.push(`return ${isAsync(handler) && hasAsync ? 'await ' : ''}$(${handlerNoContext ? '' : 'c'});`); 153 | else { 154 | const fnAsync = isAsync(handler); 155 | hasAsync ||= fnAsync; 156 | 157 | // Hold a ref to the response 158 | statements.push(`let r=${fnAsync ? 'await ' : ''}$(${handlerNoContext ? '' : 'c'});if(!(r instanceof Response))return null`); 159 | 160 | for (let i = 0, { length } = defers; i < length; ++i) { 161 | const list = defers[i]; 162 | 163 | for (let i = list.length - 1; i > -1; --i, ++idx) { 164 | const fn = list[i]; 165 | const fnKey = `f${idx}`; 166 | 167 | keys.push(fnKey); 168 | values.push(fn); 169 | 170 | const fnAsync = isAsync(fn); 171 | hasAsync ||= fnAsync; 172 | 173 | const fnNoContext = fn.length < 2; 174 | noContext &&= fnNoContext; 175 | 176 | statements.push(`const c${idx}=${fnAsync ? 'await ' : ''}${fnKey}(${fn.length === 0 ? '' : noContext ? 'r' : 'r,c'});if(c${idx} instanceof Response)r=c${idx}`); 177 | } 178 | } 179 | 180 | statements.push('return r;'); 181 | } 182 | 183 | return Function(...keys, `return ${hasAsync ? 'async ' : ''}(${noContext ? '' : 'c'})=>{${statements.join(';')}}`)(...values); 184 | } 185 | } 186 | 187 | export type BaseRoute = Route; 188 | 189 | // Route list 190 | export type RoutesRecord = BaseRoute[]; 191 | 192 | -------------------------------------------------------------------------------- /src/core/server/types/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Params } from '@bit-js/blitz'; 2 | import { htmlPair, jsonPair, type BasicResponse, type JsonResponse, type NullableBody } from '../utils/responses'; 3 | import type { CommonHeaders, CommonResponseInit } from '../types/responseInit'; 4 | 5 | // Base context 6 | export class Context implements CommonResponseInit { 7 | status!: number; 8 | headers: CommonHeaders; 9 | 10 | readonly path: string; 11 | readonly pathStart: number; 12 | readonly pathEnd: number; 13 | readonly params!: Params; 14 | readonly req: Request; 15 | 16 | /** 17 | * Parse the request 18 | */ 19 | constructor(req: Request) { 20 | this.req = req; 21 | this.headers = []; 22 | 23 | const { url } = req; 24 | 25 | const start = url.indexOf('/', 12); 26 | const end = url.indexOf('?', start + 1); 27 | const pathEnd = end === -1 ? url.length : end; 28 | 29 | this.pathStart = start; 30 | this.pathEnd = pathEnd; 31 | this.path = url.substring(start, pathEnd); 32 | } 33 | 34 | /** 35 | * Send a `BodyInit` as response 36 | */ 37 | body(body: T): BasicResponse { 38 | return new Response(body, this as any) as any; 39 | } 40 | 41 | /** 42 | * Send response as JSON 43 | */ 44 | json(body: T): JsonResponse { 45 | this.headers.push(jsonPair); 46 | return new Response(JSON.stringify(body), this as any); 47 | } 48 | 49 | /** 50 | * Send HTML response 51 | */ 52 | html(body: T): BasicResponse { 53 | this.headers.push(htmlPair); 54 | return new Response(body, this as any) as any; 55 | } 56 | 57 | /** 58 | * Send HTML response 59 | */ 60 | redirect(location: string, status: 301 | 302 | 307 | 308): Response { 61 | this.headers.push(['Location', location]); 62 | this.status = status; 63 | return new Response(null, this as any); 64 | } 65 | 66 | /** 67 | * Send an empty response 68 | */ 69 | end(): BasicResponse<''> { 70 | return new Response(null, this as any) as any; 71 | } 72 | } 73 | 74 | export type BaseContext = Context; 75 | 76 | // Basic handler and actions 77 | export type BaseHandler = (c: Context> & Set) => any; 78 | 79 | export type Fn = (c: BaseContext & T) => any; 80 | export type DeferFn = (res: Response, c: BaseContext & T) => any; 81 | -------------------------------------------------------------------------------- /src/core/server/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Byte } from '..'; 2 | import type { RoutesRecord } from '../route'; 3 | 4 | export interface Plugin { 5 | plug: (app: Byte) => Byte | void | null | undefined; 6 | } 7 | 8 | export type BasePlugin = Plugin; 9 | 10 | export type InferPluginState = Plugins extends [infer Item extends BasePlugin, ...infer Rest extends BasePlugin[]] 11 | ? (Item extends Plugin ? State : {}) & InferPluginState 12 | : {}; 13 | -------------------------------------------------------------------------------- /src/core/server/types/responseInit.ts: -------------------------------------------------------------------------------- 1 | export interface StatusMap { 2 | 100: 'Continue'; 3 | 101: 'Switching Protocols'; 4 | 103: 'Early Hints'; 5 | 6 | 200: 'OK'; 7 | 201: 'Created'; 8 | 202: 'Accepted'; 9 | 203: 'Non-Authoritative Information'; 10 | 204: 'No Content'; 11 | 205: 'Reset Content'; 12 | 206: 'Partial Content'; 13 | 207: 'Multi-Status'; 14 | 208: 'Already Reported'; 15 | 226: 'IM Used'; 16 | 17 | 300: 'Multiple Choices'; 18 | 301: 'Moved Permanently'; 19 | 302: 'Found'; 20 | 303: 'See Other'; 21 | 304: 'Not Modified'; 22 | 307: 'Temporary Redirect'; 23 | 308: 'Permanent Redirect'; 24 | 25 | 400: 'Bad Request'; 26 | 401: 'Unauthorized'; 27 | 402: 'Payment Required'; 28 | 403: 'Forbidden'; 29 | 404: 'Not Found'; 30 | 405: 'Method Not Allowed'; 31 | 406: 'Not Acceptable'; 32 | 407: 'Proxy Authentication Required'; 33 | 408: 'Request Timeout'; 34 | 409: 'Conflict'; 35 | 410: 'Gone'; 36 | 411: 'Length Required'; 37 | 412: 'Precondition Failed'; 38 | 413: 'Payload Too Large'; 39 | 414: 'URI Too Long'; 40 | 415: 'Unsupported Media Type'; 41 | 416: 'Range Not Satisfiable'; 42 | 417: 'Expectation Failed'; 43 | 418: 'I\'m a teapot'; 44 | 421: 'Misdirected Request'; 45 | 422: 'Unprocessable Content'; 46 | 423: 'Locked'; 47 | 424: 'Failed Dependency'; 48 | 425: 'Too Early'; 49 | 426: 'Upgrade Required'; 50 | 428: 'Precondition Required'; 51 | 429: 'Too Many Requests'; 52 | 431: 'Request Header Fields To Large'; 53 | 451: 'Unavailable For Legal Reasons'; 54 | 55 | 500: 'Internal Server Error'; 56 | 501: 'Not Implemented'; 57 | 502: 'Bad Gateway'; 58 | 503: 'Service Unavailable'; 59 | 504: 'Gateway Timeout'; 60 | 505: 'HTTP Version Not Supported'; 61 | 506: 'Variant Also Negotiates'; 62 | 507: 'Insufficient Storage'; 63 | 508: 'Loop Detected'; 64 | 510: 'Not Extended'; 65 | 511: 'Network Authentication Required'; 66 | } 67 | 68 | export type CommonStatus = keyof StatusMap; 69 | export type CommonHeaderName = 'Set-Cookie' | 'Cache-Control' | 'Server' 70 | | 'Location' | 'ETag' | 'Referrer-Policy' | 'Vary' | 'Link' 71 | | 'Access-Control-Allow-Credentials' | 'Access-Control-Allow-Headers' 72 | | 'Access-Control-Allow-Methods' | 'Access-Control-Allow-Origin' 73 | | 'Access-Control-Expose-Headers' | 'Access-Control-Max-Age' 74 | | 'Access-Control-Request-Headers' | 'Access-Control-Request-Method' 75 | | 'Strict-Transport-Security' | 'Content-Security-Policy' | 'Connection' 76 | | 'Server-Timing' | 'Keep-Alive' | 'Last-Modified' | 'Expires'; 77 | 78 | export type ImageMIMETypes = `image/${'bmp' | 'avif' | 'gif' | 'jpeg' | 'png' | 'svg+xml' | 'webp'}`; 79 | export type TextMIMETypes = `text/${'css' | 'csv' | 'html' | 'plain' | 'javascript' | 'event-stream'}`; 80 | export type AppMIMETypes = `application/${'octet-stream' | 'gzip' | 'json' | 'pdf' | 'xml' | 'zip' | 'ogg' | 'rtf'}`; 81 | export type AudioMIMETypes = `audio/${'midi' | 'ogg' | 'webm' | 'mpeg' | 'wav'}`; 82 | export type VideoMIMETypes = `video/${'mp4' | 'mpeg' | 'ogg' | 'webm'}`; 83 | export type FontMIMETypes = `font/${'woff' | 'woff2' | 'ttf'}`; 84 | 85 | export type MIMETypes = ImageMIMETypes | TextMIMETypes 86 | | AppMIMETypes | AudioMIMETypes | VideoMIMETypes 87 | | FontMIMETypes | (string & {}); 88 | 89 | export type CommonHeaders = (readonly [CommonHeaderName, string] | readonly ['Content-Type', MIMETypes] | readonly [string, string])[]; 90 | 91 | /** 92 | * ResponseInit with commonly used props value 93 | */ 94 | export interface CommonResponseInit { 95 | status?: CommonStatus | (number & {}); 96 | statusText?: StatusMap[CommonStatus] | (string & {}); 97 | headers?: CommonHeaders; 98 | } 99 | -------------------------------------------------------------------------------- /src/core/server/utils/macro.ts: -------------------------------------------------------------------------------- 1 | import type { Fn } from '../types/handler'; 2 | 3 | // Mark async macro 4 | export const AsyncFunction = async function () { }.constructor; 5 | export function $async(fn: T): T { 6 | fn.constructor = AsyncFunction; 7 | return fn; 8 | } 9 | export function isAsync(fn: any) { 10 | return fn.constructor === AsyncFunction; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/core/server/utils/responses.ts: -------------------------------------------------------------------------------- 1 | import type { CommonResponseInit } from '../types/responseInit'; 2 | import type { MaybePromise } from '../../utils/types'; 3 | 4 | // Basic response 5 | export interface BasicResponse extends Response { 6 | text: () => Promise; 7 | clone: () => this; 8 | } 9 | 10 | // What a normal handler should return 11 | export type GenericResponse = MaybePromise | Response>; 12 | export type ExtractResponse = Extract; 13 | export type ExcludeResponse = Exclude; 14 | 15 | // JSON response 16 | export interface JsonResponse extends Response { 17 | json: () => Promise; 18 | clone: () => this; 19 | } 20 | 21 | export type NullableBody = BodyInit | null; 22 | 23 | export const jsonPair = ['Content-Type', 'application/json'] as const; 24 | const jsonHeaders = [jsonPair]; 25 | const jsonInit = { headers: jsonHeaders }; 26 | 27 | export const htmlPair = ['Content-Type', 'text/html'] as const; 28 | const htmlHeaders = [htmlPair]; 29 | const htmlInit = { headers: htmlHeaders }; 30 | 31 | /** 32 | * Create a static response handler 33 | */ 34 | export const send = { 35 | body(body: T, init?: CommonResponseInit): () => BasicResponse { 36 | const res = typeof init === 'undefined' ? new Response(body) : new Response(body, init as ResponseInit); 37 | return (): any => res.clone(); 38 | }, 39 | 40 | json(body: T, init?: CommonResponseInit): () => JsonResponse { 41 | if (typeof init === 'undefined') 42 | init = jsonInit; 43 | else if (typeof init.headers === 'undefined') 44 | init.headers = jsonHeaders; 45 | else 46 | init.headers.push(jsonPair); 47 | 48 | const res = new Response(JSON.stringify(body), init as ResponseInit); 49 | return (): any => res.clone(); 50 | }, 51 | 52 | html(body: T, init?: CommonResponseInit): () => BasicResponse { 53 | if (typeof init === 'undefined') 54 | init = htmlInit; 55 | else if (typeof init.headers === 'undefined') 56 | init.headers = htmlHeaders; 57 | else 58 | init.headers.push(htmlPair); 59 | 60 | const res = new Response(body, init as ResponseInit); 61 | return (): any => res.clone(); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/core/utils/methods.ts: -------------------------------------------------------------------------------- 1 | // Request methods 2 | export const methods = ['get', 'post', 'put', 'delete', 'options', 'head', 'patch', 'connect', 'trace'] as const; 3 | export type RequestMethod = typeof methods[number]; 4 | 5 | export interface ProtoSchema extends Record { } 6 | -------------------------------------------------------------------------------- /src/core/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type ReturnOf = T extends (...args: any[]) => infer R ? R : never; 2 | export type AwaitedReturn = T extends (...args: any[]) => infer R ? Awaited : never; 3 | 4 | export type MaybePromise = T | Promise; 5 | export type Promisify = T extends Promise ? T : Promise; 6 | 7 | export type UnionToIntersection = 8 | (T extends any ? (x: T) => any : never) extends 9 | (x: infer R) => any ? R : never; 10 | 11 | export type RequiredKeys = { [K in keyof T]-?: {} extends Pick ? never : K }[keyof T]; 12 | 13 | export type DropFirstInTuple = ((...args: T) => any) extends (arg: any, ...rest: infer U) => any ? U : T; 14 | export type LastItem = T[DropFirstInTuple['length']]; 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core'; 2 | export * from './plugins'; 3 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // Server utils 2 | export * from './server/cors'; 3 | export * from './server/csrf'; 4 | export * from './server/query'; 5 | export * from './server/form'; 6 | -------------------------------------------------------------------------------- /src/plugins/server/cors.ts: -------------------------------------------------------------------------------- 1 | import type { CommonHeaders, Fn } from '../../core/server'; 2 | 3 | type Values = string | string[]; 4 | 5 | export interface CORSOptions { 6 | allowOrigin?: string; 7 | allowMethods?: Values; 8 | exposeHeaders?: Values; 9 | maxAge?: number; 10 | allowCredentials?: boolean; 11 | allowHeaders?: Values; 12 | } 13 | 14 | function parseValue(value: Values) { 15 | return typeof value === 'string' ? value : value.join(','); 16 | } 17 | 18 | const allowCredentials = ['Access-Control-Allow-Credentials', 'true'] satisfies CommonHeaders[number]; 19 | const allowAllOrigins = ['Access-Control-Allow-Origin', '*'] satisfies CommonHeaders[number]; 20 | const varyOrigin = ['Vary', 'Origin'] satisfies CommonHeaders[number]; 21 | 22 | const defaultCors = ((c) => { c.headers.push(allowAllOrigins); }) satisfies Fn; 23 | 24 | /** 25 | * Create a CORS action function 26 | */ 27 | export function cors(options?: CORSOptions) { 28 | if (typeof options === 'undefined') return defaultCors; 29 | 30 | const builder: CommonHeaders = []; 31 | 32 | // Check basic properties 33 | if (typeof options.allowHeaders !== 'undefined') 34 | builder.push(['Access-Control-Allow-Headers', parseValue(options.allowHeaders)]); 35 | if (typeof options.allowMethods !== 'undefined') 36 | builder.push(['Access-Control-Allow-Methods', parseValue(options.allowMethods)]); 37 | 38 | if (typeof options.exposeHeaders !== 'undefined') 39 | builder.push(['Access-Control-Expose-Headers', parseValue(options.exposeHeaders)]); 40 | if (typeof options.maxAge === 'number') 41 | builder.push(['Access-Control-Max-Age', `${options.maxAge}`]); 42 | if (options.allowCredentials === true) 43 | builder.push(allowCredentials); 44 | 45 | // Check allow origins 46 | if (typeof options.allowOrigin === 'string' && options.allowOrigin !== '*') 47 | builder.push(['Access-Control-Allow-Origin', options.allowOrigin], varyOrigin); 48 | else 49 | builder.push(allowAllOrigins); 50 | 51 | // Small optimization 52 | if (builder.length === 1) { 53 | const first = builder[0]; 54 | return ((c) => { c.headers.push(first); }) satisfies Fn; 55 | } 56 | 57 | return ((c) => { c.headers.push(...builder); }) satisfies Fn; 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/plugins/server/csrf.ts: -------------------------------------------------------------------------------- 1 | import type { Fn } from '../../core/server'; 2 | import { default403res } from '../../utils/defaultOptions'; 3 | 4 | const defaultCSRF = ((ctx) => { 5 | if (ctx.req.headers.get('Origin') !== ctx.req.url.substring(0, ctx.pathStart)) return default403res; 6 | }) satisfies Fn; 7 | 8 | /** 9 | * CSRF action options 10 | */ 11 | export interface CSRFOptions { 12 | origins?: string[]; 13 | verify?: (origin: string) => boolean; 14 | fallback?: Fallback; 15 | } 16 | 17 | export function csrf(options?: Options): Options['fallback'] & {} { 18 | if (typeof options === 'undefined') return defaultCSRF; 19 | 20 | const literals = []; 21 | const keys = []; 22 | const values = []; 23 | 24 | if (typeof options.origins !== 'undefined') { 25 | const obj: Record = {}; 26 | 27 | const { origins } = options; 28 | for (let i = 0, { length } = origins; i < length; ++i) obj[origins[i]] = null; 29 | 30 | keys.push('o'); 31 | values.push(obj); 32 | 33 | literals.push('_ in o'); 34 | } 35 | 36 | if (typeof options.verify !== 'undefined') { 37 | keys.push('f'); 38 | values.push(options.verify); 39 | 40 | literals.push('f(_)'); 41 | } 42 | 43 | if (literals.length === 0) 44 | return defaultCSRF; 45 | 46 | let fallbackCall: string; 47 | if (typeof options.fallback === 'undefined') { 48 | keys.push('h'); 49 | values.push(default403res); 50 | fallbackCall = 'h'; 51 | } else { 52 | const { fallback } = options; 53 | 54 | keys.push('h'); 55 | values.push(fallback); 56 | 57 | fallbackCall = `h${fallback.length === 0 ? '()' : '(c)'}`; 58 | } 59 | 60 | return Function(...keys, `return (c)=>{const _=c.req.headers.get('Origin');return ${literals.join('&&')}?null:${fallbackCall};}`)(...values); 61 | } 62 | 63 | -------------------------------------------------------------------------------- /src/plugins/server/decodeURI.ts: -------------------------------------------------------------------------------- 1 | function createHex(shift: number) { 2 | return [ 3 | 255, 4 | 255, 5 | 255, 6 | 255, 7 | 255, 8 | 255, 9 | 255, 10 | 255, 11 | 255, 12 | 255, 13 | 255, 14 | 255, 15 | 255, 16 | 255, 17 | 255, 18 | 255, 19 | 255, 20 | 255, 21 | 255, 22 | 255, 23 | 255, 24 | 255, 25 | 255, 26 | 255, 27 | 255, 28 | 255, 29 | 255, 30 | 255, 31 | 255, 32 | 255, 33 | 255, 34 | 255, 35 | 255, 36 | 255, 37 | 255, 38 | 255, 39 | 255, 40 | 255, 41 | 255, 42 | 255, 43 | 255, 44 | 255, 45 | 255, 46 | 255, 47 | 255, 48 | 255, 49 | 255, 50 | 255, 51 | 52 | 0, 53 | 1 << shift, 54 | 2 << shift, 55 | 3 << shift, 56 | 4 << shift, 57 | 5 << shift, 58 | 6 << shift, 59 | 7 << shift, 60 | 8 << shift, 61 | 9 << shift, 62 | 63 | 255, 64 | 255, 65 | 255, 66 | 255, 67 | 255, 68 | 255, 69 | 255, 70 | 71 | 10 << shift, 72 | 11 << shift, 73 | 12 << shift, 74 | 13 << shift, 75 | 14 << shift, 76 | 15 << shift, 77 | 78 | 255, 79 | 255, 80 | 255, 81 | 255, 82 | 255, 83 | 255, 84 | 255, 85 | 255, 86 | 255, 87 | 255, 88 | 255, 89 | 255, 90 | 255, 91 | 255, 92 | 255, 93 | 255, 94 | 255, 95 | 255, 96 | 255, 97 | 255, 98 | 255, 99 | 255, 100 | 255, 101 | 255, 102 | 255, 103 | 255, 104 | 105 | 10 << shift, 106 | 11 << shift, 107 | 12 << shift, 108 | 13 << shift, 109 | 14 << shift, 110 | 15 << shift 111 | ]; 112 | } 113 | 114 | const h4 = createHex(4); 115 | function highHex(code: number) { 116 | return code > 102 ? 255 : h4[code]; 117 | } 118 | 119 | const h0 = createHex(0); 120 | function lowHex(code: number) { 121 | return code > 102 ? 255 : h0[code]; 122 | } 123 | 124 | const data = [ 125 | // The first part of the table maps bytes to character to a transition. 126 | 0, 127 | 0, 128 | 0, 129 | 0, 130 | 0, 131 | 0, 132 | 0, 133 | 0, 134 | 0, 135 | 0, 136 | 0, 137 | 0, 138 | 0, 139 | 0, 140 | 0, 141 | 0, 142 | 0, 143 | 0, 144 | 0, 145 | 0, 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0, 166 | 0, 167 | 0, 168 | 0, 169 | 0, 170 | 0, 171 | 0, 172 | 0, 173 | 0, 174 | 0, 175 | 0, 176 | 0, 177 | 0, 178 | 0, 179 | 0, 180 | 0, 181 | 0, 182 | 0, 183 | 0, 184 | 0, 185 | 0, 186 | 0, 187 | 0, 188 | 0, 189 | 0, 190 | 0, 191 | 0, 192 | 0, 193 | 0, 194 | 0, 195 | 0, 196 | 0, 197 | 0, 198 | 0, 199 | 0, 200 | 0, 201 | 0, 202 | 0, 203 | 0, 204 | 0, 205 | 0, 206 | 0, 207 | 0, 208 | 0, 209 | 0, 210 | 0, 211 | 0, 212 | 0, 213 | 0, 214 | 0, 215 | 0, 216 | 0, 217 | 0, 218 | 0, 219 | 0, 220 | 0, 221 | 0, 222 | 0, 223 | 0, 224 | 0, 225 | 0, 226 | 0, 227 | 0, 228 | 0, 229 | 0, 230 | 0, 231 | 0, 232 | 0, 233 | 0, 234 | 0, 235 | 0, 236 | 0, 237 | 0, 238 | 0, 239 | 0, 240 | 0, 241 | 0, 242 | 0, 243 | 0, 244 | 0, 245 | 0, 246 | 0, 247 | 0, 248 | 0, 249 | 0, 250 | 0, 251 | 0, 252 | 0, 253 | 0, 254 | 1, 255 | 1, 256 | 1, 257 | 1, 258 | 1, 259 | 1, 260 | 1, 261 | 1, 262 | 1, 263 | 1, 264 | 1, 265 | 1, 266 | 1, 267 | 1, 268 | 1, 269 | 1, 270 | 2, 271 | 2, 272 | 2, 273 | 2, 274 | 2, 275 | 2, 276 | 2, 277 | 2, 278 | 2, 279 | 2, 280 | 2, 281 | 2, 282 | 2, 283 | 2, 284 | 2, 285 | 2, 286 | 3, 287 | 3, 288 | 3, 289 | 3, 290 | 3, 291 | 3, 292 | 3, 293 | 3, 294 | 3, 295 | 3, 296 | 3, 297 | 3, 298 | 3, 299 | 3, 300 | 3, 301 | 3, 302 | 3, 303 | 3, 304 | 3, 305 | 3, 306 | 3, 307 | 3, 308 | 3, 309 | 3, 310 | 3, 311 | 3, 312 | 3, 313 | 3, 314 | 3, 315 | 3, 316 | 3, 317 | 3, 318 | 4, 319 | 4, 320 | 5, 321 | 5, 322 | 5, 323 | 5, 324 | 5, 325 | 5, 326 | 5, 327 | 5, 328 | 5, 329 | 5, 330 | 5, 331 | 5, 332 | 5, 333 | 5, 334 | 5, 335 | 5, 336 | 5, 337 | 5, 338 | 5, 339 | 5, 340 | 5, 341 | 5, 342 | 5, 343 | 5, 344 | 5, 345 | 5, 346 | 5, 347 | 5, 348 | 5, 349 | 5, 350 | 6, 351 | 7, 352 | 7, 353 | 7, 354 | 7, 355 | 7, 356 | 7, 357 | 7, 358 | 7, 359 | 7, 360 | 7, 361 | 7, 362 | 7, 363 | 8, 364 | 7, 365 | 7, 366 | 10, 367 | 9, 368 | 9, 369 | 9, 370 | 11, 371 | 4, 372 | 4, 373 | 4, 374 | 4, 375 | 4, 376 | 4, 377 | 4, 378 | 4, 379 | 4, 380 | 4, 381 | 4, 382 | 383 | // The second part of the table maps a state to a new state when adding a 384 | // transition. 385 | 256, 386 | 256, 387 | 256, 388 | 256, 389 | 256, 390 | 256, 391 | 256, 392 | 256, 393 | 256, 394 | 256, 395 | 256, 396 | 256, 397 | 268, 398 | 256, 399 | 256, 400 | 256, 401 | 256, 402 | 280, 403 | 292, 404 | 304, 405 | 316, 406 | 328, 407 | 340, 408 | 352, 409 | 256, 410 | 268, 411 | 268, 412 | 268, 413 | 256, 414 | 256, 415 | 256, 416 | 256, 417 | 256, 418 | 256, 419 | 256, 420 | 256, 421 | 256, 422 | 256, 423 | 256, 424 | 280, 425 | 256, 426 | 256, 427 | 256, 428 | 256, 429 | 256, 430 | 256, 431 | 256, 432 | 256, 433 | 256, 434 | 280, 435 | 280, 436 | 280, 437 | 256, 438 | 256, 439 | 256, 440 | 256, 441 | 256, 442 | 256, 443 | 256, 444 | 256, 445 | 256, 446 | 280, 447 | 280, 448 | 256, 449 | 256, 450 | 256, 451 | 256, 452 | 256, 453 | 256, 454 | 256, 455 | 256, 456 | 256, 457 | 256, 458 | 304, 459 | 304, 460 | 304, 461 | 256, 462 | 256, 463 | 256, 464 | 256, 465 | 256, 466 | 256, 467 | 256, 468 | 256, 469 | 256, 470 | 256, 471 | 304, 472 | 304, 473 | 256, 474 | 256, 475 | 256, 476 | 256, 477 | 256, 478 | 256, 479 | 256, 480 | 256, 481 | 256, 482 | 304, 483 | 256, 484 | 256, 485 | 256, 486 | 256, 487 | 256, 488 | 256, 489 | 256, 490 | 256, 491 | 256, 492 | 256 493 | ]; 494 | 495 | // Maps the current transition to a mask that needs to apply to the byte. 496 | const mask = [0x7F, 0x3F, 0x3F, 0x3F, 0x00, 0x1F, 0x0F, 0x0F, 0x0F, 0x07, 0x07, 0x07]; 497 | 498 | export default function decodeURIComponent(url: string, start: number, end: number) { 499 | let percentPosition = url.indexOf('%', start); 500 | if (percentPosition === -1) return url.substring(start, end); 501 | 502 | let decoded = ''; 503 | 504 | let last = 0; 505 | let codepoint = 0; 506 | let startOfOctets = percentPosition; 507 | let state = 268; 508 | 509 | while (percentPosition < end) { 510 | const byte = highHex(url.charCodeAt(percentPosition + 1)) | lowHex(url.charCodeAt(percentPosition + 2)); 511 | const type = data[byte]; 512 | 513 | codepoint = codepoint << 6 | byte & mask[type]; 514 | state = data[state + type]; 515 | 516 | if (state === 256) return url.substring(start, end); 517 | if (state === 268) { 518 | decoded += url.substring(last, startOfOctets); 519 | decoded += codepoint > 0xFFFF 520 | ? String.fromCharCode( 521 | 0xD7C0 + (codepoint >> 10), 522 | 0xDC00 + (codepoint & 0x3FF) 523 | ) 524 | : String.fromCharCode(codepoint); 525 | 526 | last = percentPosition + 3; 527 | percentPosition = url.indexOf('%', last); 528 | 529 | if (percentPosition === -1) 530 | return decoded + url.substring(last); 531 | 532 | startOfOctets = percentPosition; 533 | codepoint = 0; 534 | } else { 535 | percentPosition += 3; 536 | if (percentPosition >= end || url.charCodeAt(percentPosition) !== 37) return url.substring(start, end); 537 | } 538 | } 539 | 540 | return decoded + url.substring(last); 541 | } 542 | -------------------------------------------------------------------------------- /src/plugins/server/form.ts: -------------------------------------------------------------------------------- 1 | import { $async, type BaseContext } from '../../core'; 2 | import { noop } from '../../utils/defaultOptions'; 3 | 4 | interface TypeMap { 5 | string: string | null; 6 | number: number; 7 | bool: boolean; 8 | file: File | null; 9 | } 10 | 11 | export interface FormPropertyOptions { 12 | type: keyof TypeMap; 13 | multipleItems?: boolean; 14 | } 15 | export type InferFormPropertyOptions = 16 | T['multipleItems'] extends true ? (TypeMap[T['type']])[] : TypeMap[T['type']]; 17 | 18 | export type FormSchema = Record; 19 | 20 | export type InferFormSchema = { 21 | [K in keyof Schema]: InferFormPropertyOptions & {} 22 | }; 23 | 24 | export const form = { 25 | get(prop: string, { type, multipleItems }: Options): (ctx: BaseContext) => Promise> { 26 | return $async(Function('n', `const p=(f)=>${type === 'string' 27 | ? multipleItems === true 28 | ? `{const v=f.getAll(${JSON.stringify(prop)});return v.every((x)=>typeof x==='string')?v:null;}` 29 | : `{const v=f.get(${JSON.stringify(prop)});return typeof v==='string'?v:null;}` 30 | : type === 'number' 31 | ? multipleItems === true 32 | ? `{const v=f.getAll(${JSON.stringify(prop)}).map((t)=>+t);return v.some(Number.isNaN)?v:null;}` 33 | : `{return +f.get(${JSON.stringify(prop)});}` 34 | : type === 'file' 35 | ? multipleItems === true 36 | ? `{const v=f.getAll(${JSON.stringify(prop)});return v.every((x)=>x instanceof File)?v:null;}` 37 | : `{const v=f.get(${JSON.stringify(prop)});return v instanceof File?v:null;}` 38 | : `f.has(${JSON.stringify(prop)})` 39 | };return (c)=>c.req.formData().then(p).catch(n);`)(noop)); 40 | }, 41 | 42 | schema(schema: Schema): (ctx: BaseContext) => Promise | null> { 43 | const parts: string[] = ['']; const sets = []; 44 | 45 | for (const key in schema) { 46 | const item = schema[key]; 47 | const { type } = item; 48 | 49 | if (type === 'string') { 50 | parts.push(item.multipleItems === true 51 | ? `const ${key}=f.getAll(${JSON.stringify(key)});if(${key}.some((x)=>typeof x!=='string'))return null;` 52 | : `const ${key}=f.get(${JSON.stringify(key)});if(typeof ${key}!=='string')return null;`); 53 | sets.push(key); 54 | } else if (type === 'number') { 55 | parts.push(item.multipleItems === true 56 | ? `const ${key}=f.getAll(${JSON.stringify(key)}).map((t)=>+t);if(${key}.some(Number.isNaN))return null;` 57 | : `const ${key}=+f.get(${JSON.stringify(key)});if(Number.isNaN(${key}))return null;`); 58 | sets.push(key); 59 | } else if (type === 'file') { 60 | parts.push(item.multipleItems === true 61 | ? `const ${key}=f.getAll(${JSON.stringify(key)});if(${key}.some((x)=>!(x instanceof File)))return null;` 62 | : `const ${key}=+f.get(${JSON.stringify(key)});if(!(${key} instanceof File))return null;`); 63 | } else 64 | sets.push(`${key}:f.has(${JSON.stringify(key)})`); 65 | } 66 | 67 | return $async(Function('n', `const p=(f)=>{${parts.join('')}return {${sets}};};return (c)=>c.req.formData().then(p).catch(n);`)(noop)); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /src/plugins/server/query.ts: -------------------------------------------------------------------------------- 1 | import type { BaseContext } from '../../core/server'; 2 | import decodeURIComponent from './decodeURI'; 3 | 4 | export type QuerySchemaTypes = 'string' | 'number' | 'bool'; 5 | 6 | interface TypeMap { 7 | string: string | null; 8 | number: number; 9 | bool: boolean; 10 | } 11 | 12 | export interface QuerySchema extends Record { } 13 | export type InferQuerySchema = { [K in keyof T]: InferQueryPropertyOptions & {} }; 14 | 15 | // Query property options 16 | export interface QueryPropertyOptions { 17 | type: QuerySchemaTypes; 18 | maxItems?: number; 19 | } 20 | 21 | export interface DefaultQueryPropertyOptions extends QueryPropertyOptions { 22 | type: 'string'; 23 | } 24 | 25 | 26 | export type InferQueryPropertyOptions = 27 | undefined extends T['maxItems'] ? TypeMap[T['type']] & {} 28 | : T['maxItems'] extends 0 ? null 29 | : T['maxItems'] extends 1 ? TypeMap[T['type']] & {} 30 | : (TypeMap[T['type']] & {})[]; 31 | 32 | const defaultOptions: DefaultQueryPropertyOptions = { type: 'string' }; 33 | 34 | // Namespace 35 | export const query = { 36 | /** 37 | * Whether query parsers should try to decode value 38 | */ 39 | decodeValue: true, 40 | 41 | /** 42 | * Get values of a key from the query 43 | */ 44 | get(name: string, { type, maxItems }: Options = defaultOptions as Options): (ctx: BaseContext) => InferQueryPropertyOptions { 45 | if (type === 'bool') { 46 | // '"key"' 47 | const search = JSON.stringify(encodeURIComponent(name)); 48 | const searchLen = search.length - 2; 49 | 50 | // Search for the key 51 | return Function(`return ({pathEnd,req:{url}})=>{const i=url.indexOf(${search},pathEnd+1);return i!==-1&&(i===pathEnd+1||url.charCodeAt(i-1)===38)&&(i+${searchLen}===url.length||url.charCodeAt(i+${searchLen})===38);}`)(); 52 | } 53 | 54 | // '"key="' 55 | const search = JSON.stringify(`${encodeURIComponent(name)}=`); 56 | const searchLen = search.length - 2; 57 | 58 | if (type === 'string') { 59 | const { decodeValue } = this; 60 | 61 | return typeof maxItems === 'undefined' || maxItems < 2 62 | ? Function('d', `return ({pathEnd,req:{url}})=>{const i=url.indexOf(${search},pathEnd+1)+${searchLen};if(i===${searchLen - 1})return null;const n=url.indexOf("&",i);return ${decodeValue ? 'd(url,i,n===-1?url.length:n)' : 'n===-1?url.substring(i):url.substring(i,n)'};}`)(this.decode) 63 | : Function('d', `return ({pathEnd,req:{url}})=>{let i=url.indexOf(${search},pathEnd+1)+${searchLen};if(i===${searchLen - 1})return [];const r=[];${decodeValue ? 'const {length}=url;' : ''}let l=0;do{const n=url.indexOf("&",i);if(n===-1){r.push(${decodeValue ? 'd(url,i,length)' : 'url.substring(i)'});return r;}r.push(${decodeValue ? 'd(url,i,n)' : 'url.substring(i,n)'});if(l===${maxItems - 1})return r;i=url.indexOf(${search},n+1)+${searchLen};++l;}while(i!==${searchLen - 1});return r;}`)(this.decode); 64 | } 65 | 66 | return typeof maxItems === 'undefined' || maxItems < 2 67 | ? Function(`return ({pathEnd,req:{url}})=>{const i=url.indexOf(${search},pathEnd+1)+${searchLen};if(i===${searchLen - 1})return Number.NaN;const n=url.indexOf("&",i);return n===-1?+url.substring(i):+url.substring(i,n);}`)() 68 | : Function(`return ({pathEnd,req:{url}})=>{let i=url.indexOf(${search},pathEnd+1)+${searchLen};if(i===${searchLen - 1})return [];const r=[];let l=0;do{const n=url.indexOf("&",i);if(n===-1){const v=+url.substring(i);if(!Number.isNaN(v))r.push(v);return r;}const v=+url.substring(i,n);if(!Number.isNaN(v)){r.push(v);if(l===${maxItems - 1})return r;++l}i=url.indexOf(${search},n+1)+${searchLen};}while(i!==${searchLen - 1});return r;}`)(); 69 | }, 70 | 71 | /** 72 | * Parse multiple keys 73 | */ 74 | schema(schema: Schema): (ctx: BaseContext) => InferQuerySchema | null { 75 | const { decodeValue } = this; 76 | 77 | const idxChecks = ['++pathEnd;const {length}=url;']; const valueChecks = []; const idxs = []; const objParts = []; 78 | let idx = 0; 79 | 80 | for (const key in schema) { 81 | const { type, maxItems } = schema[key]; 82 | 83 | if (type === 'bool') { 84 | // '"key="' 85 | const search = JSON.stringify(encodeURIComponent(key)); 86 | const searchLen = search.length - 2; 87 | 88 | idxs.push(`const i${idx}=url.indexOf(${search},pathEnd);`); 89 | objParts.push(`${key}:i${idx}!==-1&&(i${idx}===pathEnd||url.charCodeAt(i${idx}-1)===38)&&(i${idx}+${searchLen}===length||url.charCodeAt(i${idx}+${searchLen})===38)`); 90 | } else { 91 | // '"key="' 92 | const search = JSON.stringify(`${encodeURIComponent(key)}=`); 93 | const searchLen = search.length - 2; 94 | 95 | if (type === 'string') { 96 | if (typeof maxItems === 'undefined' || maxItems < 2) { 97 | idxChecks.push(`const s${idx}=url.indexOf(${search},pathEnd)+${searchLen};if(s${idx}===${searchLen - 1})return null;`); 98 | idxs.push(`const i${idx}=url.indexOf("&",s${idx});`); 99 | objParts.push(`${key}:${decodeValue ? `d(url,s${idx},i${idx}===-1?length:i${idx})` : `i${idx}===-1?url.substring(s${idx}):url.substring(s${idx},i${idx})`}`); 100 | } else { 101 | idxs.push(`const ${key}=[];let l${idx}=0;let i${idx}=url.indexOf(${search},pathEnd)+${searchLen};while(i${idx}!==${searchLen - 1}){const n=url.indexOf("&",i${idx});if(n===-1){${key}.push(${decodeValue ? `d(url,i${idx},length)` : `url.substring(i${idx})`});break;}${key}.push(${decodeValue ? `d(url,i${idx},n)` : `url.substring(i${idx},n)`});if(l${idx}===${maxItems - 1})break;i${idx}=url.indexOf(${search},n+1)+${searchLen};++l${idx};}`); 102 | objParts.push(key); 103 | } 104 | } else if (typeof maxItems === 'undefined' || maxItems < 2) { 105 | idxChecks.push(`const s${idx}=url.indexOf(${search},pathEnd)+${searchLen};if(s${idx}===${searchLen - 1})return null;`); 106 | valueChecks.push(`const i${idx}=url.indexOf("&",s${idx});const ${key}=i${idx}===-1?+url.substring(s${idx}):+url.substring(s${idx},i${idx});if(Number.isNaN(${key}))return null;`); 107 | objParts.push(key); 108 | } else { 109 | idxs.push(`const ${key}=[];let l${idx}=0;let i${idx}=url.indexOf(${search},pathEnd)+${searchLen};while(i${idx}!==${searchLen - 1}){const n=url.indexOf("&",i${idx});if(n===-1){const v=+url.substring(i${idx});if(!Number.isNaN(v))${key}.push(v);break;}const v=+url.substring(i${idx},n);if(!Number.isNaN(v)){${key}.push(v);if(l${idx}===${maxItems - 1})break;}i${idx}=url.indexOf(${search},n+1)+${searchLen};++l${idx};}`); 110 | objParts.push(key); 111 | } 112 | } 113 | 114 | ++idx; 115 | } 116 | 117 | return Function('d', `return ({pathEnd,req:{url}})=>{${idxChecks.join('')}${valueChecks.join('')}${idxs.join('')}return {${objParts.join()}};}`)(this.decode); 118 | }, 119 | 120 | /** 121 | * Try decode URI component. Fallback to the passed value if parsing failed 122 | */ 123 | decode: decodeURIComponent 124 | }; 125 | -------------------------------------------------------------------------------- /src/utils/defaultOptions.ts: -------------------------------------------------------------------------------- 1 | import type { Fn } from '../core'; 2 | 3 | export const emptyObj = {} as const; 4 | export const emptyList = []; 5 | 6 | export const default404res = new Response(null, { status: 404 }); 7 | export const default403res = new Response(null, { status: 403 }); 8 | export const default404: Fn = () => default404res; 9 | 10 | export const noop = () => null; 11 | -------------------------------------------------------------------------------- /tests/app.ts: -------------------------------------------------------------------------------- 1 | // Server 2 | import { Byte, cors, csrf, send } from '@bit-js/byte'; 3 | 4 | // Basic responses 5 | export const basicApis = new Byte() 6 | .get('/', send.body('Hi')) 7 | .get('/:id', (ctx) => ctx.body(ctx.params.id)); 8 | 9 | // Parse & send JSON 10 | export const jsonApis = new Byte() 11 | .post('/json', async (ctx) => ctx.json(await ctx.req.json())); 12 | 13 | // CORS 14 | export const apiWithCors = new Byte() 15 | .prepare(cors({ allowMethods: 'GET' })) 16 | .get('/', (ctx) => ctx.body('Hi')); 17 | 18 | // CSRF protection 19 | export const apiWithCsrf = new Byte() 20 | .use(csrf()) 21 | .get('/', send.body('Hi')); 22 | 23 | // Defers 24 | export const apiWithDefers = new Byte() 25 | .prepare((ctx) => console.time(ctx.path)) 26 | .defer((res, ctx) => { 27 | // You should change the response here 28 | console.log(res.ok); 29 | console.timeEnd(ctx.path); 30 | }) 31 | .get('/', send.body('Hi')); 32 | 33 | // Set props 34 | export const apiWithSet = new Byte() 35 | .set('startTime', performance.now) 36 | .get('/', (ctx) => ctx.body(performance.now() - ctx.startTime + '')); 37 | 38 | // Plugin test 39 | const plugin = Byte.plugin({ plug: (app) => app.set('hi', () => 'there') }); 40 | 41 | export const apiWithPlugin = new Byte() 42 | .register(plugin) 43 | .get('/', (ctx) => ctx.body(ctx.hi)); 44 | -------------------------------------------------------------------------------- /tests/bun/cors.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'bun:test'; 2 | import { apiWithCors } from '@app'; 3 | 4 | const client = apiWithCors.client(); 5 | 6 | test('CORS', async () => { 7 | const res = await client.get('/'); 8 | 9 | expect(await res.text()).toBe('Hi'); 10 | 11 | // CORS headers checking 12 | expect(res.headers.get('Access-Control-Allow-Methods')).toBe('GET'); 13 | expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/bun/csrf.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'bun:test'; 2 | import { apiWithCsrf } from '@app'; 3 | 4 | const client = apiWithCsrf.client(); 5 | 6 | test('CSRF', async () => { 7 | const res = await client.get('/'); 8 | 9 | expect(res.status).toBe(403); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/bun/defers.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, spyOn } from 'bun:test'; 2 | import { apiWithDefers } from '@app'; 3 | 4 | const client = apiWithDefers.client(); 5 | 6 | test('Defers', async () => { 7 | const timeSpy = spyOn(console, 'timeEnd'); 8 | 9 | const res = await client.get('/'); 10 | expect(await res.text()).toBe('Hi'); 11 | 12 | // CORS headers checking 13 | expect(timeSpy).toHaveBeenCalledWith('/'); 14 | }); 15 | 16 | -------------------------------------------------------------------------------- /tests/bun/fetch.spec.ts: -------------------------------------------------------------------------------- 1 | import { basicApis } from '@app'; 2 | import { test, expect } from 'bun:test'; 3 | 4 | test('Fetch', () => { 5 | expect(basicApis.fetch.toString()).toBe(basicApis.fetch.toString()); 6 | expect(() => basicApis.fetch(new Request('http://0.0.0.0/'))).not.toThrow(); 7 | }); 8 | -------------------------------------------------------------------------------- /tests/bun/index.spec.ts: -------------------------------------------------------------------------------- 1 | // Client 2 | import { basicApis } from '@app'; 3 | import { test, expect } from 'bun:test'; 4 | 5 | const client = basicApis.client(); 6 | 7 | // Main testing 8 | test('Root', async () => { 9 | const res = await client.get('/'); 10 | expect(await res.text()).toBe('Hi'); 11 | }); 12 | 13 | test('Parameter', async () => { 14 | const res = await client.get('/:id', { 15 | params: { id: 90 } 16 | }); 17 | expect(await res.text()).toBe('90'); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/bun/set.spec.ts: -------------------------------------------------------------------------------- 1 | import { apiWithSet } from '@app'; 2 | import { test, expect } from 'bun:test'; 3 | 4 | const client = apiWithSet.client(); 5 | 6 | test('Set', async () => { 7 | const res = await client.get('/'); 8 | expect(+await res.text()).not.toBeNaN(); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/bun/validator.spec.ts: -------------------------------------------------------------------------------- 1 | // Client 2 | import { jsonApis } from '@app'; 3 | import { test, expect } from 'bun:test'; 4 | 5 | const client = jsonApis.client(); 6 | 7 | test('JSON', async () => { 8 | const body = { message: 'Hi' }; 9 | 10 | const res = await client.post('/json', { body }); 11 | expect(await res.json()).toEqual(body); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext", 5 | "DOM" 6 | ], 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "jsx": "react-jsx", 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | /* Linting */ 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "strictNullChecks": true, 19 | "strictBindCallApply": true, 20 | "strictFunctionTypes": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "paths": { 24 | "@bit-js/byte": [ 25 | ".." 26 | ], 27 | "@app": [ 28 | "./app" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "." 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tests/utils/form.spec.ts: -------------------------------------------------------------------------------- 1 | import { form, Context } from '@bit-js/byte'; 2 | import { expect, test } from 'bun:test'; 3 | 4 | function context(obj: Record) { 5 | const body = new FormData(); 6 | 7 | for (const key in obj) { 8 | const value = obj[key]; 9 | 10 | if (Array.isArray(value)) { 11 | for (let i = 0, { length } = value; i < length; ++i) { 12 | const item = value[i]; 13 | 14 | body.append(key, typeof item === 'string' || item instanceof File ? item : item + ''); 15 | } 16 | } else if (typeof value === 'boolean') { 17 | if (value) 18 | body.append(key, ''); 19 | } else body.append(key, typeof value === 'string' || value instanceof File ? value : value + ''); 20 | } 21 | 22 | return new Context(new Request('http://localhost:3000', { 23 | method: 'POST', body 24 | })) 25 | } 26 | 27 | test('Form getters', async () => { 28 | const parseStr = form.get('name', { type: 'string' }); 29 | expect(await parseStr(context({ name: 'a' }))).toBe('a'); 30 | expect(await parseStr(context({ age: 16 }))).toBe(null); 31 | 32 | const parseNum = form.get('id', { type: 'number' }); 33 | expect(await parseNum(context({ id: 0 }))).toBe(0); 34 | expect(await parseNum(context({ id: 'str' }))).toBe(NaN); 35 | 36 | 37 | const parseBool = form.get('darkMode', { type: 'bool' }); 38 | expect(await parseBool(context({ darkMode: '' }))).toBe(true); 39 | expect(await parseBool(context({ other: '' }))).toBe(false); 40 | }); 41 | 42 | test('Form schema', async () => { 43 | const parseForm = form.schema({ 44 | name: { type: 'string' }, 45 | age: { type: 'number' }, 46 | darkMode: { type: 'bool' }, 47 | ids: { type: 'number', multipleItems: true } 48 | }); 49 | 50 | const o1 = { 51 | name: 'dave', 52 | age: 18, 53 | darkMode: true, 54 | ids: [5, 6] 55 | }; 56 | expect(await parseForm(context(o1))).toEqual(o1); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/utils/query.spec.ts: -------------------------------------------------------------------------------- 1 | import { query, Context, stringifyQuery } from '@bit-js/byte'; 2 | import { expect, test } from 'bun:test'; 3 | 4 | const q = { 5 | name: 'Item', 6 | id: 1, 7 | category: ['a', 'b', 'c'], 8 | rate: [4, 5, 6], 9 | darkMode: true 10 | }; 11 | 12 | const ctx = new Context(new Request('http://localhost:3000/' + stringifyQuery(q))); 13 | 14 | test('Query getters', () => { 15 | const getName = query.get('name'); 16 | expect(getName(ctx)).toBe(q.name); 17 | 18 | const getCats = query.get('category', { 19 | type: 'string', 20 | maxItems: 10 21 | }); 22 | expect(getCats(ctx)).toEqual(q.category); 23 | 24 | const getRates = query.get('rate', { 25 | type: 'number', 26 | maxItems: 10 27 | }); 28 | expect(getRates(ctx)).toEqual(q.rate); 29 | 30 | const getID = query.get('id', { type: 'number' }); 31 | expect(getID(ctx)).toBe(q.id); 32 | 33 | const isDarkMode = query.get('darkMode', { type: 'bool' }); 34 | expect(isDarkMode(ctx)).toBe(q.darkMode); 35 | }); 36 | 37 | test('Query schema', () => { 38 | const parse = query.schema({ 39 | name: { type: 'string' }, 40 | id: { type: 'number' }, 41 | darkMode: { type: 'bool' }, 42 | category: { 43 | type: 'string', 44 | maxItems: 10 45 | }, 46 | rate: { 47 | type: 'number', 48 | maxItems: 10 49 | } 50 | }); 51 | expect(q).toMatchObject(parse(ctx)!); 52 | }); 53 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext", 5 | "DOM" 6 | ], 7 | "target": "ESNext", 8 | "moduleDetection": "force", 9 | "jsx": "react-jsx", 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | /* Linting */ 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "strictNullChecks": true, 18 | "strictBindCallApply": true, 19 | "strictFunctionTypes": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "forceConsistentCasingInFileNames": true, 22 | /* Emit declarations */ 23 | "declaration": true, 24 | "stripInternal": true, 25 | "declarationDir": "types", 26 | "emitDeclarationOnly": true 27 | }, 28 | "include": [ 29 | "./src" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------