UNPKG

21.7 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3exports.Compiler = exports.JSII_DIAGNOSTICS_CODE = exports.DIAGNOSTICS = void 0;
4const fs = require("node:fs");
5const path = require("node:path");
6const chalk = require("chalk");
7const log4js = require("log4js");
8const ts = require("typescript");
9const assembler_1 = require("./assembler");
10const find_utils_1 = require("./common/find-utils");
11const downlevel_dts_1 = require("./downlevel-dts");
12const jsii_diagnostic_1 = require("./jsii-diagnostic");
13const deprecation_warnings_1 = require("./transforms/deprecation-warnings");
14const tsconfig_1 = require("./tsconfig");
15const compiler_options_1 = require("./tsconfig/compiler-options");
16const tsconfig_validator_1 = require("./tsconfig/tsconfig-validator");
17const validator_1 = require("./tsconfig/validator");
18const utils = require("./utils");
19const LOG = log4js.getLogger('jsii/compiler');
20exports.DIAGNOSTICS = 'diagnostics';
21exports.JSII_DIAGNOSTICS_CODE = 9999;
22class Compiler {
23 constructor(options) {
24 this.options = options;
25 this.rootFiles = [];
26 if (options.generateTypeScriptConfig != null && options.typeScriptConfig != null) {
27 throw new Error('Cannot use `generateTypeScriptConfig` and `typeScriptConfig` together. Provide only one of them.');
28 }
29 this.projectRoot = this.options.projectInfo.projectRoot;
30 const configFileName = options.typeScriptConfig ?? options.generateTypeScriptConfig ?? 'tsconfig.json';
31 this.configPath = path.join(this.projectRoot, configFileName);
32 this.userProvidedTypeScriptConfig = Boolean(options.typeScriptConfig);
33 this.system = {
34 ...ts.sys,
35 getCurrentDirectory: () => this.projectRoot,
36 createDirectory: (pth) => ts.sys.createDirectory(path.resolve(this.projectRoot, pth)),
37 deleteFile: ts.sys.deleteFile && ((pth) => ts.sys.deleteFile(path.join(this.projectRoot, pth))),
38 fileExists: (pth) => ts.sys.fileExists(path.resolve(this.projectRoot, pth)),
39 getFileSize: ts.sys.getFileSize && ((pth) => ts.sys.getFileSize(path.resolve(this.projectRoot, pth))),
40 readFile: (pth, encoding) => ts.sys.readFile(path.resolve(this.projectRoot, pth), encoding),
41 watchFile: ts.sys.watchFile &&
42 ((pth, callback, pollingInterval, watchOptions) => ts.sys.watchFile(path.resolve(this.projectRoot, pth), callback, pollingInterval, watchOptions)),
43 writeFile: (pth, data, writeByteOrderMark) => ts.sys.writeFile(path.resolve(this.projectRoot, pth), data, writeByteOrderMark),
44 };
45 this.tsconfig = this.configureTypeScript();
46 this.compilerHost = ts.createIncrementalCompilerHost(this.tsconfig.compilerOptions, this.system);
47 }
48 /**
49 * Compiles the configured program.
50 *
51 * @param files can be specified to override the standard source code location logic. Useful for example when testing "negatives".
52 */
53 emit(...files) {
54 this.prepareForBuild(...files);
55 return this.buildOnce();
56 }
57 async watch(opts) {
58 this.prepareForBuild();
59 const host = ts.createWatchCompilerHost(this.configPath, {
60 ...this.tsconfig.compilerOptions,
61 noEmitOnError: false,
62 }, this.system, ts.createEmitAndSemanticDiagnosticsBuilderProgram, opts?.reportDiagnostics, opts?.reportWatchStatus, this.tsconfig.watchOptions);
63 if (!host.getDefaultLibLocation) {
64 throw new Error('No default library location was found on the TypeScript compiler host!');
65 }
66 const orig = host.afterProgramCreate;
67 // This is a callback cascade, so it's "okay" to return an unhandled promise there. This may
68 // cause an unhandled promise rejection warning, but that's not a big deal.
69 //
70 // eslint-disable-next-line @typescript-eslint/no-misused-promises
71 host.afterProgramCreate = (builderProgram) => {
72 const emitResult = this.consumeProgram(builderProgram.getProgram(), host.getDefaultLibLocation());
73 for (const diag of emitResult.diagnostics.filter((d) => d.code === exports.JSII_DIAGNOSTICS_CODE)) {
74 utils.logDiagnostic(diag, this.projectRoot);
75 }
76 if (orig) {
77 orig.call(host, builderProgram);
78 }
79 if (opts?.compilationComplete) {
80 opts.compilationComplete(emitResult);
81 }
82 };
83 const watch = ts.createWatchProgram(host);
84 if (opts?.nonBlocking) {
85 // In non-blocking mode, returns the handle to the TypeScript watch interface.
86 return watch;
87 }
88 // In blocking mode, returns a never-resolving promise.
89 return new Promise(() => null);
90 }
91 /**
92 * Prepares the project for build, by creating the necessary configuration
93 * file(s), and assigning the relevant root file(s).
94 *
95 * @param files the files that were specified as input in the CLI invocation.
96 */
97 configureTypeScript() {
98 if (this.userProvidedTypeScriptConfig) {
99 const config = this.readTypeScriptConfig();
100 // emit a warning if validation is disabled
101 const rules = this.options.validateTypeScriptConfig ?? tsconfig_1.TypeScriptConfigValidationRuleSet.NONE;
102 if (rules === tsconfig_1.TypeScriptConfigValidationRuleSet.NONE) {
103 utils.logDiagnostic(jsii_diagnostic_1.JsiiDiagnostic.JSII_4009_DISABLED_TSCONFIG_VALIDATION.create(undefined, this.configPath), this.projectRoot);
104 }
105 // validate the user provided config
106 if (rules !== tsconfig_1.TypeScriptConfigValidationRuleSet.NONE) {
107 const configName = path.relative(this.projectRoot, this.configPath);
108 try {
109 const validator = new tsconfig_validator_1.TypeScriptConfigValidator(rules);
110 validator.validate({
111 ...config,
112 // convert the internal format to the user format which is what the validator operates on
113 compilerOptions: (0, compiler_options_1.convertForJson)(config.compilerOptions),
114 });
115 }
116 catch (error) {
117 if (error instanceof validator_1.ValidationError) {
118 utils.logDiagnostic(jsii_diagnostic_1.JsiiDiagnostic.JSII_4000_FAILED_TSCONFIG_VALIDATION.create(undefined, configName, rules, error.violations), this.projectRoot);
119 }
120 throw new Error(`Failed validation of tsconfig "compilerOptions" in "${configName}" against rule set "${rules}"!`);
121 }
122 }
123 return config;
124 }
125 // generated config if none is provided by the user
126 return this.buildTypeScriptConfig();
127 }
128 /**
129 * Final preparations of the project for build.
130 *
131 * These are preparations that either
132 * - must happen immediately before the build, or
133 * - can be different for every build like assigning the relevant root file(s).
134 *
135 * @param files the files that were specified as input in the CLI invocation.
136 */
137 prepareForBuild(...files) {
138 if (!this.userProvidedTypeScriptConfig) {
139 this.writeTypeScriptConfig();
140 }
141 this.rootFiles = this.determineSources(files);
142 }
143 /**
144 * Do a single build
145 */
146 buildOnce() {
147 if (!this.compilerHost.getDefaultLibLocation) {
148 throw new Error('No default library location was found on the TypeScript compiler host!');
149 }
150 const tsconf = this.tsconfig;
151 const prog = ts.createIncrementalProgram({
152 rootNames: this.rootFiles.concat(_pathOfLibraries(this.compilerHost)),
153 options: tsconf.compilerOptions,
154 // Make the references absolute for the compiler
155 projectReferences: tsconf.references?.map((ref) => ({
156 path: path.resolve(path.dirname(this.configPath), ref.path),
157 })),
158 host: this.compilerHost,
159 });
160 return this.consumeProgram(prog.getProgram(), this.compilerHost.getDefaultLibLocation());
161 }
162 consumeProgram(program, stdlib) {
163 const diagnostics = [...ts.getPreEmitDiagnostics(program)];
164 let hasErrors = false;
165 if (!hasErrors && this.diagsHaveAbortableErrors(diagnostics)) {
166 hasErrors = true;
167 LOG.error('Compilation errors prevented the JSII assembly from being created');
168 }
169 // Do the "Assembler" part first because we need some of the analysis done in there
170 // to post-process the AST
171 const assembler = new assembler_1.Assembler(this.options.projectInfo, this.system, program, stdlib, {
172 stripDeprecated: this.options.stripDeprecated,
173 stripDeprecatedAllowListFile: this.options.stripDeprecatedAllowListFile,
174 addDeprecationWarnings: this.options.addDeprecationWarnings,
175 compressAssembly: this.options.compressAssembly,
176 });
177 try {
178 const assmEmit = assembler.emit();
179 if (!hasErrors && (assmEmit.emitSkipped || this.diagsHaveAbortableErrors(assmEmit.diagnostics))) {
180 hasErrors = true;
181 LOG.error('Type model errors prevented the JSII assembly from being created');
182 }
183 diagnostics.push(...assmEmit.diagnostics);
184 }
185 catch (e) {
186 diagnostics.push(jsii_diagnostic_1.JsiiDiagnostic.JSII_9997_UNKNOWN_ERROR.createDetached(e));
187 hasErrors = true;
188 }
189 // Do the emit, but add in transformers which are going to replace real
190 // comments with synthetic ones.
191 const emit = program.emit(undefined, // targetSourceFile
192 undefined, // writeFile
193 undefined, // cancellationToken
194 undefined, // emitOnlyDtsFiles
195 assembler.customTransformers);
196 diagnostics.push(...emit.diagnostics);
197 if (!hasErrors && (emit.emitSkipped || this.diagsHaveAbortableErrors(emit.diagnostics))) {
198 hasErrors = true;
199 LOG.error('Compilation errors prevented the JSII assembly from being created');
200 }
201 if (!hasErrors) {
202 (0, downlevel_dts_1.emitDownleveledDeclarations)(this.options.projectInfo);
203 }
204 // Some extra validation on the config.
205 // Make sure that { "./.warnings.jsii.js": "./.warnings.jsii.js" } is in the set of
206 // exports, if they are specified.
207 if (this.options.addDeprecationWarnings && this.options.projectInfo.exports !== undefined) {
208 const expected = `./${deprecation_warnings_1.WARNINGSCODE_FILE_NAME}`;
209 const warningsExport = Object.entries(this.options.projectInfo.exports).filter(([k, v]) => k === expected && v === expected);
210 if (warningsExport.length === 0) {
211 hasErrors = true;
212 diagnostics.push(jsii_diagnostic_1.JsiiDiagnostic.JSII_0007_MISSING_WARNINGS_EXPORT.createDetached());
213 }
214 }
215 return {
216 emitSkipped: hasErrors,
217 diagnostics: ts.sortAndDeduplicateDiagnostics(diagnostics),
218 emittedFiles: emit.emittedFiles,
219 };
220 }
221 /**
222 * Build the TypeScript config object from jsii config
223 *
224 * This is the object that will be written to disk
225 * unless an existing tsconfig was provided.
226 */
227 buildTypeScriptConfig() {
228 let references;
229 const isComposite = this.options.projectReferences !== undefined
230 ? this.options.projectReferences
231 : this.options.projectInfo.projectReferences !== undefined
232 ? this.options.projectInfo.projectReferences
233 : false;
234 if (isComposite) {
235 references = this.findProjectReferences();
236 }
237 const pi = this.options.projectInfo;
238 return {
239 compilerOptions: {
240 ...pi.tsc,
241 ...compiler_options_1.BASE_COMPILER_OPTIONS,
242 // Enable composite mode if project references are enabled
243 composite: isComposite,
244 // When incremental, configure a tsbuildinfo file
245 tsBuildInfoFile: path.join(pi.tsc?.outDir ?? '.', 'tsconfig.tsbuildinfo'),
246 },
247 include: [pi.tsc?.rootDir != null ? path.join(pi.tsc.rootDir, '**', '*.ts') : path.join('**', '*.ts')],
248 exclude: [
249 'node_modules',
250 pi.tsc?.outDir != null ? path.resolve(pi.tsc.outDir, downlevel_dts_1.TYPES_COMPAT) : downlevel_dts_1.TYPES_COMPAT,
251 ...(pi.excludeTypescript ?? []),
252 ...(pi.tsc?.outDir != null &&
253 (pi.tsc?.rootDir == null || path.resolve(pi.tsc.outDir).startsWith(path.resolve(pi.tsc.rootDir) + path.sep))
254 ? [path.join(pi.tsc.outDir, '**', '*.ts')]
255 : []),
256 ],
257 // Change the references a little. We write 'originalpath' to the
258 // file under the 'path' key, which is the same as what the
259 // TypeScript compiler does. Make it relative so that the files are
260 // movable. Not strictly required but looks better.
261 references: references?.map((p) => ({ path: p })),
262 };
263 }
264 /**
265 * Load the TypeScript config object from a provided file
266 */
267 readTypeScriptConfig() {
268 const projectRoot = this.options.projectInfo.projectRoot;
269 const { config, error } = ts.readConfigFile(this.configPath, ts.sys.readFile);
270 if (error) {
271 utils.logDiagnostic(error, projectRoot);
272 throw new Error(`Failed to load tsconfig at ${this.configPath}`);
273 }
274 const extended = ts.parseJsonConfigFileContent(config, ts.sys, projectRoot);
275 // the tsconfig parser adds this in, but it is not an expected compilerOption
276 delete extended.options.configFilePath;
277 return {
278 compilerOptions: extended.options,
279 watchOptions: extended.watchOptions,
280 include: extended.fileNames,
281 };
282 }
283 /**
284 * Creates a `tsconfig.json` file to improve the IDE experience.
285 *
286 * @return the fully qualified path to the `tsconfig.json` file
287 */
288 writeTypeScriptConfig() {
289 const commentKey = '_generated_by_jsii_';
290 const commentValue = 'Generated by jsii - safe to delete, and ideally should be in .gitignore';
291 this.tsconfig[commentKey] = commentValue;
292 if (fs.existsSync(this.configPath)) {
293 const currentConfig = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
294 if (!(commentKey in currentConfig)) {
295 throw new Error(`A '${this.configPath}' file that was not generated by jsii is in ${this.options.projectInfo.projectRoot}. Aborting instead of overwriting.`);
296 }
297 }
298 const outputConfig = {
299 ...this.tsconfig,
300 compilerOptions: (0, compiler_options_1.convertForJson)(this.tsconfig?.compilerOptions),
301 };
302 LOG.debug(`Creating or updating ${chalk.blue(this.configPath)}`);
303 fs.writeFileSync(this.configPath, JSON.stringify(outputConfig, null, 2), 'utf8');
304 }
305 /**
306 * Find all dependencies that look like TypeScript projects.
307 *
308 * Enumerate all dependencies, if they have a tsconfig.json file with
309 * "composite: true" we consider them project references.
310 *
311 * (Note: TypeScript seems to only correctly find transitive project references
312 * if there's an "index" tsconfig.json of all projects somewhere up the directory
313 * tree)
314 */
315 findProjectReferences() {
316 const pkg = this.options.projectInfo.packageJson;
317 const ret = new Array();
318 const dependencyNames = new Set();
319 for (const dependencyMap of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
320 if (dependencyMap === undefined) {
321 continue;
322 }
323 for (const name of Object.keys(dependencyMap)) {
324 dependencyNames.add(name);
325 }
326 }
327 for (const tsconfigFile of Array.from(dependencyNames).map((depName) => this.findMonorepoPeerTsconfig(depName))) {
328 if (!tsconfigFile) {
329 continue;
330 }
331 const { config: tsconfig } = ts.readConfigFile(tsconfigFile, this.system.readFile);
332 // Add references to any TypeScript package we find that is 'composite' enabled.
333 // Make it relative.
334 if (tsconfig.compilerOptions?.composite) {
335 ret.push(path.relative(this.options.projectInfo.projectRoot, path.dirname(tsconfigFile)));
336 }
337 else {
338 // Not a composite package--if this package is in a node_modules directory, that is most
339 // likely correct, otherwise it is most likely an error (heuristic here, I don't know how to
340 // properly check this).
341 if (tsconfigFile.includes('node_modules')) {
342 LOG.warn('%s: not a composite TypeScript package, but it probably should be', path.dirname(tsconfigFile));
343 }
344 }
345 }
346 return ret;
347 }
348 /**
349 * Find source files using the same mechanism that the TypeScript compiler itself uses.
350 *
351 * Respects includes/excludes/etc.
352 *
353 * This makes it so that running 'typescript' and running 'jsii' has the same behavior.
354 */
355 determineSources(files) {
356 // explicitly requested files
357 if (files.length > 0) {
358 return [...files];
359 }
360 // for user provided config we already have parsed the full list of files
361 if (this.userProvidedTypeScriptConfig) {
362 return [...(this.tsconfig.include ?? [])];
363 }
364 // finally get the file list for the generated config
365 const parseConfigHost = parseConfigHostFromCompilerHost(this.compilerHost);
366 const parsed = ts.parseJsonConfigFileContent(this.tsconfig, parseConfigHost, this.options.projectInfo.projectRoot);
367 return [...parsed.fileNames];
368 }
369 /**
370 * Resolve the given dependency name from the current package, and find the associated tsconfig.json location
371 *
372 * Because we have the following potential directory layout:
373 *
374 * package/node_modules/some_dependency
375 * package/tsconfig.json
376 *
377 * We resolve symlinks and only find a "TypeScript" dependency if doesn't have 'node_modules' in
378 * the path after resolving symlinks (i.e., if it's a peer package in the same monorepo).
379 *
380 * Returns undefined if no such tsconfig could be found.
381 */
382 findMonorepoPeerTsconfig(depName) {
383 // eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires
384 const { builtinModules } = require('node:module');
385 if ((builtinModules ?? []).includes(depName)) {
386 // Can happen for modules like 'punycode' which are declared as dependency for polyfill purposes
387 return undefined;
388 }
389 try {
390 const depDir = (0, find_utils_1.findDependencyDirectory)(depName, this.options.projectInfo.projectRoot);
391 const dep = path.join(depDir, 'tsconfig.json');
392 if (!fs.existsSync(dep)) {
393 return undefined;
394 }
395 // Resolve symlinks, to check if this is a monorepo peer
396 const dependencyRealPath = fs.realpathSync(dep);
397 if (dependencyRealPath.split(path.sep).includes('node_modules')) {
398 return undefined;
399 }
400 return dependencyRealPath;
401 }
402 catch (e) {
403 // @types modules cannot be required, for example
404 if (['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(e.code)) {
405 return undefined;
406 }
407 throw e;
408 }
409 }
410 diagsHaveAbortableErrors(diags) {
411 return diags.some((d) => d.category === ts.DiagnosticCategory.Error ||
412 (this.options.failOnWarnings && d.category === ts.DiagnosticCategory.Warning));
413 }
414}
415exports.Compiler = Compiler;
416function _pathOfLibraries(host) {
417 if (!compiler_options_1.BASE_COMPILER_OPTIONS.lib || compiler_options_1.BASE_COMPILER_OPTIONS.lib.length === 0) {
418 return [];
419 }
420 const lib = host.getDefaultLibLocation?.();
421 if (!lib) {
422 throw new Error(`Compiler host doesn't have a default library directory available for ${compiler_options_1.BASE_COMPILER_OPTIONS.lib.join(', ')}`);
423 }
424 return compiler_options_1.BASE_COMPILER_OPTIONS.lib.map((name) => path.join(lib, name));
425}
426function parseConfigHostFromCompilerHost(host) {
427 // Copied from upstream
428 // https://github.com/Microsoft/TypeScript/blob/9e05abcfd3f8bb3d6775144ede807daceab2e321/src/compiler/program.ts#L3105
429 return {
430 fileExists: (f) => host.fileExists(f),
431 readDirectory(root, extensions, excludes, includes, depth) {
432 if (host.readDirectory === undefined) {
433 throw new Error("'CompilerHost.readDirectory' must be implemented to correctly process 'projectReferences'");
434 }
435 return host.readDirectory(root, extensions, excludes, includes, depth);
436 },
437 readFile: (f) => host.readFile(f),
438 useCaseSensitiveFileNames: host.useCaseSensitiveFileNames(),
439 trace: host.trace ? (s) => host.trace(s) : undefined,
440 };
441}
442//# sourceMappingURL=compiler.js.map
\No newline at end of file