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.
- package/README.md +9 -6
- package/package.json +2 -1
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +17 -6
- package/src/naming.test.ts +6 -0
- package/src/naming.ts +1 -1
- package/src/scaffold.test.ts +19 -3
- package/src/scaffold.ts +10 -0
- package/src/service-runtime/authctl.ts +32 -0
- package/src/service-runtime/cloudrun/bootstrap.ts +3 -4
- package/src/service-runtime/cloudrun/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- package/src/service-runtime/cloudrun/config.ts +3 -0
- package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
- package/src/service-runtime/cloudrun/deploy.ts +25 -0
- package/src/service-runtime/cloudrun/lib.test.ts +12 -1
- package/src/service-runtime/cloudrun/lib.ts +55 -15
- package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
- package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
- package/src/service-runtime/workers/cli.ts +88 -0
- package/src/service.test.ts +15 -2
- package/src/service.ts +31 -1
- package/templates/shared/.env.example +1 -1
- package/templates/shared/README.md +41 -9
- package/templates/shared/scripts/dev.ts +37 -5
- package/templates/shared/service.jsonc +8 -2
- package/templates/shared/service.yaml +4 -1
- package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
- package/templates/targets/workers/.github/workflows/preview.yml +3 -0
- package/templates/targets/workers/README.md +28 -0
- package/templates/targets/workers/package.json +6 -1
- package/templates/targets/workers/src/index.ts +36 -25
- package/templates/targets/workers/src/trigger.ts +81 -0
- package/templates/targets/workers/test/app.test.ts +46 -1
- package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
- package/templates/targets/workers/trigger.config.ts +24 -0
- package/templates/targets/workers/tsconfig.json +1 -1
- package/templates/targets/workers/wrangler.toml +2 -0
- package/templates/variants/bun-connectrpc/package.json +1 -1
- package/templates/variants/bun-connectrpc/src/index.ts +2 -6
- package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
- package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
- package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
- package/templates/variants/bun-hono/package.json +1 -1
- package/templates/variants/bun-hono/src/index.ts +2 -6
- package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
- package/templates/variants/bun-hono/src/temporal.ts +56 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
- package/templates/variants/bun-hono/src/worker.ts +21 -0
- package/templates/variants/go-chi/Dockerfile +2 -0
- package/templates/variants/go-chi/Makefile +1 -1
- package/templates/variants/go-chi/cmd/server/main.go +5 -4
- package/templates/variants/go-chi/cmd/worker/main.go +56 -0
- package/templates/variants/go-chi/internal/app/service.go +35 -3
- package/templates/variants/go-chi/internal/config/config.go +34 -3
- package/templates/variants/go-chi/internal/config/config_test.go +63 -0
- package/templates/variants/go-chi/internal/temporal/client.go +55 -0
- package/templates/variants/go-connectrpc/Dockerfile +2 -0
- package/templates/variants/go-connectrpc/Makefile +1 -1
- package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
- package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
- package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
- package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
- 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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
+
});
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|