bono-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/cli/index.ts +60 -0
  4. package/cli/src/apply.ts +149 -0
  5. package/cli/src/manifest.ts +36 -0
  6. package/cli/src/new.ts +165 -0
  7. package/cli/src/plan.ts +47 -0
  8. package/package.json +51 -0
  9. package/templates/base/.env.example +3 -0
  10. package/templates/base/README.md +46 -0
  11. package/templates/base/_gitignore +2 -0
  12. package/templates/base/biome.json +20 -0
  13. package/templates/base/package.json +25 -0
  14. package/templates/base/src/app.ts +52 -0
  15. package/templates/base/src/config/env.ts +25 -0
  16. package/templates/base/src/features/todos/todos.routes.ts +53 -0
  17. package/templates/base/src/features/todos/todos.schema.ts +29 -0
  18. package/templates/base/src/features/todos/todos.service.ts +36 -0
  19. package/templates/base/src/index.ts +19 -0
  20. package/templates/base/src/lib/app-env.ts +6 -0
  21. package/templates/base/src/lib/errors.ts +26 -0
  22. package/templates/base/src/lib/logger.ts +17 -0
  23. package/templates/base/src/lib/validate.ts +29 -0
  24. package/templates/base/src/middleware/rate-limit.ts +100 -0
  25. package/templates/base/src/middleware/request-logger.ts +21 -0
  26. package/templates/base/tsconfig.json +17 -0
  27. package/templates/integrations/compose-postgres/files/docker-compose.yml +15 -0
  28. package/templates/integrations/compose-postgres/manifest.json +5 -0
  29. package/templates/integrations/db-postgres/files/src/db/client.ts +7 -0
  30. package/templates/integrations/db-postgres/manifest.json +18 -0
  31. package/templates/integrations/drizzle/files/drizzle.config.ts +11 -0
  32. package/templates/integrations/drizzle/files/src/db/index.ts +8 -0
  33. package/templates/integrations/drizzle/files/src/db/schema/index.ts +1 -0
  34. package/templates/integrations/drizzle/files/src/db/schema/todos.ts +10 -0
  35. package/templates/integrations/drizzle/manifest.json +17 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sillyfrogster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # bono
