@schalkneethling/miyagi-core 4.2.0 → 4.3.1

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