├── LICENSE ├── README.md ├── cloudflare-example ├── .env.example ├── .env.test ├── .github │ └── funding.yml ├── .gitignore ├── LICENSE ├── README.md ├── drizzle.config.ts ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── src │ ├── app.ts │ ├── db │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 0000_same_squadron_sinister.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema.ts │ ├── env-runtime.ts │ ├── env.ts │ ├── lib │ │ ├── configure-open-api.ts │ │ ├── constants.ts │ │ ├── create-app.ts │ │ └── types.ts │ ├── middlewares │ │ └── pino-logger.ts │ └── routes │ │ ├── index.route.ts │ │ └── tasks │ │ ├── tasks.handlers.ts │ │ ├── tasks.index.ts │ │ ├── tasks.routes.ts │ │ └── tasks.test.ts ├── tsconfig.json ├── vitest.config.ts └── wrangler.toml ├── flyio-example ├── .dockerignore ├── .env.example ├── .env.test ├── .github │ ├── funding.yml │ └── workflows │ │ └── fly-deploy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── drizzle.config.ts ├── eslint.config.mjs ├── fly.toml ├── package.json ├── pnpm-lock.yaml ├── src │ ├── app.ts │ ├── db │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 0000_same_squadron_sinister.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema.ts │ ├── env.ts │ ├── index.ts │ ├── lib │ │ ├── configure-open-api.ts │ │ ├── constants.ts │ │ ├── create-app.ts │ │ └── types.ts │ ├── middlewares │ │ └── pino-logger.ts │ └── routes │ │ ├── index.route.ts │ │ └── tasks │ │ ├── tasks.handlers.ts │ │ ├── tasks.index.ts │ │ ├── tasks.routes.ts │ │ └── tasks.test.ts ├── tsconfig.json └── vitest.config.ts ├── vercel-edge-example ├── .env.example ├── .env.test ├── .github │ └── funding.yml ├── .gitignore ├── LICENSE ├── README.md ├── api │ └── index.ts ├── drizzle.config.ts ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── public │ └── .gitkeep ├── src │ ├── app.ts │ ├── db │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 0000_same_squadron_sinister.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema.ts │ ├── env.ts │ ├── index.ts │ ├── lib │ │ ├── configure-open-api.ts │ │ ├── constants.ts │ │ ├── create-app.ts │ │ └── types.ts │ ├── middlewares │ │ └── pino-logger.ts │ └── routes │ │ ├── index.route.ts │ │ └── tasks │ │ ├── tasks.handlers.ts │ │ ├── tasks.index.ts │ │ ├── tasks.routes.ts │ │ └── tasks.test.ts ├── tsconfig.json ├── vercel.json └── vitest.config.ts └── vercel-nodejs-example ├── .env.example ├── .env.test ├── .github └── funding.yml ├── .gitignore ├── LICENSE ├── README.md ├── api └── index.ts ├── drizzle.config.ts ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── public └── .gitkeep ├── src ├── app.ts ├── db │ ├── index.ts │ ├── migrations │ │ ├── 0000_same_squadron_sinister.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema.ts ├── env.ts ├── index.ts ├── lib │ ├── configure-open-api.ts │ ├── constants.ts │ ├── create-app.ts │ └── types.ts ├── middlewares │ └── pino-logger.ts └── routes │ ├── index.route.ts │ └── tasks │ ├── tasks.handlers.ts │ ├── tasks.index.ts │ ├── tasks.routes.ts │ └── tasks.test.ts ├── tsconfig.json ├── vercel.json └── vitest.config.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hono Node Deployment Examples 2 | 3 | Several Hono Node.js deployment examples that use the [hono-open-api-starter](https://github.com/w3cj/hono-open-api-starter) as a base, each configured for deployment to various platforms. 4 | 5 | * [fly.io example](./flyio-example/) 6 | * [vercel edge example](./vercel-edge-example/) 7 | * [vercel node.js example](./vercel-nodejs-example/) 8 | * [cloudflare example](./cloudflare-example/) 9 | -------------------------------------------------------------------------------- /cloudflare-example/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=9999 3 | LOG_LEVEL=debug 4 | DATABASE_URL=file:dev.db -------------------------------------------------------------------------------- /cloudflare-example/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=9999 3 | LOG_LEVEL=silent 4 | DATABASE_URL=file:test.db -------------------------------------------------------------------------------- /cloudflare-example/.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: CodingGarden 2 | patreon: CodingGarden 3 | custom: ["https://streamlabs.com/codinggarden/tip", "https://twitch.tv/products/codinggarden", "https://www.youtube.com/codinggarden/join"] 4 | -------------------------------------------------------------------------------- /cloudflare-example/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db* 31 | test.db 32 | .vercel 33 | dist 34 | .dev.vars -------------------------------------------------------------------------------- /cloudflare-example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /cloudflare-example/README.md: -------------------------------------------------------------------------------- 1 | # Hono Open API Starter 2 | 3 | A starter template for building fully documented type-safe JSON APIs with Hono and Open API. 4 | 5 | - [Hono Open API Starter](#hono-open-api-starter) 6 | - [Included](#included) 7 | - [Setup](#setup) 8 | - [Code Tour](#code-tour) 9 | - [Endpoints](#endpoints) 10 | - [References](#references) 11 | 12 | ## Included 13 | 14 | - Structured logging with [pino](https://getpino.io/) / [hono-pino](https://www.npmjs.com/package/hono-pino) 15 | - Documented / type-safe routes with [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 16 | - Interactive API documentation with [scalar](https://scalar.com/#api-docs) / [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/main/packages/hono-api-reference) 17 | - Convenience methods / helpers to reduce boilerplate with [stoker](https://www.npmjs.com/package/stoker) 18 | - Type-safe schemas and environment variables with [zod](https://zod.dev/) 19 | - Single source of truth database schemas with [drizzle](https://orm.drizzle.team/docs/overview) and [drizzle-zod](https://orm.drizzle.team/docs/zod) 20 | - Testing with [vitest](https://vitest.dev/) 21 | - Sensible editor, formatting and linting settings with [@antfu/eslint-config](https://github.com/antfu/eslint-config) 22 | 23 | ## Setup 24 | 25 | Clone this template without git history 26 | 27 | ```sh 28 | npx degit w3cj/hono-open-api-starter my-api 29 | cd my-api 30 | ``` 31 | 32 | Create `.env` file 33 | 34 | ```sh 35 | cp .env.sample .env 36 | ``` 37 | 38 | Create sqlite db / push schema 39 | 40 | ```sh 41 | pnpm drizzle-kit push 42 | ``` 43 | 44 | Install dependencies 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Run 51 | 52 | ```sh 53 | pnpm dev 54 | ``` 55 | 56 | Lint 57 | 58 | ```sh 59 | pnpm lint 60 | ``` 61 | 62 | Test 63 | 64 | ```sh 65 | pnpm test 66 | ``` 67 | 68 | ## Code Tour 69 | 70 | Base hono app exported from [app.ts](./src/app.ts). Local development uses [@hono/node-server](https://hono.dev/docs/getting-started/nodejs) defined in [index.ts](./src/index.ts) - update this file or create a new entry point to use your preferred runtime. 71 | 72 | Typesafe env defined in [env.ts](./src/env.ts) - add any other required environment variables here. The application will not start if any required environment variables are missing 73 | 74 | See [src/routes/tasks](./src/routes/tasks/) for an example Open API group. Copy this folder / use as an example for your route groups. 75 | 76 | - Router created in [tasks.index.ts](./src/routes/tasks/tasks.index.ts) 77 | - Route definitions defined in [tasks.routes.ts](./src/routes/tasks/tasks.routes.ts) 78 | - Hono request handlers defined in [tasks.handlers.ts](./src/routes/tasks/tasks.handlers.ts) 79 | - Group unit tests defined in [tasks.test.ts](./src/routes/tasks/tasks.test.ts) 80 | 81 | All app routes are grouped together and exported into single type as `AppType` in [app.ts](./src/app.ts) for use in [RPC / hono/client](https://hono.dev/docs/guides/rpc). 82 | 83 | ## Endpoints 84 | 85 | | Path | Description | 86 | | ------------------ | ------------------------ | 87 | | GET /doc | Open API Specification | 88 | | GET /reference | Scalar API Documentation | 89 | | GET /tasks | List all tasks | 90 | | POST /tasks | Create a task | 91 | | GET /tasks/{id} | Get one task by id | 92 | | PATCH /tasks/{id} | Patch one task by id | 93 | | DELETE /tasks/{id} | Delete one task by id | 94 | 95 | ## References 96 | 97 | - [What is Open API?](https://swagger.io/docs/specification/v3_0/about/) 98 | - [Hono](https://hono.dev/) 99 | - [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) 100 | - [Testing](https://hono.dev/docs/guides/testing) 101 | - [Testing Helper](https://hono.dev/docs/helpers/testing) 102 | - [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 103 | - [Scalar Documentation](https://github.com/scalar/scalar/tree/main/?tab=readme-ov-file#documentation) 104 | - [Themes / Layout](https://github.com/scalar/scalar/blob/main/documentation/themes.md) 105 | - [Configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) 106 | -------------------------------------------------------------------------------- /cloudflare-example/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | import env from "@/env-runtime"; 4 | 5 | export default defineConfig({ 6 | schema: "./src/db/schema.ts", 7 | out: "./src/db/migrations", 8 | dialect: "sqlite", 9 | driver: "turso", 10 | dbCredentials: { 11 | url: env.DATABASE_URL, 12 | authToken: env.DATABASE_AUTH_TOKEN, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /cloudflare-example/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu({ 4 | type: "app", 5 | typescript: true, 6 | formatters: true, 7 | stylistic: { 8 | indent: 2, 9 | semi: true, 10 | quotes: "double", 11 | }, 12 | ignores: ["**/migrations/*"], 13 | }, { 14 | rules: { 15 | "no-console": ["warn"], 16 | "antfu/no-top-level-await": ["off"], 17 | "node/prefer-global/process": ["off"], 18 | "node/no-process-env": ["error"], 19 | "perfectionist/sort-imports": ["error", { 20 | internalPattern: ["@/**"], 21 | }], 22 | "unicorn/filename-case": ["error", { 23 | case: "kebabCase", 24 | ignore: ["README.md"], 25 | }], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /cloudflare-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hono-open-api-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "wrangler dev", 8 | "deploy": "wrangler deploy --minify", 9 | "start": "node ./dist/src/index.js", 10 | "dev:db": "turso dev --db-file dev.db", 11 | "typecheck": "tsc --noEmit", 12 | "lint": "eslint .", 13 | "lint:fix": "npm run lint --fix", 14 | "test": "cross-env NODE_ENV=test vitest", 15 | "build": "tsc && tsc-alias" 16 | }, 17 | "dependencies": { 18 | "@hono/node-server": "^1.13.1", 19 | "@hono/zod-openapi": "^0.16.4", 20 | "@libsql/client": "^0.14.0", 21 | "@scalar/hono-api-reference": "^0.5.150", 22 | "dotenv": "^16.4.5", 23 | "dotenv-expand": "^11.0.6", 24 | "drizzle-orm": "^0.33.0", 25 | "drizzle-zod": "^0.5.1", 26 | "hono": "^4.6.3", 27 | "hono-pino": "^0.3.0", 28 | "pino": "^9.4.0", 29 | "pino-pretty": "^11.2.2", 30 | "stoker": "^1.0.9", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@antfu/eslint-config": "^3.7.3", 35 | "@cloudflare/workers-types": "^4.20241004.0", 36 | "@types/node": "^22.7.4", 37 | "cross-env": "^7.0.3", 38 | "drizzle-kit": "^0.24.2", 39 | "eslint": "^9.12.0", 40 | "eslint-plugin-format": "^0.1.2", 41 | "tsc-alias": "^1.8.10", 42 | "tsx": "^4.19.1", 43 | "turso": "^0.1.0", 44 | "typescript": "^5.6.2", 45 | "vitest": "^2.1.2", 46 | "wrangler": "^3.80.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cloudflare-example/src/app.ts: -------------------------------------------------------------------------------- 1 | import configureOpenAPI from "@/lib/configure-open-api"; 2 | import createApp from "@/lib/create-app"; 3 | import index from "@/routes/index.route"; 4 | import tasks from "@/routes/tasks/tasks.index"; 5 | 6 | const app = createApp(); 7 | 8 | configureOpenAPI(app); 9 | 10 | const routes = [ 11 | index, 12 | tasks, 13 | ] as const; 14 | 15 | routes.forEach((route) => { 16 | app.route("/", route); 17 | }); 18 | 19 | export type AppType = typeof routes[number]; 20 | 21 | export default app; 22 | -------------------------------------------------------------------------------- /cloudflare-example/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | 4 | import type { Environment } from "@/env"; 5 | 6 | import * as schema from "./schema"; 7 | 8 | export function createDb(env: Environment) { 9 | const client = createClient({ 10 | url: env.DATABASE_URL, 11 | authToken: env.DATABASE_AUTH_TOKEN, 12 | }); 13 | 14 | const db = drizzle(client, { 15 | schema, 16 | }); 17 | 18 | return { db, client }; 19 | } 20 | -------------------------------------------------------------------------------- /cloudflare-example/src/db/migrations/0000_same_squadron_sinister.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tasks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `done` integer DEFAULT false NOT NULL, 5 | `created_at` integer, 6 | `updated_at` integer 7 | ); 8 | -------------------------------------------------------------------------------- /cloudflare-example/src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "f2bcf13c-160f-4648-b726-9d503979baef", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "created_at": { 33 | "name": "created_at", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updated_at": { 40 | "name": "updated_at", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {} 51 | } 52 | }, 53 | "enums": {}, 54 | "_meta": { 55 | "schemas": {}, 56 | "tables": {}, 57 | "columns": {} 58 | }, 59 | "internal": { 60 | "indexes": {} 61 | } 62 | } -------------------------------------------------------------------------------- /cloudflare-example/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1728133964232, 9 | "tag": "0000_same_squadron_sinister", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /cloudflare-example/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 3 | 4 | export const tasks = sqliteTable("tasks", { 5 | id: integer("id", { mode: "number" }) 6 | .primaryKey({ autoIncrement: true }), 7 | name: text("name") 8 | .notNull(), 9 | done: integer("done", { mode: "boolean" }) 10 | .notNull() 11 | .default(false), 12 | createdAt: integer("created_at", { mode: "timestamp" }) 13 | .$defaultFn(() => new Date()), 14 | updatedAt: integer("updated_at", { mode: "timestamp" }) 15 | .$defaultFn(() => new Date()) 16 | .$onUpdate(() => new Date()), 17 | }); 18 | 19 | export const selectTasksSchema = createSelectSchema(tasks); 20 | 21 | export const insertTasksSchema = createInsertSchema( 22 | tasks, 23 | { 24 | name: schema => schema.name.min(1).max(500), 25 | }, 26 | ).required({ 27 | done: true, 28 | }).omit({ 29 | id: true, 30 | createdAt: true, 31 | updatedAt: true, 32 | }); 33 | 34 | export const patchTasksSchema = insertTasksSchema.partial(); 35 | -------------------------------------------------------------------------------- /cloudflare-example/src/env-runtime.ts: -------------------------------------------------------------------------------- 1 | import { parseEnv } from "./env"; 2 | 3 | // eslint-disable-next-line node/no-process-env 4 | export default parseEnv(process.env); 5 | -------------------------------------------------------------------------------- /cloudflare-example/src/env.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-process-env */ 2 | import { config } from "dotenv"; 3 | import { expand } from "dotenv-expand"; 4 | import path from "node:path"; 5 | import { z } from "zod"; 6 | 7 | expand(config({ 8 | path: path.resolve( 9 | process.cwd(), 10 | process.env.NODE_ENV === "test" ? ".env.test" : ".env", 11 | ), 12 | })); 13 | 14 | const EnvSchema = z.object({ 15 | NODE_ENV: z.string().default("development"), 16 | PORT: z.coerce.number().default(9999), 17 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), 18 | DATABASE_URL: z.string().url(), 19 | DATABASE_AUTH_TOKEN: z.string().optional(), 20 | }).superRefine((input, ctx) => { 21 | if (input.NODE_ENV === "production" && !input.DATABASE_AUTH_TOKEN) { 22 | ctx.addIssue({ 23 | code: z.ZodIssueCode.invalid_type, 24 | expected: "string", 25 | received: "undefined", 26 | path: ["DATABASE_AUTH_TOKEN"], 27 | message: "Must be set when NODE_ENV is 'production'", 28 | }); 29 | } 30 | }); 31 | 32 | export type Environment = z.infer; 33 | 34 | export function parseEnv(data: any) { 35 | const { data: env, error } = EnvSchema.safeParse(data); 36 | 37 | if (error) { 38 | const errorMessage = `❌ Invalid env - ${Object.entries(error.flatten().fieldErrors).map(([key, errors]) => `${key}: ${errors.join(",")}`).join(" | ")}`; 39 | throw new Error(errorMessage); 40 | } 41 | 42 | return env; 43 | } 44 | -------------------------------------------------------------------------------- /cloudflare-example/src/lib/configure-open-api.ts: -------------------------------------------------------------------------------- 1 | import { apiReference } from "@scalar/hono-api-reference"; 2 | 3 | import type { AppOpenAPI } from "./types"; 4 | 5 | import packageJSON from "../../package.json"; 6 | 7 | export default function configureOpenAPI(app: AppOpenAPI) { 8 | app.doc("/doc", { 9 | openapi: "3.0.0", 10 | info: { 11 | version: packageJSON.version, 12 | title: "Tasks API", 13 | }, 14 | }); 15 | 16 | app.get( 17 | "/reference", 18 | apiReference({ 19 | theme: "kepler", 20 | layout: "classic", 21 | defaultHttpClient: { 22 | targetKey: "javascript", 23 | clientKey: "fetch", 24 | }, 25 | spec: { 26 | url: "/doc", 27 | }, 28 | }), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /cloudflare-example/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 3 | 4 | export const ZOD_ERROR_MESSAGES = { 5 | REQUIRED: "Required", 6 | EXPECTED_NUMBER: "Expected number, received nan", 7 | NO_UPDATES: "No updates provided", 8 | }; 9 | 10 | export const ZOD_ERROR_CODES = { 11 | INVALID_UPDATES: "invalid_updates", 12 | }; 13 | 14 | export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); 15 | -------------------------------------------------------------------------------- /cloudflare-example/src/lib/create-app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares"; 3 | import { defaultHook } from "stoker/openapi"; 4 | 5 | import { parseEnv } from "@/env"; 6 | import { pinoLogger } from "@/middlewares/pino-logger"; 7 | 8 | import type { AppBindings, AppOpenAPI } from "./types"; 9 | 10 | export function createRouter() { 11 | return new OpenAPIHono({ 12 | strict: false, 13 | defaultHook, 14 | }); 15 | } 16 | 17 | export default function createApp() { 18 | const app = createRouter(); 19 | app.use((c, next) => { 20 | // eslint-disable-next-line node/no-process-env 21 | c.env = parseEnv(Object.assign(c.env || {}, process.env)); 22 | return next(); 23 | }); 24 | app.use(serveEmojiFavicon("📝")); 25 | app.use(pinoLogger()); 26 | 27 | app.notFound(notFound); 28 | app.onError(onError); 29 | return app; 30 | } 31 | 32 | export function createTestApp(router: R) { 33 | return createApp().route("/", router); 34 | } 35 | -------------------------------------------------------------------------------- /cloudflare-example/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"; 2 | import type { PinoLogger } from "hono-pino"; 3 | 4 | import type { Environment } from "@/env"; 5 | 6 | export interface AppBindings { 7 | Bindings: Environment; 8 | Variables: { 9 | logger: PinoLogger; 10 | }; 11 | }; 12 | 13 | export type AppOpenAPI = OpenAPIHono; 14 | 15 | export type AppRouteHandler = RouteHandler; 16 | -------------------------------------------------------------------------------- /cloudflare-example/src/middlewares/pino-logger.ts: -------------------------------------------------------------------------------- 1 | import type { Context, MiddlewareHandler } from "hono"; 2 | import type { Env } from "hono-pino"; 3 | 4 | import { logger } from "hono-pino"; 5 | import { randomUUID } from "node:crypto"; 6 | import pino from "pino"; 7 | import pretty from "pino-pretty"; 8 | 9 | import type { AppBindings } from "@/lib/types"; 10 | 11 | export function pinoLogger() { 12 | return ((c, next) => logger({ 13 | pino: pino({ 14 | level: c.env.LOG_LEVEL || "info", 15 | }, c.env.NODE_ENV === "production" ? undefined : pretty()), 16 | http: { 17 | reqId: () => randomUUID(), 18 | }, 19 | })(c as unknown as Context, next)) satisfies MiddlewareHandler; 20 | } 21 | -------------------------------------------------------------------------------- /cloudflare-example/src/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent } from "stoker/openapi/helpers"; 4 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 5 | 6 | import { createRouter } from "@/lib/create-app"; 7 | 8 | const router = createRouter() 9 | .openapi( 10 | createRoute({ 11 | tags: ["Index"], 12 | method: "get", 13 | path: "/", 14 | responses: { 15 | [HttpStatusCodes.OK]: jsonContent( 16 | createMessageObjectSchema("Tasks API"), 17 | "Tasks API Index", 18 | ), 19 | }, 20 | }), 21 | (c) => { 22 | return c.json({ 23 | message: "Tasks API on Cloudflare", 24 | }, HttpStatusCodes.OK); 25 | }, 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /cloudflare-example/src/routes/tasks/tasks.handlers.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 4 | 5 | import type { AppRouteHandler } from "@/lib/types"; 6 | 7 | import { createDb } from "@/db"; 8 | import { tasks } from "@/db/schema"; 9 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 10 | 11 | import type { CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./tasks.routes"; 12 | 13 | export const list: AppRouteHandler = async (c) => { 14 | const { db } = createDb(c.env); 15 | const tasks = await db.query.tasks.findMany(); 16 | return c.json(tasks); 17 | }; 18 | 19 | export const create: AppRouteHandler = async (c) => { 20 | const { db } = createDb(c.env); 21 | const task = c.req.valid("json"); 22 | const [inserted] = await db.insert(tasks).values(task).returning(); 23 | return c.json(inserted, HttpStatusCodes.OK); 24 | }; 25 | 26 | export const getOne: AppRouteHandler = async (c) => { 27 | const { db } = createDb(c.env); 28 | const { id } = c.req.valid("param"); 29 | const task = await db.query.tasks.findFirst({ 30 | where(fields, operators) { 31 | return operators.eq(fields.id, id); 32 | }, 33 | }); 34 | 35 | if (!task) { 36 | return c.json( 37 | { 38 | message: HttpStatusPhrases.NOT_FOUND, 39 | }, 40 | HttpStatusCodes.NOT_FOUND, 41 | ); 42 | } 43 | 44 | return c.json(task, HttpStatusCodes.OK); 45 | }; 46 | 47 | export const patch: AppRouteHandler = async (c) => { 48 | const { db } = createDb(c.env); 49 | const { id } = c.req.valid("param"); 50 | const updates = c.req.valid("json"); 51 | 52 | if (Object.keys(updates).length === 0) { 53 | return c.json( 54 | { 55 | success: false, 56 | error: { 57 | issues: [ 58 | { 59 | code: ZOD_ERROR_CODES.INVALID_UPDATES, 60 | path: [], 61 | message: ZOD_ERROR_MESSAGES.NO_UPDATES, 62 | }, 63 | ], 64 | name: "ZodError", 65 | }, 66 | }, 67 | HttpStatusCodes.UNPROCESSABLE_ENTITY, 68 | ); 69 | } 70 | 71 | const [task] = await db.update(tasks) 72 | .set(updates) 73 | .where(eq(tasks.id, id)) 74 | .returning(); 75 | 76 | if (!task) { 77 | return c.json( 78 | { 79 | message: HttpStatusPhrases.NOT_FOUND, 80 | }, 81 | HttpStatusCodes.NOT_FOUND, 82 | ); 83 | } 84 | 85 | return c.json(task, HttpStatusCodes.OK); 86 | }; 87 | 88 | export const remove: AppRouteHandler = async (c) => { 89 | const { db } = createDb(c.env); 90 | const { id } = c.req.valid("param"); 91 | const result = await db.delete(tasks) 92 | .where(eq(tasks.id, id)); 93 | 94 | if (result.rowsAffected === 0) { 95 | return c.json( 96 | { 97 | message: HttpStatusPhrases.NOT_FOUND, 98 | }, 99 | HttpStatusCodes.NOT_FOUND, 100 | ); 101 | } 102 | 103 | return c.body(null, HttpStatusCodes.NO_CONTENT); 104 | }; 105 | -------------------------------------------------------------------------------- /cloudflare-example/src/routes/tasks/tasks.index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@/lib/create-app"; 2 | 3 | import * as handlers from "./tasks.handlers"; 4 | import * as routes from "./tasks.routes"; 5 | 6 | const router = createRouter() 7 | .openapi(routes.list, handlers.list) 8 | .openapi(routes.create, handlers.create) 9 | .openapi(routes.getOne, handlers.getOne) 10 | .openapi(routes.patch, handlers.patch) 11 | .openapi(routes.remove, handlers.remove); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /cloudflare-example/src/routes/tasks/tasks.routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent, jsonContentRequired } from "stoker/openapi/helpers"; 4 | import { createErrorSchema, IdParamsSchema } from "stoker/openapi/schemas"; 5 | 6 | import { insertTasksSchema, patchTasksSchema, selectTasksSchema } from "@/db/schema"; 7 | import { notFoundSchema } from "@/lib/constants"; 8 | 9 | const tags = ["Tasks"]; 10 | 11 | export const list = createRoute({ 12 | path: "/tasks", 13 | method: "get", 14 | tags, 15 | responses: { 16 | [HttpStatusCodes.OK]: jsonContent( 17 | z.array(selectTasksSchema), 18 | "The list of tasks", 19 | ), 20 | }, 21 | }); 22 | 23 | export const create = createRoute({ 24 | path: "/tasks", 25 | method: "post", 26 | request: { 27 | body: jsonContentRequired( 28 | insertTasksSchema, 29 | "The task to create", 30 | ), 31 | }, 32 | tags, 33 | responses: { 34 | [HttpStatusCodes.OK]: jsonContent( 35 | selectTasksSchema, 36 | "The created task", 37 | ), 38 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 39 | createErrorSchema(insertTasksSchema), 40 | "The validation error(s)", 41 | ), 42 | }, 43 | }); 44 | 45 | export const getOne = createRoute({ 46 | path: "/tasks/{id}", 47 | method: "get", 48 | request: { 49 | params: IdParamsSchema, 50 | }, 51 | tags, 52 | responses: { 53 | [HttpStatusCodes.OK]: jsonContent( 54 | selectTasksSchema, 55 | "The requested task", 56 | ), 57 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 58 | notFoundSchema, 59 | "Task not found", 60 | ), 61 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 62 | createErrorSchema(IdParamsSchema), 63 | "Invalid id error", 64 | ), 65 | }, 66 | }); 67 | 68 | export const patch = createRoute({ 69 | path: "/tasks/{id}", 70 | method: "patch", 71 | request: { 72 | params: IdParamsSchema, 73 | body: jsonContentRequired( 74 | patchTasksSchema, 75 | "The task updates", 76 | ), 77 | }, 78 | tags, 79 | responses: { 80 | [HttpStatusCodes.OK]: jsonContent( 81 | selectTasksSchema, 82 | "The updated task", 83 | ), 84 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 85 | notFoundSchema, 86 | "Task not found", 87 | ), 88 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 89 | createErrorSchema(patchTasksSchema) 90 | .or(createErrorSchema(IdParamsSchema)), 91 | "The validation error(s)", 92 | ), 93 | }, 94 | }); 95 | 96 | export const remove = createRoute({ 97 | path: "/tasks/{id}", 98 | method: "delete", 99 | request: { 100 | params: IdParamsSchema, 101 | }, 102 | tags, 103 | responses: { 104 | [HttpStatusCodes.NO_CONTENT]: { 105 | description: "Task deleted", 106 | }, 107 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 108 | notFoundSchema, 109 | "Task not found", 110 | ), 111 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 112 | createErrorSchema(IdParamsSchema), 113 | "Invalid id error", 114 | ), 115 | }, 116 | }); 117 | 118 | export type ListRoute = typeof list; 119 | export type CreateRoute = typeof create; 120 | export type GetOneRoute = typeof getOne; 121 | export type PatchRoute = typeof patch; 122 | export type RemoveRoute = typeof remove; 123 | -------------------------------------------------------------------------------- /cloudflare-example/src/routes/tasks/tasks.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/ban-ts-comment */ 2 | import { testClient } from "hono/testing"; 3 | import { execSync } from "node:child_process"; 4 | import fs from "node:fs"; 5 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 6 | import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest"; 7 | import { ZodIssueCode } from "zod"; 8 | 9 | import env from "@/env-runtime"; 10 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 11 | import createApp from "@/lib/create-app"; 12 | 13 | import router from "./tasks.index"; 14 | 15 | if (env.NODE_ENV !== "test") { 16 | throw new Error("NODE_ENV must be 'test'"); 17 | } 18 | 19 | const client = testClient(createApp().route("/", router)); 20 | 21 | describe("tasks routes", () => { 22 | beforeAll(async () => { 23 | execSync("pnpm drizzle-kit push"); 24 | }); 25 | 26 | afterAll(async () => { 27 | fs.rmSync("test.db", { force: true }); 28 | }); 29 | 30 | it("post /tasks validates the body when creating", async () => { 31 | const response = await client.tasks.$post({ 32 | // @ts-expect-error 33 | json: { 34 | done: false, 35 | }, 36 | }); 37 | expect(response.status).toBe(422); 38 | if (response.status === 422) { 39 | const json = await response.json(); 40 | expect(json.error.issues[0].path[0]).toBe("name"); 41 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.REQUIRED); 42 | } 43 | }); 44 | 45 | const id = "1"; 46 | const name = "Learn vitest"; 47 | 48 | it("post /tasks creates a task", async () => { 49 | const response = await client.tasks.$post({ 50 | json: { 51 | name, 52 | done: false, 53 | }, 54 | }); 55 | expect(response.status).toBe(200); 56 | if (response.status === 200) { 57 | const json = await response.json(); 58 | expect(json.name).toBe(name); 59 | expect(json.done).toBe(false); 60 | } 61 | }); 62 | 63 | it("get /tasks lists all tasks", async () => { 64 | const response = await client.tasks.$get(); 65 | expect(response.status).toBe(200); 66 | if (response.status === 200) { 67 | const json = await response.json(); 68 | expectTypeOf(json).toBeArray(); 69 | expect(json.length).toBe(1); 70 | } 71 | }); 72 | 73 | it("get /tasks/{id} validates the id param", async () => { 74 | const response = await client.tasks[":id"].$get({ 75 | param: { 76 | id: "wat", 77 | }, 78 | }); 79 | expect(response.status).toBe(422); 80 | if (response.status === 422) { 81 | const json = await response.json(); 82 | expect(json.error.issues[0].path[0]).toBe("id"); 83 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 84 | } 85 | }); 86 | 87 | it("get /tasks/{id} returns 404 when task not found", async () => { 88 | const response = await client.tasks[":id"].$get({ 89 | param: { 90 | id: "999", 91 | }, 92 | }); 93 | expect(response.status).toBe(404); 94 | if (response.status === 404) { 95 | const json = await response.json(); 96 | expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND); 97 | } 98 | }); 99 | 100 | it("get /tasks/{id} gets a single task", async () => { 101 | const response = await client.tasks[":id"].$get({ 102 | param: { 103 | id, 104 | }, 105 | }); 106 | expect(response.status).toBe(200); 107 | if (response.status === 200) { 108 | const json = await response.json(); 109 | expect(json.name).toBe(name); 110 | expect(json.done).toBe(false); 111 | } 112 | }); 113 | 114 | it("patch /tasks/{id} validates the body when updating", async () => { 115 | const response = await client.tasks[":id"].$patch({ 116 | param: { 117 | id, 118 | }, 119 | json: { 120 | name: "", 121 | }, 122 | }); 123 | expect(response.status).toBe(422); 124 | if (response.status === 422) { 125 | const json = await response.json(); 126 | expect(json.error.issues[0].path[0]).toBe("name"); 127 | expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small); 128 | } 129 | }); 130 | 131 | it("patch /tasks/{id} validates the id param", async () => { 132 | const response = await client.tasks[":id"].$patch({ 133 | param: { 134 | id: "wat", 135 | }, 136 | json: {}, 137 | }); 138 | expect(response.status).toBe(422); 139 | if (response.status === 422) { 140 | const json = await response.json(); 141 | expect(json.error.issues[0].path[0]).toBe("id"); 142 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 143 | } 144 | }); 145 | 146 | it("patch /tasks/{id} validates empty body", async () => { 147 | const response = await client.tasks[":id"].$patch({ 148 | param: { 149 | id, 150 | }, 151 | json: {}, 152 | }); 153 | expect(response.status).toBe(422); 154 | if (response.status === 422) { 155 | const json = await response.json(); 156 | expect(json.error.issues[0].code).toBe(ZOD_ERROR_CODES.INVALID_UPDATES); 157 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.NO_UPDATES); 158 | } 159 | }); 160 | 161 | it("patch /tasks/{id} updates a single property of a task", async () => { 162 | const response = await client.tasks[":id"].$patch({ 163 | param: { 164 | id, 165 | }, 166 | json: { 167 | done: true, 168 | }, 169 | }); 170 | expect(response.status).toBe(200); 171 | if (response.status === 200) { 172 | const json = await response.json(); 173 | expect(json.done).toBe(true); 174 | } 175 | }); 176 | 177 | it("delete /tasks/{id} validates the id when deleting", async () => { 178 | const response = await client.tasks[":id"].$delete({ 179 | param: { 180 | id: "wat", 181 | }, 182 | }); 183 | expect(response.status).toBe(422); 184 | if (response.status === 422) { 185 | const json = await response.json(); 186 | expect(json.error.issues[0].path[0]).toBe("id"); 187 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 188 | } 189 | }); 190 | 191 | it("delete /tasks/{id} removes a task", async () => { 192 | const response = await client.tasks[":id"].$delete({ 193 | param: { 194 | id, 195 | }, 196 | }); 197 | expect(response.status).toBe(204); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /cloudflare-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx", 6 | "lib": [ 7 | "ESNext" 8 | ], 9 | "baseUrl": "./", 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler", 12 | "paths": { 13 | "@/*": ["./src/*"] 14 | }, 15 | "typeRoots": ["./node_modules/@types"], 16 | "types": [ 17 | "@cloudflare/workers-types/2023-07-01" 18 | ], 19 | "strict": true, 20 | "outDir": "./dist", 21 | "skipLibCheck": true 22 | }, 23 | "tsc-alias": { 24 | "resolveFullPaths": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cloudflare-example/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": path.resolve(__dirname, "./src"), 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /cloudflare-example/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "hono-starter-cloudflare" 2 | main = "src/app.ts" 3 | compatibility_date = "2024-09-25" 4 | compatibility_flags = [ "nodejs_compat" ] 5 | keep_vars = true 6 | -------------------------------------------------------------------------------- /flyio-example/.dockerignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db 31 | test.db 32 | .vercel 33 | dist -------------------------------------------------------------------------------- /flyio-example/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=9999 3 | LOG_LEVEL=debug 4 | DATABASE_URL=file:dev.db -------------------------------------------------------------------------------- /flyio-example/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=9999 3 | LOG_LEVEL=silent 4 | DATABASE_URL=file:test.db -------------------------------------------------------------------------------- /flyio-example/.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: CodingGarden 2 | patreon: CodingGarden 3 | custom: ["https://streamlabs.com/codinggarden/tip", "https://twitch.tv/products/codinggarden", "https://www.youtube.com/codinggarden/join"] 4 | -------------------------------------------------------------------------------- /flyio-example/.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /flyio-example/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db 31 | test.db 32 | .vercel 33 | dist -------------------------------------------------------------------------------- /flyio-example/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=22.6.0 5 | FROM node:${NODE_VERSION}-slim as base 6 | 7 | LABEL fly_launch_runtime="Node.js" 8 | 9 | # Node.js app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV="production" 14 | 15 | # Install pnpm 16 | ARG PNPM_VERSION=9.12.1 17 | RUN npm install -g pnpm@$PNPM_VERSION 18 | 19 | 20 | # Throw-away build stage to reduce size of final image 21 | FROM base as build 22 | 23 | # Install packages needed to build node modules 24 | RUN apt-get update -qq && \ 25 | apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 26 | 27 | # Install node modules 28 | COPY package.json pnpm-lock.yaml ./ 29 | RUN pnpm install --frozen-lockfile --prod=false 30 | 31 | # Copy application code 32 | COPY . . 33 | 34 | # Build application 35 | RUN pnpm run build 36 | 37 | # Remove development dependencies 38 | RUN pnpm prune --prod 39 | 40 | 41 | # Final stage for app image 42 | FROM base 43 | 44 | # Copy built application 45 | COPY --from=build /app /app 46 | 47 | # Start the server by default, this can be overwritten at runtime 48 | EXPOSE 3000 49 | CMD [ "pnpm", "run", "start" ] 50 | -------------------------------------------------------------------------------- /flyio-example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /flyio-example/README.md: -------------------------------------------------------------------------------- 1 | # Hono Open API Starter 2 | 3 | A starter template for building fully documented type-safe JSON APIs with Hono and Open API. 4 | 5 | - [Hono Open API Starter](#hono-open-api-starter) 6 | - [Included](#included) 7 | - [Setup](#setup) 8 | - [Code Tour](#code-tour) 9 | - [Endpoints](#endpoints) 10 | - [References](#references) 11 | 12 | ## Included 13 | 14 | - Structured logging with [pino](https://getpino.io/) / [hono-pino](https://www.npmjs.com/package/hono-pino) 15 | - Documented / type-safe routes with [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 16 | - Interactive API documentation with [scalar](https://scalar.com/#api-docs) / [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/main/packages/hono-api-reference) 17 | - Convenience methods / helpers to reduce boilerplate with [stoker](https://www.npmjs.com/package/stoker) 18 | - Type-safe schemas and environment variables with [zod](https://zod.dev/) 19 | - Single source of truth database schemas with [drizzle](https://orm.drizzle.team/docs/overview) and [drizzle-zod](https://orm.drizzle.team/docs/zod) 20 | - Testing with [vitest](https://vitest.dev/) 21 | - Sensible editor, formatting and linting settings with [@antfu/eslint-config](https://github.com/antfu/eslint-config) 22 | 23 | ## Setup 24 | 25 | Clone this template without git history 26 | 27 | ```sh 28 | npx degit w3cj/hono-open-api-starter my-api 29 | cd my-api 30 | ``` 31 | 32 | Create `.env` file 33 | 34 | ```sh 35 | cp .env.sample .env 36 | ``` 37 | 38 | Create sqlite db / push schema 39 | 40 | ```sh 41 | pnpm drizzle-kit push 42 | ``` 43 | 44 | Install dependencies 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Run 51 | 52 | ```sh 53 | pnpm dev 54 | ``` 55 | 56 | Lint 57 | 58 | ```sh 59 | pnpm lint 60 | ``` 61 | 62 | Test 63 | 64 | ```sh 65 | pnpm test 66 | ``` 67 | 68 | ## Code Tour 69 | 70 | Base hono app exported from [app.ts](./src/app.ts). Local development uses [@hono/node-server](https://hono.dev/docs/getting-started/nodejs) defined in [index.ts](./src/index.ts) - update this file or create a new entry point to use your preferred runtime. 71 | 72 | Typesafe env defined in [env.ts](./src/env.ts) - add any other required environment variables here. The application will not start if any required environment variables are missing 73 | 74 | See [src/routes/tasks](./src/routes/tasks/) for an example Open API group. Copy this folder / use as an example for your route groups. 75 | 76 | - Router created in [tasks.index.ts](./src/routes/tasks/tasks.index.ts) 77 | - Route definitions defined in [tasks.routes.ts](./src/routes/tasks/tasks.routes.ts) 78 | - Hono request handlers defined in [tasks.handlers.ts](./src/routes/tasks/tasks.handlers.ts) 79 | - Group unit tests defined in [tasks.test.ts](./src/routes/tasks/tasks.test.ts) 80 | 81 | All app routes are grouped together and exported into single type as `AppType` in [app.ts](./src/app.ts) for use in [RPC / hono/client](https://hono.dev/docs/guides/rpc). 82 | 83 | ## Endpoints 84 | 85 | | Path | Description | 86 | | ------------------ | ------------------------ | 87 | | GET /doc | Open API Specification | 88 | | GET /reference | Scalar API Documentation | 89 | | GET /tasks | List all tasks | 90 | | POST /tasks | Create a task | 91 | | GET /tasks/{id} | Get one task by id | 92 | | PATCH /tasks/{id} | Patch one task by id | 93 | | DELETE /tasks/{id} | Delete one task by id | 94 | 95 | ## References 96 | 97 | - [What is Open API?](https://swagger.io/docs/specification/v3_0/about/) 98 | - [Hono](https://hono.dev/) 99 | - [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) 100 | - [Testing](https://hono.dev/docs/guides/testing) 101 | - [Testing Helper](https://hono.dev/docs/helpers/testing) 102 | - [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 103 | - [Scalar Documentation](https://github.com/scalar/scalar/tree/main/?tab=readme-ov-file#documentation) 104 | - [Themes / Layout](https://github.com/scalar/scalar/blob/main/documentation/themes.md) 105 | - [Configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) 106 | -------------------------------------------------------------------------------- /flyio-example/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | import env from "@/env"; 4 | 5 | export default defineConfig({ 6 | schema: "./src/db/schema.ts", 7 | out: "./src/db/migrations", 8 | dialect: "sqlite", 9 | driver: "turso", 10 | dbCredentials: { 11 | url: env.DATABASE_URL, 12 | authToken: env.DATABASE_AUTH_TOKEN, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /flyio-example/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu({ 4 | type: "app", 5 | typescript: true, 6 | formatters: true, 7 | stylistic: { 8 | indent: 2, 9 | semi: true, 10 | quotes: "double", 11 | }, 12 | ignores: ["**/migrations/*"], 13 | }, { 14 | rules: { 15 | "no-console": ["warn"], 16 | "antfu/no-top-level-await": ["off"], 17 | "node/prefer-global/process": ["off"], 18 | "node/no-process-env": ["error"], 19 | "perfectionist/sort-imports": ["error", { 20 | internalPattern: ["@/**"], 21 | }], 22 | "unicorn/filename-case": ["error", { 23 | case: "kebabCase", 24 | ignore: ["README.md"], 25 | }], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /flyio-example/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for hono-open-api-starter-flyio on 2024-10-08T07:49:32-06:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'hono-open-api-starter-flyio' 7 | primary_region = 'den' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 3000 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '1gb' 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | -------------------------------------------------------------------------------- /flyio-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hono-open-api-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "tsx watch src/index.ts", 8 | "start": "node ./dist/src/index.js", 9 | "typecheck": "tsc --noEmit", 10 | "lint": "eslint .", 11 | "lint:fix": "npm run lint --fix", 12 | "test": "cross-env NODE_ENV=test vitest", 13 | "build": "tsc && tsc-alias" 14 | }, 15 | "dependencies": { 16 | "@hono/node-server": "^1.13.1", 17 | "@hono/zod-openapi": "^0.16.4", 18 | "@libsql/client": "^0.14.0", 19 | "@scalar/hono-api-reference": "^0.5.150", 20 | "dotenv": "^16.4.5", 21 | "dotenv-expand": "^11.0.6", 22 | "drizzle-orm": "^0.33.0", 23 | "drizzle-zod": "^0.5.1", 24 | "hono": "^4.6.3", 25 | "hono-pino": "^0.3.0", 26 | "pino": "^9.4.0", 27 | "pino-pretty": "^11.2.2", 28 | "stoker": "^1.0.9", 29 | "zod": "^3.23.8" 30 | }, 31 | "devDependencies": { 32 | "@antfu/eslint-config": "^3.7.3", 33 | "@flydotio/dockerfile": "^0.5.9", 34 | "@types/node": "^22.7.4", 35 | "cross-env": "^7.0.3", 36 | "drizzle-kit": "^0.24.2", 37 | "eslint": "^9.12.0", 38 | "eslint-plugin-format": "^0.1.2", 39 | "tsc-alias": "^1.8.10", 40 | "tsx": "^4.19.1", 41 | "typescript": "^5.6.2", 42 | "vitest": "^2.1.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /flyio-example/src/app.ts: -------------------------------------------------------------------------------- 1 | import configureOpenAPI from "@/lib/configure-open-api"; 2 | import createApp from "@/lib/create-app"; 3 | import index from "@/routes/index.route"; 4 | import tasks from "@/routes/tasks/tasks.index"; 5 | 6 | const app = createApp(); 7 | 8 | configureOpenAPI(app); 9 | 10 | const routes = [ 11 | index, 12 | tasks, 13 | ] as const; 14 | 15 | routes.forEach((route) => { 16 | app.route("/", route); 17 | }); 18 | 19 | export type AppType = typeof routes[number]; 20 | 21 | export default app; 22 | -------------------------------------------------------------------------------- /flyio-example/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | 4 | import env from "@/env"; 5 | 6 | import * as schema from "./schema"; 7 | 8 | const client = createClient({ 9 | url: env.DATABASE_URL, 10 | authToken: env.DATABASE_AUTH_TOKEN, 11 | }); 12 | 13 | const db = drizzle(client, { 14 | schema, 15 | }); 16 | 17 | export default db; 18 | -------------------------------------------------------------------------------- /flyio-example/src/db/migrations/0000_same_squadron_sinister.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tasks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `done` integer DEFAULT false NOT NULL, 5 | `created_at` integer, 6 | `updated_at` integer 7 | ); 8 | -------------------------------------------------------------------------------- /flyio-example/src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "f2bcf13c-160f-4648-b726-9d503979baef", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "created_at": { 33 | "name": "created_at", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updated_at": { 40 | "name": "updated_at", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {} 51 | } 52 | }, 53 | "enums": {}, 54 | "_meta": { 55 | "schemas": {}, 56 | "tables": {}, 57 | "columns": {} 58 | }, 59 | "internal": { 60 | "indexes": {} 61 | } 62 | } -------------------------------------------------------------------------------- /flyio-example/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1728133964232, 9 | "tag": "0000_same_squadron_sinister", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /flyio-example/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 3 | 4 | export const tasks = sqliteTable("tasks", { 5 | id: integer("id", { mode: "number" }) 6 | .primaryKey({ autoIncrement: true }), 7 | name: text("name") 8 | .notNull(), 9 | done: integer("done", { mode: "boolean" }) 10 | .notNull() 11 | .default(false), 12 | createdAt: integer("created_at", { mode: "timestamp" }) 13 | .$defaultFn(() => new Date()), 14 | updatedAt: integer("updated_at", { mode: "timestamp" }) 15 | .$defaultFn(() => new Date()) 16 | .$onUpdate(() => new Date()), 17 | }); 18 | 19 | export const selectTasksSchema = createSelectSchema(tasks); 20 | 21 | export const insertTasksSchema = createInsertSchema( 22 | tasks, 23 | { 24 | name: schema => schema.name.min(1).max(500), 25 | }, 26 | ).required({ 27 | done: true, 28 | }).omit({ 29 | id: true, 30 | createdAt: true, 31 | updatedAt: true, 32 | }); 33 | 34 | export const patchTasksSchema = insertTasksSchema.partial(); 35 | -------------------------------------------------------------------------------- /flyio-example/src/env.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-process-env */ 2 | import { config } from "dotenv"; 3 | import { expand } from "dotenv-expand"; 4 | import path from "node:path"; 5 | import { z } from "zod"; 6 | 7 | expand(config({ 8 | path: path.resolve( 9 | process.cwd(), 10 | process.env.NODE_ENV === "test" ? ".env.test" : ".env", 11 | ), 12 | })); 13 | 14 | const EnvSchema = z.object({ 15 | NODE_ENV: z.string().default("development"), 16 | PORT: z.coerce.number().default(9999), 17 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), 18 | DATABASE_URL: z.string().url(), 19 | DATABASE_AUTH_TOKEN: z.string().optional(), 20 | }).superRefine((input, ctx) => { 21 | if (input.NODE_ENV === "production" && !input.DATABASE_AUTH_TOKEN) { 22 | ctx.addIssue({ 23 | code: z.ZodIssueCode.invalid_type, 24 | expected: "string", 25 | received: "undefined", 26 | path: ["DATABASE_AUTH_TOKEN"], 27 | message: "Must be set when NODE_ENV is 'production'", 28 | }); 29 | } 30 | }); 31 | 32 | export type env = z.infer; 33 | 34 | // eslint-disable-next-line ts/no-redeclare 35 | const { data: env, error } = EnvSchema.safeParse(process.env); 36 | 37 | if (error) { 38 | console.error("❌ Invalid env:"); 39 | console.error(JSON.stringify(error.flatten().fieldErrors, null, 2)); 40 | process.exit(1); 41 | } 42 | 43 | export default env!; 44 | -------------------------------------------------------------------------------- /flyio-example/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | 3 | import app from "./app"; 4 | import env from "./env"; 5 | 6 | const port = env.PORT; 7 | // eslint-disable-next-line no-console 8 | console.log(`Server is running on port http://localhost:${port}`); 9 | 10 | serve({ 11 | fetch: app.fetch, 12 | port, 13 | }); 14 | -------------------------------------------------------------------------------- /flyio-example/src/lib/configure-open-api.ts: -------------------------------------------------------------------------------- 1 | import { apiReference } from "@scalar/hono-api-reference"; 2 | 3 | import type { AppOpenAPI } from "./types"; 4 | 5 | import packageJSON from "../../package.json" with { type: "json" }; 6 | 7 | export default function configureOpenAPI(app: AppOpenAPI) { 8 | app.doc("/doc", { 9 | openapi: "3.0.0", 10 | info: { 11 | version: packageJSON.version, 12 | title: "Tasks API", 13 | }, 14 | }); 15 | 16 | app.get( 17 | "/reference", 18 | apiReference({ 19 | theme: "kepler", 20 | layout: "classic", 21 | defaultHttpClient: { 22 | targetKey: "javascript", 23 | clientKey: "fetch", 24 | }, 25 | spec: { 26 | url: "/doc", 27 | }, 28 | }), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /flyio-example/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 3 | 4 | export const ZOD_ERROR_MESSAGES = { 5 | REQUIRED: "Required", 6 | EXPECTED_NUMBER: "Expected number, received nan", 7 | NO_UPDATES: "No updates provided", 8 | }; 9 | 10 | export const ZOD_ERROR_CODES = { 11 | INVALID_UPDATES: "invalid_updates", 12 | }; 13 | 14 | export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); 15 | -------------------------------------------------------------------------------- /flyio-example/src/lib/create-app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares"; 3 | import { defaultHook } from "stoker/openapi"; 4 | 5 | import { pinoLogger } from "@/middlewares/pino-logger"; 6 | 7 | import type { AppBindings, AppOpenAPI } from "./types"; 8 | 9 | export function createRouter() { 10 | return new OpenAPIHono({ 11 | strict: false, 12 | defaultHook, 13 | }); 14 | } 15 | 16 | export default function createApp() { 17 | const app = createRouter(); 18 | app.use(serveEmojiFavicon("📝")); 19 | app.use(pinoLogger()); 20 | 21 | app.notFound(notFound); 22 | app.onError(onError); 23 | return app; 24 | } 25 | 26 | export function createTestApp(router: R) { 27 | return createApp().route("/", router); 28 | } 29 | -------------------------------------------------------------------------------- /flyio-example/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"; 2 | import type { PinoLogger } from "hono-pino"; 3 | 4 | export interface AppBindings { 5 | Variables: { 6 | logger: PinoLogger; 7 | }; 8 | }; 9 | 10 | export type AppOpenAPI = OpenAPIHono; 11 | 12 | export type AppRouteHandler = RouteHandler; 13 | -------------------------------------------------------------------------------- /flyio-example/src/middlewares/pino-logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "hono-pino"; 2 | import pino from "pino"; 3 | import pretty from "pino-pretty"; 4 | 5 | import env from "@/env"; 6 | 7 | export function pinoLogger() { 8 | return logger({ 9 | pino: pino({ 10 | level: env.LOG_LEVEL || "info", 11 | }, env.NODE_ENV === "production" ? undefined : pretty()), 12 | http: { 13 | reqId: () => crypto.randomUUID(), 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /flyio-example/src/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent } from "stoker/openapi/helpers"; 4 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 5 | 6 | import { createRouter } from "@/lib/create-app"; 7 | 8 | const router = createRouter() 9 | .openapi( 10 | createRoute({ 11 | tags: ["Index"], 12 | method: "get", 13 | path: "/", 14 | responses: { 15 | [HttpStatusCodes.OK]: jsonContent( 16 | createMessageObjectSchema("Tasks API"), 17 | "Tasks API Index", 18 | ), 19 | }, 20 | }), 21 | (c) => { 22 | return c.json({ 23 | message: "Tasks API", 24 | }, HttpStatusCodes.OK); 25 | }, 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /flyio-example/src/routes/tasks/tasks.handlers.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 4 | 5 | import type { AppRouteHandler } from "@/lib/types"; 6 | 7 | import db from "@/db"; 8 | import { tasks } from "@/db/schema"; 9 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 10 | 11 | import type { CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./tasks.routes"; 12 | 13 | export const list: AppRouteHandler = async (c) => { 14 | const tasks = await db.query.tasks.findMany(); 15 | return c.json(tasks); 16 | }; 17 | 18 | export const create: AppRouteHandler = async (c) => { 19 | const task = c.req.valid("json"); 20 | const [inserted] = await db.insert(tasks).values(task).returning(); 21 | return c.json(inserted, HttpStatusCodes.OK); 22 | }; 23 | 24 | export const getOne: AppRouteHandler = async (c) => { 25 | const { id } = c.req.valid("param"); 26 | const task = await db.query.tasks.findFirst({ 27 | where(fields, operators) { 28 | return operators.eq(fields.id, id); 29 | }, 30 | }); 31 | 32 | if (!task) { 33 | return c.json( 34 | { 35 | message: HttpStatusPhrases.NOT_FOUND, 36 | }, 37 | HttpStatusCodes.NOT_FOUND, 38 | ); 39 | } 40 | 41 | return c.json(task, HttpStatusCodes.OK); 42 | }; 43 | 44 | export const patch: AppRouteHandler = async (c) => { 45 | const { id } = c.req.valid("param"); 46 | const updates = c.req.valid("json"); 47 | 48 | if (Object.keys(updates).length === 0) { 49 | return c.json( 50 | { 51 | success: false, 52 | error: { 53 | issues: [ 54 | { 55 | code: ZOD_ERROR_CODES.INVALID_UPDATES, 56 | path: [], 57 | message: ZOD_ERROR_MESSAGES.NO_UPDATES, 58 | }, 59 | ], 60 | name: "ZodError", 61 | }, 62 | }, 63 | HttpStatusCodes.UNPROCESSABLE_ENTITY, 64 | ); 65 | } 66 | 67 | const [task] = await db.update(tasks) 68 | .set(updates) 69 | .where(eq(tasks.id, id)) 70 | .returning(); 71 | 72 | if (!task) { 73 | return c.json( 74 | { 75 | message: HttpStatusPhrases.NOT_FOUND, 76 | }, 77 | HttpStatusCodes.NOT_FOUND, 78 | ); 79 | } 80 | 81 | return c.json(task, HttpStatusCodes.OK); 82 | }; 83 | 84 | export const remove: AppRouteHandler = async (c) => { 85 | const { id } = c.req.valid("param"); 86 | const result = await db.delete(tasks) 87 | .where(eq(tasks.id, id)); 88 | 89 | if (result.rowsAffected === 0) { 90 | return c.json( 91 | { 92 | message: HttpStatusPhrases.NOT_FOUND, 93 | }, 94 | HttpStatusCodes.NOT_FOUND, 95 | ); 96 | } 97 | 98 | return c.body(null, HttpStatusCodes.NO_CONTENT); 99 | }; 100 | -------------------------------------------------------------------------------- /flyio-example/src/routes/tasks/tasks.index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@/lib/create-app"; 2 | 3 | import * as handlers from "./tasks.handlers"; 4 | import * as routes from "./tasks.routes"; 5 | 6 | const router = createRouter() 7 | .openapi(routes.list, handlers.list) 8 | .openapi(routes.create, handlers.create) 9 | .openapi(routes.getOne, handlers.getOne) 10 | .openapi(routes.patch, handlers.patch) 11 | .openapi(routes.remove, handlers.remove); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /flyio-example/src/routes/tasks/tasks.routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent, jsonContentRequired } from "stoker/openapi/helpers"; 4 | import { createErrorSchema, IdParamsSchema } from "stoker/openapi/schemas"; 5 | 6 | import { insertTasksSchema, patchTasksSchema, selectTasksSchema } from "@/db/schema"; 7 | import { notFoundSchema } from "@/lib/constants"; 8 | 9 | const tags = ["Tasks"]; 10 | 11 | export const list = createRoute({ 12 | path: "/tasks", 13 | method: "get", 14 | tags, 15 | responses: { 16 | [HttpStatusCodes.OK]: jsonContent( 17 | z.array(selectTasksSchema), 18 | "The list of tasks", 19 | ), 20 | }, 21 | }); 22 | 23 | export const create = createRoute({ 24 | path: "/tasks", 25 | method: "post", 26 | request: { 27 | body: jsonContentRequired( 28 | insertTasksSchema, 29 | "The task to create", 30 | ), 31 | }, 32 | tags, 33 | responses: { 34 | [HttpStatusCodes.OK]: jsonContent( 35 | selectTasksSchema, 36 | "The created task", 37 | ), 38 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 39 | createErrorSchema(insertTasksSchema), 40 | "The validation error(s)", 41 | ), 42 | }, 43 | }); 44 | 45 | export const getOne = createRoute({ 46 | path: "/tasks/{id}", 47 | method: "get", 48 | request: { 49 | params: IdParamsSchema, 50 | }, 51 | tags, 52 | responses: { 53 | [HttpStatusCodes.OK]: jsonContent( 54 | selectTasksSchema, 55 | "The requested task", 56 | ), 57 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 58 | notFoundSchema, 59 | "Task not found", 60 | ), 61 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 62 | createErrorSchema(IdParamsSchema), 63 | "Invalid id error", 64 | ), 65 | }, 66 | }); 67 | 68 | export const patch = createRoute({ 69 | path: "/tasks/{id}", 70 | method: "patch", 71 | request: { 72 | params: IdParamsSchema, 73 | body: jsonContentRequired( 74 | patchTasksSchema, 75 | "The task updates", 76 | ), 77 | }, 78 | tags, 79 | responses: { 80 | [HttpStatusCodes.OK]: jsonContent( 81 | selectTasksSchema, 82 | "The updated task", 83 | ), 84 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 85 | notFoundSchema, 86 | "Task not found", 87 | ), 88 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 89 | createErrorSchema(patchTasksSchema) 90 | .or(createErrorSchema(IdParamsSchema)), 91 | "The validation error(s)", 92 | ), 93 | }, 94 | }); 95 | 96 | export const remove = createRoute({ 97 | path: "/tasks/{id}", 98 | method: "delete", 99 | request: { 100 | params: IdParamsSchema, 101 | }, 102 | tags, 103 | responses: { 104 | [HttpStatusCodes.NO_CONTENT]: { 105 | description: "Task deleted", 106 | }, 107 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 108 | notFoundSchema, 109 | "Task not found", 110 | ), 111 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 112 | createErrorSchema(IdParamsSchema), 113 | "Invalid id error", 114 | ), 115 | }, 116 | }); 117 | 118 | export type ListRoute = typeof list; 119 | export type CreateRoute = typeof create; 120 | export type GetOneRoute = typeof getOne; 121 | export type PatchRoute = typeof patch; 122 | export type RemoveRoute = typeof remove; 123 | -------------------------------------------------------------------------------- /flyio-example/src/routes/tasks/tasks.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/ban-ts-comment */ 2 | import { testClient } from "hono/testing"; 3 | import { execSync } from "node:child_process"; 4 | import fs from "node:fs"; 5 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 6 | import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest"; 7 | import { ZodIssueCode } from "zod"; 8 | 9 | import env from "@/env"; 10 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 11 | import createApp from "@/lib/create-app"; 12 | 13 | import router from "./tasks.index"; 14 | 15 | if (env.NODE_ENV !== "test") { 16 | throw new Error("NODE_ENV must be 'test'"); 17 | } 18 | 19 | const client = testClient(createApp().route("/", router)); 20 | 21 | describe("tasks routes", () => { 22 | beforeAll(async () => { 23 | execSync("pnpm drizzle-kit push"); 24 | }); 25 | 26 | afterAll(async () => { 27 | fs.rmSync("test.db", { force: true }); 28 | }); 29 | 30 | it("post /tasks validates the body when creating", async () => { 31 | const response = await client.tasks.$post({ 32 | // @ts-expect-error 33 | json: { 34 | done: false, 35 | }, 36 | }); 37 | expect(response.status).toBe(422); 38 | if (response.status === 422) { 39 | const json = await response.json(); 40 | expect(json.error.issues[0].path[0]).toBe("name"); 41 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.REQUIRED); 42 | } 43 | }); 44 | 45 | const id = "1"; 46 | const name = "Learn vitest"; 47 | 48 | it("post /tasks creates a task", async () => { 49 | const response = await client.tasks.$post({ 50 | json: { 51 | name, 52 | done: false, 53 | }, 54 | }); 55 | expect(response.status).toBe(200); 56 | if (response.status === 200) { 57 | const json = await response.json(); 58 | expect(json.name).toBe(name); 59 | expect(json.done).toBe(false); 60 | } 61 | }); 62 | 63 | it("get /tasks lists all tasks", async () => { 64 | const response = await client.tasks.$get(); 65 | expect(response.status).toBe(200); 66 | if (response.status === 200) { 67 | const json = await response.json(); 68 | expectTypeOf(json).toBeArray(); 69 | expect(json.length).toBe(1); 70 | } 71 | }); 72 | 73 | it("get /tasks/{id} validates the id param", async () => { 74 | const response = await client.tasks[":id"].$get({ 75 | param: { 76 | id: "wat", 77 | }, 78 | }); 79 | expect(response.status).toBe(422); 80 | if (response.status === 422) { 81 | const json = await response.json(); 82 | expect(json.error.issues[0].path[0]).toBe("id"); 83 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 84 | } 85 | }); 86 | 87 | it("get /tasks/{id} returns 404 when task not found", async () => { 88 | const response = await client.tasks[":id"].$get({ 89 | param: { 90 | id: "999", 91 | }, 92 | }); 93 | expect(response.status).toBe(404); 94 | if (response.status === 404) { 95 | const json = await response.json(); 96 | expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND); 97 | } 98 | }); 99 | 100 | it("get /tasks/{id} gets a single task", async () => { 101 | const response = await client.tasks[":id"].$get({ 102 | param: { 103 | id, 104 | }, 105 | }); 106 | expect(response.status).toBe(200); 107 | if (response.status === 200) { 108 | const json = await response.json(); 109 | expect(json.name).toBe(name); 110 | expect(json.done).toBe(false); 111 | } 112 | }); 113 | 114 | it("patch /tasks/{id} validates the body when updating", async () => { 115 | const response = await client.tasks[":id"].$patch({ 116 | param: { 117 | id, 118 | }, 119 | json: { 120 | name: "", 121 | }, 122 | }); 123 | expect(response.status).toBe(422); 124 | if (response.status === 422) { 125 | const json = await response.json(); 126 | expect(json.error.issues[0].path[0]).toBe("name"); 127 | expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small); 128 | } 129 | }); 130 | 131 | it("patch /tasks/{id} validates the id param", async () => { 132 | const response = await client.tasks[":id"].$patch({ 133 | param: { 134 | id: "wat", 135 | }, 136 | json: {}, 137 | }); 138 | expect(response.status).toBe(422); 139 | if (response.status === 422) { 140 | const json = await response.json(); 141 | expect(json.error.issues[0].path[0]).toBe("id"); 142 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 143 | } 144 | }); 145 | 146 | it("patch /tasks/{id} validates empty body", async () => { 147 | const response = await client.tasks[":id"].$patch({ 148 | param: { 149 | id, 150 | }, 151 | json: {}, 152 | }); 153 | expect(response.status).toBe(422); 154 | if (response.status === 422) { 155 | const json = await response.json(); 156 | expect(json.error.issues[0].code).toBe(ZOD_ERROR_CODES.INVALID_UPDATES); 157 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.NO_UPDATES); 158 | } 159 | }); 160 | 161 | it("patch /tasks/{id} updates a single property of a task", async () => { 162 | const response = await client.tasks[":id"].$patch({ 163 | param: { 164 | id, 165 | }, 166 | json: { 167 | done: true, 168 | }, 169 | }); 170 | expect(response.status).toBe(200); 171 | if (response.status === 200) { 172 | const json = await response.json(); 173 | expect(json.done).toBe(true); 174 | } 175 | }); 176 | 177 | it("delete /tasks/{id} validates the id when deleting", async () => { 178 | const response = await client.tasks[":id"].$delete({ 179 | param: { 180 | id: "wat", 181 | }, 182 | }); 183 | expect(response.status).toBe(422); 184 | if (response.status === 422) { 185 | const json = await response.json(); 186 | expect(json.error.issues[0].path[0]).toBe("id"); 187 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 188 | } 189 | }); 190 | 191 | it("delete /tasks/{id} removes a task", async () => { 192 | const response = await client.tasks[":id"].$delete({ 193 | param: { 194 | id, 195 | }, 196 | }); 197 | expect(response.status).toBe(204); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /flyio-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx", 6 | "baseUrl": "./", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "typeRoots": ["./node_modules/@types"], 13 | "types": [ 14 | "node" 15 | ], 16 | "strict": true, 17 | "outDir": "./dist", 18 | "skipLibCheck": true 19 | }, 20 | "tsc-alias": { 21 | "resolveFullPaths": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /flyio-example/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": path.resolve(__dirname, "./src"), 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /vercel-edge-example/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=9999 3 | LOG_LEVEL=debug 4 | DATABASE_URL=file:dev.db -------------------------------------------------------------------------------- /vercel-edge-example/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=9999 3 | LOG_LEVEL=silent 4 | DATABASE_URL=file:test.db -------------------------------------------------------------------------------- /vercel-edge-example/.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: CodingGarden 2 | patreon: CodingGarden 3 | custom: ["https://streamlabs.com/codinggarden/tip", "https://twitch.tv/products/codinggarden", "https://www.youtube.com/codinggarden/join"] 4 | -------------------------------------------------------------------------------- /vercel-edge-example/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db 31 | test.db 32 | .vercel 33 | dist -------------------------------------------------------------------------------- /vercel-edge-example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /vercel-edge-example/README.md: -------------------------------------------------------------------------------- 1 | # Hono Open API Starter 2 | 3 | A starter template for building fully documented type-safe JSON APIs with Hono and Open API. 4 | 5 | - [Hono Open API Starter](#hono-open-api-starter) 6 | - [Included](#included) 7 | - [Setup](#setup) 8 | - [Code Tour](#code-tour) 9 | - [Endpoints](#endpoints) 10 | - [References](#references) 11 | 12 | ## Included 13 | 14 | - Structured logging with [pino](https://getpino.io/) / [hono-pino](https://www.npmjs.com/package/hono-pino) 15 | - Documented / type-safe routes with [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 16 | - Interactive API documentation with [scalar](https://scalar.com/#api-docs) / [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/main/packages/hono-api-reference) 17 | - Convenience methods / helpers to reduce boilerplate with [stoker](https://www.npmjs.com/package/stoker) 18 | - Type-safe schemas and environment variables with [zod](https://zod.dev/) 19 | - Single source of truth database schemas with [drizzle](https://orm.drizzle.team/docs/overview) and [drizzle-zod](https://orm.drizzle.team/docs/zod) 20 | - Testing with [vitest](https://vitest.dev/) 21 | - Sensible editor, formatting and linting settings with [@antfu/eslint-config](https://github.com/antfu/eslint-config) 22 | 23 | ## Setup 24 | 25 | Clone this template without git history 26 | 27 | ```sh 28 | npx degit w3cj/hono-open-api-starter my-api 29 | cd my-api 30 | ``` 31 | 32 | Create `.env` file 33 | 34 | ```sh 35 | cp .env.sample .env 36 | ``` 37 | 38 | Create sqlite db / push schema 39 | 40 | ```sh 41 | pnpm drizzle-kit push 42 | ``` 43 | 44 | Install dependencies 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Run 51 | 52 | ```sh 53 | pnpm dev 54 | ``` 55 | 56 | Lint 57 | 58 | ```sh 59 | pnpm lint 60 | ``` 61 | 62 | Test 63 | 64 | ```sh 65 | pnpm test 66 | ``` 67 | 68 | ## Code Tour 69 | 70 | Base hono app exported from [app.ts](./src/app.ts). Local development uses [@hono/node-server](https://hono.dev/docs/getting-started/nodejs) defined in [index.ts](./src/index.ts) - update this file or create a new entry point to use your preferred runtime. 71 | 72 | Typesafe env defined in [env.ts](./src/env.ts) - add any other required environment variables here. The application will not start if any required environment variables are missing 73 | 74 | See [src/routes/tasks](./src/routes/tasks/) for an example Open API group. Copy this folder / use as an example for your route groups. 75 | 76 | - Router created in [tasks.index.ts](./src/routes/tasks/tasks.index.ts) 77 | - Route definitions defined in [tasks.routes.ts](./src/routes/tasks/tasks.routes.ts) 78 | - Hono request handlers defined in [tasks.handlers.ts](./src/routes/tasks/tasks.handlers.ts) 79 | - Group unit tests defined in [tasks.test.ts](./src/routes/tasks/tasks.test.ts) 80 | 81 | All app routes are grouped together and exported into single type as `AppType` in [app.ts](./src/app.ts) for use in [RPC / hono/client](https://hono.dev/docs/guides/rpc). 82 | 83 | ## Endpoints 84 | 85 | | Path | Description | 86 | | ------------------ | ------------------------ | 87 | | GET /doc | Open API Specification | 88 | | GET /reference | Scalar API Documentation | 89 | | GET /tasks | List all tasks | 90 | | POST /tasks | Create a task | 91 | | GET /tasks/{id} | Get one task by id | 92 | | PATCH /tasks/{id} | Patch one task by id | 93 | | DELETE /tasks/{id} | Delete one task by id | 94 | 95 | ## References 96 | 97 | - [What is Open API?](https://swagger.io/docs/specification/v3_0/about/) 98 | - [Hono](https://hono.dev/) 99 | - [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) 100 | - [Testing](https://hono.dev/docs/guides/testing) 101 | - [Testing Helper](https://hono.dev/docs/helpers/testing) 102 | - [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 103 | - [Scalar Documentation](https://github.com/scalar/scalar/tree/main/?tab=readme-ov-file#documentation) 104 | - [Themes / Layout](https://github.com/scalar/scalar/blob/main/documentation/themes.md) 105 | - [Configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) 106 | -------------------------------------------------------------------------------- /vercel-edge-example/api/index.ts: -------------------------------------------------------------------------------- 1 | import { handle } from "hono/vercel"; 2 | 3 | // eslint-disable-next-line ts/ban-ts-comment 4 | // @ts-expect-error 5 | // eslint-disable-next-line antfu/no-import-dist 6 | import app from "../dist/src/app.js"; 7 | 8 | export const runtime = "edge"; 9 | 10 | export const GET = handle(app); 11 | export const POST = handle(app); 12 | export const PUT = handle(app); 13 | export const PATCH = handle(app); 14 | export const DELETE = handle(app); 15 | export const HEAD = handle(app); 16 | export const OPTIONS = handle(app); 17 | -------------------------------------------------------------------------------- /vercel-edge-example/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | import env from "@/env"; 4 | 5 | export default defineConfig({ 6 | schema: "./src/db/schema.ts", 7 | out: "./src/db/migrations", 8 | dialect: "sqlite", 9 | driver: "turso", 10 | dbCredentials: { 11 | url: env.DATABASE_URL, 12 | authToken: env.DATABASE_AUTH_TOKEN, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /vercel-edge-example/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu({ 4 | type: "app", 5 | typescript: true, 6 | formatters: true, 7 | stylistic: { 8 | indent: 2, 9 | semi: true, 10 | quotes: "double", 11 | }, 12 | ignores: ["**/migrations/*"], 13 | }, { 14 | rules: { 15 | "no-console": ["warn"], 16 | "antfu/no-top-level-await": ["off"], 17 | "node/prefer-global/process": ["off"], 18 | "node/no-process-env": ["error"], 19 | "perfectionist/sort-imports": ["error", { 20 | internalPattern: ["@/**"], 21 | }], 22 | "unicorn/filename-case": ["error", { 23 | case: "kebabCase", 24 | ignore: ["README.md"], 25 | }], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /vercel-edge-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hono-open-api-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "tsx watch src/index.ts", 8 | "vercel:dev": "npm run build && vercel dev", 9 | "start": "node ./dist/src/index.js", 10 | "typecheck": "tsc --noEmit", 11 | "lint": "eslint .", 12 | "lint:fix": "npm run lint --fix", 13 | "test": "cross-env NODE_ENV=test vitest", 14 | "build": "tsc && tsc-alias" 15 | }, 16 | "dependencies": { 17 | "@hono/node-server": "^1.13.1", 18 | "@hono/zod-openapi": "^0.16.4", 19 | "@libsql/client": "^0.14.0", 20 | "@scalar/hono-api-reference": "^0.5.150", 21 | "dotenv": "^16.4.5", 22 | "dotenv-expand": "^11.0.6", 23 | "drizzle-orm": "^0.33.0", 24 | "drizzle-zod": "^0.5.1", 25 | "hono": "^4.6.3", 26 | "hono-pino": "^0.3.0", 27 | "pino": "^9.4.0", 28 | "pino-pretty": "^11.2.2", 29 | "stoker": "^1.0.9", 30 | "zod": "^3.23.8" 31 | }, 32 | "devDependencies": { 33 | "@antfu/eslint-config": "^3.7.3", 34 | "@types/node": "^22.7.4", 35 | "cross-env": "^7.0.3", 36 | "drizzle-kit": "^0.24.2", 37 | "eslint": "^9.12.0", 38 | "eslint-plugin-format": "^0.1.2", 39 | "tsc-alias": "^1.8.10", 40 | "tsx": "^4.19.1", 41 | "typescript": "^5.6.2", 42 | "vercel": "^37.6.2", 43 | "vitest": "^2.1.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /vercel-edge-example/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3cj/hono-node-deployment-examples/14f3739d7ef31d69b80af52492926257bcb0355b/vercel-edge-example/public/.gitkeep -------------------------------------------------------------------------------- /vercel-edge-example/src/app.ts: -------------------------------------------------------------------------------- 1 | import configureOpenAPI from "@/lib/configure-open-api"; 2 | import createApp from "@/lib/create-app"; 3 | import index from "@/routes/index.route"; 4 | import tasks from "@/routes/tasks/tasks.index"; 5 | 6 | const app = createApp(); 7 | 8 | configureOpenAPI(app); 9 | 10 | const routes = [ 11 | index, 12 | tasks, 13 | ] as const; 14 | 15 | routes.forEach((route) => { 16 | app.route("/", route); 17 | }); 18 | 19 | export type AppType = typeof routes[number]; 20 | 21 | export default app; 22 | -------------------------------------------------------------------------------- /vercel-edge-example/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | 4 | import env from "@/env"; 5 | 6 | import * as schema from "./schema"; 7 | 8 | const client = createClient({ 9 | url: env.DATABASE_URL, 10 | authToken: env.DATABASE_AUTH_TOKEN, 11 | }); 12 | 13 | const db = drizzle(client, { 14 | schema, 15 | }); 16 | 17 | export default db; 18 | -------------------------------------------------------------------------------- /vercel-edge-example/src/db/migrations/0000_same_squadron_sinister.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tasks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `done` integer DEFAULT false NOT NULL, 5 | `created_at` integer, 6 | `updated_at` integer 7 | ); 8 | -------------------------------------------------------------------------------- /vercel-edge-example/src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "f2bcf13c-160f-4648-b726-9d503979baef", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "created_at": { 33 | "name": "created_at", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updated_at": { 40 | "name": "updated_at", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {} 51 | } 52 | }, 53 | "enums": {}, 54 | "_meta": { 55 | "schemas": {}, 56 | "tables": {}, 57 | "columns": {} 58 | }, 59 | "internal": { 60 | "indexes": {} 61 | } 62 | } -------------------------------------------------------------------------------- /vercel-edge-example/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1728133964232, 9 | "tag": "0000_same_squadron_sinister", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /vercel-edge-example/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 3 | 4 | export const tasks = sqliteTable("tasks", { 5 | id: integer("id", { mode: "number" }) 6 | .primaryKey({ autoIncrement: true }), 7 | name: text("name") 8 | .notNull(), 9 | done: integer("done", { mode: "boolean" }) 10 | .notNull() 11 | .default(false), 12 | createdAt: integer("created_at", { mode: "timestamp" }) 13 | .$defaultFn(() => new Date()), 14 | updatedAt: integer("updated_at", { mode: "timestamp" }) 15 | .$defaultFn(() => new Date()) 16 | .$onUpdate(() => new Date()), 17 | }); 18 | 19 | export const selectTasksSchema = createSelectSchema(tasks); 20 | 21 | export const insertTasksSchema = createInsertSchema( 22 | tasks, 23 | { 24 | name: schema => schema.name.min(1).max(500), 25 | }, 26 | ).required({ 27 | done: true, 28 | }).omit({ 29 | id: true, 30 | createdAt: true, 31 | updatedAt: true, 32 | }); 33 | 34 | export const patchTasksSchema = insertTasksSchema.partial(); 35 | -------------------------------------------------------------------------------- /vercel-edge-example/src/env.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-process-env */ 2 | import { config } from "dotenv"; 3 | import { expand } from "dotenv-expand"; 4 | import path from "node:path"; 5 | import { z } from "zod"; 6 | 7 | expand(config({ 8 | path: path.resolve( 9 | process.cwd(), 10 | process.env.NODE_ENV === "test" ? ".env.test" : ".env", 11 | ), 12 | })); 13 | 14 | const EnvSchema = z.object({ 15 | NODE_ENV: z.string().default("development"), 16 | PORT: z.coerce.number().default(9999), 17 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), 18 | DATABASE_URL: z.string().url(), 19 | DATABASE_AUTH_TOKEN: z.string().optional(), 20 | }).superRefine((input, ctx) => { 21 | if (input.NODE_ENV === "production" && !input.DATABASE_AUTH_TOKEN) { 22 | ctx.addIssue({ 23 | code: z.ZodIssueCode.invalid_type, 24 | expected: "string", 25 | received: "undefined", 26 | path: ["DATABASE_AUTH_TOKEN"], 27 | message: "Must be set when NODE_ENV is 'production'", 28 | }); 29 | } 30 | }); 31 | 32 | export type env = z.infer; 33 | 34 | // eslint-disable-next-line ts/no-redeclare 35 | const { data: env, error } = EnvSchema.safeParse(process.env); 36 | 37 | if (error) { 38 | console.error("❌ Invalid env:"); 39 | console.error(JSON.stringify(error.flatten().fieldErrors, null, 2)); 40 | process.exit(1); 41 | } 42 | 43 | export default env!; 44 | -------------------------------------------------------------------------------- /vercel-edge-example/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | 3 | import app from "./app"; 4 | import env from "./env"; 5 | 6 | const port = env.PORT; 7 | // eslint-disable-next-line no-console 8 | console.log(`Server is running on port http://localhost:${port}`); 9 | 10 | serve({ 11 | fetch: app.fetch, 12 | port, 13 | }); 14 | -------------------------------------------------------------------------------- /vercel-edge-example/src/lib/configure-open-api.ts: -------------------------------------------------------------------------------- 1 | import { apiReference } from "@scalar/hono-api-reference"; 2 | 3 | import type { AppOpenAPI } from "./types"; 4 | 5 | import packageJSON from "../../package.json" with { type: "json" }; 6 | 7 | export default function configureOpenAPI(app: AppOpenAPI) { 8 | app.doc("/doc", { 9 | openapi: "3.0.0", 10 | info: { 11 | version: packageJSON.version, 12 | title: "Tasks API", 13 | }, 14 | }); 15 | 16 | app.get( 17 | "/reference", 18 | apiReference({ 19 | theme: "kepler", 20 | layout: "classic", 21 | defaultHttpClient: { 22 | targetKey: "javascript", 23 | clientKey: "fetch", 24 | }, 25 | spec: { 26 | url: "/doc", 27 | }, 28 | }), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /vercel-edge-example/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 3 | 4 | export const ZOD_ERROR_MESSAGES = { 5 | REQUIRED: "Required", 6 | EXPECTED_NUMBER: "Expected number, received nan", 7 | NO_UPDATES: "No updates provided", 8 | }; 9 | 10 | export const ZOD_ERROR_CODES = { 11 | INVALID_UPDATES: "invalid_updates", 12 | }; 13 | 14 | export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); 15 | -------------------------------------------------------------------------------- /vercel-edge-example/src/lib/create-app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares"; 3 | import { defaultHook } from "stoker/openapi"; 4 | 5 | import { pinoLogger } from "@/middlewares/pino-logger"; 6 | 7 | import type { AppBindings, AppOpenAPI } from "./types"; 8 | 9 | export function createRouter() { 10 | return new OpenAPIHono({ 11 | strict: false, 12 | defaultHook, 13 | }); 14 | } 15 | 16 | export default function createApp() { 17 | const app = createRouter(); 18 | app.use(serveEmojiFavicon("📝")); 19 | app.use(pinoLogger()); 20 | 21 | app.notFound(notFound); 22 | app.onError(onError); 23 | return app; 24 | } 25 | 26 | export function createTestApp(router: R) { 27 | return createApp().route("/", router); 28 | } 29 | -------------------------------------------------------------------------------- /vercel-edge-example/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"; 2 | import type { PinoLogger } from "hono-pino"; 3 | 4 | export interface AppBindings { 5 | Variables: { 6 | logger: PinoLogger; 7 | }; 8 | }; 9 | 10 | export type AppOpenAPI = OpenAPIHono; 11 | 12 | export type AppRouteHandler = RouteHandler; 13 | -------------------------------------------------------------------------------- /vercel-edge-example/src/middlewares/pino-logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "hono-pino"; 2 | import pino from "pino"; 3 | import pretty from "pino-pretty"; 4 | 5 | import env from "@/env"; 6 | 7 | export function pinoLogger() { 8 | return logger({ 9 | pino: pino({ 10 | level: env.LOG_LEVEL || "info", 11 | }, env.NODE_ENV === "production" ? undefined : pretty()), 12 | http: { 13 | reqId: () => crypto.randomUUID(), 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /vercel-edge-example/src/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent } from "stoker/openapi/helpers"; 4 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 5 | 6 | import { createRouter } from "@/lib/create-app"; 7 | 8 | const router = createRouter() 9 | .openapi( 10 | createRoute({ 11 | tags: ["Index"], 12 | method: "get", 13 | path: "/", 14 | responses: { 15 | [HttpStatusCodes.OK]: jsonContent( 16 | createMessageObjectSchema("Tasks API"), 17 | "Tasks API Index", 18 | ), 19 | }, 20 | }), 21 | (c) => { 22 | return c.json({ 23 | message: "Tasks API", 24 | }, HttpStatusCodes.OK); 25 | }, 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /vercel-edge-example/src/routes/tasks/tasks.handlers.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 4 | 5 | import type { AppRouteHandler } from "@/lib/types"; 6 | 7 | import db from "@/db"; 8 | import { tasks } from "@/db/schema"; 9 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 10 | 11 | import type { CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./tasks.routes"; 12 | 13 | export const list: AppRouteHandler = async (c) => { 14 | const tasks = await db.query.tasks.findMany(); 15 | return c.json(tasks); 16 | }; 17 | 18 | export const create: AppRouteHandler = async (c) => { 19 | const task = c.req.valid("json"); 20 | const [inserted] = await db.insert(tasks).values(task).returning(); 21 | return c.json(inserted, HttpStatusCodes.OK); 22 | }; 23 | 24 | export const getOne: AppRouteHandler = async (c) => { 25 | const { id } = c.req.valid("param"); 26 | const task = await db.query.tasks.findFirst({ 27 | where(fields, operators) { 28 | return operators.eq(fields.id, id); 29 | }, 30 | }); 31 | 32 | if (!task) { 33 | return c.json( 34 | { 35 | message: HttpStatusPhrases.NOT_FOUND, 36 | }, 37 | HttpStatusCodes.NOT_FOUND, 38 | ); 39 | } 40 | 41 | return c.json(task, HttpStatusCodes.OK); 42 | }; 43 | 44 | export const patch: AppRouteHandler = async (c) => { 45 | const { id } = c.req.valid("param"); 46 | const updates = c.req.valid("json"); 47 | 48 | if (Object.keys(updates).length === 0) { 49 | return c.json( 50 | { 51 | success: false, 52 | error: { 53 | issues: [ 54 | { 55 | code: ZOD_ERROR_CODES.INVALID_UPDATES, 56 | path: [], 57 | message: ZOD_ERROR_MESSAGES.NO_UPDATES, 58 | }, 59 | ], 60 | name: "ZodError", 61 | }, 62 | }, 63 | HttpStatusCodes.UNPROCESSABLE_ENTITY, 64 | ); 65 | } 66 | 67 | const [task] = await db.update(tasks) 68 | .set(updates) 69 | .where(eq(tasks.id, id)) 70 | .returning(); 71 | 72 | if (!task) { 73 | return c.json( 74 | { 75 | message: HttpStatusPhrases.NOT_FOUND, 76 | }, 77 | HttpStatusCodes.NOT_FOUND, 78 | ); 79 | } 80 | 81 | return c.json(task, HttpStatusCodes.OK); 82 | }; 83 | 84 | export const remove: AppRouteHandler = async (c) => { 85 | const { id } = c.req.valid("param"); 86 | const result = await db.delete(tasks) 87 | .where(eq(tasks.id, id)); 88 | 89 | if (result.rowsAffected === 0) { 90 | return c.json( 91 | { 92 | message: HttpStatusPhrases.NOT_FOUND, 93 | }, 94 | HttpStatusCodes.NOT_FOUND, 95 | ); 96 | } 97 | 98 | return c.body(null, HttpStatusCodes.NO_CONTENT); 99 | }; 100 | -------------------------------------------------------------------------------- /vercel-edge-example/src/routes/tasks/tasks.index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@/lib/create-app"; 2 | 3 | import * as handlers from "./tasks.handlers"; 4 | import * as routes from "./tasks.routes"; 5 | 6 | const router = createRouter() 7 | .openapi(routes.list, handlers.list) 8 | .openapi(routes.create, handlers.create) 9 | .openapi(routes.getOne, handlers.getOne) 10 | .openapi(routes.patch, handlers.patch) 11 | .openapi(routes.remove, handlers.remove); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /vercel-edge-example/src/routes/tasks/tasks.routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent, jsonContentRequired } from "stoker/openapi/helpers"; 4 | import { createErrorSchema, IdParamsSchema } from "stoker/openapi/schemas"; 5 | 6 | import { insertTasksSchema, patchTasksSchema, selectTasksSchema } from "@/db/schema"; 7 | import { notFoundSchema } from "@/lib/constants"; 8 | 9 | const tags = ["Tasks"]; 10 | 11 | export const list = createRoute({ 12 | path: "/tasks", 13 | method: "get", 14 | tags, 15 | responses: { 16 | [HttpStatusCodes.OK]: jsonContent( 17 | z.array(selectTasksSchema), 18 | "The list of tasks", 19 | ), 20 | }, 21 | }); 22 | 23 | export const create = createRoute({ 24 | path: "/tasks", 25 | method: "post", 26 | request: { 27 | body: jsonContentRequired( 28 | insertTasksSchema, 29 | "The task to create", 30 | ), 31 | }, 32 | tags, 33 | responses: { 34 | [HttpStatusCodes.OK]: jsonContent( 35 | selectTasksSchema, 36 | "The created task", 37 | ), 38 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 39 | createErrorSchema(insertTasksSchema), 40 | "The validation error(s)", 41 | ), 42 | }, 43 | }); 44 | 45 | export const getOne = createRoute({ 46 | path: "/tasks/{id}", 47 | method: "get", 48 | request: { 49 | params: IdParamsSchema, 50 | }, 51 | tags, 52 | responses: { 53 | [HttpStatusCodes.OK]: jsonContent( 54 | selectTasksSchema, 55 | "The requested task", 56 | ), 57 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 58 | notFoundSchema, 59 | "Task not found", 60 | ), 61 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 62 | createErrorSchema(IdParamsSchema), 63 | "Invalid id error", 64 | ), 65 | }, 66 | }); 67 | 68 | export const patch = createRoute({ 69 | path: "/tasks/{id}", 70 | method: "patch", 71 | request: { 72 | params: IdParamsSchema, 73 | body: jsonContentRequired( 74 | patchTasksSchema, 75 | "The task updates", 76 | ), 77 | }, 78 | tags, 79 | responses: { 80 | [HttpStatusCodes.OK]: jsonContent( 81 | selectTasksSchema, 82 | "The updated task", 83 | ), 84 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 85 | notFoundSchema, 86 | "Task not found", 87 | ), 88 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 89 | createErrorSchema(patchTasksSchema) 90 | .or(createErrorSchema(IdParamsSchema)), 91 | "The validation error(s)", 92 | ), 93 | }, 94 | }); 95 | 96 | export const remove = createRoute({ 97 | path: "/tasks/{id}", 98 | method: "delete", 99 | request: { 100 | params: IdParamsSchema, 101 | }, 102 | tags, 103 | responses: { 104 | [HttpStatusCodes.NO_CONTENT]: { 105 | description: "Task deleted", 106 | }, 107 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 108 | notFoundSchema, 109 | "Task not found", 110 | ), 111 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 112 | createErrorSchema(IdParamsSchema), 113 | "Invalid id error", 114 | ), 115 | }, 116 | }); 117 | 118 | export type ListRoute = typeof list; 119 | export type CreateRoute = typeof create; 120 | export type GetOneRoute = typeof getOne; 121 | export type PatchRoute = typeof patch; 122 | export type RemoveRoute = typeof remove; 123 | -------------------------------------------------------------------------------- /vercel-edge-example/src/routes/tasks/tasks.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/ban-ts-comment */ 2 | import { testClient } from "hono/testing"; 3 | import { execSync } from "node:child_process"; 4 | import fs from "node:fs"; 5 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 6 | import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest"; 7 | import { ZodIssueCode } from "zod"; 8 | 9 | import env from "@/env"; 10 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 11 | import createApp from "@/lib/create-app"; 12 | 13 | import router from "./tasks.index"; 14 | 15 | if (env.NODE_ENV !== "test") { 16 | throw new Error("NODE_ENV must be 'test'"); 17 | } 18 | 19 | const client = testClient(createApp().route("/", router)); 20 | 21 | describe("tasks routes", () => { 22 | beforeAll(async () => { 23 | execSync("pnpm drizzle-kit push"); 24 | }); 25 | 26 | afterAll(async () => { 27 | fs.rmSync("test.db", { force: true }); 28 | }); 29 | 30 | it("post /tasks validates the body when creating", async () => { 31 | const response = await client.tasks.$post({ 32 | // @ts-expect-error 33 | json: { 34 | done: false, 35 | }, 36 | }); 37 | expect(response.status).toBe(422); 38 | if (response.status === 422) { 39 | const json = await response.json(); 40 | expect(json.error.issues[0].path[0]).toBe("name"); 41 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.REQUIRED); 42 | } 43 | }); 44 | 45 | const id = "1"; 46 | const name = "Learn vitest"; 47 | 48 | it("post /tasks creates a task", async () => { 49 | const response = await client.tasks.$post({ 50 | json: { 51 | name, 52 | done: false, 53 | }, 54 | }); 55 | expect(response.status).toBe(200); 56 | if (response.status === 200) { 57 | const json = await response.json(); 58 | expect(json.name).toBe(name); 59 | expect(json.done).toBe(false); 60 | } 61 | }); 62 | 63 | it("get /tasks lists all tasks", async () => { 64 | const response = await client.tasks.$get(); 65 | expect(response.status).toBe(200); 66 | if (response.status === 200) { 67 | const json = await response.json(); 68 | expectTypeOf(json).toBeArray(); 69 | expect(json.length).toBe(1); 70 | } 71 | }); 72 | 73 | it("get /tasks/{id} validates the id param", async () => { 74 | const response = await client.tasks[":id"].$get({ 75 | param: { 76 | id: "wat", 77 | }, 78 | }); 79 | expect(response.status).toBe(422); 80 | if (response.status === 422) { 81 | const json = await response.json(); 82 | expect(json.error.issues[0].path[0]).toBe("id"); 83 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 84 | } 85 | }); 86 | 87 | it("get /tasks/{id} returns 404 when task not found", async () => { 88 | const response = await client.tasks[":id"].$get({ 89 | param: { 90 | id: "999", 91 | }, 92 | }); 93 | expect(response.status).toBe(404); 94 | if (response.status === 404) { 95 | const json = await response.json(); 96 | expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND); 97 | } 98 | }); 99 | 100 | it("get /tasks/{id} gets a single task", async () => { 101 | const response = await client.tasks[":id"].$get({ 102 | param: { 103 | id, 104 | }, 105 | }); 106 | expect(response.status).toBe(200); 107 | if (response.status === 200) { 108 | const json = await response.json(); 109 | expect(json.name).toBe(name); 110 | expect(json.done).toBe(false); 111 | } 112 | }); 113 | 114 | it("patch /tasks/{id} validates the body when updating", async () => { 115 | const response = await client.tasks[":id"].$patch({ 116 | param: { 117 | id, 118 | }, 119 | json: { 120 | name: "", 121 | }, 122 | }); 123 | expect(response.status).toBe(422); 124 | if (response.status === 422) { 125 | const json = await response.json(); 126 | expect(json.error.issues[0].path[0]).toBe("name"); 127 | expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small); 128 | } 129 | }); 130 | 131 | it("patch /tasks/{id} validates the id param", async () => { 132 | const response = await client.tasks[":id"].$patch({ 133 | param: { 134 | id: "wat", 135 | }, 136 | json: {}, 137 | }); 138 | expect(response.status).toBe(422); 139 | if (response.status === 422) { 140 | const json = await response.json(); 141 | expect(json.error.issues[0].path[0]).toBe("id"); 142 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 143 | } 144 | }); 145 | 146 | it("patch /tasks/{id} validates empty body", async () => { 147 | const response = await client.tasks[":id"].$patch({ 148 | param: { 149 | id, 150 | }, 151 | json: {}, 152 | }); 153 | expect(response.status).toBe(422); 154 | if (response.status === 422) { 155 | const json = await response.json(); 156 | expect(json.error.issues[0].code).toBe(ZOD_ERROR_CODES.INVALID_UPDATES); 157 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.NO_UPDATES); 158 | } 159 | }); 160 | 161 | it("patch /tasks/{id} updates a single property of a task", async () => { 162 | const response = await client.tasks[":id"].$patch({ 163 | param: { 164 | id, 165 | }, 166 | json: { 167 | done: true, 168 | }, 169 | }); 170 | expect(response.status).toBe(200); 171 | if (response.status === 200) { 172 | const json = await response.json(); 173 | expect(json.done).toBe(true); 174 | } 175 | }); 176 | 177 | it("delete /tasks/{id} validates the id when deleting", async () => { 178 | const response = await client.tasks[":id"].$delete({ 179 | param: { 180 | id: "wat", 181 | }, 182 | }); 183 | expect(response.status).toBe(422); 184 | if (response.status === 422) { 185 | const json = await response.json(); 186 | expect(json.error.issues[0].path[0]).toBe("id"); 187 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 188 | } 189 | }); 190 | 191 | it("delete /tasks/{id} removes a task", async () => { 192 | const response = await client.tasks[":id"].$delete({ 193 | param: { 194 | id, 195 | }, 196 | }); 197 | expect(response.status).toBe(204); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /vercel-edge-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx", 6 | "baseUrl": "./", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "typeRoots": ["./node_modules/@types"], 13 | "types": [ 14 | "node" 15 | ], 16 | "strict": true, 17 | "outDir": "./dist", 18 | "skipLibCheck": true 19 | }, 20 | "tsc-alias": { 21 | "resolveFullPaths": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vercel-edge-example/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/api" }] 3 | } 4 | -------------------------------------------------------------------------------- /vercel-edge-example/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": path.resolve(__dirname, "./src"), 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /vercel-nodejs-example/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=9999 3 | LOG_LEVEL=debug 4 | DATABASE_URL=file:dev.db -------------------------------------------------------------------------------- /vercel-nodejs-example/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=9999 3 | LOG_LEVEL=silent 4 | DATABASE_URL=file:test.db -------------------------------------------------------------------------------- /vercel-nodejs-example/.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: CodingGarden 2 | patreon: CodingGarden 3 | custom: ["https://streamlabs.com/codinggarden/tip", "https://twitch.tv/products/codinggarden", "https://www.youtube.com/codinggarden/join"] 4 | -------------------------------------------------------------------------------- /vercel-nodejs-example/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | dev.db 31 | test.db 32 | .vercel 33 | dist -------------------------------------------------------------------------------- /vercel-nodejs-example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2024 w3cj 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /vercel-nodejs-example/README.md: -------------------------------------------------------------------------------- 1 | # Hono Open API Starter 2 | 3 | A starter template for building fully documented type-safe JSON APIs with Hono and Open API. 4 | 5 | - [Hono Open API Starter](#hono-open-api-starter) 6 | - [Included](#included) 7 | - [Setup](#setup) 8 | - [Code Tour](#code-tour) 9 | - [Endpoints](#endpoints) 10 | - [References](#references) 11 | 12 | ## Included 13 | 14 | - Structured logging with [pino](https://getpino.io/) / [hono-pino](https://www.npmjs.com/package/hono-pino) 15 | - Documented / type-safe routes with [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 16 | - Interactive API documentation with [scalar](https://scalar.com/#api-docs) / [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/main/packages/hono-api-reference) 17 | - Convenience methods / helpers to reduce boilerplate with [stoker](https://www.npmjs.com/package/stoker) 18 | - Type-safe schemas and environment variables with [zod](https://zod.dev/) 19 | - Single source of truth database schemas with [drizzle](https://orm.drizzle.team/docs/overview) and [drizzle-zod](https://orm.drizzle.team/docs/zod) 20 | - Testing with [vitest](https://vitest.dev/) 21 | - Sensible editor, formatting and linting settings with [@antfu/eslint-config](https://github.com/antfu/eslint-config) 22 | 23 | ## Setup 24 | 25 | Clone this template without git history 26 | 27 | ```sh 28 | npx degit w3cj/hono-open-api-starter my-api 29 | cd my-api 30 | ``` 31 | 32 | Create `.env` file 33 | 34 | ```sh 35 | cp .env.sample .env 36 | ``` 37 | 38 | Create sqlite db / push schema 39 | 40 | ```sh 41 | pnpm drizzle-kit push 42 | ``` 43 | 44 | Install dependencies 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Run 51 | 52 | ```sh 53 | pnpm dev 54 | ``` 55 | 56 | Lint 57 | 58 | ```sh 59 | pnpm lint 60 | ``` 61 | 62 | Test 63 | 64 | ```sh 65 | pnpm test 66 | ``` 67 | 68 | ## Code Tour 69 | 70 | Base hono app exported from [app.ts](./src/app.ts). Local development uses [@hono/node-server](https://hono.dev/docs/getting-started/nodejs) defined in [index.ts](./src/index.ts) - update this file or create a new entry point to use your preferred runtime. 71 | 72 | Typesafe env defined in [env.ts](./src/env.ts) - add any other required environment variables here. The application will not start if any required environment variables are missing 73 | 74 | See [src/routes/tasks](./src/routes/tasks/) for an example Open API group. Copy this folder / use as an example for your route groups. 75 | 76 | - Router created in [tasks.index.ts](./src/routes/tasks/tasks.index.ts) 77 | - Route definitions defined in [tasks.routes.ts](./src/routes/tasks/tasks.routes.ts) 78 | - Hono request handlers defined in [tasks.handlers.ts](./src/routes/tasks/tasks.handlers.ts) 79 | - Group unit tests defined in [tasks.test.ts](./src/routes/tasks/tasks.test.ts) 80 | 81 | All app routes are grouped together and exported into single type as `AppType` in [app.ts](./src/app.ts) for use in [RPC / hono/client](https://hono.dev/docs/guides/rpc). 82 | 83 | ## Endpoints 84 | 85 | | Path | Description | 86 | | ------------------ | ------------------------ | 87 | | GET /doc | Open API Specification | 88 | | GET /reference | Scalar API Documentation | 89 | | GET /tasks | List all tasks | 90 | | POST /tasks | Create a task | 91 | | GET /tasks/{id} | Get one task by id | 92 | | PATCH /tasks/{id} | Patch one task by id | 93 | | DELETE /tasks/{id} | Delete one task by id | 94 | 95 | ## References 96 | 97 | - [What is Open API?](https://swagger.io/docs/specification/v3_0/about/) 98 | - [Hono](https://hono.dev/) 99 | - [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) 100 | - [Testing](https://hono.dev/docs/guides/testing) 101 | - [Testing Helper](https://hono.dev/docs/helpers/testing) 102 | - [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 103 | - [Scalar Documentation](https://github.com/scalar/scalar/tree/main/?tab=readme-ov-file#documentation) 104 | - [Themes / Layout](https://github.com/scalar/scalar/blob/main/documentation/themes.md) 105 | - [Configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) 106 | -------------------------------------------------------------------------------- /vercel-nodejs-example/api/index.ts: -------------------------------------------------------------------------------- 1 | import { handle } from "@hono/node-server/vercel"; 2 | 3 | // eslint-disable-next-line ts/ban-ts-comment 4 | // @ts-expect-error 5 | // eslint-disable-next-line antfu/no-import-dist 6 | import app from "../dist/src/app.js"; 7 | 8 | export default handle(app); 9 | -------------------------------------------------------------------------------- /vercel-nodejs-example/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | import env from "@/env"; 4 | 5 | export default defineConfig({ 6 | schema: "./src/db/schema.ts", 7 | out: "./src/db/migrations", 8 | dialect: "sqlite", 9 | driver: "turso", 10 | dbCredentials: { 11 | url: env.DATABASE_URL, 12 | authToken: env.DATABASE_AUTH_TOKEN, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /vercel-nodejs-example/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu({ 4 | type: "app", 5 | typescript: true, 6 | formatters: true, 7 | stylistic: { 8 | indent: 2, 9 | semi: true, 10 | quotes: "double", 11 | }, 12 | ignores: ["**/migrations/*"], 13 | }, { 14 | rules: { 15 | "no-console": ["warn"], 16 | "antfu/no-top-level-await": ["off"], 17 | "node/prefer-global/process": ["off"], 18 | "node/no-process-env": ["error"], 19 | "perfectionist/sort-imports": ["error", { 20 | internalPattern: ["@/**"], 21 | }], 22 | "unicorn/filename-case": ["error", { 23 | case: "kebabCase", 24 | ignore: ["README.md"], 25 | }], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /vercel-nodejs-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hono-open-api-starter", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "tsx watch src/index.ts", 8 | "vercel:dev": "npm run build && vercel dev", 9 | "start": "node ./dist/src/index.js", 10 | "typecheck": "tsc --noEmit", 11 | "lint": "eslint .", 12 | "lint:fix": "npm run lint --fix", 13 | "test": "cross-env NODE_ENV=test vitest", 14 | "build": "tsc && tsc-alias" 15 | }, 16 | "dependencies": { 17 | "@hono/node-server": "^1.13.1", 18 | "@hono/zod-openapi": "^0.16.4", 19 | "@libsql/client": "^0.14.0", 20 | "@scalar/hono-api-reference": "^0.5.150", 21 | "dotenv": "^16.4.5", 22 | "dotenv-expand": "^11.0.6", 23 | "drizzle-orm": "^0.33.0", 24 | "drizzle-zod": "^0.5.1", 25 | "hono": "^4.6.3", 26 | "hono-pino": "^0.3.0", 27 | "node-fetch": "^3.3.2", 28 | "pino": "^9.4.0", 29 | "pino-pretty": "^11.2.2", 30 | "stoker": "^1.0.9", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@antfu/eslint-config": "^3.7.3", 35 | "@types/node": "^22.7.4", 36 | "cross-env": "^7.0.3", 37 | "drizzle-kit": "^0.24.2", 38 | "eslint": "^9.12.0", 39 | "eslint-plugin-format": "^0.1.2", 40 | "tsc-alias": "^1.8.10", 41 | "tsx": "^4.19.1", 42 | "typescript": "^5.6.2", 43 | "vercel": "^37.6.2", 44 | "vitest": "^2.1.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /vercel-nodejs-example/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3cj/hono-node-deployment-examples/14f3739d7ef31d69b80af52492926257bcb0355b/vercel-nodejs-example/public/.gitkeep -------------------------------------------------------------------------------- /vercel-nodejs-example/src/app.ts: -------------------------------------------------------------------------------- 1 | import configureOpenAPI from "@/lib/configure-open-api"; 2 | import createApp from "@/lib/create-app"; 3 | import index from "@/routes/index.route"; 4 | import tasks from "@/routes/tasks/tasks.index"; 5 | 6 | const app = createApp(); 7 | 8 | configureOpenAPI(app); 9 | 10 | const routes = [ 11 | index, 12 | tasks, 13 | ] as const; 14 | 15 | routes.forEach((route) => { 16 | app.route("/", route); 17 | }); 18 | 19 | export type AppType = typeof routes[number]; 20 | 21 | export default app; 22 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | import nodeFetch from "node-fetch"; 4 | 5 | import env from "@/env"; 6 | 7 | import * as schema from "./schema"; 8 | 9 | const client = createClient({ 10 | url: env.DATABASE_URL, 11 | authToken: env.DATABASE_AUTH_TOKEN, 12 | fetch: async (request: Request) => { 13 | const decoder = new TextDecoder(); 14 | let body = "{}"; 15 | for await (const chunk of request.body!) { 16 | body = decoder.decode(chunk); 17 | } 18 | return nodeFetch(request.url, { 19 | method: "post", 20 | headers: Object.fromEntries([...request.headers.entries()]), 21 | body, 22 | }); 23 | }, 24 | }); 25 | 26 | const db = drizzle(client, { 27 | schema, 28 | }); 29 | 30 | export default db; 31 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/db/migrations/0000_same_squadron_sinister.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tasks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `name` text NOT NULL, 4 | `done` integer DEFAULT false NOT NULL, 5 | `created_at` integer, 6 | `updated_at` integer 7 | ); 8 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "f2bcf13c-160f-4648-b726-9d503979baef", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "tasks": { 8 | "name": "tasks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "done": { 25 | "name": "done", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | }, 32 | "created_at": { 33 | "name": "created_at", 34 | "type": "integer", 35 | "primaryKey": false, 36 | "notNull": false, 37 | "autoincrement": false 38 | }, 39 | "updated_at": { 40 | "name": "updated_at", 41 | "type": "integer", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false 45 | } 46 | }, 47 | "indexes": {}, 48 | "foreignKeys": {}, 49 | "compositePrimaryKeys": {}, 50 | "uniqueConstraints": {} 51 | } 52 | }, 53 | "enums": {}, 54 | "_meta": { 55 | "schemas": {}, 56 | "tables": {}, 57 | "columns": {} 58 | }, 59 | "internal": { 60 | "indexes": {} 61 | } 62 | } -------------------------------------------------------------------------------- /vercel-nodejs-example/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1728133964232, 9 | "tag": "0000_same_squadron_sinister", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /vercel-nodejs-example/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 | import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 3 | 4 | export const tasks = sqliteTable("tasks", { 5 | id: integer("id", { mode: "number" }) 6 | .primaryKey({ autoIncrement: true }), 7 | name: text("name") 8 | .notNull(), 9 | done: integer("done", { mode: "boolean" }) 10 | .notNull() 11 | .default(false), 12 | createdAt: integer("created_at", { mode: "timestamp" }) 13 | .$defaultFn(() => new Date()), 14 | updatedAt: integer("updated_at", { mode: "timestamp" }) 15 | .$defaultFn(() => new Date()) 16 | .$onUpdate(() => new Date()), 17 | }); 18 | 19 | export const selectTasksSchema = createSelectSchema(tasks); 20 | 21 | export const insertTasksSchema = createInsertSchema( 22 | tasks, 23 | { 24 | name: schema => schema.name.min(1).max(500), 25 | }, 26 | ).required({ 27 | done: true, 28 | }).omit({ 29 | id: true, 30 | createdAt: true, 31 | updatedAt: true, 32 | }); 33 | 34 | export const patchTasksSchema = insertTasksSchema.partial(); 35 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/env.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-process-env */ 2 | import { config } from "dotenv"; 3 | import { expand } from "dotenv-expand"; 4 | import path from "node:path"; 5 | import { z } from "zod"; 6 | 7 | expand(config({ 8 | path: path.resolve( 9 | process.cwd(), 10 | process.env.NODE_ENV === "test" ? ".env.test" : ".env", 11 | ), 12 | })); 13 | 14 | const EnvSchema = z.object({ 15 | NODE_ENV: z.string().default("development"), 16 | PORT: z.coerce.number().default(9999), 17 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), 18 | DATABASE_URL: z.string().url(), 19 | DATABASE_AUTH_TOKEN: z.string().optional(), 20 | }).superRefine((input, ctx) => { 21 | if (input.NODE_ENV === "production" && !input.DATABASE_AUTH_TOKEN) { 22 | ctx.addIssue({ 23 | code: z.ZodIssueCode.invalid_type, 24 | expected: "string", 25 | received: "undefined", 26 | path: ["DATABASE_AUTH_TOKEN"], 27 | message: "Must be set when NODE_ENV is 'production'", 28 | }); 29 | } 30 | }); 31 | 32 | export type env = z.infer; 33 | 34 | // eslint-disable-next-line ts/no-redeclare 35 | const { data: env, error } = EnvSchema.safeParse(process.env); 36 | 37 | if (error) { 38 | console.error("❌ Invalid env:"); 39 | console.error(JSON.stringify(error.flatten().fieldErrors, null, 2)); 40 | process.exit(1); 41 | } 42 | 43 | export default env!; 44 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | 3 | import app from "./app"; 4 | import env from "./env"; 5 | 6 | const port = env.PORT; 7 | // eslint-disable-next-line no-console 8 | console.log(`Server is running on port http://localhost:${port}`); 9 | 10 | serve({ 11 | fetch: app.fetch, 12 | port, 13 | }); 14 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/lib/configure-open-api.ts: -------------------------------------------------------------------------------- 1 | import { apiReference } from "@scalar/hono-api-reference"; 2 | 3 | import type { AppOpenAPI } from "./types"; 4 | 5 | import packageJSON from "../../package.json" with { type: "json" }; 6 | 7 | export default function configureOpenAPI(app: AppOpenAPI) { 8 | app.doc("/doc", { 9 | openapi: "3.0.0", 10 | info: { 11 | version: packageJSON.version, 12 | title: "Tasks API", 13 | }, 14 | }); 15 | 16 | app.get( 17 | "/reference", 18 | apiReference({ 19 | theme: "kepler", 20 | layout: "classic", 21 | defaultHttpClient: { 22 | targetKey: "javascript", 23 | clientKey: "fetch", 24 | }, 25 | spec: { 26 | url: "/doc", 27 | }, 28 | }), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 3 | 4 | export const ZOD_ERROR_MESSAGES = { 5 | REQUIRED: "Required", 6 | EXPECTED_NUMBER: "Expected number, received nan", 7 | NO_UPDATES: "No updates provided", 8 | }; 9 | 10 | export const ZOD_ERROR_CODES = { 11 | INVALID_UPDATES: "invalid_updates", 12 | }; 13 | 14 | export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND); 15 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/lib/create-app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares"; 3 | import { defaultHook } from "stoker/openapi"; 4 | 5 | import { pinoLogger } from "@/middlewares/pino-logger"; 6 | 7 | import type { AppBindings, AppOpenAPI } from "./types"; 8 | 9 | export function createRouter() { 10 | return new OpenAPIHono({ 11 | strict: false, 12 | defaultHook, 13 | }); 14 | } 15 | 16 | export default function createApp() { 17 | const app = createRouter(); 18 | app.use(serveEmojiFavicon("📝")); 19 | app.use(pinoLogger()); 20 | 21 | app.notFound(notFound); 22 | app.onError(onError); 23 | return app; 24 | } 25 | 26 | export function createTestApp(router: R) { 27 | return createApp().route("/", router); 28 | } 29 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi"; 2 | import type { PinoLogger } from "hono-pino"; 3 | 4 | export interface AppBindings { 5 | Variables: { 6 | logger: PinoLogger; 7 | }; 8 | }; 9 | 10 | export type AppOpenAPI = OpenAPIHono; 11 | 12 | export type AppRouteHandler = RouteHandler; 13 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/middlewares/pino-logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "hono-pino"; 2 | import pino from "pino"; 3 | import pretty from "pino-pretty"; 4 | 5 | import env from "@/env"; 6 | 7 | export function pinoLogger() { 8 | return logger({ 9 | pino: pino({ 10 | level: env.LOG_LEVEL || "info", 11 | }, env.NODE_ENV === "production" ? undefined : pretty()), 12 | http: { 13 | reqId: () => crypto.randomUUID(), 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent } from "stoker/openapi/helpers"; 4 | import { createMessageObjectSchema } from "stoker/openapi/schemas"; 5 | 6 | import { createRouter } from "@/lib/create-app"; 7 | 8 | const router = createRouter() 9 | .openapi( 10 | createRoute({ 11 | tags: ["Index"], 12 | method: "get", 13 | path: "/", 14 | responses: { 15 | [HttpStatusCodes.OK]: jsonContent( 16 | createMessageObjectSchema("Tasks API"), 17 | "Tasks API Index", 18 | ), 19 | }, 20 | }), 21 | (c) => { 22 | return c.json({ 23 | message: "Tasks API", 24 | }, HttpStatusCodes.OK); 25 | }, 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/routes/tasks/tasks.handlers.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 4 | 5 | import type { AppRouteHandler } from "@/lib/types"; 6 | 7 | import db from "@/db"; 8 | import { tasks } from "@/db/schema"; 9 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 10 | 11 | import type { CreateRoute, GetOneRoute, ListRoute, PatchRoute, RemoveRoute } from "./tasks.routes"; 12 | 13 | export const list: AppRouteHandler = async (c) => { 14 | const tasks = await db.query.tasks.findMany(); 15 | return c.json(tasks); 16 | }; 17 | 18 | export const create: AppRouteHandler = async (c) => { 19 | const task = c.req.valid("json"); 20 | const [inserted] = await db.insert(tasks).values(task).returning(); 21 | return c.json(inserted, HttpStatusCodes.OK); 22 | }; 23 | 24 | export const getOne: AppRouteHandler = async (c) => { 25 | const { id } = c.req.valid("param"); 26 | const task = await db.query.tasks.findFirst({ 27 | where(fields, operators) { 28 | return operators.eq(fields.id, id); 29 | }, 30 | }); 31 | 32 | if (!task) { 33 | return c.json( 34 | { 35 | message: HttpStatusPhrases.NOT_FOUND, 36 | }, 37 | HttpStatusCodes.NOT_FOUND, 38 | ); 39 | } 40 | 41 | return c.json(task, HttpStatusCodes.OK); 42 | }; 43 | 44 | export const patch: AppRouteHandler = async (c) => { 45 | const { id } = c.req.valid("param"); 46 | const updates = c.req.valid("json"); 47 | 48 | if (Object.keys(updates).length === 0) { 49 | return c.json( 50 | { 51 | success: false, 52 | error: { 53 | issues: [ 54 | { 55 | code: ZOD_ERROR_CODES.INVALID_UPDATES, 56 | path: [], 57 | message: ZOD_ERROR_MESSAGES.NO_UPDATES, 58 | }, 59 | ], 60 | name: "ZodError", 61 | }, 62 | }, 63 | HttpStatusCodes.UNPROCESSABLE_ENTITY, 64 | ); 65 | } 66 | 67 | const [task] = await db.update(tasks) 68 | .set(updates) 69 | .where(eq(tasks.id, id)) 70 | .returning(); 71 | 72 | if (!task) { 73 | return c.json( 74 | { 75 | message: HttpStatusPhrases.NOT_FOUND, 76 | }, 77 | HttpStatusCodes.NOT_FOUND, 78 | ); 79 | } 80 | 81 | return c.json(task, HttpStatusCodes.OK); 82 | }; 83 | 84 | export const remove: AppRouteHandler = async (c) => { 85 | const { id } = c.req.valid("param"); 86 | const result = await db.delete(tasks) 87 | .where(eq(tasks.id, id)); 88 | 89 | if (result.rowsAffected === 0) { 90 | return c.json( 91 | { 92 | message: HttpStatusPhrases.NOT_FOUND, 93 | }, 94 | HttpStatusCodes.NOT_FOUND, 95 | ); 96 | } 97 | 98 | return c.body(null, HttpStatusCodes.NO_CONTENT); 99 | }; 100 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/routes/tasks/tasks.index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@/lib/create-app"; 2 | 3 | import * as handlers from "./tasks.handlers"; 4 | import * as routes from "./tasks.routes"; 5 | 6 | const router = createRouter() 7 | .openapi(routes.list, handlers.list) 8 | .openapi(routes.create, handlers.create) 9 | .openapi(routes.getOne, handlers.getOne) 10 | .openapi(routes.patch, handlers.patch) 11 | .openapi(routes.remove, handlers.remove); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/routes/tasks/tasks.routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import * as HttpStatusCodes from "stoker/http-status-codes"; 3 | import { jsonContent, jsonContentRequired } from "stoker/openapi/helpers"; 4 | import { createErrorSchema, IdParamsSchema } from "stoker/openapi/schemas"; 5 | 6 | import { insertTasksSchema, patchTasksSchema, selectTasksSchema } from "@/db/schema"; 7 | import { notFoundSchema } from "@/lib/constants"; 8 | 9 | const tags = ["Tasks"]; 10 | 11 | export const list = createRoute({ 12 | path: "/tasks", 13 | method: "get", 14 | tags, 15 | responses: { 16 | [HttpStatusCodes.OK]: jsonContent( 17 | z.array(selectTasksSchema), 18 | "The list of tasks", 19 | ), 20 | }, 21 | }); 22 | 23 | export const create = createRoute({ 24 | path: "/tasks", 25 | method: "post", 26 | request: { 27 | body: jsonContentRequired( 28 | insertTasksSchema, 29 | "The task to create", 30 | ), 31 | }, 32 | tags, 33 | responses: { 34 | [HttpStatusCodes.OK]: jsonContent( 35 | selectTasksSchema, 36 | "The created task", 37 | ), 38 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 39 | createErrorSchema(insertTasksSchema), 40 | "The validation error(s)", 41 | ), 42 | }, 43 | }); 44 | 45 | export const getOne = createRoute({ 46 | path: "/tasks/{id}", 47 | method: "get", 48 | request: { 49 | params: IdParamsSchema, 50 | }, 51 | tags, 52 | responses: { 53 | [HttpStatusCodes.OK]: jsonContent( 54 | selectTasksSchema, 55 | "The requested task", 56 | ), 57 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 58 | notFoundSchema, 59 | "Task not found", 60 | ), 61 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 62 | createErrorSchema(IdParamsSchema), 63 | "Invalid id error", 64 | ), 65 | }, 66 | }); 67 | 68 | export const patch = createRoute({ 69 | path: "/tasks/{id}", 70 | method: "patch", 71 | request: { 72 | params: IdParamsSchema, 73 | body: jsonContentRequired( 74 | patchTasksSchema, 75 | "The task updates", 76 | ), 77 | }, 78 | tags, 79 | responses: { 80 | [HttpStatusCodes.OK]: jsonContent( 81 | selectTasksSchema, 82 | "The updated task", 83 | ), 84 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 85 | notFoundSchema, 86 | "Task not found", 87 | ), 88 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 89 | createErrorSchema(patchTasksSchema) 90 | .or(createErrorSchema(IdParamsSchema)), 91 | "The validation error(s)", 92 | ), 93 | }, 94 | }); 95 | 96 | export const remove = createRoute({ 97 | path: "/tasks/{id}", 98 | method: "delete", 99 | request: { 100 | params: IdParamsSchema, 101 | }, 102 | tags, 103 | responses: { 104 | [HttpStatusCodes.NO_CONTENT]: { 105 | description: "Task deleted", 106 | }, 107 | [HttpStatusCodes.NOT_FOUND]: jsonContent( 108 | notFoundSchema, 109 | "Task not found", 110 | ), 111 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 112 | createErrorSchema(IdParamsSchema), 113 | "Invalid id error", 114 | ), 115 | }, 116 | }); 117 | 118 | export type ListRoute = typeof list; 119 | export type CreateRoute = typeof create; 120 | export type GetOneRoute = typeof getOne; 121 | export type PatchRoute = typeof patch; 122 | export type RemoveRoute = typeof remove; 123 | -------------------------------------------------------------------------------- /vercel-nodejs-example/src/routes/tasks/tasks.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/ban-ts-comment */ 2 | import { testClient } from "hono/testing"; 3 | import { execSync } from "node:child_process"; 4 | import fs from "node:fs"; 5 | import * as HttpStatusPhrases from "stoker/http-status-phrases"; 6 | import { afterAll, beforeAll, describe, expect, expectTypeOf, it } from "vitest"; 7 | import { ZodIssueCode } from "zod"; 8 | 9 | import env from "@/env"; 10 | import { ZOD_ERROR_CODES, ZOD_ERROR_MESSAGES } from "@/lib/constants"; 11 | import createApp from "@/lib/create-app"; 12 | 13 | import router from "./tasks.index"; 14 | 15 | if (env.NODE_ENV !== "test") { 16 | throw new Error("NODE_ENV must be 'test'"); 17 | } 18 | 19 | const client = testClient(createApp().route("/", router)); 20 | 21 | describe("tasks routes", () => { 22 | beforeAll(async () => { 23 | execSync("pnpm drizzle-kit push"); 24 | }); 25 | 26 | afterAll(async () => { 27 | fs.rmSync("test.db", { force: true }); 28 | }); 29 | 30 | it("post /tasks validates the body when creating", async () => { 31 | const response = await client.tasks.$post({ 32 | // @ts-expect-error 33 | json: { 34 | done: false, 35 | }, 36 | }); 37 | expect(response.status).toBe(422); 38 | if (response.status === 422) { 39 | const json = await response.json(); 40 | expect(json.error.issues[0].path[0]).toBe("name"); 41 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.REQUIRED); 42 | } 43 | }); 44 | 45 | const id = "1"; 46 | const name = "Learn vitest"; 47 | 48 | it("post /tasks creates a task", async () => { 49 | const response = await client.tasks.$post({ 50 | json: { 51 | name, 52 | done: false, 53 | }, 54 | }); 55 | expect(response.status).toBe(200); 56 | if (response.status === 200) { 57 | const json = await response.json(); 58 | expect(json.name).toBe(name); 59 | expect(json.done).toBe(false); 60 | } 61 | }); 62 | 63 | it("get /tasks lists all tasks", async () => { 64 | const response = await client.tasks.$get(); 65 | expect(response.status).toBe(200); 66 | if (response.status === 200) { 67 | const json = await response.json(); 68 | expectTypeOf(json).toBeArray(); 69 | expect(json.length).toBe(1); 70 | } 71 | }); 72 | 73 | it("get /tasks/{id} validates the id param", async () => { 74 | const response = await client.tasks[":id"].$get({ 75 | param: { 76 | id: "wat", 77 | }, 78 | }); 79 | expect(response.status).toBe(422); 80 | if (response.status === 422) { 81 | const json = await response.json(); 82 | expect(json.error.issues[0].path[0]).toBe("id"); 83 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 84 | } 85 | }); 86 | 87 | it("get /tasks/{id} returns 404 when task not found", async () => { 88 | const response = await client.tasks[":id"].$get({ 89 | param: { 90 | id: "999", 91 | }, 92 | }); 93 | expect(response.status).toBe(404); 94 | if (response.status === 404) { 95 | const json = await response.json(); 96 | expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND); 97 | } 98 | }); 99 | 100 | it("get /tasks/{id} gets a single task", async () => { 101 | const response = await client.tasks[":id"].$get({ 102 | param: { 103 | id, 104 | }, 105 | }); 106 | expect(response.status).toBe(200); 107 | if (response.status === 200) { 108 | const json = await response.json(); 109 | expect(json.name).toBe(name); 110 | expect(json.done).toBe(false); 111 | } 112 | }); 113 | 114 | it("patch /tasks/{id} validates the body when updating", async () => { 115 | const response = await client.tasks[":id"].$patch({ 116 | param: { 117 | id, 118 | }, 119 | json: { 120 | name: "", 121 | }, 122 | }); 123 | expect(response.status).toBe(422); 124 | if (response.status === 422) { 125 | const json = await response.json(); 126 | expect(json.error.issues[0].path[0]).toBe("name"); 127 | expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small); 128 | } 129 | }); 130 | 131 | it("patch /tasks/{id} validates the id param", async () => { 132 | const response = await client.tasks[":id"].$patch({ 133 | param: { 134 | id: "wat", 135 | }, 136 | json: {}, 137 | }); 138 | expect(response.status).toBe(422); 139 | if (response.status === 422) { 140 | const json = await response.json(); 141 | expect(json.error.issues[0].path[0]).toBe("id"); 142 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 143 | } 144 | }); 145 | 146 | it("patch /tasks/{id} validates empty body", async () => { 147 | const response = await client.tasks[":id"].$patch({ 148 | param: { 149 | id, 150 | }, 151 | json: {}, 152 | }); 153 | expect(response.status).toBe(422); 154 | if (response.status === 422) { 155 | const json = await response.json(); 156 | expect(json.error.issues[0].code).toBe(ZOD_ERROR_CODES.INVALID_UPDATES); 157 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.NO_UPDATES); 158 | } 159 | }); 160 | 161 | it("patch /tasks/{id} updates a single property of a task", async () => { 162 | const response = await client.tasks[":id"].$patch({ 163 | param: { 164 | id, 165 | }, 166 | json: { 167 | done: true, 168 | }, 169 | }); 170 | expect(response.status).toBe(200); 171 | if (response.status === 200) { 172 | const json = await response.json(); 173 | expect(json.done).toBe(true); 174 | } 175 | }); 176 | 177 | it("delete /tasks/{id} validates the id when deleting", async () => { 178 | const response = await client.tasks[":id"].$delete({ 179 | param: { 180 | id: "wat", 181 | }, 182 | }); 183 | expect(response.status).toBe(422); 184 | if (response.status === 422) { 185 | const json = await response.json(); 186 | expect(json.error.issues[0].path[0]).toBe("id"); 187 | expect(json.error.issues[0].message).toBe(ZOD_ERROR_MESSAGES.EXPECTED_NUMBER); 188 | } 189 | }); 190 | 191 | it("delete /tasks/{id} removes a task", async () => { 192 | const response = await client.tasks[":id"].$delete({ 193 | param: { 194 | id, 195 | }, 196 | }); 197 | expect(response.status).toBe(204); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /vercel-nodejs-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx", 6 | "baseUrl": "./", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "typeRoots": ["./node_modules/@types"], 13 | "types": [ 14 | "node" 15 | ], 16 | "strict": true, 17 | "outDir": "./dist", 18 | "skipLibCheck": true 19 | }, 20 | "tsc-alias": { 21 | "resolveFullPaths": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /vercel-nodejs-example/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/api" }] 3 | } 4 | -------------------------------------------------------------------------------- /vercel-nodejs-example/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": path.resolve(__dirname, "./src"), 8 | }, 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------