Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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