From b24be279e2b15608628fec8ac7da3a1c4ed38eb1 Mon Sep 17 00:00:00 2001 From: RubenGlez Date: Fri, 14 Aug 2026 09:01:26 +0200 Subject: [PATCH 1/2] fix: harden the capture pipeline for production use Fixes 21 correctness, performance and reliability issues found while auditing the library for real-world usage. Silent data loss and hangs: - Hono and Next.js App Router recorded `requestBody: null` on every POST/PUT/ PATCH, because both read the body after the handler had consumed it. - Streaming responses hung the client in the Hono, Next.js and Elysia adapters, which awaited `.json()` on an open stream before returning the response. - Offline mode and the no-API-key Ollama fallback never worked: the AI SDK's default OpenAI model targets /v1/responses, which Ollama does not implement. - The failure circuit breaker never reopened, so a transient provider outage stopped all documentation until the next deploy. - NestJS never documented responses produced by exception filters. - The Next.js Pages Router created one endpoint (and one AI call) per dynamic id. Robustness: - capture() can no longer throw into the host app's response path, and self-referential bodies terminate instead of overflowing the stack. - Concurrent workers no longer collide on the unique endpoint index after the AI call has already been paid for. - Capture is bounded by default: 256 KB bodies, non-JSON responses skipped, 50 shapes per endpoint, 50 spec versions per endpoint. - Capturers expose flush(), drained on shutdown where the framework has a hook. Performance: - Repeated payload shapes are dropped synchronously, before the queue, so steady-state traffic neither retains bodies nor queues behind a generation. - The queue processes endpoints in parallel, one shape at a time per endpoint. - The size cap stops as soon as the limit is exceeded rather than serializing the whole payload; privacy rules are compiled once per config. - The dashboard reuses one database handle instead of opening a client and re-running the schema DDL per request. BREAKING: the Postgres helpers moved to @easydocs/core/storage/postgres and `postgres` is now an optional peer dependency, so SQLite installs no longer pull in the driver. Configuring storage.type: 'postgres' is unchanged. Verified with build, lint, typecheck and 254 tests (up from 202), plus an end-to-end run of real Express and Hono apps against a stub AI provider. Claude-Session: https://claude.ai/code/session_01GrfumUQ4vFUAgsFYwzDyK8 --- AGENTS.md | 22 ++ CHANGELOG.md | 72 +++++++ README.md | 37 +++- .../src/app/api/endpoints/[id]/spec/route.ts | 7 +- .../app/api/endpoints/[id]/versions/route.ts | 7 +- apps/dashboard/src/app/api/endpoints/route.ts | 7 +- apps/dashboard/src/lib/db.ts | 7 +- packages/cli/src/__tests__/port-flag.test.ts | 32 +++ packages/cli/src/index.ts | 17 +- packages/core/package.json | 17 +- .../src/__tests__/capture-circuit.test.ts | 47 +++- .../src/__tests__/capture-hotpath.test.ts | 200 ++++++++++++++++++ packages/core/src/__tests__/provider.test.ts | 24 ++- packages/core/src/__tests__/queue.test.ts | 114 ++++++++-- packages/core/src/__tests__/size.test.ts | 55 +++++ .../src/__tests__/storage-concurrency.test.ts | 82 +++++++ packages/core/src/ai/provider.ts | 18 +- packages/core/src/capture.ts | 177 ++++++++++++---- packages/core/src/index.ts | 11 +- packages/core/src/privacy/detect.ts | 74 +++++-- packages/core/src/queue.ts | 82 +++++-- packages/core/src/shape.ts | 30 ++- packages/core/src/size.ts | 58 +++++ packages/core/src/spec/builder.ts | 60 ++++-- packages/core/src/storage/adapter.ts | 33 ++- packages/core/src/storage/postgres.ts | 73 +++++-- packages/core/src/storage/sqlite.ts | 67 ++++-- packages/core/src/types.ts | 3 + packages/core/tsup.config.ts | 1 + packages/elysia/src/__tests__/plugin.test.ts | 2 +- packages/elysia/src/index.ts | 80 ++++--- .../express/src/__tests__/middleware.test.ts | 2 +- packages/express/src/index.ts | 22 +- packages/fastify/src/__tests__/plugin.test.ts | 2 +- packages/fastify/src/index.ts | 6 + packages/h3/src/__tests__/middleware.test.ts | 2 +- packages/h3/src/index.ts | 9 +- .../hono/src/__tests__/middleware.test.ts | 59 +++++- packages/hono/src/index.ts | 42 +++- .../nestjs/src/__tests__/interceptor.test.ts | 53 ++++- packages/nestjs/src/interceptor.ts | 63 ++++-- packages/nestjs/src/module.ts | 17 +- packages/nextjs/package.json | 8 +- .../nextjs/src/__tests__/app-router.test.ts | 105 +++++++++ .../nextjs/src/__tests__/pages-router.test.ts | 108 ++++++++++ packages/nextjs/src/index.ts | 74 ++++++- packages/nextjs/vitest.config.ts | 7 + .../trpc/src/__tests__/error-status.test.ts | 2 +- .../trpc/src/__tests__/middleware.test.ts | 2 +- packages/trpc/src/__tests__/qa.test.ts | 2 +- packages/trpc/src/index.ts | 7 +- pnpm-lock.yaml | 13 +- 52 files changed, 1817 insertions(+), 304 deletions(-) create mode 100644 packages/cli/src/__tests__/port-flag.test.ts create mode 100644 packages/core/src/__tests__/capture-hotpath.test.ts create mode 100644 packages/core/src/__tests__/size.test.ts create mode 100644 packages/core/src/__tests__/storage-concurrency.test.ts create mode 100644 packages/core/src/size.ts create mode 100644 packages/nextjs/src/__tests__/app-router.test.ts create mode 100644 packages/nextjs/src/__tests__/pages-router.test.ts create mode 100644 packages/nextjs/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index f304b6a..fe4a993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,28 @@ docs portal — no multi-tenant hosting, custom domains, theming, or a published ReadMe/Mintlify/Scalar). Keep dashboard work serving the developer producing the spec, not the external API consumer. +## Capture pipeline invariants + +These are load-bearing and easy to undo by accident: + +- `capture()` runs **synchronously inside the host app's response path**. It must + never throw and must stay cheap — anything expensive belongs in the queue. Only + bounded, early-exit work is acceptable there (that is why `size.ts` exists + instead of `JSON.stringify().length`). +- Bodies arrive as **live objects, not JSON text**, so anything that walks them + needs cycle protection. `JSON.stringify` throwing is not a safety net. +- Framework adapters must not consume a request or response body the handler also + owns. Cloning a `Request`/`Response` after it has been read throws, and awaiting + `.json()` on a stream never resolves — check the content type first, and clone + before the handler runs, not after. +- The `ollama` provider must use `client.chat(model)`. The AI SDK's default + OpenAI model targets `/v1/responses`, which Ollama and most OpenAI-compatible + gateways do not implement. +- Default model IDs in `ai/provider.ts` get reviewed every release. Providers + retire IDs, which breaks every user who never pinned `ai.model`. +- Postgres is an **optional peer dependency**, loaded via dynamic import so + SQLite installs never pay for it. Don't re-export it from the package root. + ## Release Run `pnpm release` (or `release:minor` / `release:major`). The script diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6b9fb..93cf30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,78 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Hono and Next.js App Router lost every request body.** Both read the body + after the route handler had already consumed it, which throws — so `requestBody` + was silently documented as `null` on every POST/PUT/PATCH. Hono now reads through + its own body cache; Next.js clones the request before invoking the handler. +- **Streaming responses hung the request.** The Hono, Next.js, and Elysia adapters + awaited `.json()` on a clone of the response before returning it; on an open SSE + stream that never resolves, so the client received nothing. All three now check + the response content type first, and non-JSON responses no longer create an + endpoint row. +- **Offline mode and the no-API-key fallback never worked.** The Ollama provider + was built with the AI SDK's default OpenAI model, which targets `/v1/responses`; + Ollama (and most OpenAI-compatible gateways) only implement + `/v1/chat/completions`, so every generation failed. +- **The failure circuit breaker never reopened.** Five consecutive generation + failures disabled capture for the lifetime of the process, so a transient + provider outage silently stopped all documentation until the next deploy. It now + retries after a 60s cooldown. +- **NestJS never documented error responses.** The interceptor used `tap()`, which + only fires on success, so every response produced by an exception filter went + unrecorded. Errors are now captured with the exception's own status and body. +- **Next.js Pages Router documented one endpoint per dynamic id.** Route params + were not separated from the query string, so `/api/users/1` and `/api/users/2` + became separate rows, each costing its own AI call. +- Concurrent writes (cluster mode, multiple replicas) no longer collide on the + unique endpoint index after the AI call has already been paid for; project and + endpoint writes are now atomic upserts. +- `capture()` can no longer throw into the host app's response path, and + self-referential bodies terminate instead of overflowing the stack. +- The dashboard no longer opens a new database client and re-runs the schema DDL + on every request to three of its API routes. +- `--port` now rejects non-numeric and out-of-range values instead of silently + binding a random port. + +### Changed +- **Capture is bounded by default.** `capture.maxBodySize` now defaults to 256 KB. + Previously there was no cap unless configured, so a stalled provider could let + the pending-capture queue retain unbounded payloads in the host app's heap. +- Repeated payload shapes are now dropped synchronously, before entering the + queue, so steady-state traffic neither retains bodies nor queues behind an + in-flight generation. The queue also processes endpoints in parallel (still one + shape at a time per endpoint). +- An endpoint stops regenerating once 50 distinct payload shapes are documented. + The previous FIFO eviction meant highly variable payloads regenerated forever. +- `spec_versions` is capped at the 50 most recent snapshots per endpoint. +- Request bodies are trimmed like responses before being sent to the model, so a + bulk payload is no longer billed in full. +- Default models updated to current, non-retired ones (`claude-sonnet-5`, + `gpt-5.4-mini-2026-03-17`). A rejected model ID now produces an explicit + "pin `ai.model`" error rather than an opaque provider 404. Pin `ai.model` + yourself for stability. +- Capturers expose `flush()`, and adapters drain the queue on shutdown where the + framework provides a hook (Fastify `onClose`, Elysia `onStop`, NestJS + `onApplicationShutdown`). Elsewhere the returned middleware carries `.flush()`. + +### Performance +- The `maxBodySize` check no longer serializes the whole payload; it stops as soon + as the limit is exceeded (~2ms → microseconds on a 1 MB body). +- Privacy rules (allowlist, key names, custom regexes) are compiled once per + config instead of on every captured request. +- The Express adapter sends the response before doing capture work. + +### Breaking +- The Postgres helpers (`createPgDB`, `pgGetAll`, `pgGetAllProjects`, + `pgGetEndpointsByProject`, `pgDeleteById`, `pgSaveManualSpec`) moved from the + package root to `@easydocs/core/storage/postgres`. The root re-export pulled the + `postgres` driver into every SQLite install; it is now loaded on demand and + declared as an optional peer dependency, so Postgres users must install + `postgres` themselves. Configuring `storage.type: 'postgres'` is unchanged. + ## [0.9.0] - 2026-07-03 ### Added diff --git a/README.md b/README.md index 0b86a7d..b2af41c 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,38 @@ own API. ## How it works 1. Middleware (or proxy) intercepts every request and response -2. A background queue feeds the captured data to an AI model — nothing blocks your request -3. The AI generates or updates an OpenAPI 3.0 Operation object for that endpoint -4. Response-shape hashing skips re-processing when the structure hasn't changed +2. Payload-shape hashing drops the capture immediately when that shape is already + documented, so steady-state traffic never reaches the queue at all +3. A background queue feeds genuinely new shapes to an AI model — nothing blocks + your request; endpoints are processed in parallel, one shape at a time each +4. The AI generates or updates an OpenAPI 3.0 Operation object for that endpoint 5. Specs are stored in SQLite (default) or Postgres 6. The dashboard reads from that database and renders live docs +Captures are bounded so documentation can never destabilise your app: bodies over +`capture.maxBodySize` (256 KB by default) are skipped, non-JSON responses +(streaming, HTML) are ignored, repeated generation failures pause capture for a +minute before retrying, and an endpoint stops regenerating once 50 distinct +payload shapes have been documented. + +### Graceful shutdown + +Spec generation is asynchronous, so a redeploy can discard work still in the +queue. Adapters with a shutdown hook drain it for you: Fastify via `onClose`, +Elysia via `onStop`, and NestJS via `onApplicationShutdown` (this one needs +`app.enableShutdownHooks()`). Elsewhere, call `flush()` on the value the adapter +returned: + +```ts +const docs = easydocs({ project: "my-api" }); +app.use(docs); + +process.on("SIGTERM", async () => { + await docs.flush(); + process.exit(0); +}); +``` + --- ## Framework adapters @@ -197,16 +223,17 @@ easydocs({ project: "my-api", // separate spec per service, default: 'default' ai: { provider: "openai", // 'openai' | 'anthropic' | 'ollama' | 'deepseek' - model: "gpt-4o", + model: "gpt-5.4-mini-2026-03-17", // pin this; provider defaults move as models retire apiKey: "...", // optional, falls back to env vars }, storage: { - type: "sqlite", // 'sqlite' | 'postgres' + type: "sqlite", // 'sqlite' | 'postgres' — for postgres, also `npm i postgres` url: "file:./docs.sqlite", }, capture: { ignoreRoutes: ["/health", "/metrics"], includePaths: ["/api"], + maxBodySize: 262144, // skip bodies over ~256 KB (the default) }, privacy: { enabled: true, // on by default; detect & redact PII/secrets diff --git a/apps/dashboard/src/app/api/endpoints/[id]/spec/route.ts b/apps/dashboard/src/app/api/endpoints/[id]/spec/route.ts index 9258701..0b94ee4 100644 --- a/apps/dashboard/src/app/api/endpoints/[id]/spec/route.ts +++ b/apps/dashboard/src/app/api/endpoints/[id]/spec/route.ts @@ -1,11 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' -import { createDB, saveManualSpec, resolveConflict } from '@easydocs/core' +import { saveManualSpec, resolveConflict } from '@easydocs/core' +import { getDb } from '@/lib/db' import type { Operation } from '@easydocs/core' -function getDb() { - return createDB(process.env.EASYDOCS_DB_URL) -} - // Save a manual spec edit export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params diff --git a/apps/dashboard/src/app/api/endpoints/[id]/versions/route.ts b/apps/dashboard/src/app/api/endpoints/[id]/versions/route.ts index a9e2ff5..622563a 100644 --- a/apps/dashboard/src/app/api/endpoints/[id]/versions/route.ts +++ b/apps/dashboard/src/app/api/endpoints/[id]/versions/route.ts @@ -1,9 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { createDB, getEndpointVersions } from '@easydocs/core' - -function getDb() { - return createDB(process.env.EASYDOCS_DB_URL) -} +import { getEndpointVersions } from '@easydocs/core' +import { getDb } from '@/lib/db' // Version history for an endpoint, newest first. export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { diff --git a/apps/dashboard/src/app/api/endpoints/route.ts b/apps/dashboard/src/app/api/endpoints/route.ts index 3f26841..4421105 100644 --- a/apps/dashboard/src/app/api/endpoints/route.ts +++ b/apps/dashboard/src/app/api/endpoints/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { fetchEndpoints } from '@/lib/db' -import { createDB, deleteEndpointById } from '@easydocs/core' +import { fetchEndpoints, getDb } from '@/lib/db' +import { deleteEndpointById } from '@easydocs/core' export async function GET(req: NextRequest) { const project = req.nextUrl.searchParams.get('project') ?? undefined @@ -11,7 +11,6 @@ export async function GET(req: NextRequest) { export async function DELETE(req: Request) { const { id } = await req.json() if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 }) - const db = createDB(process.env.EASYDOCS_DB_URL) - await deleteEndpointById(db, id as string) + await deleteEndpointById(getDb(), id as string) return NextResponse.json({ ok: true }) } diff --git a/apps/dashboard/src/lib/db.ts b/apps/dashboard/src/lib/db.ts index a84c89c..f7d9d17 100644 --- a/apps/dashboard/src/lib/db.ts +++ b/apps/dashboard/src/lib/db.ts @@ -16,7 +16,12 @@ import type { DriftReport } from '@easydocs/core' let db: ReturnType | null = null -function getDb() { +/** + * The single shared DB handle for the dashboard. Every route must use this — + * calling `createDB()` per request opens a new libsql client and re-runs the + * schema DDL on every page load. + */ +export function getDb() { if (!db) db = createDB(process.env.EASYDOCS_DB_URL) return db } diff --git a/packages/cli/src/__tests__/port-flag.test.ts b/packages/cli/src/__tests__/port-flag.test.ts new file mode 100644 index 0000000..c6b1bd6 --- /dev/null +++ b/packages/cli/src/__tests__/port-flag.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +// Black-box like the diff tests: the CLI dispatches on import. +const CLI = resolve(process.cwd(), 'dist/index.js') + +function run(...args: string[]) { + const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 10_000 }) + return { code: r.status, stdout: r.stdout, stderr: r.stderr } +} + +// `parseInt('abc')` is NaN and `server.listen(NaN)` silently binds a random +// port, so a typo'd --port used to start the proxy somewhere unpredictable +// instead of reporting the mistake. +describe('--port validation', () => { + it('rejects a non-numeric port', () => { + const r = run('proxy', '--port=abc') + expect(r.code).toBe(2) + expect(r.stderr).toContain('Invalid --port value') + }) + + it('rejects an out-of-range port', () => { + expect(run('proxy', '--port=70000').code).toBe(2) + expect(run('proxy', '--port=0').code).toBe(2) + expect(run('proxy', '--port=-1').code).toBe(2) + }) + + it('rejects a fractional port', () => { + expect(run('proxy', '--port=80.5').code).toBe(2) + }) +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b583466..91b0986 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,6 +43,19 @@ function getFlag(args: string[], name: string): string | undefined { return entry?.split('=').slice(1).join('=') } +// `parseInt('abc')` is NaN and `server.listen(NaN)` silently binds a random +// port, so a typo'd --port left the user hunting for their server. +function getPort(args: string[], fallback: number): number { + const raw = getFlag(args, 'port') + if (raw === undefined) return fallback + const port = Number(raw) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.error(`[EasyDocs] Invalid --port value: ${raw} (expected an integer between 1 and 65535)`) + process.exit(2) + } + return port +} + // Read commands resolve a project slug without creating it — a typo should // report an unknown project (exit 2), not silently insert a junk project row. async function resolveReadProject( @@ -88,7 +101,7 @@ function findDashboardDir(): string | null { } async function runDashboard(args: string[]) { - const port = parseInt(getFlag(args, 'port') ?? '4999', 10) + const port = getPort(args, 4999) const prod = args.includes('--prod') const dashboardDir = findDashboardDir() @@ -272,7 +285,7 @@ function stripHopByHop(headers: Record): Record } async function runProxy(args: string[]) { - const port = parseInt(getFlag(args, 'port') ?? '3999', 10) + const port = getPort(args, 3999) const projectSlug = getFlag(args, 'project') ?? 'default' const capturer = createCapturer(parseConfig({ project: projectSlug })) diff --git a/packages/core/package.json b/packages/core/package.json index 79e4f36..e379edc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,7 @@ "dist", "README.md" ], - "description": "Core engine for EasyDocs — generates OpenAPI specs from real API traffic. Local-first, self-hostable, open source.", + "description": "Core engine for EasyDocs \u2014 generates OpenAPI specs from real API traffic. Local-first, self-hostable, open source.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -24,6 +24,11 @@ "require": "./dist/storage/schema.cjs", "types": "./dist/storage/schema.d.ts" }, + "./storage/postgres": { + "import": "./dist/storage/postgres.js", + "require": "./dist/storage/postgres.cjs", + "types": "./dist/storage/postgres.d.ts" + }, "./spec/schema": { "import": "./dist/spec/schema.js", "require": "./dist/spec/schema.cjs", @@ -59,10 +64,10 @@ "@libsql/client": "^0.17.3", "ai": "^6.0.185", "drizzle-orm": "^0.45.2", - "postgres": "^3.4.5", "zod": "^3.25.76" }, "devDependencies": { + "postgres": "^3.4.5", "tsup": "^8.3.0", "typescript": "^5", "vite": "8.1.0", @@ -72,5 +77,13 @@ "type": "git", "url": "https://github.com/RubenGlez/easydocs", "directory": "" + }, + "peerDependencies": { + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "postgres": { + "optional": true + } } } diff --git a/packages/core/src/__tests__/capture-circuit.test.ts b/packages/core/src/__tests__/capture-circuit.test.ts index 6253894..c71eea8 100644 --- a/packages/core/src/__tests__/capture-circuit.test.ts +++ b/packages/core/src/__tests__/capture-circuit.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -19,10 +19,16 @@ function tmpDbUrl(): string { return `file:${path.join(os.tmpdir(), `easydocs-circuit-${randomUUID()}.sqlite`)}` } +const settle = () => new Promise((r) => setTimeout(r, 500)) + beforeEach(() => { calls.count = 0 }) +afterEach(() => { + vi.restoreAllMocks() +}) + describe('capture circuit breaker (A6)', () => { it('stops attempting generation after 5 consecutive failures', async () => { const c = createCapturer({ @@ -33,9 +39,40 @@ describe('capture circuit breaker (A6)', () => { for (let i = 0; i < 20; i++) { c.capture(buildCaptureEvent({ method: 'GET', path: `/r${i}`, status: 200, responseBody: { i } })) } - await new Promise((r) => setTimeout(r, 500)) - // The circuit opens on the 5th failure; the remaining 15 captures never call - // the provider again. - expect(calls.count).toBe(5) + await settle() + // The circuit opens on the 5th failure. Captures already in flight when it + // trips still complete, so the exact count depends on queue concurrency — + // what matters is that the remaining captures never reach the provider. + expect(calls.count).toBeGreaterThanOrEqual(5) + expect(calls.count).toBeLessThan(20) + }) + + it('re-attempts after the cooldown instead of staying open for the process lifetime', async () => { + const clock = { now: Date.now() } + vi.spyOn(Date, 'now').mockImplementation(() => clock.now) + + const c = createCapturer({ + storage: { type: 'sqlite', url: tmpDbUrl() }, + ai: { provider: 'ollama' }, + }) + for (let i = 0; i < 20; i++) { + c.capture(buildCaptureEvent({ method: 'GET', path: `/r${i}`, status: 200, responseBody: { i } })) + } + await settle() + const afterTrip = calls.count + expect(afterTrip).toBeLessThan(20) + + // While the circuit is open, nothing new reaches the provider. + c.capture(buildCaptureEvent({ method: 'GET', path: '/later', status: 200, responseBody: { a: 1 } })) + await settle() + expect(calls.count).toBe(afterTrip) + + // Once the cooldown elapses it tries again. Previously the circuit was + // permanent, so a transient provider outage silently stopped all + // documentation until the next deploy. + clock.now += 61_000 + c.capture(buildCaptureEvent({ method: 'GET', path: '/recovered', status: 200, responseBody: { b: 2 } })) + await settle() + expect(calls.count).toBeGreaterThan(afterTrip) }) }) diff --git a/packages/core/src/__tests__/capture-hotpath.test.ts b/packages/core/src/__tests__/capture-hotpath.test.ts new file mode 100644 index 0000000..597e829 --- /dev/null +++ b/packages/core/src/__tests__/capture-hotpath.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' + +const { calls } = vi.hoisted(() => ({ calls: { count: 0 } })) + +vi.mock('ai', () => ({ + generateText: vi.fn(async () => { + calls.count++ + return { + text: JSON.stringify({ summary: 'op', responses: { '200': { description: 'OK' } } }), + } + }), +})) + +import { createCapturer, DEFAULT_MAX_BODY_SIZE } from '../capture.js' +import { buildCaptureEvent } from '../event.js' + +function tmpDbUrl(): string { + return `file:${path.join(os.tmpdir(), `easydocs-hot-${randomUUID()}.sqlite`)}` +} + +function capturer(overrides: Record = {}) { + return createCapturer({ + storage: { type: 'sqlite', url: tmpDbUrl() }, + ai: { provider: 'ollama' }, + ...overrides, + }) +} + +const settle = () => new Promise((r) => setTimeout(r, 300)) + +beforeEach(() => { + calls.count = 0 +}) + +describe('capture hot path', () => { + // The dedup check used to live inside the queued task, so every repeat request + // was enqueued (retaining its body) and queued behind whatever was generating. + it('generates once for a repeated shape, even in a burst', async () => { + const c = capturer() + for (let i = 0; i < 50; i++) { + c.capture( + buildCaptureEvent({ method: 'GET', path: '/users', status: 200, responseBody: { data: [] } }) + ) + } + await c.flush() + await settle() + expect(calls.count).toBe(1) + }) + + it('still regenerates for a genuinely new shape', async () => { + const c = capturer() + c.capture(buildCaptureEvent({ method: 'GET', path: '/u', status: 200, responseBody: { a: 1 } })) + await c.flush() + await settle() + c.capture(buildCaptureEvent({ method: 'GET', path: '/u', status: 200, responseBody: { a: 1, b: 2 } })) + await c.flush() + await settle() + expect(calls.count).toBe(2) + }) + + describe('body size cap', () => { + const big = () => ({ items: Array.from({ length: 20_000 }, (_, i) => ({ i, pad: 'x'.repeat(40) })) }) + + it('skips oversized payloads by default (no explicit maxBodySize)', async () => { + expect(JSON.stringify(big()).length).toBeGreaterThan(DEFAULT_MAX_BODY_SIZE) + const c = capturer() + c.capture(buildCaptureEvent({ method: 'GET', path: '/big', status: 200, responseBody: big() })) + await c.flush() + await settle() + expect(calls.count).toBe(0) + }) + + it('still captures a payload under the cap', async () => { + const c = capturer() + c.capture( + buildCaptureEvent({ method: 'GET', path: '/small', status: 200, responseBody: { a: 1 } }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(1) + }) + + it('honours an explicit maxBodySize', async () => { + const c = capturer({ capture: { maxBodySize: 20 } }) + c.capture( + buildCaptureEvent({ + method: 'GET', + path: '/x', + status: 200, + responseBody: { message: 'this is comfortably over twenty bytes' }, + }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(0) + }) + }) + + // capture() runs synchronously inside the host app's res.json(), so a throw + // here would surface as an error in the user's own route handler. + it('never throws on a self-referential body', async () => { + const c = capturer() + const cyclic: Record = { name: 'node' } + cyclic.self = cyclic + + expect(() => + c.capture( + buildCaptureEvent({ method: 'POST', path: '/cycle', status: 200, requestBody: cyclic, responseBody: { ok: true } }) + ) + ).not.toThrow() + + await c.flush() + await settle() + }) + + it('stops generating once an endpoint has too many distinct shapes', async () => { + const c = capturer() + // Each response has a different key set, so each is a new shape. The old + // FIFO eviction meant shape 51 evicted shape 1, which then reappeared and + // paid for another LLM call — forever. + for (let round = 0; round < 2; round++) { + for (let i = 0; i < 60; i++) { + c.capture( + buildCaptureEvent({ + method: 'GET', + path: '/variable', + status: 200, + responseBody: Object.fromEntries(Array.from({ length: i + 1 }, (_, k) => [`f${k}`, k])), + }) + ) + await c.flush() + } + } + await settle() + expect(calls.count).toBeLessThanOrEqual(50) + }) +}) + +describe('non-JSON responses', () => { + it('skips a streaming (text/event-stream) response instead of documenting it', async () => { + const c = capturer() + c.capture( + buildCaptureEvent({ + method: 'GET', + path: '/stream', + status: 200, + responseBody: null, + responseHeaders: { 'content-type': 'text/event-stream' }, + }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(0) + }) + + it('skips an HTML response', async () => { + const c = capturer() + c.capture( + buildCaptureEvent({ + method: 'GET', + path: '/page', + status: 200, + responseBody: null, + responseHeaders: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(0) + }) + + it('still documents a 204 with no content-type at all', async () => { + const c = capturer() + c.capture( + buildCaptureEvent({ method: 'DELETE', path: '/users/{id}', status: 204, responseBody: null }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(1) + }) + + it('still documents an application/json response', async () => { + const c = capturer() + c.capture( + buildCaptureEvent({ + method: 'GET', + path: '/ok', + status: 200, + responseBody: { a: 1 }, + responseHeaders: { 'content-type': 'application/json; charset=utf-8' }, + }) + ) + await c.flush() + await settle() + expect(calls.count).toBe(1) + }) +}) diff --git a/packages/core/src/__tests__/provider.test.ts b/packages/core/src/__tests__/provider.test.ts index a22b269..d3be602 100644 --- a/packages/core/src/__tests__/provider.test.ts +++ b/packages/core/src/__tests__/provider.test.ts @@ -32,7 +32,7 @@ describe('resolveModel', () => { it('uses openai when provider is openai', () => { withEnv(NO_KEYS, () => { const model = concrete(resolveModel({ provider: 'openai', apiKey: 'sk-test' })) - expect(model.modelId).toBe('gpt-4o') + expect(model.modelId).toBe('gpt-5.4-mini-2026-03-17') expect(model.provider).toContain('openai') }) }) @@ -40,7 +40,7 @@ describe('resolveModel', () => { it('uses anthropic when provider is anthropic', () => { withEnv(NO_KEYS, () => { const model = concrete(resolveModel({ provider: 'anthropic', apiKey: 'sk-test' })) - expect(model.modelId).toBe('claude-3-5-sonnet-20241022') + expect(model.modelId).toBe('claude-sonnet-5') expect(model.provider).toContain('anthropic') }) }) @@ -145,3 +145,23 @@ describe('resolveModel', () => { }) }) }) + +describe('ollama transport', () => { + // Ollama's OpenAI-compatible API implements /v1/chat/completions but NOT the + // newer /v1/responses endpoint that the AI SDK's OpenAI provider defaults to. + // Using the default made every offline / no-API-key generation 404. + it('uses the chat-completions API, not the responses API', () => { + const model = resolveModel({ provider: 'ollama' }) + expect(model.constructor.name).toBe('OpenAIChatLanguageModel') + }) + + it('also uses chat-completions in offline mode', () => { + const model = resolveModel(undefined, true) + expect(model.constructor.name).toBe('OpenAIChatLanguageModel') + }) + + it('keeps chat-completions when pointed at a custom OpenAI-compatible gateway', () => { + const model = resolveModel({ provider: 'ollama', baseUrl: 'http://gateway.internal/v1' }) + expect(model.constructor.name).toBe('OpenAIChatLanguageModel') + }) +}) diff --git a/packages/core/src/__tests__/queue.test.ts b/packages/core/src/__tests__/queue.test.ts index a4ce0a6..9cb2503 100644 --- a/packages/core/src/__tests__/queue.test.ts +++ b/packages/core/src/__tests__/queue.test.ts @@ -1,13 +1,19 @@ import { describe, it, expect } from 'vitest' import { CaptureQueue } from '../queue.js' +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { resolve = r }) + return { promise, resolve } +} + describe('CaptureQueue', () => { - it('executes tasks in order', async () => { + it('executes same-key tasks in order', async () => { const queue = new CaptureQueue() const results: number[] = [] - queue.add(async () => { results.push(1) }) - queue.add(async () => { results.push(2) }) - queue.add(async () => { results.push(3) }) + queue.add(async () => { await Promise.resolve(); results.push(1) }, 'a') + queue.add(async () => { results.push(2) }, 'a') + queue.add(async () => { results.push(3) }, 'a') await queue.flush() expect(results).toEqual([1, 2, 3]) }) @@ -15,9 +21,9 @@ describe('CaptureQueue', () => { it('continues after a failing task', async () => { const queue = new CaptureQueue() const results: string[] = [] - queue.add(async () => { results.push('before') }) - queue.add(async () => { throw new Error('boom') }) - queue.add(async () => { results.push('after') }) + queue.add(async () => { results.push('before') }, 'a') + queue.add(async () => { throw new Error('boom') }, 'a') + queue.add(async () => { results.push('after') }, 'a') await queue.flush() expect(results).toEqual(['before', 'after']) }) @@ -27,30 +33,92 @@ describe('CaptureQueue', () => { await queue.flush() // should not hang }) - it('size reflects pending tasks', () => { + it('flush waits for in-flight tasks, not just queued ones', async () => { + const queue = new CaptureQueue() + const gate = deferred() + let done = false + queue.add(async () => { await gate.promise; done = true }, 'a') + + const flushed = queue.flush().then(() => done) + gate.resolve() + expect(await flushed).toBe(true) + }) + + // A slow generation on one endpoint used to block every other endpoint, + // because the queue ran a single task at a time. + it('runs different keys concurrently', async () => { const queue = new CaptureQueue() - let resolve!: () => void - const blocker = new Promise((r) => { resolve = r }) - queue.add(() => blocker) - queue.add(async () => {}) - expect(queue.size).toBe(1) - resolve() + const gates = [deferred(), deferred(), deferred()] + const started: string[] = [] + + gates.forEach((gate, i) => { + queue.add(async () => { started.push(`k${i}`); await gate.promise }, `k${i}`) + }) + + await Promise.resolve() + expect(started).toEqual(['k0', 'k1', 'k2']) + gates.forEach((g) => g.resolve()) + await queue.flush() + }) + + // Two shapes of the same endpoint must not run at once: both would read the + // same row and race on the read-modify-write inside upsertEndpoint. + it('never runs two tasks with the same key concurrently', async () => { + const queue = new CaptureQueue() + const gate = deferred() + let running = 0 + let maxConcurrent = 0 + + for (let i = 0; i < 3; i++) { + queue.add(async () => { + running++ + maxConcurrent = Math.max(maxConcurrent, running) + if (i === 0) await gate.promise + running-- + }, 'same-endpoint') + } + + await Promise.resolve() + gate.resolve() + await queue.flush() + expect(maxConcurrent).toBe(1) + }) + + it('honours the concurrency bound', async () => { + const queue = new CaptureQueue(1000, 2) + const gates = Array.from({ length: 4 }, deferred) + let running = 0 + let maxConcurrent = 0 + + gates.forEach((gate, i) => { + queue.add(async () => { + running++ + maxConcurrent = Math.max(maxConcurrent, running) + await gate.promise + running-- + }, `k${i}`) + }) + + await Promise.resolve() + expect(maxConcurrent).toBe(2) + gates.forEach((g) => g.resolve()) + await queue.flush() + expect(maxConcurrent).toBe(2) }) it('drops the oldest task when the pending bound is exceeded', async () => { - const queue = new CaptureQueue(2) - let release!: () => void - const blocker = new Promise((r) => { release = r }) + const queue = new CaptureQueue(2, 1) + const gate = deferred() const ran: number[] = [] - // First task blocks the worker so the rest accumulate as pending. - queue.add(() => blocker) - queue.add(async () => { ran.push(1) }) - queue.add(async () => { ran.push(2) }) - queue.add(async () => { ran.push(3) }) // pending hits the bound → evicts task 1 + // First task occupies the only worker so the rest accumulate as pending. + queue.add(() => gate.promise, 'blocker') + queue.add(async () => { ran.push(1) }, 'a') + queue.add(async () => { ran.push(2) }, 'b') + queue.add(async () => { ran.push(3) }, 'c') // pending hits the bound → evicts task 1 expect(queue.size).toBe(2) - release() + gate.resolve() await queue.flush() // Task 1 was evicted before it ran; 2 and 3 survive in order. expect(ran).toEqual([2, 3]) diff --git a/packages/core/src/__tests__/size.test.ts b/packages/core/src/__tests__/size.test.ts new file mode 100644 index 0000000..2ddc462 --- /dev/null +++ b/packages/core/src/__tests__/size.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { exceedsJsonSize } from '../size.js' + +describe('exceedsJsonSize', () => { + it('agrees with JSON.stringify on the decision, both ways', () => { + const cases: unknown[] = [ + null, + 42, + 'hello', + { a: 1, b: 'two', c: [1, 2, 3] }, + { nested: { deep: { deeper: Array.from({ length: 50 }, (_, i) => ({ i })) } } }, + Array.from({ length: 500 }, (_, i) => ({ id: i, name: 'x'.repeat(20) })), + ] + + for (const value of cases) { + const actual = JSON.stringify(value)?.length ?? 0 + // Well clear of the estimate's slack in both directions. + expect(exceedsJsonSize(value, actual * 4 + 100)).toBe(false) + expect(exceedsJsonSize(value, Math.floor(actual / 4))).toBe(actual > 0) + } + }) + + it('treats null and undefined as tiny', () => { + expect(exceedsJsonSize(null, 10)).toBe(false) + expect(exceedsJsonSize(undefined, 10)).toBe(false) + }) + + // The point of the early exit: an oversized payload costs `limit` bytes of + // work, not its full size. + it('bails out early instead of walking the whole payload', () => { + let visited = 0 + const huge = { + items: Array.from({ length: 100_000 }, (_, i) => ({ + get id() { + visited++ + return i + }, + })), + } + + expect(exceedsJsonSize(huge, 100)).toBe(true) + expect(visited).toBeLessThan(1000) + }) + + it('terminates on a self-referential object', () => { + const cyclic: Record = { a: 1 } + cyclic.self = cyclic + expect(() => exceedsJsonSize(cyclic, 1000)).not.toThrow() + }) + + it('does not treat a repeated sibling value as a cycle', () => { + const shared = { a: 'x'.repeat(100) } + expect(exceedsJsonSize({ one: shared, two: shared }, 50)).toBe(true) + }) +}) diff --git a/packages/core/src/__tests__/storage-concurrency.test.ts b/packages/core/src/__tests__/storage-concurrency.test.ts new file mode 100644 index 0000000..5bb0758 --- /dev/null +++ b/packages/core/src/__tests__/storage-concurrency.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { createTestAdapter } from '../storage/sqlite.js' +import type { DatabaseAdapter } from '../storage/adapter.js' +import type { Operation } from '../spec/schema.js' + +function spec(summary: string): Operation { + return { + summary, + tags: ['users'], + responses: { '200': { description: 'OK' } }, + } as Operation +} + +let adapter: DatabaseAdapter + +beforeEach(async () => { + adapter = await createTestAdapter() +}) + +// A plain select-then-insert races: two workers (cluster mode, several replicas +// behind one Postgres/libsql) both miss the select and both insert, and the +// loser throws on the unique index *after* paying for its LLM call. +describe('concurrent writes', () => { + it('findOrCreateProject converges on one id under concurrency', async () => { + const ids = await Promise.all( + Array.from({ length: 10 }, () => adapter.findOrCreateProject('racy')) + ) + expect(new Set(ids).size).toBe(1) + }) + + it('upsertEndpoint does not throw when the same endpoint is written concurrently', async () => { + const projectId = await adapter.findOrCreateProject('p') + + const ids = await Promise.all( + Array.from({ length: 10 }, (_, i) => + adapter.upsertEndpoint(projectId, '/users', 'GET', spec(`v${i}`), '[]') + ) + ) + + expect(new Set(ids).size).toBe(1) + const rows = await adapter.getEndpointsByProject(projectId) + expect(rows).toHaveLength(1) + }) + + it('keeps distinct endpoints separate under concurrency', async () => { + const projectId = await adapter.findOrCreateProject('p') + await Promise.all([ + adapter.upsertEndpoint(projectId, '/users', 'GET', spec('a'), '[]'), + adapter.upsertEndpoint(projectId, '/users', 'POST', spec('b'), '[]'), + adapter.upsertEndpoint(projectId, '/posts', 'GET', spec('c'), '[]'), + ]) + const rows = await adapter.getEndpointsByProject(projectId) + expect(rows).toHaveLength(3) + }) +}) + +// spec_versions used to grow without bound, and the dashboard loads the whole +// list for an endpoint. +describe('version retention', () => { + it('keeps a bounded window of the most recent versions', async () => { + const projectId = await adapter.findOrCreateProject('p') + let endpointId = '' + for (let i = 0; i < 70; i++) { + endpointId = await adapter.upsertEndpoint(projectId, '/users', 'GET', spec(`v${i}`), '[]') + } + + const versions = await adapter.getEndpointVersions(endpointId) + expect(versions.length).toBeLessThanOrEqual(50) + // Newest-first, and the newest snapshot is the one we just wrote. + expect(versions[0].spec?.summary).toBe('v69') + }) + + it('does not prune below the window', async () => { + const projectId = await adapter.findOrCreateProject('p') + let endpointId = '' + for (let i = 0; i < 5; i++) { + endpointId = await adapter.upsertEndpoint(projectId, '/users', 'GET', spec(`v${i}`), '[]') + } + const versions = await adapter.getEndpointVersions(endpointId) + expect(versions).toHaveLength(5) + }) +}) diff --git a/packages/core/src/ai/provider.ts b/packages/core/src/ai/provider.ts index 0a89a59..dd92a28 100644 --- a/packages/core/src/ai/provider.ts +++ b/packages/core/src/ai/provider.ts @@ -4,9 +4,14 @@ import { createDeepSeek } from '@ai-sdk/deepseek' import type { LanguageModel } from 'ai' import type { AIConfig } from '../types.js' -const DEFAULT_MODELS = { - openai: 'gpt-4o', - anthropic: 'claude-3-5-sonnet-20241022', +// Defaults are mid-tier models: spec generation is one call per newly-seen +// payload shape, so the flagship tier is not worth its cost here. Providers +// retire model IDs, which silently breaks every user who never set `ai.model` — +// so these get reviewed each release, and `buildOperation` turns an unknown-model +// error into an explicit "pin ai.model" message rather than a bare 404. +export const DEFAULT_MODELS = { + openai: 'gpt-5.4-mini-2026-03-17', + anthropic: 'claude-sonnet-5', ollama: 'llama3.2', deepseek: 'deepseek-chat', } @@ -79,7 +84,12 @@ export function resolveModel(config?: AIConfig, offline?: boolean): LanguageMode baseURL: config?.baseUrl ?? 'http://localhost:11434/v1', apiKey: 'ollama', }) - return client(model) + // `client(model)` returns a Responses-API model (POST /v1/responses). + // Ollama's OpenAI-compatible surface only implements /v1/chat/completions, + // as do most OpenAI-compatible gateways — so every generation 404'd, + // tripped the circuit breaker, and produced no docs. That silently broke + // both the no-API-key fallback and privacy.offline, which are pinned here. + return client.chat(model) } default: { const client = createOpenAI({ diff --git a/packages/core/src/capture.ts b/packages/core/src/capture.ts index 36d2037..197a4b4 100644 --- a/packages/core/src/capture.ts +++ b/packages/core/src/capture.ts @@ -2,6 +2,7 @@ import { CaptureQueue } from './queue.js' import { buildOperation } from './spec/builder.js' import { createAdapter } from './storage/adapter.js' import { hashShape } from './shape.js' +import { exceedsJsonSize } from './size.js' import { maybeStartDashboard } from './dashboard.js' import { detect, markSensitiveProperties } from './privacy/detect.js' import { resolveProvider, isHostedProvider } from './ai/provider.js' @@ -14,18 +15,31 @@ const DEFAULT_PROJECT = 'default' // endpoint row or an LLM call. TRACE/CONNECT likewise. const CAPTURED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) +// Default cap on a captured body. Without a cap the queue can retain up to +// maxPending payloads of unbounded size in the host app's heap, so `capture` is +// bounded by default rather than only when the user opts in. Raise or lower with +// capture.maxBodySize. +export const DEFAULT_MAX_BODY_SIZE = 256 * 1024 + // A response body we can't meaningfully document as a JSON schema: binary // payloads (Buffers/streams serialize as {type:'Buffer',data:[…]} garbage) and // non-JSON strings (text/plain, HTML) that adapters couldn't parse. Skip these // captures rather than feed noise to the model. `null` (no body) is fine. -/** Approximate serialized byte length of a body, for the maxBodySize cap. */ -function jsonSize(value: unknown): number { - if (value == null) return 0 - try { - return JSON.stringify(value)?.length ?? 0 - } catch { - return 0 +const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json\b/i + +/** + * True when the response declared a content type we can't document as a JSON + * schema (text/html, text/event-stream, an image…). Adapters null out bodies + * they couldn't parse, so without this an SSE or HTML route still became an + * endpoint row with an empty spec — and cost an LLM call to produce it. A + * response with no content-type at all (204 No Content) is still documented. + */ +function hasNonJsonContentType(headers: Record): boolean { + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() !== 'content-type') continue + return value.trim() !== '' && !JSON_CONTENT_TYPE.test(value) } + return false } function isNonJsonBody(value: unknown): boolean { @@ -39,9 +53,11 @@ function isNonJsonBody(value: unknown): boolean { return false } -// How many distinct request/response shapes to remember per endpoint before -// evicting the oldest. Bounds the tracking string; large enough to cover an -// endpoint's realistic set of status-class × shape combinations. +// How many distinct request/response shapes to document per endpoint. Once an +// endpoint has this many, it is considered fully documented and stops +// regenerating: previously the oldest key was evicted, so an endpoint whose +// payload has many optional-field combinations would evict a shape, see it +// again, and pay for another LLM call — forever. const MAX_SEEN_SHAPES = 50 // A capture only needs (re)generation when its *shape* is one we haven't @@ -103,19 +119,48 @@ function warnIfNoAIKey(config: EasyDocsConfig) { export interface Capturer { capture(event: CaptureEvent): void + /** + * Resolves once every queued capture has finished. Adapters that have a + * framework shutdown hook call this so a deploy doesn't discard specs that + * were still generating. + */ + flush(): Promise } // Stop attempting generation after this many consecutive failures — otherwise a // misconfigured/unreachable provider (e.g. Ollama fallback with no server running) // produces one error log per captured request, forever. const FAILURE_CIRCUIT_THRESHOLD = 5 +// …but re-try one capture after this long. A tripped circuit used to be +// permanent for the process lifetime, so a transient outage (provider restart, +// rate limit, a 529) silently stopped all documentation until the next deploy. +const CIRCUIT_COOLDOWN_MS = 60_000 export function createCapturer(config: EasyDocsConfig): Capturer { const adapter = createAdapter(config.storage) const queue = new CaptureQueue() const offline = config.privacy?.offline === true let consecutiveFailures = 0 - let circuitOpen = false + let circuitOpenedAt = 0 + + // Shapes already documented, per endpoint, mirrored in-process. The whole + // point is that the *sync* path can drop a repeat capture without enqueueing + // it: previously every request was queued and the dedup check happened inside + // the task, so steady-state traffic retained its bodies in the queue and sat + // behind whatever generation was in flight. + const seenByEndpoint = new Map>() + const inFlight = new Set() + const saturated = new Set() + + function circuitIsOpen(): boolean { + if (circuitOpenedAt === 0) return false + if (Date.now() - circuitOpenedAt < CIRCUIT_COOLDOWN_MS) return true + // Half-open: let one capture through. It either succeeds (closing the + // circuit) or re-opens it for another cooldown. + circuitOpenedAt = 0 + consecutiveFailures = FAILURE_CIRCUIT_THRESHOLD - 1 + return false + } if (offline) { // Fail fast on a contradictory hosted provider, before any traffic is captured. @@ -128,37 +173,67 @@ export function createCapturer(config: EasyDocsConfig): Capturer { warnIfNoAIKey(config) } - return { - capture(event: CaptureEvent) { - if (!CAPTURED_METHODS.has(event.method)) return - if (isNonJsonBody(event.response)) return - - const { ignoreRoutes, includePaths, maxBodySize } = config.capture ?? {} - // Skip oversized payloads rather than deep-clone/redact/hash a huge body on - // every capture. Enforces the documented capture.maxBodySize cap. - if (maxBodySize !== undefined && (jsonSize(event.body) > maxBodySize || jsonSize(event.response) > maxBodySize)) { - return - } - if (ignoreRoutes?.some((r) => event.path.startsWith(r))) return - if (includePaths && !includePaths.some((p) => event.path.startsWith(p))) return + function enqueue(event: CaptureEvent) { + if (!CAPTURED_METHODS.has(event.method)) return + if (isNonJsonBody(event.response)) return + if (hasNonJsonContentType(event.responseHeaders)) return - if (config.dashboard?.autoStart === true) { - maybeStartDashboard(config.dashboard.port ?? 4999).catch(() => {}) - } + const { ignoreRoutes, includePaths, maxBodySize } = config.capture ?? {} + if (ignoreRoutes?.some((r) => event.path.startsWith(r))) return + if (includePaths && !includePaths.some((p) => event.path.startsWith(p))) return + + // Skip oversized payloads rather than deep-clone/redact/hash a huge body on + // every capture. Enforces the documented capture.maxBodySize cap. + const limit = maxBodySize ?? DEFAULT_MAX_BODY_SIZE + if (exceedsJsonSize(event.body, limit) || exceedsJsonSize(event.response, limit)) return + + if (config.dashboard?.autoStart === true) { + maybeStartDashboard(config.dashboard.port ?? 4999).catch(() => {}) + } - const projectSlug = config.project ?? DEFAULT_PROJECT + if (circuitIsOpen()) return - queue.add(async () => { - // Circuit is open: generation has been failing repeatedly, so don't keep - // hammering the provider (and the error log) on every request. - if (circuitOpen) return + const projectSlug = config.project ?? DEFAULT_PROJECT + const endpointKey = `${projectSlug} ${event.method} ${event.path}` + if (saturated.has(endpointKey)) return + + const key = shapeKey(event) + if (seenByEndpoint.get(endpointKey)?.has(key)) return + + // Without this, a burst of identical first-time requests all enqueue before + // the first one finishes, and each pays for its own LLM call. + const flightKey = `${endpointKey}${key}` + if (inFlight.has(flightKey)) return + inFlight.add(flightKey) + + queue.add(async () => { + try { + if (circuitIsOpen()) return - const key = shapeKey(event) const projectId = await adapter.findOrCreateProject(projectSlug) const existing = await adapter.getEndpointByPathMethod(projectId, event.path, event.method) - const seen = parseSeenShapes(existing?.responseHash) + + // Merge what the DB already knows into the in-process cache, so a fresh + // process doesn't regenerate everything it documented in a prior run. + let seen = seenByEndpoint.get(endpointKey) + if (!seen) { + seen = new Set() + seenByEndpoint.set(endpointKey, seen) + } + for (const k of parseSeenShapes(existing?.responseHash)) seen.add(k) + // Skip only when this exact shape has already been documented. - if (existing?.spec && seen.includes(key)) return + if (existing?.spec && seen.has(key)) return + + if (seen.size >= MAX_SEEN_SHAPES) { + saturated.add(endpointKey) + console.warn( + `[EasyDocs] ${event.method} ${event.path} has ${seen.size} distinct payload shapes — ` + + 'treating it as fully documented and skipping further generation for it. ' + + 'Highly variable payloads are documented from the shapes seen so far.' + ) + return + } // Detect PII/secrets. Redact before sending to a hosted provider so values // never leave the machine; for local Ollama keep real values (better @@ -179,28 +254,44 @@ export function createCapturer(config: EasyDocsConfig): Capturer { spec = await buildOperation(eventForAI, existing?.spec ?? null, config.ai, offline) } catch (err) { if (++consecutiveFailures >= FAILURE_CIRCUIT_THRESHOLD) { - circuitOpen = true + circuitOpenedAt = Date.now() console.warn( - `[EasyDocs] Disabling capture after ${FAILURE_CIRCUIT_THRESHOLD} consecutive ` + - 'generation failures. Check your AI provider (is Ollama running / is the API key valid?).' + `[EasyDocs] Pausing capture for ${CIRCUIT_COOLDOWN_MS / 1000}s after ` + + `${FAILURE_CIRCUIT_THRESHOLD} consecutive generation failures. ` + + 'Check your AI provider (is Ollama running / is the API key valid?).' ) } throw err } consecutiveFailures = 0 + circuitOpenedAt = 0 markSensitiveProperties(spec, sensitivePaths) - // Record this shape as seen (move-to-end, capped) so future identical - // captures are skipped while genuinely new shapes still regenerate. - const nextSeen = [...seen.filter((k) => k !== key), key].slice(-MAX_SEEN_SHAPES) + seen.add(key) await adapter.upsertEndpoint( projectId, event.path, event.method, spec, - JSON.stringify(nextSeen) + JSON.stringify([...seen].slice(-MAX_SEEN_SHAPES)) ) - }) + } finally { + inFlight.delete(flightKey) + } + }, endpointKey) + } + + return { + capture(event: CaptureEvent) { + // capture() is called synchronously from inside the host app's response + // path (e.g. Express's res.json). A throw here would surface as an error + // in the user's own handler, so documentation can never break their API. + try { + enqueue(event) + } catch (err) { + console.error('[EasyDocs] Capture failed:', err) + } }, + flush: () => queue.flush(), } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1254e29..2cbd16f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,14 +14,9 @@ export { saveManualSpec, resolveConflict, } from './storage/sqlite.js' -export { - createPgDB, - pgGetAll, - pgGetEndpointsByProject, - pgGetAllProjects, - pgDeleteById, - pgSaveManualSpec, -} from './storage/postgres.js' +// The Postgres helpers deliberately are NOT re-exported here: a static +// re-export pulls the `postgres` driver into every SQLite install. Import them +// from '@easydocs/core/storage/postgres' instead. export { buildOperation } from './spec/builder.js' export { buildFullSpec } from './spec/assemble.js' export { diffSpecs, renderDiff, isEmptyDiff, classifyDiff, shouldFail, renderClassifiedDiff } from './spec/diff.js' diff --git a/packages/core/src/privacy/detect.ts b/packages/core/src/privacy/detect.ts index cb36b70..72f8287 100644 --- a/packages/core/src/privacy/detect.ts +++ b/packages/core/src/privacy/detect.ts @@ -48,21 +48,29 @@ function isFlagged(key: string, value: unknown, ctx: DetectContext): boolean { return false } -function redactTree(value: unknown, ctx: DetectContext): unknown { - if (Array.isArray(value)) return value.map((v) => redactTree(v, ctx)) +// Bodies reach us as live objects, not JSON text, so a self-referential value +// (a Mongoose doc, an entity with a back-reference) would recurse forever here. +function redactTree(value: unknown, ctx: DetectContext, seen = new Set()): unknown { if (value && typeof value === 'object') { - const out: Record = {} - for (const [k, v] of Object.entries(value as Record)) { - if (ctx.allow.has(normalizeKey(k))) { - out[k] = v - } else if (isFlagged(k, v, ctx)) { - ctx.sensitivePaths.add(k) - out[k] = redactValue(v, ctx.placeholder) - } else { - out[k] = redactTree(v, ctx) + if (seen.has(value)) return '[Circular]' + seen.add(value) + try { + if (Array.isArray(value)) return value.map((v) => redactTree(v, ctx, seen)) + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (ctx.allow.has(normalizeKey(k))) { + out[k] = v + } else if (isFlagged(k, v, ctx)) { + ctx.sensitivePaths.add(k) + out[k] = redactValue(v, ctx.placeholder) + } else { + out[k] = redactTree(v, ctx, seen) + } } + return out + } finally { + seen.delete(value) } - return out } return value } @@ -120,19 +128,47 @@ function redactHeaders(headers: Record, ctx: DetectContext): Rec return out } +// The allowlist, key-name set, and compiled custom patterns depend only on the +// config, which never changes for a given capturer — so build them once per +// distinct config instead of recompiling every regex on every captured request. +type CompiledRules = Pick +const rulesCache = new WeakMap() +let defaultRules: CompiledRules | undefined + +function compileRules(config?: PrivacyConfig): CompiledRules { + if (!config) { + defaultRules ??= { + placeholder: DEFAULT_PLACEHOLDER, + allow: new Set(), + keyNames: new Set(SENSITIVE_KEY_NAMES), + valuePatterns: [], + } + return defaultRules + } + + const cached = rulesCache.get(config) + if (cached) return cached + + const compiled: CompiledRules = { + placeholder: config.placeholder ?? DEFAULT_PLACEHOLDER, + allow: new Set((config.allowlist ?? []).map(normalizeKey)), + keyNames: new Set([ + ...SENSITIVE_KEY_NAMES, + ...(config.customRules?.keyNames ?? []).map(normalizeKey), + ]), + valuePatterns: (config.customRules?.valuePatterns ?? []).map((p) => new RegExp(p)), + } + rulesCache.set(config, compiled) + return compiled +} + /** * Scan a CaptureEvent for sensitive fields. Returns a redacted clone (safe to send * to a hosted Provider) and the set of flagged key names. Pure and offline. */ export function detect(event: CaptureEvent, config?: PrivacyConfig): DetectResult { const ctx: DetectContext = { - placeholder: config?.placeholder ?? DEFAULT_PLACEHOLDER, - allow: new Set((config?.allowlist ?? []).map(normalizeKey)), - keyNames: new Set([ - ...SENSITIVE_KEY_NAMES, - ...(config?.customRules?.keyNames ?? []).map(normalizeKey), - ]), - valuePatterns: (config?.customRules?.valuePatterns ?? []).map((p) => new RegExp(p)), + ...compileRules(config), sensitivePaths: new Set(), } diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts index dc9ac2f..d253288 100644 --- a/packages/core/src/queue.ts +++ b/packages/core/src/queue.ts @@ -5,15 +5,32 @@ type Task = () => Promise // we drop the oldest and warn, keeping the host app healthy. const DEFAULT_MAX_PENDING = 1000 +// How many capture tasks may run at once. Tasks are serialized per key (one +// endpoint at a time, so two shapes of the same endpoint can't race on the +// read-modify-write in upsertEndpoint) but run in parallel across keys, so a +// slow generation on one endpoint no longer blocks every other endpoint. +const DEFAULT_CONCURRENCY = 4 + +interface Entry { + key: string + task: Task +} + export class CaptureQueue { - private tasks: Task[] = [] - private running = false - private current: Promise = Promise.resolve() + private tasks: Entry[] = [] + private running = 0 + /** Keys with a task currently executing — used to serialize per endpoint. */ + private active = new Set() + private idle: Promise = Promise.resolve() + private resolveIdle: (() => void) | null = null private warnedFull = false - constructor(private readonly maxPending: number = DEFAULT_MAX_PENDING) {} + constructor( + private readonly maxPending: number = DEFAULT_MAX_PENDING, + private readonly concurrency: number = DEFAULT_CONCURRENCY + ) {} - add(task: Task) { + add(task: Task, key = '') { if (this.tasks.length >= this.maxPending) { this.tasks.shift() if (!this.warnedFull) { @@ -23,30 +40,57 @@ export class CaptureQueue { ) this.warnedFull = true } - } else if (this.warnedFull && this.tasks.length === 0) { - this.warnedFull = false } - this.tasks.push(task) - if (!this.running) this.current = this.pump() + this.tasks.push({ key, task }) + if (this.resolveIdle === null) { + this.idle = new Promise((resolve) => { + this.resolveIdle = resolve + }) + } + this.pump() } + /** + * Resolves once every queued capture has finished. Adapters call this on + * framework shutdown so in-flight spec generation isn't lost on deploy. + */ async flush(): Promise { - await this.current + await this.idle } - private async pump() { - this.running = true - while (this.tasks.length > 0) { - const task = this.tasks.shift()! - await task().catch((err: unknown) => { - console.error('[EasyDocs] Capture task failed:', err) - }) + private pump() { + while (this.running < this.concurrency) { + // Skip entries whose key is already executing: same-endpoint tasks stay + // strictly ordered, different endpoints proceed in parallel. + const index = this.tasks.findIndex((e) => e.key === '' || !this.active.has(e.key)) + if (index === -1) break + + const [entry] = this.tasks.splice(index, 1) + this.running++ + if (entry.key) this.active.add(entry.key) + + entry + .task() + .catch((err: unknown) => { + console.error('[EasyDocs] Capture task failed:', err) + }) + .finally(() => { + this.running-- + if (entry.key) this.active.delete(entry.key) + if (this.tasks.length > 0) { + // Queue drained below the cap; allow a fresh warning if it fills again. + if (this.warnedFull && this.tasks.length < this.maxPending) this.warnedFull = false + this.pump() + } else if (this.running === 0) { + this.warnedFull = false + this.resolveIdle?.() + this.resolveIdle = null + } + }) } - this.running = false } get size() { return this.tasks.length } } - diff --git a/packages/core/src/shape.ts b/packages/core/src/shape.ts index 65c7258..ad0a2ba 100644 --- a/packages/core/src/shape.ts +++ b/packages/core/src/shape.ts @@ -1,15 +1,33 @@ -export function extractShape(value: unknown): unknown { +// Shape extraction runs on the request hot path (see capture.ts), so it must +// terminate on any input a framework can hand us — including self-referential +// objects, which JSON.stringify would reject but which reach us unserialized. +const MAX_DEPTH = 20 + +function walk(value: unknown, depth: number, seen: Set): unknown { if (value === null) return 'null' if (value === undefined) return 'undefined' - if (Array.isArray(value)) return [extractShape(value[0])] - if (typeof value === 'object') { + if (typeof value !== 'object') return typeof value + if (depth >= MAX_DEPTH) return 'object' + + const obj = value as object + if (seen.has(obj)) return 'circular' + seen.add(obj) + try { + if (Array.isArray(obj)) return [walk(obj[0], depth + 1, seen)] return Object.fromEntries( - Object.keys(value as Record) + Object.keys(obj as Record) .sort() - .map((k) => [k, extractShape((value as Record)[k])]) + .map((k) => [k, walk((obj as Record)[k], depth + 1, seen)]) ) + } finally { + // Only reject cycles (an ancestor repeating), not a value that legitimately + // appears twice in sibling positions. + seen.delete(obj) } - return typeof value +} + +export function extractShape(value: unknown): unknown { + return walk(value, 0, new Set()) } export function hashShape(value: unknown): string { diff --git a/packages/core/src/size.ts b/packages/core/src/size.ts new file mode 100644 index 0000000..83a4786 --- /dev/null +++ b/packages/core/src/size.ts @@ -0,0 +1,58 @@ +/** + * True when `value` would serialize to more than `limit` bytes of JSON. + * + * Deliberately not `JSON.stringify(value).length > limit`: this runs inline in + * the response path for every captured request, and the stringify version costs + * the most on exactly the huge payloads the cap exists to reject (~2ms on 1MB). + * This walk stops as soon as the budget is blown, so an oversized body costs + * `limit` bytes of work, not its full size. Self-referential objects terminate + * rather than overflowing the stack. + */ +export function exceedsJsonSize(value: unknown, limit: number): boolean { + let budget = limit + const seen = new Set() + + function walk(v: unknown): boolean { + if (budget < 0) return true + if (v === null || v === undefined) { + budget -= 4 + return budget < 0 + } + switch (typeof v) { + case 'string': + budget -= v.length + 2 + return budget < 0 + case 'number': + case 'boolean': + budget -= 8 + return budget < 0 + case 'object': + break + default: + return false + } + + const obj = v as object + if (seen.has(obj)) return false + seen.add(obj) + try { + budget -= 2 + if (Array.isArray(obj)) { + for (const item of obj) { + if (walk(item)) return true + } + return false + } + for (const [k, val] of Object.entries(obj as Record)) { + budget -= k.length + 4 + if (budget < 0) return true + if (walk(val)) return true + } + return false + } finally { + seen.delete(obj) + } + } + + return walk(value) +} diff --git a/packages/core/src/spec/builder.ts b/packages/core/src/spec/builder.ts index 75b6367..67f5e44 100644 --- a/packages/core/src/spec/builder.ts +++ b/packages/core/src/spec/builder.ts @@ -1,5 +1,5 @@ import { generateText } from 'ai' -import { resolveModel } from '../ai/provider.js' +import { resolveModel, resolveProvider, DEFAULT_MODELS } from '../ai/provider.js' import { OperationSchema } from './schema.js' import type { Operation } from './schema.js' import { detectAuthSchemes, VALID_SCHEME_NAMES } from './auth.js' @@ -22,14 +22,33 @@ export function deriveTag(path: string): string { return 'default' } -function trimResponse(response: unknown, maxItems = 1): unknown { - if (Array.isArray(response)) return response.slice(0, maxItems) - if (response && typeof response === 'object') { +/** + * Collapse arrays to a single element before sending a payload to the model. + * The schema is derived from element *shape*, so the remaining items are pure + * token cost — a 500-item bulk insert used to be billed in full because only + * the response was trimmed, never the request body. + */ +function trimPayload(payload: unknown, maxItems = 1): unknown { + if (Array.isArray(payload)) return payload.slice(0, maxItems).map((v) => trimPayload(v, maxItems)) + if (payload && typeof payload === 'object') { return Object.fromEntries( - Object.entries(response as Record).map(([k, v]) => [k, trimResponse(v)]) + Object.entries(payload as Record).map(([k, v]) => [k, trimPayload(v, maxItems)]) ) } - return response + return payload +} + +// A retired or misspelled model ID is the single most likely cause of every +// generation failing at once, and providers report it as an opaque 404/400. +// Say what to do instead of surfacing the raw provider error. +function describeModelError(err: unknown, model: string): string | null { + const message = err instanceof Error ? err.message : String(err) + if (!/model|not_found|404|does not exist|deprecated|decommission/i.test(message)) return null + return ( + `[EasyDocs] The AI provider rejected model "${model}". It may have been retired. ` + + 'Set an explicit { ai: { model: "..." } } in your EasyDocs config. ' + + `Provider said: ${message}` + ) } /** Pull a JSON object out of a model's text reply, tolerating markdown fences and prose. */ @@ -55,8 +74,10 @@ export async function buildOperation( offline?: boolean ): Promise { const model = resolveModel(aiConfig, offline) + const modelId = aiConfig?.model ?? DEFAULT_MODELS[resolveProvider(aiConfig, offline)] const timeoutMs = aiConfig?.timeoutMs ?? DEFAULT_TIMEOUT_MS - const trimmedResponse = trimResponse(event.response) + const trimmedResponse = trimPayload(event.response) + const trimmedBody = trimPayload(event.body) const detectedAuth = detectAuthSchemes(event.requestHeaders, event.query) const authGuideline = @@ -121,7 +142,7 @@ export async function buildOperation( `Path: ${event.path}`, `Query params: ${JSON.stringify(event.query)}`, `Path params: ${JSON.stringify(event.params)}`, - `Request body: ${event.body ? JSON.stringify(event.body) : 'none'}`, + `Request body: ${event.body ? JSON.stringify(trimmedBody) : 'none'}`, `Response status: ${event.status}`, `Response body: ${JSON.stringify(trimmedResponse)}`, detectedAuth.length > 0 ? `Detected auth: ${detectedAuth.join(', ')}` : '', @@ -137,14 +158,21 @@ export async function buildOperation( ? basePrompt : `${basePrompt}\n\nYour previous response was invalid: ${lastError}\nReturn ONLY a corrected JSON object.` - const { text } = await generateText({ - model, - system, - prompt, - // Bound each attempt so a provider that hangs (no response, no error) - // can't block the single-worker capture queue indefinitely. - abortSignal: AbortSignal.timeout(timeoutMs), - }) + let text: string + try { + ;({ text } = await generateText({ + model, + system, + prompt, + // Bound each attempt so a provider that hangs (no response, no error) + // can't stall the capture queue indefinitely. + abortSignal: AbortSignal.timeout(timeoutMs), + })) + } catch (err) { + // The hint already embeds the provider's own message, so nothing is lost. + const hint = describeModelError(err, modelId) + throw hint ? new Error(hint) : err + } try { const parsed = OperationSchema.safeParse(extractJson(text)) diff --git a/packages/core/src/storage/adapter.ts b/packages/core/src/storage/adapter.ts index 04d16d8..609cc2b 100644 --- a/packages/core/src/storage/adapter.ts +++ b/packages/core/src/storage/adapter.ts @@ -2,7 +2,6 @@ import type { Operation } from '../spec/schema.js' import type { HttpMethod, StorageConfig } from '../types.js' import type { Endpoint, Project, SpecVersion } from './schema.js' import { createSqliteAdapter } from './sqlite.js' -import { createPostgresAdapter } from './postgres.js' export interface DatabaseAdapter { findOrCreateProject(slug: string): Promise @@ -18,9 +17,39 @@ export interface DatabaseAdapter { resolveConflict(id: string, keep: 'ai' | 'manual'): Promise } +/** + * Defer every call until `pending` resolves. Lets `createAdapter` stay + * synchronous while the Postgres driver is loaded with a dynamic import. + */ +function lazyAdapter(pending: Promise): DatabaseAdapter { + const on = (name: K): DatabaseAdapter[K] => + ((...args: unknown[]) => + pending.then((a) => (a[name] as (...a: unknown[]) => unknown)(...args))) as DatabaseAdapter[K] + + return { + findOrCreateProject: on('findOrCreateProject'), + findProject: on('findProject'), + getEndpointByPathMethod: on('getEndpointByPathMethod'), + upsertEndpoint: on('upsertEndpoint'), + getAllProjects: on('getAllProjects'), + getAllEndpoints: on('getAllEndpoints'), + getEndpointsByProject: on('getEndpointsByProject'), + getEndpointVersions: on('getEndpointVersions'), + deleteEndpointById: on('deleteEndpointById'), + saveManualSpec: on('saveManualSpec'), + resolveConflict: on('resolveConflict'), + } +} + export function createAdapter(config?: StorageConfig): DatabaseAdapter { if (config?.type === 'postgres' && config.url) { - return createPostgresAdapter(config.url, config.poolSize) + // Imported dynamically so the `postgres` driver is never loaded (or paid for + // at startup) by the SQLite default, which is what almost every install uses. + const url = config.url + const poolSize = config.poolSize + return lazyAdapter( + import('./postgres.js').then((m) => m.createPostgresAdapter(url, poolSize)) + ) } return createSqliteAdapter(config?.url) } diff --git a/packages/core/src/storage/postgres.ts b/packages/core/src/storage/postgres.ts index 5cb4f46..fa395c3 100644 --- a/packages/core/src/storage/postgres.ts +++ b/packages/core/src/storage/postgres.ts @@ -1,6 +1,6 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgresJs from 'postgres' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, notInArray } from 'drizzle-orm' import { pgTable, uuid, text, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core' import { specsEqual } from './versions.js' import type { Operation } from '../spec/schema.js' @@ -70,6 +70,12 @@ const INIT_SQL = ` CREATE INDEX IF NOT EXISTS spec_versions_endpoint ON spec_versions (endpoint_id, created_at); + + -- Also created inline above for fresh databases; declared separately so + -- databases created before it exists gain it too. ON CONFLICT in + -- pgUpsertEndpoint needs a unique index on exactly these columns. + CREATE UNIQUE INDEX IF NOT EXISTS endpoints_path_method_project + ON endpoints (path, method, project_id); ` export type PgDB = ReturnType @@ -111,11 +117,18 @@ export async function pgFindOrCreateProject(db: PgDB, slug: string): Promise k.id)) + ) + ) } async function pgHasVersions(db: PgDB, endpointId: string) { @@ -180,28 +211,30 @@ export async function pgUpsertEndpoint( const hasConflict = !!(existing?.isManuallyEdited && existing.manualSpec) - if (existing) { - await db - .update(pgEndpoints) - .set({ spec, responseHash, hasConflict, updatedAt: new Date() }) - .where(eq(pgEndpoints.id, existing.id)) - if (!specsEqual(spec, existing.spec)) { - // Endpoints created before version history have no versions; backfill the - // prior spec as a baseline so this first change has something to diff against. - if (existing.spec && !(await pgHasVersions(db, existing.id))) { - await pgRecordVersion(db, existing.id, existing.spec, 'ai') - } - await pgRecordVersion(db, existing.id, spec, 'ai') - } - return existing.id - } - + // Atomic insert-or-update: with several app replicas writing to one Postgres, + // select-then-insert collides on the unique index and throws after the LLM + // call has already been paid for. const result = await db .insert(pgEndpoints) .values({ projectId, path, method, spec, responseHash }) + .onConflictDoUpdate({ + target: [pgEndpoints.path, pgEndpoints.method, pgEndpoints.projectId], + set: { spec, responseHash, hasConflict, updatedAt: new Date() }, + }) .returning({ id: pgEndpoints.id }) - await pgRecordVersion(db, result[0].id, spec, 'ai') - return result[0].id + const id = result[0].id + + if (!existing) { + await pgRecordVersion(db, id, spec, 'ai') + } else if (!specsEqual(spec, existing.spec)) { + // Endpoints created before version history have no versions; backfill the + // prior spec as a baseline so this first change has something to diff against. + if (existing.spec && !(await pgHasVersions(db, id))) { + await pgRecordVersion(db, id, existing.spec, 'ai') + } + await pgRecordVersion(db, id, spec, 'ai') + } + return id } export async function pgGetByPathMethod( diff --git a/packages/core/src/storage/sqlite.ts b/packages/core/src/storage/sqlite.ts index bf77b61..3811ac7 100644 --- a/packages/core/src/storage/sqlite.ts +++ b/packages/core/src/storage/sqlite.ts @@ -1,6 +1,6 @@ import { createClient } from '@libsql/client' import { drizzle } from 'drizzle-orm/libsql' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq, notInArray, sql } from 'drizzle-orm' import { endpoints, projects, specVersions } from './schema.js' import { specsEqual } from './versions.js' import type { Operation } from '../spec/schema.js' @@ -96,9 +96,13 @@ export function dbReady(db: DB): Promise { export async function findOrCreateProject(db: DB, slug: string): Promise { const existing = await db.select().from(projects).where(eq(projects.slug, slug)).get() if (existing) return existing.id + // Two workers (cluster mode, multiple replicas) can both miss the select and + // then both insert; let the loser fall through to the re-read instead of + // throwing on the unique index. const id = crypto.randomUUID() - await db.insert(projects).values({ id, name: slug, slug }) - return id + await db.insert(projects).values({ id, name: slug, slug }).onConflictDoNothing() + const row = await db.select().from(projects).where(eq(projects.slug, slug)).get() + return row?.id ?? id } /** Resolve a project slug to its id WITHOUT creating it. For read paths. */ @@ -113,8 +117,27 @@ export async function getAllProjects(db: DB) { // ─── Endpoints ─────────────────────────────────────────────────────────────── +// spec_versions grows on every regeneration and the dashboard loads the whole +// list, so keep a bounded window of recent snapshots per endpoint. +const MAX_VERSIONS_PER_ENDPOINT = 50 + async function recordVersion(db: DB, endpointId: string, spec: Operation, source: 'ai' | 'manual') { await db.insert(specVersions).values({ id: crypto.randomUUID(), endpointId, spec, source }) + + const keep = await db + .select({ id: specVersions.id }) + .from(specVersions) + .where(eq(specVersions.endpointId, endpointId)) + .orderBy(desc(specVersions.createdAt), sql`rowid desc`) + .limit(MAX_VERSIONS_PER_ENDPOINT) + .all() + if (keep.length < MAX_VERSIONS_PER_ENDPOINT) return + await db.delete(specVersions).where( + and( + eq(specVersions.endpointId, endpointId), + notInArray(specVersions.id, keep.map((k) => k.id)) + ) + ) } async function hasVersions(db: DB, endpointId: string) { @@ -160,25 +183,29 @@ export async function upsertEndpoint( const hasConflict = !!(existing?.isManuallyEdited && existing.manualSpec) - if (existing) { - await db - .update(endpoints) - .set({ spec, responseHash, hasConflict, updatedAt: new Date() }) - .where(eq(endpoints.id, existing.id)) - if (!specsEqual(spec, existing.spec)) { - // Endpoints created before version history have no versions; backfill the - // prior spec as a baseline so this first change has something to diff against. - if (existing.spec && !(await hasVersions(db, existing.id))) { - await recordVersion(db, existing.id, existing.spec, 'ai') - } - await recordVersion(db, existing.id, spec, 'ai') + // One statement rather than select-then-insert/update: concurrent workers + // otherwise collide on endpoints_path_method_project and throw *after* the + // LLM call has already been paid for. + const rows = await db + .insert(endpoints) + .values({ id: crypto.randomUUID(), projectId, path, method, spec, responseHash }) + .onConflictDoUpdate({ + target: [endpoints.path, endpoints.method, endpoints.projectId], + set: { spec, responseHash, hasConflict, updatedAt: new Date() }, + }) + .returning({ id: endpoints.id }) + const id = rows[0].id + + if (!existing) { + await recordVersion(db, id, spec, 'ai') + } else if (!specsEqual(spec, existing.spec)) { + // Endpoints created before version history have no versions; backfill the + // prior spec as a baseline so this first change has something to diff against. + if (existing.spec && !(await hasVersions(db, id))) { + await recordVersion(db, id, existing.spec, 'ai') } - return existing.id + await recordVersion(db, id, spec, 'ai') } - - const id = crypto.randomUUID() - await db.insert(endpoints).values({ id, projectId, path, method, spec, responseHash }) - await recordVersion(db, id, spec, 'ai') return id } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 64f8d3c..d04e9c6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,9 @@ const DashboardConfigSchema = z.object({ const CaptureConfigSchema = z.object({ ignoreRoutes: z.array(z.string()).optional(), includePaths: z.array(z.string()).optional(), + // Bodies larger than this (approximate serialized bytes) are skipped, so a + // stalled provider can't let the pending-capture queue retain unbounded + // payloads in the host app's heap. Defaults to 256 KB. maxBodySize: z.number().int().positive().optional(), }).strict() diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 216ae57..a0edbd1 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'storage/schema': 'src/storage/schema.ts', + 'storage/postgres': 'src/storage/postgres.ts', 'spec/schema': 'src/spec/schema.ts', 'spec/diff': 'src/spec/diff.ts', 'spec/drift': 'src/spec/drift.ts', diff --git a/packages/elysia/src/__tests__/plugin.test.ts b/packages/elysia/src/__tests__/plugin.test.ts index 244feae..3f84004 100644 --- a/packages/elysia/src/__tests__/plugin.test.ts +++ b/packages/elysia/src/__tests__/plugin.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index fce533d..a37386b 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -2,42 +2,58 @@ import { createCapturer, parseConfig, buildCaptureEvent } from '@easydocs/core' import type { EasyDocsConfig } from '@easydocs/core' import { Elysia } from 'elysia' +// Only `application/json` (and `+json` suffixes) can become a documented schema. +// The check also keeps a streamed response safe: awaiting .json() on an open +// stream never resolves, and the hook is awaited before the response is sent. +const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json\b/i + +function isJson(headers: Headers): boolean { + return JSON_CONTENT_TYPE.test(headers.get('content-type') ?? '') +} + export function easydocs(config?: EasyDocsConfig) { const parsedConfig = parseConfig(config) const capturer = createCapturer(parsedConfig) - return new Elysia({ name: '@easydocs/elysia' }).onAfterHandle( - { as: 'global' }, - async ({ request, response, set, path, params, query, body }) => { - let responseBody: unknown = null - if (response instanceof Response) { - try { - responseBody = await response.clone().json() - } catch { - // non-JSON + return new Elysia({ name: '@easydocs/elysia' }) + .onStop(async () => { + // Drain queued generation so a redeploy doesn't discard in-flight specs. + await capturer.flush() + }) + .onAfterHandle( + { as: 'global' }, + async ({ request, response, set, path, params, query, body }) => { + let responseBody: unknown = null + if (response instanceof Response) { + if (isJson(response.headers)) { + try { + responseBody = await response.clone().json() + } catch { + // malformed JSON body + } + } + } else { + responseBody = response } - } else { - responseBody = response - } - const status = - response instanceof Response ? response.status : (set.status as number | undefined) ?? 200 + const status = + response instanceof Response ? response.status : (set.status as number | undefined) ?? 200 - capturer.capture( - buildCaptureEvent({ - method: request.method, - path, - query: query as Record, - params: params as Record, - requestBody: body, - responseBody, - status, - requestHeaders: Object.fromEntries(request.headers.entries()), - responseHeaders: - response instanceof Response - ? Object.fromEntries(response.headers.entries()) - : (set.headers as Record) ?? {}, - }) - ) - } - ) + capturer.capture( + buildCaptureEvent({ + method: request.method, + path, + query: query as Record, + params: params as Record, + requestBody: body, + responseBody, + status, + requestHeaders: Object.fromEntries(request.headers.entries()), + responseHeaders: + response instanceof Response + ? Object.fromEntries(response.headers.entries()) + : (set.headers as Record) ?? {}, + }) + ) + } + ) } diff --git a/packages/express/src/__tests__/middleware.test.ts b/packages/express/src/__tests__/middleware.test.ts index 7306fc2..b7f163e 100644 --- a/packages/express/src/__tests__/middleware.test.ts +++ b/packages/express/src/__tests__/middleware.test.ts @@ -5,7 +5,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 71e8abe..bf600a3 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -5,18 +5,26 @@ import type { Request, Response, NextFunction } from 'express' export function easydocs(config?: EasyDocsConfig) { const parsedConfig = parseConfig(config) const capturer = createCapturer(parsedConfig) - return function easydocsMiddleware(req: Request, res: Response, next: NextFunction) { + + const middleware = function easydocsMiddleware(req: Request, res: Response, next: NextFunction) { const startedAt = Date.now() const originalJson = res.json.bind(res) res.json = function (body: unknown) { + // Send the response first, then capture. Building the event and hashing + // the payload is cheap but not free, and none of it needs to happen before + // the client gets its bytes. + const result = originalJson(body) + capturer.capture( buildCaptureEvent({ method: req.method, // req.route.path is relative to the router's mount point; prepend // req.baseUrl so a router mounted at /api/v1 keeps its prefix and two - // routers sharing a relative path don't collide. - path: req.route?.path ? (req.baseUrl ?? '') + req.route.path : req.path, + // routers sharing a relative path don't collide. It can also be a + // RegExp or an array for pattern routes, which don't concatenate into + // a usable template — fall back to the concrete path there. + path: typeof req.route?.path === 'string' ? (req.baseUrl ?? '') + req.route.path : req.path, query: req.query as Record, params: req.params, requestBody: req.body, @@ -27,9 +35,15 @@ export function easydocs(config?: EasyDocsConfig) { durationMs: Date.now() - startedAt, }) ) - return originalJson(body) + + return result } next() } + + // Express has no shutdown hook, so expose flush on the middleware itself: + // `await mw.flush()` from your own SIGTERM handler keeps a deploy from + // discarding specs that were still generating. + return Object.assign(middleware, { flush: () => capturer.flush() }) } diff --git a/packages/fastify/src/__tests__/plugin.test.ts b/packages/fastify/src/__tests__/plugin.test.ts index ac8da0b..0835296 100644 --- a/packages/fastify/src/__tests__/plugin.test.ts +++ b/packages/fastify/src/__tests__/plugin.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index b864f6b..7698783 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -39,6 +39,12 @@ const plugin: FastifyPluginAsync = async (fastify, rawConfig) => fastify.addHook('onRequest', async (request: FastifyRequest) => { ;(request as unknown as { easydocsStart: number }).easydocsStart = Date.now() }) + + // Drain queued spec generation on shutdown so a deploy doesn't discard specs + // that were still being generated. + fastify.addHook('onClose', async () => { + await capturer.flush() + }) } export const easydocs = fp(plugin, { diff --git a/packages/h3/src/__tests__/middleware.test.ts b/packages/h3/src/__tests__/middleware.test.ts index 72476d2..e2a8dbc 100644 --- a/packages/h3/src/__tests__/middleware.test.ts +++ b/packages/h3/src/__tests__/middleware.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/h3/src/index.ts b/packages/h3/src/index.ts index 95f3437..9308d99 100644 --- a/packages/h3/src/index.ts +++ b/packages/h3/src/index.ts @@ -17,10 +17,10 @@ declare module 'h3' { } } -export function easydocs(config?: EasyDocsConfig): EventHandler { +export function easydocs(config?: EasyDocsConfig): EventHandler & { flush(): Promise } { const parsedConfig = parseConfig(config) const capturer = createCapturer(parsedConfig) - return defineEventHandler({ + const handler = defineEventHandler({ onRequest(event: H3Event) { event.context._easydocsStart = Date.now() }, @@ -56,4 +56,9 @@ export function easydocs(config?: EasyDocsConfig): EventHandler { handler: () => {}, }) + + // h3/Nitro has no shutdown hook here, so expose flush on the handler: + // `await handler.flush()` from your own SIGTERM handler keeps a deploy from + // discarding specs that were still generating. + return Object.assign(handler, { flush: () => capturer.flush() }) } diff --git a/packages/hono/src/__tests__/middleware.test.ts b/packages/hono/src/__tests__/middleware.test.ts index 91d30a5..b79bc86 100644 --- a/packages/hono/src/__tests__/middleware.test.ts +++ b/packages/hono/src/__tests__/middleware.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') @@ -19,7 +19,13 @@ function makeApp(config?: object) { app.use(easydocs(config as never)) app.get('/users', (c) => c.json({ data: [] })) app.get('/users/:id', (c) => c.json({ id: c.req.param('id') })) - app.post('/users', (c) => c.json({ created: true }, 201)) + // Reads the body, like any real handler would. + app.post('/users', async (c) => { + const input = await c.req + .json<{ name?: string }>() + .catch((): { name?: string } => ({})) + return c.json({ created: true, name: input.name }, 201) + }) return app } @@ -65,6 +71,55 @@ describe('hono middleware', () => { ) }) + // The handler above ignores the request body, which is what previously + // masked the bug: a handler that reads it consumes the underlying Request, + // and the old `c.req.raw.clone()` then threw, recording body: null. + it('captures POST body when the handler reads it (realistic handler)', async () => { + const app = new Hono() + app.use(easydocs()) + app.post('/users', async (c) => { + const input = await c.req.json<{ name: string }>() + return c.json({ created: true, name: input.name }, 201) + }) + + await app.request('/users', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Alice' }), + }) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST', body: { name: 'Alice' }, status: 201 }) + ) + }) + + // Awaiting .json() on an open stream never resolves, and the middleware + // awaits before returning — so an SSE route used to hang for the client. + it('does not buffer a streaming (non-JSON) response', async () => { + const app = new Hono() + app.use(easydocs()) + app.get('/events', () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: hello\n\n')) + // A real SSE stream stays open; never close(). + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ) + ) + + const res = await Promise.race([ + app.request('/events'), + new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 1000)), + ]) + + expect(res).not.toBe('timeout') + expect((res as Response).headers.get('content-type')).toBe('text/event-stream') + expect(getCaptureMock()).toHaveBeenCalledWith(expect.objectContaining({ response: null })) + }) + it('passes config to createCapturer', async () => { makeApp({ project: 'my-api' }) expect(createCapturer).toHaveBeenCalledWith( diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 6fe573d..1b56598 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -2,25 +2,44 @@ import { createCapturer, parseConfig, buildCaptureEvent } from '@easydocs/core' import type { EasyDocsConfig } from '@easydocs/core' import type { Context, Next } from 'hono' +// Only `application/json` (and `+json` suffixes) can become a documented schema. +// Checking the header first is also what keeps a streaming response safe: calling +// .json() on an open SSE stream never resolves, and because the middleware awaits +// it before returning, the client would never receive the response at all. +const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json\b/i + +function isJson(headers: Headers): boolean { + return JSON_CONTENT_TYPE.test(headers.get('content-type') ?? '') +} + export function easydocs(config?: EasyDocsConfig) { const parsedConfig = parseConfig(config) const capturer = createCapturer(parsedConfig) - return async function easydocsMiddleware(c: Context, next: Next) { + + const middleware = async function easydocsMiddleware(c: Context, next: Next) { const startedAt = Date.now() await next() let responseBody: unknown = null - try { - responseBody = await c.res.clone().json() - } catch { - responseBody = null + if (isJson(c.res.headers)) { + try { + responseBody = await c.res.clone().json() + } catch { + responseBody = null + } } + // Read through Hono's own body cache (`c.req.json()`), not `c.req.raw.clone()`: + // once the handler has consumed the body — which every real POST handler + // does — cloning the raw Request throws "Body is unusable" and the request + // body was silently recorded as null. let requestBody: unknown = null - try { - requestBody = await c.req.raw.clone().json() - } catch { - requestBody = null + if (isJson(c.req.raw.headers)) { + try { + requestBody = await c.req.json() + } catch { + requestBody = null + } } const url = new URL(c.req.url) @@ -40,4 +59,9 @@ export function easydocs(config?: EasyDocsConfig) { }) ) } + + // Hono has no shutdown hook, so expose flush on the middleware itself: + // `await mw.flush()` from your own SIGTERM handler keeps a deploy from + // discarding specs that were still generating. + return Object.assign(middleware, { flush: () => capturer.flush() }) } diff --git a/packages/nestjs/src/__tests__/interceptor.test.ts b/packages/nestjs/src/__tests__/interceptor.test.ts index d2fca69..4d777bd 100644 --- a/packages/nestjs/src/__tests__/interceptor.test.ts +++ b/packages/nestjs/src/__tests__/interceptor.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { EasyDocsInterceptor } from '../interceptor.js' import type { Capturer, CaptureEvent } from '@easydocs/core' function makeCapturer() { const capture = vi.fn<(event: CaptureEvent) => void>() - return { capture } satisfies Capturer + return { capture, flush: async () => {} } satisfies Capturer } function makeContext(overrides: { @@ -104,4 +104,53 @@ describe('EasyDocsInterceptor', () => { }) }) }) + + // tap() only fires on success, so every response produced by an exception + // filter (401, 404, 422, 500 …) used to go undocumented. + it('captures HttpException responses, using the exception status and body', () => { + const capturer = makeCapturer() + const interceptor = new EasyDocsInterceptor(capturer) + const ctx = makeContext({ method: 'GET', path: '/users/9', routePath: '/users/:id' }) + + class NotFound extends Error { + getStatus() { return 404 } + getResponse() { return { statusCode: 404, message: 'User not found' } } + } + const handler = { handle: () => throwError(() => new NotFound()) } + + return new Promise((resolve) => { + interceptor.intercept(ctx as never, handler).subscribe({ + error: (err) => { + // The exception must still propagate to Nest's exception filters. + expect(err).toBeInstanceOf(NotFound) + expect(capturer.capture).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/users/{id}', + status: 404, + response: { statusCode: 404, message: 'User not found' }, + }) + ) + resolve() + }, + }) + }) + }) + + it('captures a plain Error as a 500', () => { + const capturer = makeCapturer() + const interceptor = new EasyDocsInterceptor(capturer) + const ctx = makeContext({ path: '/boom', routePath: '/boom' }) + const handler = { handle: () => throwError(() => new Error('kaboom')) } + + return new Promise((resolve) => { + interceptor.intercept(ctx as never, handler).subscribe({ + error: () => { + expect(capturer.capture).toHaveBeenCalledWith( + expect.objectContaining({ status: 500, response: { message: 'kaboom' } }) + ) + resolve() + }, + }) + }) + }) }) diff --git a/packages/nestjs/src/interceptor.ts b/packages/nestjs/src/interceptor.ts index 9d96e5f..5a08c5b 100644 --- a/packages/nestjs/src/interceptor.ts +++ b/packages/nestjs/src/interceptor.ts @@ -5,7 +5,7 @@ import { CallHandler, Inject, } from '@nestjs/common' -import { Observable, tap } from 'rxjs' +import { Observable, tap, catchError, throwError } from 'rxjs' import { buildCaptureEvent } from '@easydocs/core' import type { Capturer } from '@easydocs/core' @@ -27,6 +27,23 @@ interface HttpResponse { export const EASYDOCS_CAPTURER = Symbol('EASYDOCS_CAPTURER') +/** Pull the HTTP status an exception filter will apply, defaulting to 500. */ +function errorStatus(err: unknown): number { + const getStatus = (err as { getStatus?: () => number })?.getStatus + if (typeof getStatus === 'function') { + const status = getStatus.call(err) + if (Number.isInteger(status)) return status + } + return 500 +} + +/** The body an exception filter will serialize for this exception. */ +function errorBody(err: unknown): unknown { + const getResponse = (err as { getResponse?: () => unknown })?.getResponse + if (typeof getResponse === 'function') return getResponse.call(err) + return { message: err instanceof Error ? err.message : 'Internal server error' } +} + @Injectable() export class EasyDocsInterceptor implements NestInterceptor { constructor(@Inject(EASYDOCS_CAPTURER) private readonly capturer: Capturer) {} @@ -37,24 +54,34 @@ export class EasyDocsInterceptor implements NestInterceptor { const req = http.getRequest() const res = http.getResponse() + const record = (responseBody: unknown, status: number) => { + this.capturer.capture( + buildCaptureEvent({ + method: req.method, + // route.path is relative to the router's mount; prepend baseUrl so + // mounted sub-routers keep their prefix (matches the Express adapter). + path: req.route?.path ? (req.baseUrl ?? '') + req.route.path : req.path, + query: req.query as Record, + params: req.params as Record, + requestBody: req.body, + responseBody, + status, + requestHeaders: req.headers as Record, + responseHeaders: res.getHeaders() as Record, + durationMs: Date.now() - startedAt, + }) + ) + } + return next.handle().pipe( - tap((responseBody: unknown) => { - this.capturer.capture( - buildCaptureEvent({ - method: req.method, - // route.path is relative to the router's mount; prepend baseUrl so - // mounted sub-routers keep their prefix (matches the Express adapter). - path: req.route?.path ? (req.baseUrl ?? '') + req.route.path : req.path, - query: req.query as Record, - params: req.params as Record, - requestBody: req.body, - responseBody, - status: res.statusCode, - requestHeaders: req.headers as Record, - responseHeaders: res.getHeaders() as Record, - durationMs: Date.now() - startedAt, - }) - ) + tap((responseBody: unknown) => record(responseBody, res.statusCode)), + // tap() only runs on success, so every response produced by an exception + // filter (401, 404, 422, 500 …) went undocumented. Read the status and + // body off the exception itself — res.statusCode is still the default here + // because the filter has not run yet. + catchError((err: unknown) => { + record(errorBody(err), errorStatus(err)) + return throwError(() => err) }) ) } diff --git a/packages/nestjs/src/module.ts b/packages/nestjs/src/module.ts index 792358b..9b27e32 100644 --- a/packages/nestjs/src/module.ts +++ b/packages/nestjs/src/module.ts @@ -1,11 +1,22 @@ -import { Module, DynamicModule } from '@nestjs/common' +import { Module, DynamicModule, Inject, OnApplicationShutdown } from '@nestjs/common' import { APP_INTERCEPTOR } from '@nestjs/core' import { EasyDocsInterceptor, EASYDOCS_CAPTURER } from './interceptor' import { parseConfig, createCapturer } from '@easydocs/core' -import type { EasyDocsConfig } from '@easydocs/core' +import type { EasyDocsConfig, Capturer } from '@easydocs/core' @Module({}) -export class EasyDocsModule { +export class EasyDocsModule implements OnApplicationShutdown { + constructor(@Inject(EASYDOCS_CAPTURER) private readonly capturer: Capturer) {} + + /** + * Drain queued spec generation on shutdown. Requires + * `app.enableShutdownHooks()`; without it Nest never calls this and pending + * specs are lost on deploy, same as before. + */ + async onApplicationShutdown(): Promise { + await this.capturer.flush() + } + static forRoot(config: EasyDocsConfig = {}): DynamicModule { const capturer = createCapturer(parseConfig(config)) return { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index cc78fee..2cbea7c 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -23,7 +23,9 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src/" }, "dependencies": { "@easydocs/core": "workspace:*" @@ -34,7 +36,9 @@ "devDependencies": { "next": "^16.2.11", "tsup": "^8.3.0", - "typescript": "^5" + "typescript": "^5", + "vite": "8.1.0", + "vitest": "^4.1.6" }, "repository": { "type": "git", diff --git a/packages/nextjs/src/__tests__/app-router.test.ts b/packages/nextjs/src/__tests__/app-router.test.ts new file mode 100644 index 0000000..bd5d497 --- /dev/null +++ b/packages/nextjs/src/__tests__/app-router.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { withEasydocs } from '../index.js' + +vi.mock(import('@easydocs/core'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })), + } +}) + +const { createCapturer } = await import('@easydocs/core') + +// The adapter caches one capturer per distinct config (so a 50-route app opens +// one DB client, not 50), which means createCapturer runs only on the first +// wrap. Latch onto that capturer's mock rather than re-reading mock.results. +let captureMock: ReturnType | null = null +function getCaptureMock() { + if (!captureMock) { + const results = (createCapturer as ReturnType).mock.results + captureMock = results[results.length - 1].value.capture as ReturnType + } + return captureMock +} + +/** Minimal stand-in for NextRequest — the adapter only uses these members. */ +function makeRequest(url: string, init?: RequestInit) { + const req = new Request(url, init) + const parsed = new URL(url) + return Object.assign(req, { + nextUrl: { pathname: parsed.pathname, searchParams: parsed.searchParams }, + }) +} + +describe('next.js app router', () => { + beforeEach(() => captureMock?.mockClear()) + + it('captures method, path and response body', async () => { + const handler = withEasydocs(async () => Response.json({ data: [] })) + await handler(makeRequest('http://localhost/api/users')) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ method: 'GET', path: '/api/users', response: { data: [] }, status: 200 }) + ) + }) + + // The handler consumes the request body, which is what every real POST route + // does. Cloning after the handler ran throws, so the body used to be null. + it('captures the request body when the handler reads it', async () => { + const handler = withEasydocs(async (req) => { + // The adapter's structural NextRequestLike doesn't declare json() because + // it never calls it; a real route handler does. + const input = (await (req as unknown as Request).json()) as { name: string } + return Response.json({ created: true, name: input.name }, { status: 201 }) + }) + + await handler( + makeRequest('http://localhost/api/users', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Alice' }), + }) + ) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST', body: { name: 'Alice' }, status: 201 }) + ) + }) + + it('collapses dynamic segments using the route params', async () => { + const handler = withEasydocs(async () => Response.json({ id: '42' })) + await handler(makeRequest('http://localhost/api/users/42'), { + params: Promise.resolve({ id: '42' }), + }) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ path: '/api/users/{id}', params: { id: '42' } }) + ) + }) + + // Awaiting .json() on an open stream never resolves, and the wrapper awaits + // it before returning the response — so a streaming route would hang. + it('does not buffer a streaming response', async () => { + const handler = withEasydocs( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: hello\n\n')) + // Stays open, like a real SSE route. + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ) + ) + + const result = await Promise.race([ + handler(makeRequest('http://localhost/api/events')), + new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 1000)), + ]) + + expect(result).not.toBe('timeout') + expect(getCaptureMock()).toHaveBeenCalledWith(expect.objectContaining({ response: null })) + }) +}) diff --git a/packages/nextjs/src/__tests__/pages-router.test.ts b/packages/nextjs/src/__tests__/pages-router.test.ts new file mode 100644 index 0000000..81e4e8a --- /dev/null +++ b/packages/nextjs/src/__tests__/pages-router.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { withEasydocsPagesHandler } from '../index.js' + +vi.mock(import('@easydocs/core'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })), + } +}) + +const { createCapturer } = await import('@easydocs/core') + +// The adapter caches one capturer per distinct config (so a 50-route app opens +// one DB client, not 50), which means createCapturer runs only on the first +// wrap. Latch onto that capturer's mock rather than re-reading mock.results. +let captureMock: ReturnType | null = null +function getCaptureMock() { + if (!captureMock) { + const results = (createCapturer as ReturnType).mock.results + captureMock = results[results.length - 1].value.capture as ReturnType + } + return captureMock +} + +/** + * Minimal NextApiRequest/Response stand-ins. In the Pages Router, `req.query` + * merges dynamic route params with the query string — reproducing that is the + * whole point of these tests. + */ +function makeReqRes(opts: { + method?: string + url: string + query: Record + body?: unknown +}) { + const req = { + method: opts.method ?? 'GET', + url: opts.url, + query: opts.query, + body: opts.body, + headers: {} as Record, + } + const res = { + statusCode: 200, + json(_body: unknown) { + return res + }, + getHeaders: () => ({}), + } + return { req, res } +} + +describe('next.js pages router', () => { + beforeEach(() => captureMock?.mockClear()) + + // Without params, /api/users/1 and /api/users/2 became separate endpoint rows + // — one stored endpoint and one LLM call per id. + it('collapses dynamic route segments into a template', async () => { + const handler = withEasydocsPagesHandler(async (_req, res) => { + res.json({ id: '42' }) + }) + const { req, res } = makeReqRes({ url: '/api/users/42', query: { id: '42' } }) + + await handler(req, res) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ path: '/api/users/{id}', params: { id: '42' } }) + ) + }) + + it('keeps query-string values out of the path params', async () => { + const handler = withEasydocsPagesHandler(async (_req, res) => { + res.json({ ok: true }) + }) + // `status=users` must not rewrite the /users segment into /{status}. + const { req, res } = makeReqRes({ + url: '/api/users?status=users', + query: { status: 'users' }, + }) + + await handler(req, res) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ path: '/api/users', params: {}, query: { status: 'users' } }) + ) + }) + + it('separates route params from query params', async () => { + const handler = withEasydocsPagesHandler(async (_req, res) => { + res.json({ ok: true }) + }) + const { req, res } = makeReqRes({ + url: '/api/users/42/posts?page=2', + query: { id: '42', page: '2' }, + }) + + await handler(req, res) + + expect(getCaptureMock()).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/users/{id}/posts', + params: { id: '42' }, + query: { page: '2' }, + }) + ) + }) +}) diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index e3fd68a..33171d2 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -16,6 +16,21 @@ function getCapturer(config?: EasyDocsConfig): Capturer { return capturer } +/** Flush every cached capturer. Call from an instrumentation shutdown hook. */ +export async function flushEasydocs(): Promise { + await Promise.all([...capturerCache.values()].map((c) => c.flush())) +} + +// Only `application/json` (and `+json` suffixes) can become a documented schema. +// The check also guards against streaming routes: awaiting .json() on an open +// stream never resolves, and the wrapper awaits it before returning the +// response, so a streamed route would hang for the client. +const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json\b/i + +function isJson(headers: { get(name: string): string | null }): boolean { + return JSON_CONTENT_TYPE.test(headers.get('content-type') ?? '') +} + // ─── Local structural types (avoid importing from next at build time) ────────── interface NextURL { @@ -53,13 +68,28 @@ export function withEasydocs(handler: AppRouterHandler, config?: EasyDocsConfig) const capturer = getCapturer(config) return async (req, ctx) => { const startedAt = Date.now() + + // Clone BEFORE running the handler. App Router handlers read the body with + // `await req.json()`, which consumes it — and cloning a consumed Request + // throws, so cloning afterwards silently recorded every requestBody as null. + let requestClone: { json(): Promise } | null = null + if (req.method !== 'GET' && req.method !== 'HEAD' && isJson(req.headers)) { + try { + requestClone = req.clone() + } catch { + requestClone = null + } + } + const response = await handler(req, ctx) let responseBody: unknown = null - try { - responseBody = await response.clone().json() - } catch { - // non-JSON response + if (isJson(response.headers)) { + try { + responseBody = await response.clone().json() + } catch { + // malformed JSON body + } } let resolvedParams: Record = {} @@ -69,11 +99,11 @@ export function withEasydocs(handler: AppRouterHandler, config?: EasyDocsConfig) } let requestBody: unknown = null - if (req.method !== 'GET' && req.method !== 'HEAD') { + if (requestClone) { try { - requestBody = await req.clone().json() + requestBody = await requestClone.json() } catch { - // non-JSON body + // malformed JSON body } } @@ -100,6 +130,32 @@ export function withEasydocs(handler: AppRouterHandler, config?: EasyDocsConfig) type PagesHandler = (req: NextApiRequestLike, res: NextApiResponseLike) => void | Promise +/** + * Split `req.query` — which merges dynamic route params and the query string — + * back into just the route params, by removing everything that came from the + * URL's search string. Without this the Pages Router reports concrete paths, so + * `/api/users/1` and `/api/users/2` became two endpoint rows (and two LLM calls) + * instead of one `/api/users/{id}`. + */ +function splitQuery(req: NextApiRequestLike): { + params: Record + query: Record +} { + const search = new URLSearchParams((req.url ?? '').split('?')[1] ?? '') + const searchKeys = new Set(search.keys()) + const params: Record = {} + for (const [key, value] of Object.entries(req.query ?? {})) { + if (searchKeys.has(key)) continue + // Catch-all segments ([...slug]) arrive as arrays and can't match a single + // path segment, so they're left alone. + if (Array.isArray(value)) continue + params[key] = value + } + // Route params were previously documented as query parameters too, because + // req.query carries both. + return { params, query: Object.fromEntries(search.entries()) } +} + export function withEasydocsPagesHandler( handler: PagesHandler, config?: EasyDocsConfig @@ -108,13 +164,15 @@ export function withEasydocsPagesHandler( return async (req, res) => { const startedAt = Date.now() const originalJson = res.json.bind(res) + const { params, query } = splitQuery(req) res.json = function (body: unknown) { capturer.capture( buildCaptureEvent({ method: req.method ?? 'GET', path: req.url?.split('?')[0] ?? '/', - query: req.query as Record, + query, + params, requestBody: req.body, responseBody: body, status: res.statusCode, diff --git a/packages/nextjs/vitest.config.ts b/packages/nextjs/vitest.config.ts new file mode 100644 index 0000000..647f393 --- /dev/null +++ b/packages/nextjs/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/trpc/src/__tests__/error-status.test.ts b/packages/trpc/src/__tests__/error-status.test.ts index 98ce29e..17ee109 100644 --- a/packages/trpc/src/__tests__/error-status.test.ts +++ b/packages/trpc/src/__tests__/error-status.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/trpc/src/__tests__/middleware.test.ts b/packages/trpc/src/__tests__/middleware.test.ts index 5f032d8..7fb6d73 100644 --- a/packages/trpc/src/__tests__/middleware.test.ts +++ b/packages/trpc/src/__tests__/middleware.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/trpc/src/__tests__/qa.test.ts b/packages/trpc/src/__tests__/qa.test.ts index 98281f9..1996a8d 100644 --- a/packages/trpc/src/__tests__/qa.test.ts +++ b/packages/trpc/src/__tests__/qa.test.ts @@ -4,7 +4,7 @@ import { easydocs } from '../index.js' vi.mock(import('@easydocs/core'), async (importOriginal) => { const actual = await importOriginal() - return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn() })) } + return { ...actual, createCapturer: vi.fn(() => ({ capture: vi.fn(), flush: vi.fn(async () => {}) })) } }) const { createCapturer } = await import('@easydocs/core') diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index 77b3f37..6181b41 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -68,7 +68,7 @@ export function easydocs(config?: EasyDocsConfig) { // Typed loosely: tRPC's middleware generics depend on the user's context/meta, // which this adapter is deliberately agnostic to. - return async function easydocsMiddleware(opts: any): Promise { + const middleware = async function easydocsMiddleware(opts: any): Promise { // Subscriptions are streaming, not a request/response we can document. if (opts.type === 'subscription') return opts.next() @@ -101,4 +101,9 @@ export function easydocs(config?: EasyDocsConfig) { return result } + + // tRPC has no shutdown hook, so expose flush on the middleware itself: + // `await mw.flush()` from your own SIGTERM handler keeps a deploy from + // discarding specs that were still generating. + return Object.assign(middleware, { flush: () => capturer.flush() }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6231e91..0b969d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,13 +192,13 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@libsql/client@0.17.3)(@opentelemetry/api@1.9.1)(pg@8.21.0)(postgres@3.4.5) - postgres: - specifier: ^3.4.5 - version: 3.4.5 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: + postgres: + specifier: ^3.4.5 + version: 3.4.5 tsup: specifier: ^8.3.0 version: 8.5.1(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.23)(supports-color@7.2.0)(tsx@4.22.3)(typescript@5.7.3)(yaml@2.9.0) @@ -380,6 +380,12 @@ importers: typescript: specifier: ^5 version: 5.7.3 + vite: + specifier: ^8.0.16 + version: 8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)(yaml@2.9.0) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(vite@8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)(yaml@2.9.0)) packages/trpc: dependencies: @@ -2786,6 +2792,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} From e60fc0cd52e4657226fd103ee45ac76fd4a72fdc Mon Sep 17 00:00:00 2001 From: RubenGlez Date: Fri, 14 Aug 2026 14:53:05 +0200 Subject: [PATCH 2/2] test: split the --port cases so each gets its own timeout Every case spawns a fresh Node process to run the CLI bundle, which costs over a second on a CI runner. Three of them shared one `it` and its 5s default budget, which passed locally at ~2s and timed out at 5113ms in CI. Claude-Session: https://claude.ai/code/session_01GrfumUQ4vFUAgsFYwzDyK8 --- packages/cli/src/__tests__/port-flag.test.ts | 32 +++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/__tests__/port-flag.test.ts b/packages/cli/src/__tests__/port-flag.test.ts index c6b1bd6..2cd0e47 100644 --- a/packages/cli/src/__tests__/port-flag.test.ts +++ b/packages/cli/src/__tests__/port-flag.test.ts @@ -5,8 +5,16 @@ import { resolve } from 'node:path' // Black-box like the diff tests: the CLI dispatches on import. const CLI = resolve(process.cwd(), 'dist/index.js') +// Each case boots a fresh Node process to run the bundle, which costs well over +// a second on a CI runner — comfortably past vitest's 5s default when several +// share one test. One case per `it`, with headroom. +const SPAWN_TIMEOUT_MS = 20_000 + function run(...args: string[]) { - const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 10_000 }) + const r = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }) return { code: r.status, stdout: r.stdout, stderr: r.stderr } } @@ -14,19 +22,15 @@ function run(...args: string[]) { // port, so a typo'd --port used to start the proxy somewhere unpredictable // instead of reporting the mistake. describe('--port validation', () => { - it('rejects a non-numeric port', () => { - const r = run('proxy', '--port=abc') + it.each([ + ['non-numeric', 'abc'], + ['above the valid range', '70000'], + ['zero', '0'], + ['negative', '-1'], + ['fractional', '80.5'], + ])('rejects a %s port', (_label, value) => { + const r = run('proxy', `--port=${value}`) expect(r.code).toBe(2) expect(r.stderr).toContain('Invalid --port value') - }) - - it('rejects an out-of-range port', () => { - expect(run('proxy', '--port=70000').code).toBe(2) - expect(run('proxy', '--port=0').code).toBe(2) - expect(run('proxy', '--port=-1').code).toBe(2) - }) - - it('rejects a fractional port', () => { - expect(run('proxy', '--port=80.5').code).toBe(2) - }) + }, SPAWN_TIMEOUT_MS) })