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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions apps/dashboard/src/app/api/endpoints/[id]/spec/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 2 additions & 5 deletions apps/dashboard/src/app/api/endpoints/[id]/versions/route.ts
Original file line number Diff line number Diff line change
@@ -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 }> }) {
Expand Down
7 changes: 3 additions & 4 deletions apps/dashboard/src/app/api/endpoints/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 })
}
7 changes: 6 additions & 1 deletion apps/dashboard/src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type { DriftReport } from '@easydocs/core'

let db: ReturnType<typeof createDB> | 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
}
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/__tests__/port-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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')

// 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: SPAWN_TIMEOUT_MS,
})
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.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')
}, SPAWN_TIMEOUT_MS)
})
17 changes: 15 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -272,7 +285,7 @@ function stripHopByHop(headers: Record<string, unknown>): Record<string, string>
}

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 }))

Expand Down
17 changes: 15 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -72,5 +77,13 @@
"type": "git",
"url": "https://github.com/RubenGlez/easydocs",
"directory": ""
},
"peerDependencies": {
"postgres": "^3.4.5"
},
"peerDependenciesMeta": {
"postgres": {
"optional": true
}
}
}
Loading
Loading