@buenojs/bueno 0.8.0
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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot Module Replacement (HMR) Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides live updates without full page refreshes for a better developer experience.
|
|
5
|
+
* Supports React Fast Refresh, Vue Hot Component Replacement, Svelte HMR, and Solid Hot Reloading.
|
|
6
|
+
*
|
|
7
|
+
* @module frontend/hmr
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createLogger, type Logger } from "../logger/index.js";
|
|
11
|
+
import type {
|
|
12
|
+
HMRClient,
|
|
13
|
+
HMRConfig,
|
|
14
|
+
HMRUpdate,
|
|
15
|
+
HMRUpdateError,
|
|
16
|
+
HMRDependencyNode,
|
|
17
|
+
HMRClientMessage,
|
|
18
|
+
HMRServerMessage,
|
|
19
|
+
FileChangeEvent,
|
|
20
|
+
FrontendFramework,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import { HMR_CLIENT_SCRIPT } from "./hmr-client.js";
|
|
23
|
+
|
|
24
|
+
// ============= Constants =============
|
|
25
|
+
|
|
26
|
+
const DEFAULT_DEBOUNCE_MS = 100;
|
|
27
|
+
const DEFAULT_IGNORE_PATTERNS = ["node_modules", ".git", "dist", "build", ".bun"];
|
|
28
|
+
|
|
29
|
+
// ============= File Watcher Types =============
|
|
30
|
+
|
|
31
|
+
interface FileWatchEvent {
|
|
32
|
+
event: 'create' | 'update' | 'delete';
|
|
33
|
+
filePath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type FileWatchCallback = (event: FileWatchEvent) => void;
|
|
37
|
+
|
|
38
|
+
// ============= HMRManager Class =============
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Manages Hot Module Replacement for the development server.
|
|
42
|
+
*
|
|
43
|
+
* Features:
|
|
44
|
+
* - WebSocket server for client communication
|
|
45
|
+
* - File watching with dependency tracking
|
|
46
|
+
* - Framework-specific HMR support (React, Vue, Svelte, Solid)
|
|
47
|
+
* - Debounced file change handling
|
|
48
|
+
* - Error overlay support
|
|
49
|
+
*/
|
|
50
|
+
export class HMRManager {
|
|
51
|
+
private config: HMRConfig;
|
|
52
|
+
private logger: Logger;
|
|
53
|
+
private clients: Map<string, HMRClient> = new Map();
|
|
54
|
+
private dependencyGraph: Map<string, HMRDependencyNode> = new Map();
|
|
55
|
+
private pendingUpdates: Map<string, FileChangeEvent> = new Map();
|
|
56
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
private framework: FrontendFramework;
|
|
58
|
+
private devServerPort: number;
|
|
59
|
+
private watcher: ReturnType<typeof import('fs').watch> | null = null;
|
|
60
|
+
|
|
61
|
+
constructor(framework: FrontendFramework, devServerPort: number, config?: Partial<HMRConfig>) {
|
|
62
|
+
this.framework = framework;
|
|
63
|
+
this.devServerPort = devServerPort;
|
|
64
|
+
this.config = this.normalizeConfig(config);
|
|
65
|
+
this.logger = createLogger({
|
|
66
|
+
level: "debug",
|
|
67
|
+
pretty: true,
|
|
68
|
+
context: { component: "HMRManager" },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize partial config to full config with defaults
|
|
74
|
+
*/
|
|
75
|
+
private normalizeConfig(config?: Partial<HMRConfig>): HMRConfig {
|
|
76
|
+
return {
|
|
77
|
+
enabled: config?.enabled ?? true,
|
|
78
|
+
port: config?.port ?? this.devServerPort + 1,
|
|
79
|
+
debounceMs: config?.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
80
|
+
ignorePatterns: config?.ignorePatterns ?? DEFAULT_IGNORE_PATTERNS,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get the HMR client script for injection
|
|
86
|
+
*/
|
|
87
|
+
getClientScript(): string {
|
|
88
|
+
return HMR_CLIENT_SCRIPT;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the WebSocket URL for HMR
|
|
93
|
+
*/
|
|
94
|
+
getWebSocketUrl(): string {
|
|
95
|
+
return `ws://localhost:${this.config.port}/_hmr`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the HMR port
|
|
100
|
+
*/
|
|
101
|
+
getPort(): number {
|
|
102
|
+
return this.config.port!;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if HMR is enabled
|
|
107
|
+
*/
|
|
108
|
+
isEnabled(): boolean {
|
|
109
|
+
return this.config.enabled;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handle WebSocket upgrade request
|
|
114
|
+
*/
|
|
115
|
+
handleUpgrade(request: Request): WebSocket | null {
|
|
116
|
+
const url = new URL(request.url);
|
|
117
|
+
|
|
118
|
+
if (url.pathname !== "/_hmr") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if this is a WebSocket upgrade request
|
|
123
|
+
const upgradeHeader = request.headers.get("upgrade");
|
|
124
|
+
if (upgradeHeader !== "websocket") {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create WebSocket pair using Bun's native WebSocket
|
|
129
|
+
const server = Bun.serve({
|
|
130
|
+
port: this.config.port,
|
|
131
|
+
fetch: this.handleWebSocketFetch.bind(this),
|
|
132
|
+
websocket: {
|
|
133
|
+
open: this.handleWebSocketOpen.bind(this),
|
|
134
|
+
close: this.handleWebSocketClose.bind(this),
|
|
135
|
+
message: this.handleWebSocketMessage.bind(this),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.logger.info(`HMR WebSocket server started on port ${this.config.port}`);
|
|
140
|
+
|
|
141
|
+
return null; // The server handles the WebSocket directly
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Handle fetch requests for WebSocket server
|
|
146
|
+
*/
|
|
147
|
+
private handleWebSocketFetch(request: Request, server: any): Response | undefined {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
|
|
150
|
+
if (url.pathname === "/_hmr") {
|
|
151
|
+
const success = server.upgrade(request);
|
|
152
|
+
if (success) {
|
|
153
|
+
return undefined; // WebSocket upgrade successful
|
|
154
|
+
}
|
|
155
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new Response("Not found", { status: 404 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle WebSocket connection open
|
|
163
|
+
*/
|
|
164
|
+
private handleWebSocketOpen(ws: any): void {
|
|
165
|
+
const clientId = this.generateClientId();
|
|
166
|
+
const client: HMRClient = {
|
|
167
|
+
id: clientId,
|
|
168
|
+
ws: ws,
|
|
169
|
+
subscribedFiles: new Set(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
this.clients.set(clientId, client);
|
|
173
|
+
ws.data = { clientId };
|
|
174
|
+
|
|
175
|
+
this.logger.debug(`HMR client connected: ${clientId}`);
|
|
176
|
+
|
|
177
|
+
// Send connected message
|
|
178
|
+
this.sendToClient(ws, {
|
|
179
|
+
type: "connected",
|
|
180
|
+
clientId,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle WebSocket connection close
|
|
186
|
+
*/
|
|
187
|
+
private handleWebSocketClose(ws: any): void {
|
|
188
|
+
const clientId = ws.data?.clientId;
|
|
189
|
+
if (clientId) {
|
|
190
|
+
this.clients.delete(clientId);
|
|
191
|
+
this.logger.debug(`HMR client disconnected: ${clientId}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle WebSocket message from client
|
|
197
|
+
*/
|
|
198
|
+
private handleWebSocketMessage(ws: any, message: string | Buffer): void {
|
|
199
|
+
try {
|
|
200
|
+
const data: HMRClientMessage = JSON.parse(message.toString());
|
|
201
|
+
const clientId = ws.data?.clientId;
|
|
202
|
+
|
|
203
|
+
if (!clientId) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const client = this.clients.get(clientId);
|
|
208
|
+
if (!client) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
switch (data.type) {
|
|
213
|
+
case "subscribe":
|
|
214
|
+
client.subscribedFiles.add(data.fileId);
|
|
215
|
+
this.logger.debug(`Client ${clientId} subscribed to: ${data.fileId}`);
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case "unsubscribe":
|
|
219
|
+
client.subscribedFiles.delete(data.fileId);
|
|
220
|
+
this.logger.debug(`Client ${clientId} unsubscribed from: ${data.fileId}`);
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case "ping":
|
|
224
|
+
this.sendToClient(ws, { type: "pong" });
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case "module-accepted":
|
|
228
|
+
this.handleModuleAccepted(data.moduleId, data.dependencies);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.logger.error("Failed to parse HMR message", error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Send message to a WebSocket client
|
|
238
|
+
*/
|
|
239
|
+
private sendToClient(ws: WebSocket, message: HMRServerMessage): void {
|
|
240
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
241
|
+
ws.send(JSON.stringify(message));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Broadcast update to all connected clients
|
|
247
|
+
*/
|
|
248
|
+
broadcastUpdate(update: HMRUpdate): void {
|
|
249
|
+
const message = JSON.stringify(update);
|
|
250
|
+
|
|
251
|
+
for (const client of this.clients.values()) {
|
|
252
|
+
// Check if client is subscribed to any of the changed files
|
|
253
|
+
const isSubscribed = update.changes.some(
|
|
254
|
+
(file) => client.subscribedFiles.has(file)
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (isSubscribed || update.type === "reload" || update.type === "error") {
|
|
258
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
259
|
+
client.ws.send(message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.logger.debug(`Broadcasted ${update.type} update for: ${update.fileId}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Start file watching
|
|
269
|
+
*/
|
|
270
|
+
startWatching(rootDir: string): void {
|
|
271
|
+
if (!this.config.enabled) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.logger.info(`Starting file watcher for: ${rootDir}`);
|
|
276
|
+
|
|
277
|
+
// Use Node's fs.watch for file watching
|
|
278
|
+
const fs = require('fs');
|
|
279
|
+
const path = require('path');
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
this.watcher = fs.watch(rootDir, { recursive: true }, (eventType: string, filename: string | null) => {
|
|
283
|
+
if (!filename) return;
|
|
284
|
+
|
|
285
|
+
const filePath = path.join(rootDir, filename);
|
|
286
|
+
|
|
287
|
+
if (eventType === 'rename') {
|
|
288
|
+
// Check if file exists to determine if it's create or delete
|
|
289
|
+
try {
|
|
290
|
+
fs.accessSync(filePath, fs.constants.F_OK);
|
|
291
|
+
this.handleFileChange(filePath, "create");
|
|
292
|
+
} catch {
|
|
293
|
+
this.handleFileChange(filePath, "delete");
|
|
294
|
+
}
|
|
295
|
+
} else if (eventType === 'change') {
|
|
296
|
+
this.handleFileChange(filePath, "update");
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
this.logger.info("File watcher started");
|
|
301
|
+
} catch (error) {
|
|
302
|
+
this.logger.error("Failed to start file watcher", error);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stop file watching
|
|
308
|
+
*/
|
|
309
|
+
stopWatching(): void {
|
|
310
|
+
if (this.watcher) {
|
|
311
|
+
this.watcher.close();
|
|
312
|
+
this.watcher = null;
|
|
313
|
+
this.logger.info("File watcher stopped");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Handle a file change event
|
|
319
|
+
*/
|
|
320
|
+
private handleFileChange(filePath: string, event: "create" | "update" | "delete"): void {
|
|
321
|
+
// Check if file should be ignored
|
|
322
|
+
if (this.shouldIgnoreFile(filePath)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.logger.debug(`File ${event}: ${filePath}`);
|
|
327
|
+
|
|
328
|
+
// Add to pending updates
|
|
329
|
+
this.pendingUpdates.set(filePath, {
|
|
330
|
+
path: filePath,
|
|
331
|
+
event,
|
|
332
|
+
timestamp: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Debounce updates
|
|
336
|
+
this.scheduleDebouncedUpdate();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check if a file should be ignored
|
|
341
|
+
*/
|
|
342
|
+
private shouldIgnoreFile(filePath: string): boolean {
|
|
343
|
+
// Check ignore patterns
|
|
344
|
+
for (const pattern of this.config.ignorePatterns) {
|
|
345
|
+
if (filePath.includes(pattern)) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Only watch relevant file types
|
|
351
|
+
const relevantExtensions = [".js", ".jsx", ".ts", ".tsx", ".css", ".scss", ".sass", ".less", ".vue", ".svelte"];
|
|
352
|
+
const ext = filePath.substring(filePath.lastIndexOf("."));
|
|
353
|
+
|
|
354
|
+
return !relevantExtensions.includes(ext);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Schedule debounced update processing
|
|
359
|
+
*/
|
|
360
|
+
private scheduleDebouncedUpdate(): void {
|
|
361
|
+
if (this.debounceTimer) {
|
|
362
|
+
clearTimeout(this.debounceTimer);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.debounceTimer = setTimeout(() => {
|
|
366
|
+
this.processPendingUpdates();
|
|
367
|
+
}, this.config.debounceMs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Process all pending file updates
|
|
372
|
+
*/
|
|
373
|
+
private processPendingUpdates(): void {
|
|
374
|
+
if (this.pendingUpdates.size === 0) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const updates = Array.from(this.pendingUpdates.values());
|
|
379
|
+
this.pendingUpdates.clear();
|
|
380
|
+
|
|
381
|
+
// Group updates by type
|
|
382
|
+
const changedFiles = updates.map((u) => u.path);
|
|
383
|
+
|
|
384
|
+
// Determine update type based on file changes
|
|
385
|
+
const updateType = this.determineUpdateType(changedFiles);
|
|
386
|
+
|
|
387
|
+
// Create and broadcast update
|
|
388
|
+
const update: HMRUpdate = {
|
|
389
|
+
type: updateType,
|
|
390
|
+
fileId: this.generateFileId(changedFiles[0]),
|
|
391
|
+
timestamp: Date.now(),
|
|
392
|
+
changes: changedFiles,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
this.broadcastUpdate(update);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Determine the type of update needed
|
|
400
|
+
*/
|
|
401
|
+
private determineUpdateType(changedFiles: string[]): "update" | "reload" {
|
|
402
|
+
// Check if any changed file requires a full reload
|
|
403
|
+
for (const file of changedFiles) {
|
|
404
|
+
const ext = file.substring(file.lastIndexOf("."));
|
|
405
|
+
|
|
406
|
+
// Configuration files always require full reload
|
|
407
|
+
if (file.includes("config") || file.endsWith(".config.js") || file.endsWith(".config.ts")) {
|
|
408
|
+
return "reload";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// HTML files require full reload
|
|
412
|
+
if (ext === ".html") {
|
|
413
|
+
return "reload";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check dependency graph for breaking changes
|
|
417
|
+
const node = this.dependencyGraph.get(file);
|
|
418
|
+
if (node && node.importedBy.size > 0) {
|
|
419
|
+
// If this file is imported by others, check if it's a breaking change
|
|
420
|
+
// For now, we'll be conservative and trigger an update
|
|
421
|
+
// In a full implementation, we'd analyze the actual changes
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return "update";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Handle module accepted from client
|
|
430
|
+
*/
|
|
431
|
+
private handleModuleAccepted(moduleId: string, dependencies: string[]): void {
|
|
432
|
+
// Update dependency graph
|
|
433
|
+
const node = this.dependencyGraph.get(moduleId);
|
|
434
|
+
if (node) {
|
|
435
|
+
// Update imports
|
|
436
|
+
node.imports = new Set(dependencies);
|
|
437
|
+
|
|
438
|
+
// Update reverse dependencies
|
|
439
|
+
for (const dep of dependencies) {
|
|
440
|
+
const depNode = this.dependencyGraph.get(dep);
|
|
441
|
+
if (depNode) {
|
|
442
|
+
depNode.importedBy.add(moduleId);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.logger.debug(`Module accepted: ${moduleId}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Register a file in the dependency graph
|
|
452
|
+
*/
|
|
453
|
+
registerFile(filePath: string, imports: string[] = []): void {
|
|
454
|
+
const node: HMRDependencyNode = {
|
|
455
|
+
filePath,
|
|
456
|
+
imports: new Set(imports),
|
|
457
|
+
importedBy: new Set(),
|
|
458
|
+
lastModified: Date.now(),
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Update reverse dependencies
|
|
462
|
+
for (const imp of imports) {
|
|
463
|
+
const importedNode = this.dependencyGraph.get(imp);
|
|
464
|
+
if (importedNode) {
|
|
465
|
+
importedNode.importedBy.add(filePath);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.dependencyGraph.set(filePath, node);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get files that depend on a given file
|
|
474
|
+
*/
|
|
475
|
+
getDependents(filePath: string): string[] {
|
|
476
|
+
const node = this.dependencyGraph.get(filePath);
|
|
477
|
+
if (!node) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return Array.from(node.importedBy);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Get files that a file imports
|
|
486
|
+
*/
|
|
487
|
+
getImports(filePath: string): string[] {
|
|
488
|
+
const node = this.dependencyGraph.get(filePath);
|
|
489
|
+
if (!node) {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return Array.from(node.imports);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Broadcast an error to all clients
|
|
498
|
+
*/
|
|
499
|
+
broadcastError(error: Error | HMRUpdateError): void {
|
|
500
|
+
const updateError: HMRUpdateError = error instanceof Error
|
|
501
|
+
? {
|
|
502
|
+
message: error.message,
|
|
503
|
+
stack: error.stack,
|
|
504
|
+
}
|
|
505
|
+
: error;
|
|
506
|
+
|
|
507
|
+
const update: HMRUpdate = {
|
|
508
|
+
type: "error",
|
|
509
|
+
fileId: updateError.file || "unknown",
|
|
510
|
+
timestamp: Date.now(),
|
|
511
|
+
changes: [],
|
|
512
|
+
error: updateError,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
this.broadcastUpdate(update);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Generate a unique client ID
|
|
520
|
+
*/
|
|
521
|
+
private generateClientId(): string {
|
|
522
|
+
return `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Generate a file ID from a file path
|
|
527
|
+
*/
|
|
528
|
+
private generateFileId(filePath: string): string {
|
|
529
|
+
// Normalize the file path for consistent IDs
|
|
530
|
+
return filePath.replace(/\\/g, "/");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get the number of connected clients
|
|
535
|
+
*/
|
|
536
|
+
getClientCount(): number {
|
|
537
|
+
return this.clients.size;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Get all connected client IDs
|
|
542
|
+
*/
|
|
543
|
+
getClientIds(): string[] {
|
|
544
|
+
return Array.from(this.clients.keys());
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Disconnect all clients
|
|
549
|
+
*/
|
|
550
|
+
disconnectAll(): void {
|
|
551
|
+
for (const client of this.clients.values()) {
|
|
552
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
553
|
+
client.ws.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this.clients.clear();
|
|
558
|
+
this.logger.info("All HMR clients disconnected");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Stop the HMR manager
|
|
563
|
+
*/
|
|
564
|
+
stop(): void {
|
|
565
|
+
this.stopWatching();
|
|
566
|
+
this.disconnectAll();
|
|
567
|
+
this.dependencyGraph.clear();
|
|
568
|
+
this.pendingUpdates.clear();
|
|
569
|
+
|
|
570
|
+
if (this.debounceTimer) {
|
|
571
|
+
clearTimeout(this.debounceTimer);
|
|
572
|
+
this.debounceTimer = null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
this.logger.info("HMR manager stopped");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Get framework-specific HMR runtime code
|
|
580
|
+
*/
|
|
581
|
+
getFrameworkRuntime(): string {
|
|
582
|
+
switch (this.framework) {
|
|
583
|
+
case "react":
|
|
584
|
+
return this.getReactRuntime();
|
|
585
|
+
case "vue":
|
|
586
|
+
return this.getVueRuntime();
|
|
587
|
+
case "svelte":
|
|
588
|
+
return this.getSvelteRuntime();
|
|
589
|
+
case "solid":
|
|
590
|
+
return this.getSolidRuntime();
|
|
591
|
+
default:
|
|
592
|
+
return "";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* React Fast Refresh runtime
|
|
598
|
+
*/
|
|
599
|
+
private getReactRuntime(): string {
|
|
600
|
+
return `
|
|
601
|
+
// React Fast Refresh Runtime
|
|
602
|
+
(function() {
|
|
603
|
+
if (typeof window !== 'undefined') {
|
|
604
|
+
window.__HMR_REACT_REFRESH__ = true;
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Vue HMR runtime
|
|
612
|
+
*/
|
|
613
|
+
private getVueRuntime(): string {
|
|
614
|
+
return `
|
|
615
|
+
// Vue HMR Runtime
|
|
616
|
+
(function() {
|
|
617
|
+
if (typeof window !== 'undefined') {
|
|
618
|
+
window.__HMR_VUE__ = true;
|
|
619
|
+
}
|
|
620
|
+
})();
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Svelte HMR runtime
|
|
626
|
+
*/
|
|
627
|
+
private getSvelteRuntime(): string {
|
|
628
|
+
return `
|
|
629
|
+
// Svelte HMR Runtime
|
|
630
|
+
(function() {
|
|
631
|
+
if (typeof window !== 'undefined') {
|
|
632
|
+
window.__HMR_SVELTE__ = true;
|
|
633
|
+
}
|
|
634
|
+
})();
|
|
635
|
+
`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Solid HMR runtime
|
|
640
|
+
*/
|
|
641
|
+
private getSolidRuntime(): string {
|
|
642
|
+
return `
|
|
643
|
+
// Solid HMR Runtime
|
|
644
|
+
(function() {
|
|
645
|
+
if (typeof window !== 'undefined') {
|
|
646
|
+
window.__HMR_SOLID__ = true;
|
|
647
|
+
}
|
|
648
|
+
})();
|
|
649
|
+
`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ============= Factory Function =============
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Create an HMR manager
|
|
657
|
+
*/
|
|
658
|
+
export function createHMRManager(
|
|
659
|
+
framework: FrontendFramework,
|
|
660
|
+
devServerPort: number,
|
|
661
|
+
config?: Partial<HMRConfig>
|
|
662
|
+
): HMRManager {
|
|
663
|
+
return new HMRManager(framework, devServerPort, config);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ============= Utility Functions =============
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Check if a file is an HMR boundary
|
|
670
|
+
* (a file that can accept hot updates without propagating to parents)
|
|
671
|
+
*/
|
|
672
|
+
export function isHMRBoundary(filePath: string, content: string): boolean {
|
|
673
|
+
// Check for HMR acceptance patterns
|
|
674
|
+
const patterns = [
|
|
675
|
+
/module\.hot\.accept/,
|
|
676
|
+
/import\.meta\.hot\.accept/,
|
|
677
|
+
/if\s*\(\s*import\.meta\.hot\s*\)/,
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
return patterns.some((pattern) => pattern.test(content));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Parse imports from a file's content
|
|
685
|
+
*/
|
|
686
|
+
export function parseImports(content: string, filePath: string): string[] {
|
|
687
|
+
const imports: string[] = [];
|
|
688
|
+
|
|
689
|
+
// Match ES6 imports
|
|
690
|
+
const es6Pattern = /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
691
|
+
let match;
|
|
692
|
+
|
|
693
|
+
while ((match = es6Pattern.exec(content)) !== null) {
|
|
694
|
+
imports.push(resolveImport(match[1], filePath));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Match dynamic imports
|
|
698
|
+
const dynamicPattern = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
699
|
+
|
|
700
|
+
while ((match = dynamicPattern.exec(content)) !== null) {
|
|
701
|
+
imports.push(resolveImport(match[1], filePath));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Match CommonJS requires
|
|
705
|
+
const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
706
|
+
|
|
707
|
+
while ((match = requirePattern.exec(content)) !== null) {
|
|
708
|
+
imports.push(resolveImport(match[1], filePath));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return imports;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Resolve an import path relative to the importing file
|
|
716
|
+
*/
|
|
717
|
+
function resolveImport(importPath: string, fromFile: string): string {
|
|
718
|
+
// Skip bare imports (node_modules)
|
|
719
|
+
if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
|
|
720
|
+
return importPath;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Resolve relative paths
|
|
724
|
+
const dir = fromFile.substring(0, fromFile.lastIndexOf("/"));
|
|
725
|
+
const resolved = new URL(importPath, `file://${dir}/`).pathname;
|
|
726
|
+
|
|
727
|
+
return resolved;
|
|
728
|
+
}
|