Files
makelore/electron/services/release-job.ts

197 lines
6.3 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { resolve } from 'node:path';
import {
prepareProjectRelease,
ProjectReleaseBuildError,
type PreparedRelease,
type ReleaseBuildProgress,
type ReleaseBuildRuntimeContext,
} from './project-release-builder';
type ReleaseJobPackageSummary = Omit<PreparedRelease['sourceArchive']['summary'], 'archivePath'>;
export type ReleaseJobState = 'queued' | 'running' | 'complete' | 'failed' | 'cancelled';
export type ReleaseJobStatus = {
jobId: string;
projectId: string;
state: ReleaseJobState;
phase: ReleaseBuildProgress['phase'] | 'queued';
percent: number;
errorCode?: string;
errorMessage?: string;
result?: {
package: ReleaseJobPackageSummary;
contract: PreparedRelease['contract'];
};
};
/**
* A utility-process build only needs to return the immutable package summary
* and contract to Main. Archive bytes stay in the worker and are discarded
* before it exits, so a release status request cannot duplicate a large build
* buffer in the browser process.
*/
export type ReleaseJobPreparation = {
sourceArchive: Pick<PreparedRelease['sourceArchive'], 'summary'>;
contract: PreparedRelease['contract'];
dispose(): Promise<void>;
};
export type ReleaseJobPrepare = (input: {
projectPath: string;
clientVersion: string;
signal?: AbortSignal;
onProgress?: (progress: ReleaseBuildProgress) => void;
runtimeContext?: ReleaseBuildRuntimeContext;
}) => Promise<PreparedRelease | ReleaseJobPreparation>;
type ReleaseJobRecord = ReleaseJobStatus & {
controller: AbortController;
projectPath: string;
projectKey: string;
};
export type ReleaseJobManagerOptions = {
prepare?: ReleaseJobPrepare;
acquireLease?: (lease: { id: string; kind: string }) => () => void;
dispose?: () => void;
};
/**
* Owns the asynchronous local release build contract. The manager is kept
* independent of HTTP so Main can expose start/progress/status/cancel over
* either Host API or IPC without putting project state in the Renderer.
*/
export class ReleaseJobManager extends EventEmitter {
private readonly jobs = new Map<string, ReleaseJobRecord>();
private readonly activeProjects = new Map<string, string>();
private readonly prepare: ReleaseJobPrepare;
constructor(private readonly options: ReleaseJobManagerOptions = {}) {
super();
this.prepare = options.prepare ?? prepareProjectRelease;
}
start(input: { projectId: string; projectPath: string; clientVersion: string }): ReleaseJobStatus {
const projectKey = resolve(input.projectPath);
if (this.activeProjects.has(projectKey)) {
throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY');
}
const jobId = randomUUID();
const record: ReleaseJobRecord = {
jobId,
projectId: input.projectId,
projectPath: input.projectPath,
projectKey,
state: 'queued',
phase: 'queued',
percent: 0,
controller: new AbortController(),
};
this.jobs.set(jobId, record);
this.activeProjects.set(projectKey, jobId);
void this.run(record, input.clientVersion);
return this.snapshot(record);
}
get(jobId: string): ReleaseJobStatus | null {
const record = this.jobs.get(jobId);
return record ? this.snapshot(record) : null;
}
cancel(jobId: string): ReleaseJobStatus | null {
const record = this.jobs.get(jobId);
if (!record) return null;
if (record.state === 'queued' || record.state === 'running') {
record.controller.abort();
this.update(record, {
state: 'cancelled',
errorCode: 'LOCAL_BUILD_CANCELLED',
errorMessage: '构建已取消。',
});
}
return this.snapshot(record);
}
dispose(): void {
for (const record of this.jobs.values()) record.controller.abort();
this.jobs.clear();
this.activeProjects.clear();
this.options.dispose?.();
this.removeAllListeners();
}
private async run(record: ReleaseJobRecord, clientVersion: string): Promise<void> {
const releaseLease = this.options.acquireLease?.({ id: `release-job:${record.jobId}`, kind: 'release-build' });
let prepared: PreparedRelease | null = null;
this.update(record, { state: 'running', phase: 'packaging', percent: 1 });
try {
prepared = await this.prepare({
projectPath: record.projectPath,
clientVersion,
signal: record.controller.signal,
onProgress: (progress) => this.update(record, {
state: record.controller.signal.aborted ? 'cancelled' : 'running',
phase: progress.phase,
percent: progress.percent,
}),
});
if (record.controller.signal.aborted) {
this.update(record, {
state: 'cancelled',
errorCode: 'LOCAL_BUILD_CANCELLED',
errorMessage: '构建已取消。',
});
return;
}
this.update(record, {
state: 'complete',
phase: 'complete',
percent: 100,
result: {
package: (() => {
const { archivePath: _archivePath, ...summary } = prepared.sourceArchive.summary;
return summary;
})(),
contract: prepared.contract,
},
});
} catch (error) {
const code = error instanceof ProjectReleaseBuildError
? error.code
: 'LOCAL_BUILD_FAILED';
const cancelled = code === 'LOCAL_BUILD_CANCELLED' || record.controller.signal.aborted;
this.update(record, {
state: cancelled ? 'cancelled' : 'failed',
errorCode: cancelled ? 'LOCAL_BUILD_CANCELLED' : code,
errorMessage: cancelled ? '构建已取消。' : (error instanceof Error ? error.message : String(error)),
});
} finally {
releaseLease?.();
await prepared?.dispose().catch(() => undefined);
if (this.activeProjects.get(record.projectKey) === record.jobId) {
this.activeProjects.delete(record.projectKey);
}
}
}
private update(record: ReleaseJobRecord, patch: Partial<ReleaseJobStatus>): void {
Object.assign(record, patch);
const status = this.snapshot(record);
this.emit('status', status);
this.emit(`status:${record.jobId}`, status);
}
private snapshot(record: ReleaseJobRecord): ReleaseJobStatus {
const {
controller: _controller,
projectKey: _projectKey,
projectPath: _projectPath,
...status
} = record;
return structuredClone(status);
}
}