typebulb 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/agents/claude/client.js +182 -33
- package/dist/agents/pi/client.js +243 -94
- package/dist/agents/pi/matchu-patchu.ts +1 -1
- package/dist/index.js +362 -209
- package/dist/render.js +211 -62
- package/dist/servers.js +245 -96
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ typebulb call <file> <fn> […] Invoke one server.ts export headlessly: prints
|
|
|
44
44
|
typebulb send <file> [msg] Push a message into a running bulb's page (its tb.onMessage handlers); the client-side twin of call, no --trust.
|
|
45
45
|
With --wait, a handler's non-undefined return prints on stdout (JSON; a bare string raw)
|
|
46
46
|
typebulb send <file> tb:snapshot Print the live page's rendered outline (roles, names, visible text)
|
|
47
|
+
typebulb send <file> tb:click … Click a control by role+name ('tb:click button "Pass"'); the reply is a fresh snapshot
|
|
48
|
+
typebulb send <file> tb:set … Set a form control ('tb:set combobox "level" = hard'), firing input+change
|
|
47
49
|
typebulb get <file> <kind> Print one block's content (data, insight, code, …) to stdout
|
|
48
50
|
typebulb put <file> <k>=<src> Write a file's (or stdin's) content into a block, surgically
|
|
49
51
|
typebulb pull <url|file> Fetch a bulb from typebulb.com into typebulbs/u/<user>/<slug>.bulb.md
|
|
@@ -267,8 +269,9 @@ That one launch *is* the loop: the server watches the file, so every save recomp
|
|
|
267
269
|
|
|
268
270
|
- **Structured selftest** — a handler that returns `{ count, verdict }` beats one that logs prose: `typebulb send <file> selftest --wait` prints the object as JSON, and you assert on fields instead of parsing `logs`. At most one handler, in one page, may return a value; a slow check needs `--wait=<ms>` above the 5s default.
|
|
269
271
|
- **Rendered truth** — `typebulb send <file> tb:snapshot` prints the page's accessibility outline (roles, names, visible text) without disturbing its state. Use it when logs say ok but the screen might not, and as the first probe on a live page in a state you can't reproduce — a save would hot-reload and destroy it. (`tb:` messages are answered by the runtime, never your handlers, and imply `--wait`.)
|
|
270
|
-
- **
|
|
271
|
-
- **
|
|
272
|
+
- **Acting on the page** — `typebulb send <file> 'tb:click button "Pass"'` clicks the one control matching that role and name (exact, else a unique case-insensitive substring) and replies with a fresh snapshot; `tb:set combobox "strength" = hard` is the same for form controls (checkboxes and radios take `tb:click`). A disabled, readonly, or covered target is an error naming it — that silence is the bug class these verbs catch. Needs exactly one page open, and the reply is the immediate frame (slow work: follow up with `tb:snapshot`). Only what the outline names is targetable: real `<button>`s and labeled controls, not an `onClick` `<div>`.
|
|
273
|
+
- **Poking state** — for state beyond what a form control expresses (`tb:set` covers those), author a set-handler up front: a `tb.onMessage` branch that takes a data payload (JSON arrives parsed), applies it to your state — committing the change if your framework needs an explicit step — and returns the new state: `typebulb send <file> '{"set":"speed","value":2}' --wait` prints it. In React, register it in an effect so it closes over the setters (the returned unsubscribe is the cleanup). Adding the handler later is itself the edit that destroys the state.
|
|
274
|
+
- **A page must be open** — the CLI runs no browser of its own, so every client-side check waits on a real window (and `--no-open` means there isn't one). `send` says which case it is: nobody has ever connected (share the link), or a page dropped and hasn't returned (it's stale — reload it). Never open a window at the user: the server logs `[page] connected` when a page attaches, so end your turn with the link, arming `typebulb wait <file> --match "[page] connected"` in the background first — the user opening the page is your wake-up.
|
|
272
275
|
|
|
273
276
|
### Emitting a server-only bulb
|
|
274
277
|
|
|
@@ -1304,60 +1304,206 @@ const something = require('module-name') // NOT SUPPORTED!
|
|
|
1304
1304
|
set theme(v) { if (window.__tbTheme) window.__tbTheme.set(v); }
|
|
1305
1305
|
});
|
|
1306
1306
|
|
|
1307
|
-
// tb:snapshot
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1307
|
+
// tb:snapshot / tb:click / tb:set \u2014 the reserved interrogation-and-actuation namespace
|
|
1308
|
+
// (TB-Interrogation.md, TB-Interrogation-Actuation.md). One walk is both the outline and the
|
|
1309
|
+
// selector vocabulary: snapshot renders its lines, the verbs resolve targets from its entries,
|
|
1310
|
+
// so nothing is targetable the outline doesn't name. Heuristic by design: explicit role attr, a
|
|
1311
|
+
// small implicit-role map; names come from aria-label, label association, placeholder, then
|
|
1312
|
+
// visible text \u2014 a form control's value is a stated facet ([value=\u2026]), never its name, because a
|
|
1313
|
+
// value-derived name is a self-invalidating selector.
|
|
1312
1314
|
const IMPLICIT_ROLES = { BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', OPTION: 'option', IMG: 'img', NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', ASIDE: 'complementary', FORM: 'form', DIALOG: 'dialog', TABLE: 'table', TR: 'row', TH: 'columnheader', TD: 'cell', UL: 'list', OL: 'list', LI: 'listitem', LABEL: 'label', P: 'paragraph', PROGRESS: 'progressbar', SUMMARY: 'button', FIGURE: 'figure', BLOCKQUOTE: 'blockquote', HR: 'separator' };
|
|
1313
1315
|
const INPUT_ROLES = { checkbox: 'checkbox', radio: 'radio', range: 'slider', number: 'spinbutton', search: 'searchbox', button: 'button', submit: 'button', reset: 'button', hidden: '' };
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1316
|
+
const FORM_TAGS = { INPUT: 1, TEXTAREA: 1, SELECT: 1 };
|
|
1317
|
+
const squash = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
1318
|
+
const clip = (s) => (s.length > 200 ? s.slice(0, 200) + '\u2026' : s);
|
|
1319
|
+
const roleOf = (el) => {
|
|
1320
|
+
const explicit = el.getAttribute('role');
|
|
1321
|
+
if (explicit) return explicit;
|
|
1322
|
+
const t = el.tagName;
|
|
1323
|
+
if (t === 'A') return el.hasAttribute('href') ? 'link' : '';
|
|
1324
|
+
if (t === 'INPUT') { const r = INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()]; return r === undefined ? 'textbox' : r; }
|
|
1325
|
+
if (/^H[1-6]$/.test(t)) return 'heading';
|
|
1326
|
+
return IMPLICIT_ROLES[t] || '';
|
|
1327
|
+
};
|
|
1328
|
+
const hiddenEl = (el) => {
|
|
1329
|
+
if (el.hidden || el.getAttribute('aria-hidden') === 'true') return true;
|
|
1330
|
+
const cs = getComputedStyle(el);
|
|
1331
|
+
return cs.display === 'none' || cs.visibility === 'hidden';
|
|
1332
|
+
};
|
|
1333
|
+
// The associated label's text minus the control's own subtree (accname's core rule) \u2014 the
|
|
1334
|
+
// stable identity a wrapping label's raw textContent would drown in the control's own text.
|
|
1335
|
+
const labelName = (el) => {
|
|
1336
|
+
const label = el.labels && el.labels[0];
|
|
1337
|
+
if (!label) return '';
|
|
1338
|
+
let t = '';
|
|
1339
|
+
const gather = (n) => {
|
|
1340
|
+
if (n === el) return;
|
|
1341
|
+
if (n.nodeType === 3) { t += ' ' + n.nodeValue; return; }
|
|
1342
|
+
if (n.nodeType !== 1) return;
|
|
1343
|
+
for (let c = n.firstChild; c; c = c.nextSibling) gather(c);
|
|
1331
1344
|
};
|
|
1345
|
+
gather(label);
|
|
1346
|
+
return squash(t);
|
|
1347
|
+
};
|
|
1348
|
+
const collectOutline = () => {
|
|
1349
|
+
const MAX_LINES = 400;
|
|
1332
1350
|
const lines = [];
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1351
|
+
const targets = []; // { el, role, name } per role line, in outline order
|
|
1352
|
+
const walk = (node, depth, inLabel) => {
|
|
1353
|
+
// No early stop at MAX_LINES: the cap bounds what's PRINTED, below \u2014 a truncated outline
|
|
1354
|
+
// must not make a below-the-cap target unresolvable (the walk is a plain DOM read anyway).
|
|
1355
|
+
for (let ch = node.firstChild; ch; ch = ch.nextSibling) {
|
|
1335
1356
|
if (ch.nodeType === 3) {
|
|
1336
1357
|
const t = squash(ch.nodeValue);
|
|
1337
|
-
if (t) lines.push(' '.repeat(depth) + '- text: ' + clip(t));
|
|
1358
|
+
if (t && !inLabel) lines.push(' '.repeat(depth) + '- text: ' + clip(t));
|
|
1338
1359
|
continue;
|
|
1339
1360
|
}
|
|
1340
1361
|
if (ch.nodeType !== 1) continue;
|
|
1341
1362
|
const tag = ch.tagName;
|
|
1342
1363
|
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEMPLATE' || tag === 'NOSCRIPT' || tag === 'LINK' || tag === 'META') continue;
|
|
1343
1364
|
if (hiddenEl(ch)) continue;
|
|
1365
|
+
// An associated label is naming material, not a node: its text is the control's name, so
|
|
1366
|
+
// both its own line and its raw text would be duplicates. An orphan label keeps its line.
|
|
1367
|
+
if (tag === 'LABEL' && ch.control) { walk(ch, depth, true); continue; }
|
|
1344
1368
|
const role = roleOf(ch);
|
|
1345
|
-
if (!role) { walk(ch, depth); continue; } // structural (div/span/\u2026): descend transparently
|
|
1369
|
+
if (!role) { walk(ch, depth, inLabel); continue; } // structural (div/span/\u2026): descend transparently
|
|
1346
1370
|
const attr = (n) => squash(ch.getAttribute(n) || '');
|
|
1347
1371
|
const text = squash(ch.textContent);
|
|
1348
|
-
const
|
|
1372
|
+
const formControl = FORM_TAGS[tag] && role !== 'button';
|
|
1373
|
+
let name;
|
|
1374
|
+
const aria = attr('aria-label');
|
|
1375
|
+
if (aria) name = aria;
|
|
1376
|
+
else if (tag === 'IMG') name = attr('alt') || text;
|
|
1377
|
+
else if (formControl) name = labelName(ch) || attr('placeholder') || attr('title');
|
|
1378
|
+
else if (tag === 'INPUT') name = squash(ch.value) || text; // a button-role input: value is its caption
|
|
1379
|
+
else name = text;
|
|
1380
|
+
const facets = [];
|
|
1381
|
+
if (role === 'heading') {
|
|
1382
|
+
const lv = attr('aria-level') || (/^H[1-6]$/.test(tag) ? tag.charAt(1) : '');
|
|
1383
|
+
if (lv) facets.push('level=' + lv);
|
|
1384
|
+
}
|
|
1385
|
+
if (formControl && role !== 'checkbox' && role !== 'radio') {
|
|
1386
|
+
const v = tag === 'SELECT'
|
|
1387
|
+
? squash(ch.selectedOptions && ch.selectedOptions[0] ? ch.selectedOptions[0].textContent : ch.value)
|
|
1388
|
+
: squash(ch.value);
|
|
1389
|
+
if (v) facets.push('value=' + clip(v));
|
|
1390
|
+
}
|
|
1391
|
+
if ((role === 'checkbox' || role === 'radio') && ch.checked) facets.push('checked');
|
|
1392
|
+
if (ch.disabled === true || attr('aria-disabled') === 'true') facets.push('disabled');
|
|
1349
1393
|
let line = role + (name ? ' "' + clip(name) + '"' : '');
|
|
1350
|
-
const
|
|
1351
|
-
if (lv) line += ' [level=' + lv + ']';
|
|
1394
|
+
for (const f of facets) line += ' [' + f + ']';
|
|
1352
1395
|
lines.push(' '.repeat(depth) + '- ' + line);
|
|
1353
|
-
|
|
1396
|
+
targets.push({ el: ch, role, name: name || '' });
|
|
1397
|
+
if (!(name && name === text)) walk(ch, depth + 1, false); // subtree \u2261 name \u21D2 the one line says it all
|
|
1354
1398
|
}
|
|
1355
1399
|
};
|
|
1356
|
-
walk(document.body, 0);
|
|
1400
|
+
walk(document.body, 0, false);
|
|
1357
1401
|
if (lines.length > MAX_LINES) { lines.length = MAX_LINES; lines.push('- \u2026 (truncated)'); }
|
|
1402
|
+
return { lines, targets };
|
|
1403
|
+
};
|
|
1404
|
+
const snapshot = () => {
|
|
1405
|
+
const { lines } = collectOutline();
|
|
1358
1406
|
return lines.length ? lines.join('\\n') : '(empty page)';
|
|
1359
1407
|
};
|
|
1360
1408
|
|
|
1409
|
+
// tb:click / tb:set (TB-Interrogation-Actuation.md). A target is role + name exactly as the
|
|
1410
|
+
// outline prints them: exact name first, else a unique case-insensitive substring; zero or
|
|
1411
|
+
// several is a reported error, never a guess. Errors throw \u2014 the reply leg carries them to
|
|
1412
|
+
// stderr and a non-zero exit.
|
|
1413
|
+
const describeEl = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/)[0] : '');
|
|
1414
|
+
const resolveTarget = (role, name) => {
|
|
1415
|
+
const ofRole = collectOutline().targets.filter((t) => t.role === role);
|
|
1416
|
+
if (!ofRole.length) throw new Error('no ' + role + ' in the outline \u2014 run tb:snapshot for the vocabulary');
|
|
1417
|
+
const named = ofRole.filter((t) => t.name);
|
|
1418
|
+
const exact = named.filter((t) => t.name === name);
|
|
1419
|
+
const q = name.toLowerCase();
|
|
1420
|
+
const hits = exact.length ? exact : named.filter((t) => t.name.toLowerCase().indexOf(q) !== -1);
|
|
1421
|
+
if (hits.length === 1) return hits[0].el;
|
|
1422
|
+
if (!hits.length) {
|
|
1423
|
+
const unnamed = ofRole.length - named.length;
|
|
1424
|
+
throw new Error('no ' + role + ' named "' + name + '"'
|
|
1425
|
+
+ (named.length ? ' \u2014 ' + role + ' names here: ' + named.slice(0, 8).map((t) => '"' + t.name + '"').join(', ') : '')
|
|
1426
|
+
+ (unnamed ? ' (' + unnamed + ' unnamed \u2014 an aria-label would make them targetable)' : ''));
|
|
1427
|
+
}
|
|
1428
|
+
throw new Error(hits.length + ' ' + role + 's match "' + name + '" (' + hits.map((t) => '"' + t.name + '"').join(', ') + ') \u2014 name one exactly');
|
|
1429
|
+
};
|
|
1430
|
+
// A dead control is a finding, not a no-op \u2014 one sentence, whatever deadened it.
|
|
1431
|
+
const deadControl = (role, name, state) => new Error(role + ' "' + name + '" is ' + state + ' \u2014 a dead control is a finding, not a no-op');
|
|
1432
|
+
const requireEnabled = (el, role, name) => {
|
|
1433
|
+
if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') throw deadControl(role, name, 'disabled');
|
|
1434
|
+
};
|
|
1435
|
+
// The gesture's own preamble: a user scrolls to a control before clicking it (this is not a
|
|
1436
|
+
// scroll verb \u2014 and instant, because a page's \`scroll-behavior: smooth\` would animate while the
|
|
1437
|
+
// geometry reads below saw pre-scroll layout, silently disarming the hit-test). A center the
|
|
1438
|
+
// viewport can't reach, or whose click another element would receive, is a finding.
|
|
1439
|
+
const requireClickable = (el, role, name) => {
|
|
1440
|
+
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
1441
|
+
const r = el.getBoundingClientRect();
|
|
1442
|
+
const p = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
1443
|
+
const top = document.elementFromPoint(p.x, p.y);
|
|
1444
|
+
if (!top) throw new Error(role + ' "' + name + '" is not clickable: its center is outside the viewport even after scrolling it into view (clipped by overflow?)');
|
|
1445
|
+
if (top !== el && !el.contains(top)) throw new Error(role + ' "' + name + '" is not clickable: a click at its center lands on ' + describeEl(top));
|
|
1446
|
+
return p;
|
|
1447
|
+
};
|
|
1448
|
+
// Reply after a double rAF registered post-dispatch (so a rAF-rendering framework's scheduled
|
|
1449
|
+
// frame lands first; the second survives one scheduled from a microtask), RACED against a short
|
|
1450
|
+
// timer: a hidden or occluded document runs no frames at all, and a page the browser isn't
|
|
1451
|
+
// rendering must still answer with its current DOM rather than hang the sender into a false
|
|
1452
|
+
// "stale tab" diagnosis. (The Playwright tier can't cover the hidden case \u2014 Playwright keeps
|
|
1453
|
+
// backgrounded pages rendered.) Either way: the immediate rendered consequence, never the
|
|
1454
|
+
// settled outcome \u2014 settled truth is the caller's follow-up tb:snapshot.
|
|
1455
|
+
const postFrameSnapshot = () => new Promise((resolve) => {
|
|
1456
|
+
let done = false;
|
|
1457
|
+
const finish = () => { if (!done) { done = true; resolve(snapshot()); } };
|
|
1458
|
+
requestAnimationFrame(() => requestAnimationFrame(finish));
|
|
1459
|
+
setTimeout(finish, 150);
|
|
1460
|
+
});
|
|
1461
|
+
const parseActuation = (usage, rest) => {
|
|
1462
|
+
const m = /^\\s*([\\w-]+)\\s+(?:"([^"]*)"|(\\S+))\\s*([\\s\\S]*)$/.exec(rest);
|
|
1463
|
+
if (!m) throw new Error('usage: ' + usage);
|
|
1464
|
+
return { role: m[1], name: m[2] !== undefined ? m[2] : m[3], rest: m[4] };
|
|
1465
|
+
};
|
|
1466
|
+
const actClick = (rest) => {
|
|
1467
|
+
const t = parseActuation('tb:click <role> "<name>"', rest);
|
|
1468
|
+
if (t.rest) throw new Error('usage: tb:click <role> "<name>"');
|
|
1469
|
+
const el = resolveTarget(t.role, t.name);
|
|
1470
|
+
requireEnabled(el, t.role, t.name);
|
|
1471
|
+
const p = requireClickable(el, t.role, t.name);
|
|
1472
|
+
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
|
1473
|
+
const Ctor = type.indexOf('pointer') === 0 && window.PointerEvent ? PointerEvent : MouseEvent;
|
|
1474
|
+
el.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: window, clientX: p.x, clientY: p.y }));
|
|
1475
|
+
}
|
|
1476
|
+
return postFrameSnapshot();
|
|
1477
|
+
};
|
|
1478
|
+
const actSet = (rest) => {
|
|
1479
|
+
const t = parseActuation('tb:set <role> "<name>" = <value>', rest);
|
|
1480
|
+
const vm = /^=\\s*([\\s\\S]*)$/.exec(t.rest.trim());
|
|
1481
|
+
if (!vm) throw new Error('usage: tb:set <role> "<name>" = <value>');
|
|
1482
|
+
let value = vm[1].trim();
|
|
1483
|
+
if (value.length >= 2 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') value = value.slice(1, -1);
|
|
1484
|
+
const el = resolveTarget(t.role, t.name);
|
|
1485
|
+
const tag = el.tagName;
|
|
1486
|
+
if (!FORM_TAGS[tag]) throw new Error('tb:set targets form controls (input, textarea, select) \u2014 for app state beyond a control, author a poke handler (README, "Poking state")');
|
|
1487
|
+
const type = tag === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : '';
|
|
1488
|
+
if (type === 'checkbox' || type === 'radio') throw new Error('a ' + t.role + ' takes tb:click, not a value');
|
|
1489
|
+
requireEnabled(el, t.role, t.name);
|
|
1490
|
+
if (el.readOnly === true) throw deadControl(t.role, t.name, 'readonly');
|
|
1491
|
+
if (tag === 'SELECT') {
|
|
1492
|
+
const opts = Array.from(el.options);
|
|
1493
|
+
const hit = opts.find((o) => o.value === value) || opts.find((o) => squash(o.textContent).toLowerCase() === value.toLowerCase());
|
|
1494
|
+
if (!hit) throw new Error('no option "' + value + '" in ' + t.role + ' "' + t.name + '" \u2014 options: ' + opts.map((o) => '"' + squash(o.textContent) + '"').join(', '));
|
|
1495
|
+
value = hit.value;
|
|
1496
|
+
}
|
|
1497
|
+
// The NATIVE prototype setter, not the instance property: a framework's value tracker
|
|
1498
|
+
// (React) shadows the instance to dedupe events, and a plain assignment goes unseen.
|
|
1499
|
+
const proto = tag === 'SELECT' ? HTMLSelectElement.prototype : tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
1500
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
1501
|
+
if (desc && desc.set) desc.set.call(el, value); else el.value = value;
|
|
1502
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1503
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1504
|
+
return postFrameSnapshot();
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1361
1507
|
// Events channel (dev server only): 'reload' drives hot reload (only emitted when watching),
|
|
1362
1508
|
// 'message' delivers \`typebulb send\` pushes to tb.onMessage. Connect for a CLI-served page (http
|
|
1363
1509
|
// origin, not an embed); a srcdoc embed or a file:// static export has no server, so onMessage just
|
|
@@ -1381,8 +1527,11 @@ ${wCt}
|
|
|
1381
1527
|
} catch (err) { errors.push('reply is not JSON-serializable: ' + errText(err)); }
|
|
1382
1528
|
};
|
|
1383
1529
|
const reservedCall = async () => {
|
|
1384
|
-
|
|
1385
|
-
|
|
1530
|
+
const p = env.payload;
|
|
1531
|
+
if (p === 'tb:snapshot') return snapshot();
|
|
1532
|
+
if (p === 'tb:click' || p.indexOf('tb:click ') === 0) return actClick(p.slice(9));
|
|
1533
|
+
if (p === 'tb:set' || p.indexOf('tb:set ') === 0) return actSet(p.slice(7));
|
|
1534
|
+
throw new Error('unknown reserved message: ' + p + ' (known: tb:snapshot, tb:click, tb:set)');
|
|
1386
1535
|
};
|
|
1387
1536
|
const value = parseMsg(env.payload);
|
|
1388
1537
|
const calls = typeof env.payload === 'string' && env.payload.indexOf('tb:') === 0
|