@speedkit/cli 4.24.1 → 4.25.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ # [4.25.0](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.24.2...v4.25.0) (2026-09-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **revalidate:** accept the 202 the API answers for a submitted job ([a3ba85e](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/a3ba85e454b51740bfa0b983e41f828b8ac86598))
7
+
8
+
9
+ ### Features
10
+
11
+ * **revalidate:** add sk revalidate for post-go-live purges ([5a08843](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/5a0884344bc0dfd007b8eebdb2e70cd90ae068aa))
12
+ * **revalidate:** confirm a job above 100 assets ([f64158f](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/f64158f56d1ba31b21d44051ecf3f87078aaa8e1))
13
+
14
+
15
+ ### Performance Improvements
16
+
17
+ * **revalidate:** keep the dry-run prefix filter on the index ([97f057c](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/97f057cfaae8c59b2d888a9fdfee04648c20d046))
18
+
19
+ ## [4.24.2](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.24.1...v4.24.2) (2026-09-03)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **onboarding:** match the document handler's own dependency ranges ([414bd14](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/414bd14584778c0e7b1a3f556c311cd997380df7))
25
+ * **onboarding:** resolve document handler modules at runtime ([44aa76e](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/44aa76ef1b7bf846e47b0be7f6e11b850d7dd0e6))
26
+
1
27
  ## [4.24.1](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.24.0...v4.24.1) (2026-09-02)
2
28
 
3
29
 
package/README.md CHANGED
@@ -21,7 +21,7 @@ $ npm install -g @speedkit/cli
21
21
  $ sk COMMAND
22
22
  running command...
23
23
  $ sk (--version)
24
- @speedkit/cli/4.24.1 linux-x64 node-v22.23.2
24
+ @speedkit/cli/4.25.0 linux-x64 node-v22.23.2
25
25
  $ sk --help [COMMAND]
26
26
  USAGE
27
27
  $ sk COMMAND
