create-svc 0.1.62 → 0.1.63

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 (63) hide show
  1. package/README.md +9 -6
  2. package/package.json +2 -1
  3. package/src/cli.test.ts +1 -0
  4. package/src/cli.ts +17 -6
  5. package/src/naming.test.ts +6 -0
  6. package/src/naming.ts +1 -1
  7. package/src/scaffold.test.ts +19 -3
  8. package/src/scaffold.ts +10 -0
  9. package/src/service-runtime/authctl.ts +32 -0
  10. package/src/service-runtime/cloudrun/cleanup.ts +6 -1
  11. package/src/service-runtime/cloudrun/cli.ts +52 -12
  12. package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
  13. package/src/service-runtime/cloudrun/deploy.ts +25 -0
  14. package/src/service-runtime/cloudrun/lib.test.ts +12 -1
  15. package/src/service-runtime/cloudrun/lib.ts +26 -3
  16. package/src/service-runtime/workers/cli.ts +88 -0
  17. package/src/service.test.ts +15 -2
  18. package/src/service.ts +31 -1
  19. package/templates/shared/.env.example +1 -1
  20. package/templates/shared/README.md +41 -9
  21. package/templates/shared/scripts/dev.ts +37 -5
  22. package/templates/shared/service.jsonc +8 -2
  23. package/templates/shared/service.yaml +4 -1
  24. package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
  25. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  26. package/templates/targets/workers/README.md +28 -0
  27. package/templates/targets/workers/package.json +6 -1
  28. package/templates/targets/workers/src/index.ts +36 -25
  29. package/templates/targets/workers/src/trigger.ts +81 -0
  30. package/templates/targets/workers/test/app.test.ts +46 -1
  31. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  32. package/templates/targets/workers/trigger.config.ts +24 -0
  33. package/templates/targets/workers/tsconfig.json +1 -1
  34. package/templates/targets/workers/wrangler.toml +2 -0
  35. package/templates/variants/bun-connectrpc/package.json +1 -1
  36. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  37. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  38. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  39. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  40. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  41. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  42. package/templates/variants/bun-hono/package.json +1 -1
  43. package/templates/variants/bun-hono/src/index.ts +2 -6
  44. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  45. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  46. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  47. package/templates/variants/bun-hono/src/worker.ts +21 -0
  48. package/templates/variants/go-chi/Dockerfile +2 -0
  49. package/templates/variants/go-chi/Makefile +1 -1
  50. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  51. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  52. package/templates/variants/go-chi/internal/app/service.go +35 -3
  53. package/templates/variants/go-chi/internal/config/config.go +34 -3
  54. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  55. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  56. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  57. package/templates/variants/go-connectrpc/Makefile +1 -1
  58. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  59. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  60. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  61. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  62. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  63. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
