Skip to content

Commit 3383021

Browse files
Add support for static routing to Workers Assets (#9416)
* WC-3583 Add static routing matching to rules engine * WC-3583 Support static routing in router config * WC-3583 Route requests based on static routing when present - When a request matches a static routing "exclude" rule, it's directly forwarded to the asset worker. - When a request matches a static routing "include" rule, it's directly forwarded to the user worker. - Otherwise, previous behavior takes over This also adds a new analytics field (double6) for what routing decision was made * WC-3583 Fix unawaited async assertion vitest yelled at me and told me this should be awaited and may error in future releases * WC-3583 Disable Sec-Fetch-Mode check when static routing is present When the Router worker has static routing, the check against "Sec-Fetch-Mode: navigate" is unnecessary. We have explicit static routing to indicate whether or not we should go to a User worker or the Asset worker, and should not try and guess via usually-set browser headers This adds a new parameter to unstable_canFetch RPC method, which should be fine for backwards compatibility, and can be extended in the future if needed. This was necessary because the Asset worker checks the Request headers for Sec-Fetch-Mode to indicate if it can serve an asset (including an index.html or 404.html page based on not_found_handling), but static routing is only provided to the Router worker. Thus, we need to pass more information over RPC * WC-3583 Add changeset * WC-3583 Update static routing for run_worker_first based configuration Now that we're not providing this configuration via _routes.json, we can relax some of the configuration. Much of this happens on the backend, but some field names have been changed ("include" -> "worker", "exclude"->"asset") --------- Co-authored-by: Carmen Popoviciu <[email protected]>
1 parent 4ab5a40 commit 3383021

File tree

11 files changed

+675
-44
lines changed

11 files changed

+675
-44
lines changed

.changeset/tasty-hoops-own.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cloudflare/workers-shared": minor
3+
---
4+
5+
Adds support for static routing to Workers Assets
6+
7+
Implements the proposal noted here https://github.com/cloudflare/workers-sdk/discussions/9143
8+
9+
In brief: when static routing is present for a Worker with assets, routing via those static rules takes precedence. When a request is evaluated in the Router Worker, the request path is first compared to the `"asset_worker"` rules (which are to be specified via "negative" rules, e.g. `"!/api/assets"`). If any match, the request is forwarded directly to the Asset Worker. If instead any `"user_worker"` rules match, the request is forwarded directly to the User Worker. If neither match (or static routing was not provided), the existing behavior takes over.
10+
11+
As part of this explicit routing, when static routing is present, the check against `Sec-Fetch-Mode: navigate` (to determine if this should serve an asset or go to the User Worker for not_found_handling) is disabled. Routing can be controlled by setting routing rules via `assets.run_worker_first` in your Wrangler configuration file.

packages/workers-shared/asset-worker/src/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const normalizeConfiguration = (
2020
version: 2,
2121
rules: {},
2222
},
23+
has_static_routing: configuration?.has_static_routing ?? false,
2324
account_id: configuration?.account_id ?? -1,
2425
script_id: configuration?.script_id ?? -1,
2526
debug: configuration?.debug ?? false,

packages/workers-shared/asset-worker/src/handler.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,14 @@ export const canFetch = async (
198198
configuration: Required<AssetConfig>,
199199
exists: typeof EntrypointType.prototype.unstable_exists
200200
): Promise<boolean> => {
201-
if (
202-
!(
203-
flagIsEnabled(
204-
configuration,
205-
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING
206-
) && request.headers.get("Sec-Fetch-Mode") === "navigate"
207-
)
208-
) {
201+
const shouldKeepNotFoundHandling =
202+
configuration.has_static_routing ||
203+
(flagIsEnabled(
204+
configuration,
205+
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING
206+
) &&
207+
request.headers.get("Sec-Fetch-Mode") === "navigate");
208+
if (!shouldKeepNotFoundHandling) {
209209
configuration = {
210210
...configuration,
211211
not_found_handling: "none",

packages/workers-shared/asset-worker/src/utils/rules-engine.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,43 @@ export const replacer = (str: string, replacements: Replacements) => {
2828
return str;
2929
};
3030

31+
export const generateGlobOnlyRuleRegExp = (rule: string) => {
32+
// Escape all regex characters other than globs (the "*" character) since that's all that's supported.
33+
rule = rule.split("*").map(escapeRegex).join(".*");
34+
35+
// Wrap in line terminators to be safe.
36+
rule = "^" + rule + "$";
37+
38+
return RegExp(rule);
39+
};
40+
41+
export const generateRuleRegExp = (rule: string) => {
42+
// Create :splat capturer then escape.
43+
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
44+
45+
// Create :placeholder capturers (already escaped).
46+
// For placeholders in the host, we separate at forward slashes and periods.
47+
// For placeholders in the path, we separate at forward slashes.
48+
// This matches the behavior of URLPattern.
49+
// e.g. https://:subdomain.domain/ -> https://(here).domain/
50+
// e.g. /static/:file -> /static/(image.jpg)
51+
// e.g. /blog/:post -> /blog/(an-exciting-post)
52+
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
53+
for (const host_match of host_matches) {
54+
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
55+
}
56+
57+
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
58+
for (const path_match of path_matches) {
59+
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
60+
}
61+
62+
// Wrap in line terminators to be safe.
63+
rule = "^" + rule + "$";
64+
65+
return RegExp(rule);
66+
};
67+
3168
export const generateRulesMatcher = <T>(
3269
rules?: Record<string, T>,
3370
replacerFn: (match: T, replacements: Replacements) => T = (match) => match
@@ -40,31 +77,8 @@ export const generateRulesMatcher = <T>(
4077
.map(([rule, match]) => {
4178
const crossHost = rule.startsWith("https://");
4279

43-
// Create :splat capturer then escape.
44-
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
45-
46-
// Create :placeholder capturers (already escaped).
47-
// For placeholders in the host, we separate at forward slashes and periods.
48-
// For placeholders in the path, we separate at forward slashes.
49-
// This matches the behavior of URLPattern.
50-
// e.g. https://:subdomain.domain/ -> https://(here).domain/
51-
// e.g. /static/:file -> /static/(image.jpg)
52-
// e.g. /blog/:post -> /blog/(an-exciting-post)
53-
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
54-
for (const host_match of host_matches) {
55-
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
56-
}
57-
58-
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
59-
for (const path_match of path_matches) {
60-
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
61-
}
62-
63-
// Wrap in line terminators to be safe.
64-
rule = "^" + rule + "$";
65-
6680
try {
67-
const regExp = new RegExp(rule);
81+
const regExp = generateRuleRegExp(rule);
6882
return [{ crossHost, regExp }, match];
6983
} catch {}
7084
})
@@ -131,3 +145,19 @@ export const generateRedirectsMatcher = (
131145
to: replacer(to, replacements),
132146
})
133147
);
148+
149+
export const generateStaticRoutingRuleMatcher =
150+
(rules: string[]) =>
151+
({ request }: { request: Request }) => {
152+
const { pathname } = new URL(request.url);
153+
for (const rule of rules) {
154+
try {
155+
const regExp = generateGlobOnlyRuleRegExp(rule);
156+
if (regExp.test(pathname)) {
157+
return true;
158+
}
159+
} catch {}
160+
}
161+
162+
return false;
163+
};

packages/workers-shared/asset-worker/tests/handler.test.ts

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { vi } from "vitest";
22
import { mockJaegerBinding } from "../../utils/tracing";
33
import { Analytics } from "../src/analytics";
4+
import { SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING } from "../src/compatibility-flags";
45
import { normalizeConfiguration } from "../src/configuration";
56
import { canFetch, handleRequest } from "../src/handler";
67
import type { AssetConfig } from "../../utils/types";
@@ -1124,6 +1125,80 @@ describe("[Asset Worker] `canFetch`", () => {
11241125
}
11251126
});
11261127

1128+
describe('should always return "true" for 404s or SPAs when static routing is present', async () => {
1129+
const exists = (pathname: string) => {
1130+
// only our special files are present
1131+
if (["/404.html", "/index.html"].includes(pathname)) {
1132+
return "some-etag";
1133+
}
1134+
1135+
return null;
1136+
};
1137+
1138+
for (const notFoundHandling of [
1139+
"single-page-application",
1140+
"404-page",
1141+
] as const) {
1142+
for (const headers of [{}, { "Sec-Fetch-Mode": "navigate" }] as Record<
1143+
string,
1144+
string
1145+
>[]) {
1146+
for (const flags of [
1147+
[SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING.disable],
1148+
[SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING.enable],
1149+
]) {
1150+
const config = normalizeConfiguration({
1151+
not_found_handling: notFoundHandling,
1152+
compatibility_flags: flags,
1153+
has_static_routing: true,
1154+
});
1155+
1156+
it(`notFoundHandling=${notFoundHandling} Sec-Fetch-Mode=${headers["Sec-Fetch-Mode"]} flags=${flags}`, async () => {
1157+
expect(
1158+
await canFetch(
1159+
new Request("https://example.com/foo", { headers }),
1160+
// @ts-expect-error Empty config default to using mocked jaeger
1161+
mockEnv,
1162+
config,
1163+
exists
1164+
)
1165+
).toBeTruthy();
1166+
1167+
expect(
1168+
await canFetch(
1169+
new Request("https://example.com/bar", { headers }),
1170+
// @ts-expect-error Empty config default to using mocked jaeger
1171+
mockEnv,
1172+
config,
1173+
exists
1174+
)
1175+
).toBeTruthy();
1176+
1177+
expect(
1178+
await canFetch(
1179+
new Request("https://example.com/", { headers }),
1180+
// @ts-expect-error Empty config default to using mocked jaeger
1181+
mockEnv,
1182+
config,
1183+
exists
1184+
)
1185+
).toBeTruthy();
1186+
1187+
expect(
1188+
await canFetch(
1189+
new Request("https://example.com/404", { headers }),
1190+
// @ts-expect-error Empty config default to using mocked jaeger
1191+
mockEnv,
1192+
config,
1193+
exists
1194+
)
1195+
).toBeTruthy();
1196+
});
1197+
}
1198+
}
1199+
}
1200+
});
1201+
11271202
it('should return "true" even for a bad method', async () => {
11281203
const exists = (pathname: string) => {
11291204
if (pathname === "/foo.html") {
@@ -1325,29 +1400,39 @@ describe("[Asset Worker] `canFetch`", () => {
13251400
[{ "Sec-Fetch-Mode": "cors" }, false],
13261401
] as const;
13271402

1403+
const hasStaticRoutingModes = [
1404+
[false, false],
1405+
[true, true],
1406+
] as const;
1407+
13281408
const matrix = [];
13291409
for (const compatibilityOptions of compatibilityOptionsModes) {
13301410
for (const notFoundHandling of notFoundHandlingModes) {
13311411
for (const headers of headersModes) {
1332-
matrix.push({
1333-
compatibilityDate: compatibilityOptions[0].compatibilityDate,
1334-
compatibilityFlags: compatibilityOptions[0].compatibilityFlags,
1335-
notFoundHandling: notFoundHandling[0],
1336-
headers: headers[0],
1337-
expected:
1338-
compatibilityOptions[1] && notFoundHandling[1] && headers[1],
1339-
});
1412+
for (const hasStaticRouting of hasStaticRoutingModes) {
1413+
matrix.push({
1414+
compatibilityDate: compatibilityOptions[0].compatibilityDate,
1415+
compatibilityFlags: compatibilityOptions[0].compatibilityFlags,
1416+
notFoundHandling: notFoundHandling[0],
1417+
headers: headers[0],
1418+
hasStaticRouting: hasStaticRouting[0],
1419+
expected:
1420+
(hasStaticRouting[1] && notFoundHandling[1]) ||
1421+
(compatibilityOptions[1] && notFoundHandling[1] && headers[1]),
1422+
});
1423+
}
13401424
}
13411425
}
13421426
}
13431427

13441428
it.each(matrix)(
1345-
"compatibility_date $compatibilityDate, compatibility_flags $compatibilityFlags, not_found_handling $notFoundHandling, headers: $headers -> $expected",
1429+
"compatibility_date $compatibilityDate, compatibility_flags $compatibilityFlags, not_found_handling $notFoundHandling, headers: $headers, hasStaticRouting $hasStaticRouting -> $expected",
13461430
async ({
13471431
compatibilityDate,
13481432
compatibilityFlags,
13491433
notFoundHandling,
13501434
headers,
1435+
hasStaticRouting,
13511436
expected,
13521437
}) => {
13531438
expect(
@@ -1359,6 +1444,7 @@ describe("[Asset Worker] `canFetch`", () => {
13591444
compatibility_date: compatibilityDate,
13601445
compatibility_flags: compatibilityFlags,
13611446
not_found_handling: notFoundHandling,
1447+
has_static_routing: hasStaticRouting,
13621448
}),
13631449
exists
13641450
)
@@ -1373,6 +1459,7 @@ describe("[Asset Worker] `canFetch`", () => {
13731459
compatibility_date: compatibilityDate,
13741460
compatibility_flags: compatibilityFlags,
13751461
not_found_handling: notFoundHandling,
1462+
has_static_routing: hasStaticRouting,
13761463
}),
13771464
exists
13781465
)
@@ -1387,6 +1474,7 @@ describe("[Asset Worker] `canFetch`", () => {
13871474
compatibility_date: compatibilityDate,
13881475
compatibility_flags: compatibilityFlags,
13891476
not_found_handling: notFoundHandling,
1477+
has_static_routing: hasStaticRouting,
13901478
}),
13911479
exists
13921480
)
@@ -1401,6 +1489,7 @@ describe("[Asset Worker] `canFetch`", () => {
14011489
compatibility_date: compatibilityDate,
14021490
compatibility_flags: compatibilityFlags,
14031491
not_found_handling: notFoundHandling,
1492+
has_static_routing: hasStaticRouting,
14041493
}),
14051494
exists
14061495
)

0 commit comments

Comments
 (0)