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 };
}