feat(js): add initial/experimental batch execution implementation for tsc executor (#17287)

This commit is contained in:
Leosvel Pérez Espinosa 2023-06-05 22:18:52 +01:00 committed by GitHub
parent fe27b87327
commit b6cdf9d975
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1049 additions and 159 deletions

View File

@ -706,7 +706,7 @@ A change to be made to a string
### TaskGraphExecutor
Ƭ **TaskGraphExecutor**<`T`\>: (`taskGraph`: [`TaskGraph`](../../devkit/documents/nx_devkit#taskgraph), `options`: `Record`<`string`, `T`\>, `overrides`: `T`, `context`: [`ExecutorContext`](../../devkit/documents/nx_devkit#executorcontext)) => `Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
Ƭ **TaskGraphExecutor**<`T`\>: (`taskGraph`: [`TaskGraph`](../../devkit/documents/nx_devkit#taskgraph), `options`: `Record`<`string`, `T`\>, `overrides`: `T`, `context`: [`ExecutorContext`](../../devkit/documents/nx_devkit#executorcontext)) => `Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
#### Type parameters
@ -716,7 +716,7 @@ A change to be made to a string
#### Type declaration
▸ (`taskGraph`, `options`, `overrides`, `context`): `Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
▸ (`taskGraph`, `options`, `overrides`, `context`): `Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
Implementation of a target of a project that handles multiple projects to be batched
@ -731,7 +731,7 @@ Implementation of a target of a project that handles multiple projects to be bat
##### Returns
`Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
`Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
---

View File

@ -706,7 +706,7 @@ A change to be made to a string
### TaskGraphExecutor
Ƭ **TaskGraphExecutor**<`T`\>: (`taskGraph`: [`TaskGraph`](../../devkit/documents/nx_devkit#taskgraph), `options`: `Record`<`string`, `T`\>, `overrides`: `T`, `context`: [`ExecutorContext`](../../devkit/documents/nx_devkit#executorcontext)) => `Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
Ƭ **TaskGraphExecutor**<`T`\>: (`taskGraph`: [`TaskGraph`](../../devkit/documents/nx_devkit#taskgraph), `options`: `Record`<`string`, `T`\>, `overrides`: `T`, `context`: [`ExecutorContext`](../../devkit/documents/nx_devkit#executorcontext)) => `Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
#### Type parameters
@ -716,7 +716,7 @@ A change to be made to a string
#### Type declaration
▸ (`taskGraph`, `options`, `overrides`, `context`): `Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
▸ (`taskGraph`, `options`, `overrides`, `context`): `Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
Implementation of a target of a project that handles multiple projects to be batched
@ -731,7 +731,7 @@ Implementation of a target of a project that handles multiple projects to be bat
##### Returns
`Promise`<`Record`<`string`, { `success`: `boolean` ; `terminalOutput`: `string` }\>\>
`Promise`<`Record`<`string`, `ExecutorTaskResult`\> \| `AsyncIterableIterator`<`Record`<`string`, `ExecutorTaskResult`\>\>\>
---

View File

@ -1,6 +1,7 @@
{
"name": "tsc",
"implementation": "/packages/js/src/executors/tsc/tsc.impl.ts",
"batchImplementation": "./src/executors/tsc/tsc.batch-impl",
"schema": {
"version": 2,
"outputCapture": "direct-nodejs",

View File

@ -9,6 +9,7 @@ import {
packageManagerLockFile,
readFile,
readJson,
rmDist,
runCLI,
runCLIAsync,
runCommand,
@ -149,6 +150,48 @@ describe('js e2e', () => {
expect(readJson(`dist/libs/${lib}/package.json`)).not.toHaveProperty(
'peerDependencies.tslib'
);
// check batch build
rmDist();
const batchBuildOutput = runCLI(`build ${parentLib} --skip-nx-cache`, {
env: { NX_BATCH_MODE: 'true' },
});
expect(batchBuildOutput).toContain(`Running 2 tasks with @nx/js:tsc`);
expect(batchBuildOutput).toContain(
`Compiling TypeScript files for project "${lib}"...`
);
expect(batchBuildOutput).toContain(
`Done compiling TypeScript files for project "${lib}".`
);
expect(batchBuildOutput).toContain(
`Compiling TypeScript files for project "${parentLib}"...`
);
expect(batchBuildOutput).toContain(
`Done compiling TypeScript files for project "${parentLib}".`
);
expect(batchBuildOutput).toContain(
`Successfully ran target build for project ${parentLib} and 1 task it depends on`
);
checkFilesExist(
// parent
`dist/libs/${parentLib}/package.json`,
`dist/libs/${parentLib}/README.md`,
`dist/libs/${parentLib}/tsconfig.tsbuildinfo`,
`dist/libs/${parentLib}/src/index.js`,
`dist/libs/${parentLib}/src/index.d.ts`,
`dist/libs/${parentLib}/src/lib/${parentLib}.js`,
`dist/libs/${parentLib}/src/lib/${parentLib}.d.ts`,
// child
`dist/libs/${lib}/package.json`,
`dist/libs/${lib}/README.md`,
`dist/libs/${lib}/tsconfig.tsbuildinfo`,
`dist/libs/${lib}/src/index.js`,
`dist/libs/${lib}/src/index.d.ts`,
`dist/libs/${lib}/src/lib/${lib}.js`,
`dist/libs/${lib}/src/lib/${lib}.d.ts`
);
}, 240_000);
it('should not create a `.babelrc` file when creating libs with js executors (--compiler=tsc)', () => {

View File

@ -3,6 +3,7 @@
"executors": {
"tsc": {
"implementation": "./src/executors/tsc/tsc.impl",
"batchImplementation": "./src/executors/tsc/tsc.batch-impl",
"schema": "./src/executors/tsc/schema.json",
"description": "Build a project using TypeScript."
},

View File

@ -0,0 +1,97 @@
import type { ExecutorContext } from '@nx/devkit';
import { parseTargetString } from '@nx/devkit';
import { CopyAssetsHandler } from '../../../../utils/assets/copy-assets-handler';
import { calculateProjectDependencies } from '../../../../utils/buildable-libs-utils';
import type { NormalizedExecutorOptions } from '../../../../utils/schema';
import { generateTempTsConfig } from './generate-temp-tsconfig';
import { getTaskOptions } from './get-task-options';
import type { TaskInfo } from './types';
const taskTsConfigCache = new Set<string>();
export function buildTaskInfoPerTsConfigMap(
tsConfigTaskInfoMap: Record<string, TaskInfo>,
tasksOptions: Record<string, NormalizedExecutorOptions>,
context: ExecutorContext,
tasks: string[],
shouldWatch: boolean
): void {
for (const taskName of tasks) {
if (taskTsConfigCache.has(taskName)) {
continue;
}
let taskOptions = tasksOptions[taskName];
// task is in the batch (it's meant to be processed), create TaskInfo
if (taskOptions) {
const taskInfo = createTaskInfo(taskName, taskOptions, context);
const tsConfigPath = generateTempTsConfig(
tasksOptions,
taskName,
taskOptions,
context
);
tsConfigTaskInfoMap[tsConfigPath] = taskInfo;
taskTsConfigCache.add(taskName);
} else {
// if it's not included in the provided map, it could be a cached task and
// we need to pull the options from the relevant project graph node
taskOptions = getTaskOptions(taskName, context);
generateTempTsConfig(tasksOptions, taskName, taskOptions, context);
}
buildTaskInfoPerTsConfigMap(
tsConfigTaskInfoMap,
tasksOptions,
context,
context.taskGraph.dependencies[taskName],
shouldWatch
);
}
}
function createTaskInfo(
taskName: string,
taskOptions: NormalizedExecutorOptions,
context: ExecutorContext
): TaskInfo {
const target = parseTargetString(taskName, context.projectGraph);
const taskContext = {
...context,
// batch executors don't get these in the context, we provide them
// here per task
projectName: target.project,
targetName: target.target,
configurationName: target.configuration,
};
const assetsHandler = new CopyAssetsHandler({
projectDir: taskOptions.projectRoot,
rootDir: context.root,
outputDir: taskOptions.outputPath,
assets: taskOptions.assets,
});
const {
target: projectGraphNode,
dependencies: buildableProjectNodeDependencies,
} = calculateProjectDependencies(
context.projectGraph,
context.root,
context.taskGraph.tasks[taskName].target.project,
context.taskGraph.tasks[taskName].target.target,
context.taskGraph.tasks[taskName].target.configuration
);
return {
task: taskName,
options: taskOptions,
context: taskContext,
assetsHandler,
buildableProjectNodeDependencies,
projectGraphNode,
};
}

View File

@ -0,0 +1,75 @@
import type { ExecutorContext } from '@nx/devkit';
import { joinPathFragments, normalizePath, writeJsonFile } from '@nx/devkit';
import { dirname, join, relative } from 'path';
import * as ts from 'typescript';
import type { NormalizedExecutorOptions } from '../../../../utils/schema';
import { getTaskOptions } from './get-task-options';
export function generateTempTsConfig(
taskOptionsMap: Record<string, NormalizedExecutorOptions>,
taskName: string,
taskOptions: NormalizedExecutorOptions,
context: ExecutorContext
): string {
const tmpDir = join(context.root, 'tmp');
const originalTsConfigPath = taskOptions.tsConfig;
const tmpTsConfigPath = join(
tmpDir,
relative(context.root, originalTsConfigPath)
);
const projectReferences: { path: string }[] = [];
for (const depTask of context.taskGraph.dependencies[taskName]) {
// if included in the provided map, use it
if (taskOptionsMap[depTask]) {
projectReferences.push({
path: join(
tmpDir,
relative(context.root, taskOptionsMap[depTask].tsConfig)
),
});
continue;
}
// if it's not included in the provided map, it could be a cached task and
// we need to pull the tsconfig from the relevant project graph node
const options = getTaskOptions(depTask, context);
if (options.tsConfig) {
projectReferences.push({
path: join(tmpDir, relative(context.root, options.tsConfig)),
});
}
}
writeJsonFile(tmpTsConfigPath, {
extends: normalizePath(
relative(dirname(tmpTsConfigPath), taskOptions.tsConfig)
),
compilerOptions: {
rootDir: taskOptions.rootDir,
outDir: taskOptions.outputPath,
composite: true,
declaration: true,
declarationMap: true,
tsBuildInfoFile: joinPathFragments(
taskOptions.outputPath,
'tsconfig.tsbuildinfo'
),
},
references: projectReferences,
});
/**
* Ensure the temp tsconfig has the same modified date as the original.
* Typescript compares this against the modified date of the tsbuildinfo
* file. If the tsbuildinfo file is older, the cache is invalidated.
* Since we always generate the temp tsconfig, any existing tsbuildinfo
* file will be older even if they are not older than the original tsconfig.
*/
ts.sys.setModifiedTime(
tmpTsConfigPath,
ts.sys.getModifiedTime(originalTsConfigPath)
);
return tmpTsConfigPath;
}

View File

@ -0,0 +1,43 @@
import type { ExecutorContext } from '@nx/devkit';
import { parseTargetString } from '@nx/devkit';
import type {
ExecutorOptions,
NormalizedExecutorOptions,
} from '../../../../utils/schema';
import { normalizeOptions } from '../normalize-options';
const tasksOptionsCache = new Map<string, NormalizedExecutorOptions>();
export function getTaskOptions(
taskName: string,
context: ExecutorContext
): NormalizedExecutorOptions {
if (tasksOptionsCache.has(taskName)) {
return tasksOptionsCache.get(taskName);
}
const target = context.taskGraph.tasks[taskName].target;
const projectNode = context.projectGraph.nodes[target.project];
const targetConfig = projectNode.data.targets?.[target.target];
const taskOptions: ExecutorOptions = {
...targetConfig.options,
...(target.configuration
? targetConfig.configurations?.[target.configuration]
: {}),
};
const { project } = parseTargetString(taskName, context.projectGraph);
const { sourceRoot, root } = context.projectsConfigurations.projects[project];
const normalizedTaskOptions = normalizeOptions(
taskOptions,
context.root,
sourceRoot,
root
);
tasksOptionsCache.set(taskName, normalizedTaskOptions);
return normalizedTaskOptions;
}

View File

@ -0,0 +1,8 @@
export * from './build-task-info-per-tsconfig-map';
export * from './generate-temp-tsconfig';
export * from './get-task-options';
export * from './normalize-tasks-options';
export * from './types';
export * from './typescript-compilation';
export * from './typescript-diagnostic-reporters';
export * from './watch';

View File

@ -0,0 +1,25 @@
import type { ExecutorContext } from '@nx/devkit';
import { parseTargetString } from '@nx/devkit';
import type {
ExecutorOptions,
NormalizedExecutorOptions,
} from '../../../../utils/schema';
import { normalizeOptions } from '../normalize-options';
export function normalizeTasksOptions(
inputs: Record<string, ExecutorOptions>,
context: ExecutorContext
): Record<string, NormalizedExecutorOptions> {
return Object.entries(inputs).reduce((tasksOptions, [taskName, options]) => {
const { project } = parseTargetString(taskName, context.projectGraph);
const { sourceRoot, root } =
context.projectsConfigurations.projects[project];
tasksOptions[taskName] = normalizeOptions(
options,
context.root,
sourceRoot,
root
);
return tasksOptions;
}, {} as Record<string, NormalizedExecutorOptions>);
}

View File

@ -0,0 +1,13 @@
import type { ExecutorContext, ProjectGraphProjectNode } from '@nx/devkit';
import type { CopyAssetsHandler } from '../../../../utils/assets/copy-assets-handler';
import type { DependentBuildableProjectNode } from '../../../../utils/buildable-libs-utils';
import type { NormalizedExecutorOptions } from '../../../../utils/schema';
export interface TaskInfo {
task: string;
options: NormalizedExecutorOptions;
context: ExecutorContext;
assetsHandler: CopyAssetsHandler;
buildableProjectNodeDependencies: DependentBuildableProjectNode[];
projectGraphNode: ProjectGraphProjectNode;
}

View File

@ -0,0 +1,296 @@
import type { TaskGraph } from '@nx/devkit';
import { logger } from '@nx/devkit';
import { createAsyncIterable } from '@nx/devkit/src/utils/async-iterable';
import type { BatchResults } from 'nx/src/tasks-runner/batch/batch-messages';
import * as ts from 'typescript';
import { getCustomTrasformersFactory } from '../get-custom-transformers-factory';
import type { TaskInfo } from './types';
import {
formatDiagnosticReport,
formatSolutionBuilderStatusReport,
formatWatchStatusReport,
} from './typescript-diagnostic-reporters';
// https://github.com/microsoft/TypeScript/blob/d45012c5e2ab122919ee4777a7887307c5f4a1e0/src/compiler/diagnosticMessages.json#L4050-L4053
// Typescript diagnostic message for 5083: Cannot read file '{0}'.
const TYPESCRIPT_CANNOT_READ_FILE = 5083;
// https://github.com/microsoft/TypeScript/blob/d45012c5e2ab122919ee4777a7887307c5f4a1e0/src/compiler/diagnosticMessages.json#L4211-4214
// Typescript diagnostic message for 6032: File change detected. Starting incremental compilation...
const TYPESCRIPT_FILE_CHANGE_DETECTED_STARTING_INCREMENTAL_COMPILATION = 6032;
export function compileBatchTypescript(
tsConfigTaskInfoMap: Record<string, TaskInfo>,
taskGraph: TaskGraph,
watch: boolean,
postProjectCompilationCallback: (taskInfo: TaskInfo) => void
): {
iterator: AsyncIterable<BatchResults>;
close: () => void | Promise<void>;
} {
const timeNow = Date.now();
const defaultResults: BatchResults = Object.keys(taskGraph.tasks).reduce(
(acc, task) => {
acc[task] = { success: true, startTime: timeNow, terminalOutput: '' };
return acc;
},
{} as BatchResults
);
let tearDown: (() => void) | undefined;
return {
iterator: createAsyncIterable<BatchResults>(({ next, done }) => {
if (watch) {
compileTSWithWatch(tsConfigTaskInfoMap, postProjectCompilationCallback);
tearDown = () => {
done();
};
} else {
const compilationResults = compileTS(
tsConfigTaskInfoMap,
postProjectCompilationCallback
);
next({
...defaultResults,
...compilationResults,
});
done();
}
}),
close: () => tearDown?.(),
};
}
function compileTSWithWatch(
tsConfigTaskInfoMap: Record<string, TaskInfo>,
postProjectCompilationCallback: (taskInfo: TaskInfo) => void
) {
const formatDiagnosticsHost: ts.FormatDiagnosticsHost = {
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
getNewLine: () => ts.sys.newLine,
getCanonicalFileName: (filename: string) =>
ts.sys.useCaseSensitiveFileNames ? filename : filename.toLowerCase(),
};
const solutionHost = ts.createSolutionBuilderWithWatchHost(
ts.sys,
/*createProgram*/ undefined,
(diagnostic) => {
const formattedDiagnostic = formatDiagnosticReport(
diagnostic,
formatDiagnosticsHost
);
logger.info(formattedDiagnostic);
},
(diagnostic) => {
const formattedDiagnostic = formatSolutionBuilderStatusReport(diagnostic);
logger.info(formattedDiagnostic);
},
(diagnostic, newLine) => {
const formattedDiagnostic = formatWatchStatusReport(diagnostic, newLine);
logger.info(formattedDiagnostic);
if (
diagnostic.code ===
TYPESCRIPT_FILE_CHANGE_DETECTED_STARTING_INCREMENTAL_COMPILATION
) {
// there's a change, build invalidated projects
build();
}
}
);
const rootNames = Object.keys(tsConfigTaskInfoMap);
const solutionBuilder = ts.createSolutionBuilderWithWatch(
solutionHost,
rootNames,
{}
);
const build = () => {
while (true) {
const project = solutionBuilder.getNextInvalidatedProject();
if (!project) {
break;
}
const taskInfo = tsConfigTaskInfoMap[project.project];
if (project.kind === ts.InvalidatedProjectKind.UpdateOutputFileStamps) {
// update output timestamps and mark project as complete
project.done();
continue;
}
/**
* This only applies when the deprecated `prepend` option is set to `true`.
* Skip support.
*/
if (project.kind === ts.InvalidatedProjectKind.UpdateBundle) {
logger.warn(
`The project ${taskInfo.context.projectName} ` +
`is using the deprecated "prepend" Typescript compiler option. ` +
`This option is not supported by the batch executor and it's ignored.`
);
continue;
}
// build and mark project as complete
project.done(
undefined,
undefined,
getCustomTrasformersFactory(taskInfo.options.transformers)(
project.getProgram()
)
);
postProjectCompilationCallback(taskInfo);
}
};
// initial build
build();
/**
* This is a workaround to get the TS file watching to kick off. It won't
* build twice since the `build` call above will mark invalidated projects
* as completed and then, the implementation of the `solutionBuilder.build`
* skips them.
* We can't rely solely in `solutionBuilder.build()` because it doesn't
* accept custom transformers.
*/
solutionBuilder.build();
return solutionHost;
}
function compileTS(
tsConfigTaskInfoMap: Record<string, TaskInfo>,
postProjectCompilationCallback: (taskInfo: TaskInfo) => void
): BatchResults {
const results: BatchResults = {};
let terminalOutput: string;
const logInfo = (text: string): void => {
logger.info(text);
terminalOutput += `${text}\n`;
};
const logWarn = (text: string): void => {
logger.warn(text);
terminalOutput += `${text}\n`;
};
const formatDiagnosticsHost: ts.FormatDiagnosticsHost = {
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
getNewLine: () => ts.sys.newLine,
getCanonicalFileName: (filename: string) =>
ts.sys.useCaseSensitiveFileNames ? filename : filename.toLowerCase(),
};
const solutionBuilderHost = ts.createSolutionBuilderHost(
ts.sys,
/*createProgram*/ undefined,
(diagnostic) => {
const formattedDiagnostic = formatDiagnosticReport(
diagnostic,
formatDiagnosticsHost
);
// handles edge case where a wrong a project reference path can't be read
if (diagnostic.code === TYPESCRIPT_CANNOT_READ_FILE) {
Object.values(tsConfigTaskInfoMap).forEach((taskInfo) => {
results[taskInfo.task] ??= { success: false, terminalOutput: '' };
results[taskInfo.task].success = false;
results[taskInfo.task].terminalOutput = `${
results[taskInfo.task]?.terminalOutput
}${formattedDiagnostic}`;
});
}
logInfo(formattedDiagnostic);
},
(diagnostic) => {
const formattedDiagnostic = formatSolutionBuilderStatusReport(diagnostic);
logInfo(formattedDiagnostic);
}
);
const rootNames = Object.keys(tsConfigTaskInfoMap);
const solutionBuilder = ts.createSolutionBuilder(
solutionBuilderHost,
rootNames,
{}
);
// eslint-disable-next-line no-constant-condition
while (true) {
const project = solutionBuilder.getNextInvalidatedProject();
if (!project) {
break;
}
const startTime = Date.now();
terminalOutput = '';
const taskInfo = tsConfigTaskInfoMap[project.project];
const projectName = taskInfo?.context?.projectName;
if (project.kind === ts.InvalidatedProjectKind.UpdateOutputFileStamps) {
if (projectName) {
logInfo(`Updating output timestamps of project "${projectName}"...`);
}
// update output timestamps and mark project as complete
const status = project.done();
if (projectName && status === ts.ExitStatus.Success) {
logInfo(
`Done updating output timestamps of project "${projectName}"...`
);
}
if (taskInfo) {
results[taskInfo.task] = {
success: status === ts.ExitStatus.Success,
terminalOutput,
startTime,
endTime: Date.now(),
};
}
continue;
}
/**
* This only applies when the deprecated `prepend` option is set to `true`.
* Skip support.
*/
if (project.kind === ts.InvalidatedProjectKind.UpdateBundle) {
logWarn(
`The project ${taskInfo.context.projectName} ` +
`is using the deprecated "prepend" Typescript compiler option. ` +
`This option is not supported by the batch executor and it's ignored.`
);
continue;
}
logInfo(`Compiling TypeScript files for project "${projectName}"...`);
// build and mark project as complete
const status = project.done(
undefined,
undefined,
getCustomTrasformersFactory(taskInfo.options.transformers)(
project.getProgram()
)
);
postProjectCompilationCallback(taskInfo);
if (status === ts.ExitStatus.Success) {
logInfo(`Done compiling TypeScript files for project "${projectName}".`);
}
results[taskInfo.task] = {
success: status === ts.ExitStatus.Success,
terminalOutput,
startTime,
endTime: Date.now(),
};
}
return results;
}

View File

@ -0,0 +1,67 @@
import * as ts from 'typescript';
// adapted from TS default diagnostic reporter
export function formatDiagnosticReport(
diagnostic: ts.Diagnostic,
host: ts.FormatDiagnosticsHost
): string {
const diagnostics: ts.Diagnostic[] = new Array(1);
diagnostics[0] = diagnostic;
const formattedDiagnostic =
'\n' +
ts.formatDiagnosticsWithColorAndContext(diagnostics, host) +
host.getNewLine();
diagnostics[0] = undefined;
return formattedDiagnostic;
}
// adapted from TS default solution builder status reporter
export function formatSolutionBuilderStatusReport(
diagnostic: ts.Diagnostic
): string {
let formattedDiagnostic = `[${formatColorAndReset(
getLocaleTimeString(),
ForegroundColorEscapeSequences.Grey
)}] `;
formattedDiagnostic += `${ts.flattenDiagnosticMessageText(
diagnostic.messageText,
ts.sys.newLine
)}${ts.sys.newLine + ts.sys.newLine}`;
return formattedDiagnostic;
}
// adapted from TS default watch status reporter
export function formatWatchStatusReport(
diagnostic: ts.Diagnostic,
newLine: string
): string {
let output = `[${formatColorAndReset(
getLocaleTimeString(),
ForegroundColorEscapeSequences.Grey
)}] `;
output += `${ts.flattenDiagnosticMessageText(
diagnostic.messageText,
ts.sys.newLine
)}${newLine + newLine}`;
return output;
}
function formatColorAndReset(text: string, formatStyle: string) {
const resetEscapeSequence = '\u001b[0m';
return formatStyle + text + resetEscapeSequence;
}
function getLocaleTimeString() {
return new Date().toLocaleTimeString();
}
enum ForegroundColorEscapeSequences {
Grey = '\u001b[90m',
Red = '\u001b[91m',
Yellow = '\u001b[93m',
Blue = '\u001b[94m',
Cyan = '\u001b[96m',
}

View File

@ -0,0 +1,67 @@
import { logger } from '@nx/devkit';
import { daemonClient } from 'nx/src/daemon/client/client';
import { join } from 'path';
import type { TaskInfo } from './types';
export async function watchTaskProjectsPackageJsonFileChanges(
taskInfos: TaskInfo[],
callback: (changedTaskInfos: TaskInfo[]) => void
): Promise<() => void> {
const projects: string[] = [];
const packageJsonTaskInfoMap = new Map<string, TaskInfo>();
taskInfos.forEach((t) => {
projects.push(t.context.projectName);
packageJsonTaskInfoMap.set(join(t.options.projectRoot, 'package.json'), t);
});
const unregisterFileWatcher = await daemonClient.registerFileWatcher(
{ watchProjects: projects },
(err, data) => {
if (err === 'closed') {
logger.error(`Watch error: Daemon closed the connection`);
process.exit(1);
} else if (err) {
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
} else {
const changedTasks = [];
data.changedFiles.forEach((file) => {
if (packageJsonTaskInfoMap.has(file.path)) {
changedTasks.push(packageJsonTaskInfoMap.get(file.path));
}
});
if (changedTasks.length) {
callback(changedTasks);
}
}
}
);
return () => unregisterFileWatcher();
}
export async function watchTaskProjectsFileChangesForAssets(
taskInfos: TaskInfo[]
): Promise<() => void> {
const unregisterFileWatcher = await daemonClient.registerFileWatcher(
{
watchProjects: taskInfos.map((t) => t.context.projectName),
includeDependentProjects: true,
includeGlobalWorkspaceFiles: true,
},
(err, data) => {
if (err === 'closed') {
logger.error(`Watch error: Daemon closed the connection`);
process.exit(1);
} else if (err) {
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
} else {
taskInfos.forEach((t) =>
t.assetsHandler.processWatchEvents(data.changedFiles)
);
}
}
);
return () => unregisterFileWatcher();
}

View File

@ -0,0 +1,21 @@
import * as ts from 'typescript';
import { loadTsTransformers } from '../../../utils/typescript/load-ts-transformers';
import type { TransformerEntry } from '../../../utils/typescript/types';
export function getCustomTrasformersFactory(
transformers: TransformerEntry[]
): (program: ts.Program) => ts.CustomTransformers {
const { compilerPluginHooks } = loadTsTransformers(transformers);
return (program: ts.Program): ts.CustomTransformers => ({
before: compilerPluginHooks.beforeHooks.map(
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
),
after: compilerPluginHooks.afterHooks.map(
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
),
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
(hook) => hook(program) as ts.TransformerFactory<ts.SourceFile>
),
});
}

View File

@ -0,0 +1,2 @@
export * from './get-custom-transformers-factory';
export * from './normalize-options';

View File

@ -0,0 +1,60 @@
import { join, resolve } from 'path';
import type {
ExecutorOptions,
NormalizedExecutorOptions,
} from '../../../utils/schema';
import {
FileInputOutput,
assetGlobsToFiles,
} from '../../../utils/assets/assets';
export function normalizeOptions(
options: ExecutorOptions,
contextRoot: string,
sourceRoot: string,
projectRoot: string
): NormalizedExecutorOptions {
const outputPath = join(contextRoot, options.outputPath);
const rootDir = options.rootDir
? join(contextRoot, options.rootDir)
: join(contextRoot, projectRoot);
if (options.watch == null) {
options.watch = false;
}
// TODO: put back when inlining story is more stable
// if (options.external == null) {
// options.external = 'all';
// } else if (Array.isArray(options.external) && options.external.length === 0) {
// options.external = 'none';
// }
if (Array.isArray(options.external) && options.external.length > 0) {
const firstItem = options.external[0];
if (firstItem === 'all' || firstItem === 'none') {
options.external = firstItem;
}
}
const files: FileInputOutput[] = assetGlobsToFiles(
options.assets,
contextRoot,
outputPath
);
return {
...options,
root: contextRoot,
sourceRoot,
projectRoot,
files,
outputPath,
tsConfig: join(contextRoot, options.tsConfig),
rootDir,
mainOutputPath: resolve(
outputPath,
options.main.replace(`${projectRoot}/`, '').replace('.ts', '.js')
),
};
}

View File

@ -0,0 +1,88 @@
import type { ExecutorContext, TaskGraph } from '@nx/devkit';
import { rmSync } from 'fs';
import { updatePackageJson } from '../../utils/package-json/update-package-json';
import type { ExecutorOptions } from '../../utils/schema';
import type { TaskInfo } from './lib/batch';
import {
buildTaskInfoPerTsConfigMap,
compileBatchTypescript,
normalizeTasksOptions,
watchTaskProjectsFileChangesForAssets,
watchTaskProjectsPackageJsonFileChanges,
} from './lib/batch';
export async function* tscBatchExecutor(
taskGraph: TaskGraph,
inputs: Record<string, ExecutorOptions>,
overrides: ExecutorOptions,
context: ExecutorContext
) {
const tasksOptions = normalizeTasksOptions(inputs, context);
let shouldWatch = false;
Object.values(tasksOptions).forEach((taskOptions) => {
if (taskOptions.clean) {
rmSync(taskOptions.outputPath, { force: true, recursive: true });
}
if (taskOptions.watch) {
shouldWatch = true;
}
});
const tsConfigTaskInfoMap: Record<string, TaskInfo> = {};
buildTaskInfoPerTsConfigMap(
tsConfigTaskInfoMap,
tasksOptions,
context,
Object.keys(taskGraph.tasks),
shouldWatch
);
const typescriptCompilation = compileBatchTypescript(
tsConfigTaskInfoMap,
taskGraph,
shouldWatch,
(taskInfo) => {
taskInfo.assetsHandler.processAllAssetsOnceSync();
updatePackageJson(
taskInfo.options,
taskInfo.context,
taskInfo.projectGraphNode,
taskInfo.buildableProjectNodeDependencies
);
}
);
if (shouldWatch) {
const taskInfos = Object.values(tsConfigTaskInfoMap);
const watchAssetsChangesDisposer =
await watchTaskProjectsFileChangesForAssets(taskInfos);
const watchProjectsChangesDisposer =
await watchTaskProjectsPackageJsonFileChanges(
taskInfos,
(changedTaskInfos: TaskInfo[]) => {
for (const t of changedTaskInfos) {
updatePackageJson(
t.options,
t.context,
t.projectGraphNode,
t.buildableProjectNodeDependencies
);
}
}
);
const handleTermination = async (exitCode: number) => {
await typescriptCompilation.close();
watchAssetsChangesDisposer();
watchProjectsChangesDisposer();
process.exit(exitCode);
};
process.on('SIGINT', () => handleTermination(128 + 2));
process.on('SIGTERM', () => handleTermination(128 + 15));
}
return yield* typescriptCompilation.iterator;
}
export default tscBatchExecutor;

View File

@ -1,9 +1,7 @@
import { ExecutorContext } from '@nx/devkit';
import { ExecutorOptions } from '../../utils/schema';
import {
createTypeScriptCompilationOptions,
normalizeOptions,
} from './tsc.impl';
import { normalizeOptions } from './lib';
import { createTypeScriptCompilationOptions } from './tsc.impl';
describe('tscExecutor', () => {
let context: ExecutorContext;
@ -38,19 +36,14 @@ describe('tscExecutor', () => {
describe('createTypeScriptCompilationOptions', () => {
it('should create typescript compilation options for valid config', () => {
const result = createTypeScriptCompilationOptions(
normalizeOptions(
testOptions,
'/root',
'/root/libs/ui/src',
'/root/libs/ui'
),
normalizeOptions(testOptions, '/root', 'libs/ui/src', 'libs/ui'),
context
);
expect(result).toMatchObject({
outputPath: '/root/dist/libs/ui',
projectName: 'example',
projectRoot: '/root/libs/ui',
projectRoot: 'libs/ui',
rootDir: '/root/libs/ui',
tsConfig: '/root/libs/ui/tsconfig.json',
watch: false,
@ -63,8 +56,8 @@ describe('tscExecutor', () => {
normalizeOptions(
{ ...testOptions, rootDir: 'libs/ui/src' },
'/root',
'/root/libs/ui/src',
'/root/libs/ui'
'libs/ui/src',
'libs/ui'
),
context
);

View File

@ -1,13 +1,5 @@
import { ExecutorContext } from '@nx/devkit';
import { assetGlobsToFiles, FileInputOutput } from '../../utils/assets/assets';
import type { TypeScriptCompilationOptions } from '@nx/workspace/src/utilities/typescript/compilation';
import { join, resolve } from 'path';
import {
CustomTransformers,
Program,
SourceFile,
TransformerFactory,
} from 'typescript';
import { CopyAssetsHandler } from '../../utils/assets/copy-assets-handler';
import { checkDependencies } from '../../utils/check-dependencies';
import {
@ -22,79 +14,13 @@ import {
import { updatePackageJson } from '../../utils/package-json/update-package-json';
import { ExecutorOptions, NormalizedExecutorOptions } from '../../utils/schema';
import { compileTypeScriptFiles } from '../../utils/typescript/compile-typescript-files';
import { loadTsTransformers } from '../../utils/typescript/load-ts-transformers';
import { watchForSingleFileChanges } from '../../utils/watch-for-single-file-changes';
export function normalizeOptions(
options: ExecutorOptions,
contextRoot: string,
sourceRoot: string,
projectRoot: string
): NormalizedExecutorOptions {
const outputPath = join(contextRoot, options.outputPath);
const rootDir = options.rootDir
? join(contextRoot, options.rootDir)
: projectRoot;
if (options.watch == null) {
options.watch = false;
}
// TODO: put back when inlining story is more stable
// if (options.external == null) {
// options.external = 'all';
// } else if (Array.isArray(options.external) && options.external.length === 0) {
// options.external = 'none';
// }
if (Array.isArray(options.external) && options.external.length > 0) {
const firstItem = options.external[0];
if (firstItem === 'all' || firstItem === 'none') {
options.external = firstItem;
}
}
const files: FileInputOutput[] = assetGlobsToFiles(
options.assets,
contextRoot,
outputPath
);
return {
...options,
root: contextRoot,
sourceRoot,
projectRoot,
files,
outputPath,
tsConfig: join(contextRoot, options.tsConfig),
rootDir,
mainOutputPath: resolve(
outputPath,
options.main.replace(`${projectRoot}/`, '').replace('.ts', '.js')
),
};
}
import { getCustomTrasformersFactory, normalizeOptions } from './lib';
export function createTypeScriptCompilationOptions(
normalizedOptions: NormalizedExecutorOptions,
context: ExecutorContext
): TypeScriptCompilationOptions {
const { compilerPluginHooks } = loadTsTransformers(
normalizedOptions.transformers
);
const getCustomTransformers = (program: Program): CustomTransformers => ({
before: compilerPluginHooks.beforeHooks.map(
(hook) => hook(program) as TransformerFactory<SourceFile>
),
after: compilerPluginHooks.afterHooks.map(
(hook) => hook(program) as TransformerFactory<SourceFile>
),
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
(hook) => hook(program) as TransformerFactory<SourceFile>
),
});
return {
outputPath: normalizedOptions.outputPath,
projectName: context.projectName,
@ -103,7 +29,9 @@ export function createTypeScriptCompilationOptions(
tsConfig: normalizedOptions.tsConfig,
watch: normalizedOptions.watch,
deleteOutputPath: normalizedOptions.clean,
getCustomTransformers,
getCustomTransformers: getCustomTrasformersFactory(
normalizedOptions.transformers
),
};
}

View File

@ -104,12 +104,7 @@ export class CopyAssetsHandler {
async processAllAssetsOnce(): Promise<void> {
await Promise.all(
this.assetGlobs.map(async (ag) => {
let pattern: string;
if (typeof ag === 'string') {
pattern = ag;
} else {
pattern = ag.pattern;
}
const pattern = this.normalizeAssetPattern(ag);
// fast-glob only supports Unix paths
const files = await fg(pattern.replace(/\\/g, '/'), {
@ -117,27 +112,25 @@ export class CopyAssetsHandler {
dot: true, // enable hidden files
});
this.callback(
files.reduce((acc, src) => {
if (
!ag.ignore?.some((ig) => minimatch(src, ig)) &&
!this.ignore.ignores(src)
) {
const relPath = path.relative(ag.input, src);
const dest = relPath.startsWith('..') ? src : relPath;
acc.push({
type: 'create',
src: path.join(this.rootDir, src),
dest: path.join(this.rootDir, ag.output, dest),
});
}
return acc;
}, [])
);
this.callback(this.filesToEvent(files, ag));
})
);
}
processAllAssetsOnceSync(): void {
this.assetGlobs.forEach((ag) => {
const pattern = this.normalizeAssetPattern(ag);
// fast-glob only supports Unix paths
const files = fg.sync(pattern.replace(/\\/g, '/'), {
cwd: this.rootDir,
dot: true, // enable hidden files
});
this.callback(this.filesToEvent(files, ag));
});
}
async watchAndProcessOnAssetChange(): Promise<() => void> {
const unregisterFileWatcher = await daemonClient.registerFileWatcher(
{
@ -151,7 +144,7 @@ export class CopyAssetsHandler {
} else if (err) {
logger.error(`Watch error: ${err?.message ?? 'Unknown'}`);
} else {
this.processEvents(data.changedFiles);
this.processWatchEvents(data.changedFiles);
}
}
);
@ -159,7 +152,7 @@ export class CopyAssetsHandler {
return () => unregisterFileWatcher();
}
private async processEvents(events: ChangedFile[]): Promise<void> {
async processWatchEvents(events: ChangedFile[]): Promise<void> {
const fileEvents: FileEvent[] = [];
for (const event of events) {
const pathFromRoot = path.relative(this.rootDir, event.path);
@ -184,4 +177,26 @@ export class CopyAssetsHandler {
if (fileEvents.length > 0) this.callback(fileEvents);
}
private filesToEvent(files: string[], assetGlob: AssetEntry): FileEvent[] {
return files.reduce((acc, src) => {
if (
!assetGlob.ignore?.some((ig) => minimatch(src, ig)) &&
!this.ignore.ignores(src)
) {
const relPath = path.relative(assetGlob.input, src);
const dest = relPath.startsWith('..') ? src : relPath;
acc.push({
type: 'create',
src: path.join(this.rootDir, src),
dest: path.join(this.rootDir, assetGlob.output, dest),
});
}
return acc;
}, []);
}
private normalizeAssetPattern(assetEntry: AssetEntry): string {
return typeof assetEntry === 'string' ? assetEntry : assetEntry.pattern;
}
}

View File

@ -32,12 +32,18 @@ export function isInlineGraphEmpty(inlineGraph: InlineProjectGraph): boolean {
export function handleInliningBuild(
context: ExecutorContext,
options: NormalizedExecutorOptions,
tsConfigPath: string
tsConfigPath: string,
projectName: string = context.projectName
): InlineProjectGraph {
const tsConfigJson = readJsonFile(tsConfigPath);
const pathAliases =
tsConfigJson['compilerOptions']?.['paths'] || readBasePathAliases(context);
const inlineGraph = createInlineGraph(context, options, pathAliases);
const inlineGraph = createInlineGraph(
context,
options,
pathAliases,
projectName
);
if (isInlineGraphEmpty(inlineGraph)) {
return inlineGraph;
@ -136,7 +142,7 @@ function createInlineGraph(
context: ExecutorContext,
options: NormalizedExecutorOptions,
pathAliases: Record<string, string[]>,
projectName: string = context.projectName,
projectName: string,
inlineGraph: InlineProjectGraph = emptyInlineGraph()
) {
if (options.external == null) return inlineGraph;

View File

@ -50,11 +50,12 @@ export interface ExecutorOptions {
}
export interface NormalizedExecutorOptions extends ExecutorOptions {
root?: string;
sourceRoot?: string;
rootDir: string;
projectRoot: string;
mainOutputPath: string;
files: Array<FileInputOutput>;
root?: string;
sourceRoot?: string;
}
export interface SwcExecutorOptions extends ExecutorOptions {

View File

@ -28,6 +28,10 @@ import {
} from '../../project-graph/project-graph';
import { ProjectGraph } from '../../config/project-graph';
import { readNxJson } from '../../config/configuration';
import {
getLastValueFromAsyncIterableIterator,
isAsyncIterator,
} from '../../utils/async-iterator';
export interface Target {
project: string;
@ -62,12 +66,6 @@ function isPromise<T extends { success: boolean }>(
return typeof (v as any)?.then === 'function';
}
function isAsyncIterator<T extends { success: boolean }>(
v: Promise<{ success: boolean }> | AsyncIterableIterator<T>
): v is AsyncIterableIterator<T> {
return typeof (v as any)?.[Symbol.asyncIterator] === 'function';
}
async function* promiseToIterator<T extends { success: boolean }>(
v: Promise<T>
): AsyncIterableIterator<T> {
@ -77,8 +75,6 @@ async function* promiseToIterator<T extends { success: boolean }>(
async function iteratorToProcessStatusCode(
i: AsyncIterableIterator<{ success: boolean }>
): Promise<number> {
let success: boolean;
// This is a workaround to fix an issue that only happens with
// the @angular-devkit/build-angular:browser builder. Starting
// on version 12.0.1, a SASS compilation implementation was
@ -87,18 +83,7 @@ async function iteratorToProcessStatusCode(
// like CI or when running Docker builds.
const keepProcessAliveInterval = setInterval(() => {}, 1000);
try {
let prev: IteratorResult<{ success: boolean }>;
let current: IteratorResult<{ success: boolean }>;
do {
prev = current;
current = await i.next();
} while (!current.done);
success =
current.value !== undefined || !prev
? current.value.success
: prev.value.success;
const { success } = await getLastValueFromAsyncIterableIterator(i);
return success ? 0 : 1;
} finally {
clearInterval(keepProcessAliveInterval);

View File

@ -130,6 +130,13 @@ export type CustomHasher = (
context: HasherContext
) => Promise<Hash>;
export type ExecutorTaskResult = {
success: boolean;
terminalOutput: string;
startTime?: number;
endTime?: number;
};
/**
* Implementation of a target of a project that handles multiple projects to be batched
*/
@ -147,7 +154,10 @@ export type TaskGraphExecutor<T = any> = (
*/
overrides: T,
context: ExecutorContext
) => Promise<Record<string, { success: boolean; terminalOutput: string }>>;
) => Promise<
| Record<string, ExecutorTaskResult>
| AsyncIterableIterator<Record<string, ExecutorTaskResult>>
>;
/**
* Context that is passed into an executor

View File

@ -1,4 +1,5 @@
import { TaskGraph } from '../../config/task-graph';
import type { ExecutorTaskResult } from '../../config/misc-interfaces';
import type { TaskGraph } from '../../config/task-graph';
export enum BatchMessageType {
Tasks,
@ -11,17 +12,14 @@ export interface BatchTasksMessage {
batchTaskGraph: TaskGraph;
fullTaskGraph: TaskGraph;
}
/**
* Results of running the batch. Mapped from task id to results
*/
export interface BatchResults {
[taskId: string]: {
success: boolean;
terminalOutput?: string;
startTime?: number;
endTime?: number;
};
[taskId: string]: ExecutorTaskResult;
}
export interface BatchCompleteMessage {
type: BatchMessageType.Complete;
results: BatchResults;

View File

@ -13,6 +13,10 @@ import {
readProjectsConfigurationFromProjectGraph,
} from '../../project-graph/project-graph';
import { readNxJson } from '../../config/configuration';
import {
getLastValueFromAsyncIterableIterator,
isAsyncIterator,
} from '../../utils/async-iterator';
function getBatchExecutor(executorName: string) {
const workspace = new Workspaces(workspaceRoot);
@ -69,6 +73,10 @@ async function runTasks(
throw new Error(`"${executorName} returned invalid results: ${results}`);
}
if (isAsyncIterator(results)) {
return await getLastValueFromAsyncIterableIterator(results);
}
return results;
} catch (e) {
const isVerbose = tasks[0].overrides.verbose;

View File

@ -71,6 +71,7 @@ export class ForkedProcessTaskRunner {
for (const rootTaskId of batchTaskGraph.roots) {
results[rootTaskId] = {
success: false,
terminalOutput: '',
};
}
rej(
@ -84,6 +85,14 @@ export class ForkedProcessTaskRunner {
p.on('message', (message: BatchMessage) => {
switch (message.type) {
case BatchMessageType.Complete: {
Object.entries(message.results).forEach(([taskName, result]) => {
this.options.lifeCycle.printTaskTerminalOutput(
batchTaskGraph.tasks[taskName],
result.success ? 'success' : 'failure',
result.terminalOutput
);
});
res(message.results);
break;
}

View File

@ -40,11 +40,12 @@ export class StoreRunInformationLifeCycle implements LifeCycle {
taskResults: Array<{ task: Task; status: TaskStatus; code: number }>
): void {
for (let tr of taskResults) {
if (tr.task.endTime && tr.task.startTime) {
if (tr.task.startTime) {
this.timings[tr.task.id].start = new Date(
tr.task.startTime
).toISOString();
}
if (tr.task.endTime) {
this.timings[tr.task.id].end = new Date(tr.task.endTime).toISOString();
} else {
this.timings[tr.task.id].end = this.now();

View File

@ -37,8 +37,10 @@ export class TaskProfilingLifeCycle implements LifeCycle {
metadata: TaskMetadata
): void {
for (let tr of taskResults) {
if (tr.task.endTime && tr.task.startTime) {
if (tr.task.startTime) {
this.timings[tr.task.id].perfStart = tr.task.startTime;
}
if (tr.task.endTime) {
this.timings[tr.task.id].perfEnd = tr.task.endTime;
} else {
this.timings[tr.task.id].perfEnd = performance.now();

View File

@ -27,8 +27,10 @@ export class TaskTimingsLifeCycle implements LifeCycle {
}>
): void {
for (let tr of taskResults) {
if (tr.task.endTime && tr.task.startTime) {
if (tr.task.startTime) {
this.timings[tr.task.id].start = tr.task.startTime;
}
if (tr.task.endTime) {
this.timings[tr.task.id].end = tr.task.endTime;
} else {
this.timings[tr.task.id].end = new Date().getTime();

View File

@ -144,6 +144,7 @@ export class TaskOrchestrator {
task: Task;
status: 'local-cache' | 'local-cache-kept-existing' | 'remote-cache';
}> {
const startTime = new Date().getTime();
const cachedResult = await this.cache.get(task);
if (!cachedResult || cachedResult.code !== 0) return null;
@ -165,7 +166,11 @@ export class TaskOrchestrator {
cachedResult.terminalOutput
);
return {
task,
task: {
...task,
startTime,
endTime: Date.now(),
},
status,
};
}
@ -235,7 +240,11 @@ export class TaskOrchestrator {
const batchResultEntries = Object.entries(results);
return batchResultEntries.map(([taskId, result]) => ({
...result,
task: this.taskGraph.tasks[taskId],
task: {
...this.taskGraph.tasks[taskId],
startTime: result.startTime,
endTime: result.endTime,
},
status: (result.success ? 'success' : 'failure') as TaskStatus,
terminalOutput: result.terminalOutput,
}));

View File

@ -0,0 +1,16 @@
export function isAsyncIterator<T>(v: any): v is AsyncIterableIterator<T> {
return typeof v?.[Symbol.asyncIterator] === 'function';
}
export async function getLastValueFromAsyncIterableIterator<T>(
i: AsyncIterableIterator<T>
): Promise<T> {
let prev: IteratorResult<T, T>;
let current: IteratorResult<T, T>;
do {
prev = current;
current = await i.next();
} while (!current.done);
return current.value !== undefined || !prev ? current.value : prev.value;
}