diff --git a/test-server/configurator-extension/.gitignore b/test-server/configurator-extension/.gitignore index 0ffad3615..a65f73e3f 100644 --- a/test-server/configurator-extension/.gitignore +++ b/test-server/configurator-extension/.gitignore @@ -1,2 +1,2 @@ -# Copied in from packages/analytics-browser/lib/scripts after a build; see README.md. +# Bundles and their .map files, copied in by sync-vendor.mjs after a build; see README.md. vendor/ diff --git a/test-server/configurator-extension/README.md b/test-server/configurator-extension/README.md index 61d1942f6..13abed14a 100644 --- a/test-server/configurator-extension/README.md +++ b/test-server/configurator-extension/README.md @@ -6,7 +6,16 @@ configurator can generate the code, but only an extension can put it on someone ## Setting it up -The SDK bundles aren't checked in. Build them once, then vendor them in: +It isn't in the Chrome Web Store, and a `.crx` can't be dragged into Chrome any more, so either route ends +at Load unpacked on a folder. + +**From the test server.** `/configurator-extension.zip` is this directory, vendored bundles and all, zipped +on request by `test-server/extension-archive.js`. Download it from the configurator's install panel or +directly, unzip it, and load the `configurator-extension` folder it leaves behind. Nothing to build, and +the archive always matches the checkout that served it. `vite build` emits the same file, so a hosted copy +of the configurator offers the same download. + +**From this checkout.** The SDK bundles aren't checked in. Build them once, then vendor them in: ```bash pnpm --dir packages/analytics-browser build @@ -14,9 +23,9 @@ 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. +Then load `test-server/configurator-extension` itself at `chrome://extensions` with developer mode on, and +start the test server with `pnpm dev`. This is the one to use while working on the extension: after editing +any file here, hit its reload icon — that also clears which tabs were being instrumented. ## Using it @@ -25,6 +34,12 @@ to the "Run on URL" button, and click it. The page hands the configuration to th 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. +Filling in "Mock Referrer" alongside that URL makes `document.referrer` read whatever you put there, so +attribution can be tried without having to arrive from the referring site. "Clean Session", which is on +unless you turn it off, deletes Amplitude's stored state for the site first, so every run starts with a new +device ID, a new session and no prior campaign — which is what makes a referrer worth mocking in the first +place. Untick it to pick up where the last run left off. + **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 @@ -34,9 +49,12 @@ from the start. `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 +`127.0.0.1` over http for `pnpm dev`, `local.website.com` over https for `pnpm dev:ssh` — plus the Netlify +site the `pnpm build:configurator` artifact is shared from, 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. Hosted origins are listed one at a time rather than as `https://*.netlify.app/`: +a wildcard there would offer the relay to every site on a shared domain, and the relay leads to a service +worker that can inject the SDK into any tab. 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 @@ -83,6 +101,22 @@ page uses, and travels as JSON. Regexes have nowhere to live in JSON, so `toJson `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`. +- **Both landing options are spent on the first page of a run.** A mocked referrer and a cleared session + describe arriving at a site rather than being on one, so `takePayload()` hands them to the commit that + opens the run and takes them off the stored payload. Clearing on every commit would hand out a new device + ID and session on every page and no session would last more than one pageview; a referrer mocked again + would keep claiming the visitor came from elsewhere when they came from the previous page. Pages after + the first therefore report their real referrer and keep the session that was just started. +- **A mocked referrer is only the JS view.** `handOver` shadows `document.referrer` with an own property, + which is what the campaign parser and the page-URL enrichment plugin read. The `Referer` header the page + was actually fetched with is untouched, so anything server-side still sees the truth — and modifying that + header wouldn't help, because Chrome derives `document.referrer` from the navigation's referrer rather + than from a header a `declarativeNetRequest` rule rewrote. +- **Clearing the session takes every Amplitude key with it.** It sweeps by prefix — `AMP_` and the legacy + lowercase `amp_` — across cookies, `localStorage` and `sessionStorage`, so it also clears the state of the + site's *own* Amplitude instance if it has one, in your browser only. Nothing else the site stores is + touched, and IndexedDB is left alone: session replay's recorded events live there, and a new session ID + makes them moot anyway. - **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 diff --git a/test-server/configurator-extension/background.js b/test-server/configurator-extension/background.js index bf3b54d5b..c8e88add8 100644 --- a/test-server/configurator-extension/background.js +++ b/test-server/configurator-extension/background.js @@ -19,6 +19,12 @@ const SESSION_REPLAY_BUNDLE = 'vendor/plugin-session-replay-browser-min.js'; const TABS_KEY = 'instrumentedTabs'; const LAST_PAYLOAD_KEY = 'lastPayload'; +// Everything the SDKs key their storage by, from AMPLITUDE_PREFIX in analytics-core's +// types/constants.ts: `AMP_` holds the device ID, session ID and user ID, `AMP_MKTG_` the +// last campaign, and `AMP_unsent_`, `AMP_remote_config_`, `AMP_SR_START_`, `AMP_PAGE_VIEW` the rest. The +// lowercase form is getOldCookieName()'s, still read by the cookie migration on init. +const AMPLITUDE_STORAGE_PREFIXES = ['AMP_', 'amp_']; + // 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'; @@ -49,13 +55,47 @@ async function instrumentedTabs() { return tabs; } +// A tab is not a thing that stays put: it can be closed, and Chrome can swap it for a prerendered one +// mid-navigation. Every call below that names a tab id can therefore find nothing there, and tabs, action +// and scripting all say so the same way — "No tab with id: 1234.", a message that says nothing about what +// was being attempted and so is worth catching rather than showing. +function isMissingTab(error) { + return /No tab with id/.test(error?.message ?? ''); +} + +// For the calls that only decorate a tab: a tab that has gone away has no badge or tooltip worth setting, +// and failing to set one is not worth abandoning a run over. +async function ignoreMissingTab(pending) { + try { + await pending; + } catch (error) { + if (!isMissingTab(error)) { + throw error; + } + } +} + +// An async listener that rejects has nowhere to put the error: it becomes an uncaught rejection in the +// service worker's console and on the extension's card at chrome://extensions, with nothing to say what was +// being attempted. Named here so anything that does go wrong arrives with its context attached. +function guard(name, handler) { + return (...args) => + handler(...args).catch((error) => { + // The tab this was about is gone, which onRemoved has already tidied up after. + if (isMissingTab(error)) { + return; + } + console.error(`[amplitude-configurator] ${name} failed`, error); + }); +} + // 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' }); + await ignoreMissingTab(chrome.action.setBadgeText({ tabId, text: 'on' })); } async function forget(tabId) { @@ -65,9 +105,45 @@ async function forget(tabId) { await restoreCsp(tabId); } +// The payload this commit runs with — and, as a side effect, the payload every later commit in the tab will +// run with. +// +// Two of its options describe arriving at a site rather than being on one, so they belong to the page a run +// opens with and to no other. Clearing again would hand out a new device ID and session on every page, and +// there would be no session left to watch. A referrer mocked again would keep insisting the visitor came +// from somewhere else when they in fact came from the previous page of the site, which is a story no real +// second pageview tells. Both are therefore spent here: read for this commit, then taken off what is stored. +// +// Session storage rather than a variable because the service worker is routinely torn down between marking a +// tab and the navigation it opened, which would otherwise make "first commit" mean "first since the worker +// last woke up". +async function takePayload(tabId) { + const tabs = await instrumentedTabs(); + const payload = tabs[tabId]; + if (!payload) { + return undefined; + } + const { clearSession, mockReferrer, ...rest } = payload; + if (clearSession || mockReferrer) { + await chrome.storage.session.set({ [TABS_KEY]: { ...tabs, [tabId]: rest } }); + } + return payload; +} + // 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) { + // document.referrer is a configurable accessor inherited from Document.prototype, so an own property + // shadows it for the page and for the SDK's campaign parser alike, and only for this document — the next + // page the tab commits gets a payload with no referrer in it. Only the JS view moves: the request that + // fetched this page carried whatever Referer the browser chose, and nothing here can change that after + // the fact. + if (payload.mockReferrer) { + Object.defineProperty(document, 'referrer', { + configurable: true, + get: () => payload.mockReferrer, + }); + } window.__amplitudeConfigurator = { hadGlobal: 'amplitude' in window, properties: window.amplitude ? { ...window.amplitude } : undefined, @@ -76,41 +152,99 @@ function handOver(payload, csp) { }; } -chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId, url }) => { - if (frameId !== 0 || !url.startsWith('http')) { - return; +// Runs in the page before the SDK bundle, so what init() finds is an origin the SDK has never seen: no +// device ID, no session, no stored campaign. Only Amplitude's own keys go, since the site's login and the +// rest of its storage are what make it worth testing on. +function clearStoredSession(prefixes) { + const isAmplitude = (key) => prefixes.some((prefix) => key.startsWith(prefix)); + const removed = []; + + // document.cookie yields names and values and never the domain a cookie was set on, while the SDK writes + // to the highest domain it can — so each name is expired against every suffix of this hostname as well as + // host-only. Suffixes that may not hold cookies, like a public one, are refused rather than mis-set, and + // path=/ is what the SDK writes. + const labels = location.hostname.split('.'); + const domains = ['', ...labels.map((_, index) => `.${labels.slice(index).join('.')}`)]; + for (const pair of document.cookie ? document.cookie.split('; ') : []) { + const separator = pair.indexOf('='); + const name = (separator === -1 ? pair : pair.slice(0, separator)).trim(); + if (!name || !isAmplitude(name)) { + continue; + } + for (const domain of domains) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/${domain && `; domain=${domain}`}`; + } + removed.push(`cookie ${name}`); } - const payload = (await instrumentedTabs())[tabId]; - if (!payload) { - return; + + for (const [label, store] of [ + ['localStorage', localStorage], + ['sessionStorage', sessionStorage], + ]) { + // Reading either throws outright where the site's cookie policy forbids it, which says nothing about + // the other or about the cookies above. + try { + for (const key of Object.keys(store).filter(isAmplitude)) { + store.removeItem(key); + removed.push(`${label} ${key}`); + } + } catch (error) { + console.warn(`[amplitude-configurator] ${label} could not be read, so nothing was cleared from it`, error); + } } - // 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 }); + + // Named individually: "the session was cleared" and "the session was already empty" lead to different + // places when a run doesn't look the way it was expected to. + if (removed.length) { + console.log(`[amplitude-configurator] cleared ${removed.length} stored Amplitude entries`, removed); + } else { + console.log('[amplitude-configurator] no stored Amplitude state to clear on this origin'); } - 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); +} + +chrome.webNavigation.onCommitted.addListener( + guard('injection', async ({ tabId, frameId, url }) => { + if (frameId !== 0 || !url.startsWith('http')) { + return; + } + const payload = await takePayload(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 ignoreMissingTab(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] }); + if (payload.clearSession) { + await inject({ func: clearStoredSession, args: [AMPLITUDE_STORAGE_PREFIXES] }); + } + 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) { + if (isMissingTab(error)) { + throw error; + } + console.error('[amplitude-configurator] injection failed', error); + await ignoreMissingTab(chrome.action.setBadgeText({ tabId, text: 'err' })); } - 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']; @@ -126,6 +260,14 @@ function describe(payload) { // 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.mockReferrer) { + message += ` document.referrer reads ${payload.mockReferrer} on that first page, and the truth after it.`; + } + if (payload.clearSession) { + message += + " Amplitude's stored cookies and web storage are cleared first, so the run starts with a new device ID" + + ' and session.'; + } if (payload.apiKey === PLACEHOLDER_API_KEY) { message += ' No API key is set, so events are built but rejected.'; } @@ -138,11 +280,27 @@ async function runOnUrl(payload) { 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() }); + // afterwards is what makes the ordering reliable. It also means there is a moment where the run depends + // on a tab nobody is looking at yet, and anything that closes it — a click, a tab-tidying extension, + // Chrome swapping in a prerender — leaves the steps below with nothing to work on. + let tab; + try { + 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() }); + } catch (error) { + if (!isMissingTab(error)) { + throw error; + } + if (tab) { + // The mark and the CSP rule are both keyed by tab id, and Chrome reuses ids, so leaving them behind + // would take the policy off whichever tab inherits this one's. + await forget(tab.id); + } + // The id is named because it is the one thing that ties this back to what the browser did with the tab. + throw new Error(`Tab ${tab?.id} was opened for this run and went away before it could be navigated.`); + } return { message: describe(payload) }; } @@ -155,19 +313,35 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }); // 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.action.onClicked.addListener( + guard('toolbar button', async (tab) => { + if (!tab.url?.startsWith('http')) { + return; + } + if ((await instrumentedTabs())[tab.id]) { + await forget(tab.id); + await ignoreMissingTab(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(guard('cleanup', (tabId) => forget(tabId))); -chrome.tabs.onRemoved.addListener((tabId) => forget(tabId)); +// Chrome can finish a navigation in a different tab than it started in: a prerendered page arrives in a tab +// of its own and takes the old one's place, which destroys the id everything here is keyed by. Moving the +// mark and the CSP rule across keeps the run alive, and keeps a rule from outliving the tab it was for. +chrome.tabs.onReplaced.addListener( + guard('tab replacement', async (addedTabId, removedTabId) => { + const payload = (await instrumentedTabs())[removedTabId]; + if (!payload) { + return; + } + await forget(removedTabId); + await instrument(addedTabId, payload); + }), +); diff --git a/test-server/configurator-extension/configurator-bridge.js b/test-server/configurator-extension/configurator-bridge.js index 3c43a8fa7..909d4195a 100644 --- a/test-server/configurator-extension/configurator-bridge.js +++ b/test-server/configurator-extension/configurator-bridge.js @@ -14,11 +14,20 @@ window.addEventListener('message', (event) => { return; } const { id, action, payload } = event.data; - chrome.runtime.sendMessage({ action, payload }, (response) => { - const error = chrome.runtime.lastError?.message ?? response?.error; + const respond = (error, response) => window.postMessage( { source: RESPONSE_SOURCE, id, error, result: error ? undefined : response }, window.location.origin, ); - }); + try { + chrome.runtime.sendMessage({ action, payload }, (response) => { + respond(chrome.runtime.lastError?.message ?? response?.error, response); + }); + } catch { + // Reloading the extension orphans the content scripts already sitting on open pages: this one keeps + // running, but its chrome.runtime is gone and sendMessage throws rather than reaching anything. Saying + // so beats letting the page wait out its timeout and conclude the extension is broken, which is the + // opposite of what happened — it's this copy of the page that's stale. + respond('This page predates the last extension reload and can no longer reach it. Reload this page.'); + } }); diff --git a/test-server/configurator-extension/inject.js b/test-server/configurator-extension/inject.js index f6b36d140..6e3c17a1a 100644 --- a/test-server/configurator-extension/inject.js +++ b/test-server/configurator-extension/inject.js @@ -40,6 +40,13 @@ const config = revive(payload.analytics); const replayOptions = payload.sessionReplay ? revive(payload.sessionReplay) : null; + // Read back rather than echoed from the payload, so this also reports an override that didn't take. A + // mocked referrer changes what attribution reports and nothing else on the page gives it away, which + // makes it worth a line of its own before anything reads it. + if (payload.mockReferrer) { + console.log(`[amplitude-configurator] document.referrer is mocked as ${document.referrer}`); + } + // Every event the SDK builds, whether or not it can be uploaded. The postMessage is the seam a // devtools panel would read. const eventLogPlugin = { diff --git a/test-server/configurator-extension/manifest.json b/test-server/configurator-extension/manifest.json index 26da5e7f0..b7301169f 100644 --- a/test-server/configurator-extension/manifest.json +++ b/test-server/configurator-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Amplitude Configurator Runner (spike)", - "version": "0.0.3", + "version": "0.0.9", "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": ["*://*/*"], @@ -12,6 +12,12 @@ "action": { "default_title": "Run the Amplitude SDK on this tab" }, + "web_accessible_resources": [ + { + "resources": ["vendor/*.map"], + "matches": [""] + } + ], "content_scripts": [ { "matches": [ @@ -20,7 +26,8 @@ "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*" + "https://local.website.com/configurator.html*", + "https://bespoke-melomakarona-ec9cbf.netlify.app/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 index 34a945a49..d0351ed8b 100644 --- a/test-server/configurator-extension/sync-vendor.mjs +++ b/test-server/configurator-extension/sync-vendor.mjs @@ -1,18 +1,22 @@ // Copies the SDK bundles this extension injects out of packages/, and works around Chromium's stricter -// idea of UTF-8 on the way. +// idea of UTF-8 on the way. Adjacent `.map` files come along so DevTools can unminify the injected +// scripts — the bundles already end in `//# sourceMappingURL=.map`. // // 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. +// string and regex literals they appear in, and leaves the rest of the bundle untouched. The map is +// left alone: it isn't loaded as a content script, and rewriting the escaped offsets isn't worth it +// for four characters. // // Run from the repository root: node test-server/configurator-extension/sync-vendor.mjs -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { copyFile, 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 VENDOR = path.join(HERE, 'vendor'); const BUNDLES = [ 'analytics-browser/lib/scripts/amplitude-min.js', @@ -22,10 +26,11 @@ const BUNDLES = [ // 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 }); +await mkdir(VENDOR, { recursive: true }); for (const bundle of BUNDLES) { const source = path.join(PACKAGES, bundle); + const name = path.basename(bundle); let code; try { code = await readFile(source, 'utf8'); @@ -39,9 +44,16 @@ for (const bundle of BUNDLES) { 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` : ''}`, - ); + await writeFile(path.join(VENDOR, name), output); + console.log(`${name}: ${output.length} chars${escaped ? `, escaped ${escaped} non-characters` : ''}`); + + const mapName = `${name}.map`; + const mapSource = `${source}.map`; + try { + await copyFile(mapSource, path.join(VENDOR, mapName)); + console.log(`${mapName}: copied`); + } catch { + console.error(`Missing ${bundle}.map. Build the package first, then run this again.`); + process.exitCode = 1; + } } diff --git a/test-server/configurator/blades.js b/test-server/configurator/blades.js new file mode 100644 index 000000000..d00064325 --- /dev/null +++ b/test-server/configurator/blades.js @@ -0,0 +1,26 @@ +// The product offerings a client can be built for — "blades" internally, and the SDKs initAll() wires +// up in packages/unified. Each key is both the state key holding whether that blade is switched on and +// what gates its section of the form, so nothing is configured for a product that isn't in use. +// +// Analytics is `required` because the others are plugins on the analytics SDK and take their identity +// and session from it: there is no client here that doesn't init it. Its checkbox is shown so the set +// reads as the whole product line, but it can't be switched off, and nothing about it is held in the +// state the link carries. +export const BLADES = [ + { + key: 'analytics', + label: 'Analytics', + required: true, + description: 'Always on: the other blades are plugins on the analytics SDK and build on its identity and session.', + }, + { + key: 'sessionReplay', + label: 'Session Replay', + description: 'Installs the session replay plugin alongside the analytics SDK.', + }, + { + key: 'engagement', + label: 'Guides and Surveys', + description: 'Installs the Guides and Surveys plugin alongside the analytics SDK.', + }, +]; diff --git a/test-server/configurator/components.jsx b/test-server/configurator/components.jsx index 917b03bf5..745905e1b 100644 --- a/test-server/configurator/components.jsx +++ b/test-server/configurator/components.jsx @@ -1,6 +1,8 @@ import React from 'react'; -// Prism's default build already registers the javascript grammar, so no component import is needed. +// Prism's default build already registers the javascript and markup grammars, so only the shell one the +// extension's setup commands are shown in has to be pulled in. import Prism from 'prismjs'; +import 'prismjs/components/prism-bash'; import './syntax-theme.css'; const styles = { @@ -24,10 +26,15 @@ const styles = { resize: 'vertical', }, select: { padding: '5px 8px', font: 'inherit' }, + inlineCheckbox: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }, hint: { color: '#888', fontSize: 12 }, subGroup: { padding: '0 0 0 16px', borderLeft: '2px solid #e2e2e2', margin: '0 0 12px 4px' }, subGroupTitle: { fontSize: 12, color: '#666', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: 0.4 }, - button: { font: 'inherit', fontSize: 13, padding: '4px 10px', cursor: 'pointer' }, + // flex: none so a long note beside a button in a toolbar row can't squeeze its label onto two lines. + button: { font: 'inherit', fontSize: 13, padding: '4px 10px', cursor: 'pointer', flex: 'none' }, + disabledButton: { cursor: 'not-allowed', color: '#999' }, + // Takes the button's place as the flex item, so wrapping one changes nothing about the row. + buttonTooltip: { display: 'inline-flex', flex: 'none' }, card: { border: '1px solid #ddd', borderRadius: 6, background: '#fff', padding: '10px 12px 2px', marginBottom: 10 }, cardHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }, cardTitle: { fontSize: 13, fontWeight: 600, margin: 0 }, @@ -192,6 +199,49 @@ export function CheckboxField({ id, label, checked, onChange, labelWidth, hint, ); } +// Several checkboxes on one row under a single label, for a set that reads as one choice rather than as +// unrelated switches. `role` and `aria-labelledby` stand in for a fieldset and legend, whose default +// rendering doesn't match the label-and-control rows around it. +export function CheckboxGroup({ id, label, labelWidth, description, options, values, onChange }) { + const labelStyle = { + ...styles.label, + ...(labelWidth ? { width: labelWidth, flex: 'none' } : null), + ...(description ? styles.documentedLabel : null), + }; + return ( +
+ + {label} + + {options.map((option) => ( + + ))} +
+ ); +} + // Left uncontrolled so the browser owns the open/closed state; the panel re-renders on every // keystroke elsewhere on the page and a controlled `open` would fight that. export function Panel({ title, description, badge, defaultOpen = false, children }) { @@ -209,12 +259,26 @@ export function Panel({ title, description, badge, defaultOpen = false, children ); } -export function Button({ onClick, children }) { - return ( - ); + return title ? ( + + {button} + + ) : ( + button + ); } export function Note({ children }) { diff --git a/test-server/configurator/default-state.js b/test-server/configurator/default-state.js index c7fbd69e7..b8fb933d6 100644 --- a/test-server/configurator/default-state.js +++ b/test-server/configurator/default-state.js @@ -5,19 +5,32 @@ import { createDefaultValues } from './fields.js'; import { SESSION_REPLAY_OPTIONS } from './session-replay-options.js'; // Everything the form holds, in one shape, so it can be diffed against a saved link and restored from -// one. These are also exactly the arguments buildSnippet() and buildRuntimeConfig() take, which is why -// the run page can rebuild a configuration from nothing but the link the form produced. +// one. The configuration among it is exactly the arguments buildSnippet() and buildRuntimeConfig() take, +// which is why the run page can rebuild a configuration from nothing but the link the form produced. export function createDefaultState() { return { apiKey: '', format: 'esm', + // The site "Run on URL" injects into. No SDK reads it — it rides along so that reopening a link + // doesn't cost the site the configuration was last tried on. + targetUrl: '', + // Stands in for document.referrer on the instrumented tab, so attribution can be tried without + // having to arrive from the referring site. Only the runner extension can honour it. + mockReferrer: '', + // Takes Amplitude's stored state off the site before the run starts, so it begins as a visitor the + // SDK has never seen. On by default: a run that inherits the last one's device ID and session is the + // harder thing to reason about, and it's what makes attribution look like it isn't working. Only the + // runner extension can honour it. + clearSession: true, + // Which of the optional blades are in use, keyed as BLADES describes; analytics is always on. The + // options below belong to one blade each and are only reachable while it is switched on. + sessionReplay: false, + engagement: false, configOptions: createDefaultValues(CONFIG_OPTIONS), autocapture: true, autocaptureOptions: createDefaultAutocaptureOptions(), autocaptureSubOptions: createDefaultSubOptions(), - sessionReplay: false, sessionReplayOptions: createDefaultValues(SESSION_REPLAY_OPTIONS), - engagement: false, engagementOptions: createDefaultValues(ENGAGEMENT_OPTIONS), }; } diff --git a/test-server/configurator/extension-bridge.js b/test-server/configurator/extension-bridge.js index a1e86388f..3f32a9388 100644 --- a/test-server/configurator/extension-bridge.js +++ b/test-server/configurator/extension-bridge.js @@ -5,6 +5,8 @@ // 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". +import { useEffect, useState } from 'react'; + const REQUEST_SOURCE = 'amplitude-configurator'; const RESPONSE_SOURCE = 'amplitude-configurator-extension'; @@ -17,6 +19,27 @@ export function extensionVersion() { return document.documentElement.dataset[MARKER]; } +// The version as something the page can render, for the parts of the UI that are there or not there +// depending on whether the extension is. The bridge writes its marker at document_end, which is after +// this page's own module has run, so the first render can't see it however well the extension is +// installed; by load it is there, and nothing can add it later, since content scripts only inject as a +// page loads. +export function useExtensionVersion() { + const [version, setVersion] = useState(extensionVersion); + + useEffect(() => { + const check = () => setVersion(extensionVersion()); + if (document.readyState === 'complete') { + check(); + return undefined; + } + window.addEventListener('load', check); + return () => window.removeEventListener('load', check); + }, []); + + return version; +} + // 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) { @@ -51,7 +74,13 @@ export function requestRun(payload) { } }; const timer = setTimeout( - () => finish(reject, new Error('The extension did not respond. Try reloading it at chrome://extensions.')), + // Reloading the extension is only half of it: that orphans this page's content script, so the page + // has to be reloaded after to be talking to the copy that was just loaded. + () => + finish( + reject, + new Error('The extension did not respond. Reload it at chrome://extensions, then reload this page.'), + ), RESPONSE_TIMEOUT, ); window.addEventListener('message', onMessage); diff --git a/test-server/configurator/main.jsx b/test-server/configurator/main.jsx index 6394a0ca0..80bc2c469 100644 --- a/test-server/configurator/main.jsx +++ b/test-server/configurator/main.jsx @@ -1,15 +1,17 @@ import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { Button, CheckboxField, CodeBlock, SelectField, TextField } from './components.jsx'; +import { Button, CheckboxField, CheckboxGroup, CodeBlock, Panel, SelectField, TextField } from './components.jsx'; import { AutocapturePanel } from './autocapture-panel.jsx'; import { SectionPanels } from './section-panels.jsx'; +import { BLADES } from './blades.js'; import { CONFIG_SECTIONS, SHARED_CONFIG_OPTIONS, TOP_LEVEL_CONFIG_OPTIONS } from './config-options.js'; import { ENGAGEMENT_SECTIONS } from './engagement-options.js'; import { SESSION_REPLAY_SECTIONS, TOP_LEVEL_SESSION_REPLAY_OPTIONS } from './session-replay-options.js'; import { createDefaultState } from './default-state.js'; import { OptionField } from './option-field.jsx'; +import { RunnerExtensionPanel } from './runner-extension-panel.jsx'; import { decodeStateFromUrl, encodeStateToUrl, hasSavedState } from './share-link.js'; -import { extensionVersion, requestRun, toJsonSafe } from './extension-bridge.js'; +import { requestRun, toJsonSafe, useExtensionVersion } from './extension-bridge.js'; import { buildRuntimeConfig } from './runtime-config.js'; import { buildSnippet, PLACEHOLDER_API_KEY, SNIPPET_FORMATS, snippetLanguage } from './snippet.js'; @@ -24,16 +26,23 @@ const styles = { // Sticks alongside the config column, which is far taller once a few panels are open. outputColumn: { flex: '1 1 400px', minWidth: 0, position: 'sticky', top: 24 }, sectionHeading: { fontSize: 16, margin: '0 0 8px' }, - // The API key belongs to both sections below, so it sits above the first rule. + // The API key, the shared options and the blade picker belong to every blade, so they sit above the + // first rule. 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' }, + // Matches the width the install panel below it sits at, so the two read as one column. + runPanel: { maxWidth: 760 }, + runActions: { display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 12px' }, }; // Lines up the labels of the handful of fields sitting above the panels. const TOP_LABEL_WIDTH = 100; +// Wider than the fields below, because "Mock Referrer" is longer than anything the SDK's own options are +// called. +const RUN_LABEL_WIDTH = 110; + const DEFAULT_STATE = createDefaultState(); // Sibling page that initialises the SDK with whatever the link carries. @@ -42,20 +51,33 @@ const RUN_PAGE = '/configurator-run.html'; function Configurator({ initialState }) { const [apiKey, setApiKey] = useState(initialState.apiKey); const [format, setFormat] = useState(initialState.format); + // Which of the optional blades are switched on, read out of the state by the keys BLADES names so the + // picker and the sections it gates can't drift apart. Analytics is required, so it isn't in here and + // its section is always rendered. Each blade's own options live on below whether it is on or not, so + // switching one off and back on doesn't cost the configuration that was built for it. + const [blades, setBlades] = useState(() => + Object.fromEntries(BLADES.filter((blade) => !blade.required).map(({ key }) => [key, initialState[key]])), + ); const [configOptions, setConfigOptions] = useState(initialState.configOptions); const [autocapture, setAutocapture] = useState(initialState.autocapture); // Kept when autocapture is switched off so the selections come back on re-enable. const [autocaptureOptions, setAutocaptureOptions] = useState(initialState.autocaptureOptions); const [autocaptureSubOptions, setAutocaptureSubOptions] = useState(initialState.autocaptureSubOptions); - const [sessionReplay, setSessionReplay] = useState(initialState.sessionReplay); const [sessionReplayOptions, setSessionReplayOptions] = useState(initialState.sessionReplayOptions); - const [engagement, setEngagement] = useState(initialState.engagement); const [engagementOptions, setEngagementOptions] = useState(initialState.engagementOptions); const [savedState, setSavedState] = useState(null); const [copied, setCopied] = useState(false); const [blockedRunUrl, setBlockedRunUrl] = useState(null); - const [targetUrl, setTargetUrl] = useState(''); + const [targetUrl, setTargetUrl] = useState(initialState.targetUrl); + const [mockReferrer, setMockReferrer] = useState(initialState.mockReferrer); + const [clearSession, setClearSession] = useState(initialState.clearSession); const [targetNote, setTargetNote] = useState(null); + // Running on another site is the extension's to do, so without it that button has nothing behind it. + const runnerVersion = useExtensionVersion(); + + const setBlade = (key, value) => { + setBlades((previous) => ({ ...previous, [key]: value })); + }; const setConfigOption = (key, value) => { setConfigOptions((previous) => ({ ...previous, [key]: value })); @@ -80,13 +102,15 @@ function Configurator({ initialState }) { const state = { apiKey, format, + targetUrl, + mockReferrer, + clearSession, + ...blades, configOptions, autocapture, autocaptureOptions, autocaptureSubOptions, - sessionReplay, sessionReplayOptions, - engagement, engagementOptions, }; @@ -125,22 +149,20 @@ 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. + // Nothing here checks that the extension is present, because the button is disabled until it is. 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; - } + // The address bar is where this form keeps everything else, so the site being tested is kept there + // too: reloading, or coming back to the link later, reopens with it still filled in. + window.history.replaceState(null, '', await encodeStateToUrl(state, DEFAULT_STATE)); const { apiKey: key, analytics, sessionReplay: replay, engagement: guides } = buildRuntimeConfig(state); setTargetNote('Opening…'); try { const result = await requestRun({ url: targetUrl.trim(), + // Left off entirely when blank, so the extension can tell "don't touch document.referrer" from + // "make it read empty", which is what arriving with no referrer at all looks like. + mockReferrer: mockReferrer.trim() || undefined, + clearSession, apiKey: key || PLACEHOLDER_API_KEY, analytics: toJsonSafe(analytics), sessionReplay: toJsonSafe(replay), @@ -148,7 +170,9 @@ function Configurator({ initialState }) { }); setTargetNote(result.message); } catch (error) { - setTargetNote(error.message); + // Named with the version that produced it: the extension is loaded unpacked from a working copy, so + // "it still says the same thing" is as likely to be a copy that was never reloaded as a real failure. + setTargetNote(`${error.message} (runner ${runnerVersion})`); } }; @@ -183,23 +207,67 @@ 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.'} - + {/* Its own panel because none of it is configuration: every field here describes the tab the SDK is + injected into rather than the SDK, so none of it appears in the generated snippet. */} +
+ + + + +
+ + {/* Only after a click: what this panel is for is said in its description, and repeating it here + would leave nowhere for the answer to appear. */} + {targetNote ? {targetNote} : null} +
+
+ +
))} +

