@travetto/compiler 5.0.10 → 5.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/compiler",
3
- "version": "5.0.10",
3
+ "version": "5.0.11",
4
4
  "description": "The compiler infrastructure for the Travetto framework",
5
5
  "keywords": [
6
6
  "compiler",
@@ -38,7 +38,7 @@
38
38
  "@types/node": "^22.7.4"
39
39
  },
40
40
  "peerDependencies": {
41
- "@travetto/cli": "^5.0.7"
41
+ "@travetto/cli": "^5.0.8"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@travetto/cli": {
package/src/compiler.ts CHANGED
@@ -6,7 +6,7 @@ import { ManifestIndex, ManifestModuleUtil } from '@travetto/manifest';
6
6
  import { CompilerUtil } from './util';
7
7
  import { CompilerState } from './state';
8
8
  import { CompilerWatcher } from './watch';
9
- import { CompileEmitEvent, CompileEmitter } from './types';
9
+ import { CompileEmitEvent, CompileEmitter, CompilerReset } from './types';
10
10
  import { EventUtil } from './event';
11
11
 
12
12
  import { IpcLogger } from '../support/log';
@@ -74,7 +74,7 @@ export class Compiler {
74
74
  break;
75
75
  }
76
76
  case 'reset': {
77
- log.info('Triggering reset due to change in core files', err?.cause);
77
+ log.info('Reset due to', err?.message);
78
78
  EventUtil.sendEvent('state', { state: 'reset' });
79
79
  process.exitCode = 0;
80
80
  break;
@@ -164,7 +164,7 @@ export class Compiler {
164
164
 
165
165
  EventUtil.sendEvent('state', { state: 'watch-start' });
166
166
  try {
167
- for await (const ev of new CompilerWatcher(this.#state, this.#signal).watchChanges()) {
167
+ for await (const ev of new CompilerWatcher(this.#state, this.#signal)) {
168
168
  if (ev.action !== 'delete') {
169
169
  const err = await emitter(ev.entry.sourceFile, true);
170
170
  if (err) {
@@ -193,7 +193,7 @@ export class Compiler {
193
193
 
194
194
  } catch (err) {
195
195
  if (err instanceof Error) {
196
- this.#shutdown(err.message === 'RESET' ? 'reset' : 'error', err);
196
+ this.#shutdown(err instanceof CompilerReset ? 'reset' : 'error', err);
197
197
  }
198
198
  }
199
199
  }
package/src/types.ts CHANGED
@@ -6,3 +6,5 @@ export type CompileEmitError = Error | readonly ts.Diagnostic[];
6
6
  export type CompileEmitter = (file: string, newProgram?: boolean) => Promise<CompileEmitError | undefined>;
7
7
  export type CompileEmitEvent = { file: string, i: number, total: number, err?: CompileEmitError };
8
8
  export type CompileStateEntry = { sourceFile: string, tscOutputFile: string, outputFile?: string, module: ManifestModule };
9
+ export type CompilerWatchEvent = { action: 'create' | 'update' | 'delete', file: string, entry: CompileStateEntry };
10
+ export class CompilerReset extends Error { }
package/src/watch.ts CHANGED
@@ -1,8 +1,9 @@
1
- import os from 'node:os';
1
+ import fs from 'node:fs/promises';
2
+ import { watch } from 'node:fs';
2
3
 
3
- import { ManifestContext, type ManifestModuleFileType, type ManifestModuleFolderType, ManifestModuleUtil, ManifestUtil, PackageUtil, path } from '@travetto/manifest';
4
+ import { ManifestFileUtil, ManifestModuleUtil, ManifestUtil, PackageUtil, path } from '@travetto/manifest';
4
5
 
5
- import type { CompileStateEntry } from './types';
6
+ import { CompilerReset, type CompilerWatchEvent, type CompileStateEntry } from './types';
6
7
  import { CompilerState } from './state';
7
8
  import { CompilerUtil } from './util';
8
9
 
@@ -11,249 +12,221 @@ import { IpcLogger } from '../support/log';
11
12
 
12
13
  const log = new IpcLogger({ level: 'debug' });
13
14
 
14
- type WatchAction = 'create' | 'update' | 'delete';
15
- type WatchEvent = { action: WatchAction, file: string };
16
- type CompilerWatchEvent = WatchEvent & { entry: CompileStateEntry };
17
- type FileShape = {
18
- mod: string;
19
- folderKey: ManifestModuleFolderType;
20
- fileType: ManifestModuleFileType;
21
- moduleFile: string;
22
- action: WatchAction;
23
- };
24
-
25
- const DEFAULT_WRITE_LIMIT = 1000 * 60 * 5;
26
-
27
- /**
28
- * Watch support, based on compiler state and manifest details
29
- */
15
+ type CompilerWatchEventCandidate = Omit<CompilerWatchEvent, 'entry'> & { entry?: CompileStateEntry };
16
+
30
17
  export class CompilerWatcher {
31
18
  #state: CompilerState;
32
- #signal: AbortSignal;
19
+ #cleanup: Partial<Record<'tool' | 'workspace' | 'canary', () => (void | Promise<void>)>> = {};
20
+ #watchCanary: string = '.trv/canary.id';
21
+ #lastWorkspaceModified = Date.now();
22
+ #watchCanaryFreq = 5;
23
+ #root: string;
24
+ #q: AsyncQueue<CompilerWatchEvent>;
33
25
 
34
26
  constructor(state: CompilerState, signal: AbortSignal) {
35
27
  this.#state = state;
36
- this.#signal = signal;
28
+ this.#root = state.manifest.workspace.path;
29
+ this.#q = new AsyncQueue(signal);
30
+ signal.addEventListener('abort', () => Object.values(this.#cleanup).forEach(x => x()));
37
31
  }
38
32
 
39
- #watchDog(): { reset(file: string): void, close(): void } {
40
- let lastWrite = Date.now();
41
- let writeThreshold = DEFAULT_WRITE_LIMIT;
42
- log.info('Starting watchdog');
43
- const value = setInterval(() => {
44
- if (Date.now() > (lastWrite + writeThreshold)) {
45
- const delta = (Date.now() - lastWrite) / (1000 * 60);
46
- log.info(`Watch has not seen changes in ${Math.trunc(delta)}m`);
47
- writeThreshold += DEFAULT_WRITE_LIMIT;
48
- }
49
- }, DEFAULT_WRITE_LIMIT / 10);
50
- return {
51
- close: (): void => {
52
- log.info('Closing watchdog');
53
- clearInterval(value);
54
- },
55
- reset: (file: string): void => {
56
- log.debug('Resetting watchdog', file);
57
- lastWrite = Date.now();
58
- writeThreshold = DEFAULT_WRITE_LIMIT;
33
+ async #getWatchIgnores(): Promise<string[]> {
34
+ const pkg = PackageUtil.readPackage(this.#root);
35
+ const patterns = [
36
+ ...pkg?.travetto?.build?.watchIgnores ?? [],
37
+ '**/node_modules',
38
+ '.*/**/node_modules'
39
+ ];
40
+ const ignores = new Set(['node_modules', '.git']);
41
+ for (const item of patterns) {
42
+ if (item.includes('*')) {
43
+ for await (const sub of fs.glob(item, { cwd: this.#root })) {
44
+ if (sub.startsWith('node_modules')) {
45
+ continue;
46
+ } else if (sub.endsWith('/node_modules')) {
47
+ ignores.add(sub.split('/node_modules')[0]);
48
+ } else {
49
+ ignores.add(sub);
50
+ }
51
+ }
52
+ } else {
53
+ ignores.add(item);
59
54
  }
60
- };
61
- }
62
-
63
- #reset(ev: WatchEvent): never {
64
- throw new Error('RESET', { cause: `${ev.action}:${ev.file}` });
55
+ }
56
+ return [...ignores].sort().map(x => x.endsWith('/') ? x : `${x}/`);
65
57
  }
66
58
 
67
- #getIgnores(): string[] {
68
- // TODO: Read .gitignore?
69
- let ignores = PackageUtil.readPackage(this.#state.manifest.workspace.path)?.travetto?.build?.watchIgnores;
70
-
71
- if (!ignores) {
72
- ignores = ['node_modules/**'];
59
+ #toCandidateEvent(action: CompilerWatchEvent['action'], file: string): CompilerWatchEventCandidate {
60
+ let entry = this.#state.getBySource(file);
61
+ const mod = entry?.module ?? this.#state.manifestIndex.findModuleForArbitraryFile(file);
62
+ if (mod && action === 'create' && !entry) {
63
+ const modRoot = mod.sourceFolder || this.#root;
64
+ const moduleFile = file.includes(modRoot) ? file.split(`${modRoot}/`)[1] : file;
65
+ entry = this.#state.registerInput(mod, moduleFile);
73
66
  }
74
-
75
- // TODO: Fix once node/parcel sort this out
76
- return os.platform() === 'linux' ? [] : [
77
- ...ignores,
78
- '.git', '**/.git',
79
- `${this.#state.manifest.build.outputFolder}/node_modules/**`,
80
- `${this.#state.manifest.build.compilerFolder}/node_modules/**`,
81
- `${this.#state.manifest.build.toolFolder}/**`
82
- ];
67
+ return { entry, file: entry?.sourceFile ?? file, action };
83
68
  }
84
69
 
85
- /** Watch files */
86
- async * #watchFolder(rootPath: string): AsyncIterable<WatchEvent[]> {
87
- const q = new AsyncQueue<WatchEvent[]>(this.#signal);
88
- const lib = await import('@parcel/watcher');
89
- const ignore = this.#getIgnores();
90
-
91
- const cleanup = await lib.subscribe(rootPath, (err, events) => {
92
- if (err) {
93
- q.throw(err instanceof Error ? err : new Error(`${err}`));
94
- return;
95
- }
96
- q.add(events.map(ev => ({ action: ev.type, file: path.toPosix(ev.path) })));
97
- }, { ignore });
98
-
99
- if (this.#signal.aborted) { // If already aborted, can happen async
100
- cleanup.unsubscribe();
101
- return;
70
+ #isValidEvent(ev: CompilerWatchEventCandidate): ev is CompilerWatchEvent {
71
+ const relativeFile = ev.file.replace(`${this.#root}/`, '');
72
+ if (relativeFile === this.#watchCanary) {
73
+ return false;
74
+ } else if (relativeFile.startsWith('.')) {
75
+ return false;
76
+ } else if (!ev.entry) {
77
+ log.debug(`Skipping unknown file ${relativeFile}`);
78
+ return false;
79
+ } else if (ev.action === 'update' && !this.#state.checkIfSourceChanged(ev.entry.sourceFile)) {
80
+ log.debug(`Skipping update, as contents unchanged ${relativeFile}`);
81
+ return false;
82
+ } else if (!CompilerUtil.validFile(ManifestModuleUtil.getFileType(relativeFile))) {
83
+ return false;
102
84
  }
103
-
104
- this.#signal.addEventListener('abort', () => cleanup.unsubscribe());
105
-
106
- yield* q;
85
+ return true;
107
86
  }
108
87
 
109
- async #rebuildManifestsIfNeeded(events: CompilerWatchEvent[]): Promise<void> {
110
- events = events.filter(x => x.entry.outputFile && x.action !== 'update');
111
-
112
- if (!events.length) {
88
+ async #reconcileAddRemove(compilerEvents: CompilerWatchEvent[]): Promise<void> {
89
+ const nonUpdates = compilerEvents.filter(x => x.entry.outputFile && x.action !== 'update');
90
+ if (!nonUpdates.length) {
113
91
  return;
114
92
  }
115
93
 
116
- const mods = [...new Set(events.map(v => v.entry.module.name))];
117
-
118
- const parents = new Map<string, string[]>(
119
- mods.map(m => [m, this.#state.manifestIndex.getDependentModules(m, 'parents').map(x => x.name)])
120
- );
121
-
122
- const moduleToFiles = new Map<string, { context: ManifestContext, files: FileShape[] }>(
123
- [...mods, ...parents.values()].flat().map(m => [m, {
124
- context: ManifestUtil.getModuleContext(this.#state.manifest, this.#state.manifestIndex.getManifestModule(m)!.sourceFolder),
125
- files: []
126
- }])
127
- );
128
-
129
- const allFiles = events.map(ev => {
130
- const modRoot = ev.entry.module.sourceFolder || this.#state.manifest.workspace.path;
131
- const moduleFile = ev.file.includes(modRoot) ? ev.file.split(`${modRoot}/`)[1] : ev.file;
132
- const folderKey = ManifestModuleUtil.getFolderKey(moduleFile);
133
- const fileType = ManifestModuleUtil.getFileType(moduleFile);
134
- return { mod: ev.entry.module.name, action: ev.action, moduleFile, folderKey, fileType };
135
- });
94
+ try {
95
+ const eventsByMod = new Map<string, CompilerWatchEvent[]>();
136
96
 
137
- for (const file of allFiles) {
138
- for (const parent of parents.get(file.mod)!) {
139
- const mod = moduleToFiles.get(parent);
140
- if (!mod || !mod.files) {
141
- this.#reset({ action: file.action, file: `${file.mod}/${file.moduleFile}` });
97
+ for (const ev of nonUpdates) {
98
+ const mod = ev.entry.module;
99
+ if (ev.action === 'delete') {
100
+ this.#state.removeSource(ev.entry.sourceFile);
101
+ }
102
+ for (const m of [mod, ...this.#state.manifestIndex.getDependentModules(mod.name, 'parents')]) {
103
+ if (!eventsByMod.has(m.name)) {
104
+ eventsByMod.set(m.name, []);
105
+ }
106
+ eventsByMod.get(m.name)!.push(ev);
142
107
  }
143
- mod.files.push(file);
144
108
  }
145
- }
146
109
 
147
- for (const { context, files } of moduleToFiles.values()) {
148
- const newManifest = await ManifestUtil.buildManifest(context);
149
- for (const { action, mod, fileType, moduleFile, folderKey } of files) {
150
- const modFiles = newManifest.modules[mod].files[folderKey] ??= [];
151
- const idx = modFiles.findIndex(x => x[0] === moduleFile);
152
-
153
- if (action === 'create' && idx < 0) {
154
- modFiles.push([moduleFile, fileType, Date.now()]);
155
- } else if (idx >= 0) {
156
- if (action === 'delete') {
157
- modFiles.splice(idx, 1);
158
- } else {
159
- modFiles[idx] = [moduleFile, fileType, Date.now()];
110
+ for (const [mod, events] of eventsByMod.entries()) {
111
+ const modRoot = this.#state.manifestIndex.getManifestModule(mod)!.sourceFolder;
112
+ const context = ManifestUtil.getModuleContext(this.#state.manifest, modRoot);
113
+ const newManifest = ManifestUtil.readManifestSync(ManifestUtil.getManifestLocation(context));
114
+ log.debug('Updating manifest', { module: mod });
115
+ for (const { action, file } of events) {
116
+ const resolvedRoot = modRoot || this.#root;
117
+ const moduleFile = file.includes(resolvedRoot) ? file.split(`${resolvedRoot}/`)[1] : file;
118
+ const folderKey = ManifestModuleUtil.getFolderKey(moduleFile);
119
+ const fileType = ManifestModuleUtil.getFileType(moduleFile);
120
+
121
+ const modFiles = newManifest.modules[mod].files[folderKey] ??= [];
122
+ const idx = modFiles.findIndex(x => x[0] === moduleFile);
123
+ switch (action) {
124
+ case 'create': modFiles[idx < 0 ? modFiles.length : idx] = [moduleFile, fileType, Date.now()]; break;
125
+ case 'delete': modFiles.splice(idx, 1); break;
160
126
  }
161
127
  }
128
+ await ManifestUtil.writeManifest(newManifest);
162
129
  }
163
- await ManifestUtil.writeManifest(newManifest);
164
- }
165
-
166
- // Reindex at workspace root
167
- this.#state.manifestIndex.init(ManifestUtil.getManifestLocation(this.#state.manifest));
168
- }
169
130
 
170
- /**
171
- * Get a watcher for a given compiler state
172
- * @param state
173
- * @param handler
174
- * @returns
175
- */
176
- async * watchChanges(): AsyncIterable<CompilerWatchEvent> {
177
- if (this.#signal.aborted) {
178
- return;
131
+ this.#state.manifestIndex.init(ManifestUtil.getManifestLocation(this.#state.manifest));
132
+ } catch (mErr) {
133
+ log.info('Restarting due to manifest rebuild failure', mErr);
134
+ throw new CompilerReset(`Manifest rebuild failure: ${mErr}`);
179
135
  }
136
+ }
180
137
 
181
- const manifest = this.#state.manifest;
182
- const ROOT = manifest.workspace.path;
183
- const ROOT_LOCK = 'package-lock.json';
184
- const ROOT_PKG = 'package.json';
185
- const OUTPUT_PATH = manifest.build.outputFolder;
186
- const COMPILER_PATH = manifest.build.compilerFolder;
187
- const TYPES_PATH = manifest.build.typesFolder;
188
- const COMPILER_ROOT = path.dirname(COMPILER_PATH);
189
-
190
- const IGNORE_RE = new RegExp(`^[.]|(${COMPILER_PATH}|${TYPES_PATH}|${OUTPUT_PATH})`);
191
-
192
- const watchDog = this.#watchDog();
193
-
194
- for await (const events of this.#watchFolder(ROOT)) {
138
+ async #listenWorkspace(): Promise<void> {
139
+ const lib = await import('@parcel/watcher');
140
+ const ignore = await this.#getWatchIgnores();
141
+ const packageFiles = new Set(['package-lock.json', 'yarn.lock', 'package.json'].map(x => path.resolve(this.#root, x)));
195
142
 
196
- const outEvents: CompilerWatchEvent[] = [];
143
+ log.debug('Ignore Globs', ignore);
197
144
 
198
- for (const ev of events) {
199
- const { action, file: sourceFile } = ev;
145
+ await this.#cleanup.workspace?.();
200
146
 
201
- const relativeFile = sourceFile.replace(`${ROOT}/`, '');
147
+ const listener = await lib.subscribe(this.#root, async (err, events) => {
148
+ this.#lastWorkspaceModified = Date.now();
202
149
 
203
- if (
204
- relativeFile === ROOT_LOCK ||
205
- relativeFile === ROOT_PKG ||
206
- (action === 'delete' && relativeFile === COMPILER_ROOT)
207
- ) {
208
- this.#reset(ev);
150
+ try {
151
+ if (err) {
152
+ throw err instanceof Error ? err : new Error(`${err}`);
153
+ } else if (events.length > 25) {
154
+ throw new CompilerReset(`Large influx of file changes: ${events.length}`);
155
+ } else if (events.some(ev => packageFiles.has(path.toPosix(ev.path)))) {
156
+ throw new CompilerReset('Package information changed');
209
157
  }
210
158
 
211
- if (IGNORE_RE.test(relativeFile)) {
212
- continue;
213
- }
159
+ const items = events
160
+ .map(x => this.#toCandidateEvent(x.type, path.toPosix(x.path)))
161
+ .filter(x => this.#isValidEvent(x));
214
162
 
215
- watchDog.reset(relativeFile);
163
+ await this.#reconcileAddRemove(items);
216
164
 
217
- const fileType = ManifestModuleUtil.getFileType(sourceFile);
218
- if (!CompilerUtil.validFile(fileType)) {
219
- continue;
165
+ for (const item of items) {
166
+ this.#q.add(item);
220
167
  }
168
+ } catch (out) {
169
+ return this.#q.throw(out instanceof Error ? out : new Error(`${out}`));
170
+ }
171
+ }, { ignore });
221
172
 
222
- let entry = this.#state.getBySource(sourceFile);
173
+ this.#cleanup.workspace = (): Promise<void> => listener.unsubscribe();
174
+ }
223
175
 
224
- const mod = entry?.module ?? this.#state.manifestIndex.findModuleForArbitraryFile(sourceFile);
225
- if (!mod) { // Unknown module
226
- log.debug(`Unknown module for a given file ${relativeFile}`);
227
- continue;
228
- }
176
+ async #listenToolFolder(): Promise<void> {
177
+ const build = this.#state.manifest.build;
178
+ const toolRootFolder = path.dirname(path.resolve(this.#root, build.compilerFolder));
179
+ const toolFolders = new Set([
180
+ toolRootFolder, build.compilerFolder, build.typesFolder, build.outputFolder
181
+ ].map(x => path.resolve(this.#root, x)));
229
182
 
230
- const modRoot = mod.sourceFolder || this.#state.manifest.workspace.path;
231
- const moduleFile = sourceFile.includes(modRoot) ? sourceFile.split(`${modRoot}/`)[1] : sourceFile;
232
-
233
- if (action === 'create') {
234
- entry = this.#state.registerInput(mod, moduleFile);
235
- } else if (!entry) {
236
- log.debug(`Unknown file ${relativeFile}`);
237
- continue;
238
- } else if (action === 'update' && !this.#state.checkIfSourceChanged(entry.sourceFile)) {
239
- log.debug(`Skipping update, as contents unchanged ${relativeFile}`);
240
- continue;
241
- } else if (action === 'delete') {
242
- this.#state.removeSource(entry.sourceFile);
243
- }
183
+ log.debug('Tooling Folders', [...toolFolders].map(x => x.replace(`${this.#root}/`, '')));
184
+
185
+ await this.#cleanup.tool?.();
244
186
 
245
- outEvents.push({ action, file: entry.sourceFile, entry });
187
+ const listener = watch(toolRootFolder, { encoding: 'utf8' }, async (ev, f) => {
188
+ if (!f) {
189
+ return;
190
+ }
191
+ const full = path.resolve(toolRootFolder, f);
192
+ const stat = await fs.stat(full).catch(() => null);
193
+ if (toolFolders.has(full) && !stat) {
194
+ this.#q.throw(new CompilerReset(`Tooling folder removal ${full}`));
246
195
  }
196
+ });
197
+ this.#cleanup.tool = (): void => listener.close();
198
+ }
247
199
 
248
- try {
249
- await this.#rebuildManifestsIfNeeded(outEvents);
250
- } catch (err) {
251
- log.info('Restarting due to manifest rebuild failure', err);
252
- this.#reset(events[0]);
200
+ async #listenCanary(): Promise<void> {
201
+ await this.#cleanup.canary?.();
202
+ const full = path.resolve(this.#root, this.#watchCanary);
203
+ await ManifestFileUtil.bufferedFileWrite(full, '');
204
+
205
+ log.debug('Starting workspace canary');
206
+ const canaryId = setInterval(async () => {
207
+ const delta = Math.trunc((Date.now() - this.#lastWorkspaceModified) / 1000);
208
+ if (delta > 600) {
209
+ log.error('Restarting canary due to extra long delay');
210
+ this.#lastWorkspaceModified = Date.now(); // Reset
211
+ } else if (delta > this.#watchCanaryFreq * 2) {
212
+ this.#q.throw(new CompilerReset(`Workspace watch stopped responding ${delta}s ago`));
213
+ } else if (delta > this.#watchCanaryFreq) {
214
+ log.error('Restarting parcel due to inactivity');
215
+ await this.#listenWorkspace();
216
+ } else {
217
+ await fs.utimes(full, new Date(), new Date());
253
218
  }
254
- yield* outEvents;
255
- }
219
+ }, this.#watchCanaryFreq * 1000);
256
220
 
257
- watchDog.close();
221
+ this.#cleanup.canary = (): void => clearInterval(canaryId);
222
+ }
223
+
224
+ [Symbol.asyncIterator](): AsyncIterator<CompilerWatchEvent> {
225
+ if (!this.#cleanup.workspace) {
226
+ this.#listenWorkspace();
227
+ this.#listenToolFolder();
228
+ this.#listenCanary();
229
+ }
230
+ return this.#q[Symbol.asyncIterator]();
258
231
  }
259
232
  }