@telorun/http-server 0.12.0 → 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 +20 -0
- package/README.md +9 -6
- package/dist/http-api-controller.js +24 -12
- package/dist/http-server-controller.d.ts +1 -0
- package/dist/http-server-controller.js +14 -15
- package/package.json +2 -2
- package/src/http-api-controller.ts +24 -14
- package/src/http-server-controller.ts +15 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 0.12.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
220
|
-
| `trustForwardedHeaders: true` | `<X-Forwarded-Proto>://<X-Forwarded-Host
|
|
221
|
-
| neither (default) |
|
|
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.
|
|
@@ -35,28 +35,27 @@ export class HttpServerApi {
|
|
|
35
35
|
}
|
|
36
36
|
async init() { }
|
|
37
37
|
register(app, prefix = "") {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
// Server URL precedence: an explicit `baseUrl` is an absolute, fixed
|
|
102
|
-
// override; otherwise the
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
const servers =
|
|
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 =
|
|
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.
|
|
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.
|
|
52
|
+
"@telorun/sdk": "0.33.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
|
@@ -65,32 +65,29 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
65
65
|
async init() {}
|
|
66
66
|
|
|
67
67
|
register(app: FastifyInstance, prefix = "") {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
// Server URL precedence: an explicit `baseUrl` is an absolute, fixed
|
|
184
|
-
// override; otherwise the
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
const servers =
|
|
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 =
|
|
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;
|