@@ -92,6 +92,7 @@ By either...
92
92
  * [`sk param-diff PAGEURL`](#sk-param-diff-pageurl)
93
93
  * [`sk prewarm APP PATH`](#sk-prewarm-app-path)
94
94
  * [`sk pull CUSTOMERPATH`](#sk-pull-customerpath)
95
+ * [`sk revalidate APP`](#sk-revalidate-app)
95
96
  * [`sk wpt-launcher URLS TESTID`](#sk-wpt-launcher-urls-testid)
96
97
 
97
98
  ## `sk auto-prewarm CUSTOMERPATH`
@@ -746,6 +747,49 @@ EXAMPLES
746
747
  $ sk pull customers/decathlon.de -c production
747
748
  ```
748
749
 
750
+ ## `sk revalidate APP`
751
+
752
+ Purge or refresh cached assets by prefix and query. Run it after a config that narrows scope goes live, so returning visitors stop receiving the cached HTML.
753
+
754
+ ```
755
+ USAGE
756
+ $ sk revalidate APP [--pattern <value>...] [--prefix <value>...] [--query <value>] [--type
757
+ REFRESH|INSTANT|DEPLOYMENT|PURGE] [-d] [--wait] [--yes] [-q]
758
+
759
+ ARGUMENTS
760
+ APP The customer app name
761
+
762
+ FLAGS
763
+ -d, --dryRun Report the assets the filter selects and exit. Changes nothing.
764
+ -q, --quiet less output non interactive
765
+ --pattern=<value>... URL pattern to purge, as a regex anchored at the scheme — mirror what the config excludes,
766
+ e.g. "^https://www.example.com/en/.*-c1234$". Repeatable. The command derives the prefix the
767
+ API requires from the pattern's literal head, and MongoDB reads that same literal as index
768
+ bounds.
769
+ --prefix=<value>... URL prefix, matched against the start of the cached URL. Derived from --pattern by default;
770
+ state it only to override that, or to purge a whole path without a pattern.
771
+ --query=<value> Raw MongoDB query on the asset fields, as JSON. An escape hatch for a rule --pattern cannot
772
+ express, such as a condition on another field. It replaces the query --pattern would build.
773
+ --type=<option> [default: PURGE] PURGE drops the entries and does not rebuild them, which is what an
774
+ out-of-scope URL needs.
775
+ <options: REFRESH|INSTANT|DEPLOYMENT|PURGE>
776
+ --wait Poll the job until it finishes.
777
+ --yes Skip the confirmation a job above 100 assets asks for. Required in a non-interactive shell.
778
+
779
+ DESCRIPTION
780
+ Purge or refresh cached assets by prefix and query. Run it after a config that narrows scope goes live, so returning
781
+ visitors stop receiving the cached HTML.
782
+
783
+ EXAMPLES
784
+ $ sk revalidate <app> --pattern <regex> [--pattern <regex>] [--dryRun] [--wait]
785
+
786
+ $ sk revalidate decathlon --pattern '^https://www\\.decathlon\\.de/damen/.*-c(?:1234|5678)(?:/|\\?|$)' --dryRun
787
+
788
+ $ sk revalidate decathlon --pattern '^https://www\\.decathlon\\.de/damen/.*-c1234(?:/|\\?|$)' --wait
789
+
790
+ $ sk revalidate decathlon --prefix https://www.decathlon.de/damen/ --dryRun
791
+ ```
792
+
749
793
  ## `sk wpt-launcher URLS TESTID`
750
794
 
751
795
  Analyze the performance impact that Speed Kit has compared to the original version.
@@ -0,0 +1,21 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Revalidate extends Command {
3
+ static description: string;
4
+ static args: {
5
+ app: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static flags: {
8
+ pattern: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
9
+ prefix: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
10
+ query: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ dryRun: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ static examples: string[];
18
+ run(): Promise<void>;
19
+ /** A malformed query would otherwise reach the backend as an opaque 400. */
20
+ private parseQuery;
21
+ }
@@ -0,0 +1,128 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { CLIParameters, CLIParametersChar, CLIParametersExample, } from "../models/cli-parameters.js";
3
+ import { isNonInteractive } from "../helpers/environment.js";
4
+ import { CONFIRMATION_THRESHOLD, REVALIDATE_TYPES, RevalidateContext, RevalidateFactory, } from "../services/revalidate/index.js";
5
+ const SAMPLE_SIZE = 10;
6
+ export default class Revalidate extends Command {
7
+ static description = "Purge or refresh cached assets by prefix and query. Run it after a config that narrows scope goes live, so returning visitors stop receiving the cached HTML.";
8
+ static args = {
9
+ [CLIParameters.AppName]: Args.string({
10
+ name: CLIParameters.AppName,
11
+ required: true,
12
+ description: "The customer app name",
13
+ }),
14
+ };
15
+ static flags = {
16
+ ["pattern"]: Flags.string({
17
+ multiple: true,
18
+ description: 'URL pattern to purge, as a regex anchored at the scheme — mirror what the config excludes, e.g. "^https://www.example.com/en/.*-c1234$". Repeatable. The command derives the prefix the API requires from the pattern\'s literal head, and MongoDB reads that same literal as index bounds.',
19
+ }),
20
+ ["prefix"]: Flags.string({
21
+ multiple: true,
22
+ description: "URL prefix, matched against the start of the cached URL. Derived from --pattern by default; state it only to override that, or to purge a whole path without a pattern.",
23
+ }),
24
+ ["query"]: Flags.string({
25
+ description: "Raw MongoDB query on the asset fields, as JSON. An escape hatch for a rule --pattern cannot express, such as a condition on another field. It replaces the query --pattern would build.",
26
+ }),
27
+ ["type"]: Flags.string({
28
+ options: [...REVALIDATE_TYPES],
29
+ default: "PURGE",
30
+ description: "PURGE drops the entries and does not rebuild them, which is what an out-of-scope URL needs.",
31
+ }),
32
+ [CLIParameters.DryRun]: Flags.boolean({
33
+ char: CLIParametersChar.DryRun,
34
+ default: false,
35
+ description: "Report the assets the filter selects and exit. Changes nothing.",
36
+ }),
37
+ ["wait"]: Flags.boolean({
38
+ default: false,
39
+ description: "Poll the job until it finishes.",
40
+ }),
41
+ ["yes"]: Flags.boolean({
42
+ default: false,
43
+ description: `Skip the confirmation a job above ${CONFIRMATION_THRESHOLD} assets asks for. Required in a non-interactive shell.`,
44
+ }),
45
+ [CLIParameters.Quiet]: Flags.boolean({
46
+ char: CLIParametersChar.Quiet,
47
+ default: false,
48
+ description: "less output non interactive",
49
+ }),
50
+ };
51
+ static examples = [
52
+ `$ sk revalidate <${CLIParameters.AppName}> --pattern <regex> [--pattern <regex>] [--${CLIParameters.DryRun}] [--wait]`,
53
+ `$ sk revalidate ${CLIParametersExample.AppName} --pattern '^https://www\\\\.decathlon\\\\.de/damen/.*-c(?:1234|5678)(?:/|\\\\?|$)' --${CLIParameters.DryRun}`,
54
+ `$ sk revalidate ${CLIParametersExample.AppName} --pattern '^https://www\\\\.decathlon\\\\.de/damen/.*-c1234(?:/|\\\\?|$)' --wait`,
55
+ `$ sk revalidate ${CLIParametersExample.AppName} --prefix ${CLIParametersExample.PageUrl}damen/ --${CLIParameters.DryRun}`,
56
+ ];
57
+ async run() {
58
+ const { args, flags } = await this.parse(Revalidate);
59
+ const app = args[CLIParameters.AppName];
60
+ const service = new RevalidateFactory(new RevalidateContext(app, flags[CLIParameters.Quiet])).getService();
61
+ const patterns = flags["pattern"] ?? [];
62
+ const prefixes = flags["prefix"] ?? [];
63
+ if (!patterns.length && !prefixes.length) {
64
+ this.error("state what to purge: pass --pattern, or --prefix", {
65
+ exit: 1,
66
+ });
67
+ }
68
+ const filter = service.buildFilter(patterns, prefixes);
69
+ if (flags["query"]) {
70
+ filter.query = this.parseQuery(flags["query"]);
71
+ }
72
+ service.assertFilter(filter);
73
+ this.log(`prefixes: ${filter.prefixes.join(", ")}`);
74
+ const preview = await service.preview(filter, SAMPLE_SIZE);
75
+ this.log(`filter selects ${preview.count} asset(s)`);
76
+ for (const url of preview.sample) {
77
+ this.log(` ${url}`);
78
+ }
79
+ if (preview.count > preview.sample.length) {
80
+ this.log(` … ${preview.count - preview.sample.length} more`);
81
+ }
82
+ if (flags[CLIParameters.DryRun]) {
83
+ this.log("dry run — nothing was purged");
84
+ return;
85
+ }
86
+ if (preview.count === 0) {
87
+ this.log("nothing to do — the filter selects no asset");
88
+ return;
89
+ }
90
+ const type = flags["type"];
91
+ if (preview.count > CONFIRMATION_THRESHOLD && !flags["yes"]) {
92
+ if (isNonInteractive()) {
93
+ this.error(`${preview.count} assets exceed the ${CONFIRMATION_THRESHOLD}-asset threshold, and this shell cannot ask`, {
94
+ exit: 1,
95
+ suggestions: [
96
+ "Check the --dryRun output, then re-run with --yes.",
97
+ "Or narrow --pattern until it takes only what the config excludes.",
98
+ ],
99
+ });
100
+ }
101
+ if (!(await service.confirmLargeJob(preview.count, type))) {
102
+ this.log("aborted — nothing was purged");
103
+ return;
104
+ }
105
+ }
106
+ const statusId = await service.submit(filter, type);
107
+ this.log(`submitted ${type} job ${statusId}`);
108
+ if (!flags["wait"]) {
109
+ return;
110
+ }
111
+ const status = await service.waitFor(statusId);
112
+ this.log(`assets ${status.assetCount ?? 0}, refreshed ${status.refreshedCount ?? 0}, changed ${status.changedCount ?? 0}`);
113
+ if (status.state !== "FINISHED") {
114
+ this.error(`job ${statusId} ended as ${status.state}: ${status.error}`);
115
+ }
116
+ }
117
+ /** A malformed query would otherwise reach the backend as an opaque 400. */
118
+ parseQuery(raw) {
119
+ try {
120
+ return JSON.parse(raw);
121
+ }
122
+ catch (error) {
123
+ this.error(`--query is not valid JSON: ${error.message}`, {
124
+ exit: 1,
125
+ });
126
+ }
127
+ }
128
+ }
@@ -1,7 +1,6 @@
1
1
  import { BundleService } from "./bundle-service.js";
2
2
  export declare class BundleServiceFactory {
3
3
  private customerFolder?;
4
- private additionalModulePaths;
5
- constructor(customerFolder?: string, additionalModulePaths?: string[]);
4
+ constructor(customerFolder?: string);
6
5
  buildService(): BundleService;
7
6
  }
@@ -1,12 +1,10 @@
1
1
  import { BundleService } from "./bundle-service.js";
2
2
  export class BundleServiceFactory {
3
3
  customerFolder;
4
- additionalModulePaths;
5
- constructor(customerFolder, additionalModulePaths = []) {
4
+ constructor(customerFolder) {
6
5
  this.customerFolder = customerFolder;
7
- this.additionalModulePaths = additionalModulePaths;
8
6
  }
9
7
  buildService() {
10
- return new BundleService(this.customerFolder, this.additionalModulePaths);
8
+ return new BundleService(this.customerFolder);
11
9
  }
12
10
  }
@@ -8,15 +8,11 @@ export type BundleBasePaths = Record<string, string>;
8
8
  export type BundleResolveFallback = (specifier: string) => Promise<string | null>;
9
9
  export declare class BundleService {
10
10
  private moduleDiscoveryDir;
11
- private additionalModulePaths;
12
11
  /**
13
12
  * @param moduleDiscoveryDir the directory bundled code resolves its own imports from
14
- * @param additionalModulePaths extra `node_modules` directories to search, like `NODE_PATH`.
15
- * `sk` installs the packages a customer config declares under `dependencies` into its cache
16
- * rather than into the customer folder, so esbuild would otherwise never find them.
17
13
  */
18
- constructor(moduleDiscoveryDir: string, additionalModulePaths?: string[]);
19
- bundle({ entryPoints, basePaths, define, target, minify, logLevel, alias, platform, resolveFallback, }: {
14
+ constructor(moduleDiscoveryDir: string);
15
+ bundle({ entryPoints, basePaths, define, target, minify, logLevel, alias, platform, resolveFallback, packages, }: {
20
16
  entryPoints: BundleEntryPoints;
21
17
  basePaths?: BundleBasePaths;
22
18
  target?: string | string[];
@@ -26,25 +22,8 @@ export declare class BundleService {
26
22
  logLevel?: LogLevel;
27
23
  platform?: "browser" | "node" | "neutral";
28
24
  resolveFallback?: BundleResolveFallback;
25
+ packages?: "bundle" | "external";
29
26
  }): Promise<string>;
30
- /**
31
- * Turns each alias target into an absolute path, resolved from the same directory the bundled
32
- * code resolves its own imports from.
33
- *
34
- * esbuild resolves an alias target from its working directory, not from the entry point's
35
- * resolveDir. `{ "node-fetch": "node-fetch-native" }` therefore only resolved while `sk` ran
36
- * inside the customer checkout, and failed from anywhere else — even though the bundled code
37
- * could import the very same module by name.
38
- *
39
- * A target that is not installed stays a bare name, so esbuild reports it as before.
40
- */
41
- private resolveAliases;
42
- /**
43
- * Looks the target up the way the bundled code would: the customer folder first, then the
44
- * additional module paths. Returns `null` when it is installed nowhere, so the bare name reaches
45
- * esbuild and is reported as before.
46
- */
47
- private resolveAliasTarget;
48
27
  private entryPointsPlugin;
49
28
  baqendPlugin(basePaths: BundleBasePaths): {
50
29
  name: string;
@@ -4,24 +4,18 @@ import { URL } from "node:url";
4
4
  import { BundleResolveError } from "./error/bundle-resolve-error.js";
5
5
  import { BundleFetchError } from "./error/bundle-fetch-error.js";
6
6
  import { resolve } from "node:path";
7
- import { createRequire } from "node:module";
8
7
  const FALLBACK_NAMESPACE = "bundle-resolve-fallback";
9
8
  const RELATIVE_SPECIFIER = /^\.{1,2}\//;
10
9
  const importCache = {};
11
10
  export class BundleService {
12
11
  moduleDiscoveryDir;
13
- additionalModulePaths;
14
12
  /**
15
13
  * @param moduleDiscoveryDir the directory bundled code resolves its own imports from
16
- * @param additionalModulePaths extra `node_modules` directories to search, like `NODE_PATH`.
17
- * `sk` installs the packages a customer config declares under `dependencies` into its cache
18
- * rather than into the customer folder, so esbuild would otherwise never find them.
19
14
  */
20
- constructor(moduleDiscoveryDir, additionalModulePaths = []) {
15
+ constructor(moduleDiscoveryDir) {
21
16
  this.moduleDiscoveryDir = moduleDiscoveryDir;
22
- this.additionalModulePaths = additionalModulePaths;
23
17
  }
24
- async bundle({ entryPoints, basePaths, define, target = "es5", minify = true, logLevel = "silent", alias = {}, platform, resolveFallback, }) {
18
+ async bundle({ entryPoints, basePaths, define, target = "es5", minify = true, logLevel = "silent", alias = {}, platform, resolveFallback, packages = "bundle", }) {
25
19
  const plugins = [];
26
20
  if (basePaths && Object.keys(basePaths).length > 0) {
27
21
  plugins.push(this.baqendPlugin(basePaths));
@@ -43,58 +37,14 @@ export class BundleService {
43
37
  entryPoints: Object.keys(entryPoints),
44
38
  platform: platform || "neutral",
45
39
  logLevel,
46
- nodePaths: this.additionalModulePaths,
47
- alias: this.resolveAliases(alias),
40
+ packages,
41
+ alias,
48
42
  });
49
43
  return result.outputFiles
50
44
  .map((outputFile) => outputFile.text)
51
45
  .join("")
52
46
  .trim();
53
47
  }
54
- /**
55
- * Turns each alias target into an absolute path, resolved from the same directory the bundled
56
- * code resolves its own imports from.
57
- *
58
- * esbuild resolves an alias target from its working directory, not from the entry point's
59
- * resolveDir. `{ "node-fetch": "node-fetch-native" }` therefore only resolved while `sk` ran
60
- * inside the customer checkout, and failed from anywhere else — even though the bundled code
61
- * could import the very same module by name.
62
- *
63
- * A target that is not installed stays a bare name, so esbuild reports it as before.
64
- */
65
- resolveAliases(alias) {
66
- if (Object.keys(alias).length === 0) {
67
- return alias;
68
- }
69
- // The filename never has to exist; only its directory decides where the lookup starts.
70
- const requireFrom = createRequire(resolve(this.moduleDiscoveryDir, "noop.js"));
71
- return Object.fromEntries(Object.entries(alias).map(([from, to]) => [
72
- from,
73
- this.resolveAliasTarget(requireFrom, to) ?? to,
74
- ]));
75
- }
76
- /**
77
- * Looks the target up the way the bundled code would: the customer folder first, then the
78
- * additional module paths. Returns `null` when it is installed nowhere, so the bare name reaches
79
- * esbuild and is reported as before.
80
- */
81
- resolveAliasTarget(requireFrom, target) {
82
- try {
83
- return requireFrom.resolve(target);
84
- }
85
- catch {
86
- // not in the customer folder — fall through to the additional module paths
87
- }
88
- for (const modulePath of this.additionalModulePaths) {
89
- try {
90
- return createRequire(resolve(modulePath, "noop.js")).resolve(target);
91
- }
92
- catch {
93
- // keep looking
94
- }
95
- }
96
- return null;
97
- }
98
48
  entryPointsPlugin(entryPoints) {
99
49
  // eslint-disable-next-line @typescript-eslint/no-this-alias
100
50
  const me = this;
@@ -1,62 +1,9 @@
1
1
  import { expect } from "chai";
2
2
  import { after, before, describe, it } from "mocha";
3
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { BundleService } from "./bundle-service.js";
7
- /**
8
- * The customer checkout installs the modules a document handler imports, so resolution must follow
9
- * the customer directory rather than the directory `sk` happens to run in. The fixture below is
10
- * only reachable from the customer directory, which is what makes the assertion meaningful.
11
- */
12
- describe("BundleService alias resolution", () => {
13
- const MARKER = "fixture-fetch-implementation";
14
- let customerDirectory;
15
- before(() => {
16
- customerDirectory = mkdtempSync(join(tmpdir(), "sk-bundle-"));
17
- const packageDirectory = join(customerDirectory, "node_modules", "fetch-native-fixture");
18
- mkdirSync(packageDirectory, { recursive: true });
19
- writeFileSync(join(packageDirectory, "package.json"), JSON.stringify({
20
- name: "fetch-native-fixture",
21
- version: "1.0.0",
22
- main: "index.js",
23
- }));
24
- writeFileSync(join(packageDirectory, "index.js"), `module.exports = "${MARKER}";`);
25
- });
26
- after(() => {
27
- rmSync(customerDirectory, { recursive: true, force: true });
28
- });
29
- it("resolves an alias target from the customer directory, not from the working directory", async () => {
30
- const bundle = await new BundleService(customerDirectory).bundle({
31
- entryPoints: {
32
- "handler.js": "module.exports = require('node-fetch');",
33
- },
34
- minify: false,
35
- platform: "node",
36
- logLevel: "silent",
37
- alias: { "node-fetch": "fetch-native-fixture" },
38
- });
39
- expect(bundle).to.contain(MARKER);
40
- });
41
- it("leaves an uninstalled alias target to esbuild", async () => {
42
- const bundling = new BundleService(customerDirectory).bundle({
43
- entryPoints: {
44
- "handler.js": "module.exports = require('node-fetch');",
45
- },
46
- minify: false,
47
- platform: "node",
48
- logLevel: "silent",
49
- alias: { "node-fetch": "not-installed-anywhere" },
50
- });
51
- const error = await bundling.then(() => undefined, (reason) => reason);
52
- expect(String(error)).to.contain("not-installed-anywhere");
53
- });
54
- });
55
- /**
56
- * A document handler is shared with the Baqend app it is deployed to, so it can require a module
57
- * that only exists there. The bundler must reach for that copy only after the customer folder has
58
- * had its chance, and only for the relative specifiers Baqend modules are required by.
59
- */
60
7
  describe("BundleService resolve fallback", () => {
61
8
  const bundleWithFallback = (source, fallback, moduleDiscoveryDir = process.cwd()) => new BundleService(moduleDiscoveryDir).bundle({
62
9
  entryPoints: { "handler.js": source },
@@ -111,68 +58,55 @@ describe("BundleService resolve fallback", () => {
111
58
  });
112
59
  });
113
60
  /**
114
- * `sk` installs the packages a customer config declares under `dependencies` into its own cache,
115
- * not into the customer folder, so the bundler needs that directory on its search path as well.
61
+ * A document handler imports packages that must not be bundled a native addon locates its own
62
+ * binary through `import.meta.url`, which an esbuild CJS bundle leaves undefined. Only bare
63
+ * specifiers become runtime requires; the customer's own files still have to be bundled.
116
64
  */
117
- describe("BundleService additional module paths", () => {
118
- const MARKER = "fixture-additional-dependency";
119
- let cacheDirectory;
65
+ describe("BundleService external packages", () => {
66
+ const MARKER = "fixture-relative-module";
120
67
  let customerDirectory;
121
- let additionalModulePath;
122
68
  before(() => {
123
- cacheDirectory = mkdtempSync(join(tmpdir(), "sk-cache-"));
124
- customerDirectory = mkdtempSync(join(tmpdir(), "sk-customer-"));
125
- additionalModulePath = join(cacheDirectory, "node_modules");
126
- const packageDirectory = join(additionalModulePath, "additional-fixture");
127
- mkdirSync(packageDirectory, { recursive: true });
128
- writeFileSync(join(packageDirectory, "package.json"), JSON.stringify({
129
- name: "additional-fixture",
130
- version: "1.0.0",
131
- main: "index.js",
132
- }));
133
- writeFileSync(join(packageDirectory, "index.js"), `module.exports = "${MARKER}";`);
69
+ customerDirectory = mkdtempSync(join(tmpdir(), "sk-external-"));
70
+ writeFileSync(join(customerDirectory, "helper.js"), `module.exports = "${MARKER}";`);
134
71
  });
135
72
  after(() => {
136
- rmSync(cacheDirectory, { recursive: true, force: true });
137
73
  rmSync(customerDirectory, { recursive: true, force: true });
138
74
  });
139
- it("resolves a package installed outside the customer folder", async () => {
140
- const bundle = await new BundleService(customerDirectory, [
141
- additionalModulePath,
142
- ]).bundle({
75
+ it("leaves a package as a runtime require", async () => {
76
+ const bundle = await new BundleService(customerDirectory).bundle({
143
77
  entryPoints: {
144
- "handler.js": "module.exports = require('additional-fixture');",
78
+ "handler.js": "module.exports = require('parse5');",
145
79
  },
146
80
  minify: false,
147
81
  platform: "node",
148
82
  logLevel: "silent",
83
+ packages: "external",
149
84
  });
150
- expect(bundle).to.contain(MARKER);
85
+ expect(bundle).to.contain('require("parse5")');
151
86
  });
152
- it("fails without the additional path, as it did before", async () => {
153
- const bundling = new BundleService(customerDirectory).bundle({
87
+ it("still bundles a relative import", async () => {
88
+ const bundle = await new BundleService(customerDirectory).bundle({
154
89
  entryPoints: {
155
- "handler.js": "module.exports = require('additional-fixture');",
90
+ "handler.js": "module.exports = require('./helper.js');",
156
91
  },
157
92
  minify: false,
158
93
  platform: "node",
159
94
  logLevel: "silent",
95
+ packages: "external",
160
96
  });
161
- const error = await bundling.then(() => undefined, (reason) => reason);
162
- expect(String(error)).to.contain("additional-fixture");
97
+ expect(bundle).to.contain(MARKER);
163
98
  });
164
- it("resolves an alias target from an additional module path", async () => {
165
- const bundle = await new BundleService(customerDirectory, [
166
- additionalModulePath,
167
- ]).bundle({
99
+ it("applies an alias and keeps the target a bare specifier", async () => {
100
+ const bundle = await new BundleService(customerDirectory).bundle({
168
101
  entryPoints: {
169
102
  "handler.js": "module.exports = require('node-fetch');",
170
103
  },
171
104
  minify: false,
172
105
  platform: "node",
173
106
  logLevel: "silent",
174
- alias: { "node-fetch": "additional-fixture" },
107
+ alias: { "node-fetch": "node-fetch-native" },
108
+ packages: "external",
175
109
  });
176
- expect(bundle).to.contain(MARKER);
110
+ expect(bundle).to.contain('require("node-fetch-native")');
177
111
  });
178
112
  });
@@ -8,6 +8,7 @@ export declare class DocumentHandlerServer {
8
8
  private files;
9
9
  private bundler;
10
10
  private cli;
11
+ private workSpace;
11
12
  private verboseLevel;
12
13
  private entityManager?;
13
14
  private documentHandlerCode;
@@ -15,7 +16,11 @@ export declare class DocumentHandlerServer {
15
16
  private database;
16
17
  /** Sources of app modules already looked up; `null` marks a lookup that came back empty. */
17
18
  private readonly appModules;
18
- constructor(customerConfig: CustomerConfig, files: FileListInterface, bundler: BundleService, cli: CliService, verboseLevel?: boolean, entityManager?: EntityManager);
19
+ private moduleResolver;
20
+ /**
21
+ * @param workSpace - Directory the packages a customer config declares are installed into.
22
+ */
23
+ constructor(customerConfig: CustomerConfig, files: FileListInterface, bundler: BundleService, cli: CliService, workSpace: string, verboseLevel?: boolean, entityManager?: EntityManager);
19
24
  transform(contentType: string, html: string, variation: string, url: string, headers: Record<string, string>): Promise<DocumentHandlerResponse>;
20
25
  private getTransformFunctionWrapper;
21
26
  buildDocumentHandler(): Promise<void>;
@@ -43,6 +48,18 @@ export declare class DocumentHandlerServer {
43
48
  */
44
49
  bundleDynamicFetcher(basePaths: Record<string, string>, dynamicFetcher: string): Promise<string>;
45
50
  bundleDocumentHandlerCode(name: string, code: string): Promise<string>;
51
+ /**
52
+ * Resolves the packages the bundle leaves as runtime requires.
53
+ *
54
+ * Nothing a document handler imports by name is bundled, because a bundler cannot serve them
55
+ * all: a native addon such as `node-av` locates its own `.node` file through `import.meta.url`,
56
+ * which an esbuild CJS bundle leaves undefined. Requiring at runtime also matches production,
57
+ * where the handler runs against real `node_modules`.
58
+ *
59
+ * The workspace is searched before the CLI's own dependencies, so a config that declares a
60
+ * package under `dependencies` also decides its version.
61
+ */
62
+ private getModuleResolver;
46
63
  /**
47
64
  * Loads a module the customer folder does not contain from the app the config is deployed to.
48
65
  *
@@ -12,12 +12,14 @@ import { safe } from "../../helpers/safe.js";
12
12
  import { VmEmptyResponseError } from "../onboarding/error/vm-empty-response-error.js";
13
13
  import { DocumentHandlerTransformError } from "../onboarding/error/document-handler-transform-error.js";
14
14
  import { createRequire } from "node:module";
15
+ import { resolve } from "node:path";
15
16
  import ApplicationError from "../error-handling/error/application-error.js";
16
17
  export class DocumentHandlerServer {
17
18
  customerConfig;
18
19
  files;
19
20
  bundler;
20
21
  cli;
22
+ workSpace;
21
23
  verboseLevel;
22
24
  entityManager;
23
25
  documentHandlerCode;
@@ -25,11 +27,16 @@ export class DocumentHandlerServer {
25
27
  database;
26
28
  /** Sources of app modules already looked up; `null` marks a lookup that came back empty. */
27
29
  appModules = new Map();
28
- constructor(customerConfig, files, bundler, cli, verboseLevel = false, entityManager) {
30
+ moduleResolver;
31
+ /**
32
+ * @param workSpace - Directory the packages a customer config declares are installed into.
33
+ */
34
+ constructor(customerConfig, files, bundler, cli, workSpace, verboseLevel = false, entityManager) {
29
35
  this.customerConfig = customerConfig;
30
36
  this.files = files;
31
37
  this.bundler = bundler;
32
38
  this.cli = cli;
39
+ this.workSpace = workSpace;
33
40
  this.verboseLevel = verboseLevel;
34
41
  this.entityManager = entityManager;
35
42
  }
@@ -172,7 +179,7 @@ export class DocumentHandlerServer {
172
179
  createNodeVmContext() {
173
180
  const context = {
174
181
  process: this.createProcessPartial(),
175
- require: createRequire(import.meta.url),
182
+ require: this.getModuleResolver(),
176
183
  URL,
177
184
  URLSearchParams,
178
185
  fetch: this.createFetchMock(),
@@ -248,6 +255,8 @@ export class DocumentHandlerServer {
248
255
  platform: "node",
249
256
  logLevel: "silent",
250
257
  alias: { "node-fetch": "node-fetch-native" },
258
+ // Every package stays a runtime require, see getModuleResolver().
259
+ packages: "external",
251
260
  resolveFallback: this.loadAppModule,
252
261
  }));
253
262
  if (bundling.success === true) {
@@ -255,6 +264,42 @@ export class DocumentHandlerServer {
255
264
  }
256
265
  throw this.explainUnresolvedModules(bundling.errorObj ?? new Error(bundling.error));
257
266
  }
267
+ /**
268
+ * Resolves the packages the bundle leaves as runtime requires.
269
+ *
270
+ * Nothing a document handler imports by name is bundled, because a bundler cannot serve them
271
+ * all: a native addon such as `node-av` locates its own `.node` file through `import.meta.url`,
272
+ * which an esbuild CJS bundle leaves undefined. Requiring at runtime also matches production,
273
+ * where the handler runs against real `node_modules`.
274
+ *
275
+ * The workspace is searched before the CLI's own dependencies, so a config that declares a
276
+ * package under `dependencies` also decides its version.
277
+ */
278
+ getModuleResolver() {
279
+ if (this.moduleResolver) {
280
+ return this.moduleResolver;
281
+ }
282
+ // The filename never has to exist; only its directory decides where the lookup starts.
283
+ const requireFromWorkSpace = createRequire(resolve(this.workSpace, "noop.js"));
284
+ const requireFromCli = createRequire(import.meta.url);
285
+ // Resolve before requiring, so a module that exists but throws while it initialises
286
+ // reports that failure rather than falling through to the next candidate.
287
+ this.moduleResolver = (module) => {
288
+ const inWorkSpace = safe(() => requireFromWorkSpace.resolve(module));
289
+ if (inWorkSpace.success === true) {
290
+ return requireFromWorkSpace(inWorkSpace.data);
291
+ }
292
+ const withCli = safe(() => requireFromCli.resolve(module));
293
+ if (withCli.success === true) {
294
+ return requireFromCli(withCli.data);
295
+ }
296
+ throw new ApplicationError(`[documentHandler] could not load module: ${module}`, [
297
+ `"${module}" ships neither with the CLI nor with this customer config.`,
298
+ `Add it to config_customer.json under "dependencies" and start sk again.`,
299
+ ]);
300
+ };
301
+ return this.moduleResolver;
302
+ }
258
303
  /**
259
304
  * Loads a module the customer folder does not contain from the app the config is deployed to.
260
305
  *
@@ -36,13 +36,9 @@ export default class DocumentHandlerRuntimeServiceFactory {
36
36
  const customerConfig = this.getCustomerConfig(files);
37
37
  const cliService = new CliServiceFactory().getService();
38
38
  await this.installAdditionalDependencies(customerConfig, cliService);
39
- // installAdditionalDependencies() installs into the cache, not into the customer folder, so
40
- // the bundler has to search there too or a declared dependency resolves nowhere.
41
- const bundler = new BundleServiceFactory(this.context.customerPath, [
42
- resolve(this.cliConfig.nodeModulesPath, "node_modules"),
43
- ]).buildService();
39
+ const bundler = new BundleServiceFactory(this.context.customerPath).buildService();
44
40
  const entityManagerResult = await safe(new EntityManagerFactory().getEntityManager(customerConfig.app));
45
- return new DocumentHandlerServer(customerConfig, files, bundler, cliService, this.context.verboseLevel, entityManagerResult.success ? entityManagerResult.data : null);
41
+ return new DocumentHandlerServer(customerConfig, files, bundler, cliService, this.cliConfig.nodeModulesPath, this.context.verboseLevel, entityManagerResult.success ? entityManagerResult.data : null);
46
42
  }
47
43
  prepareIntegrationApi() {
48
44
  const integrationApiContext = new IntegrationApiContext(this.context.customerPath, this.context.configName, this.context.fileDependencies, [".git", ".idea", "node_modules"]);
@@ -0,0 +1,3 @@
1
+ export * from "./revalidate-model.js";
2
+ export * from "./revalidate-service.js";
3
+ export * from "./revalidate-factory.js";
@@ -0,0 +1,3 @@
1
+ export * from "./revalidate-model.js";
2
+ export * from "./revalidate-service.js";
3
+ export * from "./revalidate-factory.js";
@@ -0,0 +1,7 @@
1
+ import { RevalidateContextInterface } from "./revalidate-model.js";
2
+ import { RevalidateService } from "./revalidate-service.js";
3
+ export declare class RevalidateFactory {
4
+ private context;
5
+ constructor(context: RevalidateContextInterface);
6
+ getService(): RevalidateService;
7
+ }
@@ -0,0 +1,14 @@
1
+ import { CliContext, CliServiceFactory } from "../cli/index.js";
2
+ import { ConfigApiContext, ConfigApiServiceFactory, } from "../config-api/index.js";
3
+ import { RevalidateService } from "./revalidate-service.js";
4
+ export class RevalidateFactory {
5
+ context;
6
+ constructor(context) {
7
+ this.context = context;
8
+ }
9
+ getService() {
10
+ const cli = new CliServiceFactory(new CliContext(this.context.quiet)).getService();
11
+ const configApi = new ConfigApiServiceFactory(new ConfigApiContext(this.context.app)).getService();
12
+ return new RevalidateService(configApi, cli, this.context.app);
13
+ }
14
+ }
@@ -0,0 +1,44 @@
1
+ export declare const REVALIDATE_TYPES: readonly ["REFRESH", "INSTANT", "DEPLOYMENT", "PURGE"];
2
+ export type RevalidateType = (typeof REVALIDATE_TYPES)[number];
3
+ /**
4
+ * The asset table the dry run reads. It holds one entry per cached asset, so the
5
+ * same filter that the job applies also selects the rows to report.
6
+ */
7
+ export declare const ASSET_TABLE = "speedKit.Asset";
8
+ /**
9
+ * Above this many assets the command asks before it purges. A filter that takes
10
+ * more than a page of assets is usually a filter mistake, and a purge cannot be
11
+ * undone — the entries are gone until traffic caches them again.
12
+ */
13
+ export declare const CONFIRMATION_THRESHOLD = 100;
14
+ /**
15
+ * Scope of a revalidation job.
16
+ *
17
+ * `prefixes` is mandatory: the backend rejects a filter without it. It bounds which
18
+ * URLs the job examines. `query` refines that set, and it takes a MongoDB query, so
19
+ * it can express a rule that a prefix cannot — a trailing category id, for example.
20
+ */
21
+ export interface RevalidateFilterInterface {
22
+ prefixes: string[];
23
+ query?: Record<string, unknown>;
24
+ }
25
+ export interface RevalidateJobStatusInterface {
26
+ state: "CREATED" | "RUNNING" | "FINISHED" | "ERROR" | "CANCELLED" | "SCHEDULED" | "DEFERRED";
27
+ assetCount?: number;
28
+ refreshedCount?: number;
29
+ changedCount?: number;
30
+ error?: string | null;
31
+ }
32
+ export interface RevalidatePreviewInterface {
33
+ count: number;
34
+ sample: string[];
35
+ }
36
+ export interface RevalidateContextInterface {
37
+ app: string;
38
+ quiet: boolean;
39
+ }
40
+ export declare class RevalidateContext implements RevalidateContextInterface {
41
+ readonly app: string;
42
+ readonly quiet: boolean;
43
+ constructor(app: string, quiet?: boolean);
44
+ }
@@ -0,0 +1,25 @@
1
+ export const REVALIDATE_TYPES = [
2
+ "REFRESH",
3
+ "INSTANT",
4
+ "DEPLOYMENT",
5
+ "PURGE",
6
+ ];
7
+ /**
8
+ * The asset table the dry run reads. It holds one entry per cached asset, so the
9
+ * same filter that the job applies also selects the rows to report.
10
+ */
11
+ export const ASSET_TABLE = "speedKit.Asset";
12
+ /**
13
+ * Above this many assets the command asks before it purges. A filter that takes
14
+ * more than a page of assets is usually a filter mistake, and a purge cannot be
15
+ * undone — the entries are gone until traffic caches them again.
16
+ */
17
+ export const CONFIRMATION_THRESHOLD = 100;
18
+ export class RevalidateContext {
19
+ app;
20
+ quiet;
21
+ constructor(app, quiet = false) {
22
+ this.app = app;
23
+ this.quiet = quiet;
24
+ }
25
+ }
@@ -0,0 +1,58 @@
1
+ import { CliService } from "../cli/index.js";
2
+ import { ConfigApiService } from "../config-api/index.js";
3
+ import { RevalidateFilterInterface, RevalidateJobStatusInterface, RevalidatePreviewInterface, RevalidateType } from "./revalidate-model.js";
4
+ /**
5
+ * Reads the literal head of an anchored pattern, which is the prefix the job needs.
6
+ *
7
+ * This is the same text MongoDB turns into index bounds, so deriving the prefix
8
+ * here keeps the two in step: the caller states the pattern once, and the prefix
9
+ * cannot drift from it. An escaped character counts as literal, so `\.` yields `.`
10
+ * and the host survives. The walk stops at the first metacharacter.
11
+ */
12
+ export declare function derivePrefix(pattern: string): string;
13
+ export declare class RevalidateService {
14
+ private readonly configApi;
15
+ private readonly cli;
16
+ private readonly app;
17
+ constructor(configApi: ConfigApiService, cli: CliService, app: string);
18
+ /**
19
+ * Builds the filter the API expects from the patterns the caller stated, and
20
+ * derives the mandatory prefixes from them.
21
+ */
22
+ buildFilter(patterns: string[], explicitPrefixes: string[]): RevalidateFilterInterface;
23
+ /**
24
+ * Rejects a filter the backend would reject, before the request goes out. The
25
+ * check is cheap here and the server-side error does not say which field is at
26
+ * fault.
27
+ */
28
+ assertFilter(filter: RevalidateFilterInterface): void;
29
+ /**
30
+ * Reports what the job would take. The Refresh API has no preview mode, so this
31
+ * runs the job's own filter against the asset table instead.
32
+ */
33
+ preview(filter: RevalidateFilterInterface, sampleSize: number): Promise<RevalidatePreviewInterface>;
34
+ /** Asks before a purge that takes more assets than the threshold allows. */
35
+ confirmLargeJob(count: number, type: RevalidateType): Promise<boolean>;
36
+ /** Submits the job and returns its status id. */
37
+ submit(filter: RevalidateFilterInterface, type: RevalidateType): Promise<string>;
38
+ status(statusId: string): Promise<RevalidateJobStatusInterface>;
39
+ /**
40
+ * Issues the request itself instead of going through the shared client. A
41
+ * submitted job answers 202, and the shared client reports every status other
42
+ * than 200 as an error — which turns an accepted job into "Error: Accepted".
43
+ */
44
+ private request;
45
+ /** Polls until the job reaches a terminal state, and reports each state change. */
46
+ waitFor(statusId: string): Promise<RevalidateJobStatusInterface>;
47
+ /**
48
+ * Mirrors the job's filter as one MongoDB query, so the dry run reports the set
49
+ * the job takes and not every URL the query alone would match.
50
+ *
51
+ * Each prefix becomes its own anchored expression, and they combine with `$or`.
52
+ * One alternation would read `^(?:a|b)`, whose first character after the anchor
53
+ * is a metacharacter — MongoDB then extracts no literal prefix and scans the
54
+ * collection. Some asset tables are far too large for that. `^literal` keeps
55
+ * each branch on the index.
56
+ */
57
+ private toMongoFilter;
58
+ }
@@ -0,0 +1,189 @@
1
+ import ApplicationError from "../error-handling/error/application-error.js";
2
+ import { ASSET_TABLE, } from "./revalidate-model.js";
3
+ const POLL_INTERVAL = 3000;
4
+ const TERMINAL_STATES = new Set(["FINISHED", "ERROR", "CANCELLED"]);
5
+ /** Escapes a prefix so it can go into the dry-run query as a literal. */
6
+ function escapeRegExp(value) {
7
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
+ }
9
+ const REGEXP_METACHARACTERS = new Set([
10
+ ".",
11
+ "*",
12
+ "+",
13
+ "?",
14
+ "$",
15
+ "{",
16
+ "}",
17
+ "(",
18
+ ")",
19
+ "|",
20
+ "[",
21
+ "]",
22
+ ]);
23
+ /**
24
+ * Reads the literal head of an anchored pattern, which is the prefix the job needs.
25
+ *
26
+ * This is the same text MongoDB turns into index bounds, so deriving the prefix
27
+ * here keeps the two in step: the caller states the pattern once, and the prefix
28
+ * cannot drift from it. An escaped character counts as literal, so `\.` yields `.`
29
+ * and the host survives. The walk stops at the first metacharacter.
30
+ */
31
+ export function derivePrefix(pattern) {
32
+ if (!pattern.startsWith("^")) {
33
+ return "";
34
+ }
35
+ let prefix = "";
36
+ for (let index = 1; index < pattern.length; index++) {
37
+ const character = pattern[index];
38
+ if (character === "\\") {
39
+ const escaped = pattern[index + 1];
40
+ if (escaped === undefined)
41
+ break;
42
+ prefix += escaped;
43
+ index++;
44
+ continue;
45
+ }
46
+ if (REGEXP_METACHARACTERS.has(character))
47
+ break;
48
+ prefix += character;
49
+ }
50
+ return prefix;
51
+ }
52
+ export class RevalidateService {
53
+ configApi;
54
+ cli;
55
+ app;
56
+ constructor(configApi, cli, app) {
57
+ this.configApi = configApi;
58
+ this.cli = cli;
59
+ this.app = app;
60
+ }
61
+ /**
62
+ * Builds the filter the API expects from the patterns the caller stated, and
63
+ * derives the mandatory prefixes from them.
64
+ */
65
+ buildFilter(patterns, explicitPrefixes) {
66
+ if (!patterns.length) {
67
+ return { prefixes: explicitPrefixes };
68
+ }
69
+ for (const pattern of patterns) {
70
+ const derived = derivePrefix(pattern);
71
+ if (!derived.startsWith("http")) {
72
+ throw new ApplicationError(`cannot derive a prefix from the pattern "${pattern}"`, [
73
+ "Anchor the pattern at the scheme, so its literal head is a URL prefix.",
74
+ 'Example: "^https://www.example.com/en/.*-c1234$".',
75
+ "MongoDB reads that same literal as index bounds, so an unanchored pattern would also scan the table.",
76
+ "Pass --prefix yourself if the pattern genuinely cannot carry the host.",
77
+ ]);
78
+ }
79
+ }
80
+ const clauses = patterns.map((pattern) => ({ url: { $regex: pattern } }));
81
+ return {
82
+ prefixes: explicitPrefixes.length
83
+ ? explicitPrefixes
84
+ : patterns.map((pattern) => derivePrefix(pattern)),
85
+ query: clauses.length === 1 ? clauses[0] : { $or: clauses },
86
+ };
87
+ }
88
+ /**
89
+ * Rejects a filter the backend would reject, before the request goes out. The
90
+ * check is cheap here and the server-side error does not say which field is at
91
+ * fault.
92
+ */
93
+ assertFilter(filter) {
94
+ if (!filter.prefixes?.length) {
95
+ throw new ApplicationError("a revalidation filter needs at least one prefix", [
96
+ "The backend rejects a filter without prefixes.",
97
+ "Pass --pattern, which derives the prefix, or --prefix to state one.",
98
+ ]);
99
+ }
100
+ for (const prefix of filter.prefixes) {
101
+ if (!prefix.startsWith("http")) {
102
+ throw new ApplicationError(`prefix "${prefix}" is not a full URL`, [
103
+ "A prefix matches the stored URL, so it carries the scheme and the host.",
104
+ ]);
105
+ }
106
+ }
107
+ }
108
+ /**
109
+ * Reports what the job would take. The Refresh API has no preview mode, so this
110
+ * runs the job's own filter against the asset table instead.
111
+ */
112
+ async preview(filter, sampleSize) {
113
+ const entityManager = await this.configApi.client.getEntityManager();
114
+ const mongoFilter = this.toMongoFilter(filter);
115
+ const count = await entityManager[ASSET_TABLE].find()
116
+ .where(mongoFilter)
117
+ .count();
118
+ const sample = await entityManager[ASSET_TABLE].find()
119
+ .where(mongoFilter)
120
+ .limit(sampleSize)
121
+ .resultList();
122
+ return {
123
+ count,
124
+ sample: sample.map((asset) => asset.url),
125
+ };
126
+ }
127
+ /** Asks before a purge that takes more assets than the threshold allows. */
128
+ async confirmLargeJob(count, type) {
129
+ return this.cli.confirm(`${type} ${count} assets? They stay out of the cache until traffic fills it again.`, false);
130
+ }
131
+ /** Submits the job and returns its status id. */
132
+ async submit(filter, type) {
133
+ const response = await this.request(`https://${this.app}.app.baqend.com/v1/asset/revalidate`, {
134
+ method: "POST",
135
+ body: JSON.stringify({ type, filter, triggeredBy: "sk-cli" }),
136
+ });
137
+ return response.statusId;
138
+ }
139
+ async status(statusId) {
140
+ return this.request(`https://${this.app}.app.baqend.com/v1/asset/revalidate/${statusId}`);
141
+ }
142
+ /**
143
+ * Issues the request itself instead of going through the shared client. A
144
+ * submitted job answers 202, and the shared client reports every status other
145
+ * than 200 as an error — which turns an accepted job into "Error: Accepted".
146
+ */
147
+ async request(url, extension = {}) {
148
+ const requestInit = await this.configApi.client.createRequest(extension);
149
+ const response = await fetch(url, requestInit);
150
+ if (!response.ok) {
151
+ const body = await response.text();
152
+ throw new ApplicationError(`the revalidation API answered ${response.status} ${response.statusText}`, [body.slice(0, 400)]);
153
+ }
154
+ const body = await response.text();
155
+ return (body ? JSON.parse(body) : {});
156
+ }
157
+ /** Polls until the job reaches a terminal state, and reports each state change. */
158
+ async waitFor(statusId) {
159
+ let lastState = "";
160
+ for (;;) {
161
+ const status = await this.status(statusId);
162
+ if (status.state !== lastState) {
163
+ this.cli.write(`revalidation job ${statusId}: ${status.state}`);
164
+ lastState = status.state;
165
+ }
166
+ if (TERMINAL_STATES.has(status.state)) {
167
+ return status;
168
+ }
169
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
170
+ }
171
+ }
172
+ /**
173
+ * Mirrors the job's filter as one MongoDB query, so the dry run reports the set
174
+ * the job takes and not every URL the query alone would match.
175
+ *
176
+ * Each prefix becomes its own anchored expression, and they combine with `$or`.
177
+ * One alternation would read `^(?:a|b)`, whose first character after the anchor
178
+ * is a metacharacter — MongoDB then extracts no literal prefix and scans the
179
+ * collection. Some asset tables are far too large for that. `^literal` keeps
180
+ * each branch on the index.
181
+ */
182
+ toMongoFilter(filter) {
183
+ const prefixClauses = filter.prefixes.map((prefix) => ({
184
+ url: { $regex: `^${escapeRegExp(prefix)}` },
185
+ }));
186
+ const prefixClause = prefixClauses.length === 1 ? prefixClauses[0] : { $or: prefixClauses };
187
+ return filter.query ? { $and: [prefixClause, filter.query] } : prefixClause;
188
+ }
189
+ }
@@ -871,6 +871,100 @@
871
871
  "pull.js"
872
872
  ]
