Files
makelore/electron/services/release-utility-process.ts

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();
},
};
}