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.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/cli/index.ts +60 -0
- package/cli/src/apply.ts +149 -0
- package/cli/src/manifest.ts +36 -0
- package/cli/src/new.ts +165 -0
- package/cli/src/plan.ts +47 -0
- package/package.json +51 -0
- package/templates/base/.env.example +3 -0
- package/templates/base/README.md +46 -0
- package/templates/base/_gitignore +2 -0
- package/templates/base/biome.json +20 -0
- package/templates/base/package.json +25 -0
- package/templates/base/src/app.ts +52 -0
- package/templates/base/src/config/env.ts +25 -0
- package/templates/base/src/features/todos/todos.routes.ts +53 -0
- package/templates/base/src/features/todos/todos.schema.ts +29 -0
- package/templates/base/src/features/todos/todos.service.ts +36 -0
- package/templates/base/src/index.ts +19 -0
- package/templates/base/src/lib/app-env.ts +6 -0
- package/templates/base/src/lib/errors.ts +26 -0
- package/templates/base/src/lib/logger.ts +17 -0
- package/templates/base/src/lib/validate.ts +29 -0
- package/templates/base/src/middleware/rate-limit.ts +100 -0
- package/templates/base/src/middleware/request-logger.ts +21 -0
- package/templates/base/tsconfig.json +17 -0
- package/templates/integrations/compose-postgres/files/docker-compose.yml +15 -0
- package/templates/integrations/compose-postgres/manifest.json +5 -0
- package/templates/integrations/db-postgres/files/src/db/client.ts +7 -0
- package/templates/integrations/db-postgres/manifest.json +18 -0
- package/templates/integrations/drizzle/files/drizzle.config.ts +11 -0
- package/templates/integrations/drizzle/files/src/db/index.ts +8 -0
- package/templates/integrations/drizzle/files/src/db/schema/index.ts +1 -0
- package/templates/integrations/drizzle/files/src/db/schema/todos.ts +10 -0
- 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
|
+
[](https://www.npmjs.com/package/bono-cli)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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
|
+
});
|
package/cli/src/apply.ts
ADDED
|
@@ -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
|
+
}
|
package/cli/src/plan.ts
ADDED
|
@@ -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,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,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,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,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 @@
|
|
|
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
|
+
}
|