hightjs 0.2.42 → 0.2.43

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/src/hotReload.ts CHANGED
@@ -6,28 +6,45 @@ import { IncomingMessage } from 'http';
6
6
  import * as url from 'url';
7
7
  import { clearFileCache } from './router';
8
8
  import Console, {Levels} from "./api/console"
9
+
10
+ interface ClientConnection {
11
+ ws: WebSocket;
12
+ pingTimer: NodeJS.Timeout;
13
+ lastPong: number;
14
+ }
15
+
9
16
  export class HotReloadManager {
10
17
  private wss: WebSocketServer | null = null;
11
18
  private watchers: chokidar.FSWatcher[] = [];
12
19
  private projectDir: string;
13
- private clients: Set<WebSocket> = new Set();
14
- private pingInterval: NodeJS.Timeout | null = null;
20
+ private clients: Map<WebSocket, ClientConnection> = new Map();
15
21
  private backendApiChangeCallback: (() => void) | null = null;
16
22
  private frontendChangeCallback: (() => void) | null = null;
23
+ private isShuttingDown: boolean = false;
24
+ private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
25
+ private customHotReloadListener: ((file: string) => Promise<void> | void) | null = null;
17
26
 
18
27
  constructor(projectDir: string) {
19
28
  this.projectDir = projectDir;
20
29
  }
21
30
 
22
31
  async start() {
23
- // Não cria servidor na porta separada - será integrado ao Express
24
32
  this.setupWatchers();
25
33
  }
26
34
 
27
- // Novo método para integrar com Express
35
+ // Método para integrar com Express
28
36
  handleUpgrade(request: IncomingMessage, socket: any, head: Buffer) {
37
+ if (this.isShuttingDown) {
38
+ socket.destroy();
39
+ return;
40
+ }
41
+
29
42
  if (!this.wss) {
30
- this.wss = new WebSocketServer({ noServer: true });
43
+ this.wss = new WebSocketServer({
44
+ noServer: true,
45
+ perMessageDeflate: false, // Desabilita compressão para melhor performance
46
+ maxPayload: 1024 * 1024 // Limite de 1MB por mensagem
47
+ });
31
48
  this.setupWebSocketServer();
32
49
  }
33
50
 
@@ -40,200 +57,350 @@ export class HotReloadManager {
40
57
  if (!this.wss) return;
41
58
 
42
59
  this.wss.on('connection', (ws: WebSocket) => {
43
- this.clients.add(ws);
60
+ if (this.isShuttingDown) {
61
+ ws.close();
62
+ return;
63
+ }
44
64
 
45
- // Setup ping/pong para manter conexão viva
46
- const ping = () => {
47
- if (ws.readyState === WebSocket.OPEN) {
65
+ // Setup ping/pong para detectar conexões mortas
66
+ const pingTimer = setInterval(() => {
67
+ const client = this.clients.get(ws);
68
+ if (client && ws.readyState === WebSocket.OPEN) {
69
+ // Se não recebeu pong há mais de 60 segundos, desconecta
70
+ if (Date.now() - client.lastPong > 60000) {
71
+ ws.terminate();
72
+ return;
73
+ }
48
74
  ws.ping();
49
75
  }
76
+ }, 30000);
77
+
78
+ const clientConnection: ClientConnection = {
79
+ ws,
80
+ pingTimer,
81
+ lastPong: Date.now()
50
82
  };
51
83
 
52
- const pingTimer = setInterval(ping, 30000); // Ping a cada 30 segundos
84
+ this.clients.set(ws, clientConnection);
53
85
 
54
86
  ws.on('pong', () => {
55
- // Cliente respondeu ao ping - conexão ainda ativa
87
+ const client = this.clients.get(ws);
88
+ if (client) {
89
+ client.lastPong = Date.now();
90
+ }
56
91
  });
57
92
 
58
93
  ws.on('close', () => {
59
- this.clients.delete(ws);
60
- clearInterval(pingTimer);
94
+ this.cleanupClient(ws);
61
95
  });
62
96
 
63
- ws.on('error', () => {
64
- this.clients.delete(ws);
65
- clearInterval(pingTimer);
97
+ ws.on('error', (error) => {
98
+ Console.logWithout(Levels.ERROR, `WebSocket error: ${error.message}`);
99
+ this.cleanupClient(ws);
66
100
  });
101
+
102
+ Console.logWithout(Levels.INFO, '🔌 Hot-reload cliente conectado');
67
103
  });
68
104
  }
69
105
 
106
+ private cleanupClient(ws: WebSocket) {
107
+ const client = this.clients.get(ws);
108
+ if (client) {
109
+ clearInterval(client.pingTimer);
110
+ this.clients.delete(ws);
111
+ }
112
+ }
113
+
70
114
  private setupWatchers() {
71
- // 1. Watcher para arquivos frontend (rotas, componentes) - EXCLUINDO backend
72
- const frontendWatcher = chokidar.watch([
73
- path.join(this.projectDir, 'src/web/**/*.{tsx,ts,jsx,js}'),
115
+ // Remove watchers antigos e use apenas um watcher global para src
116
+ const debouncedChange = this.debounce((filePath: string) => {
117
+ this.handleAnySrcChange(filePath);
118
+ }, 100);
119
+
120
+ const watcher = chokidar.watch([
121
+ path.join(this.projectDir, 'src/**/*'),
74
122
  ], {
75
123
  ignored: [
76
124
  /(^|[\/\\])\../, // arquivos ocultos
77
- path.join(this.projectDir, 'src/web/backend/**/*') // exclui toda a pasta backend
125
+ '**/node_modules/**',
126
+ '**/.git/**',
127
+ '**/dist/**'
78
128
  ],
79
129
  persistent: true,
80
- ignoreInitial: true
81
- });
82
-
83
- frontendWatcher.on('change', async (filePath) => {
84
- Console.logWithout(Levels.INFO, `🔄 Frontend alterado: ${filePath}`);
85
- clearFileCache(filePath);
86
- // Checa build do arquivo alterado
87
- const result = await this.checkFrontendBuild(filePath);
88
- if (result.error) {
89
- this.notifyClients('frontend-error', { file: filePath, error: result.error });
90
- } else {
91
- this.frontendChangeCallback?.();
92
- this.notifyClients('frontend-reload');
93
- }
94
- });
95
- frontendWatcher.on('add', async (filePath) => {
96
- Console.info(`➕ Novo arquivo frontend: ${path.basename(filePath)}`);
97
- const result = await this.checkFrontendBuild(filePath);
98
- if (result.error) {
99
- this.notifyClients('frontend-error', { file: filePath, error: result.error });
100
- } else {
101
- this.frontendChangeCallback?.();
102
- this.notifyClients('frontend-reload');
130
+ ignoreInitial: true,
131
+ usePolling: false,
132
+ awaitWriteFinish: {
133
+ stabilityThreshold: 100,
134
+ pollInterval: 50
103
135
  }
104
136
  });
105
- frontendWatcher.on('unlink', (filePath) => {
106
- Console.info(`🗑️ Arquivo frontend removido: ${path.basename(filePath)}`);
137
+
138
+ watcher.on('change', debouncedChange);
139
+ watcher.on('add', debouncedChange);
140
+ watcher.on('unlink', (filePath) => {
141
+ Console.info(`🗑️ Arquivo removido: ${path.basename(filePath)}`);
107
142
  clearFileCache(filePath);
143
+ this.clearBackendCache(filePath);
108
144
  this.frontendChangeCallback?.();
109
- this.notifyClients('frontend-reload');
145
+ this.backendApiChangeCallback?.();
146
+ this.notifyClients('src-reload', { file: filePath, event: 'unlink' });
110
147
  });
111
148
 
112
- // 2. Watcher específico para rotas de API backend
113
- const backendApiWatcher = chokidar.watch([
114
- path.join(this.projectDir, 'src/web/backend/routes/**/*.{ts,tsx,js,jsx}'),
115
- ], {
116
- ignored: /(^|[\/\\])\../,
117
- persistent: true,
118
- ignoreInitial: true
119
- });
149
+ this.watchers.push(watcher);
150
+ }
120
151
 
121
- backendApiWatcher.on('change', (filePath) => {
122
- Console.info(`🔄 API backend alterada: ${path.basename(filePath)}`);
123
- this.clearBackendCache(filePath);
124
- this.notifyClients('backend-api-reload');
152
+ private debounce(func: Function, wait: number): (...args: any[]) => void {
153
+ return (...args: any[]) => {
154
+ const key = args[0]; // usa o primeiro argumento como chave
125
155
 
126
- // Chama o callback, se definido
127
- this.backendApiChangeCallback?.();
128
- });
156
+ const existingTimer = this.debounceTimers.get(key);
157
+ if (existingTimer) {
158
+ clearTimeout(existingTimer);
159
+ }
129
160
 
130
- backendApiWatcher.on('add', (filePath) => {
131
- Console.info(`➕ Nova API backend: ${path.basename(filePath)}`);
132
- this.notifyClients('backend-api-reload');
133
- });
161
+ const timer = setTimeout(() => {
162
+ this.debounceTimers.delete(key);
163
+ func.apply(this, args);
164
+ }, wait);
134
165
 
135
- backendApiWatcher.on('unlink', (filePath) => {
136
- Console.info(`🗑️ API backend removida: ${path.basename(filePath)}`);
137
- this.clearBackendCache(filePath);
138
- this.notifyClients('backend-api-reload');
139
- });
166
+ this.debounceTimers.set(key, timer);
167
+ };
168
+ }
140
169
 
141
- // 3. Watcher para arquivos backend (server.ts, configs)
142
- const backendWatcher = chokidar.watch([
143
- path.join(this.projectDir, 'src/server.ts'),
144
- path.join(this.projectDir, 'src/**/*.ts'),
145
- '!**/src/web/**', // exclui pasta web
146
- ], {
147
- ignored: /(^|[\/\\])\../,
148
- persistent: true,
149
- ignoreInitial: true
150
- });
170
+ private async handleAnySrcChange(filePath: string) {
171
+ Console.logWithout(Levels.INFO, `🔄 Arquivo alterado: ${path.basename(filePath)}`);
151
172
 
152
- backendWatcher.on('change', () => {
153
- this.restartServer();
154
- });
173
+ // Detecta se é arquivo de frontend ou backend
174
+ const isFrontendFile = filePath.includes(path.join('src', 'web', 'routes')) ||
175
+ filePath.includes(path.join('src', 'web', 'components')) ||
176
+ filePath.includes('layout.tsx') ||
177
+ filePath.includes('not-found.tsx') ||
178
+ filePath.endsWith('.tsx') ||
179
+ filePath.endsWith('.jsx');
180
+
181
+ const isBackendFile = filePath.includes(path.join('src', 'web', 'backend')) ||
182
+ (filePath.includes(path.join('src', 'web')) && !isFrontendFile);
183
+
184
+ // Limpa o cache do arquivo alterado
185
+ clearFileCache(filePath);
186
+ this.clearBackendCache(filePath);
187
+
188
+ // Checa build se for .ts/.tsx/.js/.jsx
189
+ const ext = path.extname(filePath);
190
+ if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
191
+ const result = await this.checkFrontendBuild(filePath);
192
+ if (result.error) {
193
+ this.notifyClients('src-error', { file: filePath, error: result.error });
194
+ return;
195
+ }
196
+ }
155
197
 
156
- this.watchers.push(frontendWatcher, backendApiWatcher, backendWatcher);
198
+ // Se for arquivo de frontend, notifica o cliente para recarregar a página
199
+ if (isFrontendFile) {
200
+ Console.logWithout(Levels.INFO, `📄 Recarregando frontend...`);
201
+ this.frontendChangeCallback?.();
202
+ this.notifyClients('frontend-reload', { file: filePath, event: 'change' });
203
+ }
204
+
205
+ // Se for arquivo de backend, recarrega o módulo e notifica
206
+ if (isBackendFile) {
207
+ Console.logWithout(Levels.INFO, `⚙️ Recarregando backend...`);
208
+ this.backendApiChangeCallback?.();
209
+ this.notifyClients('backend-api-reload', { file: filePath, event: 'change' });
210
+ }
211
+
212
+ // Fallback: se não for nem frontend nem backend detectado, recarrega tudo
213
+ if (!isFrontendFile && !isBackendFile) {
214
+ Console.logWithout(Levels.INFO, `🔄 Recarregando aplicação...`);
215
+ this.frontendChangeCallback?.();
216
+ this.backendApiChangeCallback?.();
217
+ this.notifyClients('src-reload', { file: filePath, event: 'change' });
218
+ }
219
+
220
+ // Chama listener customizado se definido
221
+ if (this.customHotReloadListener) {
222
+ try {
223
+ await this.customHotReloadListener(filePath);
224
+ } catch (error) {
225
+ // @ts-ignore
226
+ Console.logWithout(Levels.ERROR, `Erro no listener customizado: ${error.message}`);
227
+ }
228
+ }
157
229
  }
158
230
 
159
231
  private notifyClients(type: string, data?: any) {
160
- const message = JSON.stringify({ type, data, timestamp: Date.now() });
232
+ if (this.isShuttingDown || this.clients.size === 0) {
233
+ return;
234
+ }
161
235
 
162
- this.clients.forEach((client) => {
163
- if (client.readyState === WebSocket.OPEN) {
164
- client.send(message);
236
+ const message = JSON.stringify({ type, data, timestamp: Date.now() });
237
+ const deadClients: WebSocket[] = [];
238
+
239
+ this.clients.forEach((client, ws) => {
240
+ if (ws.readyState === WebSocket.OPEN) {
241
+ try {
242
+ ws.send(message);
243
+ } catch (error) {
244
+ Console.logWithout(Levels.ERROR, `Erro ao enviar mensagem WebSocket: ${error}`);
245
+ deadClients.push(ws);
246
+ }
247
+ } else {
248
+ deadClients.push(ws);
165
249
  }
166
250
  });
251
+
252
+ // Remove clientes mortos
253
+ deadClients.forEach(ws => this.cleanupClient(ws));
167
254
  }
168
255
 
169
256
  private restartServer() {
170
- // Notifica clientes que o servidor está reiniciando
171
257
  this.notifyClients('server-restart');
172
-
173
- // Aguarda um pouco e tenta reconectar
174
258
  setTimeout(() => {
175
259
  this.notifyClients('server-ready');
176
260
  }, 2000);
177
261
  }
178
262
 
179
263
  stop() {
264
+ this.isShuttingDown = true;
265
+
266
+ // Limpa todos os debounce timers
267
+ this.debounceTimers.forEach(timer => clearTimeout(timer));
268
+ this.debounceTimers.clear();
269
+
180
270
  // Para todos os watchers
181
271
  this.watchers.forEach(watcher => watcher.close());
182
272
  this.watchers = [];
183
273
 
184
- // Para ping interval
185
- if (this.pingInterval) {
186
- clearInterval(this.pingInterval);
187
- }
274
+ // Limpa todos os clientes
275
+ this.clients.forEach((client, ws) => {
276
+ clearInterval(client.pingTimer);
277
+ if (ws.readyState === WebSocket.OPEN) {
278
+ ws.close();
279
+ }
280
+ });
281
+ this.clients.clear();
188
282
 
189
283
  // Fecha WebSocket server
190
284
  if (this.wss) {
191
285
  this.wss.close();
286
+ this.wss = null;
192
287
  }
193
288
  }
194
289
 
195
- // Retorna o script do cliente para injetar no HTML
290
+ // Script do cliente otimizado com reconnection backoff
196
291
  getClientScript(): string {
197
292
  return `
198
293
  <script>
199
294
  (function() {
200
295
  if (typeof window !== 'undefined') {
201
296
  let ws;
202
- let reconnectInterval;
297
+ let reconnectAttempts = 0;
298
+ let maxReconnectInterval = 30000; // 30 segundos max
299
+ let reconnectInterval = 1000; // Começa com 1 segundo
300
+ let reconnectTimer;
301
+ let isConnected = false;
203
302
 
204
303
  function connect() {
205
- ws = new WebSocket('ws://localhost:3000/hweb-hotreload/');
304
+ // Evita múltiplas tentativas simultâneas
305
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
306
+ return;
307
+ }
308
+
309
+ try {
310
+ ws = new WebSocket('ws://localhost:3000/hweb-hotreload/');
311
+
312
+ ws.onopen = function() {
313
+ console.log('🔌 Hot-reload conectado');
314
+ isConnected = true;
315
+ reconnectAttempts = 0;
316
+ reconnectInterval = 1000;
317
+ clearTimeout(reconnectTimer);
318
+ };
319
+
320
+ ws.onmessage = function(event) {
321
+ try {
322
+ const message = JSON.parse(event.data);
323
+
324
+ switch(message.type) {
325
+ case 'frontend-reload':
326
+ window.location.reload();
327
+ break;
328
+ case 'backend-api-reload':
329
+ // Recarrega apenas se necessário
330
+ window.location.reload();
331
+ break;
332
+ case 'server-restart':
333
+ console.log('🔄 Servidor reiniciando...');
334
+ break;
335
+ case 'server-ready':
336
+ setTimeout(() => window.location.reload(), 500);
337
+ break;
338
+ case 'frontend-error':
339
+ console.error('❌ Erro no frontend:', message.data);
340
+ break;
341
+ }
342
+ } catch (e) {
343
+ console.error('Erro ao processar mensagem do hot-reload:', e);
344
+ }
345
+ };
346
+
347
+ ws.onclose = function(event) {
348
+ isConnected = false;
349
+
350
+ // Não tenta reconectar se foi fechamento intencional
351
+ if (event.code === 1000) {
352
+ return;
353
+ }
354
+
355
+ scheduleReconnect();
356
+ };
357
+
358
+ ws.onerror = function(error) {
359
+ isConnected = false;
360
+ // Não loga erros de conexão para evitar spam no console
361
+ };
362
+
363
+ } catch (error) {
364
+ console.error('Erro ao criar WebSocket:', error);
365
+ scheduleReconnect();
366
+ }
367
+ }
368
+
369
+ function scheduleReconnect() {
370
+ if (reconnectTimer) {
371
+ clearTimeout(reconnectTimer);
372
+ }
206
373
 
207
- ws.onopen = function() {
208
- clearInterval(reconnectInterval);
209
- };
374
+ reconnectAttempts++;
210
375
 
211
- ws.onmessage = function(event) {
212
- const message = JSON.parse(event.data);
213
-
214
- switch(message.type) {
215
- case 'frontend-reload':
216
- window.location.reload();
217
- break;
218
- case 'server-restart':
219
- break;
220
- case 'server-ready':
221
- setTimeout(() => window.location.reload(), 500);
222
- break;
223
- }
224
- };
376
+ // Exponential backoff com jitter
377
+ const baseInterval = Math.min(reconnectInterval * Math.pow(1.5, reconnectAttempts - 1), maxReconnectInterval);
378
+ const jitter = Math.random() * 1000; // Adiciona até 1 segundo de variação
379
+ const finalInterval = baseInterval + jitter;
225
380
 
226
- ws.onclose = function() {
227
- reconnectInterval = setInterval(() => {
381
+ reconnectTimer = setTimeout(() => {
382
+ if (!isConnected) {
228
383
  connect();
229
- }, 1000);
230
- };
231
-
232
- ws.onerror = function() {
233
- // Silencioso - sem logs
234
- };
384
+ }
385
+ }, finalInterval);
235
386
  }
236
387
 
388
+ // Detecta quando a página está sendo fechada para evitar reconexões desnecessárias
389
+ window.addEventListener('beforeunload', function() {
390
+ if (ws && ws.readyState === WebSocket.OPEN) {
391
+ ws.close(1000, 'Page unloading');
392
+ }
393
+ clearTimeout(reconnectTimer);
394
+ });
395
+
396
+ // Detecta quando a aba fica visível novamente para reconectar se necessário
397
+ document.addEventListener('visibilitychange', function() {
398
+ if (!document.hidden && !isConnected) {
399
+ reconnectAttempts = 0; // Reset do contador quando a aba fica ativa
400
+ connect();
401
+ }
402
+ });
403
+
237
404
  connect();
238
405
  }
239
406
  })();
@@ -242,48 +409,67 @@ export class HotReloadManager {
242
409
  }
243
410
 
244
411
  private clearBackendCache(filePath: string) {
245
- // Limpa o cache do require para forçar reload da rota de API
246
412
  const absolutePath = path.resolve(filePath);
247
413
  delete require.cache[absolutePath];
248
414
 
249
- // Também limpa dependências relacionadas
415
+ // Limpa dependências relacionadas de forma mais eficiente
416
+ const dirname = path.dirname(absolutePath);
250
417
  Object.keys(require.cache).forEach(key => {
251
- if (key.includes(path.dirname(absolutePath))) {
418
+ if (key.startsWith(dirname)) {
252
419
  delete require.cache[key];
253
420
  }
254
421
  });
255
422
  }
256
423
 
257
- // Método para registrar callback de mudança de API backend
258
424
  onBackendApiChange(callback: () => void) {
259
425
  this.backendApiChangeCallback = callback;
260
426
  }
261
427
 
262
- // Método para registrar callback de mudança de frontend
263
428
  onFrontendChange(callback: () => void) {
264
429
  this.frontendChangeCallback = callback;
265
430
  }
266
431
 
432
+ setHotReloadListener(listener: (file: string) => Promise<void> | void) {
433
+ this.customHotReloadListener = listener;
434
+ Console.info('🔌 Hot reload listener customizado registrado');
435
+ }
436
+
437
+ removeHotReloadListener() {
438
+ this.customHotReloadListener = null;
439
+ }
440
+
267
441
  private async checkFrontendBuild(filePath: string) {
268
- // Usa ts-node para checar erros de compilação do arquivo alterado
269
- const tsNodePath = require.resolve('ts-node');
270
- const { spawn } = require('child_process');
271
- return new Promise<{ error?: string }>((resolve) => {
272
- const proc = spawn(process.execPath, [tsNodePath, '--transpile-only', filePath], {
273
- cwd: this.projectDir,
274
- env: process.env,
275
- });
276
- let errorMsg = '';
277
- proc.stderr.on('data', (data: Buffer) => {
278
- errorMsg += data.toString();
279
- });
280
- proc.on('close', (code: number) => {
281
- if (code !== 0 && errorMsg) {
282
- resolve({ error: errorMsg });
283
- } else {
284
- resolve({});
285
- }
442
+ try {
443
+ const tsNodePath = require.resolve('ts-node');
444
+ const { spawn } = require('child_process');
445
+
446
+ return new Promise<{ error?: string }>((resolve) => {
447
+ const proc = spawn(process.execPath, [tsNodePath, '--transpile-only', filePath], {
448
+ cwd: this.projectDir,
449
+ env: process.env,
450
+ timeout: 10000 // Timeout de 10 segundos
451
+ });
452
+
453
+ let errorMsg = '';
454
+
455
+ proc.stderr.on('data', (data: Buffer) => {
456
+ errorMsg += data.toString();
457
+ });
458
+
459
+ proc.on('close', (code: number) => {
460
+ if (code !== 0 && errorMsg) {
461
+ resolve({ error: errorMsg });
462
+ } else {
463
+ resolve({});
464
+ }
465
+ });
466
+
467
+ proc.on('error', (error: Error) => {
468
+ resolve({ error: error.message });
469
+ });
286
470
  });
287
- });
471
+ } catch (error) {
472
+ return { error: `Erro ao verificar build: ${error}` };
473
+ }
288
474
  }
289
475
  }