/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Command-dispatch helpers extracted from `host.ts`. * * `runExtensionCommand` finds the owning extension for a commandId, * activates it, fires the matching `onCommand:` event, loads the * handler source, wraps it via `wrapEntrySource`, and runs the entry * inside the activation's sandbox. Throws if no extension owns the * command or the bundle is missing the entry path. * * Factored out so `ExtensionHostService` stays focused on lifecycle. */ import { parseCapabilities, wrapEntrySource, type ActivationDispatcher, type ExtensionContextV1, type ExtensionLoader, type ExtensionRuntime, type RuntimeRunResult, } from '@ifc-lite/extensions'; import type { BimContext } from '@ifc-lite/sdk'; import type { IdbExtensionStorage } from './idb-storage.js'; export interface RunCommandDeps { storage: IdbExtensionStorage; loader: ExtensionLoader; runtime: ExtensionRuntime; dispatcher: ActivationDispatcher; sdk: BimContext; } /** * Dispatch an extension command end-to-end. Pure function — no * `this`. Callers (host service) inject the dependencies. */ export async function runExtensionCommand( deps: RunCommandDeps, commandId: string, ): Promise { const records = await deps.storage.listExtensions(); for (const record of records) { if (!record.enabled) continue; const bundle = deps.loader.getBundle(record.id); if (!bundle) continue; const entry = bundle.manifest.entry.commands?.[commandId]; const declared = bundle.manifest.contributes?.commands?.some((c) => c.id === commandId); if (!entry || !declared) continue; const grantsResult = parseCapabilities(record.grantedCapabilities); if (!grantsResult.ok) { throw new Error(`Cannot run ${commandId}: stored capabilities for ${record.id} are invalid.`); } const grants = grantsResult.value; const file = bundle.files.get(entry); if (!file) { throw new Error(`Command handler "${entry}" missing from bundle ${record.id}.`); } const source = file.text ?? new TextDecoder().decode(file.bytes); const wrapResult = wrapEntrySource(source, { entryFnName: 'run', filename: entry, }); if (!wrapResult.ok) { throw new Error( `Failed to prepare command "${commandId}": ${wrapResult.errors[0]?.message ?? 'wrap error'}`, ); } const wrappedSource = wrapResult.value; // Reuse the cached activation across runs — that is the behaviour // command tools shipped with originally and it works. Forcing a // brand-new sandbox per run (an earlier experiment) regressed it: // the dispose→recreate cycle is what surfaced "Lifetime not alive". // The single retry below is kept purely as a safety net for a // genuinely dead sandbox; it does NOT pre-emptively tear anything // down. const runOnce = async (isRetry: boolean): Promise => { try { const activation = await deps.runtime.activate(record.id, grants, bundle); await deps.dispatcher.fire(`onCommand:${commandId}` as const); // Set ctx via setGlobal. The BimSandboxHandle special-cases // `__ifclite_ctx__` to synthesize from the bridge-installed // `globalThis.bim` (the wrapped SDK is cyclic and would crash // JSON.stringify). The wrap also falls back to globalThis.bim // if ctx is somehow unset. setGlobal is inside the try so a // "Sandbox disposed" on a dead handle also triggers the retry. const ctx: ExtensionContextV1 = { bim: deps.sdk }; await activation.sandbox.setGlobal('__ifclite_ctx__', ctx); return await activation.sandbox.run(wrappedSource, { filename: entry }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Only on an actual failure that looks like a dead realm do we // tear down and retry once — never pre-emptively. const isDeadSandbox = /Lifetime not alive|QuickJSUseAfterFree|Sandbox was torn down|Sandbox disposed|not initialized/i.test(msg); if (isDeadSandbox && !isRetry) { await deps.runtime.deactivate(record.id); return runOnce(true); } throw err; } }; return runOnce(false); } throw new Error(`No installed, enabled extension owns command "${commandId}".`); }