Skip to content

Commit c2c38c3

Browse files
harper-josephclaude
andcommitted
fix(plugin): normalize cache-key URL encoding so equivalent URLs share one key; v0.3.3
All cache-key sites (serving lookup, auto-discovery scheduling, NonIndexable check, redirect result) now build the key via a single cacheKeyUrl() helper, so an encoded and a decoded form of the same URL (e.g. ?CN=Gender%3AMens vs ?CN=Gender:Mens) collapse to one key instead of fragmenting the cache. cacheKeyUrl presents query-legal, non-separator chars decoded (:, ',', @) so keys read like the canonical/sitemap form; structural chars (& = + # ;) stay encoded. It is a pure re-encode of an ALREADY-normalized URL — it does NOT re-apply the query allowlist, because forwarded mode already normalized with the per-route allowlist (re-applying the global one would drop route params like CN). The encoded/decoded collapse itself comes from normalizeUrl's existing searchParams.sort(). Sitemap <loc> keying is left as-is: matching serving there needs route-aware normalization (separate change; no sitemap configured for Kohl's). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 84b899d commit c2c38c3

6 files changed

Lines changed: 74 additions & 8 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.3.2",
3+
"version": "0.3.3",
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: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Readable } from 'node:stream';
33
import { CacheKey } from '../util/cacheKey.js';
44
import { getBotName } from '../util/userAgent.js';
55
import { isPrerenderCandidate } from '../util/indexSignals.js';
6-
import { normalizeUrl } from '../util/url.js';
6+
import { normalizeUrl, cacheKeyUrl } from '../util/url.js';
77
import { config } from '../config.js';
88
import { sanitizeDeviceType } from '../util/device_type.js';
99
import { isForwardedMode, resolveForwardedRequest } from '../util/ingress.js';
@@ -53,7 +53,7 @@ export async function handleBotRequest(request) {
5353
body: request._nodeRequest,
5454
});
5555
} else {
56-
const cacheKey = CacheKey.toCacheKey({ url: url.href, deviceType });
56+
const cacheKey = CacheKey.toCacheKey({ url: cacheKeyUrl(url), deviceType });
5757

5858
const page = await PrerenderedPage.get(cacheKey);
5959

@@ -77,11 +77,14 @@ export async function handleBotRequest(request) {
7777
async function handlePageScheduling(resource) {
7878
try {
7979
if (isPrerenderCandidate(resource)) {
80-
const existingNonIndexable = await databases.signals.NonIndexable.get({ id: resource.url, select: 'url' });
80+
const existingNonIndexable = await databases.signals.NonIndexable.get({
81+
id: cacheKeyUrl(resource.url),
82+
select: 'url',
83+
});
8184

8285
if (!existingNonIndexable) {
8386
for (const deviceType of config.deviceTypes.default) {
84-
const cacheKey = CacheKey.toCacheKey({ url: resource.url, deviceType });
87+
const cacheKey = CacheKey.toCacheKey({ url: cacheKeyUrl(resource.url), deviceType });
8588
const existingTarget = await RenderTarget.get({ id: cacheKey, select: 'cacheKey' });
8689
if (!existingTarget) {
8790
await RenderTarget.put(cacheKey, {

packages/plugin/src/resources/RenderQueue.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { config } from '../config.js';
33
import { currentMinuteMs, getNextRenderTime } from '../util/time.js';
44
import { QueueState } from './QueueState.js';
55
import { CacheKey } from '../util/cacheKey.js';
6+
import { cacheKeyUrl } from '../util/url.js';
67
import { RenderTarget } from './RenderTarget.js';
78

89
const protocol = server.hostname === 'localhost' ? 'http' : 'https';
@@ -74,7 +75,11 @@ export class RenderQueue extends Resource {
7475

7576
const { deviceType } = CacheKey.parse(result.id);
7677

77-
cacheKey = CacheKey.toCacheKey({ deviceType, url: result.redirectedTo });
78+
// Normalize the redirect target the same way serving does, so the rendered content
79+
// is stored under the key a bot request for that URL will look up (the renderer's
80+
// redirectedTo is decodeURI'd and skips the query allowlist — normalizeUrl re-encodes
81+
// and re-applies the policy for a consistent key).
82+
cacheKey = CacheKey.toCacheKey({ deviceType, url: cacheKeyUrl(result.redirectedTo) });
7883
}
7984

8085
try {

packages/plugin/src/util/url.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,25 @@ export const normalizeUrl = (url, returnObject = false, queryParams = config.url
2828

2929
return returnObject ? parsed : parsed.href;
3030
};
31+
32+
/**
33+
* The cache-key form of an ALREADY-NORMALIZED URL: presents `:` decoded so keys read like
34+
* the canonical/sitemap form (`?CN=Gender:Mens`) instead of the form-encoded `%3A`.
35+
*
36+
* This is a pure encoding transform — it does NOT re-apply the query allowlist or re-sort.
37+
* The caller must pass an already-normalized URL (from `normalizeUrl` or the forwarded-mode
38+
* ingress resolution), because in forwarded mode that normalization used the per-ROUTE
39+
* allowlist, which this function can't know — re-normalizing here with the global policy
40+
* would wrongly drop route params like `CN`.
41+
*
42+
* Decodes only query-legal, non-separator characters — `:` (facets), `,` (lists), `@` — so
43+
* `:` and `%3A` (etc.) address the same resource and the keys read like the canonical. Anything
44+
* structural stays encoded (`&`/`=`/`+`/`#`, and `;` which some parsers treat as a separator),
45+
* so the URL can't reparse into a different shape. (Edit `DECODE` to change the policy in one
46+
* place — every cache-key site calls this.)
47+
*/
48+
const DECODE = { '%3a': ':', '%2c': ',', '%40': '@' };
49+
export const cacheKeyUrl = (url) => {
50+
const href = typeof url === 'string' ? url : (url?.href ?? String(url));
51+
return href.replace(/%(?:3a|2c|40)/gi, (m) => DECODE[m.toLowerCase()]);
52+
};

packages/plugin/test/url.test.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, beforeEach } from 'node:test';
22
import assert from 'node:assert/strict';
33
import { applyOptions } from '../src/config.js';
4-
import { normalizeUrl } from '../src/util/url.js';
4+
import { normalizeUrl, cacheKeyUrl } from '../src/util/url.js';
55

66
beforeEach(() => applyOptions({}));
77

@@ -40,3 +40,39 @@ test('an explicit empty allowlist drops all params regardless of global policy',
4040
applyOptions({ url: { queryParams: ['*'] } });
4141
assert.equal(normalizeUrl('https://x.com/a?page=2&CN=foo', false, []), 'https://x.com/a');
4242
});
43+
44+
// cacheKeyUrl presents the cache-key form of an ALREADY-NORMALIZED url: it decodes `:` so
45+
// keys read like the canonical form (`?CN=Gender:Mens`). Pure transform — it must NOT
46+
// re-normalize or drop params (serving already applied the route/global allowlist).
47+
test('cacheKeyUrl decodes ":" and preserves the (already-normalized) query verbatim', () => {
48+
assert.equal(
49+
cacheKeyUrl('https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender%3AMens+Department%3AClothing'),
50+
'https://www.kohls.com/catalog/mens-clothing.jsp?CN=Gender:Mens+Department:Clothing'
51+
);
52+
});
53+
54+
test('cacheKeyUrl does not drop params (no allowlist re-applied)', () => {
55+
// even a param the global allowlist would drop is preserved — the caller already normalized
56+
assert.equal(cacheKeyUrl('https://x.com/a?CN=x%3Ay&foo=1'), 'https://x.com/a?CN=x:y&foo=1');
57+
});
58+
59+
test('cacheKeyUrl decodes safe query sub-delimiters (: , @) but leaves separators encoded', () => {
60+
assert.equal(cacheKeyUrl('https://x.com/a?CN=A%3AB%2CC%40D'), 'https://x.com/a?CN=A:B,C@D');
61+
// %26 (&), %3D (=), %3B (;) stay encoded — decoding them could shift how the URL parses.
62+
assert.equal(cacheKeyUrl('https://x.com/a?q=a%26b%3Dc%3Bd'), 'https://x.com/a?q=a%26b%3Dc%3Bd');
63+
});
64+
65+
test('cacheKeyUrl accepts a URL object and never throws', () => {
66+
assert.equal(cacheKeyUrl(new URL('https://x.com/a?CN=x%3Ay')), 'https://x.com/a?CN=x:y');
67+
assert.doesNotThrow(() => cacheKeyUrl('::::not a url'));
68+
});
69+
70+
// The encoded/decoded COLLAPSE comes from normalizeUrl (sort re-encodes to %3A) then
71+
// cacheKeyUrl (decode) — the pipeline the serving + scheduling paths use.
72+
test('normalizeUrl -> cacheKeyUrl collapses encoded and decoded to one key', () => {
73+
applyOptions({ url: { queryParams: ['*'] } });
74+
const enc = cacheKeyUrl(normalizeUrl('https://x.com/a?CN=Gender%3AMens'));
75+
const dec = cacheKeyUrl(normalizeUrl('https://x.com/a?CN=Gender:Mens'));
76+
assert.equal(enc, dec);
77+
assert.equal(enc, 'https://x.com/a?CN=Gender:Mens');
78+
});

0 commit comments

Comments
 (0)