nyaaorick's picture
feat: publish everything-webgpu package, engine source and documentation
1944112 verified
Raw History Blame Contribute Delete
6.5 kB
/**
* WebExtension adapter: the storage passthrough, and the message/port router.
*
* This is the whole extension-specific half of what used to be
* `src/background/background.js`. The engine no longer knows it exists — it is
* one way to reach a `ScheduledEngine`, and a page reaches the same object by
* calling its methods.
*
* The wire format is unchanged byte-for-byte from `everything-webgpu/v1`, so
* the popup, the manager page, the e2e devtest page and any external extension
* keep working with no edits.
*/
import { asEngineError } from "../engine/errors.js";
import { OP, PORT_NAME, PORT_OP, PROTOCOL } from "./protocol.js";
/**
* `browser.storage.local` already *is* the StorageAdapter shape — `get(key)`
* and `set(obj)`. The two-method interface was chosen for that reason, so this
* is a passthrough rather than a translation layer.
*
* @returns {import("../engine/model-store.js").StorageAdapter}
*/
export function webExtensionStorage() {
return browser.storage.local;
}
/**
* Wires a ScheduledEngine onto this extension's runtime messaging.
*
* @param {import("../engine/engine.js").ScheduledEngine} engine
* @returns {() => void} detach
*/
export function attachWebExtensionTransport(engine) {
const subscribers = new Set();
const unsubscribe = engine.subscribe((state) => {
broadcast({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state });
});
function broadcast(msg) {
for (const port of subscribers) {
try {
port.postMessage(msg);
} catch {
subscribers.delete(port);
}
}
}
// ------------------------------------------------------------- routing ----
async function handle(msg) {
if (!msg || msg.protocol !== PROTOCOL) {
throw new Error(`Expected protocol "${PROTOCOL}", got "${msg && msg.protocol}".`);
}
switch (msg.op) {
case OP.STATUS:
return { ok: true, state: engine.state, webgpu: engine.hasWebGPU };
case OP.LIST_MODELS:
return { ok: true, models: await engine.listModels(), state: engine.state };
case OP.LOAD:
return { ok: true, state: await engine.load(msg.modelId) };
case OP.UNLOAD:
return { ok: true, state: await engine.unload() };
case OP.CHAT:
return { ok: true, ...(await engine.complete(msg)) };
case OP.BATCH:
return { ok: true, results: await engine.batch(msg) };
case OP.CANCEL:
return { ok: true, cancelled: engine.cancel(msg.id ?? msg.session) };
case OP.CONFIGURE:
return { ok: true, ...(await engine.configure(msg)) };
default:
throw new Error(`Unknown op "${msg.op}".`);
}
}
/**
* `error` stays a plain string so every existing caller keeps working; `code`
* and `detail` are added beside it. Drop the string once consumers have
* moved — the wire protocol is versioned (`everything-webgpu/v1`) and this is
* an addition, not a break.
*/
const respond = (msg) =>
handle(msg).catch((err) => {
const e = asEngineError(err);
return { ok: false, error: e.message, code: e.code, ...(e.detail ? { detail: e.detail } : {}) };
});
/**
* The allowlist is a property of *this transport*, not of the engine: it
* answers "which other extension may send me messages", a question that has
* no meaning for a page that already holds the object.
*/
async function denyExternal(sender) {
const { allowedExternalIds } = await engine.store.getSettings();
const id = sender?.id;
if (allowedExternalIds.length === 0 || allowedExternalIds.includes(id)) return null;
return { ok: false, error: `Extension "${id}" is not on this engine's allowlist.` };
}
function attachPort(port) {
if (port.name !== PORT_NAME) return;
subscribers.add(port);
port.onDisconnect.addListener(() => subscribers.delete(port));
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
const send = (msg) => {
try {
port.postMessage(msg);
} catch {
/* port closed mid-stream */
}
};
port.onMessage.addListener(async (msg) => {
const id = msg?.id;
try {
if (!msg || msg.protocol !== PROTOCOL) throw new Error(`Expected protocol "${PROTOCOL}".`);
switch (msg.op) {
case PORT_OP.SUBSCRIBE:
return send({ protocol: PROTOCOL, op: PORT_OP.ENGINE_STATE, state: engine.state });
case PORT_OP.CHAT_STREAM: {
const result = await engine.complete(msg, (delta) =>
send({ protocol: PROTOCOL, op: PORT_OP.CHUNK, id, delta }),
);
return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, ...result });
}
case PORT_OP.BATCH_STREAM: {
const results = await engine.batch(msg, (item) =>
send({ protocol: PROTOCOL, op: PORT_OP.ITEM, id, ...item }),
);
return send({ protocol: PROTOCOL, op: PORT_OP.DONE, id, results });
}
case PORT_OP.ABORT:
return void engine.cancel(msg.session ?? id);
default:
return send({ protocol: PROTOCOL, op: msg.op, id, ...(await respond(msg)) });
}
} catch (err) {
const e = asEngineError(err);
send({
protocol: PROTOCOL,
op: PORT_OP.ERROR,
id,
error: e.message,
code: e.code,
...(e.detail ? { detail: e.detail } : {}),
});
}
});
}
const onMessage = (msg) => respond(msg);
const onMessageExternal = async (msg, sender) => (await denyExternal(sender)) ?? respond(msg);
const onConnectExternal = async (port) => {
const denial = await denyExternal(port.sender);
if (denial) {
port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ERROR, error: denial.error });
port.disconnect();
return;
}
attachPort(port);
};
browser.runtime.onMessage.addListener(onMessage);
browser.runtime.onMessageExternal.addListener(onMessageExternal);
browser.runtime.onConnect.addListener(attachPort);
browser.runtime.onConnectExternal.addListener(onConnectExternal);
return () => {
browser.runtime.onMessage.removeListener(onMessage);
browser.runtime.onMessageExternal.removeListener(onMessageExternal);
browser.runtime.onConnect.removeListener(attachPort);
browser.runtime.onConnectExternal.removeListener(onConnectExternal);
unsubscribe();
subscribers.clear();
};
}