diff --git a/Dockerfile b/Dockerfile index a3372357..4d102664 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/packages/openapi/bundled-spec.json b/packages/openapi/latest-spec.json similarity index 100% rename from packages/openapi/bundled-spec.json rename to packages/openapi/latest-spec.json diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 168c083e..d9b48958 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -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": [ diff --git a/packages/openapi/scripts/generate-all-specs.mjs b/packages/openapi/scripts/generate-all-specs.mjs new file mode 100644 index 00000000..d5076f42 --- /dev/null +++ b/packages/openapi/scripts/generate-all-specs.mjs @@ -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 ') + 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`) diff --git a/packages/openapi/specFetchHelper.ts b/packages/openapi/specFetchHelper.ts index d6caaccf..81785458 100644 --- a/packages/openapi/specFetchHelper.ts +++ b/packages/openapi/specFetchHelper.ts @@ -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. @@ -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, @@ -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, } } @@ -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 { const raw = await fs.readFile(openApiSpecPath, 'utf8') let parsed: unknown