adminforth 3.24.6 β†’ 3.25.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.
@@ -14,6 +14,13 @@ declare class CodeInjector implements ICodeInjector {
14
14
  };
15
15
  devServerPort: number;
16
16
  spaTmpPath(): string;
17
+ /**
18
+ * spa_tmp is named after the brand slug only, so every run of the same project shares one folder.
19
+ * We record who owns it next to it (not inside, so it stays out of the sources hash) purely to make
20
+ * a second process visible: two of them copy over each other's files, which surfaces later as a
21
+ * random fs error in an unrelated place. Investigation notes are in AdminForth/1519.
22
+ */
23
+ private claimSpaTmp;
17
24
  checkIconNames(icons: string[]): Promise<void>;
18
25
  registerCustomComponent(filePath: string): void;
19
26
  collectTailwindSafelist(): string[];
@@ -48,6 +55,18 @@ declare class CodeInjector implements ICodeInjector {
48
55
  migrateLegacyCustomLayout(oldMeta: any): Promise<any>;
49
56
  prepareSources(): Promise<void>;
50
57
  watchForReprepare({}: {}): Promise<void>;
58
+ private fileChangeChains;
59
+ /**
60
+ * Runs `task` for a changed watched file, serialized per path and fully guarded.
61
+ *
62
+ * Both guards matter. A watcher event only says that something happened, not that the file is still
63
+ * there - a rename, a git checkout or an editor writing through a temp file is enough for it to be
64
+ * gone by the time we act on it. And any throw here would land in a listener whose promise nobody
65
+ * awaits, i.e. an unhandled rejection, which node kills the process for.
66
+ */
67
+ private onWatchedFileChange;
68
+ private onWatchedFileRemove;
69
+ private queuePerPath;
51
70
  watchCustomComponentsForCopy({ customComponentsDir, destination, targetRoot }: {
52
71
  customComponentsDir: string;
53
72
  destination: string;
@@ -1 +1 @@
1
- {"version":3,"file":"codeInjector.d.ts","sourceRoot":"","sources":["../../modules/codeInjector.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"codeInjector.d.ts","sourceRoot":"","sources":["../../modules/codeInjector.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,9 @@
1
1
  import { exec, spawn } from 'child_process';
2
- import filewatcher from 'filewatcher'; // todo - replace this legacy (>8y old module) with chokidar
2
+ import chokidar from 'chokidar';
3
3
  import fs from 'fs';
4
- import fsExtra from 'fs-extra';
5
4
  import os from 'os';
6
5
  import path from 'path';
6
+ import pLimit from 'p-limit';
7
7
  import { promisify } from 'util';
8
8
  import yaml from 'yaml';
9
9
  import { ADMIN_FORTH_ABSOLUTE_PATH, getComponentNameFromPath, md5hash } from './utils.js';
@@ -26,6 +26,100 @@ function stripAnsiCodes(str) {
26
26
  // Regular expression to match ANSI escape codes
27
27
  return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
28
28
  }
29
+ // how many files we copy into spa_tmp at the same time, to not exhaust file descriptors on big trees.
30
+ // One limiter for the whole module, so several copyTreeAtomic calls in a row share the same budget
31
+ // instead of each opening its own 32.
32
+ const copyLimit = pLimit(32);
33
+ let atomicCopySeq = 0;
34
+ const ATOMIC_COPY_TMP_RE = /\.af-(\d+)-\d+\.tmp$/;
35
+ function isAtomicCopyTempName(name) {
36
+ return ATOMIC_COPY_TMP_RE.test(name);
37
+ }
38
+ // the pid baked into a copyFileAtomic temp name, so we can tell our own leftovers from someone else's
39
+ function atomicCopyTempPid(name) {
40
+ const match = name.match(ATOMIC_COPY_TMP_RE);
41
+ return match ? parseInt(match[1], 10) : null;
42
+ }
43
+ function isProcessAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ }
48
+ catch (e) {
49
+ return (e === null || e === void 0 ? void 0 : e.code) === 'EPERM'; // exists but is not ours to signal
50
+ }
51
+ }
52
+ /**
53
+ * Copies a single file to dest atomically: the content goes to a unique temp file next to dest
54
+ * and is then renamed over it. rename(2) replaces the destination in one step (and MoveFileEx with
55
+ * MOVEFILE_REPLACE_EXISTING does the same on Windows), so dest is never missing or half-written.
56
+ *
57
+ * fsExtra.copy() cannot be used for this: for an existing destination it does
58
+ * unlink(dest) -> copyFile(src, dest) -> chmod(dest, mode) as three separate syscalls. Anything that
59
+ * writes the same destination in between - a second hot reload event, or another process bundling into
60
+ * the same spa_tmp - makes it fail with `ENOENT: ... unlink` or `ENOENT: ... chmod`, and readers
61
+ * (vite, computeSourcesHash) can observe a missing file.
62
+ */
63
+ async function copyFileAtomic(src, dest, mode) {
64
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
65
+ const tmp = `${dest}.af-${process.pid}-${atomicCopySeq++}.tmp`;
66
+ try {
67
+ await fs.promises.copyFile(src, tmp);
68
+ if (mode !== undefined) {
69
+ // safe to chmod before the rename: tmp is private to this call, nobody else can see it
70
+ await fs.promises.chmod(tmp, mode);
71
+ }
72
+ await renameWithRetry(tmp, dest);
73
+ }
74
+ catch (e) {
75
+ await fs.promises.rm(tmp, { force: true }).catch(() => { });
76
+ throw e;
77
+ }
78
+ }
79
+ /**
80
+ * On Windows a rename over an open destination fails with EPERM/EACCES/EBUSY until the other holder
81
+ * (vite, an editor, an antivirus scanner) lets go, so give it a few short tries before giving up.
82
+ * On POSIX the first attempt always succeeds.
83
+ */
84
+ async function renameWithRetry(from, to, attempts = 5) {
85
+ for (let attempt = 1;; attempt++) {
86
+ try {
87
+ await fs.promises.rename(from, to);
88
+ return;
89
+ }
90
+ catch (e) {
91
+ const retriable = (e === null || e === void 0 ? void 0 : e.code) === 'EPERM' || (e === null || e === void 0 ? void 0 : e.code) === 'EACCES' || (e === null || e === void 0 ? void 0 : e.code) === 'EBUSY';
92
+ if (!retriable || attempt >= attempts) {
93
+ throw e;
94
+ }
95
+ await new Promise((resolve) => setTimeout(resolve, 10 * attempt));
96
+ }
97
+ }
98
+ }
99
+ /**
100
+ * Recursive copy with the semantics prepareSources relies on: symlinks are dereferenced,
101
+ * `filter` prunes a source path together with its subtree. Every file lands atomically
102
+ * (see copyFileAtomic) and file copies go through the shared copyLimit.
103
+ */
104
+ async function copyTreeAtomic(src, dest, { filter } = {}) {
105
+ const walk = async (from, to) => {
106
+ if (filter && !filter(from)) {
107
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ copyTreeAtomic filtered out, ${from}`);
108
+ return;
109
+ }
110
+ // stat (not lstat) follows symlinks, same as fsExtra's dereference: true
111
+ const stat = await fs.promises.stat(from);
112
+ if (stat.isDirectory()) {
113
+ await fs.promises.mkdir(to, { recursive: true });
114
+ const entries = await fs.promises.readdir(from);
115
+ await Promise.all(entries.map((name) => walk(path.join(from, name), path.join(to, name))));
116
+ }
117
+ else {
118
+ await copyLimit(() => copyFileAtomic(from, to, stat.mode));
119
+ }
120
+ };
121
+ await walk(src, dest);
122
+ }
29
123
  function findHomePage(menuItem) {
30
124
  for (const item of menuItem) {
31
125
  if (item.homepage) {
@@ -61,10 +155,28 @@ function hashify(obj) {
61
155
  function isFulfilled(result) {
62
156
  return result.status === 'fulfilled';
63
157
  }
64
- function notifyWatcherIssue(limit) {
65
- console.log('Ran out of file handles after watching %s files.', limit);
66
- console.log('Falling back to polling which uses more CPU.');
67
- console.log('Run ulimit -n 10000 to increase the limit for open files.');
158
+ /**
159
+ * chokidar options shared by both watchers.
160
+ *
161
+ * awaitWriteFinish is what makes a save safe to act on: chokidar waits for the size to stop changing
162
+ * before emitting, so we never copy a half-written .vue file. atomic (default true) collapses the
163
+ * unlink+add pair that editors produce when they save via a temp file and rename.
164
+ *
165
+ * Polling is deliberately not configured here: chokidar honours CHOKIDAR_USEPOLLING and
166
+ * CHOKIDAR_INTERVAL on its own, and that is the same switch vite's watcher reads.
167
+ */
168
+ function watcherOptions() {
169
+ return {
170
+ ignoreInitial: true,
171
+ awaitWriteFinish: {
172
+ stabilityThreshold: 60,
173
+ pollInterval: 20,
174
+ },
175
+ ignored: (checkedPath) => {
176
+ const base = path.basename(checkedPath);
177
+ return base === 'node_modules' || base === 'dist' || isAtomicCopyTempName(base);
178
+ },
179
+ };
68
180
  }
69
181
  class CodeInjector {
70
182
  spaTmpPath() {
@@ -74,6 +186,27 @@ class CodeInjector {
74
186
  }
75
187
  return path.join(TMP_DIR, 'adminforth', brandSlug, 'spa_tmp');
76
188
  }
189
+ /**
190
+ * spa_tmp is named after the brand slug only, so every run of the same project shares one folder.
191
+ * We record who owns it next to it (not inside, so it stays out of the sources hash) purely to make
192
+ * a second process visible: two of them copy over each other's files, which surfaces later as a
193
+ * random fs error in an unrelated place. Investigation notes are in AdminForth/1519.
194
+ */
195
+ async claimSpaTmp() {
196
+ const ownerFile = path.join(path.dirname(this.spaTmpPath()), '.owner');
197
+ const previous = await this.tryReadFile(ownerFile);
198
+ if (previous) {
199
+ const [pidText, ...command] = previous.trim().split(' ');
200
+ const pid = parseInt(pidText, 10);
201
+ if (pid && pid !== process.pid && isProcessAlive(pid)) {
202
+ afLogger.warn(`Another AdminForth process is already using ${this.spaTmpPath()}: pid ${pid}` +
203
+ `${command.length ? ` (${command.join(' ')})` : ''}. They share this folder because it is named ` +
204
+ `after the brand only, so their file copies overwrite each other. If you did not start a second ` +
205
+ `dev server or "npx adminforth bundle" on purpose, please report this on AdminForth/1519.`);
206
+ }
207
+ }
208
+ await fs.promises.writeFile(ownerFile, `${process.pid} ${process.argv.slice(1).join(' ')}`).catch(() => { });
209
+ }
77
210
  async checkIconNames(icons) {
78
211
  process.env.HEAVY_DEBUG && console.log(`Checking icon names: ${icons.join(', ')}`);
79
212
  const uniqueIcons = Array.from(new Set(icons));
@@ -128,7 +261,14 @@ class CodeInjector {
128
261
  cleanup() {
129
262
  console.log('Cleaning up...');
130
263
  this.allWatchers.forEach((watcher) => {
131
- watcher.removeAll();
264
+ // close() is async, but the signal handler exits right after us - firing it is all we can do,
265
+ // and an already-closed watcher must not throw out of a signal handler
266
+ try {
267
+ watcher.close();
268
+ }
269
+ catch (e) {
270
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ² Failed to close a watcher: ${e}`);
271
+ }
132
272
  });
133
273
  }
134
274
  constructor(adminforth) {
@@ -137,6 +277,10 @@ class CodeInjector {
137
277
  this.srcFoldersToSync = {};
138
278
  this.publicFoldersToSync = {};
139
279
  this.devServerPort = null;
280
+ // one promise chain per watched path, so two events for the same file can never have their
281
+ // copies interleaved (which is what makes fs copies race each other), and a burst of saves
282
+ // queues up instead of piling on top of each other
283
+ this.fileChangeChains = new Map();
140
284
  this.adminforth = adminforth;
141
285
  ['SIGINT', 'SIGTERM', 'SIGQUIT']
142
286
  .forEach(signal => process.on(signal, () => {
@@ -378,12 +522,9 @@ class CodeInjector {
378
522
  await Promise.all(filesUpdated.map(async (file) => {
379
523
  const src = path.join(spaDir, file);
380
524
  const dest = path.join(this.spaTmpPath(), file);
381
- // overwrite:true can't be used to not destroy cache
382
- await fsExtra.copy(src, dest, {
383
- dereference: true, // needed to dereference types
384
- // preserveTimestamps: true, // needed to not invalidate any caches
385
- });
386
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ fsExtra.copy copy single file, ${src}, ${dest}`);
525
+ // atomic: a hot reload copy must never leave dest missing for a reader (vite / computeSourcesHash)
526
+ await copyFileAtomic(src, dest);
527
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ copyFileAtomic copy single file, ${src}, ${dest}`);
387
528
  }));
388
529
  }
389
530
  async migrateLegacyCustomLayout(oldMeta) {
@@ -402,6 +543,7 @@ class CodeInjector {
402
543
  catch (e) {
403
544
  await fs.promises.mkdir(this.spaTmpPath(), { recursive: true });
404
545
  }
546
+ await this.claimSpaTmp();
405
547
  const icons = [];
406
548
  let routes = '';
407
549
  let routerComponents = '';
@@ -487,7 +629,7 @@ class CodeInjector {
487
629
  collectAssetsFromMenu(menuWithContributions);
488
630
  registerSettingPages(this.adminforth.config.auth.userMenuSettingsPages);
489
631
  const spaDir = this.getSpaDir();
490
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ fsExtra.copy from ${spaDir} -> ${this.spaTmpPath()}`);
632
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ copyTreeAtomic from ${spaDir} -> ${this.spaTmpPath()}`);
491
633
  // try to rm <spa tmp path>/src/types directory
492
634
  try {
493
635
  await fs.promises.rm(path.join(this.spaTmpPath(), 'src', 'types'), { recursive: true });
@@ -496,17 +638,13 @@ class CodeInjector {
496
638
  // ignore
497
639
  }
498
640
  // overwrite can't be used to not destroy cache
499
- await fsExtra.copy(spaDir, this.spaTmpPath(), {
641
+ await copyTreeAtomic(spaDir, this.spaTmpPath(), {
500
642
  filter: (src) => {
501
643
  // /adminforth/* used for local development and /dist/* used for production
502
644
  const filterPasses = !src.includes(`${path.sep}adminforth${path.sep}spa${path.sep}node_modules`) && !src.includes(`${path.sep}adminforth${path.sep}spa${path.sep}dist`)
503
645
  && !src.includes(`${path.sep}dist${path.sep}spa${path.sep}node_modules`) && !src.includes(`${path.sep}dist${path.sep}spa${path.sep}dist`);
504
- if (!filterPasses) {
505
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ fsExtra.copy filtered out, ${src}`);
506
- }
507
646
  return filterPasses;
508
647
  },
509
- dereference: true, // needed to dereference types
510
648
  });
511
649
  // copy whole custom directory
512
650
  if ((_a = this.adminforth.config.customization) === null || _a === void 0 ? void 0 : _a.customComponentsDir) {
@@ -520,27 +658,21 @@ class CodeInjector {
520
658
  if (customFav) {
521
659
  const faviconPath = path.join((_c = this.adminforth.config.customization) === null || _c === void 0 ? void 0 : _c.customComponentsDir, customFav.replace('@@/', ''));
522
660
  const dest = path.join(this.spaTmpPath(), 'public', 'assets', customFav.replace('@@/', ''));
523
- // make sure all folders in dest exist
524
- await fsExtra.ensureDir(path.dirname(dest));
525
- await fsExtra.copy(faviconPath, dest);
661
+ // copyFileAtomic creates all folders in dest itself
662
+ await copyFileAtomic(faviconPath, dest);
526
663
  }
527
664
  for (const [src, dest] of Object.entries(this.srcFoldersToSync)) {
528
665
  const to = path.join(this.spaTmpPath(), 'src', 'custom', dest);
529
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ srcFoldersToSync: fsExtra.copy from ${src}, ${to}`);
530
- await fsExtra.copy(src, to, {
531
- recursive: true,
532
- dereference: true,
666
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ srcFoldersToSync: copyTreeAtomic from ${src}, ${to}`);
667
+ await copyTreeAtomic(src, to, {
533
668
  // exclude if node_modules comes after /custom/ in path
534
669
  filter: (src) => !src.includes(path.join('custom', 'node_modules')),
535
670
  });
536
671
  }
537
672
  for (const [src, dest] of Object.entries(this.publicFoldersToSync)) {
538
673
  const to = path.join(this.spaTmpPath(), 'public', dest);
539
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ publicFoldersToSync: fsExtra.copy from ${src}, ${to}`);
540
- await fsExtra.copy(src, to, {
541
- recursive: true,
542
- dereference: true,
543
- });
674
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²βš™οΈ publicFoldersToSync: copyTreeAtomic from ${src}, ${to}`);
675
+ await copyTreeAtomic(src, to);
544
676
  }
545
677
  //collect all 'icon' fields from resources bulkActions
546
678
  this.adminforth.config.resources.forEach((resource) => {
@@ -815,40 +947,65 @@ class CodeInjector {
815
947
  async watchForReprepare({}) {
816
948
  const spaPath = this.getSpaDir();
817
949
  // get list of all subdirectories in spa recursively (for SPA development)
818
- const directories = [];
819
- const collectDirectories = async (dir) => {
820
- const files = await fs.promises.readdir(dir, { withFileTypes: true });
821
- for (const file of files) {
822
- if (['node_modules', 'dist'].includes(file.name)) {
823
- continue;
824
- }
825
- if (file.isDirectory()) {
826
- directories.push(path.join(dir, file.name));
827
- await collectDirectories(path.join(dir, file.name));
828
- }
829
- }
830
- };
831
- await collectDirectories(spaPath);
832
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for: ${directories.join(',')}`);
833
- const watcher = filewatcher({ debounce: 30 });
834
- directories.forEach((dir) => {
835
- // read directory files and add to watcher, only files not directories
836
- const files = fs.readdirSync(dir);
837
- files.forEach((file) => {
838
- const fullPath = path.join(dir, file);
839
- if (fs.lstatSync(fullPath).isFile()) {
840
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for file ${fullPath}`);
841
- watcher.add(fullPath);
842
- }
843
- });
844
- });
845
- watcher.on('change', async (file) => {
950
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for: ${spaPath}`);
951
+ // one recursive watcher for the whole tree, instead of one fs.watch handle per file:
952
+ // files created after startup are picked up too, which the per-file registration never did
953
+ const watcher = chokidar.watch(spaPath, watcherOptions());
954
+ const onAddOrChange = (file) => {
846
955
  process.env.HEAVY_DEBUG && console.log(`πŸ› File ${file} changed (SPA), preparing sources...`);
847
- await this.updatePartials({ filesUpdated: [file.replace(spaPath + path.sep, '')] });
956
+ this.onWatchedFileChange(file, async () => {
957
+ await this.updatePartials({ filesUpdated: [path.relative(spaPath, file)] });
958
+ });
959
+ };
960
+ watcher.on('add', onAddOrChange);
961
+ watcher.on('change', onAddOrChange);
962
+ watcher.on('unlink', (file) => {
963
+ // keep spa_tmp in sync with removals, it used to only ever grow
964
+ this.onWatchedFileRemove(path.join(this.spaTmpPath(), path.relative(spaPath, file)));
848
965
  });
849
- watcher.on('fallback', notifyWatcherIssue);
966
+ watcher.on('error', (e) => afLogger.warn(`Hot reload watcher error for ${spaPath}: ${e === null || e === void 0 ? void 0 : e.message}`));
850
967
  this.allWatchers.push(watcher);
851
968
  }
969
+ /**
970
+ * Runs `task` for a changed watched file, serialized per path and fully guarded.
971
+ *
972
+ * Both guards matter. A watcher event only says that something happened, not that the file is still
973
+ * there - a rename, a git checkout or an editor writing through a temp file is enough for it to be
974
+ * gone by the time we act on it. And any throw here would land in a listener whose promise nobody
975
+ * awaits, i.e. an unhandled rejection, which node kills the process for.
976
+ */
977
+ onWatchedFileChange(file, task) {
978
+ this.queuePerPath(file, async () => {
979
+ const stat = await fs.promises.stat(file).catch(() => null);
980
+ if (!(stat === null || stat === void 0 ? void 0 : stat.isFile())) {
981
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž ${file} is gone or is not a file, skipping hot reload copy`);
982
+ return;
983
+ }
984
+ await task();
985
+ });
986
+ }
987
+ // removes a file from spa_tmp after it was deleted from the sources, on the same queue as copies
988
+ // so a delete can never overtake a copy of the same path
989
+ onWatchedFileRemove(destPath) {
990
+ this.queuePerPath(destPath, async () => {
991
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Removing ${destPath} from spa_tmp, source is gone`);
992
+ await fs.promises.rm(destPath, { force: true });
993
+ });
994
+ }
995
+ queuePerPath(key, task) {
996
+ var _a;
997
+ const previous = (_a = this.fileChangeChains.get(key)) !== null && _a !== void 0 ? _a : Promise.resolve();
998
+ const current = previous.then(task).catch((e) => {
999
+ afLogger.warn(`Hot reload failed to sync ${key}: ${e.message}`);
1000
+ });
1001
+ this.fileChangeChains.set(key, current);
1002
+ current.then(() => {
1003
+ // keep the map from growing for the lifetime of the dev server
1004
+ if (this.fileChangeChains.get(key) === current) {
1005
+ this.fileChangeChains.delete(key);
1006
+ }
1007
+ });
1008
+ }
852
1009
  async watchCustomComponentsForCopy({ customComponentsDir, destination, targetRoot = path.join('src', 'custom') }) {
853
1010
  if (!customComponentsDir) {
854
1011
  return;
@@ -861,51 +1018,23 @@ class CodeInjector {
861
1018
  process.env.HEAVY_DEBUG && console.log(`πŸͺ²Custom components dir ${customComponentsDir} does not exist, skipping watching`);
862
1019
  return;
863
1020
  }
864
- // get all subdirs
865
- const directories = [];
866
- const files = [];
867
- const collectDirectories = async (dir) => {
868
- if (['node_modules', 'dist'].includes(path.basename(dir))) {
869
- return;
870
- }
871
- directories.push(dir);
872
- const filesAndDirs = await fs.promises.readdir(dir, { withFileTypes: true });
873
- await Promise.all(filesAndDirs.map(async (file) => {
874
- const isDir = fs.lstatSync(path.join(dir, file.name)).isDirectory();
875
- if (isDir) {
876
- await collectDirectories(path.join(dir, file.name));
877
- }
878
- else {
879
- files.push(path.join(dir, file.name));
880
- }
881
- }));
882
- };
883
- await collectDirectories(customComponentsDir);
884
- const watcher = filewatcher({ debounce: 30 });
885
- files.forEach((file) => {
886
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for file ${file}`);
887
- watcher.add(file);
888
- });
889
- process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for: ${directories.join(',')}`);
890
- watcher.on('change', async (fileOrDir) => {
891
- // copy one file
892
- const relativeFilename = fileOrDir.replace(customComponentsDir + path.sep, '');
1021
+ process.env.HEAVY_DEBUG && console.log(`πŸͺ²πŸ”Ž Watch for: ${customComponentsDir}`);
1022
+ const watcher = chokidar.watch(customComponentsDir, watcherOptions());
1023
+ const destinationOf = (fileOrDir) => path.join(this.spaTmpPath(), targetRoot, destination, path.relative(customComponentsDir, fileOrDir));
1024
+ const onAddOrChange = (fileOrDir) => {
893
1025
  process.env.HEAVY_DEBUG && console.log(`πŸ”Ž fileOrDir ${fileOrDir} changed`);
894
- process.env.HEAVY_DEBUG && console.log(`πŸ”Ž relativeFilename ${relativeFilename}`);
895
1026
  process.env.HEAVY_DEBUG && console.log(`πŸ”Ž customComponentsDir ${customComponentsDir}`);
896
1027
  process.env.HEAVY_DEBUG && console.log(`πŸ”Ž destination ${destination}`);
897
- const isFile = fs.lstatSync(fileOrDir).isFile();
898
- if (isFile) {
899
- const destPath = path.join(this.spaTmpPath(), targetRoot, destination, relativeFilename);
1028
+ this.onWatchedFileChange(fileOrDir, async () => {
1029
+ const destPath = destinationOf(fileOrDir);
900
1030
  process.env.HEAVY_DEBUG && console.log(`πŸ”Ž Copying file ${fileOrDir} to ${destPath}`);
901
- await fsExtra.copy(fileOrDir, destPath);
902
- return;
903
- }
904
- else {
905
- // for now do nothing
906
- }
907
- });
908
- watcher.on('fallback', notifyWatcherIssue);
1031
+ await copyFileAtomic(fileOrDir, destPath);
1032
+ });
1033
+ };
1034
+ watcher.on('add', onAddOrChange);
1035
+ watcher.on('change', onAddOrChange);
1036
+ watcher.on('unlink', (file) => this.onWatchedFileRemove(destinationOf(file)));
1037
+ watcher.on('error', (e) => afLogger.warn(`Hot reload watcher error for ${customComponentsDir}: ${e === null || e === void 0 ? void 0 : e.message}`));
909
1038
  this.allWatchers.push(watcher);
910
1039
  }
911
1040
  async tryReadFile(filePath) {
@@ -930,6 +1059,22 @@ class CodeInjector {
930
1059
  file.name === 'pnpm-lock.yaml' || file.name === 'package-lock.json') {
931
1060
  return '';
932
1061
  }
1062
+ // a copyFileAtomic temp file survives only if the process was killed mid-copy (SIGKILL);
1063
+ // drop it so it neither poisons the build cache hash nor accumulates in spa_tmp.
1064
+ // If it belongs to a process that is still running, it is not a leftover at all - somebody
1065
+ // else is copying into our spa_tmp right now, which is the situation behind AdminForth/1519.
1066
+ if (isAtomicCopyTempName(file.name)) {
1067
+ const ownerPid = atomicCopyTempPid(file.name);
1068
+ if (ownerPid && ownerPid !== process.pid && isProcessAlive(ownerPid)) {
1069
+ afLogger.warn(`Caught another live process (pid ${ownerPid}) copying into ${this.spaTmpPath()} right now ` +
1070
+ `(${filePath}). Two processes sharing one spa_tmp overwrite each other's files - ` +
1071
+ `please report this on AdminForth/1519.`);
1072
+ }
1073
+ else {
1074
+ await fs.promises.rm(filePath, { force: true }).catch(() => { });
1075
+ }
1076
+ return '';
1077
+ }
933
1078
  allFiles.push(filePath);
934
1079
  if (file.isDirectory()) {
935
1080
  return this.computeSourcesHash(filePath, allFiles);
@@ -951,7 +1096,8 @@ class CodeInjector {
951
1096
  if (file.name === 'node_modules' || file.name === 'dist' ||
952
1097
  file.name === 'i18n-messages.json' || file.name === 'i18n-empty.json' ||
953
1098
  file.name === 'hashes.json' || file.name === 'package.json' ||
954
- file.name === 'pnpm-lock.yaml' || file.name === 'package-lock.json') {
1099
+ file.name === 'pnpm-lock.yaml' || file.name === 'package-lock.json' ||
1100
+ isAtomicCopyTempName(file.name)) {
955
1101
  return;
956
1102
  }
957
1103
  if (file.isDirectory()) {
@@ -1029,7 +1175,7 @@ class CodeInjector {
1029
1175
  // create serveDir if not exists
1030
1176
  await fs.promises.mkdir(serveDir, { recursive: true });
1031
1177
  // copy i18n messages to serve dir
1032
- await fsExtra.copy(path.join(cwd, 'i18n-messages.json'), path.join(serveDir, 'i18n-messages.json'));
1178
+ await copyFileAtomic(path.join(cwd, 'i18n-messages.json'), path.join(serveDir, 'i18n-messages.json'));
1033
1179
  // save hash
1034
1180
  await fs.promises.writeFile(path.join(serveDir, '.adminforth_messages_hash'), sourcesHash);
1035
1181
  }
@@ -1087,7 +1233,7 @@ class CodeInjector {
1087
1233
  // TODO probably add option to build with tsh check (plain 'build')
1088
1234
  await this.runPackageManagerShell({ command: 'run build-only', cwd });
1089
1235
  // coy dist to serveDir
1090
- await fsExtra.copy(path.join(cwd, 'dist'), serveDir, { recursive: true });
1236
+ await copyTreeAtomic(path.join(cwd, 'dist'), serveDir);
1091
1237
  // save hash
1092
1238
  await fs.promises.writeFile(path.join(serveDir, '.adminforth_build_hash'), sourcesHash);
1093
1239
  // save sources hashes to file for later debugging if needed