@telorun/http-server 0.26.0 → 0.27.0

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/README.md CHANGED
@@ -10,6 +10,7 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
10
10
  - **Typed returns and catches** — render successful values and structured `InvokeError`s into status + headers + per-MIME bodies via CEL.
11
11
  - **OpenAPI operation metadata** — a route may declare `operationId`, `summary`, `description`, and `tags`; they are rendered into the generated OpenAPI document.
12
12
  - **Composable mounts** — attach `Telo.Mount` resources (HTTP APIs, MCP endpoints, custom mounts) under any path prefix.
13
+ - **Browsable API docs** — `Http.Reference` renders the generated OpenAPI document as an interactive page under a prefix you choose, and a mount's `when:` leaves it out of a production deployment.
13
14
  - **Serve a frontend** — `Http.Static` serves a directory of assets (a built SPA, plain HTML) so one application delivers both its API and its UI.
14
15
  - **CORS and content-type parsers** — first-class manifest fields; no controller code needed.
15
16
 
@@ -19,6 +20,7 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
19
20
  | --- | --- |
20
21
  | `Http.Server` | Long-lived HTTP listener that hosts mounts on configured paths and ports. |
21
22
  | `Http.Api` | Mountable router exposing route definitions with returns/catches rendering. |
23
+ | `Http.Reference` | Mountable API reference: the server's OpenAPI document rendered as a browsable page, plus the document itself as JSON and YAML. |
22
24
  | `Http.Static` | Mountable static-file server for a directory of assets (built SPA, plain HTML, images). |
23
25
 
24
26
  ## Example
@@ -80,6 +82,7 @@ code: |
80
82
  ## Reference
81
83
 
82
84
  - [`Http.Server` / `Http.Api` returns & catches](docs/returns-and-catches.md) — outcome lists, MIME negotiation, stream mode.
85
+ - [API reference docs](docs/api-reference.md) — `Http.Reference`, choosing its prefix, and leaving the docs out of production with `when:`.
83
86
  - [Serving static files & frontends](docs/static-files.md) — `Http.Static`, manifest-relative roots, SPA fallback, asset caching.
84
87
  - [Log events](docs/log-events.md) — the `event_name` and attributes every implementation of this kind emits, and how to turn request logging off.
85
88
 
