@telorun/http-server 0.20.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 +3 -0
- package/dist/http-reference-controller.d.ts +7 -0
- package/dist/http-reference-controller.js +54 -0
- package/dist/http-server-controller.d.ts +10 -7
- package/dist/http-server-controller.js +103 -45
- package/dist/http-static-controller.js +1 -11
- package/dist/mount-prefix.d.ts +11 -0
- package/dist/mount-prefix.js +16 -0
- package/dist/openapi-spec-servers.d.ts +16 -0
- package/dist/openapi-spec-servers.js +56 -0
- package/package.json +9 -4
- package/src/http-reference-controller.ts +69 -0
- package/src/http-server-controller.ts +128 -50
- package/src/http-static-controller.ts +1 -11
- package/src/mount-prefix.ts +15 -0
- package/src/openapi-spec-servers.ts +59 -0
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?:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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();
|
|
@@ -164,8 +206,35 @@ class HttpServer {
|
|
|
164
206
|
}, { eventName: "http.server.request" });
|
|
165
207
|
});
|
|
166
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Accept a multipart body out of the box.
|
|
211
|
+
*
|
|
212
|
+
* Fastify ships parsers for JSON and urlencoded and nothing else, so a route
|
|
213
|
+
* receiving a file upload answered 415 before any handler ran — a failure that
|
|
214
|
+
* names a media type the author DID send and points at no fix. Every server
|
|
215
|
+
* taking an upload had to discover `contentTypeParsers` first.
|
|
216
|
+
*
|
|
217
|
+
* Registered as RAW BYTES rather than a string, because that is what a
|
|
218
|
+
* multipart body is: decoding it as text corrupts every binary part, and the
|
|
219
|
+
* parts are the point. The handler receives the undrained request stream, which
|
|
220
|
+
* `Multipart.Decoder` consumes.
|
|
221
|
+
*
|
|
222
|
+
* Registered UNCONDITIONALLY, as a regex. Fastify keys a duplicate on the exact
|
|
223
|
+
* string (or the regex's `toString()`) and consults its string parsers before
|
|
224
|
+
* its regex ones, so a declared `multipart/form-data` neither collides with this
|
|
225
|
+
* nor is shadowed by it — it simply wins for its own type. Skipping the default
|
|
226
|
+
* whenever any multipart parser was declared would instead disable it for the
|
|
227
|
+
* SIBLING subtypes the author did not customize, so declaring a parser for
|
|
228
|
+
* `form-data` would silently restore the 415 for `related` and `mixed`.
|
|
229
|
+
*/
|
|
230
|
+
installDefaultMultipartParser() {
|
|
231
|
+
this.app.addContentTypeParser(/^multipart\//, (_req, payload, done) => {
|
|
232
|
+
done(null, payload);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
167
235
|
async setupPlugins() {
|
|
168
236
|
this.installRequestLogging();
|
|
237
|
+
this.installDefaultMultipartParser();
|
|
169
238
|
for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
|
|
170
239
|
if (stream) {
|
|
171
240
|
// Raw passthrough: omit `parseAs` so Fastify hands the handler the
|
|
@@ -240,6 +309,12 @@ class HttpServer {
|
|
|
240
309
|
// override; otherwise the URL is relative (`/`) so the doc is correct behind
|
|
241
310
|
// any proxy/ingress/origin — the client resolves it against wherever the
|
|
242
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.
|
|
243
318
|
const servers = [{ url: this.resource.baseUrl ?? "/" }];
|
|
244
319
|
await this.app.register(swagger, {
|
|
245
320
|
openapi: {
|
|
@@ -248,61 +323,43 @@ class HttpServer {
|
|
|
248
323
|
servers,
|
|
249
324
|
},
|
|
250
325
|
});
|
|
251
|
-
const referencePrefix = "/reference";
|
|
252
326
|
// `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
|
|
253
327
|
// default to absolute URLs built per-request from the now-trusted
|
|
254
|
-
// X-Forwarded-* headers
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
|
|
258
|
-
// rewrite would no-op and the relative default would still apply. The
|
|
259
|
-
// `openapi-server-url` integration test guards this path.
|
|
260
|
-
const specPath = `${referencePrefix}/openapi.json`;
|
|
261
|
-
this.app.addHook("onSend", async (request, reply, payload) => {
|
|
262
|
-
if (request.url.split("?")[0] !== specPath)
|
|
263
|
-
return payload;
|
|
264
|
-
const text = typeof payload === "string"
|
|
265
|
-
? payload
|
|
266
|
-
: Buffer.isBuffer(payload)
|
|
267
|
-
? payload.toString("utf8")
|
|
268
|
-
: null;
|
|
269
|
-
if (text === null)
|
|
270
|
-
return payload;
|
|
271
|
-
try {
|
|
272
|
-
const doc = JSON.parse(text);
|
|
273
|
-
if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
|
|
274
|
-
doc.servers = [{ url: `${request.protocol}://${request.host}` }];
|
|
275
|
-
const out = JSON.stringify(doc);
|
|
276
|
-
reply.header("content-length", Buffer.byteLength(out));
|
|
277
|
-
return out;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
// Not a JSON document we can rewrite — leave the response untouched.
|
|
282
|
-
}
|
|
283
|
-
return payload;
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
await this.app.register(apiReference, {
|
|
287
|
-
routePrefix: referencePrefix,
|
|
288
|
-
});
|
|
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);
|
|
289
331
|
}
|
|
290
332
|
}
|
|
291
333
|
setupRoutes() {
|
|
292
334
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
293
|
-
const mounts = this.
|
|
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
|
+
}
|
|
294
345
|
// const resolveSchema = createSchemaResolver(this.ctx);
|
|
295
|
-
for (
|
|
346
|
+
for (let index = 0; index < mounts.length; index++) {
|
|
347
|
+
if (this.attachedMounts.has(index))
|
|
348
|
+
continue;
|
|
349
|
+
const mount = mounts[index];
|
|
296
350
|
const prefix = mount.path || "";
|
|
297
351
|
// `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
|
|
298
352
|
// (x-telo-ref `Telo.Mount`) — a same-module or imported-library mount, uniformly.
|
|
299
353
|
const api = mount.mount;
|
|
300
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.
|
|
301
357
|
throw new Error(`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`);
|
|
302
358
|
}
|
|
303
359
|
api.register(this.app, prefix);
|
|
360
|
+
this.attachedMounts.add(index);
|
|
304
361
|
}
|
|
305
|
-
if (this.resolvedNotFoundHandler) {
|
|
362
|
+
if (this.resolvedNotFoundHandler && !this.notFoundHandlerInstalled) {
|
|
306
363
|
const handler = this.resolvedNotFoundHandler;
|
|
307
364
|
this.app.setNotFoundHandler(async (request, reply) => {
|
|
308
365
|
const normalizedHeaders = {};
|
|
@@ -357,6 +414,7 @@ class HttpServer {
|
|
|
357
414
|
}
|
|
358
415
|
return reply.send(result?.body ?? result);
|
|
359
416
|
});
|
|
417
|
+
this.notFoundHandlerInstalled = true;
|
|
360
418
|
}
|
|
361
419
|
}
|
|
362
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
|
-
|
|
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.
|
|
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.
|
|
52
|
-
"@telorun/http-dispatch": "0.
|
|
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.
|
|
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, {
|
|
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?:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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();
|
|
@@ -261,8 +314,36 @@ class HttpServer implements ResourceInstance {
|
|
|
261
314
|
});
|
|
262
315
|
}
|
|
263
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Accept a multipart body out of the box.
|
|
319
|
+
*
|
|
320
|
+
* Fastify ships parsers for JSON and urlencoded and nothing else, so a route
|
|
321
|
+
* receiving a file upload answered 415 before any handler ran — a failure that
|
|
322
|
+
* names a media type the author DID send and points at no fix. Every server
|
|
323
|
+
* taking an upload had to discover `contentTypeParsers` first.
|
|
324
|
+
*
|
|
325
|
+
* Registered as RAW BYTES rather than a string, because that is what a
|
|
326
|
+
* multipart body is: decoding it as text corrupts every binary part, and the
|
|
327
|
+
* parts are the point. The handler receives the undrained request stream, which
|
|
328
|
+
* `Multipart.Decoder` consumes.
|
|
329
|
+
*
|
|
330
|
+
* Registered UNCONDITIONALLY, as a regex. Fastify keys a duplicate on the exact
|
|
331
|
+
* string (or the regex's `toString()`) and consults its string parsers before
|
|
332
|
+
* its regex ones, so a declared `multipart/form-data` neither collides with this
|
|
333
|
+
* nor is shadowed by it — it simply wins for its own type. Skipping the default
|
|
334
|
+
* whenever any multipart parser was declared would instead disable it for the
|
|
335
|
+
* SIBLING subtypes the author did not customize, so declaring a parser for
|
|
336
|
+
* `form-data` would silently restore the 415 for `related` and `mixed`.
|
|
337
|
+
*/
|
|
338
|
+
private installDefaultMultipartParser(): void {
|
|
339
|
+
this.app.addContentTypeParser(/^multipart\//, (_req, payload, done) => {
|
|
340
|
+
done(null, payload);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
264
344
|
private async setupPlugins() {
|
|
265
345
|
this.installRequestLogging();
|
|
346
|
+
this.installDefaultMultipartParser();
|
|
266
347
|
for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
|
|
267
348
|
if (stream) {
|
|
268
349
|
// Raw passthrough: omit `parseAs` so Fastify hands the handler the
|
|
@@ -339,6 +420,12 @@ class HttpServer implements ResourceInstance {
|
|
|
339
420
|
// override; otherwise the URL is relative (`/`) so the doc is correct behind
|
|
340
421
|
// any proxy/ingress/origin — the client resolves it against wherever the
|
|
341
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.
|
|
342
429
|
const servers = [{ url: this.resource.baseUrl ?? "/" }];
|
|
343
430
|
await this.app.register(swagger, {
|
|
344
431
|
openapi: {
|
|
@@ -347,63 +434,53 @@ class HttpServer implements ResourceInstance {
|
|
|
347
434
|
servers,
|
|
348
435
|
},
|
|
349
436
|
});
|
|
350
|
-
const referencePrefix = "/reference";
|
|
351
437
|
// `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
|
|
352
438
|
// default to absolute URLs built per-request from the now-trusted
|
|
353
|
-
// X-Forwarded-* headers
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const specPath = `${referencePrefix}/openapi.json`;
|
|
360
|
-
this.app.addHook("onSend", async (request, reply, payload) => {
|
|
361
|
-
if (request.url.split("?")[0] !== specPath) return payload;
|
|
362
|
-
const text =
|
|
363
|
-
typeof payload === "string"
|
|
364
|
-
? payload
|
|
365
|
-
: Buffer.isBuffer(payload)
|
|
366
|
-
? payload.toString("utf8")
|
|
367
|
-
: null;
|
|
368
|
-
if (text === null) return payload;
|
|
369
|
-
try {
|
|
370
|
-
const doc = JSON.parse(text);
|
|
371
|
-
if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
|
|
372
|
-
doc.servers = [{ url: `${request.protocol}://${request.host}` }];
|
|
373
|
-
const out = JSON.stringify(doc);
|
|
374
|
-
reply.header("content-length", Buffer.byteLength(out));
|
|
375
|
-
return out;
|
|
376
|
-
}
|
|
377
|
-
} catch {
|
|
378
|
-
// Not a JSON document we can rewrite — leave the response untouched.
|
|
379
|
-
}
|
|
380
|
-
return payload;
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
await this.app.register(apiReference, {
|
|
384
|
-
routePrefix: referencePrefix,
|
|
385
|
-
});
|
|
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
|
+
);
|
|
386
445
|
}
|
|
387
446
|
}
|
|
388
447
|
|
|
389
448
|
private setupRoutes(): void {
|
|
390
449
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
391
|
-
const mounts = this.
|
|
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
|
+
}
|
|
392
464
|
// const resolveSchema = createSchemaResolver(this.ctx);
|
|
393
|
-
for (
|
|
465
|
+
for (let index = 0; index < mounts.length; index++) {
|
|
466
|
+
if (this.attachedMounts.has(index)) continue;
|
|
467
|
+
const mount = mounts[index];
|
|
394
468
|
const prefix = mount.path || "";
|
|
395
469
|
// `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
|
|
396
470
|
// (x-telo-ref `Telo.Mount`) — a same-module or imported-library mount, uniformly.
|
|
397
471
|
const api = mount.mount;
|
|
398
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.
|
|
399
475
|
throw new Error(
|
|
400
476
|
`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`,
|
|
401
477
|
);
|
|
402
478
|
}
|
|
403
479
|
api.register(this.app, prefix);
|
|
480
|
+
this.attachedMounts.add(index);
|
|
404
481
|
}
|
|
405
482
|
|
|
406
|
-
if (this.resolvedNotFoundHandler) {
|
|
483
|
+
if (this.resolvedNotFoundHandler && !this.notFoundHandlerInstalled) {
|
|
407
484
|
const handler = this.resolvedNotFoundHandler;
|
|
408
485
|
this.app.setNotFoundHandler(async (request, reply) => {
|
|
409
486
|
const normalizedHeaders: Record<string, any> = {};
|
|
@@ -483,6 +560,7 @@ class HttpServer implements ResourceInstance {
|
|
|
483
560
|
}
|
|
484
561
|
return reply.send(result?.body ?? result);
|
|
485
562
|
});
|
|
563
|
+
this.notFoundHandlerInstalled = true;
|
|
486
564
|
}
|
|
487
565
|
}
|
|
488
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
|
+
}
|