@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 +10 -0
- package/README.md +3 -0
- 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-static-controller.ts +113 -0
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.
|
|
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.
|
|
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
|
+
}
|