From d4547a4710d733c3946d839f409caa0e928956cf Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Wed, 8 Jul 2026 11:35:14 -0700 Subject: [PATCH 1/2] feat: instrument desktop shell with cooperative AEM RUM Add a local /.rum proxy to rum.hlx.page, load helix-rum-js in the renderer for stock top/click collection, and fire virtual pageview tops on shell navigation with https://desktop.aem.live referers. Co-authored-by: Cursor --- src/main/index.js | 8 ++ src/main/rum-proxy.js | 224 ++++++++++++++++++++++++++++++++++++++ src/preload/index.cjs | 1 + src/renderer/index.html | 2 +- src/renderer/renderer.js | 16 +++ src/renderer/rum-paths.js | 64 +++++++++++ src/renderer/rum.js | 62 +++++++++++ src/rum-config.js | 17 +++ test/rum-paths.test.js | 49 +++++++++ test/rum-proxy.test.js | 133 ++++++++++++++++++++++ 10 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 src/main/rum-proxy.js create mode 100644 src/renderer/rum-paths.js create mode 100644 src/renderer/rum.js create mode 100644 src/rum-config.js create mode 100644 test/rum-paths.test.js create mode 100644 test/rum-proxy.test.js diff --git a/src/main/index.js b/src/main/index.js index 73c4a66..d2c09d4 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -70,6 +70,7 @@ import { checkLocalSyncBadges, checkPullStatus, runPull, runRevert, } from './da-sync.js'; import { runHelix6BulkWorkflow } from './helix6-bulk.js'; +import { startRumProxy } from './rum-proxy.js'; import log from './logger.js'; // Use the basic (plaintext) Chromium password store instead of the macOS @@ -89,6 +90,8 @@ let mainWindow; let sitesCache = []; /** @type {ReturnType|null} */ let previewRegistry = null; +/** @type {{ baseUrl: string, close: () => Promise }|null} */ +let rumProxy = null; /** @type {{ clearCache: () => void }|null} */ let contentDaLiveAuth = null; @@ -372,6 +375,8 @@ async function createWindow() { ipcMain.handle('app:get-version', () => app.getVersion()); +ipcMain.handle('rum:get-base-url', () => rumProxy?.baseUrl ?? null); + ipcMain.handle('app:is-dev', () => !app.isPackaged); ipcMain.handle('dev:open-app-devtools', (event) => { @@ -1030,6 +1035,8 @@ ipcMain.handle('dev:capture-screenshot', async (event) => { app.whenReady().then(async () => { contentDaLiveAuth = initContentDaLiveAuth(tokenPath(), session); + rumProxy = await startRumProxy({ fetchFn: chromiumFetch, log }); + previewRegistry = createPreviewServerRegistry({ startPreviewServer, createHeadHtmlCache, @@ -1059,6 +1066,7 @@ app.whenReady().then(async () => { app.on('will-quit', () => { previewRegistry?.closeAll().catch(() => {}); + rumProxy?.close().catch(() => {}); }); }); diff --git a/src/main/rum-proxy.js b/src/main/rum-proxy.js new file mode 100644 index 0000000..f81cc5d --- /dev/null +++ b/src/main/rum-proxy.js @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { createServer } from 'node:http'; +import { Readable } from 'node:stream'; +import { DESKTOP_RUM_ORIGIN, RUM_UPSTREAM_ORIGIN } from '../rum-config.js'; + +const noop = () => {}; + +/** + * @param {import('electron-log').MainLogger|undefined} log + */ +function rumLogger(log) { + if (log?.scope) { + return log.scope('rum'); + } + return { + debug: noop, + info: noop, + warn: noop, + error: noop, + }; +} + +const SKIP_REQUEST_HEADERS = new Set([ + 'connection', + 'host', + 'proxy-connection', + 'origin', + 'referer', +]); + +/** + * @param {string} name lower-cased header name + * @returns {boolean} + */ +function shouldSkipRequestHeader(name) { + return SKIP_REQUEST_HEADERS.has(name) || name.startsWith('sec-fetch-'); +} + +const SKIP_RESPONSE_HEADERS = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'content-security-policy', + 'keep-alive', + 'transfer-encoding', +]); + +/** + * @param {string|undefined|null} referer + * @returns {boolean} + */ +export function shouldRewriteRumReferer(referer) { + if (typeof referer !== 'string' || referer.length === 0) { + return true; + } + if (referer.startsWith(DESKTOP_RUM_ORIGIN)) { + return false; + } + if (referer.startsWith('file:')) { + return true; + } + if (referer.includes('127.0.0.1') || referer.includes('localhost')) { + return true; + } + return referer.startsWith('null'); +} + +/** + * Rewrites RUM beacon JSON so referer uses {@link DESKTOP_RUM_ORIGIN}. Cooperative + * `top` pings carry the full virtual path; click pings from the enhancer inherit the + * most recent desktop referer when their payload still reflects file://. + * + * @param {string} bodyText + * @param {string} fallbackReferer + * @returns {{ body: string, referer: string }} + */ +export function rewriteRumBeaconBody(bodyText, fallbackReferer) { + const fallback = fallbackReferer || `${DESKTOP_RUM_ORIGIN}/`; + try { + const payload = JSON.parse(bodyText); + if (!payload || typeof payload !== 'object') { + return { body: bodyText, referer: fallback }; + } + const current = typeof payload.referer === 'string' ? payload.referer : ''; + if (!shouldRewriteRumReferer(current)) { + return { body: bodyText, referer: current }; + } + payload.referer = fallback; + return { body: JSON.stringify(payload), referer: fallback }; + } catch { + return { body: bodyText, referer: fallback }; + } +} + +/** + * @param {import('node:http').IncomingMessage} req + * @returns {Promise} + */ +async function readRequestBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +/** + * Local forwarder for `/.rum/*` so the renderer can load helix-rum-js and send + * beacons without widening CSP to rum.hlx.page directly. + * + * @param {{ + * fetchFn?: typeof fetch, + * log?: import('electron-log').MainLogger, + * }} [options] + */ +export async function startRumProxy({ fetchFn = fetch, log } = {}) { + const scope = rumLogger(log); + let lastDesktopReferer = `${DESKTOP_RUM_ORIGIN}/`; + + const server = createServer(async (req, res) => { + if (!req.url) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + const requestUrl = new URL(req.url, 'http://127.0.0.1'); + if (!requestUrl.pathname.startsWith('/.rum/')) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const upstreamUrl = `${RUM_UPSTREAM_ORIGIN}${requestUrl.pathname}${requestUrl.search}`; + /** @type {Record} */ + const reqHeaders = {}; + const names = Object.keys(req.headers); + for (let i = 0; i < names.length; i += 1) { + const name = names[i]; + if (!shouldSkipRequestHeader(name.toLowerCase())) { + reqHeaders[name] = /** @type {string} */ (req.headers[name]); + } + } + + let body; + if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') { + const raw = await readRequestBody(req); + if (raw.length > 0) { + const rewritten = rewriteRumBeaconBody(raw.toString('utf8'), lastDesktopReferer); + body = rewritten.body; + lastDesktopReferer = rewritten.referer; + reqHeaders.referer = rewritten.referer; + reqHeaders['content-type'] = req.headers['content-type'] || 'application/json'; + reqHeaders['content-length'] = String(Buffer.byteLength(body)); + } + } + + try { + const upstream = await fetchFn(upstreamUrl, { + method: req.method, + headers: reqHeaders, + body, + redirect: 'follow', + }); + + /** @type {Record} */ + const respHeaders = { + 'access-control-allow-origin': '*', + }; + upstream.headers.forEach((value, key) => { + if (!SKIP_RESPONSE_HEADERS.has(key.toLowerCase())) { + respHeaders[key] = value; + } + }); + + res.writeHead(upstream.status, respHeaders); + if (req.method === 'HEAD' || !upstream.body) { + res.end(); + return; + } + Readable.fromWeb(upstream.body).pipe(res); + } catch (err) { + scope.warn(`upstream ${req.method} ${requestUrl.pathname} failed: ${err.message}`); + res.writeHead(502); + res.end('Bad gateway'); + } + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('RUM proxy did not bind to a TCP port'); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + scope.info(`RUM proxy listening on ${baseUrl}`); + + return { + baseUrl, + close: () => new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }), + }; +} diff --git a/src/preload/index.cjs b/src/preload/index.cjs index fb50688..f9f73f9 100644 --- a/src/preload/index.cjs +++ b/src/preload/index.cjs @@ -16,6 +16,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('aemDesktop', { getVersion: () => ipcRenderer.invoke('app:get-version'), + getRumBaseUrl: () => ipcRenderer.invoke('rum:get-base-url'), openExternal: (url) => ipcRenderer.invoke('app:open-external', { url }), showErrorDialog: (options) => ipcRenderer.invoke('app:show-error-dialog', options), captureScreenshot: () => ipcRenderer.invoke('dev:capture-screenshot'), diff --git a/src/renderer/index.html b/src/renderer/index.html index e8985a4..ca59cf8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ AEM Desktop diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 70ea6a5..722c199 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -31,6 +31,7 @@ import { togglePathsCheckState, } from './review-view.js'; import { renderDocumentView, renderDocumentDiffView } from './document-view.js'; +import { initDesktopRum, trackDesktopPageView } from './rum.js'; const state = { view: 'home', @@ -204,9 +205,19 @@ function showView(view) { } } +function trackShellPageView(overrides = {}) { + const site = activeSite(); + trackDesktopPageView({ + view: state.view, + site: site ? { org: site.org, repo: site.repo } : null, + daPath: overrides.daPath ?? state.tree.openedDaPath, + }); +} + function goHome() { showView('home'); renderSites(); + trackShellPageView(); } async function enterBrowse(siteId) { @@ -220,6 +231,7 @@ async function enterBrowse(siteId) { showView('browse'); renderSites(); await refreshTree(); + trackShellPageView(); } function show(element) { @@ -1250,6 +1262,7 @@ async function previewFile(item) { previewOrigin: preview.previewOrigin, }); syncBrowseContentView(); + trackShellPageView({ daPath: item.daPath }); if (browseContentMode === 'code') { await loadBrowseCode(item); } else if (browseContentMode === 'document') { @@ -2366,6 +2379,7 @@ async function openPushModal() { ); showView('review'); + trackShellPageView(); try { const { empty, diffs } = await loadReviewChanges(); @@ -2916,6 +2930,7 @@ async function loadSyncFolderPreference() { async function init() { wireUi(); state.icons = await loadIcons(); + await initDesktopRum(() => window.aemDesktop.getRumBaseUrl()); await loadSyncFolderPreference(); updateBrowseCodeAvailability(); syncReviewContentView(); @@ -2933,6 +2948,7 @@ async function init() { } }); showView('home'); + trackShellPageView(); } init(); diff --git a/src/renderer/rum-paths.js b/src/renderer/rum-paths.js new file mode 100644 index 0000000..330bf00 --- /dev/null +++ b/src/renderer/rum-paths.js @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { DESKTOP_RUM_ORIGIN } from '../rum-config.js'; + +/** + * @param {string} daPath + * @returns {string} + */ +export function normalizeDaPathForRum(daPath) { + if (!daPath || daPath === '/') { + return ''; + } + return daPath.startsWith('/') ? daPath : `/${daPath}`; +} + +/** + * Builds the virtual path segment reported to RUM for a desktop shell view. + * + * @param {'home'|'browse'|'review'} view + * @param {{ org?: string, repo?: string }|null|undefined} site + * @param {string|undefined|null} daPath + * @returns {string} + */ +export function buildDesktopRumPath(view, site, daPath) { + if (view === 'home') { + return '/'; + } + if (!site?.org || !site?.repo) { + return '/'; + } + const base = `/sites/${site.org}/${site.repo}`; + if (view === 'review') { + return `${base}/review`; + } + if (view === 'browse') { + const suffix = normalizeDaPathForRum(daPath || ''); + if (suffix) { + return `${base}/content${suffix}`; + } + return base; + } + return '/'; +} + +/** + * @param {string} virtualPath + * @returns {string} + */ +export function desktopRumReferer(virtualPath) { + if (!virtualPath || virtualPath === '/') { + return `${DESKTOP_RUM_ORIGIN}/`; + } + const path = virtualPath.startsWith('/') ? virtualPath : `/${virtualPath}`; + return `${DESKTOP_RUM_ORIGIN}${path}`; +} diff --git a/src/renderer/rum.js b/src/renderer/rum.js new file mode 100644 index 0000000..52bd356 --- /dev/null +++ b/src/renderer/rum.js @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { buildDesktopRumPath, desktopRumReferer } from './rum-paths.js'; + +const RUM_STANDALONE_PATH = '/.rum/@adobe/helix-rum-js@^2/dist/rum-standalone.js'; + +/** + * Loads helix-rum-js through the local `/.rum` proxy. Sampling, `top`, and `click` + * behave like a normal page load; virtual pageviews call {@link trackDesktopPageView}. + * + * @param {() => Promise} getBaseUrl + */ +export async function initDesktopRum(getBaseUrl) { + const baseUrl = await getBaseUrl(); + if (!baseUrl) { + return; + } + + window.RUM_BASE = baseUrl.replace(/\/+$/, ''); + + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.defer = true; + script.src = `${window.RUM_BASE}${RUM_STANDALONE_PATH}`; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load helix-rum-js')); + document.head.append(script); + }); +} + +/** + * Cooperative virtual pageview for the desktop shell SPA. Uses stock `sampleRUM` + * so enhancer click tracking stays wired for sampled sessions. + * + * @param {{ + * view: 'home'|'browse'|'review', + * site?: { org?: string, repo?: string }|null, + * daPath?: string|null, + * }} options + */ +export function trackDesktopPageView({ view, site = null, daPath = null }) { + const rum = window.hlx?.rum; + if (!rum?.isSelected) { + return; + } + const { sampleRUM } = rum; + if (typeof sampleRUM !== 'function') { + return; + } + + const virtualPath = buildDesktopRumPath(view, site, daPath); + sampleRUM('top', { referer: desktopRumReferer(virtualPath) }); +} diff --git a/src/rum-config.js b/src/rum-config.js new file mode 100644 index 0000000..1eeb33b --- /dev/null +++ b/src/rum-config.js @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** Virtual origin reported to AEM RUM for the desktop shell. */ +export const DESKTOP_RUM_ORIGIN = 'https://desktop.aem.live'; + +/** Upstream host that serves helix-rum-js and receives beacon POSTs. */ +export const RUM_UPSTREAM_ORIGIN = 'https://rum.hlx.page'; diff --git a/test/rum-paths.test.js b/test/rum-paths.test.js new file mode 100644 index 0000000..03382fa --- /dev/null +++ b/test/rum-paths.test.js @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildDesktopRumPath, + desktopRumReferer, + normalizeDaPathForRum, +} from '../src/renderer/rum-paths.js'; +import { DESKTOP_RUM_ORIGIN } from '../src/rum-config.js'; + +test('normalizeDaPathForRum keeps leading slash', () => { + assert.equal(normalizeDaPathForRum('blog/post'), '/blog/post'); + assert.equal(normalizeDaPathForRum('/blog/post'), '/blog/post'); + assert.equal(normalizeDaPathForRum('/'), ''); +}); + +test('buildDesktopRumPath maps shell views to virtual URLs', () => { + assert.equal(buildDesktopRumPath('home', null), '/'); + assert.equal( + buildDesktopRumPath('browse', { org: 'acme', repo: 'site' }), + '/sites/acme/site', + ); + assert.equal( + buildDesktopRumPath('browse', { org: 'acme', repo: 'site' }, '/blog/post'), + '/sites/acme/site/content/blog/post', + ); + assert.equal( + buildDesktopRumPath('review', { org: 'acme', repo: 'site' }), + '/sites/acme/site/review', + ); +}); + +test('desktopRumReferer prefixes desktop origin', () => { + assert.equal(desktopRumReferer('/'), `${DESKTOP_RUM_ORIGIN}/`); + assert.equal( + desktopRumReferer('/sites/acme/site'), + `${DESKTOP_RUM_ORIGIN}/sites/acme/site`, + ); +}); diff --git a/test/rum-proxy.test.js b/test/rum-proxy.test.js new file mode 100644 index 0000000..bb2a667 --- /dev/null +++ b/test/rum-proxy.test.js @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + rewriteRumBeaconBody, + shouldRewriteRumReferer, + startRumProxy, +} from '../src/main/rum-proxy.js'; +import { DESKTOP_RUM_ORIGIN } from '../src/rum-config.js'; + +test('shouldRewriteRumReferer keeps desktop referers', () => { + assert.equal( + shouldRewriteRumReferer(`${DESKTOP_RUM_ORIGIN}/sites/org/repo`), + false, + ); +}); + +test('shouldRewriteRumReferer rewrites file and localhost referers', () => { + assert.equal(shouldRewriteRumReferer('file:///index.html'), true); + assert.equal(shouldRewriteRumReferer('http://127.0.0.1:4567/'), true); + assert.equal(shouldRewriteRumReferer(''), true); +}); + +test('rewriteRumBeaconBody preserves cooperative desktop referer', () => { + const referer = `${DESKTOP_RUM_ORIGIN}/sites/org/repo/content/blog/post`; + const input = JSON.stringify({ + weight: 100, + id: 'abc', + referer, + checkpoint: 'top', + t: 12, + }); + const out = rewriteRumBeaconBody(input, `${DESKTOP_RUM_ORIGIN}/`); + assert.equal(out.body, input); + assert.equal(out.referer, referer); +}); + +test('rewriteRumBeaconBody rewrites file referer to fallback', () => { + const input = JSON.stringify({ + weight: 100, + id: 'abc', + referer: 'file:///renderer/index.html', + checkpoint: 'click', + t: 42, + }); + const fallback = `${DESKTOP_RUM_ORIGIN}/sites/org/repo`; + const out = rewriteRumBeaconBody(input, fallback); + const payload = JSON.parse(out.body); + assert.equal(payload.referer, fallback); + assert.equal(out.referer, fallback); +}); + +test('rum proxy forwards GET script requests to rum.hlx.page', async () => { + let upstreamUrl; + const proxy = await startRumProxy({ + fetchFn: async (url) => { + upstreamUrl = url; + return new Response('// rum', { + status: 200, + headers: { 'content-type': 'text/javascript; charset=utf-8' }, + }); + }, + }); + + try { + const resp = await fetch( + `${proxy.baseUrl}/.rum/@adobe/helix-rum-js@2/dist/rum-standalone.js`, + ); + assert.equal(resp.status, 200); + assert.match(upstreamUrl, /^https:\/\/rum\.hlx\.page\/\.rum\//); + } finally { + await proxy.close(); + } +}); + +test('rum proxy rewrites beacon referer and forwards upstream Referer header', async () => { + /** @type {Record|undefined} */ + let forwardedHeaders; + /** @type {string|undefined} */ + let forwardedBody; + const proxy = await startRumProxy({ + fetchFn: async (_url, init) => { + forwardedHeaders = init?.headers; + forwardedBody = typeof init?.body === 'string' ? init.body : undefined; + return new Response(null, { status: 201 }); + }, + }); + + try { + const cooperativeReferer = `${DESKTOP_RUM_ORIGIN}/sites/org/repo/content/page`; + const resp = await fetch(`${proxy.baseUrl}/.rum/100`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + weight: 100, + id: 'abc', + referer: cooperativeReferer, + checkpoint: 'top', + t: 1, + }), + }); + assert.equal(resp.status, 201); + assert.equal(forwardedHeaders?.referer, cooperativeReferer); + assert.equal(JSON.parse(forwardedBody || '{}').referer, cooperativeReferer); + + const clickResp = await fetch(`${proxy.baseUrl}/.rum/100`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + weight: 100, + id: 'abc', + referer: 'file:///index.html', + checkpoint: 'click', + t: 2, + }), + }); + assert.equal(clickResp.status, 201); + assert.equal(forwardedHeaders?.referer, cooperativeReferer); + assert.equal(JSON.parse(forwardedBody || '{}').referer, cooperativeReferer); + } finally { + await proxy.close(); + } +}); From c25661087789fed33570196f5f106166dc0916ff Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Wed, 8 Jul 2026 11:42:40 -0700 Subject: [PATCH 2/2] fix: stop forwarding desktop Referer header on RUM proxy upstream Chromium net.fetch blocks cross-origin Referer values, causing 502s on beacon POSTs. Keep site identity in the JSON body only. Also force RUM sampling in dev since the file:// shell has no ?rum=on query string. Co-authored-by: Cursor --- src/main/rum-proxy.js | 4 +++- src/renderer/renderer.js | 5 ++++- src/renderer/rum.js | 16 +++++++++++++++- test/rum-proxy.test.js | 6 +++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/rum-proxy.js b/src/main/rum-proxy.js index f81cc5d..1395475 100644 --- a/src/main/rum-proxy.js +++ b/src/main/rum-proxy.js @@ -159,7 +159,9 @@ export async function startRumProxy({ fetchFn = fetch, log } = {}) { const rewritten = rewriteRumBeaconBody(raw.toString('utf8'), lastDesktopReferer); body = rewritten.body; lastDesktopReferer = rewritten.referer; - reqHeaders.referer = rewritten.referer; + // Site identity lives in the JSON payload. Do not forward an HTTP Referer + // to rum.hlx.page — Chromium net.fetch rejects cross-origin referers with + // ERR_BLOCKED_BY_CLIENT (same constraint as the preview proxy). reqHeaders['content-type'] = req.headers['content-type'] || 'application/json'; reqHeaders['content-length'] = String(Buffer.byteLength(body)); } diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 722c199..cee795c 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -2930,7 +2930,10 @@ async function loadSyncFolderPreference() { async function init() { wireUi(); state.icons = await loadIcons(); - await initDesktopRum(() => window.aemDesktop.getRumBaseUrl()); + await initDesktopRum( + () => window.aemDesktop.getRumBaseUrl(), + () => window.aemDesktop.isDev(), + ); await loadSyncFolderPreference(); updateBrowseCodeAvailability(); syncReviewContentView(); diff --git a/src/renderer/rum.js b/src/renderer/rum.js index 52bd356..dcfbf77 100644 --- a/src/renderer/rum.js +++ b/src/renderer/rum.js @@ -18,8 +18,9 @@ const RUM_STANDALONE_PATH = '/.rum/@adobe/helix-rum-js@^2/dist/rum-standalone.js * behave like a normal page load; virtual pageviews call {@link trackDesktopPageView}. * * @param {() => Promise} getBaseUrl + * @param {() => Promise} [isDev] */ -export async function initDesktopRum(getBaseUrl) { +export async function initDesktopRum(getBaseUrl, isDev = async () => false) { const baseUrl = await getBaseUrl(); if (!baseUrl) { return; @@ -27,6 +28,19 @@ export async function initDesktopRum(getBaseUrl) { window.RUM_BASE = baseUrl.replace(/\/+$/, ''); + // file:// shell has no query string; force sampling in dev or via localStorage. + try { + const force = localStorage.getItem('rum') || localStorage.getItem('optel'); + if (force === 'on') { + window.SAMPLE_PAGEVIEWS_AT_RATE = 'on'; + } + } catch { + // localStorage unavailable + } + if (!window.SAMPLE_PAGEVIEWS_AT_RATE && await isDev()) { + window.SAMPLE_PAGEVIEWS_AT_RATE = 'on'; + } + await new Promise((resolve, reject) => { const script = document.createElement('script'); script.defer = true; diff --git a/test/rum-proxy.test.js b/test/rum-proxy.test.js index bb2a667..ab58b59 100644 --- a/test/rum-proxy.test.js +++ b/test/rum-proxy.test.js @@ -83,7 +83,7 @@ test('rum proxy forwards GET script requests to rum.hlx.page', async () => { } }); -test('rum proxy rewrites beacon referer and forwards upstream Referer header', async () => { +test('rum proxy rewrites beacon referer in JSON body but not HTTP Referer header', async () => { /** @type {Record|undefined} */ let forwardedHeaders; /** @type {string|undefined} */ @@ -110,7 +110,7 @@ test('rum proxy rewrites beacon referer and forwards upstream Referer header', a }), }); assert.equal(resp.status, 201); - assert.equal(forwardedHeaders?.referer, cooperativeReferer); + assert.equal(forwardedHeaders?.referer, undefined); assert.equal(JSON.parse(forwardedBody || '{}').referer, cooperativeReferer); const clickResp = await fetch(`${proxy.baseUrl}/.rum/100`, { @@ -125,7 +125,7 @@ test('rum proxy rewrites beacon referer and forwards upstream Referer header', a }), }); assert.equal(clickResp.status, 201); - assert.equal(forwardedHeaders?.referer, cooperativeReferer); + assert.equal(forwardedHeaders?.referer, undefined); assert.equal(JSON.parse(forwardedBody || '{}').referer, cooperativeReferer); } finally { await proxy.close();