@schalkneethling/miyagi-core 4.2.0 → 4.3.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.
@@ -4,9 +4,9 @@
4
4
  */
5
5
 
6
6
  import anymatch from "anymatch";
7
+ import chokidar from "chokidar";
7
8
  import fs from "fs";
8
9
  import path from "path";
9
- import watch from "node-watch";
10
10
  import { WebSocketServer } from "ws";
11
11
  import getConfig from "../config.js";
12
12
  import yargs from "./args.js";
@@ -19,64 +19,284 @@ import setEngines from "./engines.js";
19
19
  import setStatic from "./static.js";
20
20
  import setViews from "./views.js";
21
21
 
22
- let triggeredEvents = [];
23
- let timeout;
22
+ const SOCKET_PATH = "/__miyagi_ws";
23
+ const sockets = new Set();
24
+ const DEFAULT_RELOAD_MESSAGE = {
25
+ type: "reload",
26
+ scope: "iframe",
27
+ reason: "change",
28
+ paths: [],
29
+ };
30
+ const RELOAD_SCOPES = new Set(["none", "iframe", "parent"]);
31
+ let restartFileWatcher = null;
24
32
 
25
33
  /**
26
- * @param {boolean} [reload] - is true if the page should be reloaded
27
- * @param {boolean} [reloadParent] - is true if the parent window should be reloaded
34
+ * @param {string} eventType
35
+ * @returns {number}
28
36
  */