873
873
  },
874
+ "revalidate": {
875
+ "aliases": [],
876
+ "args": {
877
+ "app": {
878
+ "description": "The customer app name",
879
+ "name": "app",
880
+ "required": true
881
+ }
882
+ },
883
+ "description": "Purge or refresh cached assets by prefix and query. Run it after a config that narrows scope goes live, so returning visitors stop receiving the cached HTML.",
884
+ "examples": [
885
+ "$ sk revalidate <app> --pattern <regex> [--pattern <regex>] [--dryRun] [--wait]",
886
+ "$ sk revalidate decathlon --pattern '^https://www\\\\.decathlon\\\\.de/damen/.*-c(?:1234|5678)(?:/|\\\\?|$)' --dryRun",
887
+ "$ sk revalidate decathlon --pattern '^https://www\\\\.decathlon\\\\.de/damen/.*-c1234(?:/|\\\\?|$)' --wait",
888
+ "$ sk revalidate decathlon --prefix https://www.decathlon.de/damen/ --dryRun"
889
+ ],
890
+ "flags": {
891
+ "pattern": {
892
+ "description": "URL pattern to purge, as a regex anchored at the scheme — mirror what the config excludes, e.g. \"^https://www.example.com/en/.*-c1234$\". Repeatable. The command derives the prefix the API requires from the pattern's literal head, and MongoDB reads that same literal as index bounds.",
893
+ "name": "pattern",
894
+ "hasDynamicHelp": false,
895
+ "multiple": true,
896
+ "type": "option"
897
+ },
898
+ "prefix": {
899
+ "description": "URL prefix, matched against the start of the cached URL. Derived from --pattern by default; state it only to override that, or to purge a whole path without a pattern.",
900
+ "name": "prefix",
901
+ "hasDynamicHelp": false,
902
+ "multiple": true,
903
+ "type": "option"
904
+ },
905
+ "query": {
906
+ "description": "Raw MongoDB query on the asset fields, as JSON. An escape hatch for a rule --pattern cannot express, such as a condition on another field. It replaces the query --pattern would build.",
907
+ "name": "query",
908
+ "hasDynamicHelp": false,
909
+ "multiple": false,
910
+ "type": "option"
911
+ },
912
+ "type": {
913
+ "description": "PURGE drops the entries and does not rebuild them, which is what an out-of-scope URL needs.",
914
+ "name": "type",
915
+ "default": "PURGE",
916
+ "hasDynamicHelp": false,
917
+ "multiple": false,
918
+ "options": [
919
+ "REFRESH",
920
+ "INSTANT",
921
+ "DEPLOYMENT",
922
+ "PURGE"
923
+ ],
924
+ "type": "option"
925
+ },
926
+ "dryRun": {
927
+ "char": "d",
928
+ "description": "Report the assets the filter selects and exit. Changes nothing.",
929
+ "name": "dryRun",
930
+ "allowNo": false,
931
+ "type": "boolean"
932
+ },
933
+ "wait": {
934
+ "description": "Poll the job until it finishes.",
935
+ "name": "wait",
936
+ "allowNo": false,
937
+ "type": "boolean"
938
+ },
939
+ "yes": {
940
+ "description": "Skip the confirmation a job above 100 assets asks for. Required in a non-interactive shell.",
941
+ "name": "yes",
942
+ "allowNo": false,
943
+ "type": "boolean"
944
+ },
945
+ "quiet": {
946
+ "char": "q",
947
+ "description": "less output non interactive",
948
+ "name": "quiet",
949
+ "allowNo": false,
950
+ "type": "boolean"
951
+ }
952
+ },
953
+ "hasDynamicHelp": false,
954
+ "hiddenAliases": [],
955
+ "id": "revalidate",
956
+ "pluginAlias": "@speedkit/cli",
957
+ "pluginName": "@speedkit/cli",
958
+ "pluginType": "core",
959
+ "strict": true,
960
+ "enableJsonFlag": false,
961
+ "isESM": true,
962
+ "relativePath": [
963
+ "dist",
964
+ "commands",
965
+ "revalidate.js"
966
+ ]
967
+ },
874
968
  "wpt-launcher": {
875
969
  "aliases": [],
876
970
  "args": {
@@ -1220,5 +1314,5 @@
1220
1314
  ]
1221
1315
  }
