@speedkit/cli 4.25.2 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +192 -19
  3. package/dist/commands/auto-prewarm.d.ts +8 -1
  4. package/dist/commands/auto-prewarm.js +18 -7
  5. package/dist/commands/generate-pop-config.d.ts +1 -0
  6. package/dist/commands/generate-pop-config.js +9 -3
  7. package/dist/commands/prewarm.js +3 -1
  8. package/dist/commands/query/parameter.d.ts +26 -0
  9. package/dist/commands/query/parameter.js +27 -0
  10. package/dist/commands/query/prewarm.d.ts +31 -0
  11. package/dist/commands/query/prewarm.js +31 -0
  12. package/dist/commands/revalidate.js +8 -6
  13. package/dist/helpers/clipboard.d.ts +19 -0
  14. package/dist/helpers/clipboard.js +70 -0
  15. package/dist/helpers/evaluate-speed-kit-config.d.ts +8 -18
  16. package/dist/helpers/evaluate-speed-kit-config.js +8 -6
  17. package/dist/helpers/evaluate-speed-kit-config.spec.d.ts +1 -0
  18. package/dist/helpers/evaluate-speed-kit-config.spec.js +78 -0
  19. package/dist/models/cli-parameters.d.ts +73 -0
  20. package/dist/models/cli-parameters.js +151 -0
  21. package/dist/models/cli-parameters.spec.d.ts +1 -0
  22. package/dist/models/cli-parameters.spec.js +47 -0
  23. package/dist/services/athena/athena-service.d.ts +2 -0
  24. package/dist/services/athena/athena-service.js +15 -4
  25. package/dist/services/athena/athena-service.spec.d.ts +1 -0
  26. package/dist/services/athena/athena-service.spec.js +74 -0
  27. package/dist/services/deploy/handler/install-resource-handler.js +3 -4
  28. package/dist/services/onboarding/onboarding-service-factory.js +2 -2
  29. package/dist/services/pop-config/pop-config-service.d.ts +8 -1
  30. package/dist/services/pop-config/pop-config-service.js +16 -28
  31. package/dist/services/prewarm/assets/asset-api-client.d.ts +1 -1
  32. package/dist/services/prewarm/assets/asset-api-client.js +5 -4
  33. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +12 -2
  34. package/dist/services/prewarm/auto-pre-warm-factory.js +20 -6
  35. package/dist/services/prewarm/csv-reader.d.ts +21 -2
  36. package/dist/services/prewarm/csv-reader.js +71 -9
  37. package/dist/services/prewarm/csv-reader.spec.d.ts +1 -0
  38. package/dist/services/prewarm/csv-reader.spec.js +75 -0
  39. package/dist/services/prewarm/index.d.ts +1 -0
  40. package/dist/services/prewarm/index.js +1 -0
  41. package/dist/services/prewarm/pre-warm-factory.d.ts +0 -2
  42. package/dist/services/prewarm/pre-warm-factory.js +8 -11
  43. package/dist/services/prewarm/pre-warm-model.d.ts +14 -1
  44. package/dist/services/prewarm/pre-warm-model.js +0 -1
  45. package/dist/services/prewarm/pre-warm-service.d.ts +9 -4
  46. package/dist/services/prewarm/pre-warm-service.js +18 -14
  47. package/dist/services/prewarm/prewarm-targets.d.ts +12 -0
  48. package/dist/services/prewarm/prewarm-targets.js +18 -0
  49. package/dist/services/prewarm/prewarm-targets.spec.d.ts +1 -0
  50. package/dist/services/prewarm/prewarm-targets.spec.js +29 -0
  51. package/dist/services/query-builder/error/match-matches-nothing-error.d.ts +11 -0
  52. package/dist/services/query-builder/error/match-matches-nothing-error.js +17 -0
  53. package/dist/services/query-builder/queries/device.d.ts +21 -0
  54. package/dist/services/query-builder/queries/device.js +26 -0
  55. package/dist/services/query-builder/queries/index.d.ts +6 -0
  56. package/dist/services/query-builder/queries/index.js +6 -0
  57. package/dist/services/query-builder/queries/page-filter.d.ts +39 -0
  58. package/dist/services/query-builder/queries/page-filter.js +135 -0
  59. package/dist/services/query-builder/queries/page-source.d.ts +30 -0
  60. package/dist/services/query-builder/queries/page-source.js +79 -0
  61. package/dist/services/query-builder/queries/parameter-query.d.ts +14 -0
  62. package/dist/services/query-builder/queries/parameter-query.js +142 -0
  63. package/dist/services/query-builder/queries/pops-query.d.ts +27 -0
  64. package/dist/services/query-builder/queries/pops-query.js +65 -0
  65. package/dist/services/query-builder/queries/prewarm-query.d.ts +18 -0
  66. package/dist/services/query-builder/queries/prewarm-query.js +227 -0
  67. package/dist/services/query-builder/queries/query-model.d.ts +97 -0
  68. package/dist/services/query-builder/queries/query-model.js +19 -0
  69. package/dist/services/query-builder/queries/spec/golden-configs.d.ts +18 -0
  70. package/dist/services/query-builder/queries/spec/golden-configs.js +116 -0
  71. package/dist/services/query-builder/queries/spec/golden.spec.d.ts +1 -0
  72. package/dist/services/query-builder/queries/spec/golden.spec.js +161 -0
  73. package/dist/services/query-builder/queries/spec/pops-query.spec.d.ts +1 -0
  74. package/dist/services/query-builder/queries/spec/pops-query.spec.js +58 -0
  75. package/dist/services/query-builder/queries/spec/queries.spec.d.ts +1 -0
  76. package/dist/services/query-builder/queries/spec/queries.spec.js +312 -0
  77. package/dist/services/query-builder/queries/spec/reported-bugs.spec.d.ts +1 -0
  78. package/dist/services/query-builder/queries/spec/reported-bugs.spec.js +86 -0
  79. package/dist/services/query-builder/query-builder-factory.d.ts +2 -2
  80. package/dist/services/query-builder/query-builder-factory.js +35 -7
  81. package/dist/services/query-builder/query-builder-model.d.ts +31 -19
  82. package/dist/services/query-builder/query-builder-model.js +5 -5
  83. package/dist/services/query-builder/query-builder-service.d.ts +26 -5
  84. package/dist/services/query-builder/query-builder-service.js +157 -44
  85. package/dist/services/query-builder/query-command.d.ts +26 -0
  86. package/dist/services/query-builder/query-command.js +35 -0
  87. package/dist/services/query-builder/rules/index.d.ts +8 -0
  88. package/dist/services/query-builder/rules/index.js +8 -0
  89. package/dist/services/query-builder/rules/match-pattern.d.ts +51 -0
  90. package/dist/services/query-builder/rules/match-pattern.js +141 -0
  91. package/dist/services/query-builder/rules/pattern-language.d.ts +59 -0
  92. package/dist/services/query-builder/rules/pattern-language.js +159 -0
  93. package/dist/services/query-builder/rules/rule-model.d.ts +107 -0
  94. package/dist/services/query-builder/rules/rule-model.js +54 -0
  95. package/dist/services/query-builder/rules/rule-parser.d.ts +33 -0
  96. package/dist/services/query-builder/rules/rule-parser.js +189 -0
  97. package/dist/services/query-builder/rules/rule-reducer.d.ts +71 -0
  98. package/dist/services/query-builder/rules/rule-reducer.js +383 -0
  99. package/dist/services/query-builder/rules/rule-relevance.d.ts +43 -0
  100. package/dist/services/query-builder/rules/rule-relevance.js +69 -0
  101. package/dist/services/query-builder/rules/rule-to-sql.d.ts +51 -0
  102. package/dist/services/query-builder/rules/rule-to-sql.js +75 -0
  103. package/dist/services/query-builder/rules/spec/pattern-language.spec.d.ts +1 -0
  104. package/dist/services/query-builder/rules/spec/pattern-language.spec.js +90 -0
  105. package/dist/services/query-builder/rules/spec/rule-parser.spec.d.ts +1 -0
  106. package/dist/services/query-builder/rules/spec/rule-parser.spec.js +150 -0
  107. package/dist/services/query-builder/rules/spec/rule-reducer.spec.d.ts +1 -0
  108. package/dist/services/query-builder/rules/spec/rule-reducer.spec.js +169 -0
  109. package/dist/services/query-builder/rules/spec/rule-relevance.spec.d.ts +1 -0
  110. package/dist/services/query-builder/rules/spec/rule-relevance.spec.js +88 -0
  111. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.d.ts +1 -0
  112. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.js +110 -0
  113. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.d.ts +1 -0
  114. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.js +55 -0
  115. package/dist/services/query-builder/rules/spec/strip-parameters.spec.d.ts +1 -0
  116. package/dist/services/query-builder/rules/spec/strip-parameters.spec.js +85 -0
  117. package/dist/services/query-builder/rules/strip-parameters.d.ts +39 -0
  118. package/dist/services/query-builder/rules/strip-parameters.js +197 -0
  119. package/dist/services/query-builder/sql/expression.d.ts +73 -0
  120. package/dist/services/query-builder/sql/expression.js +133 -0
  121. package/dist/services/query-builder/sql/format.d.ts +14 -0
  122. package/dist/services/query-builder/sql/format.js +48 -0
  123. package/dist/services/query-builder/sql/index.d.ts +4 -0
  124. package/dist/services/query-builder/sql/index.js +4 -0
  125. package/dist/services/query-builder/sql/predicate.d.ts +66 -0
  126. package/dist/services/query-builder/sql/predicate.js +124 -0
  127. package/dist/services/query-builder/sql/regex.d.ts +28 -0
  128. package/dist/services/query-builder/sql/regex.js +81 -0
  129. package/dist/services/query-builder/sql/spec/expression.spec.d.ts +1 -0
  130. package/dist/services/query-builder/sql/spec/expression.spec.js +75 -0
  131. package/dist/services/query-builder/sql/spec/format.spec.d.ts +1 -0
  132. package/dist/services/query-builder/sql/spec/format.spec.js +37 -0
  133. package/dist/services/query-builder/sql/spec/predicate.spec.d.ts +1 -0
  134. package/dist/services/query-builder/sql/spec/predicate.spec.js +78 -0
  135. package/dist/services/query-builder/sql/spec/regex.spec.d.ts +1 -0
  136. package/dist/services/query-builder/sql/spec/regex.spec.js +62 -0
  137. package/dist/services/revalidate/revalidate-model.d.ts +9 -3
  138. package/dist/services/revalidate/revalidate-model.js +12 -3
  139. package/oclif.manifest.json +354 -104
  140. package/package.json +11 -2
  141. package/dist/commands/build-parameter-query.d.ts +0 -14
  142. package/dist/commands/build-parameter-query.js +0 -25
  143. package/dist/commands/build-prewarm-query.d.ts +0 -16
  144. package/dist/commands/build-prewarm-query.js +0 -37
  145. package/dist/helpers/build-query-helper.d.ts +0 -32
  146. package/dist/helpers/build-query-helper.js +0 -223
  147. package/dist/helpers/get-parsed-config.d.ts +0 -15
  148. package/dist/helpers/get-parsed-config.js +0 -62
  149. package/dist/services/onboarding/dashboard/parameter-query-builder.d.ts +0 -8
  150. package/dist/services/onboarding/dashboard/parameter-query-builder.js +0 -63
