Skip to content

Commit 4daf845

Browse files
tonyxiaoclaude
andcommitted
feat(service): split webhook ingress into its own app and CLI subcommand
Extract the webhook route into `createWebhookApp({ push_event })` — a thin, standalone Hono app with no CRUD machinery. The main service app mounts it via `app.route('')` so existing behavior is unchanged. Add `sync-service webhook` subcommand for production deployments where webhook ingress runs on its own host/port (Temporal mode only). It creates a TemporalBridge directly — no full SyncService needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
1 parent ad727a5 commit 4daf845

4 files changed

Lines changed: 119 additions & 34 deletions

File tree

apps/service/src/api/app.ts

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
fileStateStore,
2424
fileLogSink,
2525
} from '../lib/stores-fs.js'
26+
import { createWebhookApp } from './webhook-app.js'
2627

2728
// MARK: - Helpers
2829

@@ -118,13 +119,6 @@ export function createApp(options?: AppOptions) {
118119
id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: 'sync_abc123' }),
119120
})
120121

121-
const WebhookParam = z.object({
122-
credential_id: z.string().openapi({
123-
param: { name: 'credential_id', in: 'path' },
124-
example: 'cred_abc123',
125-
}),
126-
})
127-
128122
// ── Health ──────────────────────────────────────────────────────
129123

130124
app.openapi(
@@ -800,33 +794,9 @@ export function createApp(options?: AppOptions) {
800794
}
801795
)
802796

803-
// MARK: - Webhook ingress
797+
// MARK: - Webhook ingress (mounted from webhook-app.ts)
804798

805-
app.openapi(
806-
createRoute({
807-
operationId: 'pushWebhook',
808-
method: 'post',
809-
path: '/webhooks/{credential_id}',
810-
tags: ['Webhooks'],
811-
summary: 'Ingest a Stripe webhook event',
812-
description:
813-
"Receives a raw Stripe webhook event, verifies its signature using the credential's webhook secret, and enqueues it for processing by the active sync.",
814-
request: { params: WebhookParam },
815-
responses: {
816-
200: {
817-
content: { 'text/plain': { schema: z.literal('ok') } },
818-
description: 'Event accepted',
819-
},
820-
},
821-
}),
822-
async (c) => {
823-
const { credential_id } = c.req.valid('param')
824-
const body = await c.req.text()
825-
const headers = Object.fromEntries(c.req.raw.headers.entries())
826-
service.push_event(credential_id, { body, headers })
827-
return c.text('ok', 200)
828-
}
829-
)
799+
app.route('', createWebhookApp({ push_event: (id, e) => service.push_event(id, e) }))
830800

831801
// MARK: - OpenAPI spec + Swagger UI
832802

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
2+
3+
export interface WebhookAppOptions {
4+
/** Called for each incoming webhook event. Fire-and-forget. */
5+
push_event: (credentialId: string, event: unknown) => void
6+
}
7+
8+
/**
9+
* Standalone webhook ingress app — POST /webhooks/{credential_id}.
10+
*
11+
* Deliberately thin: no credential management, no sync CRUD. Just receives a
12+
* raw Stripe event and hands it off to `push_event`, which in Temporal mode
13+
* signals the matching workflow(s) via TemporalBridge.
14+
*
15+
* Used in two ways:
16+
* 1. Mounted inside the main service app for single-process dev.
17+
* 2. As a standalone server via `sync-service webhook` for production
18+
* deployments where webhook ingress runs on its own port/host.
19+
*/
20+
export function createWebhookApp({ push_event }: WebhookAppOptions) {
21+
const app = new OpenAPIHono({
22+
defaultHook: (result, c) => {
23+
if (!result.success) {
24+
return c.json({ error: result.error.issues }, 400)
25+
}
26+
},
27+
})
28+
29+
const WebhookParam = z.object({
30+
credential_id: z.string().openapi({
31+
param: { name: 'credential_id', in: 'path' },
32+
example: 'cred_abc123',
33+
}),
34+
})
35+
36+
app.openapi(
37+
createRoute({
38+
operationId: 'pushWebhook',
39+
method: 'post',
40+
path: '/webhooks/{credential_id}',
41+
tags: ['Webhooks'],
42+
summary: 'Ingest a Stripe webhook event',
43+
description:
44+
"Receives a raw Stripe webhook event, verifies its signature using the credential's webhook secret, and enqueues it for processing by the active sync.",
45+
request: { params: WebhookParam },
46+
responses: {
47+
200: {
48+
content: { 'text/plain': { schema: z.literal('ok') } },
49+
description: 'Event accepted',
50+
},
51+
},
52+
}),
53+
async (c) => {
54+
const { credential_id } = c.req.valid('param')
55+
const body = await c.req.text()
56+
const headers = Object.fromEntries(c.req.raw.headers.entries())
57+
push_event(credential_id, { body, headers })
58+
return c.text('ok', 200)
59+
}
60+
)
61+
62+
return app
63+
}

