@telorun/http-server 0.28.0 → 0.29.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/dist/http-server-controller.js +58 -64
- package/package.json +2 -2
- package/src/http-server-controller.ts +67 -72
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
|
-
import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
|
|
3
2
|
import swagger from "@fastify/swagger";
|
|
4
3
|
import { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
|
|
5
4
|
import { isInvokeError, SEVERITY, severityForLevel, } from "@telorun/sdk";
|
|
6
5
|
import addFormats from "ajv-formats";
|
|
7
6
|
import Fastify, { LogController, } from "fastify";
|
|
8
7
|
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
8
|
+
import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
|
|
9
9
|
import { publishSpecServerUrlPolicy } from "./openapi-spec-servers.js";
|
|
10
10
|
class HttpServer {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
listening = false;
|
|
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;
|
|
11
|
+
/** Fastify's `close()` is the inverse of everything registered on the
|
|
12
|
+
* instance, so two effects name it; running it twice is a no-op. */
|
|
13
|
+
closed = false;
|
|
21
14
|
app;
|
|
22
15
|
host;
|
|
23
16
|
port;
|
|
@@ -85,20 +78,30 @@ class HttpServer {
|
|
|
85
78
|
* Registering plugins and routes: nothing observable, and nothing repeatable —
|
|
86
79
|
* a route registers exactly once.
|
|
87
80
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
81
|
+
* Written as if it runs once, because it does. A failed `init()` recovers
|
|
82
|
+
* through this effect and the instance is discarded, so the next pass builds a
|
|
83
|
+
* fresh Fastify rather than re-entering one that already has half its routes —
|
|
84
|
+
* which is what the mount-by-mount resumability bookkeeping used to buy, and
|
|
85
|
+
* what Fastify's duplicate-route error used to punish.
|
|
86
|
+
*
|
|
87
|
+
* ONE effect, not one per plugin and mount: Fastify has no unregister, so
|
|
88
|
+
* `close()` is the only real inverse of anything attached to the instance.
|
|
89
|
+
* Registering a per-mount inverse that undid nothing would be decoration.
|
|
95
90
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
init() {
|
|
92
|
+
return this.ctx.effect("fastify plugins and routes", async () => {
|
|
98
93
|
await this.setupPlugins();
|
|
99
|
-
this.
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
this.setupRoutes();
|
|
95
|
+
return { result: undefined, inverse: () => this.closeApp() };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Idempotent: two effects name `close()` as their inverse (the routes, and
|
|
99
|
+
* the open socket), and both may unwind in one teardown. */
|
|
100
|
+
async closeApp() {
|
|
101
|
+
if (this.closed)
|
|
102
|
+
return;
|
|
103
|
+
this.closed = true;
|
|
104
|
+
await this.app.close();
|
|
102
105
|
}
|
|
103
106
|
/**
|
|
104
107
|
* The access log, emitted by this kind rather than by Fastify.
|
|
@@ -333,19 +336,14 @@ class HttpServer {
|
|
|
333
336
|
setupRoutes() {
|
|
334
337
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
335
338
|
const mounts = this.activeMounts();
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
}
|
|
339
|
+
for (const skipped of (this.resource.mounts ?? []).filter((mount) => mount.when === false)) {
|
|
340
|
+
// Said out loud: a route set that is absent because a condition excluded
|
|
341
|
+
// it is indistinguishable at request time from one that failed to
|
|
342
|
+
// register.
|
|
343
|
+
this.ctx.log.debug("Mount excluded by its `when` condition", { "http.route": skipped.path || "/" }, { eventName: "http.server.mount.excluded" });
|
|
344
344
|
}
|
|
345
345
|
// const resolveSchema = createSchemaResolver(this.ctx);
|
|
346
346
|
for (let index = 0; index < mounts.length; index++) {
|
|
347
|
-
if (this.attachedMounts.has(index))
|
|
348
|
-
continue;
|
|
349
347
|
const mount = mounts[index];
|
|
350
348
|
const prefix = mount.path || "";
|
|
351
349
|
// `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
|
|
@@ -357,9 +355,8 @@ class HttpServer {
|
|
|
357
355
|
throw new Error(`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`);
|
|
358
356
|
}
|
|
359
357
|
api.register(this.app, prefix);
|
|
360
|
-
this.attachedMounts.add(index);
|
|
361
358
|
}
|
|
362
|
-
if (this.resolvedNotFoundHandler
|
|
359
|
+
if (this.resolvedNotFoundHandler) {
|
|
363
360
|
const handler = this.resolvedNotFoundHandler;
|
|
364
361
|
this.app.setNotFoundHandler(async (request, reply) => {
|
|
365
362
|
const normalizedHeaders = {};
|
|
@@ -414,12 +411,23 @@ class HttpServer {
|
|
|
414
411
|
}
|
|
415
412
|
return reply.send(result?.body ?? result);
|
|
416
413
|
});
|
|
417
|
-
this.notFoundHandlerInstalled = true;
|
|
418
414
|
}
|
|
419
415
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Two effects, and no error handling of its own.
|
|
418
|
+
*
|
|
419
|
+
* The hold is one, the open socket is the other. A `listen()` that throws — a bound port — unwinds the run frame,
|
|
420
|
+
* so the hold is released and the routes are torn down without this method
|
|
421
|
+
* knowing anything about recovery; the same unwind is what stops the server at
|
|
422
|
+
* teardown, which is why there is no `teardown()` left to keep in step.
|
|
423
|
+
*/
|
|
424
|
+
run() {
|
|
425
|
+
return this.ctx
|
|
426
|
+
.effect("kernel hold", async () => ({
|
|
427
|
+
result: undefined,
|
|
428
|
+
inverse: this.ctx.acquireHold(`http server ${this.resource.metadata.name}`),
|
|
429
|
+
}))
|
|
430
|
+
.effect("listening socket", async () => {
|
|
423
431
|
await this.app.listen({
|
|
424
432
|
host: this.host,
|
|
425
433
|
port: this.port,
|
|
@@ -430,7 +438,6 @@ class HttpServer {
|
|
|
430
438
|
// Fastify's wording, which is the thing this kind's own contract forbids.
|
|
431
439
|
listenTextResolver: () => LISTEN_SUPERSEDED,
|
|
432
440
|
});
|
|
433
|
-
this.listening = true;
|
|
434
441
|
this.ctx.log.info("Listening", {
|
|
435
442
|
"server.address": this.host,
|
|
436
443
|
"server.port": this.port,
|
|
@@ -448,30 +455,17 @@ class HttpServer {
|
|
|
448
455
|
mounts: this.resource.mounts,
|
|
449
456
|
openapi: this.resource.openapi,
|
|
450
457
|
});
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (this.releaseHold) {
|
|
463
|
-
this.releaseHold();
|
|
464
|
-
this.releaseHold = null;
|
|
465
|
-
}
|
|
466
|
-
await this.app.close();
|
|
467
|
-
// Only if a socket actually opened. A server that initialized but was never
|
|
468
|
-
// listed in `targets:`, or whose `listen()` threw, would otherwise report a
|
|
469
|
-
// close for something that never started — and a consumer pairing the two
|
|
470
|
-
// events for uptime or leak detection sees an unmatched close.
|
|
471
|
-
if (this.listening) {
|
|
472
|
-
this.listening = false;
|
|
473
|
-
this.ctx.log.info("Stopped listening", { "server.address": this.host, "server.port": this.port }, { eventName: "http.server.stopped" });
|
|
474
|
-
}
|
|
458
|
+
return {
|
|
459
|
+
result: undefined,
|
|
460
|
+
// Paired with the record above, which is what the `listening` flag used
|
|
461
|
+
// to approximate: a server that never listened has no such effect, so it
|
|
462
|
+
// cannot report a close a consumer would fail to match with a start.
|
|
463
|
+
inverse: async () => {
|
|
464
|
+
await this.closeApp();
|
|
465
|
+
this.ctx.log.info("Stopped listening", { "server.address": this.host, "server.port": this.port }, { eventName: "http.server.stopped" });
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
});
|
|
475
469
|
}
|
|
476
470
|
}
|
|
477
471
|
export async function create(resource, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@types/node": "^20.0.0",
|
|
61
61
|
"typescript": "^5.0.0",
|
|
62
62
|
"vitest": "^2.1.8",
|
|
63
|
-
"@telorun/sdk": "0.
|
|
63
|
+
"@telorun/sdk": "0.82.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@telorun/sdk": "*"
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
|
-
import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
|
|
3
2
|
import swagger from "@fastify/swagger";
|
|
4
3
|
import {
|
|
5
4
|
CatchEntry,
|
|
@@ -26,6 +25,7 @@ import Fastify, {
|
|
|
26
25
|
type FastifyServerOptions,
|
|
27
26
|
} from "fastify";
|
|
28
27
|
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
28
|
+
import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
|
|
29
29
|
import { publishSpecServerUrlPolicy } from "./openapi-spec-servers.js";
|
|
30
30
|
|
|
31
31
|
/** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
|
|
@@ -92,16 +92,9 @@ type ResolvedHandler = {
|
|
|
92
92
|
};
|
|
93
93
|
|
|
94
94
|
class HttpServer implements ResourceInstance {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
private listening = false;
|
|
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;
|
|
95
|
+
/** Fastify's `close()` is the inverse of everything registered on the
|
|
96
|
+
* instance, so two effects name it; running it twice is a no-op. */
|
|
97
|
+
private closed = false;
|
|
105
98
|
private readonly app: FastifyInstance;
|
|
106
99
|
private readonly host: string;
|
|
107
100
|
private readonly port: number;
|
|
@@ -177,20 +170,30 @@ class HttpServer implements ResourceInstance {
|
|
|
177
170
|
* Registering plugins and routes: nothing observable, and nothing repeatable —
|
|
178
171
|
* a route registers exactly once.
|
|
179
172
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
173
|
+
* Written as if it runs once, because it does. A failed `init()` recovers
|
|
174
|
+
* through this effect and the instance is discarded, so the next pass builds a
|
|
175
|
+
* fresh Fastify rather than re-entering one that already has half its routes —
|
|
176
|
+
* which is what the mount-by-mount resumability bookkeeping used to buy, and
|
|
177
|
+
* what Fastify's duplicate-route error used to punish.
|
|
178
|
+
*
|
|
179
|
+
* ONE effect, not one per plugin and mount: Fastify has no unregister, so
|
|
180
|
+
* `close()` is the only real inverse of anything attached to the instance.
|
|
181
|
+
* Registering a per-mount inverse that undid nothing would be decoration.
|
|
187
182
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
183
|
+
init() {
|
|
184
|
+
return this.ctx.effect("fastify plugins and routes", async () => {
|
|
190
185
|
await this.setupPlugins();
|
|
191
|
-
this.
|
|
192
|
-
|
|
193
|
-
|
|
186
|
+
this.setupRoutes();
|
|
187
|
+
return { result: undefined, inverse: () => this.closeApp() };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Idempotent: two effects name `close()` as their inverse (the routes, and
|
|
192
|
+
* the open socket), and both may unwind in one teardown. */
|
|
193
|
+
private async closeApp(): Promise<void> {
|
|
194
|
+
if (this.closed) return;
|
|
195
|
+
this.closed = true;
|
|
196
|
+
await this.app.close();
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
/**
|
|
@@ -448,22 +451,18 @@ class HttpServer implements ResourceInstance {
|
|
|
448
451
|
private setupRoutes(): void {
|
|
449
452
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
450
453
|
const mounts = this.activeMounts();
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
{ eventName: "http.server.mount.excluded" },
|
|
461
|
-
);
|
|
462
|
-
}
|
|
454
|
+
for (const skipped of (this.resource.mounts ?? []).filter((mount) => mount.when === false)) {
|
|
455
|
+
// Said out loud: a route set that is absent because a condition excluded
|
|
456
|
+
// it is indistinguishable at request time from one that failed to
|
|
457
|
+
// register.
|
|
458
|
+
this.ctx.log.debug(
|
|
459
|
+
"Mount excluded by its `when` condition",
|
|
460
|
+
{ "http.route": skipped.path || "/" },
|
|
461
|
+
{ eventName: "http.server.mount.excluded" },
|
|
462
|
+
);
|
|
463
463
|
}
|
|
464
464
|
// const resolveSchema = createSchemaResolver(this.ctx);
|
|
465
465
|
for (let index = 0; index < mounts.length; index++) {
|
|
466
|
-
if (this.attachedMounts.has(index)) continue;
|
|
467
466
|
const mount = mounts[index];
|
|
468
467
|
const prefix = mount.path || "";
|
|
469
468
|
// `mount.mount` is the live Telo.Mount instance injected by the kernel at Phase 5
|
|
@@ -477,10 +476,9 @@ class HttpServer implements ResourceInstance {
|
|
|
477
476
|
);
|
|
478
477
|
}
|
|
479
478
|
api.register(this.app, prefix);
|
|
480
|
-
this.attachedMounts.add(index);
|
|
481
479
|
}
|
|
482
480
|
|
|
483
|
-
if (this.resolvedNotFoundHandler
|
|
481
|
+
if (this.resolvedNotFoundHandler) {
|
|
484
482
|
const handler = this.resolvedNotFoundHandler;
|
|
485
483
|
this.app.setNotFoundHandler(async (request, reply) => {
|
|
486
484
|
const normalizedHeaders: Record<string, any> = {};
|
|
@@ -560,14 +558,25 @@ class HttpServer implements ResourceInstance {
|
|
|
560
558
|
}
|
|
561
559
|
return reply.send(result?.body ?? result);
|
|
562
560
|
});
|
|
563
|
-
this.notFoundHandlerInstalled = true;
|
|
564
561
|
}
|
|
565
562
|
}
|
|
566
563
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
564
|
+
/**
|
|
565
|
+
* Two effects, and no error handling of its own.
|
|
566
|
+
*
|
|
567
|
+
* The hold is one, the open socket is the other. A `listen()` that throws — a bound port — unwinds the run frame,
|
|
568
|
+
* so the hold is released and the routes are torn down without this method
|
|
569
|
+
* knowing anything about recovery; the same unwind is what stops the server at
|
|
570
|
+
* teardown, which is why there is no `teardown()` left to keep in step.
|
|
571
|
+
*/
|
|
572
|
+
run() {
|
|
573
|
+
return this.ctx
|
|
574
|
+
.effect("kernel hold", async () => ({
|
|
575
|
+
result: undefined,
|
|
576
|
+
inverse: this.ctx.acquireHold(`http server ${this.resource.metadata.name}`),
|
|
577
|
+
}))
|
|
578
|
+
.effect("listening socket", async () => {
|
|
579
|
+
await this.app.listen({
|
|
571
580
|
host: this.host,
|
|
572
581
|
port: this.port,
|
|
573
582
|
// Fastify announces "Server listening at http://…" through the injected
|
|
@@ -577,7 +586,6 @@ class HttpServer implements ResourceInstance {
|
|
|
577
586
|
// Fastify's wording, which is the thing this kind's own contract forbids.
|
|
578
587
|
listenTextResolver: () => LISTEN_SUPERSEDED,
|
|
579
588
|
});
|
|
580
|
-
this.listening = true;
|
|
581
589
|
this.ctx.log.info(
|
|
582
590
|
"Listening",
|
|
583
591
|
{
|
|
@@ -599,34 +607,21 @@ class HttpServer implements ResourceInstance {
|
|
|
599
607
|
mounts: this.resource.mounts,
|
|
600
608
|
openapi: this.resource.openapi,
|
|
601
609
|
});
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
await this.app.close();
|
|
618
|
-
// Only if a socket actually opened. A server that initialized but was never
|
|
619
|
-
// listed in `targets:`, or whose `listen()` threw, would otherwise report a
|
|
620
|
-
// close for something that never started — and a consumer pairing the two
|
|
621
|
-
// events for uptime or leak detection sees an unmatched close.
|
|
622
|
-
if (this.listening) {
|
|
623
|
-
this.listening = false;
|
|
624
|
-
this.ctx.log.info(
|
|
625
|
-
"Stopped listening",
|
|
626
|
-
{ "server.address": this.host, "server.port": this.port },
|
|
627
|
-
{ eventName: "http.server.stopped" },
|
|
628
|
-
);
|
|
629
|
-
}
|
|
610
|
+
return {
|
|
611
|
+
result: undefined,
|
|
612
|
+
// Paired with the record above, which is what the `listening` flag used
|
|
613
|
+
// to approximate: a server that never listened has no such effect, so it
|
|
614
|
+
// cannot report a close a consumer would fail to match with a start.
|
|
615
|
+
inverse: async () => {
|
|
616
|
+
await this.closeApp();
|
|
617
|
+
this.ctx.log.info(
|
|
618
|
+
"Stopped listening",
|
|
619
|
+
{ "server.address": this.host, "server.port": this.port },
|
|
620
|
+
{ eventName: "http.server.stopped" },
|
|
621
|
+
);
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
});
|
|
630
625
|
}
|
|
631
626
|
}
|
|
632
627
|
|