diff --git a/test-server/configurator-extension/.gitignore b/test-server/configurator-extension/.gitignore new file mode 100644 index 0000000000..0ffad36151 --- /dev/null +++ b/test-server/configurator-extension/.gitignore @@ -0,0 +1,2 @@ +# Copied in from packages/analytics-browser/lib/scripts after a build; see README.md. +vendor/ diff --git a/test-server/configurator-extension/README.md b/test-server/configurator-extension/README.md new file mode 100644 index 0000000000..61d1942f69 --- /dev/null +++ b/test-server/configurator-extension/README.md @@ -0,0 +1,90 @@ +# Configurator runner extension + +Runs a configuration built in the configurator on a site that doesn't have Amplitude installed, by +injecting the Browser SDK into it. It exists because a web page can't instrument another origin: the +configurator can generate the code, but only an extension can put it on someone else's site. + +## Setting it up + +The SDK bundles aren't checked in. Build them once, then vendor them in: + +```bash +pnpm --dir packages/analytics-browser build +pnpm --dir packages/plugin-session-replay-browser build +node test-server/configurator-extension/sync-vendor.mjs +``` + +Load `test-server/configurator-extension` at `chrome://extensions` with developer mode on, and start the +test server with `pnpm dev`. After editing any file here, hit the extension's reload icon — that also +clears which tabs were being instrumented. + +## Using it + +**From the configurator.** Open `/configurator.html`, build a configuration, put a URL in the field next +to the "Run on URL" button, and click it. The page hands the configuration to this extension, which opens +that URL in a new tab and initialises the SDK there. The note beside the button says what was installed, +and the tab's console logs every event the SDK builds. + +**On the tab you're looking at.** Click the toolbar button to instrument the current tab with whatever the +configurator sent last, or a debug-everything default if it hasn't sent anything yet. The badge reads `on`, +and clicking again switches it off. Either way the tab reloads, since that's the only way to catch a page +from the start. + +## How the pieces fit + +`configurator-bridge.js` sits on the configurator page and relays `window.postMessage` requests to +`background.js`. Its `matches` in the manifest cover the hosts the test server uses — `localhost` and +`127.0.0.1` over http for `pnpm dev`, `local.website.com` over https for `pnpm dev:ssh` — and nothing +else. Ports aren't part of a match pattern, so any port is covered, but serving the configurator from a +host that isn't listed is why the page would report no extension. It's scoped to the configurator's own +path rather than a host wildcard so the relay isn't offered to every site, given how much the extension is +allowed to do. Going through the page rather than `chrome.runtime.sendMessage` means the page needs no +extension ID and no `externally_connectable` entry, and it lets the extension announce itself so the +button can tell "not installed" from "not responding". + +`background.js` marks a tab, then injects on `webNavigation.onCommitted`: the configuration, the SDK +bundle, session replay's bundle if it's configured, and finally `inject.js`, which wires them together. +All of it goes into the page's main world, because the SDK has to share globals with the page — in an +isolated world its patches to `fetch`, `XMLHttpRequest` and `history` would apply to nothing, and network +and SPA page-view tracking would quietly capture nothing while clicks kept working. + +`csp.js` owns the site's Content-Security-Policy: `instrument()` removes it for that one tab and `forget()` +puts it back, so no path can mark a tab without clearing the way or leave a tab unprotected afterwards. It +also records what the policy said, which is the part worth having — see below. + +The configuration is materialised on the configurator side by `runtime-config.js`, the same code the run +page uses, and travels as JSON. Regexes have nowhere to live in JSON, so `toJsonSafe()` turns them into +`{ __regex, __flags }` markers and the reviver in `inject.js` turns them back. + +## Things worth knowing + +- **Host permissions are `*://*/*`.** That's what "any URL a customer gives us" means. A version anyone + else installs should ask per-site with `optional_host_permissions` and a prompt instead. +- **The site's CSP is removed for the tab, and reported.** Injecting bundles works on a page that forbids + inline scripts, because injected files are exempt — but once the SDK runs, its requests are the page's + requests, so a strict `connect-src` or `script-src` blocks event uploads, remote config and the CDN + bundles. A `declarativeNetRequest` session rule removes the `content-security-policy` header, scoped to + the instrumented tab by the `tabIds` condition, which only session-scoped rules support. Note that a + second policy could not have loosened the first: CSP enforces every policy it is given, so appending only + narrows, and a declarative rule can't read the existing value to rewrite it. Removal is the only option + the browser offers. +- **What the policy would have blocked is the finding, not the obstacle.** A customer whose CSP stops + Amplitude wants to know exactly that, so the original policy is compared against what the configuration + will actually reach for and reported in the instrumented page's console and the toolbar button's tooltip. + The comparison is a heuristic, not a CSP implementation: it follows the `default-src` fallback chain and + matches host, wildcard and scheme sources, but ignores ports, paths and scheme upgrades. +- **Only header-delivered policies can be removed.** A policy in a `` tag never crosses + the network and is in force before anything here could see it, which makes `target-page.html` a harsher + case than most real sites. Removal is also per-tab: another tab on the same URL keeps its policy. +- **Guides and Surveys isn't injected.** It fetches its bundle from the CDN per project, and the runner + doesn't load it yet. The site's CSP is no longer the reason — with the header removed, this is only a + matter of `inject.js` learning to add the script tag the run page already uses. +- **Chromium is stricter about UTF-8 than UTF-8 is.** Content script files go through + `base::IsStringUTF8`, which rejects Unicode non-characters, and the session replay bundle contains four + literal U+FFFE characters. Chrome rejects the whole file with "It isn't UTF-8 encoded", which is why + `sync-vendor.mjs` escapes them on the way in rather than a plain `cp`. +- **Where events go.** The API key comes from the configurator, and events land in whatever project owns + it. Use a scratch project, not a customer's production key. +- **The page's own Amplitude.** The bundle merges itself onto an existing `window.amplitude`, which would + leave the site's `init` and `track` pointing at the injected instance. `background.js` snapshots the + global first and `inject.js` puts it back, keeping its own client to itself. diff --git a/test-server/configurator-extension/background.js b/test-server/configurator-extension/background.js new file mode 100644 index 0000000000..bf3b54d5b5 --- /dev/null +++ b/test-server/configurator-extension/background.js @@ -0,0 +1,173 @@ +// Decides which tabs get instrumented, and injects the SDK into them. +// +// Injection happens on webNavigation.onCommitted rather than through a registered content script, +// because a registered script can't be handed anything: this has to carry a configuration, and +// executeScript(func, args) is the only injection that takes arguments. The cost is timing — onCommitted +// with injectImmediately lands before the page's own scripts, but a little later than a document_start +// registration would. If catching the very first request ever matters more than being configurable, +// register instead and bake the configuration into a file. +// +// Two things stay true either way, and both matter: +// world: 'MAIN' the SDK shares globals with the page, so it sees the page's own fetch, XHR and +// history. In an isolated world those patches would apply to nothing. +// top frame only for now, so a page full of iframes doesn't get an instance per frame. +import { cspReport, relaxCsp, restoreCsp } from './csp.js'; + +const SDK_BUNDLE = 'vendor/amplitude-min.js'; +const SESSION_REPLAY_BUNDLE = 'vendor/plugin-session-replay-browser-min.js'; + +const TABS_KEY = 'instrumentedTabs'; +const LAST_PAYLOAD_KEY = 'lastPayload'; + +// What the configurator sends when no API key has been typed in — PLACEHOLDER_API_KEY in its snippet.js. +const PLACEHOLDER_API_KEY = 'YOUR_API_KEY'; + +// What the toolbar button uses before the configurator has sent anything. +const FALLBACK_PAYLOAD = { + apiKey: 'REPLACE_WITH_A_SCRATCH_PROJECT_KEY', + analytics: { + logLevel: 4, + autocapture: { + attribution: true, + fileDownloads: true, + formInteractions: true, + pageViews: true, + sessions: true, + elementInteractions: true, + frustrationInteractions: true, + // The default only captures 500-599, which makes a healthy site look like nothing is working. + networkTracking: { captureRules: [{ hosts: ['*'], statusCodeRange: '200-599' }] }, + webVitals: true, + }, + }, + sessionReplay: null, + engagement: null, +}; + +async function instrumentedTabs() { + const { [TABS_KEY]: tabs = {} } = await chrome.storage.session.get(TABS_KEY); + return tabs; +} + +// Both transitions carry the CSP rule with them, so no path can mark a tab and forget to clear the way for +// what the SDK is about to do — or leave a tab unprotected after instrumentation stops. +async function instrument(tabId, payload) { + const tabs = await instrumentedTabs(); + await chrome.storage.session.set({ [TABS_KEY]: { ...tabs, [tabId]: payload } }); + await relaxCsp(tabId); + await chrome.action.setBadgeText({ tabId, text: 'on' }); +} + +async function forget(tabId) { + const tabs = await instrumentedTabs(); + delete tabs[tabId]; + await chrome.storage.session.set({ [TABS_KEY]: tabs }); + await restoreCsp(tabId); +} + +// Runs in the page before the SDK bundle: saves what the page had under window.amplitude, since the +// bundle is about to write over it, and leaves the configuration where inject.js will look for it. +function handOver(payload, csp) { + window.__amplitudeConfigurator = { + hadGlobal: 'amplitude' in window, + properties: window.amplitude ? { ...window.amplitude } : undefined, + payload, + csp, + }; +} + +chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId, url }) => { + if (frameId !== 0 || !url.startsWith('http')) { + return; + } + const payload = (await instrumentedTabs())[tabId]; + if (!payload) { + return; + } + // Read before injecting: by the time a navigation commits the response headers have arrived, which is + // where the policy the page was sent is still visible. + const csp = cspReport(tabId, payload); + if (csp) { + await chrome.action.setTitle({ tabId, title: csp.summary }); + } + const target = { tabId }; + const inject = (options) => + chrome.scripting.executeScript({ target, world: 'MAIN', injectImmediately: true, ...options }); + try { + await inject({ func: handOver, args: [payload, csp] }); + await inject({ files: [SDK_BUNDLE] }); + if (payload.sessionReplay) { + // Its own call: a plugin bundle that won't load shouldn't stop analytics from running, and + // inject.js reports the gap when the global it expects isn't there. + try { + await inject({ files: [SESSION_REPLAY_BUNDLE] }); + } catch (error) { + console.warn('[amplitude-configurator] session replay bundle failed to load', error); + } + } + await inject({ files: ['inject.js'] }); + } catch (error) { + console.error('[amplitude-configurator] injection failed', error); + await chrome.action.setBadgeText({ tabId, text: 'err' }); + } +}); + +function describe(payload) { + const parts = ['analytics']; + if (payload.sessionReplay) { + parts.push('session replay'); + } + let message = `Opened ${payload.url} with ${parts.join(' and ')}.`; + // Said before the navigation happens, so it can only promise the behaviour; what the policy actually + // said reaches the page console, which is where the rest of the run is read anyway. + message += " That tab's Content-Security-Policy is removed, and its console reports what it would have blocked."; + if (payload.engagement) { + // Its bundle is fetched from the CDN at runtime rather than packaged here. A strict CSP no longer + // stands in the way, so this is now only a matter of the runner learning to load it. + message += ' Guides and Surveys was left out: the runner does not load its CDN bundle yet.'; + } + if (payload.apiKey === PLACEHOLDER_API_KEY) { + message += ' No API key is set, so events are built but rejected.'; + } + return message; +} + +async function runOnUrl(payload) { + const url = new URL(payload.url); + if (!/^https?:$/.test(url.protocol)) { + throw new Error('Only http and https URLs can be instrumented.'); + } + // The tab opens blank so it can be marked for instrumentation before it commits anything; navigating + // afterwards is what makes the ordering reliable. + const tab = await chrome.tabs.create({ url: 'about:blank', active: true }); + await instrument(tab.id, payload); + await chrome.storage.session.set({ [LAST_PAYLOAD_KEY]: payload }); + await chrome.tabs.update(tab.id, { url: url.toString() }); + return { message: describe(payload) }; +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.action !== 'run-on-url') { + return false; + } + runOnUrl(message.payload).then(sendResponse, (error) => sendResponse({ error: error.message })); + return true; +}); + +// The toolbar button instruments the tab you're looking at, with whatever the configurator sent last. +chrome.action.onClicked.addListener(async (tab) => { + if (!tab.url?.startsWith('http')) { + return; + } + if ((await instrumentedTabs())[tab.id]) { + await forget(tab.id); + await chrome.action.setBadgeText({ tabId: tab.id, text: '' }); + } else { + const { [LAST_PAYLOAD_KEY]: lastPayload } = await chrome.storage.session.get(LAST_PAYLOAD_KEY); + await instrument(tab.id, lastPayload ?? FALLBACK_PAYLOAD); + } + // The injection only happens on the next commit, which is also the only way to catch a page early. + await chrome.tabs.reload(tab.id); +}); + +chrome.tabs.onRemoved.addListener((tabId) => forget(tabId)); diff --git a/test-server/configurator-extension/configurator-bridge.js b/test-server/configurator-extension/configurator-bridge.js new file mode 100644 index 0000000000..3c43a8fa75 --- /dev/null +++ b/test-server/configurator-extension/configurator-bridge.js @@ -0,0 +1,24 @@ +// Sits on the configurator page and relays its requests to the service worker. +// +// This runs in an isolated world, so window.postMessage is the only channel it shares with the page — +// which is the point: the page gets to reach the extension without knowing its ID, and the marker below +// lets the page tell that the extension is there at all. +const REQUEST_SOURCE = 'amplitude-configurator'; +const RESPONSE_SOURCE = 'amplitude-configurator-extension'; + +document.documentElement.dataset.amplitudeConfigurator = chrome.runtime.getManifest().version; + +window.addEventListener('message', (event) => { + // Only messages this page sent to itself: anything cross-window is somebody else's business. + if (event.source !== window || event.data?.source !== REQUEST_SOURCE) { + return; + } + const { id, action, payload } = event.data; + chrome.runtime.sendMessage({ action, payload }, (response) => { + const error = chrome.runtime.lastError?.message ?? response?.error; + window.postMessage( + { source: RESPONSE_SOURCE, id, error, result: error ? undefined : response }, + window.location.origin, + ); + }); +}); diff --git a/test-server/configurator-extension/csp.js b/test-server/configurator-extension/csp.js new file mode 100644 index 0000000000..896174e7ce --- /dev/null +++ b/test-server/configurator-extension/csp.js @@ -0,0 +1,194 @@ +// Takes the site's Content-Security-Policy off the tab being instrumented, and reports what it said. +// +// Script *files* injected with chrome.scripting are exempt from the page's CSP, which is why the SDK +// bundles load at all. Nothing the SDK does afterwards is exempt: the event uploads, the remote +// configuration fetch, the CDN bundles and session replay's blob: worker are ordinary page requests, so a +// strict connect-src or script-src stops them. Removing the header is the only offer the browser makes +// here — CSP enforces every policy it is handed, so a second policy can only narrow the first, and a +// declarative rule can't read the existing value in order to rewrite it. +// +// Removing it silently would throw away the most useful thing a run can tell you, so the original is +// recorded and handed to the page: a customer whose CSP blocks Amplitude wants to hear which directive did +// it. Only header-delivered policies can be touched — one in a never crosses the network +// and is already in force by the time any of this could see it. + +const CSP_HEADERS = ['content-security-policy', 'content-security-policy-report-only']; + +// Hosts by server zone, from the SDK's own constants: analytics-core's types/constants.ts and +// remote-config.ts, and session-replay-browser's constants.ts. +const ZONES = { + US: { + events: 'https://api2.amplitude.com', + remoteConfig: 'https://sr-client-cfg.amplitude.com', + replay: 'https://api-sr.amplitude.com', + }, + EU: { + events: 'https://api.eu.amplitude.com', + remoteConfig: 'https://sr-client-cfg.eu.amplitude.com', + replay: 'https://api-sr.eu.amplitude.com', + }, +}; + +// Mirrors shouldFetchRemoteConfig() in analytics-browser/src/config.ts, which is the source of truth: on +// unless something turns it off, and the nested setting wins. +function fetchesRemoteConfig(analytics) { + if (analytics.remoteConfig?.fetchRemoteConfig === true) { + return true; + } + return analytics.remoteConfig?.fetchRemoteConfig !== false && analytics.fetchRemoteConfig !== false; +} + +// Only what this configuration will actually reach for: a policy that would stop session replay is worth +// nobody's attention if session replay is off. +function requirementsFor({ analytics = {}, sessionReplay, engagement }) { + const zone = ZONES[analytics.serverZone] ?? ZONES.US; + const events = analytics.serverUrl ? new URL(analytics.serverUrl).origin : zone.events; + const requirements = [{ directive: 'connect-src', target: events, reason: 'event uploads' }]; + if (fetchesRemoteConfig(analytics)) { + requirements.push({ directive: 'connect-src', target: zone.remoteConfig, reason: 'remote configuration' }); + } + if (sessionReplay) { + requirements.push( + { directive: 'connect-src', target: zone.replay, reason: 'session replay uploads' }, + { directive: 'worker-src', target: 'blob:', reason: "session replay's compression worker" }, + ); + } + if (engagement) { + // The only piece still fetched from the CDN at runtime rather than injected from this extension. + requirements.push({ + directive: 'script-src', + target: 'https://cdn.amplitude.com', + reason: 'the Guides and Surveys bundle', + }); + } + return requirements; +} + +// Where each directive looks when it isn't named in the policy, per the CSP fallback chain. +const FALLBACKS = { + 'connect-src': ['default-src'], + 'script-src': ['default-src'], + 'worker-src': ['child-src', 'script-src', 'default-src'], +}; + +// Tabs whose rule is in place. Rehydrated from the rules themselves, since they are the state that +// survives the service worker being torn down between a run and the navigation it opened. +const relaxed = new Set(); +chrome.declarativeNetRequest + .getSessionRules() + .then((rules) => rules.forEach(({ id }) => relaxed.add(id))) + .catch(() => undefined); + +// Only the policy of a tab under instrumentation is worth keeping, and only until the tab goes away. +const policies = new Map(); + +// An observer, not a modifier: what it sees is the response as it arrived, before the rule below takes the +// header off. That ordering is what makes reporting the original policy possible at all. +chrome.webRequest.onHeadersReceived.addListener( + ({ tabId, responseHeaders }) => { + if (!relaxed.has(tabId)) { + return; + } + const header = responseHeaders?.find(({ name }) => name.toLowerCase() === CSP_HEADERS[0]); + policies.set(tabId, header?.value); + }, + { urls: [''], types: ['main_frame'] }, + ['responseHeaders'], +); + +// Tab id doubles as the rule id. Session scope is not a preference: the tabIds condition is only supported +// there, and a rule outliving the browser would leave a stranger's tab unprotected. +export async function relaxCsp(tabId) { + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [tabId], + addRules: [ + { + id: tabId, + priority: 1, + action: { + type: 'modifyHeaders', + responseHeaders: CSP_HEADERS.map((header) => ({ header, operation: 'remove' })), + }, + condition: { tabIds: [tabId], resourceTypes: ['main_frame', 'sub_frame'] }, + }, + ], + }); + relaxed.add(tabId); +} + +export async function restoreCsp(tabId) { + relaxed.delete(tabId); + policies.delete(tabId); + await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [tabId] }); +} + +// First occurrence wins, which is how CSP reads a repeated directive. +function parsePolicy(policy) { + const directives = new Map(); + for (const part of policy.split(';')) { + const [name, ...sources] = part.trim().split(/\s+/); + if (name && !directives.has(name.toLowerCase())) { + directives.set( + name.toLowerCase(), + sources.map((source) => source.toLowerCase()), + ); + } + } + return directives; +} + +function sourcesFor(directives, directive) { + for (const name of [directive, ...(FALLBACKS[directive] ?? [])]) { + if (directives.has(name)) { + return directives.get(name); + } + } + return undefined; +} + +// Close enough to be useful, and not a CSP implementation: enough of the grammar to tell an origin that is +// plainly allowed from one that is plainly not. +function permits(sources, target) { + if (sources.includes("'none'")) { + return false; + } + // A scheme has to be named outright — * covers network schemes only, not blob: or data:. + if (target.endsWith(':')) { + return sources.includes(target); + } + const { protocol, host } = new URL(target); + return sources.some((source) => { + if (source === '*' || source === protocol) { + return true; + } + // A host-source may carry a scheme, a port and a path, none of which change which host it names. Ports + // and paths are ignored rather than compared, and so is the scheme: an http source matches https under + // the spec's upgrade rules, and a policy naming http for an Amplitude endpoint isn't worth modelling. + const named = source.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[:/].*$/, ''); + return named === host || (named.startsWith('*.') && host.endsWith(named.slice(1))); + }); +} + +// What the page was sent, and which of the SDK's requests it would have refused. Absent directives mean +// silence rather than permission only where the fallback chain runs out, so a policy naming neither the +// directive nor default-src allows the request. +export function cspReport(tabId, payload) { + const policy = policies.get(tabId); + if (!policy) { + return null; + } + const directives = parsePolicy(policy); + const blocked = requirementsFor(payload).filter(({ directive, target }) => { + const sources = sourcesFor(directives, directive); + return sources !== undefined && !permits(sources, target); + }); + return { policy, blocked, summary: summarise(blocked) }; +} + +function summarise(blocked) { + if (blocked.length === 0) { + return 'The site sent a Content-Security-Policy that allows what the SDK needs.'; + } + const listed = blocked.map(({ reason, directive }) => `${reason} (${directive})`).join(', '); + return `The site's Content-Security-Policy would have blocked ${listed}.`; +} diff --git a/test-server/configurator-extension/inject.js b/test-server/configurator-extension/inject.js new file mode 100644 index 0000000000..f6b36d1402 --- /dev/null +++ b/test-server/configurator-extension/inject.js @@ -0,0 +1,81 @@ +// Runs in the page's own world, after the SDK bundle, with the configuration left for it by +// background.js. +(() => { + const handover = window.__amplitudeConfigurator; + delete window.__amplitudeConfigurator; + if (!handover?.payload) { + console.warn('[amplitude-configurator] nothing to run: no configuration was handed over'); + return; + } + + // The configuration arrived as JSON, so regexes came through as markers. The encoder is toJsonSafe() + // in the configurator's extension-bridge.js — the two have to agree on this shape. + const revive = (value) => { + if (Array.isArray(value)) { + return value.map(revive); + } + if (value !== null && typeof value === 'object') { + if (typeof value.__regex === 'string') { + return new RegExp(value.__regex, value.__flags); + } + return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, revive(nested)])); + } + return value; + }; + + // The policy was taken off this tab before the page loaded, so what follows is not what the site would do + // on its own. Whether that mattered is worth saying out loud either way. + if (handover.csp?.blocked.length) { + console.warn( + `[amplitude-configurator] ${handover.csp.summary} It was removed for this tab, so this run says nothing` + + ' about whether the site can run Amplitude as it stands.', + handover.csp.policy, + ); + } else if (handover.csp) { + console.log(`[amplitude-configurator] ${handover.csp.summary}`, handover.csp.policy); + } + + const { payload } = handover; + const { apiKey } = payload; + const config = revive(payload.analytics); + const replayOptions = payload.sessionReplay ? revive(payload.sessionReplay) : null; + + // Every event the SDK builds, whether or not it can be uploaded. The postMessage is the seam a + // devtools panel would read. + const eventLogPlugin = { + name: 'configurator-event-log', + type: 'enrichment', + setup: async () => undefined, + execute: async (event) => { + console.log('[amplitude-configurator]', event.event_type, event); + window.postMessage({ source: 'amplitude-configurator-events', event }, window.location.origin); + return event; + }, + }; + + const client = window.amplitude.createInstance('configuratorRun'); + + // Give the page its global back before doing anything else. The bundle merged itself onto whatever was + // there, which on a site already running Amplitude means its init and track now point at the instance + // the bundle brought with it. + if (handover.hadGlobal) { + Object.assign(window.amplitude, handover.properties); + } else { + delete window.amplitude; + } + + client.add(eventLogPlugin); + client.init(apiKey, config); + + if (replayOptions) { + if (window.sessionReplay?.plugin) { + client.add(window.sessionReplay.plugin(replayOptions)); + } else { + console.warn('[amplitude-configurator] session replay was configured but its bundle is missing'); + } + } + + // Left behind deliberately: this is a debugging tool, and the console is where it gets debugged from. + window.__amplitudeConfiguratorClient = client; + console.log('[amplitude-configurator] initialised', { apiKey, config, sessionReplay: replayOptions }); +})(); diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json new file mode 100644 index 0000000000..26da5e7f09 --- /dev/null +++ b/test-server/configurator-extension/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Amplitude Configurator Runner (spike)", + "version": "0.0.3", + "description": "Runs a configuration built in the Amplitude configurator on any site, by injecting the Browser SDK into it.", + "permissions": ["declarativeNetRequestWithHostAccess", "scripting", "storage", "webNavigation", "webRequest"], + "host_permissions": ["*://*/*"], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "Run the Amplitude SDK on this tab" + }, + "content_scripts": [ + { + "matches": [ + "http://localhost/configurator.html*", + "https://localhost/configurator.html*", + "http://127.0.0.1/configurator.html*", + "https://127.0.0.1/configurator.html*", + "http://local.website.com/configurator.html*", + "https://local.website.com/configurator.html*" + ], + "js": ["configurator-bridge.js"], + "run_at": "document_end" + } + ] +} diff --git a/test-server/configurator-extension/sync-vendor.mjs b/test-server/configurator-extension/sync-vendor.mjs new file mode 100644 index 0000000000..34a945a49b --- /dev/null +++ b/test-server/configurator-extension/sync-vendor.mjs @@ -0,0 +1,47 @@ +// Copies the SDK bundles this extension injects out of packages/, and works around Chromium's stricter +// idea of UTF-8 on the way. +// +// Chromium loads content script files through base::IsStringUTF8, which rejects Unicode non-characters +// as well as malformed sequences. The session replay bundle carries four literal U+FFFE characters — +// PostCSS comparing a string's first character against a byte order mark — so Chrome refuses the whole +// file with "It isn't UTF-8 encoded". Escaping those code points is semantically identical inside the +// string and regex literals they appear in, and leaves the rest of the bundle untouched. +// +// Run from the repository root: node test-server/configurator-extension/sync-vendor.mjs +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const HERE = path.dirname(new URL(import.meta.url).pathname); +const PACKAGES = path.resolve(HERE, '../../packages'); + +const BUNDLES = [ + 'analytics-browser/lib/scripts/amplitude-min.js', + 'plugin-session-replay-browser/lib/scripts/plugin-session-replay-browser-min.js', +]; + +// Non-characters: U+FDD0–U+FDEF and the last two code points of every plane. +const REJECTED = /[\uFDD0-\uFDEF\uFFFE\uFFFF]/g; + +await mkdir(path.join(HERE, 'vendor'), { recursive: true }); + +for (const bundle of BUNDLES) { + const source = path.join(PACKAGES, bundle); + let code; + try { + code = await readFile(source, 'utf8'); + } catch { + console.error(`Missing ${bundle}. Build the package first, then run this again.`); + process.exitCode = 1; + continue; + } + let escaped = 0; + const output = code.replace(REJECTED, (character) => { + escaped += 1; + return `\\u${character.codePointAt(0).toString(16)}`; + }); + const destination = path.join(HERE, 'vendor', path.basename(bundle)); + await writeFile(destination, output); + console.log( + `${path.basename(bundle)}: ${output.length} chars${escaped ? `, escaped ${escaped} non-characters` : ''}`, + ); +} diff --git a/test-server/configurator-extension/target-page.html b/test-server/configurator-extension/target-page.html new file mode 100644 index 0000000000..7a872cc1da --- /dev/null +++ b/test-server/configurator-extension/target-page.html @@ -0,0 +1,26 @@ + + + + + + + + Target page (extension spike) + + +

