create-svc 0.1.62 → 0.1.64

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 (67) 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/bootstrap.ts +3 -4
  11. package/src/service-runtime/cloudrun/cleanup.ts +6 -1
  12. package/src/service-runtime/cloudrun/cli.ts +52 -12
  13. package/src/service-runtime/cloudrun/config.ts +3 -0
  14. package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
  15. package/src/service-runtime/cloudrun/deploy.ts +25 -0
  16. package/src/service-runtime/cloudrun/lib.test.ts +12 -1
  17. package/src/service-runtime/cloudrun/lib.ts +55 -15
  18. package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
  19. package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
  20. package/src/service-runtime/workers/cli.ts +88 -0
  21. package/src/service.test.ts +15 -2
  22. package/src/service.ts +31 -1
  23. package/templates/shared/.env.example +1 -1
  24. package/templates/shared/README.md +41 -9
  25. package/templates/shared/scripts/dev.ts +37 -5
  26. package/templates/shared/service.jsonc +8 -2
  27. package/templates/shared/service.yaml +4 -1
  28. package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
  29. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  30. package/templates/targets/workers/README.md +28 -0
  31. package/templates/targets/workers/package.json +6 -1
  32. package/templates/targets/workers/src/index.ts +36 -25
  33. package/templates/targets/workers/src/trigger.ts +81 -0
  34. package/templates/targets/workers/test/app.test.ts +46 -1
  35. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  36. package/templates/targets/workers/trigger.config.ts +24 -0
  37. package/templates/targets/workers/tsconfig.json +1 -1
  38. package/templates/targets/workers/wrangler.toml +2 -0
  39. package/templates/variants/bun-connectrpc/package.json +1 -1
  40. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  41. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  42. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  43. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  44. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  45. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  46. package/templates/variants/bun-hono/package.json +1 -1
  47. package/templates/variants/bun-hono/src/index.ts +2 -6
  48. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  49. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  50. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  51. package/templates/variants/bun-hono/src/worker.ts +21 -0
  52. package/templates/variants/go-chi/Dockerfile +2 -0
  53. package/templates/variants/go-chi/Makefile +1 -1
  54. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  55. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  56. package/templates/variants/go-chi/internal/app/service.go +35 -3
  57. package/templates/variants/go-chi/internal/config/config.go +34 -3
  58. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  59. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  60. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  61. package/templates/variants/go-connectrpc/Makefile +1 -1
  62. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  63. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  65. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  66. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  67. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { createApp } from "../src/index";
3
+ import type { TriggerDispatcher } from "../src/trigger";
3
4
 