2
+
3
+ [![npm](https://img.shields.io/npm/v/bono-cli)](https://www.npmjs.com/package/bono-cli)
4
+ [![license](https://img.shields.io/npm/l/bono-cli)](LICENSE)
5
+ [![bun](https://img.shields.io/badge/runtime-bun-f9f1e1)](https://bun.sh)
6
+
7
+ Scaffold a Hono API on Bun with the boring setup already done.
8
+
9
+ ```sh
10
+ bunx bono-cli new my-api
11
+ ```
12
+
13
+ Prompts for Postgres, docker-compose, and Drizzle. `--base` skips them all.
14
+
15
+ ## The base
16
+
17
+ - Feature-based file structure, with a `todos` example to copy from
18
+ - pino logging with request IDs
19
+ - One error shape everywhere, `AppError` to control it
20
+ - zod env validation, crashes at startup instead of at runtime
21
+ - Request validation wired to the same error shape
22
+ - Rate limiting behind a swappable store
23
+ - Health endpoint, CORS, graceful shutdown
24
+ - Biome and `bun test` configured
25
+
26
+ ## Flags
27
+
28
+ | Flag | |
29
+ | --- | --- |
30
+ | `--base` | Skip prompts, base only |
31
+ | `--database <postgres\|none>` | |
32
+ | `--docker` / `--no-docker` | docker-compose for local Postgres |
33
+ | `--orm <drizzle\|none>` | |
34
+ | `--no-git` | Skip `git init` |
35
+ | `--no-install` | Skip `bun install` |
36
+
37
+ ## Requirements
38
+
39
+ Bun 1.2+
40
+
41
+ ## License
42
+
43
+ MIT
package/cli/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from "node:util";
3
+ import { runNew } from "./src/new.ts";
4
+
5
+ const HELP = `bono - scaffold a production-ready Hono API on Bun
6
+
7
+ Usage:
8
+ bono new <name> [options]
9
+
10
+ Options:
11
+ --base Skip all prompts, generate the base
12
+ --database <postgres|none> Answer the database prompt
13
+ --orm <drizzle|none> Answer the ORM prompt
14
+ --docker / --no-docker Answer the docker-compose prompt
15
+ --no-git Skip git init
16
+ --no-install Skip bun install
17
+ --help, -h Show this help
18
+ `;
19
+
20
+ const { values, positionals } = parseArgs({
21
+ args: Bun.argv.slice(2),
22
+ allowPositionals: true,
23
+ options: {
24
+ base: { type: "boolean", default: false },
25
+ database: { type: "string" },
26
+ orm: { type: "string" },
27
+ docker: { type: "boolean", default: false },
28
+ "no-docker": { type: "boolean", default: false },
29
+ "no-git": { type: "boolean", default: false },
30
+ "no-install": { type: "boolean", default: false },
31
+ help: { type: "boolean", short: "h", default: false },
32
+ },
33
+ });
34
+
35
+ const [command, projectName] = positionals;
36
+
37
+ if (values.help || !command) {
38
+ console.log(HELP);
39
+ process.exit(values.help ? 0 : 1);
40
+ }
41
+
42
+ if (command !== "new") {
43
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ if (!projectName) {
48
+ console.error(`Missing project name.\n\nUsage: bono new <name>`);
49
+ process.exit(1);
50
+ }
51
+
52
+ await runNew({
53
+ projectName,
54
+ base: values.base,
55
+ database: values.database,
56
+ orm: values.orm,
57
+ docker: values.docker ? true : values["no-docker"] ? false : undefined,
58
+ git: !values["no-git"],
59
+ install: !values["no-install"],
60
+ });
@@ -0,0 +1,149 @@
1
+ import { cpSync, existsSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type IntegrationManifest, loadManifest } from "./manifest.ts";
4
+
5
+ /** Copies the base template and personalizes it (name, .gitignore). */
6
+ export async function copyBase(
7
+ templatesDir: string,
8
+ dest: string,
9
+ projectName: string,
10
+ ): Promise<void> {
11
+ // The filter matters when running from the repo, where the template
12
+ // workspace has its own install and env; the published tarball ships none
13
+ // of these.
14
+ const skip = ["node_modules", "bun.lock", ".env"];
15
+ cpSync(join(templatesDir, "base"), dest, {
16
+ recursive: true,
17
+ filter: (source) => !skip.some((name) => source.endsWith(`/${name}`)),
18
+ });
19
+
20
+ // npm strips .gitignore from published tarballs, so the template ships
21
+ // it as _gitignore and it gets renamed here.
22
+ renameSync(join(dest, "_gitignore"), join(dest, ".gitignore"));
23
+
24
+ const packageJsonPath = join(dest, "package.json");
25
+ const packageJson = await Bun.file(packageJsonPath).json();
26
+ packageJson.name = projectName;
27
+ await writeJson(packageJsonPath, packageJson);
28
+
29
+ const readmePath = join(dest, "README.md");
30
+ const readme = await Bun.file(readmePath).text();
31
+ await Bun.write(readmePath, readme.replace(/^# .+/, `# ${projectName}`));
32
+ }
33
+
34
+ /** Copies an integration's files in and applies its manifest. */
35
+ export async function applyIntegration(
36
+ templatesDir: string,
37
+ dest: string,
38
+ name: string,
39
+ ): Promise<IntegrationManifest> {
40
+ const integrationDir = join(templatesDir, "integrations", name);
41
+ const manifest = await loadManifest(integrationDir);
42
+
43
+ const filesDir = join(integrationDir, "files");
44
+ if (existsSync(filesDir)) {
45
+ cpSync(filesDir, dest, { recursive: true });
46
+ }
47
+
48
+ await mergePackageJson(dest, manifest);
49
+ await appendEnv(dest, manifest.env ?? []);
50
+ for (const insert of manifest.inserts ?? []) {
51
+ await applyInsert(dest, insert.file, insert.anchor, insert.line);
52
+ }
53
+ if (manifest.readme) {
54
+ await appendReadme(dest, manifest.readme);
55
+ }
56
+ return manifest;
57
+ }
58
+
59
+ /** Appends an integration's docs to the generated README, once. */
60
+ async function appendReadme(dest: string, section: string): Promise<void> {
61
+ const path = join(dest, "README.md");
62
+ const current = await Bun.file(path).text();
63
+ const trimmed = section.trim();
64
+ if (current.includes(trimmed)) {
65
+ return;
66
+ }
67
+ await Bun.write(path, `${current.trimEnd()}\n\n${trimmed}\n`);
68
+ }
69
+
70
+ async function mergePackageJson(
71
+ dest: string,
72
+ manifest: IntegrationManifest,
73
+ ): Promise<void> {
74
+ const path = join(dest, "package.json");
75
+ const packageJson = await Bun.file(path).json();
76
+ packageJson.dependencies = sortKeys({
77
+ ...packageJson.dependencies,
78
+ ...manifest.dependencies,
79
+ });
80
+ packageJson.devDependencies = sortKeys({
81
+ ...packageJson.devDependencies,
82
+ ...manifest.devDependencies,
83
+ });
84
+ packageJson.scripts = { ...packageJson.scripts, ...manifest.scripts };
85
+ await writeJson(path, packageJson);
86
+ }
87
+
88
+ /** Adds env entries to .env.example, skipping keys that already exist. */
89
+ async function appendEnv(
90
+ dest: string,
91
+ entries: { key: string; value: string }[],
92
+ ): Promise<void> {
93
+ if (entries.length === 0) {
94
+ return;
95
+ }
96
+ const path = join(dest, ".env.example");
97
+ const current = await Bun.file(path).text();
98
+ const existingKeys = new Set(
99
+ current
100
+ .split("\n")
101
+ .map((line) => line.split("=")[0]?.trim())
102
+ .filter(Boolean),
103
+ );
104
+ const missing = entries.filter((entry) => !existingKeys.has(entry.key));
105
+ if (missing.length === 0) {
106
+ return;
107
+ }
108
+ const lines = missing
109
+ .map((entry) => `${entry.key}=${entry.value}`)
110
+ .join("\n");
111
+ const separator = current.endsWith("\n") ? "" : "\n";
112
+ await Bun.write(path, `${current}${separator}${lines}\n`);
113
+ }
114
+
115
+ /**
116
+ * Inserts a line directly above a known anchor comment in a base file.
117
+ * Applying the same insert twice is a no-op.
118
+ */
119
+ async function applyInsert(
120
+ dest: string,
121
+ file: string,
122
+ anchor: string,
123
+ line: string,
124
+ ): Promise<void> {
125
+ const path = join(dest, file);
126
+ const content = await Bun.file(path).text();
127
+ if (content.includes(line)) {
128
+ return;
129
+ }
130
+ const lines = content.split("\n");
131
+ const anchorIndex = lines.findIndex(
132
+ (candidate) => candidate.trim() === anchor.trim(),
133
+ );
134
+ if (anchorIndex === -1) {
135
+ throw new Error(`Anchor "${anchor}" not found in ${file}`);
136
+ }
137
+ lines.splice(anchorIndex, 0, line);
138
+ await Bun.write(path, lines.join("\n"));
139
+ }
140
+
141
+ function sortKeys(record: Record<string, string>): Record<string, string> {
142
+ return Object.fromEntries(
143
+ Object.entries(record).sort(([a], [b]) => a.localeCompare(b)),
144
+ );
145
+ }
146
+
147
+ async function writeJson(path: string, value: unknown): Promise<void> {
148
+ await Bun.write(path, `${JSON.stringify(value, null, 2)}\n`);
149
+ }
@@ -0,0 +1,36 @@
1
+ import { join } from "node:path";
2
+
3
+ export interface EnvEntry {
4
+ key: string;
5
+ value: string;
6
+ }
7
+
8
+ /** A line added to an existing base file, placed before a known anchor comment. */
9
+ export interface Insert {
10
+ file: string;
11
+ anchor: string;
12
+ line: string;
13
+ }
14
+
15
+ export interface IntegrationManifest {
16
+ name: string;
17
+ description: string;
18
+ dependencies?: Record<string, string>;
19
+ devDependencies?: Record<string, string>;
20
+ scripts?: Record<string, string>;
21
+ env?: EnvEntry[];
22
+ inserts?: Insert[];
23
+ /** Markdown appended to the generated project's README. */
24
+ readme?: string;
25
+ }
26
+
27
+ export async function loadManifest(
28
+ integrationDir: string,
29
+ ): Promise<IntegrationManifest> {
30
+ const path = join(integrationDir, "manifest.json");
31
+ const manifest = (await Bun.file(path).json()) as IntegrationManifest;
32
+ if (!manifest.name || !manifest.description) {
33
+ throw new Error(`Manifest at ${path} is missing name or description`);
34
+ }
35
+ return manifest;
36
+ }
package/cli/src/new.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { applyIntegration, copyBase } from "./apply.ts";
6
+ import {
7
+ type Answers,
8
+ type Database,
9
+ integrationsFor,
10
+ type Orm,
11
+ validateAnswers,
12
+ } from "./plan.ts";
13
+
14
+ export interface NewOptions {
15
+ projectName: string;
16
+ base: boolean;
17
+ database?: string;
18
+ orm?: string;
19
+ docker?: boolean;
20
+ git: boolean;
21
+ install: boolean;
22
+ }
23
+
24
+ export async function runNew(options: NewOptions): Promise<void> {
25
+ const dest = resolve(process.cwd(), options.projectName);
26
+ const templatesDir = resolve(import.meta.dir, "../../templates");
27
+
28
+ if (!/^[a-z0-9][a-z0-9-_]*$/i.test(options.projectName)) {
29
+ failInput(
30
+ `"${options.projectName}" is not a valid project name (letters, numbers, - and _).`,
31
+ );
32
+ }
33
+ if (existsSync(dest)) {
34
+ failInput(`${dest} already exists.`);
35
+ }
36
+
37
+ p.intro(pc.bold("bono"));
38
+
39
+ const answers = await collectAnswers(options);
40
+ const validationError = validateAnswers(answers);
41
+ if (validationError) {
42
+ failInput(validationError);
43
+ }
44
+
45
+ const spinner = p.spinner();
46
+ spinner.start("Copying base");
47
+ await copyBase(templatesDir, dest, options.projectName);
48
+ for (const name of integrationsFor(answers)) {
49
+ spinner.message(`Adding ${name}`);
50
+ await applyIntegration(templatesDir, dest, name);
51
+ }
52
+ spinner.stop("Project files written");
53
+
54
+ if (options.git) {
55
+ run(["git", "init", "-b", "main"], dest, "git init failed");
56
+ }
57
+ if (options.install) {
58
+ const installSpinner = p.spinner();
59
+ installSpinner.start("Installing dependencies");
60
+ run(["bun", "install"], dest, "bun install failed");
61
+ installSpinner.stop("Dependencies installed");
62
+ }
63
+
64
+ const nextSteps = [
65
+ `cd ${options.projectName}`,
66
+ ...(options.install ? [] : ["bun install"]),
67
+ "cp .env.example .env",
68
+ ...(answers.docker ? ["docker compose up -d"] : []),
69
+ "bun run dev",
70
+ ].join("\n");
71
+ p.note(nextSteps, "Next steps");
72
+ p.outro("Done.");
73
+ }
74
+
75
+ async function collectAnswers(options: NewOptions): Promise<Answers> {
76
+ // Flags answer prompts directly; --base or a non-interactive terminal
77
+ // answers everything not given with "none".
78
+ const skipPrompts = options.base || !process.stdout.isTTY;
79
+
80
+ const database =
81
+ (options.database as Database | undefined) ??
82
+ (skipPrompts ? "none" : await askDatabase());
83
+
84
+ if (database === "none") {
85
+ return { database, docker: false, orm: "none" };
86
+ }
87
+
88
+ const docker = options.docker ?? (skipPrompts ? false : await askDocker());
89
+
90
+ const orm =
91
+ (options.orm as Orm | undefined) ?? (skipPrompts ? "none" : await askOrm());
92
+
93
+ return { database, docker, orm };
94
+ }
95
+
96
+ async function askDatabase(): Promise<Database> {
97
+ const choice = await p.select({
98
+ message: "Database?",
99
+ options: [
100
+ {
101
+ value: "postgres" as const,
102
+ label: "Postgres",
103
+ hint: "Bun-native client, zero dependencies",
104
+ },
105
+ {
106
+ value: "none" as const,
107
+ label: "None",
108
+ hint: "set one up yourself later",
109
+ },
110
+ ],
111
+ });
112
+ return cancelled(choice);
113
+ }
114
+
115
+ async function askDocker(): Promise<boolean> {
116
+ const choice = await p.confirm({
117
+ message: "docker-compose for local Postgres?",
118
+ });
119
+ return cancelled(choice);
120
+ }
121
+
122
+ async function askOrm(): Promise<Orm> {
123
+ const choice = await p.select({
124
+ message: "ORM?",
125
+ options: [
126
+ {
127
+ value: "drizzle" as const,
128
+ label: "Drizzle",
129
+ hint: "schema, migrations, typed queries",
130
+ },
131
+ {
132
+ value: "none" as const,
133
+ label: "None",
134
+ hint: "raw SQL through the client",
135
+ },
136
+ ],
137
+ });
138
+ return cancelled(choice);
139
+ }
140
+
141
+ function cancelled<T>(value: T | symbol): T {
142
+ if (p.isCancel(value)) {
143
+ p.cancel("Cancelled, nothing was created.");
144
+ process.exit(1);
145
+ }
146
+ return value;
147
+ }
148
+
149
+ function run(command: string[], cwd: string, errorMessage: string): void {
150
+ const result = Bun.spawnSync(command, {
151
+ cwd,
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ });
155
+ if (result.exitCode !== 0) {
156
+ p.log.error(`${errorMessage}:\n${result.stderr.toString()}`);
157
+ process.exit(1);
158
+ }
159
+ }
160
+
161
+ // Input mistakes are not failures; keep them calm, not red.
162
+ function failInput(message: string): never {
163
+ p.log.warn(message);
164
+ process.exit(1);
165
+ }
@@ -0,0 +1,47 @@
1
+ export const DATABASES = ["postgres", "none"] as const;
2
+ export const ORMS = ["drizzle", "none"] as const;
3
+
4
+ export type Database = (typeof DATABASES)[number];
5
+ export type Orm = (typeof ORMS)[number];
6
+
7
+ export interface Answers {
8
+ database: Database;
9
+ /** docker-compose for local Postgres; only meaningful with database=postgres. */
10
+ docker: boolean;
11
+ orm: Orm;
12
+ }
13
+
14
+ /**
15
+ * Maps prompt answers to the integration folders to apply, in order.
16
+ * The whole CLI's decision logic lives here so it can be tested as data.
17
+ */
18
+ export function integrationsFor(answers: Answers): string[] {
19
+ if (answers.database === "none") {
20
+ return [];
21
+ }
22
+ const integrations = ["db-postgres"];
23
+ if (answers.docker) {
24
+ integrations.push("compose-postgres");
25
+ }
26
+ if (answers.orm === "drizzle") {
27
+ integrations.push("drizzle");
28
+ }
29
+ return integrations;
30
+ }
31
+
32
+ /** Rejects combinations that make no sense before any file is written. */
33
+ export function validateAnswers(answers: Answers): string | null {
34
+ if (!DATABASES.includes(answers.database)) {
35
+ return `Unknown database "${answers.database}". Expected: ${DATABASES.join(", ")}`;
36
+ }
37
+ if (!ORMS.includes(answers.orm)) {
38
+ return `Unknown ORM "${answers.orm}". Expected: ${ORMS.join(", ")}`;
39
+ }
40
+ if (answers.database === "none" && answers.orm !== "none") {
41
+ return "An ORM without a database makes no sense. Pick a database or drop the ORM.";
42
+ }
43
+ if (answers.database === "none" && answers.docker) {
44
+ return "docker-compose is only for the Postgres option.";
45
+ }
46
+ return null;
47
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "bono-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for scaffolding Hono APIs on Bun",
5
+ "license": "MIT",
6
+ "author": "Sillyfrogster (https://github.com/Sillyfrogster)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Sillyfrogster/bono.git"
10
+ },
11
+ "homepage": "https://github.com/Sillyfrogster/bono#readme",
12
+ "bugs": "https://github.com/Sillyfrogster/bono/issues",
13
+ "type": "module",
14
+ "bin": {
15
+ "bono": "cli/index.ts"
16
+ },
17
+ "files": [
18
+ "cli",
19
+ "templates",
20
+ "!cli/**/*.test.ts",
21
+ "!**/node_modules",
22
+ "!**/bun.lock",
23
+ "!**/.env"
24
+ ],
25
+ "scripts": {
26
+ "test": "bun test cli",
27
+ "check": "biome check .",
28
+ "typecheck": "tsc --noEmit",
29
+ "verify": "bun scripts/verify-combos.ts",
30
+ "prepublishOnly": "bun test cli && bun run verify"
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^0.11.0",
34
+ "picocolors": "^1.1.1"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "^2.0.0",
38
+ "@types/bun": "latest",
39
+ "typescript": "^5.8.0"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.2.0"
43
+ },
44
+ "keywords": [
45
+ "hono",
46
+ "bun",
47
+ "scaffold",
48
+ "cli",
49
+ "api"
50
+ ]
51
+ }
@@ -0,0 +1,3 @@
1
+ NODE_ENV=development
2
+ PORT=3000
3
+ LOG_LEVEL=info
@@ -0,0 +1,46 @@
1
+ # bono-app
2
+
3
+ Hono on Bun, scaffolded by [bono](https://www.npmjs.com/package/bono-cli).
4
+
5
+ ```sh
6
+ cp .env.example .env
7
+ bun run dev
8
+ ```
9
+
10
+ ## Scripts
11
+
12
+ | | |
13
+ | --- | --- |
14
+ | `bun run dev` | start with watch |
15
+ | `bun run start` | start |
16
+ | `bun test` | tests |
17
+ | `bun run check` | lint + format |
18
+ | `bun run typecheck` | tsc |
19
+
20
+ ## Structure
21
+
22
+ ```
23
+ src/
24
+ index.ts server start + shutdown
25
+ app.ts middleware + route mounting
26
+ config/env.ts env validation
27
+ lib/ logger, errors, validate helper
28
+ middleware/ request logging, rate limiting
29
+ features/ one folder per feature
30
+ ```
31
+
32
+ `features/todos` is the example. Copy the pattern, then delete it.
33
+
34
+ ## Errors
35
+
36
+ Everything error-shaped comes back as:
37
+
38
+ ```json
39
+ { "error": { "code": "TODO_NOT_FOUND", "message": "..." }, "requestId": "..." }
40
+ ```
41
+
42
+ Throw `AppError(status, code, message)` anywhere. Anything else thrown is a 500 with the details kept out of the response.
43
+
44
+ ## Rate limiting
45
+
46
+ In-memory, 100 req/min per IP. Implement `RateLimitStore` (`src/middleware/rate-limit.ts`) to back it with something shared.
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ .env
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
3
+ "formatter": {
4
+ "enabled": true,
5
+ "indentStyle": "space",
6
+ "indentWidth": 2
7
+ },
8
+ "linter": {
9
+ "enabled": true,
10
+ "rules": {
11
+ "preset": "recommended"
12
+ }
13
+ },
14
+ "javascript": {
15
+ "formatter": {
16
+ "quoteStyle": "double",
17
+ "semicolons": "always"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "bono-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "bun --watch src/index.ts",
8
+ "start": "bun src/index.ts",
9
+ "test": "bun test",
10
+ "check": "biome check .",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@hono/zod-validator": "^0.7.0",
15
+ "hono": "^4.8.0",
16
+ "pino": "^9.7.0",
17
+ "zod": "^4.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@biomejs/biome": "^2.0.0",
21
+ "@types/bun": "latest",
22
+ "pino-pretty": "^13.0.0",
23
+ "typescript": "^5.8.0"
24
+ }
25
+ }
@@ -0,0 +1,52 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { requestId } from "hono/request-id";
4
+ import { todosRoutes } from "./features/todos/todos.routes.ts";
5
+ import type { AppEnv } from "./lib/app-env.ts";
6
+ import { AppError, type ErrorBody } from "./lib/errors.ts";
7
+ import { logger } from "./lib/logger.ts";
8
+ import { rateLimit } from "./middleware/rate-limit.ts";
9
+ import { requestLogger } from "./middleware/request-logger.ts";
10
+
11
+ export const app = new Hono<AppEnv>();
12
+
13
+ app.use(requestId());
14
+ app.use(requestLogger());
15
+ app.use(cors());
16
+ app.use(rateLimit());
17
+ // bono:middleware
18
+
19
+ app.get("/health", (c) => c.json({ status: "ok" }));
20
+ app.route("/todos", todosRoutes);
21
+ // bono:routes
22
+
23
+ app.notFound((c) => {
24
+ const body: ErrorBody = {
25
+ error: {
26
+ code: "NOT_FOUND",
27
+ message: `No route for ${c.req.method} ${c.req.path}`,
28
+ },
29
+ requestId: c.get("requestId") ?? "",
30
+ };
31
+ return c.json(body, 404);
32
+ });
33
+
34
+ app.onError((err, c) => {
35
+ const requestId = c.get("requestId") ?? "";
36
+
37
+ if (err instanceof AppError) {
38
+ const body: ErrorBody = {
39
+ error: { code: err.code, message: err.message },
40
+ requestId,
41
+ };
42
+ return c.json(body, err.status);
43
+ }
44
+
45
+ // Unexpected errors: log everything, leak nothing.
46
+ logger.error({ requestId, err }, "unhandled error");
47
+ const body: ErrorBody = {
48
+ error: { code: "INTERNAL_ERROR", message: "Something went wrong" },
49
+ requestId,
50
+ };
51
+ return c.json(body, 500);
52
+ });
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ const envSchema = z.object({
4
+ NODE_ENV: z
5
+ .enum(["development", "test", "production"])
6
+ .default("development"),
7
+ PORT: z.coerce.number().int().positive().default(3000),
8
+ LOG_LEVEL: z
9
+ .enum(["fatal", "error", "warn", "info", "debug", "trace"])
10
+ .default("info"),
11
+ // bono:env
12
+ });
13
+
14
+ const parsed = envSchema.safeParse(process.env);
15
+
16
+ if (!parsed.success) {
17
+ // The logger depends on this file, so plain stderr is the only option here.
18
+ console.error("Invalid environment variables:");
19
+ for (const issue of parsed.error.issues) {
20
+ console.error(` ${issue.path.join(".")}: ${issue.message}`);
21
+ }
22
+ process.exit(1);
23
+ }
24
+
25
+ export const env = parsed.data;
@@ -0,0 +1,53 @@
1
+ import { Hono } from "hono";
2
+ import type { AppEnv } from "../../lib/app-env.ts";
3
+ import { AppError } from "../../lib/errors.ts";
4
+ import { validate } from "../../lib/validate.ts";
5
+ import {
6
+ createTodoSchema,
7
+ todoIdParamSchema,
8
+ updateTodoSchema,
9
+ } from "./todos.schema.ts";
10
+ import {
11
+ createTodo,
12
+ deleteTodo,
13
+ getTodo,
14
+ listTodos,
15
+ updateTodo,
16
+ } from "./todos.service.ts";
17
+
18
+ export const todosRoutes = new Hono<AppEnv>()
19
+ .get("/", (c) => {
20
+ return c.json(listTodos());
21
+ })
22
+ .get("/:id", validate("param", todoIdParamSchema), (c) => {
23
+ const { id } = c.req.valid("param");
24
+ const todo = getTodo(id);
25
+ if (!todo) {
26
+ throw new AppError(404, "TODO_NOT_FOUND", `No todo with id ${id}`);
27
+ }
28
+ return c.json(todo);
29
+ })
30
+ .post("/", validate("json", createTodoSchema), (c) => {
31
+ const todo = createTodo(c.req.valid("json"));
32
+ return c.json(todo, 201);
33
+ })
34
+ .patch(
35
+ "/:id",
36
+ validate("param", todoIdParamSchema),
37
+ validate("json", updateTodoSchema),
38
+ (c) => {
39
+ const { id } = c.req.valid("param");
40
+ const todo = updateTodo(id, c.req.valid("json"));
41
+ if (!todo) {
42
+ throw new AppError(404, "TODO_NOT_FOUND", `No todo with id ${id}`);
43
+ }
44
+ return c.json(todo);
45
+ },
46
+ )
47
+ .delete("/:id", validate("param", todoIdParamSchema), (c) => {
48
+ const { id } = c.req.valid("param");
49
+ if (!deleteTodo(id)) {
50
+ throw new AppError(404, "TODO_NOT_FOUND", `No todo with id ${id}`);
51
+ }
52
+ return c.body(null, 204);
53
+ });
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+
3
+ export const todoSchema = z.object({
4
+ id: z.uuid(),
5
+ title: z.string().min(1).max(200),
6
+ completed: z.boolean(),
7
+ createdAt: z.iso.datetime(),
8
+ });
9
+
10
+ export const createTodoSchema = z.object({
11
+ title: z.string().min(1).max(200),
12
+ });
13
+
14
+ export const updateTodoSchema = z
15
+ .object({
16
+ title: z.string().min(1).max(200).optional(),
17
+ completed: z.boolean().optional(),
18
+ })
19
+ .refine((body) => Object.keys(body).length > 0, {
20
+ message: "Provide at least one field to update",
21
+ });
22
+
23
+ export const todoIdParamSchema = z.object({
24
+ id: z.uuid(),
25
+ });
26
+
27
+ export type Todo = z.infer<typeof todoSchema>;
28
+ export type CreateTodo = z.infer<typeof createTodoSchema>;
29
+ export type UpdateTodo = z.infer<typeof updateTodoSchema>;
@@ -0,0 +1,36 @@
1
+ import type { CreateTodo, Todo, UpdateTodo } from "./todos.schema.ts";
2
+
3
+ const todos = new Map<string, Todo>();
4
+
5
+ export function listTodos(): Todo[] {
6
+ return [...todos.values()];
7
+ }
8
+
9
+ export function getTodo(id: string): Todo | undefined {
10
+ return todos.get(id);
11
+ }
12
+
13
+ export function createTodo(input: CreateTodo): Todo {
14
+ const todo: Todo = {
15
+ id: crypto.randomUUID(),
16
+ title: input.title,
17
+ completed: false,
18
+ createdAt: new Date().toISOString(),
19
+ };
20
+ todos.set(todo.id, todo);
21
+ return todo;
22
+ }
23
+
24
+ export function updateTodo(id: string, input: UpdateTodo): Todo | undefined {
25
+ const existing = todos.get(id);
26
+ if (!existing) {
27
+ return undefined;
28
+ }
29
+ const updated: Todo = { ...existing, ...input };
30
+ todos.set(id, updated);
31
+ return updated;
32
+ }
33
+
34
+ export function deleteTodo(id: string): boolean {
35
+ return todos.delete(id);
36
+ }
@@ -0,0 +1,19 @@
1
+ import { app } from "./app.ts";
2
+ import { env } from "./config/env.ts";
3
+ import { logger } from "./lib/logger.ts";
4
+
5
+ const server = Bun.serve({
6
+ port: env.PORT,
7
+ fetch: app.fetch,
8
+ });
9
+
10
+ logger.info({ port: env.PORT, env: env.NODE_ENV }, "server listening");
11
+
12
+ async function shutdown(signal: string): Promise<void> {
13
+ logger.info({ signal }, "shutting down");
14
+ await server.stop();
15
+ process.exit(0);
16
+ }
17
+
18
+ process.on("SIGINT", () => void shutdown("SIGINT"));
19
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
@@ -0,0 +1,6 @@
1
+ import type { RequestIdVariables } from "hono/request-id";
2
+
3
+ /** Hono env shared by the app and every route file. */
4
+ export type AppEnv = {
5
+ Variables: RequestIdVariables;
6
+ };
@@ -0,0 +1,26 @@
1
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
2
+
3
+ /**
4
+ * Throw this anywhere in a handler or service to produce a clean error
5
+ * response. Anything else that throws becomes a 500 with no leaked details.
6
+ */
7
+ export class AppError extends Error {
8
+ readonly status: ContentfulStatusCode;
9
+ readonly code: string;
10
+
11
+ constructor(status: ContentfulStatusCode, code: string, message: string) {
12
+ super(message);
13
+ this.status = status;
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ /** The one error response shape used everywhere. */
19
+ export interface ErrorBody {
20
+ error: {
21
+ code: string;
22
+ message: string;
23
+ details?: unknown;
24
+ };
25
+ requestId: string;
26
+ }
@@ -0,0 +1,17 @@
1
+ import pino from "pino";
2
+ import { env } from "../config/env.ts";
3
+
4
+ const usePretty = env.NODE_ENV === "development" && process.stdout.isTTY;
5
+
6
+ export const logger = pino(
7
+ // Silent during `bun test` unless LOG_LEVEL is set explicitly.
8
+ {
9
+ level:
10
+ env.NODE_ENV === "test" && !process.env.LOG_LEVEL
11
+ ? "silent"
12
+ : env.LOG_LEVEL,
13
+ },
14
+ usePretty
15
+ ? (await import("pino-pretty")).default({ colorize: true })
16
+ : undefined,
17
+ );
@@ -0,0 +1,29 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import type { ValidationTargets } from "hono";
3
+ import type { ZodType } from "zod";
4
+ import type { ErrorBody } from "./errors.ts";
5
+
6
+ /**
7
+ * zValidator wrapped so validation failures use the app's error shape
8
+ * instead of the library default. Use it exactly like zValidator:
9
+ *
10
+ * app.post("/", validate("json", createTodoSchema), handler)
11
+ */
12
+ export function validate<Schema extends ZodType, Target extends keyof ValidationTargets>(target: Target, schema: Schema) {
13
+ return zValidator(target, schema, (result, c) => {
14
+ if (!result.success) {
15
+ const body: ErrorBody = {
16
+ error: {
17
+ code: "VALIDATION_ERROR",
18
+ message: `Invalid ${target}`,
19
+ details: result.error.issues.map((issue) => ({
20
+ path: issue.path.join("."),
21
+ message: issue.message,
22
+ })),
23
+ },
24
+ requestId: c.get("requestId") ?? "",
25
+ };
26
+ return c.json(body, 400);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,100 @@
1
+ import type { Context } from "hono";
2
+ import { getConnInfo } from "hono/bun";
3
+ import { createMiddleware } from "hono/factory";
4
+ import type { ErrorBody } from "../lib/errors.ts";
5
+
6
+ /**
7
+ * Where hit counts live.
8
+ */
9
+ export interface RateLimitStore {
10
+ hit(
11
+ key: string,
12
+ windowMs: number,
13
+ ): Promise<{ count: number; resetAt: number }>;
14
+ }
15
+
16
+ export class MemoryRateLimitStore implements RateLimitStore {
17
+ private readonly windows = new Map<
18
+ string,
19
+ { count: number; resetAt: number }
20
+ >();
21
+
22
+ async hit(
23
+ key: string,
24
+ windowMs: number,
25
+ ): Promise<{ count: number; resetAt: number }> {
26
+ const now = Date.now();
27
+ const existing = this.windows.get(key);
28
+ if (existing && existing.resetAt > now) {
29
+ const updated = { count: existing.count + 1, resetAt: existing.resetAt };
30
+ this.windows.set(key, updated);
31
+ return updated;
32
+ }
33
+ this.sweep(now);
34
+ const fresh = { count: 1, resetAt: now + windowMs };
35
+ this.windows.set(key, fresh);
36
+ return fresh;
37
+ }
38
+
39
+ // Drops expired windows so the map doesn't grow forever.
40
+ private sweep(now: number): void {
41
+ for (const [key, window] of this.windows) {
42
+ if (window.resetAt <= now) {
43
+ this.windows.delete(key);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ const DEFAULT_WINDOW_MS = 60_000;
50
+ const DEFAULT_MAX_REQUESTS = 100;
51
+
52
+ export interface RateLimitOptions {
53
+ windowMs?: number;
54
+ max?: number;
55
+ store?: RateLimitStore;
56
+ }
57
+
58
+ export function rateLimit(options: RateLimitOptions = {}) {
59
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
60
+ const max = options.max ?? DEFAULT_MAX_REQUESTS;
61
+ const store = options.store ?? new MemoryRateLimitStore();
62
+
63
+ return createMiddleware(async (c, next) => {
64
+ const key = clientKey(c);
65
+ const { count, resetAt } = await store.hit(key, windowMs);
66
+ const secondsToReset = Math.max(
67
+ 0,
68
+ Math.ceil((resetAt - Date.now()) / 1000),
69
+ );
70
+
71
+ c.header("RateLimit-Limit", String(max));
72
+ c.header("RateLimit-Remaining", String(Math.max(0, max - count)));
73
+ c.header("RateLimit-Reset", String(secondsToReset));
74
+
75
+ if (count > max) {
76
+ const body: ErrorBody = {
77
+ error: {
78
+ code: "RATE_LIMITED",
79
+ message: "Too many requests, retry later",
80
+ },
81
+ requestId: c.get("requestId") ?? "",
82
+ };
83
+ return c.json(body, 429);
84
+ }
85
+ await next();
86
+ });
87
+ }
88
+
89
+ // Proxy header first, then the socket address. "unknown" only happens in tests.
90
+ function clientKey(c: Context): string {
91
+ const forwarded = c.req.header("x-forwarded-for");
92
+ if (forwarded) {
93
+ return forwarded.split(",")[0]?.trim() ?? "unknown";
94
+ }
95
+ try {
96
+ return getConnInfo(c).remote.address ?? "unknown";
97
+ } catch {
98
+ return "unknown";
99
+ }
100
+ }
@@ -0,0 +1,21 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import { logger } from "../lib/logger.ts";
3
+
4
+ /** One structured log line per request: method, path, status, duration, request ID. */
5
+ export function requestLogger() {
6
+ return createMiddleware(async (c, next) => {
7
+ const startedAt = performance.now();
8
+ await next();
9
+ const durationMs = Math.round(performance.now() - startedAt);
10
+ logger.info(
11
+ {
12
+ requestId: c.get("requestId"),
13
+ method: c.req.method,
14
+ path: c.req.path,
15
+ status: c.res.status,
16
+ durationMs,
17
+ },
18
+ "request",
19
+ );
20
+ });
21
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "Preserve",
5
+ "moduleResolution": "bundler",
6
+ "moduleDetection": "force",
7
+ "allowImportingTsExtensions": true,
8
+ "verbatimModuleSyntax": true,
9
+ "noEmit": true,
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noFallthroughCasesInSwitch": true,
13
+ "skipLibCheck": true,
14
+ "types": ["bun"]
15
+ },
16
+ "include": ["src"]
17
+ }
@@ -0,0 +1,15 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:17-alpine
4
+ restart: unless-stopped
5
+ environment:
6
+ POSTGRES_USER: postgres
7
+ POSTGRES_PASSWORD: postgres
8
+ POSTGRES_DB: app
9
+ ports:
10
+ - "5432:5432"
11
+ volumes:
12
+ - postgres-data:/var/lib/postgresql/data
13
+
14
+ volumes:
15
+ postgres-data:
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "compose-postgres",
3
+ "description": "docker-compose service for local Postgres",
4
+ "readme": "## Local Postgres\n\n```sh\ndocker compose up -d\n```\n\nCredentials match the default `DATABASE_URL`."
5
+ }
@@ -0,0 +1,7 @@
1
+ import { SQL } from "bun";
2
+ import { env } from "../config/env.ts";
3
+
4
+ /**
5
+ * Bun's native Postgres client.
6
+ */
7
+ export const sql = new SQL(env.DATABASE_URL);
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "db-postgres",
3
+ "description": "Postgres client using Bun's native SQL bindings (no dependencies)",
4
+ "env": [
5
+ {
6
+ "key": "DATABASE_URL",
7
+ "value": "postgres://postgres:postgres@localhost:5432/app"
8
+ }
9
+ ],
10
+ "inserts": [
11
+ {
12
+ "file": "src/config/env.ts",
13
+ "anchor": "// bono:env",
14
+ "line": " DATABASE_URL: z.url(),"
15
+ }
16
+ ],
17
+ "readme": "## Database\n\n`sql` from `src/db/client.ts` (Bun's native Postgres client, no driver dep). Tagged templates are parameterized:\n\n```ts\nconst rows = await sql`select * from todos where id = ${id}`;\n```\n\nConnection string: `DATABASE_URL` in `.env`."
18
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ dialect: "postgresql",
5
+ schema: "./src/db/schema",
6
+ out: "./drizzle",
7
+ dbCredentials: {
8
+ // biome-ignore lint/style/noNonNullAssertion: fails loudly at CLI time if unset
9
+ url: process.env.DATABASE_URL!,
10
+ },
11
+ });
@@ -0,0 +1,8 @@
1
+ import { drizzle } from "drizzle-orm/bun-sql";
2
+ import { sql } from "./client.ts";
3
+ import * as schema from "./schema/index.ts";
4
+
5
+ /**
6
+ * Query through Drizzle
7
+ */
8
+ export const db = drizzle({ client: sql, schema });
@@ -0,0 +1 @@
1
+ export * from "./todos.ts";
@@ -0,0 +1,10 @@
1
+ import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
2
+
3
+ // Example table mirroring the todos feature. Replace with your own tables;
4
+ // each table gets its own file, exported from schema/index.ts.
5
+ export const todos = pgTable("todos", {
6
+ id: uuid("id").primaryKey().defaultRandom(),
7
+ title: text("title").notNull(),
8
+ completed: boolean("completed").notNull().default(false),
9
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
10
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "drizzle",
3
+ "description": "Drizzle ORM over the Bun Postgres client, with drizzle-kit migrations",
4
+ "dependencies": {
5
+ "drizzle-orm": "^0.44.0"
6
+ },
7
+ "devDependencies": {
8
+ "drizzle-kit": "^0.31.0",
9
+ "pg": "^8.16.0"
10
+ },
11
+ "scripts": {
12
+ "db:generate": "drizzle-kit generate",
13
+ "db:migrate": "drizzle-kit migrate",
14
+ "db:push": "drizzle-kit push"
15
+ },
16
+ "readme": "## Drizzle\n\n`db` from `src/db/index.ts`. Tables go in `src/db/schema/`, one file per table, exported from `schema/index.ts`.\n\n```sh\nbun run db:generate # migration from schema changes\nbun run db:migrate # apply\n```\n\n`pg` is dev-only for drizzle-kit; the app uses Bun's native client."
17
+ }