apps/service/src/cli/main.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path'
2+
import os from 'node:os'
23
import { Readable } from 'node:stream'
34
import { defineCommand } from 'citty'
45
import { createCliFromSpec } from '@stripe/sync-ts-cli/openapi'
@@ -125,6 +126,53 @@ const workerCmd = defineCommand({
125126
},
126127
})
127128

129+
// Standalone webhook ingress command (Temporal mode only)
130+
const webhookCmd = defineCommand({
131+
meta: { name: 'webhook', description: 'Start the webhook ingress server (Temporal mode)' },
132+
args: {
133+
port: {
134+
type: 'string',
135+
default: '4030',
136+
description: 'HTTP server port (default: 4030)',
137+
},
138+
'data-dir': {
139+
type: 'string',
140+
description: 'Data directory — must point at the same store as the sync service',
141+
},
142+
'temporal-address': {
143+
type: 'string',
144+
required: true,
145+
description: 'Temporal server address (e.g. localhost:7233)',
146+
},
147+
'temporal-task-queue': {
148+
type: 'string',
149+
default: 'sync-engine',
150+
description: 'Temporal task queue name (default: sync-engine)',
151+
},
152+
},
153+
async run({ args }) {
154+
const { fileConfigStore } = await import('../lib/stores-fs.js')
155+
const { TemporalBridge } = await import('../temporal/bridge.js')
156+
const { createWebhookApp } = await import('../api/webhook-app.js')
157+
158+
const temporal = await createTemporalClient(
159+
args['temporal-address'],
160+
args['temporal-task-queue'] || 'sync-engine'
161+
)
162+
const dataDir =
163+
args['data-dir'] || process.env.DATA_DIR || path.join(os.homedir(), '.stripe-sync')
164+
const configs = fileConfigStore(`${dataDir}/syncs`)
165+
const bridge = new TemporalBridge(temporal.client, temporal.taskQueue, configs)
166+
167+
const app = createWebhookApp({ push_event: (id, e) => bridge.pushEvent(id, e) })
168+
const port = Number(args.port)
169+
serve({ fetch: app.fetch, port }, () => {
170+
console.log(`Webhook server listening on http://localhost:${port}`)
171+
console.log(` Temporal: ${args['temporal-address']} (queue: ${args['temporal-task-queue'] || 'sync-engine'})`)
172+
})
173+
},
174+
})
175+
128176
export async function createProgram(opts?: { dataDir?: string }) {
129177
const app = createApp({ dataDir: opts?.dataDir })
130178
const res = await app.request('/openapi.json')
@@ -155,6 +203,6 @@ export async function createProgram(opts?: { dataDir?: string }) {
155203

156204
return defineCommand({
157205
...specCli,
158-
subCommands: { serve: serveCmd, worker: workerCmd, ...specCli.subCommands },
206+
subCommands: { serve: serveCmd, worker: workerCmd, webhook: webhookCmd, ...specCli.subCommands },
159207
})
160208
}

apps/service/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export type { TemporalOptions } from './temporal/bridge.js'
4141
export { createApp } from './api/app.js'
4242
export type { AppOptions } from './api/app.js'
4343

44+
// Standalone webhook ingress app
45+
export { createWebhookApp } from './api/webhook-app.js'
46+
export type { WebhookAppOptions } from './api/webhook-app.js'
47+
4448
// Temporal workflow types (for consumers that need to reference them)
4549
export type { RunResult, SyncActivities, WorkflowStatus } from './temporal/types.js'
4650
export { createActivities } from './temporal/activities.js'

0 commit comments

Comments
 (0)