Skip to content

Commit 2e28e45

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

File tree

2 files changed

+319
-25
lines changed

2 files changed

+319
-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: 265 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,263 @@ 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(["/:placeholder"]);
217+
expect(
218+
matcher({ request: new Request("http://example.com/foo") })
219+
).toEqual(false);
220+
expect(
221+
matcher({ request: new Request("https://example.com/:placeholder") })
222+
).toEqual(true);
223+
}
224+
225+
{
226+
const matcher = generateStaticRoutingRuleMatcher(["/foo*"]);
227+
expect(
228+
matcher({ request: new Request("http://example.com/foo") })
229+
).toEqual(true);
230+
expect(
231+
matcher({ request: new Request("https://example.com/foo/") })
232+
).toEqual(true);
233+
expect(
234+
matcher({ request: new Request("https://example.com/foo/bar") })
235+
).toEqual(true);
236+
expect(
237+
matcher({ request: new Request("https://example.com/foobar") })
238+
).toEqual(true);
239+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
240+
false
241+
);
242+
expect(
243+
matcher({ request: new Request("https://example.com/baz") })
244+
).toEqual(false);
245+
expect(
246+
matcher({ request: new Request("https://example.com/baz/foo") })
247+
).toEqual(false);
248+
}
249+
250+
{
251+
const matcher = generateStaticRoutingRuleMatcher(["/*.html"]);
252+
expect(
253+
matcher({ request: new Request("http://example.com/foo.html") })
254+
).toEqual(true);
255+
expect(
256+
matcher({ request: new Request("http://example.com/foo/bar.html") })
257+
).toEqual(true);
258+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
259+
false
260+
);
261+
expect(
262+
matcher({ request: new Request("http://example.com/foo") })
263+
).toEqual(false);
264+
expect(
265+
matcher({ request: new Request("http://example.com/foo/bar") })
266+
).toEqual(false);
267+
}
268+
269+
{
270+
const matcher = generateStaticRoutingRuleMatcher(["/login/*"]);
271+
expect(
272+
matcher({ request: new Request("http://example.com/login/foo") })
273+
).toEqual(true);
274+
expect(
275+
matcher({ request: new Request("http://example2.com/login/foo") })
276+
).toEqual(true);
277+
expect(
278+
matcher({ request: new Request("http://example.com/foo/login/foo") })
279+
).toEqual(false);
280+
expect(
281+
matcher({
282+
request: new Request("http://example.com/foo?bar=baz/login/foo"),
283+
})
284+
).toEqual(false);
285+
}
286+
287+
{
288+
const matcher = generateStaticRoutingRuleMatcher(["/*"]);
289+
expect(
290+
matcher({ request: new Request("http://foo.example.com/bar") })
291+
).toEqual(true);
292+
expect(
293+
matcher({
294+
request: new Request("http://example2.com/foo.example.com/baz"),
295+
})
296+
).toEqual(true);
297+
expect(
298+
matcher({
299+
request: new Request("http://example2.com/?q=foo.example.com/baz"),
300+
})
301+
).toEqual(true);
302+
expect(
303+
matcher({ request: new Request("https://example.com/foo.html") })
304+
).toEqual(true);
305+
expect(
306+
matcher({ request: new Request("https://example.com/foo/bar.html") })
307+
).toEqual(true);
308+
expect(
309+
matcher({ request: new Request("http://example.com/foo") })
310+
).toEqual(true);
311+
expect(
312+
matcher({ request: new Request("https://example.com/foo/") })
313+
).toEqual(true);
314+
expect(
315+
matcher({ request: new Request("https://example.com/foo/bar") })
316+
).toEqual(true);
317+
expect(
318+
matcher({ request: new Request("https://example.com/foobar") })
319+
).toEqual(true);
320+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
321+
true
322+
);
323+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
324+
true
325+
);
326+
expect(matcher({ request: new Request("http://example.com") })).toEqual(
327+
true
328+
);
329+
expect(matcher({ request: new Request("https://example.com") })).toEqual(
330+
true
331+
);
332+
}
333+
334+
{
335+
const matcher = generateStaticRoutingRuleMatcher(["*/*"]);
336+
expect(
337+
matcher({ request: new Request("http://foo.example.com/bar") })
338+
).toEqual(true);
339+
expect(
340+
matcher({
341+
request: new Request("http://example2.com/foo.example.com/baz"),
342+
})
343+
).toEqual(true);
344+
expect(
345+
matcher({
346+
request: new Request("http://example2.com/?q=foo.example.com/baz"),
347+
})
348+
).toEqual(true);
349+
expect(
350+
matcher({ request: new Request("https://example.com/foo.html") })
351+
).toEqual(true);
352+
expect(
353+
matcher({ request: new Request("https://example.com/foo/bar.html") })
354+
).toEqual(true);
355+
expect(
356+
matcher({ request: new Request("http://example.com/foo") })
357+
).toEqual(true);
358+
expect(
359+
matcher({ request: new Request("https://example.com/foo/") })
360+
).toEqual(true);
361+
expect(
362+
matcher({ request: new Request("https://example.com/foo/bar") })
363+
).toEqual(true);
364+
expect(
365+
matcher({ request: new Request("https://example.com/foobar") })
366+
).toEqual(true);
367+
expect(matcher({ request: new Request("http://example.com/") })).toEqual(
368+
true
369+
);
370+
expect(matcher({ request: new Request("https://example.com/") })).toEqual(
371+
true
372+
);
373+
expect(matcher({ request: new Request("http://example.com") })).toEqual(
374+
true
375+
);
376+
expect(matcher({ request: new Request("https://example.com") })).toEqual(
377+
true
378+
);
379+
}
380+
});
381+
});

0 commit comments

Comments
 (0)