@telorun/http-server 0.11.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 95f168e: Cache, rate-limit, and background-task primitives, plus a comprehensive URL-shortener example.
8
+
9
+ - New `cache` family: the backend-pluggable `Cache.Store` abstract with `Cache.Lookup` / `Cache.Entry` (freshness-aware: `ttl` fresh window + optional `staleTtl` grace window, `state` of `miss`/`fresh`/`stale`) and the `Cache.View` read-through decorator (single-flight background revalidation). Backends ship as `cache-memory` (`CacheMemory.Store`) and `cache-redis` (`CacheRedis.Store`, with observable degrade-to-`fallback`).
10
+ - New `rate-limit` module: `RateLimit.Guard`, a non-throwing sliding-window limiter whose counters live in any `Cache.Store`.
11
+ - `run` gains `Run.Detach` (generic, zero-config fire-and-forget).
12
+ - SDK + kernel: `ResourceContext.runDetached(fn)` runs a function detached from the caller's cancellation/trace scope; the kernel tracks each detached task against its owning resource and drains it (bounded) when that resource tears down, routing failures to the EventBus. Used by `Run.Detach` and `Cache.View`'s background revalidation.
13
+ - `http-server`: `Http.Server.trustProxy` and a derived `request.ip` in the handler CEL context (canonical client address for rate-limit keys).
14
+
15
+ ### Patch Changes
16
+
17
+ - 95f168e: Fix OpenAPI documentation conflating routes from different mounts.
18
+
19
+ Each `Http.Api` route is now registered at its full `<mountPrefix><path>` instead of inside a Fastify `{ prefix }`-encapsulated context, and the generated OpenAPI `servers` block is a single origin (`baseUrl`, the forwarded host, or relative `/`) rather than one entry per mount prefix. Previously `@fastify/swagger` stripped each mount's prefix from the documented path while the prefixes were hoisted into `servers`, so different APIs mounted at different prefixes collapsed together — e.g. an `Http.Api` mounted at `/admin` was documented at `/links` instead of `/admin/links`. Actual request routing was unaffected; this corrects only the generated document.
20
+
21
+ - @telorun/http-dispatch@0.4.1
22
+
23
+ ## 0.12.0
24
+
25
+ ### Minor Changes
26
+
27
+ - a8c99ab: Generic dispatch tracing: trace every capability dispatch (invoke and run) through one instrumented chokepoint and carry trace data in a structured event payload instead of the event name.
28
+
29
+ - Dispatch events drop the kind from the name (`<name>.Invoked` / `.Run`, plus error/cancel variants). The payload now carries `{ spanId, parentSpanId, capability, phase, outcome, ref: { kind, name }, … }`; consumers read the payload and never parse the dotted name. Lifecycle events (`Kind.name.Created` / `.Initialized` / `.Teardown`) are unchanged.
30
+ - `run()` is now span-instrumented like `invoke()`: it mints and propagates a trace id, so Runnables (e.g. a `Run.Sequence` boot target) appear in the trace and their nested invokes re-parent correctly instead of detaching as false roots. Long-lived Services emit a `<name>.Running` start span. Run failures emit `<name>.RunFailed` (rethrown, never swallowed).
31
+ - Invoke/run emit a `<name>.Invoking` / `.Running` start span when tracing is on.
32
+ - SDK: new `REF_IDENTITY` / `stampRefIdentity` / `getRefIdentity`. The kernel stamps a resolved `!ref`'s kind+name onto the injected instance so `executeInvokeStep` routes pre-injected live instances through the traced chokepoint instead of calling `.invoke()` directly and escaping instrumentation.
33
+ - The boot `targets` run is wrapped in an application span (`<appName>.Run`, `ref.kind: "Telo.Application"`), so the application is the trace root with its targets nested beneath. Pre-resolved `!ref` boot targets now dispatch through a new `EvaluationContext.runResolved` (the `run()` analog of `invokeResolved`) instead of calling `instance.run()` directly, so they emit their own run spans nested under the app.
34
+ - A `Telo.Service`'s long-lived `run()` no longer establishes the cancellation/trace ALS scope (its token is delivered via the explicit `run(invokeCtx)` argument instead). This stops the boot scope leaking onto async resources the service creates — e.g. an HTTP server's socket — so inbound work (each request) starts as its own root trace with no inherited boot cancellation token, instead of nesting under the bootstrap trace. Runnables keep the ALS scope so their steps still nest and inherit cancellation.
35
+ - `EventBus.emit` short-circuits in O(1) when there are no subscribers, keeping the always-through-the-chokepoint dispatch effectively free when nobody is listening.
36
+ - OpenTelemetry-ready trace model: every span carries a `traceId` (OTel-compatible 16-byte hex), minted at the root and inherited by descendants, so an exporter groups a trace without walking the parent chain. New generic `ctx.openSpan(base, { ref, label, attributes, inbound? })` primitive opens an inbound-boundary span (capability `"request"`) that roots its own trace; `inbound` allows continuing an upstream distributed trace later. The `TracePayload` gains `traceId`, `label`, and `attributes`.
37
+ - `http-server`: each inbound request opens a request span attributed to the `Http.Api` and labelled with the route (`"POST /feedback"`, attributes `{ method, path }`); the handler invoke and its subtree nest under it, as a trace separate from the bootstrap.
38
+ - Trace context capture: a trace's root span carries `payload.context` — a redacted snapshot of the CEL root scope available to the trace (`variables`, `resources` snapshots, `ports`, and `secrets` with values masked to `"[secret]"`; host `env` omitted). Lets a debug consumer see what data an execution could reference beyond its own inputs/outputs. The UI renders it as an "Available context" section on the root node.
39
+
40
+ ### Patch Changes
41
+
42
+ - @telorun/http-dispatch@0.4.1
43
+
3
44
  ## 0.11.1
