192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { utilityProcess, type UtilityProcess } from 'electron';
|
|
import {
|
|
prepareProjectReleaseSummary,
|
|
ProjectReleaseBuildError,
|
|
type ReleaseBuildProgress,
|
|
type ReleaseBuildRuntimeContext,
|
|
} from './project-release-builder';
|
|
import type { ReleaseJobPrepare, ReleaseJobPreparation } from './release-job';
|
|
|
|
type UtilityStartMessage = {
|
|
type: 'start';
|
|
jobId: string;
|
|
projectPath: string;
|
|
clientVersion: string;
|
|
runtimeContext: ReleaseBuildRuntimeContext;
|
|
};
|
|
|
|
type UtilityCancelMessage = {
|
|
type: 'cancel';
|
|
jobId: string;
|
|
};
|
|
|
|
type UtilityMessage =
|
|
| { type: 'progress'; jobId: string; progress: ReleaseBuildProgress }
|
|
| {
|
|
type: 'complete';
|
|
jobId: string;
|
|
packageSummary: ReleaseJobPreparation['sourceArchive']['summary'];
|
|
contract: ReleaseJobPreparation['contract'];
|
|
}
|
|
| {
|
|
type: 'error';
|
|
jobId: string;
|
|
code: string;
|
|
message: string;
|
|
};
|
|
|
|
type UtilityProcessFactoryOptions = {
|
|
workerPath?: string;
|
|
runtimeContext: ReleaseBuildRuntimeContext;
|
|
/** Useful for diagnostics and a safe development fallback before a build. */
|
|
forceDirect?: boolean;
|
|
};
|
|
|
|
export type ReleaseUtilityPreparer = {
|
|
prepare: ReleaseJobPrepare;
|
|
dispose(): void;
|
|
};
|
|
|
|
const knownBuildCodes = new Set([
|
|
'LOCAL_BUILD_RUNTIME_UNAVAILABLE',
|
|
'LOCAL_BUILD_FAILED',
|
|
'LOCAL_BUILD_TIMEOUT',
|
|
'LOCAL_BUILD_CANCELLED',
|
|
'LOCAL_BUILD_OUTPUT_MISSING',
|
|
'LOCAL_BUILD_BUSY',
|
|
]);
|
|
|
|
function workerMessage(value: unknown): UtilityMessage | null {
|
|
const candidate = value && typeof value === 'object' && 'data' in value
|
|
? (value as { data?: unknown }).data
|
|
: value;
|
|
if (!candidate || typeof candidate !== 'object') return null;
|
|
const message = candidate as Partial<UtilityMessage>;
|
|
return typeof message.type === 'string' && typeof message.jobId === 'string'
|
|
? message as UtilityMessage
|
|
: null;
|
|
}
|
|
|
|
function toBuildError(code: string, message?: string): ProjectReleaseBuildError {
|
|
const safeCode = knownBuildCodes.has(code)
|
|
? code as ConstructorParameters<typeof ProjectReleaseBuildError>[0]
|
|
: 'LOCAL_BUILD_FAILED';
|
|
const error = new ProjectReleaseBuildError(safeCode);
|
|
if (message && message !== error.message) {
|
|
error.message = message;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
function defaultWorkerPath(): string {
|
|
// Main is bundled into dist-electron/main; the utility entry is emitted next
|
|
// to it so the packaged asar contains an explicit, inspectable worker.
|
|
return join(__dirname, '../utility/release-utility-worker.js');
|
|
}
|
|
|
|
export function createReleaseUtilityPreparer(
|
|
options: UtilityProcessFactoryOptions,
|
|
): ReleaseUtilityPreparer {
|
|
const workers = new Set<UtilityProcess>();
|
|
const workerPath = options.workerPath ?? defaultWorkerPath();
|
|
const canFork = !options.forceDirect
|
|
&& process.env.NIANCODE_DISABLE_RELEASE_UTILITY !== '1'
|
|
&& existsSync(workerPath);
|
|
|
|
const prepare: ReleaseJobPrepare = async (input) => {
|
|
if (!canFork) {
|
|
return await prepareProjectReleaseSummary({
|
|
...input,
|
|
runtimeContext: { ...options.runtimeContext, ...input.runtimeContext },
|
|
});
|
|
}
|
|
|
|
const child = utilityProcess.fork(workerPath, [], {
|
|
serviceName: 'Makelore Release Build',
|
|
stdio: 'ignore',
|
|
});
|
|
workers.add(child);
|
|
const jobId = randomUUID();
|
|
|
|
return await new Promise<ReleaseJobPreparation>((resolve, reject) => {
|
|
let settled = false;
|
|
let cancelTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = (): void => {
|
|
workers.delete(child);
|
|
if (cancelTimer !== null) clearTimeout(cancelTimer);
|
|
input.signal?.removeEventListener('abort', abort);
|
|
};
|
|
|
|
const finish = (callback: () => void): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
callback();
|
|
};
|
|
|
|
const abort = (): void => {
|
|
if (settled) return;
|
|
child.postMessage({ type: 'cancel', jobId } satisfies UtilityCancelMessage);
|
|
cancelTimer = setTimeout(() => {
|
|
if (!settled) {
|
|
child.kill();
|
|
finish(() => reject(new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED')));
|
|
}
|
|
}, 2_000);
|
|
};
|
|
|
|
child.on('message', (value) => {
|
|
const message = workerMessage(value);
|
|
if (!message || message.jobId !== jobId) return;
|
|
if (message.type === 'progress') {
|
|
input.onProgress?.(message.progress);
|
|
return;
|
|
}
|
|
if (message.type === 'complete') {
|
|
finish(() => resolve({
|
|
sourceArchive: { summary: message.packageSummary },
|
|
contract: message.contract,
|
|
dispose: async () => undefined,
|
|
}));
|
|
return;
|
|
}
|
|
if (message.type === 'error') {
|
|
finish(() => reject(toBuildError(message.code, message.message)));
|
|
}
|
|
});
|
|
child.on('error', () => {
|
|
finish(() => reject(new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE')));
|
|
});
|
|
child.on('exit', (code) => {
|
|
if (settled) return;
|
|
finish(() => reject(
|
|
input.signal?.aborted
|
|
? new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED')
|
|
: new ProjectReleaseBuildError(code === 0 ? 'LOCAL_BUILD_FAILED' : 'LOCAL_BUILD_RUNTIME_UNAVAILABLE'),
|
|
));
|
|
});
|
|
|
|
input.signal?.addEventListener('abort', abort, { once: true });
|
|
child.postMessage({
|
|
type: 'start',
|
|
jobId,
|
|
projectPath: input.projectPath,
|
|
clientVersion: input.clientVersion,
|
|
runtimeContext: { ...options.runtimeContext, ...input.runtimeContext },
|
|
} satisfies UtilityStartMessage);
|
|
});
|
|
};
|
|
|
|
return {
|
|
prepare,
|
|
dispose: () => {
|
|
for (const child of workers) child.kill();
|
|
workers.clear();
|
|
},
|
|
};
|
|
}
|