@telorun/http-server 0.10.0 → 0.11.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 +6 -0
- package/README.md +25 -2
- package/dist/http-server-controller.d.ts +1 -0
- package/dist/http-server-controller.js +51 -10
- package/package.json +2 -2
- package/src/http-server-controller.ts +50 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b1dd65c: `Http.Server`: the generated OpenAPI `servers` URL now defaults to a **relative** path (the mount prefix), so the reference UI and spec are correct behind any proxy/ingress/origin with no configuration — fixing the previous hardcoded `http://<bind-host>:<port>` that didn't match the reachable URL. Add an opt-in `trustForwardedHeaders` boolean: when enabled the server honors the standard `X-Forwarded-Proto` / `X-Forwarded-Host` headers and advertises an absolute `servers` URL (and request protocol/host) matching the fronting proxy. An explicit `baseUrl` still overrides both.
|
|
8
|
+
|
|
3
9
|
## 0.10.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
|
|
|
24
24
|
kind: Telo.Application
|
|
25
25
|
metadata: { name: hello-http, version: 1.0.0 }
|
|
26
26
|
imports:
|
|
27
|
-
Http: std/http-server@0.
|
|
28
|
-
JS: std/javascript@0.
|
|
27
|
+
Http: std/http-server@0.11.0
|
|
28
|
+
JS: std/javascript@0.5.0
|
|
29
29
|
targets: [ !ref Server ]
|
|
30
30
|
---
|
|
31
31
|
kind: Http.Server
|
|
@@ -206,3 +206,26 @@ request:
|
|
|
206
206
|
description: "Validation schema for the request payload"
|
|
207
207
|
required: ["path", "method"]
|
|
208
208
|
```
|
|
209
|
+
|
|
210
|
+
### 5. External URL & OpenAPI `servers`
|
|
211
|
+
|
|
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 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
|
+
|
|
217
|
+
| Manifest | `servers[].url` |
|
|
218
|
+
| --- | --- |
|
|
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 |
|
|
222
|
+
|
|
223
|
+
- The default is **relative** so the document is correct behind any proxy, ingress,
|
|
224
|
+
or origin with zero configuration.
|
|
225
|
+
- `trustForwardedHeaders` is a **boolean** on purpose: the only portable cross-runtime
|
|
226
|
+
signal is the standard `X-Forwarded-Proto` / `X-Forwarded-Host` (RFC 7239 `Forwarded`)
|
|
227
|
+
headers. Fine-grained "trusted proxy IP/CIDR/hop" lists are framework-specific and
|
|
228
|
+
MUST NOT leak into the manifest. Default `false`; only enable behind a trusted proxy
|
|
229
|
+
(a client with direct network access could otherwise spoof the headers).
|
|
230
|
+
- When `trustForwardedHeaders` is set, the request protocol/host exposed to handlers
|
|
231
|
+
MUST also reflect the forwarded headers.
|
|
@@ -13,6 +13,7 @@ class HttpServer {
|
|
|
13
13
|
host;
|
|
14
14
|
port;
|
|
15
15
|
baseUrl;
|
|
16
|
+
trustForwardedHeaders;
|
|
16
17
|
resource;
|
|
17
18
|
ctx;
|
|
18
19
|
resolvedNotFoundHandler;
|
|
@@ -22,12 +23,16 @@ class HttpServer {
|
|
|
22
23
|
this.host = resource.host || "0.0.0.0";
|
|
23
24
|
this.port = Number(resource.port || 0);
|
|
24
25
|
this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
|
|
26
|
+
this.trustForwardedHeaders = resource.trustForwardedHeaders === true;
|
|
25
27
|
this.resolvedNotFoundHandler = resolvedNotFoundHandler;
|
|
26
28
|
if (!this.port) {
|
|
27
29
|
throw new Error("Http.Server port is required");
|
|
28
30
|
}
|
|
29
31
|
this.app = Fastify({
|
|
30
32
|
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,
|
|
31
36
|
ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
|
|
32
37
|
});
|
|
33
38
|
}
|
|
@@ -91,16 +96,15 @@ class HttpServer {
|
|
|
91
96
|
throw error;
|
|
92
97
|
});
|
|
93
98
|
if (this.resource.openapi) {
|
|
94
|
-
const servers = [];
|
|
95
|
-
// const routesByName = new Map<string, HttpRouteResource>();
|
|
96
99
|
const mounts = this.resource.mounts || [];
|
|
97
|
-
const prefixes = new Set();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}));
|
|
104
108
|
await this.app.register(swagger, {
|
|
105
109
|
openapi: {
|
|
106
110
|
openapi: "3.0.0",
|
|
@@ -108,8 +112,45 @@ class HttpServer {
|
|
|
108
112
|
servers,
|
|
109
113
|
},
|
|
110
114
|
});
|
|
115
|
+
const referencePrefix = "/reference";
|
|
116
|
+
// `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
|
|
117
|
+
// default to absolute URLs built per-request from the now-trusted
|
|
118
|
+
// X-Forwarded-* headers, so the served spec advertises the real proxy URL.
|
|
119
|
+
if (this.trustForwardedHeaders && !this.resource.baseUrl) {
|
|
120
|
+
// Couples to the Scalar plugin's default spec endpoint
|
|
121
|
+
// (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
|
|
122
|
+
// rewrite would no-op and the relative default would still apply. The
|
|
123
|
+
// `openapi-server-url` integration test guards this path.
|
|
124
|
+
const specPath = `${referencePrefix}/openapi.json`;
|
|
125
|
+
this.app.addHook("onSend", async (request, reply, payload) => {
|
|
126
|
+
if (request.url.split("?")[0] !== specPath)
|
|
127
|
+
return payload;
|
|
128
|
+
const text = typeof payload === "string"
|
|
129
|
+
? payload
|
|
130
|
+
: Buffer.isBuffer(payload)
|
|
131
|
+
? payload.toString("utf8")
|
|
132
|
+
: null;
|
|
133
|
+
if (text === null)
|
|
134
|
+
return payload;
|
|
135
|
+
try {
|
|
136
|
+
const doc = JSON.parse(text);
|
|
137
|
+
if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
|
|
138
|
+
doc.servers = prefixes.map((prefix) => ({
|
|
139
|
+
url: `${request.protocol}://${request.host}${prefix}`,
|
|
140
|
+
}));
|
|
141
|
+
const out = JSON.stringify(doc);
|
|
142
|
+
reply.header("content-length", Buffer.byteLength(out));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Not a JSON document we can rewrite — leave the response untouched.
|
|
148
|
+
}
|
|
149
|
+
return payload;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
111
152
|
await this.app.register(apiReference, {
|
|
112
|
-
routePrefix:
|
|
153
|
+
routePrefix: referencePrefix,
|
|
113
154
|
});
|
|
114
155
|
}
|
|
115
156
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.26.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
|
@@ -45,6 +45,7 @@ type HttpServerResource = RuntimeResource & {
|
|
|
45
45
|
host?: string;
|
|
46
46
|
port?: number;
|
|
47
47
|
baseUrl?: string;
|
|
48
|
+
trustForwardedHeaders?: boolean;
|
|
48
49
|
logger?: boolean;
|
|
49
50
|
cors?: CorsOptions;
|
|
50
51
|
contentTypeParsers?: Array<{ contentType: string; parser?: Invocable; stream?: boolean }>;
|
|
@@ -83,6 +84,7 @@ class HttpServer implements ResourceInstance {
|
|
|
83
84
|
private readonly host: string;
|
|
84
85
|
private readonly port: number;
|
|
85
86
|
private readonly baseUrl: string;
|
|
87
|
+
private readonly trustForwardedHeaders: boolean;
|
|
86
88
|
private readonly resource: HttpServerResource;
|
|
87
89
|
private readonly ctx: ResourceContext;
|
|
88
90
|
private readonly resolvedNotFoundHandler: ResolvedHandler | null;
|
|
@@ -97,6 +99,7 @@ class HttpServer implements ResourceInstance {
|
|
|
97
99
|
this.host = resource.host || "0.0.0.0";
|
|
98
100
|
this.port = Number(resource.port || 0);
|
|
99
101
|
this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
|
|
102
|
+
this.trustForwardedHeaders = resource.trustForwardedHeaders === true;
|
|
100
103
|
this.resolvedNotFoundHandler = resolvedNotFoundHandler;
|
|
101
104
|
|
|
102
105
|
if (!this.port) {
|
|
@@ -104,6 +107,9 @@ class HttpServer implements ResourceInstance {
|
|
|
104
107
|
}
|
|
105
108
|
this.app = Fastify({
|
|
106
109
|
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,
|
|
107
113
|
ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
|
|
108
114
|
});
|
|
109
115
|
}
|
|
@@ -172,16 +178,15 @@ class HttpServer implements ResourceInstance {
|
|
|
172
178
|
throw error;
|
|
173
179
|
});
|
|
174
180
|
if (this.resource.openapi) {
|
|
175
|
-
const servers = [];
|
|
176
|
-
// const routesByName = new Map<string, HttpRouteResource>();
|
|
177
181
|
const mounts = this.resource.mounts || [];
|
|
178
|
-
const prefixes = new Set();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
190
|
await this.app.register(swagger, {
|
|
186
191
|
openapi: {
|
|
187
192
|
openapi: "3.0.0",
|
|
@@ -189,8 +194,43 @@ class HttpServer implements ResourceInstance {
|
|
|
189
194
|
servers,
|
|
190
195
|
},
|
|
191
196
|
});
|
|
197
|
+
const referencePrefix = "/reference";
|
|
198
|
+
// `trustForwardedHeaders` (and no fixed baseUrl) upgrades the relative
|
|
199
|
+
// default to absolute URLs built per-request from the now-trusted
|
|
200
|
+
// X-Forwarded-* headers, so the served spec advertises the real proxy URL.
|
|
201
|
+
if (this.trustForwardedHeaders && !this.resource.baseUrl) {
|
|
202
|
+
// Couples to the Scalar plugin's default spec endpoint
|
|
203
|
+
// (`<routePrefix>/openapi.json`); if it ever served the doc elsewhere the
|
|
204
|
+
// rewrite would no-op and the relative default would still apply. The
|
|
205
|
+
// `openapi-server-url` integration test guards this path.
|
|
206
|
+
const specPath = `${referencePrefix}/openapi.json`;
|
|
207
|
+
this.app.addHook("onSend", async (request, reply, payload) => {
|
|
208
|
+
if (request.url.split("?")[0] !== specPath) return payload;
|
|
209
|
+
const text =
|
|
210
|
+
typeof payload === "string"
|
|
211
|
+
? payload
|
|
212
|
+
: Buffer.isBuffer(payload)
|
|
213
|
+
? payload.toString("utf8")
|
|
214
|
+
: null;
|
|
215
|
+
if (text === null) return payload;
|
|
216
|
+
try {
|
|
217
|
+
const doc = JSON.parse(text);
|
|
218
|
+
if (doc && typeof doc === "object" && Array.isArray(doc.servers)) {
|
|
219
|
+
doc.servers = prefixes.map((prefix) => ({
|
|
220
|
+
url: `${request.protocol}://${request.host}${prefix}`,
|
|
221
|
+
}));
|
|
222
|
+
const out = JSON.stringify(doc);
|
|
223
|
+
reply.header("content-length", Buffer.byteLength(out));
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Not a JSON document we can rewrite — leave the response untouched.
|
|
228
|
+
}
|
|
229
|
+
return payload;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
192
232
|
await this.app.register(apiReference, {
|
|
193
|
-
routePrefix:
|
|
233
|
+
routePrefix: referencePrefix,
|
|
194
234
|
});
|
|
195
235
|
}
|
|
196
236
|
}
|