Skip to content

Commit 2d2bac6

Browse files
authored
refactor: split sync-engine CLI and serve binaries (#310)
Separate the bundled server startup path from the full OpenAPI CLI bootstrap so Docker and local development can use a minimal server entrypoint without CLI-only connector discovery behavior. Constraint: Keep connector discovery policy at the entrypoint boundary Confidence: medium Scope-risk: moderate Not-tested: Docker daemon-based checks unavailable in this environment Made-with: Cursor Committed-By-Agent: cursor
1 parent 456e3e7 commit 2d2bac6

25 files changed

Lines changed: 412 additions & 158 deletions

Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ ENV NODE_ENV=production
4949
ENV GIT_COMMIT=$GIT_COMMIT
5050
ENV BUILD_DATE=$BUILD_DATE
5151
ENV COMMIT_URL=$COMMIT_URL
52-
ENTRYPOINT ["node", "--use-env-proxy", "dist/cli/index.js"]
53-
CMD ["serve"]
52+
ENTRYPOINT ["node", "--use-env-proxy", "dist/bin/serve.js"]
5453

5554
# ===========================================================================
5655
# Service (also used for the worker container — same image, different CMD)

apps/engine/package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"description": "Stripe Sync Engine — sync Stripe data to Postgres",
66
"type": "module",
77
"bin": {
8-
"sync-engine": "./dist/cli/index.js"
8+
"sync-engine": "./dist/bin/sync-engine.js",
9+
"sync-engine-serve": "./dist/bin/serve.js"
910
},
1011
"exports": {
1112
".": {
@@ -19,9 +20,9 @@
1920
"import": "./dist/cli/command.js"
2021
},
2122
"./api": {
22-
"bun": "./src/api/app.ts",
23-
"types": "./dist/api/app.d.ts",
24-
"import": "./dist/api/app.js"
23+
"bun": "./src/api/index.ts",
24+
"types": "./dist/api/index.d.ts",
25+
"import": "./dist/api/index.js"
2526
},
2627
"./api/openapi-utils": {
2728
"bun": "./src/api/openapi-utils.ts",
@@ -32,7 +33,7 @@
3233
"scripts": {
3334
"build": "tsc",
3435
"x:watch": "sh -c 'if command -v bun > /dev/null 2>&1; then bun --watch \"$@\"; else tsx --watch --conditions bun \"$@\"; fi' --",
35-
"dev": "LOG_LEVEL=${LOG_LEVEL:-trace} LOG_PRETTY=${LOG_PRETTY:-true} DANGEROUSLY_VERBOSE_LOGGING=true pnpm x:watch src/api/index.ts",
36+
"dev": "LOG_LEVEL=${LOG_LEVEL:-trace} LOG_PRETTY=${LOG_PRETTY:-true} DANGEROUSLY_VERBOSE_LOGGING=true pnpm x:watch src/bin/serve.ts",
3637
"test": "vitest run",
3738
"generate:types": "openapi-typescript src/__generated__/openapi.json -o src/__generated__/openapi.d.ts"
3839
},
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const bootstrap = vi.fn()
4+
const resolver = {
5+
resolveSource: vi.fn(),
6+
resolveDestination: vi.fn(),
7+
sources: () => new Map(),
8+
destinations: () => new Map(),
9+
}
10+
const createConnectorResolver = vi.fn(async () => resolver)
11+
const startApiServer = vi.fn()
12+
const defaultConnectors = {
13+
sources: { stripe: {} },
14+
destinations: { postgres: {}, google_sheets: {} },
15+
}
16+
17+
vi.mock('../bin/bootstrap.js', () => ({
18+
bootstrap,
19+
}))
20+
21+
vi.mock('../lib/index.js', () => ({
22+
createConnectorResolver,
23+
}))
24+
25+
vi.mock('../lib/default-connectors.js', () => ({
26+
defaultConnectors,
27+
}))
28+
29+
vi.mock('../api/server.js', () => ({
30+
startApiServer,
31+
}))
32+
33+
describe('serve bin', () => {
34+
beforeEach(() => {
35+
vi.resetModules()
36+
vi.clearAllMocks()
37+
delete process.env.PORT
38+
})
39+
40+
it('starts the bundled-only server with dynamic discovery disabled', async () => {
41+
process.env.PORT = '4010'
42+
43+
await import('../bin/serve.js')
44+
45+
expect(bootstrap).toHaveBeenCalledOnce()
46+
expect(createConnectorResolver).toHaveBeenCalledWith(defaultConnectors, {
47+
path: false,
48+
npm: false,
49+
})
50+
expect(startApiServer).toHaveBeenCalledWith({
51+
resolver,
52+
port: 4010,
53+
})
54+
})
55+
})

apps/engine/src/__tests__/docker.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ describe('Docker image', { timeout: 180_000 }, () => {
3232
})
3333

3434
it('--version prints version and exits', () => {
35-
const out = docker('run', '--rm', IMAGE, '--version')
35+
const out = docker('run', '--rm', '--entrypoint', 'node', IMAGE, 'dist/bin/sync-engine.js', '--version')
3636
expect(out).toMatch(/\d+\.\d+\.\d+/)
3737
})
3838

3939
it('--help prints usage and exits', () => {
40-
const out = docker('run', '--rm', IMAGE, '--help')
40+
const out = docker('run', '--rm', '--entrypoint', 'node', IMAGE, 'dist/bin/sync-engine.js', '--help')
4141
expect(out).toContain('sync-engine')
4242
expect(out).toContain('serve')
4343
expect(out).toContain('sync')
@@ -73,7 +73,17 @@ describe('Docker image', { timeout: 180_000 }, () => {
7373

7474
it('check exits non-zero without valid config', () => {
7575
expect(() =>
76-
docker('run', '--rm', IMAGE, 'check', '--postgres-url', 'postgres://invalid:5432/db')
76+
docker(
77+
'run',
78+
'--rm',
79+
'--entrypoint',
80+
'node',
81+
IMAGE,
82+
'dist/bin/sync-engine.js',
83+
'check',
84+
'--postgres-url',
85+
'postgres://invalid:5432/db'
86+
)
7787
).toThrow()
7888
})
7989
})

apps/engine/src/api/index.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const createConnectorResolver = vi.fn(async () => ({}))
4+
const createApp = vi.fn(async () => ({ fetch: vi.fn() }))
5+
const startApiServer = vi.fn()
6+
const serve = vi.fn()
7+
8+
vi.mock('../lib/index.js', () => ({
9+
createConnectorResolver,
10+
}))
11+
12+
vi.mock('./app.js', () => ({
13+
createApp,
14+
}))
15+
16+
vi.mock('./server.js', () => ({
17+
startApiServer,
18+
}))
19+
20+
vi.mock('@hono/node-server', () => ({
21+
serve,
22+
}))
23+
24+
vi.mock('../logger.js', () => ({
25+
logger: {
26+
info: vi.fn(),
27+
warn: vi.fn(),
28+
error: vi.fn(),
29+
},
30+
}))
31+
32+
describe('api/index', () => {
33+
beforeEach(() => {
34+
vi.resetModules()
35+
vi.clearAllMocks()
36+
Reflect.deleteProperty(globalThis as Record<string, unknown>, 'Bun')
37+
})
38+
39+
it('exports the API surface without starting a server', async () => {
40+
const mod = await import('./index.js')
41+
42+
expect(typeof mod.createApp).toBe('function')
43+
expect(typeof mod.startApiServer).toBe('function')
44+
expect(createConnectorResolver).not.toHaveBeenCalled()
45+
expect(createApp).not.toHaveBeenCalled()
46+
expect(startApiServer).not.toHaveBeenCalled()
47+
expect(serve).not.toHaveBeenCalled()
48+
})
49+
})

apps/engine/src/api/index.ts

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,3 @@
1-
#!/usr/bin/env node
2-
3-
import source from '@stripe/sync-source-stripe'
4-
import pgDestination from '@stripe/sync-destination-postgres'
5-
import sheetsDestination from '@stripe/sync-destination-google-sheets'
6-
import { createConnectorResolver } from '../lib/index.js'
7-
import { createApp } from './app.js'
8-
import { logger } from '../logger.js'
9-
import { ENGINE_SERVER_OPTIONS } from '../http-server-options.js'
10-
11-
const port = Number(process.env.PORT || 3001)
12-
13-
async function main() {
14-
if (process.env.DANGEROUSLY_VERBOSE_LOGGING === 'true') {
15-
logger.warn(
16-
'⚠️ DANGEROUSLY_VERBOSE_LOGGING is enabled — all request headers and message payloads will be logged. Do not use in production.'
17-
)
18-
}
19-
20-
const resolver = await createConnectorResolver({
21-
sources: { stripe: source },
22-
destinations: { postgres: pgDestination, google_sheets: sheetsDestination },
23-
})
24-
const app = await createApp(resolver)
25-
26-
// Use the web-standard fetch handler with the runtime's native server.
27-
// Bun.serve() properly cancels ReadableStreams on client disconnect;
28-
// @hono/node-server is the fallback for Node.js / tsx.
29-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
30-
if (typeof (globalThis as any).Bun !== 'undefined') {
31-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32-
;(globalThis as any).Bun.serve({ fetch: app.fetch, port, idleTimeout: 60 })
33-
logger.warn(
34-
{ port, server: 'Bun.serve' },
35-
`Sync Engine API listening on http://localhost:${port}`
36-
)
37-
} else {
38-
const { serve } = await import('@hono/node-server')
39-
serve(
40-
{
41-
fetch: app.fetch,
42-
port,
43-
serverOptions: ENGINE_SERVER_OPTIONS,
44-
},
45-
(info) => {
46-
logger.info(
47-
{ port: info.port },
48-
`Sync Engine API listening on http://localhost:${info.port}`
49-
)
50-
}
51-
)
52-
}
53-
}
54-
55-
main()
1+
export { createApp } from './app.js'
2+
export { startApiServer } from './server.js'
3+
export type { StartApiServerOptions } from './server.js'

apps/engine/src/api/server.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { serve } from '@hono/node-server'
2+
import type { ConnectorResolver } from '../lib/index.js'
3+
import { createApp } from './app.js'
4+
import { logger } from '../logger.js'
5+
import { ENGINE_SERVER_OPTIONS } from '../http-server-options.js'
6+
7+
export interface StartApiServerOptions {
8+
resolver: ConnectorResolver
9+
port?: number
10+
}
11+
12+
type BunLike = {
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
serve: (options: {
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
fetch: (...args: any[]) => unknown
17+
port: number
18+
idleTimeout?: number
19+
}) => unknown
20+
}
21+
22+
export async function startApiServer({ resolver, port }: StartApiServerOptions) {
23+
const listenPort = port ?? Number(process.env['PORT'] || 3000)
24+
25+
if (process.env.DANGEROUSLY_VERBOSE_LOGGING === 'true') {
26+
logger.warn(
27+
'⚠️ DANGEROUSLY_VERBOSE_LOGGING is enabled — all request headers and message payloads will be logged. Do not use in production.'
28+
)
29+
}
30+
31+
const app = await createApp(resolver)
32+
const bun = (globalThis as typeof globalThis & { Bun?: BunLike }).Bun
33+
34+
if (bun) {
35+
bun.serve({ fetch: app.fetch, port: listenPort, idleTimeout: 60 })
36+
logger.warn(
37+
{ port: listenPort, server: 'Bun.serve' },
38+
`Sync Engine API listening on http://localhost:${listenPort}`
39+
)
40+
return
41+
}
42+
43+
return serve(
44+
{
45+
fetch: app.fetch,
46+
port: listenPort,
47+
serverOptions: ENGINE_SERVER_OPTIONS,
48+
},
49+
(info) => {
50+
logger.info(
51+
{ port: info.port },
52+
`Sync Engine API listening on http://localhost:${info.port}`
53+
)
54+
}
55+
)
56+
}

apps/engine/src/bin/bootstrap.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import 'dotenv/config'
2+
import { assertUseEnvProxy } from '@stripe/sync-ts-cli/env-proxy'
3+
4+
export function bootstrap() {
5+
assertUseEnvProxy()
6+
}

apps/engine/src/bin/serve.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env node
2+
import { startApiServer } from '../api/server.js'
3+
import { defaultConnectors } from '../lib/default-connectors.js'
4+
import { createConnectorResolver } from '../lib/index.js'
5+
import { bootstrap } from './bootstrap.js'
6+
7+
bootstrap()
8+
9+
const resolver = await createConnectorResolver(defaultConnectors, {
10+
path: false,
11+
npm: false,
12+
})
13+
14+
await startApiServer({
15+
resolver,
16+
port: process.env['PORT'] ? Number(process.env['PORT']) : undefined,
17+
})

apps/engine/src/bin/sync-engine.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env node
2+
import { runMain } from 'citty'
3+
import { createProgram } from '../cli/command.js'
4+
import { bootstrap } from './bootstrap.js'
5+
6+
bootstrap()
7+
8+
const program = await createProgram()
9+
runMain(program)

0 commit comments

Comments
 (0)