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.
- package/dist/modules/codeInjector.d.ts +19 -0
- package/dist/modules/codeInjector.d.ts.map +1 -1
- package/dist/modules/codeInjector.js +251 -105
- package/dist/modules/codeInjector.js.map +1 -1
- package/dist/modules/configValidator.d.ts.map +1 -1
- package/dist/modules/configValidator.js +13 -0
- package/dist/modules/configValidator.js.map +1 -1
- package/dist/modules/restApi.d.ts +4 -0
- package/dist/modules/restApi.d.ts.map +1 -1
- package/dist/modules/restApi.js +16 -1
- package/dist/modules/restApi.js.map +1 -1
- package/dist/spa/src/types/Back.ts +38 -0
- package/dist/types/Back.d.ts +36 -0
- package/dist/types/Back.d.ts.map +1 -1
- package/dist/types/Back.js.map +1 -1
- package/package.json +2 -2
|
@@ -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":"AAQA,OAAO,UAAwC,MAAM,aAAa,CAAC;AAEnE,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
382
|
-
await
|
|
383
|
-
|
|
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(`πͺ²βοΈ
|
|
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
|
|
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
|
-
//
|
|
524
|
-
await
|
|
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:
|
|
530
|
-
await
|
|
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:
|
|
540
|
-
await
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
865
|
-
const
|
|
866
|
-
const
|
|
867
|
-
const
|
|
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
|
-
|
|
898
|
-
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
|
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
|
|
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
|