-
Notifications
You must be signed in to change notification settings - Fork 1.4k
workers assets shared routing cleanup #14878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b426bc8
c75c3f0
5ca6353
4c294aa
3b42d43
e44c386
90fe87c
9bfa82d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( Why the extra work triggers for ordinary URLs and where it is duplicated
The shadow block then builds and runs the matchers four extra times: two redirect matchers ( The same duplication exists in the router: Prompt for agentsWas 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, | ||
|
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 { | ||
|
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("/"); | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| 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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.