@atlassian/chippy 0.0.1-beta.1 → 0.0.1-beta.2
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 +142 -54
- package/bin/chippy.mjs +53 -95
- package/bin/pack.mjs +20 -0
- package/bin/skill-dir.mjs +15 -0
- package/lib/avalanche.js +54 -23
- package/lib/avalanche.js.map +2 -2
- package/lib/bootstrap.js +20 -1
- package/lib/bootstrap.js.map +3 -3
- package/lib/extension/avalanche.es.js +173 -34
- package/lib/extension/avalanche.es.js.map +1 -1
- package/lib/extension/chippy-page-bridge.js +164 -44
- package/lib/mcp-skill/README.md +2 -4
- package/lib/mcp-skill/SKILL.md +21 -16
- package/lib/mcp-skill/extension/armed-reload-startup.js +83 -0
- package/lib/mcp-skill/extension/avalanche.es.js +173 -34
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -1
- package/lib/mcp-skill/extension/avalanche.iife.js +173 -34
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -1
- package/lib/mcp-skill/extension/background.js +2 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +164 -44
- package/lib/mcp-skill/extension/content-script.js +23 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +18 -1
- package/lib/mcp-skill/extension/early-bootstrap.js +3 -1
- package/lib/mcp-skill/extension/manifest.json +1 -1
- package/lib/mcp-skill/extension/panel-settings.js +2 -20
- package/lib/mcp-skill/extension/startup-handoff.js +30 -0
- package/lib/mcp-skill/references/README.md +2 -2
- package/lib/mcp-skill/references/avalanche-detector.md +3 -3
- package/lib/mcp-skill/references/browser-runtime-api.md +4 -4
- package/lib/mcp-skill/references/command-output-formats.md +12 -2
- package/lib/mcp-skill/references/command-surface.md +6 -6
- package/lib/mcp-skill/references/mode-a-injection.md +43 -9
- package/lib/mcp-skill/references/playwright-cli-setup.md +5 -5
- package/lib/mcp-skill/references/playwright-probes.md +1 -1
- package/lib/mcp-skill/references/recipes.txt +1 -1
- package/lib/mcp-skill/references/stability-render.md +4 -4
- package/lib/mcp-skill/references/test-mode-playbook.md +2 -1
- package/lib/mcp-skill/references/troubleshooting.md +6 -1
- package/lib/mcp-skill/scripts/chippy-helper-server.js +116 -58
- package/lib/mcp-skill/scripts/chippy.sh +41 -7
- package/lib/mcp-skill/scripts/helper-access.js +20 -6
- package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
- package/lib/mcp-skill/scripts/page-socket-trust.js +81 -0
- package/lib/plugin.js.map +2 -2
- package/lib/storybook/connection-fallback.jsx +1 -1
- package/lib/storybook/manager-owner.js +42 -0
- package/lib/storybook/manager.jsx +22 -0
- package/lib/storybook/preview.js +53 -18
- package/lib/storybook/setup.js +15 -4
- package/lib/types/chippy-bridge/fiber-source.d.ts +14 -3
- package/lib/types/chippy-bridge/react-renderer.d.ts +30 -5
- package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
- package/lib/types/debug-render/core.d.ts +6 -0
- package/package.json +3 -1
|
@@ -11,6 +11,8 @@ const {
|
|
|
11
11
|
} = require('../browser-scripts/playwright-session');
|
|
12
12
|
const { createProbeAnnotationChannel } = require('./probe-annotation-channel');
|
|
13
13
|
const helperAccess = require('./helper-access');
|
|
14
|
+
const pageSocketTrust = require('./page-socket-trust');
|
|
15
|
+
const helperProxy = require('./helper-proxy');
|
|
14
16
|
|
|
15
17
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
16
18
|
|
|
@@ -74,6 +76,21 @@ function waitForPendingResult(pendingMap, requestId, timeoutMs, timeoutError) {
|
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
// The page answered, but without the Avalanche runtime it cannot run the
|
|
80
|
+
// command. Report that as an unavailable runtime instead of an empty result.
|
|
81
|
+
const BRIDGE_MODULE_MISSING_HINT = 'The page has the Chippy bridge but not the Avalanche runtime (avalanche.iife.js). '
|
|
82
|
+
+ 'Load it after inject.js, import /__chippy/inject.mjs instead, or reload a page managed by the extension or proxy.';
|
|
83
|
+
|
|
84
|
+
function pageResultStatus(result) {
|
|
85
|
+
if (!result.ok) return 504;
|
|
86
|
+
return result.result && result.result.code === 'bridge-module-missing' ? 503 : 200;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function withPageResultHint(result) {
|
|
90
|
+
if (pageResultStatus(result) !== 503) return result;
|
|
91
|
+
return { ...result, ok: false, error: result.result.error, hint: BRIDGE_MODULE_MISSING_HINT };
|
|
92
|
+
}
|
|
93
|
+
|
|
77
94
|
function resolvePendingResult(pendingMap, requestId, result) {
|
|
78
95
|
const pending = pendingMap.get(requestId);
|
|
79
96
|
if (!pending) return false;
|
|
@@ -138,10 +155,11 @@ async function requestProbeCommand(clientId, command, timeoutMs = 10_000) {
|
|
|
138
155
|
Math.max(100, Number(timeoutMs || 10_000)),
|
|
139
156
|
'Timeout waiting for probe command result'
|
|
140
157
|
);
|
|
158
|
+
const status = pageResultStatus(result);
|
|
141
159
|
return {
|
|
142
|
-
ok:
|
|
143
|
-
code:
|
|
144
|
-
payload: { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId },
|
|
160
|
+
ok: status === 200,
|
|
161
|
+
code: status,
|
|
162
|
+
payload: { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId },
|
|
145
163
|
};
|
|
146
164
|
}
|
|
147
165
|
|
|
@@ -262,7 +280,7 @@ async function handoffToPlaywright({ clientId, url, probeIds, browserName, headl
|
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
// When active, non-Chippy requests are forwarded to targetPort (proxy mode).
|
|
265
|
-
const proxyState = { active: false, targetPort: null, originalUrl: null, startedAt: null };
|
|
283
|
+
const proxyState = { active: false, targetPort: null, originalUrl: null, target: null, startedAt: null };
|
|
266
284
|
|
|
267
285
|
// ─── Proxy HTML injection ─────────────────────────────────────────────────────
|
|
268
286
|
|
|
@@ -298,15 +316,21 @@ function buildProxyInlineBundle() {
|
|
|
298
316
|
].join('\n');
|
|
299
317
|
}
|
|
300
318
|
|
|
319
|
+
// Without the extension, the proxy does its job: the DevTools hook shim and the
|
|
320
|
+
// Avalanche runtime load before page code, and the page bridge connects last.
|
|
321
|
+
// startup-handoff.js turns an armed reload's stored arm into the startup config
|
|
322
|
+
// before Avalanche evaluates, so the detector installs before React commits.
|
|
323
|
+
const PROXY_EARLY_SCRIPTS = [
|
|
324
|
+
`${CHIPPY_PREFIX}/devtools-hook-shim.js`,
|
|
325
|
+
`${CHIPPY_PREFIX}/startup-handoff.js`,
|
|
326
|
+
`${CHIPPY_PREFIX}/avalanche.iife.js`,
|
|
327
|
+
];
|
|
328
|
+
|
|
301
329
|
function injectIntoHtmlDocument(html) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const i = lower.lastIndexOf(tag);
|
|
307
|
-
if (i >= 0) return `${html.slice(0, i)}${bundle}\n${html.slice(i)}`;
|
|
308
|
-
}
|
|
309
|
-
return `${html}\n${bundle}`;
|
|
330
|
+
return helperProxy.injectProxyDocument(html, {
|
|
331
|
+
earlyScripts: PROXY_EARLY_SCRIPTS,
|
|
332
|
+
bridgeBundle: buildProxyInlineBundle(),
|
|
333
|
+
});
|
|
310
334
|
}
|
|
311
335
|
|
|
312
336
|
function stripHopHeaders(headers, bodyBuffer, stripCsp = false) {
|
|
@@ -384,6 +408,15 @@ const CORS_HEADERS = {
|
|
|
384
408
|
'Cache-Control': 'no-store',
|
|
385
409
|
};
|
|
386
410
|
|
|
411
|
+
// The detector's canonical modes. An unknown string would reach
|
|
412
|
+
// createAvalancheDetector and leave its mode state unset.
|
|
413
|
+
const DETECTION_MODES = ['sources', 'full', 'stats', 'amplifiers'];
|
|
414
|
+
function detectionModeError(options) {
|
|
415
|
+
const mode = options && typeof options === 'object' ? options.detectionMode : undefined;
|
|
416
|
+
if (mode === undefined || DETECTION_MODES.includes(mode)) return null;
|
|
417
|
+
return `detectionMode must be one of ${DETECTION_MODES.join(', ')} (got ${JSON.stringify(mode)})`;
|
|
418
|
+
}
|
|
419
|
+
|
|
387
420
|
function json(res, code, payload) {
|
|
388
421
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
389
422
|
res.end(JSON.stringify(payload, null, 2));
|
|
@@ -633,20 +666,26 @@ function isChippyPath(p) { return p === CHIPPY_PREFIX || p.startsWith(`${CHIPPY_
|
|
|
633
666
|
|
|
634
667
|
function getProxySummary() {
|
|
635
668
|
if (!proxyState.active) return null;
|
|
636
|
-
return {
|
|
669
|
+
return {
|
|
670
|
+
active: true,
|
|
671
|
+
targetPort: proxyState.targetPort,
|
|
672
|
+
targetHost: proxyState.target?.hostHeader || null,
|
|
673
|
+
originalUrl: proxyState.originalUrl,
|
|
674
|
+
injectTarget: getProxyInjectTarget(),
|
|
675
|
+
startedAt: proxyState.startedAt,
|
|
676
|
+
};
|
|
637
677
|
}
|
|
638
678
|
|
|
639
679
|
function startProxy(targetPort, originalUrl) {
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
console.log(`[proxy] enabled -> port=${p}${originalUrl ? ` url=${originalUrl}` : ''}`);
|
|
680
|
+
const target = helperProxy.resolveProxyTarget(targetPort, originalUrl);
|
|
681
|
+
Object.assign(proxyState, { active: true, targetPort: target.port, originalUrl: originalUrl || null, target, startedAt: nowIso() });
|
|
682
|
+
console.log(`[proxy] enabled -> ${target.hostHeader}${originalUrl ? ` url=${originalUrl}` : ''}`);
|
|
644
683
|
return getProxySummary();
|
|
645
684
|
}
|
|
646
685
|
|
|
647
686
|
function stopProxy() {
|
|
648
687
|
const prev = getProxySummary();
|
|
649
|
-
Object.assign(proxyState, { active: false, targetPort: null, originalUrl: null, startedAt: null });
|
|
688
|
+
Object.assign(proxyState, { active: false, targetPort: null, originalUrl: null, target: null, startedAt: null });
|
|
650
689
|
console.log(`[proxy] disabled${prev?.targetPort ? ` (was ${prev.targetPort})` : ''}`);
|
|
651
690
|
return { active: false };
|
|
652
691
|
}
|
|
@@ -661,11 +700,11 @@ function forwardHeaders(headers, targetHost) {
|
|
|
661
700
|
}
|
|
662
701
|
|
|
663
702
|
function proxyRequest(req, res) {
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
console.log(`[proxy] ${req.method} ${req.url} -> ${url}`);
|
|
703
|
+
const target = proxyState.target;
|
|
704
|
+
console.log(`[proxy] ${req.method} ${req.url} -> http://${target.hostHeader}${req.url}`);
|
|
667
705
|
|
|
668
|
-
const
|
|
706
|
+
const options = helperProxy.upstreamRequestOptions(target, req, forwardHeaders(req.headers, target.hostHeader));
|
|
707
|
+
const upstream = http.request(options, (upRes) => {
|
|
669
708
|
console.log(`[proxy] ${req.method} ${req.url} <- ${upRes.statusCode}`);
|
|
670
709
|
if (!shouldInjectIntoProxyHtml(req, upRes)) {
|
|
671
710
|
res.writeHead(upRes.statusCode || 502, upRes.statusMessage, upRes.headers);
|
|
@@ -701,12 +740,13 @@ function proxyRequest(req, res) {
|
|
|
701
740
|
}
|
|
702
741
|
|
|
703
742
|
function proxyUpgrade(req, socket, head) {
|
|
704
|
-
const
|
|
705
|
-
console.log(`[proxy-ws] ${req.url} -> ${
|
|
706
|
-
const upstream = http.request(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
743
|
+
const target = proxyState.target;
|
|
744
|
+
console.log(`[proxy-ws] ${req.url} -> ${target.hostHeader}`);
|
|
745
|
+
const upstream = http.request(helperProxy.upstreamRequestOptions(target, { url: req.url, method: 'GET' }, {
|
|
746
|
+
...forwardHeaders(req.headers, target.hostHeader),
|
|
747
|
+
connection: req.headers.connection || 'Upgrade',
|
|
748
|
+
upgrade: req.headers.upgrade || 'websocket',
|
|
749
|
+
}));
|
|
710
750
|
upstream.on('upgrade', (upRes, upSock, upHead) => {
|
|
711
751
|
const lines = [`HTTP/1.1 ${upRes.statusCode || 101} ${upRes.statusMessage || 'Switching Protocols'}`];
|
|
712
752
|
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
@@ -820,8 +860,11 @@ function updateClientFromMessage(client, msg) {
|
|
|
820
860
|
|
|
821
861
|
// ─── WebSocket attachment ─────────────────────────────────────────────────────
|
|
822
862
|
|
|
823
|
-
|
|
863
|
+
const clientIdBindings = pageSocketTrust.createClientIdBindings();
|
|
864
|
+
|
|
865
|
+
function attachSocket(socket, socketOrigin) {
|
|
824
866
|
const id = crypto.randomUUID();
|
|
867
|
+
clientIdBindings.claim(id, socketOrigin);
|
|
825
868
|
const client = {
|
|
826
869
|
socket, clientId: id,
|
|
827
870
|
connectedAt: nowIso(), lastMessageAt: nowIso(), lastHelloAt: null,
|
|
@@ -848,6 +891,15 @@ function attachSocket(socket) {
|
|
|
848
891
|
if (frame.opcode !== 0x1) continue;
|
|
849
892
|
try {
|
|
850
893
|
const msg = JSON.parse(frame.payload.toString('utf8'));
|
|
894
|
+
const rejected = pageSocketTrust.checkPageMessage({
|
|
895
|
+
bindings: clientIdBindings, socketOrigin, currentClientId: client.clientId, msg,
|
|
896
|
+
});
|
|
897
|
+
if (rejected) {
|
|
898
|
+
console.warn(`[ws] rejected message from origin=${socketOrigin || 'none'}: ${rejected}`);
|
|
899
|
+
wsSend(socket, JSON.stringify({ type: 'error', message: rejected }));
|
|
900
|
+
socket.destroy();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
851
903
|
const prevId = client.clientId;
|
|
852
904
|
updateClientFromMessage(client, msg);
|
|
853
905
|
if (client.clientId !== prevId) { clients.delete(prevId); clients.set(client.clientId, client); }
|
|
@@ -871,6 +923,8 @@ function attachSocket(socket) {
|
|
|
871
923
|
ack: true,
|
|
872
924
|
action: msg.action || 'page-reload',
|
|
873
925
|
clientId: client.clientId,
|
|
926
|
+
...(typeof msg.earlyStartup === 'string' ? { earlyStartup: msg.earlyStartup } : {}),
|
|
927
|
+
...(msg.ok === false && typeof msg.error === 'string' ? { error: msg.error } : {}),
|
|
874
928
|
});
|
|
875
929
|
}
|
|
876
930
|
} catch (e) {
|
|
@@ -1091,6 +1145,11 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1091
1145
|
return true;
|
|
1092
1146
|
}
|
|
1093
1147
|
|
|
1148
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/startup-handoff.js`) {
|
|
1149
|
+
serveExtensionFile(res, 'startup-handoff.js', 'application/javascript; charset=utf-8');
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1094
1153
|
if (method === 'GET' && p === `${CHIPPY_PREFIX}/avalanche.iife.js`) {
|
|
1095
1154
|
serveExtensionFile(res, 'avalanche.iife.js', 'application/javascript; charset=utf-8', (src) =>
|
|
1096
1155
|
`${src}\n;(() => {\n const api = globalThis.__AVALANCHE__;\n if (api && api.default && typeof api.default === 'object') {\n Object.assign(api, api.default);\n }\n})();\n`);
|
|
@@ -1199,6 +1258,8 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1199
1258
|
const clientId = p.split('/')[4];
|
|
1200
1259
|
try {
|
|
1201
1260
|
const body = await parseJsonBody(req);
|
|
1261
|
+
const modeError = detectionModeError(body?.options);
|
|
1262
|
+
if (modeError) { json(res, 400, { ok: false, error: modeError }); return true; }
|
|
1202
1263
|
const result = await requestAvalanche(clientId, body);
|
|
1203
1264
|
json(res, result.code, result.payload);
|
|
1204
1265
|
} catch (e) {
|
|
@@ -1389,9 +1450,8 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1389
1450
|
const autoStart = body.autoStart !== false;
|
|
1390
1451
|
if (autoStart) {
|
|
1391
1452
|
const incomingOptions = body.options && typeof body.options === 'object' ? body.options : {};
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
// reach createAvalancheDetector and leave its internal mode state unset.
|
|
1453
|
+
const modeError = detectionModeError(incomingOptions);
|
|
1454
|
+
if (modeError) { json(res, 400, { ok: false, error: modeError }); return true; }
|
|
1395
1455
|
const arm = {
|
|
1396
1456
|
autoStart: true,
|
|
1397
1457
|
options: {
|
|
@@ -1439,10 +1499,22 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1439
1499
|
const action = resolved.client.integration?.kind === 'storybook'
|
|
1440
1500
|
? 'storybook-force-remount'
|
|
1441
1501
|
: 'reload';
|
|
1442
|
-
|
|
1502
|
+
// An armed page gets its arm with the reload or remount, so Avalanche is
|
|
1503
|
+
// installed before React's first commit.
|
|
1504
|
+
const pageOrigin = normalizeArmOrigin(resolved.client.page?.url || '');
|
|
1505
|
+
const arm = armedAvalancheByClient.get(resolved.clientId) || (pageOrigin && armedAvalanche.get(pageOrigin)) || null;
|
|
1506
|
+
const command = arm ? { type: action, requestId, avalanche: arm } : { type: action, requestId };
|
|
1507
|
+
const sent = sendToClient(resolved.clientId, command);
|
|
1443
1508
|
if (!sent.ok) { json(res, sent.code, { ok: false, error: sent.error }); return true; }
|
|
1444
1509
|
const ack = await waitForPendingResult(pendingReloadAck, requestId, 1500, 'Timeout waiting for reload ack');
|
|
1445
|
-
|
|
1510
|
+
// 504: no ack arrived. 409: the page answered but could not reload.
|
|
1511
|
+
const refused = ack.ok && ack.result?.ok === false;
|
|
1512
|
+
const status = !ack.ok ? 504 : refused ? 409 : 200;
|
|
1513
|
+
json(res, status, {
|
|
1514
|
+
...ack,
|
|
1515
|
+
...(refused ? { ok: false, error: ack.result.error || 'The page did not reload' } : {}),
|
|
1516
|
+
reloading: sent.clientId, resolvedFrom: sent.resolvedFrom, requestId,
|
|
1517
|
+
});
|
|
1446
1518
|
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1447
1519
|
return true;
|
|
1448
1520
|
}
|
|
@@ -1497,7 +1569,7 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1497
1569
|
if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
|
|
1498
1570
|
// Wait for computed-style-result from the page
|
|
1499
1571
|
const result = await waitForPendingResult(pendingComputedStyle, requestId, timeoutMs, 'Timeout waiting for computed style result');
|
|
1500
|
-
json(res, result
|
|
1572
|
+
json(res, pageResultStatus(result), { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
|
|
1501
1573
|
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1502
1574
|
return true;
|
|
1503
1575
|
}
|
|
@@ -1615,7 +1687,7 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1615
1687
|
const delivered = sendToClient(clientId, wsCommand);
|
|
1616
1688
|
if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
|
|
1617
1689
|
const result = await waitForPendingResult(pendingEvalProbe, requestId, timeoutMs, 'Timeout waiting for eval-probe result');
|
|
1618
|
-
json(res, result
|
|
1690
|
+
json(res, pageResultStatus(result), { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
|
|
1619
1691
|
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1620
1692
|
return true;
|
|
1621
1693
|
}
|
|
@@ -1633,22 +1705,6 @@ async function handleChippyHttp(req, res, url) {
|
|
|
1633
1705
|
return true;
|
|
1634
1706
|
}
|
|
1635
1707
|
|
|
1636
|
-
// Proxy bootstrap (browser redirect)
|
|
1637
|
-
if (method === 'GET' && p === `${CHIPPY_PREFIX}/proxy/go`) {
|
|
1638
|
-
try {
|
|
1639
|
-
const targetPort = url.searchParams.get('targetPort');
|
|
1640
|
-
const originalUrl = url.searchParams.get('originalUrl') || null;
|
|
1641
|
-
const redirectTo = url.searchParams.get('redirectTo') || '/';
|
|
1642
|
-
startProxy(targetPort, originalUrl);
|
|
1643
|
-
console.log(`[proxy-bootstrap] redirect -> ${redirectTo}`);
|
|
1644
|
-
text(res, 200, `<!doctype html><meta http-equiv="refresh" content="0;url=${redirectTo.replace(/"/g, '"')}"><title>Chippy</title><p>Redirecting…</p>`, 'text/html; charset=utf-8');
|
|
1645
|
-
} catch (e) {
|
|
1646
|
-
console.error(`[proxy-bootstrap] failed: ${e.message}`);
|
|
1647
|
-
text(res, 400, `Could not start proxy: ${e.message}`);
|
|
1648
|
-
}
|
|
1649
|
-
return true;
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
1708
|
return false;
|
|
1653
1709
|
}
|
|
1654
1710
|
|
|
@@ -1657,7 +1713,7 @@ function handleChippyUpgrade(req, socket) {
|
|
|
1657
1713
|
if (!key) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; }
|
|
1658
1714
|
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
1659
1715
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
1660
|
-
attachSocket(socket);
|
|
1716
|
+
attachSocket(socket, pageSocketTrust.handshakeOrigin(req.headers));
|
|
1661
1717
|
}
|
|
1662
1718
|
|
|
1663
1719
|
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
@@ -1666,10 +1722,11 @@ function chippyRoute(pathname) {
|
|
|
1666
1722
|
return isChippyPath(pathname) ? pathname.slice(CHIPPY_PREFIX.length) : null;
|
|
1667
1723
|
}
|
|
1668
1724
|
|
|
1669
|
-
function rejectUnauthorized(req,
|
|
1725
|
+
function rejectUnauthorized(req, url) {
|
|
1670
1726
|
return helperAccess.authorizeRequest({
|
|
1671
1727
|
method: req.method || 'GET',
|
|
1672
|
-
route: chippyRoute(pathname),
|
|
1728
|
+
route: chippyRoute(url.pathname),
|
|
1729
|
+
query: url.searchParams,
|
|
1673
1730
|
headers: req.headers,
|
|
1674
1731
|
token: helperToken,
|
|
1675
1732
|
loopback: loopbackBind,
|
|
@@ -1678,7 +1735,7 @@ function rejectUnauthorized(req, pathname) {
|
|
|
1678
1735
|
|
|
1679
1736
|
const server = http.createServer(async (req, res) => {
|
|
1680
1737
|
const url = new URL(req.url, baseUrlFromReq(req));
|
|
1681
|
-
const denied = rejectUnauthorized(req, url
|
|
1738
|
+
const denied = rejectUnauthorized(req, url);
|
|
1682
1739
|
if (denied) { json(res, denied.status, { ok: false, error: denied.error }); return; }
|
|
1683
1740
|
if (isChippyPath(url.pathname)) {
|
|
1684
1741
|
if (!(await handleChippyHttp(req, res, url))) json(res, 404, { ok: false, error: 'Chippy route not found' });
|
|
@@ -1689,8 +1746,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1689
1746
|
});
|
|
1690
1747
|
|
|
1691
1748
|
server.on('upgrade', (req, socket, head) => {
|
|
1692
|
-
const
|
|
1693
|
-
const
|
|
1749
|
+
const upgradeUrl = new URL(req.url, 'http://127.0.0.1');
|
|
1750
|
+
const p = upgradeUrl.pathname;
|
|
1751
|
+
const denied = rejectUnauthorized(req, upgradeUrl);
|
|
1694
1752
|
if (denied) { socket.write(`HTTP/1.1 ${denied.status} ${denied.status === 401 ? 'Unauthorized' : 'Forbidden'}\r\n\r\n`); socket.destroy(); return; }
|
|
1695
1753
|
if (p === `${CHIPPY_PREFIX}/ws`) { handleChippyUpgrade(req, socket); return; }
|
|
1696
1754
|
if (proxyState.active && !isChippyPath(p)) { proxyUpgrade(req, socket, head); return; }
|
|
@@ -179,6 +179,22 @@ stop_helper() {
|
|
|
179
179
|
return 1
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
# The helper explains failures in its JSON body (`error`, `hint`); show them
|
|
183
|
+
# instead of leaving only the HTTP status.
|
|
184
|
+
print_helper_error_body() {
|
|
185
|
+
[[ -s "$1" ]] || return 0
|
|
186
|
+
node -e '
|
|
187
|
+
const fs = require("fs");
|
|
188
|
+
const raw = fs.readFileSync(process.argv[1], "utf8");
|
|
189
|
+
let body;
|
|
190
|
+
try { body = JSON.parse(raw); } catch { process.stdout.write(` helper: ${raw.trim().slice(0, 500)}\n`); process.exit(0); }
|
|
191
|
+
for (const key of ["error", "hint", "next"]) {
|
|
192
|
+
const value = body && body[key];
|
|
193
|
+
if (value) process.stdout.write(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}\n`);
|
|
194
|
+
}
|
|
195
|
+
' "$1" 2>/dev/null || true
|
|
196
|
+
}
|
|
197
|
+
|
|
182
198
|
run_curl() {
|
|
183
199
|
local method="$1"
|
|
184
200
|
local url="$2"
|
|
@@ -245,6 +261,7 @@ run_curl() {
|
|
|
245
261
|
if [[ -n "$stderr_text" ]]; then
|
|
246
262
|
echo " → curl: $stderr_text" >&2
|
|
247
263
|
fi
|
|
264
|
+
print_helper_error_body "$response_file" >&2
|
|
248
265
|
rm -f "$response_file" "$stderr_file"
|
|
249
266
|
if [[ "$http_code" =~ ^[0-9]+$ && "$http_code" -ge 400 ]]; then
|
|
250
267
|
return 22
|
|
@@ -252,6 +269,16 @@ run_curl() {
|
|
|
252
269
|
return "$code"
|
|
253
270
|
}
|
|
254
271
|
|
|
272
|
+
require_detection_mode() {
|
|
273
|
+
case "$2" in
|
|
274
|
+
sources|full|stats|amplifiers) ;;
|
|
275
|
+
*)
|
|
276
|
+
echo "$1 detectionMode must be sources, full, stats, or amplifiers (got '$2')" >&2
|
|
277
|
+
exit 1
|
|
278
|
+
;;
|
|
279
|
+
esac
|
|
280
|
+
}
|
|
281
|
+
|
|
255
282
|
post_json() {
|
|
256
283
|
local path="$1"
|
|
257
284
|
local body="$2"
|
|
@@ -303,6 +330,7 @@ case "$cmd" in
|
|
|
303
330
|
;;
|
|
304
331
|
inject-url)
|
|
305
332
|
echo "Import integration is document-scoped: a full reload/navigation removes Chippy and requires the user or automation to inject it again." >&2
|
|
333
|
+
echo "The classic inject.js installs Chippy only; load $BASE_URL/avalanche.iife.js after it for bridge and Avalanche commands, or import $BASE_URL/inject.mjs instead." >&2
|
|
306
334
|
printf '%s/inject.js\n' "$BASE_URL"
|
|
307
335
|
;;
|
|
308
336
|
recipes)
|
|
@@ -964,6 +992,7 @@ case "$cmd" in
|
|
|
964
992
|
echo "avalanche-install requires <clientId> [detectionMode]" >&2
|
|
965
993
|
exit 1
|
|
966
994
|
fi
|
|
995
|
+
require_detection_mode avalanche-install "$detection_mode"
|
|
967
996
|
body="{\"action\":\"install\",\"options\":{\"captureFibers\":true,\"detectionMode\":\"$detection_mode\",\"historySize\":1000}}"
|
|
968
997
|
post_avalanche "$client_id" "$body"
|
|
969
998
|
;;
|
|
@@ -974,6 +1003,7 @@ case "$cmd" in
|
|
|
974
1003
|
echo "avalanche-recapture requires <clientId> [detectionMode]" >&2
|
|
975
1004
|
exit 1
|
|
976
1005
|
fi
|
|
1006
|
+
require_detection_mode avalanche-recapture "$detection_mode"
|
|
977
1007
|
body="{\"action\":\"install\",\"options\":{\"captureFibers\":true,\"detectionMode\":\"$detection_mode\",\"historySize\":1000}}"
|
|
978
1008
|
post_avalanche "$client_id" "$body" >/dev/null
|
|
979
1009
|
post_avalanche "$client_id" '{"action":"clear"}' >/dev/null
|
|
@@ -1083,10 +1113,7 @@ case "$cmd" in
|
|
|
1083
1113
|
echo "avalanche-arm captureFibers must be true or false" >&2
|
|
1084
1114
|
exit 1
|
|
1085
1115
|
fi
|
|
1086
|
-
|
|
1087
|
-
echo "avalanche-arm detectionMode must be sources, full, stats, or amplifiers" >&2
|
|
1088
|
-
exit 1
|
|
1089
|
-
fi
|
|
1116
|
+
require_detection_mode avalanche-arm "$detection_mode"
|
|
1090
1117
|
body="{\"clientId\":$(json_escape "$client_id"),\"origin\":$(json_escape "$origin"),\"autoStart\":true,\"max\":$max_commits,\"options\":{\"captureFibers\":$capture_fibers,\"detectionMode\":\"$detection_mode\"}}"
|
|
1091
1118
|
post_json "/api/avalanche-arm" "$body" >/dev/null
|
|
1092
1119
|
# Reset first (clear prior capture), then install detector — no reload needed
|
|
@@ -1142,7 +1169,15 @@ case "$cmd" in
|
|
|
1142
1169
|
"$0" page-reload "$client_id"
|
|
1143
1170
|
echo >&2
|
|
1144
1171
|
echo "== wait ==" >&2
|
|
1145
|
-
"$0" page-wait-reload "$client_id" "$timeout_ms"
|
|
1172
|
+
wait_result=$("$0" page-wait-reload "$client_id" "$timeout_ms")
|
|
1173
|
+
printf '%s\n' "$wait_result"
|
|
1174
|
+
if ! printf '%s' "$wait_result" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.exit(JSON.parse(s).reloaded===true?0:1)}catch{process.exit(1)}})'; then
|
|
1175
|
+
echo "reload-with-avalanche: the page did not reconnect within ${timeout_ms}ms after the reload." >&2
|
|
1176
|
+
echo " meaning: Chippy was not loaded again in the new document, so nothing captured its commits" >&2
|
|
1177
|
+
echo " next: with Mode A, load the runtimes as init scripts (installHelpers(page, { init: true })) so they return on reload," >&2
|
|
1178
|
+
echo " or skip the reload: avalanche-install, then trigger the interaction and read avalanche-status" >&2
|
|
1179
|
+
exit 1
|
|
1180
|
+
fi
|
|
1146
1181
|
if [[ "$settle_ms" -gt 0 ]]; then
|
|
1147
1182
|
echo >&2
|
|
1148
1183
|
echo "== settle (${settle_ms}ms) ==" >&2
|
|
@@ -1187,8 +1222,7 @@ case "$cmd" in
|
|
|
1187
1222
|
};
|
|
1188
1223
|
process.stdout.write(JSON.stringify(body));
|
|
1189
1224
|
' "$url" "$probe_json" "$browser_name" "$headless") || exit 1
|
|
1190
|
-
|
|
1191
|
-
"$BASE_URL/api/pages/$client_id/handoff-to-playwright" --data-binary "$body"
|
|
1225
|
+
post_json "/api/pages/$client_id/handoff-to-playwright" "$body"
|
|
1192
1226
|
;;
|
|
1193
1227
|
proxy-state)
|
|
1194
1228
|
curl "${CURL_OPTS[@]}" "$BASE_URL/api/proxy/state"
|
|
@@ -29,16 +29,15 @@ const PUBLIC_GET_PATHS = new Set([
|
|
|
29
29
|
'/avalanche.es.js.map',
|
|
30
30
|
'/avalanche.iife.js.map',
|
|
31
31
|
'/devtools-hook-shim.js',
|
|
32
|
-
'/
|
|
33
|
-
'/proxy/go',
|
|
32
|
+
'/startup-handoff.js',
|
|
34
33
|
'/ws',
|
|
35
34
|
]);
|
|
36
35
|
|
|
37
36
|
// Routes the published browser extension calls from its service worker. The
|
|
38
37
|
// extension sends no token, so these stay open to extension origins and
|
|
39
|
-
// non-browser clients on every bind. They list pages and arm capture;
|
|
40
|
-
// not run page code.
|
|
41
|
-
const EXTENSION_ROUTES = new Set(['GET /api/pages', 'POST /api/avalanche-arm']);
|
|
38
|
+
// non-browser clients on every bind. They list pages and read or arm capture;
|
|
39
|
+
// they do not run page code.
|
|
40
|
+
const EXTENSION_ROUTES = new Set(['GET /api/pages', 'GET /api/avalanche-arm', 'POST /api/avalanche-arm']);
|
|
42
41
|
|
|
43
42
|
function stateDirectory(env = process.env) {
|
|
44
43
|
return env.CHIPPY_STATE_DIR || path.join(os.homedir(), '.chippy');
|
|
@@ -109,6 +108,19 @@ function isExtensionOrigin(origin) {
|
|
|
109
108
|
return typeof origin === 'string' && /^(chrome|moz)-extension:\/\//.test(origin);
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
function originOf(value) {
|
|
112
|
+
try { return new URL(value).origin; } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A page may ask whether its own origin is armed (the Storybook addon does this
|
|
116
|
+
// at preview start). It may not list other origins or look up client ids.
|
|
117
|
+
function isSelfArmLookup(method, route, origin, query) {
|
|
118
|
+
if (method !== 'GET' || route !== '/api/avalanche-arm' || !query || typeof origin !== 'string') return false;
|
|
119
|
+
if (query.get('clientId')) return false;
|
|
120
|
+
const asked = query.get('origin');
|
|
121
|
+
return !!asked && originOf(asked) === origin;
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
function tokensMatch(expected, received) {
|
|
113
125
|
if (typeof received !== 'string' || !expected) return false;
|
|
114
126
|
const a = Buffer.from(expected);
|
|
@@ -117,7 +129,8 @@ function tokensMatch(expected, received) {
|
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
// Returns null when the request may proceed, otherwise { status, error }.
|
|
120
|
-
|
|
132
|
+
// `query` is the request's URLSearchParams.
|
|
133
|
+
function authorizeRequest({ method, route, headers, token, loopback, query = null, env = process.env }) {
|
|
121
134
|
if (loopback && !isAllowedHost(headers.host, env)) {
|
|
122
135
|
return { status: 403, error: 'Host header is not a loopback host; set CHIPPY_HELPER_ALLOWED_HOSTS to allow it' };
|
|
123
136
|
}
|
|
@@ -127,6 +140,7 @@ function authorizeRequest({ method, route, headers, token, loopback, env = proce
|
|
|
127
140
|
if (kind === 'extension') {
|
|
128
141
|
const origin = headers.origin;
|
|
129
142
|
if (origin === undefined || isExtensionOrigin(origin)) return null;
|
|
143
|
+
if (isSelfArmLookup(method, route, origin, query)) return null;
|
|
130
144
|
}
|
|
131
145
|
return { status: 401, error: 'Missing or invalid X-Chippy-Token header' };
|
|
132
146
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Pure helpers for the helper's proxy mode: where to forward requests, and how
|
|
3
|
+
// to put Chippy into the proxied HTML document.
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
|
6
|
+
|
|
7
|
+
function isLoopbackHostname(hostname) {
|
|
8
|
+
const name = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
9
|
+
return LOOPBACK_HOSTNAMES.has(name) || /^127(?:\.\d{1,3}){3}$/.test(name) || name.endsWith('.localhost');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The proxy forwards only to this machine. Dev servers bind `localhost`,
|
|
13
|
+
// `127.0.0.1`, or `::1` depending on the tool and platform (Vite on macOS binds
|
|
14
|
+
// `::1`), so the original page URL decides the loopback name when it names one.
|
|
15
|
+
// `localhost` is the default because it resolves to both address families.
|
|
16
|
+
function resolveProxyTarget(targetPort, originalUrl) {
|
|
17
|
+
const port = Number(targetPort);
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid target port: ${targetPort}`);
|
|
19
|
+
let hostname = 'localhost';
|
|
20
|
+
if (originalUrl) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try { parsed = new URL(String(originalUrl)); } catch { throw new Error(`Invalid originalUrl: ${originalUrl}`); }
|
|
23
|
+
const named = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
24
|
+
if (!isLoopbackHostname(named)) {
|
|
25
|
+
throw new Error(`Proxy targets must be on this machine; originalUrl host ${parsed.hostname} is not a loopback name`);
|
|
26
|
+
}
|
|
27
|
+
hostname = named;
|
|
28
|
+
}
|
|
29
|
+
const hostHeader = `${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`;
|
|
30
|
+
return { hostname, port, hostHeader };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Options for http.request. `autoSelectFamily` tries every address `localhost`
|
|
34
|
+
// resolves to, so a server listening on only one family is still reached.
|
|
35
|
+
function upstreamRequestOptions(target, req, headers) {
|
|
36
|
+
return {
|
|
37
|
+
host: target.hostname,
|
|
38
|
+
port: target.port,
|
|
39
|
+
path: req.url,
|
|
40
|
+
method: req.method,
|
|
41
|
+
headers,
|
|
42
|
+
autoSelectFamily: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HEAD_OPEN = /<head(?:\s[^>]*)?>/i;
|
|
47
|
+
const HTML_OPEN = /<html(?:\s[^>]*)?>/i;
|
|
48
|
+
const DOCTYPE = /^\s*<!doctype[^>]*>/i;
|
|
49
|
+
|
|
50
|
+
// Classic scripts at the start of <head> run before the document's module
|
|
51
|
+
// scripts, so the DevTools hook exists when react-dom evaluates.
|
|
52
|
+
function insertAtHeadStart(html, markup) {
|
|
53
|
+
for (const pattern of [HEAD_OPEN, HTML_OPEN, DOCTYPE]) {
|
|
54
|
+
const match = pattern.exec(html);
|
|
55
|
+
if (match) {
|
|
56
|
+
const at = match.index + match[0].length;
|
|
57
|
+
return `${html.slice(0, at)}\n${markup}\n${html.slice(at)}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return `${markup}\n${html}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function insertAtBodyEnd(html, markup) {
|
|
64
|
+
const lower = html.toLowerCase();
|
|
65
|
+
for (const tag of ['</body>', '</html>']) {
|
|
66
|
+
const i = lower.lastIndexOf(tag);
|
|
67
|
+
if (i >= 0) return `${html.slice(0, i)}${markup}\n${html.slice(i)}`;
|
|
68
|
+
}
|
|
69
|
+
return `${html}\n${markup}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `earlyScripts` are helper-relative script paths that must run before page
|
|
73
|
+
// code; `bridgeBundle` is inline markup that connects the page to the helper.
|
|
74
|
+
function injectProxyDocument(html, { earlyScripts = [], bridgeBundle = '' } = {}) {
|
|
75
|
+
const early = earlyScripts
|
|
76
|
+
.map((src) => `<script data-chippy-proxy-early="true" src="${src}"></script>`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
const withEarly = early ? insertAtHeadStart(html, early) : html;
|
|
79
|
+
return bridgeBundle ? insertAtBodyEnd(withEarly, bridgeBundle) : withEarly;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
injectProxyDocument,
|
|
84
|
+
isLoopbackHostname,
|
|
85
|
+
resolveProxyTarget,
|
|
86
|
+
upstreamRequestOptions,
|
|
87
|
+
};
|