export const CONTRACT_VERSION = 1;
export class EngineError extends Error {
constructor(rawEnvelope) {
let type = "EngineError";
let message = typeof rawEnvelope === "string" ? rawEnvelope : String(rawEnvelope);
try {
const parsed = JSON.parse(rawEnvelope);
type = parsed.__type ?? type;
message = parsed.message ?? parsed.Message ?? message;
} catch {
}
super(message);
this.name = "EngineError";
this.type = type;
this.envelope = rawEnvelope;
}
}
let messageCounter = 0;
function nextId() {
messageCounter += 1;
return `m${messageCounter}`;
}
function resolveWorkerUrl({ workerUrl, assetBase }) {
if (workerUrl) return workerUrl;
if (assetBase) {
const base = String(assetBase).replace(/\/?$/, "/");
return new URL("dynoxide-worker.js", base);
}
return new URL("./dynoxide-worker.js", import.meta.url);
}
export class EngineClient {
#worker;
#onMessage;
#onError;
#onMessageError;
#pending = new Map();
#ready;
#deadError = null;
constructor({
workerUrl,
assetBase,
createWorker,
name = "dynoxide.db",
ephemeral = false,
} = {}) {
this.name = name;
this.ephemeral = ephemeral;
this.contractVersion = null;
this.capabilities = [];
this.persistenceMode = "unknown";
this.#worker = createWorker
? createWorker()
: new Worker(resolveWorkerUrl({ workerUrl, assetBase }), { type: "module" });
this.#onMessage = (event) => {
const { id, ok, result, error } = event.data ?? {};
const entry = this.#pending.get(id);
if (!entry) return;
this.#pending.delete(id);
if (ok) entry.resolve(result);
else entry.reject(new EngineError(error));
};
this.#worker.addEventListener("message", this.#onMessage);
this.#onError = (event) => {
this.#die(new EngineError(`engine worker error: ${event.message ?? "crashed"}`));
};
this.#worker.addEventListener("error", this.#onError);
this.#onMessageError = () => {
this.#die(new EngineError("engine worker messageerror: a reply could not be deserialised"));
};
this.#worker.addEventListener("messageerror", this.#onMessageError);
this.#ready = this.#boot();
this.#ready.catch(() => {});
}
#rejectAll(error) {
for (const { reject } of this.#pending.values()) reject(error);
this.#pending.clear();
}
#die(error) {
this.#deadError ??= error;
this.#rejectAll(this.#deadError);
}
#post(op, payload) {
if (this.#deadError) {
return Promise.reject(this.#deadError);
}
return new Promise((resolve, reject) => {
const id = nextId();
this.#pending.set(id, { resolve, reject });
this.#worker.postMessage({ id, op, payload, contractVersion: CONTRACT_VERSION });
});
}
async #boot() {
const raw = await this.#post("open", {
name: this.name,
ephemeral: this.ephemeral,
});
const descriptor = JSON.parse(raw);
if (descriptor.contractVersion !== CONTRACT_VERSION) {
throw new EngineError(
JSON.stringify({
__type: "com.dynoxide.wasm#ContractMismatch",
message:
`dynoxide engine contract mismatch: client expects ${CONTRACT_VERSION}, ` +
`engine reports ${descriptor.contractVersion}. Rebuild the embed against the matching engine.`,
}),
);
}
this.contractVersion = descriptor.contractVersion;
this.capabilities = descriptor.capabilities ?? [];
this.persistenceMode = descriptor.persistenceMode ?? "unknown";
return descriptor;
}
ready() {
return this.#ready;
}
get persistent() {
return this.persistenceMode === "opfs";
}
supports(op) {
return this.capabilities.includes(op);
}
async execute(op, request = {}) {
await this.#ready;
const raw = await this.#post("execute", { op, request: request ?? {} });
return JSON.parse(raw);
}
terminate() {
this.#die(new EngineError("engine has been terminated"));
this.#worker.removeEventListener?.("message", this.#onMessage);
this.#worker.removeEventListener?.("error", this.#onError);
this.#worker.removeEventListener?.("messageerror", this.#onMessageError);
this.#worker.terminate?.();
}
}