perf(startup): load canvas, node-pty, ssh2, html-to-text and the OpenCode SDK on first use
Main module evaluation drops from ~420 ms to ~330 ms. Types stay as import type; the DeepSeek Harness chunks stay static because the protocol chunk shares code with Main startup paths.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
|
||||
import {
|
||||
import type {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage,
|
||||
type OpencodeClient,
|
||||
type PermissionRequest,
|
||||
type PermissionRuleset,
|
||||
type QuestionRequest,
|
||||
AssistantMessage,
|
||||
OpencodeClient,
|
||||
PermissionRequest,
|
||||
PermissionRuleset,
|
||||
QuestionRequest,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import spawn from "cross-spawn";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
@@ -740,11 +740,27 @@ async function defaultValidateBinary(
|
||||
});
|
||||
}
|
||||
|
||||
type OpencodeSdk = typeof import("@opencode-ai/sdk/v2");
|
||||
let opencodeSdk: OpencodeSdk | undefined;
|
||||
|
||||
// The SDK is loaded when an OpenCode client is first created, not at Main
|
||||
// startup (P6). initializeClient awaits this before any default factory runs.
|
||||
async function loadOpencodeSdk(): Promise<OpencodeSdk> {
|
||||
opencodeSdk ??= await import("@opencode-ai/sdk/v2");
|
||||
return opencodeSdk;
|
||||
}
|
||||
|
||||
const defaultCreateClient: typeof createOpencodeClient = (config) => {
|
||||
if (!opencodeSdk) throw new Error("OpenCode SDK is not loaded");
|
||||
return opencodeSdk.createOpencodeClient(config);
|
||||
};
|
||||
|
||||
async function defaultCheckServerHealth(
|
||||
url: string,
|
||||
authorization: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const { createOpencodeClient } = await loadOpencodeSdk();
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
headers: { Authorization: authorization },
|
||||
@@ -798,7 +814,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
spawn,
|
||||
detectBinary: defaultDetectBinary,
|
||||
validateBinary: defaultValidateBinary,
|
||||
createClient: createOpencodeClient,
|
||||
createClient: defaultCreateClient,
|
||||
checkServerHealth: defaultCheckServerHealth,
|
||||
platform: process.platform,
|
||||
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -1483,6 +1499,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (baseUrl && this.options.modelProfile) {
|
||||
throw new Error("OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务");
|
||||
}
|
||||
await loadOpencodeSdk();
|
||||
if (!baseUrl && this.options.embedded) {
|
||||
this.server = await this.launchEmbedded(signal);
|
||||
baseUrl = this.server.url;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { convert } from 'html-to-text'
|
||||
import { createLazyRequire } from './lazy-require'
|
||||
|
||||
/**
|
||||
* Loads html-to-text on first use instead of at Main startup (P6, ~45 ms).
|
||||
* Synchronous so the text checks keep their signatures.
|
||||
*/
|
||||
export const loadHtmlToText =
|
||||
createLazyRequire<typeof import('html-to-text')>('html-to-text')
|
||||
|
||||
export function hasExtractedDocumentText(content: string): boolean {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
|
||||
.replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '')
|
||||
return Boolean(convert(text, { wordwrap: false }).trim())
|
||||
return Boolean(loadHtmlToText().convert(text, { wordwrap: false }).trim())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas'
|
||||
import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts'
|
||||
import type { DocumentParsingSettings } from '../shared/document-parsing-contracts'
|
||||
import type { ParsedDocument } from './knowledge/document-parser'
|
||||
@@ -96,6 +95,8 @@ export class DocumentResultStorage {
|
||||
if (!image) throw new Error('解析图片不存在')
|
||||
const data = await readFile(join(this.require(id).directory, 'images', imageId))
|
||||
if (thumbnail) {
|
||||
// Native canvas loads on first use (P6).
|
||||
const { createCanvas, loadImage } = await import('@napi-rs/canvas')
|
||||
const source = await loadImage(data)
|
||||
const scale = Math.min(1, 640 / Math.max(source.width, source.height))
|
||||
const canvas = createCanvas(Math.max(1, Math.round(source.width * scale)), Math.max(1, Math.round(source.height * scale)))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { loadImage } from '@napi-rs/canvas'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
maximumDocumentExtractedCharacters,
|
||||
@@ -131,6 +130,8 @@ export class HttpDocumentOcr {
|
||||
: bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? 'image/png'
|
||||
: bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : undefined
|
||||
if (!mimeType) throw new Error('图片格式不支持')
|
||||
// Native canvas loads on first use (P6).
|
||||
const { loadImage } = await import('@napi-rs/canvas')
|
||||
const image = await loadImage(bytes)
|
||||
if (!image.width || !image.height || image.width * image.height > 40_000_000) throw new Error('图片尺寸无效或超过 4000 万像素')
|
||||
imageBytes += bytes.length
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { convert } from 'html-to-text'
|
||||
import { loadHtmlToText } from '../document-extracted-text'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { extname } from 'node:path'
|
||||
import type { ParsedDocumentImage } from '../http-document-ocr'
|
||||
@@ -701,7 +701,7 @@ export async function parseDocument(
|
||||
} else if (['.docx', '.xlsx', '.pptx'].includes(extension)) {
|
||||
sections = parseOfficeArchive(buffer, extension)
|
||||
} else if (['.html', '.htm'].includes(extension)) {
|
||||
const content = convert(decodeText(buffer), {
|
||||
const content = loadHtmlToText().convert(decodeText(buffer), {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'script', format: 'skip' },
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLazyRequire } from './lazy-require'
|
||||
import { loadHtmlToText } from './document-extracted-text'
|
||||
import { loadSsh2 } from './ssh/ssh2-module'
|
||||
|
||||
describe('createLazyRequire', () => {
|
||||
it('loads a module on first call and returns the cached instance afterwards', () => {
|
||||
const load = createLazyRequire<typeof import('node:path')>('node:path')
|
||||
const first = load()
|
||||
expect(typeof first.join).toBe('function')
|
||||
expect(load()).toBe(first)
|
||||
})
|
||||
|
||||
it('surfaces a missing module at first use, not at import time', () => {
|
||||
const load = createLazyRequire('goodbuddy-module-that-does-not-exist')
|
||||
expect(load).toThrow()
|
||||
})
|
||||
|
||||
it('resolves the deferred Main dependencies', () => {
|
||||
expect(typeof loadHtmlToText().convert).toBe('function')
|
||||
expect(typeof loadSsh2().Client).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
/**
|
||||
* Synchronously loads a dependency on first use instead of at module
|
||||
* evaluation (P6). Main is ESM, but some test fixtures bundle the same code as
|
||||
* CommonJS, where `import.meta.url` is empty and `__filename` exists instead.
|
||||
*/
|
||||
export function createLazyRequire<T>(specifier: string): () => T {
|
||||
let loaded: T | undefined
|
||||
return () => {
|
||||
loaded ??= createRequire(import.meta.url || __filename)(specifier) as T
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCanvas } from '@napi-rs/canvas'
|
||||
// @napi-rs/canvas is a native module (~85 ms to load); load it on first OCR render (P6).
|
||||
|
||||
export async function* renderOcrPdf(
|
||||
buffer: Buffer,
|
||||
@@ -6,7 +6,10 @@ export async function* renderOcrPdf(
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<{ pageNumber: number; data: Buffer; width: number; height: number }> {
|
||||
signal?.throwIfAborted()
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const [{ getDocument }, { createCanvas }] = await Promise.all([
|
||||
import('pdfjs-dist/legacy/build/pdf.mjs'),
|
||||
import('@napi-rs/canvas')
|
||||
])
|
||||
const task = getDocument({
|
||||
data: Uint8Array.from(buffer), disableFontFace: true,
|
||||
isOffscreenCanvasSupported: false, useSystemFonts: false, useWorkerFetch: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHmac, getCiphers, randomBytes } from "node:crypto";
|
||||
import { connect as connectLoopback, type Socket } from "node:net";
|
||||
import { Client } from "ssh2";
|
||||
import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
import { loadSsh2 } from "./ssh2-module";
|
||||
import {
|
||||
BoundedStagedSftp,
|
||||
type BoundedSftpLimits,
|
||||
@@ -344,7 +344,7 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
validatePoolTarget(target);
|
||||
validateConnectionIdentity(target, identity);
|
||||
const resolvedDependencies: SshConnectionDependencies = {
|
||||
createClient: () => new Client(),
|
||||
createClient: () => new (loadSsh2().Client)(),
|
||||
supportedOpenSslCiphers: getCiphers,
|
||||
systemAgent: defaultSystemAgent,
|
||||
controlPlanePackageInstaller: Buffer.alloc(0),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createHash, getCiphers } from 'node:crypto'
|
||||
import {
|
||||
import type {
|
||||
Client,
|
||||
type Algorithms,
|
||||
type CipherAlgorithm,
|
||||
type ConnectConfig,
|
||||
type ServerHostKeyAlgorithm
|
||||
Algorithms,
|
||||
CipherAlgorithm,
|
||||
ConnectConfig,
|
||||
ServerHostKeyAlgorithm
|
||||
} from 'ssh2'
|
||||
import { loadSsh2 } from './ssh2-module'
|
||||
import type {
|
||||
SshHostConnectionTestResult
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
@@ -336,7 +337,7 @@ export class Ssh2Transport implements SshTransport {
|
||||
dependencies: Partial<SshTransportDependencies> = {}
|
||||
) {
|
||||
this.dependencies = {
|
||||
createClient: () => new Client(),
|
||||
createClient: () => new (loadSsh2().Client)(),
|
||||
now: Date.now,
|
||||
supportedOpenSslCiphers: getCiphers,
|
||||
systemAgent: defaultSystemAgent,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createLazyRequire } from '../lazy-require'
|
||||
|
||||
/**
|
||||
* Loads ssh2 on the first SSH connection instead of at Main startup (P6):
|
||||
* evaluating it costs ~65 ms and most sessions never open a remote host.
|
||||
* Synchronous so existing client factories keep their signatures.
|
||||
*/
|
||||
export const loadSsh2 = createLazyRequire<typeof import('ssh2')>('ssh2')
|
||||
@@ -3,7 +3,7 @@ import { constants as fileSystemConstants } from 'node:fs'
|
||||
import { access, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type * as nodePty from 'node-pty'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
@@ -393,7 +393,9 @@ export class LocalTerminalSession {
|
||||
: undefined,
|
||||
this.dependencies
|
||||
)
|
||||
const spawn = this.dependencies.spawn ?? nodePty.spawn
|
||||
// node-pty is native; load it when the first terminal starts (P6).
|
||||
const spawn = this.dependencies.spawn ??
|
||||
(await import('node-pty')).spawn
|
||||
this.pty = spawn(this.launch.shell, spec?.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: this.size.cols,
|
||||
|
||||
Reference in New Issue
Block a user