perf: optimize app startup and background lifecycles
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
169
electron/api/host-api-dispatcher.ts
Normal file
169
electron/api/host-api-dispatcher.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
electron/api/host-api-transport.ts
Normal file
18
electron/api/host-api-transport.ts
Normal 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;
|
||||
}
|
||||
52
electron/api/route-handlers.ts
Normal file
52
electron/api/route-handlers.ts
Normal 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,
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user