perf: optimize app startup and background lifecycles
This commit is contained in:
1 parent
31332f6d76
commit
927e13349b
121 files changed
+4359
-1400
No files matched your search
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user