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.
- 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/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- 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 +26 -3
- 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
|
@@ -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,
|
package/src/service.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
|
4
|
+
const { apiCommand, workerCommand } = parseCommands(Bun.argv.slice(2));
|
|
5
5
|
|
|
6
|
-
if (
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|