Analytics

@@ -261,60 +338,42 @@ function Configurator({ initialState }) { />
-
-

Session Replay

- - - {sessionReplay ? ( - <> - {TOP_LEVEL_SESSION_REPLAY_OPTIONS.map((field) => ( - setSessionReplayOption(field.key, value)} - labelWidth={TOP_LABEL_WIDTH} - /> - ))} - - +

Session Replay

+ + {TOP_LEVEL_SESSION_REPLAY_OPTIONS.map((field) => ( + setSessionReplayOption(field.key, value)} + labelWidth={TOP_LABEL_WIDTH} /> - - ) : null} -
+ ))} -
-

Guides and Surveys

+ +
+ ) : null} + + {blades.engagement ? ( +
+

Guides and Surveys

- - {engagement ? ( - ) : null} -
+ + ) : null}
diff --git a/test-server/configurator/runner-extension-panel.jsx b/test-server/configurator/runner-extension-panel.jsx new file mode 100644 index 000000000..7e0343bc7 --- /dev/null +++ b/test-server/configurator/runner-extension-panel.jsx @@ -0,0 +1,109 @@ +// How to get hold of the extension "Run on URL" needs. It isn't in the Chrome Web Store, so there is no +// install link to point at: the steps are the download this server builds, and Load unpacked. They mirror +// test-server/configurator-extension/README.md, which is the fuller account. +import React, { useEffect, useState } from 'react'; +import { CodeBlock, Panel } from './components.jsx'; + +const EXTENSION_DIRECTORY = 'test-server/configurator-extension'; + +const REPOSITORY_URL = `https://github.com/amplitude/Amplitude-TypeScript/tree/main/${EXTENSION_DIRECTORY}`; + +// Built by test-server/extension-archive.js, which owns this path, out of the extension directory as it +// stands in whatever checkout is serving this page. +const ARCHIVE_URL = '/configurator-extension.zip'; + +// What this server's copy of the extension says its version is, served by test-server/extension-archive.js +// alongside the archive itself. +const VERSION_URL = '/configurator-extension-version.json'; + +// The folder the archive holds, which is also the folder to load from a checkout, so the last two steps +// read the same either way. +const UNPACKED_FOLDER = 'configurator-extension'; + +// The bundles the extension injects aren't checked in. The archive carries them already; a checkout has +// to build them before Chrome will accept the folder. +const SETUP_COMMANDS = `pnpm --dir packages/analytics-browser build +pnpm --dir packages/plugin-session-replay-browser build +node ${EXTENSION_DIRECTORY}/sync-vendor.mjs`; + +const styles = { + wrapper: { maxWidth: 760, margin: '0 0 20px' }, + note: { color: '#888', fontSize: 12, margin: '0 0 10px' }, + steps: { margin: '0 0 10px', paddingLeft: 20, fontSize: 13, color: '#444', lineHeight: 1.6 }, + step: { marginBottom: 6 }, + commands: { margin: '8px 0 4px' }, + stale: { color: '#a15c00', fontSize: 13, margin: '0 0 10px', fontWeight: 600 }, +}; + +// Undefined until it has been read, and stays undefined where nothing serves it — a hosted copy built +// before this existed, say — which the caller reads as "nothing to compare". +function useShippedVersion() { + const [version, setVersion] = useState(undefined); + + useEffect(() => { + void fetch(VERSION_URL) + .then((response) => (response.ok ? response.json() : null)) + .then((body) => setVersion(body?.version)) + .catch(() => setVersion(undefined)); + }, []); + + return version; +} + +export function RunnerExtensionPanel({ version }) { + const shipped = useShippedVersion(); + // Only ever true when both are known. The page can't reach the extension's folder, so this comparison is + // the only thing that can tell a stale copy from a current one, and a stale copy is indistinguishable + // from a broken one otherwise: both simply fail to answer. + const isStale = Boolean(version && shipped && version !== shipped); + const staleNote = isStale + ? `The installed runner is ${version}, and this server ships ${shipped}. Reload it at chrome://extensions, then` + + ' reload this page — in that order, since reloading the extension is what leaves this page talking to the' + + ' copy it replaced.' + : null; + + let badge = 'not detected on this page'; + if (isStale) { + badge = `installed · ${version} · out of date`; + } else if (version) { + badge = `installed · ${version}`; + } + + return ( +
+ {/* Outside the panel rather than inside it: the panel is collapsed until someone opens it, and a stale + copy is exactly the thing nobody thinks to go looking for. */} + {staleNote ?

{staleNote}

: null} + +

