perf: optimize app startup and background lifecycles

This commit is contained in:
inman
2026-08-19 00:56:15 +08:00
parent 31332f6d76
commit 927e13349b
121 changed files with 4359 additions and 1400 deletions

View File

@@ -12,6 +12,8 @@ import type {
AgentBrowserSnapshot,
} from '../../shared/agent-browser';
import type { StaticArtifactSnapshot } from '../services/static-release-server';
import type { BackgroundLifecycleController } from '../main/background-lifecycle';
import type { ReleaseJobManager } from '../services/release-job';
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
@@ -31,6 +33,10 @@ export interface AgentBrowserService {
visible: boolean;
bounds?: AgentBrowserBounds;
}): Promise<AgentBrowserSnapshot>;
setDiagnostics(input: {
projectPath: string;
enabled: boolean;
}): Promise<AgentBrowserSnapshot>;
navigate(input: {
projectPath: string;
action: 'url' | 'back' | 'forward' | 'reload';
@@ -69,4 +75,6 @@ export interface HostApiContext {
agentBrowser?: AgentBrowserService;
worksSubmissionBinding?: WorksSubmissionBindingStore;
imageWorkspace?: DesignWorkspaceModule;
lifecycle?: BackgroundLifecycleController;
releaseJobs?: ReleaseJobManager;
}

View File

@@ -0,0 +1,169 @@
import { Readable, Writable } from 'node:stream';
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from './context';
import { hostApiRouteHandlers } from './route-handlers';
import { requireJsonContentType, sendJson } from './route-utils';
export type HostApiDispatchInput = {
path: string;
method?: string;
headers?: Record<string, string>;
body?: string;
};
export type HostApiDispatchData = {
status: number;
ok: boolean;
json?: unknown;
text?: string;
};
class DispatchResponse extends Writable {
statusCode = 200;
statusMessage?: string;
headersSent = false;
socket = null;
private readonly headers = new Map<string, string | string[]>();
private readonly chunks: Buffer[] = [];
constructor() {
super({
write: (chunk: Buffer | string, _encoding, callback) => {
this.chunks.push(Buffer.isBuffer(chunk) ? Buffer.from(chunk) : Buffer.from(chunk));
callback();
},
});
}
setHeader(name: string, value: string | number | readonly string[]): this {
this.headers.set(name.toLowerCase(), Array.isArray(value) ? [...value] : String(value));
return this;
}
getHeader(name: string): string | string[] | undefined {
return this.headers.get(name.toLowerCase());
}
hasHeader(name: string): boolean {
return this.headers.has(name.toLowerCase());
}
removeHeader(name: string): void {
this.headers.delete(name.toLowerCase());
}
writeHead(
statusCode: number,
statusMessageOrHeaders?: string | Record<string, string | number | readonly string[]>,
maybeHeaders?: Record<string, string | number | readonly string[]>,
): this {
this.statusCode = statusCode;
if (typeof statusMessageOrHeaders === 'string') {
this.statusMessage = statusMessageOrHeaders;
for (const [name, value] of Object.entries(maybeHeaders ?? {})) this.setHeader(name, value);
} else {
for (const [name, value] of Object.entries(statusMessageOrHeaders ?? {})) this.setHeader(name, value);
}
this.headersSent = true;
return this;
}
flushHeaders(): void {
this.headersSent = true;
}
body(): Buffer {
return Buffer.concat(this.chunks);
}
contentType(): string {
const value = this.getHeader('content-type');
return Array.isArray(value) ? value[0] ?? '' : value ?? '';
}
}
function createRequest(input: HostApiDispatchInput): IncomingMessage {
const body = input.body ?? '';
const request = Readable.from(body ? [Buffer.from(body, 'utf8')] : []) as Readable & Partial<IncomingMessage>;
const headers: IncomingHttpHeaders = Object.create(null) as IncomingHttpHeaders;
for (const [name, value] of Object.entries(input.headers ?? {})) {
headers[name.toLowerCase()] = value;
}
if (body && headers['content-length'] === undefined) {
headers['content-length'] = String(Buffer.byteLength(body, 'utf8'));
}
request.method = (input.method ?? 'GET').toUpperCase();
request.url = input.path;
request.headers = headers;
return request as IncomingMessage;
}
function readResponseData(response: DispatchResponse): HostApiDispatchData {
const body = response.body();
if (response.statusCode === 204 || body.length === 0) {
return { status: response.statusCode, ok: response.statusCode >= 200 && response.statusCode < 300 };
}
const text = body.toString('utf8');
if (response.contentType().toLowerCase().includes('json')) {
try {
return {
status: response.statusCode,
ok: response.statusCode >= 200 && response.statusCode < 300,
json: JSON.parse(text) as unknown,
};
} catch {
// Keep malformed route output observable to the Renderer instead of
// silently treating it as an empty response.
}
}
return {
status: response.statusCode,
ok: response.statusCode >= 200 && response.statusCode < 300,
text,
};
}
/**
* Dispatch a Renderer Host API request without a second IPC -> loopback HTTP
* hop. This adapter deliberately reuses the existing Node route handlers so
* authentication, validation and response contracts remain unchanged.
*/
export async function dispatchHostApiRequest(
ctx: HostApiContext,
input: HostApiDispatchInput,
): Promise<HostApiDispatchData> {
const request = createRequest(input);
const response = new DispatchResponse();
const url = new URL(input.path, 'http://127.0.0.1');
try {
if (!requireJsonContentType(request)) {
sendJson(response as unknown as ServerResponse, 415, {
success: false,
error: 'Content-Type must be application/json',
});
return readResponseData(response);
}
for (const handler of hostApiRouteHandlers) {
if (await handler(request, response as unknown as ServerResponse, url, ctx)) {
return readResponseData(response);
}
}
sendJson(response as unknown as ServerResponse, 404, {
success: false,
error: `No route for ${request.method} ${url.pathname}`,
});
return readResponseData(response);
} catch (error) {
if (!response.headersSent && !response.writableEnded) {
sendJson(response as unknown as ServerResponse, 500, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
return readResponseData(response);
}
throw error;
}
}

View File

@@ -0,0 +1,18 @@
/**
* Transport-only path classification. Kept free of route imports so the
* lightweight IPC registration path does not eagerly load every Host API
* service before the first window is painted.
*/
export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean {
const normalizedMethod = method.toUpperCase();
const pathname = path.split('?', 1)[0] || path;
if (pathname === '/api/events' || pathname === '/api/opencode/events') return true;
if (pathname.startsWith('/api/ai-proxy/')) return true;
if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true;
if (
pathname.includes('/assets/')
&& pathname.endsWith('/content')
&& (normalizedMethod === 'GET' || normalizedMethod === 'HEAD')
) return true;
return false;
}

View File

@@ -0,0 +1,52 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from './context';
import { handleAiProxyRoutes } from './routes/ai-proxy';
import { handleAiHardwareRoutes } from './routes/ai-hardware';
import { handleAppRoutes } from './routes/app';
import { handleAuthRoutes } from './routes/auth';
import { handleOpencodeRoutes } from './routes/opencode';
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
import { handleLearningRoutes } from './routes/learning';
import { handleWorksRoutes } from './routes/works';
import { handleUserSyncRoutes } from './routes/user-sync';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleFileRoutes } from './routes/files';
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
import { handleAgentBrowserRoutes } from './routes/agent-browser';
export type HostApiRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
/**
* The same route list is used by the loopback compatibility server and by the
* Main-owned IPC dispatcher. Keeping one ordered list prevents the two
* transports from drifting into subtly different authorization or response
* behavior.
*/
export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleAiProxyRoutes,
handleAiHardwareRoutes,
handleAppRoutes,
handleAuthRoutes,
handleImageWorkspaceRoutes,
handleImagePromptMuseumRoutes,
handleLearningRoutes,
handleWorksRoutes,
handleAgentBrowserRoutes,
handleUserSyncRoutes,
handleOpencodeRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleFileRoutes,
handleMeowaGameAssetsRoutes,
handleLogRoutes,
handleUsageRoutes,
];

View File

@@ -11,6 +11,7 @@ type AgentBrowserBody = {
url?: unknown;
action?: unknown;
visible?: unknown;
enabled?: unknown;
bounds?: unknown;
method?: unknown;
params?: unknown;
@@ -237,6 +238,20 @@ export async function handleAgentBrowserRoutes(
bounds: parseBounds(body.bounds),
});
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', browser);
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const browser = await service.setDiagnostics({
projectPath: project.path,
enabled: body.enabled === true,
});
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, browser });
return true;
}