4
45
 
5
46
  ### Patch Changes
package/README.md CHANGED
@@ -24,8 +24,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
24
24
  kind: Telo.Application
25
25
  metadata: { name: hello-http, version: 1.0.0 }
26
26
  imports:
27
- Http: std/http-server@0.12.0
28
- JS: std/javascript@0.5.0
27
+ Http: std/http-server@<version>
28
+ JS: std/javascript@<version>
29
29
  targets: [ !ref Server ]
30
30
  ---
31
31
  kind: Http.Server
@@ -210,15 +210,18 @@ request:
210
210
  ### 5. External URL & OpenAPI `servers`
211
211
 
212
212
  A server is usually reached through a reverse proxy / ingress, so its own bound
213
- `host:port` is not the URL clients use. The generated OpenAPI `servers` block MUST
214
- follow this resolution, identically across runtimes (Node/Rust/Go) the inputs
215
- are standard HTTP, never a framework's proxy-config object:
213
+ `host:port` is not the URL clients use. The generated OpenAPI `servers` block is a
214
+ **single origin** each operation is documented at its full `<mountPrefix><path>`,
215
+ so different APIs mounted at different prefixes stay distinct (an `Http.Api` mounted
216
+ at `/admin` is documented at `/admin/...`, never flattened to `/...`). The origin
217
+ resolves identically across runtimes (Node/Rust/Go) — the inputs are standard HTTP,
218
+ never a framework's proxy-config object:
216
219
 
217
220
  | Manifest | `servers[].url` |
218
221
  | --- | --- |
219
- | `baseUrl: <url>` | `<url><mountPrefix>` — explicit, fixed; wins over everything |
220
- | `trustForwardedHeaders: true` | `<X-Forwarded-Proto>://<X-Forwarded-Host><mountPrefix>`, derived per request |
221
- | neither (default) | `<mountPrefix>` — **relative**; the client resolves it against the origin the document was loaded from |
222
+ | `baseUrl: <url>` | `<url>` — explicit, fixed; wins over everything |
223
+ | `trustForwardedHeaders: true` | `<X-Forwarded-Proto>://<X-Forwarded-Host>`, derived per request |
224
+ | neither (default) | `/` — **relative**; the client resolves it against the origin the document was loaded from |
222
225
 
223
226
  - The default is **relative** so the document is correct behind any proxy, ingress,
224
227
  or origin with zero configuration.
@@ -51,7 +51,8 @@ export declare class HttpServerApi implements ResourceInstance {
51
51
  private readonly ctx;
52
52
  readonly manifest: HttpApiManifest;
53
53
  private readonly handlerRefs;
54
- constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>);
54
+ private readonly apiName;
55
+ constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>, apiName: string);
55
56
  init(): Promise<void>;