@@ -0,0 +1,7 @@
1
+ import { type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
2
+ type HttpReferenceResource = RuntimeResource & {
3
+ title?: string;
4
+ theme?: string;
5
+ };
6
+ export declare function create(resource: HttpReferenceResource): Promise<ResourceInstance>;
7
+ export {};
@@ -0,0 +1,54 @@
1
+ import apiReference from "@scalar/fastify-api-reference";
2
+ import { normalizeMountPrefix } from "./mount-prefix.js";
3
+ import { installSpecServerUrlRewrite, specServerUrlIsPerRequest } from "./openapi-spec-servers.js";
4
+ /** Whether the server this mount was attached to registered an OpenAPI document.
5
+ * The same test the reference renderer makes before it would fall back to
6
+ * serving nothing. */
7
+ function hasOpenApiDocument(app) {
8
+ return (app.hasPlugin("@fastify/swagger") &&
9
+ typeof app.swagger === "function");
10
+ }
11
+ /**
12
+ * The API reference: the server's OpenAPI document rendered as a browsable page,
13
+ * plus the document itself at `<prefix>/openapi.json` and `<prefix>/openapi.yaml`.
14
+ *
15
+ * A mount rather than a fixed `/reference` route on the server, so the prefix is
16
+ * the author's and the docs are one entry in `mounts:` — which is what lets a
17
+ * `when:` leave them out of a production deployment. The document itself is still
18
+ * the server's: `@fastify/swagger` collects a route's schema through an `onRoute`
19
+ * hook in the encapsulation context it was registered in, so it has to be at the
20
+ * root scope before any mount registers, and only the RENDERING can move here.
21
+ */
22
+ class HttpReference {
23
+ title;
24
+ theme;
25
+ constructor(resource) {
26
+ this.title = resource.title;
27
+ this.theme = resource.theme;
28
+ }
29
+ async init() { }
30
+ register(app, prefix = "") {
31
+ const routePrefix = normalizeMountPrefix(prefix);
32
+ if (!hasOpenApiDocument(app)) {
33
+ throw new Error(`Http.Reference mounted at '${routePrefix}' has nothing to render: the Http.Server ` +
34
+ `it is mounted on declares no \`openapi:\` block, so no OpenAPI document is ` +
35
+ `collected. Add one to the server:\n\n` +
36
+ ` openapi:\n info:\n title: My API\n version: 1.0.0`);
37
+ }
38
+ // Scalar drops a trailing slash from its route prefix, so a reference mounted
39
+ // at the root serves `/openapi.json` rather than `//openapi.json`.
40
+ const base = routePrefix === "/" ? "" : routePrefix;
41
+ if (specServerUrlIsPerRequest(app)) {
42
+ installSpecServerUrlRewrite(app, `${base}/openapi.json`);
43
+ }
44
+ const configuration = {};
45
+ if (this.title !== undefined)
46
+ configuration.pageTitle = this.title;
47
+ if (this.theme !== undefined)
48
+ configuration.theme = this.theme;
49
+ app.register(apiReference, { routePrefix, configuration });
50
+ }
51
+ }
52
+ export async function create(resource) {
53
+ return new HttpReference(resource);
54
+ }
@@ -21,6 +21,15 @@ type CorsOptions = {
21
21
  strictPreflight?: boolean;
22
22
  hideOptionsRoute?: boolean;
23
23
  };
24
+ type HttpMount = {
25
+ path?: string;
26
+ mount?: Mountable;
27
+ logging?: {
28
+ level?: LevelName;
29
+ };
30
+ /** Expanded at startup (`x-telo-eval: compile`); `false` leaves the mount out. */
31
+ when?: boolean;
32
+ };
24
33
  type HttpServerResource = RuntimeResource & {
25
34
  host?: string;
26
35
  port?: number;
@@ -39,13 +48,7 @@ type HttpServerResource = RuntimeResource & {
39
48
  version: string;
40
49
  };
41
50
  };
42
- mounts?: Array<{
43
- path?: string;
44
- mount?: Mountable;
45
- logging?: {
46
- level?: LevelName;
47
- };
48
- }>;
51
+ mounts?: HttpMount[];
49
52
  notFoundHandler?: {
50
53
  invoke: KindRef<Invocable>;
51
54
  inputs?: Record<string, unknown>;
@@ -1,18 +1,23 @@
1
1
  import cors from "@fastify/cors";
2
2
  import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
3
3
  import swagger from "@fastify/swagger";
4
- import apiReference from "@scalar/fastify-api-reference";
5
4
  import { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
6
5
  import { isInvokeError, SEVERITY, severityForLevel, } from "@telorun/sdk";
7
6
  import addFormats from "ajv-formats";
8
- import Fastify from "fastify";
7
+ import Fastify, { LogController, } from "fastify";
9
8
  import { fastifyReplySink } from "./fastify-reply-sink.js";
9
+ import { publishSpecServerUrlPolicy } from "./openapi-spec-servers.js";
10
10
  class HttpServer {
11
11
  releaseHold = null;
12
12
  /** Whether a socket actually opened, so `http.server.stopped` is only emitted
13
13
  * for a server that emitted `http.server.started`. */
14
14
  listening = false;
15
15
  pluginsInitialized = false;
16
+ /** Indices into `activeMounts()` already attached, so a later init pass
17
+ * registers only what is still missing — see `init()`. */
18
+ attachedMounts = new Set();
19
+ notFoundHandlerInstalled = false;
20
+ excludedMountsLogged = false;
16
21
  app;
17
22
  host;
18
23
  port;
@@ -36,14 +41,24 @@ class HttpServer {
36
41
  // protocol/host (request.protocol/host) and the canonical client address
37
42
  // (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
38
43
  // it, the legacy `trustForwardedHeaders` boolean still applies.
39
- const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
44
+ //
45
+ // The hop count is expressed as the predicate it means — trust the rightmost
46
+ // N entries of X-Forwarded-For — rather than handed to Fastify as a number:
47
+ // Fastify 5.12 stopped honouring the numeric form and now trusts NOTHING for
48
+ // it, which would turn this kind's documented hop-count option into a silent
49
+ // no-op. The spoofing hazard that motivated their change is the one the
50
+ // schema already states: only enable this behind a trusted proxy.
51
+ const configuredTrust = resource.trustProxy ?? this.trustForwardedHeaders;
52
+ const trustProxy = typeof configuredTrust === "number"
53
+ ? (address, hop) => hop < configuredTrust
54
+ : configuredTrust;
40
55
  // §13.3: replacement, not bridging — Fastify's Pino instance is swapped for
41
56
  // a Telo-backed adapter, so its records are Telo records at the source and
42
57
  // inherit the root `logging:` block's level, encoding, redaction, and sinks.
43
58
  //
44
59
  // The adapter is injected UNCONDITIONALLY. It used to be gated on `info`
45
60
  // being enabled, because that was what avoided building a per-request record
46
- // at a raised threshold — but `disableRequestLogging` now removes that cost
61
+ // at a raised threshold — but disabled request logging now removes that cost
47
62
  // outright, so the gate's only remaining effect was to hand Fastify its null
48
63
  // logger at `level: warn` and silently drop every diagnostic it owns: the
49
64
  // error handler's own failures, reply-send failures, aborted-request hooks.
@@ -59,11 +74,25 @@ class HttpServer {
59
74
  // `onRequest` / `onResponse` instead (see `installRequestLogging`), so the
60
75
  // access record's shape is this KIND's contract rather than Pino's prose —
61
76
  // which is what lets a Rust or Go implementation emit the same thing.
62
- disableRequestLogging: true,
77
+ // The top-level `disableRequestLogging` option is deprecated (FSTDEP023);
78
+ // the log controller carries the same switch.
79
+ logController: new LogController({ disableRequestLogging: true }),
63
80
  trustProxy,
64
81
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
65
82
  });
66
83
  }
84
+ /**
85
+ * Registering plugins and routes: nothing observable, and nothing repeatable —
86
+ * a route registers exactly once.
87
+ *
88
+ * The multi-pass init loop calls `init()` AGAIN on a resource whose init threw,
89
+ * which is how a mount that was not yet injected gets its second chance. So
90
+ * this has to be RESUMABLE rather than merely re-runnable: each mount records
91
+ * that it attached, and a later pass registers only what is still missing.
92
+ * Re-running the whole set answered with Fastify's duplicate-route error and
93
+ * buried the reason the first pass failed; refusing to re-run at all would have
94
+ * made a first-pass failure permanent, which is the retry the loop exists for.
95
+ */
67
96
  async init() {
68
97
  if (!this.pluginsInitialized) {
69
98
  await this.setupPlugins();
@@ -93,7 +122,7 @@ class HttpServer {
93
122
  * scope, so it cannot quieten `/health` while leaving `/api` alone. This can.
94
123
  */
95
124
  mountLogFloors() {
96
- return (this.resource.mounts ?? [])
125
+ return this.activeMounts()
97
126
  .flatMap((mount) => {
98
127
  const level = mount.logging?.level;
99
128
  if (!level)
@@ -102,6 +131,19 @@ class HttpServer {
102
131
  })
103
132
  .sort((a, b) => b.prefix.length - a.prefix.length);
104
133
  }
134
+ /**
135
+ * The mounts this server attaches, `when:`-gated ones dropped.
136
+ *
137
+ * `when` is a startup decision, not a per-request one: it resolves with the
138
+ * rest of the server's configuration, so an excluded mount registers no routes
139
+ * at all rather than answering 404 — which is what makes leaving the API
140
+ * reference out of a production deployment mean something. The referenced
141
+ * resource is still created and initialized: it is an ordinary ref slot, filled
142
+ * before this server ever runs.
143
+ */
144
+ activeMounts() {
145
+ return (this.resource.mounts ?? []).filter((mount) => mount.when !== false);
146
+ }
105
147
  installRequestLogging() {
106
148
  const log = this.ctx.log;
107
149
  const floors = this.mountLogFloors();
@@ -267,6 +309,12 @@ class HttpServer {
267
309
  // override; otherwise the URL is relative (`/`) so the doc is correct behind
268
310
  // any proxy/ingress/origin — the client resolves it against wherever the
269
311
  // reference was loaded.
312
+ //
313
+ // This registers the document and nothing that serves it: rendering it is
314
+ // Http.Reference's, mounted where the author wants it. Collection has to
315
+ // stay here because @fastify/swagger reads a route's schema through an
316
+ // `onRoute` hook in its own encapsulation context, so it must be at the
317
+ // root scope before any mount registers its routes.
270
318
  const servers = [{ url: this.resource.baseUrl ?? "/" }];
271
319
  await this.app.register(swagger, {
272
320
  openapi: {
@@ -275,61 +323,43 @@ class HttpServer {
275
323
  servers,
276
324
  },
277
325
  });
278
- const referencePrefix = "/reference";
279
326
  // `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
280
327
  // default to absolute URLs built per-request from the now-trusted
281
- // X-Forwarded-* headers, so the served spec advertises the real proxy URL.
282
- if (this.trustForwardedHeaders && !this.resource.baseUrl) {
283
- // Couples to the Scalar plugin's default spec endpoint
284
- // (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
285
- // rewrite would no-op and the relative default would still apply. The
286
- // `openapi-server-url` integration test guards this path.
287
- const specPath = `${referencePrefix}/openapi.json`;
288
- this.app.addHook("onSend", async (request, reply, payload) => {
289
- if (request.url.split("?")[0] !== specPath)
290
- return payload;
291
- const text = typeof payload === "string"
292
- ? payload
293
- : Buffer.isBuffer(payload)
294
- ? payload.toString("utf8")
295
- : null;
296
- if (text === null)
297
- return payload;
298
- try {
299
- const doc = JSON.parse(text);
300
- if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
301
- doc.servers = [{ url: `${request.protocol}://${request.host}` }];
302
- const out = JSON.stringify(doc);
303
- reply.header("content-length", Buffer.byteLength(out));
304
- return out;
305
- }
306
- }
307
- catch {
308
- // Not a JSON document we can rewrite — leave the response untouched.
309
- }
310
- return payload;
311
- });
312
- }
313
- await this.app.register(apiReference, {
314
- routePrefix: referencePrefix,
315
- });
328
+ // X-Forwarded-* headers. The rewrite itself belongs to whichever mount
329
+ // serves the document, which is the only side that knows its path.
330
+ publishSpecServerUrlPolicy(this.app, this.trustForwardedHeaders && !this.resource.baseUrl);
316
331
  }
317
332
  }
318
333
  setupRoutes() {
319
334
  // const routesByName = new Map<string, HttpRouteResource>();
320
- const mounts = this.resource.mounts || [];
335
+ const mounts = this.activeMounts();
336
+ if (!this.excludedMountsLogged) {
337
+ this.excludedMountsLogged = true;
338
+ for (const skipped of (this.resource.mounts ?? []).filter((mount) => mount.when === false)) {
339
+ // Said out loud: a route set that is absent because a condition excluded
340
+ // it is indistinguishable at request time from one that failed to
341
+ // register. Once, not once per init pass.
342
+ this.ctx.log.debug("Mount excluded by its `when` condition", { "http.route": skipped.path || "/" }, { eventName: "http.server.mount.excluded" });
343
+ }
344
+ }
321
345
  // const resolveSchema = createSchemaResolver(this.ctx);
322
- for (const mount of mounts) {
346
+ for (let index = 0; index < mounts.length; index++) {
347
+ if (this.attachedMounts.has(index))
348
+ continue;
349
+ const mount = mounts[index];
323
350
  const prefix = mount.path || "";
324
351
  // `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
325
352
  // (x-telo-ref `Telo.Mount`) — a same-module or imported-library mount, uniformly.
326
353
  const api = mount.mount;
327
354
  if (!api || typeof api.register !== "function") {
355
+ // Thrown, not recorded: Phase-5 injection runs again before the next
356
+ // init pass, so a mount that is merely not created YET resolves then.
328
357
  throw new Error(`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`);
329
358
  }
330
359
  api.register(this.app, prefix);
360
+ this.attachedMounts.add(index);
331
361
  }
332
- if (this.resolvedNotFoundHandler) {
362
+ if (this.resolvedNotFoundHandler && !this.notFoundHandlerInstalled) {
333
363
  const handler = this.resolvedNotFoundHandler;
334
364
  this.app.setNotFoundHandler(async (request, reply) => {
335
365
  const normalizedHeaders = {};
@@ -384,6 +414,7 @@ class HttpServer {
384
414
  }
385
415
  return reply.send(result?.body ?? result);
386
416
  });
417
+ this.notFoundHandlerInstalled = true;
387
418
  }
388
419
  }
389
420
  async run() {
@@ -2,17 +2,7 @@ import fastifyStatic from "@fastify/static";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- /** Collapse a mount prefix to a single leading slash with no trailing slash;
6
- * an empty/`"/"` prefix becomes `"/"`. Unlike Http.Api (which returns `""` and
7
- * concatenates the prefix onto each route path on the root app), this serves
8
- * from an encapsulated `register({ prefix })`, which needs a non-empty prefix —
9
- * hence root maps to `"/"`, not `""`. */
10
- function normalizeMountPrefix(prefix) {
11
- const trimmed = prefix.replace(/\/+$/, "");
12
- if (!trimmed)
13
- return "/";
14
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
15
- }
5
+ import { normalizeMountPrefix } from "./mount-prefix.js";
16
6
  /** Serves a directory of static assets (a built SPA, plain HTML, images, …) as a
17
7
  * Telo.Mount. Mirrors Http.Api's `register(app, prefix)` contract so it slots into
18
8
  * Http.Server.mounts identically. Backed by @fastify/static, which handles MIME,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Collapse a mount prefix to a single leading slash with no trailing slash; an
3
+ * empty/`"/"` prefix becomes `"/"`.
4
+ *
5
+ * Shared by the mounts that hand their prefix to a Fastify plugin — Http.Static's
6
+ * encapsulated `register({ prefix })` and Http.Reference's `routePrefix` — both of
7
+ * which need a non-empty prefix, hence root maps to `"/"`. Http.Api is the odd one
8
+ * out: it returns `""` and concatenates the prefix onto each route path on the
9
+ * root app, so it does not use this.
10
+ */
11
+ export declare function normalizeMountPrefix(prefix: string): `/${string}`;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Collapse a mount prefix to a single leading slash with no trailing slash; an
3
+ * empty/`"/"` prefix becomes `"/"`.
4
+ *
5
+ * Shared by the mounts that hand their prefix to a Fastify plugin — Http.Static's
6
+ * encapsulated `register({ prefix })` and Http.Reference's `routePrefix` — both of
7
+ * which need a non-empty prefix, hence root maps to `"/"`. Http.Api is the odd one
8
+ * out: it returns `""` and concatenates the prefix onto each route path on the
9
+ * root app, so it does not use this.
10
+ */
11
+ export function normalizeMountPrefix(prefix) {
12
+ const trimmed = prefix.replace(/\/+$/, "");
13
+ if (!trimmed)
14
+ return "/";
15
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
16
+ }
@@ -0,0 +1,16 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ /** Declare whether the served document's server URL must be rebuilt per request
3
+ * (a trusted proxy in front, and no fixed `baseUrl` to state instead). */
4
+ export declare function publishSpecServerUrlPolicy(app: FastifyInstance, perRequest: boolean): void;
5
+ /** The policy the server published, absent when it declared no `openapi:` block. */
6
+ export declare function specServerUrlIsPerRequest(app: FastifyInstance): boolean;
7
+ /**
8
+ * Rewrite the served document's `servers:` to the URL this request arrived on.
9
+ *
10
+ * The registered document declares a relative server URL (`/`), which is correct
11
+ * behind any origin because the client resolves it against wherever the reference
12
+ * was loaded. With a trusted proxy in front, the real URL is knowable per request
13
+ * from the now-trusted `X-Forwarded-*` headers, so the served document advertises
14
+ * it instead of leaving the client to infer it.
15
+ */
16
+ export declare function installSpecServerUrlRewrite(app: FastifyInstance, specPath: string): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `servers:` array of the OpenAPI document as it is SERVED, which neither
3
+ * half of the docs can decide alone.
4
+ *
5
+ * `Http.Server` owns the policy — it holds `baseUrl` and knows whether forwarded
6
+ * headers are trusted — while `Http.Reference` owns the route the document is
7
+ * served from, so the rewrite is installed by the mount and decided by the
8
+ * server. The server publishes its decision as a Fastify decorator rather than
9
+ * passing it down a ref: a mount is reached through `register(app, prefix)` and
10
+ * has no handle on the server resource.
11
+ */
12
+ const SPEC_SERVER_URL_PER_REQUEST = "teloOpenapiSpecServerUrlPerRequest";
13
+ /** Declare whether the served document's server URL must be rebuilt per request
14
+ * (a trusted proxy in front, and no fixed `baseUrl` to state instead). */
15
+ export function publishSpecServerUrlPolicy(app, perRequest) {
16
+ app.decorate(SPEC_SERVER_URL_PER_REQUEST, perRequest);
17
+ }
18
+ /** The policy the server published, absent when it declared no `openapi:` block. */
19
+ export function specServerUrlIsPerRequest(app) {
20
+ return app[SPEC_SERVER_URL_PER_REQUEST] === true;
21
+ }
22
+ /**
23
+ * Rewrite the served document's `servers:` to the URL this request arrived on.
24
+ *
25
+ * The registered document declares a relative server URL (`/`), which is correct
26
+ * behind any origin because the client resolves it against wherever the reference
27
+ * was loaded. With a trusted proxy in front, the real URL is knowable per request
28
+ * from the now-trusted `X-Forwarded-*` headers, so the served document advertises
29
+ * it instead of leaving the client to infer it.
30
+ */
31
+ export function installSpecServerUrlRewrite(app, specPath) {
32
+ app.addHook("onSend", async (request, reply, payload) => {
33
+ if (request.url.split("?")[0] !== specPath)
34
+ return payload;
35
+ const text = typeof payload === "string"
36
+ ? payload
37
+ : Buffer.isBuffer(payload)
38
+ ? payload.toString("utf8")
39
+ : null;
40
+ if (text === null)
41
+ return payload;
42
+ try {
43
+ const doc = JSON.parse(text);
44
+ if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
45
+ doc.servers = [{ url: `${request.protocol}://${request.host}` }];
46
+ const out = JSON.stringify(doc);
47
+ reply.header("content-length", Buffer.byteLength(out));
48
+ return out;
49
+ }
50
+ }
51
+ catch {
52
+ // Not a JSON document we can rewrite — leave the response untouched.
53
+ }
54
+ return payload;
55
+ });
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -34,6 +34,11 @@
34
34
  "bun": "./src/http-api-controller.ts",
35
35
  "import": "./dist/http-api-controller.js"
36
36
  },
37
+ "./http-reference": {
38
+ "types": "./dist/http-reference-controller.d.ts",
39
+ "bun": "./src/http-reference-controller.ts",
40
+ "import": "./dist/http-reference-controller.js"
41
+ },
37
42
  "./http-static": {
38
43
  "types": "./dist/http-static-controller.d.ts",
39
44
  "bun": "./src/http-static-controller.ts",
@@ -48,14 +53,14 @@
48
53
  "@sinclair/typebox": "^0.34.48",
49
54
  "ajv": "^8.17.1",
50
55
  "ajv-formats": "^3.0.1",
51
- "fastify": "^5.7.2",
52
- "@telorun/http-dispatch": "0.11.1"
56
+ "fastify": "^5.12.1",
57
+ "@telorun/http-dispatch": "0.11.2"
53
58
  },
54
59
  "devDependencies": {
55
60
  "@types/node": "^20.0.0",
56
61
  "typescript": "^5.0.0",
57
62
  "vitest": "^2.1.8",
58
- "@telorun/sdk": "0.75.0"
63
+ "@telorun/sdk": "0.79.0"
59
64
  },
60
65
  "peerDependencies": {
61
66
  "@telorun/sdk": "*"
@@ -0,0 +1,69 @@
1
+ import apiReference from "@scalar/fastify-api-reference";
2
+ import { type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
+ import { FastifyInstance } from "fastify";
4
+ import { normalizeMountPrefix } from "./mount-prefix.js";
5
+ import { installSpecServerUrlRewrite, specServerUrlIsPerRequest } from "./openapi-spec-servers.js";
6
+
7
+ type HttpReferenceResource = RuntimeResource & {
8
+ title?: string;
9
+ theme?: string;
10
+ };
11
+
12
+ /** Whether the server this mount was attached to registered an OpenAPI document.
13
+ * The same test the reference renderer makes before it would fall back to
14
+ * serving nothing. */
15
+ function hasOpenApiDocument(app: FastifyInstance): boolean {
16
+ return (
17
+ app.hasPlugin("@fastify/swagger") &&
18
+ typeof (app as unknown as { swagger?: unknown }).swagger === "function"
19
+ );
20
+ }
21
+
22
+ /**
23
+ * The API reference: the server's OpenAPI document rendered as a browsable page,
24
+ * plus the document itself at `<prefix>/openapi.json` and `<prefix>/openapi.yaml`.
25
+ *
26
+ * A mount rather than a fixed `/reference` route on the server, so the prefix is
27
+ * the author's and the docs are one entry in `mounts:` — which is what lets a
28
+ * `when:` leave them out of a production deployment. The document itself is still
29
+ * the server's: `@fastify/swagger` collects a route's schema through an `onRoute`
30
+ * hook in the encapsulation context it was registered in, so it has to be at the
31
+ * root scope before any mount registers, and only the RENDERING can move here.
32
+ */
33
+ class HttpReference implements ResourceInstance {
34
+ private readonly title?: string;
35
+ private readonly theme?: string;
36
+
37
+ constructor(resource: HttpReferenceResource) {
38
+ this.title = resource.title;
39
+ this.theme = resource.theme;
40
+ }
41
+
42
+ async init() {}
43
+
44
+ register(app: FastifyInstance, prefix = ""): void {
45
+ const routePrefix = normalizeMountPrefix(prefix);
46
+ if (!hasOpenApiDocument(app)) {
47
+ throw new Error(
48
+ `Http.Reference mounted at '${routePrefix}' has nothing to render: the Http.Server ` +
49
+ `it is mounted on declares no \`openapi:\` block, so no OpenAPI document is ` +
50
+ `collected. Add one to the server:\n\n` +
51
+ ` openapi:\n info:\n title: My API\n version: 1.0.0`,
52
+ );
53
+ }
54
+ // Scalar drops a trailing slash from its route prefix, so a reference mounted
55
+ // at the root serves `/openapi.json` rather than `//openapi.json`.
56
+ const base = routePrefix === "/" ? "" : routePrefix;
57
+ if (specServerUrlIsPerRequest(app)) {
58
+ installSpecServerUrlRewrite(app, `${base}/openapi.json`);
59
+ }
60
+ const configuration: Record<string, unknown> = {};
61
+ if (this.title !== undefined) configuration.pageTitle = this.title;
62
+ if (this.theme !== undefined) configuration.theme = this.theme;
63
+ app.register(apiReference, { routePrefix, configuration });
64
+ }
65
+ }
66
+
67
+ export async function create(resource: HttpReferenceResource): Promise<ResourceInstance> {
68
+ return new HttpReference(resource);
69
+ }
@@ -1,7 +1,6 @@
1
1
  import cors from "@fastify/cors";
2
2
  import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
3
3
  import swagger from "@fastify/swagger";
4
- import apiReference from "@scalar/fastify-api-reference";
5
4
  import {
6
5
  CatchEntry,
7
6
  dispatchCatches,
@@ -20,8 +19,14 @@ import {
20
19
  type RuntimeResource,
21
20
  } from "@telorun/sdk";
22
21
  import addFormats from "ajv-formats";
23
- import Fastify, { FastifyInstance, type FastifyRequest } from "fastify";
22
+ import Fastify, {
23
+ FastifyInstance,
24
+ LogController,
25
+ type FastifyRequest,
26
+ type FastifyServerOptions,
27
+ } from "fastify";
24
28
  import { fastifyReplySink } from "./fastify-reply-sink.js";
29
+ import { publishSpecServerUrlPolicy } from "./openapi-spec-servers.js";
25
30
 
26
31
  /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
27
32
  * live instance into a mount's `mount` slot (x-telo-ref `Telo.Mount`) — cross-module refs
@@ -45,6 +50,16 @@ type CorsOptions = {
45
50
  hideOptionsRoute?: boolean;
46
51
  };
47
52
 
53
+ type HttpMount = {
54
+ path?: string;
55
+ // x-telo-ref `Telo.Mount`: Phase 5 replaces this slot with the live mounted
56
+ // instance (Http.Api, Mcp.HttpEndpoint, …), local or imported.
57
+ mount?: Mountable;
58
+ logging?: { level?: LevelName };
59
+ /** Expanded at startup (`x-telo-eval: compile`); `false` leaves the mount out. */
60
+ when?: boolean;
61
+ };
62
+
48
63
  type HttpServerResource = RuntimeResource & {
49
64
  host?: string;
50
65
  port?: number;
@@ -59,13 +74,7 @@ type HttpServerResource = RuntimeResource & {
59
74
  version: string;
60
75
  };
61
76
  };
62
- mounts?: Array<{
63
- path?: string;
64
- // x-telo-ref `Telo.Mount`: Phase 5 replaces this slot with the live mounted
65
- // instance (Http.Api, Mcp.HttpEndpoint, …), local or imported.
66
- mount?: Mountable;
67
- logging?: { level?: LevelName };
68
- }>;
77
+ mounts?: HttpMount[];
69
78
  notFoundHandler?: {
70
79
  invoke: KindRef<Invocable>;
71
80
  inputs?: Record<string, unknown>;
@@ -88,6 +97,11 @@ class HttpServer implements ResourceInstance {
88
97
  * for a server that emitted `http.server.started`. */
89
98
  private listening = false;
90
99
  private pluginsInitialized = false;
100
+ /** Indices into `activeMounts()` already attached, so a later init pass
101
+ * registers only what is still missing — see `init()`. */
102
+ private readonly attachedMounts = new Set<number>();
103
+ private notFoundHandlerInstalled = false;
104
+ private excludedMountsLogged = false;
91
105
  private readonly app: FastifyInstance;
92
106
  private readonly host: string;
93
107
  private readonly port: number;
@@ -117,14 +131,25 @@ class HttpServer implements ResourceInstance {
117
131
  // protocol/host (request.protocol/host) and the canonical client address
118
132
  // (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
119
133
  // it, the legacy `trustForwardedHeaders` boolean still applies.
120
- const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
134
+ //
135
+ // The hop count is expressed as the predicate it means — trust the rightmost
136
+ // N entries of X-Forwarded-For — rather than handed to Fastify as a number:
137
+ // Fastify 5.12 stopped honouring the numeric form and now trusts NOTHING for
138
+ // it, which would turn this kind's documented hop-count option into a silent
139
+ // no-op. The spoofing hazard that motivated their change is the one the
140
+ // schema already states: only enable this behind a trusted proxy.
141
+ const configuredTrust = resource.trustProxy ?? this.trustForwardedHeaders;
142
+ const trustProxy: FastifyServerOptions["trustProxy"] =
143
+ typeof configuredTrust === "number"
144
+ ? (address, hop) => hop < configuredTrust
145
+ : configuredTrust;
121
146
  // §13.3: replacement, not bridging — Fastify's Pino instance is swapped for
122
147
  // a Telo-backed adapter, so its records are Telo records at the source and
123
148
  // inherit the root `logging:` block's level, encoding, redaction, and sinks.
124
149
  //
125
150
  // The adapter is injected UNCONDITIONALLY. It used to be gated on `info`
126
151
  // being enabled, because that was what avoided building a per-request record
127
- // at a raised threshold — but `disableRequestLogging` now removes that cost
152
+ // at a raised threshold — but disabled request logging now removes that cost
128
153
  // outright, so the gate's only remaining effect was to hand Fastify its null
129
154
  // logger at `level: warn` and silently drop every diagnostic it owns: the
130
155
  // error handler's own failures, reply-send failures, aborted-request hooks.
@@ -140,12 +165,26 @@ class HttpServer implements ResourceInstance {
140
165
  // `onRequest` / `onResponse` instead (see `installRequestLogging`), so the
141
166
  // access record's shape is this KIND's contract rather than Pino's prose —
142
167
  // which is what lets a Rust or Go implementation emit the same thing.
143
- disableRequestLogging: true,
168
+ // The top-level `disableRequestLogging` option is deprecated (FSTDEP023);
169
+ // the log controller carries the same switch.
170
+ logController: new LogController({ disableRequestLogging: true }),
144
171
  trustProxy,
145
172
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
146
173
  });
147
174
  }
148
175
 
176
+ /**
177
+ * Registering plugins and routes: nothing observable, and nothing repeatable —
178
+ * a route registers exactly once.
179
+ *
180
+ * The multi-pass init loop calls `init()` AGAIN on a resource whose init threw,
181
+ * which is how a mount that was not yet injected gets its second chance. So
182
+ * this has to be RESUMABLE rather than merely re-runnable: each mount records
183
+ * that it attached, and a later pass registers only what is still missing.
184
+ * Re-running the whole set answered with Fastify's duplicate-route error and
185
+ * buried the reason the first pass failed; refusing to re-run at all would have
186
+ * made a first-pass failure permanent, which is the retry the loop exists for.
187
+ */
149
188
  async init() {
150
189
  if (!this.pluginsInitialized) {
151
190
  await this.setupPlugins();
@@ -176,7 +215,7 @@ class HttpServer implements ResourceInstance {
176
215
  * scope, so it cannot quieten `/health` while leaving `/api` alone. This can.
177
216
  */
178
217
  private mountLogFloors(): ReadonlyArray<{ prefix: string; floor: number }> {
179
- return (this.resource.mounts ?? [])
218
+ return this.activeMounts()
180
219
  .flatMap((mount) => {
181
220
  const level = mount.logging?.level;
182
221
  if (!level) return [];
@@ -185,6 +224,20 @@ class HttpServer implements ResourceInstance {
185
224
  .sort((a, b) => b.prefix.length - a.prefix.length);
186
225
  }
187
226
 
227
+ /**
228
+ * The mounts this server attaches, `when:`-gated ones dropped.
229
+ *
230
+ * `when` is a startup decision, not a per-request one: it resolves with the
231
+ * rest of the server's configuration, so an excluded mount registers no routes
232
+ * at all rather than answering 404 — which is what makes leaving the API
233
+ * reference out of a production deployment mean something. The referenced
234
+ * resource is still created and initialized: it is an ordinary ref slot, filled
235
+ * before this server ever runs.
236
+ */
237
+ private activeMounts(): ReadonlyArray<HttpMount> {
238
+ return (this.resource.mounts ?? []).filter((mount) => mount.when !== false);
239
+ }
240
+
188
241
  private installRequestLogging() {
189
242
  const log = this.ctx.log;
190
243
  const floors = this.mountLogFloors();
@@ -367,6 +420,12 @@ class HttpServer implements ResourceInstance {
367
420
  // override; otherwise the URL is relative (`/`) so the doc is correct behind
368
421
  // any proxy/ingress/origin — the client resolves it against wherever the
369
422
  // reference was loaded.
423
+ //
424
+ // This registers the document and nothing that serves it: rendering it is
425
+ // Http.Reference's, mounted where the author wants it. Collection has to
426
+ // stay here because @fastify/swagger reads a route's schema through an
427
+ // `onRoute` hook in its own encapsulation context, so it must be at the
428
+ // root scope before any mount registers its routes.
370
429
  const servers = [{ url: this.resource.baseUrl ?? "/" }];
371
430
  await this.app.register(swagger, {
372
431
  openapi: {
@@ -375,63 +434,53 @@ class HttpServer implements ResourceInstance {
375
434
  servers,
376
435
  },
377
436
  });
378
- const referencePrefix = "/reference";
379
437
  // `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
380
438
  // default to absolute URLs built per-request from the now-trusted
381
- // X-Forwarded-* headers, so the served spec advertises the real proxy URL.
382
- if (this.trustForwardedHeaders && !this.resource.baseUrl) {
383
- // Couples to the Scalar plugin's default spec endpoint
384
- // (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
385
- // rewrite would no-op and the relative default would still apply. The
386
- // `openapi-server-url` integration test guards this path.
387
- const specPath = `${referencePrefix}/openapi.json`;
388
- this.app.addHook("onSend", async (request, reply, payload) => {
389
- if (request.url.split("?")[0] !== specPath) return payload;
390
- const text =
391
- typeof payload === "string"
392
- ? payload
393
- : Buffer.isBuffer(payload)
394
- ? payload.toString("utf8")
395
- : null;
396
- if (text === null) return payload;
397
- try {
398
- const doc = JSON.parse(text);
399
- if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
400
- doc.servers = [{ url: `${request.protocol}://${request.host}` }];
401
- const out = JSON.stringify(doc);
402
- reply.header("content-length", Buffer.byteLength(out));
403
- return out;
404
- }
405
- } catch {
406
- // Not a JSON document we can rewrite — leave the response untouched.
407
- }
408
- return payload;
409
- });
410
- }
411
- await this.app.register(apiReference, {
412
- routePrefix: referencePrefix,
413
- });
439
+ // X-Forwarded-* headers. The rewrite itself belongs to whichever mount
440
+ // serves the document, which is the only side that knows its path.
441
+ publishSpecServerUrlPolicy(
442
+ this.app,
443
+ this.trustForwardedHeaders && !this.resource.baseUrl,
444
+ );
414
445
  }
415
446
  }
416
447
 
417
448
  private setupRoutes(): void {
418
449
  // const routesByName = new Map<string, HttpRouteResource>();
419
- const mounts = this.resource.mounts || [];
450
+ const mounts = this.activeMounts();
451
+ if (!this.excludedMountsLogged) {
452
+ this.excludedMountsLogged = true;
453
+ for (const skipped of (this.resource.mounts ?? []).filter((mount) => mount.when === false)) {
454
+ // Said out loud: a route set that is absent because a condition excluded
455
+ // it is indistinguishable at request time from one that failed to
456
+ // register. Once, not once per init pass.
457
+ this.ctx.log.debug(
458
+ "Mount excluded by its `when` condition",
459
+ { "http.route": skipped.path || "/" },
460
+ { eventName: "http.server.mount.excluded" },
461
+ );
462
+ }
463
+ }
420
464
  // const resolveSchema = createSchemaResolver(this.ctx);
421
- for (const mount of mounts) {
465
+ for (let index = 0; index < mounts.length; index++) {
466
+ if (this.attachedMounts.has(index)) continue;
467
+ const mount = mounts[index];
422
468
  const prefix = mount.path || "";
423
469
  // `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
424
470
  // (x-telo-ref `Telo.Mount`) — a same-module or imported-library mount, uniformly.
425
471
  const api = mount.mount;
426
472
  if (!api || typeof api.register !== "function") {
473
+ // Thrown, not recorded: Phase-5 injection runs again before the next
474
+ // init pass, so a mount that is merely not created YET resolves then.
427
475
  throw new Error(
428
476
  `Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`,
429
477
  );
430
478
  }
431
479
  api.register(this.app, prefix);
480
+ this.attachedMounts.add(index);
432
481
  }
433
482
 
434
- if (this.resolvedNotFoundHandler) {
483
+ if (this.resolvedNotFoundHandler && !this.notFoundHandlerInstalled) {
435
484
  const handler = this.resolvedNotFoundHandler;
436
485
  this.app.setNotFoundHandler(async (request, reply) => {
437
486
  const normalizedHeaders: Record<string, any> = {};
@@ -511,6 +560,7 @@ class HttpServer implements ResourceInstance {
511
560
  }
512
561
  return reply.send(result?.body ?? result);
513
562
  });
563
+ this.notFoundHandlerInstalled = true;
514
564
  }
515
565
  }
516
566
 
@@ -4,6 +4,7 @@ import { FastifyInstance } from "fastify";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { normalizeMountPrefix } from "./mount-prefix.js";
7
8
 
8
9
  type HttpStaticResource = RuntimeResource & {
9
10
  root: string;
@@ -13,17 +14,6 @@ type HttpStaticResource = RuntimeResource & {
13
14
  immutable?: boolean;
14
15
  };
15
16
 
16
- /** Collapse a mount prefix to a single leading slash with no trailing slash;
17
- * an empty/`"/"` prefix becomes `"/"`. Unlike Http.Api (which returns `""` and
18
- * concatenates the prefix onto each route path on the root app), this serves
19
- * from an encapsulated `register({ prefix })`, which needs a non-empty prefix —
20
- * hence root maps to `"/"`, not `""`. */
21
- function normalizeMountPrefix(prefix: string): string {
22
- const trimmed = prefix.replace(/\/+$/, "");
23
- if (!trimmed) return "/";
24
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
25
- }
26
-
27
17
  /** Serves a directory of static assets (a built SPA, plain HTML, images, …) as a
28
18
  * Telo.Mount. Mirrors Http.Api's `register(app, prefix)` contract so it slots into
29
19
  * Http.Server.mounts identically. Backed by @fastify/static, which handles MIME,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Collapse a mount prefix to a single leading slash with no trailing slash; an
3
+ * empty/`"/"` prefix becomes `"/"`.
4
+ *
5
+ * Shared by the mounts that hand their prefix to a Fastify plugin — Http.Static's
6
+ * encapsulated `register({ prefix })` and Http.Reference's `routePrefix` — both of
7
+ * which need a non-empty prefix, hence root maps to `"/"`. Http.Api is the odd one
8
+ * out: it returns `""` and concatenates the prefix onto each route path on the
9
+ * root app, so it does not use this.
10
+ */
11
+ export function normalizeMountPrefix(prefix: string): `/${string}` {
12
+ const trimmed = prefix.replace(/\/+$/, "");
13
+ if (!trimmed) return "/";
14
+ return trimmed.startsWith("/") ? (trimmed as `/${string}`) : `/${trimmed}`;
15
+ }
@@ -0,0 +1,59 @@
1
+ import type { FastifyInstance } from "fastify";
2
+
3
+ /**
4
+ * The `servers:` array of the OpenAPI document as it is SERVED, which neither
5
+ * half of the docs can decide alone.
6
+ *
7
+ * `Http.Server` owns the policy — it holds `baseUrl` and knows whether forwarded
8
+ * headers are trusted — while `Http.Reference` owns the route the document is
9
+ * served from, so the rewrite is installed by the mount and decided by the
10
+ * server. The server publishes its decision as a Fastify decorator rather than
11
+ * passing it down a ref: a mount is reached through `register(app, prefix)` and
12
+ * has no handle on the server resource.
13
+ */
14
+ const SPEC_SERVER_URL_PER_REQUEST = "teloOpenapiSpecServerUrlPerRequest";
15
+
16
+ /** Declare whether the served document's server URL must be rebuilt per request
17
+ * (a trusted proxy in front, and no fixed `baseUrl` to state instead). */
18
+ export function publishSpecServerUrlPolicy(app: FastifyInstance, perRequest: boolean): void {
19
+ app.decorate(SPEC_SERVER_URL_PER_REQUEST, perRequest);
20
+ }
21
+
22
+ /** The policy the server published, absent when it declared no `openapi:` block. */
23
+ export function specServerUrlIsPerRequest(app: FastifyInstance): boolean {
24
+ return (app as unknown as Record<string, unknown>)[SPEC_SERVER_URL_PER_REQUEST] === true;
25
+ }
26
+
27
+ /**
28
+ * Rewrite the served document's `servers:` to the URL this request arrived on.
29
+ *
30
+ * The registered document declares a relative server URL (`/`), which is correct
31
+ * behind any origin because the client resolves it against wherever the reference
32
+ * was loaded. With a trusted proxy in front, the real URL is knowable per request
33
+ * from the now-trusted `X-Forwarded-*` headers, so the served document advertises
34
+ * it instead of leaving the client to infer it.
35
+ */
36
+ export function installSpecServerUrlRewrite(app: FastifyInstance, specPath: string): void {
37
+ app.addHook("onSend", async (request, reply, payload) => {
38
+ if (request.url.split("?")[0] !== specPath) return payload;
39
+ const text =
40
+ typeof payload === "string"
41
+ ? payload
42
+ : Buffer.isBuffer(payload)
43
+ ? payload.toString("utf8")
44
+ : null;
45
+ if (text === null) return payload;
46
+ try {
47
+ const doc = JSON.parse(text);
48
+ if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
49
+ doc.servers = [{ url: `${request.protocol}://${request.host}` }];
50
+ const out = JSON.stringify(doc);
51
+ reply.header("content-length", Buffer.byteLength(out));
52
+ return out;
53
+ }
54
+ } catch {
55
+ // Not a JSON document we can rewrite — leave the response untouched.
56
+ }
57
+ return payload;
58
+ });
59
+ }