Skip to content

Commit b3f290a

Browse files
Add support for static routing (i.e. \_routes.json) to Workers Assets (#9280)
* 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
1 parent c2678d1 commit b3f290a

File tree

11 files changed

+623
-57
lines changed

11 files changed

+623
-57
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 (i.e. \_routes.json) 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 "exclude" rules. If any match, the request is forwarded directly to the Asset Worker. If instead any "include" 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 guess if this should serve an asset or go to the User Worker for not_found_handling) is disabled. Routing can be controlled by uploading a \_routes.json, and asset serving (including when an index.html or 404.html page is served) will be more simple.

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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ export const canFetch = async (
203203
flagIsEnabled(
204204
configuration,
205205
SEC_FETCH_MODE_NAVIGATE_HEADER_PREFERS_ASSET_SERVING
206-
) && request.headers.get("Sec-Fetch-Mode") === "navigate"
206+
) &&
207+
request.headers.get("Sec-Fetch-Mode") === "navigate" &&
208+
!configuration.has_static_routing
207209
)
208210
) {
209211
configuration = {

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: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,41 +1325,56 @@ describe("[Asset Worker] `canFetch`", () => {
13251325
[{ "Sec-Fetch-Mode": "cors" }, false],
13261326
] as const;
13271327

1328+
const staticRoutingModes = [
1329+
[false, true],
1330+
[true, false],
1331+
] as const;
1332+
13281333
const matrix = [];
13291334
for (const compatibilityOptions of compatibilityOptionsModes) {
13301335
for (const notFoundHandling of notFoundHandlingModes) {
13311336
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-
});
1337+
for (const hasStaticRouting of staticRoutingModes) {
1338+
matrix.push({
1339+
compatibilityDate: compatibilityOptions[0].compatibilityDate,
1340+
compatibilityFlags: compatibilityOptions[0].compatibilityFlags,
1341+
notFoundHandling: notFoundHandling[0],
1342+
headers: headers[0],
1343+
hasStaticRouting: hasStaticRouting[0],
1344+
expected:
1345+
compatibilityOptions[1] &&
1346+
notFoundHandling[1] &&
1347+
headers[1] &&
1348+
hasStaticRouting[1],
1349+
});
1350+
}
13401351
}
13411352
}
13421353
}
13431354

13441355
it.each(matrix)(
1345-
"compatibility_date $compatibilityDate, compatibility_flags $compatibilityFlags, not_found_handling $notFoundHandling, headers: $headers -> $expected",
1356+
"compatibility_date $compatibilityDate, compatibility_flags $compatibilityFlags, not_found_handling $notFoundHandling, headers: $headers, hasStaticRouting $hasStaticRouting -> $expected",
13461357
async ({
13471358
compatibilityDate,
13481359
compatibilityFlags,
13491360
notFoundHandling,
13501361
headers,
1362+
hasStaticRouting,
13511363
expected,
13521364
}) => {
13531365
expect(
13541366
await canFetch(
13551367
new Request("https://example.com/foo", { headers }),
13561368
// @ts-expect-error Empty config default to using mocked jaeger
13571369
mockEnv,
1358-
normalizeConfiguration({
1359-
compatibility_date: compatibilityDate,
1360-
compatibility_flags: compatibilityFlags,
1361-
not_found_handling: notFoundHandling,
1362-
}),
1370+
{
1371+
...normalizeConfiguration({
1372+
compatibility_date: compatibilityDate,
1373+
compatibility_flags: compatibilityFlags,
1374+
not_found_handling: notFoundHandling,
1375+
has_static_routing: hasStaticRouting,
1376+
}),
1377+
},
13631378
exists
13641379
)
13651380
).toBeTruthy();
@@ -1369,11 +1384,14 @@ describe("[Asset Worker] `canFetch`", () => {
13691384
new Request("https://example.com/bar", { headers }),
13701385
// @ts-expect-error Empty config default to using mocked jaeger
13711386
mockEnv,
1372-
normalizeConfiguration({
1373-
compatibility_date: compatibilityDate,
1374-
compatibility_flags: compatibilityFlags,
1375-
not_found_handling: notFoundHandling,
1376-
}),
1387+
{
1388+
...normalizeConfiguration({
1389+
compatibility_date: compatibilityDate,
1390+
compatibility_flags: compatibilityFlags,
1391+
not_found_handling: notFoundHandling,
1392+
has_static_routing: hasStaticRouting,
1393+
}),
1394+
},
13771395
exists
13781396
)
13791397
).toBe(expected);
@@ -1383,11 +1401,14 @@ describe("[Asset Worker] `canFetch`", () => {
13831401
new Request("https://example.com/", { headers }),
13841402
// @ts-expect-error Empty config default to using mocked jaeger
13851403
mockEnv,
1386-
normalizeConfiguration({
1387-
compatibility_date: compatibilityDate,
1388-
compatibility_flags: compatibilityFlags,
1389-
not_found_handling: notFoundHandling,
1390-
}),
1404+
{
1405+
...normalizeConfiguration({
1406+
compatibility_date: compatibilityDate,
1407+
compatibility_flags: compatibilityFlags,
1408+
not_found_handling: notFoundHandling,
1409+
has_static_routing: hasStaticRouting,
1410+
}),
1411+
},
13911412
exists
13921413
)
13931414
).toBeTruthy();
@@ -1397,11 +1418,14 @@ describe("[Asset Worker] `canFetch`", () => {
13971418
new Request("https://example.com/404", { headers }),
13981419
// @ts-expect-error Empty config default to using mocked jaeger
13991420
mockEnv,
1400-
normalizeConfiguration({
1401-
compatibility_date: compatibilityDate,
1402-
compatibility_flags: compatibilityFlags,
1403-
not_found_handling: notFoundHandling,
1404-
}),
1421+
{
1422+
...normalizeConfiguration({
1423+
compatibility_date: compatibilityDate,
1424+
compatibility_flags: compatibilityFlags,
1425+
not_found_handling: notFoundHandling,
1426+
has_static_routing: hasStaticRouting,
1427+
}),
1428+
},
14051429
exists
14061430
)
14071431
).toBeTruthy();

0 commit comments

Comments
 (0)