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.
- package/dist/build.d.ts +4 -12
- package/dist/build.d.ts.map +1 -0
- package/dist/chokidar.d.ts +7 -9
- package/dist/chokidar.d.ts.map +1 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +250 -21
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/dom.d.ts +7 -14
- package/dist/dom.d.ts.map +1 -0
- package/dist/el.d.ts +19 -191
- package/dist/el.d.ts.map +1 -0
- package/dist/fs.d.ts +35 -35
- package/dist/fs.d.ts.map +1 -0
- package/dist/hmr.d.ts +3 -3
- package/dist/hmr.d.ts.map +1 -0
- package/dist/http.d.ts +20 -22
- package/dist/http.d.ts.map +1 -0
- package/dist/https.d.ts +12 -15
- package/dist/https.d.ts.map +1 -0
- package/dist/index.d.ts +10 -629
- package/dist/index.d.ts.map +1 -0
- package/dist/mime-types.d.ts +9 -9
- package/dist/mime-types.d.ts.map +1 -0
- package/dist/path.d.ts +22 -19
- package/dist/path.d.ts.map +1 -0
- package/dist/router.d.ts +10 -17
- package/dist/router.d.ts.map +1 -0
- package/dist/runtime.d.ts +5 -6
- package/dist/runtime.d.ts.map +1 -0
- package/dist/server.d.ts +109 -7
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +712 -137
- package/dist/server.mjs +711 -137
- package/dist/state.d.ts +21 -27
- package/dist/state.d.ts.map +1 -0
- package/dist/style.d.ts +14 -55
- package/dist/style.d.ts.map +1 -0
- package/dist/types.d.ts +26 -240
- package/dist/types.d.ts.map +1 -0
- package/dist/ws.d.ts +14 -17
- package/dist/ws.d.ts.map +1 -0
- package/dist/wss.d.ts +16 -16
- package/dist/wss.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/build.ts +337 -0
- package/src/chokidar.ts +401 -0
- package/src/cli.ts +638 -0
- package/src/config.ts +205 -0
- package/src/dom.ts +817 -0
- package/src/el.ts +164 -0
- package/src/fs.ts +727 -0
- package/src/hmr.ts +137 -0
- package/src/http.ts +775 -0
- package/src/https.ts +411 -0
- package/src/index.ts +14 -0
- package/src/mime-types.ts +222 -0
- package/src/path.ts +493 -0
- package/src/router.ts +237 -0
- package/src/runtime.ts +97 -0
- package/src/server.ts +1593 -0
- package/src/state.ts +468 -0
- package/src/style.ts +524 -0
- package/{dist/types-Du6kfwTm.d.ts ā src/types.ts} +58 -141
- package/src/ws.ts +506 -0
- package/src/wss.ts +241 -0
- package/dist/build.d.mts +0 -20
- package/dist/chokidar.d.mts +0 -134
- package/dist/dom.d.mts +0 -87
- package/dist/el.d.mts +0 -207
- package/dist/fs.d.mts +0 -255
- package/dist/hmr.d.mts +0 -38
- package/dist/http.d.mts +0 -163
- package/dist/https.d.mts +0 -108
- package/dist/index.d.mts +0 -629
- package/dist/mime-types.d.mts +0 -48
- package/dist/path.d.mts +0 -163
- package/dist/router.d.mts +0 -47
- package/dist/runtime.d.mts +0 -97
- package/dist/server.d.mts +0 -7
- package/dist/state.d.mts +0 -111
- package/dist/style.d.mts +0 -159
- package/dist/types-C0nGi6MX.d.mts +0 -346
- package/dist/types.d.mts +0 -452
- package/dist/ws.d.mts +0 -195
- 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
|
+
}
|