在 Makelore 构建并预检静态发布产物

This commit is contained in:
2026-08-12 21:19:39 +08:00
parent 3a80625fe2
commit 5b44864265
26 changed files with 1275 additions and 217 deletions

View File

@@ -19,6 +19,7 @@ import { AgentBrowserCdpGuard } from './cdp-guard';
import { AgentBrowserEventBuffer } from './event-buffer';
import { AgentBrowserFault } from './fault';
import { AgentBrowserPayloadStore } from './payload-store';
import { startStaticReleaseServer, type StaticArtifactSnapshot } from '../services/static-release-server';
const CDP_PROTOCOL_VERSION = '1.3';
const INLINE_RESULT_BYTES = 64 * 1024;
@@ -59,6 +60,7 @@ const PUBLISH_PREFLIGHT_INSPECTION = `(() => {
};
})()`;
const SAFE_PREFLIGHT_MESSAGES = {
PUBLISH_PREFLIGHT_UNAVAILABLE: '暂时无法启动作品检查,请稍后重试。',
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
@@ -202,6 +204,28 @@ export class AgentBrowserModule {
return { ok: true };
}
async preflightStaticArtifact(snapshot: StaticArtifactSnapshot): Promise<{ ok: true }> {
this.assertAvailable();
const deadline = Date.now() + PUBLISH_PREFLIGHT_TIMEOUT_MS;
let artifactServer: Awaited<ReturnType<typeof startStaticReleaseServer>>;
try {
artifactServer = await beforePublishPreflightDeadline(
startStaticReleaseServer(snapshot),
deadline,
);
} catch {
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_UNAVAILABLE');
}
try {
for (const viewport of PUBLISH_PREFLIGHT_VIEWPORTS) {
await this.preflightStaticViewport(artifactServer.entryUrl, viewport, deadline);
}
return { ok: true };
} finally {
await beforePublishPreflightDeadline(artifactServer.close(), deadline).catch(() => undefined);
}
}
async getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> {
if (!this.record) return this.closedSnapshot();
if (projectPath) this.assertProject(this.record, projectPath);
@@ -495,6 +519,7 @@ export class AgentBrowserModule {
const previewOrigin = new URL(targetUrl).origin;
let view: AgentBrowserViewPort | null = null;
let releaseOriginRestriction: (() => void) | null = null;
let contentLoadStarted = false;
let runtimeError = false;
const onDebuggerMessage: PortListener = (_event, methodValue, paramsValue) => {
if (typeof methodValue !== 'string') return;
@@ -569,13 +594,14 @@ export class AgentBrowserModule {
}),
deadline,
);
contentLoadStarted = true;
await beforePublishPreflightDeadline(view.webContents.loadURL(targetUrl), deadline);
await beforePublishPreflightDeadline(delay(PUBLISH_PREFLIGHT_SETTLE_MS), deadline);
const inspected = await beforePublishPreflightDeadline(
view.webContents.executeJavaScript(PUBLISH_PREFLIGHT_INSPECTION),
deadline,
);
if (new URL(view.webContents.getURL()).origin !== previewOrigin) {
if (view.webContents.getURL() !== targetUrl) {
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_LOAD_FAILED');
}
if (runtimeError) {
@@ -596,7 +622,11 @@ export class AgentBrowserModule {
} catch (error) {
if (error instanceof PublishPreflightFault) throw error;
throw new PublishPreflightFault(
isTimeoutError(error) ? 'PUBLISH_PREFLIGHT_TIMEOUT' : 'PUBLISH_PREFLIGHT_LOAD_FAILED',
isTimeoutError(error)
? 'PUBLISH_PREFLIGHT_TIMEOUT'
: contentLoadStarted
? 'PUBLISH_PREFLIGHT_LOAD_FAILED'
: 'PUBLISH_PREFLIGHT_UNAVAILABLE',
);
} finally {
bestEffortCleanup(() => view?.webContents.debugger.removeListener('message', onDebuggerMessage));

View File

@@ -11,10 +11,12 @@ import type {
AgentBrowserPayloadChunk,
AgentBrowserSnapshot,
} from '../../shared/agent-browser';
import type { StaticArtifactSnapshot } from '../services/static-release-server';
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
export interface AgentBrowserService {
preflightStaticArtifact(snapshot: StaticArtifactSnapshot): Promise<{ ok: true }>;
preflightCurrentProject(projectPath: string): Promise<{ ok: true }>;
getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> | AgentBrowserSnapshot;
open(input: {

View File

@@ -1,7 +1,7 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { randomUUID } from 'node:crypto';
import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { app } from 'electron';
import { lstat, mkdir, open, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -10,9 +10,9 @@ import { trustedWorksProjectPlayUrl } from '../works-play-url';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import {
createStaticProjectPackage,
ProjectPackageError,
} from '../../services/project-packager';
import { prepareProjectRelease, ProjectReleaseBuildError } from '../../services/project-release-builder';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { logger } from '../../utils/logger';
@@ -126,14 +126,12 @@ function sendPublishSourceFailure(
}
const SAFE_PUBLISH_PREFLIGHT_CODES = new Set([
'PREVIEW_REQUIRED',
'PUBLISH_PREFLIGHT_LOAD_FAILED',
'PUBLISH_PREFLIGHT_RUNTIME_ERROR',
'PUBLISH_PREFLIGHT_BLANK',
'PUBLISH_PREFLIGHT_TIMEOUT',
]);
const SAFE_PUBLISH_PREFLIGHT_MESSAGES: Record<string, string> = {
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
@@ -157,7 +155,17 @@ async function sendPublishSourceUpstreamError(
): Promise<void> {
let code = fallbackCode;
let error = fallbackMessage;
if (response.status === 401) {
let payload: unknown = null;
try { payload = await readResponsePayload(response); } catch { /* project only stable codes */ }
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
const upstreamCode = isRecord(payload)
? readOptionalString(payload.code) ?? (detail ? readOptionalString(detail.code) : undefined)
: undefined;
if (upstreamCode === 'CLIENT_BUILD_PROTOCOL_REQUIRED') {
code = upstreamCode;
error = '请升级 Makelore 并重新提交。';
}
else if (response.status === 401) {
code = 'AUTH_REQUIRED';
error = '登录状态已失效,请重新登录。';
} else if (response.status === 403) {
@@ -748,6 +756,9 @@ async function readAutomaticVersionName(projectPath: string): Promise<string> {
function createSourceUploadForm(
archiveBytes: Buffer,
archiveName: string,
builtArchiveBytes: Buffer,
builtArchiveName: string,
artifactContract: object,
versionName: string,
): FormData {
const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' });
@@ -755,6 +766,8 @@ function createSourceUploadForm(
form.set('version_name', versionName);
form.set('change_log', SOURCE_PUBLISH_CHANGE_LOG);
form.set('archive', archiveBlob, archiveName);
form.set('built_archive', new Blob([new Uint8Array(builtArchiveBytes)], { type: 'application/zip' }), builtArchiveName);
form.set('artifact_contract', JSON.stringify(artifactContract));
return form;
}
@@ -763,6 +776,9 @@ async function uploadSourceProjectVersion(input: {
appId: string;
archiveBytes: Buffer;
archiveName: string;
builtArchiveBytes: Buffer;
builtArchiveName: string;
artifactContract: object;
versionName: string;
idempotencyKey: string;
}): Promise<Response> {
@@ -780,6 +796,9 @@ async function uploadSourceProjectVersion(input: {
body: createSourceUploadForm(
input.archiveBytes,
input.archiveName,
input.builtArchiveBytes,
input.builtArchiveName,
input.artifactContract,
input.versionName,
),
},
@@ -847,24 +866,11 @@ async function handlePublishProjectSource(
return;
}
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-'));
const archivePath = join(temporaryDirectory, 'project.zip');
let prepared: Awaited<ReturnType<typeof prepareProjectRelease>> | null = null;
try {
if (!ctx.agentBrowser) {
sendPublishSourceFailure(
res,
400,
'PREVIEW_REQUIRED',
'请先在 Makelore 内置浏览器中打开当前项目预览。',
);
return;
}
await ctx.agentBrowser.preflightCurrentProject(localProject.path);
const packageSummary = await createStaticProjectPackage({
projectPath: localProject.path,
archivePath,
});
const archiveBytes = await readFile(archivePath);
if (!ctx.agentBrowser) throw new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE');
prepared = await prepareProjectRelease({ projectPath: localProject.path, clientVersion: app.getVersion() });
await ctx.agentBrowser.preflightStaticArtifact(prepared.staticArtifact);
const versionName = await readAutomaticVersionName(localProject.path);
const idempotencyKey = `makelore-${randomUUID()}`;
@@ -910,8 +916,11 @@ async function handlePublishProjectSource(
const uploadResponse = await uploadSourceProjectVersion({
accessToken,
appId,
archiveBytes,
archiveName: packageSummary.archiveName,
archiveBytes: prepared.sourceArchive.bytes,
archiveName: prepared.sourceArchive.name,
builtArchiveBytes: prepared.builtArchive.bytes,
builtArchiveName: prepared.builtArchive.name,
artifactContract: prepared.contract,
versionName,
idempotencyKey,
});
@@ -943,7 +952,7 @@ async function handlePublishProjectSource(
versionId: uploadPayload.version_id,
versionName,
reviewStatus: uploadPayload.review_status,
zipSha256: packageSummary.sha256,
zipSha256: prepared.contract.source_digest,
});
} catch {
logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved');
@@ -952,7 +961,7 @@ async function handlePublishProjectSource(
} else {
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
}
const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary;
const { archivePath: _archivePath, ...rendererPackageSummary } = prepared.sourceArchive.summary;
sendJson(res, uploadResponse.status, {
success: true,
package: rendererPackageSummary,
@@ -960,7 +969,7 @@ async function handlePublishProjectSource(
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
await prepared?.dispose().catch(() => undefined);
}
}
@@ -1147,12 +1156,15 @@ export async function handleWorksRoutes(
const isProjectStatus = /^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(url.pathname);
if (isProjectSourcePublish) {
const isPackageError = error instanceof ProjectPackageError;
const isLocalBuildError = error instanceof ProjectReleaseBuildError;
sendPublishSourceFailure(
res,
isPackageError || isSafePublishPreflightError(error) ? 400 : 503,
isPackageError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
isPackageError || isLocalBuildError || isSafePublishPreflightError(error) ? 400 : 503,
isPackageError || isLocalBuildError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
isPackageError
? error.message
: isLocalBuildError
? error.code
: isSafePublishPreflightError(error)
? SAFE_PUBLISH_PREFLIGHT_MESSAGES[error.code]
: '发布服务暂时不可用,请稍后重试。',

View File

@@ -4,7 +4,8 @@
*/
import { app, BrowserWindow, nativeImage, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { createServer } from 'node:http';
import { join } from 'node:path';
import { OpencodeManager } from '../opencode/manager';
import { buildOpencodeRuntimeConfigFromNianCodeProviders } from '../opencode/provider-config';
import {
@@ -66,6 +67,7 @@ import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browse
import { browserOAuthManager } from '../utils/browser-oauth';
import { createProjectProgressSync } from '../services/project-progress-sync';
import { createWorksSubmissionBindingStore } from '../services/works-submission-binding';
import { createStaticArtifactSnapshot } from '../services/static-release-server';
import {
consumeWorksSquareStartupRuntimeCleanupRequired,
getWorksSquareSessionRestoreStatus,
@@ -162,6 +164,7 @@ let hostApiServer: Server | null = null;
let projectProgressSync: ReturnType<typeof createProjectProgressSync> | null = null;
let worksSubmissionBinding: ReturnType<typeof createWorksSubmissionBindingStore> | null = null;
let agentBrowser: AgentBrowserModule | null = null;
let applicationInitialization: Promise<void> | null = null;
let imageWorkspaceModule: DesignWorkspaceModule | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
@@ -653,7 +656,8 @@ if (gotTheLock) {
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
applicationInitialization = initialize();
void applicationInitialization.catch((error) => {
logger.error('Application initialization failed:', error);
});
@@ -754,20 +758,52 @@ if (gotTheLock) {
// Export for testing
export { mainWindow, opencodeManager, opencodeProjectStore };
export async function runLocalPreviewPreflightE2E(url: string): Promise<{ ok: true }> {
if (!isE2EMode || !agentBrowser) throw new Error('E2E local preview preflight is unavailable');
const projectPath = join(app.getPath('temp'), 'makelore-local-preview-preflight-e2e');
await agentBrowser.open({
projectId: 'local-preview-preflight-e2e',
projectPath,
url,
visible: false,
export async function runLocalPreviewPreflightE2E(
scenario: 'success' | 'external' = 'success',
): Promise<{ ok: boolean; externalRequests: number; code?: string }> {
if (!isE2EMode) throw new Error('E2E local preview preflight is unavailable');
const initialization = applicationInitialization;
if (!initialization) {
throw new Error('E2E local preview preflight is not ready');
}
await initialization;
const browser = agentBrowser;
if (!browser) {
throw new Error('E2E local preview preflight is not ready');
}
let externalRequests = 0;
const externalServer = createServer((_request, response) => {
externalRequests += 1;
response.end('blocked');
});
try {
return await agentBrowser.preflightCurrentProject(projectPath);
await new Promise<void>((resolveListen, reject) => {
externalServer.once('error', reject);
externalServer.listen(0, '127.0.0.1', resolveListen);
});
const address = externalServer.address();
if (!address || typeof address === 'string') throw new Error('E2E external server unavailable');
const externalScript = scenario === 'external'
? `<script src="http://127.0.0.1:${address.port}/blocked.js"></script>`
: '';
const artifact = createStaticArtifactSnapshot([{
path: 'index.html',
bytes: Buffer.from(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><main style="width:100vw;height:100vh">Playable</main>${externalScript}`),
}]);
try {
await browser.preflightStaticArtifact(artifact);
return { ok: true, externalRequests };
} catch (error) {
return {
ok: false,
externalRequests,
code: error && typeof error === 'object' && 'code' in error
? String(error.code)
: 'PUBLISH_PREFLIGHT_UNAVAILABLE',
};
}
} finally {
await agentBrowser.close(projectPath).catch(() => undefined);
await agentBrowser.resetProfile(projectPath).catch(() => undefined);
await new Promise<void>((resolveClose) => externalServer.close(() => resolveClose()));
}
}

View File

@@ -0,0 +1,202 @@
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 { tmpdir } from 'node:os';
import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager';
import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime';
import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server';
const require = createRequire(import.meta.url);
const AdmZip = require('adm-zip') as typeof import('adm-zip');
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;
export type ArtifactContract = {
schema_version: 1;
entry_path: 'index.html';
source_digest: string;
built_archive_digest: string;
artifact_digest: string;
file_count: number;
total_bytes: number;
files: Array<{ path: string; size: number; sha256: string }>;
security_profile: 'works-square-static-sandbox-v1';
toolchain: { client: 'makelore'; client_version: string; node: string; npm: string; vite: string };
};
export type PreparedRelease = {
sourceArchive: { path: string; name: 'project.zip'; bytes: Buffer; summary: StaticProjectPackageSummary };
builtArchive: { path: string; name: 'built-project.zip'; bytes: Buffer };
contract: ArtifactContract;
distRoot: string;
staticArtifact: StaticArtifactSnapshot;
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') {
super(code);
this.name = 'ProjectReleaseBuildError';
}
}
const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
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 safeArchivePath(name: string): string[] | null {
if (!name || name.includes('\\') || name.startsWith('/') || name.includes('\0')) return null;
const parts = name.split('/');
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return null;
if (parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return 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))) {
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) {
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');
}
}
}
async function collectOutput(root: string): 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) {
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);
else {
total += stats.size;
if (files.length + 1 > MAX_OUTPUT_FILES || total > MAX_OUTPUT_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
files.push({ path: archivePath(relative(root, absolute)), bytes: await readFile(absolute) });
}
}
}
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) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING');
try {
if (!new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).trim()) throw new Error('empty');
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
return files;
}
export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise<PreparedRelease> {
const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-'));
try {
const sourcePath = join(taskRoot, 'project.zip');
const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath });
const sourceBytes = await readFile(sourcePath);
const snapshot = join(taskRoot, 'snapshot');
const distRoot = join(taskRoot, 'owned-dist');
const npmUserConfig = join(taskRoot, 'empty-npmrc');
const npmCache = join(taskRoot, 'npm-cache');
await mkdir(snapshot, { recursive: true });
await writeFile(npmUserConfig, '', 'utf8');
await extractSnapshot(sourcePath, snapshot);
const runtime = await resolvePublishRuntime();
await runElectronNode({
args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache],
cwd: snapshot,
deadlineMs: 5 * 60_000,
});
let viteVersion = '';
try {
const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown };
if (typeof vitePackage.version !== 'string') throw new Error('missing vite version');
viteVersion = vitePackage.version;
} catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); }
// Vite config and plugins execute with desktop-user authority. This build is not a sandbox or trust proof.
await runElectronNode({
args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'],
cwd: snapshot,
deadlineMs: 3 * 60_000,
});
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();
const builtPath = join(taskRoot, 'built-project.zip');
await writeFile(builtPath, builtBytes);
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) => ({
path: file.path,
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 }); },
};
} catch (error) {
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;
}
}

View File

@@ -0,0 +1,108 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { app } from 'electron';
const require = createRequire(import.meta.url);
const OUTPUT_LIMIT = 64 * 1024;
export class PublishRuntimeError extends Error {
constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT') {
super(code);
this.name = 'PublishRuntimeError';
}
}
export type PublishRuntime = {
npmCli: string;
nodeVersion: string;
npmVersion: string;
};
function childEnvironment(): NodeJS.ProcessEnv {
const allowed = ['ALLUSERSPROFILE', 'APPDATA', 'COMMONPROGRAMFILES', 'COMMONPROGRAMFILES(X86)',
'COMMONPROGRAMW6432', 'COMSPEC', 'HOME', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA',
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS',
'http_proxy', 'https_proxy', 'no_proxy',
'NUMBER_OF_PROCESSORS', 'OS', 'PATH', 'PATHEXT', 'PROGRAMDATA', 'PROGRAMFILES',
'PROGRAMFILES(X86)', 'PROGRAMW6432', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'TMP',
'USERPROFILE', 'WINDIR', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME'];
const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', npm_config_update_notifier: 'false' };
for (const key of allowed) {
const value = process.env[key];
if (value !== undefined) env[key] = value;
}
return env;
}
async function terminateTree(child: ChildProcess): Promise<void> {
if (!child.pid || child.exitCode !== null) return;
if (process.platform === 'win32') {
const taskkill = join(process.env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'taskkill.exe');
await new Promise<void>((resolve) => {
const killer = spawn(taskkill, ['/pid', String(child.pid), '/t', '/f'], { shell: false, windowsHide: true });
killer.once('close', () => resolve());
killer.once('error', () => resolve());
});
} else {
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
}
}
export async function runElectronNode(input: {
args: string[];
cwd?: string;
deadlineMs: number;
}): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(process.execPath, input.args, {
cwd: input.cwd,
env: childEnvironment(),
shell: false,
windowsHide: true,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const append = (current: string, chunk: Buffer) => (current + chunk.toString('utf8')).slice(-OUTPUT_LIMIT);
child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk); });
child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk); });
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
void terminateTree(child);
}, input.deadlineMs);
child.once('error', () => {
clearTimeout(timer);
reject(new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'));
});
child.once('close', (code) => {
clearTimeout(timer);
if (timedOut) reject(new PublishRuntimeError('LOCAL_BUILD_TIMEOUT'));
else if (code !== 0) reject(new PublishRuntimeError('LOCAL_BUILD_FAILED'));
else resolve({ stdout, stderr });
});
});
}
export async function resolvePublishRuntime(): Promise<PublishRuntime> {
try {
const packagedNpmPackage = join(process.resourcesPath, 'publish-runtime', 'package.json');
const npmPackage = app.isPackaged && existsSync(packagedNpmPackage)
? packagedNpmPackage
: require.resolve('npm/package.json');
const npmCli = join(dirname(npmPackage), 'bin', 'npm-cli.js');
const npmResult = await runElectronNode({ args: [npmCli, '--version'], deadlineMs: 10_000 });
const nodeResult = await runElectronNode({ args: ['--version'], deadlineMs: 10_000 });
return {
npmCli,
nodeVersion: nodeResult.stdout.trim().replace(/^v/, ''),
npmVersion: npmResult.stdout.trim(),
};
} catch (error) {
if (error instanceof PublishRuntimeError) throw error;
throw new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE');
}
}

View File

@@ -0,0 +1,182 @@
import { randomBytes } from 'node:crypto';
import { createServer, type ServerResponse } from 'node:http';
import type { Socket } from 'node:net';
import { extname } from 'node:path';
const MIME_TYPES: Readonly<Record<string, string>> = {
'.avif': 'image/avif',
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.mjs': 'text/javascript; charset=utf-8',
'.otf': 'font/otf',
'.ogg': 'audio/ogg',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.wav': 'audio/wav',
};
const MAX_FILES = 2_000;
const MAX_BYTES = 50 * 1024 * 1024;
declare const staticArtifactSnapshotBrand: unique symbol;
export interface StaticArtifactSnapshot {
readonly [staticArtifactSnapshotBrand]: true;
}
export interface StaticArtifactFile {
readonly path: string;
readonly bytes: Buffer;
}
export interface StaticReleaseServer {
readonly entryUrl: string;
close(): Promise<void>;
}
const snapshotFiles = new WeakMap<StaticArtifactSnapshot, ReadonlyMap<string, Buffer>>();
export function createStaticArtifactSnapshot(
files: readonly StaticArtifactFile[],
): StaticArtifactSnapshot {
if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES) {
throw new Error('Static artifact file set is invalid.');
}
const owned = new Map<string, Buffer>();
const destinationKeys = new Set<string>();
let totalBytes = 0;
for (const file of files) {
if (!file || !isSafeArtifactPath(file.path) || !Buffer.isBuffer(file.bytes)) {
throw new Error('Static artifact file is invalid.');
}
const destinationKey = file.path.toLowerCase();
if (destinationKeys.has(destinationKey)) {
throw new Error('Static artifact paths must be unique.');
}
destinationKeys.add(destinationKey);
const bytes = Buffer.from(file.bytes);
totalBytes += bytes.length;
if (totalBytes > MAX_BYTES) throw new Error('Static artifact is too large.');
owned.set(file.path, bytes);
}
const entry = owned.get('index.html');
if (!entry?.length) throw new Error('Static artifact entry is missing.');
const handle = Object.create(null) as StaticArtifactSnapshot;
Object.defineProperty(handle, 'toJSON', {
value: () => { throw new Error('Static artifact snapshots are Main-owned and cannot be serialized.'); },
});
Object.freeze(handle);
snapshotFiles.set(handle, owned);
return handle;
}
export function staticArtifactSnapshotFiles(
snapshot: StaticArtifactSnapshot,
): readonly StaticArtifactFile[] {
const files = snapshotFiles.get(snapshot);
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
return Array.from(files, ([path, bytes]) => ({ path, bytes: Buffer.from(bytes) }));
}
export async function startStaticReleaseServer(
snapshot: StaticArtifactSnapshot,
): Promise<StaticReleaseServer> {
const files = snapshotFiles.get(snapshot);
if (!files) throw new Error('Static artifact snapshot must be Main-owned.');
const nonce = randomBytes(24).toString('hex');
const sockets = new Set<Socket>();
const server = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.setHeader('Allow', 'GET, HEAD');
sendEmpty(response, 405);
return;
}
try {
const rawUrl = request.url ?? '';
if (rawUrl.includes('\0') || rawUrl.includes('\\')) throw new Error('unsafe');
const url = new URL(rawUrl, 'http://127.0.0.1');
const prefix = `/${nonce}/`;
if (!url.pathname.startsWith(prefix)) throw new Error('outside');
const rawPath = url.pathname.slice(prefix.length);
if (!rawPath || rawPath.endsWith('/')) throw new Error('directory');
let decoded: string;
try {
decoded = decodeURIComponent(rawPath);
} catch {
throw new Error('encoding');
}
if (!isSafeArtifactPath(decoded)) throw new Error('unsafe');
const bytes = files.get(decoded);
if (!bytes) throw new Error('missing');
response.statusCode = 200;
response.setHeader('Content-Type', MIME_TYPES[extname(decoded).toLowerCase()] ?? 'application/octet-stream');
response.setHeader('Content-Length', String(bytes.length));
response.end(request.method === 'HEAD' ? undefined : bytes);
} catch {
sendEmpty(response, 404);
}
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
try {
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject);
resolveListen();
});
});
} catch (error) {
for (const socket of sockets) socket.destroy();
throw error;
}
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Static artifact server did not bind TCP.');
let closed: Promise<void> | null = null;
return {
entryUrl: `http://127.0.0.1:${address.port}/${nonce}/index.html`,
close: () => {
closed ??= new Promise<void>((resolveClose) => {
server.close(() => resolveClose());
server.closeAllConnections?.();
for (const socket of sockets) socket.destroy();
setTimeout(resolveClose, 1_000).unref?.();
});
return closed;
},
};
}
function isSafeArtifactPath(path: string): boolean {
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false;
const parts = path.split('/');
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return false;
return !parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));
}
function sendEmpty(response: ServerResponse, status: number): void {
if (response.headersSent) return;
response.statusCode = status;
response.setHeader('Content-Length', '0');
response.end();
}