mates-fullstack 1.0.0-beta.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/README.md +311 -0
- package/dist/arctic-auth.d.ts +101 -0
- package/dist/arctic-auth.d.ts.map +1 -0
- package/dist/arctic-auth.js +538 -0
- package/dist/arctic-auth.js.map +1 -0
- package/dist/asset-manifest.d.ts +14 -0
- package/dist/asset-manifest.d.ts.map +1 -0
- package/dist/asset-manifest.js +102 -0
- package/dist/asset-manifest.js.map +1 -0
- package/dist/browser.d.ts +18 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +25 -0
- package/dist/browser.js.map +1 -0
- package/dist/build-esbuild.d.ts +29 -0
- package/dist/build-esbuild.d.ts.map +1 -0
- package/dist/build-esbuild.js +699 -0
- package/dist/build-esbuild.js.map +1 -0
- package/dist/build-prod.d.ts +126 -0
- package/dist/build-prod.d.ts.map +1 -0
- package/dist/build-prod.js +1014 -0
- package/dist/build-prod.js.map +1 -0
- package/dist/cli-new.d.ts +14 -0
- package/dist/cli-new.d.ts.map +1 -0
- package/dist/cli-new.js +637 -0
- package/dist/cli-new.js.map +1 -0
- package/dist/client.d.ts +43 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +130 -0
- package/dist/client.js.map +1 -0
- package/dist/cors.d.ts +16 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +60 -0
- package/dist/cors.js.map +1 -0
- package/dist/ctx.d.ts +78 -0
- package/dist/ctx.d.ts.map +1 -0
- package/dist/ctx.js +280 -0
- package/dist/ctx.js.map +1 -0
- package/dist/dev-watcher.d.ts +23 -0
- package/dist/dev-watcher.d.ts.map +1 -0
- package/dist/dev-watcher.js +136 -0
- package/dist/dev-watcher.js.map +1 -0
- package/dist/docs-generator.d.ts +69 -0
- package/dist/docs-generator.d.ts.map +1 -0
- package/dist/docs-generator.js +557 -0
- package/dist/docs-generator.js.map +1 -0
- package/dist/docs-page.d.ts +20 -0
- package/dist/docs-page.d.ts.map +1 -0
- package/dist/docs-page.js +1152 -0
- package/dist/docs-page.js.map +1 -0
- package/dist/download.d.ts +78 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +202 -0
- package/dist/download.js.map +1 -0
- package/dist/env-loader.d.ts +76 -0
- package/dist/env-loader.d.ts.map +1 -0
- package/dist/env-loader.js +213 -0
- package/dist/env-loader.js.map +1 -0
- package/dist/errors.d.ts +146 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +386 -0
- package/dist/errors.js.map +1 -0
- package/dist/head.d.ts +31 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +245 -0
- package/dist/head.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/internal-prefixes.d.ts +16 -0
- package/dist/internal-prefixes.d.ts.map +1 -0
- package/dist/internal-prefixes.js +16 -0
- package/dist/internal-prefixes.js.map +1 -0
- package/dist/internal.d.ts +25 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +25 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +166 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +261 -0
- package/dist/jwt.js.map +1 -0
- package/dist/log.d.ts +44 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +66 -0
- package/dist/log.js.map +1 -0
- package/dist/logger.d.ts +76 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +138 -0
- package/dist/logger.js.map +1 -0
- package/dist/main-runner.d.ts +59 -0
- package/dist/main-runner.d.ts.map +1 -0
- package/dist/main-runner.js +157 -0
- package/dist/main-runner.js.map +1 -0
- package/dist/mates-auth.d.ts +82 -0
- package/dist/mates-auth.d.ts.map +1 -0
- package/dist/mates-auth.js +323 -0
- package/dist/mates-auth.js.map +1 -0
- package/dist/middleware.d.ts +30 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +67 -0
- package/dist/middleware.js.map +1 -0
- package/dist/project-resolver.d.ts +102 -0
- package/dist/project-resolver.d.ts.map +1 -0
- package/dist/project-resolver.js +271 -0
- package/dist/project-resolver.js.map +1 -0
- package/dist/rate-limit.d.ts +37 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +109 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect.d.ts +84 -0
- package/dist/redirect.d.ts.map +1 -0
- package/dist/redirect.js +105 -0
- package/dist/redirect.js.map +1 -0
- package/dist/renderer.d.ts +91 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +630 -0
- package/dist/renderer.js.map +1 -0
- package/dist/request-logger.d.ts +12 -0
- package/dist/request-logger.d.ts.map +1 -0
- package/dist/request-logger.js +55 -0
- package/dist/request-logger.js.map +1 -0
- package/dist/rest.d.ts +25 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +93 -0
- package/dist/rest.js.map +1 -0
- package/dist/router.d.ts +71 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +222 -0
- package/dist/router.js.map +1 -0
- package/dist/rpc-registry.d.ts +84 -0
- package/dist/rpc-registry.d.ts.map +1 -0
- package/dist/rpc-registry.js +271 -0
- package/dist/rpc-registry.js.map +1 -0
- package/dist/rpc-runner.d.ts +82 -0
- package/dist/rpc-runner.d.ts.map +1 -0
- package/dist/rpc-runner.js +564 -0
- package/dist/rpc-runner.js.map +1 -0
- package/dist/sanitize.d.ts +61 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +193 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/security-headers.d.ts +114 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +121 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/server-fn.d.ts +323 -0
- package/dist/server-fn.d.ts.map +1 -0
- package/dist/server-fn.js +373 -0
- package/dist/server-fn.js.map +1 -0
- package/dist/server-public.d.ts +13 -0
- package/dist/server-public.d.ts.map +1 -0
- package/dist/server-public.js +12 -0
- package/dist/server-public.js.map +1 -0
- package/dist/server-timeout.d.ts +38 -0
- package/dist/server-timeout.d.ts.map +1 -0
- package/dist/server-timeout.js +46 -0
- package/dist/server-timeout.js.map +1 -0
- package/dist/server.d.ts +100 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1218 -0
- package/dist/server.js.map +1 -0
- package/dist/socket-router.d.ts +153 -0
- package/dist/socket-router.d.ts.map +1 -0
- package/dist/socket-router.js +612 -0
- package/dist/socket-router.js.map +1 -0
- package/dist/sso.d.ts +90 -0
- package/dist/sso.d.ts.map +1 -0
- package/dist/sso.js +261 -0
- package/dist/sso.js.map +1 -0
- package/dist/ssr-context.d.ts +49 -0
- package/dist/ssr-context.d.ts.map +1 -0
- package/dist/ssr-context.js +85 -0
- package/dist/ssr-context.js.map +1 -0
- package/dist/ssr-globals.d.ts +32 -0
- package/dist/ssr-globals.d.ts.map +1 -0
- package/dist/ssr-globals.js +1010 -0
- package/dist/ssr-globals.js.map +1 -0
- package/dist/ssr-template.d.ts +73 -0
- package/dist/ssr-template.d.ts.map +1 -0
- package/dist/ssr-template.js +507 -0
- package/dist/ssr-template.js.map +1 -0
- package/dist/stack-mapper.d.ts +25 -0
- package/dist/stack-mapper.d.ts.map +1 -0
- package/dist/stack-mapper.js +139 -0
- package/dist/stack-mapper.js.map +1 -0
- package/dist/stream.d.ts +89 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +299 -0
- package/dist/stream.js.map +1 -0
- package/dist/upload.d.ts +69 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +110 -0
- package/dist/upload.js.map +1 -0
- package/dist/validate.d.ts +58 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +89 -0
- package/dist/validate.js.map +1 -0
- package/dist/verify-package.d.ts +3 -0
- package/dist/verify-package.d.ts.map +1 -0
- package/dist/verify-package.js +128 -0
- package/dist/verify-package.js.map +1 -0
- package/package.json +79 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mates-fullstack — server.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure Node.js (node:http) HTTP server.
|
|
5
|
+
* No express, no hono, no fastify, no Bun APIs.
|
|
6
|
+
*
|
|
7
|
+
* Request routing order:
|
|
8
|
+
* 1. GET /health, /healthz → 200 JSON { status: "ok", timestamp }
|
|
9
|
+
* 2. GET /__mates_reload (dev only) → SSE live-reload stream
|
|
10
|
+
* 3. POST /__mates_log (dev only) → console.log browser messages
|
|
11
|
+
* 4. GET /_mates/* → virtual files (rpc-client.js, errors.js, etc.)
|
|
12
|
+
* 5. Upgrade: websocket → emit "upgrade" (caller handles WS)
|
|
13
|
+
* 6. onRequest hooks → raw HTTP escape hatch
|
|
14
|
+
* 7. GET /api/docs → docs page (dev or API_DOCS=true)
|
|
15
|
+
* 8. POST /api/** → RPC runner
|
|
16
|
+
* 9. /api/** (non-POST) → 405
|
|
17
|
+
* 10. Static assets: /assets/** or public/→ serveStatic()
|
|
18
|
+
* 11. GET /** → renderPage() (SSR)
|
|
19
|
+
* 12. (other methods) /** → 405
|
|
20
|
+
* 13. fallthrough → 404
|
|
21
|
+
*/
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { createContext, applyResHeaders } from "./ctx.js";
|
|
27
|
+
import { runRpcRequest } from "./rpc-runner.js";
|
|
28
|
+
import { runRequestHooks, runResponseHooks } from "./middleware.js";
|
|
29
|
+
import { lookupRpcFn } from "./rpc-registry.js";
|
|
30
|
+
import { isPathInside } from "./project-resolver.js";
|
|
31
|
+
import { serializeError } from "./errors.js";
|
|
32
|
+
import { getPublicEnv } from "./env-loader.js";
|
|
33
|
+
import { isRedirect } from "./redirect.js";
|
|
34
|
+
import { devLog, devWarn, devError, fatalError, startupLog } from "./log.js";
|
|
35
|
+
import { _getServerTimeoutMs } from "./server-timeout.js";
|
|
36
|
+
import { matchAndRunRest } from "./rest.js";
|
|
37
|
+
import { runWithContext, setSSRContext } from "./ssr-context.js";
|
|
38
|
+
import { BROWSER_LOG_ENDPOINT, INTERNAL_ASSET_PREFIX, INTERNAL_MATES_PREFIX, INTERNAL_PKG_PREFIX, INTERNAL_SRC_PREFIX, RELOAD_ENDPOINT, RUNTIME_RELOAD_ENDPOINT, } from "./internal-prefixes.js";
|
|
39
|
+
const _serverConfig = {
|
|
40
|
+
staticStreamTimeout: 60000,
|
|
41
|
+
maxSseClients: 500,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Configure server-level limits. Call before startServer().
|
|
45
|
+
*/
|
|
46
|
+
export function configureServer(options) {
|
|
47
|
+
if (options.staticStreamTimeout !== undefined)
|
|
48
|
+
_serverConfig.staticStreamTimeout = options.staticStreamTimeout;
|
|
49
|
+
if (options.maxSseClients !== undefined)
|
|
50
|
+
_serverConfig.maxSseClients = options.maxSseClients;
|
|
51
|
+
}
|
|
52
|
+
// ─── MIME types ───────────────────────────────────────────────────────────────
|
|
53
|
+
const MIME_TYPES = {
|
|
54
|
+
".html": "text/html; charset=utf-8",
|
|
55
|
+
".js": "application/javascript; charset=utf-8",
|
|
56
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
57
|
+
".css": "text/css; charset=utf-8",
|
|
58
|
+
".json": "application/json; charset=utf-8",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".jpg": "image/jpeg",
|
|
61
|
+
".jpeg": "image/jpeg",
|
|
62
|
+
".gif": "image/gif",
|
|
63
|
+
".svg": "image/svg+xml",
|
|
64
|
+
".ico": "image/x-icon",
|
|
65
|
+
".woff": "font/woff",
|
|
66
|
+
".woff2": "font/woff2",
|
|
67
|
+
".ttf": "font/ttf",
|
|
68
|
+
".webp": "image/webp",
|
|
69
|
+
".avif": "image/avif",
|
|
70
|
+
".txt": "text/plain; charset=utf-8",
|
|
71
|
+
".map": "application/json",
|
|
72
|
+
".wasm": "application/wasm",
|
|
73
|
+
".xml": "application/xml",
|
|
74
|
+
".pdf": "application/pdf",
|
|
75
|
+
".mp4": "video/mp4",
|
|
76
|
+
".mp3": "audio/mpeg",
|
|
77
|
+
".webm": "video/webm",
|
|
78
|
+
".ogg": "audio/ogg",
|
|
79
|
+
};
|
|
80
|
+
// ─── Security headers ─────────────────────────────────────────────────────────
|
|
81
|
+
const BASE_SECURITY_HEADERS = {
|
|
82
|
+
"X-Content-Type-Options": "nosniff",
|
|
83
|
+
"X-Frame-Options": "DENY",
|
|
84
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
85
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
86
|
+
"X-XSS-Protection": "0",
|
|
87
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
88
|
+
"Cross-Origin-Resource-Policy": "same-site",
|
|
89
|
+
Server: "mates",
|
|
90
|
+
"X-Powered-By": "",
|
|
91
|
+
};
|
|
92
|
+
const CSP_DEV = "default-src 'self'; " +
|
|
93
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
94
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
95
|
+
"img-src 'self' data: https:; " +
|
|
96
|
+
"font-src 'self' https://fonts.gstatic.com; " +
|
|
97
|
+
"connect-src 'self'; " +
|
|
98
|
+
"frame-ancestors 'none'";
|
|
99
|
+
const CSP_PROD = "default-src 'self'; " +
|
|
100
|
+
"script-src 'self'; " +
|
|
101
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
102
|
+
"img-src 'self' data: https:; " +
|
|
103
|
+
"font-src 'self' https://fonts.gstatic.com; " +
|
|
104
|
+
"connect-src 'self'; " +
|
|
105
|
+
"frame-ancestors 'none'";
|
|
106
|
+
const HSTS = "max-age=63072000; includeSubDomains; preload";
|
|
107
|
+
// ─── Module-level state ───────────────────────────────────────────────────────
|
|
108
|
+
/** Set of connected SSE reload clients */
|
|
109
|
+
const _sseClients = new Set();
|
|
110
|
+
/** Current SSE generation string */
|
|
111
|
+
let _sseGeneration = "0";
|
|
112
|
+
/**
|
|
113
|
+
* Last HMR message broadcast to clients, serialized as a single line of JSON.
|
|
114
|
+
* Sent verbatim to newly-connected clients so they can establish a baseline
|
|
115
|
+
* generation without triggering a reload.
|
|
116
|
+
*/
|
|
117
|
+
let _sseMessage = JSON.stringify({ t: _sseGeneration, kind: "init" });
|
|
118
|
+
/** Active request counter */
|
|
119
|
+
let _activeRequests = 0;
|
|
120
|
+
/** Whether graceful shutdown is in progress */
|
|
121
|
+
let _isShuttingDown = false;
|
|
122
|
+
/** Callbacks to call when active requests reach zero during shutdown */
|
|
123
|
+
const _drainCallbacks = [];
|
|
124
|
+
// ─── Public helpers ───────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Trigger an HMR event for all connected dev clients.
|
|
127
|
+
* Called by the CLI (via the runtime-reload endpoint) when a file change
|
|
128
|
+
* triggers a rebuild.
|
|
129
|
+
*
|
|
130
|
+
* `update.kind === "css"` swaps the stylesheet in place (no page reload,
|
|
131
|
+
* preserving application state); anything else falls back to a full reload.
|
|
132
|
+
*/
|
|
133
|
+
export function notifyReload(generation, update = { kind: "reload" }) {
|
|
134
|
+
_sseGeneration = generation;
|
|
135
|
+
_sseMessage = JSON.stringify({ t: generation, ...update });
|
|
136
|
+
devLog(`dev ${update.kind} update (generation ${generation}) — ` +
|
|
137
|
+
`notifying ${_sseClients.size} browser(s)`);
|
|
138
|
+
for (const client of _sseClients) {
|
|
139
|
+
try {
|
|
140
|
+
client.write(`data: ${_sseMessage}\n\n`);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Client disconnected — will be cleaned up on "close"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Returns the current number of in-flight requests.
|
|
149
|
+
*/
|
|
150
|
+
export function getActiveRequests() {
|
|
151
|
+
return _activeRequests;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns the client IP address from the request.
|
|
155
|
+
* When trustProxy=true, reads X-Forwarded-For / X-Real-IP first.
|
|
156
|
+
*/
|
|
157
|
+
export function getClientIp(req, trustProxy) {
|
|
158
|
+
if (trustProxy) {
|
|
159
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
160
|
+
if (forwarded) {
|
|
161
|
+
const first = Array.isArray(forwarded)
|
|
162
|
+
? forwarded[0]
|
|
163
|
+
: forwarded.split(",")[0];
|
|
164
|
+
return first.trim();
|
|
165
|
+
}
|
|
166
|
+
const realIp = req.headers["x-real-ip"];
|
|
167
|
+
if (realIp) {
|
|
168
|
+
return Array.isArray(realIp) ? realIp[0] : realIp;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return req.socket?.remoteAddress ?? "unknown";
|
|
172
|
+
}
|
|
173
|
+
// ─── Security header application ─────────────────────────────────────────────
|
|
174
|
+
function applySecurityHeaders(res, dev, isHtml = false) {
|
|
175
|
+
if (res.headersSent)
|
|
176
|
+
return;
|
|
177
|
+
for (const [key, value] of Object.entries(BASE_SECURITY_HEADERS)) {
|
|
178
|
+
// X-Powered-By: set to empty removes it from common middleware defaults
|
|
179
|
+
if (key === "X-Powered-By") {
|
|
180
|
+
res.removeHeader("X-Powered-By");
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
res.setHeader(key, value);
|
|
184
|
+
}
|
|
185
|
+
if (isHtml) {
|
|
186
|
+
res.setHeader("Content-Security-Policy", dev ? CSP_DEV : CSP_PROD);
|
|
187
|
+
if (!dev) {
|
|
188
|
+
res.setHeader("Strict-Transport-Security", HSTS);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ─── JSON response helpers ────────────────────────────────────────────────────
|
|
193
|
+
function sendJson(res, status, data, dev) {
|
|
194
|
+
if (res.headersSent)
|
|
195
|
+
return;
|
|
196
|
+
let body;
|
|
197
|
+
try {
|
|
198
|
+
body = JSON.stringify(data);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
body = '{"error":"Serialization error"}';
|
|
202
|
+
}
|
|
203
|
+
const buf = Buffer.from(body, "utf8");
|
|
204
|
+
applySecurityHeaders(res, dev, false);
|
|
205
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
206
|
+
res.setHeader("Content-Length", buf.byteLength);
|
|
207
|
+
res.writeHead(status);
|
|
208
|
+
res.end(buf);
|
|
209
|
+
}
|
|
210
|
+
function sendError(res, status, message, dev) {
|
|
211
|
+
sendJson(res, status, { error: message, status }, dev);
|
|
212
|
+
}
|
|
213
|
+
function send404(res, dev) {
|
|
214
|
+
sendError(res, 404, "Not Found", dev);
|
|
215
|
+
}
|
|
216
|
+
function send405(res, dev) {
|
|
217
|
+
if (!res.headersSent) {
|
|
218
|
+
applySecurityHeaders(res, dev, false);
|
|
219
|
+
res.setHeader("Allow", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS");
|
|
220
|
+
}
|
|
221
|
+
sendError(res, 405, "Method Not Allowed", dev);
|
|
222
|
+
}
|
|
223
|
+
function send400(res, message, dev) {
|
|
224
|
+
sendError(res, 400, message, dev);
|
|
225
|
+
}
|
|
226
|
+
function send401(res, message, dev) {
|
|
227
|
+
if (!res.headersSent) {
|
|
228
|
+
res.setHeader("WWW-Authenticate", 'Bearer realm="mates-api-docs"');
|
|
229
|
+
}
|
|
230
|
+
sendError(res, 401, message, dev);
|
|
231
|
+
}
|
|
232
|
+
function send500(res, dev) {
|
|
233
|
+
sendError(res, 500, "Internal Server Error", dev);
|
|
234
|
+
}
|
|
235
|
+
function createFetchRequest(req, pathname, trustProxy) {
|
|
236
|
+
const host = req.headers.host ?? "localhost";
|
|
237
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
238
|
+
const proto = trustProxy && forwardedProto
|
|
239
|
+
? Array.isArray(forwardedProto)
|
|
240
|
+
? forwardedProto[0]
|
|
241
|
+
: forwardedProto.split(",")[0].trim()
|
|
242
|
+
: "http";
|
|
243
|
+
const url = new URL(req.url ?? pathname, `${proto}://${host}`);
|
|
244
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
245
|
+
const headers = new Headers();
|
|
246
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
247
|
+
if (value === undefined)
|
|
248
|
+
continue;
|
|
249
|
+
if (Array.isArray(value)) {
|
|
250
|
+
for (const item of value)
|
|
251
|
+
headers.append(key, item);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
headers.set(key, value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const init = { method, headers };
|
|
258
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
259
|
+
init.body = req;
|
|
260
|
+
init.duplex = "half";
|
|
261
|
+
}
|
|
262
|
+
return new Request(url, init);
|
|
263
|
+
}
|
|
264
|
+
async function sendFetchResponse(response, ctx, req, res, dev) {
|
|
265
|
+
if (res.headersSent)
|
|
266
|
+
return;
|
|
267
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
268
|
+
applySecurityHeaders(res, dev, contentType.includes("text/html"));
|
|
269
|
+
response.headers.forEach((value, key) => {
|
|
270
|
+
if (key.toLowerCase() === "set-cookie")
|
|
271
|
+
return;
|
|
272
|
+
res.setHeader(key, value);
|
|
273
|
+
});
|
|
274
|
+
const setCookies = response.headers.getSetCookie?.();
|
|
275
|
+
if (setCookies?.length) {
|
|
276
|
+
res.setHeader("set-cookie", setCookies);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const setCookie = response.headers.get("set-cookie");
|
|
280
|
+
if (setCookie)
|
|
281
|
+
res.setHeader("set-cookie", setCookie);
|
|
282
|
+
}
|
|
283
|
+
applyResHeaders(ctx.resHeaders, res);
|
|
284
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
285
|
+
res.statusCode = response.status;
|
|
286
|
+
res.statusMessage = response.statusText || res.statusMessage;
|
|
287
|
+
if (!res.hasHeader("Content-Length")) {
|
|
288
|
+
res.setHeader("Content-Length", body.byteLength);
|
|
289
|
+
}
|
|
290
|
+
if (req.method?.toUpperCase() === "HEAD") {
|
|
291
|
+
res.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
res.end(body);
|
|
295
|
+
}
|
|
296
|
+
// ─── Static file serving ─────────────────────────────────────────────────────
|
|
297
|
+
function serveStatic(pathname, publicDir, req, res, dev) {
|
|
298
|
+
// Strip null bytes to prevent null-byte injection attacks
|
|
299
|
+
if (pathname.includes("\0")) {
|
|
300
|
+
send404(res, dev);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Safely resolve and check for path traversal
|
|
304
|
+
const filePath = path.resolve(publicDir, "." + pathname);
|
|
305
|
+
if (!isPathInside(publicDir, filePath)) {
|
|
306
|
+
send404(res, dev);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Resolve symlinks to prevent symlink escapes outside public dir
|
|
310
|
+
let realPath;
|
|
311
|
+
try {
|
|
312
|
+
realPath = fs.realpathSync(filePath);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
send404(res, dev);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!isPathInside(publicDir, realPath)) {
|
|
319
|
+
send404(res, dev);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
let stat;
|
|
323
|
+
try {
|
|
324
|
+
stat = fs.statSync(realPath);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
send404(res, dev);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!stat.isFile()) {
|
|
331
|
+
send404(res, dev);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// ETag based on mtime + size
|
|
335
|
+
const etag = `"${stat.mtimeMs}-${stat.size}"`;
|
|
336
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
337
|
+
if (!dev && ifNoneMatch === etag) {
|
|
338
|
+
applySecurityHeaders(res, dev, false);
|
|
339
|
+
res.writeHead(304);
|
|
340
|
+
res.end();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Cache-Control
|
|
344
|
+
let cacheControl;
|
|
345
|
+
if (dev) {
|
|
346
|
+
cacheControl = "no-store";
|
|
347
|
+
}
|
|
348
|
+
else if (pathname.startsWith("/assets/")) {
|
|
349
|
+
cacheControl = "public, max-age=31536000, immutable";
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
cacheControl = "public, max-age=3600";
|
|
353
|
+
}
|
|
354
|
+
const ext = path.extname(realPath).toLowerCase();
|
|
355
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
356
|
+
if (res.headersSent)
|
|
357
|
+
return;
|
|
358
|
+
applySecurityHeaders(res, dev, false);
|
|
359
|
+
res.setHeader("Content-Type", contentType);
|
|
360
|
+
res.setHeader("Cache-Control", cacheControl);
|
|
361
|
+
res.setHeader("ETag", etag);
|
|
362
|
+
res.setHeader("Content-Length", stat.size);
|
|
363
|
+
res.writeHead(200);
|
|
364
|
+
// Set a timeout on slow client connections to prevent resource exhaustion
|
|
365
|
+
// A client reading a large file very slowly should not hold the connection indefinitely
|
|
366
|
+
req.setTimeout(_serverConfig.staticStreamTimeout, () => {
|
|
367
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
368
|
+
res.end();
|
|
369
|
+
}
|
|
370
|
+
req.destroy();
|
|
371
|
+
});
|
|
372
|
+
const stream = fs.createReadStream(realPath);
|
|
373
|
+
stream.on("error", (err) => {
|
|
374
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
375
|
+
res.end();
|
|
376
|
+
}
|
|
377
|
+
devError("static stream error:", err.message);
|
|
378
|
+
});
|
|
379
|
+
stream.pipe(res);
|
|
380
|
+
}
|
|
381
|
+
// ─── Dev package module serving ───────────────────────────────────────────────
|
|
382
|
+
function _parsePkgRequest(pathname) {
|
|
383
|
+
const parts = pathname
|
|
384
|
+
.replace(new RegExp(`^${INTERNAL_PKG_PREFIX}/`), "")
|
|
385
|
+
.split("/")
|
|
386
|
+
.filter(Boolean);
|
|
387
|
+
if (parts.length === 0)
|
|
388
|
+
return null;
|
|
389
|
+
const scoped = parts[0].startsWith("@");
|
|
390
|
+
const pkg = scoped ? `${parts[0]}/${parts[1] ?? ""}` : parts[0];
|
|
391
|
+
const relParts = parts.slice(scoped ? 2 : 1);
|
|
392
|
+
return { pkg, rel: relParts.join("/") };
|
|
393
|
+
}
|
|
394
|
+
function _findPackageDir(projectRoot, pkg) {
|
|
395
|
+
const direct = path.join(projectRoot, "node_modules", pkg);
|
|
396
|
+
if (fs.existsSync(path.join(direct, "package.json")))
|
|
397
|
+
return direct;
|
|
398
|
+
try {
|
|
399
|
+
const req = createRequire(path.join(projectRoot, "package.json"));
|
|
400
|
+
const pkgJson = req.resolve(`${pkg}/package.json`);
|
|
401
|
+
return path.dirname(pkgJson);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function _resolvePackageExport(pkgDir, rel) {
|
|
408
|
+
const pkgJson = path.join(pkgDir, "package.json");
|
|
409
|
+
if (!fs.existsSync(pkgJson))
|
|
410
|
+
return null;
|
|
411
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
|
|
412
|
+
if (rel) {
|
|
413
|
+
const explicit = path.join(pkgDir, rel);
|
|
414
|
+
if (fs.existsSync(explicit) && fs.statSync(explicit).isFile())
|
|
415
|
+
return explicit;
|
|
416
|
+
if (fs.existsSync(`${explicit}.js`))
|
|
417
|
+
return `${explicit}.js`;
|
|
418
|
+
if (fs.existsSync(`${explicit}.ts`))
|
|
419
|
+
return `${explicit}.ts`;
|
|
420
|
+
}
|
|
421
|
+
const exportKey = rel ? `./${rel}` : ".";
|
|
422
|
+
const exp = resolvePackageExportValue(pkg.exports, exportKey);
|
|
423
|
+
const target = typeof exp === "string"
|
|
424
|
+
? exp
|
|
425
|
+
: (exp?.browser ??
|
|
426
|
+
exp?.import ??
|
|
427
|
+
exp?.module ??
|
|
428
|
+
exp?.default ??
|
|
429
|
+
(rel ? null : (pkg.module ?? pkg.main)));
|
|
430
|
+
if (!target || typeof target !== "string")
|
|
431
|
+
return null;
|
|
432
|
+
return path.resolve(pkgDir, target);
|
|
433
|
+
}
|
|
434
|
+
function resolvePackageExportValue(exportsField, exportKey) {
|
|
435
|
+
if (!exportsField)
|
|
436
|
+
return null;
|
|
437
|
+
if (typeof exportsField === "string")
|
|
438
|
+
return exportKey === "." ? exportsField : null;
|
|
439
|
+
if (exportKey === "." && (exportsField.import || exportsField.default)) {
|
|
440
|
+
return exportsField;
|
|
441
|
+
}
|
|
442
|
+
return exportsField[exportKey] ?? null;
|
|
443
|
+
}
|
|
444
|
+
function _packageImportUrl(projectRoot, spec, exportRel) {
|
|
445
|
+
const parts = spec.split("/");
|
|
446
|
+
const scoped = parts[0].startsWith("@");
|
|
447
|
+
const pkg = scoped ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
448
|
+
const rel = exportRel ?? parts.slice(scoped ? 2 : 1).join("/");
|
|
449
|
+
const pkgDir = _findPackageDir(projectRoot, pkg);
|
|
450
|
+
if (!pkgDir)
|
|
451
|
+
return null;
|
|
452
|
+
const file = _resolvePackageExport(pkgDir, rel);
|
|
453
|
+
if (!file)
|
|
454
|
+
return null;
|
|
455
|
+
const relFile = path.relative(pkgDir, file).split(path.sep).join("/");
|
|
456
|
+
return `${INTERNAL_PKG_PREFIX}/${pkg}/${relFile}`;
|
|
457
|
+
}
|
|
458
|
+
export function buildDevImportMap(projectRoot) {
|
|
459
|
+
const imports = {};
|
|
460
|
+
const matesUrl = _packageImportUrl(projectRoot, "mates");
|
|
461
|
+
if (matesUrl) {
|
|
462
|
+
imports.mates = matesUrl;
|
|
463
|
+
imports["mates/"] = `${INTERNAL_PKG_PREFIX}/mates/`;
|
|
464
|
+
}
|
|
465
|
+
const litHtmlUrl = _packageImportUrl(projectRoot, "lit-html");
|
|
466
|
+
if (litHtmlUrl) {
|
|
467
|
+
imports["lit-html"] = litHtmlUrl;
|
|
468
|
+
imports["lit-html/"] = `${INTERNAL_PKG_PREFIX}/lit-html/`;
|
|
469
|
+
}
|
|
470
|
+
// Browser-side imports of the fullstack package must use the browser-safe
|
|
471
|
+
// entry. The public server barrel exports Node-only modules like server.js.
|
|
472
|
+
for (const pkg of ["mates-fullstack"]) {
|
|
473
|
+
const browserUrl = _packageImportUrl(projectRoot, pkg, "browser");
|
|
474
|
+
if (browserUrl)
|
|
475
|
+
imports[pkg] = browserUrl;
|
|
476
|
+
const clientUrl = _packageImportUrl(projectRoot, `${pkg}/client`);
|
|
477
|
+
if (clientUrl)
|
|
478
|
+
imports[`${pkg}/client`] = clientUrl;
|
|
479
|
+
if (browserUrl || clientUrl)
|
|
480
|
+
imports[`${pkg}/`] = `${INTERNAL_PKG_PREFIX}/${pkg}/`;
|
|
481
|
+
}
|
|
482
|
+
const devtoolsUrl = _packageImportUrl(projectRoot, "mates-devtools");
|
|
483
|
+
if (devtoolsUrl) {
|
|
484
|
+
imports["mates-devtools"] = devtoolsUrl;
|
|
485
|
+
imports["mates-devtools/"] = `${INTERNAL_PKG_PREFIX}/mates-devtools/`;
|
|
486
|
+
}
|
|
487
|
+
return imports;
|
|
488
|
+
}
|
|
489
|
+
// ─── SSE live-reload ──────────────────────────────────────────────────────────
|
|
490
|
+
function handleSseReload(req, res) {
|
|
491
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
492
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
493
|
+
res.setHeader("Connection", "keep-alive");
|
|
494
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
495
|
+
res.writeHead(200);
|
|
496
|
+
// Send the current HMR message immediately so the client can establish a
|
|
497
|
+
// baseline generation (and detect a stale page after a server restart).
|
|
498
|
+
res.write(`data: ${_sseMessage}\n\n`);
|
|
499
|
+
// Limit SSE clients to prevent resource exhaustion from too many connections
|
|
500
|
+
if (_sseClients.size >= _serverConfig.maxSseClients) {
|
|
501
|
+
// Remove oldest client to make room
|
|
502
|
+
const oldest = _sseClients.values().next().value;
|
|
503
|
+
if (oldest) {
|
|
504
|
+
oldest.end();
|
|
505
|
+
_sseClients.delete(oldest);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
_sseClients.add(res);
|
|
509
|
+
const cleanup = () => {
|
|
510
|
+
_sseClients.delete(res);
|
|
511
|
+
};
|
|
512
|
+
res.on("close", cleanup);
|
|
513
|
+
res.on("error", cleanup);
|
|
514
|
+
// Keep the connection alive
|
|
515
|
+
req.on("close", () => {
|
|
516
|
+
_sseClients.delete(res);
|
|
517
|
+
if (!res.writableEnded) {
|
|
518
|
+
res.end();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// ─── API docs ─────────────────────────────────────────────────────────────────
|
|
523
|
+
function isApiDocsEnabled(dev) {
|
|
524
|
+
return dev || process.env.API_DOCS === "true";
|
|
525
|
+
}
|
|
526
|
+
function isApiDocsAuthorized(req, dev) {
|
|
527
|
+
// Dev mode: always authorized
|
|
528
|
+
if (dev)
|
|
529
|
+
return true;
|
|
530
|
+
// Production: check for explicit public access flag
|
|
531
|
+
if (process.env.API_DOCS_PUBLIC === "true")
|
|
532
|
+
return true;
|
|
533
|
+
// Production: require a token
|
|
534
|
+
const token = process.env.API_DOCS_TOKEN;
|
|
535
|
+
if (!token)
|
|
536
|
+
return false;
|
|
537
|
+
const auth = req.headers.authorization;
|
|
538
|
+
const bearer = Array.isArray(auth) ? auth[0] : auth;
|
|
539
|
+
if (bearer === `Bearer ${token}`)
|
|
540
|
+
return true;
|
|
541
|
+
const header = req.headers["x-api-docs-token"];
|
|
542
|
+
const tokenHeader = Array.isArray(header) ? header[0] : header;
|
|
543
|
+
return tokenHeader === token;
|
|
544
|
+
}
|
|
545
|
+
async function handleApiDocs(res, dev, apiDir, sharedTypesDir, projectRoot) {
|
|
546
|
+
try {
|
|
547
|
+
const { generateDocs } = (await import("./docs-generator.js"));
|
|
548
|
+
const { serveDocsPage } = (await import("./docs-page.js"));
|
|
549
|
+
const docs = apiDir
|
|
550
|
+
? await generateDocs(apiDir, sharedTypesDir, projectRoot)
|
|
551
|
+
: [];
|
|
552
|
+
applySecurityHeaders(res, dev, true);
|
|
553
|
+
serveDocsPage(res, docs, dev);
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
// Fallback: minimal plain-text response if docs modules fail
|
|
557
|
+
if (!res.headersSent) {
|
|
558
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
559
|
+
res.end("API docs unavailable.");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// ─── Dev no-bundle source/module serving ─────────────────────────────────────
|
|
564
|
+
const DEV_MODULE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|json|css)$/i;
|
|
565
|
+
async function serveDevSourceModule(pathname, res, options) {
|
|
566
|
+
if (!options.dev || !options.noBundle)
|
|
567
|
+
return false;
|
|
568
|
+
const sourceRoot = path.resolve(options.sourceRoot ?? options.projectRoot);
|
|
569
|
+
const rel = pathname
|
|
570
|
+
.replace(new RegExp(`^${INTERNAL_SRC_PREFIX}/?`), "")
|
|
571
|
+
.replace(/^\/+/, "");
|
|
572
|
+
// Client code may import server/api/** (converted to RPC stubs), but all
|
|
573
|
+
// other server/** files remain server-only in no-bundle mode too.
|
|
574
|
+
if (rel.startsWith("server/") && !rel.startsWith("server/api/"))
|
|
575
|
+
return false;
|
|
576
|
+
const filePath = path.resolve(sourceRoot, rel);
|
|
577
|
+
if (!isPathInside(sourceRoot, filePath) ||
|
|
578
|
+
!DEV_MODULE_EXT_RE.test(filePath)) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const { transformDevModule } = await import("./build-esbuild.js");
|
|
582
|
+
const sourceUrl = `${INTERNAL_SRC_PREFIX}/${rel}`;
|
|
583
|
+
const result = await transformDevModule(filePath, {
|
|
584
|
+
projectRoot: sourceRoot,
|
|
585
|
+
moduleRoot: sourceRoot,
|
|
586
|
+
modulePrefix: INTERNAL_SRC_PREFIX,
|
|
587
|
+
apiDir: options.sourceApiDir ?? options.paths.apiDir,
|
|
588
|
+
publicEnv: getPublicEnv(),
|
|
589
|
+
sourceUrl,
|
|
590
|
+
importMap: buildDevImportMap(options.projectRoot),
|
|
591
|
+
});
|
|
592
|
+
if (!result)
|
|
593
|
+
return false;
|
|
594
|
+
sendJsModule(res, result.code, options.dev);
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
async function serveDevPackageModule(pathname, res, options) {
|
|
598
|
+
if (!options.dev || !options.noBundle)
|
|
599
|
+
return false;
|
|
600
|
+
const parsed = _parsePkgRequest(pathname);
|
|
601
|
+
if (!parsed)
|
|
602
|
+
return false;
|
|
603
|
+
const pkgDir = _findPackageDir(options.projectRoot, parsed.pkg);
|
|
604
|
+
if (!pkgDir)
|
|
605
|
+
return false;
|
|
606
|
+
const filePath = _resolvePackageExport(pkgDir, parsed.rel);
|
|
607
|
+
if (!filePath || !isPathInside(pkgDir, filePath))
|
|
608
|
+
return false;
|
|
609
|
+
if (!DEV_MODULE_EXT_RE.test(filePath)) {
|
|
610
|
+
const relFile = "/" + path.relative(pkgDir, filePath).split(path.sep).join("/");
|
|
611
|
+
serveStatic(relFile, pkgDir, { headers: {}, method: "GET" }, res, options.dev);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
const { transformDevModule } = await import("./build-esbuild.js");
|
|
615
|
+
const modulePrefix = `${INTERNAL_PKG_PREFIX}/${parsed.pkg}`;
|
|
616
|
+
const sourceUrl = `${modulePrefix}/${path.relative(pkgDir, filePath).split(path.sep).join("/")}`;
|
|
617
|
+
const result = await transformDevModule(filePath, {
|
|
618
|
+
projectRoot: options.projectRoot,
|
|
619
|
+
moduleRoot: pkgDir,
|
|
620
|
+
modulePrefix,
|
|
621
|
+
apiDir: null,
|
|
622
|
+
publicEnv: getPublicEnv(),
|
|
623
|
+
sourceUrl,
|
|
624
|
+
importMap: buildDevImportMap(options.projectRoot),
|
|
625
|
+
});
|
|
626
|
+
if (!result)
|
|
627
|
+
return false;
|
|
628
|
+
sendJsModule(res, result.code, options.dev);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
function serveDevAsset(pathname, req, res, options) {
|
|
632
|
+
if (!options.dev || !options.noBundle)
|
|
633
|
+
return false;
|
|
634
|
+
const sourceRoot = path.resolve(options.sourceRoot ?? options.projectRoot);
|
|
635
|
+
const rel = pathname
|
|
636
|
+
.replace(new RegExp(`^${INTERNAL_ASSET_PREFIX}/?`), "")
|
|
637
|
+
.replace(/^\/+/, "");
|
|
638
|
+
const filePath = path.resolve(sourceRoot, rel);
|
|
639
|
+
if (!isPathInside(sourceRoot, filePath))
|
|
640
|
+
return false;
|
|
641
|
+
try {
|
|
642
|
+
if (!fs.statSync(filePath).isFile())
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
// Serve the raw file. The no-bundle transform replaces `import logo from "./logo.svg"`
|
|
649
|
+
// with `const logo = "/_asset/..."` — the URL is always used as a plain string, never
|
|
650
|
+
// as a live ESM import. So browsers fetching <img src> or CSS url() get the real content.
|
|
651
|
+
serveStatic("/" + rel, sourceRoot, req, res, options.dev);
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
function sendJsModule(res, code, dev) {
|
|
655
|
+
const buf = Buffer.from(code, "utf-8");
|
|
656
|
+
applySecurityHeaders(res, dev, false);
|
|
657
|
+
res.writeHead(200, {
|
|
658
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
659
|
+
"Content-Length": buf.length,
|
|
660
|
+
"Cache-Control": "no-store",
|
|
661
|
+
});
|
|
662
|
+
res.end(buf);
|
|
663
|
+
}
|
|
664
|
+
function getCurrentCssFilename(options) {
|
|
665
|
+
const configured = options.cssFilename;
|
|
666
|
+
const assetsDir = path.join(options.paths.outDir, "assets");
|
|
667
|
+
if (configured && fs.existsSync(path.join(assetsDir, configured))) {
|
|
668
|
+
return configured;
|
|
669
|
+
}
|
|
670
|
+
if (!fs.existsSync(assetsDir))
|
|
671
|
+
return null;
|
|
672
|
+
return (fs
|
|
673
|
+
.readdirSync(assetsDir)
|
|
674
|
+
.filter((file) => /^styles.*\.css$/i.test(file))
|
|
675
|
+
.sort()
|
|
676
|
+
.at(-1) ?? null);
|
|
677
|
+
}
|
|
678
|
+
// ─── Request tracking ─────────────────────────────────────────────────────────
|
|
679
|
+
async function refreshDevRuntime(options, generation, kind = "full", changedPath = null) {
|
|
680
|
+
const { paths } = options;
|
|
681
|
+
const gen = generation ?? Date.now().toString(36);
|
|
682
|
+
// CSS-only updates don't touch server modules — swap the single linked
|
|
683
|
+
// stylesheet in the browser without reloading application state.
|
|
684
|
+
if (kind === "css") {
|
|
685
|
+
const cssFilename = getCurrentCssFilename(options);
|
|
686
|
+
const css = cssFilename ? `/assets/${cssFilename}` : null;
|
|
687
|
+
notifyReload(gen, css ? { kind: "css", css } : { kind: "reload" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (kind === "js" && changedPath) {
|
|
691
|
+
const { clearTransformCache } = await import("./build-esbuild.js");
|
|
692
|
+
clearTransformCache();
|
|
693
|
+
notifyReload(gen, { kind: "js", path: changedPath });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (paths.apiDir) {
|
|
697
|
+
const { scanRpcFunctions } = await import("./rpc-registry.js");
|
|
698
|
+
await scanRpcFunctions(paths.apiDir, true);
|
|
699
|
+
}
|
|
700
|
+
if (paths.socketDir) {
|
|
701
|
+
const { scanSocketHandlers } = await import("./socket-router.js");
|
|
702
|
+
await scanSocketHandlers(paths.socketDir, true);
|
|
703
|
+
}
|
|
704
|
+
if (paths.mainFile) {
|
|
705
|
+
const { reloadMainFile } = await import("./main-runner.js");
|
|
706
|
+
await reloadMainFile(paths.mainFile);
|
|
707
|
+
}
|
|
708
|
+
notifyReload(gen, { kind: "reload" });
|
|
709
|
+
}
|
|
710
|
+
function trackRequest(res) {
|
|
711
|
+
_activeRequests++;
|
|
712
|
+
// Both "finish" (response sent) and "close" (socket closed) can fire for
|
|
713
|
+
// the same response on keep-alive connections. Guard with a one-shot flag
|
|
714
|
+
// so the counter and drain callbacks are only triggered once per request.
|
|
715
|
+
let decremented = false;
|
|
716
|
+
const decrement = () => {
|
|
717
|
+
if (decremented)
|
|
718
|
+
return;
|
|
719
|
+
decremented = true;
|
|
720
|
+
_activeRequests = Math.max(0, _activeRequests - 1);
|
|
721
|
+
if (_isShuttingDown && _activeRequests === 0) {
|
|
722
|
+
for (const cb of _drainCallbacks)
|
|
723
|
+
cb();
|
|
724
|
+
_drainCallbacks.length = 0;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
res.on("finish", decrement);
|
|
728
|
+
res.on("close", decrement);
|
|
729
|
+
}
|
|
730
|
+
// ─── Main request handler ─────────────────────────────────────────────────────
|
|
731
|
+
async function handleRequest(req, res, options) {
|
|
732
|
+
const { dev, paths, trustProxy = false } = options;
|
|
733
|
+
const timeoutMs = options.timeoutMs ?? _getServerTimeoutMs();
|
|
734
|
+
// Track active requests for graceful shutdown
|
|
735
|
+
trackRequest(res);
|
|
736
|
+
// Wire up connection-level error handling
|
|
737
|
+
req.on("error", (err) => {
|
|
738
|
+
if (err.code === "ECONNRESET" || err.code === "EPIPE") {
|
|
739
|
+
devWarn("client connection error:", err.code);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
devError("request error:", err.message);
|
|
743
|
+
try {
|
|
744
|
+
req.socket?.destroy();
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
/* ignore */
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
res.on("error", (err) => {
|
|
751
|
+
if (err.code === "ECONNRESET" || err.code === "EPIPE") {
|
|
752
|
+
devWarn("response error:", err.code);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
devError("response write error:", err.message);
|
|
756
|
+
});
|
|
757
|
+
// Parse URL
|
|
758
|
+
let url;
|
|
759
|
+
try {
|
|
760
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
send400(res, "Malformed URL", dev);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const pathname = url.pathname;
|
|
767
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
768
|
+
// ── 1. Health checks ──────────────────────────────────────────────────────
|
|
769
|
+
if (method === "GET" && (pathname === "/health" || pathname === "/healthz")) {
|
|
770
|
+
sendJson(res, 200, { status: "ok", timestamp: new Date().toISOString() }, dev);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
// ── 2. SSE live-reload (dev only) ─────────────────────────────────────────
|
|
774
|
+
if (dev && method === "GET" && pathname === RELOAD_ENDPOINT) {
|
|
775
|
+
handleSseReload(req, res);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
// ── 2b. Dev runtime refresh after rebuild ────────────────────────────────
|
|
779
|
+
// The dev parent process rebuilds dist/ files, then calls this endpoint.
|
|
780
|
+
// We rescan compiled RPC/socket/main modules with cache-busting imports,
|
|
781
|
+
// update the reload generation, and browsers reload via SSE. No server
|
|
782
|
+
// process restart needed for normal source changes.
|
|
783
|
+
if (dev && method === "POST" && pathname === RUNTIME_RELOAD_ENDPOINT) {
|
|
784
|
+
try {
|
|
785
|
+
const rawKind = url.searchParams.get("kind");
|
|
786
|
+
const kind = rawKind === "css" || rawKind === "js" ? rawKind : "full";
|
|
787
|
+
await refreshDevRuntime(options, url.searchParams.get("generation"), kind, url.searchParams.get("path"));
|
|
788
|
+
res.writeHead(204);
|
|
789
|
+
res.end();
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
devError("runtime refresh error:", err?.message ?? err);
|
|
793
|
+
send500(res, dev);
|
|
794
|
+
}
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// ── 3. Browser log forwarding (dev only) ──────────────────────────────────
|
|
798
|
+
if (dev && method === "POST" && pathname === BROWSER_LOG_ENDPOINT) {
|
|
799
|
+
let body = "";
|
|
800
|
+
req.setEncoding("utf8");
|
|
801
|
+
req.on("data", (chunk) => {
|
|
802
|
+
body += chunk;
|
|
803
|
+
});
|
|
804
|
+
req.on("end", () => {
|
|
805
|
+
try {
|
|
806
|
+
const parsed = JSON.parse(body);
|
|
807
|
+
const level = parsed.level ?? "log";
|
|
808
|
+
const msg = parsed.message ?? body;
|
|
809
|
+
if (level === "error") {
|
|
810
|
+
devError("[browser]", msg);
|
|
811
|
+
}
|
|
812
|
+
else if (level === "warn") {
|
|
813
|
+
devWarn("[browser]", msg);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
devLog("[browser]", msg);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
devLog("[browser]", body);
|
|
821
|
+
}
|
|
822
|
+
if (!res.headersSent) {
|
|
823
|
+
res.writeHead(204);
|
|
824
|
+
res.end();
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// ── 4. Dev no-bundle source/package/asset modules ────────────────────────
|
|
830
|
+
if (dev && options.noBundle && method === "GET") {
|
|
831
|
+
if (pathname.startsWith(`${INTERNAL_SRC_PREFIX}/`)) {
|
|
832
|
+
if (await serveDevSourceModule(pathname, res, options))
|
|
833
|
+
return;
|
|
834
|
+
send404(res, dev);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (pathname.startsWith(`${INTERNAL_PKG_PREFIX}/`)) {
|
|
838
|
+
if (await serveDevPackageModule(pathname, res, options))
|
|
839
|
+
return;
|
|
840
|
+
send404(res, dev);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (pathname.startsWith(`${INTERNAL_ASSET_PREFIX}/`)) {
|
|
844
|
+
if (serveDevAsset(pathname, req, res, options))
|
|
845
|
+
return;
|
|
846
|
+
send404(res, dev);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// ── 5. Virtual /_mates/ files (browser-side RPC helpers, error classes, etc.)
|
|
851
|
+
// These are generated at build time by the esbuild RPC stub plugin and marked
|
|
852
|
+
// as external ("/_mates/*") so they are NOT bundled. The server must serve
|
|
853
|
+
// them as plain JS modules.
|
|
854
|
+
if (method === "GET" && pathname.startsWith(`${INTERNAL_MATES_PREFIX}/`)) {
|
|
855
|
+
try {
|
|
856
|
+
const { serveVirtualFile } = await import("./build-esbuild.js");
|
|
857
|
+
const content = await serveVirtualFile(pathname);
|
|
858
|
+
if (content !== null) {
|
|
859
|
+
const buf = Buffer.from(content, "utf-8");
|
|
860
|
+
if (!res.headersSent) {
|
|
861
|
+
applySecurityHeaders(res, dev, false);
|
|
862
|
+
res.writeHead(200, {
|
|
863
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
864
|
+
"Content-Length": buf.length,
|
|
865
|
+
"Cache-Control": "no-cache",
|
|
866
|
+
});
|
|
867
|
+
res.end(buf);
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
// Fall through to 404 if virtual file module not available
|
|
874
|
+
}
|
|
875
|
+
send404(res, dev);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// ── 6. WebSocket upgrades — let the server emit "upgrade" (handled externally)
|
|
879
|
+
// We do NOT handle them here — they are handled via server.on("upgrade", ...)
|
|
880
|
+
// by the caller. If we reach here it was not an upgrade request.
|
|
881
|
+
const c = createContext(req, trustProxy);
|
|
882
|
+
// Wrap the entire request dispatch in AsyncLocalStorage so that
|
|
883
|
+
// getSSRContext(), getSSRRequest(), isSSRContext(), etc. work correctly
|
|
884
|
+
// in middleware, RPC functions, and SSR render.
|
|
885
|
+
await runWithContext(c.req.raw, false, async () => {
|
|
886
|
+
setSSRContext(c);
|
|
887
|
+
// ── 7. onRequest middleware ───────────────────────────────────────────────
|
|
888
|
+
try {
|
|
889
|
+
const result = await runRequestHooks(c);
|
|
890
|
+
if (result.response) {
|
|
891
|
+
c.resStatus = result.response.status;
|
|
892
|
+
await runResponseHooks(c);
|
|
893
|
+
await sendFetchResponse(result.response, c, req, res, dev);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
if (!res.headersSent) {
|
|
899
|
+
// For page requests (GET /), onRequest auth errors should NOT return JSON.
|
|
900
|
+
// Clear auth and fall through to SSR so the client router handles redirects.
|
|
901
|
+
if (method === "GET" && !pathname.startsWith("/api/")) {
|
|
902
|
+
c.auth = null;
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
const serialized = serializeError(err, dev);
|
|
906
|
+
sendJson(res, serialized.status, serialized, dev);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// ── 8. REST routes ────────────────────────────────────────────────────────
|
|
912
|
+
try {
|
|
913
|
+
const restResponse = await matchAndRunRest(c);
|
|
914
|
+
if (restResponse) {
|
|
915
|
+
c.resStatus = restResponse.status;
|
|
916
|
+
await runResponseHooks(c);
|
|
917
|
+
await sendFetchResponse(restResponse, c, req, res, dev);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
if (!res.headersSent) {
|
|
923
|
+
const serialized = serializeError(err, dev);
|
|
924
|
+
sendJson(res, serialized.status, serialized, dev);
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// ── 8. API docs ───────────────────────────────────────────────────────────
|
|
929
|
+
if (method === "GET" && pathname === "/api/docs") {
|
|
930
|
+
if (!isApiDocsEnabled(dev)) {
|
|
931
|
+
send404(res, dev);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!isApiDocsAuthorized(req, dev)) {
|
|
935
|
+
send401(res, "API docs authentication required", dev);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const docsApiDir = options.sourceApiDir ?? paths.apiDir;
|
|
939
|
+
const sourceRoot = path.resolve(options.sourceRoot ?? options.projectRoot);
|
|
940
|
+
const sourceSharedTypesDir = path.join(sourceRoot, "shared", "types");
|
|
941
|
+
const docsSharedTypesDir = fs.existsSync(sourceSharedTypesDir)
|
|
942
|
+
? sourceSharedTypesDir
|
|
943
|
+
: paths.sharedTypesDir;
|
|
944
|
+
handleApiDocs(res, dev, docsApiDir, docsSharedTypesDir, options.projectRoot);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// ── 7 & 8. RPC /api/** ────────────────────────────────────────────────────
|
|
948
|
+
if (pathname.startsWith("/api/")) {
|
|
949
|
+
if (method !== "POST") {
|
|
950
|
+
send405(res, dev);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const entry = lookupRpcFn(pathname);
|
|
954
|
+
if (!entry) {
|
|
955
|
+
send404(res, dev);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
applySecurityHeaders(res, dev, false);
|
|
959
|
+
try {
|
|
960
|
+
await runRpcRequest(req, res, entry, dev, timeoutMs, c);
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
if (!res.headersSent) {
|
|
964
|
+
const serialized = serializeError(err, dev);
|
|
965
|
+
sendJson(res, serialized.status, serialized, dev);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
// ── 10. Static files ──────────────────────────────────────────────────────
|
|
971
|
+
const publicDir = paths.publicDir;
|
|
972
|
+
// /assets/** — always served from clientBundlePath's directory or dist/assets
|
|
973
|
+
if (pathname.startsWith("/assets/")) {
|
|
974
|
+
const assetsDir = options.clientBundlePath
|
|
975
|
+
? path.dirname(options.clientBundlePath)
|
|
976
|
+
: path.join(options.projectRoot, "dist", "assets");
|
|
977
|
+
const assetFile = path.resolve(assetsDir, "." + pathname.replace(/^\/assets/, ""));
|
|
978
|
+
if (isPathInside(assetsDir, assetFile)) {
|
|
979
|
+
try {
|
|
980
|
+
const stat = fs.statSync(assetFile);
|
|
981
|
+
if (stat.isFile()) {
|
|
982
|
+
serveStatic("/" + path.relative(assetsDir, assetFile).replace(/\\/g, "/"), assetsDir, req, res, dev);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
/* fall through */
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// Also try public/assets
|
|
991
|
+
if (publicDir) {
|
|
992
|
+
serveStatic(pathname, publicDir, req, res, dev);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
send404(res, dev);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
// public/ static assets
|
|
999
|
+
if (publicDir) {
|
|
1000
|
+
const filePath = path.resolve(publicDir, "." + pathname);
|
|
1001
|
+
if (isPathInside(publicDir, filePath)) {
|
|
1002
|
+
try {
|
|
1003
|
+
const stat = fs.statSync(filePath);
|
|
1004
|
+
if (stat.isFile()) {
|
|
1005
|
+
serveStatic(pathname, publicDir, req, res, dev);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
/* not a static file, fall through to SSR */
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// ── 11. SSR — GET only ──────────────────────────────────────────────────────────
|
|
1015
|
+
if (method === "GET" || method === "HEAD") {
|
|
1016
|
+
const { appFile } = paths;
|
|
1017
|
+
// SPA mode or no App.ts — return 404
|
|
1018
|
+
if (!appFile) {
|
|
1019
|
+
send404(res, dev);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
applySecurityHeaders(res, dev, true);
|
|
1023
|
+
// Mark this as an SSR page request before rendering so middleware
|
|
1024
|
+
// can check c.isSSR to conditionally skip or adjust behavior.
|
|
1025
|
+
c.isSSR = true;
|
|
1026
|
+
try {
|
|
1027
|
+
const { renderApp } = await import("./renderer.js");
|
|
1028
|
+
const requestPath = url.pathname + url.search;
|
|
1029
|
+
const result = await renderApp(appFile, requestPath, dev, timeoutMs);
|
|
1030
|
+
const { buildShell } = await import("./head.js");
|
|
1031
|
+
const sourceRoot = options.sourceRoot ?? options.projectRoot;
|
|
1032
|
+
const clientEntry = dev && options.noBundle && options.sourceClientEntry
|
|
1033
|
+
? `${INTERNAL_SRC_PREFIX}/${path.relative(sourceRoot, options.sourceClientEntry).split(path.sep).join("/")}`
|
|
1034
|
+
: `/assets/${path.basename(options.clientBundlePath ?? "client.js")}`;
|
|
1035
|
+
const cssFilename = getCurrentCssFilename(options);
|
|
1036
|
+
const cssUrl = cssFilename ? `/assets/${cssFilename}` : null;
|
|
1037
|
+
const fullHtml = buildShell({
|
|
1038
|
+
projectRoot: options.projectRoot,
|
|
1039
|
+
bodyHtml: result.html,
|
|
1040
|
+
clientEntry,
|
|
1041
|
+
cssUrl,
|
|
1042
|
+
ssrStyles: result.styles,
|
|
1043
|
+
dev,
|
|
1044
|
+
importMap: dev && options.noBundle
|
|
1045
|
+
? buildDevImportMap(options.projectRoot)
|
|
1046
|
+
: null,
|
|
1047
|
+
});
|
|
1048
|
+
const buf = Buffer.from(fullHtml, "utf-8");
|
|
1049
|
+
if (!res.headersSent) {
|
|
1050
|
+
res.writeHead(200, {
|
|
1051
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1052
|
+
"Content-Length": buf.length,
|
|
1053
|
+
"Cache-Control": dev ? "no-store" : "no-cache",
|
|
1054
|
+
});
|
|
1055
|
+
if (method !== "HEAD")
|
|
1056
|
+
res.end(buf);
|
|
1057
|
+
else
|
|
1058
|
+
res.end();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
if (isRedirect(err)) {
|
|
1063
|
+
if (!res.headersSent) {
|
|
1064
|
+
res.writeHead(err.status, { Location: err.url });
|
|
1065
|
+
res.end();
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
devError("SSR render error:", err);
|
|
1070
|
+
if (!res.headersSent)
|
|
1071
|
+
send500(res, dev);
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// ── 12. Method not allowed for non-GET/HEAD ────────────────────────────────
|
|
1076
|
+
send405(res, dev);
|
|
1077
|
+
}); // end runWithContext
|
|
1078
|
+
}
|
|
1079
|
+
// ─── startServer ─────────────────────────────────────────────────────────────
|
|
1080
|
+
/**
|
|
1081
|
+
* Create and start the Node.js HTTP server.
|
|
1082
|
+
* Returns a cleanup/shutdown function.
|
|
1083
|
+
*/
|
|
1084
|
+
export async function startServer(options) {
|
|
1085
|
+
// Older tests/internal callers may omit projectRoot; paths.projectRoot is the
|
|
1086
|
+
// canonical fallback.
|
|
1087
|
+
options.projectRoot = options.projectRoot ?? options.paths.projectRoot;
|
|
1088
|
+
const { paths, dev } = options;
|
|
1089
|
+
const timeoutMs = options.timeoutMs ?? _getServerTimeoutMs();
|
|
1090
|
+
const port = paths.port;
|
|
1091
|
+
// Reset module-level state (supports hot-reload in tests)
|
|
1092
|
+
_activeRequests = 0;
|
|
1093
|
+
_isShuttingDown = false;
|
|
1094
|
+
_drainCallbacks.length = 0;
|
|
1095
|
+
_sseClients.clear();
|
|
1096
|
+
_sseGeneration =
|
|
1097
|
+
process.env.MATES_RELOAD_GENERATION ?? Date.now().toString(36);
|
|
1098
|
+
let stopSocketServer = null;
|
|
1099
|
+
const server = http.createServer(async (req, res) => {
|
|
1100
|
+
// Abort quickly during shutdown — send 503
|
|
1101
|
+
if (_isShuttingDown) {
|
|
1102
|
+
applySecurityHeaders(res, dev, false);
|
|
1103
|
+
res.setHeader("Retry-After", "5");
|
|
1104
|
+
sendJson(res, 503, { error: "Server is shutting down", status: 503 }, dev);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
await handleRequest(req, res, options);
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
// Absolute last resort — handleRequest itself threw unexpectedly.
|
|
1112
|
+
// This should never happen but if it does, the server must not crash.
|
|
1113
|
+
devError("Unhandled server error — this is a framework bug, please report it.", err?.message ?? err);
|
|
1114
|
+
if (!res.headersSent) {
|
|
1115
|
+
try {
|
|
1116
|
+
const body = JSON.stringify({
|
|
1117
|
+
__type: "AppError",
|
|
1118
|
+
message: dev
|
|
1119
|
+
? `Server encountered an unknown error: ${err?.message ?? err}`
|
|
1120
|
+
: "Server encountered an unknown error.",
|
|
1121
|
+
status: 500,
|
|
1122
|
+
code: "UNKNOWN_ERROR",
|
|
1123
|
+
});
|
|
1124
|
+
res.writeHead(500, {
|
|
1125
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1126
|
+
"Content-Length": Buffer.byteLength(body),
|
|
1127
|
+
});
|
|
1128
|
+
res.end(body);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// Socket may be gone — nothing more we can do
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
// Limit concurrent connections to prevent resource exhaustion
|
|
1137
|
+
server.maxConnections = options.maxConnections ?? 1000;
|
|
1138
|
+
// Apply module-level server config from options
|
|
1139
|
+
configureServer(options);
|
|
1140
|
+
// Expose upgrade event — WebSocket caller attaches its own handler
|
|
1141
|
+
// We simply do nothing here; Node.js will emit "upgrade" on the server.
|
|
1142
|
+
// If nobody handles it, Node.js destroys the socket (correct behavior).
|
|
1143
|
+
server.on("error", (err) => {
|
|
1144
|
+
if (err.code === "EADDRINUSE") {
|
|
1145
|
+
fatalError(`Port ${port} is already in use.\n` +
|
|
1146
|
+
` Stop the existing process or change the PORT environment variable.`);
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
devError("server error:", err.message);
|
|
1150
|
+
});
|
|
1151
|
+
await new Promise((resolve, reject) => {
|
|
1152
|
+
server.listen(port, () => resolve());
|
|
1153
|
+
server.once("error", reject);
|
|
1154
|
+
});
|
|
1155
|
+
if (paths.socketDir) {
|
|
1156
|
+
try {
|
|
1157
|
+
const { attachSocketServer } = await import("./socket-router.js");
|
|
1158
|
+
stopSocketServer = await attachSocketServer({ server, dev });
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
devWarn(`WebSocket server failed to attach: ${err?.message ?? err}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (dev) {
|
|
1165
|
+
startupLog(`dev server running at http://localhost:${port}`);
|
|
1166
|
+
startupLog(`dev reload generation: ${_sseGeneration}`);
|
|
1167
|
+
}
|
|
1168
|
+
// ── Graceful shutdown ────────────────────────────────────────────────────
|
|
1169
|
+
const shutdownTimeoutMs = timeoutMs * 2;
|
|
1170
|
+
function shutdown() {
|
|
1171
|
+
if (_isShuttingDown)
|
|
1172
|
+
return;
|
|
1173
|
+
_isShuttingDown = true;
|
|
1174
|
+
stopSocketServer?.();
|
|
1175
|
+
// Close all SSE connections
|
|
1176
|
+
for (const client of _sseClients) {
|
|
1177
|
+
try {
|
|
1178
|
+
if (!client.writableEnded)
|
|
1179
|
+
client.end();
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
/* ignore */
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
_sseClients.clear();
|
|
1186
|
+
// Stop accepting new connections
|
|
1187
|
+
server.close();
|
|
1188
|
+
if (_activeRequests === 0) {
|
|
1189
|
+
// Nothing in flight — done immediately
|
|
1190
|
+
_callShutdownRenderer();
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
// Wait for in-flight requests to drain
|
|
1194
|
+
const drainTimeout = setTimeout(() => {
|
|
1195
|
+
devWarn(`shutdown timeout — ${_activeRequests} request(s) still active`);
|
|
1196
|
+
_callShutdownRenderer();
|
|
1197
|
+
}, shutdownTimeoutMs);
|
|
1198
|
+
_drainCallbacks.push(() => {
|
|
1199
|
+
clearTimeout(drainTimeout);
|
|
1200
|
+
_callShutdownRenderer();
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
return shutdown;
|
|
1204
|
+
}
|
|
1205
|
+
function _callShutdownRenderer() {
|
|
1206
|
+
// If the renderer exposes a shutdown hook, call it
|
|
1207
|
+
try {
|
|
1208
|
+
// Dynamic require to avoid a hard dep at module level
|
|
1209
|
+
const renderer = require("./renderer.js");
|
|
1210
|
+
if (typeof renderer.shutdownRenderer === "function") {
|
|
1211
|
+
renderer.shutdownRenderer();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
// renderer not available — no-op
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
//# sourceMappingURL=server.js.map
|