56
57
  register(app: FastifyInstance, prefix?: string): void;
57
58
  private registerRoutes;
@@ -26,35 +26,36 @@ export class HttpServerApi {
26
26
  ctx;
27
27
  manifest;
28
28
  handlerRefs;
29
- constructor(ctx, manifest, handlerRefs) {
29
+ apiName;
30
+ constructor(ctx, manifest, handlerRefs, apiName) {
30
31
  this.ctx = ctx;
31
32
  this.manifest = manifest;
32
33
  this.handlerRefs = handlerRefs;
34
+ this.apiName = apiName;
33
35
  }
34
36
  async init() { }
35
37
  register(app, prefix = "") {
36
- if (prefix) {
37
- app.register(async (scoped) => {
38
- this.registerRoutes(scoped);
39
- }, { prefix });
40
- }
41
- else {
42
- this.registerRoutes(app);
43
- }
38
+ // Register each route at its full `prefix + path` on the root app rather than
39
+ // inside a `{ prefix }`-encapsulated context. Fastify encapsulation makes
40
+ // @fastify/swagger strip the prefix from the documented path, which conflates
41
+ // routes from different mounts (e.g. an Api at `/admin` documented as `/links`).
42
+ // Carrying the prefix on the path keeps the OpenAPI doc unambiguous for any mix
43
+ // of mounts; a single `servers` origin is set by the server controller.
44
+ this.registerRoutes(app, normalizeMountPrefix(prefix));
44
45
  }
45
- registerRoutes(app) {
46
+ registerRoutes(app, prefix) {
46
47
  const routes = this.manifest.routes || [];
47
48
  for (const route of routes) {
48
- this.registerRoute(app, route);
49
+ this.registerRoute(app, route, prefix);
49
50
  }
50
51
  }
51
- registerRoute(app, route) {
52
+ registerRoute(app, route, prefix) {
52
53
  // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
53
54
  const handler = route.handler;
54
55
  const handlerRef = this.handlerRefs.get(route);
55
56
  const handlerKind = handlerRef?.kind ?? "";
56
57
  const handlerName = handlerRef?.name ?? "";
57
- const translatedPath = translateOpenApiPath(route.request.path);
58
+ const translatedPath = prefix + translateOpenApiPath(route.request.path);
58
59
  const schema = { response: {} };
59
60
  // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
60
61
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
@@ -95,6 +96,9 @@ export class HttpServerApi {
95
96
  query: request.query || {},
96
97
  headers: normalizeHeaders(request.headers),
97
98
  body: streamBody ? toByteStream(request) : request.body,
99
+ // Canonical client address — honours X-Forwarded-For per the
100
+ // server's `trustProxy` setting (Fastify resolves it).
101
+ ip: request.ip,
98
102
  },
99
103
  };
100
104
  const acceptHeader = request.headers["accept"]?.toString();
@@ -118,22 +122,35 @@ export class HttpServerApi {
118
122
  if (!reply.sent)
119
123
  cancellation.cancel("client-disconnect");
120
124
  });
125
+ // Open a request span rooting this request's own trace: the handler (and
126
+ // its nested invokes) nest under it, and it's labelled with the route so
127
+ // the trace shows the actual method+path, attributed to this Http.Api.
128
+ const span = await this.ctx.openSpan(cancellation.context, {
129
+ ref: { kind: "Http.Api", name: this.apiName },
130
+ label: `${route.request.method} ${route.request.path}`,
131
+ attributes: { method: route.request.method, path: route.request.path },
132
+ });
121
133
  let result;
122
134
  try {
123
135
  result = handler
124
- ? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, cancellation.context)
136
+ ? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, span.context)
125
137
  : undefined;
126
138
  }
127
139
  catch (err) {
128
140
  if (isCancellationError(err)) {
141
+ await span.settle("cancelled");
129
142
  if (!reply.sent)
130
143
  reply.code(499).send();
131
144
  return;
132
145
  }
133
- if (!isInvokeError(err))
146
+ if (!isInvokeError(err)) {
147
+ await span.settle("failed");
134
148
  throw err;
149
+ }
150
+ await span.settle("rejected");
135
151
  return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
136
152
  }
153
+ await span.settle("ok");
137
154
  return dispatchReturns(route.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink, (err, errCtx) => this.ctx.emitEvent("Http.Api.streamFailed", {
138
155
  path: route.request.path,
139
156
  method: route.request.method,
@@ -169,7 +186,7 @@ export async function create(resource, ctx) {
169
186
  handlerRefs.set(route, { kind: "", name: h });
170
187
  }
171
188
  }
172
- return new HttpServerApi(ctx, resource, handlerRefs);
189
+ return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
173
190
  }
174
191
  /**
175
192
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
@@ -178,6 +195,16 @@ export async function create(resource, ctx) {
178
195
  function translateOpenApiPath(openApiPath) {
179
196
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
180
197
  }
198
+ /**
199
+ * Normalizes a mount prefix into a path segment that prepends cleanly to a route
200
+ * path: the root mount (`""` / `"/"`) contributes nothing, and a trailing slash
201
+ * is dropped so `"/admin" + "/links"` is `/admin/links`, never `/admin//links`.
202
+ */
203
+ function normalizeMountPrefix(prefix) {
204
+ if (!prefix || prefix === "/")
205
+ return "";
206
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
207
+ }
181
208
  /**
182
209
  * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
183
210
  * stream content-type parser (`contentTypeParsers[].stream`) for the request's
@@ -26,6 +26,7 @@ type HttpServerResource = RuntimeResource & {
26
26
  port?: number;
27
27
  baseUrl?: string;
28
28
  trustForwardedHeaders?: boolean;
29
+ trustProxy?: boolean | number;
29
30
  logger?: boolean;
30
31
  cors?: CorsOptions;
31
32
  contentTypeParsers?: Array<{
@@ -28,11 +28,14 @@ class HttpServer {
28
28
  if (!this.port) {
29
29
  throw new Error("Http.Server port is required");
30
30
  }
31
+ // `trustProxy` is the single Fastify knob behind both the forwarded
32
+ // protocol/host (request.protocol/host) and the canonical client address
33
+ // (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
34
+ // it, the legacy `trustForwardedHeaders` boolean still applies.
35
+ const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
31
36
  this.app = Fastify({
32
37
  logger: resource.logger,
33
- // Honour X-Forwarded-Proto / X-Forwarded-Host so request.protocol/host (and
34
- // the OpenAPI servers derived from them) reflect a fronting proxy's URL.
35
- trustProxy: this.trustForwardedHeaders,
38
+ trustProxy,
36
39
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
37
40
  });
38
41
  }
@@ -96,15 +99,13 @@ class HttpServer {
96
99
  throw error;
97
100
  });
98
101
  if (this.resource.openapi) {
99
- const mounts = this.resource.mounts || [];
100
- const prefixes = [...new Set(mounts.map((mount) => mount.path || ""))];
101
- // Server URL precedence: an explicit `baseUrl` is an absolute, fixed
102
- // override; otherwise the URLs are relative (just the mount prefix) so the
103
- // doc is correct behind any proxy/ingress/origin without configuration
104
- // the client resolves them against wherever the reference was loaded.
105
- const servers = prefixes.map((prefix) => ({
106
- url: this.resource.baseUrl ? this.resource.baseUrl + prefix : prefix || "/",
107
- }));
102
+ // Each route is documented at its full `mount-prefix + path` (see
103
+ // http-api-controller), so the server is a single origin, not one entry per
104
+ // mount. Server URL precedence: an explicit `baseUrl` is an absolute, fixed
105
+ // override; otherwise the URL is relative (`/`) so the doc is correct behind
106
+ // any proxy/ingress/origin the client resolves it against wherever the
107
+ // reference was loaded.
108
+ const servers = [{ url: this.resource.baseUrl ?? "/" }];
108
109
  await this.app.register(swagger, {
109
110
  openapi: {
110
111
  openapi: "3.0.0",
@@ -135,9 +136,7 @@ class HttpServer {
135
136
  try {
136
137
  const doc = JSON.parse(text);
137
138
  if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
138
- doc.servers = prefixes.map((prefix) => ({
139
- url: `${request.protocol}://${request.host}${prefix}`,
140
- }));
139
+ doc.servers = [{ url: `${request.protocol}://${request.host}` }];
141
140
  const out = JSON.stringify(doc);
142
141
  reply.header("content-length", Buffer.byteLength(out));
143
142
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -49,7 +49,7 @@
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
51
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.31.0"
52
+ "@telorun/sdk": "0.33.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
@@ -59,37 +59,35 @@ export class HttpServerApi implements ResourceInstance {
59
59
  private readonly ctx: ResourceContext,
60
60
  readonly manifest: HttpApiManifest,
61
61
  private readonly handlerRefs: WeakMap<object, HandlerRef>,
62
+ private readonly apiName: string,
62
63
  ) {}
63
64
 
64
65
  async init() {}
65
66
 
66
67
  register(app: FastifyInstance, prefix = "") {
67
- if (prefix) {
68
- app.register(
69
- async (scoped) => {
70
- this.registerRoutes(scoped);
71
- },
72
- { prefix },
73
- );
74
- } else {
75
- this.registerRoutes(app);
76
- }
68
+ // Register each route at its full `prefix + path` on the root app rather than
69
+ // inside a `{ prefix }`-encapsulated context. Fastify encapsulation makes
70
+ // @fastify/swagger strip the prefix from the documented path, which conflates
71
+ // routes from different mounts (e.g. an Api at `/admin` documented as `/links`).
72
+ // Carrying the prefix on the path keeps the OpenAPI doc unambiguous for any mix
73
+ // of mounts; a single `servers` origin is set by the server controller.
74
+ this.registerRoutes(app, normalizeMountPrefix(prefix));
77
75
  }
78
76
 
79
- private registerRoutes(app: FastifyInstance) {
77
+ private registerRoutes(app: FastifyInstance, prefix: string) {
80
78
  const routes = this.manifest.routes || [];
81
79
  for (const route of routes) {
82
- this.registerRoute(app, route);
80
+ this.registerRoute(app, route, prefix);
83
81
  }
84
82
  }
85
83
 
86
- private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
84
+ private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest, prefix: string) {
87
85
  // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
88
86
  const handler = route.handler as unknown as ResourceInstance | undefined;
89
87
  const handlerRef = this.handlerRefs.get(route as unknown as object);
90
88
  const handlerKind = handlerRef?.kind ?? "";
91
89
  const handlerName = handlerRef?.name ?? "";
92
- const translatedPath = translateOpenApiPath(route.request.path);
90
+ const translatedPath = prefix + translateOpenApiPath(route.request.path);
93
91
 
94
92
  const schema: any = { response: {} };
95
93
 
@@ -130,6 +128,9 @@ export class HttpServerApi implements ResourceInstance {
130
128
  query: request.query || {},
131
129
  headers: normalizeHeaders(request.headers),
132
130
  body: streamBody ? toByteStream(request) : request.body,
131
+ // Canonical client address — honours X-Forwarded-For per the
132
+ // server's `trustProxy` setting (Fastify resolves it).
133
+ ip: request.ip,
133
134
  },
134
135
  };
135
136
  const acceptHeader = (
@@ -159,6 +160,15 @@ export class HttpServerApi implements ResourceInstance {
159
160
  if (!reply.sent) cancellation.cancel("client-disconnect");
160
161
  });
161
162
 
163
+ // Open a request span rooting this request's own trace: the handler (and
164
+ // its nested invokes) nest under it, and it's labelled with the route so
165
+ // the trace shows the actual method+path, attributed to this Http.Api.
166
+ const span = await this.ctx.openSpan(cancellation.context, {
167
+ ref: { kind: "Http.Api", name: this.apiName },
168
+ label: `${route.request.method} ${route.request.path}`,
169
+ attributes: { method: route.request.method, path: route.request.path },
170
+ });
171
+
162
172
  let result: unknown;
163
173
  try {
164
174
  result = handler
@@ -167,15 +177,20 @@ export class HttpServerApi implements ResourceInstance {
167
177
  handlerName,
168
178
  handler,
169
179
  invokeInput,
170
- cancellation.context,
180
+ span.context,
171
181
  )
172
182
  : undefined;
173
183
  } catch (err) {
174
184
  if (isCancellationError(err)) {
185
+ await span.settle("cancelled");
175
186
  if (!reply.sent) reply.code(499).send();
176
187
  return;
177
188
  }
178
- if (!isInvokeError(err)) throw err;
189
+ if (!isInvokeError(err)) {
190
+ await span.settle("failed");
191
+ throw err;
192
+ }
193
+ await span.settle("rejected");
179
194
  return dispatchCatches(
180
195
  route.catches,
181
196
  { code: err.code, message: err.message, data: err.data },
@@ -187,6 +202,7 @@ export class HttpServerApi implements ResourceInstance {
187
202
  );
188
203
  }
189
204
 
205
+ await span.settle("ok");
190
206
  return dispatchReturns(
191
207
  route.returns,
192
208
  result,
@@ -232,7 +248,7 @@ export async function create(resource: any, ctx: ResourceContext): Promise<HttpS
232
248
  handlerRefs.set(route, { kind: "", name: h });
233
249
  }
234
250
  }
235
- return new HttpServerApi(ctx, resource, handlerRefs);
251
+ return new HttpServerApi(ctx, resource, handlerRefs, resource?.metadata?.name ?? "");
236
252
  }
237
253
 
238
254
  /**
@@ -243,6 +259,16 @@ function translateOpenApiPath(openApiPath: string): string {
243
259
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
244
260
  }
245
261
 
262
+ /**
263
+ * Normalizes a mount prefix into a path segment that prepends cleanly to a route
264
+ * path: the root mount (`""` / `"/"`) contributes nothing, and a trailing slash
265
+ * is dropped so `"/admin" + "/links"` is `/admin/links`, never `/admin//links`.
266
+ */
267
+ function normalizeMountPrefix(prefix: string): string {
268
+ if (!prefix || prefix === "/") return "";
269
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
270
+ }
271
+
246
272
  /**
247
273
  * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
248
274
  * stream content-type parser (`contentTypeParsers[].stream`) for the request's
@@ -46,6 +46,7 @@ type HttpServerResource = RuntimeResource & {
46
46
  port?: number;
47
47
  baseUrl?: string;
48
48
  trustForwardedHeaders?: boolean;
49
+ trustProxy?: boolean | number;
49
50
  logger?: boolean;
50
51
  cors?: CorsOptions;
51
52
  contentTypeParsers?: Array<{ contentType: string; parser?: Invocable; stream?: boolean }>;
@@ -105,11 +106,14 @@ class HttpServer implements ResourceInstance {
105
106
  if (!this.port) {
106
107
  throw new Error("Http.Server port is required");
107
108
  }
109
+ // `trustProxy` is the single Fastify knob behind both the forwarded
110
+ // protocol/host (request.protocol/host) and the canonical client address
111
+ // (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
112
+ // it, the legacy `trustForwardedHeaders` boolean still applies.
113
+ const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
108
114
  this.app = Fastify({
109
115
  logger: resource.logger,
110
- // Honour X-Forwarded-Proto / X-Forwarded-Host so request.protocol/host (and
111
- // the OpenAPI servers derived from them) reflect a fronting proxy's URL.
112
- trustProxy: this.trustForwardedHeaders,
116
+ trustProxy,
113
117
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
114
118
  });
115
119
  }
@@ -178,15 +182,13 @@ class HttpServer implements ResourceInstance {
178
182
  throw error;
179
183
  });
180
184
  if (this.resource.openapi) {
181
- const mounts = this.resource.mounts || [];
182
- const prefixes = [...new Set(mounts.map((mount) => mount.path || ""))];
183
- // Server URL precedence: an explicit `baseUrl` is an absolute, fixed
184
- // override; otherwise the URLs are relative (just the mount prefix) so the
185
- // doc is correct behind any proxy/ingress/origin without configuration
186
- // the client resolves them against wherever the reference was loaded.
187
- const servers = prefixes.map((prefix) => ({
188
- url: this.resource.baseUrl ? this.resource.baseUrl + prefix : prefix || "/",
189
- }));
185
+ // Each route is documented at its full `mount-prefix + path` (see
186
+ // http-api-controller), so the server is a single origin, not one entry per
187
+ // mount. Server URL precedence: an explicit `baseUrl` is an absolute, fixed
188
+ // override; otherwise the URL is relative (`/`) so the doc is correct behind
189
+ // any proxy/ingress/origin the client resolves it against wherever the
190
+ // reference was loaded.
191
+ const servers = [{ url: this.resource.baseUrl ?? "/" }];
190
192
  await this.app.register(swagger, {
191
193
  openapi: {
192
194
  openapi: "3.0.0",
@@ -216,9 +218,7 @@ class HttpServer implements ResourceInstance {
216
218
  try {
217
219
  const doc = JSON.parse(text);
218
220
  if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
219
- doc.servers = prefixes.map((prefix) => ({
220
- url: `${request.protocol}://${request.host}${prefix}`,
221
- }));
221
+ doc.servers = [{ url: `${request.protocol}://${request.host}` }];
222
222
  const out = JSON.stringify(doc);
223
223
  reply.header("content-length", Buffer.byteLength(out));
224
224
  return out;
@@ -48,6 +48,7 @@ describe("http-server request cancellation", () => {
48
48
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
49
49
  h.invoke(input, c as { cancellation?: any }),
50
50
  emitEvent: () => {},
51
+ openSpan: async (base: unknown) => ({ context: base, settle: async () => {} }),
51
52
  } as unknown as Parameters<typeof create>[1];
52
53
 
53
54
  const resource = {
@@ -0,0 +1,82 @@
1
+ import { createCancellationSource } from "@telorun/sdk";
2
+ import Fastify from "fastify";
3
+ import type { AddressInfo } from "node:net";
4
+ import { describe, expect, it } from "vitest";
5
+ import { create } from "../src/http-api-controller.js";
6
+
7
+ /**
8
+ * Each inbound request opens a trace span attributed to the Http.Api and labelled
9
+ * with the route, under which the handler invoke nests. This drives the real
10
+ * controller (real Fastify) with a mock ResourceContext that captures the
11
+ * `openSpan` call, asserting the controller passes the route's `{kind,name}`,
12
+ * label and `{method,path}` attributes — and settles the span on success.
13
+ *
14
+ * The generic span mechanics (rooting a detached trace, nesting, trace id) are a
15
+ * kernel concern, tested there; this test owns only the HTTP-specific wiring.
16
+ */
17
+ describe("http-server request span", () => {
18
+ it("opens a route-labelled span and dispatches the handler under it", async () => {
19
+ const spanCalls: any[] = [];
20
+ const settled: string[] = [];
21
+
22
+ const handler = {
23
+ async invoke(input: unknown) {
24
+ return { echoed: (input as { value: number }).value };
25
+ },
26
+ snapshot: () => ({}),
27
+ };
28
+
29
+ const ctx = {
30
+ validateSchema: () => {},
31
+ resolveChildren: () => ({ kind: "JS.Script", name: "Echo" }),
32
+ moduleContext: { expandWith: (value: unknown) => value },
33
+ createCancellationSource: () => createCancellationSource(),
34
+ invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown) =>
35
+ h.invoke(input),
36
+ emitEvent: () => {},
37
+ openSpan: async (base: unknown, opts: unknown) => {
38
+ spanCalls.push(opts);
39
+ return {
40
+ context: base,
41
+ settle: async (outcome: string) => {
42
+ settled.push(outcome);
43
+ },
44
+ };
45
+ },
46
+ } as unknown as Parameters<typeof create>[1];
47
+
48
+ const resource = {
49
+ metadata: { name: "EchoApi", module: "test" },
50
+ routes: [
51
+ {
52
+ request: { path: "/echo", method: "GET" },
53
+ handler,
54
+ inputs: { value: 1 },
55
+ returns: [{ status: 200, content: { "application/json": { body: { ok: true } } } }],
56
+ },
57
+ ],
58
+ };
59
+
60
+ const api = await create(resource, ctx);
61
+ const app = Fastify({ logger: false });
62
+ api.register(app);
63
+ await app.listen({ host: "127.0.0.1", port: 0 });
64
+ const { port } = app.server.address() as AddressInfo;
65
+
66
+ try {
67
+ const res = await fetch(`http://127.0.0.1:${port}/echo`);
68
+ expect(res.status).toBe(200);
69
+ await res.json();
70
+
71
+ expect(spanCalls).toHaveLength(1);
72
+ expect(spanCalls[0]).toEqual({
73
+ ref: { kind: "Http.Api", name: "EchoApi" },
74
+ label: "GET /echo",
75
+ attributes: { method: "GET", path: "/echo" },
76
+ });
77
+ expect(settled).toEqual(["ok"]);
78
+ } finally {
79
+ await app.close().catch(() => {});
80
+ }
81
+ });
82
+ });