create-svc 0.1.2 → 0.1.3

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 (69) hide show
  1. package/package.json +4 -1
  2. package/src/cli.ts +328 -108
  3. package/src/gcp.test.ts +71 -0
  4. package/src/gcp.ts +97 -0
  5. package/src/naming.test.ts +37 -0
  6. package/src/naming.ts +103 -0
  7. package/src/neon.test.ts +48 -0
  8. package/src/neon.ts +76 -0
  9. package/src/post-scaffold.ts +77 -0
  10. package/src/scaffold.test.ts +66 -31
  11. package/src/scaffold.ts +60 -55
  12. package/templates/shared/.github/workflows/ci.yml +22 -0
  13. package/templates/shared/.github/workflows/deploy.yml +30 -0
  14. package/templates/shared/.github/workflows/personal.yml +41 -0
  15. package/templates/shared/.github/workflows/preview-cleanup.yml +25 -0
  16. package/templates/shared/.github/workflows/preview.yml +29 -0
  17. package/templates/shared/README.md +37 -0
  18. package/templates/shared/scripts/cloudrun/bootstrap.ts +76 -0
  19. package/templates/shared/scripts/cloudrun/config.ts +57 -0
  20. package/templates/shared/scripts/cloudrun/deploy.ts +82 -0
  21. package/templates/shared/scripts/cloudrun/lib.ts +380 -0
  22. package/templates/shared/scripts/cloudrun/neon.ts +104 -0
  23. package/templates/shared/service.yaml +28 -0
  24. package/templates/variants/bun-connectrpc/Dockerfile +13 -0
  25. package/templates/variants/bun-connectrpc/package.json +20 -0
  26. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -0
  27. package/templates/variants/bun-connectrpc/src/index.ts +32 -0
  28. package/templates/variants/bun-connectrpc/test/app.test.ts +17 -0
  29. package/templates/variants/bun-connectrpc/tsconfig.json +10 -0
  30. package/templates/variants/bun-hono/Dockerfile +13 -0
  31. package/templates/variants/bun-hono/package.json +21 -0
  32. package/templates/variants/bun-hono/scripts/codegen.ts +1 -0
  33. package/templates/variants/bun-hono/src/index.ts +24 -0
  34. package/templates/variants/bun-hono/test/app.test.ts +12 -0
  35. package/templates/variants/bun-hono/tsconfig.json +10 -0
  36. package/templates/variants/go-chi/Dockerfile +23 -0
  37. package/templates/variants/go-chi/buf.gen.yaml +10 -0
  38. package/templates/variants/go-chi/buf.yaml +9 -0
  39. package/templates/variants/go-chi/cmd/server/main.go +52 -0
  40. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +623 -0
  41. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  42. package/templates/variants/go-chi/go.mod +10 -0
  43. package/templates/variants/go-chi/internal/app/service.go +109 -0
  44. package/templates/variants/go-chi/internal/app/token_source.go +50 -0
  45. package/templates/variants/go-chi/internal/cloudflare/client.go +160 -0
  46. package/templates/variants/go-chi/internal/config/config.go +23 -0
  47. package/templates/variants/go-chi/internal/connectapi/handler.go +79 -0
  48. package/templates/variants/go-chi/internal/httpapi/routes.go +93 -0
  49. package/templates/variants/go-chi/internal/vault/client.go +148 -0
  50. package/templates/variants/go-chi/package.json +16 -0
  51. package/templates/variants/go-chi/protos/dns/v1/dns.proto +58 -0
  52. package/templates/variants/go-chi/test/go.test.ts +19 -0
  53. package/templates/variants/go-connectrpc/Dockerfile +23 -0
  54. package/templates/variants/go-connectrpc/buf.gen.yaml +10 -0
  55. package/templates/variants/go-connectrpc/buf.yaml +9 -0
  56. package/templates/variants/go-connectrpc/cmd/server/main.go +51 -0
  57. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +623 -0
  58. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +192 -0
  59. package/templates/variants/go-connectrpc/go.mod +10 -0
  60. package/templates/variants/go-connectrpc/internal/app/service.go +109 -0
  61. package/templates/variants/go-connectrpc/internal/app/token_source.go +50 -0
  62. package/templates/variants/go-connectrpc/internal/cloudflare/client.go +160 -0
  63. package/templates/variants/go-connectrpc/internal/config/config.go +23 -0
  64. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +79 -0
  65. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +93 -0
  66. package/templates/variants/go-connectrpc/internal/vault/client.go +148 -0
  67. package/templates/variants/go-connectrpc/package.json +16 -0
  68. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +58 -0
  69. package/templates/variants/go-connectrpc/test/go.test.ts +19 -0
