create-svc 0.1.9 → 0.1.11

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 (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -0,0 +1,49 @@
1
+ import { SQL } from "bun";
2
+ import { ensureLocalPostgres } from "./local-docker";
3
+
4
+ await ensureLocalPostgres();
5
+
6
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
7
+ if (!databaseUrl) {
8
+ throw new Error("DATABASE_URL is required");
9
+ }
10
+
11
+ const client = new SQL(databaseUrl);
12
+ await waitForDatabase(client);
13
+ const migrationId = "0000_init_waitlist";
14
+ const migrationSql = await Bun.file(new URL("../migrations/0000_init.sql", import.meta.url)).text();
15
+
16
+ await client.unsafe(`create table if not exists schema_migrations (
17
+ id text primary key,
18
+ applied_at timestamptz not null default now()
19
+ )`);
20
+
21
+ const existing = await client`select id from schema_migrations where id = ${migrationId} limit 1`;
22
+ if (existing.length === 0) {
23
+ await client.unsafe(migrationSql);
24
+ await client`insert into schema_migrations (id) values (${migrationId})`;
25
+ console.log(`Applied migration ${migrationId}`);
26
+ } else {
27
+ console.log(`Migration ${migrationId} already applied`);
28
+ }
29
+
30
+ async function waitForDatabase(client: SQL, timeoutMs = 30_000) {
31
+ const start = Date.now();
32
+ let lastError: unknown;
33
+
34
+ while (Date.now() - start < timeoutMs) {
35
+ try {
36
+ await client.unsafe("select 1");
37
+ return;
38
+ } catch (error) {
39
+ lastError = error;
40
+ await Bun.sleep(1_000);
41
+ }
42
+ }
43
+
44
+ throw new Error(`Timed out waiting for DATABASE_URL to accept connections: ${formatError(lastError)}`);
45
+ }
46
+
47
+ function formatError(error: unknown) {
48
+ return error instanceof Error ? error.message : String(error ?? "unknown error");
49
+ }
@@ -0,0 +1,200 @@
1
+ type AuthConfig = {
2
+ enabled: boolean;
3
+ issuer: string;
4
+ audience: string;
5
+ jwksUrl: string;
6
+ };
7
+
8
+ type JwtHeader = {
9
+ alg?: string;
10
+ kid?: string;
11
+ };
12
+
13
+ type JwtClaims = {
14
+ iss?: string;
15
+ aud?: string | string[];
16
+ exp?: number;
17
+ nbf?: number;
18
+ };
19
+
20
+ type Jwk = JsonWebKey & {
21
+ kid?: string;
22
+ };
23
+
24
+ type Jwks = {
25
+ keys: Jwk[];
26
+ };
27
+
28
+ type NodeHandler = (request: any, response: any) => void;
29
+
30
+ const encoder = new TextEncoder();
31
+ const jwksCache = new Map<string, { expiresAt: number; jwks: Jwks }>();
32
+
33
+ export function withServiceAuth(handler: NodeHandler): NodeHandler {
34
+ return (request, response) => {
35
+ void authorizeRequest(request)
36
+ .then((authorized) => {
37
+ if (!authorized) {
38
+ respondUnauthorized(response);
39
+ return;
40
+ }
41
+ handler(request, response);
42
+ })
43
+ .catch(() => respondUnauthorized(response));
44
+ };
45
+ }
46
+
47
+ async function authorizeRequest(request: any) {
48
+ const config = authConfigFromEnv();
49
+ if (!config.enabled || isPublicPath(request)) {
50
+ return true;
51
+ }
52
+
53
+ const authorization = Array.isArray(request.headers.authorization)
54
+ ? request.headers.authorization[0]
55
+ : request.headers.authorization ?? "";
56
+ const token = bearerToken(authorization);
57
+ if (!token) {
58
+ return false;
59
+ }
60
+
61
+ await verifyAccessToken(token, config);
62
+ return true;
63
+ }
64
+
65
+ function authConfigFromEnv(): AuthConfig {
66
+ return {
67
+ enabled: truthy(Bun.env.AUTH_ENABLED),
68
+ issuer: Bun.env.AUTH_ISSUER ?? "",
69
+ audience: Bun.env.AUTH_AUDIENCE ?? "",
70
+ jwksUrl: Bun.env.AUTH_JWKS_URL ?? "",
71
+ };
72
+ }
73
+
74
+ function isPublicPath(request: any) {
75
+ const path = new URL(request.url ?? "/", "http://localhost").pathname;
76
+ return path === "/" || path === "/healthz" || path === "/readyz" || path.startsWith("/webhooks/");
77
+ }
78
+
79
+ async function verifyAccessToken(token: string, config: AuthConfig): Promise<JwtClaims> {
80
+ const parts = token.split(".");
81
+ if (parts.length !== 3 || !config.issuer || !config.audience || !config.jwksUrl) {
82
+ throw new Error("invalid auth config or token");
83
+ }
84
+
85
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
86
+ const header = decodeJSON<JwtHeader>(encodedHeader);
87
+ const claims = decodeJSON<JwtClaims>(encodedPayload);
88
+ const jwks = await fetchJwks(config.jwksUrl);
89
+ const key = selectKey(jwks, header);
90
+ if (!key || !header.alg) {
91
+ throw new Error("matching jwk not found");
92
+ }
93
+
94
+ const algorithm = importAlgorithm(header.alg, key);
95
+ const cryptoKey = await crypto.subtle.importKey("jwk", key, algorithm.import, false, ["verify"]);
96
+ const verified = await crypto.subtle.verify(
97
+ algorithm.verify,
98
+ cryptoKey,
99
+ toArrayBuffer(decodeBase64Url(encodedSignature)),
100
+ encoder.encode(`${encodedHeader}.${encodedPayload}`)
101
+ );
102
+ if (!verified) {
103
+ throw new Error("bad signature");
104
+ }
105
+
106
+ validateClaims(claims, config);
107
+ return claims;
108
+ }
109
+
110
+ async function fetchJwks(jwksUrl: string): Promise<Jwks> {
111
+ const cached = jwksCache.get(jwksUrl);
112
+ if (cached && cached.expiresAt > Date.now()) {
113
+ return cached.jwks;
114
+ }
115
+
116
+ const response = await fetch(jwksUrl);
117
+ if (!response.ok) {
118
+ throw new Error(`jwks fetch failed: ${response.status}`);
119
+ }
120
+ const jwks = (await response.json()) as Jwks;
121
+ jwksCache.set(jwksUrl, { jwks, expiresAt: Date.now() + 5 * 60 * 1000 });
122
+ return jwks;
123
+ }
124
+
125
+ function selectKey(jwks: Jwks, header: JwtHeader): Jwk | undefined {
126
+ if (header.kid) {
127
+ return jwks.keys.find((key) => key.kid === header.kid);
128
+ }
129
+ return jwks.keys.length === 1 ? jwks.keys[0] : undefined;
130
+ }
131
+
132
+ function importAlgorithm(alg: string, key: JsonWebKey) {
133
+ if (alg === "RS256") {
134
+ return {
135
+ import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
136
+ verify: { name: "RSASSA-PKCS1-v1_5" },
137
+ } as const;
138
+ }
139
+ if (alg === "ES256" && key.crv === "P-256") {
140
+ return {
141
+ import: { name: "ECDSA", namedCurve: "P-256" },
142
+ verify: { name: "ECDSA", hash: "SHA-256" },
143
+ } as const;
144
+ }
145
+ if (alg === "EdDSA" && key.crv === "Ed25519") {
146
+ return {
147
+ import: { name: "Ed25519" },
148
+ verify: { name: "Ed25519" },
149
+ } as const;
150
+ }
151
+ throw new Error(`unsupported jwt alg: ${alg}`);
152
+ }
153
+
154
+ function validateClaims(claims: JwtClaims, config: AuthConfig) {
155
+ const now = Math.floor(Date.now() / 1000);
156
+ if (claims.iss !== config.issuer) {
157
+ throw new Error("issuer mismatch");
158
+ }
159
+ if (!audienceMatches(claims.aud, config.audience)) {
160
+ throw new Error("audience mismatch");
161
+ }
162
+ if (typeof claims.exp !== "number" || claims.exp <= now - 30) {
163
+ throw new Error("token expired");
164
+ }
165
+ if (typeof claims.nbf === "number" && claims.nbf > now + 30) {
166
+ throw new Error("token not active");
167
+ }
168
+ }
169
+
170
+ function audienceMatches(audience: JwtClaims["aud"], expected: string) {
171
+ return Array.isArray(audience) ? audience.includes(expected) : audience === expected;
172
+ }
173
+
174
+ function decodeJSON<T>(value: string): T {
175
+ return JSON.parse(new TextDecoder().decode(decodeBase64Url(value))) as T;
176
+ }
177
+
178
+ function decodeBase64Url(value: string): Uint8Array {
179
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
180
+ return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
181
+ }
182
+
183
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
184
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
185
+ }
186
+
187
+ function bearerToken(value: string) {
188
+ const [scheme, token] = value.trim().split(/\s+/, 2);
189
+ return /^Bearer$/i.test(scheme) ? token : "";
190
+ }
191
+
192
+ function respondUnauthorized(response: any) {
193
+ response.statusCode = 401;
194
+ response.setHeader("Content-Type", "application/json");
195
+ response.end(JSON.stringify({ error: "invalid bearer token", code: "unauthorized" }));
196
+ }
197
+
198
+ function truthy(value: string | undefined) {
199
+ return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
200
+ }
@@ -0,0 +1,15 @@
1
+ import { SQL } from "bun";
2
+ import { drizzle } from "drizzle-orm/bun-sql";
3
+
4
+ export function requireDatabaseUrl() {
5
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
6
+ if (!databaseUrl) {
7
+ throw new Error("DATABASE_URL is required");
8
+ }
9
+ return databaseUrl;
10
+ }
11
+
12
+ export function createDb(databaseUrl = requireDatabaseUrl()) {
13
+ const client = new SQL(databaseUrl);
14
+ return drizzle({ client });
15
+ }
@@ -0,0 +1,126 @@
1
+ import { desc, eq } from "drizzle-orm";
2
+ import type { createDb } from "./client";
3
+ import { waitlistEntries, waitlistTriggers } from "./schema";
4
+ import type { WaitlistEntry, WaitlistTrigger } from "../waitlist/types";
5
+
6
+ type Database = ReturnType<typeof createDb>;
7
+ type WaitlistEntryRow = typeof waitlistEntries.$inferSelect;
8
+
9
+ type CreateEntryRecord = {
10
+ id: string;
11
+ email: string;
12
+ name: string | null;
13
+ company: string | null;
14
+ source: string | null;
15
+ };
16
+
17
+ type CreateTriggerRecord = {
18
+ id: string;
19
+ type: string;
20
+ entryId: string | null;
21
+ payloadJson: string;
22
+ };
23
+
24
+ export class WaitlistRepository {
25
+ constructor(private readonly db: Database) {}
26
+
27
+ async createEntry(input: CreateEntryRecord): Promise<WaitlistEntry> {
28
+ const now = new Date();
29
+ const [row] = await this.db
30
+ .insert(waitlistEntries)
31
+ .values({
32
+ id: input.id,
33
+ email: input.email,
34
+ name: input.name,
35
+ company: input.company,
36
+ source: input.source,
37
+ status: "joined",
38
+ createdAt: now,
39
+ updatedAt: now,
40
+ })
41
+ .returning();
42
+ return toWaitlistEntry(row);
43
+ }
44
+
45
+ async getEntryById(entryId: string): Promise<WaitlistEntry | null> {
46
+ const [row] = await this.db.select().from(waitlistEntries).where(eq(waitlistEntries.id, entryId)).limit(1);
47
+ return row ? toWaitlistEntry(row) : null;
48
+ }
49
+
50
+ async getEntryByEmail(email: string): Promise<WaitlistEntry | null> {
51
+ const [row] = await this.db.select().from(waitlistEntries).where(eq(waitlistEntries.email, email)).limit(1);
52
+ return row ? toWaitlistEntry(row) : null;
53
+ }
54
+
55
+ async listEntries(input: { status?: string | null; limit?: number | null } = {}): Promise<WaitlistEntry[]> {
56
+ const limit = clampLimit(input.limit);
57
+ const rows = input.status
58
+ ? await this.db
59
+ .select()
60
+ .from(waitlistEntries)
61
+ .where(eq(waitlistEntries.status, input.status as WaitlistEntryRow["status"]))
62
+ .orderBy(desc(waitlistEntries.createdAt))
63
+ .limit(limit)
64
+ : await this.db.select().from(waitlistEntries).orderBy(desc(waitlistEntries.createdAt)).limit(limit);
65
+ return rows.map(toWaitlistEntry);
66
+ }
67
+
68
+ async updateEntryStatus(entryId: string, status: WaitlistEntryRow["status"]): Promise<WaitlistEntry | null> {
69
+ const [row] = await this.db
70
+ .update(waitlistEntries)
71
+ .set({
72
+ status,
73
+ updatedAt: new Date(),
74
+ })
75
+ .where(eq(waitlistEntries.id, entryId))
76
+ .returning();
77
+ return row ? toWaitlistEntry(row) : null;
78
+ }
79
+
80
+ async createTrigger(input: CreateTriggerRecord): Promise<WaitlistTrigger> {
81
+ const [row] = await this.db
82
+ .insert(waitlistTriggers)
83
+ .values({
84
+ id: input.id,
85
+ type: input.type,
86
+ entryId: input.entryId,
87
+ status: "queued",
88
+ payloadJson: input.payloadJson,
89
+ createdAt: new Date(),
90
+ })
91
+ .returning();
92
+ return toWaitlistTrigger(row);
93
+ }
94
+ }
95
+
96
+ function clampLimit(value: number | null | undefined) {
97
+ if (!value || !Number.isFinite(value)) {
98
+ return 100;
99
+ }
100
+ return Math.min(Math.max(Math.trunc(value), 1), 500);
101
+ }
102
+
103
+ function toWaitlistEntry(row: WaitlistEntryRow): WaitlistEntry {
104
+ return {
105
+ id: row.id,
106
+ email: row.email,
107
+ name: row.name,
108
+ company: row.company,
109
+ source: row.source,
110
+ status: row.status,
111
+ createdAt: row.createdAt.toISOString(),
112
+ updatedAt: row.updatedAt.toISOString(),
113
+ };
114
+ }
115
+
116
+ function toWaitlistTrigger(row: typeof waitlistTriggers.$inferSelect): WaitlistTrigger {
117
+ return {
118
+ id: row.id,
119
+ type: row.type,
120
+ entryId: row.entryId,
121
+ status: row.status,
122
+ payloadJson: row.payloadJson,
123
+ createdAt: row.createdAt.toISOString(),
124
+ processedAt: row.processedAt?.toISOString() ?? null,
125
+ };
126
+ }
@@ -0,0 +1,26 @@
1
+ import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
2
+
3
+ export const waitlistEntries = pgTable(
4
+ "waitlist_entries",
5
+ {
6
+ id: text("id").primaryKey(),
7
+ email: text("email").notNull(),
8
+ name: text("name"),
9
+ company: text("company"),
10
+ source: text("source"),
11
+ status: text("status").$type<"joined" | "invited" | "converted" | "archived">().notNull().default("joined"),
12
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
13
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
14
+ },
15
+ (table) => [uniqueIndex("waitlist_entries_email_key").on(table.email)]
16
+ );
17
+
18
+ export const waitlistTriggers = pgTable("waitlist_triggers", {
19
+ id: text("id").primaryKey(),
20
+ type: text("type").notNull(),
21
+ entryId: text("entry_id").references(() => waitlistEntries.id),
22
+ status: text("status").$type<"queued" | "processed" | "failed">().notNull().default("queued"),
23
+ payloadJson: text("payload_json").notNull(),
24
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
25
+ processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
26
+ });
@@ -1,32 +1,204 @@
1
- type ConnectResponse = {
2
- message: string;
3
- };
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
3
+ import type { ServiceImpl } from "@connectrpc/connect";
4
+ import { createServer } from "node:http";
5
+ import { WaitlistService as WaitlistRpcService } from "../gen/protos/waitlist/v1/waitlist_pb.js";
6
+ import { withServiceAuth } from "./auth";
7
+ import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
8
+ import { startTemporalWorker } from "./temporal/worker";
9
+ import type { WaitlistEntry, WaitlistTrigger } from "./waitlist/types";
4
10
 
