├── .gitignore ├── README.md ├── bun.lock ├── cli └── build.ts ├── client └── index.ts ├── index.ts ├── package.json ├── routes └── api │ ├── user.ts │ └── user │ └── [id].ts ├── src ├── errors.ts ├── generate-router.ts ├── router.ts └── validate.ts ├── tsconfig.json ├── typest.config.ts ├── typest.dev.ts └── typest.router.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typest.ts 2 | 3 | ### Edge,Bun,Next.js Native Framework 4 | 5 | --- 6 | 7 | **typest.ts** is a minimal, Bun-native, edge-first backend framework built to feel like *Next.js App Router* for APIs. 8 | Just drop a `.ts` file, define your Zod types, and instantly get a fully typed, validated API with an auto-generated client SDK. 9 | 10 | > **Zod-Powered. ts-rest Connected. Bun Fast. Edge Native.** 11 | 12 | --- 13 | 14 | ## 🚀 Why typest.ts? 15 | 16 | Modern frontend development has evolved — with Next.js App Router, we structure our UI with files and composable types. 17 | Yet, backend APIs are still trapped in verbose DTOs, OpenAPI schemas, and separate type definitions. 18 | 19 | **typest.ts** eliminates the gap: 20 | 21 | * File-based API routes like Next.js App Router 22 | * Input/output schemas written with Zod 23 | * Automatic type-safe SDKs via ts-rest 24 | * Zero config, zero boilerplate 25 | * Runs on Bun — instantly and at the edge 26 | 27 | --- 28 | 29 | ## 🔥 Features 30 | 31 | * ✅ **Zod-first input/output schemas** 32 | * ✅ **tRPC-like type propagation** without the complexity 33 | * ✅ **Next.js-style file routing** (`routes/api/user.ts → GET /api/user`) 34 | * ✅ **Bun-native runtime** with near-zero cold starts 35 | * ✅ **Edge-first design** — deploy anywhere: Vercel Edge, Cloudflare Workers, etc 36 | * ✅ **Auto-generated type-safe clients** with `typest build` 37 | 38 | --- 39 | 40 | ## 🧬 Example: Fully Typed API in One File 41 | 42 | ```ts 43 | // routes/api/user.ts 44 | import { z } from "zod"; 45 | 46 | export const input = { 47 | query: z.object({ id: z.string() }) 48 | }; 49 | 50 | export const output = z.object({ 51 | id: z.string(), 52 | name: z.string() 53 | }); 54 | 55 | export const GET = async ({ input }) => { 56 | return { id: input.query.id, name: "Alice" }; 57 | }; 58 | ``` 59 | 60 | --- 61 | 62 | ## 💻 Next.js App Router Integration 63 | 64 | `typest.ts` was designed with **Next.js App Router developers in mind**. 65 | Just run `typest build`, import the generated SDK in your React Client Component, and call your API with full type safety: 66 | 67 | ```tsx 68 | 'use client'; 69 | import { api } from '../client'; 70 | 71 | const user = await api.user.GET({ id: '123' }); 72 | ``` 73 | 74 | --- 75 | 76 | ## 🌐 Edge Native, Bun Fast 77 | 78 | `typest.ts` leverages **Bun as its runtime** — fast startup, native `fetch`, built-in `.env` support, and blazing performance. 79 | No Express, no Fastify, no polyfills. Just native speed and minimalism. 80 | 81 | The result? **First-class support for edge deployment**: 82 | 83 | * ✅ Vercel Edge Functions 84 | * ✅ Cloudflare Workers 85 | * ✅ Deno Deploy (coming soon) 86 | 87 | --- 88 | 89 | ## 🧠 Philosophy 90 | 91 | > "Types are truth. Zod is law. Files are APIs." 92 | 93 | `typest.ts` isn't just a framework — it's a new way of thinking about APIs: 94 | 95 | * Write a file 96 | * Define your types 97 | * Get an API + SDK instantly 98 | 99 | No codegen. No OpenAPI hell. No DTOs. Just types. 100 | 101 | --- 102 | 103 | ## 📦 Get Started 104 | 105 | ```bash 106 | npx typest init 107 | npx typest dev 108 | npx typest build 109 | ``` 110 | 111 | --- 112 | 113 | ## 🧪 Tech Stack 114 | 115 | * Runtime: **Bun** 116 | * Validation: **Zod** 117 | * SDK Generator: **ts-rest** 118 | 119 | --- 120 | 121 | ## 🏁 Join the Typed Future 122 | 123 | typest.ts is the backend framework for frontend minds. 124 | Build APIs like you write React components. With types, with structure, with speed. 125 | 126 | > **Just Bun. Just Zod. Just structure. Just typest.** 127 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "bevel.ts", 6 | "dependencies": { 7 | "@ts-rest/core": "^3.52.1", 8 | "zod": "^3.25.49", 9 | }, 10 | "devDependencies": { 11 | "@types/bun": "latest", 12 | "bun-types": "^1.2.15", 13 | "tsx": "^4.19.4", 14 | }, 15 | "peerDependencies": { 16 | "typescript": "^5", 17 | }, 18 | }, 19 | }, 20 | "packages": { 21 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], 22 | 23 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], 24 | 25 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="], 26 | 27 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="], 28 | 29 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="], 30 | 31 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="], 32 | 33 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="], 34 | 35 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="], 36 | 37 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="], 38 | 39 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="], 40 | 41 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="], 42 | 43 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="], 44 | 45 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="], 46 | 47 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="], 48 | 49 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="], 50 | 51 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], 52 | 53 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], 54 | 55 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], 56 | 57 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="], 58 | 59 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="], 60 | 61 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="], 62 | 63 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="], 64 | 65 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="], 66 | 67 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="], 68 | 69 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="], 70 | 71 | "@ts-rest/core": ["@ts-rest/core@3.52.1", "", { "peerDependencies": { "@types/node": "^18.18.7 || >=20.8.4", "zod": "^3.22.3" }, "optionalPeers": ["@types/node", "zod"] }, "sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g=="], 72 | 73 | "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], 74 | 75 | "@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="], 76 | 77 | "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], 78 | 79 | "esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="], 80 | 81 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 82 | 83 | "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], 84 | 85 | "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 86 | 87 | "tsx": ["tsx@4.19.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q=="], 88 | 89 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 90 | 91 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 92 | 93 | "zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="], 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cli/build.ts: -------------------------------------------------------------------------------- 1 | import { generateRouterFile } from '../src/generate-router'; 2 | import config from '../typest.config'; 3 | 4 | const outFile = config.routerOutFile || './typest.router.ts'; 5 | generateRouterFile(outFile); -------------------------------------------------------------------------------- /client/index.ts: -------------------------------------------------------------------------------- 1 | import { initClient } from '@ts-rest/core'; // Assuming 'initClient' is the correct export 2 | import { router } from '../typest.router'; 3 | 4 | export const api = initClient(router, { baseUrl: 'http://localhost:3000', baseHeaders: {} }); -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bevel.ts", 3 | "module": "index.ts", 4 | "type": "module", 5 | "private": true, 6 | "devDependencies": { 7 | "@types/bun": "latest", 8 | "bun-types": "^1.2.15", 9 | "tsx": "^4.19.4" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5" 13 | }, 14 | "dependencies": { 15 | "@ts-rest/core": "^3.52.1", 16 | "zod": "^3.25.49" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /routes/api/user.ts: -------------------------------------------------------------------------------- 1 | // routes/api/user.ts 2 | import { z } from 'zod'; 3 | 4 | export const input = { 5 | query: z.object({ id: z.string() }) 6 | }; 7 | 8 | export const output = z.object({ 9 | id: z.string(), 10 | name: z.string(), 11 | }); 12 | 13 | type InputType = { 14 | query: z.infer; 15 | }; 16 | 17 | export const GET = async ({ input }: { input: InputType }) => { 18 | return { id: input.query.id, name: "AleX" }; 19 | }; 20 | -------------------------------------------------------------------------------- /routes/api/user/[id].ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { NotFoundError } from '../../../src/errors'; 3 | 4 | export const input = { 5 | params: z.object({ id: z.string().uuid() }), // UUIDである必要あり 6 | }; 7 | 8 | export const output = z.object({ 9 | id: z.string(), 10 | name: z.string(), 11 | }); 12 | 13 | type InputType = { 14 | params: z.infer; 15 | }; 16 | 17 | export const GET = async ({ input }: { input: InputType }) => { 18 | if (input.params.id === '00000000-0000-0000-0000-000000000000') { 19 | throw new NotFoundError('User not found'); 20 | } 21 | 22 | return { id: input.params.id, name: "Alice" }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class TypestError extends Error { 2 | status: number; 3 | constructor(message: string, status = 500) { 4 | super(message); 5 | this.status = status; 6 | } 7 | } 8 | 9 | export class BadRequestError extends TypestError { 10 | constructor(message = 'Bad Request') { 11 | super(message, 400); 12 | } 13 | } 14 | 15 | export class UnauthorizedError extends TypestError { 16 | constructor(message = 'Unauthorized') { 17 | super(message, 401); 18 | } 19 | } 20 | 21 | export class NotFoundError extends TypestError { 22 | constructor(message = 'Not Found') { 23 | super(message, 404); 24 | } 25 | } 26 | 27 | export class InternalServerError extends TypestError { 28 | constructor(message = 'Internal Server Error') { 29 | super(message, 500); 30 | } 31 | } -------------------------------------------------------------------------------- /src/generate-router.ts: -------------------------------------------------------------------------------- 1 | import { scanRoutes } from './router'; 2 | import { z } from 'zod'; 3 | import { writeFileSync } from 'fs'; 4 | 5 | // 🔧 スキーマをZodコードとして出力する関数 6 | function printZod(schema: any): string { 7 | if (schema instanceof z.ZodObject) { 8 | const shape = schema._def.shape(); 9 | const fields = Object.entries(shape).map( 10 | ([key, val]) => `${key}: ${printZod(val)}` 11 | ); 12 | return `z.object({ ${fields.join(', ')} })`; 13 | } 14 | 15 | if (schema instanceof z.ZodString) return 'z.string()'; 16 | if (schema instanceof z.ZodNumber) return 'z.number()'; 17 | if (schema instanceof z.ZodBoolean) return 'z.boolean()'; 18 | if (schema instanceof z.ZodOptional) return `z.optional(${printZod(schema._def.innerType)})`; 19 | if (schema instanceof z.ZodArray) return `z.array(${printZod(schema._def.type)})`; 20 | 21 | // TODO: 他の型(union, enum, literalなど)はここで追加 22 | return 'z.any()'; 23 | } 24 | 25 | export function generateRouterFile(outFile: string) { 26 | const routes = scanRoutes(); 27 | const lines: string[] = []; 28 | 29 | lines.push(`import { initContract } from '@ts-rest/core';`); 30 | lines.push(`import { z } from 'zod';`); 31 | lines.push(`\nconst c = initContract();`); 32 | lines.push(`export const router = c.router({`); 33 | 34 | for (const route of routes) { 35 | const mod = require(`../${route.filePath}`); 36 | const routeName = route.path 37 | .replace(/^\/api\//, '') 38 | 39 | .replace(/[^a-zA-Z0-9]/g, '_'); 40 | 41 | const input = mod.input?.body || mod.input?.query || z.object({}); 42 | const output = mod.output || z.any(); 43 | 44 | lines.push(` ${routeName}: {`); 45 | lines.push(` path: '${route.path}',`); 46 | lines.push(` method: '${route.method}',`); 47 | lines.push(` query: ${printZod(input)},`); 48 | lines.push(` responses: {`); 49 | lines.push(` 200: ${printZod(output)}`); 50 | lines.push(` }`); 51 | lines.push(` },`); 52 | } 53 | 54 | lines.push(`});`); 55 | writeFileSync(outFile, lines.join('\n')); 56 | console.log(`✅ Generated router to ${outFile}`); 57 | } 58 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, statSync } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | export type RouteHandler = { 5 | path: string; 6 | method: string; 7 | handler: Function; 8 | filePath: string; // Added filePath property 9 | }; 10 | 11 | export function scanRoutes(dir = 'routes'): RouteHandler[] { 12 | 13 | const routes: RouteHandler[] = []; 14 | function walk(current: string, base = '') { 15 | const full = join(dir, current); 16 | const stat = statSync(full); 17 | 18 | 19 | if (current.endsWith('.ts') && !current.endsWith('.test.ts') && !current.includes('__test__') && stat.isDirectory()) { 20 | for (const file of readdirSync(full)) { 21 | walk(join(current, file), join(base, file)); 22 | } 23 | } else if (current.endsWith('.ts')) { 24 | const mod = require(join(process.cwd(), dir, current)); 25 | const routePath = '/' + current 26 | .replace(/^api/, 'api') // 先頭のapiだけ残す 27 | .replace(/\\/g, '/') 28 | .replace(/\.ts$/, '') 29 | .replace(/\[([^\]]+)\]/g, ':$1'); 30 | 31 | 32 | for (const method of ['GET', 'POST', 'PUT', 'DELETE']) { 33 | if (typeof mod[method] === 'function') { 34 | routes.push({ 35 | path: routePath, 36 | method, 37 | handler: mod[method], 38 | filePath: join(dir, current).replace(/\\/g, '/') // ←ここ追加 39 | }); 40 | 41 | } 42 | } 43 | } 44 | } 45 | 46 | walk('api'); 47 | return routes; 48 | } -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | interface ModuleInput { 4 | params?: z.ZodTypeAny; 5 | query?: z.ZodTypeAny; 6 | body?: z.ZodTypeAny; 7 | } 8 | 9 | interface Module { 10 | input?: ModuleInput; 11 | } 12 | 13 | interface ParseInputResult { 14 | input: Record | null; 15 | error: { status: number; body: { error: string; details?: { field: string; message: string }[] } } | null; 16 | } 17 | 18 | export async function parseInput( 19 | mod: Module, 20 | req: Request, 21 | routeParams: Record 22 | ): Promise { 23 | const result: Record = {}; 24 | 25 | if (!mod.input) return { input: {}, error: null }; 26 | 27 | try { 28 | if (mod.input.params) { 29 | result.params = mod.input.params.parse(routeParams); 30 | } 31 | if (mod.input.query) { 32 | const queryObj = Object.fromEntries(new URL(req.url).searchParams); 33 | result.query = mod.input.query.parse(queryObj); 34 | } 35 | if (mod.input.body) { 36 | const json = await req.json(); 37 | result.body = mod.input.body.parse(json); 38 | } 39 | return { input: result, error: null }; 40 | } catch (err) { 41 | return { input: null, error: formatZodError(err) }; 42 | } 43 | } 44 | 45 | function formatZodError(err: any) { 46 | if (err instanceof z.ZodError) { 47 | return { 48 | status: 400, 49 | body: { 50 | error: 'Invalid request', 51 | details: err.errors.map(e => ({ 52 | field: e.path.join('.'), 53 | message: e.message, 54 | })), 55 | }, 56 | }; 57 | } 58 | return { 59 | status: 500, 60 | body: { error: 'Unknown validation error' }, 61 | }; 62 | } 63 | export function validateOutput(mod: any, data: any): null | { status: number; body: any } { 64 | if (!mod.output) return null; 65 | 66 | try { 67 | mod.output.parse(data); 68 | return null; 69 | } catch (err) { 70 | if (err instanceof z.ZodError) { 71 | return { 72 | status: 500, 73 | body: { 74 | error: 'Output schema validation failed', 75 | details: err.errors.map(e => ({ 76 | field: e.path.join('.'), 77 | message: e.message, 78 | })), 79 | }, 80 | }; 81 | } 82 | return { 83 | status: 500, 84 | body: { error: 'Unknown output validation error' }, 85 | }; 86 | } 87 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | 20 | 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /typest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | routesDir: './routes', 3 | clientOutDir: './client', 4 | routerOutFile: './typest.router.ts' 5 | }; 6 | -------------------------------------------------------------------------------- /typest.dev.ts: -------------------------------------------------------------------------------- 1 | import { serve } from 'bun'; 2 | import { scanRoutes } from './src/router'; 3 | import { watch } from 'fs'; 4 | import { parseInput } from './src/validate'; 5 | import { TypestError } from './src/errors'; 6 | import { validateOutput } from './src/validate'; 7 | 8 | let routes = scanRoutes(); 9 | console.log('🛠️ Debug: Scanned Routes'); 10 | for (const r of routes) { 11 | console.log(`[${r.method}] ${r.path} → ${r.filePath}`); 12 | } 13 | 14 | function reloadRoutes() { 15 | try { 16 | Object.keys(require.cache).forEach((key) => delete require.cache[key]); 17 | routes = scanRoutes(); 18 | console.log('♻️ Routes reloaded'); 19 | } catch (err) { 20 | console.error('❌ Failed to reload routes:', err); 21 | } 22 | } 23 | 24 | watch('./routes', { recursive: true }, (_, filename) => { 25 | console.log(`🔄 Change detected in ${filename}`); 26 | reloadRoutes(); 27 | }); 28 | 29 | // 動的ルート対応: pathのマッチとparams抽出 30 | function matchRoute(routePath: string, reqPath: string): { matched: boolean, params: Record } { 31 | const paramNames: string[] = []; 32 | const pattern = routePath.replace(/:([^/]+)/g, (_, name) => { 33 | paramNames.push(name); 34 | return '([^/]+)'; 35 | }); 36 | 37 | const regex = new RegExp(`^${pattern}$`); 38 | const match = reqPath.match(regex); 39 | if (!match) return { matched: false, params: {} }; 40 | 41 | const params: Record = {}; 42 | paramNames.forEach((name, i) => { 43 | params[name] = match[i + 1] || ''; 44 | }); 45 | 46 | return { matched: true, params }; 47 | } 48 | 49 | serve({ 50 | port: 3000, 51 | async fetch(req) { 52 | const url = new URL(req.url); 53 | const pathname = url.pathname; 54 | const method = req.method; 55 | 56 | for (const route of routes) { 57 | const { matched, params } = matchRoute(route.path, pathname); 58 | if (matched && method === route.method) { 59 | console.log('✅ Match found:', route.path, '→', pathname); 60 | console.log('📦 Params extracted:', params); 61 | const importPath = './routes' + 62 | route.path 63 | .replace('/api', '') 64 | .replace(/:[^/]+/g, '') // すべての :param を除去 65 | .replace(/\\/g, '/') 66 | .replace(/\/+$/, '') // 末尾の余計なスラッシュを削除 67 | + '.ts'; 68 | console.log('📁 Importing module:', importPath); 69 | 70 | const mod = await import(`./${route.filePath}`); 71 | 72 | const { input, error } = await parseInput(mod, req, params); 73 | 74 | try { 75 | const result = await route.handler({ input }); 76 | 77 | const outputError = validateOutput(mod, result); 78 | if (outputError) { 79 | return new Response(JSON.stringify(outputError.body), { 80 | status: outputError.status, 81 | headers: { 'Content-Type': 'application/json' } 82 | }); 83 | } 84 | 85 | return new Response(JSON.stringify(result), { 86 | headers: { 'Content-Type': 'application/json' } 87 | }); 88 | } catch (err) { 89 | if (err instanceof TypestError) { 90 | return new Response(JSON.stringify({ error: err.message }), { 91 | status: err.status, 92 | headers: { 'Content-Type': 'application/json' } 93 | }); 94 | } else { 95 | return new Response(JSON.stringify({ error: 'Internal Server Error' }), { 96 | status: 500, 97 | headers: { 'Content-Type': 'application/json' } 98 | }); 99 | } 100 | } 101 | } 102 | } 103 | 104 | return new Response('Not Found', { status: 404 }); 105 | } 106 | }); 107 | 108 | console.log('🚀 TypeScript dev server running on http://localhost:3000'); -------------------------------------------------------------------------------- /typest.router.ts: -------------------------------------------------------------------------------- 1 | import { initContract } from '@ts-rest/core'; 2 | import { z } from 'zod'; 3 | 4 | const c = initContract(); 5 | export const router = c.router({ 6 | user__id: { 7 | path: '/api/user/:id', 8 | method: 'GET', 9 | query: z.object({ }), 10 | responses: { 11 | 200: z.object({ id: z.string(), name: z.string() }) 12 | } 13 | }, 14 | user: { 15 | path: '/api/user', 16 | method: 'GET', 17 | query: z.object({ id: z.string() }), 18 | responses: { 19 | 200: z.object({ id: z.string(), name: z.string() }) 20 | } 21 | }, 22 | }); --------------------------------------------------------------------------------