@luckystack/docs-ui 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/mounting.md CHANGED
@@ -1,226 +1,226 @@
1
- # Mounting the docs UI
2
-
3
- > Dev-only. The default route returns `404` in production unless `enabledInProd: true` is explicitly set. Never mount this on a public surface where API discoverability is sensitive.
4
-
5
- `@luckystack/docs-ui` exposes a single mount function that builds a `customRoutes` handler. The handler is composable with `createLuckyStackServer` / `bootstrapLuckyStack` and does not take ownership of the request pipeline — it claims the configured docs route and falls through for everything else.
6
-
7
- This document is the deep reference for:
8
-
9
- - `mountDocsUi(options?)` — the factory that builds the handler.
10
- - `DocsRouteHandler` — the handler contract.
11
- - `MountDocsUiOptions` — the full option surface.
12
-
13
- For the HTML rendering side of the pipeline see `./html-generation.md`. For optional branding/template overrides see `./theming.md`. For the JSON fields the renderer consumes see `./extension-fields.md`.
14
-
15
- ## Function signatures
16
-
17
- ```ts
18
- export const mountDocsUi = (options?: MountDocsUiOptions): DocsRouteHandler;
19
-
20
- export type DocsRouteHandler = (
21
- req: IncomingMessage,
22
- res: ServerResponse,
23
- ) => Promise<boolean>;
24
-
25
- export interface MountDocsUiOptions {
26
- routePath?: string;
27
- pageTitle?: string;
28
- enabledInProd?: boolean;
29
- apiDocsPath?: string;
30
- branding?: DocsBranding;
31
- template?: DocsTemplateBuilder;
32
- enableTryItOut?: boolean;
33
- authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
34
- }
35
- ```
36
-
37
- ## `MountDocsUiOptions` argument breakdown
38
-
39
- | Option | Type | Default | Purpose |
40
- | --- | --- | --- | --- |
41
- | `routePath` | `string` | `/_docs` | URL path the HTML page is served from. The JSON sibling is automatically derived as `${routePath}/api.json`. Both paths are claimed by the handler. |
42
- | `pageTitle` | `string` | `LuckyStack — API docs` | Used for the `<title>` element and the `<h1>` in the header. Pass your project name explicitly — there is no implicit lookup from `ProjectConfig` because consumer config shapes vary too widely to introspect. |
43
- | `enabledInProd` | `boolean` | `false` | Set to `true` to render the docs in production (`NODE_ENV === 'production'`). Use only for an internal developer-portal deployment with its own auth layer in front. |
44
- | `apiDocsPath` | `string` | `getGeneratedApiDocsPath()` | Absolute filesystem path to `apiDocs.generated.json`. Default reads from `ProjectConfig.paths.generatedApiDocs` via `@luckystack/core`. Override only when running a non-standard artifact layout. |
45
- | `branding` | `DocsBranding` | `{}` | Optional logo / accent color / font family. Applied by the default template only. See `./theming.md`. |
46
- | `template` | `DocsTemplateBuilder` | `undefined` | Custom HTML builder. When provided, the default `renderDocsHtml` is bypassed entirely. See `./html-generation.md` and `./theming.md`. |
47
- | `enableTryItOut` | `boolean` | `false` | Renders an inline request runner under each endpoint. Off by default — the runner needs a logged-in browser session because it hits the live server with `credentials: 'include'`. |
48
- | `authorize` | `(req: IncomingMessage) => boolean \| Promise<boolean>` | `undefined` | Optional per-request authorization hook. Called after the env/bind-address gate passes (so it only runs when the route is being served). Return `true` to allow the request, `false` to serve `403 Forbidden`. Use to restrict docs access to authenticated or IP-allowlisted callers on non-loopback deployments (e.g. an internal developer-portal opened with `enabledInProd: true`). |
49
-
50
- All options are individually optional; calling `mountDocsUi()` with no arguments is valid and produces the default behavior.
51
-
52
- ## `DocsRouteHandler` contract
53
-
54
- The factory returns an async function with this exact shape:
55
-
56
- ```ts
57
- type DocsRouteHandler = (
58
- req: IncomingMessage,
59
- res: ServerResponse,
60
- ) => Promise<boolean>;
61
- ```
62
-
63
- Return-value contract:
64
-
65
- - `true` — the handler has fully processed the request and called `res.end(...)`. The caller (typically `@luckystack/server`'s `customRoutes` orchestration) must not invoke any further handler for this request.
66
- - `false` — the handler did not match the URL. The request body is untouched and the response is unwritten. The caller is free to delegate to the next handler or fall through to the framework's default routing.
67
-
68
- The handler is safe to compose: it inspects only `req.url` and `req.method` and never reads the body. It is always async because reading `apiDocs.generated.json` from disk happens via `fs.promises.readFile` wrapped in `tryCatch`.
69
-
70
- ## Compositing with `createLuckyStackServer`
71
-
72
- Minimum integration — pass the handler as `customRoutes`:
73
-
74
- ```ts
75
- // luckystack/docs-ui/index.ts (overlay file)
76
- import { mountDocsUi } from '@luckystack/docs-ui';
77
-
78
- export const docsUiHandler = mountDocsUi({
79
- pageTitle: 'My App — API docs',
80
- });
81
- ```
82
-
83
- ```ts
84
- // server/server.ts
85
- import { bootstrapLuckyStack } from '@luckystack/server';
86
- import { docsUiHandler } from '../luckystack/docs-ui';
87
- import { serveFile, serveFavicon } from './prod/serveFile';
88
-
89
- await (await bootstrapLuckyStack({
90
- serveFile,
91
- serveFavicon,
92
- customRoutes: docsUiHandler,
93
- })).listen();
94
- ```
95
-
96
- Composing with another custom handler — chain by `await`-ing the docs handler first and only continuing when it returns `false`:
97
-
98
- ```ts
99
- import type { IncomingMessage, ServerResponse } from 'node:http';
100
- import { mountDocsUi } from '@luckystack/docs-ui';
101
- import { metricsHandler } from './metrics';
102
-
103
- const docsHandler = mountDocsUi();
104
-
105
- export const customRoutes = async (
106
- req: IncomingMessage,
107
- res: ServerResponse,
108
- ): Promise<boolean> => {
109
- if (await docsHandler(req, res)) return true;
110
- if (await metricsHandler(req, res)) return true;
111
- return false;
112
- };
113
- ```
114
-
115
- The order matters only when two handlers would claim the same path; `mountDocsUi` only ever claims `routePath` + `${routePath}/api.json`, so collisions are easy to avoid by picking a unique `routePath`.
116
-
117
- ## Behavior matrix
118
-
119
- The handler's branches map directly to the request shape. The order is exactly as evaluated in `src/index.ts`:
120
-
121
- | Step | Condition | Result |
122
- | --- | --- | --- |
123
- | 1 | `pathOnly !== routePath && pathOnly !== ${routePath}/api.json` | Returns `false` (pass-through). No response written. |
124
- | 2 | (`NODE_ENV === 'production'` **or** `!isLoopbackIp(getBindAddress().ip)`) and `!options.enabledInProd` | `404 Not Found` (`text/plain`). Returns `true`. The non-loopback bind-address check means a server bound to a public interface is gated even outside `NODE_ENV=production`. |
125
- | 3 | `options.authorize` is set and returns/resolves `false` | `403 Forbidden` (`text/plain`). Returns `true`. Runs only after the env/bind gate (step 2) passes. |
126
- | 4 | `req.method !== 'GET'` | `405 Method Not Allowed` (`text/plain`). Returns `true`. |
127
- | 5 | `pathOnly === ${routePath}/api.json` and the file is readable | `200 OK` (`application/json`, `Cache-Control: no-store`) with the raw file contents. Returns `true`. |
128
- | 6 | `pathOnly === ${routePath}/api.json` and the file is missing/unreadable | `404 Not Found` (`application/json`) with `{ error, expectedAt, hint }`. Returns `true`. |
129
- | 7 | `pathOnly === routePath` and `options.template` is set | `200 OK` (`text/html; charset=utf-8`) with the template builder's output. Returns `true`. |
130
- | 8 | `pathOnly === routePath` and no template | `200 OK` (`text/html; charset=utf-8`) with `renderDocsHtml(...)`. Returns `true`. |
131
-
132
- The order is critical: the env/bind gate runs before the `authorize` hook and before method-checking, so a `POST /_docs` in production (or on a public-bind server) still returns `404` — it's gated first — not `405`, and `authorize` is never consulted on a gated request. The gate fires on `NODE_ENV === 'production'` **or** a non-loopback bind address (`!isLoopbackIp(getBindAddress().ip)`), so a staging/preview server bound to a public interface is locked down even when `NODE_ENV` is not `production`; set `enabledInProd: true` to serve the route there. The match itself ignores query strings (the path is split on `?` before comparing) and is strict on trailing slashes — `/_docs/` does NOT match the default route.
133
-
134
- ## Response shapes
135
-
136
- ### HTML page (`GET ${routePath}`)
137
-
138
- ```
139
- HTTP/1.1 200 OK
140
- Content-Type: text/html; charset=utf-8
141
- Cache-Control: no-store
142
-
143
- <!DOCTYPE html>
144
- <html lang="en">
145
- ...self-contained docs page (see ./html-generation.md)...
146
- ```
147
-
148
- ### JSON endpoint, success (`GET ${routePath}/api.json`)
149
-
150
- ```
151
- HTTP/1.1 200 OK
152
- Content-Type: application/json
153
- Cache-Control: no-store
154
-
155
- { "apis": { "<page>": [ { "name": "<name>", "version": "<version>", "method": "POST", ... } ] }, "syncs": { "<page>": [ { ... } ] } }
156
- ```
157
-
158
- The file is streamed as-is from disk; the handler does not parse, validate, or transform it. Forward-compatible extension fields (see `./extension-fields.md`) pass through unchanged.
159
-
160
- ### JSON endpoint, missing file
161
-
162
- ```
163
- HTTP/1.1 404 Not Found
164
- Content-Type: application/json
165
-
166
- {
167
- "error": "apiDocs.generated.json not found",
168
- "expectedAt": "<resolved apiDocsPath>",
169
- "hint": "Run `npm run generateArtifacts` to generate it."
170
- }
171
- ```
172
-
173
- This is intentionally a structured payload so the client-side renderer can surface a helpful empty state. `Cache-Control` is omitted on the error path — the request should be retried after running the artifact generator.
174
-
175
- `expectedAt` is **dev-only**: it exposes the absolute filesystem path and is omitted when `NODE_ENV === 'production'` to avoid leaking internal directory structure (DOCSUI-8). In production the payload contains only `error` and `hint`.
176
-
177
- ### Production lockdown (`enabledInProd: false`)
178
-
179
- ```
180
- HTTP/1.1 404 Not Found
181
- Content-Type: text/plain
182
-
183
- Not Found
184
- ```
185
-
186
- The same plain `Not Found` is returned for both the HTML and the JSON sub-route, so probing tools cannot infer which routes the handler owns.
187
-
188
- ### Wrong method
189
-
190
- ```
191
- HTTP/1.1 405 Method Not Allowed
192
- Content-Type: text/plain
193
-
194
- Method Not Allowed
195
- ```
196
-
197
- `POST`, `PUT`, `DELETE`, and other verbs all map to `405`. The `enableTryItOut` runner posts to `/api/<page>/<name>/<version>?stream=false` (the framework's HTTP API surface), not to the docs route — so `GET`-only is sufficient.
198
-
199
- ## `apiDocsPath` resolution
200
-
201
- The default `apiDocsPath` is resolved lazily on every request via `getGeneratedApiDocsPath()` from `@luckystack/core`. That helper reads `ProjectConfig.paths.generatedApiDocs`, which is populated either via the framework defaults or via an overlay's `registerProjectConfig` call.
202
-
203
- When you pass `apiDocsPath` explicitly:
204
-
205
- - The value must be an absolute path. The handler does no resolution relative to `process.cwd()`.
206
- - The file is read with `fs.promises.readFile(path, 'utf8')` on every request — there is no in-memory cache. The doc-set is dev-only and the artifact regenerates often; caching is intentionally avoided.
207
- - Read errors are caught via `tryCatch`. The response is a structured `404` with `expectedAt` pointing at the resolved path so you can diff it against your actual layout.
208
-
209
- ## Edge cases
210
-
211
- - **Missing `apiDocs.generated.json`** — Returns the structured `404` JSON payload above. Run `npm run generateArtifacts` to produce it.
212
- - **Trailing slashes** — The handler does strict string equality on the resolved path. `/_docs/` is treated as a different route from `/_docs`. Configure `routePath` to include or omit the trailing slash consistently with the rest of your router.
213
- - **Query strings** — The path is split on `?` before comparison, so `/_docs?theme=dark` matches. Query parameters are otherwise ignored by the handler.
214
- - **Custom `routePath`** — Any string is accepted; the `${routePath}/api.json` sibling is derived by simple concatenation. Avoid trailing slashes in `routePath` (`/_docs/` would yield `/_docs//api.json`).
215
- - **`HEAD` requests** — Not special-cased. They fall into the `405` branch. If you need `HEAD` support, wrap the handler.
216
- - **Concurrent requests** — Each request reads the JSON file independently. There is no shared mutable state inside the handler.
217
- - **`req.url` missing** — The handler treats an undefined `url` as an empty string, which never matches `routePath`. It returns `false` (pass-through) cleanly.
218
-
219
- ## Related
220
-
221
- - HTML rendering pipeline: `./html-generation.md`
222
- - Branding and full template replacement: `./theming.md`
223
- - Extension fields rendered from `apiDocs.generated.json`: `./extension-fields.md`
224
- - AI summary + function index: `../CLAUDE.md`
225
- - Consumer quickstart: `../README.md`
226
- - Source: `../src/index.ts`
1
+ # Mounting the docs UI
2
+
3
+ > Dev-only. The default route returns `404` in production unless `enabledInProd: true` is explicitly set. Never mount this on a public surface where API discoverability is sensitive.
4
+
5
+ `@luckystack/docs-ui` exposes a single mount function that builds a `customRoutes` handler. The handler is composable with `createLuckyStackServer` / `bootstrapLuckyStack` and does not take ownership of the request pipeline — it claims the configured docs route and falls through for everything else.
6
+
7
+ This document is the deep reference for:
8
+
9
+ - `mountDocsUi(options?)` — the factory that builds the handler.
10
+ - `DocsRouteHandler` — the handler contract.
11
+ - `MountDocsUiOptions` — the full option surface.
12
+
13
+ For the HTML rendering side of the pipeline see `./html-generation.md`. For optional branding/template overrides see `./theming.md`. For the JSON fields the renderer consumes see `./extension-fields.md`.
14
+
15
+ ## Function signatures
16
+
17
+ ```ts
18
+ export const mountDocsUi = (options?: MountDocsUiOptions): DocsRouteHandler;
19
+
20
+ export type DocsRouteHandler = (
21
+ req: IncomingMessage,
22
+ res: ServerResponse,
23
+ ) => Promise<boolean>;
24
+
25
+ export interface MountDocsUiOptions {
26
+ routePath?: string;
27
+ pageTitle?: string;
28
+ enabledInProd?: boolean;
29
+ apiDocsPath?: string;
30
+ branding?: DocsBranding;
31
+ template?: DocsTemplateBuilder;
32
+ enableTryItOut?: boolean;
33
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
34
+ }
35
+ ```
36
+
37
+ ## `MountDocsUiOptions` argument breakdown
38
+
39
+ | Option | Type | Default | Purpose |
40
+ | --- | --- | --- | --- |
41
+ | `routePath` | `string` | `/_docs` | URL path the HTML page is served from. The JSON sibling is automatically derived as `${routePath}/api.json`. Both paths are claimed by the handler. |
42
+ | `pageTitle` | `string` | `LuckyStack — API docs` | Used for the `<title>` element and the `<h1>` in the header. Pass your project name explicitly — there is no implicit lookup from `ProjectConfig` because consumer config shapes vary too widely to introspect. |
43
+ | `enabledInProd` | `boolean` | `false` | Set to `true` to render the docs in production (`NODE_ENV === 'production'`). Use only for an internal developer-portal deployment with its own auth layer in front. |
44
+ | `apiDocsPath` | `string` | `getGeneratedApiDocsPath()` | Absolute filesystem path to `apiDocs.generated.json`. Default reads from `ProjectConfig.paths.generatedApiDocs` via `@luckystack/core`. Override only when running a non-standard artifact layout. |
45
+ | `branding` | `DocsBranding` | `{}` | Optional logo / accent color / font family. Applied by the default template only. See `./theming.md`. |
46
+ | `template` | `DocsTemplateBuilder` | `undefined` | Custom HTML builder. When provided, the default `renderDocsHtml` is bypassed entirely. See `./html-generation.md` and `./theming.md`. |
47
+ | `enableTryItOut` | `boolean` | `false` | Renders an inline request runner under each endpoint. Off by default — the runner needs a logged-in browser session because it hits the live server with `credentials: 'include'`. |
48
+ | `authorize` | `(req: IncomingMessage) => boolean \| Promise<boolean>` | `undefined` | Optional per-request authorization hook. Called after the env/bind-address gate passes (so it only runs when the route is being served). Return `true` to allow the request, `false` to serve `403 Forbidden`. Use to restrict docs access to authenticated or IP-allowlisted callers on non-loopback deployments (e.g. an internal developer-portal opened with `enabledInProd: true`). |
49
+
50
+ All options are individually optional; calling `mountDocsUi()` with no arguments is valid and produces the default behavior.
51
+
52
+ ## `DocsRouteHandler` contract
53
+
54
+ The factory returns an async function with this exact shape:
55
+
56
+ ```ts
57
+ type DocsRouteHandler = (
58
+ req: IncomingMessage,
59
+ res: ServerResponse,
60
+ ) => Promise<boolean>;
61
+ ```
62
+
63
+ Return-value contract:
64
+
65
+ - `true` — the handler has fully processed the request and called `res.end(...)`. The caller (typically `@luckystack/server`'s `customRoutes` orchestration) must not invoke any further handler for this request.
66
+ - `false` — the handler did not match the URL. The request body is untouched and the response is unwritten. The caller is free to delegate to the next handler or fall through to the framework's default routing.
67
+
68
+ The handler is safe to compose: it inspects only `req.url` and `req.method` and never reads the body. It is always async because reading `apiDocs.generated.json` from disk happens via `fs.promises.readFile` wrapped in `tryCatch`.
69
+
70
+ ## Compositing with `createLuckyStackServer`
71
+
72
+ Minimum integration — pass the handler as `customRoutes`:
73
+
74
+ ```ts
75
+ // luckystack/docs-ui/index.ts (overlay file)
76
+ import { mountDocsUi } from '@luckystack/docs-ui';
77
+
78
+ export const docsUiHandler = mountDocsUi({
79
+ pageTitle: 'My App — API docs',
80
+ });
81
+ ```
82
+
83
+ ```ts
84
+ // server/server.ts
85
+ import { bootstrapLuckyStack } from '@luckystack/server';
86
+ import { docsUiHandler } from '../luckystack/docs-ui';
87
+ import { serveFile, serveFavicon } from './prod/serveFile';
88
+
89
+ await (await bootstrapLuckyStack({
90
+ serveFile,
91
+ serveFavicon,
92
+ customRoutes: docsUiHandler,
93
+ })).listen();
94
+ ```
95
+
96
+ Composing with another custom handler — chain by `await`-ing the docs handler first and only continuing when it returns `false`:
97
+
98
+ ```ts
99
+ import type { IncomingMessage, ServerResponse } from 'node:http';
100
+ import { mountDocsUi } from '@luckystack/docs-ui';
101
+ import { metricsHandler } from './metrics';
102
+
103
+ const docsHandler = mountDocsUi();
104
+
105
+ export const customRoutes = async (
106
+ req: IncomingMessage,
107
+ res: ServerResponse,
108
+ ): Promise<boolean> => {
109
+ if (await docsHandler(req, res)) return true;
110
+ if (await metricsHandler(req, res)) return true;
111
+ return false;
112
+ };
113
+ ```
114
+
115
+ The order matters only when two handlers would claim the same path; `mountDocsUi` only ever claims `routePath` + `${routePath}/api.json`, so collisions are easy to avoid by picking a unique `routePath`.
116
+
117
+ ## Behavior matrix
118
+
119
+ The handler's branches map directly to the request shape. The order is exactly as evaluated in `src/index.ts`:
120
+
121
+ | Step | Condition | Result |
122
+ | --- | --- | --- |
123
+ | 1 | `pathOnly !== routePath && pathOnly !== ${routePath}/api.json` | Returns `false` (pass-through). No response written. |
124
+ | 2 | (`NODE_ENV === 'production'` **or** `!isLoopbackIp(getBindAddress().ip)`) and `!options.enabledInProd` | `404 Not Found` (`text/plain`). Returns `true`. The non-loopback bind-address check means a server bound to a public interface is gated even outside `NODE_ENV=production`. |
125
+ | 3 | `options.authorize` is set and returns/resolves `false` | `403 Forbidden` (`text/plain`). Returns `true`. Runs only after the env/bind gate (step 2) passes. |
126
+ | 4 | `req.method !== 'GET'` | `405 Method Not Allowed` (`text/plain`). Returns `true`. |
127
+ | 5 | `pathOnly === ${routePath}/api.json` and the file is readable | `200 OK` (`application/json`, `Cache-Control: no-store`) with the raw file contents. Returns `true`. |
128
+ | 6 | `pathOnly === ${routePath}/api.json` and the file is missing/unreadable | `404 Not Found` (`application/json`) with `{ error, expectedAt, hint }`. Returns `true`. |
129
+ | 7 | `pathOnly === routePath` and `options.template` is set | `200 OK` (`text/html; charset=utf-8`) with the template builder's output. Returns `true`. |
130
+ | 8 | `pathOnly === routePath` and no template | `200 OK` (`text/html; charset=utf-8`) with `renderDocsHtml(...)`. Returns `true`. |
131
+
132
+ The order is critical: the env/bind gate runs before the `authorize` hook and before method-checking, so a `POST /_docs` in production (or on a public-bind server) still returns `404` — it's gated first — not `405`, and `authorize` is never consulted on a gated request. The gate fires on `NODE_ENV === 'production'` **or** a non-loopback bind address (`!isLoopbackIp(getBindAddress().ip)`), so a staging/preview server bound to a public interface is locked down even when `NODE_ENV` is not `production`; set `enabledInProd: true` to serve the route there. The match itself ignores query strings (the path is split on `?` before comparing) and is strict on trailing slashes — `/_docs/` does NOT match the default route.
133
+
134
+ ## Response shapes
135
+
136
+ ### HTML page (`GET ${routePath}`)
137
+
138
+ ```
139
+ HTTP/1.1 200 OK
140
+ Content-Type: text/html; charset=utf-8
141
+ Cache-Control: no-store
142
+
143
+ <!DOCTYPE html>
144
+ <html lang="en">
145
+ ...self-contained docs page (see ./html-generation.md)...
146
+ ```
147
+
148
+ ### JSON endpoint, success (`GET ${routePath}/api.json`)
149
+
150
+ ```
151
+ HTTP/1.1 200 OK
152
+ Content-Type: application/json
153
+ Cache-Control: no-store
154
+
155
+ { "apis": { "<page>": [ { "name": "<name>", "version": "<version>", "method": "POST", ... } ] }, "syncs": { "<page>": [ { ... } ] } }
156
+ ```
157
+
158
+ The file is streamed as-is from disk; the handler does not parse, validate, or transform it. Forward-compatible extension fields (see `./extension-fields.md`) pass through unchanged.
159
+
160
+ ### JSON endpoint, missing file
161
+
162
+ ```
163
+ HTTP/1.1 404 Not Found
164
+ Content-Type: application/json
165
+
166
+ {
167
+ "error": "apiDocs.generated.json not found",
168
+ "expectedAt": "<resolved apiDocsPath>",
169
+ "hint": "Run `npm run generateArtifacts` to generate it."
170
+ }
171
+ ```
172
+
173
+ This is intentionally a structured payload so the client-side renderer can surface a helpful empty state. `Cache-Control` is omitted on the error path — the request should be retried after running the artifact generator.
174
+
175
+ `expectedAt` is **dev-only**: it exposes the absolute filesystem path and is omitted when `NODE_ENV === 'production'` to avoid leaking internal directory structure (DOCSUI-8). In production the payload contains only `error` and `hint`.
176
+
177
+ ### Production lockdown (`enabledInProd: false`)
178
+
179
+ ```
180
+ HTTP/1.1 404 Not Found
181
+ Content-Type: text/plain
182
+
183
+ Not Found
184
+ ```
185
+
186
+ The same plain `Not Found` is returned for both the HTML and the JSON sub-route, so probing tools cannot infer which routes the handler owns.
187
+
188
+ ### Wrong method
189
+
190
+ ```
191
+ HTTP/1.1 405 Method Not Allowed
192
+ Content-Type: text/plain
193
+
194
+ Method Not Allowed
195
+ ```
196
+
197
+ `POST`, `PUT`, `DELETE`, and other verbs all map to `405`. The `enableTryItOut` runner posts to `/api/<page>/<name>/<version>?stream=false` (the framework's HTTP API surface), not to the docs route — so `GET`-only is sufficient.
198
+
199
+ ## `apiDocsPath` resolution
200
+
201
+ The default `apiDocsPath` is resolved lazily on every request via `getGeneratedApiDocsPath()` from `@luckystack/core`. That helper reads `ProjectConfig.paths.generatedApiDocs`, which is populated either via the framework defaults or via an overlay's `registerProjectConfig` call.
202
+
203
+ When you pass `apiDocsPath` explicitly:
204
+
205
+ - The value must be an absolute path. The handler does no resolution relative to `process.cwd()`.
206
+ - The file is read with `fs.promises.readFile(path, 'utf8')` on every request — there is no in-memory cache. The doc-set is dev-only and the artifact regenerates often; caching is intentionally avoided.
207
+ - Read errors are caught via `tryCatch`. The response is a structured `404` with `expectedAt` pointing at the resolved path so you can diff it against your actual layout.
208
+
209
+ ## Edge cases
210
+
211
+ - **Missing `apiDocs.generated.json`** — Returns the structured `404` JSON payload above. Run `npm run generateArtifacts` to produce it.
212
+ - **Trailing slashes** — The handler does strict string equality on the resolved path. `/_docs/` is treated as a different route from `/_docs`. Configure `routePath` to include or omit the trailing slash consistently with the rest of your router.
213
+ - **Query strings** — The path is split on `?` before comparison, so `/_docs?theme=dark` matches. Query parameters are otherwise ignored by the handler.
214
+ - **Custom `routePath`** — Any string is accepted; the `${routePath}/api.json` sibling is derived by simple concatenation. Avoid trailing slashes in `routePath` (`/_docs/` would yield `/_docs//api.json`).
215
+ - **`HEAD` requests** — Not special-cased. They fall into the `405` branch. If you need `HEAD` support, wrap the handler.
216
+ - **Concurrent requests** — Each request reads the JSON file independently. There is no shared mutable state inside the handler.
217
+ - **`req.url` missing** — The handler treats an undefined `url` as an empty string, which never matches `routePath`. It returns `false` (pass-through) cleanly.
218
+
219
+ ## Related
220
+
221
+ - HTML rendering pipeline: `./html-generation.md`
222
+ - Branding and full template replacement: `./theming.md`
223
+ - Extension fields rendered from `apiDocs.generated.json`: `./extension-fields.md`
224
+ - AI summary + function index: `../CLAUDE.md`
225
+ - Consumer quickstart: `../README.md`
226
+ - Source: `../src/index.ts`