@telorun/http-server 0.27.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.
@@ -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
- releaseHold = null;
12
- /** Whether a socket actually opened, so `http.server.stopped` is only emitted
13
- * for a server that emitted `http.server.started`. */
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
- * 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.
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
- async init() {
97
- if (!this.pluginsInitialized) {
91
+ init() {
92
+ return this.ctx.effect("fastify plugins and routes", async () => {
98
93
  await this.setupPlugins();
99
- this.pluginsInitialized = true;
100
- }
101
- this.setupRoutes();
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
- 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
- }
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 && !this.notFoundHandlerInstalled) {
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
- async run() {
421
- this.releaseHold = this.ctx.acquireHold();
422
- try {
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
- catch (error) {
453
- await this.app.close();
454
- if (this.releaseHold) {
455
- this.releaseHold();
456
- this.releaseHold = null;
457
- }
458
- throw error;
459
- }
460
- }
461
- async teardown() {
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.27.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",
@@ -54,13 +54,13 @@
54
54
  "ajv": "^8.17.1",
55
55
  "ajv-formats": "^3.0.1",
56
56
  "fastify": "^5.12.1",
57
- "@telorun/http-dispatch": "0.11.2"
57
+ "@telorun/http-dispatch": "0.12.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^20.0.0",
61
61
  "typescript": "^5.0.0",
62
62
  "vitest": "^2.1.8",
63
- "@telorun/sdk": "0.79.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
- private releaseHold: (() => void) | null = null;
96
- /** Whether a socket actually opened, so `http.server.stopped` is only emitted
97
- * for a server that emitted `http.server.started`. */
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
- * 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.
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
- async init() {
189
- if (!this.pluginsInitialized) {
183
+ init() {
184
+ return this.ctx.effect("fastify plugins and routes", async () => {
190
185
  await this.setupPlugins();
191
- this.pluginsInitialized = true;
192
- }
193
- this.setupRoutes();
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
- 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
- }
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 && !this.notFoundHandlerInstalled) {
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
- async run(): Promise<void> {
568
- this.releaseHold = this.ctx.acquireHold();
569
- try {
570
- await this.app.listen({
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
- } catch (error) {
603
- await this.app.close();
604
- if (this.releaseHold) {
605
- this.releaseHold();
606
- this.releaseHold = null;
607
- }
608
- throw error;
609
- }
610
- }
611
-
612
- async teardown(): Promise<void> {
613
- if (this.releaseHold) {
614
- this.releaseHold();
615
- this.releaseHold = null;
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