threadforge 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/forge.js +1050 -0
- package/bin/host-commands.js +344 -0
- package/bin/platform-commands.js +570 -0
- package/package.json +71 -0
- package/shared/auth.js +475 -0
- package/src/core/DirectMessageBus.js +364 -0
- package/src/core/EndpointResolver.js +247 -0
- package/src/core/ForgeContext.js +2227 -0
- package/src/core/ForgeHost.js +122 -0
- package/src/core/ForgePlatform.js +145 -0
- package/src/core/Ingress.js +768 -0
- package/src/core/Interceptors.js +420 -0
- package/src/core/MessageBus.js +310 -0
- package/src/core/Prometheus.js +305 -0
- package/src/core/RequestContext.js +413 -0
- package/src/core/RoutingStrategy.js +316 -0
- package/src/core/Supervisor.js +1306 -0
- package/src/core/ThreadAllocator.js +196 -0
- package/src/core/WorkerChannelManager.js +879 -0
- package/src/core/config.js +624 -0
- package/src/core/host-config.js +311 -0
- package/src/core/network-utils.js +166 -0
- package/src/core/platform-config.js +308 -0
- package/src/decorators/ServiceProxy.js +899 -0
- package/src/decorators/index.js +571 -0
- package/src/deploy/NginxGenerator.js +865 -0
- package/src/deploy/PlatformManifestGenerator.js +96 -0
- package/src/deploy/RouteManifestGenerator.js +112 -0
- package/src/deploy/index.js +984 -0
- package/src/frontend/FrontendDevLifecycle.js +65 -0
- package/src/frontend/FrontendPluginOrchestrator.js +187 -0
- package/src/frontend/SiteResolver.js +63 -0
- package/src/frontend/StaticMountRegistry.js +90 -0
- package/src/frontend/index.js +5 -0
- package/src/frontend/plugins/index.js +2 -0
- package/src/frontend/plugins/viteFrontend.js +79 -0
- package/src/frontend/types.js +35 -0
- package/src/index.js +56 -0
- package/src/internals.js +31 -0
- package/src/plugins/PluginManager.js +537 -0
- package/src/plugins/ScopedPostgres.js +192 -0
- package/src/plugins/ScopedRedis.js +142 -0
- package/src/plugins/index.js +1729 -0
- package/src/registry/ServiceRegistry.js +796 -0
- package/src/scaling/ScaleAdvisor.js +442 -0
- package/src/services/Service.js +195 -0
- package/src/services/worker-bootstrap.js +676 -0
- package/src/templates/auth-service.js +65 -0
- package/src/templates/identity-service.js +75 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nginx Config Generator v2
|
|
5
|
+
*
|
|
6
|
+
* Routes directly to edge services by path prefix.
|
|
7
|
+
* No JS gateway in the middle.
|
|
8
|
+
*
|
|
9
|
+
* /api/users/* → users service (port 3001)
|
|
10
|
+
* /api/billing/* → billing service (port 3002)
|
|
11
|
+
* /auth/* → auth service (port 3003)
|
|
12
|
+
*
|
|
13
|
+
* Each service can be on multiple machines (multiple upstreams).
|
|
14
|
+
* nginx load-balances across them with least_conn.
|
|
15
|
+
*
|
|
16
|
+
* If a service has type: 'edge' and a prefix, nginx routes
|
|
17
|
+
* that path to it. If no prefix, it's a catch-all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SAFE_DOMAIN_RE = /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/;
|
|
21
|
+
const SAFE_PREFIX_RE = /^\/[a-zA-Z0-9\/_-]*$/;
|
|
22
|
+
const SAFE_SERVICE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
23
|
+
|
|
24
|
+
function resolveStaticPath(staticPath, label = "staticDir") {
|
|
25
|
+
if (typeof staticPath !== "string" || staticPath.trim() === "") {
|
|
26
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
27
|
+
}
|
|
28
|
+
if (staticPath.includes("\0")) {
|
|
29
|
+
throw new Error(`${label} contains an invalid null byte`);
|
|
30
|
+
}
|
|
31
|
+
const containsTraversal = staticPath
|
|
32
|
+
.replace(/\\/g, "/")
|
|
33
|
+
.split("/")
|
|
34
|
+
.some((segment) => segment === "..");
|
|
35
|
+
if (containsTraversal) {
|
|
36
|
+
throw new Error(`${label} must not include path traversal segments`);
|
|
37
|
+
}
|
|
38
|
+
return path.resolve(staticPath).replace(/\/+$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateDomain(domain) {
|
|
42
|
+
if (domain && !SAFE_DOMAIN_RE.test(domain)) {
|
|
43
|
+
throw new Error(`Invalid domain name: "${domain}". Only alphanumeric, dots, and hyphens allowed.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} options
|
|
49
|
+
* @param {string} options.domain
|
|
50
|
+
* @param {Object} options.services - service name → { port, prefix, upstreams: [{host, port}] }
|
|
51
|
+
* @param {Object} [options.ssl] - { cert, key }
|
|
52
|
+
* @param {Object} [options.rateLimits]
|
|
53
|
+
* @param {string} [options.staticDir]
|
|
54
|
+
* @param {boolean} [options.websockets]
|
|
55
|
+
* @param {string[]} [options.websocketPaths] - Array of WebSocket paths (default ['/ws'])
|
|
56
|
+
* @param {{path: string, service: string}[]} [options.websocketRoutes]
|
|
57
|
+
* @param {number} [options.maxBodySize]
|
|
58
|
+
*/
|
|
59
|
+
export function generateNginxConfig(options = {}) {
|
|
60
|
+
const domain = options.domain ?? "localhost";
|
|
61
|
+
validateDomain(domain);
|
|
62
|
+
|
|
63
|
+
const services = options.services ?? {};
|
|
64
|
+
const ssl = options.ssl;
|
|
65
|
+
const maxBodySize = options.maxBodySize ?? 10;
|
|
66
|
+
const rateLimits = options.rateLimits ?? {};
|
|
67
|
+
|
|
68
|
+
let config = `# nginx.conf — Auto-generated by ThreadForge
|
|
69
|
+
# Domain: ${domain}
|
|
70
|
+
# Edge services: ${Object.keys(services).length}
|
|
71
|
+
#
|
|
72
|
+
# nginx routes directly to edge services — no JS gateway bottleneck.
|
|
73
|
+
# Each service handles its own routes. Cross-service calls go via IPC/HTTP.
|
|
74
|
+
|
|
75
|
+
worker_processes auto;
|
|
76
|
+
worker_rlimit_nofile 65535;
|
|
77
|
+
|
|
78
|
+
events {
|
|
79
|
+
worker_connections 16384;
|
|
80
|
+
multi_accept on;
|
|
81
|
+
# Nginx auto-selects the optimal event method (epoll on Linux, kqueue on BSD)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
http {
|
|
85
|
+
sendfile on;
|
|
86
|
+
tcp_nopush on;
|
|
87
|
+
tcp_nodelay on;
|
|
88
|
+
keepalive_timeout 65;
|
|
89
|
+
keepalive_requests 1000;
|
|
90
|
+
types_hash_max_size 2048;
|
|
91
|
+
server_tokens off;
|
|
92
|
+
include /etc/nginx/mime.types;
|
|
93
|
+
default_type application/json;
|
|
94
|
+
|
|
95
|
+
# Structured JSON logging
|
|
96
|
+
log_format json escape=json '{'
|
|
97
|
+
'"time":"$time_iso8601",'
|
|
98
|
+
'"addr":"$remote_addr",'
|
|
99
|
+
'"method":"$request_method",'
|
|
100
|
+
'"uri":"$request_uri",'
|
|
101
|
+
'"status":$status,'
|
|
102
|
+
'"bytes":$body_bytes_sent,'
|
|
103
|
+
'"ms":$request_time,'
|
|
104
|
+
'"upstream":"$upstream_addr",'
|
|
105
|
+
'"upstream_ms":"$upstream_response_time"'
|
|
106
|
+
'}';
|
|
107
|
+
access_log /var/log/nginx/access.log json;
|
|
108
|
+
error_log /var/log/nginx/error.log warn;
|
|
109
|
+
|
|
110
|
+
gzip on;
|
|
111
|
+
gzip_vary on;
|
|
112
|
+
gzip_proxied any;
|
|
113
|
+
gzip_comp_level 4;
|
|
114
|
+
gzip_types text/plain text/css application/json application/javascript text/xml;
|
|
115
|
+
|
|
116
|
+
# Rate limiting zones
|
|
117
|
+
limit_req_zone $binary_remote_addr zone=general:10m rate=${rateLimits.requestsPerSecond ?? 30}r/s;
|
|
118
|
+
limit_req_zone $binary_remote_addr zone=auth:10m rate=${rateLimits.authPerSecond ?? 5}r/s;
|
|
119
|
+
limit_conn_zone $binary_remote_addr zone=connlimit:10m;
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
// Add real_ip configuration if proxy addresses provided
|
|
123
|
+
if (options.trustedProxies?.length > 0) {
|
|
124
|
+
for (const proxy of options.trustedProxies) {
|
|
125
|
+
config += ` set_real_ip_from ${proxy};\n`;
|
|
126
|
+
}
|
|
127
|
+
config += ` real_ip_header X-Forwarded-For;\n`;
|
|
128
|
+
config += ` real_ip_recursive on;\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
config += `
|
|
132
|
+
|
|
133
|
+
client_max_body_size ${maxBodySize}m;
|
|
134
|
+
client_body_timeout 15s;
|
|
135
|
+
client_header_timeout 15s;
|
|
136
|
+
send_timeout 30s;
|
|
137
|
+
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
// Generate an upstream block for each edge service
|
|
141
|
+
for (const [name, svc] of Object.entries(services)) {
|
|
142
|
+
const safeName = name.replace(/:/g, "_");
|
|
143
|
+
if (!SAFE_SERVICE_NAME_RE.test(safeName)) {
|
|
144
|
+
throw new Error(`Invalid service name for nginx upstream: "${name}"`);
|
|
145
|
+
}
|
|
146
|
+
config += ` # ── ${safeName} service ──\n`;
|
|
147
|
+
config += ` upstream forge_${safeName} {\n`;
|
|
148
|
+
config += ` least_conn;\n`;
|
|
149
|
+
for (const up of svc.upstreams) {
|
|
150
|
+
config += ` server ${up.host}:${up.port} max_fails=3 fail_timeout=30s;\n`;
|
|
151
|
+
}
|
|
152
|
+
config += ` keepalive 32;\n`;
|
|
153
|
+
config += ` keepalive_requests 1000;\n`;
|
|
154
|
+
config += ` keepalive_timeout 60s;\n`;
|
|
155
|
+
config += ` }\n\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// HTTPS redirect
|
|
159
|
+
if (ssl) {
|
|
160
|
+
config += ` server {
|
|
161
|
+
listen 80;
|
|
162
|
+
server_name ${domain};
|
|
163
|
+
return 301 https://$host$request_uri;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Main server block
|
|
170
|
+
config += ` server {\n`;
|
|
171
|
+
if (ssl) {
|
|
172
|
+
config += ` listen 443 ssl http2;\n`;
|
|
173
|
+
config += ` server_name ${domain};\n`;
|
|
174
|
+
config += ` ssl_certificate ${ssl.cert};\n`;
|
|
175
|
+
config += ` ssl_certificate_key ${ssl.key};\n`;
|
|
176
|
+
config += ` ssl_protocols TLSv1.2 TLSv1.3;\n`;
|
|
177
|
+
config += ` ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;\n`;
|
|
178
|
+
config += ` ssl_prefer_server_ciphers off;\n`;
|
|
179
|
+
config += ` ssl_session_cache shared:SSL:10m;\n`;
|
|
180
|
+
} else {
|
|
181
|
+
config += ` listen 80;\n`;
|
|
182
|
+
config += ` server_name ${domain};\n`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
config += `
|
|
186
|
+
# Security headers
|
|
187
|
+
add_header X-Frame-Options "DENY" always;
|
|
188
|
+
add_header X-Content-Type-Options "nosniff" always;
|
|
189
|
+
add_header Content-Security-Policy "frame-ancestors 'none'" always;
|
|
190
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
191
|
+
|
|
192
|
+
# Per-IP connection limit
|
|
193
|
+
limit_conn connlimit ${rateLimits.maxConnsPerIP ?? 100};
|
|
194
|
+
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
// Static files (Bug #6: path traversal + Bug #2: security headers in child block)
|
|
198
|
+
if (options.staticDir) {
|
|
199
|
+
const staticDir = resolveStaticPath(options.staticDir, "staticDir");
|
|
200
|
+
config += ` # Static files (served by nginx, never touches Node)
|
|
201
|
+
location /static/ {
|
|
202
|
+
alias ${staticDir}/;
|
|
203
|
+
add_header Cache-Control "public, max-age=86400";
|
|
204
|
+
add_header X-Content-Type-Options nosniff always;
|
|
205
|
+
add_header X-Frame-Options DENY always;
|
|
206
|
+
add_header Content-Security-Policy "frame-ancestors 'none'" always;
|
|
207
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Route each service by its prefix
|
|
214
|
+
// Sort by prefix length descending so more specific prefixes match first
|
|
215
|
+
const sorted = Object.entries(services)
|
|
216
|
+
.filter(([, s]) => s.prefix)
|
|
217
|
+
.sort((a, b) => (b[1].prefix?.length ?? 0) - (a[1].prefix?.length ?? 0));
|
|
218
|
+
|
|
219
|
+
for (const [name, svc] of sorted) {
|
|
220
|
+
const safeName = name.replace(/:/g, "_");
|
|
221
|
+
if (!SAFE_SERVICE_NAME_RE.test(safeName)) {
|
|
222
|
+
throw new Error(`Invalid service name for nginx config: "${name}"`);
|
|
223
|
+
}
|
|
224
|
+
const prefix = svc.prefix;
|
|
225
|
+
if (prefix && !SAFE_PREFIX_RE.test(prefix)) {
|
|
226
|
+
throw new Error(`Invalid nginx location prefix: "${prefix}". Only alphanumeric, slashes, underscores, and hyphens allowed.`);
|
|
227
|
+
}
|
|
228
|
+
const isAuth = /auth|login|oauth/i.test(prefix);
|
|
229
|
+
const zone = isAuth ? "auth" : "general";
|
|
230
|
+
const burst = isAuth ? (rateLimits.authBurst ?? 10) : (rateLimits.burst ?? 50);
|
|
231
|
+
|
|
232
|
+
config += ` # ${safeName} service → ${prefix}
|
|
233
|
+
location ${prefix} {
|
|
234
|
+
limit_req zone=${zone} burst=${burst} nodelay;
|
|
235
|
+
|
|
236
|
+
proxy_pass http://forge_${safeName};
|
|
237
|
+
proxy_http_version 1.1;
|
|
238
|
+
proxy_set_header Connection "";
|
|
239
|
+
proxy_set_header Host $host;
|
|
240
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
241
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
242
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
243
|
+
proxy_set_header X-Request-ID $request_id;
|
|
244
|
+
|
|
245
|
+
proxy_connect_timeout 5s;
|
|
246
|
+
proxy_read_timeout 30s;
|
|
247
|
+
proxy_buffering on;
|
|
248
|
+
proxy_buffer_size 8k;
|
|
249
|
+
proxy_buffers 8 8k;
|
|
250
|
+
|
|
251
|
+
proxy_next_upstream error timeout http_502 http_503 http_504;
|
|
252
|
+
proxy_next_upstream_tries 2;
|
|
253
|
+
proxy_next_upstream_timeout 5s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// WebSocket routes
|
|
260
|
+
const wsRoutes = Array.isArray(options.websocketRoutes) ? options.websocketRoutes : [];
|
|
261
|
+
if (wsRoutes.length > 0) {
|
|
262
|
+
for (const route of wsRoutes) {
|
|
263
|
+
const wsPath = route.path;
|
|
264
|
+
const wsService = route.service;
|
|
265
|
+
if (!services[wsService]) {
|
|
266
|
+
throw new Error(`WebSocket route "${wsPath}" references unknown nginx service "${wsService}"`);
|
|
267
|
+
}
|
|
268
|
+
if (!SAFE_PREFIX_RE.test(wsPath)) {
|
|
269
|
+
throw new Error(`Invalid websocket path "${wsPath}" for nginx location`);
|
|
270
|
+
}
|
|
271
|
+
config += ` # WebSocket — ${wsPath} -> ${wsService}
|
|
272
|
+
location ${wsPath} {
|
|
273
|
+
proxy_pass http://forge_${wsService};
|
|
274
|
+
proxy_http_version 1.1;
|
|
275
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
276
|
+
proxy_set_header Connection "upgrade";
|
|
277
|
+
proxy_set_header Host $host;
|
|
278
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
279
|
+
proxy_read_timeout 3600s;
|
|
280
|
+
proxy_send_timeout 3600s;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
} else if (options.websockets) {
|
|
286
|
+
// Backward-compat fallback (manifest-level websocket toggles)
|
|
287
|
+
const wsService = Object.keys(services)[0];
|
|
288
|
+
if (!wsService) {
|
|
289
|
+
throw new Error("WebSocket routing requested but no edge services were provided");
|
|
290
|
+
}
|
|
291
|
+
const wsPaths = options.websocketPaths || ['/ws'];
|
|
292
|
+
for (const wsPath of wsPaths) {
|
|
293
|
+
config += ` # WebSocket (legacy) — ${wsPath}
|
|
294
|
+
location ${wsPath} {
|
|
295
|
+
proxy_pass http://forge_${wsService};
|
|
296
|
+
proxy_http_version 1.1;
|
|
297
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
298
|
+
proxy_set_header Connection "upgrade";
|
|
299
|
+
proxy_set_header Host $host;
|
|
300
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
301
|
+
proxy_read_timeout 3600s;
|
|
302
|
+
proxy_send_timeout 3600s;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Catch-all for services without a prefix (legacy gateway pattern)
|
|
310
|
+
const catchAll = Object.entries(services).find(([, s]) => !s.prefix);
|
|
311
|
+
if (catchAll) {
|
|
312
|
+
const [name] = catchAll;
|
|
313
|
+
config += ` # ${name} (catch-all)
|
|
314
|
+
location / {
|
|
315
|
+
limit_req zone=general burst=${rateLimits.burst ?? 50} nodelay;
|
|
316
|
+
|
|
317
|
+
proxy_pass http://forge_${name};
|
|
318
|
+
proxy_http_version 1.1;
|
|
319
|
+
proxy_set_header Connection "";
|
|
320
|
+
proxy_set_header Host $host;
|
|
321
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
322
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
323
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
324
|
+
proxy_set_header X-Request-ID $request_id;
|
|
325
|
+
|
|
326
|
+
proxy_connect_timeout 5s;
|
|
327
|
+
proxy_read_timeout 30s;
|
|
328
|
+
proxy_buffering on;
|
|
329
|
+
|
|
330
|
+
proxy_next_upstream error timeout http_502 http_503 http_504;
|
|
331
|
+
proxy_next_upstream_tries 2;
|
|
332
|
+
proxy_next_upstream_timeout 5s;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Health check (all services — pick the first one)
|
|
339
|
+
const firstService = Object.keys(services)[0];
|
|
340
|
+
config += ` # Health check
|
|
341
|
+
location /health {
|
|
342
|
+
proxy_pass http://forge_${firstService};
|
|
343
|
+
proxy_http_version 1.1;
|
|
344
|
+
proxy_set_header Connection "";
|
|
345
|
+
access_log off;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
`;
|
|
349
|
+
|
|
350
|
+
// Error pages
|
|
351
|
+
config += ` error_page 429 @rate_limited;
|
|
352
|
+
location @rate_limited {
|
|
353
|
+
default_type application/json;
|
|
354
|
+
return 429 '{"error":"Rate limit exceeded","retryAfter":1}';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
error_page 502 503 504 @upstream_error;
|
|
358
|
+
location @upstream_error {
|
|
359
|
+
default_type application/json;
|
|
360
|
+
return 503 '{"error":"Service temporarily unavailable"}';
|
|
361
|
+
}
|
|
362
|
+
`;
|
|
363
|
+
|
|
364
|
+
// Plugin-contributed locations (Redis Commander, pgAdmin, etc.)
|
|
365
|
+
if (options.pluginLocations) {
|
|
366
|
+
for (const loc of options.pluginLocations) {
|
|
367
|
+
// Validate plugin config doesn't contain unbalanced braces or dangerous directives
|
|
368
|
+
const configText = loc.config.trim();
|
|
369
|
+
const openBraces = (configText.match(/\{/g) || []).length;
|
|
370
|
+
const closeBraces = (configText.match(/\}/g) || []).length;
|
|
371
|
+
if (openBraces !== closeBraces) {
|
|
372
|
+
throw new Error(`Plugin location "${loc.path}" has unbalanced braces in config — potential injection`);
|
|
373
|
+
}
|
|
374
|
+
config += `
|
|
375
|
+
# Plugin: ${loc.path}
|
|
376
|
+
location ${loc.path} {
|
|
377
|
+
${configText
|
|
378
|
+
.split("\n")
|
|
379
|
+
.map((l) => l.trim())
|
|
380
|
+
.join("\n ")}
|
|
381
|
+
}
|
|
382
|
+
`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
config += ` }
|
|
387
|
+
}
|
|
388
|
+
`;
|
|
389
|
+
|
|
390
|
+
return config;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Build the nginx service map from a deployment manifest.
|
|
395
|
+
*
|
|
396
|
+
* Scans the manifest and service configs to determine:
|
|
397
|
+
* - Which services are edge (need nginx routing)
|
|
398
|
+
* - Their prefixes
|
|
399
|
+
* - Their upstream addresses (host:port per node)
|
|
400
|
+
*/
|
|
401
|
+
export function buildNginxServiceMap(manifest, serviceConfigs, deployPorts = {}) {
|
|
402
|
+
const services = {};
|
|
403
|
+
|
|
404
|
+
for (const [svcName, svcConfig] of Object.entries(serviceConfigs)) {
|
|
405
|
+
if (svcConfig.type !== "edge") continue;
|
|
406
|
+
|
|
407
|
+
// Default to 3000 if no port specified in deploy overrides or service config.
|
|
408
|
+
// This fallback is common for single-service setups but may be wrong for multi-service deployments.
|
|
409
|
+
const port = deployPorts[svcName] ?? svcConfig.port ?? 3000;
|
|
410
|
+
if (!deployPorts[svcName] && !svcConfig.port) {
|
|
411
|
+
console.warn(`[Deploy] Edge service "${svcName}" has no explicit port — defaulting to 3000. Set a port in your service config or deploy manifest.`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const upstreams = [];
|
|
415
|
+
|
|
416
|
+
// Find all nodes that run this service
|
|
417
|
+
for (const [, node] of Object.entries(manifest.nodes)) {
|
|
418
|
+
if (node.services.includes(svcName)) {
|
|
419
|
+
upstreams.push({
|
|
420
|
+
host: node.host,
|
|
421
|
+
port,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (upstreams.length === 0) {
|
|
427
|
+
console.warn(`[Deploy] Edge service "${svcName}" is not assigned to any node`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
services[svcName] = {
|
|
432
|
+
prefix: svcConfig.prefix ?? null,
|
|
433
|
+
port,
|
|
434
|
+
upstreams,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return services;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Build websocket route mappings from per-service websocket config.
|
|
443
|
+
*
|
|
444
|
+
* Supports service-level config forms:
|
|
445
|
+
* websocket: true
|
|
446
|
+
* websocket: ["/ws", "/ws/chat"]
|
|
447
|
+
* websocket: { enabled: true, paths: ["/ws"] }
|
|
448
|
+
*
|
|
449
|
+
* @param {Object} serviceConfigs
|
|
450
|
+
* @param {Object} nginxServices - output from buildNginxServiceMap
|
|
451
|
+
* @param {Object} [legacy]
|
|
452
|
+
* @param {boolean} [legacy.websockets]
|
|
453
|
+
* @param {string[]} [legacy.websocketPaths]
|
|
454
|
+
* @returns {{path: string, service: string}[]}
|
|
455
|
+
*/
|
|
456
|
+
export function buildNginxWebSocketRoutes(serviceConfigs, nginxServices, legacy = {}) {
|
|
457
|
+
const routeMap = new Map(); // path -> service
|
|
458
|
+
|
|
459
|
+
for (const [svcName, svcConfig] of Object.entries(serviceConfigs)) {
|
|
460
|
+
if (svcConfig.type !== "edge") continue;
|
|
461
|
+
if (!nginxServices[svcName]) continue;
|
|
462
|
+
|
|
463
|
+
const raw = svcConfig.websocket;
|
|
464
|
+
if (!raw) continue;
|
|
465
|
+
|
|
466
|
+
let enabled = true;
|
|
467
|
+
let paths = ["/ws"];
|
|
468
|
+
|
|
469
|
+
if (raw === true) {
|
|
470
|
+
paths = ["/ws"];
|
|
471
|
+
} else if (Array.isArray(raw)) {
|
|
472
|
+
paths = raw;
|
|
473
|
+
} else if (typeof raw === "object") {
|
|
474
|
+
enabled = raw.enabled !== false;
|
|
475
|
+
if (raw.paths !== undefined) paths = raw.paths;
|
|
476
|
+
} else {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!enabled) continue;
|
|
481
|
+
|
|
482
|
+
for (const path of paths) {
|
|
483
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
484
|
+
throw new Error(`Invalid websocket path "${path}" for service "${svcName}"`);
|
|
485
|
+
}
|
|
486
|
+
const existing = routeMap.get(path);
|
|
487
|
+
if (existing && existing !== svcName) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`WebSocket path collision: "${path}" is configured for both "${existing}" and "${svcName}"`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
routeMap.set(path, svcName);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Backward compatibility for manifest-level websocket toggles
|
|
497
|
+
if (routeMap.size === 0 && legacy.websockets) {
|
|
498
|
+
const wsService = Object.keys(nginxServices)[0];
|
|
499
|
+
if (!wsService) {
|
|
500
|
+
throw new Error("WebSocket routes requested but no edge services are available for nginx routing");
|
|
501
|
+
}
|
|
502
|
+
for (const path of legacy.websocketPaths ?? ["/ws"]) {
|
|
503
|
+
routeMap.set(path, wsService);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return [...routeMap.entries()].map(([path, service]) => ({ path, service }));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Generate an nginx config for ForgeHost with per-project server blocks.
|
|
512
|
+
*
|
|
513
|
+
* Each project gets its own `server { server_name ... }` block routing
|
|
514
|
+
* to that project's service upstreams, with X-Forge-Project injected.
|
|
515
|
+
*
|
|
516
|
+
* @param {Object} options
|
|
517
|
+
* @param {Object} options.hostMeta - From resolveHostConfig().hostMeta
|
|
518
|
+
* @param {Object} options.services - Flat service map (namespaced)
|
|
519
|
+
* @param {Object} [options.ssl] - { cert, key } paths for TLS
|
|
520
|
+
* @param {number} [options.maxBodySize] - Max body size in MB (default 10)
|
|
521
|
+
*/
|
|
522
|
+
export function generateHostNginxConfig(options = {}) {
|
|
523
|
+
const { hostMeta, services, ssl, maxBodySize = 10 } = options;
|
|
524
|
+
|
|
525
|
+
let config = `# ForgeHost nginx config — auto-generated
|
|
526
|
+
# Do not edit — regenerate with: forge host generate
|
|
527
|
+
|
|
528
|
+
events {
|
|
529
|
+
worker_connections 16384;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
http {
|
|
533
|
+
gzip on;
|
|
534
|
+
gzip_types text/plain text/css application/json application/javascript text/xml;
|
|
535
|
+
|
|
536
|
+
client_max_body_size ${maxBodySize}m;
|
|
537
|
+
client_body_timeout 15s;
|
|
538
|
+
send_timeout 30s;
|
|
539
|
+
|
|
540
|
+
`;
|
|
541
|
+
|
|
542
|
+
// Generate upstream blocks for all edge services
|
|
543
|
+
for (const [svcName, svc] of Object.entries(services)) {
|
|
544
|
+
if (svc.type !== "edge") continue;
|
|
545
|
+
const safeName = svcName.replace(/:/g, "_");
|
|
546
|
+
config += ` upstream forge_${safeName} {\n`;
|
|
547
|
+
config += ` server 127.0.0.1:${svc.port};\n`;
|
|
548
|
+
config += ` keepalive 16;\n`;
|
|
549
|
+
config += ` }\n\n`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Generate a server block per project
|
|
553
|
+
for (const [projectId, meta] of Object.entries(hostMeta)) {
|
|
554
|
+
if (!meta.domain) continue;
|
|
555
|
+
|
|
556
|
+
// Validate domain (Bug #1)
|
|
557
|
+
validateDomain(meta.domain);
|
|
558
|
+
|
|
559
|
+
const projectServices = Object.entries(services).filter(
|
|
560
|
+
([name]) => meta.services.includes(name),
|
|
561
|
+
);
|
|
562
|
+
const edgeServices = projectServices.filter(([, s]) => s.type === "edge");
|
|
563
|
+
|
|
564
|
+
if (edgeServices.length === 0) continue;
|
|
565
|
+
|
|
566
|
+
config += ` # ── Project: ${projectId} ──\n`;
|
|
567
|
+
config += ` server {\n`;
|
|
568
|
+
|
|
569
|
+
if (ssl) {
|
|
570
|
+
config += ` listen 443 ssl http2;\n`;
|
|
571
|
+
config += ` server_name ${meta.domain};\n`;
|
|
572
|
+
config += ` ssl_certificate ${ssl.cert};\n`;
|
|
573
|
+
config += ` ssl_certificate_key ${ssl.key};\n`;
|
|
574
|
+
} else {
|
|
575
|
+
config += ` listen 80;\n`;
|
|
576
|
+
config += ` server_name ${meta.domain};\n`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
config += `\n`;
|
|
580
|
+
config += ` # Security headers\n`;
|
|
581
|
+
config += ` add_header X-Frame-Options "SAMEORIGIN" always;\n`;
|
|
582
|
+
config += ` add_header X-Content-Type-Options "nosniff" always;\n`;
|
|
583
|
+
config += `\n`;
|
|
584
|
+
|
|
585
|
+
for (const [svcName, svc] of edgeServices) {
|
|
586
|
+
const safeName = svcName.replace(/:/g, "_");
|
|
587
|
+
const prefix = svc.prefix ?? "/";
|
|
588
|
+
|
|
589
|
+
config += ` location ${prefix} {\n`;
|
|
590
|
+
config += ` proxy_pass http://forge_${safeName};\n`;
|
|
591
|
+
config += ` proxy_http_version 1.1;\n`;
|
|
592
|
+
config += ` proxy_set_header Connection "";\n`;
|
|
593
|
+
config += ` proxy_set_header Host $host;\n`;
|
|
594
|
+
config += ` proxy_set_header X-Real-IP $remote_addr;\n`;
|
|
595
|
+
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`;
|
|
596
|
+
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`;
|
|
597
|
+
config += ` proxy_set_header X-Forge-Project ${projectId};\n`;
|
|
598
|
+
config += ` }\n\n`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
config += ` }\n\n`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
config += `}\n`;
|
|
605
|
+
return config;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
import { resolveAppDomains } from "../core/platform-config.js";
|
|
609
|
+
|
|
610
|
+
function validateStaticDir(staticDir, appId) {
|
|
611
|
+
try {
|
|
612
|
+
return resolveStaticPath(staticDir, `static dir for app "${appId}"`);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (/path traversal/i.test(err.message)) {
|
|
615
|
+
throw new Error(`Invalid static dir for app "${appId}": path traversal not allowed`);
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`Invalid static dir for app "${appId}": ${err.message}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizeMountBasePath(basePath = "/") {
|
|
622
|
+
if (!basePath || basePath === "/") return "/";
|
|
623
|
+
let normalized = basePath;
|
|
624
|
+
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
|
625
|
+
if (normalized.length > 1 && normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
626
|
+
return normalized;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function cacheControlFromPolicy(policy = "short") {
|
|
630
|
+
if (policy === "immutable") return "public, max-age=31536000, immutable";
|
|
631
|
+
if (policy === "none") return "no-store";
|
|
632
|
+
return "public, max-age=300";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Generate an nginx config for Platform Mode with per-domain TLS,
|
|
637
|
+
* per-app static dirs, shared auth mount, and X-Forwarded-Host.
|
|
638
|
+
*
|
|
639
|
+
* Each app gets its own `server { server_name ... }` block with
|
|
640
|
+
* individual SSL certificates (defaults to Let's Encrypt paths).
|
|
641
|
+
*
|
|
642
|
+
* @param {Object} options
|
|
643
|
+
* @param {Object} options.apps - Map<appId, { domains, ssl?, static?, staticMounts?, services[] }>
|
|
644
|
+
* @param {Object} options.services - Flat namespaced service map
|
|
645
|
+
* @param {Object} options.hostMeta - From resolveHostConfig().hostMeta
|
|
646
|
+
* @param {Object} [options.defaultSsl] - Shared wildcard cert { cert, key }
|
|
647
|
+
* @param {number} [options.maxBodySize] - Max body size in MB (default 10)
|
|
648
|
+
*/
|
|
649
|
+
export function generatePlatformNginxConfig(options = {}) {
|
|
650
|
+
const { apps, services, hostMeta, defaultSsl, maxBodySize = 10 } = options;
|
|
651
|
+
|
|
652
|
+
// Validate all domains upfront
|
|
653
|
+
for (const app of Object.values(apps ?? {})) {
|
|
654
|
+
const domains = resolveAppDomains(app);
|
|
655
|
+
for (const d of domains) {
|
|
656
|
+
validateDomain(d);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let config = `# ForgePlatform nginx config — auto-generated
|
|
661
|
+
# Do not edit — regenerate with: forge platform generate
|
|
662
|
+
|
|
663
|
+
events {
|
|
664
|
+
worker_connections 16384;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
http {
|
|
668
|
+
gzip on;
|
|
669
|
+
gzip_types text/plain text/css application/json application/javascript text/xml;
|
|
670
|
+
|
|
671
|
+
client_max_body_size ${maxBodySize}m;
|
|
672
|
+
client_body_timeout 15s;
|
|
673
|
+
send_timeout 30s;
|
|
674
|
+
|
|
675
|
+
`;
|
|
676
|
+
|
|
677
|
+
// Generate upstream blocks for all edge services
|
|
678
|
+
for (const [svcName, svc] of Object.entries(services)) {
|
|
679
|
+
if (svc.type !== "edge") continue;
|
|
680
|
+
const safeName = svcName.replace(/:/g, "_");
|
|
681
|
+
config += ` upstream forge_${safeName} {\n`;
|
|
682
|
+
config += ` server 127.0.0.1:${svc.port};\n`;
|
|
683
|
+
config += ` keepalive 16;\n`;
|
|
684
|
+
config += ` }\n\n`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Collect all domains for HTTP->HTTPS redirect
|
|
688
|
+
const allDomains = [];
|
|
689
|
+
for (const app of Object.values(apps ?? {})) {
|
|
690
|
+
allDomains.push(...resolveAppDomains(app));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Shared HTTP→HTTPS redirect block (only if any app has SSL)
|
|
694
|
+
const hasAnySsl = defaultSsl || Object.values(apps ?? {}).some((a) => a.ssl);
|
|
695
|
+
if (hasAnySsl && allDomains.length > 0) {
|
|
696
|
+
config += ` # HTTP → HTTPS redirect (all platform domains)\n`;
|
|
697
|
+
config += ` server {\n`;
|
|
698
|
+
config += ` listen 80;\n`;
|
|
699
|
+
config += ` server_name ${allDomains.join(" ")};\n`;
|
|
700
|
+
config += ` return 301 https://$host$request_uri;\n`;
|
|
701
|
+
config += ` }\n\n`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Find shared auth service (if any)
|
|
705
|
+
const authService = Object.entries(services).find(
|
|
706
|
+
([name, svc]) => (name === "auth" || name.endsWith(":auth")) && svc.type === "edge",
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// Generate a server block per app
|
|
710
|
+
for (const [appId, app] of Object.entries(apps ?? {})) {
|
|
711
|
+
const domains = resolveAppDomains(app);
|
|
712
|
+
if (domains.length === 0) continue;
|
|
713
|
+
|
|
714
|
+
const meta = hostMeta?.[appId];
|
|
715
|
+
const projectServices = Object.entries(services).filter(
|
|
716
|
+
([name]) => meta?.services?.includes(name),
|
|
717
|
+
);
|
|
718
|
+
const edgeServices = projectServices.filter(([, s]) => s.type === "edge");
|
|
719
|
+
|
|
720
|
+
config += ` # ── App: ${appId} ──\n`;
|
|
721
|
+
config += ` server {\n`;
|
|
722
|
+
|
|
723
|
+
const ssl = app.ssl ?? defaultSsl;
|
|
724
|
+
if (ssl) {
|
|
725
|
+
const certPath = ssl.cert ?? `/etc/letsencrypt/live/${domains[0]}/fullchain.pem`;
|
|
726
|
+
const keyPath = ssl.key ?? `/etc/letsencrypt/live/${domains[0]}/privkey.pem`;
|
|
727
|
+
config += ` listen 443 ssl http2;\n`;
|
|
728
|
+
config += ` server_name ${domains.join(" ")};\n`;
|
|
729
|
+
config += ` ssl_certificate ${certPath};\n`;
|
|
730
|
+
config += ` ssl_certificate_key ${keyPath};\n`;
|
|
731
|
+
config += ` ssl_protocols TLSv1.2 TLSv1.3;\n`;
|
|
732
|
+
} else {
|
|
733
|
+
config += ` listen 80;\n`;
|
|
734
|
+
config += ` server_name ${domains.join(" ")};\n`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
config += `\n`;
|
|
738
|
+
config += ` # Security headers\n`;
|
|
739
|
+
config += ` add_header X-Frame-Options "SAMEORIGIN" always;\n`;
|
|
740
|
+
config += ` add_header X-Content-Type-Options "nosniff" always;\n`;
|
|
741
|
+
config += `\n`;
|
|
742
|
+
|
|
743
|
+
const rootEdgeServices = edgeServices.filter(([, svc]) => (svc.prefix ?? "/") === "/");
|
|
744
|
+
if (rootEdgeServices.length > 1) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`App "${appId}" has multiple edge services mounted at "/". ` +
|
|
747
|
+
`Only one root edge prefix is supported per app.`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const rootEdgeService = rootEdgeServices[0] ?? null;
|
|
751
|
+
const rootEdgeSafeName = rootEdgeService ? rootEdgeService[0].replace(/:/g, "_") : null;
|
|
752
|
+
let rootProxyViaNamedLocation = false;
|
|
753
|
+
let rootStaticMountDefined = false;
|
|
754
|
+
|
|
755
|
+
// Prefer explicit staticMounts (frontend plugin output), fallback to legacy static.
|
|
756
|
+
const staticMounts = Array.isArray(app.staticMounts) && app.staticMounts.length > 0
|
|
757
|
+
? app.staticMounts
|
|
758
|
+
: (app.static
|
|
759
|
+
? [{
|
|
760
|
+
dir: app.static,
|
|
761
|
+
basePath: "/static",
|
|
762
|
+
spaFallback: false,
|
|
763
|
+
cachePolicy: "short",
|
|
764
|
+
}]
|
|
765
|
+
: []);
|
|
766
|
+
|
|
767
|
+
for (const mount of staticMounts) {
|
|
768
|
+
const safeStatic = validateStaticDir(mount.dir, appId);
|
|
769
|
+
const basePath = normalizeMountBasePath(mount.basePath ?? "/");
|
|
770
|
+
const cacheControl = cacheControlFromPolicy(mount.cachePolicy);
|
|
771
|
+
const spaFallback = Boolean(mount.spaFallback);
|
|
772
|
+
|
|
773
|
+
if (basePath === "/") {
|
|
774
|
+
if (rootStaticMountDefined) {
|
|
775
|
+
throw new Error(`App "${appId}" defines multiple static mounts at "/"`);
|
|
776
|
+
}
|
|
777
|
+
rootStaticMountDefined = true;
|
|
778
|
+
config += ` location / {\n`;
|
|
779
|
+
config += ` root ${safeStatic};\n`;
|
|
780
|
+
if (rootEdgeSafeName) {
|
|
781
|
+
rootProxyViaNamedLocation = true;
|
|
782
|
+
if (spaFallback) {
|
|
783
|
+
config += ` try_files $uri $uri/ /index.html @forge_${rootEdgeSafeName}_root;\n`;
|
|
784
|
+
} else {
|
|
785
|
+
config += ` try_files $uri $uri/ @forge_${rootEdgeSafeName}_root;\n`;
|
|
786
|
+
}
|
|
787
|
+
} else {
|
|
788
|
+
if (spaFallback) {
|
|
789
|
+
config += ` try_files $uri $uri/ /index.html;\n`;
|
|
790
|
+
} else {
|
|
791
|
+
config += ` try_files $uri $uri/ =404;\n`;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
config += ` add_header Cache-Control "${cacheControl}";\n`;
|
|
795
|
+
config += ` add_header X-Content-Type-Options "nosniff" always;\n`;
|
|
796
|
+
config += ` }\n\n`;
|
|
797
|
+
} else {
|
|
798
|
+
config += ` location ${basePath}/ {\n`;
|
|
799
|
+
config += ` alias ${safeStatic}/;\n`;
|
|
800
|
+
if (spaFallback) {
|
|
801
|
+
config += ` try_files $uri $uri/ ${basePath}/index.html;\n`;
|
|
802
|
+
} else {
|
|
803
|
+
config += ` try_files $uri $uri/ =404;\n`;
|
|
804
|
+
}
|
|
805
|
+
config += ` add_header Cache-Control "${cacheControl}";\n`;
|
|
806
|
+
config += ` add_header X-Content-Type-Options "nosniff" always;\n`;
|
|
807
|
+
config += ` }\n\n`;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Shared auth mount (if auth service exists and this isn't the auth service itself)
|
|
812
|
+
if (authService) {
|
|
813
|
+
const [authName] = authService;
|
|
814
|
+
const safeAuthName = authName.replace(/:/g, "_");
|
|
815
|
+
config += ` location /auth/ {\n`;
|
|
816
|
+
config += ` proxy_pass http://forge_${safeAuthName};\n`;
|
|
817
|
+
config += ` proxy_http_version 1.1;\n`;
|
|
818
|
+
config += ` proxy_set_header Connection "";\n`;
|
|
819
|
+
config += ` proxy_set_header Host $host;\n`;
|
|
820
|
+
config += ` proxy_set_header X-Forwarded-Host $host;\n`;
|
|
821
|
+
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`;
|
|
822
|
+
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`;
|
|
823
|
+
config += ` proxy_set_header X-Forge-Project ${appId};\n`;
|
|
824
|
+
config += ` }\n\n`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// App-specific edge service routes
|
|
828
|
+
for (const [svcName, svc] of edgeServices) {
|
|
829
|
+
const safeName = svcName.replace(/:/g, "_");
|
|
830
|
+
const prefix = svc.prefix ?? "/";
|
|
831
|
+
|
|
832
|
+
if (prefix === "/" && rootProxyViaNamedLocation) {
|
|
833
|
+
config += ` location @forge_${safeName}_root {\n`;
|
|
834
|
+
config += ` proxy_pass http://forge_${safeName};\n`;
|
|
835
|
+
config += ` proxy_http_version 1.1;\n`;
|
|
836
|
+
config += ` proxy_set_header Connection "";\n`;
|
|
837
|
+
config += ` proxy_set_header Host $host;\n`;
|
|
838
|
+
config += ` proxy_set_header X-Real-IP $remote_addr;\n`;
|
|
839
|
+
config += ` proxy_set_header X-Forwarded-Host $host;\n`;
|
|
840
|
+
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`;
|
|
841
|
+
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`;
|
|
842
|
+
config += ` proxy_set_header X-Forge-Project ${appId};\n`;
|
|
843
|
+
config += ` }\n\n`;
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
config += ` location ${prefix} {\n`;
|
|
848
|
+
config += ` proxy_pass http://forge_${safeName};\n`;
|
|
849
|
+
config += ` proxy_http_version 1.1;\n`;
|
|
850
|
+
config += ` proxy_set_header Connection "";\n`;
|
|
851
|
+
config += ` proxy_set_header Host $host;\n`;
|
|
852
|
+
config += ` proxy_set_header X-Real-IP $remote_addr;\n`;
|
|
853
|
+
config += ` proxy_set_header X-Forwarded-Host $host;\n`;
|
|
854
|
+
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`;
|
|
855
|
+
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`;
|
|
856
|
+
config += ` proxy_set_header X-Forge-Project ${appId};\n`;
|
|
857
|
+
config += ` }\n\n`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
config += ` }\n\n`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
config += `}\n`;
|
|
864
|
+
return config;
|
|
865
|
+
}
|