File size: 10,423 Bytes
cc6b6d8 757768f cc6b6d8 08a393b cc6b6d8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | // 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 };
}
|