@telorun/http-server 0.13.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,15 @@
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
+
3
13
  ## 0.13.0
4
14
 
5
15
  ### 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
 
@@ -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.13.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.33.0"
58
+ "@telorun/sdk": "0.34.0"
53
59
  },
54
60
  "peerDependencies": {
55
61
  "@telorun/sdk": "*"
@@ -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
+ }