├── .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 |
--------------------------------------------------------------------------------