unroll / lib /api.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
10.4 kB
// window.unroll: the stable public API for people and agents. The page supplies the implementations;
// this file adds argument checking, uniform JSON results, events, batches and waiting.
import { METHODS, EVENTS, URL_PARAMS, KEYS, API_VERSION, checkArgs } from './apispec.js';
export class ApiError extends Error { constructor(message, code = 'invalid') { super(message); this.code = code; } }
const json = (v) => JSON.parse(JSON.stringify(v ?? null));
const get = (o, path) => path.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o);
export function installAPI(impl, { stateFn, onTick }) {
const subs = new Map();
let nextId = 1;
const emit = (event, payload) => {
for (const [, s] of subs) if (s.event === event) { try { s.cb(json(payload)); } catch (e) { console.warn('unroll event handler', e); } }
};
const api = {};
const ext = {}; // name -> { group, summary, params, fn } for add-ons such as lib/bench.js
const wrap = (name) => async (...args) => {
const bad = ext[name] ? null : checkArgs(name, args);
if (bad) { emit('error', { method: name, error: bad }); return { ok: false, error: bad, code: 'invalid' }; }
try {
const out = await (ext[name] ? ext[name].fn : impl[name])(...args);
return { ok: true, ...(out && typeof out === 'object' && !Array.isArray(out) ? json(out) : { result: json(out) }) };
} catch (e) {
const error = e && e.message ? e.message : String(e);
emit('error', { method: name, error });
return { ok: false, error, code: e.code || 'failed' };
}
};
// meta methods that live here
impl.schema = async () => ({
version: API_VERSION, app: window.UNROLL_VERSION || null,
methods: Object.fromEntries([...Object.entries(METHODS), ...Object.entries(ext)].map(([k, m]) => [k, { group: m.group, summary: m.summary, params: m.params, ...(ext[k] ? { extension: true } : {}) }])),
extensions: [...new Set(Object.values(ext).map((m) => m.group))],
events: EVENTS, urlParams: URL_PARAMS, keys: KEYS,
conventions: 'Every method returns a Promise of { ok: true, ... } or { ok: false, error, code }. Nothing throws. Screen coordinates are CSS pixels; scene coordinates are millimeters. Add-ons register more groups with unroll.register(group, methods, specs); they appear here with extension: true.',
});
// help(): a short guide for agents; help('setLayer') or help('destroy') narrows it to a method or a group
impl.help = async (topic) => {
const all = { ...METHODS, ...ext };
const fmtM = (k, m) => ({ method: k, group: m.group, summary: m.summary, params: (m.params || []).map((p) => `${p.name}${p.required === false ? '?' : ''}: ${p.type}${p.desc ? ` (${p.desc})` : ''}`), call: `unroll.${k}(${(m.params || []).map((p) => p.name).join(', ')})` });
if (topic !== undefined) {
if (typeof topic !== 'string') throw new ApiError('help: topic must be a method name (e.g. "setLayer") or a group (e.g. "destroy").');
if (all[topic]) return fmtM(topic, all[topic]);
const g = Object.entries(all).filter(([, m]) => m.group === topic);
if (g.length) return { group: topic, methods: g.map(([k, m]) => fmtM(k, m)) };
throw new ApiError(`help: no method or group "${topic}". Groups: ${[...new Set(Object.values(all).map((m) => m.group))].join(', ')}.`, 'not_found');
}
return {
about: 'window.unroll drives every control of the Unroll scroll viewer. Every call returns a Promise of { ok: true, ... } or { ok: false, error, code }; nothing throws.',
start: ['await unroll.waitFor("ready")', 'await unroll.getState()', 'await unroll.setUnroll(0.5)', 'await unroll.setLayer("overlay")', 'await unroll.screenshot()'],
groups: Object.fromEntries([...new Set(Object.values(all).map((m) => m.group))].map((g) => [g, Object.keys(all).filter((k) => all[k].group === g)])),
more: 'unroll.help("destroy") for a group, unroll.help("setLayer") for one method, unroll.schema() for everything as JSON. Docs: /AGENTS.md, /llms.txt, /api.json, /docs/EMBED.md.',
version: window.UNROLL_VERSION || null,
};
};
impl.on = (event, cb) => {
if (typeof cb !== 'function') throw new ApiError('on: callback must be a function.');
const id = nextId++; subs.set(id, { event, cb }); return { id };
};
impl.off = (id) => { if (!subs.delete(id)) throw new ApiError(`off: no subscription with id ${id}.`, 'not_found'); return {}; };
impl.run = async (steps, options = {}) => {
const results = [];
for (const [i, s] of steps.entries()) {
const method = Array.isArray(s) ? s[0] : s && s.method;
const args = Array.isArray(s) ? s.slice(1) : (s && s.args) || [];
if (typeof method !== 'string' || !fns[method] || method === 'run') { results.push({ ok: false, error: `Step ${i}: unknown method ${JSON.stringify(method)}.`, code: 'invalid' }); if (!options.continueOnError) break; continue; }
const r = await call(method, args);
results.push({ method, ...r });
if (!r.ok && !options.continueOnError) break;
}
return { results, completed: results.filter((r) => r.ok).length, total: steps.length };
};
impl.waitFor = (cond, options = {}) => new Promise((resolve, reject) => {
const timeout = options.timeout ?? 10000;
if (typeof cond === 'string' && !['ready', 'settled', 'overlayLoaded'].includes(cond)) { reject(new ApiError(`waitFor: unknown condition "${cond}". Use "ready", "settled", "overlayLoaded" or an object.`)); return; }
const test = (st) => {
if (cond === 'ready') return st.ready;
if (cond === 'settled') return st.settled;
if (cond === 'overlayLoaded') return st.layers.overlayLoaded;
return Object.entries(cond).every(([path, want]) => {
const v = get(st, path);
if (want && typeof want === 'object' && !Array.isArray(want)) {
return Object.entries(want).every(([op, x]) => (op === 'eq' ? v === x : op === 'ne' ? v !== x : op === 'gte' ? v >= x : op === 'lte' ? v <= x : op === 'gt' ? v > x : op === 'lt' ? v < x : op === 'in' ? Array.isArray(x) && x.includes(v) : false));
}
return JSON.stringify(v) === JSON.stringify(want);
});
};
const t0 = performance.now();
const tick = () => {
const st = stateFn();
if (test(st)) { resolve({ state: st, ms: Math.round(performance.now() - t0) }); return; }
if (performance.now() - t0 > timeout) { reject(new ApiError(`waitFor timed out after ${timeout} ms.`, 'timeout')); return; }
setTimeout(tick, 50);
};
tick();
});
const call = (name, args) => (fns[name] ? fns[name](...args) : Promise.resolve({ ok: false, error: `Unknown method "${name}".`, code: 'invalid' }));
const fns = {};
for (const name of Object.keys(METHODS)) {
if (!impl[name]) throw new Error(`API method ${name} has no implementation`);
fns[name] = wrap(name);
const [g, m] = name.split('.');
if (m) { api[g] = api[g] || {}; api[g][m] = fns[name]; } else api[g] = fns[name];
}
// state change events, checked a few times a second
let last = null, lastT = null, wasSettled = false;
setInterval(() => {
const st = stateFn();
const cmp = { layer: st.layer, focus: st.focus, panels: st.panels, destroy: { on: st.destroy.on, tool: st.destroy.tool, sandbox: st.destroy.sandbox }, scroll: st.scroll.key };
if (last) {
const changed = Object.keys(cmp).filter((k) => JSON.stringify(cmp[k]) !== JSON.stringify(last[k]));
if (changed.length) emit('change', { changed, state: st });
if (changed.includes('layer')) emit('layer', { layer: st.layer });
if (changed.includes('focus')) emit('focus', { focus: st.focus });
if (changed.includes('panels')) emit('panel', { panels: st.panels });
if (changed.includes('destroy')) emit('destroy', { destroy: st.destroy });
}
if (st.settled && !wasSettled) {
emit('settled', { state: st });
if (lastT === null || Math.abs(st.t - lastT) > 1e-4) { emit('unroll', { t: st.t }); emit('change', { changed: ['t'], state: st }); }
emit('camera', { camera: st.camera });
lastT = st.t;
}
wasSettled = st.settled;
last = json(cmp);
if (onTick) onTick(st);
}, 200);
// Extensions: a script registers a new group (for example window.unroll.bench) with
// window.unroll.register('bench', { run: fn, ... }, { run: { summary, params } }).
// Scripts that load before the API can push the same arguments onto window.unrollExtensions.
const reserved = new Set(['version', 'register', ...Object.keys(api)]);
const register = (group, methods, specs = {}) => {
if (typeof group !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(group)) return { ok: false, error: 'register: group must be a simple lower camel case name.', code: 'invalid' };
if (reserved.has(group)) return { ok: false, error: `register: "${group}" is already taken.`, code: 'conflict' };
if (!methods || typeof methods !== 'object' || !Object.keys(methods).length) return { ok: false, error: 'register: methods must be an object of functions.', code: 'invalid' };
const ns = {};
for (const [m, fn] of Object.entries(methods)) {
if (typeof fn !== 'function') return { ok: false, error: `register: ${group}.${m} is not a function.`, code: 'invalid' };
const name = `${group}.${m}`, sp = specs[m] || {};
ext[name] = { group, summary: sp.summary || '', params: sp.params || [], fn };
fns[name] = wrap(name); ns[m] = fns[name];
}
reserved.add(group);
Object.defineProperty(api, group, { value: Object.freeze(ns), enumerable: true, writable: false, configurable: false });
emit('extension', { group, methods: Object.keys(ns) });
return { ok: true, group, methods: Object.keys(ns) };
};
for (const k of Object.keys(api)) {
if (api[k] && typeof api[k] === 'object') Object.freeze(api[k]);
Object.defineProperty(api, k, { writable: false, configurable: false });
}
Object.defineProperty(api, 'register', { value: register, enumerable: true, writable: false, configurable: false });
Object.defineProperty(window, 'unroll', { value: api, configurable: false, writable: false });
const queued = Array.isArray(window.unrollExtensions) ? window.unrollExtensions : [];
window.unrollExtensions = { push: (...items) => { for (const it of items) register(...it); return 0; } };
for (const it of queued) register(...it);
return { api, emit, call };
}