Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 114 additions & 80 deletions src/local.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,78 @@ async function decodeProxyResponseBuffer(responseBuffer, proxyRes) {
return responseBuffer.toString('utf8');
}

// window.pageSchemas is set in the first large <script> in <body>. Inject after
// that </script> but before later bundles (and before the small
// `if (window.campaign)` script). Edge strips <!-- _footer_integrations_ -->
// before HTML is sent, and injecting before </body> runs too late (React has
// already read pageSchemas).
const AFTER_CAMPAIGN_BOOTSTRAP =
/(<\/script>)(\s*<script>\s*if\s*\(\s*window\.campaign\s*\)\s*\{)/;

/**
* Insert a generated <script> into the campaign HTML.
*
* The payload embeds page copy verbatim, so it can legitimately contain `$1`,
* `$&`, `` $` ``, `$'` or `$$` — for example the copy "less than $2 a day".
* Those sequences are special inside a String.prototype.replace REPLACEMENT
* STRING, so the payload must always be supplied via a replacer FUNCTION.
* Passing it as a template literal silently rewrites the payload and produces
* an unparseable <script> ("SyntaxError: Invalid or unexpected token").
*/
export function injectPageOverride(output, pageOverride) {
if (!pageOverride) {
return output;
}

if (AFTER_CAMPAIGN_BOOTSTRAP.test(output)) {
return output.replace(
AFTER_CAMPAIGN_BOOTSTRAP,
(match, bootstrapEnd, nextScript) =>
`${bootstrapEnd}${pageOverride}${nextScript}`
);
}

if (output.includes('<!-- _footer_integrations_ -->')) {
return output.replace(
'<!-- _footer_integrations_ -->',
() => `${pageOverride}\n<!-- _footer_integrations_ -->`
);
}

return output.replace('</body>', () => `${pageOverride}\n</body>`);
}

/**
* Build the proxy that serves the campaign locally.
*
* `onHtml` receives the decoded upstream body and returns the HTML to send.
*
* NOTE: the response handler MUST be registered under `on: { proxyRes }`.
* http-proxy-middleware v3 ignores the v2 `onProxyRes` option, and because
* `selfHandleResponse` is still honoured nothing would ever write to the
* response — every proxied request would hang until the client times out.
*/
export function createCampaignProxy({ target, secure, onHtml }) {
return createProxyMiddleware({
target,
changeOrigin: true,
secure,
autoRewrite: true,
cookieDomainRewrite: true,
followRedirects: true,
selfHandleResponse: true,
on: {
proxyRes: responseInterceptor(
async (responseBuffer, proxyRes, req, res) =>
onHtml(
await decodeProxyResponseBuffer(responseBuffer, proxyRes),
{ proxyRes, req, res }
)
),
},
});
}

function buildCssErrorComment(errorMessage) {
return `/*\n${errorMessage}\n*/`;
}
Expand DownExpand Up@@ -385,90 +457,53 @@ export default async function start(options = {}) {
// set up the Raisely proxy
app.use(
'/',
createProxyMiddleware({
createCampaignProxy({
target,
changeOrigin: true,
secure: !config.proxyUrl,
autoRewrite: true,
cookieDomainRewrite: true,
followRedirects: true,
selfHandleResponse: true,
onProxyRes: responseInterceptor(
async (responseBuffer, proxyRes, req, res) => {
const response = await decodeProxyResponseBuffer(
responseBuffer,
proxyRes
onHtml: async (response) => {
let pageOverride = '';
if (response.includes('window.pageSchemas')) {
try {
const compiledMap = await compileAllLocalPages({
campaignUuid,
});
pageOverride = buildPageOverrideScript(compiledMap);
} catch (e) {
console.error(e);
}
}

// Match by path so it works regardless of whether the upstream
// embeds api.raisely.com, api.raisely.test:2999, or any other host.
const stylesPath = `/v3/campaigns/${campaignUuid}/styles.css`;
const componentsPath = `/v3/campaigns/${campaignUuid}/components.js`;
const localBase = `http://localhost:${port}`;
const upstreamUrlRe = (path) =>
new RegExp(
`https?://[^"'\\s)]+${path.replace(/[/.]/g, '\\$&')}`,
'g'
);

let pageOverride = '';
if (response.includes('window.pageSchemas')) {
try {
const compiledMap = await compileAllLocalPages({
campaignUuid,
});
pageOverride = buildPageOverrideScript(compiledMap);
} catch (e) {
console.error(e);
}
}
let output = response
.replace(upstreamUrlRe(stylesPath), `${localBase}${stylesPath}`)
.replace(
upstreamUrlRe(componentsPath),
`${localBase}${componentsPath}`
);

// Match by path so it works regardless of whether the upstream
// embeds api.raisely.com, api.raisely.test:2999, or any other host.
const stylesPath = `/v3/campaigns/${campaignUuid}/styles.css`;
const componentsPath = `/v3/campaigns/${campaignUuid}/components.js`;
const localBase = `http://localhost:${port}`;
const upstreamUrlRe = (path) =>
new RegExp(
`https?://[^"'\\s)]+${path.replace(/[/.]/g, '\\$&')}`,
'g'
);

let output = response
.replace(upstreamUrlRe(stylesPath), `${localBase}${stylesPath}`)
.replace(
upstreamUrlRe(componentsPath),
`${localBase}${componentsPath}`
);

// window.pageSchemas is set in the first large <script> in <body>.
// Inject after that </script> but before later bundles (and before the small
// `if (window.campaign)` script). Edge strips <!-- _footer_integrations_ -->
// before HTML is sent, and injecting before </body> runs too late (React
// already read pageSchemas).
if (pageOverride) {
const afterCampaignBootstrap =
/(<\/script>)(\s*<script>\s*if\s*\(\s*window\.campaign\s*\)\s*\{)/;
if (afterCampaignBootstrap.test(output)) {
output = output.replace(
afterCampaignBootstrap,
`$1${pageOverride}$2`
);
} else if (
output.includes('<!-- _footer_integrations_ -->')
) {
output = output.replace(
'<!-- _footer_integrations_ -->',
`${pageOverride}\n<!-- _footer_integrations_ -->`
);
} else {
output = output.replace(
'</body>',
`${pageOverride}\n</body>`
);
}
}
output = injectPageOverride(output, pageOverride);

const apiRedirectScript = buildApiRedirectScript(config.apiUrl);
if (apiRedirectScript) {
output = output.replace(
/<head([^>]*)>/i,
`<head$1>${apiRedirectScript}`
);
}
const apiRedirectScript = buildApiRedirectScript(config.apiUrl);
if (apiRedirectScript) {
output = output.replace(
/<head([^>]*)>/i,
(match, attrs) => `<head${attrs}>${apiRedirectScript}`
);
}

return output.replace(
'</head>',
`
return output.replace(
'</head>',
() => `
<script>
const check = () => {
fetch('/reload')
Expand All@@ -485,9 +520,8 @@ export default async function start(options = {}) {
var raiselyReload = setInterval(check, 500);
</script>
</head>`
);
}
),
);
},
})
);

Expand Down
148 changes: 148 additions & 0 deletions tests/local-proxy.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
import { describe, test } from 'vitest';
import assert from 'node:assert/strict';
import http from 'node:http';
import vm from 'node:vm';

import express from 'express';

import { createCampaignProxy, injectPageOverride } from '../src/local.js';
import { buildPageOverrideScript } from '../src/actions/pages.js';

/** Campaign HTML shaped like the real one: pageSchemas bootstrap, then the small window.campaign script. */
function campaignHtml(bodyCopy = 'hello') {
return [
'<!doctype html><html><head><title>t</title></head><body>',
`<script>window.pageSchemas = [{"uuid":"page-1","body":${JSON.stringify(
bodyCopy
)}}];</script>`,
'<script>\n\t\t\tif (window.campaign) { console.log(1); }</script>',
'</body></html>',
].join('\n');
}

async function listen(server) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return server.address().port;
}

describe('injectPageOverride', () => {
test('inserts the payload between the bootstrap and the window.campaign script', () => {
const out = injectPageOverride(campaignHtml(), '<script>PAYLOAD</script>');
assert.ok(out.includes('];</script><script>PAYLOAD</script>'));
assert.ok(
out.indexOf('PAYLOAD') > out.indexOf('window.pageSchemas'),
'payload must come after the pageSchemas bootstrap'
);
assert.ok(
out.indexOf('PAYLOAD') < out.indexOf('if (window.campaign)'),
'payload must come before the window.campaign script'
);
});

test('returns the html untouched when there is no payload', () => {
const html = campaignHtml();
assert.equal(injectPageOverride(html, ''), html);
});

// Regression: page copy legitimately contains dollar amounts. `$1`..`$9`, `$&`,
// backtick-$ and `$'` are all special in a String.replace REPLACEMENT string, so
// interpolating the payload into a template literal silently rewrites it and the
// injected <script> stops parsing.
for (const token of ['$2', '$1', '$&', "$'", '$$', '$`']) {
test(`preserves a literal ${token} in the payload`, () => {
const payload = `<script>var copy = "less than ${token} a day";</script>`;
const out = injectPageOverride(campaignHtml(), payload);
assert.ok(
out.includes(payload),
`payload was rewritten by String.replace: ${out.slice(
out.indexOf('var copy'),
out.indexOf('var copy') + 120
)}`
);
});
}

test('a compiled page containing "$2" still yields a parseable script', () => {
// Full path: real compiled-template payload -> injection -> must still parse.
const script = buildPageOverrideScript({
'page-1': 'return "surviving on less than $2 a day";',
});
const out = injectPageOverride(campaignHtml(), script);

assert.ok(out.includes(script), 'payload must survive injection byte-for-byte');

const inner = script
.replace(/^\s*<script>/, '')
.replace(/<\/script>\s*$/, '');
assert.doesNotThrow(
() => new vm.Script(inner),
'injected page-override script must be syntactically valid JavaScript'
);
assert.ok(out.includes('less than $2 a day'));
});

test('falls back to the footer marker, then </body>', () => {
const footer = '<html><body><!-- _footer_integrations_ --></body></html>';
assert.ok(injectPageOverride(footer, '<b>$1</b>').includes('<b>$1</b>'));

const plain = '<html><body>hi</body></html>';
const out = injectPageOverride(plain, '<b>$&</b>');
assert.ok(out.includes('<b>$&</b>\n</body>'));
});
});

describe('createCampaignProxy', () => {
// Regression: the response handler must be registered under `on: { proxyRes }`.
// http-proxy-middleware v3 ignores the v2 `onProxyRes` option while still
// honouring selfHandleResponse, so nothing ever writes to the response and
// every proxied request hangs until the client gives up.
test('returns the transformed upstream body instead of hanging', async () => {
const upstream = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(campaignHtml());
});
const upstreamPort = await listen(upstream);

const app = express();
app.use(
'/',
createCampaignProxy({
target: `http://127.0.0.1:${upstreamPort}`,
secure: false,
onHtml: async (html) => html.replace('<title>t</title>', '<title>local</title>'),
})
);
const proxy = http.createServer(app);
const proxyPort = await listen(proxy);

try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

let res;
try {
res = await fetch(`http://127.0.0.1:${proxyPort}/`, {
signal: controller.signal,
});
} catch (e) {
if (e.name === 'AbortError') {
assert.fail(
'proxied request never returned — the response handler is not ' +
'registered (http-proxy-middleware v3 needs `on: { proxyRes }`, ' +
'not the v2 `onProxyRes`), while selfHandleResponse is still set'
);
}
throw e;
}
const body = await res.text();
clearTimeout(timer);

assert.equal(res.status, 200);
assert.ok(body.includes('<title>local</title>'), 'onHtml must be applied');
assert.ok(body.includes('window.pageSchemas'), 'upstream body must pass through');
} finally {
await new Promise((r) => proxy.close(r));
await new Promise((r) => upstream.close(r));
}
}, 20000);
});