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.
Files changed (202) hide show
  1. package/README.md +311 -0
  2. package/dist/arctic-auth.d.ts +101 -0
  3. package/dist/arctic-auth.d.ts.map +1 -0
  4. package/dist/arctic-auth.js +538 -0
  5. package/dist/arctic-auth.js.map +1 -0
  6. package/dist/asset-manifest.d.ts +14 -0
  7. package/dist/asset-manifest.d.ts.map +1 -0
  8. package/dist/asset-manifest.js +102 -0
  9. package/dist/asset-manifest.js.map +1 -0
  10. package/dist/browser.d.ts +18 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +25 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/build-esbuild.d.ts +29 -0
  15. package/dist/build-esbuild.d.ts.map +1 -0
  16. package/dist/build-esbuild.js +699 -0
  17. package/dist/build-esbuild.js.map +1 -0
  18. package/dist/build-prod.d.ts +126 -0
  19. package/dist/build-prod.d.ts.map +1 -0
  20. package/dist/build-prod.js +1014 -0
  21. package/dist/build-prod.js.map +1 -0
  22. package/dist/cli-new.d.ts +14 -0
  23. package/dist/cli-new.d.ts.map +1 -0
  24. package/dist/cli-new.js +637 -0
  25. package/dist/cli-new.js.map +1 -0
  26. package/dist/client.d.ts +43 -0
  27. package/dist/client.d.ts.map +1 -0
  28. package/dist/client.js +130 -0
  29. package/dist/client.js.map +1 -0
  30. package/dist/cors.d.ts +16 -0
  31. package/dist/cors.d.ts.map +1 -0
  32. package/dist/cors.js +60 -0
  33. package/dist/cors.js.map +1 -0
  34. package/dist/ctx.d.ts +78 -0
  35. package/dist/ctx.d.ts.map +1 -0
  36. package/dist/ctx.js +280 -0
  37. package/dist/ctx.js.map +1 -0
  38. package/dist/dev-watcher.d.ts +23 -0
  39. package/dist/dev-watcher.d.ts.map +1 -0
  40. package/dist/dev-watcher.js +136 -0
  41. package/dist/dev-watcher.js.map +1 -0
  42. package/dist/docs-generator.d.ts +69 -0
  43. package/dist/docs-generator.d.ts.map +1 -0
  44. package/dist/docs-generator.js +557 -0
  45. package/dist/docs-generator.js.map +1 -0
  46. package/dist/docs-page.d.ts +20 -0
  47. package/dist/docs-page.d.ts.map +1 -0
  48. package/dist/docs-page.js +1152 -0
  49. package/dist/docs-page.js.map +1 -0
  50. package/dist/download.d.ts +78 -0
  51. package/dist/download.d.ts.map +1 -0
  52. package/dist/download.js +202 -0
  53. package/dist/download.js.map +1 -0
  54. package/dist/env-loader.d.ts +76 -0
  55. package/dist/env-loader.d.ts.map +1 -0
  56. package/dist/env-loader.js +213 -0
  57. package/dist/env-loader.js.map +1 -0
  58. package/dist/errors.d.ts +146 -0
  59. package/dist/errors.d.ts.map +1 -0
  60. package/dist/errors.js +386 -0
  61. package/dist/errors.js.map +1 -0
  62. package/dist/head.d.ts +31 -0
  63. package/dist/head.d.ts.map +1 -0
  64. package/dist/head.js +245 -0
  65. package/dist/head.js.map +1 -0
  66. package/dist/index.d.ts +30 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +30 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/internal-prefixes.d.ts +16 -0
  71. package/dist/internal-prefixes.d.ts.map +1 -0
  72. package/dist/internal-prefixes.js +16 -0
  73. package/dist/internal-prefixes.js.map +1 -0
  74. package/dist/internal.d.ts +25 -0
  75. package/dist/internal.d.ts.map +1 -0
  76. package/dist/internal.js +25 -0
  77. package/dist/internal.js.map +1 -0
  78. package/dist/jwt.d.ts +166 -0
  79. package/dist/jwt.d.ts.map +1 -0
  80. package/dist/jwt.js +261 -0
  81. package/dist/jwt.js.map +1 -0
  82. package/dist/log.d.ts +44 -0
  83. package/dist/log.d.ts.map +1 -0
  84. package/dist/log.js +66 -0
  85. package/dist/log.js.map +1 -0
  86. package/dist/logger.d.ts +76 -0
  87. package/dist/logger.d.ts.map +1 -0
  88. package/dist/logger.js +138 -0
  89. package/dist/logger.js.map +1 -0
  90. package/dist/main-runner.d.ts +59 -0
  91. package/dist/main-runner.d.ts.map +1 -0
  92. package/dist/main-runner.js +157 -0
  93. package/dist/main-runner.js.map +1 -0
  94. package/dist/mates-auth.d.ts +82 -0
  95. package/dist/mates-auth.d.ts.map +1 -0
  96. package/dist/mates-auth.js +323 -0
  97. package/dist/mates-auth.js.map +1 -0
  98. package/dist/middleware.d.ts +30 -0
  99. package/dist/middleware.d.ts.map +1 -0
  100. package/dist/middleware.js +67 -0
  101. package/dist/middleware.js.map +1 -0
  102. package/dist/project-resolver.d.ts +102 -0
  103. package/dist/project-resolver.d.ts.map +1 -0
  104. package/dist/project-resolver.js +271 -0
  105. package/dist/project-resolver.js.map +1 -0
  106. package/dist/rate-limit.d.ts +37 -0
  107. package/dist/rate-limit.d.ts.map +1 -0
  108. package/dist/rate-limit.js +109 -0
  109. package/dist/rate-limit.js.map +1 -0
  110. package/dist/redirect.d.ts +84 -0
  111. package/dist/redirect.d.ts.map +1 -0
  112. package/dist/redirect.js +105 -0
  113. package/dist/redirect.js.map +1 -0
  114. package/dist/renderer.d.ts +91 -0
  115. package/dist/renderer.d.ts.map +1 -0
  116. package/dist/renderer.js +630 -0
  117. package/dist/renderer.js.map +1 -0
  118. package/dist/request-logger.d.ts +12 -0
  119. package/dist/request-logger.d.ts.map +1 -0
  120. package/dist/request-logger.js +55 -0
  121. package/dist/request-logger.js.map +1 -0
  122. package/dist/rest.d.ts +25 -0
  123. package/dist/rest.d.ts.map +1 -0
  124. package/dist/rest.js +93 -0
  125. package/dist/rest.js.map +1 -0
  126. package/dist/router.d.ts +71 -0
  127. package/dist/router.d.ts.map +1 -0
  128. package/dist/router.js +222 -0
  129. package/dist/router.js.map +1 -0
  130. package/dist/rpc-registry.d.ts +84 -0
  131. package/dist/rpc-registry.d.ts.map +1 -0
  132. package/dist/rpc-registry.js +271 -0
  133. package/dist/rpc-registry.js.map +1 -0
  134. package/dist/rpc-runner.d.ts +82 -0
  135. package/dist/rpc-runner.d.ts.map +1 -0
  136. package/dist/rpc-runner.js +564 -0
  137. package/dist/rpc-runner.js.map +1 -0
  138. package/dist/sanitize.d.ts +61 -0
  139. package/dist/sanitize.d.ts.map +1 -0
  140. package/dist/sanitize.js +193 -0
  141. package/dist/sanitize.js.map +1 -0
  142. package/dist/security-headers.d.ts +114 -0
  143. package/dist/security-headers.d.ts.map +1 -0
  144. package/dist/security-headers.js +121 -0
  145. package/dist/security-headers.js.map +1 -0
  146. package/dist/server-fn.d.ts +323 -0
  147. package/dist/server-fn.d.ts.map +1 -0
  148. package/dist/server-fn.js +373 -0
  149. package/dist/server-fn.js.map +1 -0
  150. package/dist/server-public.d.ts +13 -0
  151. package/dist/server-public.d.ts.map +1 -0
  152. package/dist/server-public.js +12 -0
  153. package/dist/server-public.js.map +1 -0
  154. package/dist/server-timeout.d.ts +38 -0
  155. package/dist/server-timeout.d.ts.map +1 -0
  156. package/dist/server-timeout.js +46 -0
  157. package/dist/server-timeout.js.map +1 -0
  158. package/dist/server.d.ts +100 -0
  159. package/dist/server.d.ts.map +1 -0
  160. package/dist/server.js +1218 -0
  161. package/dist/server.js.map +1 -0
  162. package/dist/socket-router.d.ts +153 -0
  163. package/dist/socket-router.d.ts.map +1 -0
  164. package/dist/socket-router.js +612 -0
  165. package/dist/socket-router.js.map +1 -0
  166. package/dist/sso.d.ts +90 -0
  167. package/dist/sso.d.ts.map +1 -0
  168. package/dist/sso.js +261 -0
  169. package/dist/sso.js.map +1 -0
  170. package/dist/ssr-context.d.ts +49 -0
  171. package/dist/ssr-context.d.ts.map +1 -0
  172. package/dist/ssr-context.js +85 -0
  173. package/dist/ssr-context.js.map +1 -0
  174. package/dist/ssr-globals.d.ts +32 -0
  175. package/dist/ssr-globals.d.ts.map +1 -0
  176. package/dist/ssr-globals.js +1010 -0
  177. package/dist/ssr-globals.js.map +1 -0
  178. package/dist/ssr-template.d.ts +73 -0
  179. package/dist/ssr-template.d.ts.map +1 -0
  180. package/dist/ssr-template.js +507 -0
  181. package/dist/ssr-template.js.map +1 -0
  182. package/dist/stack-mapper.d.ts +25 -0
  183. package/dist/stack-mapper.d.ts.map +1 -0
  184. package/dist/stack-mapper.js +139 -0
  185. package/dist/stack-mapper.js.map +1 -0
  186. package/dist/stream.d.ts +89 -0
  187. package/dist/stream.d.ts.map +1 -0
  188. package/dist/stream.js +299 -0
  189. package/dist/stream.js.map +1 -0
  190. package/dist/upload.d.ts +69 -0
  191. package/dist/upload.d.ts.map +1 -0
  192. package/dist/upload.js +110 -0
  193. package/dist/upload.js.map +1 -0
  194. package/dist/validate.d.ts +58 -0
  195. package/dist/validate.d.ts.map +1 -0
  196. package/dist/validate.js +89 -0
  197. package/dist/validate.js.map +1 -0
  198. package/dist/verify-package.d.ts +3 -0
  199. package/dist/verify-package.d.ts.map +1 -0
  200. package/dist/verify-package.js +128 -0
  201. package/dist/verify-package.js.map +1 -0
  202. 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