├── .github ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── releases │ └── yarn-4.5.0.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── apps └── docs │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── src │ ├── pages │ │ ├── _app.tsx │ │ ├── _meta.ts │ │ ├── documentation │ │ │ ├── _meta.ts │ │ │ ├── client.mdx │ │ │ ├── context.mdx │ │ │ ├── error-handling.mdx │ │ │ ├── handler.mdx │ │ │ ├── routers.mdx │ │ │ ├── routes.mdx │ │ │ └── streaming.mdx │ │ ├── getting-started.mdx │ │ ├── help.mdx │ │ ├── index.mdx │ │ ├── recipes │ │ │ ├── _meta.ts │ │ │ ├── cookies.mdx │ │ │ ├── cors.mdx │ │ │ ├── getting-clients-ip.mdx │ │ │ └── stripe-webhook.mdx │ │ └── runtimes.mdx │ └── theme.config.tsx │ └── tsconfig.json ├── bun.lockb ├── constraints.pro ├── doc.png ├── examples ├── ai │ ├── .env.example │ ├── demo.cjs │ ├── package.json │ ├── src │ │ ├── ai.ts │ │ ├── client.ts │ │ ├── context.ts │ │ └── index.ts │ └── tsconfig.json ├── basic │ ├── README.md │ ├── package.json │ ├── src │ │ ├── context.ts │ │ ├── data.ts │ │ └── index.ts │ └── tsconfig.json ├── bench │ ├── package.json │ ├── src │ │ ├── context.ts │ │ └── index.ts │ └── tsconfig.json ├── client │ ├── README.md │ ├── package.json │ ├── src │ │ ├── basic.ts │ │ └── index.ts │ └── tsconfig.json └── deno │ ├── package.json │ ├── src │ ├── context.ts │ └── index.ts │ └── tsconfig.json ├── package.json ├── packages ├── client │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── router.test.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── core │ ├── README.md │ ├── package.json │ ├── src │ │ ├── cors │ │ │ ├── cors.test.ts │ │ │ └── cors.ts │ │ ├── error.ts │ │ ├── handler.ts │ │ ├── head.ts │ │ ├── index.ts │ │ ├── request.ts │ │ ├── route.ts │ │ ├── router │ │ │ ├── router.test.ts │ │ │ ├── router.ts │ │ │ └── types.ts │ │ ├── stream │ │ │ └── stream.ts │ │ └── util.ts │ ├── test │ │ └── big-router │ │ │ ├── big-router.ts │ │ │ ├── bigger-router.ts │ │ │ ├── mount-me.ts │ │ │ └── router.ts │ ├── tsconfig.json │ └── tsup.config.ts └── uws │ ├── README.md │ ├── bench.ts │ ├── package.json │ ├── src │ ├── index.ts │ └── uws.test.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── tsconfig.json ├── tsconfig.options.json ├── tsup.config.ts └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [alii] 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '39 12 * * 6' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | language: ['javascript'] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v1 27 | with: 28 | languages: ${{ matrix.language }} 29 | 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@v1 32 | 33 | - name: Perform CodeQL Analysis 34 | uses: github/codeql-action/analyze@v1 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check: 10 | name: test and typecheck 11 | 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | node: ['20.x', '22.x', '23.x', '24.x'] 16 | os: [ubuntu-latest, windows-latest] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Install Node ${{ matrix.node }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node }} 26 | 27 | - name: Install dependencies 28 | run: yarn 29 | 30 | - name: Build 31 | run: yarn build 32 | 33 | - name: Run tests 34 | run: yarn test-all 35 | 36 | - name: Run tsc 37 | run: yarn tsc -build 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | desktop.ini 3 | .DS_Store 4 | .idea 5 | compile 6 | dist 7 | *.log 8 | .yarn/* 9 | !.yarn/releases 10 | !.yarn/plugins 11 | !.yarn/sdks 12 | 13 | # Environment variables 14 | .env 15 | .env.local 16 | .env.*.local 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | .yarn 3 | build/ 4 | compile/ 5 | dist/ 6 | **/llhttp/base64.ts 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "arrowParens": "avoid", 4 | "quoteProps": "as-needed", 5 | "printWidth": 120, 6 | "bracketSpacing": false, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | yarnPath: .yarn/releases/yarn-4.5.0.cjs 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2021 Alistair Smith. https://alistair.cloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot](./doc.png) 2 | 3 | # `kaito-http` 4 | 5 | #### Functional HTTP Framework for TypeScript 6 | 7 | View the [documentation here](https://http.kaito.cloud) 8 | 9 | #### Credits 10 | 11 | - [Alistair Smith](https://twitter.com/alistaiir) 12 | -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /apps/docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import nextra from 'nextra'; 4 | 5 | const withNextra = nextra({ 6 | theme: 'nextra-theme-docs', 7 | themeConfig: './src/theme.config.tsx', 8 | }); 9 | 10 | export default withNextra({ 11 | reactStrictMode: true, 12 | }); 13 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http/docs", 3 | "private": true, 4 | "version": "3.2.1", 5 | "author": "Alistair Smith ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "es-urlcat": "^1.0.0", 9 | "next": "^15.1.4", 10 | "nextra": "3.3.1", 11 | "nextra-theme-docs": "3.3.1", 12 | "pathcat": "^1.4.0", 13 | "react": "^19.0.0", 14 | "react-dom": "^19.0.0" 15 | }, 16 | "scripts": { 17 | "dev": "next", 18 | "start": "next start", 19 | "build": "next build" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^22.10.5", 23 | "@types/react": "^19.0.5", 24 | "@types/react-dom": "^19.0.3", 25 | "typescript": "^5.7.3" 26 | }, 27 | "description": "Functional HTTP Framework for TypeScript", 28 | "homepage": "https://github.com/kaito-http/kaito", 29 | "keywords": [ 30 | "typescript", 31 | "http", 32 | "framework" 33 | ], 34 | "repository": "https://github.com/kaito-http/kaito" 35 | } 36 | -------------------------------------------------------------------------------- /apps/docs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type {AppProps} from 'next/app'; 2 | 3 | export default function App({Component, pageProps}: AppProps) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/docs/src/pages/_meta.ts: -------------------------------------------------------------------------------- 1 | import type {Meta} from 'nextra'; 2 | 3 | const meta: Meta = { 4 | index: 'Introduction', 5 | 'getting-started': 'Getting Started', 6 | runtimes: 'Runtimes', 7 | help: 'Help', 8 | documentation: { 9 | type: 'separator', 10 | title: 'Documentation', 11 | }, 12 | recipes: { 13 | type: 'separator', 14 | title: 'Recipes', 15 | }, 16 | }; 17 | 18 | export default meta; 19 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/_meta.ts: -------------------------------------------------------------------------------- 1 | import type {Meta} from 'nextra'; 2 | 3 | const meta: Meta = { 4 | context: 'Context', 5 | routes: 'Routes', 6 | routers: 'Routers', 7 | handler: 'Handler', 8 | client: 'Client', 9 | streaming: 'Streaming', 10 | 'error-handling': 'Error Handling', 11 | }; 12 | 13 | export default meta; 14 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/client.mdx: -------------------------------------------------------------------------------- 1 | import {Callout} from 'nextra/components'; 2 | 3 | 4 | The Kaito client is a recent addition to the Kaito ecosystem. It's still in the early stages of development. While we 5 | think it's definitely stable, there may be missing features or unexpected behaviours. 6 | 7 | 8 | # Client 9 | 10 | Kaito provides a strongly-typed HTTP client that seamlessly integrates with your Kaito server. The client supports all HTTP methods, streaming responses, and Server-Sent Events (SSE) out of the box. 11 | 12 | To ensure compatibility, always use matching versions of the client and server packages, as they are released together. 13 | 14 | ```bash 15 | bun i @kaito-http/client 16 | ``` 17 | 18 | ## Basic Usage 19 | 20 | Create a client instance by providing your API's type and base URL: 21 | 22 | ```ts filename="api/index.ts" 23 | const app = router().merge('/v1', v1); 24 | 25 | const handler = createKaitoHTTPHandler({ 26 | router: app, 27 | // ... 28 | }); 29 | 30 | export type App = typeof app; 31 | ``` 32 | 33 | ```ts filename="client/index.ts" /type {App}/ // 34 | import {createKaitoHTTPClient} from '@kaito-http/client'; 35 | import type {App} from '../api/index.ts'; // Use `import type` to avoid runtime overhead 36 | 37 | const api = createKaitoHTTPClient({ 38 | base: 'http://localhost:3000', 39 | }); 40 | ``` 41 | 42 | ## Making Requests 43 | 44 | ### Normal Requests 45 | 46 | The Kaito client ensures type safety across your entire API. It automatically: 47 | 48 | - Validates input data (query parameters, path parameters, and request body) 49 | - Constructs the correct URL 50 | - Provides proper TypeScript types for the response 51 | 52 | ```ts 53 | // `user` will be fully typed based on your route definition 54 | const user = await api.get('/v1/users/:id', { 55 | params: { 56 | id: '123', 57 | }, 58 | }); 59 | 60 | console.log(user); 61 | 62 | await api.post('/v1/users/@me', { 63 | body: { 64 | name: 'John Doe', // Body schema is enforced by TypeScript 65 | }, 66 | }); 67 | ``` 68 | 69 | ### Non-JSON Responses 70 | 71 | For endpoints that return a `Response` instance, you must pass `response: true` to the request options. This is enforced for you at a compile time type level, so you 72 | can't accidentally forget to pass it. The option is needed so the runtime JavaScript doesn't assume the response is JSON. 73 | 74 | ```ts 75 | const response = await api.get('/v1/response/', { 76 | response: true, 77 | }); 78 | 79 | const text = await response.text(); // or you could use .arrayBuffer() or .blob(), etc 80 | ``` 81 | 82 | ### Server-Sent Events (SSE) 83 | 84 | The client provides built-in support for SSE streams. You can iterate over the events using a `for await...of` loop: 85 | 86 | ```ts 87 | // GET request with SSE 88 | const stream = await api.get('/v1/sse_stream', { 89 | sse: true, // sse: true is enforced at a compile time type level 90 | query: { 91 | content: 'Your streaming content', 92 | }, 93 | }); 94 | 95 | for await (const event of stream) { 96 | console.log('event', event.data); 97 | } 98 | 99 | // POST request with SSE 100 | const postStream = await api.post('/v1/sse_stream', { 101 | sse: true, 102 | body: { 103 | count: 20, 104 | }, 105 | }); 106 | 107 | for await (const event of postStream) { 108 | // Handle different event types 109 | switch (event.event) { 110 | case 'numbers': 111 | console.log(event.data.digits); // TypeScript knows this is a number 112 | break; 113 | case 'data': 114 | console.log(event.data.obj); // TypeScript knows this is an object 115 | break; 116 | case 'text': 117 | console.log(event.data.text); // TypeScript knows this is a string 118 | break; 119 | } 120 | } 121 | ``` 122 | 123 | ## Cancelling Requests 124 | 125 | You can use an `AbortSignal` to cancel a request 126 | 127 | ```ts 128 | // Cancel requests using AbortSignal 129 | const controller = new AbortController(); 130 | const user = await api.get('/v1/users/:id', { 131 | params: {id: '123'}, 132 | signal: controller.signal, 133 | }); 134 | ``` 135 | 136 | ## Error Handling 137 | 138 | When a route throws an error, the client throws a `KaitoClientHTTPError` with detailed information about what went wrong: 139 | 140 | - `.request`: The original `Request` object 141 | - `.response`: The `Response` object containing status code and headers 142 | - `.body`: The error response with this structure: 143 | ```ts 144 | { 145 | success: false, 146 | message: string, 147 | // Additional error details may be included 148 | } 149 | ``` 150 | 151 | Here's how to handle errors effectively: 152 | 153 | ```ts 154 | import {isKaitoClientHTTPError} from '@kaito-http/client'; 155 | 156 | try { 157 | const response = await api.get('/v1/this-will-throw'); 158 | } catch (error: unknown) { 159 | if (isKaitoClientHTTPError(error)) { 160 | console.log('Error message:', error.message); 161 | console.log('Status code:', error.response.status); 162 | console.log('Error details:', error.body); 163 | } 164 | } 165 | ``` 166 | 167 | ## Customizing Request Behavior 168 | 169 | The client provides two powerful options for customizing how requests are made: `fetch` and `before`. 170 | 171 | ### Request Preprocessing 172 | 173 | The `before` option lets you modify requests before they are sent. This is perfect for: 174 | 175 | - Adding authentication headers 176 | - Setting up request tracking 177 | - Modifying request parameters 178 | 179 | ```ts 180 | const api = createKaitoHTTPClient({ 181 | base: 'http://localhost:3000', 182 | before: async (url, init) => { 183 | // Set credentials 184 | const request = new Request(url, { 185 | ...init, 186 | credentials: 'include', 187 | }); 188 | 189 | // Add authentication 190 | request.headers.set('Authorization', `Bearer ${getToken()}`); 191 | 192 | // Add tracking headers 193 | request.headers.set('X-Request-ID', generateRequestId()); 194 | 195 | return request; 196 | }, 197 | }); 198 | ``` 199 | 200 | You can combine both options for maximum flexibility: 201 | 202 | ```ts 203 | const api = createKaitoHTTPClient({ 204 | base: 'http://localhost:3000', 205 | before: async (url, init) => { 206 | const request = new Request(url, init); 207 | request.headers.set('Authorization', `Bearer ${getToken()}`); 208 | return request; 209 | }, 210 | fetch: async request => { 211 | const response = await fetch(request); 212 | // Add response processing here 213 | return response; 214 | }, 215 | }); 216 | ``` 217 | 218 | ### Custom Fetch Implementation 219 | 220 | You can provide a custom `fetch` implementation to override the default global `fetch`. This is useful when you need to: 221 | 222 | - Use a different fetch implementation 223 | - Add global request interceptors 224 | - Modify how requests are made 225 | 226 | ```ts 227 | const api = createKaitoHTTPClient({ 228 | base: 'http://localhost:3000', 229 | fetch: async request => { 230 | // Use a custom fetch implementation 231 | return await customFetch(request); 232 | 233 | // Or modify the response if you really want to 234 | const response = await fetch(request); 235 | return new Response(response.body, { 236 | status: response.status, 237 | headers: { 238 | ...Object.fromEntries(response.headers), 239 | 'X-Custom-Header': 'value', 240 | }, 241 | }); 242 | }, 243 | }); 244 | ``` 245 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/context.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Context 3 | description: Context is a way to pass common data to all routes 4 | --- 5 | 6 | import {Callout} from 'nextra/components'; 7 | 8 | # Context 9 | 10 | In Kaito, context is information shared to every single route. It's provided at the root of your application and is generated on every single request. 11 | 12 | Below is an example of things that you could include, but it's really up to you and what you would find useful to include in your app. 13 | 14 | In the documentation about routers, you'll learn about how routers can create their own context for enabling powerful separation of concerns. 15 | 16 | 17 | Passing the request/response objects to context is ok, but we firmly recommend you read the 18 | [routes](/documentation/routes) documentation for information on the Request/Response model. 19 | 20 | 21 | ```ts filename="context.ts" 22 | import {createUtilities} from '@kaito-http/core'; 23 | import {db} from './db.ts'; 24 | 25 | // Use the `createUtilities` helper to also create a strongly typed `router` function 26 | export const {getContext, router} = createUtilities(async (req, res) => { 27 | return { 28 | req, 29 | res, 30 | time: new Date(), 31 | 32 | searchForUser: async (query: string) => { 33 | return await db.users.search(query); 34 | }, 35 | 36 | // This is just an example 37 | getSession: async () => { 38 | const cookies = req.headers.cookie; 39 | 40 | // this is bad code, use a cookie parser library! npmjs.com/package/cookie is a good one 41 | const token = cookies 42 | ?.split(';') 43 | .find(cookie => cookie.startsWith('token=')) 44 | .split('=')[1]; 45 | 46 | return await db.sessions.findByToken(token); 47 | }, 48 | }; 49 | }); 50 | ``` 51 | 52 | You can then use this context, when setup correctly with a router, inside every single route. E.g. 53 | 54 | ```ts filename="routes/users.ts" 55 | import {z} from 'zod'; 56 | import {router} from '../context.ts'; 57 | 58 | export const users = router().get('/users/search', { 59 | query: { 60 | search: z.string().max(200), 61 | }, 62 | 63 | async run({ctx, query}) { 64 | const users = await ctx.searchForUser(query.search); 65 | return users; 66 | }, 67 | }); 68 | ``` 69 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/error-handling.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Error Handling 3 | description: Learn how errors are handled 4 | --- 5 | 6 | import {Callout} from 'nextra/components'; 7 | 8 | # Error Handling 9 | 10 | Errors are handled very gracefully in Kaito. You can throw anywhere in any route, and it will be caught and handled. This allows you to focus on the happy-paths of your application, and know that errors are handled gracefully. The other advantage to this pattern is that you don't have to worry about error messages that could contain sensitive information, as everything passes through your onError handler. 11 | 12 | Kaito has a built in error called `KaitoError`. You can throw this error in your routes, and it will be caught and sent back to the client. 13 | 14 | 15 | Throwing a `KaitoError` will **not** call your `.onError` handler defined in your server. KaitoErrors are handled 16 | internally and are always sent back to the client. 17 | 18 | 19 | ```ts filename="routes/users.ts" {8} 20 | export const users = router().get('/:id', async ({ctx, params}) => { 21 | const user = await ctx.db.users.findOne({ 22 | where: {id: params.id}, 23 | }); 24 | 25 | if (!user) { 26 | // Client will receive this status code and error message always. This bypasses your .onError() handler 27 | throw new KaitoError(404, 'User not found'); 28 | } 29 | 30 | return user; 31 | }); 32 | ``` 33 | 34 | All other errors will be forwarded to your `.onError` handler, which dictates what status and message should be sent to the client. 35 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/handler.mdx: -------------------------------------------------------------------------------- 1 | # The handler 2 | 3 | The `createKaitoHandler` function is a very minimal part of Kaito, it simply wraps all your functions together and returns a `request -> response` function. 4 | 5 | ```ts 6 | import {createKaitoHandler} from '@kaito-http/core'; 7 | 8 | const handler = createKaitoHandler({ 9 | getContext, 10 | router, 11 | onError, 12 | }); 13 | 14 | Bun.serve({fetch: handler, port: 3000}); 15 | ``` 16 | 17 | ## `onError` 18 | 19 | In the example above, you can see I have included a property called `onError`. This is a function that is called whenever an error is thrown in the request lifecycle. This function should reply with an object that contains a `status` and `message`. These will be used to reply to the client. 20 | 21 | ```ts 22 | import {createKaitoHandler} from '@kaito-http/core'; 23 | import {ZodError} from 'zod'; 24 | 25 | const handler = createKaitoHandler({ 26 | onError: async ({error, req}) => { 27 | if (error instanceof ZodError) { 28 | return {status: 400, message: 'Invalid request'}; 29 | } 30 | 31 | return {status: 500, message: 'Internal Server Error'}; 32 | }, 33 | // ... 34 | }); 35 | ``` 36 | 37 | ## Before/Transform 38 | 39 | Kaito has a basic lifecycle for transform/intercepting requests and responses. The most common use case is to add CORS headers. 40 | 41 | - `.before()` runs before the router is handled. You can return early here to stop the request from being handled by the router. 42 | - `.transform()` runs on EVERY response. That includes ones from `.before()` and ones from the router. 43 | 44 | ```ts 45 | const ALLOWED_ORIGINS = ['http://localhost:3000', 'https://app.example.com']; 46 | 47 | const server = createKaitoHandler({ 48 | getContext, 49 | router, 50 | 51 | before: async req => { 52 | if (req.method === 'OPTIONS') { 53 | return new Response(null, {status: 204}); // Return early to skip the router. This response will be passed to `.transform()` 54 | } 55 | }, 56 | 57 | transform: async (request, response) => { 58 | const origin = request.headers.get('origin'); 59 | 60 | // Include CORS headers if the origin is allowed 61 | if (origin && ALLOWED_ORIGINS.includes(origin)) { 62 | response.headers.set('Access-Control-Allow-Origin', origin); 63 | response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 64 | response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 65 | response.headers.set('Access-Control-Max-Age', '86400'); 66 | response.headers.set('Access-Control-Allow-Credentials', 'true'); 67 | } 68 | }, 69 | }); 70 | ``` 71 | 72 | You can also return a response inside of `.transform()` as well, but we find that there are few use cases for this. Most of the time you'll only be mutating headers. 73 | 74 | This feature was called Before/After in Kaito v2. The functionality has changed a little bit, so migrating to v3 might require some changes. 75 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/routers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Routers 3 | description: Learn about Routers in Kaito, and how you can interface with them in your app 4 | --- 5 | 6 | import {Callout} from 'nextra-theme-docs'; 7 | 8 | # Routers 9 | 10 | Routers are a collection of routes and their metadata. They can be merged together to allow for separating logic into different files. 11 | 12 | - Routers are immutable, and all methods return a new router instance 13 | - You chain router methods: `router().post().get()` 14 | - Use `.through()` to change the context instance for all routes defined after the `.through()` 15 | 16 | ## Creating a Router 17 | 18 | When using `createUtilities`, you get a `router` function that you can use to create a new router. This can be called as many times as you like, and you can merge routers together with a prefix to create a larger router. 19 | 20 | ```ts {1,3} 21 | import {createUtilities} from '@kaito-http/core'; 22 | 23 | export const {getContext, router} = createUtilities(async (req, res) => { 24 | // ... 25 | }); 26 | 27 | const app = router().get(...); 28 | ``` 29 | 30 | And then you are safe to use the `router` function around your app, which will guarantee context type safety. 31 | 32 | ## Router Merging 33 | 34 | Routers can be merged, which brings one router's routes into another, with a prefix. This is incredibly useful for larger apps, for example when you have multiple versions of an API. 35 | 36 | ```ts {4} 37 | import {v1} from './routers/v1'; 38 | import {v2} from './routers/v1'; 39 | 40 | export const api = router().merge('/v1', v1).merge('/v2', v2); 41 | ``` 42 | 43 | You can expect all type information to be carried over as well as the route names and correct prefixes. 44 | 45 | ## `.through()` 46 | 47 | Kaito takes a different approach to traditional express.js style "middleware." This is mostly because of the inpredictable nature of such a pattern. Kaito offers a superior alternative, `.through()`. 48 | 49 | ### How to use `.through()` 50 | 51 | `.through()` accepts a function that is provided the current context, and should return the next context (learn more about context [here](/documentation/context)). This will swap out the context for all routes defined after the `.through()`. You can also throw any kind of errors inside the callback, and they will be caught and handled as you would expect. 52 | 53 | #### Examples 54 | 55 | Take the following snippet: 56 | 57 | ```ts 58 | const postsRouter = router().post('/', async ({ctx}) => { 59 | // Imagine we wanted to get the current user here. Right now this is not defined anywhere 60 | const user = ctx.user; 61 | 62 | // ... 63 | }); 64 | ``` 65 | 66 | One common reason to reach for `.through()` is to append specific properties to the context, and in our example that will be accessing the session. 67 | 68 | ```ts 69 | const postsRouter = router() 70 | .through(async ctx => { 71 | // Just an example. This getSession() method would be 72 | // defined in the root `getContext` function. Probably 73 | // would read the cookies and then resolve a session from a database. 74 | const session = await ctx.getSession(); 75 | 76 | if (!session) { 77 | throw new KaitoError(401, 'You are not logged in'); 78 | } 79 | 80 | return { 81 | ...ctx, 82 | user: session.user, 83 | }; 84 | }) 85 | .post('/', async ({ctx}) => { 86 | // ctx.user is now defined, and correctly typed! 87 | ctx.user; // => {id: string, name: string, ...} 88 | 89 | await ctx.db.posts.create(ctx.user.id); 90 | }); 91 | ``` 92 | 93 | ##### Multiple `.through()` calls 94 | 95 | You can call `.through()` multiple times, where each `.through()` will accept the result of the previous call. 96 | 97 | ```ts 98 | const usersRouter = router() 99 | .through(async ctx => { 100 | const session = await ctx.getSession(); 101 | 102 | if (!session) { 103 | throw new KaitoError(401, 'You are not logged in'); 104 | } 105 | 106 | return { 107 | ...ctx, 108 | user: session.user, 109 | }; 110 | }) 111 | .post('/posts', async ({ctx}) => { 112 | const post = await ctx.db.posts.create(ctx.user.id); 113 | return post; 114 | }) 115 | .through(async ctx => { 116 | // ctx.user is guaranteed to exist here, because of the previous `.through()` 117 | const checkIfUserIsAdmin = await checkIfUserIsAdmin(ctx.user); 118 | 119 | if (!checkIfUserIsAdmin) { 120 | throw new KaitoError(403, 'Forbidden'); 121 | } 122 | 123 | return { 124 | ...ctx, 125 | user: { 126 | ...ctx.user, 127 | isAdmin: true, 128 | }, 129 | }; 130 | }) 131 | .delete('/posts', async ({ctx, body, query, params}) => { 132 | ctx.user.isAdmin; // => true 133 | await deleteAllPosts(); 134 | }); 135 | ``` 136 | 137 | ## Composition 138 | 139 | A nice pattern that `.through()` enables is to export a router from another file that already has some 'through-logic' applied to it. This allows for extremely powerful composition of routers. 140 | 141 | ```ts filename="routers/authed.ts" 142 | export const authedRouter = router().through(async ctx => { 143 | const session = await ctx.getSession(); 144 | 145 | if (!session) { 146 | throw new KaitoError(401, 'You are not logged in'); 147 | } 148 | 149 | return { 150 | ...ctx, 151 | user: session.user, 152 | }; 153 | }); 154 | ``` 155 | 156 | ```ts filename="routes/posts.ts" 157 | import {authedRouter} from '../routers/authed.ts'; 158 | 159 | // Note: I am not calling calling authedRouter here. All router methods are immutable 160 | // so we can just import the router and use it as is, rather than instantiating it again 161 | // for the sake of some syntax 162 | export const postsRouter = authedRouter.post('/', async ({ctx}) => { 163 | // There is now NO .through() logic here, but we still 164 | // get access to a strongly typed `ctx.user` object! Incredible right? 165 | await ctx.db.posts.create(ctx.user.id); 166 | }); 167 | ``` 168 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/routes.mdx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import {Callout} from 'nextra/components'; 3 | 4 | # Routes 5 | 6 | Routes are the basic building blocks of Kaito. They represent a single HTTP route with a optional input schemas (body, query) and execution logic. 7 | 8 | ## Creating a Route 9 | 10 | Creating a route requires us to have a router already established. If you don't, please checkout the getting started guide. 11 | 12 | Here's an extremely basic example of a ping/pong route. 13 | 14 | ```ts 15 | const app = router().get('/ping', async () => 'pong'); 16 | ``` 17 | 18 | You must either return something JSON serializable, a `Response` object, or throw an error 19 | 20 | ## Request/Response model 21 | 22 | Understanding how Kaito handles requests and responses is crucial, so let's cover that first. 23 | 24 | For each incoming request, Kaito creates two important objects: 25 | 26 | 1. A `KaitoRequest` object - This is a thin wrapper around the standard `Request` object, providing a similar API 27 | 28 | 2. A `KaitoHead` object - This is a wrapper around a `Headers` object and a status code. It's used so the router knows what changes you might have made to the status code or headers. 29 | 30 | And then the router handles requests very similarly to the following: 31 | 32 | ```ts 33 | const kaitoRequest = new KaitoRequest(reqFromServer); // reqFromServer is a `Request` instance 34 | const kaitoHead = new KaitoHead(); 35 | 36 | const context = await getContext(kaitoRequest, kaitoHead); 37 | const result = await route(context); 38 | 39 | if (result instanceof Response) { 40 | return result; 41 | } 42 | 43 | // Create the final response object using the 44 | // headers and status code from the kaitoHead object 45 | const response = Response.json(result, { 46 | status: kaitoHead.status(), // status will always default to 200 47 | headers: kaitoHead.headers, 48 | }); 49 | 50 | return response; 51 | ``` 52 | 53 | So to summarise 54 | 55 | - Check if you returned a `Response` instance, and if so, return that 56 | - Otherwise get the headers and status code from the `KaitoHead` object 57 | - Automatically set `Content-Type: application/json` if you return a JSON-serializable value 58 | - Use these to build the final response 59 | 60 | Ultimately the important thing to understand here is that if you return a `Response` instance directly, Kaito will use that as-is and ignore any changes made to the `KaitoHead` object. This gives you full control when needed, but means you need to set all headers and status codes on the `Response` object itself. 61 | 62 | ## Input 63 | 64 | Routes can also take a query and body schema provided by Zod. Internally, Kaito wil validate all request bodies and query params so you can be absolutely certain you are processing the right data. 65 | 66 | Route query schemas should always take a string, or array of strings as the input. This is because query params are always strings. It is safe to transform them into other types, but you should always be able to handle a string. 67 | 68 | ```ts 69 | import {z} from 'zod'; 70 | 71 | const router = router().post('/echo', { 72 | query: { 73 | skip: z.string().transform(value => parseInt(value)), 74 | take: z.string().transform(value => parseInt(value)), 75 | }, 76 | body: z.number(), 77 | async run({body, query}) { 78 | // Echo it back 79 | return {body, query}; 80 | }, 81 | }); 82 | ``` 83 | 84 | Zod schemas can be of any shape or size, including objects, booleans, numbers and literals. For more reference, please read the Zod Documentation. 85 | -------------------------------------------------------------------------------- /apps/docs/src/pages/documentation/streaming.mdx: -------------------------------------------------------------------------------- 1 | # Streaming 2 | 3 | Kaito supports streaming out of the box we let you return a `Response` with a `ReadableStream` body, but we also have built-in utilities for doing server-sent events (SSE). 4 | 5 | ## SSE 6 | 7 | SSE is a technology that allows you to stream data to the client in realtime. It's a very simple protocol. You can [learn about it on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). 8 | 9 | Kaito has a built-in utility for doing SSE, called `sse`. This function returns a `Response` object that you can return from your route. 10 | 11 | ```ts 12 | const stream = router().get('/', async ({ctx}) => { 13 | return sse(async function* () { 14 | yield {data: 'Hello, world!'}; 15 | await new Promise(resolve => setTimeout(resolve, 1000)); 16 | yield {data: 'Hello, world!'}; 17 | }); 18 | }); 19 | ``` 20 | 21 | ## Basic streaming 22 | 23 | You can also just return a response with a `ReadableStream` body. 24 | 25 | ```ts 26 | router().get('/', async () => { 27 | const stream = new ReadableStream({ 28 | async start(controller) { 29 | controller.enqueue('Hello, '); 30 | await sleep(1000); 31 | controller.enqueue('world!'); 32 | await sleep(1000); 33 | controller.close(); 34 | }, 35 | }); 36 | 37 | return new Response(stream, { 38 | headers: {'Content-Type': 'text/plain'}, 39 | }); 40 | }); 41 | ``` 42 | -------------------------------------------------------------------------------- /apps/docs/src/pages/getting-started.mdx: -------------------------------------------------------------------------------- 1 | import {Callout} from 'nextra/components'; 2 | 3 | # Get started with Kaito 4 | 5 | ```bash 6 | bun i @kaito-http/core # Or use pnpm, npm, or yarn 7 | ``` 8 | 9 | You'll also need a validation library, we recommend [Zod](https://zod.dev/) as we support it with no extra work, but in theory you can use any validation library. [Learn more about validation](/documentation/validation) 10 | 11 | ## Concepts 12 | 13 | Kaito is very simple to use, it revolves around a few basic concepts: 14 | 15 | ### Routes 16 | 17 | A route is a single HTTP request handler. It accepts your context object, and returns a value that will be JSON encoded, or a Response object. 18 | 19 | ```ts 20 | // Return a JSON value... 21 | export const users = router().get('/users', async ({ctx}) => { 22 | return [ 23 | {id: 1, name: 'John Doe'}, 24 | {id: 2, name: 'Jane Bar'}, 25 | ]; 26 | }); 27 | 28 | // ...or return a Response object 29 | export const images = router().get('/cool.png', async ({ctx}) => { 30 | return new Response(myImageBuffer, { 31 | headers: {'Content-Type': 'image/png'}, 32 | }); 33 | }); 34 | ``` 35 | 36 | ### Router 37 | 38 | The router class holds all of our routes and their type information. Every app must have **at least** one router. Routers can be merged together, allowing you to organize them into different files by responsibility or whatever you want. 39 | 40 | ### Context 41 | 42 | Context is generated on every single request. It is up to you as the developer to decide what to place inside your context, but it will be available to access in every single route. For example, you could include a method to get the current user session, or a database connection, or anything else you want. 43 | 44 | ## Quickstart Example 45 | 46 | We first need to setup our context. You can put this in a `context.ts` file, for example. 47 | 48 | ```typescript 49 | import {createUtilities} from '@kaito-http/core'; 50 | 51 | const serverStarted = Date.now(); 52 | 53 | export const {router, getContext} = createUtilities(async (req, res) => { 54 | // Our context object will include the request object and the server uptime. 55 | 56 | return { 57 | req, 58 | uptime: Date.now() - serverStarted, 59 | }; 60 | }); 61 | ``` 62 | 63 | Secondly, we'll need to create a router, our first route, and a handler function to accept requests from the runtime and/or server. 64 | 65 | 66 | We're using `Bun.serve()` in this example, which is an API specific to the Bun runtime. The [runtimes](/runtimes) page 67 | will show you how to use Kaito with other runtimes like Node.js, Deno, etc. 68 | 69 | 70 | ```typescript 71 | import {createKaitoHandler} from '@kaito-http/core'; 72 | import {router, getContext} from './context.ts'; 73 | 74 | const app = router().get('/', async ({ctx}) => { 75 | return { 76 | uptime: ctx.uptime, 77 | time_now: Date.now(), 78 | }; 79 | }); 80 | 81 | // handle is typed as `(req: Request) => Promise 82 | const handle = createKaitoHandler({ 83 | // Pass our getContext function to the root server options 84 | getContext, 85 | 86 | // And pass our root router 87 | router: app, 88 | 89 | // Define an async `onError` handler, in case something goes wrong inside of the route 90 | // This will handle all thrown errors EXCEPT for KaitoError, which is a special error type 91 | // you can throw for simple error messages with a status code 92 | onError: async ({error}) => { 93 | console.log(error); 94 | 95 | return { 96 | status: 500, 97 | message: error.message, 98 | }; 99 | }, 100 | }); 101 | 102 | // Here we are using Bun.serve from the Bun runtime, but 103 | // Kaito works with any runtime that supports the Fetch API 104 | const server = Bun.serve({ 105 | port: 4000, 106 | fetch: handle, 107 | }); 108 | 109 | console.log(`Server is running on ${server.url}`); 110 | ``` 111 | 112 | Awesome! So we now have a server that can be accessed at `http://localhost:4000` that mounts a GET route to `/` which will respond with the server's uptime, and the time now. 113 | 114 | Try it out by running `bun src/index.ts` and navigating to [http://localhost:4000/](http://localhost:4000/). 115 | -------------------------------------------------------------------------------- /apps/docs/src/pages/help.mdx: -------------------------------------------------------------------------------- 1 | # Help 2 | 3 | You can get help in a few ways. Any of these will (might) work. 4 | 5 | 1. [Join the Discord](https://discord.gg/PeEPDMKBEn) - please ping me and if I still don't see it feel free to dm me lol 6 | 2. [Create an issue on GitHub](https://github.com/kaito-http/kaito/issues) 7 | 3. Pray to the internet gods 8 | 4. Quit programming and take up shrimp farming (preferred solution) 9 | -------------------------------------------------------------------------------- /apps/docs/src/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import {Callout} from 'nextra/components'; 2 | 3 | # Kaito 4 | 5 | Kaito is a seriously good HTTP Server Framework for TypeScript. It works with many runtimes, including Bun, Node, Deno, Cloudflare Workers, Vercel Edge Functions, & more. It's based on the modern Web Fetch API using Request/Response objects. 6 | 7 | ### Features 8 | 9 | - ✍ Works with any validation library, although we recommend [Zod](https://zod.dev/) 10 | - 🧑‍💻 Insane TypeScript support, with full e2e type safety to your client 11 | - ⚡ Stupidly fast, built for performance and is often many times faster than other frameworks 12 | - 💨 Very intuitive API, only need to learn a few concepts and the rest falls into place 13 | - 📦 Absolutely tiny package size, making it ideal for serverless environments 14 | - 🏎️ Streaming support with built-in utilities for doing server-sent events (SSE) 15 | 16 | ### The client 17 | 18 | Kaito has an optional client library that is built on top of the Fetch API. It works great in the browser and also in any server runtime! The client can import the TypeScript definition of your server and allow for full e2e type safety. 19 | 20 | This means url paths, query params, request body, and response body are all fully typed. You can break things in the backend and know exactly what needs to be fixed in the frontend. It's truly the best way to build a full stack application with TypeScript everywhere. 21 | 22 | [Learn more about the client →](/documentation/client) 23 | 24 | ### Why Kaito? 25 | 26 | The most popular HTTP Server frameworks on npm are all built for Node.js, and are all similar iterations of a very dated pattern that was popularized by Express.js. Kaito is different. We've reimagined what a modern HTTP Server Framework should be, and we've built it from the ground up to be the best possible experience for TypeScript developers. 27 | -------------------------------------------------------------------------------- /apps/docs/src/pages/recipes/_meta.ts: -------------------------------------------------------------------------------- 1 | import type {Meta} from 'nextra'; 2 | 3 | const meta: Meta = { 4 | 'getting-clients-ip': "Getting the Client's IP", 5 | cors: 'Handling CORS', 6 | cookies: 'Handling Cookies', 7 | 'stripe-webhook': 'Stripe webhook', 8 | }; 9 | 10 | export default meta; 11 | -------------------------------------------------------------------------------- /apps/docs/src/pages/recipes/cookies.mdx: -------------------------------------------------------------------------------- 1 | # Handling cookies with Kaito 2 | 3 | Kaito removed all cookie-related functionality in v3. It's easy to reimplement, and we suggest you do this inside of your context. 4 | 5 | ## Example 6 | 7 | We recommend using the package [`cookie`](https://www.npmjs.com/package/cookie). It's simple and has a tiny footprint & zero dependencies. 8 | 9 | ```bash 10 | bun i cookie 11 | ``` 12 | 13 | ```ts 14 | import {serialize, parse, type SerializeOptions} from 'cookie'; 15 | 16 | export const {getContext, router} = createUtilities(async (req, res) => { 17 | return { 18 | req, 19 | res, 20 | get cookies() { 21 | const header = req.headers.get('cookie'); 22 | return header ? parse(header) : {}; 23 | }, 24 | setCookie(name: string, value: string, options: SerializeOptions) { 25 | res.headers.append('Set-Cookie', serialize(name, value, options)); 26 | }, 27 | }; 28 | }); 29 | ``` 30 | 31 | ## References 32 | 33 | - [MDN: Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) 34 | -------------------------------------------------------------------------------- /apps/docs/src/pages/recipes/cors.mdx: -------------------------------------------------------------------------------- 1 | # Handling CORS with Kaito 2 | 3 | Kaito does not include any CORS handling out of the box. This is by design to keep the library lightweight and unopinionated. You can easily implement CORS handling in your server by using the [`before & transform`](/documentation/server#beforetransform) lifecycle methods. 4 | 5 | ## Example 6 | 7 | ```ts 8 | const ALLOWED_ORIGINS = ['http://localhost:3000', 'https://app.example.com']; 9 | 10 | const server = createKaitoHandler({ 11 | getContext, 12 | router, 13 | 14 | before: async req => { 15 | if (req.method === 'OPTIONS') { 16 | return new Response(null, {status: 204}); // Return early to skip the router. This response will be passed to `.transform()` 17 | } 18 | }, 19 | 20 | transform: async (request, response) => { 21 | const origin = request.headers.get('origin'); 22 | 23 | // Include CORS headers if the origin is allowed 24 | if (origin && ALLOWED_ORIGINS.includes(origin)) { 25 | response.headers.set('Access-Control-Allow-Origin', origin); 26 | response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 27 | response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 28 | response.headers.set('Access-Control-Max-Age', '86400'); 29 | response.headers.set('Access-Control-Allow-Credentials', 'true'); 30 | } 31 | }, 32 | }); 33 | ``` 34 | 35 | ## References 36 | 37 | - [MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) 38 | - [MDN: Access-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 39 | -------------------------------------------------------------------------------- /apps/docs/src/pages/recipes/getting-clients-ip.mdx: -------------------------------------------------------------------------------- 1 | # Reading the client IP address 2 | 3 | Getting the client IP address in Kaito differs depending on what server runtime you're using and also if you are using a reverse proxy or not. 4 | 5 | For example, if you are using a reverse proxy like Cloudflare, you'll need to use the `cf-connecting-ip` header to get the client IP address. 6 | 7 | ## Cloudflare proxy example 8 | 9 | ```ts 10 | export const myRouter = router().get('/ip', async ({ctx}) => { 11 | const ip = ctx.req.headers.get('cf-connecting-ip'); // you must pass `.req` in your context object 12 | 13 | if (!ip) { 14 | throw new KaitoError(400, 'Probably not behind Cloudflare!'); 15 | } 16 | 17 | return ip; 18 | }); 19 | ``` 20 | 21 | Other reverse proxies will have different headers, nginx for example uses `x-forwarded-for`. Consult the docs of your reverse proxy to see how to get the client IP address. 22 | 23 | In most cases, apps ARE behind reverse proxies, and it's rare that you'll want the IP address of the connection itself. If you actually do need it, you'll have to consult the docs of your server runtime. Below are a couple snippets that may be helpful, though. 24 | 25 | ## Bun 26 | 27 | With `Bun.serve()`, you can access the client IP address with the server's `.remoteIP` method. We recommend storing this in AsyncLocalStorage so you can access it in your route handlers. 28 | 29 | Bear in mind that you can move the ip property to your context object to make it cleaner to access. Below is just a lightweight example. 30 | 31 | ```ts 32 | import {AsyncLocalStorage} from 'node:async_hooks'; 33 | 34 | const ipStore = new AsyncLocalStorage(); 35 | 36 | const app = router().get('/ip', async ({ctx}) => { 37 | const ip = ipStore.getStore()!; 38 | return ip; 39 | }); 40 | 41 | const handle = createKaitoHandler({ 42 | router: app, 43 | // ... 44 | }); 45 | 46 | const server = Bun.serve({ 47 | port: 3000, 48 | fetch: async (request, server) => { 49 | const ip = server.remoteIP(request); 50 | return ipStore.run(ip, () => handle(request)); 51 | }, 52 | }); 53 | ``` 54 | 55 | ## Node.js 56 | 57 | Since you'll be using `@kaito-http/uws` with Node.js, the client IP address is available through the `context` parameter passed to your fetch handler. If you need to access it in nested functions or route handlers, you can use AsyncLocalStorage to make it available throughout the request lifecycle. 58 | 59 | ```ts 60 | import {AsyncLocalStorage} from 'node:async_hooks'; 61 | import {KaitoServer} from '@kaito-http/uws'; 62 | 63 | const ipStore = new AsyncLocalStorage(); 64 | 65 | const app = router().get('/ip', async ({ctx}) => { 66 | const ip = ipStore.getStore()!; 67 | return ip; 68 | }); 69 | 70 | const handle = createKaitoHandler({ 71 | router: app, 72 | // ... 73 | }); 74 | 75 | const server = await KaitoServer.serve({ 76 | port: 3000, 77 | fetch: async (request, context) => { 78 | return ipStore.run(context.remoteAddress, () => handle(request)); 79 | }, 80 | }); 81 | ``` 82 | 83 | --- 84 | 85 | If you're not using Bun or Node.js, you should consult the docs of your server runtime to see how to get the client IP address. We'll happily accept a PR to this docs page to add snippets for other server runtimes. 86 | 87 | ### Kaito v1 & v2 88 | 89 | Previous versions of Kaito were built on top of Node.js only, so you could just access the `req.socket.remoteAddress` property to get the client's IP address. The Web Fetch API doesn't have a `.socket` property on requests, so most server implementations let you access the remote ip address or socket instance (if any) through other means. 90 | -------------------------------------------------------------------------------- /apps/docs/src/pages/recipes/stripe-webhook.mdx: -------------------------------------------------------------------------------- 1 | # Setting up a Stripe webhook with Kaito 2 | 3 | An advantage of Kaito moving to support Request/Response APIs in v3 is that it made it super easy to setup a Stripe webhook. You can read more at the bottom of this page about why that was the case. 4 | 5 | ## Example 6 | 7 | ```ts filename="context.ts" 8 | export const {router} = createUtilities(async (req, res) => { 9 | return { 10 | bodyAsText: async () => await req.text(), 11 | }; 12 | }); 13 | ``` 14 | 15 | ```ts filename="stripe.ts" 16 | import {router} from './context.ts'; 17 | import stripe from '@stripe/stripe-js'; 18 | 19 | // Create a crypto provider for stripe to use, required in some runtimes that don't define `crypto.subtle` globally. 20 | // If you're unsure, try without, and then bring it back if you get an error. 21 | const webCrypto = stripe.createSubtleCryptoProvider(); 22 | 23 | // Notice how we don't define a body schema, we're using stripe's webhook logic to parse the body for us 24 | // which requires the raw body as a string. 25 | export const stripe = router().post('/webhook', async ({ctx}) => { 26 | const body = await ctx.bodyAsText(); 27 | 28 | const sig = ctx.req.headers.get('stripe-signature'); 29 | 30 | if (!sig) { 31 | throw new KaitoError(400, 'No signature provided'); 32 | } 33 | 34 | const event = await stripe.webhooks.constructEventAsync( 35 | body, 36 | sig, 37 | process.env.STRIPE_ENDPOINT_SECRET!, // You should validate this exists, and not use the `!` operator 38 | undefined, 39 | webCrypto, 40 | ); 41 | 42 | console.log('Stripe event:', event); 43 | }); 44 | ``` 45 | -------------------------------------------------------------------------------- /apps/docs/src/pages/runtimes.mdx: -------------------------------------------------------------------------------- 1 | # Runtimes 2 | 3 | Kaito v3+ was designed to work with any runtime, including serverless runtimes like Cloudflare Workers or the Vercel Edge Runtime. 4 | 5 | ## Bun 6 | 7 | Bun is the runtime most suited for Kaito, as it has the fastest Request/Response server built in. 8 | 9 | ```ts 10 | import {createKaitoHandler} from '@kaito-http/core'; 11 | 12 | const handle = createKaitoHandler({ 13 | // ... 14 | }); 15 | 16 | const server = Bun.serve({ 17 | fetch: handle, 18 | port: 3000, 19 | }); 20 | 21 | console.log(`Listening at ${server.url}`); 22 | ``` 23 | 24 | ## Node.js 25 | 26 | Node.js does NOT have a native Request/Response based HTTP server built in, so we built one ourselves! It's based on `uWebSockets.js`, which is a stupidly fast HTTP server written in C++ with Node.js bindings. It's actually the same server that Bun uses, so it offers almost as good performance as Bun. 27 | 28 | ### Installation 29 | 30 | ```bash 31 | bun i @kaito-http/uws 32 | ``` 33 | 34 | To be super clear, `@kaito-http/uws` works with Node.js only, we're only using Bun as a package manager in the command above. You can use any other package manager like `npm` or `yarn`. 35 | 36 | ### Usage 37 | 38 | ```ts 39 | import {createKaitoHandler} from '@kaito-http/core'; 40 | import {KaitoServer} from '@kaito-http/uws'; 41 | 42 | const handle = createKaitoHandler({ 43 | // ... 44 | }); 45 | 46 | const server = await KaitoServer.serve({ 47 | fetch: handle, 48 | port: 3000, 49 | }); 50 | 51 | console.log(`Listening at ${server.url}`); 52 | ``` 53 | 54 | ## Deno 55 | 56 | Deno supports the Fetch API natively, so you can use Kaito with Deno without any extra work. 57 | 58 | ```ts 59 | import {createKaitoHandler} from '@kaito-http/core'; 60 | 61 | const handle = createKaitoHandler({ 62 | // ... 63 | }); 64 | 65 | const server = Deno.serve( 66 | { 67 | port: 3000, 68 | }, 69 | handle, 70 | ); 71 | 72 | console.log(`Listening on`, server.addr); 73 | ``` 74 | 75 | ## Cloudflare Workers 76 | 77 | Cloudflare Workers supports the Fetch API natively, so you can use Kaito with Cloudflare Workers without any extra work. 78 | 79 | ```ts 80 | import {createKaitoHandler} from '@kaito-http/core'; 81 | 82 | const handle = createKaitoHandler({ 83 | // ... 84 | }); 85 | 86 | export default { 87 | fetch: handle, 88 | } satisfies ExportedHandler; 89 | ``` 90 | 91 | ### Environment variables 92 | 93 | Cloudflare Workers passes environment variables to the handler function, which is a little awkward with Kaito. Our recommendation is to use AsyncLocalStorage to pass info between the handler and the router. This requires you to enable the node compatibility mode on your Cloudflare Worker. 94 | 95 | ```ts 96 | import {AsyncLocalStorage} from 'node:async_hooks'; 97 | 98 | interface Env { 99 | STRIPE_SECRET_KEY: string; 100 | } 101 | 102 | // Recommendation is to move the storage instance to the `context.ts` file 103 | // and include the value in your context object. 104 | const storage = new AsyncLocalStorage<{ 105 | env: Env; 106 | cfCtx: ExecutionContext; // has .waitUntil() and .passThroughOnException() 107 | }>(); 108 | 109 | const app = router().get('/', async ({ctx}) => { 110 | return { 111 | freeStripeKey: ctx.env.STRIPE_SECRET_KEY, // obviously don't send your stripe key to the client lol 112 | }; 113 | }); 114 | 115 | const handle = createKaitoHandler({ 116 | router: app, 117 | getContext: async req => { 118 | const store = storage.getStore()!; 119 | return { 120 | env: store.env, 121 | cfCtx: store.cfCtx, 122 | }; 123 | }, 124 | // ... 125 | }); 126 | 127 | export default { 128 | fetch: async (request, env, ctx) => { 129 | return storage.run({env, cfCtx: ctx}, () => handle(request)); 130 | }, 131 | } satisfies ExportedHandler; 132 | ``` 133 | -------------------------------------------------------------------------------- /apps/docs/src/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import type {DocsThemeConfig} from 'nextra-theme-docs'; 2 | import {useConfig} from 'nextra-theme-docs'; 3 | import {pathcat} from 'pathcat'; 4 | 5 | const config: DocsThemeConfig = { 6 | project: { 7 | link: 'https://github.com/kaito-http/kaito', 8 | }, 9 | 10 | docsRepositoryBase: 'https://github.com/kaito-http/kaito/blob/master/apps/docs', 11 | 12 | toc: { 13 | float: true, 14 | }, 15 | 16 | sidebar: {}, 17 | 18 | feedback: { 19 | labels: 'docs-feedback', 20 | content: 'Feedback', 21 | }, 22 | 23 | faviconGlyph: '✦', 24 | logo: Kaito, 25 | 26 | footer: { 27 | content: ( 28 | 29 | An open-source project by Alistair Smith 30 | 31 | ), 32 | }, 33 | 34 | chat: { 35 | link: 'https://discord.gg/PeEPDMKBEn', 36 | }, 37 | 38 | head: function Head() { 39 | const config = useConfig(); 40 | 41 | const meta = config.frontMatter as {title?: string; description?: string; image?: string}; 42 | const title = config.title ?? meta.title; 43 | 44 | const ogImage = 45 | meta.image ?? 46 | pathcat('https://ogmeta.kaito.cloud', '/', { 47 | title, 48 | subtitle: meta.description ?? undefined ?? 'Kaito: An HTTP framework for TypeScript', 49 | dark: 'true', 50 | }); 51 | 52 | return ( 53 | <> 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {' '} 65 | 66 | {title} 67 | 68 | ); 69 | }, 70 | }; 71 | 72 | export default config; 73 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "incremental": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "module": "Preserve", 11 | "moduleResolution": "Bundler", 12 | "noEmit": true, 13 | "strict": true, 14 | "target": "ES2017", 15 | "isolatedModules": true 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules", "compile"] 19 | } 20 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaito-http/kaito/60c641d3b0b3c350e770033a8bdc3bf16fd785e8/bun.lockb -------------------------------------------------------------------------------- /constraints.pro: -------------------------------------------------------------------------------- 1 | gen_enforced_field(WorkspaceCwd, 'license', 'MIT'). 2 | gen_enforced_field(WorkspaceCwd, 'homepage', 'https://github.com/kaito-http/kaito'). 3 | gen_enforced_field(WorkspaceCwd, 'repository', 'https://github.com/kaito-http/kaito'). 4 | gen_enforced_field(WorkspaceCwd, 'author', 'Alistair Smith '). 5 | gen_enforced_field(WorkspaceCwd, 'description', 'Functional HTTP Framework for TypeScript'). 6 | gen_enforced_field(WorkspaceCwd, 'version', '3.2.1'). 7 | gen_enforced_field(WorkspaceCwd, 'keywords', ['typescript', 'http', 'framework']). 8 | -------------------------------------------------------------------------------- /doc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaito-http/kaito/60c641d3b0b3c350e770033a8bdc3bf16fd785e8/doc.png -------------------------------------------------------------------------------- /examples/ai/.env.example: -------------------------------------------------------------------------------- 1 | GEMINI_API_KEY= 2 | -------------------------------------------------------------------------------- /examples/ai/demo.cjs: -------------------------------------------------------------------------------- 1 | const {spawn} = require('child_process'); 2 | const net = require('net'); 3 | const path = require('path'); 4 | 5 | // Change to the examples/ai directory 6 | process.chdir(path.dirname(__filename)); 7 | 8 | // Function to check if port is available 9 | function waitForPort(port) { 10 | return new Promise(resolve => { 11 | const intervalId = setInterval(() => { 12 | const socket = new net.Socket(); 13 | 14 | socket.on('connect', () => { 15 | socket.destroy(); 16 | clearInterval(intervalId); 17 | resolve(); 18 | }); 19 | 20 | socket.on('error', () => { 21 | socket.destroy(); 22 | }); 23 | 24 | socket.connect(port, 'localhost'); 25 | }, 100); 26 | }); 27 | } 28 | 29 | let serverProcess = null; 30 | 31 | async function main() { 32 | try { 33 | // Start server in background 34 | serverProcess = spawn('node', ['--import=tsx', './src/index.ts'], { 35 | stdio: ['ignore', 'inherit', 'inherit'], 36 | detached: false, 37 | }); 38 | 39 | // Wait for server to be ready 40 | await waitForPort(3000); 41 | 42 | // Run CLI in foreground 43 | const cliProcess = spawn('node', ['--import=tsx', './src/client.ts'], { 44 | stdio: 'inherit', 45 | }); 46 | 47 | // Wait for CLI to exit 48 | await new Promise((resolve, reject) => { 49 | cliProcess.on('exit', code => { 50 | if (code === 0) resolve(); 51 | else reject(new Error(`CLI exited with code ${code}`)); 52 | }); 53 | }); 54 | } finally { 55 | // Cleanup server process 56 | if (serverProcess) { 57 | serverProcess.kill(); 58 | } 59 | } 60 | } 61 | 62 | // Handle process signals for cleanup 63 | ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach(signal => { 64 | process.on(signal, () => { 65 | if (serverProcess) { 66 | serverProcess.kill(); 67 | } 68 | process.exit(); 69 | }); 70 | }); 71 | 72 | main().catch(error => { 73 | console.error('Error:', error); 74 | if (serverProcess) { 75 | serverProcess.kill(); 76 | } 77 | process.exit(1); 78 | }); 79 | -------------------------------------------------------------------------------- /examples/ai/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http-examples/ai", 3 | "private": true, 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "homepage": "https://github.com/kaito-http/kaito", 8 | "repository": "https://github.com/kaito-http/kaito", 9 | "license": "MIT", 10 | "version": "3.2.1", 11 | "scripts": { 12 | "dev": "node --watch --import=tsx src/index.ts", 13 | "start": "node --import=tsx src/index.ts", 14 | "cli": "node --import=tsx src/client.ts", 15 | "demo": "node demo.cjs" 16 | }, 17 | "dependencies": { 18 | "@google/generative-ai": "^0.21.0", 19 | "@kaito-http/client": "workspace:^", 20 | "@kaito-http/core": "workspace:^", 21 | "@kaito-http/uws": "workspace:^", 22 | "dotenv": "^16.3.1", 23 | "zod": "^3.24.1" 24 | }, 25 | "devDependencies": { 26 | "tsx": "^4.19.2", 27 | "typescript": "^5.7.2" 28 | }, 29 | "keywords": [ 30 | "typescript", 31 | "http", 32 | "framework" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /examples/ai/src/ai.ts: -------------------------------------------------------------------------------- 1 | import type {GenerativeModel} from '@google/generative-ai'; 2 | import {GoogleGenerativeAI} from '@google/generative-ai'; 3 | 4 | export function createGoogleAI(): GoogleGenerativeAI { 5 | const apiKey = process.env.GEMINI_API_KEY; 6 | if (!apiKey) { 7 | throw new Error('GEMINI_API_KEY environment variable is not set'); 8 | } 9 | const genAI = new GoogleGenerativeAI(apiKey); 10 | return genAI; 11 | } 12 | 13 | export async function* tellMeAStory(model: GenerativeModel, {topic}: {topic: string | undefined}) { 14 | const prompt = `Write a story about ${topic}`; 15 | 16 | const result = await model.generateContentStream(prompt); 17 | 18 | // Print text as it comes in. 19 | for await (const chunk of result.stream) { 20 | const chunkText = chunk.text(); 21 | yield chunkText; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/ai/src/client.ts: -------------------------------------------------------------------------------- 1 | import {createKaitoHTTPClient} from '@kaito-http/client'; 2 | import {stdin as input, stdout as output} from 'node:process'; 3 | import * as readline from 'node:readline/promises'; 4 | import type {App} from './index.ts'; 5 | 6 | const api = createKaitoHTTPClient({ 7 | base: 'http://localhost:3000', 8 | }); 9 | 10 | async function main() { 11 | const rl = readline.createInterface({input, output}); 12 | 13 | try { 14 | while (true) { 15 | const topic = await rl.question('What would you like a story about? '); 16 | 17 | const stream = await api.get('/v1/stories', { 18 | sse: true, 19 | query: { 20 | topic, 21 | }, 22 | }); 23 | 24 | for await (const chunk of stream) { 25 | // this does not necessarily flush afaik 26 | process.stdout.write(chunk.data); 27 | } 28 | 29 | // this will definitely flush stdout 30 | console.log('\n'); 31 | } 32 | } finally { 33 | rl.close(); 34 | } 35 | } 36 | 37 | main().catch(e => console.error('Error in ai client', e)); 38 | -------------------------------------------------------------------------------- /examples/ai/src/context.ts: -------------------------------------------------------------------------------- 1 | import {createUtilities} from '@kaito-http/core'; 2 | 3 | const serverStarted = Date.now(); 4 | 5 | export const {getContext, router} = createUtilities(async (req, _res) => { 6 | // Passing req is OK, but I personally prefer to avoid it. 7 | // Instead, the logic I would have used req for should be 8 | // included in this context file, allowing for it to be 9 | // shared between routes. 10 | 11 | return { 12 | req, 13 | uptime: Date.now() - serverStarted, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /examples/ai/src/index.ts: -------------------------------------------------------------------------------- 1 | import {createKaitoHandler} from '@kaito-http/core'; 2 | import {sse} from '@kaito-http/core/stream'; 3 | import {KaitoServer} from '@kaito-http/uws'; 4 | import 'dotenv/config'; 5 | import {z} from 'zod'; 6 | import {createGoogleAI, tellMeAStory} from './ai.ts'; 7 | import {getContext, router} from './context.ts'; 8 | 9 | // Create a single instance of the google AI client 10 | const googleAI = createGoogleAI(); 11 | const gemini = googleAI.getGenerativeModel({model: process.env.GEMINI_MODEL ?? 'gemini-1.5-flash'}); 12 | 13 | const v1 = router() 14 | .get('/story', { 15 | run: async ({query}) => { 16 | console.error('story query', query); 17 | return sse(async function* () { 18 | console.error('getting story'); 19 | const storyGenerator = tellMeAStory(gemini, { 20 | topic: 'kaito, a typesafe Functional HTTP Framework for TypeScript', 21 | }); 22 | 23 | console.error('got story'); 24 | for await (const chunk of storyGenerator) { 25 | yield { 26 | data: chunk, 27 | }; 28 | } 29 | }); 30 | }, 31 | }) 32 | .get('/stories', { 33 | query: { 34 | topic: z.string(), 35 | }, 36 | run: async ({query}) => { 37 | console.error('story query', query); 38 | return sse(async function* () { 39 | console.error('getting story'); 40 | const storyGenerator = tellMeAStory(gemini, { 41 | topic: query.topic || 'kaito, a typesafe Functional HTTP Framework for TypeScript', 42 | }); 43 | console.error('got story'); 44 | for await (const chunk of storyGenerator) { 45 | yield { 46 | data: chunk, 47 | }; 48 | } 49 | }); 50 | }, 51 | }); 52 | 53 | const root = router().merge('/v1', v1); 54 | 55 | const handle = createKaitoHandler({ 56 | router: root, 57 | getContext, 58 | 59 | onError: async ({error}) => ({ 60 | status: 500, 61 | message: error.message, 62 | }), 63 | }); 64 | 65 | const server = await KaitoServer.serve({ 66 | port: 3000, 67 | fetch: handle, 68 | }); 69 | 70 | console.log('Server listening at', server.url); 71 | 72 | export type App = typeof root; 73 | -------------------------------------------------------------------------------- /examples/ai/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # basic 2 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http-examples/basic", 3 | "private": true, 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "homepage": "https://github.com/kaito-http/kaito", 8 | "repository": "https://github.com/kaito-http/kaito", 9 | "license": "MIT", 10 | "version": "3.2.1", 11 | "scripts": { 12 | "dev": "node --import=tsx --watch src/index.ts", 13 | "start": "node --import=tsx src/index.ts" 14 | }, 15 | "dependencies": { 16 | "@kaito-http/client": "workspace:^", 17 | "@kaito-http/core": "workspace:^", 18 | "@kaito-http/uws": "workspace:^", 19 | "cookie": "^1.0.2", 20 | "stripe": "^17.5.0", 21 | "zod": "^3.24.1" 22 | }, 23 | "devDependencies": { 24 | "tsx": "^4.19.2", 25 | "typescript": "^5.7.2" 26 | }, 27 | "keywords": [ 28 | "typescript", 29 | "http", 30 | "framework" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /examples/basic/src/context.ts: -------------------------------------------------------------------------------- 1 | import {createUtilities} from '@kaito-http/core'; 2 | import {serialize, type SerializeOptions} from 'cookie'; 3 | 4 | const serverStarted = Date.now(); 5 | 6 | export const {getContext, router} = createUtilities(async (req, res) => { 7 | // Passing req is OK, but I personally prefer to avoid it. 8 | // Instead, the logic I would have used req for should be 9 | // included in this context file, allowing for it to be 10 | // shared between routes. 11 | 12 | return { 13 | req, 14 | 15 | cookie(name: string, value: string, options: SerializeOptions = {}) { 16 | res.headers.set('Set-Cookie', serialize(name, value, options)); 17 | }, 18 | 19 | uptime: Date.now() - serverStarted, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /examples/basic/src/data.ts: -------------------------------------------------------------------------------- 1 | export type ExampleSSETypes = 2 | | { 3 | event: 'numbers'; 4 | data: { 5 | digits: number; 6 | }; 7 | } 8 | | { 9 | event: 'text'; 10 | data: { 11 | text: string; 12 | }; 13 | } 14 | | { 15 | event: 'data'; 16 | data: { 17 | obj: { 18 | str: string; 19 | num: number; 20 | nested: { 21 | bool: boolean; 22 | }; 23 | }; 24 | }; 25 | }; 26 | 27 | export function randomEvent(): ExampleSSETypes { 28 | // Randomly choose between the three event types 29 | const choice = Math.floor(Math.random() * 3); 30 | 31 | switch (choice) { 32 | case 0: 33 | return { 34 | event: 'numbers', 35 | data: { 36 | digits: Math.floor(Math.random() * 100), 37 | }, 38 | }; 39 | case 1: 40 | return { 41 | event: 'text', 42 | data: { 43 | text: 'lorem ipsum', 44 | }, 45 | }; 46 | default: 47 | return { 48 | event: 'data', 49 | data: { 50 | obj: { 51 | str: 'Example string', 52 | num: Math.random() * 100, 53 | nested: { 54 | bool: Math.random() > 0.5, 55 | }, 56 | }, 57 | }, 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/basic/src/index.ts: -------------------------------------------------------------------------------- 1 | import {createKaitoHandler, KaitoError} from '@kaito-http/core'; 2 | import {sse, sseFromAnyReadable} from '@kaito-http/core/stream'; 3 | import {KaitoServer} from '@kaito-http/uws'; 4 | import stripe from 'stripe'; 5 | import {z} from 'zod'; 6 | import {getContext, router} from './context.ts'; 7 | import {randomEvent} from './data.ts'; 8 | 9 | async function sleep(ms: number) { 10 | await new Promise(resolve => setTimeout(resolve, ms)); 11 | } 12 | 13 | const users = router() 14 | .post('/:id', { 15 | body: z.object({ 16 | name: z.string(), 17 | }), 18 | 19 | query: { 20 | limit: z 21 | .string() 22 | .transform(value => parseInt(value, 10)) 23 | .default('10'), 24 | skip: z.string().transform(value => parseInt(value, 10)), 25 | }, 26 | 27 | async run({ctx, body, params, query}) { 28 | return { 29 | uptime: ctx.uptime, 30 | body, 31 | params, 32 | query, 33 | }; 34 | }, 35 | }) 36 | .post('/set-me-a-cookie', async ({ctx}) => { 37 | ctx.cookie('ThisIsACookie', 'ThisIsAValue', { 38 | expires: /* in a day */ new Date(Date.now() + 1000 * 60 * 60 * 24), 39 | }); 40 | }); 41 | 42 | const webCrypto = stripe.createSubtleCryptoProvider(); 43 | const exampleHandlingStripe = router().post('/webhook', async ({ctx}) => { 44 | const body = await ctx.req.text(); 45 | 46 | const sig = ctx.req.headers.get('stripe-signature'); 47 | 48 | if (!sig) { 49 | throw new KaitoError(400, 'No signature provided'); 50 | } 51 | 52 | const event = await stripe.webhooks.constructEventAsync( 53 | body, 54 | sig, 55 | process.env.STRIPE_ENDPOINT_SECRET!, // You should validate this exists, and not use the `!` operator 56 | undefined, 57 | webCrypto, 58 | ); 59 | 60 | console.log('Stripe event:', event); 61 | }); 62 | 63 | const exampleReturningResponse = router() 64 | .get('/', async () => { 65 | return new Response('Hello world', { 66 | status: 200, 67 | headers: { 68 | 'Content-Type': 'text/plain', 69 | }, 70 | }); 71 | }) 72 | .get('/stream', async () => { 73 | const stream = new ReadableStream({ 74 | async start(controller) { 75 | controller.enqueue('Hello, '); 76 | await sleep(1000); 77 | controller.enqueue('world!'); 78 | await sleep(1000); 79 | controller.close(); 80 | }, 81 | }); 82 | 83 | return new Response(stream, { 84 | headers: { 85 | 'Content-Type': 'text/plain', 86 | }, 87 | }); 88 | }) 89 | .get('/stream-sse-kinda', async () => { 90 | const stream = new ReadableStream({ 91 | async start(controller) { 92 | controller.enqueue('hello!'); 93 | await sleep(1000); 94 | controller.enqueue('world!'); 95 | await sleep(1000); 96 | controller.close(); 97 | }, 98 | }); 99 | 100 | return sseFromAnyReadable(stream, chunk => ({ 101 | event: 'message', 102 | data: chunk, 103 | })); 104 | }); 105 | 106 | const v1 = router() 107 | // Basic inline route 108 | .get('/time', async () => Date.now()) 109 | 110 | .merge('/stripe', exampleHandlingStripe) 111 | .merge('/response', exampleReturningResponse) 112 | 113 | // Basic object route 114 | .post('/time', { 115 | async run() { 116 | return {t: Date.now()}; 117 | }, 118 | }) 119 | 120 | // How to throw an error 121 | .get('/throw', { 122 | run() { 123 | throw new KaitoError(400, 'Something was intentionally thrown'); 124 | }, 125 | }) 126 | 127 | // Example parsing request body 128 | .post('/echo', { 129 | body: z.record(z.string(), z.unknown()), 130 | query: { 131 | name: z.string(), 132 | }, 133 | async run({body, query}) { 134 | // Body is typed as `Record` 135 | return {body, name: query.name}; 136 | }, 137 | }) 138 | 139 | // example streaming SSE responses to get request using low level interface 140 | .get('/sse_stream', { 141 | query: { 142 | content: z.string(), 143 | }, 144 | run: async ({query}) => { 145 | // This is an example of using the SSESource interface 146 | return sse({ 147 | async start(controller) { 148 | // TODO: use `using` once Node.js supports it 149 | // ensure controller is closed 150 | // using c = controller; 151 | try { 152 | let i = 0; 153 | 154 | for await (const word of query.content.split(' ')) { 155 | i++; 156 | controller.enqueue({ 157 | id: i.toString(), 158 | data: word, // only strings are supported in this SSE interface 159 | }); 160 | } 161 | } finally { 162 | controller.close(); 163 | } 164 | }, 165 | }); 166 | }, 167 | }) 168 | 169 | // example streaming SSE responses to post request with just an async generator 170 | .post('/sse_stream', { 171 | body: z.object({ 172 | count: z.number(), 173 | }), 174 | run: async ({body}) => { 175 | // This is an example of a discriminated union being sent on the stream 176 | return sse(async function* () { 177 | for (let i = 0; i < Math.max(body.count, 100); i++) { 178 | yield randomEvent(); // random event is a discriminated union on "data" 179 | await sleep(100); 180 | } 181 | }); 182 | }, 183 | }) 184 | 185 | // example streaming SSE responses to post request with just an async generator 186 | .post('/sse_stream_union', { 187 | body: z.object({ 188 | count: z.number(), 189 | }), 190 | run: async ({body}) => { 191 | // This is an example of a union of different types being sent on the stream 192 | return sse(async function* () { 193 | for (let i = 0; i < Math.max(body.count, 100); i++) { 194 | yield { 195 | data: randomEvent().data, // this is just a union, not discriminated 196 | }; 197 | await sleep(100); 198 | } 199 | }); 200 | }, 201 | }) 202 | 203 | // Merge this router with another router (users). 204 | .merge('/users', users); 205 | 206 | const exampleOfThrough = router() 207 | .through(async old => ({ 208 | ...old, 209 | lol: new Date(), 210 | })) 211 | .get('/test', async ({ctx}) => ctx.lol.getTime()); 212 | 213 | const root = router() 214 | // Basic inline access context 215 | .get('/uptime', async ({ctx}) => ctx.uptime) 216 | .post('/uptime', async ({ctx}) => ctx.uptime) 217 | 218 | .merge('/through', exampleOfThrough) 219 | 220 | // Accessing query 221 | .get('/query', { 222 | query: { 223 | age: z 224 | .string() 225 | .transform(value => parseInt(value, 10)) 226 | .default('10'), 227 | }, 228 | 229 | run: async ({query}) => query.age, 230 | }) 231 | 232 | // Merge this router with another router (v1) 233 | .merge('/v1', v1); 234 | 235 | const ALLOWED_ORIGINS = ['http://localhost:3000', 'https://app.example.com']; 236 | 237 | const handle = createKaitoHandler({ 238 | router: root, 239 | getContext, 240 | 241 | onError: async ({error}) => ({ 242 | status: 500, 243 | message: error.message, 244 | }), 245 | 246 | // Runs before the router is called. In this case we are handling OPTIONS requests 247 | // If you return a response from this function, it WILL be passed to `.transform()` before being sent to the client 248 | before: async req => { 249 | if (req.method === 'OPTIONS') { 250 | return new Response(null, {status: 204}); 251 | } 252 | }, 253 | 254 | transform: async (request, response) => { 255 | const origin = request.headers.get('origin'); 256 | 257 | // Include CORS headers if the origin is allowed 258 | if (origin && ALLOWED_ORIGINS.includes(origin)) { 259 | response.headers.set('Access-Control-Allow-Origin', origin); 260 | response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 261 | response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 262 | response.headers.set('Access-Control-Max-Age', '86400'); 263 | response.headers.set('Access-Control-Allow-Credentials', 'true'); 264 | } 265 | }, 266 | }); 267 | 268 | const server = await KaitoServer.serve({ 269 | port: 3000, 270 | fetch: handle, 271 | }); 272 | 273 | console.log('Server listening at', server.url); 274 | 275 | export type App = typeof root; 276 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http-examples/bench", 3 | "private": true, 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "homepage": "https://github.com/kaito-http/kaito", 8 | "repository": "https://github.com/kaito-http/kaito", 9 | "license": "MIT", 10 | "version": "3.2.1", 11 | "scripts": { 12 | "dev": "node --import=tsx --watch src/index.ts", 13 | "start": "node --import=tsx src/index.ts" 14 | }, 15 | "dependencies": { 16 | "@kaito-http/core": "workspace:^" 17 | }, 18 | "devDependencies": { 19 | "tsx": "^4.19.2", 20 | "typescript": "^5.7.2" 21 | }, 22 | "keywords": [ 23 | "typescript", 24 | "http", 25 | "framework" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/bench/src/context.ts: -------------------------------------------------------------------------------- 1 | import {createUtilities, KaitoRequest} from '@kaito-http/core'; 2 | 3 | const start = performance.now(); 4 | 5 | export interface AppContext { 6 | req: KaitoRequest; 7 | uptime: number; 8 | } 9 | 10 | export const {getContext, router} = createUtilities(async req => { 11 | return { 12 | req, 13 | uptime: performance.now() - start, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /examples/bench/src/index.ts: -------------------------------------------------------------------------------- 1 | import {createKaitoHandler} from '@kaito-http/core'; 2 | import {sse} from '@kaito-http/core/stream'; 3 | import {KaitoServer} from '@kaito-http/uws'; 4 | import {getContext, router} from './context.ts'; 5 | 6 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 7 | 8 | const root = router() 9 | .get('/hello', () => 'hi' as const) 10 | .get('/stream', async () => { 11 | const text = "This is an example of text being streamed every 100ms by using Kaito's sse() function"; 12 | 13 | return sse(async function* () { 14 | for (const word in text.split(' ')) { 15 | yield {data: word, event: 'cool', retry: 1000}; 16 | 17 | await sleep(100); 18 | } 19 | }); 20 | }); 21 | 22 | const fetch = createKaitoHandler({ 23 | router: root, 24 | getContext, 25 | 26 | onError: async ({error}) => ({ 27 | status: 500, 28 | message: error.message, 29 | }), 30 | }); 31 | 32 | const server = await KaitoServer.serve({ 33 | fetch, 34 | port: 3000, 35 | host: '127.0.0.1', 36 | // static: { 37 | // '/static/file.txt': new Response('Hello, world!'), 38 | // }, 39 | }); 40 | 41 | console.log('Server listening at', server.url); 42 | 43 | export type App = typeof root; 44 | -------------------------------------------------------------------------------- /examples/bench/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | -------------------------------------------------------------------------------- /examples/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http-examples/client", 3 | "packageManager": "yarn@4.5.0", 4 | "type": "module", 5 | "scripts": { 6 | "start": "node --import=tsx src/index.ts" 7 | }, 8 | "dependencies": { 9 | "@kaito-http-examples/basic": "workspace:^", 10 | "@kaito-http/client": "workspace:^" 11 | }, 12 | "devDependencies": { 13 | "typescript": "^5.7.2" 14 | }, 15 | "author": "Alistair Smith ", 16 | "description": "Functional HTTP Framework for TypeScript", 17 | "homepage": "https://github.com/kaito-http/kaito", 18 | "keywords": [ 19 | "typescript", 20 | "http", 21 | "framework" 22 | ], 23 | "license": "MIT", 24 | "repository": "https://github.com/kaito-http/kaito", 25 | "version": "3.2.1" 26 | } 27 | -------------------------------------------------------------------------------- /examples/client/src/basic.ts: -------------------------------------------------------------------------------- 1 | import type {App} from '@kaito-http-examples/basic/src/index.ts'; 2 | import {createKaitoHTTPClient} from '@kaito-http/client'; 3 | 4 | const api = createKaitoHTTPClient({ 5 | base: 'http://localhost:3000', 6 | }); 7 | 8 | function assertNever(x: never): never { 9 | throw new Error(`Unhandled case: ${JSON.stringify(x)}`); 10 | } 11 | 12 | const getResponse = await api.get('/v1/response/', { 13 | response: true, 14 | }); 15 | 16 | console.log(await getResponse.text()); // This response contains some text! So we don't want the Kaito client to parse it 17 | 18 | const getSSE = await api.get('/v1/sse_stream', { 19 | sse: true, 20 | query: { 21 | content: 'This is an example of SSE streaming text', 22 | }, 23 | }); 24 | 25 | for await (const event of getSSE) { 26 | console.log('event', event.data); 27 | } 28 | 29 | const postSSE = await api.post('/v1/sse_stream', { 30 | sse: true, 31 | body: { 32 | count: 20, 33 | }, 34 | }); 35 | 36 | for await (const event of postSSE) { 37 | switch (event.event) { 38 | case 'numbers': 39 | const foo: number = event.data.digits; 40 | console.log(foo); 41 | break; 42 | case 'data': 43 | const bar: object = event.data.obj; 44 | console.log(bar); 45 | break; 46 | case 'text': 47 | console.log(event.data.text); 48 | break; 49 | case undefined: 50 | throw new Error('event name missing'); 51 | default: 52 | assertNever(event); 53 | } 54 | } 55 | 56 | const postUnionSSE = await api.post('/v1/sse_stream_union', { 57 | sse: true, 58 | body: { 59 | count: 20, 60 | }, 61 | }); 62 | 63 | for await (const event of postUnionSSE) { 64 | // this is a union 65 | const data = event.data; 66 | if (!data) { 67 | // data missing and event set is legal in SSE but not in this api 68 | throw new Error('missing data'); 69 | } 70 | 71 | if ('digits' in data) { 72 | // ts knows this is a number 73 | console.log(data.digits); 74 | } else if ('text' in data) { 75 | // ts knows this is a string 76 | console.log(data.text); 77 | } else { 78 | // ts knows this is the only remaining possibility and it's a record. 79 | console.log(data.obj); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /examples/client/src/index.ts: -------------------------------------------------------------------------------- 1 | import type {App} from '@kaito-http-examples/bench/src/index.ts'; 2 | import {createKaitoHTTPClient} from '@kaito-http/client'; 3 | 4 | const api = createKaitoHTTPClient({ 5 | base: 'http://localhost:3000', 6 | }); 7 | 8 | const test = await api.get('/hello'); 9 | console.log(test); 10 | 11 | const stream = await api.get('/stream', { 12 | sse: true, 13 | }); 14 | 15 | for await (const chunk of stream) { 16 | console.log(chunk); 17 | } 18 | -------------------------------------------------------------------------------- /examples/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/deno/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http-examples/deno", 3 | "private": true, 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "homepage": "https://github.com/kaito-http/kaito", 8 | "repository": "https://github.com/kaito-http/kaito", 9 | "license": "MIT", 10 | "version": "3.2.1", 11 | "scripts": { 12 | "start": "deno --allow-net src/index.ts" 13 | }, 14 | "dependencies": { 15 | "@kaito-http/core": "workspace:^" 16 | }, 17 | "devDependencies": { 18 | "typescript": "^5.7.2" 19 | }, 20 | "keywords": [ 21 | "typescript", 22 | "http", 23 | "framework" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/deno/src/context.ts: -------------------------------------------------------------------------------- 1 | import {createUtilities, KaitoRequest} from '@kaito-http/core'; 2 | 3 | const start = performance.now(); 4 | 5 | export interface AppContext { 6 | req: KaitoRequest; 7 | uptime: number; 8 | } 9 | 10 | export const {getContext, router} = createUtilities(async req => { 11 | return { 12 | req, 13 | uptime: performance.now() - start, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /examples/deno/src/index.ts: -------------------------------------------------------------------------------- 1 | // fake typing deno 2 | declare const Deno: {serve: (options: {port: number}, handler: (req: Request) => Promise) => {}}; 3 | 4 | import {createKaitoHandler} from '@kaito-http/core'; 5 | import {getContext, router} from './context.ts'; 6 | 7 | const root = router().get('/', async () => 'hello from Deno.serve()'); 8 | 9 | const fetch = createKaitoHandler({ 10 | router: root, 11 | getContext, 12 | 13 | onError: async ({error}) => ({ 14 | status: 500, 15 | message: error.message, 16 | }), 17 | }); 18 | 19 | Deno.serve({port: 3000}, fetch); 20 | 21 | export type App = typeof root; 22 | -------------------------------------------------------------------------------- /examples/deno/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kaito-http", 3 | "version": "3.2.1", 4 | "repository": "https://github.com/kaito-http/kaito", 5 | "author": "Alistair Smith ", 6 | "license": "MIT", 7 | "private": true, 8 | "type": "module", 9 | "workspaces": { 10 | "packages": [ 11 | "packages/*", 12 | "apps/*", 13 | "examples/*" 14 | ] 15 | }, 16 | "scripts": { 17 | "lint": "yarn workspaces foreach -Rp -j unlimited run prettier . --write", 18 | "compile": "tsc -build", 19 | "build": "yarn workspaces foreach -Rp --topological-dev -j unlimited --from '@kaito-http/*' --no-private run build", 20 | "test-all": "yarn workspaces foreach -Rp -j unlimited --from '@kaito-http/*' --no-private run test", 21 | "attw": "yarn workspaces foreach -Rp -j unlimited --from '@kaito-http/*' --no-private run attw", 22 | "release": "yarn constraints --fix && yarn build && yarn publish-all", 23 | "release-beta": "yarn constraints --fix && yarn build && yarn publish-beta", 24 | "publish-all": "yarn workspaces foreach -R --from '@kaito-http/*' --no-private npm publish --access public", 25 | "publish-beta": "yarn workspaces foreach -R --from '@kaito-http/*' --no-private npm publish --access public --tag=beta" 26 | }, 27 | "description": "Functional HTTP Framework for TypeScript", 28 | "homepage": "https://github.com/kaito-http/kaito", 29 | "keywords": [ 30 | "typescript", 31 | "http", 32 | "framework" 33 | ], 34 | "devDependencies": { 35 | "prettier": "^3.4.2", 36 | "tsup": "^8.3.5", 37 | "typescript": "^5.7.2" 38 | }, 39 | "packageManager": "yarn@4.5.0" 40 | } 41 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http/client", 3 | "version": "3.2.1", 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "scripts": { 8 | "build": "tsup", 9 | "attw": "attw --profile node18 --pack .", 10 | "test": "node --test --import=tsx src/**/*.test.ts" 11 | }, 12 | "exports": { 13 | "./package.json": "./package.json", 14 | ".": { 15 | "import": "./dist/index.js", 16 | "require": "./dist/index.cjs" 17 | } 18 | }, 19 | "homepage": "https://github.com/kaito-http/kaito", 20 | "repository": "https://github.com/kaito-http/kaito", 21 | "keywords": [ 22 | "typescript", 23 | "http", 24 | "framework" 25 | ], 26 | "license": "MIT", 27 | "files": [ 28 | "package.json", 29 | "README.md", 30 | "dist" 31 | ], 32 | "devDependencies": { 33 | "@arethetypeswrong/cli": "^0.17.2", 34 | "@kaito-http/core": "workspace:^", 35 | "tsup": "^8.3.5" 36 | }, 37 | "dependencies": { 38 | "pathcat": "^1.4.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/client/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {describe, test} from 'node:test'; 3 | import {createKaitoHTTPClient, KaitoClientHTTPError, KaitoSSEStream} from './index.ts'; 4 | 5 | import type {App} from './router.test.ts'; 6 | 7 | describe('KaitoHTTPClient', () => { 8 | describe('Basic HTTP operations', () => { 9 | test('should make GET requests', async () => { 10 | const mockFetch = async (req: Request) => { 11 | assert.equal(req.method, 'GET'); 12 | assert.equal(req.url, 'http://api.example.com/users?limit=10'); 13 | return new Response( 14 | JSON.stringify({ 15 | success: true, 16 | data: [{id: 1, name: 'Test User'}], 17 | }), 18 | ); 19 | }; 20 | 21 | const client = createKaitoHTTPClient({ 22 | base: 'http://api.example.com', 23 | fetch: mockFetch, 24 | }); 25 | 26 | const result = await client.get('/users', { 27 | query: {limit: '10'}, 28 | }); 29 | 30 | assert.deepEqual(result, [{id: 1, name: 'Test User'}]); 31 | }); 32 | 33 | test('should make POST requests with body', async () => { 34 | const mockFetch = async (req: Request) => { 35 | assert.equal(req.method, 'POST'); 36 | assert.equal(req.url, 'http://api.example.com/users'); 37 | const body = await req.json(); 38 | assert.deepEqual(body, {name: 'New User'}); 39 | return new Response( 40 | JSON.stringify({ 41 | success: true, 42 | data: {id: 1, name: 'New User'}, 43 | }), 44 | ); 45 | }; 46 | 47 | const client = createKaitoHTTPClient({ 48 | base: 'http://api.example.com', 49 | fetch: mockFetch, 50 | }); 51 | 52 | const result = await client.post('/users', { 53 | body: {name: 'New User'}, 54 | }); 55 | assert.deepEqual(result, {id: 1, name: 'New User'}); 56 | }); 57 | 58 | test('should handle URL parameters', async () => { 59 | const mockFetch = async (req: Request) => { 60 | assert.equal(req.method, 'GET'); 61 | assert.equal(req.url, 'http://api.example.com/users/123'); 62 | return new Response( 63 | JSON.stringify({ 64 | success: true, 65 | data: {id: 123, name: 'Test User'}, 66 | }), 67 | ); 68 | }; 69 | 70 | const client = createKaitoHTTPClient({ 71 | base: 'http://api.example.com', 72 | fetch: mockFetch, 73 | }); 74 | 75 | const result = await client.get('/users/:id', { 76 | params: {id: '123'}, 77 | }); 78 | assert.deepEqual(result, {id: 123, name: 'Test User'}); 79 | }); 80 | 81 | test('should handle query parameters', async () => { 82 | const mockFetch = async (req: Request) => { 83 | assert.equal(req.method, 'GET'); 84 | const url = new URL(req.url); 85 | assert.equal(url.searchParams.get('limit'), '10'); 86 | return new Response( 87 | JSON.stringify({ 88 | success: true, 89 | data: [{id: 1, name: 'Test User'}], 90 | }), 91 | ); 92 | }; 93 | 94 | const client = createKaitoHTTPClient({ 95 | base: 'http://api.example.com', 96 | fetch: mockFetch, 97 | }); 98 | 99 | const result = await client.get('/users', { 100 | query: {limit: '10'}, 101 | }); 102 | 103 | assert.deepEqual(result, [{id: 1, name: 'Test User'}]); 104 | }); 105 | }); 106 | 107 | describe('Error handling', () => { 108 | test('should throw KaitoClientHTTPError for error responses', async () => { 109 | const mockFetch = async () => { 110 | return Response.json( 111 | { 112 | success: false, 113 | message: 'Not Found', 114 | data: null, 115 | }, 116 | {status: 404}, 117 | ); 118 | }; 119 | 120 | const client = createKaitoHTTPClient({ 121 | base: 'http://api.example.com', 122 | fetch: mockFetch, 123 | }); 124 | 125 | await assert.rejects( 126 | async () => { 127 | await client.get('/users/:id', { 128 | params: {id: '999'}, 129 | }); 130 | }, 131 | (error: unknown) => { 132 | assert(error instanceof KaitoClientHTTPError); 133 | assert.equal(error.response.status, 404); 134 | assert.deepEqual(error.body, { 135 | success: false, 136 | message: 'Not Found', 137 | data: null, 138 | }); 139 | return true; 140 | }, 141 | ); 142 | }); 143 | 144 | test('should handle non-JSON error responses', async () => { 145 | const mockFetch = async () => { 146 | return new Response('Internal Server Error', { 147 | status: 500, 148 | headers: {'Content-Type': 'text/plain'}, 149 | }); 150 | }; 151 | 152 | const client = createKaitoHTTPClient({ 153 | base: 'http://api.example.com', 154 | fetch: mockFetch, 155 | }); 156 | 157 | await assert.rejects( 158 | async () => { 159 | await client.get('/users', { 160 | query: {limit: '10'}, 161 | }); 162 | }, 163 | (error: unknown) => { 164 | assert(error instanceof KaitoClientHTTPError); 165 | assert(error.body.message.includes('Request to')); 166 | assert.equal(error.response.status, 500); 167 | return true; 168 | }, 169 | ); 170 | }); 171 | }); 172 | 173 | describe('SSE Streaming', () => { 174 | test('should handle SSE streams', async () => { 175 | const mockEvents = [ 176 | {event: 'message', data: 'Hello'}, 177 | {event: 'message', data: 'World'}, 178 | ]; 179 | 180 | const mockFetch = async () => { 181 | const encoder = new TextEncoder(); 182 | const stream = new ReadableStream({ 183 | start(controller) { 184 | mockEvents.forEach(event => { 185 | const eventString = `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`; 186 | controller.enqueue(encoder.encode(eventString)); 187 | }); 188 | controller.close(); 189 | }, 190 | }); 191 | 192 | return new Response(stream, { 193 | headers: {'Content-Type': 'text/event-stream'}, 194 | }); 195 | }; 196 | 197 | const client = createKaitoHTTPClient({ 198 | base: 'http://api.example.com', 199 | fetch: mockFetch, 200 | }); 201 | 202 | const stream = await client.get('/stream', { 203 | sse: true, 204 | }); 205 | 206 | assert(stream instanceof KaitoSSEStream); 207 | 208 | const events = []; 209 | for await (const event of stream) { 210 | events.push(event); 211 | } 212 | 213 | assert.equal(events.length, 2); 214 | assert.deepEqual(events[0], {event: 'message', data: 'Hello'}); 215 | assert.deepEqual(events[1], {event: 'message', data: 'World'}); 216 | }); 217 | }); 218 | 219 | describe('Request modifications', () => { 220 | test('should allow request modification via before hook', async () => { 221 | const client = createKaitoHTTPClient({ 222 | base: 'http://api.example.com', 223 | before: async (url, init) => { 224 | const request = new Request(url, init); 225 | request.headers.set('Authorization', 'Bearer test-token'); 226 | return request; 227 | }, 228 | fetch: async req => { 229 | assert.equal(req.headers.get('Authorization'), 'Bearer test-token'); 230 | return new Response( 231 | JSON.stringify({ 232 | success: true, 233 | data: [{id: 1, name: 'Test User'}], 234 | }), 235 | ); 236 | }, 237 | }); 238 | 239 | const result = await client.get('/users', { 240 | query: {limit: '10'}, 241 | }); 242 | 243 | assert.deepEqual(result, [{id: 1, name: 'Test User'}]); 244 | }); 245 | 246 | test('should handle AbortController signals', async () => { 247 | const controller = new AbortController(); 248 | const client = createKaitoHTTPClient({ 249 | base: 'http://api.example.com', 250 | fetch: () => 251 | new Promise((_, reject) => { 252 | controller.signal.addEventListener('abort', () => { 253 | reject(new DOMException('The operation was aborted.', 'AbortError')); 254 | }); 255 | setTimeout(() => controller.abort(), 0); 256 | }), 257 | }); 258 | 259 | await assert.rejects( 260 | () => 261 | client.get('/users', { 262 | signal: controller.signal, 263 | query: {limit: '10'}, 264 | }), 265 | {name: 'AbortError'}, 266 | ); 267 | }); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /packages/client/src/index.ts: -------------------------------------------------------------------------------- 1 | import type {APIResponse, ErroredAPIResponse, InferParsable, InferRoutes, KaitoMethod, Router} from '@kaito-http/core'; 2 | import type {KaitoSSEResponse, SSEEvent} from '@kaito-http/core/stream'; 3 | import {pathcat} from 'pathcat'; 4 | import pkg from '../package.json' with {type: 'json'}; 5 | 6 | export type PickRequiredKeys = { 7 | [K in keyof T as undefined extends T[K] ? never : K]: T[K]; 8 | }; 9 | 10 | export type IfNeverThenUndefined = [T] extends [never] ? undefined : T; 11 | export type IfNoKeysThenUndefined = [keyof T] extends [never] ? undefined : T; 12 | 13 | export type MakeQueryUndefinedIfNoRequiredKeys = [keyof T] extends [never] 14 | ? undefined 15 | : [keyof PickRequiredKeys] extends [never] 16 | ? T | undefined 17 | : T; 18 | 19 | export type RemoveOnlyUndefinedKeys = { 20 | [K in keyof T as [T[K]] extends [undefined] ? never : K]: T[K]; 21 | }; 22 | 23 | export type UndefinedKeysToOptional = { 24 | [K in keyof T as undefined extends T[K] ? K : never]?: T[K]; 25 | } & { 26 | [K in keyof T as undefined extends T[K] ? never : K]: T[K]; 27 | }; 28 | 29 | export type IsExactly = T extends A ? (A extends T ? True : False) : False; 30 | 31 | export type AlwaysEnabledOptions = { 32 | signal?: AbortSignal | null | undefined; 33 | }; 34 | 35 | export type ExtractRouteParams = string extends T 36 | ? string 37 | : T extends `${string}:${infer Param}/${infer Rest}` 38 | ? Param | ExtractRouteParams 39 | : T extends `${string}:${infer Param}` 40 | ? Param 41 | : never; 42 | 43 | export class KaitoClientHTTPError extends Error { 44 | constructor( 45 | public readonly request: Request, 46 | public readonly response: Response, 47 | public readonly body: ErroredAPIResponse, 48 | ) { 49 | super(body.message); 50 | } 51 | } 52 | 53 | export type JSONIFY = T extends {toJSON(...args: any): infer R} 54 | ? R 55 | : T extends Record 56 | ? {[K in keyof T]: JSONIFY} 57 | : T extends Array 58 | ? Array> 59 | : T; 60 | 61 | export type Prettify = { 62 | [K in keyof T]: T[K]; 63 | } & {}; 64 | 65 | export interface KaitoHTTPClientRootOptions { 66 | /** 67 | * The base URL for all API requests. 68 | * All paths will be resolved relative to this URL. 69 | * 70 | * @example 'https://api.example.com' 71 | */ 72 | base: string; 73 | 74 | /** 75 | * A function that is called before the request is made. 76 | * Useful for adding headers, authentication, etc. 77 | * 78 | * @param url The URL to make the request to 79 | * @param init The request init object 80 | * @returns A Promise resolving to the modified Request object 81 | * 82 | * @example 83 | * ```ts 84 | * before: async (url, init) => { 85 | * const request = new Request(url, init); 86 | * request.headers.set('Authorization', 'Bearer token'); 87 | * return request; 88 | * } 89 | * ``` 90 | */ 91 | before?: (url: URL, init: RequestInit) => Promise; 92 | 93 | /** 94 | * Custom fetch implementation to use instead of the global fetch. 95 | * Useful for adding custom fetch behavior or using a different fetch implementation. 96 | * 97 | * @param request The Request object to fetch 98 | * @returns A Promise resolving to the Response 99 | * 100 | * @example 101 | * ```ts 102 | * fetch: async (request) => { 103 | * const response = await customFetch(request); 104 | * return response; 105 | * } 106 | * ``` 107 | */ 108 | fetch?: (request: Request) => Promise; 109 | } 110 | 111 | export class KaitoSSEStream> implements AsyncIterable { 112 | private readonly stream: ReadableStream; 113 | 114 | // buffer needed because when reading from the stream, 115 | // we might receive a chunk that: 116 | // - Contains multiple complete events 117 | // - Contains partial events 118 | // - Cuts an event in the middle 119 | private buffer = ''; 120 | 121 | public constructor(stream: ReadableStream) { 122 | this.stream = stream.pipeThrough(new TextDecoderStream()); 123 | } 124 | 125 | private parseEvent(eventText: string): T | null { 126 | const lines = eventText.split('\n'); 127 | const event: Partial = {}; 128 | 129 | for (const line of lines) { 130 | const colonIndex = line.indexOf(':'); 131 | 132 | if (colonIndex === -1) { 133 | continue; 134 | } 135 | 136 | const field = line.slice(0, colonIndex).trim(); 137 | const value = line.slice(colonIndex + 1).trim(); 138 | 139 | switch (field) { 140 | case 'event': 141 | event.event = value; 142 | break; 143 | case 'data': 144 | event.data = JSON.parse(value) as T['data']; 145 | break; 146 | case 'id': 147 | event.id = value; 148 | break; 149 | case 'retry': 150 | event.retry = parseInt(value, 10); 151 | break; 152 | } 153 | } 154 | 155 | return 'data' in event ? (event as T) : null; 156 | } 157 | 158 | async *[Symbol.asyncIterator](): AsyncGenerator { 159 | for await (const chunk of this.stream) { 160 | this.buffer += chunk; 161 | const events = this.buffer.split('\n\n'); 162 | this.buffer = events.pop() || ''; 163 | 164 | for (const eventText of events) { 165 | const event = this.parseEvent(eventText); 166 | if (event) yield event; 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * Get the underlying stream for more advanced use cases 173 | */ 174 | public getStream() { 175 | return this.stream; 176 | } 177 | } 178 | 179 | export function createKaitoHTTPClient = never>( 180 | rootOptions: KaitoHTTPClientRootOptions, 181 | ) { 182 | type ROUTES = InferRoutes; 183 | 184 | type RequestOptionsFor['path']> = { 185 | body: IfNeverThenUndefined['body']>>['input']>; 186 | 187 | params: IfNoKeysThenUndefined, string>>; 188 | 189 | query: MakeQueryUndefinedIfNoRequiredKeys< 190 | Prettify< 191 | UndefinedKeysToOptional<{ 192 | [Key in keyof NonNullable['query']>]: InferParsable< 193 | NonNullable['query']>[Key] 194 | >['input']; 195 | }> 196 | > 197 | >; 198 | 199 | sse: IfNeverThenUndefined< 200 | JSONIFY['run']>>> extends KaitoSSEResponse 201 | ? true 202 | : never 203 | >; 204 | 205 | response: IfNeverThenUndefined< 206 | IsExactly['run']>>>, Response, true, never> 207 | >; 208 | }; 209 | 210 | const create = (method: M) => { 211 | return async ['path']>( 212 | path: Path, 213 | ...[options = {}]: [keyof PickRequiredKeys>] extends [never] 214 | ? [options?: AlwaysEnabledOptions] 215 | : [options: RemoveOnlyUndefinedKeys>> & AlwaysEnabledOptions] 216 | ): Promise< 217 | JSONIFY['run']>>> extends KaitoSSEResponse< 218 | infer U extends SSEEvent 219 | > 220 | ? KaitoSSEStream 221 | : JSONIFY['run']>>> 222 | > => { 223 | const params = (options as {params?: {}}).params ?? {}; 224 | const query = (options as {query?: {}}).query ?? {}; 225 | const body = (options as {body?: unknown}).body ?? undefined; 226 | 227 | const url = new URL(pathcat(rootOptions.base, path, {...params, ...query})); 228 | 229 | const headers = new Headers({ 230 | Accept: 'application/json', 231 | }); 232 | 233 | if (typeof window === 'undefined' && !headers.has('User-Agent')) { 234 | headers.set('User-Agent', `kaito-http/client ${pkg.version}`); 235 | } 236 | 237 | const init: RequestInit = { 238 | headers, 239 | method, 240 | }; 241 | 242 | if (options.signal !== undefined) { 243 | init.signal = options.signal; 244 | } 245 | 246 | if (body !== undefined) { 247 | headers.set('Content-Type', 'application/json'); 248 | init.body = JSON.stringify(body); 249 | } 250 | 251 | const request = new Request(url, init); 252 | 253 | const response = await (rootOptions.fetch ?? fetch)( 254 | rootOptions.before ? await rootOptions.before(url, init) : request, 255 | ); 256 | 257 | if (!response.ok) { 258 | if (response.headers.get('content-type')?.includes('application/json')) { 259 | // Try to assume non-ok responses are from `.onError()` and so therefore do have a body 260 | // but in the case we somehow got a non-ok response without a body, we'll just throw 261 | // an error with the status text and status code 262 | 263 | const json = await response.json().then( 264 | data => data as ErroredAPIResponse, 265 | () => null, 266 | ); 267 | 268 | if (json) { 269 | throw new KaitoClientHTTPError(request, response, json); 270 | } 271 | } 272 | 273 | throw new KaitoClientHTTPError(request, response, { 274 | message: `Request to ${url} failed with status ${response.status} and no obvious body`, 275 | success: false, 276 | data: null, 277 | }); 278 | } 279 | 280 | if ('response' in options && options.response) { 281 | return response as never; 282 | } 283 | 284 | if ('sse' in options && options.sse) { 285 | if (response.body === null) { 286 | throw new Error('Response body is null, so cannot stream'); 287 | } 288 | 289 | return new KaitoSSEStream(response.body) as never; 290 | } 291 | 292 | const result = (await response.json()) as APIResponse; 293 | 294 | if (!result.success) { 295 | // In theory success is always true because we've already checked the response status 296 | throw new KaitoClientHTTPError(request, response, result); 297 | } 298 | 299 | return result.data; 300 | }; 301 | }; 302 | 303 | return { 304 | get: create('GET'), 305 | post: create('POST'), 306 | put: create('PUT'), 307 | patch: create('PATCH'), 308 | delete: create('DELETE'), 309 | head: create('HEAD'), 310 | options: create('OPTIONS'), 311 | }; 312 | } 313 | 314 | export function isKaitoClientHTTPError(error: unknown): error is KaitoClientHTTPError { 315 | return error instanceof KaitoClientHTTPError; 316 | } 317 | -------------------------------------------------------------------------------- /packages/client/src/router.test.ts: -------------------------------------------------------------------------------- 1 | import type {Route, Router} from '@kaito-http/core'; 2 | import type {KaitoSSEResponse, SSEEvent} from '@kaito-http/core/stream'; 3 | import type {z} from 'zod'; 4 | 5 | interface Ctx {} 6 | 7 | export type App = Router< 8 | Ctx, 9 | Ctx, 10 | | Route< 11 | Ctx, 12 | Ctx, 13 | { 14 | id: number; 15 | name: string; 16 | }[], 17 | '/users', 18 | 'GET', 19 | { 20 | limit: z.ZodPipeline, z.ZodNumber>; 21 | }, 22 | never 23 | > 24 | | Route< 25 | Ctx, 26 | Ctx, 27 | { 28 | id: number; 29 | name: string; 30 | }, 31 | '/users', 32 | 'POST', 33 | {}, 34 | z.ZodObject< 35 | { 36 | name: z.ZodString; 37 | }, 38 | 'strip', 39 | z.ZodTypeAny, 40 | { 41 | name: string; 42 | }, 43 | { 44 | name: string; 45 | } 46 | > 47 | > 48 | | Route< 49 | Ctx, 50 | Ctx, 51 | { 52 | id: number; 53 | name: string; 54 | }, 55 | '/users/:id', 56 | 'GET', 57 | {}, 58 | never 59 | > 60 | | Route>, '/stream', 'GET', {}, never> 61 | >; 62 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | }, 6 | "include": ["package.json", "src/**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/client/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {config} from '../../tsup.config.ts'; 2 | export default config; 3 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # `kaito-http` 2 | 3 | #### An HTTP Framework for TypeScript 4 | 5 | View the [documentation here](https://kaito.cloud) 6 | 7 | #### Credits 8 | 9 | - [Alistair Smith](https://twitter.com/alistaiir) 10 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http/core", 3 | "version": "3.2.1", 4 | "type": "module", 5 | "author": "Alistair Smith ", 6 | "description": "Functional HTTP Framework for TypeScript", 7 | "scripts": { 8 | "build": "tsup", 9 | "attw": "attw --profile node16 --pack .", 10 | "test": "node --test --import=tsx ./src/**/*.test.ts" 11 | }, 12 | "exports": { 13 | "./package.json": "./package.json", 14 | ".": { 15 | "import": "./dist/index.js", 16 | "require": "./dist/index.cjs" 17 | }, 18 | "./stream": { 19 | "import": "./dist/stream/stream.js", 20 | "require": "./dist/stream/stream.cjs" 21 | }, 22 | "./cors": { 23 | "import": "./dist/cors/cors.js", 24 | "require": "./dist/cors/cors.cjs" 25 | } 26 | }, 27 | "homepage": "https://github.com/kaito-http/kaito", 28 | "repository": "https://github.com/kaito-http/kaito", 29 | "keywords": [ 30 | "typescript", 31 | "http", 32 | "framework" 33 | ], 34 | "license": "MIT", 35 | "devDependencies": { 36 | "@arethetypeswrong/cli": "^0.17.2", 37 | "@types/node": "^22.10.2", 38 | "tsup": "^8.3.5", 39 | "typescript": "^5.7.2" 40 | }, 41 | "files": [ 42 | "package.json", 43 | "README.md", 44 | "dist" 45 | ], 46 | "bugs": { 47 | "url": "https://github.com/kaito-http/kaito/issues" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/core/src/cors/cors.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {describe, it} from 'node:test'; 3 | import {experimental_createCORSTransform, experimental_createOriginMatcher} from './cors.ts'; 4 | 5 | describe('CORS', () => { 6 | describe('createOriginMatcher', () => { 7 | it('should match exact origins', () => { 8 | const matcher = experimental_createOriginMatcher(['https://example.com']); 9 | 10 | assert.equal(matcher('https://example.com'), true); 11 | assert.equal(matcher('http://example.com'), false); 12 | assert.equal(matcher('https://subdomain.example.com'), false); 13 | }); 14 | 15 | it('should match wildcard subdomains', () => { 16 | const matcher = experimental_createOriginMatcher(['*.example.com']); 17 | 18 | assert.equal(matcher('https://app.example.com'), true); 19 | assert.equal(matcher('http://staging.example.com'), true); 20 | assert.equal(matcher('https://example.com'), false); 21 | assert.equal(matcher('https://evil-example.com'), false); 22 | }); 23 | 24 | it('should handle multiple patterns', () => { 25 | const matcher = experimental_createOriginMatcher([ 26 | 'https://example.com', 27 | '*.trusted.com', 28 | 'http://localhost:3000', 29 | ]); 30 | 31 | assert.equal(matcher('https://example.com'), true); 32 | assert.equal(matcher('https://app.trusted.com'), true); 33 | assert.equal(matcher('http://localhost:3000'), true); 34 | assert.equal(matcher('https://untrusted.com'), false); 35 | }); 36 | 37 | it('should escape special regex characters', () => { 38 | const matcher = experimental_createOriginMatcher([ 39 | 'https://special-chars.com?test=1', 40 | '*.special-chars.com+test', 41 | ]); 42 | 43 | assert.equal(matcher('https://special-chars.com?test=1'), true); 44 | assert.equal(matcher('https://app.special-chars.com+test'), true); 45 | assert.equal(matcher('https://special-chars.comtest'), false); 46 | }); 47 | 48 | it('should handle empty origins array', () => { 49 | const matcher = experimental_createOriginMatcher([]); 50 | assert.equal(matcher('https://example.com'), false); 51 | }); 52 | }); 53 | 54 | describe('createCORSTransform', () => { 55 | it('should set CORS headers for allowed origins', () => { 56 | const corsTransform = experimental_createCORSTransform(['https://example.com']); 57 | const request = new Request('https://api.example.com', { 58 | headers: {Origin: 'https://example.com'}, 59 | }); 60 | const response = new Response(null, { 61 | headers: new Headers(), 62 | }); 63 | 64 | corsTransform(request, response); 65 | 66 | assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://example.com'); 67 | assert.equal(response.headers.get('Access-Control-Allow-Methods'), 'GET, POST, PUT, DELETE, OPTIONS'); 68 | assert.equal(response.headers.get('Access-Control-Allow-Headers'), 'Content-Type, Authorization'); 69 | assert.equal(response.headers.get('Access-Control-Max-Age'), '86400'); 70 | assert.equal(response.headers.get('Access-Control-Allow-Credentials'), 'true'); 71 | }); 72 | 73 | it('should not set CORS headers for disallowed origins', () => { 74 | const corsTransform = experimental_createCORSTransform(['https://example.com']); 75 | const request = new Request('https://api.example.com', { 76 | headers: {Origin: 'https://evil.com'}, 77 | }); 78 | const response = new Response(null, { 79 | headers: new Headers(), 80 | }); 81 | 82 | corsTransform(request, response); 83 | 84 | assert.equal(response.headers.get('Access-Control-Allow-Origin'), null); 85 | assert.equal(response.headers.get('Access-Control-Allow-Methods'), null); 86 | assert.equal(response.headers.get('Access-Control-Allow-Headers'), null); 87 | assert.equal(response.headers.get('Access-Control-Max-Age'), null); 88 | assert.equal(response.headers.get('Access-Control-Allow-Credentials'), null); 89 | }); 90 | 91 | it('should handle requests without Origin header', () => { 92 | const corsTransform = experimental_createCORSTransform(['https://example.com']); 93 | const request = new Request('https://api.example.com'); 94 | const response = new Response(null, { 95 | headers: new Headers(), 96 | }); 97 | 98 | corsTransform(request, response); 99 | 100 | assert.equal(response.headers.get('Access-Control-Allow-Origin'), null); 101 | }); 102 | 103 | it('should support wildcard origins in transform', () => { 104 | const corsTransform = experimental_createCORSTransform(['*.example.com']); 105 | const request = new Request('https://api.example.com', { 106 | headers: {Origin: 'https://app.example.com'}, 107 | }); 108 | const response = new Response(null, { 109 | headers: new Headers(), 110 | }); 111 | 112 | corsTransform(request, response); 113 | 114 | assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); 115 | }); 116 | 117 | it('should preserve existing headers not related to CORS', () => { 118 | const corsTransform = experimental_createCORSTransform(['https://example.com']); 119 | const request = new Request('https://api.example.com', { 120 | headers: {Origin: 'https://example.com'}, 121 | }); 122 | const response = new Response(null, { 123 | headers: new Headers({ 124 | 'Content-Type': 'application/json', 125 | 'X-Custom-Header': 'test', 126 | }), 127 | }); 128 | 129 | corsTransform(request, response); 130 | 131 | assert.equal(response.headers.get('Content-Type'), 'application/json'); 132 | assert.equal(response.headers.get('X-Custom-Header'), 'test'); 133 | assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://example.com'); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /packages/core/src/cors/cors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a function that matches origins against a predefined set of patterns, supporting wildcards. 3 | * The matcher handles both exact matches and wildcard subdomain patterns (e.g., '*.example.com'). 4 | * 5 | * **⚠️ This API is experimental and may change or even be removed in the future. ⚠️** 6 | * 7 | * @param origins Array of origin patterns to match against. 8 | * Patterns can be exact origins (e.g., 'https://example.com') or wildcard patterns (e.g., '*.example.com') that match subdomains. 9 | * @returns A function that tests if an origin matches any of the patterns 10 | * 11 | * @example 12 | * ```typescript 13 | * const allowedOrigins = [ 14 | * 'https://example.com', 15 | * '*.trusted-domain.com' // Won't match https://evil-domain.com, only subdomains 16 | * ]; 17 | * 18 | * const matcher = createOriginMatcher(allowedOrigins); 19 | * 20 | * // Exact match 21 | * console.log(matcher('https://example.com')); // true 22 | * console.log(matcher('http://example.com')); // false 23 | * 24 | * // Wildcard subdomain matches 25 | * console.log(matcher('https://app.trusted-domain.com')); // true 26 | * console.log(matcher('https://staging.trusted-domain.com')); // true 27 | * console.log(matcher('https://trusted-domain.com')); // false, because it's not a subdomain 28 | * console.log(matcher('https://evil-domain.com')); // false 29 | * ``` 30 | */ 31 | export function experimental_createOriginMatcher(origins: string[]) { 32 | if (origins.length === 0) { 33 | return () => false; //lol 34 | } 35 | 36 | const source = origins 37 | .map(origin => { 38 | if (origin.startsWith('*.')) { 39 | const escapedDomain = origin.slice(2).replace(/[.+?^${}()|[\]\\]/g, '\\$&'); 40 | return `^(?:https?:\/\/)[^.]+\\.${escapedDomain}$`; 41 | } else { 42 | const escapedOrigin = origin.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); 43 | return `^${escapedOrigin}$`; 44 | } 45 | }) 46 | .join('|'); 47 | 48 | const regex = new RegExp(source); 49 | 50 | return (origin: string) => regex.test(origin); 51 | } 52 | 53 | /** 54 | * Create a function to apply CORS headers with sane defaults for most apps. 55 | * 56 | * **⚠️ This API is experimental and may change or even be removed in the future. ⚠️** 57 | * 58 | * @param options Options object 59 | * @returns A function that will mutate the Response object by applying the CORS headers 60 | * @example 61 | * ```ts 62 | * const cors = createCORSHandler({ 63 | * origins: ['https://example.com', "*.allows-subdomains.com", "http://localhost:3000"], 64 | * }); 65 | * 66 | * const handler = createKaitoHandler({ 67 | * // ... 68 | * transform: async (request, response) => { 69 | * cors(request, response); 70 | * } 71 | * }); 72 | * ``` 73 | */ 74 | export function experimental_createCORSTransform(origins: string[]) { 75 | const matcher = experimental_createOriginMatcher(origins); 76 | 77 | return (request: Request, response: Response) => { 78 | const origin = request.headers.get('Origin'); 79 | 80 | if (origin && matcher(origin)) { 81 | response.headers.set('Access-Control-Allow-Origin', origin); 82 | response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 83 | response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 84 | response.headers.set('Access-Control-Max-Age', '86400'); 85 | response.headers.set('Access-Control-Allow-Credentials', 'true'); 86 | } 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /packages/core/src/error.ts: -------------------------------------------------------------------------------- 1 | export class WrappedError extends Error { 2 | public static maybe(maybeError: T) { 3 | if (maybeError instanceof Error) { 4 | return maybeError; 5 | } 6 | 7 | return WrappedError.from(maybeError); 8 | } 9 | 10 | public static from(data: T) { 11 | return new WrappedError(data); 12 | } 13 | 14 | private constructor(public readonly data: T) { 15 | super('Something was thrown, but it was not an instance of Error, so a WrappedError was created.'); 16 | } 17 | } 18 | 19 | export class KaitoError extends Error { 20 | constructor( 21 | public readonly status: number, 22 | message: string, 23 | ) { 24 | super(message); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/src/handler.ts: -------------------------------------------------------------------------------- 1 | import type {KaitoError} from './error.ts'; 2 | import type {KaitoRequest} from './request.ts'; 3 | import type {Router} from './router/router.ts'; 4 | import type {GetContext} from './util.ts'; 5 | 6 | export type HandlerConfig = { 7 | /** 8 | * The root router to mount on this handler. 9 | */ 10 | router: Router; 11 | 12 | /** 13 | * A function that is called to get the context for a request. 14 | * 15 | * This is useful for things like authentication, to pass in a database connection, etc. 16 | * 17 | * It's fine for this function to throw; if it does, the error is passed to the `onError` function. 18 | */ 19 | getContext: GetContext; 20 | 21 | /** 22 | * A function that is called when an error occurs inside a route handler. 23 | * 24 | * The result of this function is used to determine the response status and message, and is 25 | * always sent to the client. You could include logic to check for production vs development 26 | * environments here, and this would also be a good place to include error tracking 27 | * like Sentry or Rollbar. 28 | * 29 | * @param arg - The error thrown, and the KaitoRequest instance 30 | * @returns A KaitoError or an object with a status and message 31 | */ 32 | onError: (arg: {error: Error; req: KaitoRequest}) => Promise; 33 | 34 | /** 35 | * A function that is called before every request. Most useful for bailing out early in the case of an OPTIONS request. 36 | * 37 | * @example 38 | * ```ts 39 | * before: async req => { 40 | * if (req.method === 'OPTIONS') { 41 | * return new Response(null, {status: 204}); 42 | * } 43 | * } 44 | * ``` 45 | */ 46 | before?: (req: Request) => Promise | Response | void | undefined; 47 | 48 | /** 49 | * Transforms the response before it is sent to the client. Very useful for settings headers like CORS. 50 | * 51 | * You can also return a new response in this function, or just mutate the current one. 52 | * 53 | * This function WILL receive the result of `.before()` if you return a response from it. This means 54 | * you only need to define headers in a single place. 55 | * 56 | * @example 57 | * ```ts 58 | * transform: async (req, res) => { 59 | * res.headers.set('Access-Control-Allow-Origin', 'http://localhost:3000'); 60 | * res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 61 | * res.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 62 | * res.headers.set('Access-Control-Max-Age', '86400'); 63 | * res.headers.set('Access-Control-Allow-Credentials', 'true'); 64 | * } 65 | * ``` 66 | */ 67 | transform?: (req: Request, res: Response) => Promise | Response | void | undefined; 68 | }; 69 | 70 | export function createKaitoHandler(config: HandlerConfig) { 71 | const handle = config.router.freeze(config); 72 | 73 | return async (request: Request): Promise => { 74 | if (config.before) { 75 | const result = await config.before(request); 76 | 77 | if (result instanceof Response) { 78 | if (config.transform) { 79 | const result2 = await config.transform(request, result); 80 | if (result2 instanceof Response) return result; 81 | } 82 | 83 | return result; 84 | } 85 | } 86 | 87 | const response = await handle(request); 88 | 89 | if (config.transform) { 90 | const result = await config.transform(request, response); 91 | if (result instanceof Response) return result; 92 | } 93 | 94 | return response; 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /packages/core/src/head.ts: -------------------------------------------------------------------------------- 1 | import type {APIResponse} from './util.ts'; 2 | 3 | /** 4 | * This class is merely a wrapper around a `Headers` object and a status code. 5 | * It's used while the router is executing a route to store any mutations to the status 6 | * code or headers that the developer may want to make. 7 | * 8 | * This exists because there's otherwise no way to indicate back to Kaito that 9 | * the developer wants to change the status code or headers. 10 | * 11 | * @example 12 | * ```ts 13 | * const response = new KaitoHead(); 14 | * 15 | * response.status(200); 16 | * response.headers.set('Content-Type', 'application/json'); 17 | * 18 | * console.log(response.headers); // Headers {'content-type': 'application/json'} 19 | * ``` 20 | */ 21 | export class KaitoHead { 22 | private _headers: Headers | null; 23 | private _status: number; 24 | 25 | public constructor() { 26 | this._headers = null; 27 | this._status = 200; 28 | } 29 | 30 | public get headers() { 31 | if (this._headers === null) { 32 | this._headers = new Headers(); 33 | } 34 | 35 | return this._headers; 36 | } 37 | 38 | /** 39 | * Gets the status code of this KaitoHead instance 40 | * @returns The status code 41 | */ 42 | public status(): number; 43 | 44 | /** 45 | * Sets the status code of this KaitoHead instance 46 | * @param status The status code to set 47 | * @returns This KaitoHead instance 48 | */ 49 | public status(status: number): this; 50 | 51 | public status(status?: number) { 52 | if (status === undefined) { 53 | return this._status; 54 | } 55 | 56 | this._status = status; 57 | return this; 58 | } 59 | 60 | /** 61 | * Turn this KaitoHead instance into a Response instance 62 | * @param body The Kaito JSON format to be sent as the response body 63 | * @returns A Response instance, ready to be sent 64 | */ 65 | public toResponse(body: APIResponse): Response { 66 | const init: ResponseInit = { 67 | status: this._status, 68 | }; 69 | 70 | if (this._headers) { 71 | init.headers = this._headers; 72 | } 73 | 74 | return Response.json(body, init); 75 | } 76 | 77 | /** 78 | * Whether this KaitoHead instance has been touched/modified 79 | */ 80 | public get touched() { 81 | return this._status !== 200 || this._headers !== null; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error.ts'; 2 | export * from './handler.ts'; 3 | export * from './head.ts'; 4 | export * from './request.ts'; 5 | export * from './route.ts'; 6 | export * from './router/router.ts'; 7 | export * from './router/types.ts'; 8 | export * from './util.ts'; 9 | -------------------------------------------------------------------------------- /packages/core/src/request.ts: -------------------------------------------------------------------------------- 1 | export class KaitoRequest { 2 | public readonly url: URL; 3 | 4 | private readonly _request: Request; 5 | 6 | public constructor(url: URL, request: Request) { 7 | this._request = request; 8 | this.url = url; 9 | } 10 | 11 | public get headers() { 12 | return this._request.headers; 13 | } 14 | 15 | public get method() { 16 | return this._request.method; 17 | } 18 | 19 | public async arrayBuffer(): Promise { 20 | return this._request.arrayBuffer(); 21 | } 22 | 23 | public async blob(): Promise { 24 | return this._request.blob(); 25 | } 26 | 27 | public async formData(): Promise { 28 | return this._request.formData(); 29 | } 30 | 31 | public async bytes(): Promise { 32 | const buffer = await this.arrayBuffer(); 33 | return new Uint8Array(buffer); 34 | } 35 | 36 | public async json(): Promise { 37 | return this._request.json(); 38 | } 39 | 40 | public async text(): Promise { 41 | return this._request.text(); 42 | } 43 | 44 | public get request() { 45 | return this._request; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/core/src/route.ts: -------------------------------------------------------------------------------- 1 | import type {KaitoMethod} from './router/types.ts'; 2 | import type {ExtractRouteParams, InferParsable, Parsable} from './util.ts'; 3 | 4 | export type RouteArgument = { 5 | ctx: Context; 6 | body: BodyOutput; 7 | query: QueryOutput; 8 | params: ExtractRouteParams; 9 | }; 10 | 11 | export type AnyQueryDefinition = Record>; 12 | 13 | export type Through = (context: From) => Promise; 14 | 15 | export type Route< 16 | // Router context 17 | ContextFrom, 18 | ContextTo, 19 | // Route information 20 | Result, 21 | Path extends string, 22 | Method extends KaitoMethod, 23 | // Schemas 24 | Query extends AnyQueryDefinition, 25 | Body extends Parsable, 26 | > = { 27 | through: Through; 28 | body?: Body; 29 | query?: Query; 30 | path: Path; 31 | method: Method; 32 | run( 33 | arg: RouteArgument< 34 | Path, 35 | ContextTo, 36 | { 37 | [Key in keyof Query]: InferParsable['output']; 38 | }, 39 | InferParsable['output'] 40 | >, 41 | ): Promise | Result; 42 | }; 43 | 44 | export type AnyRoute = Route< 45 | ContextFrom, 46 | ContextTo, 47 | any, 48 | any, 49 | any, 50 | AnyQueryDefinition, 51 | any 52 | >; 53 | -------------------------------------------------------------------------------- /packages/core/src/router/router.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import {describe, it} from 'node:test'; 3 | import {z} from 'zod'; 4 | import {KaitoError} from '../error.ts'; 5 | import {Router} from './router.ts'; 6 | 7 | type Context = { 8 | userId: string; 9 | }; 10 | 11 | type AuthContext = Context & { 12 | isAdmin: boolean; 13 | }; 14 | 15 | describe('Router', () => { 16 | describe('create', () => { 17 | it('should create an empty router', () => { 18 | const router = Router.create(); 19 | assert.strictEqual(router.routes.size, 0); 20 | }); 21 | }); 22 | 23 | describe('route handling', () => { 24 | it('should handle GET requests', async () => { 25 | const router = Router.create().get('/users', { 26 | run: async () => ({users: []}), 27 | }); 28 | 29 | const handler = router.freeze({ 30 | getContext: async () => ({userId: '123'}), 31 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 32 | }); 33 | 34 | const response = await handler(new Request('http://localhost/users', {method: 'GET'})); 35 | const data = await response.json(); 36 | 37 | assert.strictEqual(response.status, 200); 38 | assert.deepStrictEqual(data, { 39 | success: true, 40 | data: {users: []}, 41 | message: 'OK', 42 | }); 43 | }); 44 | 45 | it('should handle POST requests with body parsing', async () => { 46 | const router = Router.create().post('/users', { 47 | body: z.object({name: z.string()}), 48 | run: async ({body}) => ({id: '1', name: body.name}), 49 | }); 50 | 51 | const handler = router.freeze({ 52 | getContext: async () => ({userId: '123'}), 53 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 54 | }); 55 | 56 | const response = await handler( 57 | new Request('http://localhost/users', { 58 | method: 'POST', 59 | headers: {'Content-Type': 'application/json'}, 60 | body: JSON.stringify({name: 'John'}), 61 | }), 62 | ); 63 | const data = await response.json(); 64 | 65 | assert.strictEqual(response.status, 200); 66 | assert.deepStrictEqual(data, { 67 | success: true, 68 | data: {id: '1', name: 'John'}, 69 | message: 'OK', 70 | }); 71 | }); 72 | 73 | it('should handle URL parameters', async () => { 74 | const router = Router.create().get('/users/:id', { 75 | run: async ({params}) => ({id: params.id}), 76 | }); 77 | 78 | const handler = router.freeze({ 79 | getContext: async () => ({userId: '123'}), 80 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 81 | }); 82 | 83 | const response = await handler(new Request('http://localhost/users/456', {method: 'GET'})); 84 | const data = await response.json(); 85 | 86 | assert.strictEqual(response.status, 200); 87 | assert.deepStrictEqual(data, { 88 | success: true, 89 | data: {id: '456'}, 90 | message: 'OK', 91 | }); 92 | }); 93 | 94 | it('should handle query parameters', async () => { 95 | const router = Router.create().get('/search', { 96 | query: { 97 | q: z.string(), 98 | limit: z 99 | .string() 100 | .transform(value => Number(value)) 101 | .pipe(z.number()), 102 | }, 103 | run: async ({query}) => ({ 104 | query: query.q, 105 | limit: query.limit, 106 | }), 107 | }); 108 | 109 | const handler = router.freeze({ 110 | getContext: async () => ({userId: '123'}), 111 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 112 | }); 113 | 114 | const response = await handler(new Request('http://localhost/search?q=test&limit=10', {method: 'GET'})); 115 | const data = await response.json(); 116 | 117 | assert.strictEqual(response.status, 200); 118 | assert.deepStrictEqual(data, { 119 | success: true, 120 | data: {query: 'test', limit: 10}, 121 | message: 'OK', 122 | }); 123 | }); 124 | }); 125 | 126 | describe('middleware and context', () => { 127 | it('should transform context through middleware', async () => { 128 | const router = Router.create() 129 | .through(async ctx => ({ 130 | ...ctx, 131 | isAdmin: ctx.userId === 'admin', 132 | })) 133 | .get('/admin', { 134 | run: async ({ctx}) => ({ 135 | isAdmin: (ctx as AuthContext).isAdmin, 136 | }), 137 | }); 138 | 139 | const handler = router.freeze({ 140 | getContext: async () => ({userId: 'admin'}), 141 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 142 | }); 143 | 144 | const response = await handler(new Request('http://localhost/admin', {method: 'GET'})); 145 | const data = await response.json(); 146 | 147 | assert.strictEqual(response.status, 200); 148 | assert.deepStrictEqual(data, { 149 | success: true, 150 | data: {isAdmin: true}, 151 | message: 'OK', 152 | }); 153 | }); 154 | }); 155 | 156 | describe('error handling', () => { 157 | it('should handle KaitoError with custom status', async () => { 158 | const router = Router.create().get('/error', { 159 | run: async () => { 160 | throw new KaitoError(403, 'Forbidden'); 161 | }, 162 | }); 163 | 164 | const handler = router.freeze({ 165 | getContext: async () => ({userId: '123'}), 166 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 167 | }); 168 | 169 | const response = await handler(new Request('http://localhost/error', {method: 'GET'})); 170 | const data = await response.json(); 171 | 172 | assert.strictEqual(response.status, 403); 173 | assert.deepStrictEqual(data, { 174 | success: false, 175 | data: null, 176 | message: 'Forbidden', 177 | }); 178 | }); 179 | 180 | it('should handle generic errors with server error handler', async () => { 181 | const router = Router.create().get('/error', { 182 | run: async () => { 183 | throw new Error('Something went wrong'); 184 | }, 185 | }); 186 | 187 | const handler = router.freeze({ 188 | getContext: async () => ({userId: '123'}), 189 | onError: async () => ({status: 500, message: 'Custom Error Message'}), 190 | }); 191 | 192 | const response = await handler(new Request('http://localhost/error', {method: 'GET'})); 193 | const data = await response.json(); 194 | 195 | assert.strictEqual(response.status, 500); 196 | assert.deepStrictEqual(data, { 197 | success: false, 198 | data: null, 199 | message: 'Custom Error Message', 200 | }); 201 | }); 202 | }); 203 | 204 | describe('router merging', () => { 205 | it('should merge routers with prefix', async () => { 206 | const userRouter = Router.create().get('/me', { 207 | run: async ({ctx}) => ({id: ctx.userId}), 208 | }); 209 | 210 | const mainRouter = Router.create().merge('/api', userRouter); 211 | 212 | const handler = mainRouter.freeze({ 213 | getContext: async () => ({userId: '123'}), 214 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 215 | }); 216 | 217 | const response = await handler(new Request('http://localhost/api/me', {method: 'GET'})); 218 | const data = await response.json(); 219 | 220 | assert.strictEqual(response.status, 200); 221 | assert.deepStrictEqual(data, { 222 | success: true, 223 | data: {id: '123'}, 224 | message: 'OK', 225 | }); 226 | }); 227 | }); 228 | 229 | describe('404 handling', () => { 230 | it('should return 404 for non-existent routes', async () => { 231 | const router = Router.create(); 232 | const handler = router.freeze({ 233 | getContext: async () => ({userId: '123'}), 234 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 235 | }); 236 | 237 | const response = await handler(new Request('http://localhost/not-found', {method: 'GET'})); 238 | const data = await response.json(); 239 | 240 | assert.strictEqual(response.status, 404); 241 | assert.deepStrictEqual(data, { 242 | success: false, 243 | data: null, 244 | message: 'Cannot GET /not-found', 245 | }); 246 | }); 247 | 248 | it('should return 404 for wrong method on existing path', async () => { 249 | const router = Router.create().get('/users', { 250 | run: async () => ({users: []}), 251 | }); 252 | 253 | const handler = router.freeze({ 254 | getContext: async () => ({userId: '123'}), 255 | onError: async () => ({status: 500, message: 'Internal Server Error'}), 256 | }); 257 | 258 | const response = await handler(new Request('http://localhost/users', {method: 'POST'})); 259 | const data = await response.json(); 260 | 261 | assert.strictEqual(response.status, 404); 262 | assert.deepStrictEqual(data, { 263 | success: false, 264 | data: null, 265 | message: 'Cannot POST /users', 266 | }); 267 | }); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /packages/core/src/router/router.ts: -------------------------------------------------------------------------------- 1 | import {KaitoError, WrappedError} from '../error.ts'; 2 | import type {HandlerConfig} from '../handler.ts'; 3 | import {KaitoHead} from '../head.ts'; 4 | import {KaitoRequest} from '../request.ts'; 5 | import type {AnyQueryDefinition, AnyRoute, Route} from '../route.ts'; 6 | import {isNodeLikeDev, type ErroredAPIResponse, type Parsable} from '../util.ts'; 7 | import type {KaitoMethod} from './types.ts'; 8 | 9 | type PrefixRoutesPathInner = 10 | R extends Route< 11 | infer ContextFrom, 12 | infer ContextTo, 13 | infer Result, 14 | infer Path, 15 | infer Method, 16 | infer Query, 17 | infer BodyOutput 18 | > 19 | ? Route 20 | : never; 21 | 22 | type PrefixRoutesPath = R extends R 23 | ? PrefixRoutesPathInner 24 | : never; 25 | 26 | export type RouterState = { 27 | routes: Set; 28 | through: (context: ContextFrom) => Promise; 29 | }; 30 | 31 | export type InferRoutes> = R extends Router ? R : never; 32 | 33 | export class Router { 34 | private readonly state: RouterState; 35 | 36 | public static create = (): Router => 37 | new Router({ 38 | through: async context => context, 39 | routes: new Set(), 40 | }); 41 | 42 | private static parseQuery(schema: T | undefined, url: URL) { 43 | if (!schema) { 44 | return {}; 45 | } 46 | 47 | const result: Record = {}; 48 | for (const key in schema) { 49 | if (!schema.hasOwnProperty(key)) continue; 50 | const value = url.searchParams.get(key); 51 | result[key] = (schema[key] as Parsable).parse(value); 52 | } 53 | 54 | return result as { 55 | [Key in keyof T]: ReturnType; 56 | }; 57 | } 58 | 59 | public constructor(options: RouterState) { 60 | this.state = options; 61 | } 62 | 63 | public get routes() { 64 | return this.state.routes; 65 | } 66 | 67 | public add = < 68 | Result, 69 | Path extends string, 70 | Method extends KaitoMethod, 71 | Query extends AnyQueryDefinition = {}, 72 | Body extends Parsable = never, 73 | >( 74 | method: Method, 75 | path: Path, 76 | route: 77 | | (Method extends 'GET' 78 | ? Omit< 79 | Route, 80 | 'body' | 'path' | 'method' | 'through' 81 | > 82 | : Omit, 'path' | 'method' | 'through'>) 83 | | Route['run'], 84 | ): Router> => { 85 | const merged: Route = { 86 | // TODO: Ideally fix the typing here, but this will be replaced in Kaito v4 where all routes must return a Response (which we can type) 87 | ...((typeof route === 'object' ? route : {run: route}) as {run: never}), 88 | method, 89 | path, 90 | through: this.state.through, 91 | }; 92 | 93 | return new Router({ 94 | ...this.state, 95 | routes: new Set([...this.state.routes, merged]), 96 | }); 97 | }; 98 | 99 | public readonly merge = ( 100 | pathPrefix: PathPrefix, 101 | other: Router, 102 | ): Router, AnyRoute>> => { 103 | const newRoutes = [...other.state.routes].map(route => ({ 104 | ...route, 105 | path: `${pathPrefix}${route.path as string}`, 106 | })); 107 | 108 | return new Router, AnyRoute>>({ 109 | ...this.state, 110 | routes: new Set([...this.state.routes, ...newRoutes] as never), 111 | }); 112 | }; 113 | 114 | public freeze = (server: Omit, 'router'>) => { 115 | const routes = new Map>(); 116 | 117 | for (const route of this.state.routes) { 118 | if (!routes.has(route.path)) { 119 | routes.set(route.path, new Map()); 120 | } 121 | 122 | routes.get(route.path)!.set(route.method, route); 123 | } 124 | 125 | const findRoute = (method: KaitoMethod, path: string): {route?: AnyRoute; params: Record} => { 126 | const params: Record = {}; 127 | const pathParts = path.split('/').filter(Boolean); 128 | 129 | for (const [routePath, methodHandlers] of routes) { 130 | const routeParts = routePath.split('/').filter(Boolean); 131 | 132 | if (routeParts.length !== pathParts.length) continue; 133 | 134 | let matches = true; 135 | for (let i = 0; i < routeParts.length; i++) { 136 | const routePart = routeParts[i]; 137 | const pathPart = pathParts[i]; 138 | 139 | if (routePart && pathPart && routePart.startsWith(':')) { 140 | params[routePart.slice(1)] = pathPart; 141 | } else if (routePart !== pathPart) { 142 | matches = false; 143 | break; 144 | } 145 | } 146 | 147 | if (matches) { 148 | const route = methodHandlers.get(method); 149 | if (route) return {route, params}; 150 | } 151 | } 152 | 153 | return {params}; 154 | }; 155 | 156 | return async (req: Request): Promise => { 157 | const url = new URL(req.url); 158 | const method = req.method as KaitoMethod; 159 | 160 | const {route, params} = findRoute(method, url.pathname); 161 | 162 | if (!route) { 163 | const body: ErroredAPIResponse = { 164 | success: false, 165 | data: null, 166 | message: `Cannot ${method} ${url.pathname}`, 167 | }; 168 | 169 | return Response.json(body, {status: 404}); 170 | } 171 | 172 | const request = new KaitoRequest(url, req); 173 | const head = new KaitoHead(); 174 | 175 | try { 176 | const body = route.body ? await route.body.parse(await req.json()) : undefined; 177 | const query = Router.parseQuery(route.query, url); 178 | 179 | const rootCtx = await server.getContext(request, head); 180 | const ctx = await route.through(rootCtx); 181 | 182 | const result = await route.run({ 183 | ctx, 184 | body, 185 | query, 186 | params, 187 | }); 188 | 189 | if (result instanceof Response) { 190 | if (isNodeLikeDev) { 191 | if (head.touched) { 192 | const msg = [ 193 | 'Kaito detected that you used the KaitoHead object to modify the headers or status, but then returned a Response in the route', 194 | 'This is usually a mistake, as your Response object will override any changes you made to the headers or status code.', 195 | '', 196 | 'This warning was shown because `process.env.NODE_ENV=development`', 197 | ].join('\n'); 198 | 199 | console.warn(msg); 200 | } 201 | } 202 | 203 | return result; 204 | } 205 | 206 | return head.toResponse({ 207 | success: true, 208 | data: result, 209 | message: 'OK', 210 | }); 211 | } catch (e) { 212 | const error = WrappedError.maybe(e); 213 | 214 | if (error instanceof KaitoError) { 215 | return head.status(error.status).toResponse({ 216 | success: false, 217 | data: null, 218 | message: error.message, 219 | }); 220 | } 221 | 222 | const {status, message} = await server 223 | .onError({error, req: request}) 224 | .catch(() => ({status: 500, message: 'Internal Server Error'})); 225 | 226 | return head.status(status).toResponse({ 227 | success: false, 228 | data: null, 229 | message, 230 | }); 231 | } 232 | }; 233 | }; 234 | 235 | private readonly method = 236 | (method: M) => 237 | ( 238 | path: Path, 239 | route: 240 | | (M extends 'GET' 241 | ? Omit, 'body' | 'path' | 'method' | 'through'> 242 | : Omit, 'path' | 'method' | 'through'>) 243 | | Route['run'], 244 | ) => { 245 | return this.add(method, path, route); 246 | }; 247 | 248 | public get = this.method('GET'); 249 | public post = this.method('POST'); 250 | public put = this.method('PUT'); 251 | public patch = this.method('PATCH'); 252 | public delete = this.method('DELETE'); 253 | public head = this.method('HEAD'); 254 | public options = this.method('OPTIONS'); 255 | 256 | public through = ( 257 | through: (context: ContextTo) => Promise, 258 | ): Router => { 259 | return new Router({ 260 | ...this.state, 261 | through: async context => through(await this.state.through(context)), 262 | }); 263 | }; 264 | } 265 | -------------------------------------------------------------------------------- /packages/core/src/router/types.ts: -------------------------------------------------------------------------------- 1 | export type KaitoMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE'; 2 | -------------------------------------------------------------------------------- /packages/core/src/stream/stream.ts: -------------------------------------------------------------------------------- 1 | export class KaitoSSEResponse<_T> extends Response { 2 | constructor(body: ReadableStream, init?: ResponseInit) { 3 | const headers = new Headers(init?.headers); 4 | 5 | headers.set('Content-Type', 'text/event-stream'); 6 | headers.set('Cache-Control', 'no-cache'); 7 | headers.set('Connection', 'keep-alive'); 8 | 9 | super(body, { 10 | ...init, 11 | headers, 12 | }); 13 | } 14 | 15 | async *[Symbol.asyncIterator]() { 16 | for await (const chunk of this.body!) { 17 | yield chunk; 18 | } 19 | } 20 | } 21 | 22 | export type SSEEvent = ( 23 | | { 24 | data: T; 25 | event?: E | undefined; 26 | } 27 | | { 28 | data?: T | undefined; 29 | event: E; 30 | } 31 | ) & { 32 | retry?: number; 33 | id?: string; 34 | }; 35 | 36 | /** 37 | * Converts an SSE Event into a string, ready for sending to the client 38 | * @param event The SSE Event 39 | * @returns A stringified version 40 | */ 41 | export function sseEventToString(event: SSEEvent): string { 42 | let result = ''; 43 | 44 | if (event.event) { 45 | result += `event:${event.event}\n`; 46 | } 47 | 48 | if (event.id) { 49 | result += `id:${event.id}\n`; 50 | } 51 | 52 | if (event.retry) { 53 | result += `retry:${event.retry}\n`; 54 | } 55 | 56 | if (event.data !== undefined) { 57 | result += `data:${JSON.stringify(event.data)}`; 58 | } 59 | 60 | return result; 61 | } 62 | 63 | export class SSEController implements Disposable { 64 | private readonly controller: ReadableStreamDefaultController; 65 | 66 | public constructor(controller: ReadableStreamDefaultController) { 67 | this.controller = controller; 68 | } 69 | 70 | public enqueue(event: SSEEvent): void { 71 | this.controller.enqueue(sseEventToString(event) + '\n\n'); 72 | } 73 | 74 | public close(): void { 75 | this.controller.close(); 76 | } 77 | 78 | [Symbol.dispose](): void { 79 | this.close(); 80 | } 81 | } 82 | 83 | export interface SSESource { 84 | cancel?: UnderlyingSourceCancelCallback; 85 | start?(controller: SSEController): Promise; 86 | pull?(controller: SSEController): Promise; 87 | } 88 | 89 | function sseFromSource(source: SSESource) { 90 | const start = source.start; 91 | const pull = source.pull; 92 | const cancel = source.cancel; 93 | 94 | const readable = new ReadableStream({ 95 | ...(cancel ? {cancel} : {}), 96 | 97 | ...(start 98 | ? { 99 | start: async controller => { 100 | await start(new SSEController(controller)); 101 | }, 102 | } 103 | : {}), 104 | 105 | ...(pull 106 | ? { 107 | pull: async controller => { 108 | await pull(new SSEController(controller)); 109 | }, 110 | } 111 | : {}), 112 | }); 113 | 114 | return new KaitoSSEResponse>(readable); 115 | } 116 | 117 | export function sse>( 118 | source: SSESource | AsyncGenerator | (() => AsyncGenerator), 119 | ): KaitoSSEResponse { 120 | const evaluated = typeof source === 'function' ? source() : source; 121 | 122 | if ('next' in evaluated) { 123 | const generator = evaluated; 124 | return sseFromSource({ 125 | async start(controller) { 126 | // TODO: use `using` once Node.js supports it 127 | // // ensures close is called on controller when we're done 128 | // using c = controller; 129 | try { 130 | for await (const event of generator) { 131 | controller.enqueue(event); 132 | } 133 | } finally { 134 | controller.close(); 135 | } 136 | }, 137 | }); 138 | } else { 139 | // if the SSESource interface is used only strings are permitted. 140 | // serialization / deserialization for objects is left to the user 141 | return sseFromSource(evaluated); 142 | } 143 | } 144 | 145 | export function sseFromAnyReadable( 146 | stream: ReadableStream, 147 | transform: (chunk: R) => SSEEvent, 148 | ): KaitoSSEResponse> { 149 | const transformer = new TransformStream({ 150 | transform: (chunk, controller) => { 151 | controller.enqueue(transform(chunk)); 152 | }, 153 | }); 154 | 155 | return sse(stream.pipeThrough(transformer)); 156 | } 157 | -------------------------------------------------------------------------------- /packages/core/src/util.ts: -------------------------------------------------------------------------------- 1 | import type {KaitoHead} from './head.ts'; 2 | import type {KaitoRequest} from './request.ts'; 3 | import {Router} from './router/router.ts'; 4 | 5 | /** 6 | * A helper to check if the environment is Node.js-like and the NODE_ENV is development 7 | */ 8 | export const isNodeLikeDev = 9 | typeof process !== 'undefined' && typeof process.env !== 'undefined' && process.env.NODE_ENV === 'development'; 10 | 11 | export type ErroredAPIResponse = {success: false; data: null; message: string}; 12 | export type SuccessfulAPIResponse = {success: true; data: T; message: 'OK'}; 13 | export type APIResponse = ErroredAPIResponse | SuccessfulAPIResponse; 14 | export type AnyResponse = APIResponse; 15 | export type MakeOptional = T extends T ? Omit & Partial> : never; 16 | 17 | export type ExtractRouteParams = string extends T 18 | ? Record 19 | : T extends `${string}:${infer Param}/${infer Rest}` 20 | ? {[k in Param | keyof ExtractRouteParams]: string} 21 | : T extends `${string}:${infer Param}` 22 | ? {[k in Param]: string} 23 | : {}; 24 | 25 | /** 26 | * A function that is called to get the context for a request. 27 | * 28 | * This is useful for things like authentication, to pass in a database connection, etc. 29 | * 30 | * It's fine for this function to throw; if it does, the error is passed to the `onError` function. 31 | * 32 | * @param req - The kaito request object, which contains the request method, url, headers, etc 33 | * @param head - The kaito head object, which contains getters and setters for headers and status 34 | * @returns The context for your routes 35 | */ 36 | export type GetContext = (req: KaitoRequest, head: KaitoHead) => Promise; 37 | 38 | /** 39 | * A helper function to create typed necessary functions 40 | * 41 | * @example 42 | * ```ts 43 | * const {router, getContext} = createUtilities(async (req, res) => { 44 | * // Return context here 45 | * }) 46 | * 47 | * const app = router().get('/', async () => "hello"); 48 | * 49 | * const server = createKaitoHandler({ 50 | * router: app, 51 | * getContext, 52 | * // ... 53 | * }); 54 | * ``` 55 | */ 56 | export function createUtilities(getContext: GetContext): { 57 | getContext: GetContext; 58 | router: () => Router; 59 | } { 60 | return { 61 | getContext, 62 | router: () => Router.create(), 63 | }; 64 | } 65 | 66 | export interface Parsable { 67 | _input: Input; 68 | parse: (value: unknown) => Output; 69 | } 70 | 71 | export type InferParsable = 72 | T extends Parsable 73 | ? { 74 | input: Input; 75 | output: Output; 76 | } 77 | : never; 78 | 79 | export function parsable(parse: (value: unknown) => T): Parsable { 80 | return { 81 | parse, 82 | } as Parsable; 83 | } 84 | -------------------------------------------------------------------------------- /packages/core/test/big-router/big-router.ts: -------------------------------------------------------------------------------- 1 | import {mountMe} from './mount-me.ts'; 2 | import {router} from './router.ts'; 3 | 4 | export const bigrouter = router() 5 | .merge( 6 | '/8', 7 | mountMe.get('/test-8', async () => 8), 8 | ) 9 | .merge( 10 | '/9', 11 | mountMe.get('/test-9', async () => 9), 12 | ) 13 | .merge( 14 | '/10', 15 | mountMe.get('/1test-0', async () => 10), 16 | ) 17 | .merge( 18 | '/11', 19 | mountMe.get('/1test-1', async () => 11), 20 | ) 21 | .merge( 22 | '/12', 23 | mountMe.get('/1test-2', async () => 12), 24 | ) 25 | .merge( 26 | '/13', 27 | mountMe.get('/1test-3', async () => 13), 28 | ) 29 | .merge( 30 | '/14', 31 | mountMe.get('/1test-4', async () => 14), 32 | ) 33 | .get('/43', async () => 43) 34 | .get('/44', async () => 44) 35 | .get('/45', async () => 45) 36 | .get('/46', async () => 46) 37 | .get('/47', async () => 47) 38 | .get('/48', async () => 48) 39 | .get('/49', async () => 49) 40 | .get('/50', async () => 50); 41 | -------------------------------------------------------------------------------- /packages/core/test/big-router/bigger-router.ts: -------------------------------------------------------------------------------- 1 | import {bigrouter} from './big-router.ts'; 2 | import {router} from './router.ts'; 3 | 4 | export const biggerRouter = router().merge('/bigrouter', bigrouter); 5 | 6 | console.log(biggerRouter.routes.size); 7 | 8 | type g = (typeof biggerRouter)['routes'] extends Set ? R : never; 9 | type R = g['path']; 10 | 11 | declare const r: R; 12 | -------------------------------------------------------------------------------- /packages/core/test/big-router/mount-me.ts: -------------------------------------------------------------------------------- 1 | import {type Parsable} from '../../src/util.ts'; 2 | import {router} from './router.ts'; 3 | 4 | declare const schema: Parsable<{hello: string}, string>; 5 | 6 | export const mountMe = router() 7 | .post('/post', { 8 | query: { 9 | name: schema, 10 | }, 11 | body: schema, 12 | run: async ({ctx, body, query}) => { 13 | return body.hello + ctx.foo + query.name.hello; 14 | }, 15 | }) 16 | .put('/put', { 17 | body: schema, 18 | run: async ({ctx}) => { 19 | return ctx.foo; 20 | }, 21 | }) 22 | .patch('/patch', { 23 | body: schema, 24 | run: async ({ctx}) => { 25 | return ctx.foo; 26 | }, 27 | }) 28 | .delete('/delete', { 29 | body: schema, 30 | run: async ({ctx}) => { 31 | return ctx.foo; 32 | }, 33 | }) 34 | .get('/get', { 35 | run: async ({ctx}) => { 36 | return ctx.foo; 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /packages/core/test/big-router/router.ts: -------------------------------------------------------------------------------- 1 | import {createUtilities} from '../../src/index.ts'; 2 | 3 | export const {router} = createUtilities(async req => { 4 | return {req, foo: 1}; 5 | }); 6 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'tsup'; 2 | import {config} from '../../tsup.config.ts'; 3 | 4 | export default defineConfig({ 5 | ...config, 6 | entry: [...config.entry, './src/stream/stream.ts', './src/cors/cors.ts'], 7 | }); 8 | -------------------------------------------------------------------------------- /packages/uws/README.md: -------------------------------------------------------------------------------- 1 | # @kaito-http/uws 2 | 3 | A high-performance Request/Response Web Fetch API based Node.js-only HTTP server using [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js). 4 | 5 | ## Features 6 | 7 | - High-performance HTTP server using uWebSockets.js 8 | - Full Web Standards API compatibility (Request/Response) 9 | - Automatic request abortion handling via AbortSignal 10 | - Client IP address access 11 | - Streaming request and response support 12 | 13 | ## Installation 14 | 15 | ```bash 16 | bun i @kaito-http/uws 17 | ``` 18 | 19 | ## Basic Usage 20 | 21 | ```typescript 22 | import {KaitoServer} from '@kaito-http/uws'; 23 | 24 | const server = await KaitoServer.serve({ 25 | port: 3000, 26 | fetch: async request => { 27 | return new Response('Hello World!'); 28 | }, 29 | }); 30 | 31 | console.log(`Server running at ${server.url}`); 32 | ``` 33 | 34 | ## Request Signal Support 35 | 36 | The server automatically provides an `AbortSignal` on each request that gets triggered when the client disconnects: 37 | 38 | ```typescript 39 | import {KaitoServer} from '@kaito-http/uws'; 40 | 41 | const server = await KaitoServer.serve({ 42 | port: 3000, 43 | fetch: async request => { 44 | // The request.signal is automatically set up 45 | console.log('Request signal aborted:', request.signal.aborted); 46 | 47 | // Listen for client disconnection 48 | request.signal.addEventListener('abort', () => { 49 | console.log('Client disconnected, cleaning up...'); 50 | // Clean up resources, cancel operations, etc. 51 | }); 52 | 53 | // Simulate long-running operation 54 | return new Promise(resolve => { 55 | const timeout = setTimeout(() => { 56 | resolve(new Response('Operation completed')); 57 | }, 5000); 58 | 59 | // Cancel operation if client disconnects 60 | request.signal.addEventListener('abort', () => { 61 | clearTimeout(timeout); 62 | console.log('Operation cancelled due to client disconnect'); 63 | }); 64 | }); 65 | }, 66 | }); 67 | ``` 68 | 69 | ## Remote Address Access 70 | 71 | Get the client's IP address from the `context` parameter: 72 | 73 | ```typescript 74 | import {KaitoServer} from '@kaito-http/uws'; 75 | 76 | const server = await KaitoServer.serve({ 77 | port: 3000, 78 | fetch: async (request, context) => { 79 | console.log(`Request from: ${context.remoteAddress}`); 80 | return new Response(`Your IP is: ${context.remoteAddress}`); 81 | }, 82 | }); 83 | ``` 84 | 85 | If you need to access the IP address in nested functions or route handlers, use AsyncLocalStorage: 86 | 87 | ```typescript 88 | import {AsyncLocalStorage} from 'node:async_hooks'; 89 | import {KaitoServer} from '@kaito-http/uws'; 90 | 91 | const ipStore = new AsyncLocalStorage(); 92 | 93 | function handleRequest() { 94 | const clientIP = ipStore.getStore()!; 95 | return new Response(`Your IP is: ${clientIP}`); 96 | } 97 | 98 | const server = await KaitoServer.serve({ 99 | port: 3000, 100 | fetch: async (request, context) => { 101 | // Store IP in AsyncLocalStorage for access in nested functions 102 | return ipStore.run(context.remoteAddress, () => handleRequest()); 103 | }, 104 | }); 105 | ``` 106 | 107 | ## Server Configuration 108 | 109 | ```typescript 110 | import {KaitoServer} from '@kaito-http/uws'; 111 | 112 | const server = await KaitoServer.serve({ 113 | port: 3000, 114 | host: '127.0.0.1', // defaults to '0.0.0.0' 115 | fetch: async request => { 116 | // Your request handler 117 | return new Response('Hello!'); 118 | }, 119 | }); 120 | 121 | // Server properties 122 | console.log('Server address:', server.address); // "127.0.0.1:3000" 123 | console.log('Server URL:', server.url); // "http://127.0.0.1:3000" 124 | 125 | // Close the server 126 | server.close(); 127 | ``` 128 | -------------------------------------------------------------------------------- /packages/uws/bench.ts: -------------------------------------------------------------------------------- 1 | import {KaitoServer} from './src/index.ts'; 2 | 3 | await KaitoServer.serve({ 4 | port: 3000, 5 | fetch: () => new Response('Hello, world!'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/uws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaito-http/uws", 3 | "type": "module", 4 | "version": "3.2.1", 5 | "description": "Functional HTTP Framework for TypeScript", 6 | "scripts": { 7 | "build": "tsup", 8 | "attw": "attw --profile node16 --pack .", 9 | "test": "node --test --import=tsx ./src/**/*.test.ts" 10 | }, 11 | "exports": { 12 | "./package.json": "./package.json", 13 | ".": { 14 | "import": "./dist/index.js", 15 | "require": "./dist/index.cjs" 16 | } 17 | }, 18 | "files": [ 19 | "./package.json", 20 | "dist", 21 | "README.md" 22 | ], 23 | "author": "Alistair Smith ", 24 | "homepage": "https://github.com/kaito-http/kaito", 25 | "keywords": [ 26 | "typescript", 27 | "http", 28 | "framework" 29 | ], 30 | "repository": "https://github.com/kaito-http/kaito", 31 | "license": "MIT", 32 | "dependencies": { 33 | "uWebSockets.js": "https://github.com/kaito-http/uWebSockets.js#e17a089c17dc722354953bcca000ad09a03bf3f2" 34 | }, 35 | "devDependencies": { 36 | "@arethetypeswrong/cli": "^0.17.2", 37 | "tsup": "^8.3.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/uws/src/index.ts: -------------------------------------------------------------------------------- 1 | import uWS from 'uWebSockets.js'; 2 | 3 | export interface RequestContext { 4 | /** 5 | * The remote address of the client 6 | */ 7 | remoteAddress: string; 8 | } 9 | 10 | export interface ServeOptions { 11 | /** 12 | * The port to listen on 13 | */ 14 | port: number; 15 | 16 | /** 17 | * The host to listen on 18 | */ 19 | host: string; 20 | 21 | // static?: Record<`/${string}`, Response>; 22 | 23 | /** 24 | * This function is called for every request. 25 | * 26 | * @param request - The request that was made 27 | * @returns A response to send to the client 28 | */ 29 | fetch: (request: Request, context: RequestContext) => Response | PromiseLike; 30 | 31 | /** 32 | * This function is called when an error occurs in the fetch handler. 33 | * 34 | * @param error - The error that occurred 35 | * @param request - The request that caused the error 36 | * @returns A response to send to the client 37 | */ 38 | onError: (error: unknown, request: Request) => Response | PromiseLike; 39 | } 40 | 41 | type ExpandOptions = {[K in keyof T]: T[K]} & {}; 42 | export type ServeUserOptions = ExpandOptions< 43 | Omit & Partial> 44 | >; 45 | 46 | async function asyncTry(fn: (...args: A) => T | PromiseLike, ...args: A): Promise { 47 | try { 48 | return await fn(...args); 49 | } catch (error) { 50 | throw error; 51 | } 52 | } 53 | 54 | const SPACE = ' '; 55 | const GET = 'get'; 56 | const HEAD = 'head'; 57 | const CONTENT_LENGTH = 'content-length'; 58 | const QMARK = '?'; 59 | const EMPTY = ''; 60 | 61 | function context(res: uWS.HttpResponse): RequestContext { 62 | return { 63 | get remoteAddress() { 64 | const value = Buffer.from(res.getRemoteAddressAsText()).toString('ascii'); 65 | Object.defineProperty(this, 'remoteAddress', {value}); 66 | return value; 67 | }, 68 | }; 69 | } 70 | 71 | /** 72 | * The main class for creating a Kaito server 73 | */ 74 | export class KaitoServer implements Disposable { 75 | private static getRequestBodyStream(res: uWS.HttpResponse) { 76 | return new ReadableStream({ 77 | start(controller) { 78 | res.onData((ab, isLast) => { 79 | const chunk = new Uint8Array(ab.slice(0)); 80 | 81 | controller.enqueue(chunk); 82 | 83 | if (isLast) { 84 | controller.close(); 85 | } 86 | }); 87 | 88 | res.onAborted(() => { 89 | controller.error(new Error('Request aborted')); 90 | }); 91 | }, 92 | }); 93 | } 94 | 95 | // this function must not throw by any means 96 | private static readonly DEFAULT_ON_ERROR: ServeOptions['onError'] = error => { 97 | console.error('[@kaito-http/uws] Error in fetch handler:'); 98 | console.error(error); 99 | 100 | return new Response('Internal Server Error', { 101 | status: 500, 102 | statusText: 'Internal Server Error', 103 | }); 104 | }; 105 | 106 | /** 107 | * Create a new Kaito server 108 | * 109 | * @param options - The options for the server 110 | * @returns A Kaito server instance 111 | * @example 112 | * ```typescript 113 | * using server = await KaitoServer.serve({ 114 | * port: 3000, 115 | * fetch: async request => new Response('Hello, world!'), 116 | * }); 117 | * ``` 118 | */ 119 | public static async serve(options: ServeUserOptions) { 120 | const fullOptions: ServeOptions = { 121 | host: '0.0.0.0', 122 | onError: this.DEFAULT_ON_ERROR, 123 | ...options, 124 | }; 125 | 126 | const {origin} = new URL('http://' + fullOptions.host + ':' + fullOptions.port); 127 | 128 | const app = uWS.App(); 129 | 130 | // for await (const [path, response] of Object.entries(fullOptions.static ?? {})) { 131 | // const buffer = await response.arrayBuffer(); 132 | // const statusAsBuffer = Buffer.from(response.status.toString().concat(SPACE, response.statusText)); 133 | // const headersFastArray = Array.from(response.headers.entries()); 134 | 135 | // app.any(path, res => { 136 | // res.writeStatus(statusAsBuffer); 137 | // for (const [header, value] of headersFastArray) { 138 | // res.writeHeader(header, value); 139 | // } 140 | // res.end(buffer); 141 | // }); 142 | // } 143 | 144 | app.any('/*', async (res, req) => { 145 | const headers = new Headers(); 146 | req.forEach((k, v) => headers.set(k, v)); 147 | 148 | const method = req.getMethod(); 149 | // req.getUrl does not include the query string in the url 150 | const query = req.getQuery(); 151 | 152 | const url = origin.concat(req.getUrl(), query ? QMARK + query : EMPTY); 153 | 154 | const controller = new AbortController(); 155 | 156 | const request = new Request(url, { 157 | headers, 158 | method, 159 | body: method === GET || method === HEAD ? null : this.getRequestBodyStream(res), 160 | signal: controller.signal, 161 | // @ts-expect-error undici in Node.js doesn't define the types 162 | duplex: 'half', 163 | }); 164 | 165 | res.onAborted(() => { 166 | controller.abort(); 167 | }); 168 | 169 | const response = await asyncTry(options.fetch, request, context(res)) 170 | .catch(error => fullOptions.onError(error, request)) 171 | .catch(error => this.DEFAULT_ON_ERROR(error, request)); 172 | 173 | // request was aborted before the handler was finished 174 | if (controller.signal.aborted) { 175 | return; 176 | } 177 | 178 | res.cork(() => { 179 | res.writeStatus(response.status.toString().concat(SPACE, response.statusText)); 180 | 181 | for (const [header, value] of response.headers) { 182 | res.writeHeader(header, value); 183 | } 184 | 185 | if (!response.body) { 186 | res.end(); 187 | } 188 | }); 189 | 190 | if (!response.body) { 191 | return; 192 | } 193 | 194 | if (response.headers.has(CONTENT_LENGTH)) { 195 | const contentLength = parseInt(response.headers.get(CONTENT_LENGTH)!); 196 | 197 | if (contentLength < 65536) { 198 | res.end(await response.arrayBuffer()); 199 | return; 200 | } 201 | } 202 | 203 | const writeNext = async (data: Uint8Array): Promise => { 204 | if (controller.signal.aborted) { 205 | return; 206 | } 207 | 208 | let writeSucceeded: boolean | undefined; 209 | res.cork(() => { 210 | writeSucceeded = res.write(data); 211 | }); 212 | 213 | if (!writeSucceeded) { 214 | return new Promise((resolve, reject) => { 215 | let offset = 0; 216 | 217 | res.onWritable(availableSpace => { 218 | let ok: boolean | undefined; 219 | 220 | if (controller.signal.aborted) { 221 | reject(); 222 | return false; 223 | } 224 | 225 | res.cork(() => { 226 | const chunk = data.subarray(offset, offset + availableSpace); 227 | ok = res.write(chunk); 228 | }); 229 | 230 | if (ok) { 231 | offset += availableSpace; 232 | 233 | if (offset >= data.length) { 234 | resolve(); 235 | return false; 236 | } 237 | } 238 | 239 | return true; 240 | }); 241 | }); 242 | } 243 | }; 244 | 245 | try { 246 | const reader = response.body.getReader(); 247 | 248 | while (!controller.signal.aborted) { 249 | const {done, value} = await reader.read(); 250 | 251 | if (done) { 252 | break; 253 | } 254 | 255 | if (value) { 256 | await writeNext(value); 257 | } 258 | } 259 | } finally { 260 | if (!controller.signal.aborted) { 261 | res.cork(() => res.end()); 262 | } 263 | } 264 | }); 265 | 266 | await new Promise((resolve, reject) => { 267 | app.listen(fullOptions.host, fullOptions.port, ok => { 268 | if (ok) { 269 | resolve(); 270 | } else { 271 | reject(new Error('Failed to listen on port ' + fullOptions.port)); 272 | } 273 | }); 274 | }); 275 | 276 | return new KaitoServer(app, fullOptions); 277 | } 278 | 279 | private readonly app: ReturnType; 280 | private readonly options: ServeOptions; 281 | 282 | private constructor(app: ReturnType, options: ServeOptions) { 283 | this.app = app; 284 | this.options = options; 285 | } 286 | 287 | [Symbol.dispose](): void { 288 | return this.close(); 289 | } 290 | 291 | public close() { 292 | this.app.close(); 293 | } 294 | 295 | public get address() { 296 | return `${this.options.host}:${this.options.port}`; 297 | } 298 | 299 | public get url() { 300 | return `http://${this.address}`; 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /packages/uws/src/uws.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict'; 2 | import {once} from 'node:events'; 3 | import {type AddressInfo, createServer} from 'node:net'; 4 | import {text} from 'node:stream/consumers'; 5 | import {describe, test} from 'node:test'; 6 | import {KaitoServer, type ServeUserOptions} from './index.ts'; 7 | 8 | async function getPort(): Promise { 9 | const server = createServer(); 10 | server.listen(0); 11 | await once(server, 'listening'); 12 | const port = (server.address() as AddressInfo).port; 13 | server.close(); 14 | return port; 15 | } 16 | 17 | async function createTestServer(options: Partial = {}) { 18 | const port = await getPort(); 19 | const server = await KaitoServer.serve({ 20 | port, 21 | fetch: options.fetch ?? (async () => new Response('ok')), 22 | ...options, 23 | }); 24 | return server; 25 | } 26 | 27 | describe('KaitoServer', () => { 28 | test('basic GET request', async () => { 29 | using server = await createTestServer({ 30 | fetch: async req => { 31 | assert.equal(req.method, 'GET'); 32 | return new Response('ok'); 33 | }, 34 | }); 35 | 36 | const res = await fetch(server.url); 37 | assert.equal(await res.text(), 'ok'); 38 | }); 39 | 40 | test('request with query parameters', async () => { 41 | using server = await createTestServer({ 42 | fetch: async req => { 43 | const url = new URL(req.url); 44 | assert.equal(url.searchParams.get('foo'), 'bar'); 45 | assert.equal(url.searchParams.get('baz'), 'qux'); 46 | return new Response('ok'); 47 | }, 48 | }); 49 | 50 | const res = await fetch(`${server.url}/?foo=bar&baz=qux`); 51 | assert.equal(await res.text(), 'ok'); 52 | }); 53 | 54 | test('POST request with JSON body', async () => { 55 | const testData = {hello: 'world'}; 56 | 57 | using server = await createTestServer({ 58 | fetch: async req => { 59 | assert.equal(req.method, 'POST'); 60 | assert.equal(req.headers.get('content-type'), 'application/json'); 61 | const body = await req.json(); 62 | assert.deepEqual(body, testData); 63 | return new Response('ok'); 64 | }, 65 | }); 66 | 67 | const res = await fetch(server.url, { 68 | method: 'POST', 69 | headers: { 70 | 'Content-Type': 'application/json', 71 | }, 72 | body: JSON.stringify(testData), 73 | }); 74 | assert.equal(await res.text(), 'ok'); 75 | }); 76 | 77 | test('POST request with large body', async () => { 78 | const largeData = Buffer.alloc(100_000_000, 'x').toString(); 79 | 80 | using server = await createTestServer({ 81 | fetch: async req => { 82 | assert.equal(req.method, 'POST'); 83 | const body = await req.text(); 84 | assert.equal(body, largeData); 85 | return new Response('ok'); 86 | }, 87 | }); 88 | 89 | const res = await fetch(server.url, { 90 | method: 'POST', 91 | body: largeData, 92 | }); 93 | 94 | assert.equal(await res.text(), 'ok'); 95 | }); 96 | 97 | test('POST request with streaming body', async () => { 98 | const chunks = ['chunk1', 'chunk2', 'chunk3']; 99 | const expectedBody = chunks.join(''); 100 | const encoder = new TextEncoder(); 101 | 102 | using server = await createTestServer({ 103 | fetch: async req => { 104 | assert.equal(req.method, 'POST'); 105 | assert(req.body instanceof ReadableStream); 106 | const [a, b] = req.body.tee(); 107 | 108 | const body = text(a); 109 | 110 | let i = 0; 111 | for await (const chunk of b) { 112 | assert.equal(Buffer.from(chunk).toString('utf-8'), chunks[i] ?? ''); 113 | i++; 114 | } 115 | 116 | assert.equal(await body, expectedBody); 117 | return new Response('ok'); 118 | }, 119 | }); 120 | 121 | const stream = new ReadableStream({ 122 | async start(controller) { 123 | for (const chunk of chunks) { 124 | controller.enqueue(encoder.encode(chunk)); 125 | await new Promise(resolve => setTimeout(resolve, 50)); 126 | } 127 | controller.close(); 128 | }, 129 | }); 130 | 131 | const res = await fetch(server.url, { 132 | method: 'POST', 133 | body: stream, 134 | // @ts-expect-error - duplex is not in @types/node 135 | duplex: 'half', 136 | headers: { 137 | 'Content-Type': 'text/plain', 138 | }, 139 | }); 140 | 141 | assert.equal(await res.text(), 'ok'); 142 | }); 143 | 144 | test('custom headers', async () => { 145 | using server = await createTestServer({ 146 | fetch: async req => { 147 | assert.equal(req.headers.get('x-custom-header'), 'test-value'); 148 | return new Response('ok', { 149 | headers: { 150 | 'x-response-header': 'response-value', 151 | }, 152 | }); 153 | }, 154 | }); 155 | 156 | const res = await fetch(server.url, { 157 | headers: { 158 | 'x-custom-header': 'test-value', 159 | }, 160 | }); 161 | assert.equal(res.headers.get('x-response-header'), 'response-value'); 162 | }); 163 | 164 | test('streaming response', async () => { 165 | const chunks = ['Hello', ' ', 'World']; 166 | const encoder = new TextEncoder(); 167 | 168 | using server = await createTestServer({ 169 | fetch: async () => { 170 | const stream = new ReadableStream({ 171 | async start(controller) { 172 | for (const chunk of chunks) { 173 | controller.enqueue(encoder.encode(chunk)); 174 | } 175 | controller.close(); 176 | }, 177 | }); 178 | 179 | return new Response(stream); 180 | }, 181 | }); 182 | 183 | const res = await fetch(server.url); 184 | const text = await res.text(); 185 | assert.equal(text, chunks.join('')); 186 | }); 187 | 188 | test('response status codes', async () => { 189 | using server = await createTestServer({ 190 | fetch: async () => { 191 | return new Response('not found', { 192 | status: 404, 193 | statusText: 'Not Found', 194 | }); 195 | }, 196 | }); 197 | 198 | const res = await fetch(server.url); 199 | assert.equal(res.status, 404); 200 | assert.equal(res.statusText, 'Not Found'); 201 | assert.equal(await res.text(), 'not found'); 202 | }); 203 | 204 | test('multiple concurrent requests', async () => { 205 | let requestCount = 0; 206 | 207 | using server = await createTestServer({ 208 | fetch: async () => { 209 | requestCount++; 210 | return new Response('ok'); 211 | }, 212 | }); 213 | 214 | const requests = Array.from({length: 10}, () => fetch(server.url)); 215 | await Promise.all(requests); 216 | assert.equal(requestCount, 10); 217 | }); 218 | 219 | test('request with non-default host', async () => { 220 | const port = await getPort(); 221 | using server = await KaitoServer.serve({ 222 | port, 223 | host: '127.0.0.1', 224 | fetch: async req => { 225 | const url = new URL(req.url); 226 | assert.equal(url.hostname, '127.0.0.1'); 227 | return new Response('ok'); 228 | }, 229 | }); 230 | 231 | const res = await fetch(server.url); 232 | assert.equal(await res.text(), 'ok'); 233 | }); 234 | 235 | test('binary data handling', async () => { 236 | const binaryData = new Uint8Array([1, 2, 3, 4, 5]); 237 | 238 | using server = await createTestServer({ 239 | fetch: async req => { 240 | const body = new Uint8Array(await req.arrayBuffer()); 241 | assert.deepEqual(body, binaryData); 242 | return new Response(body); 243 | }, 244 | }); 245 | 246 | const res = await fetch(server.url, { 247 | method: 'POST', 248 | body: binaryData, 249 | }); 250 | const responseData = new Uint8Array(await res.arrayBuffer()); 251 | assert.deepEqual(responseData, binaryData); 252 | }); 253 | 254 | test('request.signal property for abort handling', async () => { 255 | let signalWasValid = false; 256 | let requestAborted = false; 257 | 258 | using server = await createTestServer({ 259 | fetch: async req => { 260 | assert.ok(req.signal instanceof AbortSignal, 'request.signal should be an AbortSignal'); 261 | signalWasValid = true; 262 | 263 | req.signal.addEventListener('abort', () => { 264 | requestAborted = true; 265 | }); 266 | 267 | assert.equal(req.signal.aborted, false, 'signal should not be aborted initially'); 268 | 269 | return new Promise(resolve => { 270 | const timeout = setTimeout(() => { 271 | if (!req.signal.aborted) { 272 | resolve(new Response('completed')); 273 | } 274 | }, 100); 275 | 276 | req.signal.addEventListener('abort', () => { 277 | clearTimeout(timeout); 278 | resolve(new Response('aborted')); 279 | }); 280 | }); 281 | }, 282 | }); 283 | 284 | const responsePromise = fetch(server.url); 285 | await new Promise(resolve => setTimeout(resolve, 100)); 286 | 287 | server.close(); 288 | 289 | let didError = false; 290 | 291 | try { 292 | await responsePromise; 293 | } catch (error) { 294 | didError = true; 295 | assert.ok(error instanceof TypeError, 'request should be an error'); 296 | } 297 | 298 | assert.equal(didError, true, 'request should have errored'); 299 | assert.equal(requestAborted, true, 'request should have been aborted'); 300 | assert.equal(signalWasValid, true, 'request.signal should have been a valid AbortSignal'); 301 | }); 302 | 303 | test('request signal abort state', async () => { 304 | let signalChecks: {aborted: boolean; reason?: any}[] = []; 305 | 306 | using server = await createTestServer({ 307 | fetch: async req => { 308 | signalChecks.push({ 309 | aborted: req.signal.aborted, 310 | reason: req.signal.reason, 311 | }); 312 | 313 | await new Promise(resolve => setTimeout(resolve, 50)); 314 | 315 | signalChecks.push({ 316 | aborted: req.signal.aborted, 317 | reason: req.signal.reason, 318 | }); 319 | 320 | return new Response('ok'); 321 | }, 322 | }); 323 | 324 | const res = await fetch(server.url); 325 | assert.equal(await res.text(), 'ok'); 326 | 327 | assert.equal(signalChecks.length, 2); 328 | assert.equal(signalChecks[0]!.aborted, false); 329 | assert.equal(signalChecks[1]!.aborted, false); 330 | }); 331 | 332 | test('request context', async () => { 333 | let remoteAddress: string | undefined; 334 | 335 | using server = await createTestServer({ 336 | fetch: async (_request, context) => { 337 | assert.equal(context.remoteAddress, '127.0.0.1'); 338 | remoteAddress = context.remoteAddress; 339 | return new Response(context.remoteAddress); 340 | }, 341 | }); 342 | 343 | const res = await fetch(server.url); 344 | assert.equal(await res.text(), '127.0.0.1'); 345 | assert.ok(typeof remoteAddress === 'string', 'remoteAddress should be a string'); 346 | }); 347 | 348 | test('server properties', async () => { 349 | const port = await getPort(); 350 | const host = '127.0.0.1'; 351 | 352 | using server = await KaitoServer.serve({ 353 | port, 354 | host, 355 | fetch: async () => new Response('ok'), 356 | }); 357 | 358 | assert.equal(server.address, `${host}:${port}`); 359 | assert.equal(server.url, `http://${host}:${port}`); 360 | }); 361 | 362 | test('errors in fetch handler return 500 Internal Server Error', async () => { 363 | using server = await createTestServer({ 364 | fetch: async () => { 365 | throw new Error('Something went wrong'); 366 | }, 367 | }); 368 | 369 | const res = await fetch(server.url); 370 | assert.equal(res.status, 500); 371 | assert.equal(await res.text(), 'Internal Server Error'); 372 | }); 373 | 374 | test('async errors in fetch handler are caught and handled', async () => { 375 | using server = await createTestServer({ 376 | fetch: async () => { 377 | await new Promise(resolve => setTimeout(resolve, 10)); 378 | throw new Error('Async error occurred'); 379 | }, 380 | }); 381 | 382 | const res = await fetch(server.url); 383 | assert.equal(res.status, 500); 384 | assert.equal(await res.text(), 'Internal Server Error'); 385 | }); 386 | 387 | test('synchronous errors in fetch handler are caught and handled', async () => { 388 | using server = await createTestServer({ 389 | fetch: () => { 390 | throw new Error('Sync error occurred'); 391 | }, 392 | }); 393 | 394 | const res = await fetch(server.url); 395 | assert.equal(res.status, 500); 396 | assert.equal(await res.text(), 'Internal Server Error'); 397 | }); 398 | 399 | test('custom onError handler receives error and can return custom response', async () => { 400 | let errorReceived: Error | undefined; 401 | let requestReceived: Request | undefined; 402 | 403 | using server = await createTestServer({ 404 | fetch: async () => { 405 | throw new Error('Test error message'); 406 | }, 407 | onError: (error, request) => { 408 | errorReceived = error as Error; 409 | requestReceived = request; 410 | return new Response('Custom error response', { 411 | status: 418, 412 | statusText: "I'm a teapot", 413 | }); 414 | }, 415 | }); 416 | 417 | const res = await fetch(server.url); 418 | assert.equal(res.status, 418); 419 | assert.equal(res.statusText, "I'm a teapot"); 420 | assert.equal(await res.text(), 'Custom error response'); 421 | assert.ok(errorReceived instanceof Error); 422 | assert.equal(errorReceived.message, 'Test error message'); 423 | assert.ok(requestReceived instanceof Request); 424 | }); 425 | 426 | test('async custom onError handler works correctly', async () => { 427 | using server = await createTestServer({ 428 | fetch: async () => { 429 | throw new Error('Async fetch error'); 430 | }, 431 | onError: async error => { 432 | await new Promise(resolve => setTimeout(resolve, 10)); 433 | return new Response(`Handled: ${(error as Error).message}`, { 434 | status: 503, 435 | statusText: 'Service Unavailable', 436 | }); 437 | }, 438 | }); 439 | 440 | const res = await fetch(server.url); 441 | assert.equal(res.status, 503); 442 | assert.equal(res.statusText, 'Service Unavailable'); 443 | assert.equal(await res.text(), 'Handled: Async fetch error'); 444 | }); 445 | 446 | test('errors thrown in onError handler fallback to default error handler', async () => { 447 | using server = await createTestServer({ 448 | fetch: async () => { 449 | throw new Error('Original error'); 450 | }, 451 | onError: () => { 452 | throw new Error('Error in error handler'); 453 | }, 454 | }); 455 | 456 | const res = await fetch(server.url); 457 | assert.equal(res.status, 500); 458 | assert.equal(await res.text(), 'Internal Server Error'); 459 | }); 460 | 461 | test('async errors thrown in onError handler fallback to default error handler', async () => { 462 | using server = await createTestServer({ 463 | fetch: async () => { 464 | throw new Error('Original async error'); 465 | }, 466 | onError: async () => { 467 | await new Promise(resolve => setTimeout(resolve, 10)); 468 | throw new Error('Async error in error handler'); 469 | }, 470 | }); 471 | 472 | const res = await fetch(server.url); 473 | assert.equal(res.status, 500); 474 | assert.equal(await res.text(), 'Internal Server Error'); 475 | }); 476 | 477 | test('rejecting promises in onError handler fallback to default error handler', async () => { 478 | using server = await createTestServer({ 479 | fetch: async () => { 480 | throw new Error('Original error'); 481 | }, 482 | onError: () => { 483 | return Promise.reject(new Error('Promise rejection in error handler')); 484 | }, 485 | }); 486 | 487 | const res = await fetch(server.url); 488 | assert.equal(res.status, 500); 489 | assert.equal(await res.text(), 'Internal Server Error'); 490 | }); 491 | 492 | test('error handling preserves request details when onError fails', async () => { 493 | using server = await createTestServer({ 494 | fetch: async request => { 495 | assert.ok(request.url.includes(server.url)); 496 | throw new Error('Test error with request details'); 497 | }, 498 | onError: (error, request) => { 499 | // Verify we receive both error and request in onError 500 | assert.ok(error instanceof Error); 501 | assert.ok(request instanceof Request); 502 | // But then fail in the error handler 503 | throw new Error('onError handler failed'); 504 | }, 505 | }); 506 | 507 | const res = await fetch(`${server.url}/test-path?param=value`, { 508 | method: 'POST', 509 | headers: { 510 | 'Content-Type': 'application/json', 511 | }, 512 | body: JSON.stringify({test: 'data'}), 513 | }); 514 | 515 | assert.equal(res.status, 500); 516 | assert.equal(await res.text(), 'Internal Server Error'); 517 | }); 518 | 519 | // test('static routes', async () => { 520 | // const server = await createTestServer({ 521 | // static: { 522 | // '/static/file.txt': new Response('Hello, world!'), 523 | // '/static/stream': new Response( 524 | // new ReadableStream({ 525 | // async start(controller) { 526 | // controller.enqueue(new TextEncoder().encode('Hello, world!')); 527 | // controller.close(); 528 | // }, 529 | // }), 530 | // ), 531 | // }, 532 | // }); 533 | 534 | // try { 535 | // const res = await fetch(server.url + '/static/file.txt'); 536 | // assert.equal(await res.text(), 'Hello, world!'); 537 | 538 | // const streamed = await fetch(server.url + '/static/file.txt'); 539 | // assert.equal(await streamed.text(), 'Hello, world!'); 540 | // } finally { 541 | // server.close(); 542 | // } 543 | // }); 544 | }); 545 | -------------------------------------------------------------------------------- /packages/uws/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.options.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/uws/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {config} from '../../tsup.config.ts'; 2 | export default config; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.options.json", 3 | "exclude": ["./apps/docs/**/*"], 4 | "references": [ 5 | { 6 | "path": "./packages/client" 7 | }, 8 | { 9 | "path": "./packages/core" 10 | }, 11 | { 12 | "path": "./packages/uws" 13 | }, 14 | { 15 | "path": "./examples/ai" 16 | }, 17 | { 18 | "path": "./examples/basic" 19 | }, 20 | { 21 | "path": "./examples/bench" 22 | }, 23 | { 24 | "path": "./examples/client" 25 | }, 26 | { 27 | "path": "./examples/deno" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.options.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "NodeNext", 4 | "module": "NodeNext", 5 | "target": "ESNext", 6 | "strict": true, 7 | "emitDeclarationOnly": true, // Required for composite builds. 8 | "composite": true, 9 | "outDir": "${configDir}/compile", // Specify an output folder for all emitted files. 10 | "tsBuildInfoFile": "${configDir}/compile/.tsbuildinfo", // Specify the folder for .tsbuildinfo incremental compilation files. 11 | "useUnknownInCatchVariables": true, 12 | "noImplicitOverride": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "exactOptionalPropertyTypes": true, 17 | "noImplicitReturns": false, 18 | "noUncheckedIndexedAccess": true, 19 | "allowImportingTsExtensions": true, 20 | "verbatimModuleSyntax": true, 21 | "isolatedModules": true, 22 | "skipLibCheck": true 23 | }, 24 | "exclude": [ 25 | "apps/docs/**/*", 26 | "compile/", 27 | "**/*/compile/**/*", 28 | "**/*/dist/**/*", 29 | "node_modules", 30 | "tsup.config.ts", 31 | "**/tsup.config.ts" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {type Options} from 'tsup'; 2 | 3 | export const config = { 4 | entry: ['./src/index.ts'], 5 | clean: true, 6 | dts: { 7 | compilerOptions: { 8 | composite: false, 9 | }, 10 | }, 11 | format: ['esm', 'cjs'], 12 | } satisfies Options; 13 | --------------------------------------------------------------------------------