1222
1316
  },
1223
- "version": "4.24.1"
1317
+ "version": "4.25.0"
1224
1318
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@speedkit/cli",
3
3
  "description": "Speed Kit CLI",
4
- "version": "4.24.1",
4
+ "version": "4.25.0",
5
5
  "author": {
6
6
  "name": "Baqend.com",
7
7
  "email": "info@baqend.com"
@@ -91,6 +91,8 @@
91
91
  "@eslint/eslintrc": "^3.3.5",
92
92
  "@eslint/js": "^10.0.1",
93
93
  "@inquirer/prompts": "^8.4.2",
94
+ "@nats-io/nats-core": "^3.2.0",
95
+ "@nats-io/transport-node": "^3.2.0",
94
96
  "@oclif/core": "^4.10.6",
95
97
  "@oclif/errors": "^1.3.6",
96
98
  "@oclif/plugin-autocomplete": "^3.2.46",
@@ -102,8 +104,13 @@
102
104
  "chokidar": "^5.0.0",
103
105
  "cli-progress": "^3.12.0",
104
106
  "clipboardy": "^5.3.1",
107
+ "css-select": "^5.1.0",
105
108
  "deepmerge": "^4.3.1",
106
109
  "diff": "^9.0.0",
110
+ "dom-serializer": "^2.0.0",
111
+ "domelementtype": "^2.3.0",
112
+ "domhandler": "^5.0.3",
113
+ "domutils": "^3.1.0",
107
114
  "dotenv": "^17.4.2",
108
115
  "encoding-japanese": "^2.2.0",
109
116
  "esbuild": "^0.27.3",
@@ -115,8 +122,11 @@
115
122
  "iconv-lite": "^0.7.2",
116
123
  "json2csv": "^6.0.0-alpha.2",
117
124
  "node-fetch": "^3.3.2",
125
+ "node-fetch-native": "^1.6.7",
118
126
  "nunjucks": "^3.2.4",
119
127
  "nypm": "^0.6.6",
128
+ "parse5": "^7.1.2",
129
+ "parse5-htmlparser2-tree-adapter": "^7.0.0",
120
130
  "puppeteer": "^24.41.0",
121
131
  "puppeteer-extra": "^3.3.6",
122
132
  "semver": "^7.7.4",