elit 3.0.1 → 3.0.3

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 +250 -21
  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 +109 -7
  33. package/dist/server.d.ts.map +1 -0
  34. package/dist/server.js +712 -137
  35. package/dist/server.mjs +711 -137
  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 +1593 -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,1593 @@
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 = async (rootDir: string, basePath: string = '', mode: 'dev' | 'preview' = 'dev'): Promise<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
+ // Base Elit imports
142
+ const elitImports: ImportMapEntry = {
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
+
154
+ // Generate external library imports
155
+ const externalImports = await generateExternalImportMaps(rootDir, basePath);
156
+
157
+ // Merge imports (Elit imports take precedence)
158
+ const allImports = { ...externalImports, ...elitImports };
159
+
160
+ return `<script type="importmap">${JSON.stringify({ imports: allImports }, null, 2)}</script>`;
161
+ };
162
+
163
+ // Helper function to generate HMR script (reused in serveFile and serveSSR)
164
+ const createHMRScript = (port: number, wsPath: string): string =>
165
+ `<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>`;
166
+
167
+ // Helper function to rewrite relative paths with basePath (reused in serveFile and serveSSR)
168
+ const rewriteRelativePaths = (html: string, basePath: string): string => {
169
+ if (!basePath) return html;
170
+ // Rewrite paths starting with ./ or just relative paths (not starting with /, http://, https://)
171
+ html = html.replace(/(<script[^>]+src=["'])(?!https?:\/\/|\/)(\.\/)?([^"']+)(["'])/g, `$1${basePath}/$3$4`);
172
+ html = html.replace(/(<link[^>]+href=["'])(?!https?:\/\/|\/)(\.\/)?([^"']+)(["'])/g, `$1${basePath}/$3$4`);
173
+ return html;
174
+ };
175
+
176
+ // Helper function to normalize basePath (reused in serveFile and serveSSR)
177
+ const normalizeBasePath = (basePath?: string): string => basePath && basePath !== '/' ? basePath : '';
178
+
179
+ // Helper function to find dist or node_modules directory by walking up the directory tree
180
+ async function findSpecialDir(startDir: string, targetDir: string): Promise<string | null> {
181
+ let currentDir = startDir;
182
+ const maxLevels = 5; // Prevent infinite loop
183
+
184
+ for (let i = 0; i < maxLevels; i++) {
185
+ const targetPath = resolve(currentDir, targetDir);
186
+ try {
187
+ const stats = await stat(targetPath);
188
+ if (stats.isDirectory()) {
189
+ return currentDir; // Return the parent directory containing the target
190
+ }
191
+ } catch {
192
+ // Directory doesn't exist, try parent
193
+ }
194
+
195
+ const parentDir = resolve(currentDir, '..');
196
+ if (parentDir === currentDir) break; // Reached filesystem root
197
+ currentDir = parentDir;
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ // ===== External Library Import Maps =====
204
+
205
+ interface PackageExports {
206
+ [key: string]: string | PackageExports;
207
+ }
208
+
209
+ interface PackageJson {
210
+ name?: string;
211
+ main?: string;
212
+ module?: string;
213
+ browser?: string | Record<string, string | false>;
214
+ exports?: string | PackageExports | { [key: string]: any };
215
+ type?: 'module' | 'commonjs';
216
+ sideEffects?: boolean | string[];
217
+ }
218
+
219
+ interface ImportMapEntry {
220
+ [importName: string]: string;
221
+ }
222
+
223
+ // Cache for generated import maps to avoid re-scanning
224
+ const importMapCache = new Map<string, ImportMapEntry>();
225
+
226
+ /**
227
+ * Clear import map cache (useful when packages are added/removed)
228
+ */
229
+ export function clearImportMapCache(): void {
230
+ importMapCache.clear();
231
+ }
232
+
233
+ /**
234
+ * Scan node_modules and generate import maps for external libraries
235
+ */
236
+ async function generateExternalImportMaps(rootDir: string, basePath: string = ''): Promise<ImportMapEntry> {
237
+ const cacheKey = `${rootDir}:${basePath}`;
238
+ if (importMapCache.has(cacheKey)) {
239
+ return importMapCache.get(cacheKey)!;
240
+ }
241
+
242
+ const importMap: ImportMapEntry = {};
243
+ const nodeModulesPath = await findNodeModules(rootDir);
244
+
245
+ if (!nodeModulesPath) {
246
+ importMapCache.set(cacheKey, importMap);
247
+ return importMap;
248
+ }
249
+
250
+ try {
251
+ const { readdir } = await import('./fs');
252
+ const packages = await readdir(nodeModulesPath);
253
+
254
+ for (const pkgEntry of packages) {
255
+ // Convert Dirent to string
256
+ const pkg = typeof pkgEntry === 'string' ? pkgEntry : pkgEntry.name;
257
+
258
+ // Skip special directories
259
+ if (pkg.startsWith('.')) continue;
260
+
261
+ // Handle scoped packages (@org/package)
262
+ if (pkg.startsWith('@')) {
263
+ try {
264
+ const scopedPackages = await readdir(join(nodeModulesPath, pkg));
265
+ for (const scopedEntry of scopedPackages) {
266
+ const scopedPkg = typeof scopedEntry === 'string' ? scopedEntry : scopedEntry.name;
267
+ const fullPkgName = `${pkg}/${scopedPkg}`;
268
+ await processPackage(nodeModulesPath, fullPkgName, importMap, basePath);
269
+ }
270
+ } catch {
271
+ // Skip if can't read scoped directory
272
+ }
273
+ } else {
274
+ await processPackage(nodeModulesPath, pkg, importMap, basePath);
275
+ }
276
+ }
277
+ } catch (error) {
278
+ console.error('[Import Maps] Error scanning node_modules:', error);
279
+ }
280
+
281
+ importMapCache.set(cacheKey, importMap);
282
+ return importMap;
283
+ }
284
+
285
+ /**
286
+ * Find node_modules directory by walking up the directory tree
287
+ */
288
+ async function findNodeModules(startDir: string): Promise<string | null> {
289
+ const foundDir = await findSpecialDir(startDir, 'node_modules');
290
+ return foundDir ? join(foundDir, 'node_modules') : null;
291
+ }
292
+
293
+ /**
294
+ * Check if a package is browser-compatible
295
+ */
296
+ function isBrowserCompatible(pkgName: string, pkgJson: PackageJson): boolean {
297
+ // Skip build tools, compilers, and Node.js-only packages
298
+ const buildTools = [
299
+ 'typescript', 'esbuild', '@esbuild/',
300
+ 'tsx', 'tsup', 'rollup', 'vite', 'webpack', 'parcel',
301
+ 'terser', 'uglify', 'babel', '@babel/',
302
+ 'postcss', 'autoprefixer', 'cssnano',
303
+ 'sass', 'less', 'stylus'
304
+ ];
305
+
306
+ const nodeOnly = [
307
+ 'node-', '@node-', 'fsevents', 'chokidar',
308
+ 'express', 'koa', 'fastify', 'nest',
309
+ 'commander', 'yargs', 'inquirer', 'chalk', 'ora',
310
+ 'nodemon', 'pm2', 'dotenv'
311
+ ];
312
+
313
+ const testingTools = [
314
+ 'jest', 'vitest', 'mocha', 'chai', 'jasmine',
315
+ '@jest/', '@testing-library/', '@vitest/',
316
+ 'playwright', 'puppeteer', 'cypress'
317
+ ];
318
+
319
+ const linters = [
320
+ 'eslint', '@eslint/', 'prettier', 'tslint',
321
+ 'stylelint', 'commitlint'
322
+ ];
323
+
324
+ const typeDefinitions = [
325
+ '@types/', '@typescript-eslint/'
326
+ ];
327
+
328
+ const utilities = [
329
+ 'get-tsconfig', 'resolve-pkg-maps', 'pkg-types',
330
+ 'fast-glob', 'globby', 'micromatch',
331
+ 'execa', 'cross-spawn', 'shelljs'
332
+ ];
333
+
334
+ // Combine all skip lists
335
+ const skipPatterns = [
336
+ ...buildTools,
337
+ ...nodeOnly,
338
+ ...testingTools,
339
+ ...linters,
340
+ ...typeDefinitions,
341
+ ...utilities
342
+ ];
343
+
344
+ // Check if package name matches skip patterns
345
+ if (skipPatterns.some(pattern => pkgName.startsWith(pattern))) {
346
+ return false;
347
+ }
348
+
349
+ // Skip CommonJS-only lodash (prefer lodash-es)
350
+ if (pkgName === 'lodash') {
351
+ return false;
352
+ }
353
+
354
+ // Prefer packages with explicit browser field or module field (ESM)
355
+ if (pkgJson.browser || pkgJson.module) {
356
+ return true;
357
+ }
358
+
359
+ // Prefer packages with exports field that includes "import" or "browser"
360
+ if (pkgJson.exports) {
361
+ const exportsStr = JSON.stringify(pkgJson.exports);
362
+ if (exportsStr.includes('"import"') || exportsStr.includes('"browser"')) {
363
+ return true;
364
+ }
365
+ }
366
+
367
+ // Skip packages that are explicitly marked as type: "commonjs" without module/browser fields
368
+ if (pkgJson.type === 'commonjs' && !pkgJson.module && !pkgJson.browser) {
369
+ return false;
370
+ }
371
+
372
+ // Default: allow if it has exports or is type: "module"
373
+ return !!(pkgJson.exports || pkgJson.type === 'module' || pkgJson.module);
374
+ }
375
+
376
+ /**
377
+ * Process a single package and add its exports to the import map
378
+ */
379
+ async function processPackage(
380
+ nodeModulesPath: string,
381
+ pkgName: string,
382
+ importMap: ImportMapEntry,
383
+ basePath: string
384
+ ): Promise<void> {
385
+ const pkgPath = join(nodeModulesPath, pkgName);
386
+ const pkgJsonPath = join(pkgPath, 'package.json');
387
+
388
+ try {
389
+ const pkgJsonContent = await readFile(pkgJsonPath);
390
+ const pkgJson: PackageJson = JSON.parse(pkgJsonContent.toString());
391
+
392
+ // Check if package is browser-compatible
393
+ if (!isBrowserCompatible(pkgName, pkgJson)) {
394
+ return;
395
+ }
396
+
397
+ const baseUrl = basePath ? `${basePath}/node_modules/${pkgName}` : `/node_modules/${pkgName}`;
398
+
399
+ // Handle exports field (modern)
400
+ if (pkgJson.exports) {
401
+ processExportsField(pkgName, pkgJson.exports, baseUrl, importMap);
402
+ }
403
+ // Fallback to main/module/browser fields (legacy)
404
+ else {
405
+ const entryPoint = pkgJson.browser || pkgJson.module || pkgJson.main || 'index.js';
406
+ importMap[pkgName] = `${baseUrl}/${entryPoint}`;
407
+
408
+ // Add trailing slash for subpath imports
409
+ importMap[`${pkgName}/`] = `${baseUrl}/`;
410
+ }
411
+ } catch {
412
+ // Skip packages without package.json or invalid JSON
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Process package.json exports field and add to import map
418
+ */
419
+ function processExportsField(
420
+ pkgName: string,
421
+ exports: string | PackageExports | { [key: string]: any },
422
+ baseUrl: string,
423
+ importMap: ImportMapEntry
424
+ ): void {
425
+ // Simple string export
426
+ if (typeof exports === 'string') {
427
+ importMap[pkgName] = `${baseUrl}/${exports}`;
428
+ importMap[`${pkgName}/`] = `${baseUrl}/`;
429
+ return;
430
+ }
431
+
432
+ // Object exports
433
+ if (typeof exports === 'object' && exports !== null) {
434
+ // Handle "." export (main entry)
435
+ if ('.' in exports) {
436
+ const dotExport = exports['.'];
437
+ const resolved = resolveExport(dotExport);
438
+ if (resolved) {
439
+ importMap[pkgName] = `${baseUrl}/${resolved}`;
440
+ }
441
+ } else if ('import' in exports) {
442
+ // Root-level import/require
443
+ const resolved = resolveExport(exports);
444
+ if (resolved) {
445
+ importMap[pkgName] = `${baseUrl}/${resolved}`;
446
+ }
447
+ }
448
+
449
+ // Handle subpath exports
450
+ for (const [key, value] of Object.entries(exports)) {
451
+ if (key === '.' || key === 'import' || key === 'require' || key === 'types' || key === 'default') {
452
+ continue;
453
+ }
454
+
455
+ const resolved = resolveExport(value);
456
+ if (resolved) {
457
+ // Remove leading ./ from key
458
+ const cleanKey = key.startsWith('./') ? key.slice(2) : key;
459
+ const importName = cleanKey ? `${pkgName}/${cleanKey}` : pkgName;
460
+ importMap[importName] = `${baseUrl}/${resolved}`;
461
+ }
462
+ }
463
+
464
+ // Always add trailing slash for subpath imports
465
+ importMap[`${pkgName}/`] = `${baseUrl}/`;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Resolve export value to actual file path
471
+ * Handles conditional exports (import/require/default)
472
+ */
473
+ function resolveExport(exportValue: any): string | null {
474
+ if (typeof exportValue === 'string') {
475
+ // Remove leading ./
476
+ return exportValue.startsWith('./') ? exportValue.slice(2) : exportValue;
477
+ }
478
+
479
+ if (typeof exportValue === 'object' && exportValue !== null) {
480
+ // Prefer import over require over default
481
+ const resolved = exportValue.import || exportValue.browser || exportValue.default || exportValue.require;
482
+
483
+ // Handle nested objects recursively (e.g., TypeScript's complex exports)
484
+ if (typeof resolved === 'object' && resolved !== null) {
485
+ return resolveExport(resolved);
486
+ }
487
+
488
+ if (typeof resolved === 'string') {
489
+ return resolved.startsWith('./') ? resolved.slice(2) : resolved;
490
+ }
491
+ }
492
+
493
+ return null;
494
+ }
495
+
496
+ // ===== Middleware =====
497
+
498
+ export function cors(options: {
499
+ origin?: string | string[];
500
+ methods?: string[];
501
+ credentials?: boolean;
502
+ maxAge?: number;
503
+ } = {}): Middleware {
504
+ const { origin = '*', methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], credentials = true, maxAge = 86400 } = options;
505
+
506
+ return async (ctx, next) => {
507
+ const requestOriginHeader = ctx.req.headers.origin;
508
+ const requestOrigin = Array.isArray(requestOriginHeader) ? requestOriginHeader[0] : (requestOriginHeader || '');
509
+ const allowOrigin = Array.isArray(origin) && origin.includes(requestOrigin) ? requestOrigin : (Array.isArray(origin) ? '' : origin);
510
+
511
+ if (allowOrigin) ctx.res.setHeader('Access-Control-Allow-Origin', allowOrigin);
512
+ ctx.res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
513
+ ctx.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
514
+ if (credentials) ctx.res.setHeader('Access-Control-Allow-Credentials', 'true');
515
+ ctx.res.setHeader('Access-Control-Max-Age', String(maxAge));
516
+
517
+ if (ctx.req.method === 'OPTIONS') {
518
+ ctx.res.writeHead(204);
519
+ ctx.res.end();
520
+ return;
521
+ }
522
+ await next();
523
+ };
524
+ }
525
+
526
+ export function logger(options: { format?: 'simple' | 'detailed' } = {}): Middleware {
527
+ const { format = 'simple' } = options;
528
+ return async (ctx, next) => {
529
+ const start = Date.now();
530
+ const { method, url } = ctx.req;
531
+ await next();
532
+ const duration = Date.now() - start;
533
+ const status = ctx.res.statusCode;
534
+ console.log(format === 'detailed' ? `[${new Date().toISOString()}] ${method} ${url} ${status} - ${duration}ms` : `${method} ${url} - ${status} (${duration}ms)`);
535
+ };
536
+ }
537
+
538
+ export function errorHandler(): Middleware {
539
+ return async (ctx, next) => {
540
+ try {
541
+ await next();
542
+ } catch (error) {
543
+ console.error('Error:', error);
544
+ if (!ctx.res.headersSent) {
545
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
546
+ ctx.res.end(JSON.stringify({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error' }));
547
+ }
548
+ }
549
+ };
550
+ }
551
+
552
+ export function rateLimit(options: { windowMs?: number; max?: number; message?: string } = {}): Middleware {
553
+ const { windowMs = 60000, max = 100, message = 'Too many requests' } = options;
554
+ const clients = new Map<string, { count: number; resetTime: number }>();
555
+
556
+ return async (ctx, next) => {
557
+ const ip = ctx.req.socket.remoteAddress || 'unknown';
558
+ const now = Date.now();
559
+ let clientData = clients.get(ip);
560
+
561
+ if (!clientData || now > clientData.resetTime) {
562
+ clientData = { count: 0, resetTime: now + windowMs };
563
+ clients.set(ip, clientData);
564
+ }
565
+
566
+ if (++clientData.count > max) {
567
+ ctx.res.writeHead(429, { 'Content-Type': 'application/json' });
568
+ ctx.res.end(JSON.stringify({ error: message }));
569
+ return;
570
+ }
571
+ await next();
572
+ };
573
+ }
574
+
575
+ export function bodyLimit(options: { limit?: number } = {}): Middleware {
576
+ const { limit = 1024 * 1024 } = options;
577
+ return async (ctx, next) => {
578
+ const contentLength = ctx.req.headers['content-length'];
579
+ const contentLengthStr = Array.isArray(contentLength) ? contentLength[0] : (contentLength || '0');
580
+ if (parseInt(contentLengthStr, 10) > limit) {
581
+ ctx.res.writeHead(413, { 'Content-Type': 'application/json' });
582
+ ctx.res.end(JSON.stringify({ error: 'Request body too large' }));
583
+ return;
584
+ }
585
+ await next();
586
+ };
587
+ }
588
+
589
+ export function cacheControl(options: { maxAge?: number; public?: boolean } = {}): Middleware {
590
+ const { maxAge = 3600, public: isPublic = true } = options;
591
+ return async (ctx, next) => {
592
+ ctx.res.setHeader('Cache-Control', `${isPublic ? 'public' : 'private'}, max-age=${maxAge}`);
593
+ await next();
594
+ };
595
+ }
596
+
597
+ export function compress(): Middleware {
598
+ return async (ctx, next) => {
599
+ const acceptEncoding = ctx.req.headers['accept-encoding'] || '';
600
+ if (!acceptEncoding.includes('gzip')) {
601
+ await next();
602
+ return;
603
+ }
604
+
605
+ // Store original end method
606
+ const originalEnd = ctx.res.end.bind(ctx.res);
607
+ const chunks: Buffer[] = [];
608
+
609
+ // Intercept response data
610
+ ctx.res.write = ((chunk: any) => {
611
+ chunks.push(Buffer.from(chunk));
612
+ return true;
613
+ }) as any;
614
+
615
+ ctx.res.end = ((chunk?: any) => {
616
+ if (chunk) chunks.push(Buffer.from(chunk));
617
+
618
+ const buffer = Buffer.concat(chunks);
619
+ const { gzipSync } = require('zlib');
620
+ const compressed = gzipSync(buffer);
621
+
622
+ ctx.res.setHeader('Content-Encoding', 'gzip');
623
+ ctx.res.setHeader('Content-Length', compressed.length);
624
+ originalEnd(compressed);
625
+ return ctx.res;
626
+ }) as any;
627
+
628
+ await next();
629
+ };
630
+ }
631
+
632
+ export function security(): Middleware {
633
+ return async (ctx, next) => {
634
+ ctx.res.setHeader('X-Content-Type-Options', 'nosniff');
635
+ ctx.res.setHeader('X-Frame-Options', 'DENY');
636
+ ctx.res.setHeader('X-XSS-Protection', '1; mode=block');
637
+ ctx.res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
638
+ await next();
639
+ };
640
+ }
641
+
642
+ // ===== Proxy Handler =====
643
+
644
+ function rewritePath(path: string, pathRewrite?: Record<string, string>): string {
645
+ if (!pathRewrite) return path;
646
+
647
+ for (const [from, to] of Object.entries(pathRewrite)) {
648
+ const regex = new RegExp(from);
649
+ if (regex.test(path)) {
650
+ return path.replace(regex, to);
651
+ }
652
+ }
653
+ return path;
654
+ }
655
+
656
+ export function createProxyHandler(proxyConfigs: ProxyConfig[]) {
657
+ return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
658
+ const url = req.url || '/';
659
+ const path = url.split('?')[0];
660
+
661
+ // Find matching proxy configuration (first match wins)
662
+ const proxy = proxyConfigs.find(p => path.startsWith(p.context));
663
+ if (!proxy) return false;
664
+
665
+ const { target, changeOrigin, pathRewrite, headers } = proxy;
666
+
667
+ try {
668
+ const targetUrl = new URL(target);
669
+ const isHttps = targetUrl.protocol === 'https:';
670
+ const requestLib = isHttps ? httpsRequest : httpRequest;
671
+
672
+ // Rewrite path if needed
673
+ let proxyPath = rewritePath(url, pathRewrite);
674
+
675
+ // Build the full proxy URL
676
+ const proxyUrl = `${isHttps ? 'https' : 'http'}://${targetUrl.hostname}:${targetUrl.port || (isHttps ? 443 : 80)}${proxyPath}`;
677
+
678
+ // Build proxy request options
679
+ const proxyReqHeaders: Record<string, string | number | string[]> = {};
680
+ for (const [key, value] of Object.entries(req.headers)) {
681
+ if (value !== undefined) {
682
+ proxyReqHeaders[key] = value;
683
+ }
684
+ }
685
+ if (headers) {
686
+ for (const [key, value] of Object.entries(headers)) {
687
+ if (value !== undefined) {
688
+ proxyReqHeaders[key] = value;
689
+ }
690
+ }
691
+ }
692
+
693
+ // Change origin if requested
694
+ if (changeOrigin) {
695
+ proxyReqHeaders.host = targetUrl.host;
696
+ }
697
+
698
+ // Remove headers that shouldn't be forwarded
699
+ delete proxyReqHeaders['host'];
700
+
701
+ const proxyReqOptions = {
702
+ method: req.method,
703
+ headers: proxyReqHeaders
704
+ };
705
+
706
+ // Create proxy request
707
+ const proxyReq = requestLib(proxyUrl, proxyReqOptions, (proxyRes) => {
708
+ // Forward status code and headers - convert incoming headers properly
709
+ const outgoingHeaders: Record<string, string | number | string[]> = {};
710
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
711
+ if (value !== undefined) {
712
+ outgoingHeaders[key] = value;
713
+ }
714
+ }
715
+ res.writeHead(proxyRes.statusCode || 200, outgoingHeaders);
716
+
717
+ // Pipe response using read/write instead of pipe
718
+ proxyRes.on('data', (chunk) => res.write(chunk));
719
+ proxyRes.on('end', () => res.end());
720
+ });
721
+
722
+ // Handle errors
723
+ proxyReq.on('error', (error) => {
724
+ console.error('[Proxy] Error proxying %s to %s:', url, target, error.message);
725
+ if (!res.headersSent) {
726
+ json(res, { error: 'Bad Gateway', message: 'Proxy error' }, 502);
727
+ }
728
+ });
729
+
730
+ // Forward request body
731
+ req.on('data', (chunk) => proxyReq.write(chunk));
732
+ req.on('end', () => proxyReq.end());
733
+
734
+ return true;
735
+ } catch (error) {
736
+ console.error('[Proxy] Invalid proxy configuration for %s:', path, error);
737
+ return false;
738
+ }
739
+ };
740
+ }
741
+
742
+ // ===== State Management =====
743
+
744
+ export type StateChangeHandler<T = any> = (value: T, oldValue: T) => void;
745
+
746
+ export interface SharedStateOptions<T = any> {
747
+ initial: T;
748
+ persist?: boolean;
749
+ validate?: (value: T) => boolean;
750
+ }
751
+
752
+ export class SharedState<T = any> {
753
+ private _value: T;
754
+ private listeners = new Set<WebSocket>();
755
+ private changeHandlers = new Set<StateChangeHandler<T>>();
756
+ private options: SharedStateOptions<T>;
757
+
758
+ constructor(
759
+ public readonly key: string,
760
+ options: SharedStateOptions<T>
761
+ ) {
762
+ this.options = options;
763
+ this._value = options.initial;
764
+ }
765
+
766
+ get value(): T {
767
+ return this._value;
768
+ }
769
+
770
+ set value(newValue: T) {
771
+ if (this.options.validate && !this.options.validate(newValue)) {
772
+ throw new Error(`Invalid state value for "${this.key}"`);
773
+ }
774
+
775
+ const oldValue = this._value;
776
+ this._value = newValue;
777
+
778
+ this.changeHandlers.forEach(handler => {
779
+ handler(newValue, oldValue);
780
+ });
781
+
782
+ this.broadcast();
783
+ }
784
+
785
+ update(updater: (current: T) => T): void {
786
+ this.value = updater(this._value);
787
+ }
788
+
789
+ subscribe(ws: WebSocket): void {
790
+ this.listeners.add(ws);
791
+ this.sendTo(ws);
792
+ }
793
+
794
+ unsubscribe(ws: WebSocket): void {
795
+ this.listeners.delete(ws);
796
+ }
797
+
798
+ onChange(handler: StateChangeHandler<T>): () => void {
799
+ this.changeHandlers.add(handler);
800
+ return () => this.changeHandlers.delete(handler);
801
+ }
802
+
803
+ private broadcast(): void {
804
+ const message = JSON.stringify({ type: 'state:update', key: this.key, value: this._value, timestamp: Date.now() });
805
+ this.listeners.forEach(ws => ws.readyState === ReadyState.OPEN && ws.send(message));
806
+ }
807
+
808
+ private sendTo(ws: WebSocket): void {
809
+ if (ws.readyState === ReadyState.OPEN) {
810
+ ws.send(JSON.stringify({ type: 'state:init', key: this.key, value: this._value, timestamp: Date.now() }));
811
+ }
812
+ }
813
+
814
+ get subscriberCount(): number {
815
+ return this.listeners.size;
816
+ }
817
+
818
+ clear(): void {
819
+ this.listeners.clear();
820
+ this.changeHandlers.clear();
821
+ }
822
+ }
823
+
824
+ export class StateManager {
825
+ private states = new Map<string, SharedState<any>>();
826
+
827
+ create<T>(key: string, options: SharedStateOptions<T>): SharedState<T> {
828
+ if (this.states.has(key)) return this.states.get(key) as SharedState<T>;
829
+ const state = new SharedState<T>(key, options);
830
+ this.states.set(key, state);
831
+ return state;
832
+ }
833
+
834
+ get<T>(key: string): SharedState<T> | undefined {
835
+ return this.states.get(key) as SharedState<T>;
836
+ }
837
+
838
+ has(key: string): boolean {
839
+ return this.states.has(key);
840
+ }
841
+
842
+ delete(key: string): boolean {
843
+ const state = this.states.get(key);
844
+ if (state) {
845
+ state.clear();
846
+ return this.states.delete(key);
847
+ }
848
+ return false;
849
+ }
850
+
851
+ subscribe(key: string, ws: WebSocket): void {
852
+ this.states.get(key)?.subscribe(ws);
853
+ }
854
+
855
+ unsubscribe(key: string, ws: WebSocket): void {
856
+ this.states.get(key)?.unsubscribe(ws);
857
+ }
858
+
859
+ unsubscribeAll(ws: WebSocket): void {
860
+ this.states.forEach(state => state.unsubscribe(ws));
861
+ }
862
+
863
+ handleStateChange(key: string, value: any): void {
864
+ const state = this.states.get(key);
865
+ if (state) state.value = value;
866
+ }
867
+
868
+ keys(): string[] {
869
+ return Array.from(this.states.keys());
870
+ }
871
+
872
+ clear(): void {
873
+ this.states.forEach(state => state.clear());
874
+ this.states.clear();
875
+ }
876
+ }
877
+
878
+ // ===== Development Server =====
879
+
880
+ const defaultOptions: Omit<Required<DevServerOptions>, 'api' | 'clients' | 'root' | 'basePath' | 'ssr' | 'proxy' | 'index'> = {
881
+ port: 3000,
882
+ host: 'localhost',
883
+ https: false,
884
+ open: true,
885
+ watch: ['**/*.ts', '**/*.js', '**/*.html', '**/*.css'],
886
+ ignore: ['node_modules/**', 'dist/**', '.git/**', '**/*.d.ts'],
887
+ logging: true,
888
+ middleware: [],
889
+ worker: [],
890
+ mode: 'dev'
891
+ };
892
+
893
+ interface NormalizedClient {
894
+ root: string;
895
+ basePath: string;
896
+ index?: string;
897
+ ssr?: () => Child | string;
898
+ api?: ServerRouter;
899
+ proxyHandler?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
900
+ mode: 'dev' | 'preview';
901
+ }
902
+
903
+ export function createDevServer(options: DevServerOptions): DevServer {
904
+ const config = { ...defaultOptions, ...options };
905
+ const wsClients = new Set<WebSocket>();
906
+ const stateManager = new StateManager();
907
+
908
+ // Normalize clients configuration - support both new API (clients array) and legacy API (root/basePath)
909
+ 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;
910
+ if (!clientsToNormalize) throw new Error('DevServerOptions must include either "clients" array or "root" directory');
911
+
912
+ const normalizedClients: NormalizedClient[] = clientsToNormalize.map(client => {
913
+ let basePath = client.basePath || '';
914
+ if (basePath) {
915
+ // Remove leading/trailing slashes safely without ReDoS vulnerability
916
+ while (basePath.startsWith('/')) basePath = basePath.slice(1);
917
+ while (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
918
+ basePath = basePath ? '/' + basePath : '';
919
+ }
920
+
921
+ // Normalize index path - convert ./path to /path
922
+ let indexPath = client.index;
923
+ if (indexPath) {
924
+ // Remove leading ./ and ensure it starts with /
925
+ indexPath = indexPath.replace(/^\.\//, '/');
926
+ if (!indexPath.startsWith('/')) {
927
+ indexPath = '/' + indexPath;
928
+ }
929
+ }
930
+
931
+ return {
932
+ root: client.root,
933
+ basePath,
934
+ index: indexPath,
935
+ ssr: client.ssr,
936
+ api: client.api,
937
+ proxyHandler: client.proxy ? createProxyHandler(client.proxy) : undefined,
938
+ mode: client.mode || 'dev'
939
+ };
940
+ });
941
+
942
+ // Create global proxy handler if proxy config exists
943
+ const globalProxyHandler = config.proxy ? createProxyHandler(config.proxy) : null;
944
+
945
+ // HTTP Server
946
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
947
+ const originalUrl = req.url || '/';
948
+
949
+ // Find matching client based on basePath
950
+ const matchedClient = normalizedClients.find(c => c.basePath && originalUrl.startsWith(c.basePath)) || normalizedClients.find(c => !c.basePath);
951
+ if (!matchedClient) return send404(res, '404 Not Found');
952
+
953
+ // Try client-specific proxy first
954
+ if (matchedClient.proxyHandler) {
955
+ try {
956
+ const proxied = await matchedClient.proxyHandler(req, res);
957
+ if (proxied) {
958
+ if (config.logging) console.log(`[Proxy] ${req.method} ${originalUrl} -> proxied (client-specific)`);
959
+ return;
960
+ }
961
+ } catch (error) {
962
+ console.error('[Proxy] Error (client-specific):', error);
963
+ }
964
+ }
965
+
966
+ // Try global proxy if client-specific didn't match
967
+ if (globalProxyHandler) {
968
+ try {
969
+ const proxied = await globalProxyHandler(req, res);
970
+ if (proxied) {
971
+ if (config.logging) console.log(`[Proxy] ${req.method} ${originalUrl} -> proxied (global)`);
972
+ return;
973
+ }
974
+ } catch (error) {
975
+ console.error('[Proxy] Error (global):', error);
976
+ }
977
+ }
978
+
979
+ const url = matchedClient.basePath ? (originalUrl.slice(matchedClient.basePath.length) || '/') : originalUrl;
980
+
981
+ // Try client-specific API routes first
982
+ if (matchedClient.api && url.startsWith('/api')) {
983
+ const handled = await matchedClient.api.handle(req, res);
984
+ if (handled) return;
985
+ }
986
+
987
+ // Try global API routes (fallback)
988
+ if (config.api && url.startsWith('/api')) {
989
+ const handled = await config.api.handle(req, res);
990
+ if (handled) return;
991
+ }
992
+
993
+ // For root path requests, prioritize SSR over index files if SSR is configured
994
+ let filePath: string;
995
+ if (url === '/' && matchedClient.ssr && !matchedClient.index) {
996
+ // Use SSR directly when configured and no custom index specified
997
+ return await serveSSR(res, matchedClient);
998
+ } else {
999
+ // Use custom index file if specified, otherwise default to /index.html
1000
+ filePath = url === '/' ? (matchedClient.index || '/index.html') : url;
1001
+ }
1002
+
1003
+ // Remove query string
1004
+ filePath = filePath.split('?')[0];
1005
+
1006
+ if (config.logging && filePath === '/src/pages') {
1007
+ console.log(`[DEBUG] Request for /src/pages received`);
1008
+ }
1009
+
1010
+ // Security: Check for null bytes early
1011
+ if (filePath.includes('\0')) {
1012
+ if (config.logging) console.log(`[403] Rejected path with null byte: ${filePath}`);
1013
+ return send403(res, '403 Forbidden');
1014
+ }
1015
+
1016
+ // Handle /dist/* and /node_modules/* requests - serve from parent folder
1017
+ const isDistRequest = filePath.startsWith('/dist/');
1018
+ const isNodeModulesRequest = filePath.startsWith('/node_modules/');
1019
+ let normalizedPath: string;
1020
+
1021
+ // Normalize and validate the path for both /dist/* and regular requests
1022
+ const tempPath = normalize(filePath).replace(/\\/g, '/').replace(/^\/+/, '');
1023
+ if (tempPath.includes('..')) {
1024
+ if (config.logging) console.log(`[403] Path traversal attempt: ${filePath}`);
1025
+ return send403(res, '403 Forbidden');
1026
+ }
1027
+ normalizedPath = tempPath;
1028
+
1029
+ // Resolve file path
1030
+ const rootDir = await realpath(resolve(matchedClient.root));
1031
+ let baseDir = rootDir;
1032
+
1033
+ // Auto-detect base directory for /dist/* and /node_modules/* requests
1034
+ if (isDistRequest || isNodeModulesRequest) {
1035
+ const targetDir = isDistRequest ? 'dist' : 'node_modules';
1036
+ const foundDir = await findSpecialDir(matchedClient.root, targetDir);
1037
+ baseDir = foundDir ? await realpath(foundDir) : rootDir;
1038
+ }
1039
+
1040
+ let fullPath;
1041
+
1042
+ try {
1043
+ // First check path without resolving symlinks for security
1044
+ const unresolvedPath = resolve(join(baseDir, normalizedPath));
1045
+ if (!unresolvedPath.startsWith(baseDir.endsWith(sep) ? baseDir : baseDir + sep)) {
1046
+ if (config.logging) console.log(`[403] File access outside of root (before symlink): ${unresolvedPath}`);
1047
+ return send403(res, '403 Forbidden');
1048
+ }
1049
+
1050
+ // Then resolve symlinks to get actual file
1051
+ fullPath = await realpath(unresolvedPath);
1052
+ if (config.logging && filePath === '/src/pages') {
1053
+ console.log(`[DEBUG] Initial resolve succeeded: ${fullPath}`);
1054
+ }
1055
+ } catch (firstError) {
1056
+ // If file not found, try different extensions
1057
+ let resolvedPath: string | undefined;
1058
+
1059
+ if (config.logging && !normalizedPath.includes('.')) {
1060
+ console.log(`[DEBUG] File not found: ${normalizedPath}, trying extensions...`);
1061
+ }
1062
+
1063
+ // If .js file not found, try .ts file
1064
+ if (normalizedPath.endsWith('.js')) {
1065
+ const tsPath = normalizedPath.replace(/\.js$/, '.ts');
1066
+ try {
1067
+ const tsFullPath = await realpath(resolve(join(baseDir, tsPath)));
1068
+ // Security: Ensure path is strictly within the allowed root directory
1069
+ if (!tsFullPath.startsWith(baseDir.endsWith(sep) ? baseDir : baseDir + sep)) {
1070
+ if (config.logging) console.log(`[403] Fallback TS path outside of root: ${tsFullPath}`);
1071
+ return send403(res, '403 Forbidden');
1072
+ }
1073
+ resolvedPath = tsFullPath;
1074
+ } catch {
1075
+ // Continue to next attempt
1076
+ }
1077
+ }
1078
+
1079
+ // If no extension, try adding .ts or .js, or index files
1080
+ if (!resolvedPath && !normalizedPath.includes('.')) {
1081
+ // Try .ts first
1082
+ try {
1083
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath + '.ts')));
1084
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}.ts`);
1085
+ } catch {
1086
+ // Try .js
1087
+ try {
1088
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath + '.js')));
1089
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}.js`);
1090
+ } catch {
1091
+ // Try index.ts in directory
1092
+ try {
1093
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath, 'index.ts')));
1094
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}/index.ts`);
1095
+ } catch {
1096
+ // Try index.js in directory
1097
+ try {
1098
+ resolvedPath = await realpath(resolve(join(baseDir, normalizedPath, 'index.js')));
1099
+ if (config.logging) console.log(`[DEBUG] Found: ${normalizedPath}/index.js`);
1100
+ } catch {
1101
+ if (config.logging) console.log(`[DEBUG] Not found: all attempts failed for ${normalizedPath}`);
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ if (!resolvedPath) {
1109
+ if (!res.headersSent) {
1110
+ // If index.html not found but SSR function exists, use SSR
1111
+ if (filePath === '/index.html' && matchedClient.ssr) {
1112
+ return await serveSSR(res, matchedClient);
1113
+ }
1114
+ if (config.logging) console.log(`[404] ${filePath}`);
1115
+ return send404(res, '404 Not Found');
1116
+ }
1117
+ return;
1118
+ }
1119
+
1120
+ fullPath = resolvedPath;
1121
+ }
1122
+
1123
+ // Check if resolved path is a directory, try index files
1124
+ try {
1125
+ const stats = await stat(fullPath);
1126
+ if (stats.isDirectory()) {
1127
+ if (config.logging) console.log(`[DEBUG] Path is directory: ${fullPath}, trying index files...`);
1128
+ let indexPath: string | undefined;
1129
+
1130
+ // Try index.ts first
1131
+ try {
1132
+ indexPath = await realpath(resolve(join(fullPath, 'index.ts')));
1133
+ if (config.logging) console.log(`[DEBUG] Found index.ts in directory`);
1134
+ } catch {
1135
+ // Try index.js
1136
+ try {
1137
+ indexPath = await realpath(resolve(join(fullPath, 'index.js')));
1138
+ if (config.logging) console.log(`[DEBUG] Found index.js in directory`);
1139
+ } catch {
1140
+ if (config.logging) console.log(`[DEBUG] No index file found in directory`);
1141
+ // If index.html not found in directory but SSR function exists, use SSR
1142
+ if (matchedClient.ssr) {
1143
+ return await serveSSR(res, matchedClient);
1144
+ }
1145
+ return send404(res, '404 Not Found');
1146
+ }
1147
+ }
1148
+
1149
+ fullPath = indexPath;
1150
+ }
1151
+ } catch (statError) {
1152
+ if (config.logging) console.log(`[404] ${filePath}`);
1153
+ return send404(res, '404 Not Found');
1154
+ }
1155
+
1156
+ // Security check already done before resolving symlinks (line 733)
1157
+ // No need to check again after symlink resolution as that would block legitimate symlinks
1158
+
1159
+ try {
1160
+ const stats = await stat(fullPath);
1161
+
1162
+ if (stats.isDirectory()) {
1163
+ try {
1164
+ const indexPath = await realpath(resolve(join(fullPath, 'index.html')));
1165
+ if (!indexPath.startsWith(rootDir + sep) && indexPath !== rootDir) {
1166
+ return send403(res, '403 Forbidden');
1167
+ }
1168
+ await stat(indexPath);
1169
+ return serveFile(indexPath, res, matchedClient, isDistRequest || isNodeModulesRequest);
1170
+ } catch {
1171
+ return send404(res, '404 Not Found');
1172
+ }
1173
+ }
1174
+
1175
+ await serveFile(fullPath, res, matchedClient, isDistRequest || isNodeModulesRequest);
1176
+ } catch (error) {
1177
+ // Only send 404 if response hasn't been sent yet
1178
+ if (!res.headersSent) {
1179
+ if (config.logging) console.log(`[404] ${filePath}`);
1180
+ send404(res, '404 Not Found');
1181
+ }
1182
+ }
1183
+ });
1184
+
1185
+ // Serve file helper
1186
+ async function serveFile(filePath: string, res: ServerResponse, client: NormalizedClient, isNodeModulesOrDist: boolean = false) {
1187
+ try {
1188
+ const rootDir = await realpath(resolve(client.root));
1189
+
1190
+ // Security: Check path before resolving symlinks
1191
+ const unresolvedPath = resolve(filePath);
1192
+
1193
+ // Skip security check for node_modules and dist (these may be symlinks)
1194
+ if (!isNodeModulesOrDist) {
1195
+ // Check if path is within project root
1196
+ if (!unresolvedPath.startsWith(rootDir + sep) && unresolvedPath !== rootDir) {
1197
+ if (config.logging) console.log(`[403] Attempted to serve file outside allowed directories: ${filePath}`);
1198
+ return send403(res, '403 Forbidden');
1199
+ }
1200
+ }
1201
+
1202
+ // Resolve symlinks to get actual file path
1203
+ let resolvedPath;
1204
+ try {
1205
+ resolvedPath = await realpath(unresolvedPath);
1206
+
1207
+ // For symlinked packages (like node_modules/elit), allow serving from outside rootDir
1208
+ if (isNodeModulesOrDist && resolvedPath) {
1209
+ // Allow it - this is a symlinked package
1210
+ if (config.logging && !resolvedPath.startsWith(rootDir + sep)) {
1211
+ console.log(`[DEBUG] Serving symlinked file: ${resolvedPath}`);
1212
+ }
1213
+ }
1214
+ } catch {
1215
+ // If index.html not found but SSR function exists, use SSR
1216
+ if (filePath.endsWith('index.html') && client.ssr) {
1217
+ return await serveSSR(res, client);
1218
+ }
1219
+ return send404(res, '404 Not Found');
1220
+ }
1221
+
1222
+ let content = await readFile(resolvedPath);
1223
+ const ext = extname(resolvedPath);
1224
+ let mimeType = lookup(resolvedPath) || 'application/octet-stream';
1225
+
1226
+ // Handle TypeScript files - transpile only (no bundling)
1227
+ if (ext === '.ts' || ext === '.tsx') {
1228
+ try {
1229
+ let transpiled: string;
1230
+
1231
+ if (isDeno) {
1232
+ // Deno - use Deno.emit
1233
+ // @ts-ignore
1234
+ const result = await Deno.emit(resolvedPath, {
1235
+ check: false,
1236
+ compilerOptions: {
1237
+ sourceMap: true,
1238
+ inlineSourceMap: true,
1239
+ target: 'ES2020',
1240
+ module: 'esnext'
1241
+ },
1242
+ sources: {
1243
+ [resolvedPath]: content.toString()
1244
+ }
1245
+ });
1246
+
1247
+ transpiled = result.files[resolvedPath.replace(/\.tsx?$/, '.js')] || '';
1248
+
1249
+ } else if (isBun) {
1250
+ // Bun - use Bun.Transpiler
1251
+ // @ts-ignore
1252
+ const transpiler = new Bun.Transpiler({
1253
+ loader: ext === '.tsx' ? 'tsx' : 'ts',
1254
+ target: 'browser'
1255
+ });
1256
+
1257
+ // @ts-ignore
1258
+ transpiled = transpiler.transformSync(content.toString());
1259
+ } else {
1260
+ // Node.js - use esbuild
1261
+ const { build } = await import('esbuild');
1262
+ const result = await build({
1263
+ stdin: {
1264
+ contents: content.toString(),
1265
+ loader: ext === '.tsx' ? 'tsx' : 'ts',
1266
+ resolveDir: resolve(resolvedPath, '..'),
1267
+ sourcefile: resolvedPath
1268
+ },
1269
+ format: 'esm',
1270
+ target: 'es2020',
1271
+ write: false,
1272
+ bundle: false,
1273
+ sourcemap: 'inline'
1274
+ });
1275
+
1276
+ transpiled = result.outputFiles[0].text;
1277
+ }
1278
+
1279
+ // Rewrite .ts imports to .js for browser compatibility
1280
+ // This allows developers to write import './file.ts' in their source code
1281
+ // and the dev server will automatically rewrite it to import './file.js'
1282
+ transpiled = transpiled.replace(
1283
+ /from\s+["']([^"']+)\.ts(x?)["']/g,
1284
+ (_, path, tsx) => `from "${path}.js${tsx}"`
1285
+ );
1286
+ transpiled = transpiled.replace(
1287
+ /import\s+["']([^"']+)\.ts(x?)["']/g,
1288
+ (_, path, tsx) => `import "${path}.js${tsx}"`
1289
+ );
1290
+
1291
+ content = Buffer.from(transpiled);
1292
+ mimeType = 'application/javascript';
1293
+ } catch (error) {
1294
+ if (config.logging) console.error('[500] TypeScript compilation error:', error);
1295
+ return send500(res, `TypeScript compilation error:\n${error}`);
1296
+ }
1297
+ }
1298
+
1299
+ // Inject HMR client and import map for HTML files
1300
+ if (ext === '.html') {
1301
+ const wsPath = normalizeBasePath(client.basePath);
1302
+ const hmrScript = createHMRScript(config.port, wsPath);
1303
+ let html = content.toString();
1304
+
1305
+ // If SSR is configured, extract and inject styles from SSR
1306
+ let ssrStyles = '';
1307
+ if (client.ssr) {
1308
+ try {
1309
+ const result = client.ssr();
1310
+ let ssrHtml: string;
1311
+
1312
+ // Convert SSR result to string
1313
+ if (typeof result === 'string') {
1314
+ ssrHtml = result;
1315
+ } else if (typeof result === 'object' && result !== null && 'tagName' in result) {
1316
+ ssrHtml = dom.renderToString(result as VNode);
1317
+ } else {
1318
+ ssrHtml = String(result);
1319
+ }
1320
+
1321
+ // Extract <style> tags from SSR output
1322
+ const styleMatches = ssrHtml.match(/<style[^>]*>[\s\S]*?<\/style>/g);
1323
+ if (styleMatches) {
1324
+ ssrStyles = styleMatches.join('\n');
1325
+ }
1326
+ } catch (error) {
1327
+ if (config.logging) console.error('[Warning] Failed to extract styles from SSR:', error);
1328
+ }
1329
+ }
1330
+
1331
+ // Fix relative paths to use basePath
1332
+ const basePath = normalizeBasePath(client.basePath);
1333
+ html = rewriteRelativePaths(html, basePath);
1334
+
1335
+ // Inject base tag if basePath is configured and not '/'
1336
+ if (client.basePath && client.basePath !== '/') {
1337
+ const baseTag = `<base href="${client.basePath}/">`;
1338
+ // Check if base tag already exists
1339
+ if (!html.includes('<base')) {
1340
+ // Try to inject after viewport meta tag
1341
+ if (html.includes('<meta name="viewport"')) {
1342
+ html = html.replace(
1343
+ /<meta name="viewport"[^>]*>/,
1344
+ (match) => `${match}\n ${baseTag}`
1345
+ );
1346
+ } else if (html.includes('<head>')) {
1347
+ // If no viewport, inject right after <head>
1348
+ html = html.replace('<head>', `<head>\n ${baseTag}`);
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ // Inject import map and SSR styles into <head>
1354
+ const elitImportMap = await createElitImportMap(client.root, basePath, client.mode);
1355
+ const headInjection = ssrStyles ? `${ssrStyles}\n${elitImportMap}` : elitImportMap;
1356
+ html = html.includes('</head>') ? html.replace('</head>', `${headInjection}</head>`) : html;
1357
+ html = html.includes('</body>') ? html.replace('</body>', `${hmrScript}</body>`) : html + hmrScript;
1358
+ content = Buffer.from(html);
1359
+ }
1360
+
1361
+ // Set cache headers based on file type
1362
+ const cacheControl = ext === '.html' || ext === '.ts' || ext === '.tsx'
1363
+ ? 'no-cache, no-store, must-revalidate' // Don't cache HTML/TS files in dev
1364
+ : 'public, max-age=31536000, immutable'; // Cache static assets for 1 year
1365
+
1366
+ const headers: any = {
1367
+ 'Content-Type': mimeType,
1368
+ 'Cache-Control': cacheControl
1369
+ };
1370
+
1371
+ // Apply gzip compression for text-based files
1372
+ const compressible = /^(text\/|application\/(javascript|json|xml))/.test(mimeType);
1373
+
1374
+ if (compressible && content.length > 1024) {
1375
+ const { gzipSync } = require('zlib');
1376
+ const compressed = gzipSync(content);
1377
+ headers['Content-Encoding'] = 'gzip';
1378
+ headers['Content-Length'] = compressed.length;
1379
+ res.writeHead(200, headers);
1380
+ res.end(compressed);
1381
+ } else {
1382
+ res.writeHead(200, headers);
1383
+ res.end(content);
1384
+ }
1385
+
1386
+ if (config.logging) console.log(`[200] ${relative(client.root, filePath)}`);
1387
+ } catch (error) {
1388
+ if (config.logging) console.error('[500] Error reading file:', error);
1389
+ send500(res, '500 Internal Server Error');
1390
+ }
1391
+ }
1392
+
1393
+ // SSR helper - Generate HTML from SSR function
1394
+ async function serveSSR(res: ServerResponse, client: NormalizedClient) {
1395
+ try {
1396
+ if (!client.ssr) {
1397
+ return send500(res, 'SSR function not configured');
1398
+ }
1399
+
1400
+ const result = client.ssr();
1401
+ let html: string;
1402
+
1403
+ // If result is a string, use it directly
1404
+ if (typeof result === 'string') {
1405
+ html = result;
1406
+ }
1407
+ // If result is a VNode, render it to HTML string
1408
+ else if (typeof result === 'object' && result !== null && 'tagName' in result) {
1409
+ const vnode = result as VNode;
1410
+ if (vnode.tagName === 'html') {
1411
+ html = dom.renderToString(vnode);
1412
+ } else {
1413
+ // Wrap in basic HTML structure if not html tag
1414
+ 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>`;
1415
+ }
1416
+ } else {
1417
+ html = String(result);
1418
+ }
1419
+
1420
+ // Fix relative paths to use basePath
1421
+ const basePath = normalizeBasePath(client.basePath);
1422
+ html = rewriteRelativePaths(html, basePath);
1423
+
1424
+ // Inject HMR script
1425
+ const hmrScript = createHMRScript(config.port, basePath);
1426
+
1427
+ // Inject import map in head, HMR script in body
1428
+ const elitImportMap = await createElitImportMap(client.root, basePath, client.mode);
1429
+ html = html.includes('</head>') ? html.replace('</head>', `${elitImportMap}</head>`) : html;
1430
+ html = html.includes('</body>') ? html.replace('</body>', `${hmrScript}</body>`) : html + hmrScript;
1431
+
1432
+ res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
1433
+ res.end(html);
1434
+
1435
+ if (config.logging) console.log(`[200] SSR rendered`);
1436
+ } catch (error) {
1437
+ if (config.logging) console.error('[500] SSR Error:', error);
1438
+ send500(res, '500 SSR Error');
1439
+ }
1440
+ }
1441
+
1442
+ // WebSocket Server for HMR
1443
+ const wss = new WebSocketServer({ server });
1444
+
1445
+ if (config.logging) {
1446
+ console.log('[HMR] WebSocket server initialized');
1447
+ }
1448
+
1449
+ wss.on('connection', (ws: WebSocket, req) => {
1450
+ wsClients.add(ws);
1451
+
1452
+ const message: HMRMessage = { type: 'connected', timestamp: Date.now() };
1453
+ ws.send(JSON.stringify(message));
1454
+
1455
+ if (config.logging) {
1456
+ console.log('[HMR] Client connected from', req.socket.remoteAddress);
1457
+ }
1458
+
1459
+ // Handle incoming messages
1460
+ ws.on('message', (data: string) => {
1461
+ try {
1462
+ const msg = JSON.parse(data.toString());
1463
+
1464
+ // Handle state subscription
1465
+ if (msg.type === 'state:subscribe') {
1466
+ stateManager.subscribe(msg.key, ws);
1467
+ if (config.logging) {
1468
+ console.log(`[State] Client subscribed to "${msg.key}"`);
1469
+ }
1470
+ }
1471
+
1472
+ // Handle state unsubscribe
1473
+ else if (msg.type === 'state:unsubscribe') {
1474
+ stateManager.unsubscribe(msg.key, ws);
1475
+ if (config.logging) {
1476
+ console.log(`[State] Client unsubscribed from "${msg.key}"`);
1477
+ }
1478
+ }
1479
+
1480
+ // Handle state change from client
1481
+ else if (msg.type === 'state:change') {
1482
+ stateManager.handleStateChange(msg.key, msg.value);
1483
+ if (config.logging) {
1484
+ console.log(`[State] Client updated "${msg.key}"`);
1485
+ }
1486
+ }
1487
+ } catch (error) {
1488
+ if (config.logging) {
1489
+ console.error('[WebSocket] Message parse error:', error);
1490
+ }
1491
+ }
1492
+ });
1493
+
1494
+ ws.on('close', () => {
1495
+ wsClients.delete(ws);
1496
+ stateManager.unsubscribeAll(ws);
1497
+ if (config.logging) {
1498
+ console.log('[HMR] Client disconnected');
1499
+ }
1500
+ });
1501
+ });
1502
+
1503
+ // File watcher - watch all client roots
1504
+ const watchPaths = normalizedClients.flatMap(client =>
1505
+ config.watch.map(pattern => join(client.root, pattern))
1506
+ );
1507
+
1508
+ const watcher = watch(watchPaths, {
1509
+ ignored: (path: string) => config.ignore.some(pattern => path.includes(pattern.replace('/**', '').replace('**/', ''))),
1510
+ ignoreInitial: true,
1511
+ persistent: true
1512
+ });
1513
+
1514
+ watcher.on('change', (path: string) => {
1515
+ if (config.logging) console.log(`[HMR] File changed: ${path}`);
1516
+ const message = JSON.stringify({ type: 'update', path, timestamp: Date.now() } as HMRMessage);
1517
+ wsClients.forEach(client => client.readyState === ReadyState.OPEN && client.send(message));
1518
+ });
1519
+
1520
+ watcher.on('add', (path: string) => config.logging && console.log(`[HMR] File added: ${path}`));
1521
+ watcher.on('unlink', (path: string) => config.logging && console.log(`[HMR] File removed: ${path}`));
1522
+
1523
+ // Increase max listeners to prevent warnings
1524
+ server.setMaxListeners(20);
1525
+
1526
+ // Start server
1527
+ server.listen(config.port, config.host, () => {
1528
+ if (config.logging) {
1529
+ console.log('\nšŸš€ Elit Dev Server');
1530
+ console.log(`\n āžœ Local: http://${config.host}:${config.port}`);
1531
+
1532
+ if (normalizedClients.length > 1) {
1533
+ console.log(` āžœ Clients:`);
1534
+ normalizedClients.forEach(client => {
1535
+ const clientUrl = `http://${config.host}:${config.port}${client.basePath}`;
1536
+ console.log(` - ${clientUrl} → ${client.root}`);
1537
+ });
1538
+ } else {
1539
+ const client = normalizedClients[0];
1540
+ console.log(` āžœ Root: ${client.root}`);
1541
+ if (client.basePath) {
1542
+ console.log(` āžœ Base: ${client.basePath}`);
1543
+ }
1544
+ }
1545
+
1546
+ console.log(`\n[HMR] Watching for file changes...\n`);
1547
+ }
1548
+
1549
+ // Open browser to first client
1550
+ if (config.open && normalizedClients.length > 0) {
1551
+ const firstClient = normalizedClients[0];
1552
+ const url = `http://${config.host}:${config.port}${firstClient.basePath}`;
1553
+
1554
+ const open = async () => {
1555
+ const { default: openBrowser } = await import('open');
1556
+ await openBrowser(url);
1557
+ };
1558
+ open().catch(() => {
1559
+ // Fail silently if open package is not available
1560
+ });
1561
+ }
1562
+ });
1563
+
1564
+ // Cleanup function
1565
+ let isClosing = false;
1566
+ const close = async () => {
1567
+ if (isClosing) return;
1568
+ isClosing = true;
1569
+ if (config.logging) console.log('\n[Server] Shutting down...');
1570
+ await watcher.close();
1571
+ wss.close();
1572
+ wsClients.forEach(client => client.close());
1573
+ wsClients.clear();
1574
+ return new Promise<void>((resolve) => {
1575
+ server.close(() => {
1576
+ if (config.logging) console.log('[Server] Closed');
1577
+ resolve();
1578
+ });
1579
+ });
1580
+ };
1581
+
1582
+ // Get the primary URL (first client's basePath)
1583
+ const primaryClient = normalizedClients[0];
1584
+ const primaryUrl = `http://${config.host}:${config.port}${primaryClient.basePath}`;
1585
+
1586
+ return {
1587
+ server: server as any,
1588
+ wss: wss as any,
1589
+ url: primaryUrl,
1590
+ state: stateManager,
1591
+ close
1592
+ };
1593
+ }