@avijs/compiler 1.0.0-rc.4 → 1.0.0-rc.5
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/build.d.ts.map +1 -1
- package/dist/build.js +13 -91
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +24 -6
- package/dist/client.js.map +1 -1
- package/dist/inject.d.ts.map +1 -1
- package/dist/inject.js +93 -99
- package/dist/inject.js.map +1 -1
- package/dist/outputs.d.ts +11 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +116 -0
- package/dist/outputs.js.map +1 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +23 -1
- package/dist/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +12 -103
- package/src/client.ts +28 -7
- package/src/inject.ts +104 -110
- package/src/outputs.ts +143 -0
- package/src/watch.ts +21 -1
package/src/inject.ts
CHANGED
|
@@ -61,13 +61,19 @@ class InjectGenerator {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
run(): GeneratedInjects {
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const devOnly = devOnlyPackages(this.program);
|
|
65
|
+
for (const source of this.program.getSourceFiles()) {
|
|
66
|
+
if (!this.#inProject(source) || path.resolve(source.fileName) === this.out) continue;
|
|
67
|
+
// Test files are not loaded by the app, and the generated file must not import them.
|
|
68
|
+
if (/\.(test|spec)\.[cm]?tsx?$/.test(source.fileName)) continue;
|
|
69
|
+
const classes = source.statements.filter(ts.isClassDeclaration);
|
|
70
|
+
if (classes.length === 0) continue;
|
|
71
|
+
// Importing a file runs its top level. Only import files that just declare things: never an
|
|
72
|
+
// entry point, a worker or a seed script, and nothing a production install may lack.
|
|
73
|
+
const reason = runsCode(source)
|
|
74
|
+
? 'runs code when imported (an entry point or a script)'
|
|
75
|
+
: importsDevOnly(source, devOnly);
|
|
76
|
+
for (const node of classes) this.#class(node, source, reason);
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
this.#entries.sort((a, b) => a.order.localeCompare(b.order));
|
|
@@ -111,7 +117,7 @@ class InjectGenerator {
|
|
|
111
117
|
};
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
#class(node: ts.ClassDeclaration, source: ts.SourceFile): void {
|
|
120
|
+
#class(node: ts.ClassDeclaration, source: ts.SourceFile, unsafe: string | undefined): void {
|
|
115
121
|
if (!node.name) return;
|
|
116
122
|
const hasList = node.members.some(
|
|
117
123
|
(member) =>
|
|
@@ -150,6 +156,15 @@ class InjectGenerator {
|
|
|
150
156
|
return;
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
if (unsafe) {
|
|
160
|
+
this.#report(
|
|
161
|
+
source,
|
|
162
|
+
node.name,
|
|
163
|
+
`${node.name.text} is in a file that ${unsafe}, so inject.gen.ts cannot import it: move the class to its own file, or add static inject`,
|
|
164
|
+
'AVI203',
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
153
168
|
const cls = this.#importClass(node, source);
|
|
154
169
|
if (!cls) return;
|
|
155
170
|
this.#entries.push({
|
|
@@ -207,42 +222,6 @@ class InjectGenerator {
|
|
|
207
222
|
return callee === 'createToken' || callee === 'defineConfig';
|
|
208
223
|
}
|
|
209
224
|
|
|
210
|
-
/** The project's files the app loads: value imports followed from its entry points. */
|
|
211
|
-
#loaded(): ts.SourceFile[] {
|
|
212
|
-
const project = this.program
|
|
213
|
-
.getSourceFiles()
|
|
214
|
-
.filter(
|
|
215
|
-
(source) => this.#inProject(source) && !/\.(test|spec)\.[cm]?tsx?$/.test(source.fileName),
|
|
216
|
-
);
|
|
217
|
-
const entries = project.filter((source) => startsApp(source));
|
|
218
|
-
if (entries.length === 0) {
|
|
219
|
-
const first = project[0];
|
|
220
|
-
if (first) {
|
|
221
|
-
this.#report(
|
|
222
|
-
first,
|
|
223
|
-
first,
|
|
224
|
-
'No app entry found (AviFactory.create, register, createAvi, runWorker or cluster): no dependency lists inferred',
|
|
225
|
-
'AVI202',
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
return [];
|
|
229
|
-
}
|
|
230
|
-
const seen = new Set<ts.SourceFile>(entries);
|
|
231
|
-
const queue = [...entries];
|
|
232
|
-
for (let source = queue.shift(); source; source = queue.shift()) {
|
|
233
|
-
for (const specifier of valueImports(source)) {
|
|
234
|
-
const file = this.#checker
|
|
235
|
-
.getSymbolAtLocation(specifier)
|
|
236
|
-
?.declarations?.find(ts.isSourceFile);
|
|
237
|
-
if (file && !seen.has(file) && this.#inProject(file)) {
|
|
238
|
-
seen.add(file);
|
|
239
|
-
queue.push(file);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return [...seen];
|
|
244
|
-
}
|
|
245
|
-
|
|
246
225
|
#importClass(node: ts.ClassDeclaration, source: ts.SourceFile): string | undefined {
|
|
247
226
|
const symbol = node.name && this.#checker.getSymbolAtLocation(node.name);
|
|
248
227
|
const name = symbol && this.#exportName(symbol, source);
|
|
@@ -346,78 +325,93 @@ function isDecorated(node: ts.ClassDeclaration): boolean {
|
|
|
346
325
|
return (ts.getDecorators(node) ?? []).length > 0;
|
|
347
326
|
}
|
|
348
327
|
|
|
349
|
-
/** Whether a file
|
|
350
|
-
function
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
328
|
+
/** Whether a file does more than declare things when imported: a call, a loop, a top-level await. */
|
|
329
|
+
function runsCode(source: ts.SourceFile): boolean {
|
|
330
|
+
const awaits = (node: ts.Node): boolean =>
|
|
331
|
+
ts.isAwaitExpression(node) ||
|
|
332
|
+
(!ts.isFunctionLike(node) && !ts.isClassLike(node) && ts.forEachChild(node, awaits) === true);
|
|
333
|
+
return source.statements.some((statement) => {
|
|
334
|
+
if (
|
|
335
|
+
ts.isImportDeclaration(statement) ||
|
|
336
|
+
ts.isImportEqualsDeclaration(statement) ||
|
|
337
|
+
ts.isExportDeclaration(statement) ||
|
|
338
|
+
ts.isClassDeclaration(statement) ||
|
|
339
|
+
ts.isFunctionDeclaration(statement) ||
|
|
340
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
341
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
342
|
+
ts.isEnumDeclaration(statement) ||
|
|
343
|
+
ts.isModuleDeclaration(statement) ||
|
|
344
|
+
ts.isEmptyStatement(statement)
|
|
345
|
+
) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
if (ts.isVariableStatement(statement) || ts.isExportAssignment(statement)) {
|
|
349
|
+
return awaits(statement);
|
|
350
|
+
}
|
|
351
|
+
// 'use strict' and other directives.
|
|
352
|
+
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression))
|
|
353
|
+
return false;
|
|
354
|
+
return true;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Packages the project only has as devDependencies, from the package.json next to its tsconfig. */
|
|
359
|
+
function devOnlyPackages(program: ts.Program): Set<string> {
|
|
360
|
+
const config = program.getCompilerOptions()['configFilePath'];
|
|
361
|
+
let dir = typeof config === 'string' ? path.dirname(config) : program.getCurrentDirectory();
|
|
362
|
+
for (;;) {
|
|
363
|
+
const file = path.join(dir, 'package.json');
|
|
364
|
+
if (ts.sys.fileExists(file)) {
|
|
365
|
+
try {
|
|
366
|
+
const pkg = JSON.parse(ts.sys.readFile(file) ?? '{}') as Record<
|
|
367
|
+
string,
|
|
368
|
+
Record<string, string> | undefined
|
|
369
|
+
>;
|
|
370
|
+
const runtime = new Set([
|
|
371
|
+
...Object.keys(pkg['dependencies'] ?? {}),
|
|
372
|
+
...Object.keys(pkg['peerDependencies'] ?? {}),
|
|
373
|
+
...Object.keys(pkg['optionalDependencies'] ?? {}),
|
|
374
|
+
]);
|
|
375
|
+
return new Set(
|
|
376
|
+
Object.keys(pkg['devDependencies'] ?? {}).filter((name) => !runtime.has(name)),
|
|
377
|
+
);
|
|
378
|
+
} catch {
|
|
379
|
+
return new Set();
|
|
374
380
|
}
|
|
375
381
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
382
|
+
const parent = path.dirname(dir);
|
|
383
|
+
if (parent === dir) return new Set();
|
|
384
|
+
dir = parent;
|
|
385
|
+
}
|
|
380
386
|
}
|
|
381
387
|
|
|
382
|
-
/**
|
|
383
|
-
function
|
|
384
|
-
const found: ts.StringLiteral[] = [];
|
|
388
|
+
/** Why a file cannot be imported in production: it imports a package from devDependencies. */
|
|
389
|
+
function importsDevOnly(source: ts.SourceFile, devOnly: ReadonlySet<string>): string | undefined {
|
|
385
390
|
for (const statement of source.statements) {
|
|
386
|
-
if (ts.isImportDeclaration(statement)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (!allTypes) found.push(statement.moduleSpecifier);
|
|
398
|
-
} else if (
|
|
399
|
-
ts.isExportDeclaration(statement) &&
|
|
400
|
-
!statement.isTypeOnly &&
|
|
401
|
-
statement.moduleSpecifier &&
|
|
402
|
-
ts.isStringLiteral(statement.moduleSpecifier)
|
|
403
|
-
) {
|
|
404
|
-
found.push(statement.moduleSpecifier);
|
|
405
|
-
}
|
|
391
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
392
|
+
continue;
|
|
393
|
+
const clause = statement.importClause;
|
|
394
|
+
// Type-only imports are erased.
|
|
395
|
+
if (clause && (clause.getText(source).startsWith('type ') || isTypeOnlyClause(clause)))
|
|
396
|
+
continue;
|
|
397
|
+
const specifier = statement.moduleSpecifier.text;
|
|
398
|
+
const name = specifier.startsWith('@')
|
|
399
|
+
? specifier.split('/').slice(0, 2).join('/')
|
|
400
|
+
: specifier.split('/')[0];
|
|
401
|
+
if (name && devOnly.has(name)) return `imports ${name}, a devDependency`;
|
|
406
402
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
visit(source);
|
|
420
|
-
return found;
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isTypeOnlyClause(clause: ts.ImportClause): boolean {
|
|
407
|
+
const named = clause.namedBindings;
|
|
408
|
+
return (
|
|
409
|
+
clause.name === undefined &&
|
|
410
|
+
named !== undefined &&
|
|
411
|
+
ts.isNamedImports(named) &&
|
|
412
|
+
named.elements.length > 0 &&
|
|
413
|
+
named.elements.every((element) => element.isTypeOnly)
|
|
414
|
+
);
|
|
421
415
|
}
|
|
422
416
|
|
|
423
417
|
export const plural = (count: number, noun: string): string =>
|
package/src/outputs.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import ts from 'typescript';
|
|
13
|
+
|
|
14
|
+
/** Lists the files `avi build` and `avi dev` compiled into an output folder. */
|
|
15
|
+
const MARKER = '.avi-build';
|
|
16
|
+
const OUTPUT = /\.(?:[cm]?js|d\.[cm]?ts|map)$/;
|
|
17
|
+
|
|
18
|
+
interface OutputFolder {
|
|
19
|
+
/** The real path. */
|
|
20
|
+
readonly out: string;
|
|
21
|
+
/** The configured path, as the compiler writes to it. */
|
|
22
|
+
readonly configured: string;
|
|
23
|
+
readonly key: (relative: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The output folder, when it is safe to clean: a real folder inside the project, reached without
|
|
28
|
+
* links, not hidden or `node_modules`.
|
|
29
|
+
*/
|
|
30
|
+
function outputFolder(outDir: string | undefined, projectDir: string): OutputFolder | undefined {
|
|
31
|
+
if (!outDir || !existsSync(outDir)) return undefined;
|
|
32
|
+
let out: string;
|
|
33
|
+
let root: string;
|
|
34
|
+
try {
|
|
35
|
+
out = realpathSync.native(outDir);
|
|
36
|
+
root = realpathSync.native(projectDir);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const ignoreCase = !ts.sys.useCaseSensitiveFileNames;
|
|
41
|
+
const key = (relative: string) => (ignoreCase ? relative.toLowerCase() : relative);
|
|
42
|
+
const inside = path.relative(root, out);
|
|
43
|
+
// A link between the project and the output folder changes the path inside the project. Links
|
|
44
|
+
// above the project (macOS /var, Windows short names) do not matter.
|
|
45
|
+
const configured = path.relative(path.resolve(projectDir), path.resolve(outDir));
|
|
46
|
+
if (key(inside) !== key(configured)) return undefined;
|
|
47
|
+
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) return undefined;
|
|
48
|
+
if (inside.split(path.sep).some((part) => part.startsWith('.') || part === 'node_modules')) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return { out, configured: path.resolve(outDir), key };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Paths by their place in the output folder: the real and configured paths may differ above it. */
|
|
55
|
+
function relativeTo(folder: OutputFolder, written: Iterable<string>): string[] {
|
|
56
|
+
return [...written]
|
|
57
|
+
.map((file) => path.relative(folder.configured, file))
|
|
58
|
+
.filter((relative) => relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
|
59
|
+
.map((relative) => relative.split(path.sep).join('/'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Deletes the files an earlier build wrote to `outDir` and this one did not, such as a deleted
|
|
64
|
+
* module's tests. Nothing else in the folder is touched: hand-written files, a bundler's assets.
|
|
65
|
+
*/
|
|
66
|
+
export function removeStale(
|
|
67
|
+
outDir: string | undefined,
|
|
68
|
+
projectDir: string,
|
|
69
|
+
written: Iterable<string>,
|
|
70
|
+
): void {
|
|
71
|
+
const folder = outputFolder(outDir, projectDir);
|
|
72
|
+
if (!folder) return;
|
|
73
|
+
const { out, key } = folder;
|
|
74
|
+
const current = relativeTo(folder, written).sort();
|
|
75
|
+
const keep = new Set(current.map(key));
|
|
76
|
+
for (const relative of readMarker(out)) {
|
|
77
|
+
if (keep.has(key(relative)) || !OUTPUT.test(relative)) continue;
|
|
78
|
+
const file = path.resolve(out, relative);
|
|
79
|
+
const within = path.relative(out, file);
|
|
80
|
+
if (!within || within.startsWith('..') || path.isAbsolute(within)) continue;
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = lstatSync(file);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Only a plain file: never a link, and never through one.
|
|
88
|
+
const dir = path.dirname(file);
|
|
89
|
+
if (!stat.isFile() || key(realpathSync.native(dir)) !== key(dir)) continue;
|
|
90
|
+
rmSync(file);
|
|
91
|
+
removeEmptyDirs(path.dirname(file), out);
|
|
92
|
+
}
|
|
93
|
+
writeMarker(out, current);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adds files a watch build wrote to the list, without removing anything: it only emits what
|
|
98
|
+
* changed. The next full build removes the ones whose source is gone.
|
|
99
|
+
*/
|
|
100
|
+
export function recordOutputs(
|
|
101
|
+
outDir: string | undefined,
|
|
102
|
+
projectDir: string,
|
|
103
|
+
written: Iterable<string>,
|
|
104
|
+
): void {
|
|
105
|
+
const folder = outputFolder(outDir, projectDir);
|
|
106
|
+
if (!folder) return;
|
|
107
|
+
const files = new Map(readMarker(folder.out).map((file) => [folder.key(file), file]));
|
|
108
|
+
const before = files.size;
|
|
109
|
+
for (const file of relativeTo(folder, written)) files.set(folder.key(file), file);
|
|
110
|
+
if (files.size === before && existsSync(path.join(folder.out, MARKER))) return;
|
|
111
|
+
writeMarker(folder.out, [...files.values()].sort());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeMarker(out: string, files: string[]): void {
|
|
115
|
+
writeFileSync(
|
|
116
|
+
path.join(out, MARKER),
|
|
117
|
+
`${JSON.stringify({ note: 'Written by avi build and avi dev: the files they compiled here, so the next build can remove the ones whose source is gone.', files }, null, 2)}\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readMarker(out: string): string[] {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(readFileSync(path.join(out, MARKER), 'utf8')) as {
|
|
124
|
+
files?: unknown;
|
|
125
|
+
};
|
|
126
|
+
return Array.isArray(parsed.files)
|
|
127
|
+
? parsed.files.filter((file): file is string => typeof file === 'string')
|
|
128
|
+
: [];
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function removeEmptyDirs(dir: string, stop: string): void {
|
|
135
|
+
for (
|
|
136
|
+
let current = dir;
|
|
137
|
+
current !== stop && current.startsWith(stop);
|
|
138
|
+
current = path.dirname(current)
|
|
139
|
+
) {
|
|
140
|
+
if (readdirSync(current).length > 0) return;
|
|
141
|
+
rmdirSync(current);
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/watch.ts
CHANGED
|
@@ -4,6 +4,7 @@ import ts from 'typescript';
|
|
|
4
4
|
import { formatDiagnostic, type Diagnostic } from './check.js';
|
|
5
5
|
import { generateFrom, sameContent } from './client.js';
|
|
6
6
|
import { INJECT_HEADER, generateInjectsFrom, plural } from './inject.js';
|
|
7
|
+
import { recordOutputs } from './outputs.js';
|
|
7
8
|
|
|
8
9
|
export interface WatchProjectOptions {
|
|
9
10
|
/** Path to tsconfig.json. */
|
|
@@ -31,10 +32,20 @@ export interface ProjectWatcher {
|
|
|
31
32
|
*/
|
|
32
33
|
export function watchProject(options: WatchProjectOptions): ProjectWatcher {
|
|
33
34
|
const emit = options.emit ?? true;
|
|
35
|
+
// What each build emits goes on avi build's list, so it can remove outputs of deleted files.
|
|
36
|
+
const projectDir = path.dirname(path.resolve(options.project));
|
|
37
|
+
const emitted = new Set<string>();
|
|
38
|
+
const system: ts.System = {
|
|
39
|
+
...ts.sys,
|
|
40
|
+
writeFile: (file, text, bom) => {
|
|
41
|
+
ts.sys.writeFile(file, text, bom);
|
|
42
|
+
emitted.add(path.resolve(file));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
34
45
|
const host = ts.createWatchCompilerHost(
|
|
35
46
|
path.resolve(options.project),
|
|
36
47
|
emit ? { preserveWatchOutput: true } : { noEmit: true },
|
|
37
|
-
|
|
48
|
+
system,
|
|
38
49
|
ts.createEmitAndSemanticDiagnosticsBuilderProgram,
|
|
39
50
|
undefined,
|
|
40
51
|
// Without emit the only output is the generated files'; tsc's status lines would be noise.
|
|
@@ -42,8 +53,11 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
|
|
|
42
53
|
);
|
|
43
54
|
|
|
44
55
|
let generatedErrors = 0;
|
|
56
|
+
// A file event can still start a build after close(); it must not write or report.
|
|
57
|
+
let closed = false;
|
|
45
58
|
const status = host.onWatchStatusChange?.bind(host);
|
|
46
59
|
host.onWatchStatusChange = (diagnostic, newLine, compilerOptions, errorCount) => {
|
|
60
|
+
if (closed) return;
|
|
47
61
|
status?.(diagnostic, newLine, compilerOptions, errorCount);
|
|
48
62
|
if (errorCount !== undefined) options.onBuild?.(errorCount + generatedErrors);
|
|
49
63
|
};
|
|
@@ -51,6 +65,7 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
|
|
|
51
65
|
const writers = generatedWriters(options);
|
|
52
66
|
const build = host.afterProgramCreate?.bind(host);
|
|
53
67
|
host.afterProgramCreate = (builder) => {
|
|
68
|
+
if (closed) return;
|
|
54
69
|
const program = builder.getProgram();
|
|
55
70
|
const results = writers.map((writer) => ({ out: writer.out, ...writer.write(program) }));
|
|
56
71
|
generatedErrors = results.reduce((sum, result) => sum + result.errors, 0);
|
|
@@ -62,11 +77,16 @@ export function watchProject(options: WatchProjectOptions): ProjectWatcher {
|
|
|
62
77
|
// report its stale errors. A file outside the program starts nothing, so build now.
|
|
63
78
|
if (results.some((result) => result.written && program.getSourceFile(result.out))) return;
|
|
64
79
|
build?.(builder);
|
|
80
|
+
if (emitted.size > 0) {
|
|
81
|
+
recordOutputs(program.getCompilerOptions().outDir, projectDir, emitted);
|
|
82
|
+
emitted.clear();
|
|
83
|
+
}
|
|
65
84
|
};
|
|
66
85
|
|
|
67
86
|
const watcher = ts.createWatchProgram(host);
|
|
68
87
|
return {
|
|
69
88
|
close: () => {
|
|
89
|
+
closed = true;
|
|
70
90
|
watcher.close();
|
|
71
91
|
},
|
|
72
92
|
};
|