+ The extension isn't in the Chrome Web Store, so Chrome will only take it as an unpacked folder. This + server builds that folder into an archive for you, SDK bundles included. +

+
    +
  1. + Download{' '} + + configurator-extension.zip + + , built from the checkout serving this page, so it matches the SDK it configures. +
  2. +
  3. + Unzip it. That leaves one {UNPACKED_FOLDER} folder — keep it somewhere it can stay, since + Chrome loads the extension from where it sits rather than copying it. +
  4. +
  5. + Open chrome://extensions — paste it into the address bar, since Chrome won't follow a link + there — and turn on Developer mode. +
  6. +
  7. + Click Load unpacked and choose that {UNPACKED_FOLDER} folder. +
  8. +
  9. + Reload this page. The extension attaches to it as it loads, so a page open from before the install + can't see it. +
  10. +
+
+
+ ); +} diff --git a/test-server/configurator/snippet.js b/test-server/configurator/snippet.js index 359368173..1ca12a203 100644 --- a/test-server/configurator/snippet.js +++ b/test-server/configurator/snippet.js @@ -188,7 +188,7 @@ function autocaptureEntry({ autocapture, autocaptureOptions, autocaptureSubOptio // initAll() spreads its shared options over the analytics ones, so one of these nested under // `analytics` would be overwritten with undefined. They have to be hoisted. This is the same set the -// form renders above the per-SDK sections. +// form renders above the per-blade sections. const UNIFIED_SHARED_KEYS = SHARED_CONFIG_OPTIONS.map((field) => field.key); function unifiedEntries(state) { diff --git a/test-server/extension-archive.js b/test-server/extension-archive.js new file mode 100644 index 000000000..a21cf505e --- /dev/null +++ b/test-server/extension-archive.js @@ -0,0 +1,218 @@ +// Serves the configurator's runner extension as a zip, so installing it doesn't take a checkout of this +// repository or a build of the SDK. +// +// The archive isn't an installable package. Chrome only accepts an unpacked folder from outside the Web +// Store — a .crx can't be dragged in any more — so this is a way to get the files: download, unzip, hand +// the folder to "Load unpacked". Everything is nested under one directory named after the source folder, +// so every unzipping tool produces the same single folder to point at. +// +// It's built from the working tree per request rather than checked in, which keeps it in step with the +// extension and with the SDK bundles vendored into it. `vite build` emits the same bytes as a static +// asset, since a hosted copy of the configurator has no middleware to build it on demand. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { deflateRawSync } from 'node:zlib'; + +// Also hardcoded by the configurator's runner-extension-panel.jsx, which links to it. It can't import +// this module for the constant: this one reads the filesystem. +const ARCHIVE_NAME = 'configurator-extension.zip'; + +const ARCHIVE_URL = `/${ARCHIVE_NAME}`; + +// The version this server would hand out, for the configurator to hold against the one the installed +// extension reports. An unpacked extension is a folder Chrome loaded once and won't look at again until it +// is reloaded, and the page it talks to is pinned to whichever copy was loaded when the page opened, so the +// two drift apart constantly during development with nothing to say that they have. It is served rather +// than read from the folder by the page because a hosted copy of the configurator has no folder to read. +const VERSION_NAME = 'configurator-extension-version.json'; + +const VERSION_URL = `/${VERSION_NAME}`; + +function shippedVersion(extensionDir) { + const manifest = JSON.parse(readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8')); + return JSON.stringify({ version: manifest.version }); +} + +// The single directory every entry sits under, named after the folder in the repository so the install +// steps read the same whether the folder came from a checkout or from here. +const ROOT_DIRECTORY = 'configurator-extension'; + +// Only meaningful in a checkout: .gitignore covers the vendored bundles, which are already inside the +// archive, and the script that produces them has nothing left to do once they are. +const EXCLUDED = new Set(['.gitignore', 'sync-vendor.mjs']); + +const VENDOR_DIRECTORY = 'vendor/'; + +const SYNC_COMMAND = 'node test-server/configurator-extension/sync-vendor.mjs'; + +// CRC-32 (IEEE 802.3), which every zip entry carries twice: once in its local header and once in the +// central directory. +const CRC_TABLE = Int32Array.from({ length: 256 }, (_unused, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value; +}); + +function crc32(bytes) { + let crc = -1; + for (const byte of bytes) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff]; + } + return (crc ^ -1) >>> 0; +} + +// A fixed 1980-01-01 in the DOS encoding zip entries use, rather than each file's mtime: nothing reads +// these timestamps, and a constant makes the archive byte-identical between builds of the same files. +const DOS_TIME = 0; +const DOS_DATE = (1 << 5) | 1; + +// A regular file, 0644, in the high half of the external attributes field, so unzipping on a Unix system +// doesn't produce files with no permissions at all. Shifted back to unsigned, since << is signed. +const UNIX_FILE_MODE = (0o100644 << 16) >>> 0; + +// Paths of every file to archive, relative to the extension directory, depth first and sorted so the +// archive doesn't depend on the order the filesystem hands entries back. +function collectFiles(root, prefix = '') { + const entries = readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + ); + return entries.flatMap((entry) => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (EXCLUDED.has(relative)) { + return []; + } + return entry.isDirectory() ? collectFiles(root, relative) : [relative]; + }); +} + +// No data descriptors and no zip64: the whole archive is assembled in memory, so every size and checksum +// is known before its header is written, and a few hundred kilobytes of minified JS is nowhere near the +// 4 GB the classic format tops out at. Directory entries are left out — the paths imply them. +function zip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const { name, bytes } of files) { + const compressed = deflateRawSync(bytes, { level: 9 }); + const nameBytes = Buffer.from(name, 'utf8'); + const checksum = crc32(bytes); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed to extract: 2.0, for deflate + local.writeUInt16LE(0, 6); // no flags: sizes are known up front and the names are ASCII + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(bytes.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); // no extra field + localParts.push(local, nameBytes, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(DOS_TIME, 12); + central.writeUInt16LE(DOS_DATE, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(bytes.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); // no extra field + central.writeUInt16LE(0, 32); // no comment + central.writeUInt16LE(0, 34); // single disk + central.writeUInt16LE(0, 36); // internal attributes + central.writeUInt32LE(UNIX_FILE_MODE, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBytes); + + offset += local.length + nameBytes.length + compressed.length; + } + + const directory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); // this disk + end.writeUInt16LE(0, 6); // the disk the central directory starts on + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); // no archive comment + + return Buffer.concat([...localParts, directory, end]); +} + +function buildArchive(extensionDir) { + if (!existsSync(extensionDir)) { + throw new Error(`There is no extension at ${extensionDir}.`); + } + const names = collectFiles(extensionDir); + // An extension without the bundles it injects loads and then fails on the first run, which is a much + // worse thing to hand someone than no download at all. + if (!names.some((name) => name.startsWith(VENDOR_DIRECTORY))) { + throw new Error(`The extension has no vendored SDK bundles. Build them, then: ${SYNC_COMMAND}`); + } + return zip( + names.map((name) => ({ + name: `${ROOT_DIRECTORY}/${name}`, + bytes: readFileSync(path.join(extensionDir, name)), + })), + ); +} + +export function createExtensionArchive(extensionDir) { + const serve = (req, res, next) => { + const url = req.url?.split('?')[0]; + if (url === VERSION_URL) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(shippedVersion(extensionDir)); + return; + } + if (url !== ARCHIVE_URL) { + next(); + return; + } + try { + const archive = buildArchive(extensionDir); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${ARCHIVE_NAME}"`); + // Rebuilt per request, so an edit to the extension is in the next download rather than in whatever + // the browser kept. + res.setHeader('Cache-Control', 'no-store'); + res.end(archive); + } catch (error) { + // Plain text and a 503: this is read by whoever clicked the link, and the fix is a command. + res.statusCode = 503; + res.setHeader('Content-Type', 'text/plain'); + res.end(`${error.message}\n`); + } + }; + + return { + name: 'configurator-extension-archive', + configureServer(server) { + server.middlewares.use(serve); + }, + configurePreviewServer(server) { + server.middlewares.use(serve); + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: VERSION_NAME, source: shippedVersion(extensionDir) }); + try { + this.emitFile({ type: 'asset', fileName: ARCHIVE_NAME, source: buildArchive(extensionDir) }); + } catch (error) { + this.warn(`${error.message} This build has no ${ARCHIVE_NAME}.`); + } + }, + }; +} diff --git a/vite.config.js b/vite.config.js index 2383ccd30..1cf5523be 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,9 +5,11 @@ import path from 'path'; import fs from 'fs'; import glob from 'fast-glob'; import { createMockApi } from './test-server/mock-api.js'; +import { createExtensionArchive } from './test-server/extension-archive.js'; const packagesDir = path.resolve(__dirname, 'packages'); const testServerDir = path.resolve(__dirname, 'test-server'); +const extensionDir = path.resolve(testServerDir, 'configurator-extension'); const ignorePkg = (pkgName) => { return pkgName.startsWith('.') || @@ -160,5 +162,7 @@ export default defineConfig({ fileListingPlugin(), spaRoutingPlugin(), createMockApi(), + // Offers the configurator's runner extension as a download, since it isn't in the Chrome Web Store. + createExtensionArchive(extensionDir), ], });