Skip to content

Commit bd0253d

Browse files
WC-3583 Add static routing matching to rules engine
1 parent a6cfaa2 commit bd0253d

File tree

2 files changed

+309
-25
lines changed

2 files changed

+309
-25
lines changed

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/rules-engine.test.ts

Lines changed: 255 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, test } from "vitest";
2-
import { generateRulesMatcher, replacer } from "../src/utils/rules-engine";
2+
import {
3+
generateRulesMatcher,
4+
generateStaticRoutingRuleMatcher,
5+
replacer,
6+
} from "../src/utils/rules-engine";
37

48
describe("rules engine", () => {
59
test("it should match simple pathname hosts", () => {
@@ -115,3 +119,253 @@ describe("replacer", () => {
115119
);
116120
});
117121
});
122+
123+
describe("static routing rules", () => {
124+
test("should return true for a request that matches", () => {
125+
expect(
126+
generateStaticRoutingRuleMatcher(["/some/path"])({
127+
request: new Request("https://site.com/some/path"),
128+
})
129+
).toEqual(true);
130+
131+
expect(
132+
generateStaticRoutingRuleMatcher(["/some/*"])({
133+
request: new Request("https://site.com/some/path"),
134+
})
135+
).toEqual(true);
136+
137+
expect(
138+
generateStaticRoutingRuleMatcher(["/no/match", "/some/*"])({
139+
request: new Request("https://site.com/some/path"),
140+
})
141+
).toEqual(true);
142+
});
143+
144+
test("should return false for a request that does not match", () => {
145+
expect(
146+
generateStaticRoutingRuleMatcher(["/some/path"])({
147+
request: new Request("https://site.com"),
148+
})
149+
).toEqual(false);
150+
151+
expect(
152+
generateStaticRoutingRuleMatcher(["/some/*"])({
153+
request: new Request("https://site.com/path"),
154+
})
155+
).toEqual(false);
156+
157+
expect(
158+
generateStaticRoutingRuleMatcher(["/some/path", "/other/path"])({
159+
request: new Request("https://site.com/path"),
160+
})
161+
).toEqual(false);
162+
163+
expect(
164+
generateStaticRoutingRuleMatcher([])({
165+
request: new Request("https://site.com/some/path"),
166+
})
167+
).toEqual(false);
168+
});
169+
170+
test("should ignore regex characters other than a glob", () => {
171+
{
172+
const matcher = generateStaticRoutingRuleMatcher(["/"]);
173+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
174+
true
175+
);
176+
expect(matcher({ request: new Request("http://example.com") })).toEqual(
177+
true
178+
);
179+
expect(
180+
matcher({ request: new Request("http://example.com/?foo=bar") })
181+
).toEqual(true);
182+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
183+
true
184+
);
185+
expect(
186+
matcher({ request: new Request("http://example.com/foo") })
187+
).toEqual(false);
188+
}
189+
190+
{
191+
const matcher = generateStaticRoutingRuleMatcher(["/foo"]);
192+
expect(
193+
matcher({ request: new Request("http://example.com/foo") })
194+
).toEqual(true);
195+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
196+
false
197+
);
198+
expect(
199+
matcher({ request: new Request("https://example.com/foo/") })
200+
).toEqual(false);
201+
expect(
202+
matcher({ request: new Request("https://example.com/foo/bar") })
203+
).toEqual(false);
204+
expect(
205+
matcher({ request: new Request("https://example.com/baz") })
206+
).toEqual(false);
207+
expect(
208+
matcher({ request: new Request("https://example.com/baz/foo") })
209+
).toEqual(false);
210+
expect(
211+
matcher({ request: new Request("https://example.com/foobar") })
212+
).toEqual(false);
213+
}
214+
215+
{
216+
const matcher = generateStaticRoutingRuleMatcher(["/foo*"]);
217+
expect(
218+
matcher({ request: new Request("http://example.com/foo") })
219+
).toEqual(true);
220+
expect(
221+
matcher({ request: new Request("https://example.com/foo/") })
222+
).toEqual(true);
223+
expect(
224+
matcher({ request: new Request("https://example.com/foo/bar") })
225+
).toEqual(true);
226+
expect(
227+
matcher({ request: new Request("https://example.com/foobar") })
228+
).toEqual(true);
229+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
230+
false
231+
);
232+
expect(
233+
matcher({ request: new Request("https://example.com/baz") })
234+
).toEqual(false);
235+
expect(
236+
matcher({ request: new Request("https://example.com/baz/foo") })
237+
).toEqual(false);
238+
}
239+
240+
{
241+
const matcher = generateStaticRoutingRuleMatcher(["/*.html"]);
242+
expect(
243+
matcher({ request: new Request("http://example.com/foo.html") })
244+
).toEqual(true);
245+
expect(
246+
matcher({ request: new Request("http://example.com/foo/bar.html") })
247+
).toEqual(true);
248+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
249+
false
250+
);
251+
expect(
252+
matcher({ request: new Request("http://example.com/foo") })
253+
).toEqual(false);
254+
expect(
255+
matcher({ request: new Request("http://example.com/foo/bar") })
256+
).toEqual(false);
257+
}
258+
259+
{
260+
const matcher = generateStaticRoutingRuleMatcher(["/login/*"]);
261+
expect(
262+
matcher({ request: new Request("http://example.com/login/foo") })
263+
).toEqual(true);
264+
expect(
265+
matcher({ request: new Request("http://example2.com/login/foo") })
266+
).toEqual(true);
267+
expect(
268+
matcher({ request: new Request("http://example.com/foo/login/foo") })
269+
).toEqual(false);
270+
expect(
271+
matcher({
272+
request: new Request("http://example.com/foo?bar=baz/login/foo"),
273+
})
274+
).toEqual(false);
275+
}
276+
277+
{
278+
const matcher = generateStaticRoutingRuleMatcher(["/*"]);
279+
expect(
280+
matcher({ request: new Request("http://foo.example.com/bar") })
281+
).toEqual(true);
282+
expect(
283+
matcher({
284+
request: new Request("http://example2.com/foo.example.com/baz"),
285+
})
286+
).toEqual(true);
287+
expect(
288+
matcher({
289+
request: new Request("http://example2.com/?q=foo.example.com/baz"),
290+
})
291+
).toEqual(true);
292+
expect(
293+
matcher({ request: new Request("https://example.com/foo.html") })
294+
).toEqual(true);
295+
expect(
296+
matcher({ request: new Request("https://example.com/foo/bar.html") })
297+
).toEqual(true);
298+
expect(
299+
matcher({ request: new Request("http://example.com/foo") })
300+
).toEqual(true);
301+
expect(
302+
matcher({ request: new Request("https://example.com/foo/") })
303+
).toEqual(true);
304+
expect(
305+
matcher({ request: new Request("https://example.com/foo/bar") })
306+
).toEqual(true);
307+
expect(
308+
matcher({ request: new Request("https://example.com/foobar") })
309+
).toEqual(true);
310+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
311+
true
312+
);
313+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
314+
true
315+
);
316+
expect(matcher({ request: new Request("http://example.com") })).toEqual(
317+
true
318+
);
319+
expect(matcher({ request: new Request("https://example.com") })).toEqual(
320+
true
321+
);
322+
}
323+
324+
{
325+
const matcher = generateStaticRoutingRuleMatcher(["*/*"]);
326+
expect(
327+
matcher({ request: new Request("http://foo.example.com/bar") })
328+
).toEqual(true);
329+
expect(
330+
matcher({
331+
request: new Request("http://example2.com/foo.example.com/baz"),
332+
})
333+
).toEqual(true);
334+
expect(
335+
matcher({
336+
request: new Request("http://example2.com/?q=foo.example.com/baz"),
337+
})
338+
).toEqual(true);
339+
expect(
340+
matcher({ request: new Request("https://example.com/foo.html") })
341+
).toEqual(true);
342+
expect(
343+
matcher({ request: new Request("https://example.com/foo/bar.html") })
344+
).toEqual(true);
345+
expect(
346+
matcher({ request: new Request("http://example.com/foo") })
347+
).toEqual(true);
348+
expect(
349+
matcher({ request: new Request("https://example.com/foo/") })
350+
).toEqual(true);
351+
expect(
352+
matcher({ request: new Request("https://example.com/foo/bar") })
353+
).toEqual(true);
354+
expect(
355+
matcher({ request: new Request("https://example.com/foobar") })
356+
).toEqual(true);
357+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
358+
true
359+
);
360+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
361+
true
362+
);
363+
expect(matcher({ request: new Request("http://example.com") })).toEqual(
364+
true
365+
);
366+
expect(matcher({ request: new Request("https://example.com") })).toEqual(
367+
true
368+
);
369+
}
370+
});
371+
});

0 commit comments

Comments
 (0)