export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1"; export const PROTOCOL_SCHEMA = 1; let CLIENT_INSTANCE = 0; function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function commandError(message) { const payload = isObject(message?.payload) ? message.payload : {}; const reason = payload.reason ?? payload.gate_reason ?? payload.message_key ?? payload.code ?? "worker_error"; return Object.assign(new Error(String(reason)), { code: payload.code ?? "worker_error", reason: payload.reason ?? payload.gate_reason ?? null, message_key: payload.message_key ?? null, response: message, }); } export class WorkerClient { constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = null, timeoutMs = 10000, maxPending = 64 } = {}) { this.contextId = contextId; this.sessionId = sessionId ?? `ui-${++CLIENT_INSTANCE}`; this.timeoutMs = timeoutMs; this.maxPending = maxPending; this.worker = worker ?? (workerFactory ? workerFactory() : new Worker("../worker/sim-worker.mjs", { type: "module" })); this.seq = 0; this.pending = new Map(); this.completed = new Map(); this.listeners = new Set(); this.closed = false; this.worker.onmessage = (event) => this.#receive(event.data); this.worker.onerror = (event) => this.#failAll(new Error(event?.message ?? "worker_exception")); } onMessage(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } #emit(message) { for (const listener of this.listeners) listener(message); } #receive(message) { this.#emit(message); if (!isObject(message)) return; const operationId = message.operation_id ?? message.payload?.operation_id; const pending = operationId ? this.pending.get(operationId) : this.pending.get(`seq:${message.seq}`); if (!pending) return; clearTimeout(pending.timer); this.pending.delete(operationId ?? `seq:${message.seq}`); if (operationId) this.completed.set(operationId, message); if (message.kind === "ERROR" || message.payload?.status === "error" || message.payload?.status === "blocked") { pending.reject(commandError(message)); } else pending.resolve(message); } #failAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); } send(kind, payload = {}, { operationId = null, timeoutMs = this.timeoutMs } = {}) { if (this.closed) return Promise.reject(new Error("worker_client_closed")); if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1) return Promise.reject(new Error("worker_client_backpressure")); if (operationId && this.completed.has(operationId)) return Promise.resolve(this.completed.get(operationId)); if (operationId && this.pending.has(operationId)) return this.pending.get(operationId).promise; const seq = ++this.seq; const message = { schema_version: PROTOCOL_SCHEMA, context_id: this.contextId, session_id: this.sessionId, seq, kind, ...(operationId ? { operation_id: operationId } : {}), payload, }; let resolvePromise; let rejectPromise; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); const pendingKey = operationId ?? `seq:${seq}`; const timer = setTimeout(() => { this.pending.delete(pendingKey); const error = new Error("worker_timeout"); error.code = "worker_timeout"; rejectPromise(error); }, timeoutMs); if (this.pending.size >= this.maxPending) { clearTimeout(timer); rejectPromise(Object.assign(new Error("worker_backpressure"), { code: "worker_backpressure" })); return promise; } this.pending.set(pendingKey, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq, operationId, pendingKey }); try { this.worker.postMessage(message); } catch (error) { clearTimeout(timer); this.pending.delete(pendingKey); rejectPromise(error); } return promise; } hello() { return this.send("HELLO", {}); } attach(operationId = "session-attach") { return this.send("SESSION_ATTACH", {}, { operationId }); } loadWorkspace(rootPath = "/workspace", configuration = undefined, hal = undefined, operationId = "workspace-load") { return this.send("LOAD_WORKSPACE", { root_path: rootPath, ...(configuration ? { configuration } : {}), ...(hal ? { hal } : {}) }, { operationId }); } loadProgram(source, path = "program.ngc", operationId = `program-load-${this.seq + 1}`) { return this.send("PROGRAM_LOAD", { source, path }, { operationId }); } runProgram(operationId = `program-run-${this.seq + 1}`) { return this.send("PROGRAM_RUN", {}, { operationId }); } pauseProgram(operationId = `program-pause-${this.seq + 1}`) { return this.send("PROGRAM_PAUSE", {}, { operationId }); } resumeProgram(operationId = `program-resume-${this.seq + 1}`) { return this.send("PROGRAM_RESUME", {}, { operationId }); } abortProgram(operationId = `program-abort-${this.seq + 1}`) { return this.send("PROGRAM_ABORT", {}, { operationId }); } restartProgram(operationId = `program-restart-${this.seq + 1}`) { return this.send("PROGRAM_RESTART", {}, { operationId }); } stepProgram(operationId = `program-step-${this.seq + 1}`) { return this.send("PROGRAM_STEP", {}, { operationId }); } seekProgram(line, operationId = `program-seek-${this.seq + 1}`) { return this.send("PROGRAM_SEEK", { line }, { operationId }); } mdi(block, operationId = `mdi-${this.seq + 1}`) { return this.send("MDI_EXECUTE", { block }, { operationId }); } step(cycles = 1, operationId = `step-${this.seq + 1}`) { return this.send("SIM_STEP", { cycles }, { operationId }); } async stepMany(cycles = 1, { chunk = 1, operationPrefix = "batch-step" } = {}) { if (!Number.isSafeInteger(cycles) || cycles < 0 || cycles > 1000000 || !Number.isSafeInteger(chunk) || chunk < 1 || chunk > 1000000) return Promise.reject(Object.assign(new Error("worker_step_batch_invalid"), { code: "worker_step_batch_invalid" })); const responses = []; let remaining = cycles; let index = 0; while (remaining > 0) { const amount = Math.min(chunk, remaining); responses.push(await this.step(amount, `${operationPrefix}-${index}`)); remaining -= amount; index += 1; } return { status: "verified", cycles, chunks: responses.length, last: responses.at(-1) ?? null }; } cancel(operationId, reason = "cancel_requested") { const pending = this.pending.get(operationId); if (!pending) return Promise.resolve({ status: "verified", cancelled: false, operation_id: operationId }); clearTimeout(pending.timer); this.pending.delete(operationId); pending.reject(Object.assign(new Error("worker_cancelled"), { code: "worker_cancelled", reason })); const seq = ++this.seq; const message = { schema_version: PROTOCOL_SCHEMA, context_id: this.contextId, session_id: this.sessionId, seq, kind: "CANCEL", operation_id: `cancel:${operationId}`, payload: { operation_id: operationId, reason } }; try { this.worker.postMessage(message); } catch { /* cancellation is local even if transport is closed */ } return Promise.resolve({ status: "verified", cancelled: true, operation_id: operationId }); } snapshot(operationId = `snapshot-${this.seq + 1}`) { return this.send("SNAPSHOT_REQUEST", {}, { operationId }); } checkpoint(operationId = `checkpoint-${this.seq + 1}`) { return this.send("CHECKPOINT", {}, { operationId }); } close() { if (this.closed) return; this.closed = true; this.#failAll(new Error("worker_client_closed")); this.worker.terminate?.(); } }