@atlassian/chippy 0.0.0-stage → 0.0.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +276 -2
- package/bin/chippy.mjs +92 -0
- package/bin/pack.mjs +204 -0
- package/bin/skill-dir.mjs +15 -0
- package/lib/avalanche.js +2601 -0
- package/lib/avalanche.js.map +7 -0
- package/lib/bootstrap.d.ts +1 -0
- package/lib/bootstrap.js +70 -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 +5535 -0
- package/lib/extension/avalanche.es.js.map +1 -0
- package/lib/extension/chippy-page-bridge.js +1953 -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 +148 -0
- package/lib/mcp-skill/SKILL.md +335 -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/armed-reload-startup.js +83 -0
- package/lib/mcp-skill/extension/avalanche.es.js +5535 -0
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -0
- package/lib/mcp-skill/extension/avalanche.iife.js +5536 -0
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -0
- package/lib/mcp-skill/extension/background.js +325 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +1953 -0
- package/lib/mcp-skill/extension/content-script.js +190 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +85 -0
- package/lib/mcp-skill/extension/early-bootstrap.js +49 -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 +173 -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/extension/startup-handoff.js +30 -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 +1085 -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 +102 -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 +194 -0
- package/lib/mcp-skill/references/troubleshooting.md +59 -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 +1768 -0
- package/lib/mcp-skill/scripts/chippy-mcp.cjs +273 -0
- package/lib/mcp-skill/scripts/chippy.sh +1252 -0
- package/lib/mcp-skill/scripts/format-probe-result.js +34 -0
- package/lib/mcp-skill/scripts/helper-access.js +158 -0
- package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
- package/lib/mcp-skill/scripts/page-socket-trust.js +81 -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-owner.js +42 -0
- package/lib/storybook/manager.jsx +425 -0
- package/lib/storybook/preset.js +7 -0
- package/lib/storybook/preview.js +298 -0
- package/lib/storybook/probe-annotation-editor.jsx +52 -0
- package/lib/storybook/probe-intent.js +23 -0
- package/lib/storybook/setup.js +104 -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 +48 -0
- package/lib/types/chippy-bridge/react-renderer.d.ts +74 -0
- package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
- package/lib/types/chippy-bridge/source-map.d.ts +30 -0
- package/lib/types/debug-render/core.d.ts +78 -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 +106 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
const mode = process.argv[2] || 'text';
|
|
5
|
+
let input = '';
|
|
6
|
+
|
|
7
|
+
process.stdin.on('data', (chunk) => {
|
|
8
|
+
input += chunk;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
process.stdin.on('end', () => {
|
|
12
|
+
const payload = JSON.parse(input);
|
|
13
|
+
if (mode === 'json') {
|
|
14
|
+
process.stdout.write(JSON.stringify(payload, null, 2));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!payload?.ok || !payload?.result?.ok) {
|
|
18
|
+
process.stdout.write(JSON.stringify(payload, null, 2));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const result = payload.result;
|
|
22
|
+
const text = String(result.text || '');
|
|
23
|
+
if (mode === 'complexity') {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
const start = lines.indexOf('complexity:');
|
|
26
|
+
const end = lines.indexOf('structure:');
|
|
27
|
+
if (start >= 0) {
|
|
28
|
+
const until = end > start ? end : lines.length;
|
|
29
|
+
process.stdout.write(lines.slice(start, until).join('\n'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
process.stdout.write(text || JSON.stringify(payload, null, 2));
|
|
34
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Access control for the Chippy helper.
|
|
3
|
+
//
|
|
4
|
+
// The helper can run scripts in connected pages, so its command API must not
|
|
5
|
+
// be reachable from arbitrary websites or other hosts. Three checks apply:
|
|
6
|
+
// 1. The helper binds to loopback by default. A wider bind (for example
|
|
7
|
+
// 0.0.0.0 for a remote dev environment or container) is opt-in.
|
|
8
|
+
// 2. When bound to loopback, the Host header must name a loopback host. This
|
|
9
|
+
// blocks DNS-rebinding pages that resolve their own name to 127.0.0.1.
|
|
10
|
+
// 3. Protected routes require the X-Chippy-Token header. CORS does not allow
|
|
11
|
+
// that header, so a cross-origin page cannot send it.
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
|
|
17
|
+
const TOKEN_HEADER = 'x-chippy-token';
|
|
18
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
19
|
+
|
|
20
|
+
// Routes that pages load without credentials: health, injected bundles, and
|
|
21
|
+
// the page WebSocket.
|
|
22
|
+
const PUBLIC_GET_PATHS = new Set([
|
|
23
|
+
'',
|
|
24
|
+
'/health',
|
|
25
|
+
'/inject.js',
|
|
26
|
+
'/inject.mjs',
|
|
27
|
+
'/avalanche.es.js',
|
|
28
|
+
'/avalanche.iife.js',
|
|
29
|
+
'/avalanche.es.js.map',
|
|
30
|
+
'/avalanche.iife.js.map',
|
|
31
|
+
'/devtools-hook-shim.js',
|
|
32
|
+
'/startup-handoff.js',
|
|
33
|
+
'/ws',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Routes the published browser extension calls from its service worker. The
|
|
37
|
+
// extension sends no token, so these stay open to extension origins and
|
|
38
|
+
// non-browser clients on every bind. They list pages and read or arm capture;
|
|
39
|
+
// they do not run page code.
|
|
40
|
+
const EXTENSION_ROUTES = new Set(['GET /api/pages', 'GET /api/avalanche-arm', 'POST /api/avalanche-arm']);
|
|
41
|
+
|
|
42
|
+
function stateDirectory(env = process.env) {
|
|
43
|
+
return env.CHIPPY_STATE_DIR || path.join(os.homedir(), '.chippy');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveTokenFile(port, env = process.env) {
|
|
47
|
+
return path.join(stateDirectory(env), `helper-${port}.token`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readHelperToken(port, env = process.env) {
|
|
51
|
+
if (env.CHIPPY_HELPER_TOKEN) return env.CHIPPY_HELPER_TOKEN;
|
|
52
|
+
try {
|
|
53
|
+
return fs.readFileSync(resolveTokenFile(port, env), 'utf8').trim() || null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createHelperToken(env = process.env) {
|
|
60
|
+
return env.CHIPPY_HELPER_TOKEN || crypto.randomBytes(32).toString('hex');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Call only after the server is listening, so a helper that lost the port
|
|
64
|
+
// race cannot replace the running helper's token.
|
|
65
|
+
function writeHelperToken(port, token, env = process.env) {
|
|
66
|
+
if (env.CHIPPY_HELPER_TOKEN) return null;
|
|
67
|
+
const file = resolveTokenFile(port, env);
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
69
|
+
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
|
|
70
|
+
fs.chmodSync(file, 0o600);
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isLoopbackBind(host) {
|
|
75
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hostnameOf(hostHeader) {
|
|
79
|
+
if (!hostHeader) return '';
|
|
80
|
+
const value = String(hostHeader).toLowerCase();
|
|
81
|
+
if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1);
|
|
82
|
+
return value.split(':')[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function allowedHosts(env = process.env) {
|
|
86
|
+
const extra = (env.CHIPPY_HELPER_ALLOWED_HOSTS || '')
|
|
87
|
+
.split(',')
|
|
88
|
+
.map((h) => h.trim().toLowerCase())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
return new Set([...LOOPBACK_HOSTS, ...extra]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isAllowedHost(hostHeader, env = process.env) {
|
|
94
|
+
return allowedHosts(env).has(hostnameOf(hostHeader));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Returns 'public', 'extension', or 'protected'. `route` is the path after the
|
|
98
|
+
// Chippy prefix, or null for proxied application paths.
|
|
99
|
+
function classifyRoute(method, route) {
|
|
100
|
+
if (route === null) return 'public';
|
|
101
|
+
if (method === 'OPTIONS') return 'public';
|
|
102
|
+
if (method === 'GET' && PUBLIC_GET_PATHS.has(route)) return 'public';
|
|
103
|
+
if (EXTENSION_ROUTES.has(`${method} ${route}`)) return 'extension';
|
|
104
|
+
return 'protected';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isExtensionOrigin(origin) {
|
|
108
|
+
return typeof origin === 'string' && /^(chrome|moz)-extension:\/\//.test(origin);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function originOf(value) {
|
|
112
|
+
try { return new URL(value).origin; } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A page may ask whether its own origin is armed (the Storybook addon does this
|
|
116
|
+
// at preview start). It may not list other origins or look up client ids.
|
|
117
|
+
function isSelfArmLookup(method, route, origin, query) {
|
|
118
|
+
if (method !== 'GET' || route !== '/api/avalanche-arm' || !query || typeof origin !== 'string') return false;
|
|
119
|
+
if (query.get('clientId')) return false;
|
|
120
|
+
const asked = query.get('origin');
|
|
121
|
+
return !!asked && originOf(asked) === origin;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tokensMatch(expected, received) {
|
|
125
|
+
if (typeof received !== 'string' || !expected) return false;
|
|
126
|
+
const a = Buffer.from(expected);
|
|
127
|
+
const b = Buffer.from(received);
|
|
128
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Returns null when the request may proceed, otherwise { status, error }.
|
|
132
|
+
// `query` is the request's URLSearchParams.
|
|
133
|
+
function authorizeRequest({ method, route, headers, token, loopback, query = null, env = process.env }) {
|
|
134
|
+
if (loopback && !isAllowedHost(headers.host, env)) {
|
|
135
|
+
return { status: 403, error: 'Host header is not a loopback host; set CHIPPY_HELPER_ALLOWED_HOSTS to allow it' };
|
|
136
|
+
}
|
|
137
|
+
const kind = classifyRoute(method, route);
|
|
138
|
+
if (kind === 'public') return null;
|
|
139
|
+
if (tokensMatch(token, headers[TOKEN_HEADER])) return null;
|
|
140
|
+
if (kind === 'extension') {
|
|
141
|
+
const origin = headers.origin;
|
|
142
|
+
if (origin === undefined || isExtensionOrigin(origin)) return null;
|
|
143
|
+
if (isSelfArmLookup(method, route, origin, query)) return null;
|
|
144
|
+
}
|
|
145
|
+
return { status: 401, error: 'Missing or invalid X-Chippy-Token header' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
TOKEN_HEADER,
|
|
150
|
+
authorizeRequest,
|
|
151
|
+
classifyRoute,
|
|
152
|
+
createHelperToken,
|
|
153
|
+
isAllowedHost,
|
|
154
|
+
isLoopbackBind,
|
|
155
|
+
readHelperToken,
|
|
156
|
+
resolveTokenFile,
|
|
157
|
+
writeHelperToken,
|
|
158
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Pure helpers for the helper's proxy mode: where to forward requests, and how
|
|
3
|
+
// to put Chippy into the proxied HTML document.
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
|
6
|
+
|
|
7
|
+
function isLoopbackHostname(hostname) {
|
|
8
|
+
const name = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
9
|
+
return LOOPBACK_HOSTNAMES.has(name) || /^127(?:\.\d{1,3}){3}$/.test(name) || name.endsWith('.localhost');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The proxy forwards only to this machine. Dev servers bind `localhost`,
|
|
13
|
+
// `127.0.0.1`, or `::1` depending on the tool and platform (Vite on macOS binds
|
|
14
|
+
// `::1`), so the original page URL decides the loopback name when it names one.
|
|
15
|
+
// `localhost` is the default because it resolves to both address families.
|
|
16
|
+
function resolveProxyTarget(targetPort, originalUrl) {
|
|
17
|
+
const port = Number(targetPort);
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid target port: ${targetPort}`);
|
|
19
|
+
let hostname = 'localhost';
|
|
20
|
+
if (originalUrl) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try { parsed = new URL(String(originalUrl)); } catch { throw new Error(`Invalid originalUrl: ${originalUrl}`); }
|
|
23
|
+
const named = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
24
|
+
if (!isLoopbackHostname(named)) {
|
|
25
|
+
throw new Error(`Proxy targets must be on this machine; originalUrl host ${parsed.hostname} is not a loopback name`);
|
|
26
|
+
}
|
|
27
|
+
hostname = named;
|
|
28
|
+
}
|
|
29
|
+
const hostHeader = `${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`;
|
|
30
|
+
return { hostname, port, hostHeader };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Options for http.request. `autoSelectFamily` tries every address `localhost`
|
|
34
|
+
// resolves to, so a server listening on only one family is still reached.
|
|
35
|
+
function upstreamRequestOptions(target, req, headers) {
|
|
36
|
+
return {
|
|
37
|
+
host: target.hostname,
|
|
38
|
+
port: target.port,
|
|
39
|
+
path: req.url,
|
|
40
|
+
method: req.method,
|
|
41
|
+
headers,
|
|
42
|
+
autoSelectFamily: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HEAD_OPEN = /<head(?:\s[^>]*)?>/i;
|
|
47
|
+
const HTML_OPEN = /<html(?:\s[^>]*)?>/i;
|
|
48
|
+
const DOCTYPE = /^\s*<!doctype[^>]*>/i;
|
|
49
|
+
|
|
50
|
+
// Classic scripts at the start of <head> run before the document's module
|
|
51
|
+
// scripts, so the DevTools hook exists when react-dom evaluates.
|
|
52
|
+
function insertAtHeadStart(html, markup) {
|
|
53
|
+
for (const pattern of [HEAD_OPEN, HTML_OPEN, DOCTYPE]) {
|
|
54
|
+
const match = pattern.exec(html);
|
|
55
|
+
if (match) {
|
|
56
|
+
const at = match.index + match[0].length;
|
|
57
|
+
return `${html.slice(0, at)}\n${markup}\n${html.slice(at)}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return `${markup}\n${html}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function insertAtBodyEnd(html, markup) {
|
|
64
|
+
const lower = html.toLowerCase();
|
|
65
|
+
for (const tag of ['</body>', '</html>']) {
|
|
66
|
+
const i = lower.lastIndexOf(tag);
|
|
67
|
+
if (i >= 0) return `${html.slice(0, i)}${markup}\n${html.slice(i)}`;
|
|
68
|
+
}
|
|
69
|
+
return `${html}\n${markup}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `earlyScripts` are helper-relative script paths that must run before page
|
|
73
|
+
// code; `bridgeBundle` is inline markup that connects the page to the helper.
|
|
74
|
+
function injectProxyDocument(html, { earlyScripts = [], bridgeBundle = '' } = {}) {
|
|
75
|
+
const early = earlyScripts
|
|
76
|
+
.map((src) => `<script data-chippy-proxy-early="true" src="${src}"></script>`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
const withEarly = early ? insertAtHeadStart(html, early) : html;
|
|
79
|
+
return bridgeBundle ? insertAtBodyEnd(withEarly, bridgeBundle) : withEarly;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
injectProxyDocument,
|
|
84
|
+
isLoopbackHostname,
|
|
85
|
+
resolveProxyTarget,
|
|
86
|
+
upstreamRequestOptions,
|
|
87
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Page WebSocket identity checks for the Chippy helper.
|
|
3
|
+
//
|
|
4
|
+
// Any page in the browser can open the page WebSocket, because the pages
|
|
5
|
+
// Chippy inspects run on arbitrary origins. What a page cannot forge is the
|
|
6
|
+
// Origin header its browser sends with the WebSocket handshake. These checks
|
|
7
|
+
// bind a socket's claims to that origin:
|
|
8
|
+
// 1. The page URL a socket reports must belong to its handshake origin, so a
|
|
9
|
+
// page cannot appear to the agent as another site.
|
|
10
|
+
// 2. A client id belongs to the origin that first claimed it. A reload of the
|
|
11
|
+
// same page may reuse its id; a page on another origin may not take it
|
|
12
|
+
// over, even after the original socket closed.
|
|
13
|
+
// A socket without an Origin header is a non-browser client on this machine;
|
|
14
|
+
// such a client can already read the helper token, so its origin is `null`
|
|
15
|
+
// and it is held to the same id binding as any other origin.
|
|
16
|
+
|
|
17
|
+
const MAX_BOUND_CLIENT_IDS = 2000;
|
|
18
|
+
|
|
19
|
+
function handshakeOrigin(headers) {
|
|
20
|
+
const origin = headers && headers.origin;
|
|
21
|
+
return typeof origin === 'string' && origin ? origin : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function urlOrigin(value) {
|
|
25
|
+
try { return new URL(value).origin; } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns null when the page URL is consistent with the socket, otherwise an error.
|
|
29
|
+
function checkPageUrl(socketOrigin, pageUrl) {
|
|
30
|
+
if (socketOrigin === null || pageUrl == null || pageUrl === '') return null;
|
|
31
|
+
const claimed = urlOrigin(String(pageUrl));
|
|
32
|
+
if (claimed === socketOrigin) return null;
|
|
33
|
+
return `page URL ${JSON.stringify(String(pageUrl))} does not belong to the WebSocket origin ${socketOrigin}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pageUrlsOf(msg) {
|
|
37
|
+
const urls = [];
|
|
38
|
+
if (msg && msg.page && msg.page.url != null) urls.push(msg.page.url);
|
|
39
|
+
if (msg && msg.snapshot && msg.snapshot.page && msg.snapshot.page.url != null) urls.push(msg.snapshot.page.url);
|
|
40
|
+
return urls;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createClientIdBindings({ max = MAX_BOUND_CLIENT_IDS } = {}) {
|
|
44
|
+
const owners = new Map();
|
|
45
|
+
|
|
46
|
+
function bind(clientId, origin) {
|
|
47
|
+
owners.delete(clientId);
|
|
48
|
+
owners.set(clientId, origin);
|
|
49
|
+
while (owners.size > max) owners.delete(owners.keys().next().value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Returns null and records the binding when `origin` may use `clientId`.
|
|
53
|
+
function claim(clientId, origin) {
|
|
54
|
+
if (owners.has(clientId) && owners.get(clientId) !== origin) {
|
|
55
|
+
return `client id ${JSON.stringify(clientId)} belongs to another origin`;
|
|
56
|
+
}
|
|
57
|
+
bind(clientId, origin);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { claim, ownerOf: (clientId) => (owners.has(clientId) ? owners.get(clientId) : undefined) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Returns null when `msg` may be applied to a socket from `socketOrigin`
|
|
65
|
+
// currently registered as `currentClientId`, otherwise an error string.
|
|
66
|
+
function checkPageMessage({ bindings, socketOrigin, currentClientId, msg }) {
|
|
67
|
+
for (const url of pageUrlsOf(msg)) {
|
|
68
|
+
const error = checkPageUrl(socketOrigin, url);
|
|
69
|
+
if (error) return error;
|
|
70
|
+
}
|
|
71
|
+
const requested = msg && msg.clientId ? String(msg.clientId) : null;
|
|
72
|
+
if (requested && requested !== currentClientId) return bindings.claim(requested, socketOrigin);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
checkPageMessage,
|
|
78
|
+
checkPageUrl,
|
|
79
|
+
createClientIdBindings,
|
|
80
|
+
handshakeOrigin,
|
|
81
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
|
|
3
|
+
const DIRECT_PROBE_IDS = new Set(['1', '2']);
|
|
4
|
+
|
|
5
|
+
function normalizedAnnotation(marker) {
|
|
6
|
+
return {
|
|
7
|
+
intent: marker?.intent ?? null,
|
|
8
|
+
comment: typeof marker?.comment === 'string' ? marker.comment : '',
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function annotationChanged(previousMarker, nextMarker) {
|
|
13
|
+
const previous = normalizedAnnotation(previousMarker);
|
|
14
|
+
const next = normalizedAnnotation(nextMarker);
|
|
15
|
+
return previous.intent !== next.intent || previous.comment !== next.comment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createProbeAnnotationChannel({ maxEvents = 50, serializeMarker = (marker) => marker } = {}) {
|
|
19
|
+
const queues = new Map();
|
|
20
|
+
const waiters = new Map();
|
|
21
|
+
let sequence = 0;
|
|
22
|
+
|
|
23
|
+
function getQueue(clientId) {
|
|
24
|
+
if (!queues.has(clientId)) queues.set(clientId, []);
|
|
25
|
+
return queues.get(clientId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getWaiters(clientId) {
|
|
29
|
+
if (!waiters.has(clientId)) waiters.set(clientId, []);
|
|
30
|
+
return waiters.get(clientId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deliver(clientId, event) {
|
|
34
|
+
const waiting = getWaiters(clientId);
|
|
35
|
+
while (waiting.length) {
|
|
36
|
+
const waiter = waiting.shift();
|
|
37
|
+
if (waiter.settled) continue;
|
|
38
|
+
waiter.finish(event);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const queue = getQueue(clientId);
|
|
42
|
+
queue.push(event);
|
|
43
|
+
while (queue.length > maxEvents) queue.shift();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publish({ clientId, at, page, integration, previousSnapshot, snapshot }) {
|
|
47
|
+
const previousMarkers = previousSnapshot?.markers || {};
|
|
48
|
+
const nextMarkers = snapshot?.markers || {};
|
|
49
|
+
const directIds = Object.keys(nextMarkers).filter((id) => DIRECT_PROBE_IDS.has(id));
|
|
50
|
+
const changedProbeIds = directIds.filter((id) => annotationChanged(previousMarkers[id], nextMarkers[id]));
|
|
51
|
+
const annotatedProbeIds = directIds.filter((id) => {
|
|
52
|
+
const annotation = normalizedAnnotation(nextMarkers[id]);
|
|
53
|
+
return annotation.intent !== null || annotation.comment.length > 0;
|
|
54
|
+
});
|
|
55
|
+
const selectedProbeIds = changedProbeIds.length
|
|
56
|
+
? changedProbeIds
|
|
57
|
+
: (annotatedProbeIds.length ? annotatedProbeIds : directIds);
|
|
58
|
+
|
|
59
|
+
const event = {
|
|
60
|
+
eventId: `probe-annotation:${++sequence}`,
|
|
61
|
+
sequence,
|
|
62
|
+
at,
|
|
63
|
+
clientId,
|
|
64
|
+
reason: 'marker-annotation-change',
|
|
65
|
+
page: page || null,
|
|
66
|
+
integration: integration || null,
|
|
67
|
+
changedProbeIds,
|
|
68
|
+
annotations: selectedProbeIds.map((probeId) => ({
|
|
69
|
+
probeId,
|
|
70
|
+
...normalizedAnnotation(nextMarkers[probeId]),
|
|
71
|
+
marker: serializeMarker(nextMarkers[probeId]),
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
deliver(clientId, event);
|
|
75
|
+
return event;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wait(clientId, timeoutMs) {
|
|
79
|
+
const queue = getQueue(clientId);
|
|
80
|
+
if (queue.length) {
|
|
81
|
+
return { promise: Promise.resolve(queue.shift()), cancel() {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let waiter;
|
|
85
|
+
const promise = new Promise((resolve) => {
|
|
86
|
+
waiter = {
|
|
87
|
+
settled: false,
|
|
88
|
+
timer: null,
|
|
89
|
+
finish(event) {
|
|
90
|
+
if (this.settled) return;
|
|
91
|
+
this.settled = true;
|
|
92
|
+
clearTimeout(this.timer);
|
|
93
|
+
const waiting = waiters.get(clientId);
|
|
94
|
+
const index = waiting?.indexOf(this) ?? -1;
|
|
95
|
+
if (index >= 0) waiting.splice(index, 1);
|
|
96
|
+
if (waiting?.length === 0) waiters.delete(clientId);
|
|
97
|
+
resolve(event);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
waiter.timer = setTimeout(() => waiter.finish(null), timeoutMs);
|
|
101
|
+
getWaiters(clientId).push(waiter);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
promise,
|
|
106
|
+
cancel() {
|
|
107
|
+
if (!waiter || waiter.settled) return;
|
|
108
|
+
waiter.finish(null);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { publish, wait };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { createProbeAnnotationChannel };
|