Skip to content

Commit 74fefa5

Browse files
Merge pull request #28 from HarperFast/fix/cache-key-consistency
fix: one canonical cache key across the whole render flow (plugin v0.7.0, browser v1.11.0)
2 parents fa3ec94 + e48b1ee commit 74fefa5

13 files changed

Lines changed: 401 additions & 156 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender-browser",
3-
"version": "1.10.0",
3+
"version": "1.11.0",
44
"type": "module",
55
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
66
"keywords": [

packages/browser/src/renderer.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { RenderTimings } from './RenderJob.js';
33
import { settings } from './settings.js';
44
import { CACHE_REPLAY_HEADER, getResourceCache } from './ResourceCache.js';
55
import type { PostProcessConfig } from './config.js';
6-
import { normalizeUrlForCompare, canonicalAllowsIndex } from './util/url.js';
6+
import { canonicalizeUrl, canonicalAllowsIndex } from './util/url.js';
77

88
const noop = () => {};
99

@@ -288,16 +288,17 @@ const renderer: Renderer = async (page, job) => {
288288
};
289289
const statusCode = job.httpResponse.statusCode;
290290

291-
const pageUrl = normalizeUrlForCompare(page.url());
292-
const jobUrl = normalizeUrlForCompare(job.url);
293-
294-
if (pageUrl !== jobUrl) {
295-
job.redirectedTo = pageUrl;
291+
// Redirect detection uses the SHARED canonical form (matches the plugin), so encoding,
292+
// param order, hash, and trailing-slash differences never trip a false redirect. Post
293+
// back the RAW final URL — the plugin canonicalizes it with the real route allowlist.
294+
const rawPageUrl = page.url();
295+
if (canonicalizeUrl(rawPageUrl) !== canonicalizeUrl(job.url)) {
296+
job.redirectedTo = rawPageUrl;
296297
}
297298

298299
if (statusCode === 200) {
299300
const { canonicalHref, noindex } = await page.evaluate(extractIndexSignals);
300-
job.isIndexable = !noindex && canonicalAllowsIndex(canonicalHref, pageUrl);
301+
job.isIndexable = !noindex && canonicalAllowsIndex(canonicalHref, rawPageUrl);
301302

302303
if (job.isIndexable || job.isFromSitemap) {
303304
const ppStart = Date.now();

packages/browser/src/util/url.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,56 @@ const safeDecodeURI = (s: string): string => {
2222
}
2323
};
2424

25+
/**
26+
* Canonical URL-half of a cache key. This MUST stay byte-for-byte identical to the plugin's
27+
* `canonicalizeUrl` (packages/plugin/src/util/url.js) — the two are pinned by the shared test
28+
* vector at repo root (`test-vectors/canonicalize-url.json`), asserted by both packages' test
29+
* suites, so the copies cannot drift. The browser uses it ONLY to detect a genuine redirect
30+
* (does the final page URL canonicalize to a different key than the job URL?); it forms no
31+
* cache key itself and posts the RAW page URL back for the plugin to canonicalize with the
32+
* route allowlist. See that file for the full rule list.
33+
*
34+
* `queryParams` defaults to `['*']` (keep all) — the browser has no route config, and for
35+
* redirect detection keeping every param is the conservative choice; the plugin re-keys with
36+
* the real per-route allowlist.
37+
*/
38+
const FIXED_DECODE: Record<string, string> = { '%3a': ':', '%2c': ',', '%40': '@' };
39+
40+
export const canonicalizeUrl = (url: string | URL, queryParams: string[] = ['*']): string => {
41+
const parsed = url instanceof URL ? new URL(url.href) : new URL(url);
42+
parsed.hash = '';
43+
44+
const rawQuery = parsed.search.startsWith('?') ? parsed.search.slice(1) : parsed.search;
45+
let query = '';
46+
if (rawQuery) {
47+
const keepAll = queryParams.includes('*');
48+
const keep = keepAll ? null : new Set(queryParams);
49+
const segments = rawQuery.split('&').filter((seg) => {
50+
if (seg === '') return false;
51+
if (keepAll) return true;
52+
const rawKey = seg.split('=')[0];
53+
let key: string;
54+
try {
55+
key = decodeURIComponent(rawKey);
56+
} catch {
57+
key = rawKey;
58+
}
59+
return keep!.has(key);
60+
});
61+
segments.sort();
62+
if (segments.length) query = `?${segments.join('&')}`;
63+
}
64+
65+
const path =
66+
parsed.pathname !== '/' && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
67+
68+
let half = `${parsed.protocol}//${parsed.host}${path}${query}`;
69+
half = half.replace(/%(?:3a|2c|40)/gi, (m) => FIXED_DECODE[m.toLowerCase()]);
70+
half = half.replace(/%[0-9a-f]{2}/gi, (m) => m.toUpperCase());
71+
half = half.replace(/\|/g, '%7C');
72+
return half;
73+
};
74+
2575
export const normalizeUrlForCompare = (url: string | URL): string => {
2676
const parsed = new URL(url);
2777
parsed.searchParams.sort();

packages/browser/test/url.test.ts

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,83 @@
11
import { test } from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { normalizeUrlForCompare, normalizeCanonicalUrl, canonicalAllowsIndex } from '../dist/util/url.js';
3+
import { readFileSync } from 'node:fs';
4+
import {
5+
canonicalizeUrl,
6+
normalizeUrlForCompare,
7+
normalizeCanonicalUrl,
8+
canonicalAllowsIndex,
9+
} from '../dist/util/url.js';
410

5-
// The reported bug: the request carries %3A (encoded colon) while the page's canonical uses
6-
// the literal ':'. They name the same resource, so the page is self-canonical → indexable.
11+
// The single shared vector, asserted by BOTH the browser (this file) and the plugin suite,
12+
// so the two canonicalizeUrl copies (TS here, JS in the plugin) cannot drift.
13+
const VECTORS: { url: string; allowlist: string[]; expected: string }[] = JSON.parse(
14+
readFileSync(new URL('../../../test-vectors/canonicalize-url.json', import.meta.url), 'utf8')
15+
);
16+
17+
test('canonicalizeUrl matches every shared cache-key vector (must equal the plugin)', () => {
18+
for (const { url, allowlist, expected } of VECTORS) {
19+
assert.equal(canonicalizeUrl(url, allowlist), expected, `vector: ${url} @ ${JSON.stringify(allowlist)}`);
20+
}
21+
});
22+
23+
// Redirect detection compares canonicalizeUrl(page.url()) vs canonicalizeUrl(job.url). A page
24+
// that did not redirect must NOT be flagged just because Chrome's page.url() re-encodes, adds a
25+
// trailing slash, or reorders params relative to the stored key.
26+
test('canonicalizeUrl does not flag a non-redirect (encoding / trailing slash / order)', () => {
27+
const jobUrl = 'https://example.com/p?b=2&a=1';
28+
assert.equal(canonicalizeUrl('https://example.com/p/?a=1&b=2'), canonicalizeUrl(jobUrl)); // slash + order
29+
assert.equal(canonicalizeUrl('https://example.com/p?a=1&b=2#frag'), canonicalizeUrl(jobUrl)); // hash
30+
// ':' literal (Chrome's form) matches a stored key that decoded '%3A' to ':'
31+
assert.equal(canonicalizeUrl('https://example.com/a?f=X:Y'), canonicalizeUrl('https://example.com/a?f=X%3AY'));
32+
});
33+
34+
// --- indexability (self-canonical) comparison — separate from the cache key ---
35+
36+
// The request carries %3A (encoded colon) while the page's canonical uses the literal ':'.
37+
// They name the same resource, so the page is self-canonical → indexable.
738
test('canonical with a decoded reserved char (: vs %3A) counts as self-canonical', () => {
8-
const requested = 'https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender%3AMens+Department%3AClothing';
9-
const canonical = 'https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender:Mens+Department:Clothing';
39+
const requested = 'https://example.com/c/page.jsp?f=A%3AB+g%3AC';
40+
const canonical = 'https://example.com/c/page.jsp?f=A:B+g:C';
1041
assert.equal(canonicalAllowsIndex(canonical, requested), true);
1142
});
1243

1344
test('a genuinely different canonical is NOT self-canonical (stays non-indexable)', () => {
14-
const requested = 'https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender%3AMens';
15-
const canonical = 'https://www.kohls.com/catalog/womens-clothing.jsp?CN=Gender%3AWomens';
45+
const requested = 'https://example.com/c/page-a.jsp?f=A%3AB';
46+
const canonical = 'https://example.com/c/page-b.jsp?f=X%3AY';
1647
assert.equal(canonicalAllowsIndex(canonical, requested), false);
1748
});
1849

1950
test('no canonical → indexable', () => {
20-
assert.equal(canonicalAllowsIndex(null, 'https://www.kohls.com/x'), true);
21-
assert.equal(canonicalAllowsIndex(undefined, 'https://www.kohls.com/x'), true);
22-
assert.equal(canonicalAllowsIndex('', 'https://www.kohls.com/x'), true);
51+
assert.equal(canonicalAllowsIndex(null, 'https://example.com/x'), true);
52+
assert.equal(canonicalAllowsIndex(undefined, 'https://example.com/x'), true);
53+
assert.equal(canonicalAllowsIndex('', 'https://example.com/x'), true);
2354
});
2455

2556
test('param order, trailing slash, and hash differences do not break self-canonical', () => {
26-
const current = 'https://www.kohls.com/p?b=2&a=1';
27-
assert.equal(canonicalAllowsIndex('https://www.kohls.com/p?a=1&b=2', current), true); // order
28-
assert.equal(canonicalAllowsIndex('https://www.kohls.com/p/?b=2&a=1', current), true); // trailing slash
29-
assert.equal(canonicalAllowsIndex('https://www.kohls.com/p?b=2&a=1#frag', current), true); // hash
57+
const current = 'https://example.com/p?b=2&a=1';
58+
assert.equal(canonicalAllowsIndex('https://example.com/p?a=1&b=2', current), true); // order
59+
assert.equal(canonicalAllowsIndex('https://example.com/p/?b=2&a=1', current), true); // trailing slash
60+
assert.equal(canonicalAllowsIndex('https://example.com/p?b=2&a=1#frag', current), true); // hash
3061
});
3162

3263
test('a relative canonical resolves against the current URL', () => {
33-
const current = 'https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender%3AMens';
34-
assert.equal(canonicalAllowsIndex('/catalog/mens-clothing.jsp?CN=Gender:Mens', current), true);
64+
const current = 'https://example.com/c/page.jsp?f=A%3AB';
65+
assert.equal(canonicalAllowsIndex('/c/page.jsp?f=A:B', current), true);
3566
});
3667

3768
test('a malformed canonical fails open (does not drop indexability)', () => {
38-
assert.equal(canonicalAllowsIndex('http://[bad', 'https://www.kohls.com/x'), true);
69+
assert.equal(canonicalAllowsIndex('http://[bad', 'https://example.com/x'), true);
3970
});
4071

4172
test('normalizeCanonicalUrl canonicalizes encoding, param order, hash, and trailing slash', () => {
4273
assert.equal(
43-
normalizeCanonicalUrl('https://x.com/a/?c=2&b=Gender:Mens#h'),
44-
normalizeCanonicalUrl('https://x.com/a?b=Gender%3AMens&c=2')
74+
normalizeCanonicalUrl('https://x.com/a/?c=2&b=A:B#h'),
75+
normalizeCanonicalUrl('https://x.com/a?b=A%3AB&c=2')
4576
);
4677
});
4778

4879
test('normalizeCanonicalUrl is idempotent', () => {
49-
const once = normalizeCanonicalUrl('https://www.kohls.com/p/?b=2&a=Gender:Mens#h');
80+
const once = normalizeCanonicalUrl('https://example.com/p/?b=2&a=A:B#h');
5081
assert.equal(normalizeCanonicalUrl(once), once);
5182
});
5283

@@ -60,6 +91,7 @@ test('malformed percent-encoding falls back to raw instead of throwing', () => {
6091
for (const bad of ['https://x.com/%E0%A0/a', 'https://x.com/p?x=%E0%A0']) {
6192
assert.doesNotThrow(() => normalizeUrlForCompare(bad));
6293
assert.doesNotThrow(() => normalizeCanonicalUrl(bad));
94+
assert.doesNotThrow(() => canonicalizeUrl(bad));
6395
}
6496
assert.doesNotThrow(() => canonicalAllowsIndex('https://x.com/a', 'https://x.com/%E0%A0/a'));
6597
});

packages/plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"type": "module",
55
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
66
"license": "Apache-2.0",

packages/plugin/src/http_handlers/bot_request.js

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
22
import { CacheKey } from '../util/cacheKey.js';
33
import { getBotName } from '../util/userAgent.js';
44
import { isPrerenderCandidate } from '../util/indexSignals.js';
5-
import { normalizeUrl, cacheKeyUrl } from '../util/url.js';
5+
import { canonicalizeUrl } from '../util/url.js';
66
import { config } from '../config.js';
77
import { sanitizeDeviceType } from '../util/device_type.js';
88
import { isForwardedMode, resolveForwardedRequest } from '../util/ingress.js';
@@ -22,7 +22,7 @@ export async function handleBotRequest(request) {
2222
if (!target) {
2323
return { headers: {}, status: 400 };
2424
}
25-
const { url, deviceType, noCache, route } = target;
25+
const { url, cacheUrl, deviceType, noCache, route } = target;
2626

2727
request.botName = getBotName(request.headers);
2828
if (config.analytics.enabled && (request.botName !== 'other' || config.analytics.recordUnmatched)) {
@@ -34,7 +34,7 @@ export async function handleBotRequest(request) {
3434
// `noCache` marks an unrecognized forwarded path we proxy but never cache.
3535
const info = { route, noCache };
3636

37-
const resource = await resolveResource({ request, url, deviceType, info });
37+
const resource = await resolveResource({ request, url, cacheUrl, deviceType, info });
3838
maybeSchedule(resource, noCache);
3939

4040
return deliverResource(resource, request, info);
@@ -55,11 +55,20 @@ function resolveBotTarget(request) {
5555
if (isForwardedMode()) {
5656
const target = request._prerenderTarget ?? resolveForwardedRequest(request);
5757
if (!target) return null;
58-
return { url: target.url, deviceType: target.deviceType, noCache: !!target.noCache, route: target.route };
58+
return {
59+
url: target.url,
60+
cacheUrl: target.cacheUrl,
61+
deviceType: target.deviceType,
62+
noCache: !!target.noCache,
63+
route: target.route,
64+
};
5965
}
6066

67+
// Native/prefix mode: the request path (minus the bot prefix) IS the absolute target URL.
68+
const cacheUrl = canonicalizeUrl(request.url.slice(config.botPathPrefix.length), config.url.queryParams);
6169
return {
62-
url: normalizeUrl(request.url.slice(config.botPathPrefix.length), true),
70+
url: new URL(cacheUrl),
71+
cacheUrl,
6372
deviceType: sanitizeDeviceType(request.headers.get(config.ingress.deviceTypeHeader)),
6473
noCache: false,
6574
route: undefined,
@@ -69,7 +78,7 @@ function resolveBotTarget(request) {
6978
// Resolve the resource to serve: an origin proxy for non-GET/HEAD, else a fresh cache hit,
7079
// an on-demand render, or an origin proxy per the miss mode. Populates the debug `info`
7180
// (cacheKey/url/cacheStatus/source/renderNowStatus) as a side effect.
72-
async function resolveResource({ request, url, deviceType, info }) {
81+
async function resolveResource({ request, url, cacheUrl, deviceType, info }) {
7382
if (request.method !== 'GET' && request.method !== 'HEAD') {
7483
logger.warn(`Unexpected Request ${request.method} ${url}`);
7584
info.source = 'origin';
@@ -82,12 +91,11 @@ async function resolveResource({ request, url, deviceType, info }) {
8291
});
8392
}
8493

85-
// `url.href` re-serializes on each access; read it once and reuse for the cache key
86-
// and the debug info.
87-
const href = url.href;
88-
const cacheKey = CacheKey.toCacheKey({ url: cacheKeyUrl(href), deviceType });
94+
// `cacheUrl` is the canonical URL-half (already computed at ingress); it IS the cache-key
95+
// url component, so no re-normalization here.
96+
const cacheKey = CacheKey.toCacheKey({ url: cacheUrl, deviceType });
8997
info.cacheKey = cacheKey;
90-
info.url = href;
98+
info.url = cacheUrl;
9199

92100
// On-demand levers apply only to an authorized GET for a non-excluded URL.
93101
const authorized = request.method === 'GET' && isRenderNowAuthorized(request.headers) && !isExcludedUrl(url);
@@ -198,14 +206,18 @@ async function renderNow({ url, deviceType, cacheKey, request }) {
198206
async function handlePageScheduling(resource) {
199207
try {
200208
if (isPrerenderCandidate(resource)) {
209+
// resource.url is the origin-fetch URL built from the canonical half, so it is
210+
// already route-filtered; canonicalize idempotently ('*' keeps it as-is) so this
211+
// url-half equals CacheKey.extractUrl of the render key (and the NonIndexable id).
212+
const canonicalUrl = canonicalizeUrl(resource.url, ['*']);
201213
const existingNonIndexable = await databases.signals.NonIndexable.get({
202-
id: cacheKeyUrl(resource.url),
214+
id: canonicalUrl,
203215
select: 'url',
204216
});
205217

206218
if (!existingNonIndexable) {
207219
for (const deviceType of config.deviceTypes.default) {
208-
const cacheKey = CacheKey.toCacheKey({ url: cacheKeyUrl(resource.url), deviceType });
220+
const cacheKey = CacheKey.toCacheKey({ url: canonicalUrl, deviceType });
209221
const existingTarget = await RenderTarget.get({ id: cacheKey, select: 'cacheKey' });
210222
if (!existingTarget) {
211223
// No explicit time → RenderTarget.put jitters the first render across the

packages/plugin/src/resources/RenderQueue.js

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { config } from '../config.js';
33
import { currentMinuteMs } from '../util/time.js';
44
import { QueueState } from './QueueState.js';
55
import { CacheKey } from '../util/cacheKey.js';
6-
import { cacheKeyUrl, normalizeUrl } from '../util/url.js';
6+
import { canonicalizeUrl } from '../util/url.js';
7+
import { queryAllowlistFor } from '../util/ingress.js';
78
import { RenderTarget } from './RenderTarget.js';
89

910
const protocol = server.hostname === 'localhost' ? 'http' : 'https';
@@ -68,19 +69,25 @@ export class RenderQueue extends Resource {
6869
const url = result.redirectedTo || result.url;
6970

7071
if (result.redirectedTo) {
71-
if (result.redirectedTo !== result.url) {
72+
const { deviceType } = CacheKey.parse(result.id);
73+
74+
// The browser posts the RAW final page URL as `redirectedTo`. Canonicalize it the
75+
// same way serving does — with the allowlist a bot READ of that target would use
76+
// (route-aware) — so the rendered content is stored under the key that read computes.
77+
const redirectKey = CacheKey.toCacheKey({
78+
deviceType,
79+
url: canonicalizeUrl(result.redirectedTo, queryAllowlistFor(result.redirectedTo)),
80+
});
81+
82+
// Only treat it as a redirect when the final URL canonicalizes to a DIFFERENT key.
83+
// A target whose page URL collapses back to the same key (trailing slash, param
84+
// reorder, encoding) is not a redirect — keep it under result.id and, crucially,
85+
// do NOT delete its RenderTarget (which would drop it from the recurring rotation).
86+
if (redirectKey !== result.id) {
7287
logger.warn(`Skipped prerendered url due to redirect: ${result.id} redirected to ${result.redirectedTo}`);
7388
await RenderTarget.delete(result.id);
89+
cacheKey = redirectKey;
7490
}
75-
76-
const { deviceType } = CacheKey.parse(result.id);
77-
78-
// Normalize the redirect target the same way serving does, so the rendered content is
79-
// stored under the key a bot request for that URL will look up. redirectedTo is the
80-
// renderer's decodeURI'd form; normalizeUrl re-sorts and standardizes the encoding
81-
// (['*'] keeps all params — the matched route, hence its allowlist, isn't known here),
82-
// then cacheKeyUrl applies the shared cache-key encoding.
83-
cacheKey = CacheKey.toCacheKey({ deviceType, url: cacheKeyUrl(normalizeUrl(result.redirectedTo, false, ['*'])) });
8491
}
8592

8693
try {

0 commit comments

Comments
 (0)