@telorun/http-server 0.12.0 → 0.14.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,35 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca095ac: Add `Http.Static`, a `Telo.Mount` that serves a directory of static assets (a built SPA, plain HTML, images, …). Mount it on an `Http.Server` alongside an `Http.Api` so one application delivers both its API and its frontend. Supports a manifest-relative `root` (assets ship with the app), `index`, `spaFallback` for client-side routing, and `maxAge` / `immutable` cache control. Backed by `@fastify/static` (MIME, ETag, conditional and range requests).
8
+
9
+ ### Patch Changes
10
+
11
+ - @telorun/http-dispatch@0.4.1
12
+
13
+ ## 0.13.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 95f168e: Cache, rate-limit, and background-task primitives, plus a comprehensive URL-shortener example.
18
+
19
+ - 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`).
20
+ - New `rate-limit` module: `RateLimit.Guard`, a non-throwing sliding-window limiter whose counters live in any `Cache.Store`.
21
+ - `run` gains `Run.Detach` (generic, zero-config fire-and-forget).
22
+ - 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.
23
+ - `http-server`: `Http.Server.trustProxy` and a derived `request.ip` in the handler CEL context (canonical client address for rate-limit keys).
24
+
25
+ ### Patch Changes
26
+
27
+ - 95f168e: Fix OpenAPI documentation conflating routes from different mounts.
28
+
29
+ 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.
30
+
31
+ - @telorun/http-dispatch@0.4.1
32
+
3
33
  ## 0.12.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -9,6 +9,7 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
9
9
  - **Schema-driven validation** — `request.schema` (`body`, `query`, `params`, `headers`) yields a standardized HTTP 400 with `details[]` on failure.
10
10
  - **Typed returns and catches** — render successful values and structured `InvokeError`s into status + headers + per-MIME bodies via CEL.
11
11
  - **Composable mounts** — attach `Telo.Mount` resources (HTTP APIs, MCP endpoints, custom mounts) under any path prefix.
12
+ - **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.
12
13
  - **CORS and content-type parsers** — first-class manifest fields; no controller code needed.
13
14
 
14
15
  ## Kinds
@@ -17,6 +18,7 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
17
18
  | --- | --- |
18
19
  | `Http.Server` | Long-lived HTTP listener that hosts mounts on configured paths and ports. |
19
20
  | `Http.Api` | Mountable router exposing route definitions with returns/catches rendering. |
21
+ | `Http.Static` | Mountable static-file server for a directory of assets (built SPA, plain HTML, images). |
20
22
 
21
23
  ## Example
22
24
 
@@ -77,6 +79,7 @@ code: |
77
79
  ## Reference
78
80
 
79
81
  - [`Http.Server` / `Http.Api` returns & catches](docs/returns-and-catches.md) — outcome lists, MIME negotiation, stream mode.
82
+ - [Serving static files & frontends](docs/static-files.md) — `Http.Static`, manifest-relative roots, SPA fallback, asset caching.
80
83
 
81
84
  ## Implementation Contract
82
85
 
@@ -210,15 +213,18 @@ request:
210
213
  ### 5. External URL & OpenAPI `servers`
211
214
 
212
215
  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:
216
+ `host:port` is not the URL clients use. The generated OpenAPI `servers` block is a
217
+ **single origin** each operation is documented at its full `<mountPrefix><path>`,
218
+ so different APIs mounted at different prefixes stay distinct (an `Http.Api` mounted
219
+ at `/admin` is documented at `/admin/...`, never flattened to `/...`). The origin
220
+ resolves identically across runtimes (Node/Rust/Go) — the inputs are standard HTTP,
221
+ never a framework's proxy-config object:
216
222
 
217
223
  | Manifest | `servers[].url` |
218
224
  | --- | --- |
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 |
225
+ | `baseUrl: <url>` | `<url>` — explicit, fixed; wins over everything |
226
+ | `trustForwardedHeaders: true` | `<X-Forwarded-Proto>://<X-Forwarded-Host>`, derived per request |
227
+ | neither (default) | `/` — **relative**; the client resolves it against the origin the document was loaded from |
222
228
 
223
229
  - The default is **relative** so the document is correct behind any proxy, ingress,
224
230
  or origin with zero configuration.
@@ -35,28 +35,27 @@ export class HttpServerApi {
35
35
  }
36
36
  async init() { }
37
37
  register(app, prefix = "") {
38
- if (prefix) {
39
- app.register(async (scoped) => {
40
- this.registerRoutes(scoped);
41
- }, { prefix });
42
- }
43
- else {
44
- this.registerRoutes(app);
45
- }
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));
46
45
  }
