perf: optimize app startup and background lifecycles
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { lstat, mkdir, mkdtemp, open, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { app } from 'electron';
|
||||
import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager';
|
||||
import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime';
|
||||
import {
|
||||
PublishRuntimeError,
|
||||
resolvePublishRuntime,
|
||||
runElectronNode,
|
||||
type PublishRuntimeContext,
|
||||
} from './publish-runtime';
|
||||
import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server';
|
||||
import { writeStoredZip } from './streaming-zip';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
@@ -13,6 +22,7 @@ const FIXED_ZIP_TIME = new Date(1980, 0, 1);
|
||||
const FILE_MODE = 0o100644;
|
||||
const MAX_OUTPUT_FILES = 2_000;
|
||||
const MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
|
||||
const activeReleaseProjects = new Set<string>();
|
||||
|
||||
export type ArtifactContract = {
|
||||
schema_version: 1;
|
||||
@@ -36,8 +46,15 @@ export type PreparedRelease = {
|
||||
dispose(): Promise<void>;
|
||||
};
|
||||
|
||||
export type PreparedReleaseSummary = {
|
||||
sourceArchive: { path: string; name: 'project.zip'; summary: StaticProjectPackageSummary };
|
||||
builtArchive: { path: string; name: 'built-project.zip' };
|
||||
contract: ArtifactContract;
|
||||
dispose(): Promise<void>;
|
||||
};
|
||||
|
||||
export class ProjectReleaseBuildError extends Error {
|
||||
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') {
|
||||
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_CANCELLED' | 'LOCAL_BUILD_OUTPUT_MISSING' | 'LOCAL_BUILD_BUSY') {
|
||||
super(code);
|
||||
this.name = 'ProjectReleaseBuildError';
|
||||
}
|
||||
@@ -47,10 +64,8 @@ const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).di
|
||||
const archivePath = (value: string) => sep === '/' ? value : value.split(sep).join('/');
|
||||
const comparePaths = (left: string, right: string) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
||||
|
||||
function isSafeArchiveFile(entry: import('adm-zip').IZipEntry): boolean {
|
||||
const unixMode = (entry.attr >>> 16) & 0xffff;
|
||||
const unixType = unixMode & 0xf000;
|
||||
return unixType === 0 || unixType === 0x8000;
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED');
|
||||
}
|
||||
|
||||
function safeArchivePath(name: string): string[] | null {
|
||||
@@ -61,44 +76,177 @@ function safeArchivePath(name: string): string[] | null {
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function extractSnapshot(archive: string, target: string): Promise<void> {
|
||||
const zip = new AdmZip(archive);
|
||||
const destinations = new Set<string>();
|
||||
for (const entry of zip.getEntries()) {
|
||||
const name = entry.entryName;
|
||||
const parts = safeArchivePath(name);
|
||||
if (entry.header.encripted || !parts || (!entry.isDirectory && !isSafeArchiveFile(entry))) {
|
||||
const ZIP_EOCD_SIGNATURE = 0x06054b50;
|
||||
const ZIP_CENTRAL_SIGNATURE = 0x02014b50;
|
||||
const ZIP_LOCAL_SIGNATURE = 0x04034b50;
|
||||
const ZIP_STORE_METHOD = 0;
|
||||
const ZIP_MAX_COMMENT_BYTES = 0xffff;
|
||||
const MAX_SOURCE_ARCHIVE_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
type StoredZipCentralEntry = {
|
||||
name: string;
|
||||
parts: string[];
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localOffset: number;
|
||||
};
|
||||
|
||||
async function readZipBytes(
|
||||
handle: Awaited<ReturnType<typeof open>>,
|
||||
position: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> {
|
||||
throwIfAborted(signal);
|
||||
const result = Buffer.alloc(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
throwIfAborted(signal);
|
||||
const read = await handle.read(result, offset, length - offset, position + offset);
|
||||
if (read.bytesRead <= 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findZipEndOfCentralDirectory(tail: Buffer): number {
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) === ZIP_EOCD_SIGNATURE) return offset;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function readStoredZipEntries(
|
||||
archive: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ handle: Awaited<ReturnType<typeof open>>; size: number; entries: StoredZipCentralEntry[] }> {
|
||||
const handle = await open(archive, 'r');
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > MAX_SOURCE_ARCHIVE_BYTES) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const unixType = ((entry.attr >>> 16) & 0xffff) & 0xf000;
|
||||
if (unixType !== 0 && unixType !== 0x4000) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
continue;
|
||||
}
|
||||
const destinationKey = name.toLowerCase();
|
||||
if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
destinations.add(destinationKey);
|
||||
if (entry.header.size > MAX_OUTPUT_BYTES) {
|
||||
const tailLength = Math.min(stats.size, 22 + ZIP_MAX_COMMENT_BYTES);
|
||||
const tail = await readZipBytes(handle, stats.size - tailLength, tailLength, signal);
|
||||
const endOffset = findZipEndOfCentralDirectory(tail);
|
||||
if (endOffset < 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const entryCount = tail.readUInt16LE(endOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(endOffset + 16);
|
||||
if (
|
||||
tail.readUInt16LE(endOffset + 4) !== 0
|
||||
|| tail.readUInt16LE(endOffset + 6) !== 0
|
||||
|| entryCount === 0
|
||||
|| entryCount > MAX_OUTPUT_FILES
|
||||
|| centralSize > stats.size
|
||||
|| centralOffset > stats.size
|
||||
|| centralOffset + centralSize > stats.size
|
||||
) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
const destination = join(target, ...parts);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
try {
|
||||
const data = entry.getData();
|
||||
await writeFile(destination, data, { flag: 'wx', mode: 0o600 });
|
||||
} catch {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
|
||||
const entries: StoredZipCentralEntry[] = [];
|
||||
let cursor = centralOffset;
|
||||
const centralEnd = centralOffset + centralSize;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
throwIfAborted(signal);
|
||||
if (cursor + 46 > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const header = await readZipBytes(handle, cursor, 46, signal);
|
||||
if (header.readUInt32LE(0) !== ZIP_CENTRAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const nameLength = header.readUInt16LE(28);
|
||||
const extraLength = header.readUInt16LE(30);
|
||||
const commentLength = header.readUInt16LE(32);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (cursor + recordLength > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const name = (await readZipBytes(handle, cursor + 46, nameLength, signal)).toString('utf8');
|
||||
const parts = safeArchivePath(name);
|
||||
const flags = header.readUInt16LE(8);
|
||||
const method = header.readUInt16LE(10);
|
||||
const compressedSize = header.readUInt32LE(20);
|
||||
const uncompressedSize = header.readUInt32LE(24);
|
||||
const localOffset = header.readUInt32LE(42);
|
||||
const unixMode = (header.readUInt32LE(38) >>> 16) & 0xffff;
|
||||
const unixType = unixMode & 0xf000;
|
||||
if (
|
||||
!parts
|
||||
|| (flags & 0x0001) !== 0
|
||||
|| method !== ZIP_STORE_METHOD
|
||||
|| compressedSize === 0xffffffff
|
||||
|| uncompressedSize === 0xffffffff
|
||||
|| localOffset >= centralOffset
|
||||
|| (unixType !== 0 && unixType !== 0x8000)
|
||||
) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
entries.push({ name, parts, compressedSize, uncompressedSize, localOffset });
|
||||
cursor += recordLength;
|
||||
}
|
||||
if (cursor !== centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
return { handle, size: stats.size, entries };
|
||||
} catch (error) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectOutput(root: string): Promise<Array<{ path: string; bytes: Buffer }>> {
|
||||
async function extractSnapshot(archive: string, target: string, signal?: AbortSignal): Promise<void> {
|
||||
const zip = await readStoredZipEntries(archive, signal);
|
||||
const destinations = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
for (const entry of zip.entries) {
|
||||
throwIfAborted(signal);
|
||||
const destinationKey = entry.name.toLowerCase();
|
||||
if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
destinations.add(destinationKey);
|
||||
if (entry.uncompressedSize > MAX_SOURCE_ARCHIVE_BYTES || entry.uncompressedSize !== entry.compressedSize) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
totalBytes += entry.uncompressedSize;
|
||||
if (totalBytes > MAX_SOURCE_ARCHIVE_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const localHeader = await readZipBytes(zip.handle, entry.localOffset, 30, signal);
|
||||
if (localHeader.readUInt32LE(0) !== ZIP_LOCAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const localFlags = localHeader.readUInt16LE(6);
|
||||
const localMethod = localHeader.readUInt16LE(8);
|
||||
const localNameLength = localHeader.readUInt16LE(26);
|
||||
const localExtraLength = localHeader.readUInt16LE(28);
|
||||
if ((localFlags & 0x0001) !== 0 || localMethod !== ZIP_STORE_METHOD) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const localName = (await readZipBytes(zip.handle, entry.localOffset + 30, localNameLength, signal)).toString('utf8');
|
||||
if (localName !== entry.name) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength;
|
||||
const dataEnd = dataStart + entry.compressedSize;
|
||||
if (dataStart > zip.size || dataEnd > zip.size || dataEnd < dataStart) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
const destination = join(target, ...entry.parts);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
try {
|
||||
if (entry.compressedSize === 0) {
|
||||
await writeFile(destination, Buffer.alloc(0), { flag: 'wx', mode: 0o600 });
|
||||
} else {
|
||||
const input = zip.handle.createReadStream({ start: dataStart, end: dataEnd - 1, autoClose: false });
|
||||
const output = createWriteStream(destination, { flags: 'wx', mode: 0o600 });
|
||||
await pipeline(input, output, { signal });
|
||||
}
|
||||
const written = await lstat(destination);
|
||||
if (!written.isFile() || written.size !== entry.uncompressedSize) throw new Error('size mismatch');
|
||||
} catch {
|
||||
await rm(destination, { force: true }).catch(() => undefined);
|
||||
throwIfAborted(signal);
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await zip.handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectOutput(root: string, signal?: AbortSignal): Promise<Array<{ path: string; bytes: Buffer }>> {
|
||||
const files: Array<{ path: string; bytes: Buffer }> = [];
|
||||
let total = 0;
|
||||
async function walk(directory: string): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((a, b) => comparePaths(a.name, b.name));
|
||||
for (const entry of entries) {
|
||||
throwIfAborted(signal);
|
||||
const absolute = join(directory, entry.name);
|
||||
const stats = await lstat(absolute);
|
||||
if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
@@ -124,25 +272,131 @@ async function collectOutput(root: string): Promise<Array<{ path: string; bytes:
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise<PreparedRelease> {
|
||||
const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-'));
|
||||
async function hashFile(filePath: string, signal?: AbortSignal): Promise<{ bytes: number; sha256: string }> {
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const stream = createReadStream(filePath);
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
throwIfAborted(signal);
|
||||
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += value.length;
|
||||
hash.update(value);
|
||||
}
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
return { bytes, sha256: hash.digest('hex') };
|
||||
}
|
||||
|
||||
async function collectOutputMetadata(
|
||||
root: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Array<{ path: string; filePath: string; size: number; sha256: string }>> {
|
||||
const files: Array<{ path: string; filePath: string; size: number; sha256: string }> = [];
|
||||
let total = 0;
|
||||
|
||||
async function walk(directory: string): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((a, b) => comparePaths(a.name, b.name));
|
||||
for (const entry of entries) {
|
||||
throwIfAborted(signal);
|
||||
const absolute = join(directory, entry.name);
|
||||
const stats = await lstat(absolute);
|
||||
if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
await walk(absolute);
|
||||
continue;
|
||||
}
|
||||
const path = archivePath(relative(root, absolute));
|
||||
if (files.length + 1 > MAX_OUTPUT_FILES || total + stats.size > MAX_OUTPUT_BYTES) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
}
|
||||
const digest = await hashFile(absolute, signal);
|
||||
if (digest.bytes !== stats.size) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
total += digest.bytes;
|
||||
files.push({ path, filePath: absolute, size: digest.bytes, sha256: digest.sha256 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await walk(root);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectReleaseBuildError) throw error;
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
}
|
||||
files.sort((a, b) => comparePaths(a.path, b.path));
|
||||
if (files.some((file) => file.path.toLowerCase() === 'release.json')) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
}
|
||||
const index = files.find((file) => file.path === 'index.html');
|
||||
if (!index || index.size === 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
|
||||
return files;
|
||||
}
|
||||
|
||||
export type ReleaseBuildProgress = {
|
||||
phase: 'packaging' | 'installing' | 'building' | 'archiving' | 'complete';
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type ReleaseBuildRuntimeContext = Partial<PublishRuntimeContext> & {
|
||||
/** Persistent npm cache supplied by Main so utility processes never need app.getPath(). */
|
||||
npmCacheDir?: string;
|
||||
};
|
||||
|
||||
type ReleaseBuildInput = {
|
||||
projectPath: string;
|
||||
clientVersion: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: ReleaseBuildProgress) => void;
|
||||
runtimeContext?: ReleaseBuildRuntimeContext;
|
||||
};
|
||||
|
||||
async function prepareProjectReleaseInternal(
|
||||
input: ReleaseBuildInput,
|
||||
retainBuffers: boolean,
|
||||
): Promise<PreparedRelease | PreparedReleaseSummary> {
|
||||
const projectKey = resolve(input.projectPath);
|
||||
if (activeReleaseProjects.has(projectKey)) {
|
||||
throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY');
|
||||
}
|
||||
activeReleaseProjects.add(projectKey);
|
||||
const progress = (phase: ReleaseBuildProgress['phase'], percent: number): void => {
|
||||
input.onProgress?.({ phase, percent });
|
||||
throwIfAborted(input.signal);
|
||||
};
|
||||
let taskRoot: string;
|
||||
try {
|
||||
taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-'));
|
||||
} catch (error) {
|
||||
activeReleaseProjects.delete(projectKey);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const sourcePath = join(taskRoot, 'project.zip');
|
||||
progress('packaging', 5);
|
||||
const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath });
|
||||
const sourceBytes = await readFile(sourcePath);
|
||||
progress('packaging', 20);
|
||||
const sourceBytes = retainBuffers ? await readFile(sourcePath) : undefined;
|
||||
const snapshot = join(taskRoot, 'snapshot');
|
||||
const distRoot = join(taskRoot, 'owned-dist');
|
||||
const npmUserConfig = join(taskRoot, 'empty-npmrc');
|
||||
const npmCache = join(taskRoot, 'npm-cache');
|
||||
const npmCache = input.runtimeContext?.npmCacheDir
|
||||
?? join(app.getPath('userData'), 'npm-cache');
|
||||
await mkdir(snapshot, { recursive: true });
|
||||
await writeFile(npmUserConfig, '', 'utf8');
|
||||
await extractSnapshot(sourcePath, snapshot);
|
||||
const runtime = await resolvePublishRuntime();
|
||||
await extractSnapshot(sourcePath, snapshot, input.signal);
|
||||
progress('installing', 25);
|
||||
const runtime = await resolvePublishRuntime(input.runtimeContext);
|
||||
await runElectronNode({
|
||||
args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache],
|
||||
args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--progress=false', '--userconfig', npmUserConfig, '--cache', npmCache],
|
||||
cwd: snapshot,
|
||||
deadlineMs: 5 * 60_000,
|
||||
signal: input.signal,
|
||||
});
|
||||
progress('installing', 55);
|
||||
let viteVersion = '';
|
||||
try {
|
||||
const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown };
|
||||
@@ -154,18 +408,38 @@ export async function prepareProjectRelease(input: { projectPath: string; client
|
||||
args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'],
|
||||
cwd: snapshot,
|
||||
deadlineMs: 3 * 60_000,
|
||||
signal: input.signal,
|
||||
});
|
||||
const output = await collectOutput(distRoot);
|
||||
const zip = new AdmZip();
|
||||
const files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) }));
|
||||
for (const file of output) {
|
||||
zip.addFile(file.path, file.bytes, '', FILE_MODE);
|
||||
const entry = zip.getEntry(file.path);
|
||||
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
||||
}
|
||||
const builtBytes = zip.toBuffer();
|
||||
progress('building', 80);
|
||||
progress('archiving', 90);
|
||||
const builtPath = join(taskRoot, 'built-project.zip');
|
||||
await writeFile(builtPath, builtBytes);
|
||||
let files: Array<{ path: string; size: number; sha256: string }>;
|
||||
let builtBytes: Buffer | undefined;
|
||||
let builtArchiveDigest: string;
|
||||
let staticArtifact: StaticArtifactSnapshot | undefined;
|
||||
if (retainBuffers) {
|
||||
const output = await collectOutput(distRoot, input.signal);
|
||||
files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) }));
|
||||
const zip = new AdmZip();
|
||||
for (const file of output) {
|
||||
zip.addFile(file.path, file.bytes, '', FILE_MODE);
|
||||
const entry = zip.getEntry(file.path);
|
||||
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
||||
}
|
||||
builtBytes = zip.toBuffer();
|
||||
await writeFile(builtPath, builtBytes);
|
||||
builtArchiveDigest = sha256(builtBytes);
|
||||
staticArtifact = createStaticArtifactSnapshot(output);
|
||||
} else {
|
||||
const metadata = await collectOutputMetadata(distRoot, input.signal);
|
||||
files = metadata.map(({ path, size, sha256: digest }) => ({ path, size, sha256: digest }));
|
||||
const streamed = await writeStoredZip(
|
||||
metadata.map((file) => ({ path: file.path, source: { filePath: file.filePath } })),
|
||||
builtPath,
|
||||
{ signal: input.signal, date: FIXED_ZIP_TIME },
|
||||
);
|
||||
builtArchiveDigest = streamed.sha256;
|
||||
}
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
// Matches the server's canonical JSON: sorted files, sorted object keys, no whitespace.
|
||||
const artifactDigest = sha256(JSON.stringify(files.map((file) => ({
|
||||
@@ -173,30 +447,50 @@ export async function prepareProjectRelease(input: { projectPath: string; client
|
||||
sha256: file.sha256,
|
||||
size: file.size,
|
||||
}))));
|
||||
const staticArtifact = createStaticArtifactSnapshot(output);
|
||||
return {
|
||||
sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes, summary },
|
||||
builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes },
|
||||
distRoot,
|
||||
staticArtifact,
|
||||
contract: {
|
||||
schema_version: 1,
|
||||
entry_path: 'index.html',
|
||||
source_digest: sha256(sourceBytes),
|
||||
built_archive_digest: sha256(builtBytes),
|
||||
artifact_digest: artifactDigest,
|
||||
file_count: files.length,
|
||||
total_bytes: totalBytes,
|
||||
files,
|
||||
security_profile: 'works-square-static-sandbox-v1',
|
||||
toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion },
|
||||
},
|
||||
dispose: async () => { await rm(taskRoot, { recursive: true, force: true }); },
|
||||
const contract: ArtifactContract = {
|
||||
schema_version: 1,
|
||||
entry_path: 'index.html',
|
||||
source_digest: sourceBytes ? sha256(sourceBytes) : (await hashFile(sourcePath, input.signal)).sha256,
|
||||
built_archive_digest: builtArchiveDigest,
|
||||
artifact_digest: artifactDigest,
|
||||
file_count: files.length,
|
||||
total_bytes: totalBytes,
|
||||
files,
|
||||
security_profile: 'works-square-static-sandbox-v1',
|
||||
toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion },
|
||||
};
|
||||
const dispose = async (): Promise<void> => { await rm(taskRoot, { recursive: true, force: true }); };
|
||||
const prepared: PreparedRelease | PreparedReleaseSummary = retainBuffers
|
||||
? {
|
||||
sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes!, summary },
|
||||
builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes! },
|
||||
distRoot,
|
||||
staticArtifact: staticArtifact!,
|
||||
contract,
|
||||
dispose,
|
||||
}
|
||||
: {
|
||||
sourceArchive: { path: sourcePath, name: 'project.zip', summary },
|
||||
builtArchive: { path: builtPath, name: 'built-project.zip' },
|
||||
contract,
|
||||
dispose,
|
||||
};
|
||||
progress('complete', 100);
|
||||
activeReleaseProjects.delete(projectKey);
|
||||
return prepared;
|
||||
} catch (error) {
|
||||
activeReleaseProjects.delete(projectKey);
|
||||
await rm(taskRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (error instanceof ProjectReleaseBuildError) throw error;
|
||||
if (error instanceof PublishRuntimeError) throw new ProjectReleaseBuildError(error.code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareProjectRelease(input: ReleaseBuildInput): Promise<PreparedRelease> {
|
||||
return await prepareProjectReleaseInternal(input, true) as PreparedRelease;
|
||||
}
|
||||
|
||||
export async function prepareProjectReleaseSummary(input: ReleaseBuildInput): Promise<PreparedReleaseSummary> {
|
||||
return await prepareProjectReleaseInternal(input, false) as PreparedReleaseSummary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user