Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Pre-generate OpenAPI specs from the stripe/openapi repo
FROM node:24-alpine AS spec-builder
RUN apk add --no-cache git
RUN git clone --filter=blob:none https://github.com/stripe/openapi /stripe-openapi
COPY packages/openapi/scripts/generate-all-specs.mjs /generate-all-specs.mjs
RUN node /generate-all-specs.mjs /stripe-openapi /generated-specs

# Install deps and create standalone deployment
# Expects pre-built dist/ directories in the build context (from `pnpm build`)
FROM node:24-alpine AS build
Expand All @@ -9,6 +16,7 @@ RUN corepack enable

WORKDIR /app
COPY . ./
COPY --from=spec-builder /generated-specs ./packages/openapi/generated-specs

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm --filter @stripe/sync-engine deploy --prod /deploy
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion packages/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
}
},
"scripts": {
"build": "tsc && cp bundled-spec.json dist/bundled-spec.json",
"build": "tsc && cp latest-spec.json dist/latest-spec.json && (cp -r generated-specs dist/generated-specs 2>/dev/null || true)",
"test": "vitest --passWithNoTests"
},
"files": [
Expand Down
59 changes: 59 additions & 0 deletions packages/openapi/scripts/generate-all-specs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process'
import { writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'

const [repoPath, outputDir] = process.argv.slice(2)
if (!repoPath || !outputDir) {
console.error('Usage: node generate-all-specs.mjs <repoPath> <outputDir>')
process.exit(1)
}

const execOpts = { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }

let log
try {
log = execSync(
`git -C ${repoPath} log --format="%H" -- latest/openapi.spec3.sdk.json openapi/spec3.json`,
execOpts
)
} catch (err) {
console.error(`Fatal: cannot access repo at ${repoPath}: ${err.message}`)
process.exit(1)
}

const shas = log.trim().split('\n').filter(Boolean)
mkdirSync(outputDir, { recursive: true })

const seen = new Map()
const paths = ['latest/openapi.spec3.sdk.json', 'openapi/spec3.json']

for (const sha of shas) {
let raw = null
for (const p of paths) {
try {
raw = execSync(`git -C ${repoPath} show ${sha}:${p}`, execOpts)
break
} catch {
// file doesn't exist at this path for this commit
}
}
if (!raw) continue

let version
try {
version = JSON.parse(raw).info?.version
} catch {
continue
}
if (!version || seen.has(version)) continue

const filename = `${version}.json`
writeFileSync(join(outputDir, filename), raw)
seen.set(version, filename)
console.error(`Generated ${version}`)
}

const manifest = Object.fromEntries(seen)
writeFileSync(join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
console.error(`Done: ${seen.size} specs from ${shas.length} commits`)
60 changes: 54 additions & 6 deletions packages/openapi/specFetchHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import type { OpenApiSpec, ResolveSpecConfig, ResolvedOpenApiSpec } from './types.js'

const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), 'stripe-sync-openapi-cache')
const GENERATED_SPECS_DIR = fileURLToPath(new URL('./generated-specs', import.meta.url))

// CDN mirror of the official Stripe REST API specs (from github.com/stripe/openapi).
// Served from stripe-sync.dev — no auth, no GitHub rate limits.
Expand All @@ -14,8 +15,8 @@ const STRIPE_SPEC_CDN_BASE_URL =
process.env.STRIPE_SPEC_CDN_BASE_URL ?? 'https://stripe-sync.dev/stripe-api-specs'

// The spec bundled into this package at build time.
// Update this constant and bundled-spec.json together when bumping.
export const BUNDLED_API_VERSION = '2026-03-25.dahlia'
// Update this constant and latest-spec.json together when bumping.
export const LATEST_BUNDLED_API_VERSION = '2026-03-25.dahlia'

export async function resolveOpenApiSpec(
config: ResolveSpecConfig,
Expand All @@ -40,14 +41,25 @@ export async function resolveOpenApiSpec(

// If the requested version matches what's bundled, serve from the filesystem
// without any network calls or caching overhead.
if (extractDatePart(apiVersion) === extractDatePart(BUNDLED_API_VERSION)) {
const bundledPath = fileURLToPath(new URL('./bundled-spec.json', import.meta.url))
const spec = await readSpecFromPath(bundledPath)
if (extractDatePart(apiVersion) === extractDatePart(LATEST_BUNDLED_API_VERSION)) {
const latestPath = fileURLToPath(new URL('./latest-spec.json', import.meta.url))
const spec = await readSpecFromPath(latestPath)
return {
apiVersion,
spec,
source: 'bundled',
cachePath: bundledPath,
cachePath: latestPath,
}
}

// Try pre-generated specs from the generated-specs directory (built at Docker image time).
const preGenSpec = await tryLoadPreGeneratedSpec(apiVersion)
if (preGenSpec) {
return {
apiVersion,
spec: preGenSpec.spec,
source: 'bundled',
cachePath: preGenSpec.path,
}
}

Expand Down Expand Up @@ -99,6 +111,42 @@ export async function resolveOpenApiSpec(
}
}

// Module-scope manifest cache: undefined = not yet tried, null = tried and missing
let generatedSpecManifest: { [version: string]: string } | null | undefined = undefined

async function tryLoadPreGeneratedSpec(
apiVersion: string
): Promise<{ spec: OpenApiSpec; path: string } | null> {
try {
if (generatedSpecManifest === null) return null

if (generatedSpecManifest === undefined) {
try {
const manifestRaw = await fs.readFile(
path.join(GENERATED_SPECS_DIR, 'manifest.json'),
'utf8'
)
generatedSpecManifest = JSON.parse(manifestRaw) as { [version: string]: string }
} catch {
generatedSpecManifest = null
return null
}
}

const requestedDate = extractDatePart(apiVersion)
const matchedKey = Object.keys(generatedSpecManifest).find(
(key) => extractDatePart(key) === requestedDate
)
if (!matchedKey) return null

const specPath = path.join(GENERATED_SPECS_DIR, generatedSpecManifest[matchedKey])
const spec = await readSpecFromPath(specPath)
return { spec, path: specPath }
} catch {
return null
}
}

async function readSpecFromPath(openApiSpecPath: string): Promise<OpenApiSpec> {
const raw = await fs.readFile(openApiSpecPath, 'utf8')
let parsed: unknown
Expand Down
Loading