elit 3.0.1 → 3.0.2

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 (87) hide show
  1. package/dist/build.d.ts +4 -12
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/chokidar.d.ts +7 -9
  4. package/dist/chokidar.d.ts.map +1 -0
  5. package/dist/cli.d.ts +6 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +17 -4
  8. package/dist/config.d.ts +29 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/dom.d.ts +7 -14
  11. package/dist/dom.d.ts.map +1 -0
  12. package/dist/el.d.ts +19 -191
  13. package/dist/el.d.ts.map +1 -0
  14. package/dist/fs.d.ts +35 -35
  15. package/dist/fs.d.ts.map +1 -0
  16. package/dist/hmr.d.ts +3 -3
  17. package/dist/hmr.d.ts.map +1 -0
  18. package/dist/http.d.ts +20 -22
  19. package/dist/http.d.ts.map +1 -0
  20. package/dist/https.d.ts +12 -15
  21. package/dist/https.d.ts.map +1 -0
  22. package/dist/index.d.ts +10 -629
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/mime-types.d.ts +9 -9
  25. package/dist/mime-types.d.ts.map +1 -0
  26. package/dist/path.d.ts +22 -19
  27. package/dist/path.d.ts.map +1 -0
  28. package/dist/router.d.ts +10 -17
  29. package/dist/router.d.ts.map +1 -0
  30. package/dist/runtime.d.ts +5 -6
  31. package/dist/runtime.d.ts.map +1 -0
  32. package/dist/server.d.ts +105 -7
  33. package/dist/server.d.ts.map +1 -0
  34. package/dist/server.js +14 -2
  35. package/dist/server.mjs +14 -2
  36. package/dist/state.d.ts +21 -27
  37. package/dist/state.d.ts.map +1 -0
  38. package/dist/style.d.ts +14 -55
  39. package/dist/style.d.ts.map +1 -0
  40. package/dist/types.d.ts +26 -240
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/ws.d.ts +14 -17
  43. package/dist/ws.d.ts.map +1 -0
  44. package/dist/wss.d.ts +16 -16
  45. package/dist/wss.d.ts.map +1 -0
  46. package/package.json +3 -2
  47. package/src/build.ts +337 -0
  48. package/src/chokidar.ts +401 -0
  49. package/src/cli.ts +638 -0
  50. package/src/config.ts +205 -0
  51. package/src/dom.ts +817 -0
  52. package/src/el.ts +164 -0
  53. package/src/fs.ts +727 -0
  54. package/src/hmr.ts +137 -0
  55. package/src/http.ts +775 -0
  56. package/src/https.ts +411 -0
  57. package/src/index.ts +14 -0
  58. package/src/mime-types.ts +222 -0
  59. package/src/path.ts +493 -0
  60. package/src/router.ts +237 -0
  61. package/src/runtime.ts +97 -0
  62. package/src/server.ts +1290 -0
  63. package/src/state.ts +468 -0
  64. package/src/style.ts +524 -0
  65. package/{dist/types-Du6kfwTm.d.ts → src/types.ts} +58 -141
  66. package/src/ws.ts +506 -0
  67. package/src/wss.ts +241 -0
  68. package/dist/build.d.mts +0 -20
  69. package/dist/chokidar.d.mts +0 -134
  70. package/dist/dom.d.mts +0 -87
  71. package/dist/el.d.mts +0 -207
  72. package/dist/fs.d.mts +0 -255
  73. package/dist/hmr.d.mts +0 -38
  74. package/dist/http.d.mts +0 -163
  75. package/dist/https.d.mts +0 -108
  76. package/dist/index.d.mts +0 -629
  77. package/dist/mime-types.d.mts +0 -48
  78. package/dist/path.d.mts +0 -163
  79. package/dist/router.d.mts +0 -47
  80. package/dist/runtime.d.mts +0 -97
  81. package/dist/server.d.mts +0 -7
  82. package/dist/state.d.mts +0 -111
  83. package/dist/style.d.mts +0 -159
  84. package/dist/types-C0nGi6MX.d.mts +0 -346
  85. package/dist/types.d.mts +0 -452
  86. package/dist/ws.d.mts +0 -195
  87. package/dist/wss.d.mts +0 -108