4
5
  type JoinResponse = {
5
6
  entry: {
@@ -92,7 +93,8 @@ test("waitlist join persists an idempotent entry", async () => {
92
93
  });
93
94
 
94
95
  test("waitlist trigger is queued for cron processing", async () => {
95
- const response = await createApp().request("/v1/triggers/waitlist", {
96
+ const calls: unknown[] = [];
97
+ const response = await createApp({ triggerDispatcher: mockTriggerDispatcher(calls) }).request("/v1/triggers/waitlist", {
96
98
  method: "POST",
97
99
  body: JSON.stringify({ type: "cron.digest" }),
98
100
  headers: { "content-type": "application/json" },
@@ -104,5 +106,48 @@ test("waitlist trigger is queued for cron processing", async () => {
104
106
  type: "cron.digest",
105
107
  status: "queued",
106
108
  },
109
+ trigger_dev_run_id: "run_test",
107
110
  });
111
+ expect(calls).toHaveLength(1);
108
112
  });
113
+
114
+ test("waitlist trigger reports missing Trigger.dev configuration", async () => {
115
+ const response = await createApp().request("/v1/triggers/waitlist", {
116
+ method: "POST",
117
+ body: JSON.stringify({ type: "manual" }),
118
+ headers: { "content-type": "application/json" },
119
+ });
120
+
121
+ expect(response.status).toBe(500);
122
+ await expect(response.json()).resolves.toMatchObject({
123
+ code: "trigger_dev_not_configured",
124
+ });
125
+ });
126
+
127
+ test("webhook dispatches a Trigger.dev run", async () => {
128
+ const calls: unknown[] = [];
129
+ const response = await createApp({ triggerDispatcher: mockTriggerDispatcher(calls) }).request("/webhooks/stripe", {
130
+ method: "POST",
131
+ body: JSON.stringify({ id: "evt_test" }),
132
+ headers: { "content-type": "application/json" },
133
+ });
134
+
135
+ expect(response.status).toBe(202);
136
+ await expect(response.json()).resolves.toMatchObject({
137
+ trigger: {
138
+ type: "webhook.stripe",
139
+ status: "queued",
140
+ },
141
+ trigger_dev_run_id: "run_test",
142
+ });
143
+ expect(calls).toHaveLength(1);
144
+ });
145
+
146
+ function mockTriggerDispatcher(calls: unknown[]): TriggerDispatcher {
147
+ return {
148
+ async dispatchWaitlistFollowUp(trigger) {
149
+ calls.push(trigger);
150
+ return { id: "run_test" };
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,24 @@
1
+ import { logger, task } from "@trigger.dev/sdk";
2
+
3
+ export type WaitlistFollowUpPayload = {
4
+ triggerId: string;
5
+ type: string;
6
+ entryId: string | null;
7
+ payload: unknown;
8
+ };
9
+
10
+ export const waitlistFollowUp = task({
11
+ id: "{{SERVICE_ID}}-waitlist-follow-up",
12
+ run: async (payload: WaitlistFollowUpPayload) => {
13
+ logger.info("Processing waitlist follow-up", {
14
+ triggerId: payload.triggerId,
15
+ type: payload.type,
16
+ entryId: payload.entryId,
17
+ });
18
+
19
+ return {
20
+ status: "queued",
21
+ triggerId: payload.triggerId,
22
+ };
23
+ },
24
+ });
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from "@trigger.dev/sdk";
2
+
3
+ const project = process.env.TRIGGER_PROJECT_REF;
4
+
5
+ if (!project) {
6
+ throw new Error("TRIGGER_PROJECT_REF is required to build or deploy Trigger.dev tasks");
7
+ }
8
+
9
+ export default defineConfig({
10
+ project,
11
+ dirs: ["./trigger"],
12
+ runtime: "node-22",
13
+ maxDuration: 300,
14
+ retries: {
15
+ enabledInDev: false,
16
+ default: {
17
+ maxAttempts: 3,
18
+ minTimeoutInMs: 1_000,
19
+ maxTimeoutInMs: 10_000,
20
+ factor: 2,
21
+ randomize: true,
22
+ },
23
+ },
24
+ });
@@ -7,5 +7,5 @@
7
7
  "skipLibCheck": true,
8
8
  "types": ["bun", "@cloudflare/workers-types"]
9
9
  },
10
- "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
10
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "trigger/**/*.ts", "trigger.config.ts"]
11
11
  }
@@ -11,6 +11,8 @@ AUTH_ENABLED = "true"
11
11
  AUTH_ISSUER = "{{AUTH_ISSUER}}"
12
12
  AUTH_AUDIENCE = "{{AUTH_AUDIENCE}}"
13
13
  AUTH_JWKS_URL = "{{AUTH_JWKS_URL}}"
14
+ TRIGGER_TASK_ID = "{{SERVICE_ID}}-waitlist-follow-up"
15
+ TRIGGER_API_URL = "https://api.trigger.dev"
14
16
 
15
17
  [[routes]]
16
18
  pattern = "{{API_HOSTNAME}}"
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
6
- "dev": "bun run ./scripts/dev.ts bun run ./src/index.ts",
6
+ "dev": "bun run ./scripts/dev.ts bun run ./src/index.ts --worker bun run ./src/worker.ts",
7
7
  "service": "service",
8
8
  "migrate": "service migrate",
9
9
  "gen": "bun run ./scripts/codegen.ts",
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
5
5
  import { WaitlistService as WaitlistRpcService } from "../gen/protos/waitlist/v1/waitlist_pb.js";
6
6
  import { withServiceAuth } from "./auth";
7
7
  import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
8
- import { startTemporalWorker } from "./temporal/worker";
8
+ import { assertTemporalRuntimeConfig } from "./temporal";
9
9
  import type { WaitlistEntry, WaitlistTrigger } from "./waitlist/types";
10
10
 
11
11
  type RpcService = ServiceImpl<typeof WaitlistRpcService>;
@@ -236,11 +236,7 @@ function headersPayload(headers: Parameters<FallbackHandler>[0]["headers"]) {
236
236
  }
237
237
 
238
238
  if (import.meta.main) {
239
- const temporalWorker = await startTemporalWorker();
240
- if (temporalWorker) {
241
- console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
242
- }
243
-
239
+ assertTemporalRuntimeConfig();
244
240
  const port = Number(Bun.env.PORT ?? 8080);
245
241
  const server = createServer(withServiceAuth(createHandler(createDefaultWaitlistService())));
246
242
  server.listen(port);
@@ -0,0 +1,28 @@
1
+ import { Client, Connection } from "@temporalio/client";
2
+ import type { WaitlistFollowUpInput } from "./activities";
3
+ import { waitlistFollowUpWorkflow } from "./workflows";
4
+
5
+ export function temporalClientEnabled() {
6
+ return (Bun.env.TEMPORAL_ENABLED ?? "").trim().toLowerCase() === "true";
7
+ }
8
+
9
+ export async function startWaitlistFollowUpWorkflow(input: WaitlistFollowUpInput) {
10
+ if (!temporalClientEnabled()) {
11
+ return undefined;
12
+ }
13
+
14
+ const address = Bun.env.TEMPORAL_ADDRESS || "localhost:7233";
15
+ const namespace = Bun.env.TEMPORAL_NAMESPACE || "default";
16
+ const taskQueue = Bun.env.TEMPORAL_TASK_QUEUE || "{{SERVICE_NAME}}";
17
+ const apiKey = Bun.env.TEMPORAL_API_KEY?.trim();
18
+ const connection = await Connection.connect({
19
+ address,
20
+ ...(apiKey ? { apiKey } : {}),
21
+ });
22
+ const client = new Client({ connection, namespace });
23
+ return client.workflow.start(waitlistFollowUpWorkflow, {
24
+ workflowId: `waitlist-follow-up-${input.triggerId ?? crypto.randomUUID()}`,
25
+ taskQueue,
26
+ args: [input],
27
+ });
28
+ }
@@ -0,0 +1,56 @@
1
+ export type TemporalRuntimeConfig = {
2
+ enabled: boolean;
3
+ address: string;
4
+ namespace: string;
5
+ taskQueue: string;
6
+ };
7
+
8
+ type Env = Record<string, string | undefined>;
9
+
10
+ export function resolveTemporalRuntimeConfig(env: Env = Bun.env): TemporalRuntimeConfig {
11
+ const enabled = readBoolean(env.TEMPORAL_ENABLED, true);
12
+ const cloudRun = isCloudRun(env);
13
+
14
+ return {
15
+ enabled,
16
+ address: readString(env.TEMPORAL_ADDRESS, cloudRun ? "" : "localhost:7233"),
17
+ namespace: readString(env.TEMPORAL_NAMESPACE, cloudRun ? "" : "default"),
18
+ taskQueue: readString(env.TEMPORAL_TASK_QUEUE, "{{SERVICE_NAME}}"),
19
+ };
20
+ }
21
+
22
+ export function assertTemporalRuntimeConfig(config = resolveTemporalRuntimeConfig()) {
23
+ if (!config.enabled) {
24
+ return config;
25
+ }
26
+
27
+ const missing = [
28
+ config.address ? "" : "TEMPORAL_ADDRESS",
29
+ config.namespace ? "" : "TEMPORAL_NAMESPACE",
30
+ ].filter(Boolean);
31
+
32
+ if (missing.length > 0) {
33
+ throw new Error(
34
+ `Temporal is enabled, but ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} required. Set Temporal Cloud connection settings or TEMPORAL_ENABLED=false.`
35
+ );
36
+ }
37
+
38
+ return config;
39
+ }
40
+
41
+ function readBoolean(value: string | undefined, fallback: boolean) {
42
+ const normalized = value?.trim().toLowerCase();
43
+ if (!normalized) {
44
+ return fallback;
45
+ }
46
+ return !["0", "false", "no", "off"].includes(normalized);
47
+ }
48
+
49
+ function readString(value: string | undefined, fallback: string) {
50
+ const normalized = value?.trim();
51
+ return normalized || fallback;
52
+ }
53
+
54
+ function isCloudRun(env: Env) {
55
+ return Boolean(env.K_SERVICE?.trim());
56
+ }
@@ -1,5 +1,6 @@
1
1
  import { createDb } from "../db/client";
2
2
  import { WaitlistRepository } from "../db/repository";
3
+ import { startWaitlistFollowUpWorkflow } from "../temporal/client";
3
4
  import type {
4
5
  JoinWaitlistInput,
5
6
  ListWaitlistEntriesInput,
@@ -109,12 +110,21 @@ export class DefaultWaitlistService implements WaitlistService {
109
110
  await this.getWaitlistEntry(input.entryId);
110
111
  }
111
112
 
112
- return this.repository.createTrigger({
113
+ const trigger = await this.repository.createTrigger({
113
114
  id: crypto.randomUUID(),
114
115
  type,
115
116
  entryId: input.entryId?.trim() || null,
116
117
  payloadJson: normalizePayloadJson(input.payloadJson),
117
118
  });
119
+
120
+ const payload = parsePayloadJson(trigger.payloadJson);
121
+ startWaitlistFollowUpWorkflow({
122
+ triggerId: trigger.id,
123
+ email: typeof payload.email === "string" ? payload.email : undefined,
124
+ type: trigger.type,
125
+ }).catch((error) => console.error("failed to start waitlist follow-up workflow", error));
126
+
127
+ return trigger;
118
128
  }
119
129
 
120
130
  async recordWebhookEvent(input: RecordWebhookEventInput) {
@@ -192,3 +202,8 @@ function normalizePayloadJson(value: string | null | undefined) {
192
202
  JSON.parse(payload);
193
203
  return payload;
194
204
  }
205
+
206
+ function parsePayloadJson(value: string) {
207
+ const parsed = JSON.parse(value || "{}");
208
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
209
+ }
@@ -0,0 +1,21 @@
1
+ import { assertTemporalRuntimeConfig } from "./temporal";
2
+ import { startTemporalWorker } from "./temporal/worker";
3
+
4
+ assertTemporalRuntimeConfig();
5
+ const temporalWorker = await startTemporalWorker();
6
+ if (!temporalWorker) {
7
+ throw new Error("Temporal worker is disabled. Set TEMPORAL_ENABLED=true or do not run the worker process.");
8
+ }
9
+
10
+ console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
11
+
12
+ Bun.serve({
13
+ port: Number(Bun.env.PORT ?? 8080),
14
+ fetch: (request) => {
15
+ const path = new URL(request.url).pathname;
16
+ if (path === "/healthz" || path === "/readyz") {
17
+ return Response.json({ status: "ok", worker: "temporal" });
18
+ }
19
+ return Response.json({ status: "ok", worker: "temporal" });
20
+ },
21
+ });
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { createIntrospectionDocument, isLocalRpcIntrospectionEnabled } from "../src/index";
3
+ import { assertTemporalRuntimeConfig, resolveTemporalRuntimeConfig } from "../src/temporal";
3
4
 
4
5
  test("local introspection document exposes waitlist service and methods", () => {
5
6
  const document = createIntrospectionDocument();
@@ -16,3 +17,24 @@ test("local introspection defaults to enabled outside Cloud Run", () => {
16
17
 
17
18
  expect(isLocalRpcIntrospectionEnabled()).toBeTrue();
18
19
  });
20
+
21
+ test("Temporal runtime config defaults to enabled local development", () => {
22
+ expect(resolveTemporalRuntimeConfig({})).toEqual({
23
+ enabled: true,
24
+ address: "localhost:7233",
25
+ namespace: "default",
26
+ taskQueue: "{{SERVICE_NAME}}",
27
+ });
28
+ });
29
+
30
+ test("Temporal runtime config supports explicit opt-out", () => {
31
+ expect(resolveTemporalRuntimeConfig({ TEMPORAL_ENABLED: "false", K_SERVICE: "svc" })).toMatchObject({
32
+ enabled: false,
33
+ });
34
+ });
35
+
36
+ test("Temporal runtime config fails clearly in Cloud Run without connection settings", () => {
37
+ expect(() => assertTemporalRuntimeConfig(resolveTemporalRuntimeConfig({ K_SERVICE: "svc" }))).toThrow(
38
+ "TEMPORAL_ADDRESS and TEMPORAL_NAMESPACE"
39
+ );
40
+ });
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
6
- "dev": "bun run ./scripts/dev.ts bun run ./src/index.ts",
6
+ "dev": "bun run ./scripts/dev.ts bun run ./src/index.ts --worker bun run ./src/worker.ts",
7
7
  "service": "service",
8
8
  "migrate": "service migrate",
9
9
  "gen": "bun run ./scripts/codegen.ts",
@@ -1,7 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { authMiddleware } from "./auth";
3
3
  import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
4
- import { startTemporalWorker } from "./temporal/worker";
4
+ import { assertTemporalRuntimeConfig } from "./temporal";
5
5
 
6
6
  export function createApp(service: WaitlistService) {
7
7
  const app = new Hono();
@@ -169,11 +169,7 @@ function webhookEventId(payload: unknown, headers: Headers) {
169
169
  }
170
170
 
171
171
  if (import.meta.main) {
172
- const temporalWorker = await startTemporalWorker();
173
- if (temporalWorker) {
174
- console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
175
- }
176
-
172
+ assertTemporalRuntimeConfig();
177
173
  Bun.serve({
178
174
  port: Number(Bun.env.PORT ?? 3000),
179
175
  fetch: createApp(createDefaultWaitlistService()).fetch,
@@ -0,0 +1,28 @@
1
+ import { Client, Connection } from "@temporalio/client";
2
+ import type { WaitlistFollowUpInput } from "./activities";
3
+ import { waitlistFollowUpWorkflow } from "./workflows";
4
+
5
+ export function temporalClientEnabled() {
6
+ return (Bun.env.TEMPORAL_ENABLED ?? "").trim().toLowerCase() === "true";
7
+ }
8
+
9
+ export async function startWaitlistFollowUpWorkflow(input: WaitlistFollowUpInput) {
10
+ if (!temporalClientEnabled()) {
11
+ return undefined;
12
+ }
13
+
14
+ const address = Bun.env.TEMPORAL_ADDRESS || "localhost:7233";
15
+ const namespace = Bun.env.TEMPORAL_NAMESPACE || "default";
16
+ const taskQueue = Bun.env.TEMPORAL_TASK_QUEUE || "{{SERVICE_NAME}}";
17
+ const apiKey = Bun.env.TEMPORAL_API_KEY?.trim();
18
+ const connection = await Connection.connect({
19
+ address,
20
+ ...(apiKey ? { apiKey } : {}),
21
+ });
22
+ const client = new Client({ connection, namespace });
23
+ return client.workflow.start(waitlistFollowUpWorkflow, {
24
+ workflowId: `waitlist-follow-up-${input.triggerId ?? crypto.randomUUID()}`,
25
+ taskQueue,
26
+ args: [input],
27
+ });
28
+ }