5
- export async function handleRequest(request: Request) {
6
- const url = new URL(request.url);
11
+ type RpcService = ServiceImpl<typeof WaitlistRpcService>;
12
+ type FallbackHandler = NonNullable<Parameters<typeof connectNodeAdapter>[0]["fallback"]>;
7
13
 
8
- if (url.pathname === "/healthz") {
9
- return Response.json({ status: "ok", runtime: "bun", framework: "connectrpc" });
14
+ export function createRpcService(service: WaitlistService): Partial<RpcService> {
15
+ return {
16
+ async joinWaitlist(request) {
17
+ const result = await service.joinWaitlist({
18
+ email: request.email,
19
+ name: request.name || null,
20
+ company: request.company || null,
21
+ source: request.source || null,
22
+ });
23
+ return { entry: toRpcEntry(result.entry), created: result.created };
24
+ },
25
+ async getWaitlistEntry(request) {
26
+ return { entry: toRpcEntry(await service.getWaitlistEntry(request.entryId)) };
27
+ },
28
+ async getWaitlistEntryByEmail(request) {
29
+ return { entry: toRpcEntry(await service.getWaitlistEntryByEmail(request.email)) };
30
+ },
31
+ async listWaitlistEntries(request) {
32
+ const entries = await service.listWaitlistEntries({
33
+ status: request.status || null,
34
+ limit: request.limit || null,
35
+ });
36
+ return { entries: entries.map(toRpcEntry) };
37
+ },
38
+ async updateWaitlistEntry(request) {
39
+ return {
40
+ entry: toRpcEntry(
41
+ await service.updateWaitlistEntry({
42
+ entryId: request.entryId,
43
+ status: request.status,
44
+ })
45
+ ),
46
+ };
47
+ },
48
+ async exportWaitlistEntries(request) {
49
+ return {
50
+ csv: await service.exportWaitlistEntries({
51
+ status: request.status || null,
52
+ limit: request.limit || null,
53
+ }),
54
+ };
55
+ },
56
+ async recordTrigger(request) {
57
+ const trigger = (await service.recordTrigger({
58
+ type: request.type,
59
+ entryId: request.entryId || null,
60
+ payloadJson: request.payloadJson || "{}",
61
+ })) as WaitlistTrigger;
62
+ return { trigger: toRpcTrigger(trigger) };
63
+ },
64
+ };
65
+ }
66
+
67
+ export function createHandler(service: WaitlistService) {
68
+ return connectNodeAdapter({
69
+ routes: (router) => {
70
+ router.service(WaitlistRpcService, createRpcService(service));
71
+ },
72
+ fallback: (async (request: Parameters<FallbackHandler>[0], response: Parameters<FallbackHandler>[1]) => {
73
+ const url = new URL(request.url ?? "/", "http://localhost");
74
+ const path = url.pathname;
75
+
76
+ if (path === "/healthz" || path === "/readyz") {
77
+ respondJson(response, 200, { status: "ok" });
78
+ return;
79
+ }
80
+
81
+ if (path === "/") {
82
+ respondJson(response, 200, {
83
+ service: "{{SERVICE_NAME}}",
84
+ domain: "waitlist",
85
+ apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (path === "/debug/connectrpc" && isLocalRpcIntrospectionEnabled()) {
91
+ respondJson(response, 200, createIntrospectionDocument());
92
+ return;
93
+ }
94
+
95
+ if (request.method === "POST" && path.startsWith("/webhooks/")) {
96
+ try {
97
+ const provider = path.split("/").filter(Boolean)[1] ?? "generic";
98
+ const rawBody = await readRawBody(request);
99
+ const trigger = await service.recordTrigger({
100
+ type: `webhook.${provider}`,
101
+ payloadJson: JSON.stringify({ headers: request.headers, rawBody }),
102
+ });
103
+ respondJson(response, 202, { trigger });
104
+ } catch (error) {
105
+ respondAppError(response, error);
106
+ }
107
+ return;
108
+ }
109
+
110
+ if (request.method === "GET" && path.startsWith("/webhooks/") && path.endsWith("/health")) {
111
+ const provider = path.split("/").filter(Boolean)[1] ?? "generic";
112
+ respondJson(response, 200, { status: "ok", provider });
113
+ return;
114
+ }
115
+
116
+ respondJson(response, 404, { error: "not found" });
117
+ }) as FallbackHandler,
118
+ });
119
+ }
120
+
121
+ export function createIntrospectionDocument() {
122
+ return {
123
+ service: WaitlistRpcService.typeName,
124
+ file: WaitlistRpcService.file.proto.name,
125
+ methods: WaitlistRpcService.methods.map((method) => ({
126
+ name: method.name,
127
+ localName: method.localName,
128
+ kind: method.methodKind,
129
+ input: method.input.typeName,
130
+ output: method.output.typeName,
131
+ })),
132
+ };
133
+ }
134
+
135
+ export function isLocalRpcIntrospectionEnabled() {
136
+ const override = Bun.env.ENABLE_RPC_INTROSPECTION?.trim().toLowerCase();
137
+ if (override) {
138
+ return !["0", "false", "no", "off"].includes(override);
10
139
  }
140
+ return !Bun.env.K_SERVICE && Bun.env.NODE_ENV !== "production";
141
+ }
142
+
143
+ function toRpcEntry(entry: WaitlistEntry) {
144
+ return {
145
+ id: entry.id,
146
+ email: entry.email,
147
+ name: entry.name ?? "",
148
+ company: entry.company ?? "",
149
+ source: entry.source ?? "",
150
+ status: entry.status,
151
+ createdAt: entry.createdAt,
152
+ updatedAt: entry.updatedAt,
153
+ };
154
+ }
155
+
156
+ function toRpcTrigger(trigger: WaitlistTrigger) {
157
+ return {
158
+ id: trigger.id,
159
+ type: trigger.type,
160
+ entryId: trigger.entryId ?? "",
161
+ status: trigger.status,
162
+ payloadJson: trigger.payloadJson,
163
+ createdAt: trigger.createdAt,
164
+ processedAt: trigger.processedAt ?? "",
165
+ };
166
+ }
167
+
168
+ function respondJson(response: Parameters<FallbackHandler>[1], status: number, body: unknown) {
169
+ response.statusCode = status;
170
+ response.setHeader("Content-Type", "application/json");
171
+ response.end(JSON.stringify(body));
172
+ }
11
173
 
12
- if (url.pathname === "/rpc.example.v1.Service/Ping" && request.method === "POST") {
13
- const payload = (await request.json().catch(() => ({}))) as { name?: string };
14
- const body: ConnectResponse = {
15
- message: `hello ${payload.name?.trim() || "{{SERVICE_NAME}}"}`,
16
- };
17
- return Response.json(body, {
18
- headers: {
19
- "Content-Type": "application/json",
20
- },
21
- });
174
+ function respondAppError(response: Parameters<FallbackHandler>[1], error: unknown) {
175
+ if (error instanceof AppError) {
176
+ respondJson(response, error.status, { error: error.message, code: error.code });
177
+ return;
22
178
  }
179
+ if (error instanceof ConnectError) {
180
+ respondJson(response, 500, { error: error.message, code: error.code });
181
+ return;
182
+ }
183
+ respondJson(response, 500, { error: error instanceof Error ? error.message : String(error), code: Code[Code.Internal] });
184
+ }
23
185
 
24
- return Response.json({ error: "not found" }, { status: 404 });
186
+ function readRawBody(request: Parameters<FallbackHandler>[0]) {
187
+ return new Promise<string>((resolve, reject) => {
188
+ const chunks: Buffer[] = [];
189
+ request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
190
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
191
+ request.on("error", reject);
192
+ });
25
193
  }
26
194
 
27
195
  if (import.meta.main) {
28
- Bun.serve({
29
- port: Number(Bun.env.PORT ?? 8080),
30
- fetch: handleRequest,
31
- });
196
+ const temporalWorker = await startTemporalWorker();
197
+ if (temporalWorker) {
198
+ console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
199
+ }
200
+
201
+ const port = Number(Bun.env.PORT ?? 8080);
202
+ const server = createServer(withServiceAuth(createHandler(createDefaultWaitlistService())));
203
+ server.listen(port);
32
204
  }
@@ -0,0 +1,14 @@
1
+ export type WaitlistFollowUpInput = {
2
+ triggerId?: string;
3
+ email?: string;
4
+ type: string;
5
+ };
6
+
7
+ export async function recordWaitlistFollowUp(input: WaitlistFollowUpInput) {
8
+ return {
9
+ status: "queued",
10
+ triggerId: input.triggerId ?? null,
11
+ email: input.email ?? null,
12
+ type: input.type,
13
+ };
14
+ }