create-svc 0.1.61 → 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 (64) 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-cleanup.yml +1 -1
  26. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  27. package/templates/targets/workers/README.md +28 -0
  28. package/templates/targets/workers/package.json +6 -1
  29. package/templates/targets/workers/src/index.ts +36 -25
  30. package/templates/targets/workers/src/trigger.ts +81 -0
  31. package/templates/targets/workers/test/app.test.ts +46 -1
  32. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  33. package/templates/targets/workers/trigger.config.ts +24 -0
  34. package/templates/targets/workers/tsconfig.json +1 -1
  35. package/templates/targets/workers/wrangler.toml +2 -0
  36. package/templates/variants/bun-connectrpc/package.json +1 -1
  37. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  38. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  39. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  40. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  41. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  42. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  43. package/templates/variants/bun-hono/package.json +1 -1
  44. package/templates/variants/bun-hono/src/index.ts +2 -6
  45. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  46. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  47. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  48. package/templates/variants/bun-hono/src/worker.ts +21 -0
  49. package/templates/variants/go-chi/Dockerfile +2 -0
  50. package/templates/variants/go-chi/Makefile +1 -1
  51. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  52. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  53. package/templates/variants/go-chi/internal/app/service.go +35 -3
  54. package/templates/variants/go-chi/internal/config/config.go +34 -3
  55. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  56. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  57. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  58. package/templates/variants/go-connectrpc/Makefile +1 -1
  59. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  60. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  61. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  62. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  63. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  64. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
@@ -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
+ }
@@ -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,20 @@ 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
  payload: input.payload ?? {},
117
118
  });
119
+
120
+ startWaitlistFollowUpWorkflow({
121
+ triggerId: trigger.id,
122
+ email: trigger.payload && typeof trigger.payload === "object" && "email" in trigger.payload ? String(trigger.payload.email) : undefined,
123
+ type: trigger.type,
124
+ }).catch((error) => console.error("failed to start waitlist follow-up workflow", error));
125
+
126
+ return trigger;
118
127
  }
119
128
 
120
129
  async recordWebhookEvent(input: RecordWebhookEventInput) {
@@ -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
+ });
@@ -8,12 +8,14 @@ COPY cmd ./cmd
8
8
 
9
9
  RUN go mod download
10
10
  RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
11
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/worker ./cmd/worker
11
12
 
12
13
  FROM gcr.io/distroless/base-debian12
13
14
 
14
15
  WORKDIR /app
15
16
 
16
17
  COPY --from=builder /out/server /app/server
18
+ COPY --from=builder /out/worker /app/worker
17
19
 
18
20
  ENV PORT=8080
19
21
 
@@ -5,7 +5,7 @@ WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
5
5
  ATLAS ?= atlas
6
6
 
7
7
  dev:
8
- @bun run ./scripts/dev.ts go run ./cmd/server
8
+ @bun run ./scripts/dev.ts go run ./cmd/server --worker go run ./cmd/worker
9
9
 
10
10
  migrate:
11
11
  @bun run ./scripts/ensure-local-db.ts
@@ -31,17 +31,18 @@ func main() {
31
31
 
32
32
  service := app.NewWaitlistService(db)
33
33
  if cfg.TemporalEnabled {
34
- stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
34
+ temporalConfig := temporalapp.WorkerConfig{
35
35
  Address: cfg.TemporalAddress,
36
36
  Namespace: cfg.TemporalNamespace,
37
37
  TaskQueue: cfg.TemporalTaskQueue,
38
38
  APIKey: cfg.TemporalAPIKey,
39
- })
39
+ }
40
+ dispatcher, err := temporalapp.NewTriggerDispatcher(temporalConfig)
40
41
  if err != nil {
41
42
  log.Fatal(err)
42
43
  }
43
- defer stopTemporal()
44
- log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
44
+ defer dispatcher.Close()
45
+ service.SetTriggerDispatcher(dispatcher)
45
46
  }
46
47
 
47
48
  router := chi.NewRouter()