package/src/server.ts ADDED
@@ -0,0 +1,1290 @@
1
+ /**
2
+ * Development server with HMR support
3
+ * Cross-runtime transpilation support
4
+ * - Node.js: uses esbuild
5
+ * - Bun: uses Bun.Transpiler
6
+ * - Deno: uses Deno.emit
7
+ */
8
+
9
+ import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from './http';
10
+ import { request as httpsRequest } from './https';
11
+ import { WebSocketServer, WebSocket, ReadyState } from './ws';
12
+ import { watch } from './chokidar';
13
+ import { readFile, stat, realpath } from './fs';
14
+ import { join, extname, relative, resolve, normalize, sep } from './path';
15
+ import { lookup } from './mime-types';
16
+ import { isBun, isDeno } from './runtime';
17
+ import type { DevServerOptions, DevServer, HMRMessage, Child, VNode, ProxyConfig } from './types';
18
+ import { dom } from './dom';
19
+
20
+ // ===== Router =====
21
+
22
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
23
+
24
+ export interface ServerRouteContext {
25
+ req: IncomingMessage;
26
+ res: ServerResponse;
27
+ params: Record<string, string>;
28
+ query: Record<string, string>;
29
+ body: any;
30
+ headers: Record<string, string | string[] | undefined>;
31
+ }
32
+
33
+ export type ServerRouteHandler = (ctx: ServerRouteContext) => void | Promise<void>;
34
+ export type Middleware = (ctx: ServerRouteContext, next: () => Promise<void>) => void | Promise<void>;
35
+
36
+ interface ServerRoute {
37
+ method: HttpMethod;
38
+ pattern: RegExp;
39
+ paramNames: string[];
40
+ handler: ServerRouteHandler;
41
+ }
42
+
43
+ export class ServerRouter {
44
+ private routes: ServerRoute[] = [];
45
+ private middlewares: Middleware[] = [];
46
+
47
+ use(middleware: Middleware): this {
48
+ this.middlewares.push(middleware);
49
+ return this;
50
+ }
51
+
52
+ get = (path: string, handler: ServerRouteHandler): this => this.addRoute('GET', path, handler);
53
+ post = (path: string, handler: ServerRouteHandler): this => this.addRoute('POST', path, handler);
54
+ put = (path: string, handler: ServerRouteHandler): this => this.addRoute('PUT', path, handler);
55
+ delete = (path: string, handler: ServerRouteHandler): this => this.addRoute('DELETE', path, handler);
56
+ patch = (path: string, handler: ServerRouteHandler): this => this.addRoute('PATCH', path, handler);
57
+ options = (path: string, handler: ServerRouteHandler): this => this.addRoute('OPTIONS', path, handler);
58
+
59
+ private addRoute(method: HttpMethod, path: string, handler: ServerRouteHandler): this {
60
+ const { pattern, paramNames } = this.pathToRegex(path);
61
+ this.routes.push({ method, pattern, paramNames, handler });
62
+ return this;
63
+ }
64
+
65
+ private pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
66
+ const paramNames: string[] = [];
67
+ const pattern = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '\\/').replace(/:(\w+)/g, (_, name) => (paramNames.push(name), '([^\\/]+)'));
68
+ return { pattern: new RegExp(`^${pattern}$`), paramNames };
69
+ }
70
+
71
+ private parseQuery(url: string): Record<string, string> {
72
+ const query: Record<string, string> = {};
73
+ url.split('?')[1]?.split('&').forEach(p => { const [k, v] = p.split('='); if (k) query[k] = v || ''; });
74
+ return query;
75
+ }
76
+
77
+ private parseBody(req: IncomingMessage): Promise<any> {
78
+ return new Promise((resolve, reject) => {
79
+ let body = '';
80
+ req.on('data', chunk => body += chunk);
81
+ req.on('end', () => {
82
+ try {
83
+ const ct = req.headers['content-type'] || '';
84
+ resolve(ct.includes('json') ? (body ? JSON.parse(body) : {}) : ct.includes('urlencoded') ? Object.fromEntries(new URLSearchParams(body)) : body);
85
+ } catch (e) { reject(e); }
86
+ });
87
+ req.on('error', reject);
88
+ });
89
+ }
90
+
91
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
92
+ const method = req.method as HttpMethod, url = req.url || '/', path = url.split('?')[0];
93
+
94
+ for (const route of this.routes) {
95
+ if (route.method !== method || !route.pattern.test(path)) continue;
96
+ const match = path.match(route.pattern)!;
97
+ const params = Object.fromEntries(route.paramNames.map((name, i) => [name, match[i + 1]]));
98
+
99
+ let body: any = {};
100
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
101
+ try { body = await this.parseBody(req); }
102
+ catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"error":"Invalid request body"}'); return true; }
103
+ }
104
+
105
+ const ctx: ServerRouteContext = { req, res, params, query: this.parseQuery(url), body, headers: req.headers as any };
106
+ let i = 0;
107
+ const next = async () => i < this.middlewares.length && await this.middlewares[i++](ctx, next);
108
+
109
+ try { await next(); await route.handler(ctx); }
110
+ catch (e) {
111
+ console.error('Route error:', e);
112
+ !res.headersSent && (res.writeHead(500, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ error: 'Internal Server Error', message: e instanceof Error ? e.message : 'Unknown' })));
113
+ }
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ }
119
+
120
+ export const json = (res: ServerResponse, data: any, status = 200) => (res.writeHead(status, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(data)));
121
+ export const text = (res: ServerResponse, data: string, status = 200) => (res.writeHead(status, { 'Content-Type': 'text/plain' }), res.end(data));
122
+ export const html = (res: ServerResponse, data: string, status = 200) => (res.writeHead(status, { 'Content-Type': 'text/html' }), res.end(data));
123
+ export const status = (res: ServerResponse, code: number, message = '') => (res.writeHead(code, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ status: code, message })));
124
+
125
+ // Helper functions for common responses
126
+ const sendError = (res: ServerResponse, code: number, msg: string): void => { res.writeHead(code, { 'Content-Type': 'text/plain' }); res.end(msg); };
127
+ const send404 = (res: ServerResponse, msg = 'Not Found'): void => sendError(res, 404, msg);
128
+ const send403 = (res: ServerResponse, msg = 'Forbidden'): void => sendError(res, 403, msg);
129
+ const send500 = (res: ServerResponse, msg = 'Internal Server Error'): void => sendError(res, 500, msg);
130
+
131
+ // Import map for all Elit client-side modules (reused in serveFile and serveSSR)
132
+ const createElitImportMap = (basePath: string = '', mode: 'dev' | 'preview' = 'dev'): string => {
133
+ // In dev mode, use built files from node_modules/elit/dist
134
+ // In preview mode, use built files from dist
135
+ const srcPath = mode === 'dev'
136
+ ? (basePath ? `${basePath}/node_modules/elit/src` : '/node_modules/elit/src')
137
+ : (basePath ? `${basePath}/node_modules/elit/dist` : '/node_modules/elit/dist');
138
+
139
+ const fileExt = mode === 'dev' ? '.ts' : '.mjs';
140
+
141
+ return `<script type="importmap">{
142
+ "imports": {
143
+ "elit": "${srcPath}/index${fileExt}",
144
+ "elit/": "${srcPath}/",
145
+ "elit/dom": "${srcPath}/dom${fileExt}",
146
+ "elit/state": "${srcPath}/state${fileExt}",
147
+ "elit/style": "${srcPath}/style${fileExt}",
148
+ "elit/el": "${srcPath}/el${fileExt}",
149
+ "elit/router": "${srcPath}/router${fileExt}",
150
+ "elit/hmr": "${srcPath}/hmr${fileExt}",
151
+ "elit/types": "${srcPath}/types${fileExt}"
152
+ }
153
+ }</script>`;
154
+ };
155
+
156
+ // Helper function to generate HMR script (reused in serveFile and serveSSR)
157
+ const createHMRScript = (port: number, wsPath: string): string =>
158
+ `<script>(function(){let ws;let retries=0;let maxRetries=5;function connect(){ws=new WebSocket('ws://'+window.location.hostname+':${port}${wsPath}');ws.onopen=()=>{console.log('[Elit HMR] Connected');retries=0};ws.onmessage=(e)=>{const d=JSON.parse(e.data);if(d.type==='update'){console.log('[Elit HMR] File updated:',d.path);window.location.reload()}else if(d.type==='reload'){console.log('[Elit HMR] Reloading...');window.location.reload()}else if(d.type==='error')console.error('[Elit HMR] Error:',d.error)};ws.onclose=()=>{if(retries<maxRetries){retries++;setTimeout(connect,1000*retries)}else if(retries===maxRetries){console.log('[Elit HMR] Connection closed. Start dev server to reconnect.')}};ws.onerror=()=>{ws.close()}}connect()})();</script>`;
159
+
160
+ // Helper function to rewrite relative paths with basePath (reused in serveFile and serveSSR)
161
+ const rewriteRelativePaths = (html: string, basePath: string): string => {
162
+ if (!basePath) return html;
163
+ // Rewrite paths starting with ./ or just relative paths (not starting with /, http://, https://)
164
+ html = html.replace(/(<script[^>]+src=["'])(?!https?:\/\/|\/)(\.\/)?([^"']+)(["'])/g, `$1${basePath}/$3$4`);
165
+ html = html.replace(/(<link[^>]+href=["'])(?!https?:\/\/|\/)(\.\/)?([^"']+)(["'])/g, `$1${basePath}/$3$4`);
166
+ return html;
167
+ };
168
+
169
+ // Helper function to normalize basePath (reused in serveFile and serveSSR)
170
+ const normalizeBasePath = (basePath?: string): string => basePath && basePath !== '/' ? basePath : '';
171
+
172
+ // Helper function to find dist or node_modules directory by walking up the directory tree
173
+ async function findSpecialDir(startDir: string, targetDir: string): Promise<string | null> {
174
+ let currentDir = startDir;
175
+ const maxLevels = 5; // Prevent infinite loop
176
+
177
+ for (let i = 0; i < maxLevels; i++) {
178
+ const targetPath = resolve(currentDir, targetDir);
179
+ try {
180
+ const stats = await stat(targetPath);
181
+ if (stats.isDirectory()) {
182
+ return currentDir; // Return the parent directory containing the target
183
+ }
184
+ } catch {
185
+ // Directory doesn't exist, try parent
186
+ }
187
+
188
+ const parentDir = resolve(currentDir, '..');
189
+ if (parentDir === currentDir) break; // Reached filesystem root
190
+ currentDir = parentDir;
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ // ===== Middleware =====
197
+
198
+ export function cors(options: {
199
+ origin?: string | string[];
200
+ methods?: string[];
201
+ credentials?: boolean;
202
+ maxAge?: number;
203
+ } = {}): Middleware {
204
+ const { origin = '*', methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], credentials = true, maxAge = 86400 } = options;
205
+
206
+ return async (ctx, next) => {
207
+ const requestOriginHeader = ctx.req.headers.origin;
208
+ const requestOrigin = Array.isArray(requestOriginHeader) ? requestOriginHeader[0] : (requestOriginHeader || '');
209
+ const allowOrigin = Array.isArray(origin) && origin.includes(requestOrigin) ? requestOrigin : (Array.isArray(origin) ? '' : origin);
210
+
211
+ if (allowOrigin) ctx.res.setHeader('Access-Control-Allow-Origin', allowOrigin);
212
+ ctx.res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
213
+ ctx.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
214
+ if (credentials) ctx.res.setHeader('Access-Control-Allow-Credentials', 'true');
215
+ ctx.res.setHeader('Access-Control-Max-Age', String(maxAge));
216
+
217
+ if (ctx.req.method === 'OPTIONS') {
218
+ ctx.res.writeHead(204);
219
+ ctx.res.end();
220
+ return;
221
+ }
222
+ await next();
223
+ };
224
+ }
225
+
226
+ export function logger(options: { format?: 'simple' | 'detailed' } = {}): Middleware {
227
+ const { format = 'simple' } = options;
228
+ return async (ctx, next) => {
229
+ const start = Date.now();
230
+ const { method, url } = ctx.req;
231
+ await next();
232
+ const duration = Date.now() - start;
233
+ const status = ctx.res.statusCode;
234
+ console.log(format === 'detailed' ? `[${new Date().toISOString()}] ${method} ${url} ${status} - ${duration}ms` : `${method} ${url} - ${status} (${duration}ms)`);
235
+ };
236
+ }
237
+
238
+ export function errorHandler(): Middleware {
239
+ return async (ctx, next) => {
240
+ try {
241
+ await next();
242
+ } catch (error) {
243
+ console.error('Error:', error);
244
+ if (!ctx.res.headersSent) {
245
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
246
+ ctx.res.end(JSON.stringify({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error' }));
247
+ }
248
+ }
249
+ };
250
+ }
251
+
252
+ export function rateLimit(options: { windowMs?: number; max?: number; message?: string } = {}): Middleware {
253
+ const { windowMs = 60000, max = 100, message = 'Too many requests' } = options;
254
+ const clients = new Map<string, { count: number; resetTime: number }>();
255
+
256
+ return async (ctx, next) => {
257
+ const ip = ctx.req.socket.remoteAddress || 'unknown';
258
+ const now = Date.now();
259
+ let clientData = clients.get(ip);
260
+
261
+ if (!clientData || now > clientData.resetTime) {
262
+ clientData = { count: 0, resetTime: now + windowMs };
263
+ clients.set(ip, clientData);
264
+ }
265
+
266
+ if (++clientData.count > max) {
267
+ ctx.res.writeHead(429, { 'Content-Type': 'application/json' });
268
+ ctx.res.end(JSON.stringify({ error: message }));
269
+ return;
270
+ }
271
+ await next();
272
+ };
273
+ }
274
+
275
+ export function bodyLimit(options: { limit?: number } = {}): Middleware {
276
+ const { limit = 1024 * 1024 } = options;
277
+ return async (ctx, next) => {
278
+ const contentLength = ctx.req.headers['content-length'];
279
+ const contentLengthStr = Array.isArray(contentLength) ? contentLength[0] : (contentLength || '0');
280
+ if (parseInt(contentLengthStr, 10) > limit) {
281
+ ctx.res.writeHead(413, { 'Content-Type': 'application/json' });
282
+ ctx.res.end(JSON.stringify({ error: 'Request body too large' }));
283
+ return;
284
+ }
285
+ await next();
286
+ };
287
+ }
288
+
289
+ export function cacheControl(options: { maxAge?: number; public?: boolean } = {}): Middleware {
290
+ const { maxAge = 3600, public: isPublic = true } = options;
291
+ return async (ctx, next) => {
292
+ ctx.res.setHeader('Cache-Control', `${isPublic ? 'public' : 'private'}, max-age=${maxAge}`);
293
+ await next();
294
+ };
295
+ }
296
+
297
+ export function compress(): Middleware {
298
+ return async (ctx, next) => {
299
+ const acceptEncoding = ctx.req.headers['accept-encoding'] || '';
300
+ if (!acceptEncoding.includes('gzip')) {
301
+ await next();
302
+ return;
303
+ }
304
+
305
+ // Store original end method
306
+ const originalEnd = ctx.res.end.bind(ctx.res);
307
+ const chunks: Buffer[] = [];
308
+
309
+ // Intercept response data
310
+ ctx.res.write = ((chunk: any) => {
311
+ chunks.push(Buffer.from(chunk));
312
+ return true;
313
+ }) as any;
314
+
315
+ ctx.res.end = ((chunk?: any) => {
316
+ if (chunk) chunks.push(Buffer.from(chunk));
317
+
318
+ const buffer = Buffer.concat(chunks);
319
+ const { gzipSync } = require('zlib');
320
+ const compressed = gzipSync(buffer);
321
+
322
+ ctx.res.setHeader('Content-Encoding', 'gzip');
323
+ ctx.res.setHeader('Content-Length', compressed.length);
324
+ originalEnd(compressed);
325
+ return ctx.res;
326
+ }) as any;
327
+
328
+ await next();
329
+ };
330
+ }
331
+
332
+ export function security(): Middleware {
333
+ return async (ctx, next) => {
334
+ ctx.res.setHeader('X-Content-Type-Options', 'nosniff');
335
+ ctx.res.setHeader('X-Frame-Options', 'DENY');
336
+ ctx.res.setHeader('X-XSS-Protection', '1; mode=block');
337
+ ctx.res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
338
+ await next();
339
+ };
340
+ }
341
+
342
+ // ===== Proxy Handler =====
343
+
344
+ function rewritePath(path: string, pathRewrite?: Record<string, string>): string {
345
+ if (!pathRewrite) return path;
346
+
347
+ for (const [from, to] of Object.entries(pathRewrite)) {
348
+ const regex = new RegExp(from);
349
+ if (regex.test(path)) {
350
+ return path.replace(regex, to);
351
+ }
352
+ }
353
+ return path;
354
+ }
355
+
356
+ export function createProxyHandler(proxyConfigs: ProxyConfig[]) {
357
+ return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
358
+ const url = req.url || '/';
359
+ const path = url.split('?')[0];
360
+
361
+ // Find matching proxy configuration (first match wins)
362
+ const proxy = proxyConfigs.find(p => path.startsWith(p.context));
363
+ if (!proxy) return false;
364
+
365
+ const { target, changeOrigin, pathRewrite, headers } = proxy;
366
+
367
+ try {
368
+ const targetUrl = new URL(target);
369
+ const isHttps = targetUrl.protocol === 'https:';
370
+ const requestLib = isHttps ? httpsRequest : httpRequest;
371
+
372
+ // Rewrite path if needed
373
+ let proxyPath = rewritePath(url, pathRewrite);
374
+
375
+ // Build the full proxy URL
376
+ const proxyUrl = `${isHttps ? 'https' : 'http'}://${targetUrl.hostname}:${targetUrl.port || (isHttps ? 443 : 80)}${proxyPath}`;
377
+
378
+ // Build proxy request options
379
+ const proxyReqHeaders: Record<string, string | number | string[]> = {};
380
+ for (const [key, value] of Object.entries(req.headers)) {
381
+ if (value !== undefined) {
382
+ proxyReqHeaders[key] = value;
383
+ }
384
+ }
385
+ if (headers) {
386
+ for (const [key, value] of Object.entries(headers)) {
387
+ if (value !== undefined) {
388
+ proxyReqHeaders[key] = value;
389
+ }
390
+ }
391
+ }
392
+
393
+ // Change origin if requested
394
+ if (changeOrigin) {
395
+ proxyReqHeaders.host = targetUrl.host;
396
+ }
397
+
398
+ // Remove headers that shouldn't be forwarded
399
+ delete proxyReqHeaders['host'];
400
+
401
+ const proxyReqOptions = {
402
+ method: req.method,
403
+ headers: proxyReqHeaders
404
+ };
405
+
406
+ // Create proxy request
407
+ const proxyReq = requestLib(proxyUrl, proxyReqOptions, (proxyRes) => {
408
+ // Forward status code and headers - convert incoming headers properly
409
+ const outgoingHeaders: Record<string, string | number | string[]> = {};
410
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
411
+ if (value !== undefined) {
412
+ outgoingHeaders[key] = value;
413
+ }
414
+ }
415
+ res.writeHead(proxyRes.statusCode || 200, outgoingHeaders);
416
+
417
+ // Pipe response using read/write instead of pipe
418
+ proxyRes.on('data', (chunk) => res.write(chunk));
419
+ proxyRes.on('end', () => res.end());
420
+ });
421
+
422
+ // Handle errors
423
+ proxyReq.on('error', (error) => {
424
+ console.error('[Proxy] Error proxying %s to %s:', url, target, error.message);
425
+ if (!res.headersSent) {
426
+ json(res, { error: 'Bad Gateway', message: 'Proxy error' }, 502);
427
+ }
428
+ });
429
+
430
+ // Forward request body
431
+ req.on('data', (chunk) => proxyReq.write(chunk));
432
+ req.on('end', () => proxyReq.end());
433
+
434
+ return true;
435
+ } catch (error) {
436
+ console.error('[Proxy] Invalid proxy configuration for %s:', path, error);
437
+ return false;
438
+ }
439
+ };
440
+ }
441
+
442
+ // ===== State Management =====
443
+
444
+ export type StateChangeHandler<T = any> = (value: T, oldValue: T) => void;
445
+
446
+ export interface SharedStateOptions<T = any> {
447
+ initial: T;
448
+ persist?: boolean;
449
+ validate?: (value: T) => boolean;
450
+ }
451
+
452
+ export class SharedState<T = any> {
453
+ private _value: T;
454
+ private listeners = new Set<WebSocket>();
455
+ private changeHandlers = new Set<StateChangeHandler<T>>();
456
+ private options: SharedStateOptions<T>;
457
+
458
+ constructor(
459
+ public readonly key: string,
460
+ options: SharedStateOptions<T>
461
+ ) {
462
+ this.options = options;
463
+ this._value = options.initial;
464
+ }
465
+
466
+ get value(): T {
467
+ return this._value;
468
+ }
469
+
470
+ set value(newValue: T) {
471
+ if (this.options.validate && !this.options.validate(newValue)) {
472
+ throw new Error(`Invalid state value for "${this.key}"`);
473
+ }
474
+
475
+ const oldValue = this._value;
476
+ this._value = newValue;
477
+
478
+ this.changeHandlers.forEach(handler => {
479
+ handler(newValue, oldValue);
480
+ });
481
+
482
+ this.broadcast();
483
+ }
484
+
485
+ update(updater: (current: T) => T): void {
486
+ this.value = updater(this._value);
487
+ }
488
+
489
+ subscribe(ws: WebSocket): void {
490
+ this.listeners.add(ws);
491
+ this.sendTo(ws);
492
+ }
493
+
494
+ unsubscribe(ws: WebSocket): void {
495
+ this.listeners.delete(ws);
496
+ }
497
+
498
+ onChange(handler: StateChangeHandler<T>): () => void {
499
+ this.changeHandlers.add(handler);
500
+ return () => this.changeHandlers.delete(handler);
501
+ }
502
+
503
+ private broadcast(): void {
504
+ const message = JSON.stringify({ type: 'state:update', key: this.key, value: this._value, timestamp: Date.now() });
505
+ this.listeners.forEach(ws => ws.readyState === ReadyState.OPEN && ws.send(message));
506
+ }
507
+
508
+ private sendTo(ws: WebSocket): void {
509
+ if (ws.readyState === ReadyState.OPEN) {
510
+ ws.send(JSON.stringify({ type: 'state:init', key: this.key, value: this._value, timestamp: Date.now() }));
511
+ }
512
+ }
513
+
514
+ get subscriberCount(): number {
515
+ return this.listeners.size;
516
+ }
517
+
518
+ clear(): void {
519
+ this.listeners.clear();
520
+ this.changeHandlers.clear();
521
+ }
522
+ }
523
+
524
+ export class StateManager {
525
+ private states = new Map<string, SharedState<any>>();
526
+
527
+ create<T>(key: string, options: SharedStateOptions<T>): SharedState<T> {
528
+ if (this.states.has(key)) return this.states.get(key) as SharedState<T>;
529
+ const state = new SharedState<T>(key, options);
530
+ this.states.set(key, state);
531
+ return state;
532
+ }
533
+
534
+ get<T>(key: string): SharedState<T> | undefined {
535
+ return this.states.get(key) as SharedState<T>;
536
+ }
537
+
538
+ has(key: string): boolean {
539
+ return this.states.has(key);
540
+ }
541
+
542
+ delete(key: string): boolean {
543
+ const state = this.states.get(key);
544
+ if (state) {
545
+ state.clear();
546
+ return this.states.delete(key);
547
+ }
548
+ return false;
549
+ }
550
+
551
+ subscribe(key: string, ws: WebSocket): void {
552
+ this.states.get(key)?.subscribe(ws);
553
+ }
554
+
555
+ unsubscribe(key: string, ws: WebSocket): void {
556
+ this.states.get(key)?.unsubscribe(ws);
557
+ }
558
+
559
+ unsubscribeAll(ws: WebSocket): void {
560
+ this.states.forEach(state => state.unsubscribe(ws));
561
+ }
562
+
563
+ handleStateChange(key: string, value: any): void {
564
+ const state = this.states.get(key);
565
+ if (state) state.value = value;
566
+ }
567
+
568
+ keys(): string[] {
569
+ return Array.from(this.states.keys());
570
+ }
571
+
572
+ clear(): void {
573
+ this.states.forEach(state => state.clear());
574
+ this.states.clear();
575
+ }
576
+ }
577
+
578
+ // ===== Development Server =====
579
+
580
+ const defaultOptions: Omit<Required<DevServerOptions>, 'api' | 'clients' | 'root' | 'basePath' | 'ssr' | 'proxy' | 'index'> = {
581
+ port: 3000,
582
+ host: 'localhost',
583
+ https: false,
584
+ open: true,
585
+ watch: ['**/*.ts', '**/*.js', '**/*.html', '**/*.css'],
586
+ ignore: ['node_modules/**', 'dist/**', '.git/**', '**/*.d.ts'],
587
+ logging: true,
588
+ middleware: [],
589
+ worker: [],
590
+ mode: 'dev'
591
+ };
592
+
593
+ interface NormalizedClient {
594
+ root: string;
595
+ basePath: string;
596
+ index?: string;
597
+ ssr?: () => Child | string;
598
+ api?: ServerRouter;
599
+ proxyHandler?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
600
+ mode: 'dev' | 'preview';
601
+ }
602
+
603
+ export function createDevServer(options: DevServerOptions): DevServer {
604
+ const config = { ...defaultOptions, ...options };
605
+ const wsClients = new Set<WebSocket>();
606
+ const stateManager = new StateManager();
607
+
608
+ // Normalize clients configuration - support both new API (clients array) and legacy API (root/basePath)
609
+ const clientsToNormalize = config.clients?.length ? config.clients : config.root ? [{ root: config.root, basePath: config.basePath || '', index: config.index, ssr: config.ssr, api: config.api, proxy: config.proxy, mode: config.mode }] : null;
610
+ if (!clientsToNormalize) throw new Error('DevServerOptions must include either "clients" array or "root" directory');
611
+
612
+ const normalizedClients: NormalizedClient[] = clientsToNormalize.map(client => {
613
+ let basePath = client.basePath || '';
614
+ if (basePath) {
615
+ // Remove leading/trailing slashes safely without ReDoS vulnerability
616
+ while (basePath.startsWith('/')) basePath = basePath.slice(1);
617
+ while (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
618
+ basePath = basePath ? '/' + basePath : '';
619
+ }
620
+
621
+ // Normalize index path - convert ./path to /path
622
+ let indexPath = client.index;
623
+ if (indexPath) {
624
+ // Remove leading ./ and ensure it starts with /
625
+ indexPath = indexPath.replace(/^\.\//, '/');
626
+ if (!indexPath.startsWith('/')) {
627
+ indexPath = '/' + indexPath;
628
+ }
629
+ }
630
+
631
+ return {
632
+ root: client.root,
633
+ basePath,
634
+ index: indexPath,
635
+ ssr: client.ssr,
636
+ api: client.api,
637
+ proxyHandler: client.proxy ? createProxyHandler(client.proxy) : undefined,
638
+ mode: client.mode || 'dev'
639
+ };
640
+ });
641
+
642
+ // Create global proxy handler if proxy config exists
643
+ const globalProxyHandler = config.proxy ? createProxyHandler(config.proxy) : null;
644
+
645
+ // HTTP Server
646
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
647
+ const originalUrl = req.url || '/';
648
+
649
+ // Find matching client based on basePath
650
+ const matchedClient = normalizedClients.find(c => c.basePath && originalUrl.startsWith(c.basePath)) || normalizedClients.find(c => !c.basePath);
651
+ if (!matchedClient) return send404(res, '404 Not Found');
652
+
653
+ // Try client-specific proxy first
654
+ if (matchedClient.proxyHandler) {
655
+ try {
656
+ const proxied = await matchedClient.proxyHandler(req, res);
657
+ if (proxied) {
658
+ if (config.logging) console.log(`[Proxy] ${req.method} ${originalUrl} -> proxied (client-specific)`);
659
+ return;
660
+ }
661
+ } catch (error) {
662
+ console.error('[Proxy] Error (client-specific):', error);
663
+ }
664
+ }
665
+
666
+ // Try global proxy if client-specific didn't match
667
+ if (globalProxyHandler) {
668
+ try {
669
+ const proxied = await globalProxyHandler(req, res);
670
+ if (proxied) {
671
+ if (config.logging) console.log(`[Proxy] ${req.method} ${originalUrl} -> proxied (global)`);
672
+ return;
673
+ }
674
+ } catch (error) {
675
+ console.error('[Proxy] Error (global):', error);
676
+ }
677
+ }
678
+
679
+ const url = matchedClient.basePath ? (originalUrl.slice(matchedClient.basePath.length) || '/') : originalUrl;
680
+
681
+ // Try client-specific API routes first
682
+ if (matchedClient.api && url.startsWith('/api')) {
683
+ const handled = await matchedClient.api.handle(req, res);
684
+ if (handled) return;
685
+ }
686
+
687
+ // Try global API routes (fallback)
688
+ if (config.api && url.startsWith('/api')) {
689
+ const handled = await config.api.handle(req, res);
690
+ if (handled) return;
691
+ }
692
+
693
+ // For root path requests, prioritize SSR over index files if SSR is configured
694
+ let filePath: string;
695
+ if (url === '/' && matchedClient.ssr && !matchedClient.index) {
696
+ // Use SSR directly when configured and no custom index specified
697
+ return serveSSR(res, matchedClient);
698
+ } else {
699
+ // Use custom index file if specified, otherwise default to /index.html
700
+ filePath = url === '/' ? (matchedClient.index || '/index.html') : url;
701
+ }
702
+
703
+ // Remove query string
704
+ filePath = filePath.split('?')[0];
705
+
706
+ if (config.logging && filePath === '/src/pages') {
707
+ console.log(`[DEBUG] Request for /src/pages received`);
708
+ }
709
+
710
+ // Security: Check for null bytes early
711
+ if (filePath.includes('\0')) {
712
+ if (config.logging) console.log(`[403] Rejected path with null byte: ${filePath}`);
713
+ return send403(res, '403 Forbidden');
714
+ }
715
+
716
+ // Handle /dist/* and /node_modules/* requests - serve from parent folder
717
+ const isDistRequest = filePath.startsWith('/dist/');
718
+ const isNodeModulesRequest = filePath.startsWith('/node_modules/');
719
+ let normalizedPath: string;
720
+
721
+ // Normalize and validate the path for both /dist/* and regular requests
722
+ const tempPath = normalize(filePath).replace(/\\/g, '/').replace(/^\/+/, '');
723
+ if (tempPath.includes('..')) {
724
+ if (config.logging) console.log(`[403] Path traversal attempt: ${filePath}`);
725
+ return send403(res, '403 Forbidden');
726
+ }
727
+ normalizedPath = tempPath;
728
+
729
+ // Resolve file path
730
+ const rootDir = await realpath(resolve(matchedClient.root));
731
+ let baseDir = rootDir;
732
+
733
+ // Auto-detect base directory for /dist/* and /node_modules/* requests
734
+ if (isDistRequest || isNodeModulesRequest) {
735
+ const targetDir = isDistRequest ? 'dist' : 'node_modules';
736
+ const foundDir = await findSpecialDir(matchedClient.root, targetDir);
737
+ baseDir = foundDir ? await realpath(foundDir) : rootDir;
738
+ }
739
+
740
+ let fullPath;
741
+
742
+ try {
743
+ // First check path without resolving symlinks for security
744
+ const unresolvedPath = resolve(join(baseDir, normalizedPath));
745
+ if (!unresolvedPath.startsWith(baseDir.endsWith(sep) ? baseDir : baseDir + sep)) {
746
+ if (config.logging) console.log(`[403] File access outside of root (before symlink): ${unresolvedPath}`);
747
+ return send403(res, '403 Forbidden');
748
+ }
749
+
750
+ // Then resolve symlinks to get actual file
751
+ fullPath = await realpath(unresolvedPath);
752
+ if (config.logging && filePath === '/src/pages') {
753
+ console.log(`[DEBUG] Initial resolve succeeded: ${fullPath}`);
754
+ }
755
+ } catch (firstError) {
756
+ // If file not found, try different extensions
757
+ let resolvedPath: string | undefined;
758
+
759
+ if (config.logging && !normalizedPath.includes('.')) {
760
+ console.log(`[DEBUG] File not found: ${normalizedPath}, trying extensions...`);
761
+ }
762
+
763
+ // If .js file not found, try .ts file
764
+ if (normalizedPath.endsWith('.js')) {
765
+ const tsPath = normalizedPath.replace(/\.js$/, '.ts');
766
+ try {
767
+ const tsFullPath = await realpath(resolve(join(baseDir, tsPath)));
768
+ // Security: Ensure path is strictly within the allowed root directory
769
+ if (!tsFullPath.startsWith(baseDir.endsWith(sep) ? baseDir : baseDir + sep)) {
770
+ if (config.logging) console.log(`[403] Fallback TS path outside of root: ${tsFullPath}`);
771
+ return send403(res, '403 Forbidden');
772
+ }
773
+ resolvedPath = tsFullPath;
774
+ } catch {
775
+ // Continue to next attempt
776
+ }
777
+ }
778
+
779
+ // If no extension, try adding .ts or .js, or index files
780
+ if (!resolvedPath && !normalizedPath.includes('.')) {
781
+ // Try .ts first
782
+ try {
783
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath + '.ts')));
784
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}.ts`);
785
+ } catch {
786
+ // Try .js
787
+ try {
788
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath + '.js')));
789
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}.js`);
790
+ } catch {
791
+ // Try index.ts in directory
792
+ try {
793
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath, 'index.ts')));
794
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}/index.ts`);
795
+ } catch {
796
+ // Try index.js in directory
797
+ try {
798
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath, 'index.js')));
799
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}/index.js`);
800
+ } catch {
801
+ if (config.logging) console.log(`[DEBUG] Not found: all attempts failed for ${normalizedPath}`);
802
+ }
803
+ }
804
+ }
805
+ }
806
+ }
807
+
808
+ if (!resolvedPath) {
809
+ if (!res.headersSent) {
810
+ // If index.html not found but SSR function exists, use SSR
811
+ if (filePath === '/index.html' && matchedClient.ssr) {
812
+ return serveSSR(res, matchedClient);
813
+ }
814
+ if (config.logging) console.log(`[404] ${filePath}`);
815
+ return send404(res, '404 Not Found');
816
+ }
817
+ return;
818
+ }
819
+
820
+ fullPath = resolvedPath;
821
+ }
822
+
823
+ // Check if resolved path is a directory, try index files
824
+ try {
825
+ const stats = await stat(fullPath);
826
+ if (stats.isDirectory()) {
827
+ if (config.logging) console.log(`[DEBUG] Path is directory: ${fullPath}, trying index files...`);
828
+ let indexPath: string | undefined;
829
+
830
+ // Try index.ts first
831
+ try {
832
+ indexPath = await realpath(resolve(join(fullPath, 'index.ts')));
833
+ if (config.logging) console.log(`[DEBUG] Found index.ts in directory`);
834
+ } catch {
835
+ // Try index.js
836
+ try {
837
+ indexPath = await realpath(resolve(join(fullPath, 'index.js')));
838
+ if (config.logging) console.log(`[DEBUG] Found index.js in directory`);
839
+ } catch {
840
+ if (config.logging) console.log(`[DEBUG] No index file found in directory`);
841
+ // If index.html not found in directory but SSR function exists, use SSR
842
+ if (matchedClient.ssr) {
843
+ return serveSSR(res, matchedClient);
844
+ }
845
+ return send404(res, '404 Not Found');
846
+ }
847
+ }
848
+
849
+ fullPath = indexPath;
850
+ }
851
+ } catch (statError) {
852
+ if (config.logging) console.log(`[404] ${filePath}`);
853
+ return send404(res, '404 Not Found');
854
+ }
855
+
856
+ // Security check already done before resolving symlinks (line 733)
857
+ // No need to check again after symlink resolution as that would block legitimate symlinks
858
+
859
+ try {
860
+ const stats = await stat(fullPath);
861
+
862
+ if (stats.isDirectory()) {
863
+ try {
864
+ const indexPath = await realpath(resolve(join(fullPath, 'index.html')));
865
+ if (!indexPath.startsWith(rootDir + sep) && indexPath !== rootDir) {
866
+ return send403(res, '403 Forbidden');
867
+ }
868
+ await stat(indexPath);
869
+ return serveFile(indexPath, res, matchedClient);
870
+ } catch {
871
+ return send404(res, '404 Not Found');
872
+ }
873
+ }
874
+
875
+ await serveFile(fullPath, res, matchedClient);
876
+ } catch (error) {
877
+ // Only send 404 if response hasn't been sent yet
878
+ if (!res.headersSent) {
879
+ if (config.logging) console.log(`[404] ${filePath}`);
880
+ send404(res, '404 Not Found');
881
+ }
882
+ }
883
+ });
884
+
885
+ // Serve file helper
886
+ async function serveFile(filePath: string, res: ServerResponse, client: NormalizedClient) {
887
+ try {
888
+ const rootDir = await realpath(resolve(client.root));
889
+
890
+ // Security: Check path before resolving symlinks
891
+ const unresolvedPath = resolve(filePath);
892
+ const isNodeModules = filePath.includes('/node_modules/') || filePath.includes('\\node_modules\\');
893
+ const isDist = filePath.includes('/dist/') || filePath.includes('\\dist\\');
894
+
895
+ // Check if path is within project root (for symlinked packages like node_modules/elit)
896
+ const projectRoot = await realpath(resolve(client.root, '..'));
897
+ const isInProjectRoot = unresolvedPath.startsWith(projectRoot + sep) || unresolvedPath === projectRoot;
898
+
899
+ if (!unresolvedPath.startsWith(rootDir + sep) && unresolvedPath !== rootDir && !isInProjectRoot) {
900
+ // Allow if it's in node_modules or dist directories (these may be symlinks)
901
+ if (!isNodeModules && !isDist) {
902
+ if (config.logging) console.log(`[403] Attempted to serve file outside allowed directories: ${filePath}`);
903
+ return send403(res, '403 Forbidden');
904
+ }
905
+ }
906
+
907
+ // Resolve symlinks to get actual file path
908
+ let resolvedPath;
909
+ try {
910
+ resolvedPath = await realpath(unresolvedPath);
911
+ } catch {
912
+ // If index.html not found but SSR function exists, use SSR
913
+ if (filePath.endsWith('index.html') && client.ssr) {
914
+ return serveSSR(res, client);
915
+ }
916
+ return send404(res, '404 Not Found');
917
+ }
918
+
919
+ let content = await readFile(resolvedPath);
920
+ const ext = extname(resolvedPath);
921
+ let mimeType = lookup(resolvedPath) || 'application/octet-stream';
922
+
923
+ // Handle TypeScript files - transpile only (no bundling)
924
+ if (ext === '.ts' || ext === '.tsx') {
925
+ try {
926
+ let transpiled: string;
927
+
928
+ if (isDeno) {
929
+ // Deno - use Deno.emit
930
+ // @ts-ignore
931
+ const result = await Deno.emit(resolvedPath, {
932
+ check: false,
933
+ compilerOptions: {
934
+ sourceMap: true,
935
+ inlineSourceMap: true,
936
+ target: 'ES2020',
937
+ module: 'esnext'
938
+ },
939
+ sources: {
940
+ [resolvedPath]: content.toString()
941
+ }
942
+ });
943
+
944
+ transpiled = result.files[resolvedPath.replace(/\.tsx?$/, '.js')] || '';
945
+
946
+ } else if (isBun) {
947
+ // Bun - use Bun.Transpiler
948
+ // @ts-ignore
949
+ const transpiler = new Bun.Transpiler({
950
+ loader: ext === '.tsx' ? 'tsx' : 'ts',
951
+ target: 'browser'
952
+ });
953
+
954
+ // @ts-ignore
955
+ transpiled = transpiler.transformSync(content.toString());
956
+ } else {
957
+ // Node.js - use esbuild
958
+ const { build } = await import('esbuild');
959
+ const result = await build({
960
+ stdin: {
961
+ contents: content.toString(),
962
+ loader: ext === '.tsx' ? 'tsx' : 'ts',
963
+ resolveDir: resolve(resolvedPath, '..'),
964
+ sourcefile: resolvedPath
965
+ },
966
+ format: 'esm',
967
+ target: 'es2020',
968
+ write: false,
969
+ bundle: false,
970
+ sourcemap: 'inline'
971
+ });
972
+
973
+ transpiled = result.outputFiles[0].text;
974
+ }
975
+
976
+ // Rewrite .ts imports to .js for browser compatibility
977
+ // This allows developers to write import './file.ts' in their source code
978
+ // and the dev server will automatically rewrite it to import './file.js'
979
+ transpiled = transpiled.replace(
980
+ /from\s+["']([^"']+)\.ts(x?)["']/g,
981
+ (_, path, tsx) => `from "${path}.js${tsx}"`
982
+ );
983
+ transpiled = transpiled.replace(
984
+ /import\s+["']([^"']+)\.ts(x?)["']/g,
985
+ (_, path, tsx) => `import "${path}.js${tsx}"`
986
+ );
987
+
988
+ content = Buffer.from(transpiled);
989
+ mimeType = 'application/javascript';
990
+ } catch (error) {
991
+ if (config.logging) console.error('[500] TypeScript compilation error:', error);
992
+ return send500(res, `TypeScript compilation error:\n${error}`);
993
+ }
994
+ }
995
+
996
+ // Inject HMR client and import map for HTML files
997
+ if (ext === '.html') {
998
+ const wsPath = normalizeBasePath(client.basePath);
999
+ const hmrScript = createHMRScript(config.port, wsPath);
1000
+ let html = content.toString();
1001
+
1002
+ // If SSR is configured, extract and inject styles from SSR
1003
+ let ssrStyles = '';
1004
+ if (client.ssr) {
1005
+ try {
1006
+ const result = client.ssr();
1007
+ let ssrHtml: string;
1008
+
1009
+ // Convert SSR result to string
1010
+ if (typeof result === 'string') {
1011
+ ssrHtml = result;
1012
+ } else if (typeof result === 'object' && result !== null && 'tagName' in result) {
1013
+ ssrHtml = dom.renderToString(result as VNode);
1014
+ } else {
1015
+ ssrHtml = String(result);
1016
+ }
1017
+
1018
+ // Extract <style> tags from SSR output
1019
+ const styleMatches = ssrHtml.match(/<style[^>]*>[\s\S]*?<\/style>/g);
1020
+ if (styleMatches) {
1021
+ ssrStyles = styleMatches.join('\n');
1022
+ }
1023
+ } catch (error) {
1024
+ if (config.logging) console.error('[Warning] Failed to extract styles from SSR:', error);
1025
+ }
1026
+ }
1027
+
1028
+ // Fix relative paths to use basePath
1029
+ const basePath = normalizeBasePath(client.basePath);
1030
+ html = rewriteRelativePaths(html, basePath);
1031
+
1032
+ // Inject base tag if basePath is configured and not '/'
1033
+ if (client.basePath && client.basePath !== '/') {
1034
+ const baseTag = `<base href="${client.basePath}/">`;
1035
+ // Check if base tag already exists
1036
+ if (!html.includes('<base')) {
1037
+ // Try to inject after viewport meta tag
1038
+ if (html.includes('<meta name="viewport"')) {
1039
+ html = html.replace(
1040
+ /<meta name="viewport"[^>]*>/,
1041
+ (match) => `${match}\n ${baseTag}`
1042
+ );
1043
+ } else if (html.includes('<head>')) {
1044
+ // If no viewport, inject right after <head>
1045
+ html = html.replace('<head>', `<head>\n ${baseTag}`);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // Inject import map and SSR styles into <head>
1051
+ const elitImportMap = createElitImportMap(basePath, client.mode);
1052
+ const headInjection = ssrStyles ? `${ssrStyles}\n${elitImportMap}` : elitImportMap;
1053
+ html = html.includes('</head>') ? html.replace('</head>', `${headInjection}</head>`) : html;
1054
+ html = html.includes('</body>') ? html.replace('</body>', `${hmrScript}</body>`) : html + hmrScript;
1055
+ content = Buffer.from(html);
1056
+ }
1057
+
1058
+ // Set cache headers based on file type
1059
+ const cacheControl = ext === '.html' || ext === '.ts' || ext === '.tsx'
1060
+ ? 'no-cache, no-store, must-revalidate' // Don't cache HTML/TS files in dev
1061
+ : 'public, max-age=31536000, immutable'; // Cache static assets for 1 year
1062
+
1063
+ const headers: any = {
1064
+ 'Content-Type': mimeType,
1065
+ 'Cache-Control': cacheControl
1066
+ };
1067
+
1068
+ // Apply gzip compression for text-based files
1069
+ const compressible = /^(text\/|application\/(javascript|json|xml))/.test(mimeType);
1070
+
1071
+ if (compressible && content.length > 1024) {
1072
+ const { gzipSync } = require('zlib');
1073
+ const compressed = gzipSync(content);
1074
+ headers['Content-Encoding'] = 'gzip';
1075
+ headers['Content-Length'] = compressed.length;
1076
+ res.writeHead(200, headers);
1077
+ res.end(compressed);
1078
+ } else {
1079
+ res.writeHead(200, headers);
1080
+ res.end(content);
1081
+ }
1082
+
1083
+ if (config.logging) console.log(`[200] ${relative(client.root, filePath)}`);
1084
+ } catch (error) {
1085
+ if (config.logging) console.error('[500] Error reading file:', error);
1086
+ send500(res, '500 Internal Server Error');
1087
+ }
1088
+ }
1089
+
1090
+ // SSR helper - Generate HTML from SSR function
1091
+ function serveSSR(res: ServerResponse, client: NormalizedClient) {
1092
+ try {
1093
+ if (!client.ssr) {
1094
+ return send500(res, 'SSR function not configured');
1095
+ }
1096
+
1097
+ const result = client.ssr();
1098
+ let html: string;
1099
+
1100
+ // If result is a string, use it directly
1101
+ if (typeof result === 'string') {
1102
+ html = result;
1103
+ }
1104
+ // If result is a VNode, render it to HTML string
1105
+ else if (typeof result === 'object' && result !== null && 'tagName' in result) {
1106
+ const vnode = result as VNode;
1107
+ if (vnode.tagName === 'html') {
1108
+ html = dom.renderToString(vnode);
1109
+ } else {
1110
+ // Wrap in basic HTML structure if not html tag
1111
+ html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body>${dom.renderToString(vnode)}</body></html>`;
1112
+ }
1113
+ } else {
1114
+ html = String(result);
1115
+ }
1116
+
1117
+ // Fix relative paths to use basePath
1118
+ const basePath = normalizeBasePath(client.basePath);
1119
+ html = rewriteRelativePaths(html, basePath);
1120
+
1121
+ // Inject HMR script
1122
+ const hmrScript = createHMRScript(config.port, basePath);
1123
+
1124
+ // Inject import map in head, HMR script in body
1125
+ const elitImportMap = createElitImportMap(basePath, client.mode);
1126
+ html = html.includes('</head>') ? html.replace('</head>', `${elitImportMap}</head>`) : html;
1127
+ html = html.includes('</body>') ? html.replace('</body>', `${hmrScript}</body>`) : html + hmrScript;
1128
+
1129
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
1130
+ res.end(html);
1131
+
1132
+ if (config.logging) console.log(`[200] SSR rendered`);
1133
+ } catch (error) {
1134
+ if (config.logging) console.error('[500] SSR Error:', error);
1135
+ send500(res, '500 SSR Error');
1136
+ }
1137
+ }
1138
+
1139
+ // WebSocket Server for HMR
1140
+ const wss = new WebSocketServer({ server });
1141
+
1142
+ if (config.logging) {
1143
+ console.log('[HMR] WebSocket server initialized');
1144
+ }
1145
+
1146
+ wss.on('connection', (ws: WebSocket, req) => {
1147
+ wsClients.add(ws);
1148
+
1149
+ const message: HMRMessage = { type: 'connected', timestamp: Date.now() };
1150
+ ws.send(JSON.stringify(message));
1151
+
1152
+ if (config.logging) {
1153
+ console.log('[HMR] Client connected from', req.socket.remoteAddress);
1154
+ }
1155
+
1156
+ // Handle incoming messages
1157
+ ws.on('message', (data: string) => {
1158
+ try {
1159
+ const msg = JSON.parse(data.toString());
1160
+
1161
+ // Handle state subscription
1162
+ if (msg.type === 'state:subscribe') {
1163
+ stateManager.subscribe(msg.key, ws);
1164
+ if (config.logging) {
1165
+ console.log(`[State] Client subscribed to "${msg.key}"`);
1166
+ }
1167
+ }
1168
+
1169
+ // Handle state unsubscribe
1170
+ else if (msg.type === 'state:unsubscribe') {
1171
+ stateManager.unsubscribe(msg.key, ws);
1172
+ if (config.logging) {
1173
+ console.log(`[State] Client unsubscribed from "${msg.key}"`);
1174
+ }
1175
+ }
1176
+
1177
+ // Handle state change from client
1178
+ else if (msg.type === 'state:change') {
1179
+ stateManager.handleStateChange(msg.key, msg.value);
1180
+ if (config.logging) {
1181
+ console.log(`[State] Client updated "${msg.key}"`);
1182
+ }
1183
+ }
1184
+ } catch (error) {
1185
+ if (config.logging) {
1186
+ console.error('[WebSocket] Message parse error:', error);
1187
+ }
1188
+ }
1189
+ });
1190
+
1191
+ ws.on('close', () => {
1192
+ wsClients.delete(ws);
1193
+ stateManager.unsubscribeAll(ws);
1194
+ if (config.logging) {
1195
+ console.log('[HMR] Client disconnected');
1196
+ }
1197
+ });
1198
+ });
1199
+
1200
+ // File watcher - watch all client roots
1201
+ const watchPaths = normalizedClients.flatMap(client =>
1202
+ config.watch.map(pattern => join(client.root, pattern))
1203
+ );
1204
+
1205
+ const watcher = watch(watchPaths, {
1206
+ ignored: (path: string) => config.ignore.some(pattern => path.includes(pattern.replace('/**', '').replace('**/', ''))),
1207
+ ignoreInitial: true,
1208
+ persistent: true
1209
+ });
1210
+
1211
+ watcher.on('change', (path: string) => {
1212
+ if (config.logging) console.log(`[HMR] File changed: ${path}`);
1213
+ const message = JSON.stringify({ type: 'update', path, timestamp: Date.now() } as HMRMessage);
1214
+ wsClients.forEach(client => client.readyState === ReadyState.OPEN && client.send(message));
1215
+ });
1216
+
1217
+ watcher.on('add', (path: string) => config.logging && console.log(`[HMR] File added: ${path}`));
1218
+ watcher.on('unlink', (path: string) => config.logging && console.log(`[HMR] File removed: ${path}`));
1219
+
1220
+ // Increase max listeners to prevent warnings
1221
+ server.setMaxListeners(20);
1222
+
1223
+ // Start server
1224
+ server.listen(config.port, config.host, () => {
1225
+ if (config.logging) {
1226
+ console.log('\nšŸš€ Elit Dev Server');
1227
+ console.log(`\n āžœ Local: http://${config.host}:${config.port}`);
1228
+
1229
+ if (normalizedClients.length > 1) {
1230
+ console.log(` āžœ Clients:`);
1231
+ normalizedClients.forEach(client => {
1232
+ const clientUrl = `http://${config.host}:${config.port}${client.basePath}`;
1233
+ console.log(` - ${clientUrl} → ${client.root}`);
1234
+ });
1235
+ } else {
1236
+ const client = normalizedClients[0];
1237
+ console.log(` āžœ Root: ${client.root}`);
1238
+ if (client.basePath) {
1239
+ console.log(` āžœ Base: ${client.basePath}`);
1240
+ }
1241
+ }
1242
+
1243
+ console.log(`\n[HMR] Watching for file changes...\n`);
1244
+ }
1245
+
1246
+ // Open browser to first client
1247
+ if (config.open && normalizedClients.length > 0) {
1248
+ const firstClient = normalizedClients[0];
1249
+ const url = `http://${config.host}:${config.port}${firstClient.basePath}`;
1250
+
1251
+ const open = async () => {
1252
+ const { default: openBrowser } = await import('open');
1253
+ await openBrowser(url);
1254
+ };
1255
+ open().catch(() => {
1256
+ // Fail silently if open package is not available
1257
+ });
1258
+ }
1259
+ });
1260
+
1261
+ // Cleanup function
1262
+ let isClosing = false;
1263
+ const close = async () => {
1264
+ if (isClosing) return;
1265
+ isClosing = true;
1266
+ if (config.logging) console.log('\n[Server] Shutting down...');
1267
+ await watcher.close();
1268
+ wss.close();
1269
+ wsClients.forEach(client => client.close());
1270
+ wsClients.clear();
1271
+ return new Promise<void>((resolve) => {
1272
+ server.close(() => {
1273
+ if (config.logging) console.log('[Server] Closed');
1274
+ resolve();
1275
+ });
1276
+ });
1277
+ };
1278
+
1279
+ // Get the primary URL (first client's basePath)
1280
+ const primaryClient = normalizedClients[0];
1281
+ const primaryUrl = `http://${config.host}:${config.port}${primaryClient.basePath}`;
1282
+
1283
+ return {
1284
+ server: server as any,
1285
+ wss: wss as any,
1286
+ url: primaryUrl,
1287
+ state: stateManager,
1288
+ close
1289
+ };
1290
+ }