@@ -15,6 +15,12 @@ const config = {
15
15
  hostname: serviceConfig.dns.hostname,
16
16
  neonDatabaseName: serviceConfig.neon.database_name,
17
17
  neonRoleName: serviceConfig.neon.role_name,
18
+ triggerDev: {
19
+ projectRefEnv: serviceConfig.workers?.trigger_dev?.project_ref_env || "TRIGGER_PROJECT_REF",
20
+ accessTokenEnv: serviceConfig.workers?.trigger_dev?.access_token_env || "TRIGGER_ACCESS_TOKEN",
21
+ secretKeyEnv: serviceConfig.workers?.trigger_dev?.secret_key_env || "TRIGGER_SECRET_KEY",
22
+ waitlistTaskId: serviceConfig.workers?.trigger_dev?.waitlist_task_id || `${serviceConfig.service_id}-waitlist-follow-up`,
23
+ },
18
24
  git: {
19
25
  enabled: Boolean(serviceConfig.git?.enabled),
20
26
  owner: serviceConfig.git?.owner || "anmho",
@@ -35,18 +41,28 @@ export async function main(argv = Bun.argv.slice(2)) {
35
41
 
36
42
  if (command === "create") {
37
43
  return runMain("Create", async () => {
44
+ ensureTriggerDevConfig();
38
45
  ensureAuthResourceServer();
39
46
  ensureAuthClient();
40
47
  const databaseUrl = await resolveDatabaseUrl({ preferRemote: true });
41
48
  await applySchemaWithRetries(databaseUrl);
42
49
  await ensureHyperdrive(databaseUrl);
50
+ deployTriggerDevTasks();
51
+ publishTriggerDevSecret();
43
52
  run("wrangler", ["deploy"]);
44
53
  return `Created https://${config.hostname}`;
45
54
  });
46
55
  }
47
56
 
48
57
  if (command === "deploy") {
58
+ if (hasHelpFlag(rest)) {
59
+ console.log(formatHelp());
60
+ return;
61
+ }
49
62
  return runMain("Deploy", () => {
63
+ ensureTriggerDevConfig();
64
+ deployTriggerDevTasks();
65
+ publishTriggerDevSecret();
50
66
  run("wrangler", ["deploy", ...rest]);
51
67
  return `Deployed https://${config.hostname}`;
52
68
  });
@@ -148,6 +164,10 @@ export async function main(argv = Bun.argv.slice(2)) {
148
164
  throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
149
165
  }
150
166
 
167
+ function hasHelpFlag(args: string[]) {
168
+ return args.includes("--help") || args.includes("-h") || args.includes("help");
169
+ }
170
+
151
171
  function formatHelp() {
152
172
  return [
153
173
  "Usage:",
@@ -507,6 +527,11 @@ async function runDoctor() {
507
527
  return "dashboard directory found";
508
528
  });
509
529
  await record(results, "authctl", "warn", () => runAuthDoctor().detail);
530
+ await record(results, "Trigger.dev config", "fail", () => {
531
+ ensureTriggerDevConfig();
532
+ return `${config.triggerDev.waitlistTaskId} configured`;
533
+ });
534
+ await record(results, "Trigger.dev CLI", "warn", () => checkCommand("trigger"));
510
535
  await record(results, "deployed health", "warn", async () => {
511
536
  const response = await fetch(`https://${config.hostname}/healthz`, { signal: AbortSignal.timeout(5_000) });
512
537
  if (!response.ok) {
@@ -523,6 +548,69 @@ async function runDoctor() {
523
548
  return output;
524
549
  }
525
550
 
551
+ function ensureTriggerDevConfig() {
552
+ const missing = [];
553
+ if (!process.env[config.triggerDev.projectRefEnv]?.trim()) {
554
+ missing.push(config.triggerDev.projectRefEnv);
555
+ }
556
+ if (!process.env[config.triggerDev.accessTokenEnv]?.trim()) {
557
+ missing.push(config.triggerDev.accessTokenEnv);
558
+ }
559
+ if (!process.env[config.triggerDev.secretKeyEnv]?.trim()) {
560
+ missing.push(config.triggerDev.secretKeyEnv);
561
+ }
562
+ if (missing.length > 0) {
563
+ throw new Error(`${formatList(missing)} required for Workers Trigger.dev task deployment and dispatch`);
564
+ }
565
+ }
566
+
567
+ function formatList(values: string[]) {
568
+ if (values.length <= 1) {
569
+ return values[0] ?? "";
570
+ }
571
+ if (values.length === 2) {
572
+ return values.join(" and ");
573
+ }
574
+ return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
575
+ }
576
+
577
+ function deployTriggerDevTasks() {
578
+ run("trigger", ["deploy", "--project-ref", process.env[config.triggerDev.projectRefEnv]?.trim() || ""]);
579
+ }
580
+
581
+ function publishTriggerDevSecret() {
582
+ const secret = process.env[config.triggerDev.secretKeyEnv]?.trim();
583
+ if (!secret) {
584
+ throw new Error(`${config.triggerDev.secretKeyEnv} required to publish the Workers Trigger.dev secret`);
585
+ }
586
+ const wrangler = resolveCommandPath("wrangler");
587
+ if (!wrangler) {
588
+ throw new Error("missing required command: wrangler");
589
+ }
590
+ runShell(`printf %s "$${config.triggerDev.secretKeyEnv}" | ${shellQuote(wrangler)} secret put TRIGGER_SECRET_KEY --name ${shellQuote(config.serviceName)}`);
591
+ }
592
+
593
+ function runShell(script: string) {
594
+ const shell = Bun.which("sh");
595
+ if (!shell) {
596
+ throw new Error("missing required command: sh");
597
+ }
598
+ const result = Bun.spawnSync([shell, "-c", script], {
599
+ cwd: process.cwd(),
600
+ env: process.env,
601
+ stdin: "inherit",
602
+ stdout: "inherit",
603
+ stderr: "inherit",
604
+ });
605
+ if (!result.success) {
606
+ throw new Error(`shell command failed with exit code ${result.exitCode}: ${script}`);
607
+ }
608
+ }
609
+
610
+ function shellQuote(value: string) {
611
+ return `'${value.replace(/'/g, `'\\''`)}'`;
612
+ }
613
+
526
614
  async function record(
527
615
  results: Array<{ name: string; status: DoctorStatus; detail: string }>,
528
616
  name: string,
@@ -2,7 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { findGeneratedServiceRoot, formatOutsideServiceCommandError, generatedDependenciesInstalled, normalizeScaffoldArgs } from "./service";
5
+ import {
6
+ findGeneratedServiceRoot,
7
+ formatOutsideServiceCommandError,
8
+ generatedDependenciesInstalled,
9
+ generatedServiceCommandHelp,
10
+ normalizeScaffoldArgs,
11
+ } from "./service";
6
12
 
7
13
  test("normalizeScaffoldArgs treats explicit scaffold commands as generator commands", () => {
8
14
  expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
@@ -23,7 +29,7 @@ test("formatOutsideServiceCommandError rejects repo-local commands outside gener
23
29
  test("formatOutsideServiceCommandError does not treat positional names as scaffold commands", () => {
24
30
  const message = formatOutsideServiceCommandError("launch-api");
25
31
  expect(message).toContain("Unknown command: launch-api");
26
- expect(message).toContain("service create <service_id>");
32
+ expect(message).toContain("service new <service_id>");
27
33
  });
28
34
 
29
35
  test("findGeneratedServiceRoot detects generated service context from nested directories", async () => {
@@ -47,3 +53,10 @@ test("generatedDependenciesInstalled requires node_modules when package.json exi
47
53
  await mkdir(join(root, "node_modules"));
48
54
  expect(generatedDependenciesInstalled(root)).toBeTrue();
49
55
  });
56
+
57
+ test("generatedServiceCommandHelp intercepts deploy help before side effects", () => {
58
+ expect(generatedServiceCommandHelp(["deploy", "--help"])).toContain("service deploy");
59
+ expect(generatedServiceCommandHelp(["deploy", "-h"])).toContain("--environment");
60
+ expect(generatedServiceCommandHelp(["deploy"])).toBeUndefined();
61
+ expect(generatedServiceCommandHelp(["destroy", "--help"])).toBeUndefined();
62
+ });
package/src/service.ts CHANGED
@@ -58,7 +58,7 @@ export function formatOutsideServiceCommandError(command: string) {
58
58
  "",
59
59
  "No service.jsonc was found in this directory or its parents.",
60
60
  "To create a new service, run:",
61
- " service create <service_id>",
61
+ " service new <service_id>",
62
62
  ].join("\n");
63
63
  }
64
64
 
@@ -85,6 +85,12 @@ function isGeneratedServiceRoot(path: string) {
85
85
  }
86
86
 
87
87
  async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
88
+ const commandHelp = generatedServiceCommandHelp(argv);
89
+ if (commandHelp) {
90
+ console.log(commandHelp);
91
+ return;
92
+ }
93
+
88
94
  ensureGeneratedDependencies(serviceRoot);
89
95
  process.chdir(serviceRoot);
90
96
  process.env.CREATE_SVC_SERVICE_ROOT = serviceRoot;
@@ -123,3 +129,27 @@ function ensureGeneratedDependencies(serviceRoot: string) {
123
129
  process.exit(result.exitCode || 1);
124
130
  }
125
131
  }
132
+
133
+ export function generatedServiceCommandHelp(argv: string[]) {
134
+ const [command, ...rest] = argv;
135
+ if (command !== "deploy" || !hasHelpFlag(rest)) {
136
+ return undefined;
137
+ }
138
+
139
+ return [
140
+ "Usage:",
141
+ " service deploy [--ci] [--environment main|preview|personal] [--name <name>]",
142
+ "",
143
+ "Options:",
144
+ " --ci Run without interactive prompts",
145
+ " --environment <environment> Deploy main, preview, or personal",
146
+ " --name <name> Name preview or personal environment",
147
+ " --build <local|cloudbuild> Select image build strategy",
148
+ " --cloud-build Use Cloud Build",
149
+ " --destroy Destroy a non-main deployment environment",
150
+ ].join("\n");
151
+ }
152
+
153
+ function hasHelpFlag(args: string[]) {
154
+ return args.includes("--help") || args.includes("-h") || args.includes("help");
155
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable
5
5
 
6
- TEMPORAL_ENABLED=false
6
+ TEMPORAL_ENABLED=true
7
7
  TEMPORAL_ADDRESS=localhost:7233
8
8
  TEMPORAL_NAMESPACE=default
9
9
  TEMPORAL_TASK_QUEUE={{SERVICE_ID}}
@@ -55,10 +55,49 @@ Local runtime uses:
55
55
 
56
56
  - `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
57
57
  - `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres
58
- - `TEMPORAL_ENABLED=false` by default; set Temporal env vars locally only when you want to run against a real Temporal server
58
+ - `TEMPORAL_ENABLED=true` by default with `localhost:7233` and `default`; set `TEMPORAL_ENABLED=false` when you are not running a local Temporal server
59
59
 
60
60
  No cloud credentials are required for local HTTP development after Docker and Postgres are running.
61
61
 
62
+ ## Temporal
63
+
64
+ Temporal is enabled by default for generated services.
65
+
66
+ Local development uses these generated defaults:
67
+
68
+ - `TEMPORAL_ENABLED=true`
69
+ - `TEMPORAL_ADDRESS=localhost:7233`
70
+ - `TEMPORAL_NAMESPACE=default`
71
+ - `TEMPORAL_TASK_QUEUE={{TEMPORAL_TASK_QUEUE}}`
72
+
73
+ The generated app deploys a Cloud Run API service and, when Temporal is enabled,
74
+ a separate Cloud Run worker service named `{{SERVICE_ID}}-worker`.
75
+ When `POST /v1/triggers/waitlist` or the ConnectRPC `RecordTrigger` method
76
+ creates a trigger, the API service starts `waitlistFollowUpWorkflow` /
77
+ `WaitlistFollowUpWorkflow` asynchronously on the service task queue. The API
78
+ request only waits for the trigger record; workflow completion happens through
79
+ Temporal and is polled by the worker service.
80
+ Local `{{COMMAND_DEV}}` starts the API process and the worker process together.
81
+
82
+ Production and preview deploys render `TEMPORAL_ENABLED=true` into the Cloud Run
83
+ manifest unless you override it. For Temporal Cloud, replace the local defaults
84
+ with real connection settings before the worker can run:
85
+
86
+ - `TEMPORAL_ADDRESS`
87
+ - `TEMPORAL_NAMESPACE`
88
+ - any TLS certificate/key or API-key settings used by your worker code
89
+
90
+ If a service does not need Temporal, opt out with:
91
+
92
+ ```bash
93
+ TEMPORAL_ENABLED=false {{COMMAND_DEV}}
94
+ TEMPORAL_ENABLED=false {{COMMAND_DEPLOY}}
95
+ ```
96
+
97
+ When Cloud Run starts with Temporal enabled but without address or namespace
98
+ values, the generated runtime fails during startup with an explicit
99
+ configuration error instead of silently running a broken worker.
100
+
62
101
  ## Remote provisioning
63
102
 
64
103
  The generated service config lives in [service.jsonc](service.jsonc).
@@ -158,14 +197,7 @@ Optional remote-only Vault overrides for Neon admin key lookup:
158
197
 
159
198
  The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to create and deploy.
160
199
 
161
- ## Temporal
162
-
163
- Cloud Run variants include an in-process Temporal worker in the service process.
164
- Production Temporal is enabled when you set `TEMPORAL_ENABLED=true`, or when
165
- `TEMPORAL_ADDRESS`, `TEMPORAL_API_KEY`, or `TEMPORAL_API_KEY_SECRET` is present
166
- during deploy rendering.
167
-
168
- For Temporal Cloud, provide:
200
+ For production Temporal Cloud, provide:
169
201
 
170
202
  ```bash
171
203
  TEMPORAL_ENABLED=true
@@ -1,10 +1,10 @@
1
1
  import { readLocalEnv } from "./local-env";
2
2
  import { ensureLocalPostgres } from "./local-docker";
3
3
 
4
- const command = Bun.argv.slice(2);
4
+ const { apiCommand, workerCommand } = parseCommands(Bun.argv.slice(2));
5
5
 
6
- if (command.length === 0) {
7
- throw new Error("Usage: bun run ./scripts/dev.ts <command...>");
6
+ if (apiCommand.length === 0) {
7
+ throw new Error("Usage: bun run ./scripts/dev.ts <api-command...> [--worker <worker-command...>]");
8
8
  }
9
9
 
10
10
  await ensureLocalPostgres();
@@ -17,11 +17,43 @@ if (env.DATABASE_URL && !env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPER
17
17
  env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE = env.DATABASE_URL;
18
18
  }
19
19
 
20
- const child = Bun.spawn(command, {
20
+ const api = Bun.spawn(apiCommand, {
21
21
  stdin: "inherit",
22
22
  stdout: "inherit",
23
23
  stderr: "inherit",
24
24
  env,
25
25
  });
26
26
 
27
- process.exit(await child.exited);
27
+ const worker = workerCommand
28
+ ? Bun.spawn(workerCommand, {
29
+ stdin: "ignore",
30
+ stdout: "inherit",
31
+ stderr: "inherit",
32
+ env: {
33
+ ...env,
34
+ PORT: env.TEMPORAL_WORKER_PORT || "0",
35
+ },
36
+ })
37
+ : undefined;
38
+
39
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
40
+ process.on(signal, () => {
41
+ api.kill(signal);
42
+ worker?.kill(signal);
43
+ });
44
+ }
45
+
46
+ const exitCode = await Promise.race([api.exited, worker?.exited ?? new Promise<never>(() => {})]);
47
+ api.kill();
48
+ worker?.kill();
49
+ process.exit(exitCode);
50
+
51
+ function parseCommands(argv: string[]) {
52
+ const separator = argv.indexOf("--worker");
53
+ if (separator === -1) {
54
+ return { apiCommand: argv, workerCommand: undefined };
55
+ }
56
+ const apiCommand = argv.slice(0, separator);
57
+ const workerCommand = argv.slice(separator + 1);
58
+ return { apiCommand, workerCommand: workerCommand.length > 0 ? workerCommand : undefined };
59
+ }
@@ -53,7 +53,7 @@
53
53
  },
54
54
 
55
55
  "temporal": {
56
- "enabled": false,
56
+ "enabled": true,
57
57
  "address": "localhost:7233",
58
58
  "namespace": "default",
59
59
  "task_queue": "{{SERVICE_ID}}",
@@ -117,6 +117,12 @@
117
117
  "workers": {
118
118
  "script_name": "{{SERVICE_ID}}",
119
119
  "hyperdrive_binding": "HYPERDRIVE",
120
- "cron": "*/15 * * * *"
120
+ "trigger_dev": {
121
+ "project_ref_env": "TRIGGER_PROJECT_REF",
122
+ "access_token_env": "TRIGGER_ACCESS_TOKEN",
123
+ "secret_key_env": "TRIGGER_SECRET_KEY",
124
+ "api_url": "https://api.trigger.dev",
125
+ "waitlist_task_id": "{{SERVICE_ID}}-waitlist-follow-up"
126
+ }
121
127
  }
122
128
  }
@@ -5,18 +5,21 @@ metadata:
5
5
  labels:
6
6
  managed_by: create-service
7
7
  service_id: ${SERVICE_ID}
8
+ service_role: ${SERVICE_ROLE}
8
9
  annotations:
9
- run.googleapis.com/ingress: all
10
+ run.googleapis.com/ingress: ${SERVICE_INGRESS}
10
11
  spec:
11
12
  template:
12
13
  metadata:
13
14
  labels:
14
15
  managed_by: create-service
15
16
  service_id: ${SERVICE_ID}
17
+ service_role: ${SERVICE_ROLE}
16
18
  spec:
17
19
  serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
18
20
  containers:
19
21
  - image: ${IMAGE_URL}
22
+ ${CONTAINER_COMMAND}
20
23
  ports:
21
24
  - containerPort: 8080
22
25
  env:
@@ -16,5 +16,8 @@ jobs:
16
16
  - run: bun run deploy
17
17
  env:
18
18
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
19
+ TRIGGER_PROJECT_REF: ${{ secrets.TRIGGER_PROJECT_REF }}
20
+ TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
21
+ TRIGGER_SECRET_KEY: ${{ secrets.TRIGGER_SECRET_KEY }}
19
22
  - if: ${{ vars.GCX_ENABLED == 'true' }}
20
23
  run: bun run dashboards
@@ -30,3 +30,6 @@ jobs:
30
30
  - run: service deploy --name {{SERVICE_ID}}-pr-${{ steps.pr.outputs.number }}
31
31
  env:
32
32
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
33
+ TRIGGER_PROJECT_REF: ${{ secrets.TRIGGER_PROJECT_REF }}
34
+ TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
35
+ TRIGGER_SECRET_KEY: ${{ secrets.TRIGGER_SECRET_KEY }}
@@ -8,6 +8,7 @@ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloudflare W
8
8
  - a lightweight waitlist/launch API
9
9
  - the `service` CLI for create, deploy, doctor, dashboards, DNS, and destroy
10
10
  - a Hyperdrive binding for Neon-backed Postgres persistence
11
+ - Trigger.dev task dispatch for background trigger execution
11
12
  - a production API origin at `https://{{API_HOSTNAME}}`
12
13
 
13
14
  ## Commands
@@ -17,6 +18,8 @@ wrangler dev
17
18
  bun run test
18
19
  bun run lint
19
20
  bun run migrate
21
+ bun run trigger -- --help
22
+ bun run trigger:dev
20
23
  service create
21
24
  service deploy
22
25
  bun run dashboards
@@ -48,6 +51,31 @@ binding and create the small waitlist/trigger schema on first use.
48
51
  - `POST /webhooks/:provider`
49
52
  - `GET /webhooks/:provider/health`
50
53
 
54
+ ## Trigger.dev
55
+
56
+ Cloudflare Workers services use Trigger.dev for background work. The Worker is
57
+ the API surface; it records trigger rows and dispatches the generated
58
+ `{{SERVICE_ID}}-waitlist-follow-up` task.
59
+
60
+ Required production configuration:
61
+
62
+ ```bash
63
+ export TRIGGER_PROJECT_REF=<project ref>
64
+ export TRIGGER_ACCESS_TOKEN=<personal access token>
65
+ export TRIGGER_SECRET_KEY=<secret key>
66
+ ```
67
+
68
+ `service create` and `service deploy` deploy the Trigger.dev task before the
69
+ Worker deploy and fail clearly if these values are missing.
70
+ GitHub Actions deploys require matching repository secrets:
71
+ `TRIGGER_PROJECT_REF`, `TRIGGER_ACCESS_TOKEN`, and `TRIGGER_SECRET_KEY`.
72
+
73
+ The Trigger.dev CLI is installed in this generated package as a dev dependency
74
+ from the `trigger.dev` npm package.
75
+ Use `bun run trigger -- <args>` for ad-hoc commands, `bun run trigger:dev` for
76
+ local task development, and `bun run trigger:deploy` when you want to deploy
77
+ tasks directly without running the full service deploy.
78
+
51
79
  ## Production
52
80
 
53
81
  ```bash
@@ -13,12 +13,16 @@
13
13
  "deploy": "service deploy",
14
14
  "dashboards": "service dashboards",
15
15
  "auth": "service auth",
16
- "destroy": "service destroy"
16
+ "destroy": "service destroy",
17
+ "trigger": "trigger",
18
+ "trigger:dev": "trigger dev",
19
+ "trigger:deploy": "trigger deploy"
17
20
  },
18
21
  "dependencies": {
19
22
  "@anmho/authctl": "0.1.1",
20
23
  "@clack/prompts": "^1.2.0",
21
24
  "@neondatabase/api-client": "^2.7.1",
25
+ "@trigger.dev/sdk": "^4.4.6",
22
26
  "hono": "^4.10.1",
23
27
  "pg": "^8.16.3"
24
28
  },
@@ -26,6 +30,7 @@
26
30
  "@cloudflare/workers-types": "latest",
27
31
  "@types/pg": "^8.16.0",
28
32
  "@types/bun": "latest",
33
+ "trigger.dev": "^4.4.6",
29
34
  "typescript": "^5.9.3",
30
35
  "wrangler": "^4.49.0"
31
36
  }
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { authMiddleware } from "./auth";
3
3
  import { createStorage } from "./storage";
4
+ import { createTriggerDevDispatcher, TriggerDispatchError, type TriggerDispatcher } from "./trigger";
4
5
 
5
6
  type Env = {
6
7
  HYPERDRIVE?: Hyperdrive;
@@ -8,10 +9,14 @@ type Env = {
8
9
  AUTH_ISSUER?: string;
9
10
  AUTH_AUDIENCE?: string;
10
11
  AUTH_JWKS_URL?: string;
12
+ TRIGGER_SECRET_KEY?: string;
13
+ TRIGGER_TASK_ID?: string;
14
+ TRIGGER_API_URL?: string;
11
15
  };
12
16
 
13
- export function createApp() {
17
+ export function createApp(options: { triggerDispatcher?: TriggerDispatcher } = {}) {
14
18
  const app = new Hono<{ Bindings: Env }>();
19
+ const triggerDispatcher = options.triggerDispatcher ?? createTriggerDevDispatcher();
15
20
 
16
21
  app.get("/healthz", (context) => context.json({ status: "ok" }));
17
22
  app.get("/readyz", (context) => context.json({ status: "ok" }));
@@ -104,26 +109,36 @@ export function createApp() {
104
109
  });
105
110
 
106
111
  app.post("/v1/triggers/waitlist", async (context) => {
107
- const body = await context.req.json().catch(() => ({}));
108
- const trigger = await createStorage(context.env).recordTrigger({
109
- type: String(body.type ?? "manual"),
110
- entryId: body.entry_id ?? body.entryId ?? null,
111
- payload: body,
112
- });
113
- return context.json({ trigger }, 202);
112
+ try {
113
+ const body = await context.req.json().catch(() => ({}));
114
+ const trigger = await createStorage(context.env).recordTrigger({
115
+ type: String(body.type ?? "manual"),
116
+ entryId: body.entry_id ?? body.entryId ?? null,
117
+ payload: body,
118
+ });
119
+ const run = await triggerDispatcher.dispatchWaitlistFollowUp(trigger, context.env);
120
+ return context.json({ trigger, trigger_dev_run_id: run.id }, 202);
121
+ } catch (error) {
122
+ return writeError(context, error);
123
+ }
114
124
  });
115
125
 
116
126
  app.post("/webhooks/:provider", async (context) => {
117
- const rawBody = await context.req.text();
118
- const trigger = await createStorage(context.env).recordTrigger({
119
- type: `webhook.${context.req.param("provider")}`,
120
- entryId: null,
121
- payload: {
122
- headers: Object.fromEntries(context.req.raw.headers),
123
- rawBody,
124
- },
125
- });
126
- return context.json({ trigger }, 202);
127
+ try {
128
+ const rawBody = await context.req.text();
129
+ const trigger = await createStorage(context.env).recordTrigger({
130
+ type: `webhook.${context.req.param("provider")}`,
131
+ entryId: null,
132
+ payload: {
133
+ headers: Object.fromEntries(context.req.raw.headers),
134
+ rawBody,
135
+ },
136
+ });
137
+ const run = await triggerDispatcher.dispatchWaitlistFollowUp(trigger, context.env);
138
+ return context.json({ trigger, trigger_dev_run_id: run.id }, 202);
139
+ } catch (error) {
140
+ return writeError(context, error);
141
+ }
127
142
  });
128
143
 
129
144
  app.get("/webhooks/:provider/health", (context) => context.json({ status: "ok", provider: context.req.param("provider") }));
@@ -153,6 +168,9 @@ function writeError(context: any, error: unknown) {
153
168
  if (error instanceof ValidationError) {
154
169
  return context.json({ error: error.message, code: error.code }, 400);
155
170
  }
171
+ if (error instanceof TriggerDispatchError) {
172
+ return context.json({ error: error.message, code: error.code }, 500);
173
+ }
156
174
  console.error(error);
157
175
  return context.json({ error: "internal server error", code: "internal" }, 500);
158
176
  }
@@ -188,11 +206,4 @@ function csvCell(value: string) {
188
206
 
189
207
  export default {
190
208
  fetch: app.fetch,
191
- async scheduled(_event: ScheduledEvent, env: Env, context: ExecutionContext) {
192
- context.waitUntil(
193
- createStorage(env).claimQueuedTriggers(10).then((triggers) => {
194
- console.log("processed waitlist triggers", triggers.length);
195
- })
196
- );
197
- },
198
209
  };
@@ -0,0 +1,81 @@
1
+ import type { WaitlistTrigger } from "./storage";
2
+
3
+ export type TriggerRun = {
4
+ id: string;
5
+ };
6
+
7
+ export type TriggerDispatcher = {
8
+ dispatchWaitlistFollowUp(trigger: WaitlistTrigger, env: TriggerEnv): Promise<TriggerRun>;
9
+ };
10
+
11
+ export type TriggerEnv = {
12
+ TRIGGER_SECRET_KEY?: string;
13
+ TRIGGER_TASK_ID?: string;
14
+ TRIGGER_API_URL?: string;
15
+ };
16
+
17
+ export class TriggerDispatchError extends Error {
18
+ constructor(
19
+ readonly code: string,
20
+ message: string
21
+ ) {
22
+ super(message);
23
+ }
24
+ }
25
+
26
+ export function createTriggerDevDispatcher(): TriggerDispatcher {
27
+ return {
28
+ async dispatchWaitlistFollowUp(trigger, env) {
29
+ const config = triggerConfigFromEnv(env);
30
+ const response = await fetch(`${config.apiUrl}/api/v1/tasks/${encodeURIComponent(config.taskId)}/trigger`, {
31
+ method: "POST",
32
+ headers: {
33
+ authorization: `Bearer ${config.secretKey}`,
34
+ "content-type": "application/json",
35
+ },
36
+ body: JSON.stringify({
37
+ payload: {
38
+ triggerId: trigger.id,
39
+ type: trigger.type,
40
+ entryId: trigger.entry_id,
41
+ payload: parsePayload(trigger.payload_json),
42
+ },
43
+ options: {
44
+ idempotencyKey: trigger.id,
45
+ },
46
+ }),
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new TriggerDispatchError("trigger_dev_request_failed", `Trigger.dev task trigger failed with ${response.status}: ${await response.text()}`);
51
+ }
52
+
53
+ const result = (await response.json()) as { id?: string };
54
+ if (!result.id) {
55
+ throw new TriggerDispatchError("trigger_dev_missing_run_id", "Trigger.dev did not return a run id");
56
+ }
57
+ return { id: result.id };
58
+ },
59
+ };
60
+ }
61
+
62
+ function triggerConfigFromEnv(env: TriggerEnv = {}) {
63
+ const secretKey = env.TRIGGER_SECRET_KEY?.trim();
64
+ if (!secretKey) {
65
+ throw new TriggerDispatchError("trigger_dev_not_configured", "TRIGGER_SECRET_KEY is required to dispatch Trigger.dev tasks");
66
+ }
67
+
68
+ return {
69
+ secretKey,
70
+ taskId: env.TRIGGER_TASK_ID?.trim() || "{{SERVICE_ID}}-waitlist-follow-up",
71
+ apiUrl: (env.TRIGGER_API_URL?.trim() || "https://api.trigger.dev").replace(/\/$/, ""),
72
+ };
73
+ }
74
+
75
+ function parsePayload(value: string) {
76
+ try {
77
+ return JSON.parse(value);
78
+ } catch {
79
+ return {};
80
+ }
81
+ }