new plugin system
This commit is contained in:
@@ -177,8 +177,8 @@ const ensurePluginExtracted = (pluginId: string) =>
|
||||
ensurePluginExtractedFromRoot(PLUGIN_ROOT, pluginId, sanitizePluginId);
|
||||
const listPluginManifests = () =>
|
||||
listPluginManifestsFromRoot(PLUGIN_ROOT, pluginState, savePluginState, ensurePluginExtracted);
|
||||
const loadPluginBundle = (pluginId: string) =>
|
||||
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted);
|
||||
const loadPluginBundle = (pluginId: string, clientOS?: string, clientArch?: string) =>
|
||||
loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted, clientOS, clientArch);
|
||||
const startBuildProcess = (buildId: string, buildConfig: any) =>
|
||||
runBuildProcess(buildId, buildConfig, {
|
||||
generateBuildMutex,
|
||||
|
||||
@@ -241,6 +241,7 @@ export type PluginManifest = {
|
||||
version?: string;
|
||||
description?: string;
|
||||
binary?: string;
|
||||
binaries?: Record<string, string>;
|
||||
entry?: string;
|
||||
assets?: {
|
||||
html?: string;
|
||||
|
||||
@@ -72,30 +72,43 @@ export async function ensurePluginExtracted(
|
||||
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
let wasmEntry: Buffer | null = null;
|
||||
let htmlEntry: Buffer | null = null;
|
||||
let cssEntry: Buffer | null = null;
|
||||
let jsEntry: Buffer | null = null;
|
||||
const nativeBinaries: Map<string, Buffer> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const base = path.basename(entry.entryName);
|
||||
if (base.toLowerCase().endsWith(".wasm")) {
|
||||
wasmEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".html")) {
|
||||
const lower = base.toLowerCase();
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
||||
nativeBinaries.set(base, entry.getData());
|
||||
} else if (lower.endsWith(".html")) {
|
||||
htmlEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".css")) {
|
||||
} else if (lower.endsWith(".css")) {
|
||||
cssEntry = entry.getData();
|
||||
} else if (base.toLowerCase().endsWith(".js")) {
|
||||
} else if (lower.endsWith(".js")) {
|
||||
jsEntry = entry.getData();
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`);
|
||||
const hasNative = nativeBinaries.size > 0;
|
||||
if (!hasNative) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (no native binaries found)`);
|
||||
}
|
||||
if (!htmlEntry || !cssEntry || !jsEntry) {
|
||||
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
|
||||
}
|
||||
|
||||
const binariesMap: Record<string, string> = {};
|
||||
for (const [filename, data] of nativeBinaries) {
|
||||
await fs.writeFile(path.join(pluginDir, filename), data);
|
||||
const platformKey = derivePlatformKey(filename);
|
||||
if (platformKey) {
|
||||
binariesMap[platformKey] = filename;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||
@@ -104,7 +117,7 @@ export async function ensurePluginExtracted(
|
||||
id: safeId,
|
||||
name: safeId,
|
||||
version: "1.0.0",
|
||||
binary: `${safeId}.wasm`,
|
||||
binaries: binariesMap,
|
||||
entry: `${safeId}.html`,
|
||||
assets: {
|
||||
html: `${safeId}.html`,
|
||||
@@ -168,7 +181,9 @@ export async function loadPluginBundle(
|
||||
pluginRoot: string,
|
||||
pluginId: string,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> {
|
||||
clientOS?: string,
|
||||
clientArch?: string,
|
||||
): Promise<{ manifest: PluginManifest; binary: Uint8Array }> {
|
||||
await ensureExtracted(pluginId);
|
||||
const dir = path.join(pluginRoot, pluginId);
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
@@ -177,30 +192,46 @@ export async function loadPluginBundle(
|
||||
manifest.id = manifest.id || pluginId;
|
||||
manifest.name = manifest.name || pluginId;
|
||||
|
||||
const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`;
|
||||
let wasmPath = path.join(dir, binaryName);
|
||||
try {
|
||||
await fs.access(wasmPath);
|
||||
} catch {
|
||||
const files = await fs.readdir(dir);
|
||||
const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm"));
|
||||
if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId);
|
||||
wasmPath = path.join(dir, firstWasm);
|
||||
let binaryPath: string | null = null;
|
||||
|
||||
if (manifest.binaries && clientOS && clientArch) {
|
||||
const key = `${clientOS}-${clientArch}`;
|
||||
const filename = manifest.binaries[key];
|
||||
if (filename) {
|
||||
const candidate = path.join(dir, filename);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
binaryPath = candidate;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const wasm = new Uint8Array(await fs.readFile(wasmPath));
|
||||
return { manifest, wasm };
|
||||
|
||||
if (!binaryPath) {
|
||||
const files = await fs.readdir(dir);
|
||||
const found = files.find(
|
||||
(f) =>
|
||||
f.toLowerCase().endsWith(".so") ||
|
||||
f.toLowerCase().endsWith(".dll") ||
|
||||
f.toLowerCase().endsWith(".dylib"),
|
||||
);
|
||||
if (!found) throw new Error(`No plugin binary found for ${pluginId} (${clientOS}-${clientArch})`);
|
||||
binaryPath = path.join(dir, found);
|
||||
}
|
||||
|
||||
const binary = new Uint8Array(await fs.readFile(binaryPath));
|
||||
return { manifest, binary };
|
||||
}
|
||||
|
||||
export function sendPluginBundle(
|
||||
target: ClientInfo,
|
||||
bundle: { manifest: PluginManifest; wasm: Uint8Array },
|
||||
bundle: { manifest: PluginManifest; binary: Uint8Array },
|
||||
): void {
|
||||
const chunkSize = 16 * 1024;
|
||||
const wasm = bundle.wasm;
|
||||
const totalChunks = Math.ceil(wasm.length / chunkSize);
|
||||
const data = bundle.binary;
|
||||
const totalChunks = Math.ceil(data.length / chunkSize);
|
||||
const initPayload = {
|
||||
manifest: bundle.manifest,
|
||||
size: wasm.length,
|
||||
size: data.length,
|
||||
chunks: totalChunks,
|
||||
};
|
||||
target.ws.send(
|
||||
@@ -214,8 +245,8 @@ export function sendPluginBundle(
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, wasm.length);
|
||||
const chunk = wasm.slice(start, end);
|
||||
const end = Math.min(start + chunkSize, data.length);
|
||||
const chunk = data.slice(start, end);
|
||||
target.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
@@ -235,3 +266,15 @@ export function sendPluginBundle(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function derivePlatformKey(filename: string): string {
|
||||
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
|
||||
if (match) {
|
||||
return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`;
|
||||
}
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".dll")) return "windows-amd64";
|
||||
if (lower.endsWith(".dylib")) return "darwin-amd64";
|
||||
if (lower.endsWith(".so")) return "linux-amd64";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type PluginManifest = {
|
||||
|
||||
type PluginBundle = {
|
||||
manifest: PluginManifest;
|
||||
wasm: Uint8Array;
|
||||
binary: Uint8Array;
|
||||
};
|
||||
|
||||
type PluginState = {
|
||||
@@ -32,7 +32,7 @@ type PluginRouteDeps = {
|
||||
ensurePluginExtracted: (pluginId: string) => Promise<void>;
|
||||
savePluginState: () => Promise<void>;
|
||||
listPluginManifests: () => Promise<PluginManifest[]>;
|
||||
loadPluginBundle: (pluginId: string) => Promise<PluginBundle>;
|
||||
loadPluginBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>;
|
||||
sendPluginBundle: (target: any, bundle: PluginBundle) => void;
|
||||
markPluginLoading: (clientId: string, pluginId: string) => void;
|
||||
isPluginLoaded: (clientId: string, pluginId: string) => boolean;
|
||||
@@ -231,7 +231,7 @@ export async function handlePluginRoutes(
|
||||
return Response.json({ ok: true, loading: true });
|
||||
}
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
@@ -276,7 +276,7 @@ export async function handlePluginRoutes(
|
||||
deps.enqueuePluginEvent(targetId, pluginId, event, payload);
|
||||
if (!deps.isPluginLoading(targetId, pluginId)) {
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId);
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
|
||||
Reference in New Issue
Block a user