View File

@@ -1089,6 +1089,10 @@ async function handlePublishProjectSource(
}
let prepared: Awaited<ReturnType<typeof prepareProjectRelease>> | null = null;
const releaseLease = ctx.lifecycle?.acquireLease({
id: `release:${projectId}:${randomUUID()}`,
kind: 'release-build',
});
try {
if (!ctx.agentBrowser) throw new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE');
prepared = await prepareProjectRelease({ projectPath: localProject.path, clientVersion: app.getVersion() });
@@ -1242,10 +1246,77 @@ async function handlePublishProjectSource(
...(publishedMetadataPreserved ? { metadata_disposition: PUBLISHED_METADATA_PRESERVED } : {}),
});
} finally {
releaseLease?.();
await prepared?.dispose().catch(() => undefined);
}
}
async function handleStartReleaseJob(
req: IncomingMessage,
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
if (!ctx.releaseJobs) {
sendJson(res, 503, { success: false, code: 'RELEASE_JOBS_UNAVAILABLE', error: '发布构建暂不可用。' });
return;
}
const body = await parseJsonBody<{ projectId?: unknown }>(req);
const projectId = readOptionalString(body.projectId);
if (!projectId) {
sendJson(res, 400, { success: false, code: 'PROJECT_ID_REQUIRED', error: '缺少项目标识。' });
return;
}
const project = (await ctx.opencodeProjectStore.listProjects()).find((candidate) => candidate.id === projectId);
if (!project) {
sendJson(res, 404, { success: false, code: 'PROJECT_NOT_FOUND', error: '本地项目不存在。' });
return;
}
try {
const job = ctx.releaseJobs.start({
projectId,
projectPath: project.path,
clientVersion: app.getVersion(),
});
sendJson(res, 202, { success: true, job });
} catch (error) {
if (error instanceof ProjectReleaseBuildError) {
sendJson(res, error.code === 'LOCAL_BUILD_BUSY' ? 409 : 400, {
success: false,
code: error.code,
error: error.message,
});
return;
}
throw error;
}
}
function handleReleaseJobStatus(
res: ServerResponse,
ctx: HostApiContext,
jobId: string,
): void {
const job = ctx.releaseJobs?.get(jobId);
if (!job) {
sendJson(res, 404, { success: false, code: 'RELEASE_JOB_NOT_FOUND', error: '发布任务不存在。' });
return;
}
sendJson(res, 200, { success: true, job });
}
function handleCancelReleaseJob(
res: ServerResponse,
ctx: HostApiContext,
jobId: string,
): void {
const job = ctx.releaseJobs?.cancel(jobId);
if (!job) {
sendJson(res, 404, { success: false, code: 'RELEASE_JOB_NOT_FOUND', error: '发布任务不存在。' });
return;
}
sendJson(res, 200, { success: true, job });
}
function createSpeechTranscriptionFormData(body: SpeechTranscriptionInput): FormData {
const audioBase64 = readRequiredString(body.audioBase64, 'audioBase64');
const fileName = readOptionalString(body.fileName) ?? 'voice.wav';
@@ -1404,6 +1475,39 @@ export async function handleWorksRoutes(
return true;
}
if (url.pathname === '/api/works/projects/release-jobs' && req.method === 'POST') {
if (!hasRendererCapability(req)) {
sendJson(res, 403, {
success: false,
status: 403,
code: 'RENDERER_CAPABILITY_REQUIRED',
error: 'Renderer capability required',
});
return true;
}
await handleStartReleaseJob(req, res, ctx);
return true;
}
const releaseJobMatch = url.pathname.match(/^\/api\/works\/projects\/release-jobs\/([^/]+)(?:\/(cancel))?$/);
if (releaseJobMatch && req.method === 'GET' && !releaseJobMatch[2]) {
handleReleaseJobStatus(res, ctx, decodeURIComponent(releaseJobMatch[1]));
return true;
}
if (releaseJobMatch && req.method === 'POST' && (releaseJobMatch[2] === 'cancel' || url.searchParams.get('action') === 'cancel')) {
if (!hasRendererCapability(req)) {
sendJson(res, 403, {
success: false,
status: 403,
code: 'RENDERER_CAPABILITY_REQUIRED',
error: 'Renderer capability required',
});
return true;
}
handleCancelReleaseJob(res, ctx, decodeURIComponent(releaseJobMatch[1]));
return true;
}
if (url.pathname === '/api/works/projects/mine' && req.method === 'GET') {
await handleListMyProjects(req, res, url);
return true;

View File

@@ -1,58 +1,11 @@
import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createServer, type Server } from 'node:http';
import { getPort } from '../utils/config';
import { logger } from '../utils/logger';
import type { HostApiContext } from './context';
import { handleAiProxyRoutes } from './routes/ai-proxy';
import { handleAiHardwareRoutes } from './routes/ai-hardware';
import { handleAppRoutes } from './routes/app';
import { handleAuthRoutes } from './routes/auth';
import { handleOpencodeRoutes } from './routes/opencode';
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
import { handleLearningRoutes } from './routes/learning';
import { handleWorksRoutes } from './routes/works';
import { handleUserSyncRoutes } from './routes/user-sync';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleFileRoutes } from './routes/files';
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
import { handleAgentBrowserRoutes } from './routes/agent-browser';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
import { rotateRendererCapability } from './renderer-capability';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const coreRouteHandlers: RouteHandler[] = [
handleAiProxyRoutes,
handleAiHardwareRoutes,
handleAppRoutes,
handleAuthRoutes,
handleImageWorkspaceRoutes,
handleImagePromptMuseumRoutes,
handleLearningRoutes,
handleWorksRoutes,
handleAgentBrowserRoutes,
handleUserSyncRoutes,
handleOpencodeRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleFileRoutes,
handleMeowaGameAssetsRoutes,
handleLogRoutes,
handleUsageRoutes,
];
function buildRouteHandlers(): RouteHandler[] {
return coreRouteHandlers;
}
import { hostApiRouteHandlers } from './route-handlers';
/**
* Per-session secret token used to authenticate Host API requests.
@@ -110,8 +63,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
return;
}
const routeHandlers = buildRouteHandlers();
for (const handler of routeHandlers) {
for (const handler of hostApiRouteHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}