DocTreen
Reference

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; enabled defaults to "not production" (NODE_ENV on Node, env detection on Flask, APP_ENV on Laravel).
  • shouldExclude — filter for the exclude list (strings + regex). * and /* are excluded by default.

Adapter contract

Every adapter follows the same four steps:

  1. Take the framework instance
  2. Discover routes (eager or lazy)
  3. Call registry.add(routeEntry)
  4. 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

AdapterModeWhen routes are read
ExpressLazyapp._router.stack (v4) / app.router.stack (v5) on the first /docs hit. Solves the "middleware-before-routes" ordering problem.
FastifyEagerVia the onRoute hook at registration time. Adapter must be installed before routes.
HonoLazyapp.routes on the first docs request.
KoaLazyrouter.stack on the first docs request.
NestJSEagerapp.container.getModules() for metadata discovery, called before app.listen().
FlaskLazyapp.url_map on the first docs request (rebuilt per request under liveReload). Register the blueprint anywhere.
LaravelLazyThe router's route collection when docs are requested; ->doc() metadata rides the route action array.

Schema priority order

  1. Explicit schema via defineRoute / @DocRoute / @define_route / ->doc()
  2. Framework-native schema (Fastify JSON Schema; Flask URL-converter types for path params; nothing for the others)
  3. 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.

On this page