Files
jcoffey-dev 136fc2540b
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m45s
Sieve playground: the server's interpreter in the browser
tools/sieve-playground builds the vendored sieve-rs 0.7.3 (vnd.inbuxa.*
names included) to WebAssembly, with a static page around it, for inbuxa
Admin's Debug button on a Sieve script: until now that button sent the
script to an outside site whose interpreter knows only upstream's
extension names, so any script that requires vnd.inbuxa.* failed to
compile there.

Ported from the playground in the upstream sieve-rs repository's web/
directory (commit 549a2d57f7), which targets sieve-rs 1.x:

- run.rs and handler.rs drive 0.7's event loop the way the server's
  delivery does, carrying on after a runtime error; llm_prompt compiles
  and answers false, as on a server without a model.
- The expression functions are the server's own modules, included by
  path; tests fail if the list, ApplyString or the few functions copied
  from misc.rs/unicode.rs stop matching crates/common.
- Built for wasm32-wasip1 rather than with wasm-bindgen: sieve-rs and
  mail-builder read the clock, which panics on wasm32-unknown-unknown.
  The page's worker answers the six WASI calls, giving the module the
  playground's current-time setting. shim/gethostname stands in for a
  crate with no WASI build. Nothing in vendor/ or crates/ changes.
- The page carries no upstream names or links; storage keys and the
  samples are renamed to match.

build.sh --admin <dir> stages the result into an inbuxa-admin checkout.
2026-10-09 01:13:53 -07:00

145 lines
3.8 KiB
JavaScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
const RUN_TIMEOUT_MS = 8000;
const MAX_RESTARTS = 3;
const RESTART_WINDOW_MS = 60000;
const BASE_BACKOFF_MS = 500;
export class EngineUnavailable extends Error {}
export class Engine {
constructor() {
this.pending = new Map();
this.nextId = 1;
this.listeners = new Set();
this.restarts = [];
this.failed = null;
this.spawn();
}
onStatus(listener) {
this.listeners.add(listener);
}
emit(state, detail) {
for (const listener of this.listeners) listener(state, detail);
}
spawn() {
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
this.worker = worker;
this.ready = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.ready.catch(() => {});
worker.onmessage = (event) => {
if (worker !== this.worker) return;
const data = event.data;
if (data.type === "ready") {
this.resolveReady();
} else if (data.type === "init-error") {
this.crash(`Engine failed to load: ${data.error}`, { loadFailure: true });
} else {
this.settle(data);
}
};
worker.onerror = (event) => {
event.preventDefault();
if (worker === this.worker) {
this.crash(`Engine failed to load: ${event.message || "the worker script could not be loaded"}`, { loadFailure: true });
}
};
}
crash(reason, { loadFailure = false } = {}) {
this.worker.terminate();
this.rejectReady(new EngineUnavailable(reason));
for (const { reject, timer } of this.pending.values()) {
clearTimeout(timer);
reject(new EngineUnavailable(reason));
}
this.pending.clear();
let delay = 0;
if (loadFailure) {
const now = Date.now();
this.restarts = this.restarts.filter((time) => now - time < RESTART_WINDOW_MS);
if (this.restarts.length >= MAX_RESTARTS) {
this.failed = reason;
this.emit("failed", reason);
return;
}
this.restarts.push(now);
delay = BASE_BACKOFF_MS * 2 ** (this.restarts.length - 1);
}
this.emit("restarting", reason);
const failedReady = this.ready;
this.ready = new Promise((resolve, reject) => {
setTimeout(() => {
this.spawn();
this.ready.then(resolve, reject);
}, delay);
});
this.ready.catch(() => {});
failedReady.catch(() => {});
}
settle({ id, result, error, fatal }) {
const entry = this.pending.get(id);
if (!entry) return;
this.pending.delete(id);
clearTimeout(entry.timer);
if (error) {
entry.reject(new Error(error));
if (fatal) this.crash(`The engine crashed: ${error}`);
} else {
entry.resolve(result);
}
}
async call(op, payload, timeout) {
if (this.failed) throw new EngineUnavailable(this.failed);
await this.ready;
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = timeout
? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`The script did not finish within ${timeout / 1000} seconds and was stopped.`));
this.crash("timeout");
}, timeout)
: null;
this.pending.set(id, { resolve, reject, timer });
this.worker.postMessage({ id, op, payload });
});
}
version() {
return this.call("version");
}
capabilities() {
return this.call("capabilities");
}
defaults() {
return this.call("defaults");
}
compile(request) {
return this.call("compile", request, RUN_TIMEOUT_MS);
}
run(request) {
return this.call("run", request, RUN_TIMEOUT_MS);
}
}