@telorun/http-server 0.13.0 → 0.14.1
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 +22 -0
- package/README.md +3 -0
- package/dist/http-api-controller.js +10 -1
- package/dist/http-static-controller.d.ts +10 -0
- package/dist/http-static-controller.js +92 -0
- package/package.json +8 -2
- package/src/http-api-controller.ts +10 -1
- package/src/http-static-controller.ts +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.14.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- dded615: Templated definitions can now produce a mountable HTTP surface, and their dispatch targets are created once instead of per call.
|
|
8
|
+
|
|
9
|
+
- **`mount:` template dispatch** — a `Telo.Definition` with `capability: Telo.Mount` may declare `mount: <child>` (sibling to `invoke:` / `run:` / `provide:`) naming a `resources:` entry that is itself a `Telo.Mount` (e.g. an `Http.Api`). The template instance's `register()` delegates to that persistent child, so a library can ship a self-contained, declarative HTTP resource. The analyzer validates the new field (`MOUNT_ON_NON_MOUNT`, `MOUNT_DISPATCHER_CONFLICT`, `MOUNT_TARGET_UNKNOWN`, `MOUNT_TARGET_NOT_MOUNTABLE`).
|
|
10
|
+
- **Persistent dispatch targets** — the template controller no longer re-creates its `invoke:` / `run:` / `provide:` target on every call (`withEphemeral` is removed). Every `resources:` entry is created once at `init()` and reused; per-call data flows exclusively through the top-level `inputs:` sibling. A resource body may reference only `self`; `${{ inputs.* }}` inside a target body is no longer supported (move it to the top-level `inputs:`).
|
|
11
|
+
- **Library-scoped child resolution** — a template's `resources:` are spawned in a child context rooted on the _defining_ library's module context (new `EvaluationContext.spawnChildContext()`), so their internal kind aliases and `!ref`s resolve against the library's own imports rather than the consumer's.
|
|
12
|
+
- **http-server** — a route declared at `/` now sits at the mount root (`/todos` + `/` → `/todos`) instead of a trailing-slash variant Fastify treats as a distinct, unmatched URL, so collection-style mounts respond at the mount path itself.
|
|
13
|
+
- @telorun/http-dispatch@0.4.1
|
|
14
|
+
|
|
15
|
+
## 0.14.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 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).
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- @telorun/http-dispatch@0.4.1
|
|
24
|
+
|
|
3
25
|
## 0.13.0
|
|
4
26
|
|
|
5
27
|
### 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
|
|
|
@@ -55,7 +55,7 @@ export class HttpServerApi {
|
|
|
55
55
|
const handlerRef = this.handlerRefs.get(route);
|
|
56
56
|
const handlerKind = handlerRef?.kind ?? "";
|
|
57
57
|
const handlerName = handlerRef?.name ?? "";
|
|
58
|
-
const translatedPath = prefix
|
|
58
|
+
const translatedPath = joinMountPath(prefix, translateOpenApiPath(route.request.path));
|
|
59
59
|
const schema = { response: {} };
|
|
60
60
|
// A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
|
|
61
61
|
// server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
|
|
@@ -205,6 +205,15 @@ function normalizeMountPrefix(prefix) {
|
|
|
205
205
|
return "";
|
|
206
206
|
return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
207
207
|
}
|
|
208
|
+
/** Join a normalized mount prefix with a translated route path. A collection
|
|
209
|
+
* route declared at `/` sits at the mount root itself (`/todos` + `/` → `/todos`),
|
|
210
|
+
* not a trailing-slash variant Fastify would treat as a distinct, unmatched URL.
|
|
211
|
+
* An empty prefix (root mount) keeps `/`. */
|
|
212
|
+
function joinMountPath(prefix, path) {
|
|
213
|
+
if (path === "/")
|
|
214
|
+
return prefix || "/";
|
|
215
|
+
return prefix + path;
|
|
216
|
+
}
|
|
208
217
|
/**
|
|
209
218
|
* Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
|
|
210
219
|
* stream content-type parser (`contentTypeParsers[].stream`) for the request's
|
|
@@ -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.
|
|
3
|
+
"version": "0.14.1",
|
|
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.
|
|
58
|
+
"@telorun/sdk": "0.36.0"
|
|
53
59
|
},
|
|
54
60
|
"peerDependencies": {
|
|
55
61
|
"@telorun/sdk": "*"
|
|
@@ -87,7 +87,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
87
87
|
const handlerRef = this.handlerRefs.get(route as unknown as object);
|
|
88
88
|
const handlerKind = handlerRef?.kind ?? "";
|
|
89
89
|
const handlerName = handlerRef?.name ?? "";
|
|
90
|
-
const translatedPath = prefix
|
|
90
|
+
const translatedPath = joinMountPath(prefix, translateOpenApiPath(route.request.path));
|
|
91
91
|
|
|
92
92
|
const schema: any = { response: {} };
|
|
93
93
|
|
|
@@ -269,6 +269,15 @@ function normalizeMountPrefix(prefix: string): string {
|
|
|
269
269
|
return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/** Join a normalized mount prefix with a translated route path. A collection
|
|
273
|
+
* route declared at `/` sits at the mount root itself (`/todos` + `/` → `/todos`),
|
|
274
|
+
* not a trailing-slash variant Fastify would treat as a distinct, unmatched URL.
|
|
275
|
+
* An empty prefix (root mount) keeps `/`. */
|
|
276
|
+
function joinMountPath(prefix: string, path: string): string {
|
|
277
|
+
if (path === "/") return prefix || "/";
|
|
278
|
+
return prefix + path;
|
|
279
|
+
}
|
|
280
|
+
|
|
272
281
|
/**
|
|
273
282
|
* Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
|
|
274
283
|
* stream content-type parser (`contentTypeParsers[].stream`) for the request's
|
|
@@ -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
|
+
}
|