47
- registerRoutes(app) {
46
+ registerRoutes(app, prefix) {
48
47
  const routes = this.manifest.routes || [];
49
48
  for (const route of routes) {
50
- this.registerRoute(app, route);
49
+ this.registerRoute(app, route, prefix);
51
50
  }
52
51
  }
53
- registerRoute(app, route) {
52
+ registerRoute(app, route, prefix) {
54
53
  // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
55
54
  const handler = route.handler;
56
55
  const handlerRef = this.handlerRefs.get(route);
57
56
  const handlerKind = handlerRef?.kind ?? "";
58
57
  const handlerName = handlerRef?.name ?? "";
59
- const translatedPath = translateOpenApiPath(route.request.path);
58
+ const translatedPath = prefix + translateOpenApiPath(route.request.path);
60
59
  const schema = { response: {} };
61
60
  // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
62
61
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
@@ -97,6 +96,9 @@ export class HttpServerApi {
97
96
  query: request.query || {},
98
97
  headers: normalizeHeaders(request.headers),
99
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,
100
102
  },
101
103
  };
102
104
  const acceptHeader = request.headers["accept"]?.toString();
@@ -193,6 +195,16 @@ export async function create(resource, ctx) {
193
195
  function translateOpenApiPath(openApiPath) {
194
196
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
195
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
+ }
196
208
  /**
197
209
  * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
198
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;
@@ -0,0 +1,10 @@
1
+ import { type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
2
+ type HttpStaticResource = RuntimeResource & {
3
+ root: string;
4
+ index?: string;
5
+ spaFallback?: boolean;
6
+ maxAge?: number;
7
+ immutable?: boolean;
8
+ };
9
+ export declare function create(resource: HttpStaticResource, ctx: ResourceContext): Promise<ResourceInstance>;
10
+ export {};
@@ -0,0 +1,92 @@
1
+ import fastifyStatic from "@fastify/static";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /** Collapse a mount prefix to a single leading slash with no trailing slash;
6
+ * an empty/`"/"` prefix becomes `"/"`. Unlike Http.Api (which returns `""` and
7
+ * concatenates the prefix onto each route path on the root app), this serves
8
+ * from an encapsulated `register({ prefix })`, which needs a non-empty prefix —
9
+ * hence root maps to `"/"`, not `""`. */
10
+ function normalizeMountPrefix(prefix) {
11
+ const trimmed = prefix.replace(/\/+$/, "");
12
+ if (!trimmed)
13
+ return "/";
14
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
15
+ }
16
+ /** Serves a directory of static assets (a built SPA, plain HTML, images, …) as a
17
+ * Telo.Mount. Mirrors Http.Api's `register(app, prefix)` contract so it slots into
18
+ * Http.Server.mounts identically. Backed by @fastify/static, which handles MIME,
19
+ * ETag, conditional requests, and range requests. */
20
+ class HttpStatic {
21
+ root;
22
+ index;
23
+ spaFallback;
24
+ maxAge;
25
+ immutable;
26
+ constructor(resource, ctx) {
27
+ this.root = resolveRoot(resource.root, ctx);
28
+ this.index = resource.index ?? "index.html";
29
+ this.spaFallback = resource.spaFallback === true;
30
+ this.maxAge = resource.maxAge;
31
+ this.immutable = resource.immutable === true;
32
+ }
33
+ async init() { }
34
+ register(app, prefix = "") {
35
+ const mountPrefix = normalizeMountPrefix(prefix);
36
+ const root = this.root;
37
+ const index = this.index;
38
+ const spaFallback = this.spaFallback;
39
+ const cacheControl = this.maxAge != null;
40
+ // @fastify/static takes maxAge in milliseconds; the manifest declares seconds.
41
+ const maxAge = this.maxAge != null ? this.maxAge * 1000 : undefined;
42
+ const immutable = this.immutable;
43
+ // Encapsulated scope so the static plugin, its routes, and the SPA not-found
44
+ // handler are confined to this mount's prefix and don't collide with sibling
45
+ // mounts or the server-level notFoundHandler. `decorateReply: false` keeps
46
+ // multiple static mounts from fighting over the shared `reply.sendFile`
47
+ // decorator — the SPA fallback reads the index file directly instead.
48
+ app.register(async (scope) => {
49
+ await scope.register(fastifyStatic, {
50
+ root,
51
+ prefix: "/",
52
+ index,
53
+ // With spaFallback we want unmatched paths to fall through to the
54
+ // not-found handler (client-side routing); the wildcard glob route
55
+ // would otherwise 404 them itself.
56
+ wildcard: !spaFallback,
57
+ cacheControl,
58
+ maxAge,
59
+ immutable,
60
+ decorateReply: false,
61
+ });
62
+ if (spaFallback) {
63
+ const indexPath = join(root, index);
64
+ // Deep-link/refresh navigations are the common path for a client-routed
65
+ // SPA, so read the index once and serve the cached buffer — mirroring
66
+ // the caching @fastify/static does for real files. (A build that swaps
67
+ // index.html while the server runs isn't picked up, consistent with
68
+ // @fastify/static's own behavior.)
69
+ let indexHtml = null;
70
+ scope.setNotFoundHandler(async (_request, reply) => {
71
+ if (indexHtml === null)
72
+ indexHtml = await readFile(indexPath);
73
+ return reply.type("text/html").send(indexHtml);
74
+ });
75
+ }
76
+ }, { prefix: mountPrefix });
77
+ }
78
+ }
79
+ /** Resolve the asset root relative to the manifest that declared the resource, so
80
+ * the frontend ships co-located with the app (same pattern as mcp-client). */
81
+ function resolveRoot(root, ctx) {
82
+ if (isAbsolute(root))
83
+ return root;
84
+ const source = ctx.moduleContext.source;
85
+ if (!source.startsWith("file://"))
86
+ return resolve(root);
87
+ const baseDir = dirname(fileURLToPath(source));
88
+ return resolve(baseDir, root);
89
+ }
90
+ export async function create(resource, ctx) {
91
+ return new HttpStatic(resource, ctx);
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -33,10 +33,16 @@
33
33
  "types": "./dist/http-api-controller.d.ts",
34
34
  "bun": "./src/http-api-controller.ts",
35
35
  "import": "./dist/http-api-controller.js"
36
+ },
37
+ "./http-static": {
38
+ "types": "./dist/http-static-controller.d.ts",
39
+ "bun": "./src/http-static-controller.ts",
40
+ "import": "./dist/http-static-controller.js"
36
41
  }
37
42
  },
38
43
  "dependencies": {
39
44
  "@fastify/cors": "^11.2.0",
45
+ "@fastify/static": "^8.0.0",
40
46
  "@fastify/swagger": "^9.6.1",
41
47
  "@scalar/fastify-api-reference": "^1.44.6",
42
48
  "@sinclair/typebox": "^0.34.48",
@@ -49,7 +55,7 @@
49
55
  "@types/node": "^20.0.0",
50
56
  "typescript": "^5.0.0",
51
57
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.32.0"
58
+ "@telorun/sdk": "0.34.0"
53
59
  },
54
60
  "peerDependencies": {
55
61
  "@telorun/sdk": "*"
@@ -65,32 +65,29 @@ export class HttpServerApi implements ResourceInstance {
65
65
  async init() {}
66
66
 
67
67
  register(app: FastifyInstance, prefix = "") {
68
- if (prefix) {
69
- app.register(
70
- async (scoped) => {
71
- this.registerRoutes(scoped);
72
- },
73
- { prefix },
74
- );
75
- } else {
76
- this.registerRoutes(app);
77
- }
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));
78
75
  }
79
76
 
80
- private registerRoutes(app: FastifyInstance) {
77
+ private registerRoutes(app: FastifyInstance, prefix: string) {
81
78
  const routes = this.manifest.routes || [];
82
79
  for (const route of routes) {
83
- this.registerRoute(app, route);
80
+ this.registerRoute(app, route, prefix);
84
81
  }
85
82
  }
86
83
 
87
- private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
84
+ private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest, prefix: string) {
88
85
  // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
89
86
  const handler = route.handler as unknown as ResourceInstance | undefined;
90
87
  const handlerRef = this.handlerRefs.get(route as unknown as object);
91
88
  const handlerKind = handlerRef?.kind ?? "";
92
89
  const handlerName = handlerRef?.name ?? "";
93
- const translatedPath = translateOpenApiPath(route.request.path);
90
+ const translatedPath = prefix + translateOpenApiPath(route.request.path);
94
91
 
95
92
  const schema: any = { response: {} };
96
93
 
@@ -131,6 +128,9 @@ export class HttpServerApi implements ResourceInstance {
131
128
  query: request.query || {},
132
129
  headers: normalizeHeaders(request.headers),
133
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,
134
134
  },
135
135
  };
136
136
  const acceptHeader = (
@@ -259,6 +259,16 @@ function translateOpenApiPath(openApiPath: string): string {
259
259
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
260
260
  }
261
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
+
262
272
  /**
263
273
  * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
264
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;
@@ -0,0 +1,113 @@
1
+ import fastifyStatic from "@fastify/static";
2
+ import { type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
+ import { FastifyInstance } from "fastify";
4
+ import { readFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ type HttpStaticResource = RuntimeResource & {
9
+ root: string;
10
+ index?: string;
11
+ spaFallback?: boolean;
12
+ maxAge?: number;
13
+ immutable?: boolean;
14
+ };
15
+
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
+ /** Serves a directory of static assets (a built SPA, plain HTML, images, …) as a
28
+ * Telo.Mount. Mirrors Http.Api's `register(app, prefix)` contract so it slots into
29
+ * Http.Server.mounts identically. Backed by @fastify/static, which handles MIME,
30
+ * ETag, conditional requests, and range requests. */
31
+ class HttpStatic implements ResourceInstance {
32
+ private readonly root: string;
33
+ private readonly index: string;
34
+ private readonly spaFallback: boolean;
35
+ private readonly maxAge?: number;
36
+ private readonly immutable: boolean;
37
+
38
+ constructor(resource: HttpStaticResource, ctx: ResourceContext) {
39
+ this.root = resolveRoot(resource.root, ctx);
40
+ this.index = resource.index ?? "index.html";
41
+ this.spaFallback = resource.spaFallback === true;
42
+ this.maxAge = resource.maxAge;
43
+ this.immutable = resource.immutable === true;
44
+ }
45
+
46
+ async init() {}
47
+
48
+ register(app: FastifyInstance, prefix = ""): void {
49
+ const mountPrefix = normalizeMountPrefix(prefix);
50
+ const root = this.root;
51
+ const index = this.index;
52
+ const spaFallback = this.spaFallback;
53
+ const cacheControl = this.maxAge != null;
54
+ // @fastify/static takes maxAge in milliseconds; the manifest declares seconds.
55
+ const maxAge = this.maxAge != null ? this.maxAge * 1000 : undefined;
56
+ const immutable = this.immutable;
57
+
58
+ // Encapsulated scope so the static plugin, its routes, and the SPA not-found
59
+ // handler are confined to this mount's prefix and don't collide with sibling
60
+ // mounts or the server-level notFoundHandler. `decorateReply: false` keeps
61
+ // multiple static mounts from fighting over the shared `reply.sendFile`
62
+ // decorator — the SPA fallback reads the index file directly instead.
63
+ app.register(
64
+ async (scope) => {
65
+ await scope.register(fastifyStatic, {
66
+ root,
67
+ prefix: "/",
68
+ index,
69
+ // With spaFallback we want unmatched paths to fall through to the
70
+ // not-found handler (client-side routing); the wildcard glob route
71
+ // would otherwise 404 them itself.
72
+ wildcard: !spaFallback,
73
+ cacheControl,
74
+ maxAge,
75
+ immutable,
76
+ decorateReply: false,
77
+ });
78
+
79
+ if (spaFallback) {
80
+ const indexPath = join(root, index);
81
+ // Deep-link/refresh navigations are the common path for a client-routed
82
+ // SPA, so read the index once and serve the cached buffer — mirroring
83
+ // the caching @fastify/static does for real files. (A build that swaps
84
+ // index.html while the server runs isn't picked up, consistent with
85
+ // @fastify/static's own behavior.)
86
+ let indexHtml: Buffer | null = null;
87
+ scope.setNotFoundHandler(async (_request, reply) => {
88
+ if (indexHtml === null) indexHtml = await readFile(indexPath);
89
+ return reply.type("text/html").send(indexHtml);
90
+ });
91
+ }
92
+ },
93
+ { prefix: mountPrefix },
94
+ );
95
+ }
96
+ }
97
+
98
+ /** Resolve the asset root relative to the manifest that declared the resource, so
99
+ * the frontend ships co-located with the app (same pattern as mcp-client). */
100
+ function resolveRoot(root: string, ctx: ResourceContext): string {
101
+ if (isAbsolute(root)) return root;
102
+ const source = ctx.moduleContext.source;
103
+ if (!source.startsWith("file://")) return resolve(root);
104
+ const baseDir = dirname(fileURLToPath(source));
105
+ return resolve(baseDir, root);
106
+ }
107
+
108
+ export async function create(
109
+ resource: HttpStaticResource,
110
+ ctx: ResourceContext,
111
+ ): Promise<ResourceInstance> {
112
+ return new HttpStatic(resource, ctx);
113
+ }