Architecture
How doctreen is put together — RouteRegistry, adapter flow, zero-runtime-deps UI, and the conformance suite that keeps three implementations identical.
DocTreen is a framework-agnostic API documentation library built on a middleware-injection pattern — implemented three times, in Node.js, Python, and PHP, around the same core design.
Core components
Each implementation carries the same trio, under its language's conventions:
RouteRegistry— framework-agnostic route store. Adapters populate it; the UI reads from it.normalizeConfig— normalises the user's config object;enableddefaults to "not production" (NODE_ENVon Node, env detection on Flask,APP_ENVon Laravel).shouldExclude— filter for theexcludelist (strings + regex).*and/*are excluded by default.
Adapter contract
Every adapter follows the same four steps:
- Take the framework instance
- Discover routes (eager or lazy)
- Call
registry.add(routeEntry) - Serve the UI at
docsPath(default/docs)
Zero runtime dependencies in the UI
The docs UI is shipped as inline HTML / CSS / JS strings. No CDN, no extra package. The Postman and OpenAPI export buttons run as in-browser IIFEs.
This is why a single npm install doctreen / pip install doctreen /
composer require doctreen/doctreen is enough — no companion
@nestjs/swagger-style ecosystem.
The Python and PHP packages do not reimplement the UI: its static half (stylesheet + browser script) is extracted verbatim from the npm package by a sync script in each repo, and only the server-side render logic is ported. The three packages cannot drift apart visually by hand-copying.
Three implementations, one contract
The written contract is SPEC.md
in the Node repo; the executable contract is the
conformance suite
next to it. The order is: spec first, then fixture, then implementations.
A single fixture set is run through the Node and Python exporters on every
push (the outputs must match byte-for-byte — key order included), and the PHP
exporter is parity-tested against the npm exporter's output. SchemaNode is
therefore a plain shape in every language — a JS object, a Python dict, a
PHP array — never a class hierarchy that could serialise differently.
Lazy vs eager discovery
| Adapter | Mode | When routes are read |
|---|---|---|
| Express | Lazy | app._router.stack (v4) / app.router.stack (v5) on the first /docs hit. Solves the "middleware-before-routes" ordering problem. |
| Fastify | Eager | Via the onRoute hook at registration time. Adapter must be installed before routes. |
| Hono | Lazy | app.routes on the first docs request. |
| Koa | Lazy | router.stack on the first docs request. |
| NestJS | Eager | app.container.getModules() for metadata discovery, called before app.listen(). |
| Flask | Lazy | app.url_map on the first docs request (rebuilt per request under liveReload). Register the blueprint anywhere. |
| Laravel | Lazy | The router's route collection when docs are requested; ->doc() metadata rides the route action array. |
Schema priority order
- Explicit schema via
defineRoute/@DocRoute/@define_route/->doc() - Framework-native schema (Fastify JSON Schema; Flask URL-converter types for path params; nothing for the others)
- JSDoc parsing (Express / Hono / Koa — Node only)
The same priority feeds the docs UI, the OpenAPI export, and the schema drift comparator.
Python and PHP specifics
Two problems exist in the ports that the single-process Node runtime never had, and the architecture accounts for both:
- Multi-worker processes. gunicorn/uWSGI (Python) and PHP-FPM spawn N workers; an in-memory drift store is per-worker. The Python default store is explicitly a single-worker/development store with a pluggable shared-store interface; Laravel's default store rides the cache driver, which is already shared.
- Thread safety. Flask and Django run threaded, so the Python drift
store's
record()and the registry's lazy build are lock-protected.