Skip to content

Commit 171b24c

Browse files
committed
perf: generate bundled plugin metadata for cold startup
1 parent 3ca7922 commit 171b24c

8 files changed

Lines changed: 4564 additions & 9 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,8 @@
571571
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
572572
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
573573
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
574-
"check": "pnpm check:host-env-policy:swift && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
574+
"check": "pnpm check:host-env-policy:swift && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
575+
"check:bundled-plugin-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check",
575576
"check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check",
576577
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
577578
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export type BundledPluginPathPair = {
2+
source: string;
3+
built: string;
4+
};
5+
6+
export type BundledPluginMetadataEntry = {
7+
dirName: string;
8+
idHint: string;
9+
source: BundledPluginPathPair;
10+
setupSource?: BundledPluginPathPair;
11+
packageName?: string;
12+
packageVersion?: string;
13+
packageDescription?: string;
14+
packageManifest?: Record<string, unknown>;
15+
manifest: Record<string, unknown>;
16+
};
17+
18+
export function collectBundledPluginMetadata(params?: {
19+
repoRoot?: string;
20+
}): BundledPluginMetadataEntry[];
21+
22+
export function renderBundledPluginMetadataModule(entries: BundledPluginMetadataEntry[]): string;
23+
24+
export function writeBundledPluginMetadataModule(params?: {
25+
repoRoot?: string;
26+
outputPath?: string;
27+
check?: boolean;
28+
}): {
29+
changed: boolean;
30+
wrote: boolean;
31+
outputPath: string;
32+
};
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { spawnSync } from "node:child_process";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { pathToFileURL } from "node:url";
5+
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
6+
7+
const GENERATED_BY = "scripts/generate-bundled-plugin-metadata.mjs";
8+
const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-plugin-metadata.generated.ts";
9+
const MANIFEST_KEY = "openclaw";
10+
const FORMATTER_CWD = path.resolve(import.meta.dirname, "..");
11+
const CANONICAL_PACKAGE_ID_ALIASES = {
12+
"elevenlabs-speech": "elevenlabs",
13+
"microsoft-speech": "microsoft",
14+
"ollama-provider": "ollama",
15+
"sglang-provider": "sglang",
16+
"vllm-provider": "vllm",
17+
};
18+
19+
function readIfExists(filePath) {
20+
try {
21+
return fs.readFileSync(filePath, "utf8");
22+
} catch {
23+
return null;
24+
}
25+
}
26+
27+
function rewriteEntryToBuiltPath(entry) {
28+
if (typeof entry !== "string" || entry.trim().length === 0) {
29+
return undefined;
30+
}
31+
const normalized = entry.replace(/^\.\//u, "");
32+
return normalized.replace(/\.[^.]+$/u, ".js");
33+
}
34+
35+
function deriveIdHint({ filePath, packageName, hasMultipleExtensions }) {
36+
const base = path.basename(filePath, path.extname(filePath));
37+
const rawPackageName = packageName?.trim();
38+
if (!rawPackageName) {
39+
return base;
40+
}
41+
42+
const unscoped = rawPackageName.includes("/")
43+
? (rawPackageName.split("/").pop() ?? rawPackageName)
44+
: rawPackageName;
45+
const canonicalPackageId = CANONICAL_PACKAGE_ID_ALIASES[unscoped] ?? unscoped;
46+
const normalizedPackageId =
47+
canonicalPackageId.endsWith("-provider") && canonicalPackageId.length > "-provider".length
48+
? canonicalPackageId.slice(0, -"-provider".length)
49+
: canonicalPackageId;
50+
51+
if (!hasMultipleExtensions) {
52+
return normalizedPackageId;
53+
}
54+
return `${normalizedPackageId}/${base}`;
55+
}
56+
57+
function normalizeStringList(values) {
58+
if (!Array.isArray(values)) {
59+
return undefined;
60+
}
61+
const normalized = values.map((value) => String(value).trim()).filter(Boolean);
62+
return normalized.length > 0 ? normalized : undefined;
63+
}
64+
65+
function normalizeObject(value) {
66+
if (!value || typeof value !== "object" || Array.isArray(value)) {
67+
return undefined;
68+
}
69+
return value;
70+
}
71+
72+
function normalizePackageManifest(raw) {
73+
const packageManifest = normalizeObject(raw?.[MANIFEST_KEY]);
74+
if (!packageManifest) {
75+
return undefined;
76+
}
77+
const normalized = {
78+
...(Array.isArray(packageManifest.extensions)
79+
? { extensions: packageManifest.extensions.map((entry) => String(entry).trim()) }
80+
: {}),
81+
...(typeof packageManifest.setupEntry === "string"
82+
? { setupEntry: packageManifest.setupEntry.trim() }
83+
: {}),
84+
...(normalizeObject(packageManifest.channel) ? { channel: packageManifest.channel } : {}),
85+
...(normalizeObject(packageManifest.install) ? { install: packageManifest.install } : {}),
86+
...(normalizeObject(packageManifest.startup) ? { startup: packageManifest.startup } : {}),
87+
};
88+
return Object.keys(normalized).length > 0 ? normalized : undefined;
89+
}
90+
91+
function normalizePluginManifest(raw) {
92+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
93+
return null;
94+
}
95+
if (typeof raw.id !== "string" || !raw.id.trim()) {
96+
return null;
97+
}
98+
if (
99+
!raw.configSchema ||
100+
typeof raw.configSchema !== "object" ||
101+
Array.isArray(raw.configSchema)
102+
) {
103+
return null;
104+
}
105+
106+
return {
107+
id: raw.id.trim(),
108+
configSchema: raw.configSchema,
109+
...(raw.enabledByDefault === true ? { enabledByDefault: true } : {}),
110+
...(typeof raw.kind === "string" ? { kind: raw.kind.trim() } : {}),
111+
...(normalizeStringList(raw.channels) ? { channels: normalizeStringList(raw.channels) } : {}),
112+
...(normalizeStringList(raw.providers)
113+
? { providers: normalizeStringList(raw.providers) }
114+
: {}),
115+
...(normalizeObject(raw.providerAuthEnvVars)
116+
? { providerAuthEnvVars: raw.providerAuthEnvVars }
117+
: {}),
118+
...(Array.isArray(raw.providerAuthChoices)
119+
? { providerAuthChoices: raw.providerAuthChoices }
120+
: {}),
121+
...(normalizeStringList(raw.skills) ? { skills: normalizeStringList(raw.skills) } : {}),
122+
...(typeof raw.name === "string" ? { name: raw.name.trim() } : {}),
123+
...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
124+
...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}),
125+
...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}),
126+
};
127+
}
128+
129+
function formatTypeScriptModule(source, { outputPath }) {
130+
const formatter = spawnSync(
131+
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
132+
["exec", "oxfmt", "--stdin-filepath", outputPath],
133+
{
134+
cwd: FORMATTER_CWD,
135+
input: source,
136+
encoding: "utf8",
137+
},
138+
);
139+
if (formatter.status !== 0) {
140+
const details =
141+
formatter.stderr?.trim() || formatter.stdout?.trim() || "unknown formatter failure";
142+
throw new Error(`failed to format generated bundled plugin metadata: ${details}`);
143+
}
144+
return formatter.stdout;
145+
}
146+
147+
export function collectBundledPluginMetadata(params = {}) {
148+
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
149+
const extensionsRoot = path.join(repoRoot, "extensions");
150+
if (!fs.existsSync(extensionsRoot)) {
151+
return [];
152+
}
153+
154+
const entries = [];
155+
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
156+
if (!dirent.isDirectory()) {
157+
continue;
158+
}
159+
160+
const pluginDir = path.join(extensionsRoot, dirent.name);
161+
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
162+
const packageJsonPath = path.join(pluginDir, "package.json");
163+
if (!fs.existsSync(manifestPath) || !fs.existsSync(packageJsonPath)) {
164+
continue;
165+
}
166+
167+
const manifest = normalizePluginManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
168+
if (!manifest) {
169+
continue;
170+
}
171+
172+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
173+
const packageManifest = normalizePackageManifest(packageJson);
174+
const extensions = Array.isArray(packageManifest?.extensions)
175+
? packageManifest.extensions.filter((entry) => typeof entry === "string" && entry.trim())
176+
: [];
177+
if (extensions.length === 0) {
178+
continue;
179+
}
180+
181+
const sourceEntry = extensions[0];
182+
const builtEntry = rewriteEntryToBuiltPath(sourceEntry);
183+
if (!builtEntry) {
184+
continue;
185+
}
186+
const setupEntry =
187+
typeof packageManifest?.setupEntry === "string" &&
188+
packageManifest.setupEntry.trim().length > 0
189+
? {
190+
source: packageManifest.setupEntry.trim(),
191+
built: rewriteEntryToBuiltPath(packageManifest.setupEntry.trim()),
192+
}
193+
: undefined;
194+
195+
entries.push({
196+
dirName: dirent.name,
197+
idHint: deriveIdHint({
198+
filePath: sourceEntry,
199+
packageName: typeof packageJson.name === "string" ? packageJson.name : undefined,
200+
hasMultipleExtensions: extensions.length > 1,
201+
}),
202+
source: {
203+
source: sourceEntry,
204+
built: builtEntry,
205+
},
206+
...(setupEntry?.built
207+
? { setupSource: { source: setupEntry.source, built: setupEntry.built } }
208+
: {}),
209+
...(typeof packageJson.name === "string" ? { packageName: packageJson.name.trim() } : {}),
210+
...(typeof packageJson.version === "string"
211+
? { packageVersion: packageJson.version.trim() }
212+
: {}),
213+
...(typeof packageJson.description === "string"
214+
? { packageDescription: packageJson.description.trim() }
215+
: {}),
216+
...(packageManifest ? { packageManifest } : {}),
217+
manifest,
218+
});
219+
}
220+
221+
return entries.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
222+
}
223+
224+
export function renderBundledPluginMetadataModule(entries) {
225+
return `// Auto-generated by ${GENERATED_BY}. Do not edit directly.
226+
227+
export const GENERATED_BUNDLED_PLUGIN_METADATA = ${JSON.stringify(entries, null, 2)} as const;
228+
`;
229+
}
230+
231+
export function writeBundledPluginMetadataModule(params = {}) {
232+
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
233+
const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH);
234+
const next = formatTypeScriptModule(
235+
renderBundledPluginMetadataModule(collectBundledPluginMetadata({ repoRoot })),
236+
{ outputPath },
237+
);
238+
const current = readIfExists(outputPath);
239+
const changed = current !== next;
240+
241+
if (params.check) {
242+
return {
243+
changed,
244+
wrote: false,
245+
outputPath,
246+
};
247+
}
248+
249+
return {
250+
changed,
251+
wrote: writeTextFileIfChanged(outputPath, next),
252+
outputPath,
253+
};
254+
}
255+
256+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
257+
const result = writeBundledPluginMetadataModule({
258+
check: process.argv.includes("--check"),
259+
});
260+
261+
if (result.changed) {
262+
if (process.argv.includes("--check")) {
263+
console.error(
264+
`[bundled-plugin-metadata] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
265+
);
266+
process.exitCode = 1;
267+
} else {
268+
console.log(
269+
`[bundled-plugin-metadata] wrote ${path.relative(process.cwd(), result.outputPath)}`,
270+
);
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)