Skip to content
18 changes: 18 additions & 0 deletions apps/rush/src/IRushFrontendLaunchOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { ILaunchOptions } from '@microsoft/rush-lib';
import type { IReporterEventSink } from '@rushstack/rush-reporter';

/**
* The cross-version launch contract owned by the Rush frontend.
*
* @remarks
* Reporter selection remains in `@microsoft/rush`. The selected `rush-lib`
* receives only the typed producer sink in addition to its existing launch
* options, so an older engine can safely ignore the new property.
*/
export interface IRushFrontendLaunchOptions extends ILaunchOptions {
readonly reporterEventSink: IReporterEventSink;
readonly reporterCloseAsync: () => Promise<void>;
}
41 changes: 40 additions & 1 deletion apps/rush/src/MinimalRushConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import * as path from 'node:path';

import { JsonFile } from '@rushstack/node-core-library';
import { FileSystem, JsonFile } from '@rushstack/node-core-library';
import { RushConfiguration } from '@microsoft/rush-lib';
import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants';
import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser';
Expand All @@ -13,13 +13,18 @@ interface IMinimalRushConfigurationJson {
rushVersion?: string;
}

interface IMinimalExperimentsConfigurationJson {
useRushReporter?: boolean;
}

/**
* Represents a minimal subset of the rush.json configuration file. It provides the information necessary to
* decide which version of Rush should be installed/used.
*/
export class MinimalRushConfiguration {
private _rushVersion: string;
private _commonRushConfigFolder: string;
private _useRushReporter: boolean;

private constructor(minimalRushConfigurationJson: IMinimalRushConfigurationJson, rushJsonFilename: string) {
this._rushVersion =
Expand All @@ -30,6 +35,20 @@ export class MinimalRushConfiguration {
'config',
'rush'
);

const experimentsJsonFilename: string = path.join(
this._commonRushConfigFolder,
RushConstants.experimentsFilename
);
const experimentsConfiguration: IMinimalExperimentsConfigurationJson | undefined =
_loadExperimentsConfigurationJson(experimentsJsonFilename);
if (
experimentsConfiguration?.useRushReporter !== undefined &&
typeof experimentsConfiguration.useRushReporter !== 'boolean'
) {
throw new Error(`The "useRushReporter" setting in "${experimentsJsonFilename}" must be true or false.`);
}
this._useRushReporter = experimentsConfiguration?.useRushReporter === true;
}

public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined {
Expand Down Expand Up @@ -68,6 +87,13 @@ export class MinimalRushConfiguration {
public get commonRushConfigFolder(): string {
return this._commonRushConfigFolder;
}

/**
* Whether the repository explicitly opted in to the experimental Rush reporter frontend.
*/
public get useRushReporter(): boolean {
return this._useRushReporter;
}
}

function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined {
Expand All @@ -77,3 +103,16 @@ function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigura
return undefined;
}
}

function _loadExperimentsConfigurationJson(
experimentsJsonFilename: string
): IMinimalExperimentsConfigurationJson | undefined {
try {
return JsonFile.load(experimentsJsonFilename);
} catch (e) {
if (FileSystem.isNotExistError(e)) {
return undefined;
}
throw e;
}
}
8 changes: 3 additions & 5 deletions apps/rush/src/RushCommandSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

import * as path from 'node:path';

import type { ILaunchOptions } from '@microsoft/rush-lib/lib/index';
import { Colorize } from '@rushstack/terminal';
import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions';

type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined;

Expand All @@ -28,7 +27,7 @@ export class RushCommandSelector {
public static execute(
launcherVersion: string,
selectedRushLib: typeof import('@microsoft/rush-lib'),
options: ILaunchOptions
options: IRushFrontendLaunchOptions
): void {
const { Rush } = selectedRushLib;

Expand Down Expand Up @@ -65,8 +64,7 @@ export class RushCommandSelector {
}

function _failWithError(message: string): never {
console.log(Colorize.red(message));
return process.exit(1);
throw new Error(message);
}

function _getCommandName(): CommandName {
Expand Down
203 changes: 203 additions & 0 deletions apps/rush/src/RushFrontend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { ILaunchOptions } from '@microsoft/rush-lib';
import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter';

import {
initializeRushReporterHostAsync,
stripReporterValueControls,
type IRushReporterHostOptions,
type IInitializedRushReporterHost
} from './RushReporterHost';
import { RushCommandSelector } from './RushCommandSelector';
import { RushVersionSelector } from './RushVersionSelector';
import type { MinimalRushConfiguration } from './MinimalRushConfiguration';
import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions';

export interface IRushFrontendOptions {
readonly currentPackageVersion: string;
readonly rushVersionToLoad: string | undefined;
readonly configuration: MinimalRushConfiguration | undefined;
readonly launchOptions: ILaunchOptions;
readonly currentRushLib: typeof import('@microsoft/rush-lib');
readonly initializeReporterHostAsync?: (
options: IRushReporterHostOptions
) => Promise<IInitializedRushReporterHost>;
readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector;
readonly executeCurrentRush?: (
currentPackageVersion: string,
currentRushLib: typeof import('@microsoft/rush-lib'),
launchOptions: IRushFrontendLaunchOptions
) => void | Promise<void>;
readonly processLifecycle?: IRushFrontendProcessLifecycle;
}

type RushTerminationSignal = 'SIGINT' | 'SIGTERM';

export interface IRushFrontendProcessLifecycle {
registerBeforeExit(listener: () => void): () => void;
registerSignal(signal: RushTerminationSignal, listener: () => void): () => void;
terminate(signal: RushTerminationSignal): void;
setExitCode(exitCode: number): void;
reportCloseError(error: Error): void;
}

class RushFrontendReporterLifecycle {
private readonly _reporterHost: IInitializedRushReporterHost;
private readonly _processLifecycle: IRushFrontendProcessLifecycle;
private _disposeBeforeExit: (() => void) | undefined;
private readonly _disposeSignalHandlers: Array<() => void> = [];
private _closePromise: Promise<void> | undefined;

public constructor(
reporterHost: IInitializedRushReporterHost,
processLifecycle: IRushFrontendProcessLifecycle
) {
this._reporterHost = reporterHost;
this._processLifecycle = processLifecycle;
}

public start(): void {
this._disposeBeforeExit = this._processLifecycle.registerBeforeExit(() => {
void this.closeAsync().catch((error: Error) => {
this._processLifecycle.reportCloseError(error);
this._processLifecycle.setExitCode(1);
});
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
this._disposeSignalHandlers.push(
this._processLifecycle.registerSignal(signal, () => {
this._disposeSignals();
void this._closeForSignalAsync(signal);
})
);
}
}

public closeAsync(timeoutMs?: number): Promise<void> {
if (!this._closePromise) {
this._closePromise = Promise.resolve()
.then(() => this._reporterHost.closeAsync(timeoutMs))
.finally(() => this._dispose());
}
return this._closePromise;
}

private _dispose(): void {
this._disposeBeforeExit?.();
this._disposeBeforeExit = undefined;
this._disposeSignals();
}

private _disposeSignals(): void {
for (const dispose of this._disposeSignalHandlers.splice(0)) {
dispose();
}
}

private async _closeForSignalAsync(signal: RushTerminationSignal): Promise<void> {
const closeResult: Promise<Error | undefined> = this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS).then(
() => undefined,
(error: Error) => error
);
let timeout: ReturnType<typeof setTimeout> | undefined;
const deadline: Promise<'deadline'> = new Promise((resolve: (value: 'deadline') => void) => {
timeout = setTimeout(() => resolve('deadline'), DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS);
});

const result: Error | 'deadline' | undefined = await Promise.race([closeResult, deadline]);
if (timeout !== undefined) {
clearTimeout(timeout);
}
if (result === 'deadline') {
this._processLifecycle.reportCloseError(
new Error(`Reporter close exceeded the ${DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS}ms signal deadline.`)
);
} else if (result) {
this._processLifecycle.reportCloseError(result);
}
this._dispose();
this._processLifecycle.terminate(signal);
}
}

export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise<void> {
const {
currentPackageVersion,
rushVersionToLoad,
configuration,
launchOptions,
currentRushLib,
initializeReporterHostAsync = initializeRushReporterHostAsync,
createVersionSelector = (version: string) => new RushVersionSelector(version),
executeCurrentRush = RushCommandSelector.execute,
processLifecycle = createProcessLifecycle()
} = options;

const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({
repositoryOptIn: configuration?.useRushReporter,
forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion,
selectedRushVersion: rushVersionToLoad
});
const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled
? new RushFrontendReporterLifecycle(reporterHost, processLifecycle)
: undefined;
reporterLifecycle?.start();
if (reporterHost.selection.reporterControlsOwnedByFrontend) {
process.argv = stripReporterValueControls(
process.argv,
new Set(reporterHost.selection.reporterValueFlagsToStrip)
);
}
const reporterCloseAsync: () => Promise<void> = () =>
reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync();
const reporterLaunchOptions: IRushFrontendLaunchOptions = {
...launchOptions,
reporterEventSink: reporterHost.sink,
reporterCloseAsync
};

try {
if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) {
const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion);
await versionSelector.ensureRushVersionInstalledAsync(
rushVersionToLoad,
configuration,
reporterLaunchOptions
);
} else {
await executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions);
}
} catch (error) {
try {
await reporterCloseAsync();
} catch (closeError) {
processLifecycle.reportCloseError(closeError as Error);
processLifecycle.setExitCode(1);
}
throw error;
}
}

function createProcessLifecycle(): IRushFrontendProcessLifecycle {
return {
registerBeforeExit: (listener: () => void) => {
process.once('beforeExit', listener);
return () => process.off('beforeExit', listener);
},
registerSignal: (signal: RushTerminationSignal, listener: () => void) => {
process.once(signal, listener);
return () => process.off(signal, listener);
},
terminate: (signal: RushTerminationSignal) => {
process.kill(process.pid, signal);
},
setExitCode: (exitCode: number) => {
process.exitCode = exitCode;
},
reportCloseError: (error: Error) => {
process.stderr.write(`[reporter] Unable to finalize reporters: ${error.message}\n`);
}
};
}
Loading