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