29
- function changeFileCallback(reload, reloadParent) {
30
- if (reload && global.config.ui.reload) {
31
- // ioInstance.emit("fileChanged", reloadParent);
32
- sockets.forEach((ws) => {
33
- ws.send(reloadParent ? "reloadParent" : "");
37
+ function getEventPriority(eventType) {
38
+ // Higher value = stronger event for the same path during burst coalescing.
39
+ // Example: if a file emits "change" then "unlink", we keep "unlink".
40
+ switch (eventType) {
41
+ case "unlinkDir":
42
+ return 5;
43
+ case "unlink":
44
+ return 4;
45
+ case "addDir":
46
+ return 3;
47
+ case "add":
48
+ return 2;
49
+ case "change":
50
+ return 1;
51
+ default:
52
+ return 0;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {string} inputPath
58
+ * @returns {string}
59
+ */
60
+ function normalizeRelativePath(inputPath) {
61
+ const absolutePath = path.resolve(inputPath);
62
+ return path.relative(process.cwd(), absolutePath);
63
+ }
64
+
65
+ /**
66
+ * @param {object} source
67
+ * @returns {string}
68
+ */
69
+ function resolveSourcePath(source) {
70
+ if (path.isAbsolute(source.path)) {
71
+ return source.path;
72
+ }
73
+
74
+ return path.resolve(process.cwd(), source.path);
75
+ }
76
+
77
+ /**
78
+ * @param {string} scope
79
+ * @param {object} [payload]
80
+ */
81
+ function sendReload(scope, payload = {}) {
82
+ const normalizedScope = RELOAD_SCOPES.has(scope) ? scope : "parent";
83
+ const reloadEnabled = global.config.watch?.reload?.enabled ?? global.config.ui.reload;
84
+
85
+ if (!reloadEnabled || normalizedScope === "none") {
86
+ return;
87
+ }
88
+
89
+ const message = JSON.stringify({
90
+ ...DEFAULT_RELOAD_MESSAGE,
91
+ ...payload,
92
+ scope: normalizedScope,
93
+ });
94
+
95
+ for (const ws of sockets) {
96
+ if (ws.readyState !== 1) {
97
+ sockets.delete(ws);
98
+ continue;
99
+ }
100
+
101
+ ws.send(message);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @param {string} color
107
+ * @param {string} value
108
+ * @param {boolean} useColors
109
+ * @returns {string}
110
+ */
111
+ function colorize(color, value, useColors) {
112
+ if (!useColors) {
113
+ return value;
114
+ }
115
+
116
+ const colors = {
117
+ cyan: "\x1b[36m",
118
+ green: "\x1b[32m",
119
+ yellow: "\x1b[33m",
120
+ grey: "\x1b[90m",
121
+ reset: "\x1b[0m",
122
+ };
123
+
124
+ return `${colors[color] || ""}${value}${colors.reset}`;
125
+ }
126
+
127
+ /**
128
+ * @param {object} report
129
+ * @param {object} watchConfig
130
+ */
131
+ function printWatchReport(report, watchConfig) {
132
+ const reportConfig = watchConfig?.report || {};
133
+ if (!reportConfig.enabled || !reportConfig.onStart) {
134
+ return;
135
+ }
136
+
137
+ if (reportConfig.format === "json") {
138
+ console.info(JSON.stringify(report));
139
+ return;
140
+ }
141
+
142
+ const useColors = reportConfig.useColors && process.stdout.isTTY;
143
+
144
+ if (reportConfig.format === "summary") {
145
+ console.info(
146
+ `${colorize("cyan", "Watch report:", useColors)} backend=${report.backend
147
+ } sources=${report.meta.sourceCount} ignores=${report.meta.ignoreCount}`,
148
+ );
149
+ return;
150
+ }
151
+
152
+ console.info(colorize("cyan", "\nWatch report", useColors));
153
+ console.info(
154
+ ` ${colorize("grey", "Watcher backend:", useColors)} ${colorize(
155
+ "green",
156
+ report.backend,
157
+ useColors,
158
+ )}`,
159
+ );
160
+ console.info(
161
+ ` ${colorize("grey", "Diagnostics:", useColors)} sources=${report.meta.sourceCount
162
+ }, ignores=${report.meta.ignoreCount}`,
163
+ );
164
+ console.info(` ${colorize("grey", "Resolved sources:", useColors)}`);
165
+
166
+ for (const source of report.sources) {
167
+ const stateLabel = source.exists ? "exists" : "missing";
168
+ const stateColor = source.exists ? "green" : "yellow";
169
+ console.info(
170
+ ` - ${source.id} (${source.type}) ${source.resolvedPath} [${colorize(
171
+ stateColor,
172
+ stateLabel,
173
+ useColors,
174
+ )}]`,
175
+ );
176
+ }
177
+
178
+ console.info(` ${colorize("grey", "Ignored patterns:", useColors)}`);
179
+ for (const pattern of report.ignore.patterns) {
180
+ console.info(` - ${pattern}`);
181
+ }
182
+
183
+ console.info(` ${colorize("grey", "Reload rules:", useColors)}`);
184
+ for (const [key, scope] of Object.entries(report.reload.rules)) {
185
+ console.info(` - ${key}: ${scope}`);
186
+ }
187
+ console.info("");
188
+ }
189
+
190
+ /**
191
+ * @param {object} watchConfig
192
+ * @returns {object[]}
193
+ */
194
+ function getExtensionSources(watchConfig) {
195
+ const extensionSources = [];
196
+
197
+ for (const extension of global.config.extensions) {
198
+ const ext = Array.isArray(extension) ? extension[0] : extension;
199
+ const opts =
200
+ Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
201
+
202
+ if (!ext.extendWatcher) {
203
+ continue;
204
+ }
205
+
206
+ const extensionWatch = ext.extendWatcher(opts);
207
+ if (!extensionWatch?.folder || !extensionWatch?.lang) {
208
+ continue;
209
+ }
210
+
211
+ extensionSources.push({
212
+ id: `extension-${extensionWatch.lang}`,
213
+ type: "dir",
214
+ path: path.join(extensionWatch.folder, extensionWatch.lang),
215
+ recursive: true,
216
+ optional: true,
34
217
  });
35
218
  }
36
219
 
37
- triggeredEvents = [];
38
- log("success", `${t("updatingDone")}\n`);
220
+ return [...(watchConfig.sources || []), ...extensionSources];
39
221
  }
40
222
 
41
223
  /**
42
- * @param {Array} triggered - the triggered events
43
- * @param {Array} events - array of events to check against
44
- * @returns {boolean} is true if the triggered events include the events to check against
224
+ * @param {object} watchConfig
225
+ * @returns {object}
45
226
  */
46
- function triggeredEventsIncludes(triggered, events) {
47
- const flattened = triggered.map((event) => event.event);
227
+ function resolveWatchTargets(watchConfig) {
228
+ const sources = getExtensionSources(watchConfig);
229
+ const ignorePatterns = [
230
+ ...(watchConfig.ignore?.defaults ? ["node_modules/**", ".git/**"] : []),
231
+ ...(watchConfig.ignore?.patterns || []),
232
+ ];
233
+ const reportSources = [];
234
+ const targets = [];
235
+
236
+ for (const source of sources) {
237
+ if (!source || typeof source.path !== "string") continue;
238
+ const resolvedPath = resolveSourcePath(source);
239
+ const exists = fs.existsSync(resolvedPath);
240
+
241
+ reportSources.push({
242
+ id: source.id,
243
+ type: source.type,
244
+ inputPath: source.path,
245
+ resolvedPath,
246
+ exists,
247
+ recursive: source.recursive !== false,
248
+ optional: source.optional === true,
249
+ ignored: anymatch(ignorePatterns, normalizeRelativePath(resolvedPath)),
250
+ });
48
251
 
49
- for (let i = 0; i < flattened.length; i += 1) {
50
- if (events.includes(flattened[i])) {
51
- return true;
252
+ if (exists) {
253
+ targets.push(resolvedPath);
52
254
  }
53
255
  }
54
256
 
55
- return false;
257
+ return {
258
+ targets: [...new Set(targets)].sort(),
259
+ ignorePatterns,
260
+ report: {
261
+ backend: "chokidar",
262
+ sources: reportSources.sort((a, b) => a.id.localeCompare(b.id)),
263
+ ignore: {
264
+ defaultsEnabled: Boolean(watchConfig.ignore?.defaults),
265
+ patterns: [...new Set(ignorePatterns)].sort(),
266
+ },
267
+ reload: {
268
+ rules: watchConfig.reload.rules,
269
+ },
270
+ meta: {
271
+ sourceCount: reportSources.length,
272
+ ignoreCount: [...new Set(ignorePatterns)].length,
273
+ },
274
+ },
275
+ };
56
276
  }
57
277
 
58
278
  /**
59
- * @param {object[]} events - array of event objects
60
- * @returns {Promise<object>} the updated state.fileContents object
279
+ * @param {Array<{ eventType: string, changedPath: string, relativePath: string }>} events
280
+ * @returns {Promise<object>}
61
281
  */
62
282
  async function updateFileContents(events) {
63
283
  const data = helpers.cloneDeep(global.state.fileContents);
64
284
 
65
285
  try {
66
286
  await Promise.all(
67
- events.map(async ({ changedPath }) => {
68
- const fullPath = path.join(process.cwd(), changedPath);
287
+ events.map(async ({ changedPath, relativePath }) => {
288
+ const fullPath = path.resolve(changedPath);
69
289
 
70
290
  if (
71
- fs.existsSync(changedPath) &&
72
- fs.lstatSync(changedPath).isFile() &&
73
- (helpers.fileIsTemplateFile(changedPath) ||
74
- helpers.fileIsDataFile(changedPath) ||
75
- helpers.fileIsDocumentationFile(changedPath) ||
76
- helpers.fileIsSchemaFile(changedPath))
291
+ fs.existsSync(fullPath) &&
292
+ fs.lstatSync(fullPath).isFile() &&
293
+ (helpers.fileIsTemplateFile(relativePath) ||
294
+ helpers.fileIsDataFile(relativePath) ||
295
+ helpers.fileIsDocumentationFile(relativePath) ||
296
+ helpers.fileIsSchemaFile(relativePath))
77
297
  ) {
78
298
  try {
79
- const result = await readFile(changedPath);
299
+ const result = await readFile(fullPath);
80
300
  data[fullPath] = result;
81
301
  return Promise.resolve();
82
302
  } catch (err) {
@@ -96,9 +316,18 @@ async function updateFileContents(events) {
96
316
  }
97
317
 
98
318
  /**
99
- *
319
+ * @param {string} changedPath
320
+ * @returns {boolean}
321
+ */
322
+ function pathExistsAsDirectory(changedPath) {
323
+ return fs.existsSync(changedPath) && fs.lstatSync(changedPath).isDirectory();
324
+ }
325
+
326
+ /**
327
+ * @param {Array<{ eventType: string, changedPath: string, relativePath: string }>} events
328
+ * @returns {Promise<void>}
100
329
  */
101
- async function handleFileChange() {
330
+ async function handleFileChange(events) {
102
331
  for (const extension of global.config.extensions) {
103
332
  const ext = Array.isArray(extension) ? extension[0] : extension;
104
333
  const opts =
@@ -109,12 +338,58 @@ async function handleFileChange() {
109
338
  }
110
339
  }
111
340
 
112
- // a directory has been changed
341
+ const watchRules = global.config.watch.reload.rules;
342
+ const templateEvents = events.filter(({ relativePath }) =>
343
+ helpers.fileIsTemplateFile(relativePath),
344
+ );
345
+ const dataEvents = events.filter(({ relativePath }) =>
346
+ helpers.fileIsDataFile(relativePath),
347
+ );
348
+ const docEvents = events.filter(({ relativePath }) =>
349
+ helpers.fileIsDocumentationFile(relativePath),
350
+ );
351
+ const schemaEvents = events.filter(({ relativePath }) =>
352
+ helpers.fileIsSchemaFile(relativePath),
353
+ );
354
+ const componentAssetEvents = events.filter(({ relativePath }) =>
355
+ helpers.fileIsAssetFile(relativePath),
356
+ );
357
+ const cssEvents = events.filter(({ relativePath }) => relativePath.endsWith(".css"));
358
+ const jsEvents = events.filter(({ relativePath }) => relativePath.endsWith(".js"));
359
+ const hasRemoveEvents = events.some(({ eventType }) =>
360
+ ["unlink", "unlinkDir"].includes(eventType),
361
+ );
362
+ const hasDirectoryEvents = events.some(
363
+ ({ changedPath, eventType }) =>
364
+ ["addDir", "unlinkDir"].includes(eventType) || pathExistsAsDirectory(changedPath),
365
+ );
366
+
367
+ const configFilePath = global.config.userFileName
368
+ ? path.resolve(process.cwd(), global.config.userFileName)
369
+ : null;
370
+ const hasConfigFileEvent =
371
+ configFilePath &&
372
+ events.some(({ changedPath }) => path.resolve(changedPath) === configFilePath);
373
+
374
+ if (hasConfigFileEvent && global.config.watch.configFile.enabled) {
375
+ await configurationFileUpdated();
376
+ sendReload("parent", {
377
+ reason: "config",
378
+ paths: [global.config.userFileName],
379
+ });
380
+ return;
381
+ }
382
+
113
383
  if (
114
- triggeredEvents.some(
115
- ({ changedPath }) =>
116
- fs.existsSync(changedPath) && fs.lstatSync(changedPath).isDirectory(),
117
- )
384
+ hasDirectoryEvents &&
385
+ !hasRemoveEvents &&
386
+ templateEvents.length === 0 &&
387
+ dataEvents.length === 0 &&
388
+ docEvents.length === 0 &&
389
+ schemaEvents.length === 0 &&
390
+ componentAssetEvents.length === 0 &&
391
+ cssEvents.length === 0 &&
392
+ jsEvents.length === 0
118
393
  ) {
119
394
  await setState({
120
395
  sourceTree: true,
@@ -123,117 +398,135 @@ async function handleFileChange() {
123
398
  partials: true,
124
399
  });
125
400
 
126
- changeFileCallback(true, true);
401
+ sendReload(watchRules.unknown, {
402
+ reason: "directory",
403
+ paths: events.map(({ relativePath }) => relativePath),
404
+ });
405
+ log("success", `${t("updatingDone")}\n`);
406
+ return;
127
407
  }
128
408
 
129
- // removed a directory or file
130
- else if (triggeredEventsIncludes(triggeredEvents, ["remove"])) {
409
+ if (hasRemoveEvents) {
131
410
  await setState({
132
411
  sourceTree: true,
133
- fileContents: await updateFileContents(triggeredEvents),
412
+ fileContents: await updateFileContents(events),
134
413
  menu: true,
135
414
  partials: true,
136
415
  });
137
416
 
138
- changeFileCallback(true, true);
139
- // updated file is a template file
140
- } else if (
141
- triggeredEvents.filter((event) =>
142
- helpers.fileIsTemplateFile(event.changedPath),
143
- ).length > 0
144
- ) {
145
- if (
146
- Object.keys(global.state.partials).includes(
147
- triggeredEvents[0].changedPath.replace(
148
- path.join(global.config.components.folder, "/"),
149
- "",
150
- ),
151
- )
152
- ) {
153
- // updated
417
+ sendReload("parent", {
418
+ reason: "remove",
419
+ paths: events.map(({ relativePath }) => relativePath),
420
+ });
421
+ log("success", `${t("updatingDone")}\n`);
422
+ return;
423
+ }
424
+
425
+ if (templateEvents.length > 0) {
426
+ const allTemplatePathsExistAsPartials = templateEvents.every(({ relativePath }) => {
427
+ const shortPath = relativePath.replace(
428
+ path.join(global.config.components.folder, "/"),
429
+ "",
430
+ );
431
+ return Object.keys(global.state.partials).includes(shortPath);
432
+ });
433
+
434
+ if (allTemplatePathsExistAsPartials) {
154
435
  await setState({
155
- fileContents: await updateFileContents(triggeredEvents),
436
+ fileContents: await updateFileContents(templateEvents),
156
437
  });
157
438
 
158
- changeFileCallback(true, false);
439
+ sendReload(watchRules.template, {
440
+ reason: "template",
441
+ paths: templateEvents.map(({ relativePath }) => relativePath),
442
+ });
159
443
  } else {
160
- // added
161
444
  await setState({
162
- fileContents: await updateFileContents(triggeredEvents),
445
+ fileContents: await updateFileContents(templateEvents),
163
446
  sourceTree: true,
164
447
  menu: true,
165
448
  partials: true,
166
449
  });
167
450
 
168
- changeFileCallback(true, true);
451
+ sendReload("parent", {
452
+ reason: "template-added",
453
+ paths: templateEvents.map(({ relativePath }) => relativePath),
454
+ });
169
455
  }
170
- // updated file is a mock file
171
- } else if (
172
- triggeredEvents.some(({ changedPath }) =>
173
- helpers.fileIsDataFile(changedPath),
174
- )
175
- ) {
176
- const hasBeenAdded = !Object.keys(global.state.fileContents).includes(
177
- path.join(process.cwd(), triggeredEvents[0].changedPath),
456
+
457
+ log("success", `${t("updatingDone")}\n`);
458
+ return;
459
+ }
460
+
461
+ if (dataEvents.length > 0) {
462
+ const hasBeenAdded = dataEvents.some(
463
+ ({ eventType, changedPath }) =>
464
+ eventType === "add" ||
465
+ !Object.keys(global.state.fileContents).includes(path.resolve(changedPath)),
178
466
  );
179
467
 
180
468
  await setState({
181
- fileContents: await updateFileContents(triggeredEvents),
469
+ fileContents: await updateFileContents(dataEvents),
182
470
  sourceTree: hasBeenAdded,
183
471
  menu: true,
184
472
  });
185
473
 
186
- changeFileCallback(true, true);
187
- // updated file is a doc file
188
- } else if (
189
- triggeredEvents.some(({ changedPath }) =>
190
- helpers.fileIsDocumentationFile(changedPath),
191
- )
192
- ) {
193
- const hasBeenAdded = !Object.keys(global.state.fileContents).includes(
194
- path.join(process.cwd(), triggeredEvents[0].changedPath),
474
+ sendReload(watchRules.data, {
475
+ reason: hasBeenAdded ? "data-added" : "data",
476
+ paths: dataEvents.map(({ relativePath }) => relativePath),
477
+ });
478
+ log("success", `${t("updatingDone")}\n`);
479
+ return;
480
+ }
481
+
482
+ if (docEvents.length > 0) {
483
+ const hasBeenAdded = docEvents.some(
484
+ ({ eventType, changedPath }) =>
485
+ eventType === "add" ||
486
+ !Object.keys(global.state.fileContents).includes(path.resolve(changedPath)),
195
487
  );
196
488
 
197
489
  await setState({
198
- fileContents: await updateFileContents(triggeredEvents),
490
+ fileContents: await updateFileContents(docEvents),
199
491
  sourceTree: hasBeenAdded,
200
492
  menu: hasBeenAdded,
201
493
  });
202
494
 
203
- changeFileCallback(true, hasBeenAdded);
204
- // updated file is a schema file
205
- } else if (
206
- triggeredEvents.some(({ changedPath }) =>
207
- helpers.fileIsSchemaFile(changedPath),
208
- )
209
- ) {
495
+ sendReload(watchRules.docs, {
496
+ reason: hasBeenAdded ? "docs-added" : "docs",
497
+ paths: docEvents.map(({ relativePath }) => relativePath),
498
+ });
499
+ log("success", `${t("updatingDone")}\n`);
500
+ return;
501
+ }
502
+
503
+ if (schemaEvents.length > 0) {
210
504
  await setState({
211
- fileContents: await updateFileContents(triggeredEvents),
505
+ fileContents: await updateFileContents(schemaEvents),
212
506
  });
213
507
 
214
- changeFileCallback(true, false);
215
- // updated file is an asset file
216
- } else if (
217
- triggeredEvents.some(({ changedPath }) =>
218
- helpers.fileIsAssetFile(changedPath),
219
- )
220
- ) {
221
- if (global.config.ui.reloadAfterChanges.componentAssets) {
222
- changeFileCallback(true, false);
223
- }
224
- // updated file is a css file
225
- } else if (
226
- triggeredEvents.find(({ changedPath }) => {
227
- return changedPath.endsWith(".css");
228
- })
229
- ) {
230
- // updated file contains custom properties for the styleguide
508
+ sendReload(watchRules.schema, {
509
+ reason: "schema",
510
+ paths: schemaEvents.map(({ relativePath }) => relativePath),
511
+ });
512
+ log("success", `${t("updatingDone")}\n`);
513
+ return;
514
+ }
515
+
516
+ if (componentAssetEvents.length > 0) {
517
+ sendReload(watchRules.componentAsset, {
518
+ reason: "component-asset",
519
+ paths: componentAssetEvents.map(({ relativePath }) => relativePath),
520
+ });
521
+ log("success", `${t("updatingDone")}\n`);
522
+ return;
523
+ }
524
+
525
+ if (cssEvents.length > 0) {
231
526
  if (
232
- triggeredEvents.find(({ changedPath }) => {
233
- return global.config.assets.customProperties.files.includes(
234
- changedPath,
235
- );
236
- })
527
+ cssEvents.some(({ relativePath }) =>
528
+ global.config.assets.customProperties.files.includes(relativePath),
529
+ )
237
530
  ) {
238
531
  await setState({
239
532
  css: true,
@@ -244,156 +537,231 @@ async function handleFileChange() {
244
537
  });
245
538
  }
246
539
 
247
- if (global.config.ui.reloadAfterChanges.componentAssets) {
248
- changeFileCallback(true, false);
249
- }
250
- // updated file is a js file
251
- } else if (
252
- triggeredEvents.find(({ changedPath }) => {
253
- return changedPath.endsWith(".js");
254
- })
255
- ) {
256
- await setState({
257
- menu: true,
540
+ sendReload(watchRules.globalCss, {
541
+ reason: "css",
542
+ paths: cssEvents.map(({ relativePath }) => relativePath),
258
543
  });
544
+ log("success", `${t("updatingDone")}\n`);
545
+ return;
546
+ }
259
547
 
260
- if (global.config.ui.reloadAfterChanges.componentAssets) {
261
- changeFileCallback(true, false);
262
- }
263
- } else {
548
+ if (jsEvents.length > 0) {
264
549
  await setState({
265
- sourceTree: true,
266
- fileContents: true,
267
550
  menu: true,
268
- partials: true,
269
551
  });
270
552
 
271
- changeFileCallback(true, true);
553
+ sendReload(watchRules.globalJs, {
554
+ reason: "js",
555
+ paths: jsEvents.map(({ relativePath }) => relativePath),
556
+ });
557
+ log("success", `${t("updatingDone")}\n`);
558
+ return;
272
559
  }
560
+
561
+ await setState({
562
+ sourceTree: true,
563
+ fileContents: true,
564
+ menu: true,
565
+ partials: true,
566
+ });
567
+
568
+ sendReload(watchRules.unknown, {
569
+ reason: "unknown",
570
+ paths: events.map(({ relativePath }) => relativePath),
571
+ });
572
+ log("success", `${t("updatingDone")}\n`);
273
573
  }
274
574
 
275
- const sockets = [];
575
+ /**
576
+ * @param {object} pendingByPath
577
+ * @returns {Array<{ eventType: string, changedPath: string, relativePath: string }>}
578
+ */
579
+ function snapshotPendingEvents(pendingByPath) {
580
+ return [...pendingByPath.values()]
581
+ // Process highest-priority events first to keep structural changes deterministic.
582
+ .sort((a, b) => getEventPriority(b.eventType) - getEventPriority(a.eventType))
583
+ .map((entry) => ({
584
+ eventType: entry.eventType,
585
+ changedPath: entry.changedPath,
586
+ relativePath: normalizeRelativePath(entry.changedPath),
587
+ }));
588
+ }
276
589
 
277
590
  /**
278
591
  * @param {object} server
279
592
  */
280
593
  export default function Watcher(server) {
281
594
  const wss = new WebSocketServer({ noServer: true });
595
+ let watcher;
596
+ let debounceTimer = null;
597
+ let isProcessing = false;
598
+ const pendingByPath = new Map();
599
+
600
+ const startHeartbeat = () => {
601
+ const heartbeatConfig = global.config.watch?.socket?.heartbeat;
602
+ if (!heartbeatConfig?.enabled) {
603
+ return null;
604
+ }
282
605
 
283
- wss.on("connection", function open(ws) {
284
- sockets.push(ws);
285
- });
606
+ return setInterval(() => {
607
+ for (const ws of sockets) {
608
+ if (ws.readyState !== 1) {
609
+ sockets.delete(ws);
610
+ continue;
611
+ }
286
612
 
287
- server.on("upgrade", (request, socket, head) => {
288
- wss.handleUpgrade(request, socket, head, (ws) => {
289
- wss.emit("connection", ws, request);
290
- });
291
- });
613
+ ws.ping();
614
+ }
615
+ }, heartbeatConfig.intervalMs);
616
+ };
292
617
 
293
- const { components, docs, assets, extensions } = global.config;
294
-
295
- const sharedCssToWatch = (assets.shared?.css || [])
296
- .filter(
297
- (f) =>
298
- !f.startsWith("http://") &&
299
- !f.startsWith("https://") &&
300
- !f.startsWith("://"),
301
- )
302
- .map((f) => path.join(global.config.assets.root, f));
303
-
304
- const sharedJsToWatch = (assets.shared?.js || [])
305
- .map((file) => file.src || file)
306
- .filter(
307
- (f) =>
308
- !f.startsWith("http://") &&
309
- !f.startsWith("https://") &&
310
- !f.startsWith("://"),
311
- )
312
- .map((f) => path.join(global.config.assets.root, f));
313
-
314
- const foldersToWatch = [
315
- ...assets.folder.map((f) => path.join(global.config.assets.root, f)),
316
- ...assets.css
317
- .filter(
318
- (f) =>
319
- !f.startsWith("http://") &&
320
- !f.startsWith("https://") &&
321
- !f.startsWith("://"),
322
- )
323
- .map((f) => path.join(global.config.assets.root, f)),
324
- ...assets.js
325
- .map((file) => file.src)
326
- .filter(
327
- (f) =>
328
- !f.startsWith("http://") &&
329
- !f.startsWith("https://") &&
330
- !f.startsWith("://"),
331
- )
332
- .map((f) => path.join(global.config.assets.root, f)),
333
- ...sharedCssToWatch,
334
- ...sharedJsToWatch,
335
- ];
618
+ const heartbeatInterval = startHeartbeat();
336
619
 
337
- if (components.folder) {
338
- foldersToWatch.push(components.folder);
339
- }
620
+ const processPendingEvents = async () => {
621
+ if (isProcessing) {
622
+ return;
623
+ }
340
624
 
341
- if (docs?.folder && fs.existsSync(docs.folder)) {
342
- foldersToWatch.push(docs.folder);
343
- }
625
+ if (pendingByPath.size === 0) {
626
+ return;
627
+ }
344
628
 
345
- for (const extension of extensions) {
346
- const ext = Array.isArray(extension) ? extension[0] : extension;
347
- const opts =
348
- Array.isArray(extension) && extension[1] ? extension[1] : { locales: {} };
629
+ isProcessing = true;
630
+ try {
631
+ const events = snapshotPendingEvents(pendingByPath);
632
+ pendingByPath.clear();
633
+ await handleFileChange(events);
634
+ } catch (error) {
635
+ log("error", "Error while processing file changes.", error);
636
+ } finally {
637
+ isProcessing = false;
638
+ if (pendingByPath.size > 0) {
639
+ void processPendingEvents();
640
+ }
641
+ }
642
+ };
643
+
644
+ const enqueueEvent = (eventType, changedPath) => {
645
+ const absolutePath = path.resolve(changedPath);
646
+ const existing = pendingByPath.get(absolutePath);
647
+ const shouldReplace =
648
+ !existing ||
649
+ getEventPriority(eventType) >= getEventPriority(existing.eventType);
650
+
651
+ if (shouldReplace) {
652
+ // Keep only the strongest event per path inside the coalescing window.
653
+ pendingByPath.set(absolutePath, {
654
+ eventType,
655
+ changedPath: absolutePath,
656
+ });
657
+ }
658
+
659
+ const watchBehavior = global.config.watch?.behavior || {};
660
+ const delay = Math.max(
661
+ watchBehavior.debounceMs || 0,
662
+ watchBehavior.coalesceWindowMs || 0,
663
+ );
349
664
 
350
- if (ext.extendWatcher) {
351
- const watch = ext.extendWatcher(opts);
352
- foldersToWatch.push(path.join(watch.folder, watch.lang));
665
+ if (debounceTimer) {
666
+ clearTimeout(debounceTimer);
353
667
  }
354
- }
355
668
 
356
- if (global.config.userFileName && global.config.ui.watchConfigFile) {
357
- fs.watch(global.config.userFileName, async (eventType) => {
358
- if (eventType === "change") {
359
- configurationFileUpdated();
669
+ debounceTimer = setTimeout(() => {
670
+ debounceTimer = null;
671
+ void processPendingEvents();
672
+ }, delay);
673
+ };
674
+
675
+ const setupFileWatcher = () => {
676
+ const watchConfig = global.config.watch || {};
677
+ if (watchConfig.enabled === false) {
678
+ return;
679
+ }
680
+
681
+ if (watcher) {
682
+ void watcher.close();
683
+ }
684
+
685
+ const { targets, ignorePatterns, report } = resolveWatchTargets(watchConfig);
686
+ const awaitWriteFinish = watchConfig.behavior?.awaitWriteFinish || {};
687
+
688
+ printWatchReport(report, watchConfig);
689
+
690
+ if (targets.length === 0) {
691
+ log("error", t("watchingFilesFailed"));
692
+ return;
693
+ }
694
+
695
+ watcher = chokidar.watch(targets, {
696
+ ignoreInitial: true,
697
+ persistent: true,
698
+ ignored(changedPath) {
699
+ const relativePath = normalizeRelativePath(changedPath);
700
+ return anymatch(ignorePatterns, relativePath);
701
+ },
702
+ awaitWriteFinish: awaitWriteFinish.enabled
703
+ ? {
704
+ stabilityThreshold: awaitWriteFinish.stabilityThresholdMs,
705
+ pollInterval: awaitWriteFinish.pollIntervalMs,
706
+ }
707
+ : false,
708
+ });
709
+
710
+ watcher.on("all", (eventType, changedPath) => {
711
+ const watchDebug = global.config.watch?.debug || {};
712
+ if (watchDebug.logEvents) {
713
+ log("info", `watch:event=${eventType} path=${changedPath}`);
360
714
  }
715
+
716
+ log("info", t("updatingStarted"));
717
+ enqueueEvent(eventType, changedPath);
361
718
  });
362
- }
363
719
 
364
- let watcher;
720
+ watcher.on("error", (error) => {
721
+ log("error", t("watchingFilesFailed"), error);
722
+ });
723
+ };
724
+ restartFileWatcher = setupFileWatcher;
365
725
 
366
- try {
367
- watcher = watch(
368
- foldersToWatch.filter((folder) => fs.existsSync(folder)),
369
- {
370
- recursive: true,
371
- filter(f, skip) {
372
- if (anymatch(components.ignores, f)) return skip;
373
- return true;
374
- },
375
- },
726
+ wss.on("connection", function open(ws) {
727
+ sockets.add(ws);
728
+
729
+ ws.on("close", () => {
730
+ sockets.delete(ws);
731
+ });
732
+
733
+ ws.on("error", () => {
734
+ sockets.delete(ws);
735
+ });
736
+ });
737
+
738
+ server.on("upgrade", (request, socket, head) => {
739
+ const requestUrl = new URL(
740
+ request.url || "/",
741
+ `http://${request.headers.host || "localhost"}`,
376
742
  );
377
- } catch (e) {
378
- log("error", e);
379
- }
380
743
 
381
- if (watcher) {
382
- watcher.on("change", (event, changedPath) => {
383
- triggeredEvents.push({ event, changedPath });
384
-
385
- if (!timeout) {
386
- console.clear();
387
- log("info", t("updatingStarted"));
388
- timeout = setTimeout(() => {
389
- timeout = null;
390
- handleFileChange();
391
- }, 10);
392
- }
744
+ if (requestUrl.pathname !== SOCKET_PATH) {
745
+ socket.destroy();
746
+ return;
747
+ }
748
+
749
+ wss.handleUpgrade(request, socket, head, (ws) => {
750
+ wss.emit("connection", ws, request);
393
751
  });
394
- } else {
395
- log("error", t("watchingFilesFailed"));
396
- }
752
+ });
753
+
754
+ setupFileWatcher();
755
+
756
+ server.on("close", () => {
757
+ if (heartbeatInterval) {
758
+ clearInterval(heartbeatInterval);
759
+ }
760
+
761
+ if (watcher) {
762
+ void watcher.close();
763
+ }
764
+ });
397
765
  }
398
766
 
399
767
  /**
@@ -406,18 +774,21 @@ async function configurationFileUpdated() {
406
774
 
407
775
  if (config) {
408
776
  global.config = config;
777
+ if (restartFileWatcher) {
778
+ restartFileWatcher();
779
+ }
409
780
  await setEngines();
410
781
  await setState({
411
782
  sourceTree: true,
783
+ fileContents: true,
412
784
  menu: true,
413
785
  partials: true,
414
- fileContents: true,
415
786
  css: true,
416
787
  });
788
+
417
789
  setStatic();
418
790
  setViews();
419
791
 
420
792
  log("success", `${t("updatingConfigurationDone")}\n`);
421
- changeFileCallback(true, true);
422
793
  }
423
794
  }