@@ -0,0 +1,88 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { parseRuleSets } from "../rule-parser.js";
4
+ import { keepRelevantRules } from "../rule-relevance.js";
5
+ function blacklist(...rules) {
6
+ return parseRuleSets({ blacklist: rules }).blacklist;
7
+ }
8
+ function blacklistV2(...rules) {
9
+ return parseRuleSets({ blacklist: rules, ruleSetConfig: { version: 2 } })
10
+ .blacklist;
11
+ }
12
+ describe("keepRelevantRules content types", () => {
13
+ it("should drop a rule that only applies to other content types", () => {
14
+ const result = keepRelevantRules(blacklist({ contentType: ["image", "script"], pathname: "/media" }));
15
+ expect(result.rules).to.deep.equal([]);
16
+ expect(result.dropped[0].reason).to.contain("image, script");
17
+ });
18
+ it("should keep a rule that also applies to documents", () => {
19
+ const result = keepRelevantRules(blacklist({ contentType: ["document"], pathname: "/checkout" }));
20
+ expect(result.rules).to.have.lengthOf(1);
21
+ });
22
+ it("should keep a rule without a content type", () => {
23
+ const result = keepRelevantRules(blacklist({ pathname: "/checkout" }));
24
+ expect(result.rules).to.have.lengthOf(1);
25
+ });
26
+ });
27
+ describe("keepRelevantRules criteria outside the url", () => {
28
+ it("should drop a rule that matches on a cookie only", () => {
29
+ const result = keepRelevantRules(blacklist({ cookie: /loggedIn/ }));
30
+ expect(result.rules).to.deep.equal([]);
31
+ expect(result.dropped[0].reason).to.contain("cookie");
32
+ });
33
+ it("should keep a rule with url conditions and report it as approximated", () => {
34
+ const result = keepRelevantRules(blacklist({ cookie: /loggedIn/, pathname: "/account" }));
35
+ expect(result.rules).to.have.lengthOf(1);
36
+ expect(result.approximated).to.have.lengthOf(1);
37
+ expect(result.approximated[0].reason).to.contain("wider than the rule");
38
+ });
39
+ it("should not report a plain rule as approximated", () => {
40
+ const result = keepRelevantRules(blacklist({ pathname: "/account" }));
41
+ expect(result.approximated).to.deep.equal([]);
42
+ });
43
+ });
44
+ describe("keepRelevantRules hosts", () => {
45
+ const hosts = ["www.shop.de", "m.shop.de"];
46
+ it("should drop a rule for a host the query does not cover", () => {
47
+ const result = keepRelevantRules(blacklist({ host: ["cdn.tracking.com"] }), { hosts });
48
+ expect(result.rules).to.deep.equal([]);
49
+ expect(result.dropped[0].reason).to.contain("none of the queried origins");
50
+ });
51
+ it("should keep a rule for a covered host", () => {
52
+ const result = keepRelevantRules(blacklist({ host: ["m.shop.de"] }), {
53
+ hosts,
54
+ });
55
+ expect(result.rules).to.have.lengthOf(1);
56
+ });
57
+ it("should keep a rule whose host pattern matches", () => {
58
+ const result = keepRelevantRules(blacklist({ host: [/shop\.de$/i] }), {
59
+ hosts,
60
+ });
61
+ expect(result.rules).to.have.lengthOf(1);
62
+ });
63
+ it("should treat a host string as a prefix under version 1", () => {
64
+ const result = keepRelevantRules(blacklist({ host: ["www.shop"] }), {
65
+ hosts,
66
+ });
67
+ expect(result.rules).to.have.lengthOf(1);
68
+ });
69
+ it("should treat a host string as exact under version 2", () => {
70
+ const result = keepRelevantRules(blacklistV2({ host: ["www.shop"] }), {
71
+ hosts,
72
+ });
73
+ expect(result.rules).to.deep.equal([]);
74
+ });
75
+ it("should not be confused by a global pattern matched twice", () => {
76
+ const rules = blacklist({ host: [/shop\.de/g] }, { host: [/shop\.de/g] });
77
+ const result = keepRelevantRules(rules, { hosts });
78
+ expect(result.rules).to.have.lengthOf(2);
79
+ });
80
+ it("should keep a rule without a host condition", () => {
81
+ const result = keepRelevantRules(blacklist({ pathname: "/a" }), { hosts });
82
+ expect(result.rules).to.have.lengthOf(1);
83
+ });
84
+ it("should skip the check when no hosts are given", () => {
85
+ const result = keepRelevantRules(blacklist({ host: ["cdn.other.com"] }));
86
+ expect(result.rules).to.have.lengthOf(1);
87
+ });
88
+ });
@@ -0,0 +1,110 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { column, formatPredicate, isFalse, isTrue, leaf, } from "../../sql/index.js";
4
+ import { parseRuleSets } from "../rule-parser.js";
5
+ import { translateRules, urlSubjects } from "../rule-to-sql.js";
6
+ const subjects = urlSubjects(column("url"));
7
+ function enabledSites(rules, version) {
8
+ return parseRuleSets({
9
+ enabledSites: rules,
10
+ ...(version ? { ruleSetConfig: { version } } : {}),
11
+ }).enabledSites;
12
+ }
13
+ function sql(rules, version) {
14
+ return formatPredicate(translateRules(enabledSites(rules, version), subjects).predicate);
15
+ }
16
+ describe("urlSubjects", () => {
17
+ it("should extract the host", () => {
18
+ expect(subjects.host).to.equal("url_extract_host(url)");
19
+ });
20
+ it("should read an empty path as the root, like the browser does", () => {
21
+ expect(subjects.path).to.equal("coalesce(nullif(url_extract_path(url), ''), '/')");
22
+ });
23
+ it("should append the query string for the version 1 pathname subject", () => {
24
+ expect(subjects.pathWithSearch).to.equal(`(${subjects.path} || coalesce(nullif(('?' || url_extract_query(url)), '?'), ''))`);
25
+ });
26
+ it("should strip fragment and scheme from the url subject", () => {
27
+ expect(subjects.url).to.equal(String.raw `regexp_replace(regexp_replace(url, '#.*', ''), '^(\w+:)?//', '')`);
28
+ });
29
+ });
30
+ describe("translateRules string conditions", () => {
31
+ it("should match a pathname as a prefix under version 1", () => {
32
+ expect(sql([{ pathname: "/shop" }])).to.equal(`lower(${subjects.pathWithSearch}) like '/shop%'`);
33
+ });
34
+ it("should match a pathname exactly under version 2", () => {
35
+ expect(sql([{ pathname: "/shop" }], 2)).to.equal(`lower(${subjects.path}) = '/shop'`);
36
+ });
37
+ it("should ignore the case of a host condition", () => {
38
+ expect(sql([{ host: "WWW.Shop.de" }], 2)).to.equal(`lower(${subjects.host}) = 'www.shop.de'`);
39
+ });
40
+ it("should match a url condition against the stripped url", () => {
41
+ // `stripForRules` takes the scheme off, so the config writes the URL
42
+ // without one — and so does the comparison.
43
+ expect(sql([{ url: "www.shop.de/" }], 2)).to.equal(`lower(${subjects.url}) = 'www.shop.de/'`);
44
+ });
45
+ });
46
+ describe("translateRules regular expressions", () => {
47
+ it("should move the i flag into the pattern", () => {
48
+ expect(sql([{ pathname: [/^\/p\//i] }], 2)).to.equal(`regexp_like(${subjects.path}, '(?i)^/p/')`);
49
+ });
50
+ it("should match a version 1 pathname pattern against path and query", () => {
51
+ expect(sql([{ pathname: [/\/$/i] }])).to.equal(`regexp_like(${subjects.pathWithSearch}, '(?i)/$')`);
52
+ });
53
+ it("should report a warning with the rule it came from", () => {
54
+ const { warnings } = translateRules(enabledSites([{ pathname: [/(?<=\/de)\/p\//i] }]), subjects);
55
+ expect(warnings).to.have.lengthOf(1);
56
+ expect(warnings[0]).to.contain("enabledSites[0]");
57
+ expect(warnings[0]).to.contain("lookbehind");
58
+ });
59
+ });
60
+ describe("translateRules structure", () => {
61
+ it("should or the entries of a condition", () => {
62
+ expect(sql([{ pathname: ["/a", "/b"] }], 2)).to.equal([
63
+ `lower(${subjects.path}) = '/a'`,
64
+ `or lower(${subjects.path}) = '/b'`,
65
+ ].join("\n"));
66
+ });
67
+ it("should and the conditions of a rule", () => {
68
+ expect(sql([{ host: "www.shop.de", pathname: "/a" }], 2)).to.equal([
69
+ `lower(${subjects.host}) = 'www.shop.de'`,
70
+ `and lower(${subjects.path}) = '/a'`,
71
+ ].join("\n"));
72
+ });
73
+ it("should or the rules of a set, keeping each rule's conditions together", () => {
74
+ expect(sql([
75
+ { host: "www.shop.de", pathname: "/de/" },
76
+ { host: "www.shop.fr", pathname: "/fr/" },
77
+ ], 2)).to.equal([
78
+ "(",
79
+ ` lower(${subjects.host}) = 'www.shop.de'`,
80
+ ` and lower(${subjects.path}) = '/de/'`,
81
+ ")",
82
+ "or (",
83
+ ` lower(${subjects.host}) = 'www.shop.fr'`,
84
+ ` and lower(${subjects.path}) = '/fr/'`,
85
+ ")",
86
+ ].join("\n"));
87
+ });
88
+ it("should match nothing for a rule without conditions", () => {
89
+ // The runtime's AndRule returns false when it holds no condition.
90
+ expect(isFalse(translateRules(enabledSites([{}]), subjects).predicate)).to.equal(true);
91
+ });
92
+ it("should match nothing for a rule whose condition is empty", () => {
93
+ // `host: hasWebPSupport ? [] : [...]` is a real config. Reading the empty
94
+ // list as "any host" would make a blacklist exclude everything.
95
+ expect(isFalse(translateRules(enabledSites([{ host: [] }]), subjects).predicate)).to.equal(true);
96
+ });
97
+ it("should keep a rule whose other condition is empty out of the way", () => {
98
+ const { predicate } = translateRules(enabledSites([{ host: [], pathname: [/^\/p\//i] }]), subjects);
99
+ expect(isFalse(predicate)).to.equal(true);
100
+ });
101
+ it("should be true for an empty rule set by default", () => {
102
+ expect(isTrue(translateRules([], subjects).predicate)).to.equal(true);
103
+ });
104
+ it("should use the given meaning of an empty rule set", () => {
105
+ const { predicate } = translateRules([], subjects, {
106
+ whenEmpty: leaf("a"),
107
+ });
108
+ expect(formatPredicate(predicate)).to.equal("a");
109
+ });
110
+ });
@@ -0,0 +1,55 @@
1
+ import { PathnameRule, testCondition } from "@speedkit/rules";
2
+ import { expect } from "chai";
3
+ import { before, describe, it } from "mocha";
4
+ import { matchesExactly } from "../rule-model.js";
5
+ /**
6
+ * The query builder mirrors decisions that `@speedkit/rules` makes in the
7
+ * browser. It cannot call into the package to build SQL, so these tests pin
8
+ * the mirrored behaviour against the package itself — if the runtime ever
9
+ * changes how a condition or a subject is read, they fail here rather than in
10
+ * a query nobody re-reads.
11
+ */
12
+ const VERSIONS = [1, 2, 3];
13
+ describe("string conditions follow the runtime", () => {
14
+ for (const version of VERSIONS) {
15
+ it(`should match exactly only where the runtime does, version ${version}`, () => {
16
+ // A subject the condition is a prefix of, but not equal to.
17
+ const runtimeMatchesPrefix = testCondition("/shoes/nike", "/shoes", version);
18
+ const runtimeMatchesExact = testCondition("/shoes", "/shoes", version);
19
+ expect(runtimeMatchesExact).to.equal(true);
20
+ expect(runtimeMatchesPrefix).to.equal(!matchesExactly(version));
21
+ });
22
+ }
23
+ it("should ignore case on both sides", () => {
24
+ expect(testCondition("/Shoes", "/shOES", 1)).to.equal(true);
25
+ expect(testCondition("/Shoes", "/shOES", 2)).to.equal(true);
26
+ });
27
+ });
28
+ describe("the pathname subject follows the runtime", () => {
29
+ // The package resolves a request URL against `location`, which only exists
30
+ // in a browser. Anything that evaluates a rule outside one has to provide it.
31
+ before(() => {
32
+ const global = globalThis;
33
+ global.location ??= new URL("https://www.shop.de/");
34
+ });
35
+ function matchesPathname(pattern, ruleSetVersion) {
36
+ return new PathnameRule(pattern).matches(new Request("https://www.shop.de/shoes/?page=2"), { ruleSetVersion });
37
+ }
38
+ it("should test a version 1 pattern against path and query", () => {
39
+ // The subject ends in `2`, not in the slash, so the anchored pattern
40
+ // misses a URL a reader would expect it to cover.
41
+ expect(matchesPathname(/\/$/, 1)).to.equal(false);
42
+ });
43
+ it("should test a version 2 pattern against the path alone", () => {
44
+ expect(matchesPathname(/\/$/, 2)).to.equal(true);
45
+ });
46
+ it("should fall back to the version 1 subject for any other version", () => {
47
+ expect(matchesPathname(/\/$/, 3)).to.equal(false);
48
+ expect(matchesExactly(3)).to.equal(false);
49
+ });
50
+ it("should match a pattern that survives both versions", () => {
51
+ const bothVersions = /^[^?]*\/(\?|$)/;
52
+ expect(matchesPathname(bothVersions, 1)).to.equal(true);
53
+ expect(matchesPathname(bothVersions, 2)).to.equal(true);
54
+ });
55
+ });
@@ -0,0 +1,85 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { column, formatPredicate } from "../../sql/index.js";
4
+ import { parseRuleSets } from "../rule-parser.js";
5
+ import { urlSubjects } from "../rule-to-sql.js";
6
+ import { notStrippedParameter, prepareStripParameters, strippedUrlExpression, } from "../strip-parameters.js";
7
+ const subjects = urlSubjects(column("url"));
8
+ function prepare(stripQueryParams, version = 2) {
9
+ return prepareStripParameters(parseRuleSets({ ruleSetConfig: { version }, stripQueryParams })
10
+ .stripQueryParams, subjects);
11
+ }
12
+ function strippedUrl(stripQueryParams, version = 2) {
13
+ return strippedUrlExpression(prepare(stripQueryParams, version), "url");
14
+ }
15
+ function keptParameter(stripQueryParams, version = 2) {
16
+ return formatPredicate(notStrippedParameter(prepare(stripQueryParams, version), "key"));
17
+ }
18
+ describe("strippedUrlExpression", () => {
19
+ it("should always remove the fragment", () => {
20
+ expect(strippedUrl([])).to.equal("regexp_replace(url, '#.*')");
21
+ });
22
+ it("should match a name exactly under version 2", () => {
23
+ expect(strippedUrl(["gclid"])).to.contain("(?i)[?&](gclid)(=[^&]*)?(?=&|$)");
24
+ });
25
+ it("should match a name as a prefix under version 1", () => {
26
+ expect(strippedUrl(["utm"], 1)).to.contain("(?i)[?&](utm)[^&]*");
27
+ });
28
+ it("should escape a name that reads as a pattern", () => {
29
+ expect(strippedUrl(["a.b"])).to.contain(String.raw `(a\.b)`);
30
+ });
31
+ it("should use the prefix form for a pattern anchored at the start", () => {
32
+ expect(strippedUrl([/^utm[_-]/i])).to.contain("(?i)[?&](utm[_-])[^&]*");
33
+ });
34
+ it("should use the suffix form for a pattern anchored at the end", () => {
35
+ expect(strippedUrl([/id$/i])).to.contain("(?i)[?&][^&]*(id)(=[^&]*)?(?=&|$)");
36
+ });
37
+ it("should keep case-sensitive patterns in their own expression", () => {
38
+ const expression = strippedUrl([/^utm/, /^UTM/i]);
39
+ expect(expression).to.contain("'[?&](utm)[^&]*'");
40
+ expect(expression).to.contain("'(?i)[?&](UTM)[^&]*'");
41
+ });
42
+ it("should merge rules that strip everywhere into one expression", () => {
43
+ const merged = strippedUrl([{ params: ["a"] }, { params: ["b"] }]);
44
+ expect(merged.match(/regexp_replace/g)).to.have.lengthOf(2);
45
+ expect(merged).to.contain("(a|b)");
46
+ });
47
+ it("should apply a scoped rule only on the pages it names", () => {
48
+ const expression = strippedUrl([
49
+ { enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
50
+ ]);
51
+ expect(expression).to.contain("if(regexp_like(");
52
+ expect(expression).to.contain("(?i)^/p/");
53
+ });
54
+ it("should leave out a rule scoped to something that is never a document", () => {
55
+ const prepared = prepare([
56
+ { params: ["size"], rules: [{ contentType: ["image"] }] },
57
+ ]);
58
+ expect(prepared.groups).to.deep.equal([]);
59
+ expect(prepared.dropped[0].source).to.equal("stripQueryParams[0]");
60
+ });
61
+ });
62
+ describe("notStrippedParameter", () => {
63
+ it("should keep every parameter when nothing is stripped", () => {
64
+ expect(keptParameter([])).to.equal("true");
65
+ });
66
+ it("should reject a stripped name exactly under version 2", () => {
67
+ expect(keptParameter(["gclid"])).to.equal("not (lower(key) = 'gclid')");
68
+ });
69
+ it("should reject a stripped name as a prefix under version 1", () => {
70
+ expect(keptParameter(["utm"], 1)).to.equal("not (lower(key) like 'utm%')");
71
+ });
72
+ it("should reject every stripped name", () => {
73
+ expect(keptParameter(["a", "b"])).to.equal("not (lower(key) = 'a')\nand not (lower(key) = 'b')");
74
+ });
75
+ it("should reject a scoped name only on its own pages", () => {
76
+ expect(keptParameter([
77
+ { enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
78
+ ])).to.equal([
79
+ "not (",
80
+ " regexp_like(coalesce(nullif(url_extract_path(url), ''), '/'), '(?i)^/p/')",
81
+ " and lower(key) = 'size'",
82
+ ")",
83
+ ].join("\n"));
84
+ });
85
+ });
@@ -0,0 +1,39 @@
1
+ import { Predicate, SqlExpression } from "../sql/index.js";
2
+ import { ConditionPattern, StripParameterRule } from "./rule-model.js";
3
+ import { RelevanceContext, RuleNote } from "./rule-relevance.js";
4
+ import { RuleSubjectExpressions } from "./rule-to-sql.js";
5
+ /** Parameters that are stripped on the pages a scope predicate selects. */
6
+ export interface StripParameterGroup {
7
+ readonly params: readonly ConditionPattern[];
8
+ readonly ruleSetVersion: number;
9
+ /** `TRUE` when the parameters are stripped everywhere. */
10
+ readonly scope: Predicate;
11
+ readonly sources: string[];
12
+ }
13
+ export interface PreparedStripParameters {
14
+ /** Strip rules that cannot apply to a page impression. */
15
+ readonly dropped: RuleNote[];
16
+ readonly groups: StripParameterGroup[];
17
+ readonly warnings: string[];
18
+ }
19
+ /**
20
+ * Turns the `stripQueryParams` rules into the groups the query needs.
21
+ *
22
+ * Rules that strip everywhere are merged into a single group so their
23
+ * parameters share one set of expressions, which is what the emitted query
24
+ * looked like before scoping was understood. A rule limited to certain pages
25
+ * or resources keeps its own group and carries the predicate that selects
26
+ * them; one limited to resources that are never documents is dropped.
27
+ */
28
+ export declare function prepareStripParameters(rules: readonly StripParameterRule[], subjects: RuleSubjectExpressions, context?: RelevanceContext): PreparedStripParameters;
29
+ /**
30
+ * The URL with every stripped parameter and the fragment removed — the cache
31
+ * key Speed Kit would use. Built as nested `regexp_replace` calls, one per
32
+ * kind of parameter pattern.
33
+ */
34
+ export declare function strippedUrlExpression(prepared: PreparedStripParameters, url: SqlExpression, warnings?: string[]): SqlExpression;
35
+ /**
36
+ * Holds for a parameter name that Speed Kit keeps — the filter the parameter
37
+ * analysis applies to every key of a URL.
38
+ */
39
+ export declare function notStrippedParameter(prepared: PreparedStripParameters, key: SqlExpression, warnings?: string[]): Predicate;
@@ -0,0 +1,197 @@
1
+ import { and, call, equalsIgnoreCase, escapeRegexLiteral, formatPredicate, isTrue, literal, not, or, regexpLike, startsWith, toAthenaRegex, TRUE, } from "../sql/index.js";
2
+ import { matchesExactly, } from "./rule-model.js";
3
+ import { keepRelevantRules, } from "./rule-relevance.js";
4
+ import { translateRules } from "./rule-to-sql.js";
5
+ /**
6
+ * Turns the `stripQueryParams` rules into the groups the query needs.
7
+ *
8
+ * Rules that strip everywhere are merged into a single group so their
9
+ * parameters share one set of expressions, which is what the emitted query
10
+ * looked like before scoping was understood. A rule limited to certain pages
11
+ * or resources keeps its own group and carries the predicate that selects
12
+ * them; one limited to resources that are never documents is dropped.
13
+ */
14
+ export function prepareStripParameters(rules, subjects, context = {}) {
15
+ const dropped = [];
16
+ const warnings = [];
17
+ const groups = [];
18
+ // Groups the rules stripping everywhere are merged into, one per version.
19
+ const unscoped = new Map();
20
+ for (const rule of rules) {
21
+ const page = scopeOf(rule.enabledSites, subjects, context, warnings);
22
+ const resource = scopeOf(rule.rules, subjects, context, warnings);
23
+ if (!page || !resource) {
24
+ dropped.push({
25
+ reason: "strips parameters only on pages or resources a page impression can never be",
26
+ source: rule.source,
27
+ });
28
+ continue;
29
+ }
30
+ const scope = and(page, resource);
31
+ const merged = isTrue(scope)
32
+ ? unscoped.get(rule.ruleSetVersion)
33
+ : undefined;
34
+ if (merged) {
35
+ merged.params.push(...rule.params);
36
+ merged.sources.push(rule.source);
37
+ continue;
38
+ }
39
+ // The group holds the very arrays the map keeps, so a later rule that
40
+ // strips everywhere too extends this group instead of adding another.
41
+ const group = {
42
+ params: [...rule.params],
43
+ ruleSetVersion: rule.ruleSetVersion,
44
+ scope,
45
+ sources: [rule.source],
46
+ };
47
+ groups.push(group);
48
+ if (isTrue(scope)) {
49
+ unscoped.set(rule.ruleSetVersion, group);
50
+ }
51
+ }
52
+ return { dropped, groups, warnings };
53
+ }
54
+ /**
55
+ * The URL with every stripped parameter and the fragment removed — the cache
56
+ * key Speed Kit would use. Built as nested `regexp_replace` calls, one per
57
+ * kind of parameter pattern.
58
+ */
59
+ export function strippedUrlExpression(prepared, url, warnings = []) {
60
+ let expression = call("regexp_replace", url, literal("#.*"));
61
+ for (const group of prepared.groups) {
62
+ const replaced = replaceParameters(group, expression, warnings);
63
+ expression = isTrue(group.scope)
64
+ ? replaced
65
+ : // Commas separate the arguments, and `and` binds tighter, so the
66
+ // predicate needs no parentheses of its own here.
67
+ call("if", formatPredicate(group.scope), replaced, expression);
68
+ }
69
+ return expression;
70
+ }
71
+ /**
72
+ * Holds for a parameter name that Speed Kit keeps — the filter the parameter
73
+ * analysis applies to every key of a URL.
74
+ */
75
+ export function notStrippedParameter(prepared, key, warnings = []) {
76
+ return and(...prepared.groups.map((group) => {
77
+ const matches = group.params.map((pattern) => matchesParameter(pattern, key, group.ruleSetVersion, warnings));
78
+ return isTrue(group.scope)
79
+ ? and(...matches.map((match) => not(match)))
80
+ : not(and(group.scope, or(...matches)));
81
+ }));
82
+ }
83
+ /**
84
+ * The predicate selecting the pages or resources a strip rule is scoped to,
85
+ * or `undefined` when the scope was given but can never hold for a document.
86
+ */
87
+ function scopeOf(rules, subjects, context, warnings) {
88
+ if (!rules || rules.length === 0) {
89
+ return TRUE;
90
+ }
91
+ const relevant = keepRelevantRules(rules, context);
92
+ if (relevant.rules.length === 0) {
93
+ return undefined;
94
+ }
95
+ const { predicate, warnings: ruleWarnings } = translateRules(relevant.rules, subjects);
96
+ warnings.push(...ruleWarnings);
97
+ return predicate;
98
+ }
99
+ function matchesParameter(pattern, key, ruleSetVersion, warnings) {
100
+ if (pattern.kind === "regexp") {
101
+ const athenaRegex = toAthenaRegex(pattern.value);
102
+ warnings.push(...athenaRegex.warnings.map((warning) => `stripQueryParams: ${warning}`));
103
+ return regexpLike(key, athenaRegex.pattern);
104
+ }
105
+ // A parameter name is a string condition: case-insensitive, and a prefix
106
+ // until the config moves to rule-set version 2.
107
+ return matchesExactly(ruleSetVersion)
108
+ ? equalsIgnoreCase(key, pattern.value)
109
+ : startsWith(key, pattern.value);
110
+ }
111
+ /**
112
+ * Nests one `regexp_replace` per pattern kind around the given expression.
113
+ *
114
+ * A parameter is removed together with its value, and where in the name the
115
+ * pattern has to match depends on how it is anchored: `/^utm/` covers every
116
+ * name starting with `utm`, `/id$/` every name ending in it, a plain name
117
+ * matches the whole key. Case-insensitive patterns need their own expression
118
+ * because the flag applies to the whole alternation.
119
+ */
120
+ function replaceParameters(group, expression, warnings) {
121
+ const buckets = bucketParameters(group, warnings);
122
+ const patterns = [
123
+ prefixPattern(buckets.prefix, false),
124
+ prefixPattern(buckets.prefixIgnoreCase, true),
125
+ suffixPattern(buckets.suffix, false),
126
+ suffixPattern(buckets.suffixIgnoreCase, true),
127
+ fullPattern(buckets.full, false),
128
+ fullPattern(buckets.fullIgnoreCase, true),
129
+ ];
130
+ let replaced = expression;
131
+ for (const pattern of patterns) {
132
+ if (pattern) {
133
+ replaced = call("regexp_replace", replaced, literal(pattern), literal(""));
134
+ }
135
+ }
136
+ return replaced;
137
+ }
138
+ function bucketParameters(group, warnings) {
139
+ const buckets = {
140
+ full: [],
141
+ fullIgnoreCase: [],
142
+ prefix: [],
143
+ prefixIgnoreCase: [],
144
+ suffix: [],
145
+ suffixIgnoreCase: [],
146
+ };
147
+ for (const pattern of group.params) {
148
+ if (pattern.kind === "string") {
149
+ // A string is matched case-insensitively, so it belongs to the
150
+ // ignore-case bucket, and version 1 treats it as a prefix.
151
+ const name = escapeRegexLiteral(pattern.value);
152
+ if (matchesExactly(group.ruleSetVersion)) {
153
+ buckets.fullIgnoreCase.push(name);
154
+ }
155
+ else {
156
+ buckets.prefixIgnoreCase.push(name);
157
+ }
158
+ continue;
159
+ }
160
+ const { pattern: athenaPattern, warnings: patternWarnings } = toAthenaRegex(stripFlags(pattern.value));
161
+ warnings.push(...patternWarnings.map((warning) => `stripQueryParams: ${warning}`));
162
+ const ignoreCase = pattern.value.ignoreCase;
163
+ const anchoredStart = athenaPattern.startsWith("^");
164
+ const anchoredEnd = /(?<!\\)\$$/.test(athenaPattern);
165
+ const inner = athenaPattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : undefined);
166
+ if (anchoredStart && !anchoredEnd) {
167
+ (ignoreCase ? buckets.prefixIgnoreCase : buckets.prefix).push(inner);
168
+ continue;
169
+ }
170
+ if (anchoredEnd && !anchoredStart) {
171
+ (ignoreCase ? buckets.suffixIgnoreCase : buckets.suffix).push(inner);
172
+ continue;
173
+ }
174
+ // Anchored on both sides, or on neither: both mean the whole name.
175
+ (ignoreCase ? buckets.fullIgnoreCase : buckets.full).push(inner);
176
+ }
177
+ return buckets;
178
+ }
179
+ /** The pattern without its flags — they are applied to the whole alternation. */
180
+ function stripFlags(regExp) {
181
+ return new RegExp(regExp.source);
182
+ }
183
+ function prefixPattern(names, ignoreCase) {
184
+ return names.length > 0
185
+ ? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})[^&]*`
186
+ : null;
187
+ }
188
+ function suffixPattern(names, ignoreCase) {
189
+ return names.length > 0
190
+ ? `${ignoreCase ? "(?i)" : ""}[?&][^&]*(${names.join("|")})(=[^&]*)?(?=&|$)`
191
+ : null;
192
+ }
193
+ function fullPattern(names, ignoreCase) {
194
+ return names.length > 0
195
+ ? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})(=[^&]*)?(?=&|$)`
196
+ : null;
197
+ }
@@ -0,0 +1,73 @@
1
+ import { Predicate } from "./predicate.js";
2
+ /**
3
+ * A scalar SQL expression. Always build one through the functions in this
4
+ * module: they escape their operands, which is what keeps a config pattern
5
+ * containing a quote from producing broken SQL.
6
+ */
7
+ export type SqlExpression = string;
8
+ /**
9
+ * A string, number or boolean as a SQL literal. Quotes inside a string are
10
+ * doubled, so any config-provided value is safe to embed.
11
+ */
12
+ export declare function literal(value: boolean | number | string): SqlExpression;
13
+ /**
14
+ * A column reference, optionally qualified (`cdnclient.bot`). Rejects anything
15
+ * that is not an identifier, so a column name can never smuggle in SQL.
16
+ */
17
+ export declare function column(name: string): SqlExpression;
18
+ /** A function call with the given arguments, e.g. `url_extract_path(url)`. */
19
+ export declare function call(name: string, ...args: SqlExpression[]): SqlExpression;
20
+ /** `lower(expression)`, used to match the case-insensitive string conditions. */
21
+ export declare function lower(expression: SqlExpression): SqlExpression;
22
+ /** Concatenation of all expressions, parenthesised so it can be nested. */
23
+ export declare function concat(...expressions: SqlExpression[]): SqlExpression;
24
+ /**
25
+ * Wraps a hand-written expression. The caller is responsible for escaping its
26
+ * operands and for parenthesising anything that binds loosely.
27
+ */
28
+ export declare function raw(sql: string): SqlExpression;
29
+ /** `left = right`. */
30
+ export declare function equals(left: SqlExpression, right: SqlExpression): Predicate;
31
+ /** `left <> right`. */
32
+ export declare function notEquals(left: SqlExpression, right: SqlExpression): Predicate;
33
+ /**
34
+ * A placeholder for a value passed to Athena alongside the query.
35
+ *
36
+ * Placeholders are bound by position, in the order they appear in the printed
37
+ * SQL — so a predicate must not contain two identical comparisons over
38
+ * placeholders: {@link and} and {@link or} drop a duplicate operand, which
39
+ * would leave one value too many.
40
+ */
41
+ export declare function parameter(): SqlExpression;
42
+ /** `left >= right`. */
43
+ export declare function greaterOrEqual(left: SqlExpression, right: SqlExpression): Predicate;
44
+ /** `left > right`. */
45
+ export declare function greaterThan(left: SqlExpression, right: SqlExpression): Predicate;
46
+ /**
47
+ * `subject = 'value'`, ignoring case on both sides — the semantics of a string
48
+ * condition in a Speed Kit rule set of version 2.
49
+ */
50
+ export declare function equalsIgnoreCase(subject: SqlExpression, value: string): Predicate;
51
+ /**
52
+ * `subject like 'value%'` — a prefix match, which is what a string condition
53
+ * means in a rule set of version 1. Case is ignored by default, matching the
54
+ * runtime, which lower-cases both sides.
55
+ */
56
+ export declare function startsWith(subject: SqlExpression, value: string, options?: {
57
+ ignoreCase?: boolean;
58
+ }): Predicate;
59
+ /** `subject between low and high`. */
60
+ export declare function between(subject: SqlExpression, low: SqlExpression, high: SqlExpression): Predicate;
61
+ /** `expression is null`. */
62
+ export declare function isNull(expression: SqlExpression): Predicate;
63
+ /** `subject not in ('a', 'b')`. */
64
+ export declare function notInValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
65
+ /** `subject in ('a', 'b')`. */
66
+ export declare function inValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
67
+ /**
68
+ * `regexp_like(subject, 'pattern')`. The pattern is expected to already be in
69
+ * Athena's dialect — see {@link toAthenaRegex}.
70
+ */
71
+ export declare function regexpLike(subject: SqlExpression, pattern: string): Predicate;
72
+ /** A boolean column used as a predicate on its own, e.g. `suspicious`. */
73
+ export declare function booleanColumn(name: string): Predicate;