use std::path::Path;
const COMPAT_DATE: &str = "2024-01-01";
const MAX_NAME_LEN: usize = 63;
const REACTOR_MJS: &str = r#"// Reactor buffer-ABI marshalling (generated by plgc). The SINGLE source of the
// host-call dance: imported by worker.js (deploy) AND scripts/reactor-smoke.mjs
// (test), so the tested code is the shipped code. Keep in lockstep with
// `plg_rt_run_query`'s signature — that is the whole point of having one copy.
export const REACTOR_EXPORTS = [
"plg_init",
"plg_rt_run_query",
"plg_rt_alloc",
"plg_rt_free",
"plg_rt_atom_name",
"memory",
];
// Under `wasm-ld --allow-undefined` a missing/renamed export degrades to a
// silent import rather than a link error, so check at instantiation time.
export function assertExports(ex) {
for (const name of REACTOR_EXPORTS) {
if (!(name in ex)) throw new Error(`reactor module missing export: ${name}`);
}
}
// The reactor emits a bson envelope; the engine has no JSON. This glue decodes
// bson→JSON host-side (docs/design/WASM_HOST_GLUE.md): bson term values are
// BinData TermBuf with atom-IDS, resolved via plg_rt_atom_name (which reads the
// runtime interner — program AND query atoms). All conversion logic is host-side.
// Atom-id → name cache, scoped per module instance (a WeakMap keyed on the
// exports object, which is unique per wasm instance). Atoms are immutable and
// only added during solve, so a per-instance cache is valid across queries and
// grows monotonically — and different programs (different atom tables) can't
// conflate, which a module-level cache would.
const _atomCaches = new WeakMap();
function resolveAtom(ex, id) {
let cache = _atomCaches.get(ex);
if (!cache) { cache = new Map(); _atomCaches.set(ex, cache); }
let name = cache.get(id);
if (name !== undefined) return name;
const packed = ex.plg_rt_atom_name(id);
if (packed === 0n) return undefined; // out of range (shouldn't happen)
const ptr = Number(packed >> 32n);
const len = Number(packed & 0xffffffffn);
name = new TextDecoder().decode(new Uint8Array(ex.memory.buffer, ptr, len));
cache.set(id, name);
return name;
}
// limit/stepLimit/depthLimit map to the per-request ABI knobs; 0 = the module
// default (WASM_TIER2_PLAN.md A3). stepLimit is i64, hence the BigInt.
export function runQuery(ex, query, { limit = 0, stepLimit = 0n, depthLimit = 0 } = {}) {
const bytes = new TextEncoder().encode(query);
const qptr = ex.plg_rt_alloc(bytes.length);
new Uint8Array(ex.memory.buffer, qptr, bytes.length).set(bytes);
const packed = ex.plg_rt_run_query(qptr, bytes.length, limit, BigInt(stepLimit), depthLimit);
ex.plg_rt_free(qptr, bytes.length);
// Packed (len << 32) | ptr — the i64 return is a BigInt. Copy the result
// bytes BEFORE freeing so parsing is independent of wasm memory growth.
const len = Number(packed >> 32n);
const ptr = Number(packed & 0xffffffffn);
const bson = new Uint8Array(ex.memory.buffer, ptr, len).slice();
ex.plg_rt_free(ptr, len);
return envelopeToJson(bson, ex);
}
// ── bson document decode (the subset the reactor emits) ───────────────────
function rdI32(b, o) { return new DataView(b.buffer, b.byteOffset, b.byteLength).getInt32(o, true); }
function rdCStr(b, o) { let e = o; while (b[e] !== 0) e++; return [new TextDecoder().decode(b.subarray(o, e)), e + 1]; }
function rdStr(b, o) {
const n = rdI32(b, o); const s = new TextDecoder().decode(b.subarray(o + 4, o + 4 + n - 1));
return [s, o + 4 + n];
}
// parseDoc returns [obj, endOff]; endOff = start + total (the doc is self-delimiting).
function parseDoc(b, o) {
const start = o, total = rdI32(b, o); o += 4;
const obj = {};
while (b[o] !== 0) {
const ty = b[o++]; let k; [k, o] = rdCStr(b, o);
let v; [v, o] = rdValue(b, o, ty); obj[k] = v;
}
return [obj, start + total];
}
function parseArr(b, o) {
const start = o, total = rdI32(b, o); o += 4;
const tmp = {};
while (b[o] !== 0) {
const ty = b[o++]; let k; [k, o] = rdCStr(b, o);
let v; [v, o] = rdValue(b, o, ty); tmp[k] = v;
}
const arr = []; for (let i = 0; String(i) in tmp; i++) arr.push(tmp[String(i)]);
return [arr, start + total];
}
function rdValue(b, o, ty) {
switch (ty) {
case 0x02: return rdStr(b, o); // string
case 0x03: return parseDoc(b, o); // document
case 0x04: return parseArr(b, o); // array
case 0x05: { const n = rdI32(b, o); return [b.subarray(o + 5, o + 5 + n), o + 5 + n]; } // binary
case 0x08: return [b[o] !== 0, o + 1]; // bool
case 0x10: return [rdI32(b, o), o + 4]; // int32
default: throw new Error(`bson: unsupported element type ${ty}`);
}
}
// ── envelope assembly + term rendering ────────────────────────────────────
function envelopeToJson(bson, ex) {
const [doc] = parseDoc(bson, 0);
if ("error" in doc) return JSON.stringify({ error: doc.error });
const env = { count: doc.count, exhausted: doc.exhausted };
if ("output" in doc) env.output = doc.output;
env.solutions = doc.solutions.map((sol) => {
const o = {};
for (const k in sol) o[k] = decodeTermBuf(sol[k], ex);
return o;
});
return JSON.stringify(env);
}
// TermBuf BinData payload: [ver:u8=1][cell_count:u32 LE][root:u64 LE][cells…].
// Cell ABI mirrors plg-shared::cell: tag = word & 7, payload = word >> 3.
const TAG_ATOM = 1n, TAG_INT = 2n, TAG_STR = 3n, TAG_LST = 4n, TAG_FLT = 5n, TAG_BIG = 6n;
function decodeTermBuf(b, ex) {
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
const cellCount = dv.getUint32(1, true);
const root = dv.getBigUint64(5, true);
const cells = new Array(cellCount);
for (let i = 0; i < cellCount; i++) cells[i] = dv.getBigUint64(13 + i * 8, true);
return renderWord(root, cells, ex, new Set());
}
function bitsToFloat(bits) {
const buf = new ArrayBuffer(8); new DataView(buf).setBigUint64(0, bits, true);
return new DataView(buf).getFloat64(0, true);
}
function asI64(u) { return u >= (1n << 63n) ? u - (1n << 64n) : u; }
function isNilName(name) { return name === "[]"; }
// Render a cell word as a native JS value (→ JSON): atom→string (nil→[]),
// int/float/big→number, compound→{functor,args}, proper list→array, improper
// list→{head,tail}, unbound/cycle→"_". `visiting` holds STR/LST buffer indices
// on the render stack so shared subterms render fully but cycles cut to "_".
function renderWord(w, cells, ex, visiting) {
const tag = w & 7n, payload = w >> 3n;
if (tag === TAG_ATOM) {
const name = resolveAtom(ex, Number(payload));
return isNilName(name) ? [] : name;
}
if (tag === TAG_INT) return Number(asI64(w) >> 3n);
if (tag === TAG_FLT) return bitsToFloat(cells[Number(payload)]);
if (tag === TAG_BIG) return Number(asI64(cells[Number(payload)]));
if (tag === TAG_STR) {
const idx = Number(payload);
if (visiting.has(idx)) return "_";
visiting.add(idx);
const header = cells[idx];
const functor = resolveAtom(ex, Number(header >> 32n));
const arity = Number(header & 0xffffffffn);
const args = [];
for (let k = 0; k < arity; k++) args.push(renderWord(cells[idx + 1 + k], cells, ex, visiting));
visiting.delete(idx);
return { functor, args };
}
if (tag === TAG_LST) {
const elems = [], added = [];
let cur = w, cut = false;
while ((cur & 7n) === TAG_LST) {
const ci = Number(cur >> 3n);
if (visiting.has(ci)) { cut = true; break; }
visiting.add(ci); added.push(ci);
elems.push(renderWord(cells[ci], cells, ex, visiting)); // head
cur = cells[ci + 1]; // tail
}
for (const ci of added) visiting.delete(ci);
if (cut) return { head: elems, tail: "_" };
if ((cur & 7n) === TAG_ATOM && isNilName(resolveAtom(ex, Number(cur >> 3n)))) return elems;
return { head: elems, tail: renderWord(cur, cells, ex, visiting) };
}
return "_"; // REF (unbound) or unknown
}
"#;
const WORKER_JS: &str = r#"// Cloudflare / workerd glue for a patch-prolog reactor module (generated by
// plgc — edit freely; it is not regenerated once it exists).
//
// Build the Machine once per isolate (`plg_init`), then drive the buffer ABI
// per request via `reactor.mjs`. One in-flight query per isolate — the
// reactor's concurrency contract (WASM_TIER2_PLAN.md D3) — holds because
// `runQuery` never yields (the only await is reading the POST body, before it).
import { runQuery, assertExports } from "./reactor.mjs";
import reactorModule from "./__WASM_FILE__";
let cached;
function reactor() {
if (!cached) {
const instance = new WebAssembly.Instance(reactorModule, {});
assertExports(instance.exports);
instance.exports.plg_init();
cached = instance.exports;
}
return cached;
}
export default {
async fetch(request) {
const url = new URL(request.url);
let query = url.searchParams.get("query")?.trim();
if (!query && request.method === "POST") {
query = (await request.text()).trim();
}
const headers = { "content-type": "application/json" };
if (!query) {
return new Response(
'{"error":"missing query (use ?query=<goal> or POST the goal)"}',
{ status: 400, headers },
);
}
return new Response(runQuery(reactor(), query), { headers });
},
};
"#;
const WRANGLER_TOML: &str = r#"# Cloudflare deploy config for a patch-prolog reactor (generated by plgc).
# Deploy: wrangler deploy
# Then: curl 'https://__APP_NAME__.<your-subdomain>.workers.dev/?query=<goal>'
name = "__APP_NAME__"
main = "worker.js"
compatibility_date = "__DATE__"
# Import the compiled reactor as a WebAssembly module.
[[rules]]
globs = ["**/*.wasm"]
type = "CompiledWasm"
"#;
const CONFIG_CAPNP: &str = r#"# Local workerd config for a patch-prolog reactor (generated by plgc).
# Serve: workerd serve config.capnp
# Then: curl 'http://localhost:8080/?query=<goal>'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "main", worker = .mainWorker) ],
sockets = [ (name = "http", address = "*:8080", http = (), service = "main") ],
);
const mainWorker :Workerd.Worker = (
modules = [
(name = "worker.js", esModule = embed "worker.js"),
(name = "reactor.mjs", esModule = embed "reactor.mjs"),
(name = "__WASM_FILE__", wasm = embed "__WASM_FILE__"),
],
compatibilityDate = "__DATE__",
);
"#;
pub fn emit(wasm_path: &Path) -> Result<Vec<String>, String> {
let dir = wasm_path.parent().unwrap_or(Path::new("."));
let wasm_file = wasm_path
.file_name()
.ok_or("reactor output path has no file name")?
.to_string_lossy()
.into_owned();
let app_name = worker_name(&wasm_file);
let fill = |t: &str| {
t.replace("__WASM_FILE__", &wasm_file)
.replace("__APP_NAME__", &app_name)
.replace("__DATE__", COMPAT_DATE)
};
let mut written = Vec::new();
for (name, body) in [
("reactor.mjs", fill(REACTOR_MJS)),
("worker.js", fill(WORKER_JS)),
("wrangler.toml", fill(WRANGLER_TOML)),
("config.capnp", fill(CONFIG_CAPNP)),
] {
let path = dir.join(name);
if path.exists() {
continue;
}
std::fs::write(&path, body).map_err(|e| format!("failed to write {name}: {e}"))?;
written.push(name.to_string());
}
Ok(written)
}
fn worker_name(wasm_file: &str) -> String {
let stem = wasm_file.strip_suffix(".wasm").unwrap_or(wasm_file);
let stem = stem.strip_suffix(".worker").unwrap_or(stem);
let mut name = String::new();
for c in stem.chars() {
if c.is_ascii_alphanumeric() {
name.push(c.to_ascii_lowercase());
} else if !name.ends_with('-') {
name.push('-'); }
}
let name: String = name.trim_matches('-').chars().take(MAX_NAME_LEN).collect();
let name = name.trim_end_matches('-'); if name.is_empty() {
"prolog-worker".to_string()
} else {
name.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_name_strips_suffixes_and_sanitizes() {
assert_eq!(worker_name("deps.worker.wasm"), "deps");
assert_eq!(worker_name("my_app.worker.wasm"), "my-app");
assert_eq!(worker_name("plain.wasm"), "plain");
assert_eq!(worker_name("my__app.worker.wasm"), "my-app");
assert_eq!(worker_name(".worker.wasm"), "prolog-worker");
}
#[test]
fn worker_name_caps_length() {
let long = format!("{}.worker.wasm", "a".repeat(200));
assert_eq!(worker_name(&long).len(), MAX_NAME_LEN);
}
#[test]
fn emit_writes_glue_then_preserves_it() {
let dir = tempfile::tempdir().unwrap();
let wasm = dir.path().join("deps.worker.wasm");
std::fs::write(&wasm, b"\0asm").unwrap();
let written = emit(&wasm).unwrap();
assert_eq!(
written,
["reactor.mjs", "worker.js", "wrangler.toml", "config.capnp"]
);
let abi = std::fs::read_to_string(dir.path().join("reactor.mjs")).unwrap();
assert!(
abi.contains(
"ex.plg_rt_run_query(qptr, bytes.length, limit, BigInt(stepLimit), depthLimit)"
),
"{abi}"
);
let js = std::fs::read_to_string(dir.path().join("worker.js")).unwrap();
assert!(
js.contains(r#"import { runQuery, assertExports } from "./reactor.mjs""#),
"{js}"
);
assert!(
js.contains(r#"import reactorModule from "./deps.worker.wasm""#),
"{js}"
);
let toml = std::fs::read_to_string(dir.path().join("wrangler.toml")).unwrap();
assert!(toml.contains("name = \"deps\""), "{toml}");
let capnp = std::fs::read_to_string(dir.path().join("config.capnp")).unwrap();
assert!(capnp.contains(r#"embed "deps.worker.wasm""#), "{capnp}");
assert!(capnp.contains(r#"embed "reactor.mjs""#), "{capnp}");
std::fs::write(dir.path().join("worker.js"), "// edited").unwrap();
let again = emit(&wasm).unwrap();
assert!(again.is_empty(), "rebuild must not clobber glue: {again:?}");
assert_eq!(
std::fs::read_to_string(dir.path().join("worker.js")).unwrap(),
"// edited"
);
}
}