Target page

+

+ A stand-in for a site under test. Nothing here loads Amplitude; the extension is expected to bring it. The page's + CSP allows only same-origin scripts and no inline ones. +

+ + + +
+ + +
+ + + diff --git a/test-server/configurator-extension/target-page.js b/test-server/configurator-extension/target-page.js new file mode 100644 index 0000000000..94bd30b44f --- /dev/null +++ b/test-server/configurator-extension/target-page.js @@ -0,0 +1,15 @@ +// The site's own script: it makes a request and changes the URL the way a real page would, so the +// injected SDK has the page's fetch and history to observe rather than only its DOM. +document.getElementById('fetch-button').addEventListener('click', () => { + fetch('/api/test').catch(() => undefined); +}); + +let navigations = 0; +document.getElementById('navigate-button').addEventListener('click', () => { + navigations += 1; + history.pushState({}, '', `${location.pathname}#page-${navigations}`); +}); + +document.getElementById('a-form').addEventListener('submit', (event) => { + event.preventDefault(); +}); diff --git a/test-server/configurator/extension-bridge.js b/test-server/configurator/extension-bridge.js new file mode 100644 index 0000000000..a1e86388f0 --- /dev/null +++ b/test-server/configurator/extension-bridge.js @@ -0,0 +1,60 @@ +// Talks to the runner extension, which is the only thing that can instrument a site this page has no +// access to. +// +// The channel is window.postMessage rather than chrome.runtime.sendMessage: the extension's content +// script sits on this page and relays messages to its service worker, which means this page needs no +// extension ID and the extension needs no externally_connectable entry. It also means the extension can +// announce itself, so the button can tell "not installed" from "not responding". +const REQUEST_SOURCE = 'amplitude-configurator'; +const RESPONSE_SOURCE = 'amplitude-configurator-extension'; + +// Set by the extension's bridge content script once it has loaded. +const MARKER = 'amplitudeConfigurator'; + +const RESPONSE_TIMEOUT = 5000; + +export function extensionVersion() { + return document.documentElement.dataset[MARKER]; +} + +// A configuration crosses into the extension as JSON, which has nowhere to put a RegExp. The matching +// reviver lives in the extension's inject.js — the two have to agree on this shape. +export function toJsonSafe(value) { + if (value instanceof RegExp) { + return { __regex: value.source, __flags: value.flags }; + } + if (Array.isArray(value)) { + return value.map(toJsonSafe); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, toJsonSafe(nested)])); + } + return value; +} + +export function requestRun(payload) { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + const finish = (settle, value) => { + clearTimeout(timer); + window.removeEventListener('message', onMessage); + settle(value); + }; + const onMessage = (event) => { + if (event.source !== window || event.data?.source !== RESPONSE_SOURCE || event.data.id !== id) { + return; + } + if (event.data.error) { + finish(reject, new Error(event.data.error)); + } else { + finish(resolve, event.data.result); + } + }; + const timer = setTimeout( + () => finish(reject, new Error('The extension did not respond. Try reloading it at chrome://extensions.')), + RESPONSE_TIMEOUT, + ); + window.addEventListener('message', onMessage); + window.postMessage({ source: REQUEST_SOURCE, id, action: 'run-on-url', payload }, window.location.origin); + }); +} diff --git a/test-server/configurator/main.jsx b/test-server/configurator/main.jsx index f16ff50c9c..6394a0ca03 100644 --- a/test-server/configurator/main.jsx +++ b/test-server/configurator/main.jsx @@ -9,7 +9,9 @@ import { SESSION_REPLAY_SECTIONS, TOP_LEVEL_SESSION_REPLAY_OPTIONS } from './ses import { createDefaultState } from './default-state.js'; import { OptionField } from './option-field.jsx'; import { decodeStateFromUrl, encodeStateToUrl, hasSavedState } from './share-link.js'; -import { buildSnippet, SNIPPET_FORMATS, snippetLanguage } from './snippet.js'; +import { extensionVersion, requestRun, toJsonSafe } from './extension-bridge.js'; +import { buildRuntimeConfig } from './runtime-config.js'; +import { buildSnippet, PLACEHOLDER_API_KEY, SNIPPET_FORMATS, snippetLanguage } from './snippet.js'; const styles = { page: { fontFamily: 'system-ui, sans-serif', maxWidth: 1400, margin: '48px auto', padding: '0 16px' }, @@ -26,6 +28,7 @@ const styles = { formSection: { borderTop: '1px solid #e5e5e5', paddingTop: 16, marginTop: 20 }, toolbar: { display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px' }, toolbarNote: { color: '#888', fontSize: 12 }, + targetInput: { width: 300, padding: '6px 8px', font: 'inherit' }, }; // Lines up the labels of the handful of fields sitting above the panels. @@ -51,6 +54,8 @@ function Configurator({ initialState }) { const [savedState, setSavedState] = useState(null); const [copied, setCopied] = useState(false); const [blockedRunUrl, setBlockedRunUrl] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const [targetNote, setTargetNote] = useState(null); const setConfigOption = (key, value) => { setConfigOptions((previous) => ({ ...previous, [key]: value })); @@ -118,6 +123,35 @@ function Configurator({ initialState }) { } }; + // Running on a site this page has no access to takes the extension: it opens the tab and injects the + // SDK into it. The configuration is materialised here, where the schemas live, and travels as JSON. + const runOnUrl = async () => { + const version = extensionVersion(); + if (!version) { + // Naming the origin matters: the usual cause is that the extension's bridge doesn't list the host + // this page is being served from, and the second is an extension that was edited but not reloaded. + setTargetNote( + `No runner extension detected on ${window.location.origin}. Load test-server/configurator-extension at ` + + 'chrome://extensions, press Reload on it if it was already loaded, then reload this page.', + ); + return; + } + const { apiKey: key, analytics, sessionReplay: replay, engagement: guides } = buildRuntimeConfig(state); + setTargetNote('Opening…'); + try { + const result = await requestRun({ + url: targetUrl.trim(), + apiKey: key || PLACEHOLDER_API_KEY, + analytics: toJsonSafe(analytics), + sessionReplay: toJsonSafe(replay), + engagement: guides, + }); + setTargetNote(result.message); + } catch (error) { + setTargetNote(error.message); + } + }; + let linkNote = 'Copy this configuration as a link to bookmark or share, or run it in a new tab with the SDK initialised.'; if (isSaved) { @@ -149,6 +183,23 @@ function Configurator({ initialState }) { )} +
+ setTargetUrl(event.target.value)} + placeholder="https://example.com" + autoComplete="off" + spellCheck={false} + style={styles.targetInput} + /> + + + {targetNote ?? 'Runs this configuration on another site, through the runner extension.'} + +
+