@@ -0,0 +1,380 @@
1
+ import { config } from "./config";
2
+
3
+ type CommandOptions = {
4
+ allowFailure?: boolean;
5
+ capture?: boolean;
6
+ input?: string;
7
+ };
8
+
9
+ type DeployArgs = {
10
+ ci: boolean;
11
+ destroy: boolean;
12
+ environment: "main" | "preview" | "personal";
13
+ name?: string;
14
+ };
15
+
16
+ type DeploymentTarget = {
17
+ environment: "main" | "preview" | "personal";
18
+ serviceName: string;
19
+ branchName: string;
20
+ databaseSecretName: string;
21
+ };
22
+
23
+ const decoder = new TextDecoder();
24
+
25
+ export function requireCommand(name: string) {
26
+ if (!Bun.which(name)) {
27
+ throw new Error(`missing required command: ${name}`);
28
+ }
29
+ }
30
+
31
+ export function run(command: string, args: string[], options: CommandOptions = {}) {
32
+ const result = Bun.spawnSync([command, ...args], {
33
+ cwd: process.cwd(),
34
+ env: process.env,
35
+ stdin: options.input,
36
+ stdout: options.capture || options.allowFailure ? "pipe" : "inherit",
37
+ stderr: options.capture || options.allowFailure ? "pipe" : "inherit",
38
+ });
39
+
40
+ const stdout = result.stdout ? decoder.decode(result.stdout).trim() : "";
41
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
42
+
43
+ if (!result.success && !options.allowFailure) {
44
+ throw new Error([`command failed: ${command} ${args.join(" ")}`, stdout, stderr].filter(Boolean).join("\n"));
45
+ }
46
+
47
+ return {
48
+ success: result.success,
49
+ stdout,
50
+ stderr,
51
+ exitCode: result.exitCode,
52
+ };
53
+ }
54
+
55
+ export function gcloud(args: string[], options: CommandOptions = {}) {
56
+ const normalized = [...args];
57
+ if (config.project.quotaProjectId && !normalized.includes("--billing-project")) {
58
+ normalized.push("--billing-project", config.project.quotaProjectId);
59
+ }
60
+ return run("gcloud", normalized, options);
61
+ }
62
+
63
+ export function gh(args: string[], options: CommandOptions = {}) {
64
+ return run("gh", args, options);
65
+ }
66
+
67
+ export function ensureProject() {
68
+ if (gcloud(["projects", "describe", config.project.id], { allowFailure: true }).success) {
69
+ return;
70
+ }
71
+
72
+ if (!config.project.createIfMissing) {
73
+ throw new Error(`GCP project ${config.project.id} does not exist and createIfMissing is false`);
74
+ }
75
+
76
+ gcloud(["projects", "create", config.project.id, "--name", config.project.name]);
77
+ }
78
+
79
+ export function attachBilling() {
80
+ gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
81
+ }
82
+
83
+ export function ensureServiceAccount(email: string) {
84
+ if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.project.id], { allowFailure: true }).success) {
85
+ return;
86
+ }
87
+
88
+ const accountId = email.split("@")[0] ?? email;
89
+ gcloud(["iam", "service-accounts", "create", accountId, "--project", config.project.id, "--display-name", accountId]);
90
+ }
91
+
92
+ export function ensureProjectRole(member: string, role: string) {
93
+ gcloud(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
94
+ }
95
+
96
+ export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
97
+ gcloud([
98
+ "iam",
99
+ "service-accounts",
100
+ "add-iam-policy-binding",
101
+ serviceAccount,
102
+ "--project",
103
+ config.project.id,
104
+ "--member",
105
+ member,
106
+ "--role",
107
+ role,
108
+ ]);
109
+ }
110
+
111
+ export function ensureSecret(secretName: string) {
112
+ if (gcloud(["secrets", "describe", secretName, "--project", config.project.id], { allowFailure: true }).success) {
113
+ return;
114
+ }
115
+
116
+ gcloud(["secrets", "create", secretName, "--project", config.project.id, "--replication-policy", "automatic"]);
117
+ }
118
+
119
+ export function addSecretVersion(secretName: string, value: string) {
120
+ ensureSecret(secretName);
121
+ gcloud(["secrets", "versions", "add", secretName, "--project", config.project.id, "--data-file=-"], { input: value });
122
+ }
123
+
124
+ export function ensureSecretAccessor(secretName: string, member: string) {
125
+ gcloud(["secrets", "add-iam-policy-binding", secretName, "--project", config.project.id, "--member", member, "--role", "roles/secretmanager.secretAccessor"]);
126
+ }
127
+
128
+ export function ensureArtifactRepository() {
129
+ if (
130
+ gcloud(
131
+ ["artifacts", "repositories", "describe", config.artifactRepository, "--project", config.project.id, "--location", config.region],
132
+ { allowFailure: true }
133
+ ).success
134
+ ) {
135
+ return;
136
+ }
137
+
138
+ gcloud([
139
+ "artifacts",
140
+ "repositories",
141
+ "create",
142
+ config.artifactRepository,
143
+ "--project",
144
+ config.project.id,
145
+ "--location",
146
+ config.region,
147
+ "--repository-format",
148
+ "docker",
149
+ ]);
150
+ }
151
+
152
+ export function projectNumber() {
153
+ return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"], { capture: true }).stdout;
154
+ }
155
+
156
+ export function workloadIdentityPoolResource() {
157
+ return `projects/${projectNumber()}/locations/global/workloadIdentityPools/${config.workloadIdentityPoolId}`;
158
+ }
159
+
160
+ export function workloadIdentityProviderResource() {
161
+ return `${workloadIdentityPoolResource()}/providers/${config.workloadIdentityProviderId}`;
162
+ }
163
+
164
+ export function ensureWorkloadIdentityPool() {
165
+ if (
166
+ gcloud(["iam", "workload-identity-pools", "describe", config.workloadIdentityPoolId, "--project", config.project.id, "--location", "global"], {
167
+ allowFailure: true,
168
+ }).success
169
+ ) {
170
+ return;
171
+ }
172
+
173
+ gcloud([
174
+ "iam",
175
+ "workload-identity-pools",
176
+ "create",
177
+ config.workloadIdentityPoolId,
178
+ "--project",
179
+ config.project.id,
180
+ "--location",
181
+ "global",
182
+ "--display-name",
183
+ "GitHub Actions",
184
+ ]);
185
+ }
186
+
187
+ export function ensureWorkloadIdentityProvider() {
188
+ if (
189
+ gcloud(
190
+ [
191
+ "iam",
192
+ "workload-identity-pools",
193
+ "providers",
194
+ "describe",
195
+ config.workloadIdentityProviderId,
196
+ "--project",
197
+ config.project.id,
198
+ "--location",
199
+ "global",
200
+ "--workload-identity-pool",
201
+ config.workloadIdentityPoolId,
202
+ ],
203
+ { allowFailure: true }
204
+ ).success
205
+ ) {
206
+ return;
207
+ }
208
+
209
+ gcloud([
210
+ "iam",
211
+ "workload-identity-pools",
212
+ "providers",
213
+ "create-oidc",
214
+ config.workloadIdentityProviderId,
215
+ "--project",
216
+ config.project.id,
217
+ "--location",
218
+ "global",
219
+ "--workload-identity-pool",
220
+ config.workloadIdentityPoolId,
221
+ "--display-name",
222
+ `${config.serviceName} GitHub`,
223
+ "--issuer-uri",
224
+ "https://token.actions.githubusercontent.com",
225
+ "--attribute-mapping",
226
+ "google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner",
227
+ "--attribute-condition",
228
+ `assertion.repository=='${config.github.repo}'`,
229
+ ]);
230
+ }
231
+
232
+ export function setGithubVariable(name: string, value: string) {
233
+ gh(["variable", "set", name, "--repo", config.github.repo, "--body", value]);
234
+ }
235
+
236
+ export function imageTag() {
237
+ const gitSha = run("git", ["rev-parse", "--short", "HEAD"], { allowFailure: true, capture: true }).stdout;
238
+ return gitSha || `${Date.now()}`;
239
+ }
240
+
241
+ export function imageUrl(tag = imageTag()) {
242
+ return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}:${tag}`;
243
+ }
244
+
245
+ export function parseDeployArgs(argv: string[]): DeployArgs {
246
+ const parsed: DeployArgs = {
247
+ ci: false,
248
+ destroy: false,
249
+ environment: "main",
250
+ };
251
+
252
+ for (let i = 0; i < argv.length; i += 1) {
253
+ const token = argv[i];
254
+ if (!token) {
255
+ continue;
256
+ }
257
+
258
+ const next = argv[i + 1];
259
+ const readValue = () => {
260
+ if (!next || next.startsWith("-")) {
261
+ throw new Error(`Missing value for ${token}`);
262
+ }
263
+ i += 1;
264
+ return next;
265
+ };
266
+
267
+ if (token === "--ci") {
268
+ parsed.ci = true;
269
+ continue;
270
+ }
271
+
272
+ if (token === "--destroy") {
273
+ parsed.destroy = true;
274
+ continue;
275
+ }
276
+
277
+ if (token === "--environment") {
278
+ parsed.environment = readValue() as DeployArgs["environment"];
279
+ continue;
280
+ }
281
+
282
+ if (token.startsWith("--environment=")) {
283
+ parsed.environment = token.slice("--environment=".length) as DeployArgs["environment"];
284
+ continue;
285
+ }
286
+
287
+ if (token === "--name") {
288
+ parsed.name = readValue();
289
+ continue;
290
+ }
291
+
292
+ if (token.startsWith("--name=")) {
293
+ parsed.name = token.slice("--name=".length);
294
+ continue;
295
+ }
296
+ }
297
+
298
+ return parsed;
299
+ }
300
+
301
+ export function resolveDeploymentTarget(environment: DeployArgs["environment"], rawName?: string): DeploymentTarget {
302
+ if (environment === "main") {
303
+ return {
304
+ environment,
305
+ serviceName: config.serviceName,
306
+ branchName: config.neon.baseBranchName,
307
+ databaseSecretName: `${config.serviceName}-database-url`,
308
+ };
309
+ }
310
+
311
+ const slug = slugify(rawName || "");
312
+ if (!slug) {
313
+ throw new Error(`A name is required for ${environment} deployments`);
314
+ }
315
+
316
+ if (environment === "preview") {
317
+ return {
318
+ environment,
319
+ serviceName: `${config.serviceName}-pr-${slug}`,
320
+ branchName: `${config.neon.previewBranchPrefix}-${slug}`,
321
+ databaseSecretName: `${config.serviceName}-pr-${slug}-database-url`,
322
+ };
323
+ }
324
+
325
+ return {
326
+ environment,
327
+ serviceName: `${config.serviceName}-dev-${slug}`,
328
+ branchName: `${config.neon.personalBranchPrefix}-${slug}`,
329
+ databaseSecretName: `${config.serviceName}-dev-${slug}-database-url`,
330
+ };
331
+ }
332
+
333
+ export async function renderManifest(image: string, target: DeploymentTarget) {
334
+ const template = await Bun.file(new URL("../../service.yaml", import.meta.url)).text();
335
+ const values = {
336
+ SERVICE_NAME: target.serviceName,
337
+ RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
338
+ IMAGE_URL: image,
339
+ DATABASE_URL_SECRET: target.databaseSecretName,
340
+ SERVICE_RUNTIME: config.runtime,
341
+ SERVICE_FRAMEWORK: config.framework,
342
+ };
343
+
344
+ return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key: string) => {
345
+ const value = values[key as keyof typeof values];
346
+ if (!value) {
347
+ throw new Error(`missing manifest value for ${key}`);
348
+ }
349
+ return value;
350
+ });
351
+ }
352
+
353
+ export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
354
+ const rendered = await renderManifest(image, target);
355
+ const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
356
+ await Bun.write(path, rendered);
357
+ return path;
358
+ }
359
+
360
+ export function serviceUrl(serviceName: string) {
361
+ return gcloud(
362
+ ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"],
363
+ { capture: true }
364
+ ).stdout;
365
+ }
366
+
367
+ export function deleteService(serviceName: string) {
368
+ gcloud(["run", "services", "delete", serviceName, "--project", config.project.id, "--region", config.region, "--quiet"], {
369
+ allowFailure: true,
370
+ });
371
+ }
372
+
373
+ function slugify(value: string) {
374
+ return value
375
+ .trim()
376
+ .toLowerCase()
377
+ .replace(/[^a-z0-9]+/g, "-")
378
+ .replace(/^-+|-+$/g, "");
379
+ }
380
+
@@ -0,0 +1,104 @@
1
+ import { createApiClient } from "@neondatabase/api-client";
2
+ import { config } from "./config";
3
+
4
+ type NeonBranch = {
5
+ id: string;
6
+ name: string;
7
+ };
8
+
9
+ function neonClient() {
10
+ const apiKey = process.env.NEON_API_KEY?.trim();
11
+ if (!apiKey) {
12
+ throw new Error("NEON_API_KEY is required for Neon provisioning");
13
+ }
14
+
15
+ return createApiClient({ apiKey });
16
+ }
17
+
18
+ export async function listBranches(projectId: string) {
19
+ const payload = await neonClient().listProjectBranches({ projectId });
20
+ return (payload.branches ?? [])
21
+ .map((branch) => ({
22
+ id: branch.id ?? "",
23
+ name: branch.name ?? branch.id ?? "",
24
+ }))
25
+ .filter((branch): branch is NeonBranch => Boolean(branch.id))
26
+ .sort((left, right) => left.name.localeCompare(right.name));
27
+ }
28
+
29
+ export async function ensureDatabase(projectId: string, branchId: string, databaseName: string) {
30
+ const client = neonClient();
31
+
32
+ try {
33
+ await client.getProjectBranchDatabase(projectId, branchId, databaseName);
34
+ return;
35
+ } catch (error) {
36
+ const status = (error as { response?: { status?: number } })?.response?.status;
37
+ if (status !== 404) {
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ await client.createProjectBranchDatabase(projectId, branchId, {
43
+ database: {
44
+ name: databaseName,
45
+ },
46
+ });
47
+ }
48
+
49
+ export async function ensureBranch(projectId: string, branchName: string, parentId: string) {
50
+ const existing = (await listBranches(projectId)).find((branch) => branch.name === branchName);
51
+ if (existing) {
52
+ return existing;
53
+ }
54
+
55
+ const payload = await neonClient().createProjectBranch(projectId, {
56
+ branch: {
57
+ name: branchName,
58
+ parent_id: parentId,
59
+ },
60
+ endpoints: [
61
+ {
62
+ type: "read_write",
63
+ },
64
+ ],
65
+ });
66
+
67
+ const branch = payload.branch;
68
+ if (!branch?.id) {
69
+ throw new Error(`Neon did not return a branch for ${branchName}`);
70
+ }
71
+
72
+ return {
73
+ id: branch.id,
74
+ name: branch.name ?? branch.id,
75
+ };
76
+ }
77
+
78
+ export async function deleteBranch(projectId: string, branchId: string) {
79
+ try {
80
+ await neonClient().deleteProjectBranch(projectId, branchId);
81
+ } catch (error) {
82
+ const status = (error as { response?: { status?: number } })?.response?.status;
83
+ if (status === 404) {
84
+ return;
85
+ }
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ export async function getConnectionUri(projectId: string, branchId: string, databaseName: string, roleName: string) {
91
+ const payload = await neonClient().getConnectionUri({
92
+ projectId,
93
+ branchId,
94
+ databaseName,
95
+ roleName,
96
+ });
97
+
98
+ const uri = payload.uri;
99
+ if (!uri) {
100
+ throw new Error(`Neon did not return a connection URI for ${databaseName} in ${config.serviceName}`);
101
+ }
102
+
103
+ return uri;
104
+ }
@@ -0,0 +1,28 @@
1
+ apiVersion: serving.knative.dev/v1
2
+ kind: Service
3
+ metadata:
4
+ name: ${SERVICE_NAME}
5
+ annotations:
6
+ run.googleapis.com/ingress: all
7
+ spec:
8
+ template:
9
+ spec:
10
+ serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
11
+ containers:
12
+ - image: ${IMAGE_URL}
13
+ ports:
14
+ - containerPort: 8080
15
+ env:
16
+ - name: APP_RUNTIME
17
+ value: ${SERVICE_RUNTIME}
18
+ - name: APP_FRAMEWORK
19
+ value: ${SERVICE_FRAMEWORK}
20
+ - name: DATABASE_URL
21
+ valueFrom:
22
+ secretKeyRef:
23
+ name: ${DATABASE_URL_SECRET}
24
+ key: latest
25
+ traffic:
26
+ - latestRevision: true
27
+ percent: 100
28
+
@@ -0,0 +1,13 @@
1
+ FROM oven/bun:1.2.15
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ RUN bun install --production
7
+
8
+ COPY src ./src
9
+
10
+ ENV PORT=8080
11
+ EXPOSE 8080
12
+
13
+ ENTRYPOINT ["bun", "run", "./src/index.ts"]
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{SERVICE_NAME}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun run ./src/index.ts",
7
+ "gen": "bun run ./scripts/codegen.ts",
8
+ "lint": "bunx tsc --noEmit",
9
+ "test": "bun test",
10
+ "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
11
+ "deploy": "bun run ./scripts/cloudrun/deploy.ts"
12
+ },
13
+ "dependencies": {
14
+ "@neondatabase/api-client": "^2.7.1"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "latest",
18
+ "typescript": "^5.9.3"
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ console.log("The Bun ConnectRPC scaffold ships with hand-written route wiring.");
@@ -0,0 +1,32 @@
1
+ type ConnectResponse = {
2
+ message: string;
3
+ };
4
+
5
+ export async function handleRequest(request: Request) {
6
+ const url = new URL(request.url);
7
+
8
+ if (url.pathname === "/healthz") {
9
+ return Response.json({ status: "ok", runtime: "bun", framework: "connectrpc" });
10
+ }
11
+
12
+ if (url.pathname === "/rpc.example.v1.Service/Ping" && request.method === "POST") {
13
+ const payload = (await request.json().catch(() => ({}))) as { name?: string };
14
+ const body: ConnectResponse = {
15
+ message: `hello ${payload.name?.trim() || "{{SERVICE_NAME}}"}`,
16
+ };
17
+ return Response.json(body, {
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ },
21
+ });
22
+ }
23
+
24
+ return Response.json({ error: "not found" }, { status: 404 });
25
+ }
26
+
27
+ if (import.meta.main) {
28
+ Bun.serve({
29
+ port: Number(Bun.env.PORT ?? 8080),
30
+ fetch: handleRequest,
31
+ });
32
+ }
@@ -0,0 +1,17 @@
1
+ import { expect, test } from "bun:test";
2
+ import { handleRequest } from "../src/index";
3
+
4
+ test("connect-style ping route responds", async () => {
5
+ const response = await handleRequest(
6
+ new Request("http://localhost/rpc.example.v1.Service/Ping", {
7
+ method: "POST",
8
+ body: JSON.stringify({ name: "preview" }),
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ },
12
+ })
13
+ );
14
+
15
+ expect(response.status).toBe(200);
16
+ expect(await response.json()).toEqual({ message: "hello preview" });
17
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "types": ["bun"]
8
+ },
9
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
10
+ }
@@ -0,0 +1,13 @@
1
+ FROM oven/bun:1.2.15
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ RUN bun install --production
7
+
8
+ COPY src ./src
9
+
10
+ ENV PORT=8080
11
+ EXPOSE 8080
12
+
13
+ ENTRYPOINT ["bun", "run", "./src/index.ts"]
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "{{SERVICE_NAME}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun run ./src/index.ts",
7
+ "gen": "bun run ./scripts/codegen.ts",
8
+ "lint": "bunx tsc --noEmit",
9
+ "test": "bun test",
10
+ "bootstrap": "bun run ./scripts/cloudrun/bootstrap.ts",
11
+ "deploy": "bun run ./scripts/cloudrun/deploy.ts"
12
+ },
13
+ "dependencies": {
14
+ "@neondatabase/api-client": "^2.7.1",
15
+ "hono": "^4.10.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "latest",
19
+ "typescript": "^5.9.3"
20
+ }
21
+ }
@@ -0,0 +1 @@
1
+ console.log("No code generation required for the Hono scaffold.");
@@ -0,0 +1,24 @@
1
+ import { Hono } from "hono";
2
+
3
+ export function createApp() {
4
+ const app = new Hono();
5
+
6
+ app.get("/healthz", (context) => context.json({ status: "ok", runtime: "bun", framework: "hono" }));
7
+ app.get("/", (context) => {
8
+ const databaseConfigured = Boolean(Bun.env.DATABASE_URL?.trim());
9
+ return context.json({
10
+ service: "{{SERVICE_NAME}}",
11
+ databaseConfigured,
12
+ });
13
+ });
14
+
15
+ return app;
16
+ }
17
+
18
+ if (import.meta.main) {
19
+ const app = createApp();
20
+ Bun.serve({
21
+ port: Number(Bun.env.PORT ?? 8080),
22
+ fetch: app.fetch,
23
+ });
24
+ }
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createApp } from "../src/index";
3
+
4
+ test("health endpoint returns ok", async () => {
5
+ const response = await createApp().request("/healthz");
6
+ expect(response.status).toBe(200);
7
+ expect(await response.json()).toEqual({
8
+ status: "ok",
9
+ runtime: "bun",
10
+ framework: "hono",
11
+ });
12
+ });