@travetto/manifest 3.0.3 → 3.1.0-rc.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.
package/README.md CHANGED
@@ -36,7 +36,7 @@ During the compilation process, it is helpful to know how the output content dif
36
36
  ## Class and Function Metadata
37
37
  For the framework to work properly, metadata needs to be collected about files, classes and functions to uniquely identify them, with support for detecting changes during live reloads. To achieve this, every `class` is decorated with an additional field of `Ⲑid`. `Ⲑid` represents a computed id that is tied to the file/class combination.
38
38
 
39
- `Ⲑid` is used heavily throughout the framework for determining which classes are owned by the framework, and being able to lookup the needed data from the [RootIndex](https://github.com/travetto/travetto/tree/main/module/manifest/src/root-index.ts#L12) using the `getFunctionMetadata` method.
39
+ `Ⲑid` is used heavily throughout the framework for determining which classes are owned by the framework, and being able to lookup the needed data from the [RootIndex](https://github.com/travetto/travetto/tree/main/module/manifest/src/root-index.ts#L11) using the `getFunctionMetadata` method.
40
40
 
41
41
  **Code: Test Class**
42
42
  ```typescript
@@ -90,17 +90,27 @@ By default, all paths within the framework are assumed to be in a POSIX style, a
90
90
  ## File Watching
91
91
  The module also leverages [fetch](https://www.npmjs.com/package/@parcel/watcher), to expose a single function of `watchFolders`. Only the [Compiler](https://github.com/travetto/travetto/tree/main/module/compiler#readme "The compiler infrastructure for the Travetto framework") module packages [fetch](https://www.npmjs.com/package/@parcel/watcher) as a direct dependency. This means, that in production, by default all watch operations will fail with a missing dependency.
92
92
 
93
- **Code: Watch Folder Signature**
93
+ **Code: Watch Configuration**
94
94
  ```typescript
95
- export type WatchEvent = { action: 'create' | 'update' | 'delete', file: string };
96
- export type WatchEventFilter = (ev: WatchEvent) => boolean;
95
+ export type WatchEvent = { action: 'create' | 'update' | 'delete', file: string, folder: string };
97
96
 
98
- export type WatchEventListener = (ev: WatchEvent, folder: string) => void;
99
- export type WatchConfig = {
97
+ export type WatchFolder = {
100
98
  /**
101
- * Predicate for filtering events
99
+ * Source folder
102
100
  */
103
- filter?: WatchEventFilter;
101
+ src: string;
102
+ /**
103
+ * Target folder name, useful for deconstructing
104
+ */
105
+ target?: string;
106
+ /**
107
+ * Filter events
108
+ */
109
+ filter?: (ev: WatchEvent) => boolean;
110
+ /**
111
+ * Only look at immediate folder
112
+ */
113
+ immediate?: boolean;
104
114
  /**
105
115
  * List of top level folders to ignore
106
116
  */
@@ -113,27 +123,21 @@ export type WatchConfig = {
113
123
  * Include files that start with '.'
114
124
  */
115
125
  includeHidden?: boolean;
116
- /**
117
- * Should watching prevent normal exiting, until watch is removed?
118
- */
119
- persistent?: boolean;
120
126
  };
121
127
 
122
- /**
123
- * Watch files for a given folder
124
- * @param folder
125
- * @param onEvent
126
- * @param options
127
- */
128
- export async function watchFolderImmediate(
129
- folder: string,
130
- onEvent: WatchEventListener,
131
- options: WatchConfig = {}
132
- ): Promise<() => Promise<void>> {
128
+ export type WatchStream = AsyncIterable<WatchEvent> & { close: () => Promise<void> };
133
129
  ```
134
130
 
135
131
  This method allows for watching one or more folders, and registering a callback that will fire every time a file changes, and which of the registered folders it was triggered within. The return of the `watchFolders` is a cleanup method, that when invoked will remove and stop all watching behavior.
136
132
 
133
+ **Code: Watch Configuration**
134
+ ```typescript
135
+ export function watchFolders(
136
+ folders: string[] | WatchFolder[],
137
+ config: Omit<WatchFolder, 'src' | 'target'> = {}
138
+ ): WatchStream {
139
+ ```
140
+
137
141
  ## Anatomy of a Manifest
138
142
 
139
143
  **Code: Manifest for @travetto/manifest**
@@ -149,6 +153,9 @@ This method allows for watching one or more folders, and registering a callback
149
153
  "toolFolder": ".trv_build",
150
154
  "compilerFolder": ".trv_compiler",
151
155
  "packageManager": "npm",
156
+ "version": "3.1.0-rc.0",
157
+ "description": "Support for project indexing, manifesting, along with file watching",
158
+ "frameworkVersion": "3.1.0-rc.0",
152
159
  "modules": {
153
160
  "@travetto/manifest": {
154
161
  "main": true,
@@ -238,19 +245,19 @@ The modules represent all of the [Travetto](https://travetto.dev)-aware dependen
238
245
 
239
246
  ### Module Files
240
247
  The module files are a simple categorization of files into a predetermined set of folders:
241
- * $root - All uncategorized files at the module root
242
- * $index - __index__.ts, index.ts files at the root of the project
243
- * $package - The [Package JSON](https://docs.npmjs.com/cli/v9/configuring-npm/package-json) for the project
244
- * src - Code that should be automatically loaded at runtime. All .ts files under the src/ folder
245
- * test - Code that contains test files. All .ts files under the test/ folder
246
- * test/fixtures - Test resource files, pertains to the main module only. Located under test/fixtures/
247
- * resources - Packaged resource, meant to pertain to the main module only. Files, under resources/
248
- * support - All .ts files under the support/ folder
249
- * support/resources - Packaged resource files, meant to be included by other modules, under support/resources/
250
- * support/fixtures - Test resources meant to shared across modules. Under support/fixtures/
251
- * doc - Documentation files. All .ts files under the doc/ folder
252
- * $transformer - All .ts files under the pattern support/transform*. These are used during compilation and never at runtime
253
- * bin - Entry point .js files. All .js files under the bin/ folder
248
+ * `$root` - All uncategorized files at the module root
249
+ * `$index` - `__index__.ts`, `index.ts` files at the root of the project
250
+ * `$package` - The [Package JSON](https://docs.npmjs.com/cli/v9/configuring-npm/package-json) for the project
251
+ * `src` - Code that should be automatically loaded at runtime. All .ts files under the `src/` folder
252
+ * `test` - Code that contains test files. All .ts files under the `test/` folder
253
+ * `test/fixtures` - Test resource files, pertains to the main module only. Located under `test/fixtures/`
254
+ * `resources` - Packaged resource, meant to pertain to the main module only. Files, under `resources/`
255
+ * `support` - All .ts files under the `support/` folder
256
+ * `support/resources` - Packaged resource files, meant to be included by other modules, under `support/resources/`
257
+ * `support/fixtures` - Test resources meant to shared across modules. Under `support/fixtures/`
258
+ * `doc` - Documentation files. `DOC.tsx` and All .ts/.tsx files under the `doc/` folder
259
+ * `$transformer` - All .ts files under the pattern `support/transform*`. These are used during compilation and never at runtime
260
+ * `bin` - Entry point .js files. All .js files under the `bin/` folder
254
261
  Within each file there is a pattern of either a 3 or 4 element array:
255
262
 
256
263
  **Code: Sample file**
package/bin/context.js CHANGED
@@ -23,20 +23,27 @@ async function $getPkg(inputFolder) {
23
23
  const WS_ROOT = {};
24
24
 
25
25
  /**
26
- * Get module name from a given file
27
- * @param {string} file
28
- * @return {Promise<string|void>}
26
+ * Get module root for a given folder
27
+ * @param {string} dir
28
+ * @return {Promise<string>}
29
29
  */
30
- async function $getModuleFromFile(file) {
31
- let dir = path.dirname(file);
30
+ async function $getModuleRoot(dir) {
32
31
  let prev;
33
32
  while (dir !== prev && !(await fs.stat(path.resolve(dir, 'package.json')).catch(() => false))) {
34
33
  prev = dir;
35
34
  dir = path.dirname(dir);
36
35
  }
37
- try {
38
- return (await $getPkg(dir)).name;
39
- } catch { }
36
+ return dir;
37
+ }
38
+
39
+
40
+ /**
41
+ * Get module name from a given file
42
+ * @param {string} file
43
+ * @return {Promise<string|void>}
44
+ */
45
+ async function $getModuleFromFile(file) {
46
+ return $getPkg(await $getModuleRoot(path.dirname(file))).then(v => v.name, () => { });
40
47
  }
41
48
 
42
49
  /**
@@ -74,6 +81,7 @@ async function $getWorkspaceRoot(base = process.cwd()) {
74
81
  */
75
82
  export async function getManifestContext(folder) {
76
83
  const workspacePath = path.resolve(await $getWorkspaceRoot(folder));
84
+ const req = createRequire(`${workspacePath}/node_modules`);
77
85
 
78
86
  // If manifest specified via env var, and is a package name
79
87
  if (!folder && process.env.TRV_MODULE) {
@@ -81,7 +89,6 @@ export async function getManifestContext(folder) {
81
89
  if (/[.](t|j)s$/.test(process.env.TRV_MODULE)) {
82
90
  process.env.TRV_MODULE = await $getModuleFromFile(process.env.TRV_MODULE) ?? process.env.TRV_MODULE;
83
91
  }
84
- const req = createRequire(`${workspacePath}/node_modules`);
85
92
  try {
86
93
  folder = path.dirname(req.resolve(`${process.env.TRV_MODULE}/package.json`));
87
94
  } catch {
@@ -94,8 +101,8 @@ export async function getManifestContext(folder) {
94
101
  }
95
102
  }
96
103
 
97
- const mainPath = path.resolve(folder ?? '.');
98
- const { name: mainModule, workspaces, travetto } = (await $getPkg(mainPath));
104
+ const mainPath = await $getModuleRoot(path.resolve(folder ?? '.'));
105
+ const { name: mainModule, workspaces, travetto, version, description } = (await $getPkg(mainPath));
99
106
  const monoRepo = workspacePath !== mainPath || !!workspaces;
100
107
  const outputFolder = travetto?.outputFolder ?? '.trv_output';
101
108
 
@@ -104,6 +111,8 @@ export async function getManifestContext(folder) {
104
111
  /** @type {'yarn'|'npm'} */
105
112
  const packageManager = await fs.stat(path.resolve(workspacePath, 'yarn.lock')).then(() => 'yarn', () => 'npm');
106
113
 
114
+ const { version: frameworkVersion } = JSON.parse(await fs.readFile(req.resolve('@travetto/manifest/package.json'), 'utf8'));
115
+
107
116
  const res = {
108
117
  moduleType,
109
118
  mainModule: mainModule ?? 'untitled', // When root package.json is missing a name
@@ -113,7 +122,10 @@ export async function getManifestContext(folder) {
113
122
  outputFolder,
114
123
  toolFolder: '.trv_build',
115
124
  compilerFolder: '.trv_compiler',
116
- packageManager
125
+ packageManager,
126
+ version,
127
+ description,
128
+ frameworkVersion
117
129
  };
118
130
  return res;
119
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/manifest",
3
- "version": "3.0.3",
3
+ "version": "3.1.0-rc.1",
4
4
  "description": "Support for project indexing, manifesting, along with file watching",
5
5
  "keywords": [
6
6
  "path",
@@ -39,9 +39,12 @@ export class ModuleDependencyVisitor implements PackageVisitor<ModuleDep> {
39
39
  .map(x => new RegExp(`^${x.replace(/[*]/g, '.*?')}$`));
40
40
  }
41
41
 
42
- constructor(public ctx: ManifestContext) { }
42
+ constructor(public ctx: ManifestContext) {
43
+ this.#mainSourcePath = path.resolve(this.ctx.workspacePath, this.ctx.mainFolder);
44
+ }
43
45
 
44
46
  #mainPatterns: RegExp[] = [];
47
+ #mainSourcePath: string;
45
48
 
46
49
  /**
47
50
  * Initialize visitor, and provide global dependencies
@@ -76,7 +79,7 @@ export class ModuleDependencyVisitor implements PackageVisitor<ModuleDep> {
76
79
  * Is valid dependency for searching
77
80
  */
78
81
  valid(req: PackageVisitReq<ModuleDep>): boolean {
79
- return req.sourcePath === path.cwd() || (
82
+ return req.sourcePath === this.#mainSourcePath || (
80
83
  req.rel !== 'peer' &&
81
84
  (!!req.pkg.travetto || req.pkg.private === true || !req.sourcePath.includes('node_modules') || req.rel === 'global')
82
85
  );
@@ -261,7 +261,7 @@ export class ManifestIndex {
261
261
  return name ? this.getModule(name) : undefined;
262
262
  }
263
263
  /**
264
- * Build module list from an expression list (e.g. `@travetto/app,-@travetto/log)
264
+ * Build module list from an expression list (e.g. `@travetto/rest,-@travetto/log)
265
265
  */
266
266
  getModuleList(mode: 'local' | 'all', exprList: string = ''): Set<string> {
267
267
  const allMods = Object.keys(this.#manifest.modules);
package/src/package.ts CHANGED
@@ -3,16 +3,24 @@ import fs from 'fs/promises';
3
3
  import { createRequire } from 'module';
4
4
  import { execSync } from 'child_process';
5
5
 
6
- import { ManifestContext, Package, PackageDigest, PackageRel, PackageVisitor, PackageVisitReq, PackageWorkspaceEntry } from './types';
6
+ import { ManifestContext, Package, PackageRel, PackageVisitor, PackageVisitReq, PackageWorkspaceEntry } from './types';
7
7
  import { path } from './path';
8
8
 
9
+ /**
10
+ * Utilities for querying, traversing and reading package.json files.
11
+ */
9
12
  export class PackageUtil {
10
13
 
11
14
  static #req = createRequire(path.resolve('node_modules'));
12
- static #framework: Package;
13
15
  static #cache: Record<string, Package> = {};
14
16
  static #workspaces: Record<string, PackageWorkspaceEntry[]> = {};
15
17
 
18
+ static #exec<T>(cwd: string, cmd: string): Promise<T> {
19
+ const env = { PATH: process.env.PATH, NODE_PATH: process.env.NODE_PATH };
20
+ const text = execSync(cmd, { cwd, encoding: 'utf8', env, stdio: ['pipe', 'pipe'] }).toString().trim();
21
+ return JSON.parse(text);
22
+ }
23
+
16
24
  /**
17
25
  * Clear out cached package file reads
18
26
  */
@@ -108,18 +116,6 @@ export class PackageUtil {
108
116
  return this.readPackage(this.resolvePackagePath(moduleName));
109
117
  }
110
118
 
111
- /**
112
- * Write package
113
- */
114
- static async writePackageIfChanged(modulePath: string, pkg: Package): Promise<void> {
115
- const final = JSON.stringify(pkg, null, 2);
116
- const target = path.resolve(modulePath, 'package.json');
117
- const current = (await fs.readFile(target, 'utf8').catch(() => '')).trim();
118
- if (final !== current) {
119
- await fs.writeFile(target, `${final}\n`, 'utf8');
120
- }
121
- }
122
-
123
119
  /**
124
120
  * Visit packages with ability to track duplicates
125
121
  */
@@ -158,21 +154,6 @@ export class PackageUtil {
158
154
  return (await visitor.complete?.(out)) ?? out;
159
155
  }
160
156
 
161
- /**
162
- * Get version of manifest package
163
- */
164
- static getFrameworkVersion(): string {
165
- return (this.#framework ??= this.importPackage('@travetto/manifest')).version;
166
- }
167
-
168
- /**
169
- * Produce simple digest of
170
- */
171
- static digest(pkg: Package): PackageDigest {
172
- const { main, name, author, license, version } = pkg;
173
- return { name, main, author, license, version, framework: this.getFrameworkVersion() };
174
- }
175
-
176
157
  /**
177
158
  * Find workspace values from rootPath
178
159
  */
@@ -186,15 +167,13 @@ export class PackageUtil {
186
167
  let out: PackageWorkspaceEntry[];
187
168
  switch (ctx.packageManager) {
188
169
  case 'npm': {
189
- const text = execSync('npm query .workspace', { cwd: rootPath, encoding: 'utf8', env: { PATH: process.env.PATH, NODE_PATH: process.env.NODE_PATH } });
190
- out = JSON.parse(text)
191
- .map((d: { location: string, name: string }) => ({ sourcePath: d.location, name: d.name }));
170
+ const res = await this.#exec<{ location: string, name: string }[]>(rootPath, 'npm query .workspace');
171
+ out = res.map(d => ({ sourcePath: d.location, name: d.name }));
192
172
  break;
193
173
  }
194
174
  case 'yarn': {
195
- const text = execSync('yarn -s workspaces info', { cwd: rootPath, encoding: 'utf8', env: { PATH: process.env.PATH, NODE_PATH: process.env.NODE_PATH } });
196
- out = Object.entries<{ location: string }>(JSON.parse(text))
197
- .map(([name, { location }]) => ({ sourcePath: location, name }));
175
+ const res = await this.#exec<Record<string, { location: string }>>(rootPath, 'npm query .workspace');
176
+ out = Object.entries(res).map(([name, { location }]) => ({ sourcePath: location, name }));
198
177
  break;
199
178
  }
200
179
  }
@@ -206,34 +185,4 @@ export class PackageUtil {
206
185
  }
207
186
  return this.#workspaces[rootPath];
208
187
  }
209
-
210
- /**
211
- * Sync versions across a series of folders
212
- */
213
- static async syncVersions(folders: string[], versionMapping: Record<string, string> = {}): Promise<void> {
214
- const packages = folders.map(folder => {
215
- const pkg = this.readPackage(folder, true);
216
- versionMapping[pkg.name] = `^${pkg.version}`;
217
- return { folder, pkg };
218
- });
219
-
220
- for (const { pkg } of packages) {
221
- for (const group of [
222
- pkg.dependencies ?? {},
223
- pkg.devDependencies ?? {},
224
- pkg.optionalDependencies ?? {},
225
- pkg.peerDependencies ?? {}
226
- ]) {
227
- for (const [mod, ver] of Object.entries(versionMapping)) {
228
- if (mod in group && !/^[*]|(file:.*)$/.test(group[mod])) {
229
- group[mod] = ver;
230
- }
231
- }
232
- }
233
- }
234
-
235
- for (const { folder, pkg } of packages) {
236
- await this.writePackageIfChanged(folder, pkg);
237
- }
238
- }
239
188
  }
package/src/root-index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { path } from './path';
2
2
  import { IndexedModule, ManifestIndex } from './manifest-index';
3
- import { FunctionMetadata, Package, PackageDigest } from './types';
4
- import { PackageUtil } from './package';
3
+ import { FunctionMetadata, ManifestContext, Package } from './types';
5
4
 
6
5
  const METADATA = Symbol.for('@travetto/manifest:metadata');
7
6
  type Metadated = { [METADATA]: FunctionMetadata };
@@ -11,7 +10,6 @@ type Metadated = { [METADATA]: FunctionMetadata };
11
10
  */
12
11
  class $RootIndex extends ManifestIndex {
13
12
 
14
- #config: Package | undefined;
15
13
  #metadata = new Map<string, FunctionMetadata>();
16
14
 
17
15
  /**
@@ -20,7 +18,6 @@ class $RootIndex extends ManifestIndex {
20
18
  */
21
19
  reinitForModule(module: string): void {
22
20
  this.init(`${this.outputRoot}/node_modules/${module}`);
23
- this.#config = undefined;
24
21
  }
25
22
 
26
23
  /**
@@ -49,32 +46,28 @@ class $RootIndex extends ManifestIndex {
49
46
  }
50
47
 
51
48
  /**
52
- * Get main module for manifest
49
+ * Get main module name
53
50
  */
54
- get mainModule(): IndexedModule {
55
- return this.getModule(this.mainPackage.name)!;
51
+ get mainModuleName(): string {
52
+ return this.manifest.mainModule;
56
53
  }
57
54
 
58
55
  /**
59
- * Get main package for manifest
56
+ * Get main module for manifest
60
57
  */
61
- get mainPackage(): Package {
62
- if (!this.#config) {
63
- const { outputPath } = this.getModule(this.manifest.mainModule)!;
64
- this.#config = {
65
- ...{
66
- name: 'untitled',
67
- description: 'A Travetto application',
68
- version: '0.0.0',
69
- },
70
- ...PackageUtil.readPackage(outputPath)
71
- };
72
- }
73
- return this.#config;
58
+ get mainModule(): IndexedModule {
59
+ return this.getModule(this.mainModuleName)!;
74
60
  }
75
61
 
76
- mainDigest(): PackageDigest {
77
- return PackageUtil.digest(this.mainPackage);
62
+ /**
63
+ * Digest manifest
64
+ */
65
+ manifestDigest(): Pick<ManifestContext, 'mainModule' | 'frameworkVersion' | 'version'> {
66
+ return {
67
+ mainModule: this.manifest.mainModule,
68
+ frameworkVersion: this.manifest.frameworkVersion,
69
+ version: this.manifest.version,
70
+ };
78
71
  }
79
72
 
80
73
  /**
package/src/types.ts CHANGED
@@ -35,6 +35,9 @@ export type ManifestContext = {
35
35
  monoRepo?: boolean;
36
36
  moduleType: 'module' | 'commonjs';
37
37
  packageManager: 'yarn' | 'npm';
38
+ frameworkVersion: string;
39
+ description: string;
40
+ version: string;
38
41
  };
39
42
 
40
43
  export type ManifestRoot = ManifestContext & {
@@ -86,9 +89,6 @@ export type Package = {
86
89
  publishConfig?: { access?: 'restricted' | 'public' };
87
90
  };
88
91
 
89
- export type PackageDigestField = 'name' | 'main' | 'author' | 'license' | 'version';
90
- export type PackageDigest = Pick<Package, PackageDigestField> & { framework: string };
91
-
92
92
  type OrProm<T> = T | Promise<T>;
93
93
 
94
94
  export type PackageVisitReq<T> = { pkg: Package, rel: PackageRel, sourcePath: string, parent?: T };
package/src/util.ts CHANGED
@@ -76,6 +76,7 @@ export class ManifestUtil {
76
76
  file = path.resolve(file, MANIFEST_FILE);
77
77
  }
78
78
  const manifest: ManifestRoot = JSON.parse(readFileSync(file, 'utf8'));
79
+ // Support packaged environments, by allowing empty outputFolder
79
80
  if (!manifest.outputFolder) {
80
81
  manifest.outputFolder = path.cwd();
81
82
  manifest.workspacePath = path.cwd();
package/src/watch.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { watch, Stats } from 'fs';
2
2
  import fs from 'fs/promises';
3
+
3
4
  import { path } from './path';
4
5
 
5
6
  async function getWatcher(): Promise<typeof import('@parcel/watcher')> {
@@ -11,15 +12,25 @@ async function getWatcher(): Promise<typeof import('@parcel/watcher')> {
11
12
  }
12
13
  }
13
14
 
14
- export type WatchEvent = { action: 'create' | 'update' | 'delete', file: string };
15
- export type WatchEventFilter = (ev: WatchEvent) => boolean;
15
+ export type WatchEvent = { action: 'create' | 'update' | 'delete', file: string, folder: string };
16
16
 
17
- export type WatchEventListener = (ev: WatchEvent, folder: string) => void;
18
- export type WatchConfig = {
17
+ export type WatchFolder = {
18
+ /**
19
+ * Source folder
20
+ */
21
+ src: string;
22
+ /**
23
+ * Target folder name, useful for deconstructing
24
+ */
25
+ target?: string;
26
+ /**
27
+ * Filter events
28
+ */
29
+ filter?: (ev: WatchEvent) => boolean;
19
30
  /**
20
- * Predicate for filtering events
31
+ * Only look at immediate folder
21
32
  */
22
- filter?: WatchEventFilter;
33
+ immediate?: boolean;
23
34
  /**
24
35
  * List of top level folders to ignore
25
36
  */
@@ -32,25 +43,66 @@ export type WatchConfig = {
32
43
  * Include files that start with '.'
33
44
  */
34
45
  includeHidden?: boolean;
35
- /**
36
- * Should watching prevent normal exiting, until watch is removed?
37
- */
38
- persistent?: boolean;
39
46
  };
40
47
 
48
+ export type WatchStream = AsyncIterable<WatchEvent> & { close: () => Promise<void> };
49
+
50
+ const DEDUPE_THRESHOLD = 50;
51
+
52
+ class Queue<X> implements AsyncIterator<X>, AsyncIterable<X> {
53
+ #queue: X[] = [];
54
+ #done = false;
55
+ #ready: Promise<void>;
56
+ #fire: (() => void);
57
+ #onClose: (() => (void | Promise<void>))[] = [];
58
+ #recentKeys = new Map<string, number>();
59
+
60
+ constructor() {
61
+ this.#ready = new Promise(r => this.#fire = r);
62
+ }
63
+
64
+ // Allow for iteration
65
+ [Symbol.asyncIterator](): AsyncIterator<X> { return this; }
66
+
67
+ async next(): Promise<IteratorResult<X>> {
68
+ while (!this.#done && !this.#queue.length) {
69
+ this.#recentKeys = new Map([...this.#recentKeys.entries()] // Cull before waiting
70
+ .filter(([, time]) => (Date.now() - time) < DEDUPE_THRESHOLD));
71
+ await this.#ready;
72
+ this.#ready = new Promise(r => this.#fire = r);
73
+ }
74
+ return { value: this.#queue.shift()!, done: this.#done };
75
+ }
76
+
77
+ add(item: X | X[]): void {
78
+ const now = Date.now();
79
+ for (const value of Array.isArray(item) ? item : [item]) {
80
+ const key = JSON.stringify(value);
81
+ if ((now - (this.#recentKeys.get(key) ?? 0)) > DEDUPE_THRESHOLD) {
82
+ this.#queue.push(value);
83
+ this.#recentKeys.set(key, now);
84
+ this.#fire();
85
+ }
86
+ }
87
+ }
88
+
89
+ registerOnClose(handler: () => (void | Promise<void>)): void {
90
+ this.#onClose.push(handler);
91
+ }
92
+
93
+ async close(): Promise<void> {
94
+ this.#done = true;
95
+ this.#fire();
96
+ await Promise.all(this.#onClose.map(x => x()));
97
+ }
98
+ }
99
+
41
100
  /**
42
- * Watch files for a given folder
43
- * @param folder
44
- * @param onEvent
45
- * @param options
101
+ * Watch immediate files for a given folder
46
102
  */
47
- export async function watchFolderImmediate(
48
- folder: string,
49
- onEvent: WatchEventListener,
50
- options: WatchConfig = {}
51
- ): Promise<() => Promise<void>> {
52
- const watchPath = path.resolve(folder);
53
- const watcher = watch(watchPath, { persistent: options.persistent, encoding: 'utf8' });
103
+ async function watchFolderImmediate(queue: Queue<WatchEvent>, options: WatchFolder): Promise<void> {
104
+ const watchPath = path.resolve(options.src);
105
+ const watcher = watch(watchPath, { persistent: true, encoding: 'utf8' });
54
106
  const lastStats: Record<string, Stats | undefined> = {};
55
107
  const invalidFilter = (el: string): boolean =>
56
108
  (el === '.' || el === '..' || (!options.includeHidden && el.startsWith('.')) || !!options.ignore?.includes(el));
@@ -62,6 +114,9 @@ export async function watchFolderImmediate(
62
114
  const file = path.resolve(watchPath, el);
63
115
  lastStats[file] = await fs.stat(file);
64
116
  }
117
+
118
+ const target = options.target ?? options.src;
119
+
65
120
  watcher.on('change', async (type: string, file: string): Promise<void> => {
66
121
  if (invalidFilter(file)) {
67
122
  return;
@@ -78,69 +133,73 @@ export async function watchFolderImmediate(
78
133
  }
79
134
  let ev: WatchEvent;
80
135
  if (prevStat && !stat) {
81
- ev = { action: 'delete', file };
136
+ ev = { action: 'delete', file, folder: target };
82
137
  } else if (!prevStat && stat) {
83
- ev = { action: 'create', file };
138
+ ev = { action: 'create', file, folder: target };
84
139
  } else {
85
- ev = { action: 'update', file };
140
+ ev = { action: 'update', file, folder: target };
86
141
  }
87
142
  if (!options.filter || options.filter(ev)) {
88
- onEvent(ev, folder);
143
+ queue.add(ev);
89
144
  }
90
145
  });
91
- process.on('exit', () => watcher.close());
92
- return async () => watcher.close();
146
+
147
+ queue.registerOnClose(() => watcher.close());
93
148
  }
94
149
 
95
150
  /**
96
- * Leverages @parcel/watcher to watch a series of folders
97
- * @param folders
98
- * @param onEvent
99
- * @param options
151
+ * Watch recursive files for a given folder
100
152
  */
101
- export async function watchFolders(
102
- folders: string[] | [folder: string, targetFolder: string][] | (readonly [folder: string, targetFolder: string])[],
103
- onEvent: WatchEventListener,
104
- config: WatchConfig = {}
105
- ): Promise<() => Promise<void>> {
153
+ async function watchFolderRecursive(queue: Queue<WatchEvent>, options: WatchFolder): Promise<void> {
106
154
  const lib = await getWatcher();
107
- const createMissing = config.createMissing ?? false;
108
- const validFolders = new Set(folders.map(x => typeof x === 'string' ? x : x[0]));
109
-
110
- const subs = await Promise.all(folders.map(async value => {
111
- const folder = typeof value === 'string' ? value : value[0];
112
- const targetFolder = typeof value === 'string' ? value : value[1];
113
-
114
- if (await fs.stat(folder).then(() => true, () => createMissing)) {
115
- await fs.mkdir(folder, { recursive: true });
116
- const ignore = (await fs.readdir(folder)).filter(x => x.startsWith('.') && x.length > 2);
117
- return lib.subscribe(folder, (err, events) => {
118
- for (const ev of events) {
119
- const finalEv = { action: ev.type, file: path.toPosix(ev.path) };
120
- if (ev.type === 'delete' && validFolders.has(finalEv.file)) {
121
- return process.exit(0); // Exit when watched folder is removed
122
- }
123
- const isHidden = !config.includeHidden && finalEv.file.replace(targetFolder, '').includes('/.');
124
- const matches = !isHidden && (!config.filter || config.filter(finalEv));
125
- if (matches) {
126
- onEvent(finalEv, targetFolder);
155
+ const target = options.target ?? options.src;
156
+
157
+ if (await fs.stat(options.src).then(() => true, () => options.createMissing)) {
158
+ await fs.mkdir(options.src, { recursive: true });
159
+ const ignore = (await fs.readdir(options.src)).filter(x => x.startsWith('.') && x.length > 2);
160
+ const cleanup = await lib.subscribe(options.src, async (err, events) => {
161
+ for (const ev of events) {
162
+ const finalEv = { action: ev.type, file: path.toPosix(ev.path), folder: target };
163
+ if (ev.type !== 'delete') {
164
+ const stats = await fs.stat(finalEv.file);
165
+ if ((stats.ctimeMs - Date.now()) < DEDUPE_THRESHOLD) {
166
+ ev.type = 'create'; // Force create on newly stated files
127
167
  }
128
168
  }
129
- }, { ignore: [...ignore, ...config.ignore ?? []] });
130
- }
131
- }));
132
-
133
- // Allow for multiple calls
134
- let finalProm: Promise<void> | undefined;
135
- const remove = (): Promise<void> => finalProm ??= Promise.all(subs.map(x => x?.unsubscribe())).then(() => { });
136
169
 
137
- // Cleanup on intent to exit
138
- if (!config.persistent) {
139
- process.on('SIGUSR2', remove);
170
+ if (ev.type === 'delete' && finalEv.file === options.src) {
171
+ return queue.close();
172
+ }
173
+ const isHidden = !options.includeHidden && finalEv.file.replace(target, '').includes('/.');
174
+ const matches = !isHidden && (!options.filter || options.filter(finalEv));
175
+ if (matches) {
176
+ queue.add(finalEv);
177
+ }
178
+ }
179
+ }, { ignore: [...ignore, ...options.ignore ?? []] });
180
+ queue.registerOnClose(() => cleanup.unsubscribe());
140
181
  }
182
+ }
141
183
 
142
- // Cleanup on exit
143
- process.on('exit', remove);
144
-
145
- return remove;
184
+ /**
185
+ * Watch a series of folders
186
+ * @param folders
187
+ * @param onEvent
188
+ * @param options
189
+ */
190
+ export function watchFolders(
191
+ folders: string[] | WatchFolder[],
192
+ config: Omit<WatchFolder, 'src' | 'target'> = {}
193
+ ): WatchStream {
194
+ const queue = new Queue<WatchEvent>();
195
+ for (const folder of folders) {
196
+ if (typeof folder === 'string') {
197
+ watchFolderRecursive(queue, { ...config, src: folder });
198
+ } else if (!folder.immediate) {
199
+ watchFolderRecursive(queue, { ...config, ...folder });
200
+ } else {
201
+ watchFolderImmediate(queue, { ...config, ...folder });
202
+ }
203
+ }
204
+ return queue;
146
205
  }