@atlassian/chippy 0.0.0-stage → 0.0.1-beta.1
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/LICENSE +201 -0
- package/README.md +188 -2
- package/bin/chippy.mjs +134 -0
- package/bin/pack.mjs +184 -0
- package/lib/avalanche.js +2570 -0
- package/lib/avalanche.js.map +7 -0
- package/lib/bootstrap.d.ts +1 -0
- package/lib/bootstrap.js +51 -0
- package/lib/bootstrap.js.map +7 -0
- package/lib/browser.js +15 -0
- package/lib/browser.js.map +7 -0
- package/lib/extension/avalanche.es.js +5396 -0
- package/lib/extension/avalanche.es.js.map +1 -0
- package/lib/extension/chippy-page-bridge.js +1833 -0
- package/lib/extension/inspect-react.js +216 -0
- package/lib/extension/layout-lab.js +1453 -0
- package/lib/extension/panel-dom.js +90 -0
- package/lib/extension/resolve-probes.js +342 -0
- package/lib/mcp-skill/LICENSE +201 -0
- package/lib/mcp-skill/README.md +150 -0
- package/lib/mcp-skill/SKILL.md +330 -0
- package/lib/mcp-skill/browser-scripts/playwright-probes.js +368 -0
- package/lib/mcp-skill/browser-scripts/playwright-session.js +114 -0
- package/lib/mcp-skill/extension/avalanche.es.js +5396 -0
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -0
- package/lib/mcp-skill/extension/avalanche.iife.js +5397 -0
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -0
- package/lib/mcp-skill/extension/background.js +323 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +1833 -0
- package/lib/mcp-skill/extension/content-script.js +167 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +68 -0
- package/lib/mcp-skill/extension/early-bootstrap.js +47 -0
- package/lib/mcp-skill/extension/icon-128.png +0 -0
- package/lib/mcp-skill/extension/icon-16.png +0 -0
- package/lib/mcp-skill/extension/icon-32.png +0 -0
- package/lib/mcp-skill/extension/icon-48.png +0 -0
- package/lib/mcp-skill/extension/inspect-react.js +216 -0
- package/lib/mcp-skill/extension/layout-lab.js +1453 -0
- package/lib/mcp-skill/extension/logo.svg +14 -0
- package/lib/mcp-skill/extension/manifest.json +69 -0
- package/lib/mcp-skill/extension/panel-dom.js +90 -0
- package/lib/mcp-skill/extension/panel-settings.js +191 -0
- package/lib/mcp-skill/extension/popup.html +41 -0
- package/lib/mcp-skill/extension/popup.js +80 -0
- package/lib/mcp-skill/extension/probe-descriptor.js +241 -0
- package/lib/mcp-skill/extension/resolve-probes.js +342 -0
- package/lib/mcp-skill/package.json +1 -0
- package/lib/mcp-skill/references/README.md +56 -0
- package/lib/mcp-skill/references/active-stimulus.md +85 -0
- package/lib/mcp-skill/references/avalanche-detector.md +731 -0
- package/lib/mcp-skill/references/bdd-router.md +310 -0
- package/lib/mcp-skill/references/browser-runtime-api.md +193 -0
- package/lib/mcp-skill/references/command-output-formats.md +1075 -0
- package/lib/mcp-skill/references/command-surface.md +183 -0
- package/lib/mcp-skill/references/cynefin.md +99 -0
- package/lib/mcp-skill/references/debug-hook.md +161 -0
- package/lib/mcp-skill/references/fixing-performance.md +490 -0
- package/lib/mcp-skill/references/hook-metrics.md +199 -0
- package/lib/mcp-skill/references/hooks-inspection.md +61 -0
- package/lib/mcp-skill/references/jira-taxonomy.md +81 -0
- package/lib/mcp-skill/references/mcp.md +122 -0
- package/lib/mcp-skill/references/mode-a-injection.md +68 -0
- package/lib/mcp-skill/references/operating-model.md +128 -0
- package/lib/mcp-skill/references/playwright-cli-setup.md +105 -0
- package/lib/mcp-skill/references/playwright-probes.md +205 -0
- package/lib/mcp-skill/references/procedures.md +545 -0
- package/lib/mcp-skill/references/recipes.txt +114 -0
- package/lib/mcp-skill/references/stability-render.md +428 -0
- package/lib/mcp-skill/references/test-mode-playbook.md +193 -0
- package/lib/mcp-skill/references/troubleshooting.md +54 -0
- package/lib/mcp-skill/scripts/avalanche-analyze.cjs +495 -0
- package/lib/mcp-skill/scripts/build-avalanche-command.js +34 -0
- package/lib/mcp-skill/scripts/build-fiber-state-command.js +19 -0
- package/lib/mcp-skill/scripts/chippy-helper-server.js +1710 -0
- package/lib/mcp-skill/scripts/chippy-mcp.cjs +273 -0
- package/lib/mcp-skill/scripts/chippy.sh +1218 -0
- package/lib/mcp-skill/scripts/format-probe-result.js +34 -0
- package/lib/mcp-skill/scripts/helper-access.js +144 -0
- package/lib/mcp-skill/scripts/probe-annotation-channel.js +116 -0
- package/lib/mcp-skill/scripts/search-recipes.js +248 -0
- package/lib/mcp-skill/snippets/README.md +89 -0
- package/lib/mcp-skill/snippets/count-context-consumers.js +40 -0
- package/lib/mcp-skill/snippets/find-context-providers.js +38 -0
- package/lib/mcp-skill/snippets/find-legacy-context.js +47 -0
- package/lib/mcp-skill/snippets/find-prop.js +83 -0
- package/lib/mcp-skill/snippets/hook-metrics-sample.js +69 -0
- package/lib/mcp-skill/snippets/hook-metrics.js +60 -0
- package/lib/mcp-skill/snippets/hook-tracker.js +198 -0
- package/lib/mcp-skill/snippets/stability-nudge.js +101 -0
- package/lib/package.json +1 -0
- package/lib/plugin.js +119 -0
- package/lib/plugin.js.map +7 -0
- package/lib/storybook/avalanche-panel-model.js +79 -0
- package/lib/storybook/brand.jsx +6 -0
- package/lib/storybook/connection-fallback.jsx +47 -0
- package/lib/storybook/constants.js +15 -0
- package/lib/storybook/context-card.jsx +171 -0
- package/lib/storybook/manager.jsx +403 -0
- package/lib/storybook/preset.js +7 -0
- package/lib/storybook/preview.js +263 -0
- package/lib/storybook/probe-annotation-editor.jsx +52 -0
- package/lib/storybook/probe-intent.js +23 -0
- package/lib/storybook/setup.js +93 -0
- package/lib/types/avalanche/commit-metrics-adapter.d.ts +76 -0
- package/lib/types/avalanche/commit-stats-indices.d.ts +41 -0
- package/lib/types/avalanche/commit-surface-ownership.d.ts +9 -0
- package/lib/types/avalanche/detector.d.ts +183 -0
- package/lib/types/avalanche/interaction-capture.d.ts +25 -0
- package/lib/types/avalanche/internals.d.ts +189 -0
- package/lib/types/avalanche/report-serializer.d.ts +283 -0
- package/lib/types/avalanche/reporter.d.ts +610 -0
- package/lib/types/chippy-bridge/fiber-source.d.ts +37 -0
- package/lib/types/chippy-bridge/react-renderer.d.ts +49 -0
- package/lib/types/chippy-bridge/source-map.d.ts +30 -0
- package/lib/types/debug-render/core.d.ts +72 -0
- package/lib/types/layout-lab/fiber-boundaries.d.ts +51 -0
- package/lib/types/layout-lab/fiber-walk.d.ts +131 -0
- package/lib/types/layout-lab/marker-context.d.ts +94 -0
- package/lib/types/package/avalanche.d.ts +6 -0
- package/lib/types/package/browser.d.ts +10 -0
- package/lib/types/package/plugin.d.ts +15 -0
- package/lib/types/plugins/atl/commit-metrics-adapter.d.ts +65 -0
- package/lib/types/plugins/atl/metrics-slot.d.ts +37 -0
- package/lib/types/plugins/index.d.ts +49 -0
- package/lib/types/plugins/jira/context-provider.d.ts +36 -0
- package/package.json +104 -4
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { URL } = require('node:url');
|
|
7
|
+
const {
|
|
8
|
+
PLAYWRIGHT_UNAVAILABLE_CODE,
|
|
9
|
+
getPlaywrightAvailability,
|
|
10
|
+
launchSession,
|
|
11
|
+
} = require('../browser-scripts/playwright-session');
|
|
12
|
+
const { createProbeAnnotationChannel } = require('./probe-annotation-channel');
|
|
13
|
+
const helperAccess = require('./helper-access');
|
|
14
|
+
|
|
15
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const CHIPPY_PREFIX = '/__chippy';
|
|
18
|
+
const CHIPPY_VERSION = (() => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(path.join(path.resolve(__dirname, '..'), 'extension', 'manifest.json'), 'utf8')).version || 'unknown';
|
|
21
|
+
} catch { return 'unknown'; }
|
|
22
|
+
})();
|
|
23
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
24
|
+
const EXTENSION_ROOT = path.join(path.resolve(__dirname, '..'), 'extension');
|
|
25
|
+
// avalanche.iife.js is injected by the extension via chrome.scripting (MAIN world,
|
|
26
|
+
// pre-document). Helper bundle excludes it to avoid double install
|
|
27
|
+
// ("[avalanche] __AVALANCHE__ already defined; overwriting").
|
|
28
|
+
const INJECT_FILES = ['inspect-react.js', 'panel-dom.js', 'layout-lab.js', 'resolve-probes.js', 'chippy-page-bridge.js'];
|
|
29
|
+
|
|
30
|
+
function cliFlag(flag) {
|
|
31
|
+
const i = process.argv.findIndex((a) => a === flag || a.startsWith(`${flag}=`));
|
|
32
|
+
if (i === -1) return null;
|
|
33
|
+
const v = process.argv[i];
|
|
34
|
+
return v.includes('=') ? v.split('=').slice(1).join('=') : process.argv[i + 1] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const port = Number(cliFlag('--port') || process.env.CHIPPY_HELPER_PORT || 7823);
|
|
38
|
+
const host = cliFlag('--host') || process.env.CHIPPY_HELPER_HOST || '127.0.0.1';
|
|
39
|
+
const loopbackBind = helperAccess.isLoopbackBind(host);
|
|
40
|
+
const helperToken = helperAccess.createHelperToken();
|
|
41
|
+
|
|
42
|
+
const clients = new Map(); // clientId → client object
|
|
43
|
+
const pendingComputedStyle = new Map(); // requestId → { resolve, timer }
|
|
44
|
+
const pendingEvalProbe = new Map(); // requestId → { resolve, timer }
|
|
45
|
+
const pendingAvalanche = new Map(); // requestId → { resolve, timer }
|
|
46
|
+
const pendingReloadAck = new Map(); // requestId → { resolve, timer }
|
|
47
|
+
const pendingEvalScript = new Map(); // requestId → { resolve, timer }
|
|
48
|
+
const pendingProbeCommand = new Map(); // requestId → { resolve, timer }
|
|
49
|
+
const armedAvalanche = new Map(); // origin → { autoStart, max }
|
|
50
|
+
const armedAvalancheByClient = new Map(); // clientId → { autoStart, max }
|
|
51
|
+
const playwrightSessions = new Map(); // clientId → { browser, context, page, createdAt, sourceClientId }
|
|
52
|
+
const recentPageActivity = []; // newest first, bounded in memory
|
|
53
|
+
const fiberStateCaptures = new Map(); // captureId → compact page-side structural capture
|
|
54
|
+
const MAX_FIBER_STATE_CAPTURES = 20;
|
|
55
|
+
const probeAnnotations = createProbeAnnotationChannel({
|
|
56
|
+
serializeMarker: (marker) => sliceMarker(marker, 'standard', null),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const IMPORT_INTEGRATION = Object.freeze({
|
|
60
|
+
kind: 'import',
|
|
61
|
+
who: 'Chippy helper module import',
|
|
62
|
+
source: '.agents/skills/chippy/scripts/chippy-helper-server.js',
|
|
63
|
+
reinjectsOnReload: false,
|
|
64
|
+
reloadAction: "Run import('http://127.0.0.1:7823/__chippy/inject.mjs') again after every full page reload or navigation.",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function waitForPendingResult(pendingMap, requestId, timeoutMs, timeoutError) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
pendingMap.delete(requestId);
|
|
71
|
+
resolve({ ok: false, error: timeoutError });
|
|
72
|
+
}, Number(timeoutMs) || 5000);
|
|
73
|
+
pendingMap.set(requestId, { resolve, timer });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolvePendingResult(pendingMap, requestId, result) {
|
|
78
|
+
const pending = pendingMap.get(requestId);
|
|
79
|
+
if (!pending) return false;
|
|
80
|
+
pendingMap.delete(requestId);
|
|
81
|
+
clearTimeout(pending.timer);
|
|
82
|
+
pending.resolve({ ok: true, result });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { buildAvalancheCommand: _buildAvalancheCommandPure } = require('./build-avalanche-command');
|
|
87
|
+
const {
|
|
88
|
+
buildFiberStateCaptureCommand,
|
|
89
|
+
buildFiberStateCompareCommand,
|
|
90
|
+
} = require('./build-fiber-state-command');
|
|
91
|
+
|
|
92
|
+
function buildAvalancheCommand(body = {}) {
|
|
93
|
+
return _buildAvalancheCommandPure(body, crypto.randomUUID());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function requestEvalScript(clientId, body = {}) {
|
|
97
|
+
const timeoutMs = body.timeoutMs != null ? Number(body.timeoutMs) : 8000;
|
|
98
|
+
const command = {
|
|
99
|
+
type: 'eval-script',
|
|
100
|
+
requestId: crypto.randomUUID(),
|
|
101
|
+
source: String(body.source || ''),
|
|
102
|
+
args: body.args && typeof body.args === 'object' ? body.args : {},
|
|
103
|
+
timeoutMs: body.scriptTimeoutMs != null ? Number(body.scriptTimeoutMs) : 5000,
|
|
104
|
+
};
|
|
105
|
+
if (!command.source.trim()) {
|
|
106
|
+
return { ok: false, code: 400, payload: { ok: false, error: 'eval-script: empty source' } };
|
|
107
|
+
}
|
|
108
|
+
const delivered = sendToClient(clientId, command);
|
|
109
|
+
if (!delivered.ok) return { ok: false, code: delivered.code, payload: { ok: false, error: delivered.error } };
|
|
110
|
+
const result = await waitForPendingResult(pendingEvalScript, command.requestId, timeoutMs, 'Timeout waiting for eval-script result');
|
|
111
|
+
return {
|
|
112
|
+
ok: result.ok,
|
|
113
|
+
code: result.ok ? 200 : 504,
|
|
114
|
+
payload: { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId: command.requestId },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function requestAvalanche(clientId, body = {}) {
|
|
119
|
+
const timeoutMs = body.timeoutMs != null ? Number(body.timeoutMs) : 5000;
|
|
120
|
+
const command = buildAvalancheCommand(body);
|
|
121
|
+
const delivered = sendToClient(clientId, command);
|
|
122
|
+
if (!delivered.ok) return { ok: false, code: delivered.code, payload: { ok: false, error: delivered.error } };
|
|
123
|
+
const result = await waitForPendingResult(pendingAvalanche, command.requestId, timeoutMs, 'Timeout waiting for avalanche result');
|
|
124
|
+
return {
|
|
125
|
+
ok: result.ok,
|
|
126
|
+
code: result.ok ? 200 : 504,
|
|
127
|
+
payload: { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId: command.requestId },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function requestProbeCommand(clientId, command, timeoutMs = 10_000) {
|
|
132
|
+
const requestId = crypto.randomUUID();
|
|
133
|
+
const delivered = sendToClient(clientId, { ...command, requestId });
|
|
134
|
+
if (!delivered.ok) return { ok: false, code: delivered.code, payload: { ok: false, error: delivered.error } };
|
|
135
|
+
const result = await waitForPendingResult(
|
|
136
|
+
pendingProbeCommand,
|
|
137
|
+
requestId,
|
|
138
|
+
Math.max(100, Number(timeoutMs || 10_000)),
|
|
139
|
+
'Timeout waiting for probe command result'
|
|
140
|
+
);
|
|
141
|
+
return {
|
|
142
|
+
ok: result.ok,
|
|
143
|
+
code: result.ok ? 200 : 504,
|
|
144
|
+
payload: { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function summarizeFiberStateCapture(entry) {
|
|
149
|
+
return {
|
|
150
|
+
captureId: entry.captureId,
|
|
151
|
+
label: entry.label,
|
|
152
|
+
clientId: entry.clientId,
|
|
153
|
+
page: entry.state.page,
|
|
154
|
+
capturedAt: entry.state.capturedAt,
|
|
155
|
+
fiberCount: entry.state.fiberCount,
|
|
156
|
+
truncated: entry.state.truncated,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveFiberStateCapture(captureId) {
|
|
161
|
+
const id = String(captureId || '').trim();
|
|
162
|
+
if (!id) return { ok: false, code: 400, error: 'captureId required' };
|
|
163
|
+
if (fiberStateCaptures.has(id)) return { ok: true, capture: fiberStateCaptures.get(id) };
|
|
164
|
+
const matches = Array.from(fiberStateCaptures.values()).filter((entry) => entry.captureId.startsWith(id));
|
|
165
|
+
if (matches.length === 1) return { ok: true, capture: matches[0] };
|
|
166
|
+
if (matches.length > 1) return { ok: false, code: 409, error: `Ambiguous captureId prefix ${id}` };
|
|
167
|
+
return { ok: false, code: 404, error: `Fiber state capture not found: ${id}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function retainFiberStateCapture(entry) {
|
|
171
|
+
fiberStateCaptures.set(entry.captureId, entry);
|
|
172
|
+
while (fiberStateCaptures.size > MAX_FIBER_STATE_CAPTURES) {
|
|
173
|
+
fiberStateCaptures.delete(fiberStateCaptures.keys().next().value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function collectProbeDescriptors(clientId, probeIds) {
|
|
178
|
+
const resolved = resolveClientId(clientId);
|
|
179
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
180
|
+
const ids = (Array.isArray(probeIds) && probeIds.length ? probeIds : ['1', '2']).map((id) => String(id));
|
|
181
|
+
const source = `
|
|
182
|
+
return ((ctx) => {
|
|
183
|
+
const ids = Array.isArray(ctx.args?.probeIds) ? ctx.args.probeIds : [];
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const probeId of ids) {
|
|
186
|
+
const probe = globalThis.__CHIPPY?.layoutLab?.getMarkerProbe?.(String(probeId))?.probe;
|
|
187
|
+
const target = probe?._targetEl || null;
|
|
188
|
+
out[String(probeId)] = target
|
|
189
|
+
? globalThis.__CHIPPY.captureProbeDescriptor(target)
|
|
190
|
+
: { ok: false, error: 'Probe not placed' };
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
})(ctx);
|
|
194
|
+
`;
|
|
195
|
+
const result = await requestEvalScript(clientId, { source, args: { probeIds: ids }, timeoutMs: 8000, scriptTimeoutMs: 5000 });
|
|
196
|
+
if (!result.ok) throw new Error(result.payload?.error || 'Could not capture descriptors');
|
|
197
|
+
return result.payload?.result?.result || result.payload?.result || {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function waitForNewClient(previousIds, timeoutMs = 10000) {
|
|
201
|
+
const started = Date.now();
|
|
202
|
+
while (Date.now() - started < timeoutMs) {
|
|
203
|
+
for (const [nextClientId] of clients.entries()) {
|
|
204
|
+
if (!previousIds.has(nextClientId)) return nextClientId;
|
|
205
|
+
}
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
207
|
+
}
|
|
208
|
+
throw new Error('Timed out waiting for Playwright client to connect');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handoffToPlaywright({ clientId, url, probeIds, browserName, headless }) {
|
|
212
|
+
const playwrightAvailability = getPlaywrightAvailability();
|
|
213
|
+
if (!playwrightAvailability.ok) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: 503,
|
|
217
|
+
payload: {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: playwrightAvailability.code,
|
|
220
|
+
error: playwrightAvailability.error,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const resolved = resolveClientId(clientId);
|
|
226
|
+
if (!resolved.ok) return { ok: false, code: resolved.code, payload: { ok: false, error: resolved.error } };
|
|
227
|
+
const sourceClient = resolved.client;
|
|
228
|
+
const sourceClientId = resolved.clientId;
|
|
229
|
+
const targetUrl = String(url || sourceClient.page?.url || '').trim();
|
|
230
|
+
if (!targetUrl) return { ok: false, code: 400, payload: { ok: false, error: 'url required (or source client must have page.url)' } };
|
|
231
|
+
|
|
232
|
+
const descriptors = await collectProbeDescriptors(sourceClientId, probeIds);
|
|
233
|
+
const selected = Object.entries(descriptors)
|
|
234
|
+
.filter(([, entry]) => entry && entry.ok && entry.descriptor)
|
|
235
|
+
.map(([probeId, entry]) => ({ probeId, descriptor: entry.descriptor }));
|
|
236
|
+
if (!selected.length) {
|
|
237
|
+
return { ok: false, code: 400, payload: { ok: false, error: 'No live probes could be captured as descriptors', descriptors } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const previousIds = new Set(clients.keys());
|
|
241
|
+
const session = await launchSession({ browserName: browserName || 'chromium', url: targetUrl, headless: headless !== false });
|
|
242
|
+
const targetClientId = await waitForNewClient(previousIds, 10000);
|
|
243
|
+
const targetClient = clients.get(targetClientId);
|
|
244
|
+
if (targetClient) {
|
|
245
|
+
targetClient.transport = 'playwright';
|
|
246
|
+
targetClient.sourceClientId = clientId;
|
|
247
|
+
}
|
|
248
|
+
playwrightSessions.set(targetClientId, { ...session, createdAt: nowIso(), sourceClientId: clientId, descriptors: selected });
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
code: 200,
|
|
253
|
+
payload: {
|
|
254
|
+
ok: true,
|
|
255
|
+
sourceClientId,
|
|
256
|
+
targetClientId,
|
|
257
|
+
url: targetUrl,
|
|
258
|
+
descriptors: Object.fromEntries(selected.map(({ probeId, descriptor }) => [probeId, descriptor])),
|
|
259
|
+
target: targetClient ? summarizeClient(targetClient, 'standard') : null,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// When active, non-Chippy requests are forwarded to targetPort (proxy mode).
|
|
265
|
+
const proxyState = { active: false, targetPort: null, originalUrl: null, startedAt: null };
|
|
266
|
+
|
|
267
|
+
// ─── Proxy HTML injection ─────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function getProxyInjectTarget() {
|
|
270
|
+
if (!proxyState.originalUrl) return null;
|
|
271
|
+
try {
|
|
272
|
+
const u = new URL(proxyState.originalUrl);
|
|
273
|
+
return { pathname: u.pathname, search: u.search || '' };
|
|
274
|
+
} catch { return null; }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function shouldInjectIntoProxyHtml(req, upstreamRes) {
|
|
278
|
+
if (!proxyState.active || req.method !== 'GET') return false;
|
|
279
|
+
const status = upstreamRes.statusCode || 0;
|
|
280
|
+
if (status < 200 || status >= 300) return false;
|
|
281
|
+
if (!String(upstreamRes.headers['content-type'] || '').includes('text/html')) return false;
|
|
282
|
+
const target = getProxyInjectTarget();
|
|
283
|
+
if (!target) return false;
|
|
284
|
+
try {
|
|
285
|
+
const u = new URL(req.url, 'http://proxy.local');
|
|
286
|
+
return u.pathname === target.pathname && (!target.search || u.search === target.search);
|
|
287
|
+
} catch { return false; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildProxyInlineBundle() {
|
|
291
|
+
const cfg = JSON.stringify({ activated: true, injectedByProxy: true });
|
|
292
|
+
return [
|
|
293
|
+
'<script data-chippy-proxy-bundle="true">',
|
|
294
|
+
'window.__CHIPPY = window.__CHIPPY || {};',
|
|
295
|
+
`window.__CHIPPY.config = { ...${cfg}, helperBaseUrl: window.location.origin };`,
|
|
296
|
+
getInjectBundle(),
|
|
297
|
+
'</script>',
|
|
298
|
+
].join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function injectIntoHtmlDocument(html) {
|
|
302
|
+
const bundle = buildProxyInlineBundle();
|
|
303
|
+
const lower = html.toLowerCase();
|
|
304
|
+
// Insert just before </body> or </html>, whichever comes last
|
|
305
|
+
for (const tag of ['</body>', '</html>']) {
|
|
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}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function stripHopHeaders(headers, bodyBuffer, stripCsp = false) {
|
|
313
|
+
const next = { ...headers };
|
|
314
|
+
delete next['content-length'];
|
|
315
|
+
delete next['transfer-encoding'];
|
|
316
|
+
delete next['content-encoding'];
|
|
317
|
+
if (stripCsp) {
|
|
318
|
+
delete next['content-security-policy'];
|
|
319
|
+
delete next['content-security-policy-report-only'];
|
|
320
|
+
}
|
|
321
|
+
if (bodyBuffer) next['content-length'] = Buffer.byteLength(bodyBuffer);
|
|
322
|
+
return next;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
const nowIso = () => new Date().toISOString();
|
|
328
|
+
|
|
329
|
+
function trimRecentActivity(limit = 30) {
|
|
330
|
+
while (recentPageActivity.length > limit) recentPageActivity.pop();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function userVisiblePageName(client) {
|
|
334
|
+
const title = client.page?.title || client.latestSnapshot?.page?.title || null;
|
|
335
|
+
const storyId = client.page?.storyId || client.latestSnapshot?.page?.storyId || null;
|
|
336
|
+
const url = client.page?.url || client.latestSnapshot?.page?.url || null;
|
|
337
|
+
return title || storyId || url || `Page ${client.clientId.slice(0, 8)}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function pageLabel(client) {
|
|
341
|
+
const name = userVisiblePageName(client);
|
|
342
|
+
const storyId = client.page?.storyId || client.latestSnapshot?.page?.storyId || null;
|
|
343
|
+
if (storyId && name !== storyId) return `${name} (${storyId})`;
|
|
344
|
+
return name;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function recordClientActivity(client, reason, detail = null, at = nowIso()) {
|
|
348
|
+
client.lastActivityAt = at;
|
|
349
|
+
client.lastActivityReason = reason;
|
|
350
|
+
client.activity = client.activity || {};
|
|
351
|
+
client.activity.last = { reason, detail, at };
|
|
352
|
+
if (reason === 'focus') client.activity.lastFocusedAt = at;
|
|
353
|
+
if (reason === 'visible') client.activity.lastVisibleAt = at;
|
|
354
|
+
if (reason === 'hidden') client.activity.lastHiddenAt = at;
|
|
355
|
+
if (reason === 'pointer') client.activity.lastPointerAt = at;
|
|
356
|
+
if (reason === 'snapshot') client.activity.lastSnapshotAt = at;
|
|
357
|
+
if (reason === 'command') client.activity.lastCommandAt = at;
|
|
358
|
+
if (reason === 'hello' || reason === 'open' || reason === 'connect') client.activity.lastConnectedAt = at;
|
|
359
|
+
recentPageActivity.unshift({
|
|
360
|
+
clientId: client.clientId,
|
|
361
|
+
at,
|
|
362
|
+
reason,
|
|
363
|
+
detail,
|
|
364
|
+
integration: client.integration || null,
|
|
365
|
+
pageLabel: pageLabel(client),
|
|
366
|
+
title: client.page?.title || client.latestSnapshot?.page?.title || null,
|
|
367
|
+
url: client.page?.url || client.latestSnapshot?.page?.url || null,
|
|
368
|
+
storyId: client.page?.storyId || client.latestSnapshot?.page?.storyId || null,
|
|
369
|
+
});
|
|
370
|
+
trimRecentActivity();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function baseUrlFromReq(req) {
|
|
374
|
+
const fwd = req.headers['x-forwarded-proto'];
|
|
375
|
+
const proto = Array.isArray(fwd) ? fwd[0] : (typeof fwd === 'string' ? fwd.split(',')[0] : 'http');
|
|
376
|
+
const hostHeader = req.headers['x-forwarded-host'] || req.headers.host || `127.0.0.1:${port}`;
|
|
377
|
+
return `${proto}://${hostHeader}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const CORS_HEADERS = {
|
|
381
|
+
'Access-Control-Allow-Origin': '*',
|
|
382
|
+
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
383
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
384
|
+
'Cache-Control': 'no-store',
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
function json(res, code, payload) {
|
|
388
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
|
|
389
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function text(res, code, payload, contentType = 'text/plain; charset=utf-8') {
|
|
393
|
+
res.writeHead(code, { 'Content-Type': contentType, ...CORS_HEADERS });
|
|
394
|
+
res.end(payload);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── WebSocket framing (RFC 6455, server-side) ────────────────────────────────
|
|
398
|
+
|
|
399
|
+
function wsSend(socket, payload, opcode = 0x1) {
|
|
400
|
+
if (socket.destroyed) return;
|
|
401
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
|
|
402
|
+
const len = body.length;
|
|
403
|
+
let header;
|
|
404
|
+
if (len < 126) {
|
|
405
|
+
header = Buffer.from([0x80 | opcode, len]);
|
|
406
|
+
} else if (len < 0x10000) {
|
|
407
|
+
header = Buffer.alloc(4);
|
|
408
|
+
header[0] = 0x80 | opcode; header[1] = 126;
|
|
409
|
+
header.writeUInt16BE(len, 2);
|
|
410
|
+
} else {
|
|
411
|
+
header = Buffer.alloc(10);
|
|
412
|
+
header[0] = 0x80 | opcode; header[1] = 127;
|
|
413
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
414
|
+
}
|
|
415
|
+
socket.write(Buffer.concat([header, body]));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function wsParseFrames(buffer, frag = {}) {
|
|
419
|
+
const messages = [];
|
|
420
|
+
let off = 0;
|
|
421
|
+
while (off + 2 <= buffer.length) {
|
|
422
|
+
const b0 = buffer[off], b1 = buffer[off + 1];
|
|
423
|
+
const fin = (b0 & 0x80) !== 0;
|
|
424
|
+
const opcode = b0 & 0x0f;
|
|
425
|
+
const masked = (b1 & 0x80) !== 0;
|
|
426
|
+
let pLen = b1 & 0x7f, hLen = 2;
|
|
427
|
+
if (pLen === 126) {
|
|
428
|
+
if (off + 4 > buffer.length) break;
|
|
429
|
+
pLen = buffer.readUInt16BE(off + 2); hLen = 4;
|
|
430
|
+
} else if (pLen === 127) {
|
|
431
|
+
if (off + 10 > buffer.length) break;
|
|
432
|
+
pLen = Number(buffer.readBigUInt64BE(off + 2)); hLen = 10;
|
|
433
|
+
}
|
|
434
|
+
const mLen = masked ? 4 : 0;
|
|
435
|
+
const total = hLen + mLen + pLen;
|
|
436
|
+
if (off + total > buffer.length) break;
|
|
437
|
+
const payload = Buffer.from(buffer.subarray(off + hLen + mLen, off + hLen + mLen + pLen));
|
|
438
|
+
if (masked) {
|
|
439
|
+
const mask = buffer.subarray(off + hLen, off + hLen + 4);
|
|
440
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
|
|
441
|
+
}
|
|
442
|
+
off += total;
|
|
443
|
+
|
|
444
|
+
// Control frames (close/ping/pong) pass through directly
|
|
445
|
+
if (opcode === 0x8 || opcode === 0x9 || opcode === 0xA) {
|
|
446
|
+
messages.push({ opcode, payload });
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// Data frame: start new or continue fragment
|
|
450
|
+
if (opcode !== 0) { frag.opcode = opcode; frag.chunks = [payload]; }
|
|
451
|
+
else if (frag.chunks) { frag.chunks.push(payload); }
|
|
452
|
+
|
|
453
|
+
if (fin && frag.chunks) {
|
|
454
|
+
const full = frag.chunks.length === 1 ? frag.chunks[0] : Buffer.concat(frag.chunks);
|
|
455
|
+
messages.push({ opcode: frag.opcode, payload: full });
|
|
456
|
+
frag.chunks = null; frag.opcode = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { messages, remainder: buffer.subarray(off) };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Duplicate-page detection ─────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
function pageKey(client) {
|
|
465
|
+
const url = client.page?.url;
|
|
466
|
+
if (!url) return null;
|
|
467
|
+
try { const u = new URL(url); return `${u.origin}${u.pathname}${u.search}`; }
|
|
468
|
+
catch { return url; }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function findDuplicatePageGroups() {
|
|
472
|
+
const groups = new Map();
|
|
473
|
+
for (const c of clients.values()) {
|
|
474
|
+
const k = pageKey(c);
|
|
475
|
+
if (!k) continue;
|
|
476
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
477
|
+
groups.get(k).push(c.clientId);
|
|
478
|
+
}
|
|
479
|
+
const dupes = {};
|
|
480
|
+
for (const [k, ids] of groups) { if (ids.length > 1) dupes[k] = ids; }
|
|
481
|
+
return dupes;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function warnDuplicatesIfNeeded() {
|
|
485
|
+
const dupes = findDuplicatePageGroups();
|
|
486
|
+
const keys = Object.keys(dupes);
|
|
487
|
+
if (!keys.length) return;
|
|
488
|
+
for (const k of keys) console.warn(`⚠️ ${dupes[k].length} tabs open for "${k}" — ${dupes[k].join(', ')}`);
|
|
489
|
+
for (const ids of Object.values(dupes)) {
|
|
490
|
+
for (const id of ids) {
|
|
491
|
+
const c = clients.get(id);
|
|
492
|
+
if (c) wsSend(c.socket, JSON.stringify({
|
|
493
|
+
type: 'command',
|
|
494
|
+
command: { type: 'show-status', text: `⚠️ Chippy sees ${ids.length} tabs on this same page. Is that intentional?` },
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── Snapshot slicing ─────────────────────────────────────────────────────────
|
|
501
|
+
// ?slice=minimal → name + key + confidence (default for list)
|
|
502
|
+
// ?slice=standard → + dom, props, source, truncated chains (default for single)
|
|
503
|
+
// ?slice=full → everything
|
|
504
|
+
// ?boundaries=RE → filter parent chain to matching names
|
|
505
|
+
|
|
506
|
+
function sliceMarker(marker, mode, boundaryRe) {
|
|
507
|
+
if (!marker) return null;
|
|
508
|
+
const base = {
|
|
509
|
+
position: marker.position,
|
|
510
|
+
docked: marker.docked ?? true,
|
|
511
|
+
intent: marker.intent ?? null,
|
|
512
|
+
comment: typeof marker.comment === 'string' ? marker.comment : '',
|
|
513
|
+
};
|
|
514
|
+
const r = marker.resolved;
|
|
515
|
+
if (!r) return { ...base, resolved: null };
|
|
516
|
+
|
|
517
|
+
let parents = r.component?.parents || [];
|
|
518
|
+
const owners = r.component?.owners || [];
|
|
519
|
+
|
|
520
|
+
if (boundaryRe) {
|
|
521
|
+
try { parents = parents.filter((p) => p?.name && new RegExp(boundaryRe, 'i').test(p.name)); }
|
|
522
|
+
catch { /* invalid regexp — keep all */ }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (mode === 'minimal') {
|
|
526
|
+
return { ...base, resolved: {
|
|
527
|
+
confidence: r.confidence, note: r.note || null,
|
|
528
|
+
nearestTestId: r.nearestTestId ?? null,
|
|
529
|
+
dom: { tag: r.dom?.tag, text: r.dom?.text },
|
|
530
|
+
component: { name: r.component?.name, key: r.component?.key ?? null },
|
|
531
|
+
}};
|
|
532
|
+
}
|
|
533
|
+
if (mode === 'full') {
|
|
534
|
+
return { ...base, resolved: { ...r, component: { ...r.component, parents, owners } } };
|
|
535
|
+
}
|
|
536
|
+
// standard
|
|
537
|
+
return { ...base, resolved: {
|
|
538
|
+
confidence: r.confidence, note: r.note || null,
|
|
539
|
+
nearestTestId: r.nearestTestId ?? null,
|
|
540
|
+
dom: r.dom,
|
|
541
|
+
component: {
|
|
542
|
+
name: r.component?.name, key: r.component?.key ?? null,
|
|
543
|
+
props: r.component?.props,
|
|
544
|
+
hookCount: r.component?.hookCount ?? null,
|
|
545
|
+
hookCountTruncated: r.component?.hookCountTruncated ?? false,
|
|
546
|
+
hookCountMax: r.component?.hookCountMax ?? null,
|
|
547
|
+
source: r.component?.source,
|
|
548
|
+
owners: owners.slice(0, 10),
|
|
549
|
+
componentStack: r.component?.componentStack,
|
|
550
|
+
parents: boundaryRe ? parents : parents.slice(0, 20),
|
|
551
|
+
},
|
|
552
|
+
}};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function sliceSnapshot(snapshot, mode, boundaryRe) {
|
|
556
|
+
if (!snapshot) return null;
|
|
557
|
+
const markers = snapshot.markers || {};
|
|
558
|
+
return { ...snapshot, markers: Object.fromEntries(
|
|
559
|
+
Object.entries(markers).map(([id, m]) => [id, sliceMarker(m, mode, boundaryRe)]),
|
|
560
|
+
)};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function summarizeClient(client, mode = 'minimal', boundaryRe = null) {
|
|
564
|
+
const latestSnapshotAt = client.latestSnapshotAt || null;
|
|
565
|
+
const snapshotAgeMs = latestSnapshotAt ? (Date.now() - new Date(latestSnapshotAt).getTime()) : null;
|
|
566
|
+
const title = client.page?.title || null;
|
|
567
|
+
const pageUrl = client.page?.url || null;
|
|
568
|
+
const visibleName = userVisiblePageName(client);
|
|
569
|
+
const label = pageLabel(client);
|
|
570
|
+
const lastActivityAt = client.lastActivityAt || client.lastMessageAt || client.connectedAt || null;
|
|
571
|
+
const integrationLabel = client.integration?.who || client.integration?.kind || 'unidentified Chippy integration';
|
|
572
|
+
const summary = `${visibleName} | ${integrationLabel} | ${pageUrl || '(no url)'} | activity: ${client.lastActivityReason || 'unknown'} @ ${lastActivityAt || 'none'}${snapshotAgeMs != null ? ` | snapshot ${Math.round(snapshotAgeMs / 1000)}s ago` : ''}`;
|
|
573
|
+
return {
|
|
574
|
+
summary,
|
|
575
|
+
clientId: client.clientId,
|
|
576
|
+
pageRef: label,
|
|
577
|
+
visibleName,
|
|
578
|
+
isLikelyCurrent: Boolean(client.lastActivityReason === 'focus' || client.lastActivityReason === 'visible' || client.lastActivityReason === 'pointer'),
|
|
579
|
+
connectedAt: client.connectedAt,
|
|
580
|
+
lastMessageAt: client.lastMessageAt,
|
|
581
|
+
lastActivityAt,
|
|
582
|
+
lastActivityReason: client.lastActivityReason || null,
|
|
583
|
+
activity: client.activity || null,
|
|
584
|
+
latestSnapshotAt,
|
|
585
|
+
snapshotAgeMs,
|
|
586
|
+
bridgeVersion: client.bridgeVersion || null, page: client.page || null,
|
|
587
|
+
integration: client.integration || null,
|
|
588
|
+
activated: client.activated, probesActive: Boolean(client.probesActive),
|
|
589
|
+
buildMode: client.buildMode || 'unknown',
|
|
590
|
+
lastInspectedBoundary: client.lastInspectedBoundary || null,
|
|
591
|
+
latestSnapshot: sliceSnapshot(client.latestSnapshot, mode, boundaryRe),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function sliceParams(url) {
|
|
596
|
+
return { slice: url.searchParams.get('slice') || null, boundaries: url.searchParams.get('boundaries') || null };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildState(req) {
|
|
600
|
+
const base = baseUrlFromReq(req);
|
|
601
|
+
const url = new URL(req.url, base);
|
|
602
|
+
const { slice, boundaries } = sliceParams(url);
|
|
603
|
+
const pages = Array.from(clients.values())
|
|
604
|
+
.sort((a, b) => new Date(b.lastActivityAt || b.lastMessageAt || b.connectedAt || 0).getTime() - new Date(a.lastActivityAt || a.lastMessageAt || a.connectedAt || 0).getTime())
|
|
605
|
+
.map((c) => summarizeClient(c, slice || 'minimal', boundaries));
|
|
606
|
+
const dupes = findDuplicatePageGroups();
|
|
607
|
+
return {
|
|
608
|
+
version: CHIPPY_VERSION,
|
|
609
|
+
helperBaseUrl: base,
|
|
610
|
+
injectUrl: `${base}${CHIPPY_PREFIX}/inject.js`,
|
|
611
|
+
wsUrl: base.replace(/^http/i, 'ws') + `${CHIPPY_PREFIX}/ws`,
|
|
612
|
+
connectedClients: pages,
|
|
613
|
+
recentPages: recentPageActivity.slice(0, 10),
|
|
614
|
+
duplicatePageWarnings: Object.keys(dupes).length ? dupes : null,
|
|
615
|
+
proxy: getProxySummary(),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── HTTP body parsing ────────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
function parseJsonBody(req) {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
const chunks = [];
|
|
624
|
+
req.on('data', (c) => chunks.push(c));
|
|
625
|
+
req.on('end', () => { try { const s = Buffer.concat(chunks).toString(); resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(e); } });
|
|
626
|
+
req.on('error', reject);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ─── Proxy forwarding ─────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
function isChippyPath(p) { return p === CHIPPY_PREFIX || p.startsWith(`${CHIPPY_PREFIX}/`); }
|
|
633
|
+
|
|
634
|
+
function getProxySummary() {
|
|
635
|
+
if (!proxyState.active) return null;
|
|
636
|
+
return { active: true, targetPort: proxyState.targetPort, originalUrl: proxyState.originalUrl, injectTarget: getProxyInjectTarget(), startedAt: proxyState.startedAt };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function startProxy(targetPort, originalUrl) {
|
|
640
|
+
const p = Number(targetPort);
|
|
641
|
+
if (!Number.isFinite(p) || p < 1 || p > 65535) throw new Error(`Invalid target port: ${targetPort}`);
|
|
642
|
+
Object.assign(proxyState, { active: true, targetPort: p, originalUrl: originalUrl || null, startedAt: nowIso() });
|
|
643
|
+
console.log(`[proxy] enabled -> port=${p}${originalUrl ? ` url=${originalUrl}` : ''}`);
|
|
644
|
+
return getProxySummary();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function stopProxy() {
|
|
648
|
+
const prev = getProxySummary();
|
|
649
|
+
Object.assign(proxyState, { active: false, targetPort: null, originalUrl: null, startedAt: null });
|
|
650
|
+
console.log(`[proxy] disabled${prev?.targetPort ? ` (was ${prev.targetPort})` : ''}`);
|
|
651
|
+
return { active: false };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function forwardHeaders(headers, targetHost) {
|
|
655
|
+
const next = { ...headers };
|
|
656
|
+
delete next.host; delete next.connection; delete next['content-length']; delete next['accept-encoding'];
|
|
657
|
+
next.host = targetHost;
|
|
658
|
+
next['x-forwarded-host'] = headers.host || '';
|
|
659
|
+
next['x-forwarded-proto'] = 'http';
|
|
660
|
+
return next;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function proxyRequest(req, res) {
|
|
664
|
+
const targetHost = `127.0.0.1:${proxyState.targetPort}`;
|
|
665
|
+
const url = `http://${targetHost}${req.url}`;
|
|
666
|
+
console.log(`[proxy] ${req.method} ${req.url} -> ${url}`);
|
|
667
|
+
|
|
668
|
+
const upstream = http.request(url, { method: req.method, headers: forwardHeaders(req.headers, targetHost) }, (upRes) => {
|
|
669
|
+
console.log(`[proxy] ${req.method} ${req.url} <- ${upRes.statusCode}`);
|
|
670
|
+
if (!shouldInjectIntoProxyHtml(req, upRes)) {
|
|
671
|
+
res.writeHead(upRes.statusCode || 502, upRes.statusMessage, upRes.headers);
|
|
672
|
+
upRes.pipe(res);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// Buffer HTML response so we can inject the Chippy bundle
|
|
676
|
+
const chunks = [];
|
|
677
|
+
upRes.on('data', (c) => chunks.push(c));
|
|
678
|
+
upRes.on('end', () => {
|
|
679
|
+
try {
|
|
680
|
+
const injected = Buffer.from(injectIntoHtmlDocument(Buffer.concat(chunks).toString('utf8')));
|
|
681
|
+
console.log(`[proxy] injected chippy bundle into ${req.url}`);
|
|
682
|
+
res.writeHead(upRes.statusCode || 200, upRes.statusMessage, stripHopHeaders(upRes.headers, injected, true));
|
|
683
|
+
res.end(injected);
|
|
684
|
+
} catch (e) {
|
|
685
|
+
console.error(`[proxy] injection failed: ${e.message}`);
|
|
686
|
+
if (!res.headersSent) json(res, 500, { ok: false, error: e.message });
|
|
687
|
+
else res.end();
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
upRes.on('error', (e) => {
|
|
691
|
+
console.error(`[proxy] upstream error ${req.url}: ${e.message}`);
|
|
692
|
+
if (!res.headersSent) json(res, 502, { ok: false, error: e.message });
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
upstream.on('error', (e) => {
|
|
696
|
+
console.error(`[proxy] ${req.method} ${req.url} !! ${e.message}`);
|
|
697
|
+
if (!res.headersSent) json(res, 502, { ok: false, error: e.message });
|
|
698
|
+
});
|
|
699
|
+
req.on('error', (e) => { console.error(`[proxy] req-error: ${e.message}`); upstream.destroy(); });
|
|
700
|
+
req.pipe(upstream);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function proxyUpgrade(req, socket, head) {
|
|
704
|
+
const targetHost = `127.0.0.1:${proxyState.targetPort}`;
|
|
705
|
+
console.log(`[proxy-ws] ${req.url} -> ${targetHost}`);
|
|
706
|
+
const upstream = http.request(`http://${targetHost}${req.url}`, {
|
|
707
|
+
method: 'GET',
|
|
708
|
+
headers: { ...forwardHeaders(req.headers, targetHost), connection: req.headers.connection || 'Upgrade', upgrade: req.headers.upgrade || 'websocket' },
|
|
709
|
+
});
|
|
710
|
+
upstream.on('upgrade', (upRes, upSock, upHead) => {
|
|
711
|
+
const lines = [`HTTP/1.1 ${upRes.statusCode || 101} ${upRes.statusMessage || 'Switching Protocols'}`];
|
|
712
|
+
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
713
|
+
if (Array.isArray(v)) v.forEach((item) => lines.push(`${k}: ${item}`));
|
|
714
|
+
else if (v != null) lines.push(`${k}: ${v}`);
|
|
715
|
+
}
|
|
716
|
+
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
717
|
+
if (head?.length) upSock.write(head);
|
|
718
|
+
if (upHead?.length) socket.write(upHead);
|
|
719
|
+
upSock.on('error', () => socket.destroy());
|
|
720
|
+
socket.on('error', () => upSock.destroy());
|
|
721
|
+
upSock.pipe(socket); socket.pipe(upSock);
|
|
722
|
+
});
|
|
723
|
+
upstream.on('response', (upRes) => { socket.write(`HTTP/1.1 ${upRes.statusCode || 502} ${upRes.statusMessage || 'Bad Gateway'}\r\n\r\n`); socket.destroy(); });
|
|
724
|
+
upstream.on('error', (e) => { console.error(`[proxy-ws] ${req.url} !! ${e.message}`); socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); });
|
|
725
|
+
upstream.end();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ─── Commands & client state ──────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
function resolveClientId(clientId) {
|
|
731
|
+
const id = String(clientId || '').trim();
|
|
732
|
+
if (!id) return { ok: false, code: 400, error: 'clientId required' };
|
|
733
|
+
if (clients.has(id)) return { ok: true, clientId: id, client: clients.get(id) };
|
|
734
|
+
const matches = Array.from(clients.keys()).filter((candidate) => candidate.startsWith(id));
|
|
735
|
+
if (matches.length === 1) return { ok: true, clientId: matches[0], client: clients.get(matches[0]), resolvedFrom: id };
|
|
736
|
+
if (matches.length > 1) return { ok: false, code: 409, error: `Ambiguous clientId prefix ${id}. Matches: ${matches.join(', ')}` };
|
|
737
|
+
return { ok: false, code: 404, error: `Client not found: ${id}. Run 'pages' to see connected clients.` };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function sendToClient(clientId, command) {
|
|
741
|
+
const resolved = resolveClientId(clientId);
|
|
742
|
+
if (!resolved.ok) return { ok: false, code: resolved.code, error: resolved.error };
|
|
743
|
+
wsSend(resolved.client.socket, JSON.stringify({ type: 'command', command }));
|
|
744
|
+
return { ok: true, clientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function broadcastCommand(command, targetId = null) {
|
|
748
|
+
if (targetId) {
|
|
749
|
+
const delivered = sendToClient(targetId, command);
|
|
750
|
+
if (!delivered.ok) return { ok: false, code: delivered.code, error: delivered.error, delivered: 0 };
|
|
751
|
+
const deliveredClient = clients.get(delivered.clientId);
|
|
752
|
+
if (deliveredClient) recordClientActivity(deliveredClient, 'command', { type: command?.type || null });
|
|
753
|
+
return { ok: true, delivered: 1, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom };
|
|
754
|
+
}
|
|
755
|
+
let n = 0;
|
|
756
|
+
for (const c of clients.values()) {
|
|
757
|
+
wsSend(c.socket, JSON.stringify({ type: 'command', command }));
|
|
758
|
+
n++;
|
|
759
|
+
}
|
|
760
|
+
return { ok: true, delivered: n, targetClientId: null, resolvedFrom: null };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function detectBuildMode(msg) {
|
|
764
|
+
if (msg.buildMode) return msg.buildMode;
|
|
765
|
+
const markers = msg.snapshot?.markers || {};
|
|
766
|
+
for (const m of Object.values(markers)) {
|
|
767
|
+
if (m?.resolved?.component?.source?.fileName || m?.resolved?.dom?.source?.fileName) return 'dev';
|
|
768
|
+
if ((m?.resolved?.component?.owners || []).some((o) => o?.source?.fileName)) return 'dev';
|
|
769
|
+
}
|
|
770
|
+
for (const m of Object.values(markers)) { if (m?.resolved?.component?.name) return 'production'; }
|
|
771
|
+
return 'unknown';
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function updateClientFromMessage(client, msg) {
|
|
775
|
+
client.lastMessageAt = nowIso();
|
|
776
|
+
if (msg.type === 'hello') client.lastHelloAt = client.lastMessageAt;
|
|
777
|
+
if (msg.clientId) client.clientId = msg.clientId;
|
|
778
|
+
if (msg.bridgeVersion) client.bridgeVersion = msg.bridgeVersion;
|
|
779
|
+
if (msg.bridgeVersion) client.bridgeVersion = msg.bridgeVersion;
|
|
780
|
+
if (msg.page) client.page = msg.page;
|
|
781
|
+
// TODO: Validate and normalize the complete integration identity here
|
|
782
|
+
// before persisting it. Page selection relies on kind/who/source/reload fields
|
|
783
|
+
// as one contract, but this pass-through accepts partial or arbitrary objects.
|
|
784
|
+
if (msg.integration) client.integration = msg.integration;
|
|
785
|
+
if (typeof msg.probesActive === 'boolean') client.probesActive = msg.probesActive;
|
|
786
|
+
if (typeof msg.activated === 'boolean') client.activated = msg.activated;
|
|
787
|
+
|
|
788
|
+
if (msg.type === 'snapshot' || msg.type === 'hello') {
|
|
789
|
+
if (msg.type === 'snapshot') {
|
|
790
|
+
const previousSnapshot = client.latestSnapshot;
|
|
791
|
+
client.latestSnapshot = msg.snapshot ? { ...msg.snapshot, at: client.lastMessageAt } : null;
|
|
792
|
+
client.latestSnapshotAt = client.lastMessageAt;
|
|
793
|
+
recordClientActivity(client, 'snapshot', { reason: msg.reason || null }, client.lastMessageAt);
|
|
794
|
+
if (msg.reason === 'marker-annotation-change' && client.latestSnapshot) {
|
|
795
|
+
probeAnnotations.publish({
|
|
796
|
+
clientId: client.clientId,
|
|
797
|
+
at: client.lastMessageAt,
|
|
798
|
+
page: client.latestSnapshot.page || client.page || null,
|
|
799
|
+
integration: client.latestSnapshot.integration || client.integration || null,
|
|
800
|
+
previousSnapshot,
|
|
801
|
+
snapshot: client.latestSnapshot,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (msg.type === 'hello') {
|
|
806
|
+
recordClientActivity(client, msg.reason || 'hello', null, client.lastMessageAt);
|
|
807
|
+
}
|
|
808
|
+
const mode = detectBuildMode(msg);
|
|
809
|
+
if (mode !== 'unknown') client.buildMode = mode;
|
|
810
|
+
}
|
|
811
|
+
if (msg.type === 'activity' && msg.activity) {
|
|
812
|
+
recordClientActivity(client, msg.activity.reason || 'activity', msg.activity.detail || null, msg.activity.at || client.lastMessageAt);
|
|
813
|
+
// Record the most recent collateral-boundary selection distinctly so lookups
|
|
814
|
+
// and commands can read which owner/error/suspense boundary the user picked.
|
|
815
|
+
if (msg.activity.reason === 'inspect-boundary') {
|
|
816
|
+
client.lastInspectedBoundary = { ...(msg.activity.detail || {}), at: msg.activity.at || client.lastMessageAt };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ─── WebSocket attachment ─────────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
function attachSocket(socket) {
|
|
824
|
+
const id = crypto.randomUUID();
|
|
825
|
+
const client = {
|
|
826
|
+
socket, clientId: id,
|
|
827
|
+
connectedAt: nowIso(), lastMessageAt: nowIso(), lastHelloAt: null,
|
|
828
|
+
bridgeVersion: null,
|
|
829
|
+
page: { url: null, title: null, storyId: null },
|
|
830
|
+
integration: null,
|
|
831
|
+
activated: false, probesActive: false, buildMode: 'unknown',
|
|
832
|
+
latestSnapshotAt: null, latestSnapshot: null,
|
|
833
|
+
};
|
|
834
|
+
clients.set(id, client);
|
|
835
|
+
|
|
836
|
+
let buf = Buffer.alloc(0);
|
|
837
|
+
const frag = {};
|
|
838
|
+
|
|
839
|
+
wsSend(socket, JSON.stringify({ type: 'connected', clientId: id, connectedAt: client.connectedAt }));
|
|
840
|
+
|
|
841
|
+
socket.on('data', (chunk) => {
|
|
842
|
+
buf = Buffer.concat([buf, chunk]);
|
|
843
|
+
const { messages, remainder } = wsParseFrames(buf, frag);
|
|
844
|
+
buf = remainder;
|
|
845
|
+
for (const frame of messages) {
|
|
846
|
+
if (frame.opcode === 0x8) { socket.end(); return; }
|
|
847
|
+
if (frame.opcode === 0x9) { wsSend(socket, frame.payload, 0xA); continue; }
|
|
848
|
+
if (frame.opcode !== 0x1) continue;
|
|
849
|
+
try {
|
|
850
|
+
const msg = JSON.parse(frame.payload.toString('utf8'));
|
|
851
|
+
const prevId = client.clientId;
|
|
852
|
+
updateClientFromMessage(client, msg);
|
|
853
|
+
if (client.clientId !== prevId) { clients.delete(prevId); clients.set(client.clientId, client); }
|
|
854
|
+
if (msg.type === 'hello') warnDuplicatesIfNeeded();
|
|
855
|
+
if (msg.type === 'computed-style-result' && msg.requestId) {
|
|
856
|
+
resolvePendingResult(pendingComputedStyle, msg.requestId, msg.result);
|
|
857
|
+
}
|
|
858
|
+
if (msg.type === 'eval-probe-result' && msg.requestId) {
|
|
859
|
+
resolvePendingResult(pendingEvalProbe, msg.requestId, msg.result);
|
|
860
|
+
resolvePendingResult(pendingProbeCommand, msg.requestId, msg.result);
|
|
861
|
+
}
|
|
862
|
+
if (msg.type === 'eval-avalanche-result' && msg.requestId) {
|
|
863
|
+
resolvePendingResult(pendingAvalanche, msg.requestId, msg.result);
|
|
864
|
+
}
|
|
865
|
+
if (msg.type === 'eval-script-result' && msg.requestId) {
|
|
866
|
+
resolvePendingResult(pendingEvalScript, msg.requestId, msg.result);
|
|
867
|
+
}
|
|
868
|
+
if (msg.type === 'reload-ack' && msg.requestId) {
|
|
869
|
+
resolvePendingResult(pendingReloadAck, msg.requestId, {
|
|
870
|
+
ok: msg.ok !== false,
|
|
871
|
+
ack: true,
|
|
872
|
+
action: msg.action || 'page-reload',
|
|
873
|
+
clientId: client.clientId,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
} catch (e) {
|
|
877
|
+
console.error(`[ws] parse error client=${client.clientId}: ${e.message}`);
|
|
878
|
+
wsSend(socket, JSON.stringify({ type: 'error', message: e.message }));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// A reload can overlap two sockets that intentionally reuse the same
|
|
884
|
+
// session-stored client id. Once the new socket replaces the map entry, the
|
|
885
|
+
// old socket must not delete that newer owner when it finally closes.
|
|
886
|
+
const cleanup = () => {
|
|
887
|
+
if (clients.get(client.clientId) === client) clients.delete(client.clientId);
|
|
888
|
+
};
|
|
889
|
+
socket.on('close', cleanup);
|
|
890
|
+
socket.on('end', cleanup);
|
|
891
|
+
socket.on('error', cleanup);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ─── Inject bundle ────────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
function getInjectBundle() {
|
|
897
|
+
return INJECT_FILES.map((f) => {
|
|
898
|
+
const src = fs.readFileSync(path.join(EXTENSION_ROOT, f), 'utf8');
|
|
899
|
+
return `\n// ---- extension/${f} ----\n${src}\n`;
|
|
900
|
+
}).join('\n');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Serve a file from EXTENSION_ROOT with a clear, actionable response when it is
|
|
904
|
+
// missing. A missing asset is a 404 (not a 500) with the resolved path and a
|
|
905
|
+
// hint, so callers (Playwright `addScriptTag`, browser injection) get a
|
|
906
|
+
// diagnosable error instead of an opaque "Failed to load script".
|
|
907
|
+
function serveExtensionFile(res, fileName, contentType, transform = null) {
|
|
908
|
+
const filePath = path.join(EXTENSION_ROOT, fileName);
|
|
909
|
+
let src;
|
|
910
|
+
try {
|
|
911
|
+
src = fs.readFileSync(filePath, 'utf8');
|
|
912
|
+
} catch (e) {
|
|
913
|
+
if (e && e.code === 'ENOENT') {
|
|
914
|
+
json(res, 404, {
|
|
915
|
+
ok: false,
|
|
916
|
+
error: `extension asset not found: ${fileName}`,
|
|
917
|
+
path: filePath,
|
|
918
|
+
extensionRoot: EXTENSION_ROOT,
|
|
919
|
+
hint: `File is missing from EXTENSION_ROOT. If it is a generated bundle (avalanche.iife.js), run 'yarn avalanche:sync'. Otherwise confirm the helper was started from the correct skill directory.`,
|
|
920
|
+
});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
json(res, 500, { ok: false, error: e.message, path: filePath });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
text(res, 200, transform ? transform(src) : src, contentType);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function getClassicInjectBundle() {
|
|
930
|
+
const integration = JSON.stringify(IMPORT_INTEGRATION);
|
|
931
|
+
return [
|
|
932
|
+
'// Chippy helper classic import. This document-scoped integration must be re-injected after reload.',
|
|
933
|
+
'globalThis.__CHIPPY = globalThis.__CHIPPY || {};',
|
|
934
|
+
`globalThis.__CHIPPY.config = { ...(globalThis.__CHIPPY.config || {}), integration: ${integration} };`,
|
|
935
|
+
getInjectBundle(),
|
|
936
|
+
].join('\n\n');
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function boolParam(url, name, fallback) {
|
|
940
|
+
const value = url.searchParams.get(name);
|
|
941
|
+
if (value == null || value === '') return fallback;
|
|
942
|
+
if (/^(1|true|yes|on)$/i.test(value)) return true;
|
|
943
|
+
if (/^(0|false|no|off)$/i.test(value)) return false;
|
|
944
|
+
return fallback;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function stringParam(url, name, fallback, allowed = null) {
|
|
948
|
+
const value = url.searchParams.get(name);
|
|
949
|
+
if (value == null || value === '') return fallback;
|
|
950
|
+
if (allowed && !allowed.includes(value)) return fallback;
|
|
951
|
+
return value;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
function normalizeArmOrigin(value) {
|
|
956
|
+
if (!value || typeof value !== 'string') return '';
|
|
957
|
+
try { return new URL(value).origin; } catch { return value; }
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function getInjectEsmBundle(req, url = new URL(req.url, baseUrlFromReq(req))) {
|
|
961
|
+
const helperBaseUrl = baseUrlFromReq(req);
|
|
962
|
+
const bootConfig = {
|
|
963
|
+
helperBaseUrl,
|
|
964
|
+
activated: boolParam(url, 'activated', true),
|
|
965
|
+
ui: stringParam(url, 'ui', 'none', ['panel', 'storybook-panel', 'none']),
|
|
966
|
+
ownerType: stringParam(url, 'ownerType', 'import-mjs'),
|
|
967
|
+
ownerId: stringParam(url, 'ownerId', 'import-mjs'),
|
|
968
|
+
transport: stringParam(url, 'transport', 'import-mjs'),
|
|
969
|
+
mode: stringParam(url, 'mode', 'chippy', ['chippy', 'avalanche', 'idle']),
|
|
970
|
+
staleAfterMs: Number(url.searchParams.get('staleAfterMs') || 15000),
|
|
971
|
+
integration: IMPORT_INTEGRATION,
|
|
972
|
+
};
|
|
973
|
+
const runtimeAliases = [
|
|
974
|
+
'const global = globalThis;',
|
|
975
|
+
'const window = globalThis;',
|
|
976
|
+
'const self = globalThis;',
|
|
977
|
+
'const document = globalThis.document;',
|
|
978
|
+
`const __chippyBootConfig = ${JSON.stringify(bootConfig)};`,
|
|
979
|
+
`globalThis.__CHIPPY_STARTUP_CONFIG = { ...(globalThis.__CHIPPY_STARTUP_CONFIG || {}), ...__chippyBootConfig };`,
|
|
980
|
+
`globalThis.__CHIPPY = globalThis.__CHIPPY || {};`,
|
|
981
|
+
`globalThis.__CHIPPY.config = { ...(globalThis.__CHIPPY.config || {}), ...__chippyBootConfig };`,
|
|
982
|
+
`function __chippyAcquireOwner(nextOwner) {`,
|
|
983
|
+
` const now = Date.now();`,
|
|
984
|
+
` const current = globalThis.__CHIPPY_OWNER__ || null;`,
|
|
985
|
+
` const staleAfterMs = Number(nextOwner.staleAfterMs || 15000);`,
|
|
986
|
+
` if (!current || current.ownerId === nextOwner.ownerId || (current.heartbeatAt && now - current.heartbeatAt > staleAfterMs)) {`,
|
|
987
|
+
` const owner = { ...(current?.ownerId === nextOwner.ownerId ? current : {}), ...nextOwner, startedAt: current?.ownerId === nextOwner.ownerId ? (current.startedAt || now) : now, heartbeatAt: now };`,
|
|
988
|
+
` globalThis.__CHIPPY_OWNER__ = owner;`,
|
|
989
|
+
` globalThis.__CHIPPY_LOCK__ = globalThis.__CHIPPY_LOCK__ || {};`,
|
|
990
|
+
` globalThis.__CHIPPY_LOCK__.get = () => globalThis.__CHIPPY_OWNER__ || null;`,
|
|
991
|
+
` globalThis.__CHIPPY_LOCK__.heartbeat = (ownerId, patch = {}) => {`,
|
|
992
|
+
` if (globalThis.__CHIPPY_OWNER__?.ownerId !== ownerId) return { ok: false, reason: 'not-owner', owner: globalThis.__CHIPPY_OWNER__ || null };`,
|
|
993
|
+
` globalThis.__CHIPPY_OWNER__ = { ...globalThis.__CHIPPY_OWNER__, ...patch, heartbeatAt: Date.now() };`,
|
|
994
|
+
` return { ok: true, owner: globalThis.__CHIPPY_OWNER__ };`,
|
|
995
|
+
` };`,
|
|
996
|
+
` globalThis.__CHIPPY_LOCK__.release = (ownerId) => {`,
|
|
997
|
+
` if (globalThis.__CHIPPY_OWNER__?.ownerId !== ownerId) return { ok: false, reason: 'not-owner', owner: globalThis.__CHIPPY_OWNER__ || null };`,
|
|
998
|
+
` const owner = globalThis.__CHIPPY_OWNER__;`,
|
|
999
|
+
` delete globalThis.__CHIPPY_OWNER__;`,
|
|
1000
|
+
` return { ok: true, owner };`,
|
|
1001
|
+
` };`,
|
|
1002
|
+
` return { ok: true, owner, reused: current?.ownerId === nextOwner.ownerId, replacedStale: !!(current && current.ownerId !== nextOwner.ownerId) };`,
|
|
1003
|
+
` }`,
|
|
1004
|
+
` return { ok: false, reason: 'owned-by-other-chippy', owner: current };`,
|
|
1005
|
+
`}`,
|
|
1006
|
+
`const __chippyOwnerResult = __chippyAcquireOwner(__chippyBootConfig);`,
|
|
1007
|
+
`globalThis.__CHIPPY_OWNER_RESULT__ = __chippyOwnerResult;`,
|
|
1008
|
+
`if (!__chippyOwnerResult.ok) {`,
|
|
1009
|
+
` globalThis.__CHIPPY_BLOCKED__ = __chippyOwnerResult;`,
|
|
1010
|
+
` throw new Error('Chippy runtime already owned by ' + (__chippyOwnerResult.owner?.ownerType || __chippyOwnerResult.owner?.ownerId || 'another owner'));`,
|
|
1011
|
+
`}`,
|
|
1012
|
+
].join('\n');
|
|
1013
|
+
const avalancheUrl = JSON.stringify(`${helperBaseUrl}${CHIPPY_PREFIX}/avalanche.es.js`);
|
|
1014
|
+
return [
|
|
1015
|
+
'// Chippy helper ESM entry. Import side-effect installs window.__CHIPPY and loads Avalanche.',
|
|
1016
|
+
runtimeAliases,
|
|
1017
|
+
getInjectBundle(),
|
|
1018
|
+
'if (!globalThis.__AVALANCHE__) {',
|
|
1019
|
+
` const __avMod = await import(${avalancheUrl});`,
|
|
1020
|
+
' // avalanche.es.js only `export { api as default }` — unlike the iife bundle it',
|
|
1021
|
+
' // does not assign globalThis.__AVALANCHE__. Populate it from the module default',
|
|
1022
|
+
' // so getLab()/fiber-walk works on the import path exactly like the extension.',
|
|
1023
|
+
' const __av = (__avMod && (__avMod.default || __avMod.api)) || null;',
|
|
1024
|
+
' if (__av && !globalThis.__AVALANCHE__) {',
|
|
1025
|
+
' globalThis.__AVALANCHE__ = __av;',
|
|
1026
|
+
' if (__av.default && typeof __av.default === "object") Object.assign(__av, __av.default);',
|
|
1027
|
+
' }',
|
|
1028
|
+
'}',
|
|
1029
|
+
'if (__chippyBootConfig.activated) {',
|
|
1030
|
+
' globalThis.__CHIPPY?.pageBridge?.activate?.();',
|
|
1031
|
+
' globalThis.__CHIPPY?.pageBridge?.ensureProbes?.();',
|
|
1032
|
+
'}',
|
|
1033
|
+
'const __chippyReady = Promise.resolve().then(() => ({',
|
|
1034
|
+
' chippy: globalThis.__CHIPPY || null,',
|
|
1035
|
+
' avalanche: globalThis.__AVALANCHE__ || null,',
|
|
1036
|
+
' connState: globalThis.__CHIPPY?.pageBridge?.getState?.().connState || globalThis.__CHIPPY?.connState || null,',
|
|
1037
|
+
' integration: globalThis.__CHIPPY?.pageBridge?.getState?.().integration || __chippyBootConfig.integration,',
|
|
1038
|
+
' activated: globalThis.__CHIPPY?.pageBridge?.getState?.().activated ?? null,',
|
|
1039
|
+
' probesActive: globalThis.__CHIPPY?.pageBridge?.getState?.().probesActive ?? null,',
|
|
1040
|
+
'}));',
|
|
1041
|
+
'export { __chippyReady as ready };',
|
|
1042
|
+
'export default globalThis.__CHIPPY;',
|
|
1043
|
+
].join('\n\n');
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ─── HTTP routing ─────────────────────────────────────────────────────────────
|
|
1047
|
+
|
|
1048
|
+
async function handleChippyHttp(req, res, url) {
|
|
1049
|
+
const method = req.method || 'GET';
|
|
1050
|
+
const p = url.pathname;
|
|
1051
|
+
|
|
1052
|
+
if (method === 'OPTIONS') { json(res, 200, { ok: true }); return true; }
|
|
1053
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/health`) { json(res, 200, { ok: true, version: CHIPPY_VERSION, now: nowIso() }); return true; }
|
|
1054
|
+
if (method === 'GET' && p === CHIPPY_PREFIX) { json(res, 200, { ok: true, message: 'Chippy helper server is running.', version: CHIPPY_VERSION }); return true; }
|
|
1055
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/state`) { json(res, 200, buildState(req)); return true; }
|
|
1056
|
+
|
|
1057
|
+
if (method === 'GET' && (p === `${CHIPPY_PREFIX}/avalanche.iife.js.map` || p === `${CHIPPY_PREFIX}/avalanche.es.js.map`)) {
|
|
1058
|
+
serveExtensionFile(res, p.slice(CHIPPY_PREFIX.length + 1), 'application/json; charset=utf-8');
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Inject bundle
|
|
1063
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/inject.js`) {
|
|
1064
|
+
try { text(res, 200, getClassicInjectBundle(), 'application/javascript; charset=utf-8'); }
|
|
1065
|
+
catch (e) { json(res, 500, { ok: false, error: e.message }); }
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/avalanche.es.js`) {
|
|
1070
|
+
serveExtensionFile(res, 'avalanche.es.js', 'text/javascript; charset=utf-8');
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/inject.mjs`) {
|
|
1075
|
+
try { text(res, 200, getInjectEsmBundle(req), 'text/javascript; charset=utf-8'); }
|
|
1076
|
+
catch (e) { json(res, 500, { ok: false, error: e.message }); }
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Avalanche bundle (served separately so Playwright fallback can inject via URL,
|
|
1081
|
+
// not via filesystem path. Bundle is intentionally NOT included in the inject
|
|
1082
|
+
// bundle above — the extension installs Avalanche pre-document via
|
|
1083
|
+
// chrome.scripting.MAIN, and this route exists for the local Playwright path
|
|
1084
|
+
// when the page bundle does not ship Avalanche).
|
|
1085
|
+
// DevTools hook shim. MUST be loaded BEFORE react-dom evaluates so that
|
|
1086
|
+
// react-dom registers its renderer (and currentDispatcherRef) into the
|
|
1087
|
+
// hook's renderers Map. Without this, debug-render cannot capture hook
|
|
1088
|
+
// call stacks and `debugHook()` collapses to a flat fallback.
|
|
1089
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/devtools-hook-shim.js`) {
|
|
1090
|
+
serveExtensionFile(res, 'devtools-hook-shim.js', 'application/javascript; charset=utf-8');
|
|
1091
|
+
return true;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/avalanche.iife.js`) {
|
|
1095
|
+
serveExtensionFile(res, 'avalanche.iife.js', 'application/javascript; charset=utf-8', (src) =>
|
|
1096
|
+
`${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`);
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/recent-pages`) {
|
|
1101
|
+
json(res, 200, { ok: true, count: recentPageActivity.length, pages: recentPageActivity.slice(0, 10) });
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Pages list
|
|
1106
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/pages`) {
|
|
1107
|
+
const { slice, boundaries } = sliceParams(url);
|
|
1108
|
+
const pages = Array.from(clients.values()).map((c) => summarizeClient(c, slice || 'minimal', boundaries));
|
|
1109
|
+
const dupes = findDuplicatePageGroups();
|
|
1110
|
+
if (pages.length === 0) {
|
|
1111
|
+
json(res, 200, { ok: true, count: 0, hint: 'No clients connected. Open a page with the Chippy extension enabled and reload it.', duplicatePageWarnings: null, pages: [] });
|
|
1112
|
+
} else {
|
|
1113
|
+
json(res, 200, { ok: true, count: pages.length, duplicatePageWarnings: Object.keys(dupes).length ? dupes : null, pages });
|
|
1114
|
+
}
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Single page
|
|
1119
|
+
if (method === 'GET' && /^\/__chippy\/api\/pages\/[^/]+$/.test(p)) {
|
|
1120
|
+
const clientId = p.slice(`${CHIPPY_PREFIX}/api/pages/`.length);
|
|
1121
|
+
const resolved = resolveClientId(clientId);
|
|
1122
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1123
|
+
const { slice, boundaries } = sliceParams(url);
|
|
1124
|
+
json(res, 200, { ok: true, client: summarizeClient(resolved.client, slice || 'standard', boundaries), targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null });
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Stored snapshot
|
|
1129
|
+
if (method === 'GET' && /^\/__chippy\/api\/pages\/[^/]+\/snapshot$/.test(p)) {
|
|
1130
|
+
const clientId = p.split('/')[4];
|
|
1131
|
+
const resolved = resolveClientId(clientId);
|
|
1132
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1133
|
+
const { slice, boundaries } = sliceParams(url);
|
|
1134
|
+
const snapshot = sliceSnapshot(resolved.client.latestSnapshot, slice || 'standard', boundaries);
|
|
1135
|
+
json(res, 200, {
|
|
1136
|
+
ok: true,
|
|
1137
|
+
targetClientId: resolved.clientId,
|
|
1138
|
+
resolvedFrom: resolved.resolvedFrom || null,
|
|
1139
|
+
latestSnapshotAt: resolved.client.latestSnapshotAt || null,
|
|
1140
|
+
snapshot,
|
|
1141
|
+
});
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Request snapshot
|
|
1146
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/snapshot$/.test(p)) {
|
|
1147
|
+
const clientId = p.split('/')[4];
|
|
1148
|
+
const sent = sendToClient(clientId, { type: 'request-snapshot' });
|
|
1149
|
+
json(res, sent.ok ? 200 : sent.code, sent.ok ? { ok: true, requested: sent.clientId, resolvedFrom: sent.resolvedFrom } : { ok: false, error: sent.error });
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Wait for the next user-attached probe annotation. This is an explicit,
|
|
1154
|
+
// cancellable long poll owned by the calling agent; Chippy never installs or
|
|
1155
|
+
// modifies host lifecycle hooks to deliver these events.
|
|
1156
|
+
if (method === 'GET' && /^\/__chippy\/api\/pages\/[^/]+\/probe-annotations\/wait$/.test(p)) {
|
|
1157
|
+
const clientId = p.split('/')[4];
|
|
1158
|
+
const resolved = resolveClientId(clientId);
|
|
1159
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1160
|
+
const timeoutRaw = url.searchParams.get('timeoutMs') || '300000';
|
|
1161
|
+
const timeoutMs = Number(timeoutRaw);
|
|
1162
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 1_800_000) {
|
|
1163
|
+
json(res, 400, { ok: false, error: 'timeoutMs must be an integer from 1 through 1800000' });
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const pending = probeAnnotations.wait(resolved.clientId, timeoutMs);
|
|
1168
|
+
const cancel = () => pending.cancel();
|
|
1169
|
+
req.once('aborted', cancel);
|
|
1170
|
+
res.once('close', cancel);
|
|
1171
|
+
const event = await pending.promise;
|
|
1172
|
+
req.removeListener('aborted', cancel);
|
|
1173
|
+
res.removeListener('close', cancel);
|
|
1174
|
+
if (req.aborted || res.destroyed) return true;
|
|
1175
|
+
json(res, 200, {
|
|
1176
|
+
ok: true,
|
|
1177
|
+
status: event ? 'annotation' : 'timeout',
|
|
1178
|
+
targetClientId: resolved.clientId,
|
|
1179
|
+
resolvedFrom: resolved.resolvedFrom || null,
|
|
1180
|
+
timeoutMs,
|
|
1181
|
+
event,
|
|
1182
|
+
});
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Command
|
|
1187
|
+
if (method === 'POST' && p === `${CHIPPY_PREFIX}/api/command`) {
|
|
1188
|
+
try {
|
|
1189
|
+
const body = await parseJsonBody(req);
|
|
1190
|
+
const command = body.command || body;
|
|
1191
|
+
const delivered = broadcastCommand(command, body.clientId || null);
|
|
1192
|
+
json(res, delivered.ok ? 200 : delivered.code, delivered.ok ? { ok: true, delivered: delivered.delivered, targetClientId: delivered.targetClientId, resolvedFrom: delivered.resolvedFrom, command } : { ok: false, delivered: 0, error: delivered.error, command });
|
|
1193
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// avalanche: POST /api/pages/:clientId/avalanche { action?, max?, count?, timeoutMs? }
|
|
1198
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/avalanche$/.test(p)) {
|
|
1199
|
+
const clientId = p.split('/')[4];
|
|
1200
|
+
try {
|
|
1201
|
+
const body = await parseJsonBody(req);
|
|
1202
|
+
const result = await requestAvalanche(clientId, body);
|
|
1203
|
+
json(res, result.code, result.payload);
|
|
1204
|
+
} catch (e) {
|
|
1205
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1206
|
+
}
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Capture one compact structural fiber/prop state. Full capture data remains
|
|
1211
|
+
// helper-side; the response returns only an id and summary.
|
|
1212
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/fiber-state-captures$/.test(p)) {
|
|
1213
|
+
const clientId = p.split('/')[4];
|
|
1214
|
+
try {
|
|
1215
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1216
|
+
const result = await requestProbeCommand(clientId, buildFiberStateCaptureCommand(body), body.timeoutMs);
|
|
1217
|
+
if (!result.ok) { json(res, result.code, result.payload); return true; }
|
|
1218
|
+
const state = result.payload.result;
|
|
1219
|
+
if (!state || state.ok === false || state.version !== 1 || !Array.isArray(state.fibers)) {
|
|
1220
|
+
json(res, 502, { ok: false, error: state?.error || 'Page returned an invalid fiber state capture' });
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
const captureId = `state:${crypto.randomUUID().slice(0, 8)}`;
|
|
1224
|
+
const entry = {
|
|
1225
|
+
captureId,
|
|
1226
|
+
label: body.label == null || body.label === '' ? null : String(body.label),
|
|
1227
|
+
clientId: result.payload.targetClientId,
|
|
1228
|
+
state,
|
|
1229
|
+
};
|
|
1230
|
+
retainFiberStateCapture(entry);
|
|
1231
|
+
json(res, 200, { ok: true, capture: summarizeFiberStateCapture(entry) });
|
|
1232
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1233
|
+
return true;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/fiber-state-captures`) {
|
|
1237
|
+
json(res, 200, { ok: true, count: fiberStateCaptures.size, captures: Array.from(fiberStateCaptures.values()).map(summarizeFiberStateCapture) });
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Compare may use captures from different client/page realms. The compact
|
|
1242
|
+
// states are sent to any still-connected page so the bundled diff algorithm
|
|
1243
|
+
// remains the sole implementation.
|
|
1244
|
+
if (method === 'POST' && p === `${CHIPPY_PREFIX}/api/fiber-state-comparisons`) {
|
|
1245
|
+
try {
|
|
1246
|
+
const body = await parseJsonBody(req);
|
|
1247
|
+
const before = resolveFiberStateCapture(body.before);
|
|
1248
|
+
const after = resolveFiberStateCapture(body.after);
|
|
1249
|
+
if (!before.ok) { json(res, before.code, { ok: false, error: before.error }); return true; }
|
|
1250
|
+
if (!after.ok) { json(res, after.code, { ok: false, error: after.error }); return true; }
|
|
1251
|
+
const preferredClientIds = [body.clientId, after.capture.clientId, before.capture.clientId].filter(Boolean);
|
|
1252
|
+
let compareClientId = preferredClientIds.find((id) => resolveClientId(id).ok) || null;
|
|
1253
|
+
if (!compareClientId) compareClientId = clients.keys().next().value || null;
|
|
1254
|
+
if (!compareClientId) {
|
|
1255
|
+
json(res, 409, { ok: false, error: 'No connected page is available to run the fiber state comparison' });
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
const result = await requestProbeCommand(compareClientId, buildFiberStateCompareCommand(
|
|
1259
|
+
before.capture.state,
|
|
1260
|
+
after.capture.state,
|
|
1261
|
+
body,
|
|
1262
|
+
), body.timeoutMs);
|
|
1263
|
+
if (!result.ok) { json(res, result.code, result.payload); return true; }
|
|
1264
|
+
const diff = result.payload.result;
|
|
1265
|
+
json(res, diff?.ok === false ? 400 : 200, {
|
|
1266
|
+
ok: diff?.ok !== false,
|
|
1267
|
+
before: summarizeFiberStateCapture(before.capture),
|
|
1268
|
+
after: summarizeFiberStateCapture(after.capture),
|
|
1269
|
+
diff,
|
|
1270
|
+
});
|
|
1271
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// eval-script: POST /api/pages/:clientId/eval-script { source, args?, timeoutMs?, scriptTimeoutMs? }
|
|
1276
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/eval-script$/.test(p)) {
|
|
1277
|
+
const clientId = p.split('/')[4];
|
|
1278
|
+
try {
|
|
1279
|
+
const body = await parseJsonBody(req);
|
|
1280
|
+
const result = await requestEvalScript(clientId, body);
|
|
1281
|
+
json(res, result.code, result.payload);
|
|
1282
|
+
} catch (e) {
|
|
1283
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1284
|
+
}
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/page-click$/.test(p)) {
|
|
1289
|
+
const clientId = p.split('/')[4];
|
|
1290
|
+
try {
|
|
1291
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1292
|
+
const result = await requestProbeCommand(clientId, {
|
|
1293
|
+
type: 'page-click',
|
|
1294
|
+
target: {
|
|
1295
|
+
selector: body.selector,
|
|
1296
|
+
probeId: body.probeId,
|
|
1297
|
+
fiberId: body.fiberId,
|
|
1298
|
+
},
|
|
1299
|
+
}, body.timeoutMs);
|
|
1300
|
+
json(res, result.code, result.payload);
|
|
1301
|
+
} catch (e) {
|
|
1302
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1303
|
+
}
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/fiber-invoke$/.test(p)) {
|
|
1308
|
+
const clientId = p.split('/')[4];
|
|
1309
|
+
try {
|
|
1310
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1311
|
+
const result = await requestProbeCommand(clientId, {
|
|
1312
|
+
type: 'fiber-invoke',
|
|
1313
|
+
fiberId: body.fiberId,
|
|
1314
|
+
propName: body.propName,
|
|
1315
|
+
args: body.args,
|
|
1316
|
+
}, body.timeoutMs);
|
|
1317
|
+
json(res, result.code, result.payload);
|
|
1318
|
+
} catch (e) {
|
|
1319
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1320
|
+
}
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/fiber-schedule-update$/.test(p)) {
|
|
1325
|
+
const clientId = p.split('/')[4];
|
|
1326
|
+
try {
|
|
1327
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1328
|
+
const result = await requestProbeCommand(clientId, {
|
|
1329
|
+
type: 'fiber-schedule-update',
|
|
1330
|
+
fiberId: body.fiberId,
|
|
1331
|
+
ownerIndex: body.ownerIndex,
|
|
1332
|
+
}, body.timeoutMs);
|
|
1333
|
+
json(res, result.code, result.payload);
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1336
|
+
}
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/fiber-prop-contract$/.test(p)) {
|
|
1341
|
+
const clientId = p.split('/')[4];
|
|
1342
|
+
try {
|
|
1343
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1344
|
+
const result = await requestProbeCommand(clientId, {
|
|
1345
|
+
type: 'fiber-prop-contract',
|
|
1346
|
+
fiberId: body.fiberId,
|
|
1347
|
+
propName: body.propName,
|
|
1348
|
+
}, body.timeoutMs);
|
|
1349
|
+
json(res, result.code, result.payload);
|
|
1350
|
+
} catch (e) {
|
|
1351
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1352
|
+
}
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/handoff-to-playwright$/.test(p)) {
|
|
1357
|
+
const clientId = p.split('/')[4];
|
|
1358
|
+
try {
|
|
1359
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1360
|
+
const probeIds = Array.isArray(body.probeIds)
|
|
1361
|
+
? body.probeIds
|
|
1362
|
+
: String(body.probeIds || '1,2').split(',').map((id) => id.trim()).filter(Boolean);
|
|
1363
|
+
const result = await handoffToPlaywright({
|
|
1364
|
+
clientId,
|
|
1365
|
+
url: body.url,
|
|
1366
|
+
probeIds,
|
|
1367
|
+
browserName: body.browserName,
|
|
1368
|
+
headless: body.headless,
|
|
1369
|
+
});
|
|
1370
|
+
json(res, result.code, result.payload);
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
if (e && e.code === PLAYWRIGHT_UNAVAILABLE_CODE) {
|
|
1373
|
+
json(res, 503, { ok: false, code: PLAYWRIGHT_UNAVAILABLE_CODE, error: e.message });
|
|
1374
|
+
} else {
|
|
1375
|
+
json(res, 400, { ok: false, error: e.message });
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
// avalanche-arm: POST /api/avalanche-arm { origin?, clientId?, autoStart?, max? }
|
|
1383
|
+
if (method === 'POST' && p === `${CHIPPY_PREFIX}/api/avalanche-arm`) {
|
|
1384
|
+
try {
|
|
1385
|
+
const body = await parseJsonBody(req);
|
|
1386
|
+
const origin = body.origin ? normalizeArmOrigin(String(body.origin).trim()) : null;
|
|
1387
|
+
const clientId = body.clientId ? String(body.clientId).trim() : null;
|
|
1388
|
+
if (!origin && !clientId) { json(res, 400, { ok: false, error: 'origin or clientId required' }); return true; }
|
|
1389
|
+
const autoStart = body.autoStart !== false;
|
|
1390
|
+
if (autoStart) {
|
|
1391
|
+
const incomingOptions = body.options && typeof body.options === 'object' ? body.options : {};
|
|
1392
|
+
// TODO: Validate detectionMode against the detector's canonical
|
|
1393
|
+
// modes at this transport boundary. An arbitrary string can currently
|
|
1394
|
+
// reach createAvalancheDetector and leave its internal mode state unset.
|
|
1395
|
+
const arm = {
|
|
1396
|
+
autoStart: true,
|
|
1397
|
+
options: {
|
|
1398
|
+
captureFibers: incomingOptions.captureFibers !== false,
|
|
1399
|
+
detectionMode: typeof incomingOptions.detectionMode === 'string' ? incomingOptions.detectionMode : 'sources',
|
|
1400
|
+
},
|
|
1401
|
+
};
|
|
1402
|
+
if (body.max != null && Number.isFinite(Number(body.max)) && Number(body.max) >= 0) arm.max = Math.floor(Number(body.max));
|
|
1403
|
+
if (origin) armedAvalanche.set(origin, arm);
|
|
1404
|
+
if (clientId) armedAvalancheByClient.set(clientId, arm);
|
|
1405
|
+
json(res, 200, { ok: true, armed: true, origin, clientId, config: arm });
|
|
1406
|
+
} else {
|
|
1407
|
+
if (origin) armedAvalanche.delete(origin);
|
|
1408
|
+
if (clientId) armedAvalancheByClient.delete(clientId);
|
|
1409
|
+
json(res, 200, { ok: true, armed: false, origin, clientId });
|
|
1410
|
+
}
|
|
1411
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/avalanche-arm`) {
|
|
1416
|
+
try {
|
|
1417
|
+
const u = new URL(req.url, 'http://localhost');
|
|
1418
|
+
const origin = normalizeArmOrigin(u.searchParams.get('origin') || '');
|
|
1419
|
+
const clientId = u.searchParams.get('clientId') || '';
|
|
1420
|
+
if (clientId) {
|
|
1421
|
+
const arm = armedAvalancheByClient.get(clientId) || null;
|
|
1422
|
+
json(res, 200, { ok: true, clientId, armed: !!arm, config: arm });
|
|
1423
|
+
} else if (origin) {
|
|
1424
|
+
const arm = armedAvalanche.get(origin) || null;
|
|
1425
|
+
json(res, 200, { ok: true, origin, armed: !!arm, config: arm });
|
|
1426
|
+
} else {
|
|
1427
|
+
json(res, 200, { ok: true, origins: Object.fromEntries(armedAvalanche.entries()), clients: Object.fromEntries(armedAvalancheByClient.entries()) });
|
|
1428
|
+
}
|
|
1429
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/reload$/.test(p)) {
|
|
1434
|
+
try {
|
|
1435
|
+
const clientId = p.split('/')[4];
|
|
1436
|
+
const requestId = crypto.randomUUID();
|
|
1437
|
+
const resolved = resolveClientId(clientId);
|
|
1438
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1439
|
+
const action = resolved.client.integration?.kind === 'storybook'
|
|
1440
|
+
? 'storybook-force-remount'
|
|
1441
|
+
: 'reload';
|
|
1442
|
+
const sent = sendToClient(resolved.clientId, { type: action, requestId });
|
|
1443
|
+
if (!sent.ok) { json(res, sent.code, { ok: false, error: sent.error }); return true; }
|
|
1444
|
+
const ack = await waitForPendingResult(pendingReloadAck, requestId, 1500, 'Timeout waiting for reload ack');
|
|
1445
|
+
json(res, ack.ok ? 200 : 504, { ...ack, reloading: sent.clientId, resolvedFrom: sent.resolvedFrom, requestId });
|
|
1446
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/wait-reload$/.test(p)) {
|
|
1451
|
+
try {
|
|
1452
|
+
const clientId = p.split('/')[4];
|
|
1453
|
+
const body = await parseJsonBody(req).catch(() => ({}));
|
|
1454
|
+
const timeoutMs = Math.max(500, Math.min(60000, Number(body.timeoutMs) || 5000));
|
|
1455
|
+
const resolved = resolveClientId(clientId);
|
|
1456
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1457
|
+
const c = resolved.client;
|
|
1458
|
+
const resolvedClientId = resolved.clientId;
|
|
1459
|
+
if (c.integration?.kind === 'storybook') {
|
|
1460
|
+
json(res, 200, {
|
|
1461
|
+
ok: true,
|
|
1462
|
+
reloaded: true,
|
|
1463
|
+
remounted: true,
|
|
1464
|
+
action: 'storybook-force-remount',
|
|
1465
|
+
clientId: resolvedClientId,
|
|
1466
|
+
resolvedFrom: resolved.resolvedFrom || null,
|
|
1467
|
+
waitedMs: 0,
|
|
1468
|
+
});
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
const before = c.lastHelloAt || c.connectedAt;
|
|
1472
|
+
const start = Date.now();
|
|
1473
|
+
while (Date.now() - start < timeoutMs) {
|
|
1474
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1475
|
+
const cNow = clients.get(resolvedClientId);
|
|
1476
|
+
const after = cNow ? (cNow.lastHelloAt || cNow.connectedAt) : null;
|
|
1477
|
+
if (cNow && after && after !== before) {
|
|
1478
|
+
json(res, 200, { ok: true, reloaded: true, clientId: resolvedClientId, resolvedFrom: resolved.resolvedFrom || null, before, after, waitedMs: Date.now() - start });
|
|
1479
|
+
return true;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
json(res, 200, { ok: true, reloaded: false, clientId: resolvedClientId, resolvedFrom: resolved.resolvedFrom || null, before, timeoutMs });
|
|
1483
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// get-computed-style: POST /api/pages/:clientId/computed-style { probeId, parentLevel?, selector?, timeoutMs? }
|
|
1488
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/computed-style$/.test(p)) {
|
|
1489
|
+
const clientId = p.split('/')[4];
|
|
1490
|
+
try {
|
|
1491
|
+
const body = await parseJsonBody(req);
|
|
1492
|
+
const { probeId, parentLevel = 0, selector, timeoutMs = 5000 } = body;
|
|
1493
|
+
if (!probeId) { json(res, 400, { ok: false, error: 'probeId required' }); return true; }
|
|
1494
|
+
const requestId = crypto.randomUUID();
|
|
1495
|
+
const command = { type: 'eval-computed-style', requestId, probeId: String(probeId), parentLevel: Number(parentLevel) || 0, selector: selector || null };
|
|
1496
|
+
const delivered = sendToClient(clientId, command);
|
|
1497
|
+
if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
|
|
1498
|
+
// Wait for computed-style-result from the page
|
|
1499
|
+
const result = await waitForPendingResult(pendingComputedStyle, requestId, timeoutMs, 'Timeout waiting for computed style result');
|
|
1500
|
+
json(res, result.ok ? 200 : 504, { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
|
|
1501
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// probe-owners: GET /api/pages/:clientId/probe/:probeId/owners — live owner chain, cached snapshot fallback
|
|
1506
|
+
if (method === 'GET' && /^\/__chippy\/api\/pages\/[^/]+\/probe\/[^/]+\/owners$/.test(p)) {
|
|
1507
|
+
const parts = p.split('/');
|
|
1508
|
+
const clientId = parts[4];
|
|
1509
|
+
const probeId = parts[6];
|
|
1510
|
+
const resolved = resolveClientId(clientId);
|
|
1511
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
const live = await requestProbeCommand(resolved.clientId, {
|
|
1515
|
+
type: 'eval-probe-owners',
|
|
1516
|
+
probeId: String(probeId),
|
|
1517
|
+
}, Number(url.searchParams.get('timeoutMs')) || 5000);
|
|
1518
|
+
const livePayload = live.payload?.result && typeof live.payload.result === 'object'
|
|
1519
|
+
? live.payload.result
|
|
1520
|
+
: live.payload;
|
|
1521
|
+
if (livePayload?.ok) {
|
|
1522
|
+
json(res, 200, {
|
|
1523
|
+
...livePayload,
|
|
1524
|
+
targetClientId: resolved.clientId,
|
|
1525
|
+
resolvedFrom: resolved.resolvedFrom || null,
|
|
1526
|
+
source: 'live',
|
|
1527
|
+
});
|
|
1528
|
+
return true;
|
|
1529
|
+
}
|
|
1530
|
+
} catch {
|
|
1531
|
+
// Fall back to the last snapshot below. A stale snapshot is still useful
|
|
1532
|
+
// orientation when the live probe cache is unavailable.
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const c = resolved.client;
|
|
1536
|
+
const marker = c.latestSnapshot?.markers?.[probeId];
|
|
1537
|
+
if (!c.latestSnapshot) {
|
|
1538
|
+
json(res, 200, { ok: false, targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null, probeId, error: `No snapshot for client ${resolved.clientId}. Run 'request-snapshot <clientId>' or place a probe on the page.`, probesActive: Boolean(c.probesActive), latestSnapshotAt: c.latestSnapshotAt || null });
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
if (!marker) {
|
|
1542
|
+
json(res, 200, { ok: false, targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null, probeId, error: `Probe ${probeId} not found in snapshot. Place probe ${probeId} on the page first.`, probesActive: Boolean(c.probesActive), latestSnapshotAt: c.latestSnapshotAt || null });
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
if (!marker?.resolved) {
|
|
1546
|
+
json(res, 200, { ok: false, targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null, probeId, error: `Probe ${probeId} has no resolved data.`, probesActive: Boolean(c.probesActive), latestSnapshotAt: c.latestSnapshotAt || null, marker: sliceMarker(marker, 'minimal') });
|
|
1547
|
+
return true;
|
|
1548
|
+
}
|
|
1549
|
+
const comp = marker.resolved.component || {};
|
|
1550
|
+
const owners = (comp.owners || []).map((o, i) => ({
|
|
1551
|
+
ownerIndex: i,
|
|
1552
|
+
name: o.name || null,
|
|
1553
|
+
key: o.key ?? null,
|
|
1554
|
+
propNames: o.propNames || [],
|
|
1555
|
+
hookCount: o.hookCount ?? null,
|
|
1556
|
+
hookCountTruncated: o.hookCountTruncated ?? false,
|
|
1557
|
+
hookCountMax: o.hookCountMax ?? null,
|
|
1558
|
+
source: o.source || null,
|
|
1559
|
+
}));
|
|
1560
|
+
json(res, 200, { ok: true, targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null, source: 'snapshot', probeId, component: { name: comp.name, propNames: comp.propNames || [], hookCount: comp.hookCount ?? null, hookCountTruncated: comp.hookCountTruncated ?? false, hookCountMax: comp.hookCountMax ?? null }, owners });
|
|
1561
|
+
return true;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// probe-owner: GET /api/pages/:clientId/probe/:probeId/owners/:ownerIndex — single owner from snapshot
|
|
1565
|
+
if (method === 'GET' && /^\/__chippy\/api\/pages\/[^/]+\/probe\/[^/]+\/owners\/\d+$/.test(p)) {
|
|
1566
|
+
const parts = p.split('/');
|
|
1567
|
+
const clientId = parts[4];
|
|
1568
|
+
const probeId = parts[6];
|
|
1569
|
+
const ownerIndex = parseInt(parts[8], 10);
|
|
1570
|
+
const resolved = resolveClientId(clientId);
|
|
1571
|
+
if (!resolved.ok) { json(res, resolved.code, { ok: false, error: resolved.error }); return true; }
|
|
1572
|
+
const c = resolved.client;
|
|
1573
|
+
if (!c.latestSnapshot) { json(res, 404, { ok: false, error: `No snapshot for client ${resolved.clientId}. Run 'request-snapshot <clientId>' first.` }); return true; }
|
|
1574
|
+
const marker = c.latestSnapshot?.markers?.[probeId];
|
|
1575
|
+
if (!marker) { json(res, 404, { ok: false, error: `Probe ${probeId} not found in snapshot. Place probe ${probeId} on the page first.` }); return true; }
|
|
1576
|
+
if (!marker?.resolved) { json(res, 404, { ok: false, error: `Probe ${probeId} has no resolved data. Try refreshing the snapshot.` }); return true; }
|
|
1577
|
+
const owners = marker.resolved.component?.owners || [];
|
|
1578
|
+
const owner = owners[ownerIndex];
|
|
1579
|
+
if (!owner) { json(res, 404, { ok: false, error: 'Owner index ' + ownerIndex + ' not found (chain length: ' + owners.length + ')' }); return true; }
|
|
1580
|
+
json(res, 200, { ok: true, targetClientId: resolved.clientId, resolvedFrom: resolved.resolvedFrom || null, probeId, ownerIndex, name: owner.name || null, key: owner.key ?? null, propNames: owner.propNames || [], hookCount: owner.hookCount ?? null, hookCountTruncated: owner.hookCountTruncated ?? false, hookCountMax: owner.hookCountMax ?? null, source: owner.source || null });
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// eval-probe: POST /api/pages/:clientId/eval-probe { command, probeId, ownerIndex?, timeoutMs? }
|
|
1585
|
+
if (method === 'POST' && /^\/__chippy\/api\/pages\/[^/]+\/eval-probe$/.test(p)) {
|
|
1586
|
+
const clientId = p.split('/')[4];
|
|
1587
|
+
try {
|
|
1588
|
+
const body = await parseJsonBody(req);
|
|
1589
|
+
const { command: evalCommand, probeId, ownerIndex, prop, parentLevel, timeoutMs = 5000 } = body;
|
|
1590
|
+
if (!evalCommand) { json(res, 400, { ok: false, error: 'command required' }); return true; }
|
|
1591
|
+
// Probe-centered commands require a probeId. Fiber-direct commands do not.
|
|
1592
|
+
const PROBE_COMMANDS = new Set(['eval-probe-dom', 'eval-probe-parents', 'eval-probe-boundaries', 'eval-probe-props', 'eval-probe-hooks', 'eval-probe-hooks-summary', 'eval-prop-value']);
|
|
1593
|
+
const FIBER_COMMANDS = new Set(['find-fibers', 'capture-fiber-state', 'compare-fiber-states', 'page-boundaries', 'inspect-fiber-hooks', 'inspect-fiber-hooks-summary', 'inspect-fiber-props', 'inspect-fiber-owners', 'inspect-fiber-parents', 'inspect-fiber-boundaries', 'inspect-fiber-structure', 'describe-component', 'explain-component']);
|
|
1594
|
+
if (PROBE_COMMANDS.has(evalCommand) && !probeId) {
|
|
1595
|
+
json(res, 400, { ok: false, error: 'probeId required. Use probe id "1" or "2".' }); return true;
|
|
1596
|
+
}
|
|
1597
|
+
if (!PROBE_COMMANDS.has(evalCommand) && !FIBER_COMMANDS.has(evalCommand)) {
|
|
1598
|
+
json(res, 400, { ok: false, error: `Unknown eval-probe command: ${evalCommand}` }); return true;
|
|
1599
|
+
}
|
|
1600
|
+
const requestId = crypto.randomUUID();
|
|
1601
|
+
const wsCommand = { type: evalCommand, requestId };
|
|
1602
|
+
if (probeId != null) wsCommand.probeId = String(probeId);
|
|
1603
|
+
if (ownerIndex != null) wsCommand.ownerIndex = Number(ownerIndex);
|
|
1604
|
+
if (prop != null) wsCommand.prop = prop;
|
|
1605
|
+
if (body.parentLevel != null) wsCommand.parentLevel = Number(body.parentLevel);
|
|
1606
|
+
if (body.filters != null) wsCommand.filters = body.filters;
|
|
1607
|
+
if (body.before != null) wsCommand.before = body.before;
|
|
1608
|
+
if (body.after != null) wsCommand.after = body.after;
|
|
1609
|
+
if (body.options != null) wsCommand.options = body.options;
|
|
1610
|
+
if (body.fiberId != null) wsCommand.fiberId = String(body.fiberId);
|
|
1611
|
+
if (body.name != null) wsCommand.name = String(body.name);
|
|
1612
|
+
if (body.hookIndex != null) wsCommand.hookIndex = Number(body.hookIndex);
|
|
1613
|
+
if (body.windowSize != null) wsCommand.windowSize = Number(body.windowSize);
|
|
1614
|
+
if (body.max != null) wsCommand.max = Number(body.max);
|
|
1615
|
+
const delivered = sendToClient(clientId, wsCommand);
|
|
1616
|
+
if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
|
|
1617
|
+
const result = await waitForPendingResult(pendingEvalProbe, requestId, timeoutMs, 'Timeout waiting for eval-probe result');
|
|
1618
|
+
json(res, result.ok ? 200 : 504, { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
|
|
1619
|
+
} catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// Proxy API
|
|
1624
|
+
if (method === 'GET' && p === `${CHIPPY_PREFIX}/api/proxy/state`) { json(res, 200, { ok: true, proxy: getProxySummary() }); return true; }
|
|
1625
|
+
if (method === 'POST' && p === `${CHIPPY_PREFIX}/api/proxy/start`) {
|
|
1626
|
+
try { const body = await parseJsonBody(req); json(res, 200, { ok: true, proxy: startProxy(body.targetPort, body.originalUrl) }); }
|
|
1627
|
+
catch (e) { json(res, 400, { ok: false, error: e.message }); }
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
if (method === 'POST' && p === `${CHIPPY_PREFIX}/api/proxy/stop`) {
|
|
1631
|
+
try { json(res, 200, { ok: true, proxy: stopProxy() }); }
|
|
1632
|
+
catch (e) { json(res, 500, { ok: false, error: e.message }); }
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
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
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function handleChippyUpgrade(req, socket) {
|
|
1656
|
+
const key = req.headers['sec-websocket-key'];
|
|
1657
|
+
if (!key) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; }
|
|
1658
|
+
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
1659
|
+
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);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
1664
|
+
|
|
1665
|
+
function chippyRoute(pathname) {
|
|
1666
|
+
return isChippyPath(pathname) ? pathname.slice(CHIPPY_PREFIX.length) : null;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function rejectUnauthorized(req, pathname) {
|
|
1670
|
+
return helperAccess.authorizeRequest({
|
|
1671
|
+
method: req.method || 'GET',
|
|
1672
|
+
route: chippyRoute(pathname),
|
|
1673
|
+
headers: req.headers,
|
|
1674
|
+
token: helperToken,
|
|
1675
|
+
loopback: loopbackBind,
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const server = http.createServer(async (req, res) => {
|
|
1680
|
+
const url = new URL(req.url, baseUrlFromReq(req));
|
|
1681
|
+
const denied = rejectUnauthorized(req, url.pathname);
|
|
1682
|
+
if (denied) { json(res, denied.status, { ok: false, error: denied.error }); return; }
|
|
1683
|
+
if (isChippyPath(url.pathname)) {
|
|
1684
|
+
if (!(await handleChippyHttp(req, res, url))) json(res, 404, { ok: false, error: 'Chippy route not found' });
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (proxyState.active) { proxyRequest(req, res); return; }
|
|
1688
|
+
json(res, 404, { ok: false, error: 'Not found' });
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1692
|
+
const p = new URL(req.url, 'http://127.0.0.1').pathname;
|
|
1693
|
+
const denied = rejectUnauthorized(req, p);
|
|
1694
|
+
if (denied) { socket.write(`HTTP/1.1 ${denied.status} ${denied.status === 401 ? 'Unauthorized' : 'Forbidden'}\r\n\r\n`); socket.destroy(); return; }
|
|
1695
|
+
if (p === `${CHIPPY_PREFIX}/ws`) { handleChippyUpgrade(req, socket); return; }
|
|
1696
|
+
if (proxyState.active && !isChippyPath(p)) { proxyUpgrade(req, socket, head); return; }
|
|
1697
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
process.on('uncaughtException', (err) => console.error(`[chippy] uncaught: ${err.message}`));
|
|
1701
|
+
|
|
1702
|
+
server.listen(port, host, () => {
|
|
1703
|
+
const base = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
1704
|
+
console.log(`Chippy helper listening on ${base}`);
|
|
1705
|
+
const tokenFile = helperAccess.writeHelperToken(port, helperToken);
|
|
1706
|
+
if (tokenFile) console.log(` token: ${tokenFile}`);
|
|
1707
|
+
if (!loopbackBind) console.warn(`[chippy] bound to ${host}; command routes require the token, but the page WebSocket is reachable from the network`);
|
|
1708
|
+
console.log(` pages: ${base}${CHIPPY_PREFIX}/api/pages`);
|
|
1709
|
+
console.log(` inject: ${base}${CHIPPY_PREFIX}/inject.js`);
|
|
1710
|
+
});
|