Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/thin-pianos-create.md
Comment thread
NuroDev marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/workers-shared": minor
---

Track alternate URL paths in Workers Assets

Add internal telemetry to identify when encoded or repeated-slash paths would produce different routing decisions. Customer request handling is unchanged.
6 changes: 6 additions & 0 deletions packages/workers-shared/asset-worker/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ type Data = {
compatibilityFlags?: string[]; // converted into a bitmask
// double7 - Entrypoint discriminator (see EntrypointType enum)
entrypoint?: EntrypointType;
// double8 - Canonicalization performed while in shadow mode
pathNormalization?: number;
// double9 - Shadow decisions that differ from current behavior
pathNormalizationDifference?: number;

// -- Blobs --
// blob1 - Hostname of the request
Expand Down Expand Up @@ -142,6 +146,8 @@ export class Analytics {
this.data.status ?? -1, // double5
compatibilityFlagsBitmask, // double6
this.data.entrypoint ?? -1, // double7
this.data.pathNormalization ?? 0, // double8
this.data.pathNormalizationDifference ?? 0, // double9
],
blobs: [
this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes
Expand Down
90 changes: 86 additions & 4 deletions packages/workers-shared/asset-worker/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ import {
flagIsEnabled,
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING,
} from "./compatibility-flags";
import { attachCustomHeaders, getAssetHeaders } from "./utils/headers";
import { canonicalizePath } from "./utils/canonical-path";
import {
attachCustomHeaders,
getAssetHeaders,
getCustomHeaderMatches,
} from "./utils/headers";
import {
generateRedirectsMatcher,
staticRedirectsMatcher,
} from "./utils/rules-engine";
import type { AssetConfig } from "../../utils/types";
import type { Analytics, ServedBy } from "./analytics";
import type { CanonicalRoutingPath } from "./utils/canonical-path";
import type EntrypointType from "./worker";
import type { Env } from "./worker";

Expand All @@ -36,6 +42,12 @@ type AssetIntent = {

export type AssetIntentWithResolver = AssetIntent & { resolver: Resolver };

// Bitmask of rule decisions that differ between raw and canonical paths.
const enum CanonicalPathRuleDifference {
Redirect = 1 << 0,
Headers = 1 << 1,
}

const getResponseOrAssetIntent = async (
request: Request,
env: Env,
Expand All @@ -45,6 +57,8 @@ const getResponseOrAssetIntent = async (
): Promise<Response | AssetIntentWithResolver> => {
const url = new URL(request.url);
const { search } = url;
// Shadow-only: raw-path matching remains authoritative.
recordCanonicalPathRuleDifferences(request, configuration, analytics);

const redirectResult = handleRedirects(
env,
Expand Down Expand Up @@ -1039,9 +1053,7 @@ const handleRedirects = (
): { proxied: boolean; pathname: string } | Response => {
const jaeger = env.JAEGER ?? mockJaegerBinding();
return jaeger.enterSpan("handle_redirects", (span) => {
const redirectMatch =
staticRedirectsMatcher(configuration, host, pathname) ||
generateRedirectsMatcher(configuration)({ request })[0];
const redirectMatch = getRedirectMatch(request, configuration, host);

let proxied = false;
if (redirectMatch) {
Expand Down Expand Up @@ -1101,3 +1113,73 @@ const handleRedirects = (
return { proxied, pathname };
});
};

// Returns the first matching redirect rule, optionally using a canonical path.
export function getRedirectMatch(
request: Request,
configuration: Required<AssetConfig>,
host: string,
canonicalPath?: CanonicalRoutingPath
) {
const pathname = canonicalPath ?? new URL(request.url).pathname;
return (
staticRedirectsMatcher(configuration, host, pathname) ||
generateRedirectsMatcher(configuration)({ request, canonicalPath })[0]
);
}

/**
* Records rule differences caused by canonicalization without changing the
* raw-path response.
*/
function recordCanonicalPathRuleDifferences(
request: Request,
configuration: Required<AssetConfig>,
analytics?: Analytics
) {
if (!analytics) {
return;
}

const url = new URL(request.url);
const canonicalPath = canonicalizePath(url.pathname);
if (canonicalPath.routingPath === url.pathname) {
return;
}

let difference = 0;
// Compare selected _redirects rules, not the response they would produce.
const currentRedirect = getRedirectMatch(request, configuration, url.host);
const canonicalRedirect = getRedirectMatch(
request,
configuration,
url.host,
canonicalPath.routingPath
);
if (ruleMatchesDiffer(currentRedirect, canonicalRedirect)) {
difference |= CanonicalPathRuleDifference.Redirect;
}

// Compare selected _headers rules after placeholder substitution.
const currentHeaders = getCustomHeaderMatches(request, configuration);
const canonicalHeaders = getCustomHeaderMatches(
request,
configuration,
canonicalPath.routingPath
);
if (ruleMatchesDiffer(currentHeaders, canonicalHeaders)) {
difference |= CanonicalPathRuleDifference.Headers;
}
Comment on lines +1150 to +1172

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rule matching work is repeated up to three times per request for many common URLs

Redirect and header rules are re-compiled and re-evaluated from scratch for every request whose path is not already canonical (recordCanonicalPathRuleDifferences at packages/workers-shared/asset-worker/src/handler.ts:1152-1169), on top of the evaluation the real response already does, so each such request costs about three times the rule-matching CPU.
Impact: Requests to URLs containing very common characters like @ , ( ) + : ; = ! ' & burn several times more CPU in this hot path, adding latency for sites with large redirect/header rule sets.

Why the extra work triggers for ordinary URLs and where it is duplicated

canonicalizePath (packages/workers-shared/asset-worker/src/utils/canonical-path.ts:30-58) re-encodes each segment with encodeURIComponent, which escapes characters that URL.pathname leaves literal (!'()*+,;=:@$&). Any request path containing one of those characters therefore yields routingPath !== url.pathname, so the early return at packages/workers-shared/asset-worker/src/handler.ts:1146 does not fire.

The shadow block then builds and runs the matchers four extra times: two redirect matchers (getRedirectMatch calls generateRedirectsMatcher(configuration) which recompiles a RegExp for every rule in generateRulesMatcher, packages/workers-shared/asset-worker/src/utils/rules-engine.ts:76-88) and two header matchers (getCustomHeaderMatches, packages/workers-shared/asset-worker/src/utils/headers.ts:101-122). The authoritative pass later repeats both (handleRedirects at packages/workers-shared/asset-worker/src/handler.ts:1056 and attachCustomHeaders at packages/workers-shared/asset-worker/src/utils/headers.ts:72). The raw-path result computed at line 1152 is identical to the one handleRedirects computes and could be reused.

The same duplication exists in the router: getStaticRoutingTarget is invoked three times per request (packages/workers-shared/router-worker/src/worker.ts:288, :292, :305) although the third call recomputes exactly the raw-path result obtained by the first, each time recompiling every static-routing glob.

Prompt for agents
The shadow telemetry added in packages/workers-shared/asset-worker/src/handler.ts (recordCanonicalPathRuleDifferences) recompiles all _redirects and _headers rules up to four extra times per request, and packages/workers-shared/router-worker/src/worker.ts calls getStaticRoutingTarget three times where two distinct results are needed. Because canonicalizePath re-encodes characters that URL.pathname leaves literal (such as @ , ( ) + : ; = ! ' & ), the early-return guard rarely fires, so this extra work applies to a large share of real traffic. Consider (a) reusing the raw-path match result between the shadow comparison and the authoritative handleRedirects/attachCustomHeaders passes rather than recomputing it, (b) hoisting/caching the compiled matcher (generateRulesMatcher compiles a RegExp per rule on every invocation) so it is built at most once per request, and (c) in the router, computing the raw-path target once and reusing it for both the shadow comparison and the dispatch switch.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if (difference !== 0) {
analytics.setData({
pathNormalization: canonicalPath.normalization,
pathNormalizationDifference: difference,
});
}
}

// Matcher outputs are JSON-safe and generated in deterministic rule order.
function ruleMatchesDiffer(left: unknown, right: unknown): boolean {
return JSON.stringify(left) !== JSON.stringify(right);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
declare const routingPathBrand: unique symbol;
declare const assetPathBrand: unique symbol;

// Replaces raw URL pathname handling for routing and rule matching after rollout.
export type CanonicalRoutingPath = string & {
readonly [routingPathBrand]: "CanonicalRoutingPath";
};

// Reserved for asset lookup after decoding exactly once.
export type DecodedAssetPath = string & {
readonly [assetPathBrand]: "DecodedAssetPath";
};

// Bitmask of transformations observed while canonicalizing a request path.
export const enum PathNormalization {
None = 0,
Decoded = 1 << 0,
CollapsedSlashes = 1 << 1,
MalformedEncoding = 1 << 2,
Comment thread
WillTaylorDev marked this conversation as resolved.
Reencoded = 1 << 3,
}

export type CanonicalPath = {
routingPath: CanonicalRoutingPath;
assetPath: DecodedAssetPath;
normalization: PathNormalization;
};

// Returns both path representations so routing cannot accidentally reuse lookup input.
export function canonicalizePath(pathname: string): CanonicalPath {
Comment thread
WillTaylorDev marked this conversation as resolved.
let decodedPathname = pathname;
let normalization = PathNormalization.None;

try {
decodedPathname = decodeURIComponent(pathname);
if (decodedPathname !== pathname) {
normalization |= PathNormalization.Decoded;
}
} catch {
normalization |= PathNormalization.MalformedEncoding;
}

const collapsedPathname = decodedPathname.replace(/\/{2,}/g, "/");
if (collapsedPathname !== decodedPathname) {
normalization |= PathNormalization.CollapsedSlashes;
}

const routingPath = encodePath(collapsedPathname);
if (routingPath !== collapsedPathname) {
normalization |= PathNormalization.Reencoded;
}

return {
routingPath: routingPath as CanonicalRoutingPath,
assetPath: collapsedPathname as DecodedAssetPath,
normalization,
};
}

function encodePath(pathname: string): string {
return pathname
.split("/")
.map((segment) => {
try {
return encodeURIComponent(segment);
} catch {
return segment;
}
})
.join("/");
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
44 changes: 27 additions & 17 deletions packages/workers-shared/asset-worker/src/utils/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { generateRulesMatcher, replacer } from "./rules-engine";
import type { AssetConfig, JaegerTracing } from "../../../utils/types";
import type { AssetIntentWithResolver } from "../handler";
import type { Env } from "../worker";
import type { CanonicalRoutingPath } from "./canonical-path";

/**
* Returns a Headers object that contains additional headers (to those
Expand Down Expand Up @@ -68,23 +69,7 @@ export function attachCustomHeaders(
) {
const jaeger: JaegerTracing = env.JAEGER ?? mockJaegerBinding();
return jaeger.enterSpan("add_headers", (span) => {
// Iterate through rules and find rules that match the path
const headersMatcher = generateRulesMatcher(
configuration.headers?.version === HEADERS_VERSION
? configuration.headers.rules
: {},
({ set = {}, unset = [] }, replacements) => {
const replacedSet: Record<string, string> = {};
Object.entries(set).forEach(([key, value]) => {
replacedSet[key] = replacer(value, replacements);
});
return {
set: replacedSet,
unset,
};
}
);
const matches = headersMatcher({ request });
const matches = getCustomHeaderMatches(request, configuration);

// This keeps track of every header that we've set from _headers
// because we want to combine user declared headers but overwrite
Expand All @@ -111,3 +96,28 @@ export function attachCustomHeaders(
return response;
});
}

// Returns matching _headers rules for raw or canonical paths after substitutions.
export function getCustomHeaderMatches(
request: Request,
configuration: Required<AssetConfig>,
canonicalPath?: CanonicalRoutingPath
) {
const headersMatcher = generateRulesMatcher(
configuration.headers?.version === HEADERS_VERSION
? configuration.headers.rules
: {},
({ set = {}, unset = [] }, replacements) => {
const replacedSet: Record<string, string> = {};
Object.entries(set).forEach(([key, value]) => {
replacedSet[key] = replacer(value, replacements);
});
return {
set: replacedSet,
unset,
};
}
);

return headersMatcher({ request, canonicalPath });
}
22 changes: 18 additions & 4 deletions packages/workers-shared/asset-worker/src/utils/rules-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { REDIRECTS_VERSION } from "../handler";
import type { AssetConfig } from "../../../utils/types";
import type { CanonicalRoutingPath } from "./canonical-path";

// As the answer says, there's no downside to escaping these extra characters, so better safe than sorry
const ESCAPE_REGEX_CHARACTERS = /[-/\\^$*+?.()|[\]{}]/g;
Expand Down Expand Up @@ -87,8 +88,15 @@ export const generateRulesMatcher = <T>(
T,
][];

return ({ request }: { request: Request }) => {
const { pathname, hostname } = new URL(request.url);
return ({
request,
canonicalPath,
}: {
request: Request;
canonicalPath?: CanonicalRoutingPath;
}) => {
const { pathname: requestPathname, hostname } = new URL(request.url);
const pathname = canonicalPath ?? requestPathname;

return compiledRules
.map(([{ crossHost, regExp }, match]) => {
Expand Down Expand Up @@ -161,8 +169,14 @@ export const generateRedirectsMatcher = (

export const generateStaticRoutingRuleMatcher =
(rules: string[]) =>
({ request }: { request: Request }) => {
const { pathname } = new URL(request.url);
({
request,
canonicalPath,
}: {
request: Request;
canonicalPath?: CanonicalRoutingPath;
}) => {
const pathname = canonicalPath ?? new URL(request.url).pathname;
for (const rule of rules) {
try {
const regExp = generateGlobOnlyRuleRegExp(rule);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it } from "vitest";
import {
canonicalizePath,
PathNormalization,
} from "../src/utils/canonical-path";

describe("canonicalizePath", () => {
it("decodes once and collapses repeated slashes", ({ expect }) => {
const path = canonicalizePath("//%65xample%2Freport.json");

expect(path.routingPath).toBe("/example/report.json");
expect(path.assetPath).toBe("/example/report.json");
expect(path.normalization).toBe(
PathNormalization.Decoded | PathNormalization.CollapsedSlashes
);
});

it("does not double-decode asset names", ({ expect }) => {
const path = canonicalizePath("/%252Freport.json");

expect(path.routingPath).toBe("/%252Freport.json");
expect(path.assetPath).toBe("/%2Freport.json");
expect(path.normalization).toBe(
PathNormalization.Decoded | PathNormalization.Reencoded
);
});

it("re-encodes literal characters that have an encoded routing spelling", ({
expect,
}) => {
const path = canonicalizePath("/docs+draft");

expect(path.routingPath).toBe("/docs%2Bdraft");
expect(path.assetPath).toBe("/docs+draft");
expect(path.normalization).toBe(PathNormalization.Reencoded);
});

it("records malformed percent-encoding", ({ expect }) => {
const path = canonicalizePath("/%");

expect(path.routingPath).toBe("/%25");
expect(path.assetPath).toBe("/%");
expect(path.normalization).toBe(
PathNormalization.MalformedEncoding | PathNormalization.Reencoded
);
});

it("does not throw for an invalid Unicode character", ({ expect }) => {
expect(() => canonicalizePath("/\uD800")).not.toThrow();
});
});
Loading
Loading