@travetto/compiler
Version:
The compiler infrastructure for the Travetto framework
241 lines (207 loc) • 9 kB
text/typescript
import { createRequire } from 'node:module';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { DeltaEvent, ManifestContext, Package } from '@travetto/manifest';
import { Log } from './log.ts';
import { CommonUtil } from './util.ts';
import { TypescriptUtil } from './ts-util.ts';
type ModFile = { input: string, output: string, stale: boolean };
const SOURCE_SEED = ['package.json', '__index__.ts', 'src', 'support', 'bin'];
const PRECOMPILE_MODS = ['@travetto/manifest', '@travetto/transformer', '@travetto/compiler'];
const RECENT_STAT = (stat: { ctimeMs: number, mtimeMs: number }): number => Math.max(stat.ctimeMs, stat.mtimeMs);
const REQ = createRequire(path.resolve('node_modules')).resolve.bind(null);
const SOURCE_EXT_RE = /[.][cm]?[tj]s$/;
const BARE_IMPORT_RE = /^(@[^/]+[/])?[^.][^@/]+$/;
const OUTPUT_EXT = '.js';
/**
* Compiler Setup Utilities
*/
export class CompilerSetup {
/**
* Import compiled manifest utilities
*/
static #importManifest = (ctx: ManifestContext): Promise<
Pick<typeof import('@travetto/manifest'), 'ManifestDeltaUtil' | 'ManifestUtil'>
> => {
const all = ['util', 'delta'].map(f =>
import(CommonUtil.resolveWorkspace(ctx, ctx.build.compilerFolder, 'node_modules', `@travetto/manifest/src/${f}${OUTPUT_EXT}`))
);
return Promise.all(all).then(props => Object.assign({}, ...props));
};
/** Convert a file to a given ext */
static #sourceToExtension(sourceFile: string, ext: string): string {
return sourceFile.replace(SOURCE_EXT_RE, ext);
}
/**
* Get the output file name for a given input
*/
static #sourceToOutputExt(sourceFile: string): string {
return this.#sourceToExtension(sourceFile, OUTPUT_EXT);
}
/**
* Output a file, support for ts, js, and package.json
*/
static async #transpileFile(ctx: ManifestContext, sourceFile: string, outputFile: string): Promise<void> {
const type = CommonUtil.getFileType(sourceFile);
if (type === 'js' || type === 'ts') {
const compilerOut = CommonUtil.resolveWorkspace(ctx, ctx.build.compilerFolder, 'node_modules');
const text = (await fs.readFile(sourceFile, 'utf8'))
.replace(/from ['"](([.]+|@travetto)[/][^']+)['"]/g, (_, clause, m) => {
const s = this.#sourceToOutputExt(clause);
const suf = s.endsWith(OUTPUT_EXT) ? '' : (BARE_IMPORT_RE.test(clause) ? `/__index__${OUTPUT_EXT}` : OUTPUT_EXT);
const pre = m === '@travetto' ? `${compilerOut}/` : '';
return `from '${pre}${s}${suf}'`;
});
const ts = (await import('typescript')).default;
const content = ts.transpile(text, {
...await TypescriptUtil.getCompilerOptions(ctx),
sourceMap: false,
inlineSourceMap: true,
importHelpers: true,
}, sourceFile);
await CommonUtil.writeTextFile(outputFile, content);
} else if (type === 'package-json') {
const pkg: Package = JSON.parse(await fs.readFile(sourceFile, 'utf8'));
const main = pkg.main ? this.#sourceToOutputExt(pkg.main) : undefined;
const files = pkg.files?.map(x => this.#sourceToOutputExt(x));
const content = JSON.stringify({ ...pkg, main, type: ctx.workspace.type, files }, null, 2);
await CommonUtil.writeTextFile(outputFile, content);
}
}
/**
* Scan directory to find all project sources for comparison
*/
static async #getModuleSources(ctx: ManifestContext, module: string, seed: string[]): Promise<ModFile[]> {
const inputFolder = path.dirname(REQ(`${module}/package.json`));
const folders = seed.filter(x => !/[.]/.test(x)).map(x => path.resolve(inputFolder, x));
const files = seed.filter(x => /[.]/.test(x)).map(x => path.resolve(inputFolder, x));
while (folders.length) {
const sub = folders.pop();
if (!sub) {
continue;
}
for (const file of await fs.readdir(sub).catch(() => [])) {
if (file.startsWith('.')) {
continue;
}
const resolvedInput = path.resolve(sub, file).replaceAll('\\', '/'); // To posix
const stat = await fs.stat(resolvedInput);
if (stat.isDirectory()) {
folders.push(resolvedInput);
} else {
switch (CommonUtil.getFileType(file)) {
case 'js': case 'ts': files.push(resolvedInput);
}
}
}
}
const outputFolder = CommonUtil.resolveWorkspace(ctx, ctx.build.compilerFolder, 'node_modules', module);
const out: ModFile[] = [];
for (const input of files) {
const output = this.#sourceToOutputExt(input.replace(inputFolder, outputFolder));
const inputTs = await fs.stat(input).then(RECENT_STAT, () => 0);
if (inputTs) {
const outputTs = await fs.stat(output).then(RECENT_STAT, () => 0);
await fs.mkdir(path.dirname(output), { recursive: true });
out.push({ input, output, stale: inputTs > outputTs });
}
}
return out;
}
/**
* Recompile folder if stale
*/
static async #compileIfStale(ctx: ManifestContext, scope: string, mod: string, seed: string[]): Promise<string[]> {
const files = await this.#getModuleSources(ctx, mod, seed);
const changes = files.filter(x => x.stale).map(x => x.input);
const out: string[] = [];
try {
await Log.wrap(scope, async log => {
if (files.some(f => f.stale)) {
log.debug('Starting', mod);
for (const file of files.filter(x => x.stale)) {
await this.#transpileFile(ctx, file.input, file.output);
}
if (changes.length) {
out.push(...changes.map(x => `${mod}/${x}`));
log.debug(`Source changed: ${changes.join(', ')}`, mod);
}
log.debug('Completed', mod);
} else {
log.debug('Skipped', mod);
}
}, false);
} catch (err) {
console.error(err);
}
return out;
}
/**
* Export manifest
*/
static async exportManifest(ctx: ManifestContext, output?: string, prod?: boolean): Promise<void> {
const { ManifestUtil } = await this.#importManifest(ctx);
let manifest = await ManifestUtil.buildManifest(ctx);
// If in prod mode, only include std modules
if (prod) {
manifest = ManifestUtil.createProductionManifest(manifest);
}
if (output) {
output = await ManifestUtil.writeManifestToFile(output, manifest);
} else {
console.log(JSON.stringify(manifest, null, 2));
}
}
/**
* Sets up compiler, and produces a set of changes that need to be processed
*/
static async setup(ctx: ManifestContext): Promise<DeltaEvent[]> {
let changes = 0;
await Log.wrap('precompile', async () => {
for (const mod of PRECOMPILE_MODS) {
changes += (await this.#compileIfStale(ctx, 'precompile', mod, SOURCE_SEED)).length;
}
});
const { ManifestUtil, ManifestDeltaUtil } = await this.#importManifest(ctx);
const manifest = await Log.wrap('manifest', () =>
ManifestUtil.buildManifest(ManifestUtil.getWorkspaceContext(ctx)));
await Log.wrap('transformers', async () => {
for (const mod of Object.values(manifest.modules).filter(m => m.files.$transformer?.length)) {
changes += (await this.#compileIfStale(ctx, 'transformers', mod.name, ['package.json', ...mod.files.$transformer!.map(x => x[0])])).length;
}
});
const delta = await Log.wrap('delta', async log => {
if (changes) {
log.debug('Skipping, everything changed');
return [{ type: 'changed', file: '*', module: ctx.workspace.name, sourceFile: '' } as const];
} else {
return ManifestDeltaUtil.produceDelta(manifest);
}
});
if (changes) {
await Log.wrap('reset', async log => {
await fs.rm(CommonUtil.resolveWorkspace(ctx, ctx.build.outputFolder), { recursive: true, force: true });
log.info('Clearing output due to compiler changes');
}, false);
}
// Write manifest
await Log.wrap('manifest', async log => {
await ManifestUtil.writeManifest(manifest);
log.debug(`Wrote manifest ${ctx.workspace.name}`);
// Update all manifests when in mono repo
if (delta.length && ctx.workspace.mono) {
const names: string[] = [];
const mods = Object.values(manifest.modules).filter(x => x.workspace && x.name !== ctx.workspace.name);
for (const mod of mods) {
const modCtx = ManifestUtil.getModuleContext(ctx, mod.sourceFolder, true);
const modManifest = await ManifestUtil.buildManifest(modCtx);
await ManifestUtil.writeManifest(modManifest);
names.push(mod.name);
}
log.debug(`Changes triggered ${delta.slice(0, 10).map(x => `${x.type}:${x.module}:${x.file}`)}`);
log.debug(`Rewrote monorepo manifests [changes=${delta.length}] ${names.slice(0, 10).join(', ')}`);
}
});
return delta.filter(x => x.type === 'added' || x.type === 'changed');
}
}