@speedkit/cli 4.24.2 → 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 +18 -0
- package/README.md +45 -1
- package/dist/commands/revalidate.d.ts +21 -0
- package/dist/commands/revalidate.js +128 -0
- package/dist/services/revalidate/index.d.ts +3 -0
- package/dist/services/revalidate/index.js +3 -0
- package/dist/services/revalidate/revalidate-factory.d.ts +7 -0
- package/dist/services/revalidate/revalidate-factory.js +14 -0
- package/dist/services/revalidate/revalidate-model.d.ts +44 -0
- package/dist/services/revalidate/revalidate-model.js +25 -0
- package/dist/services/revalidate/revalidate-service.d.ts +58 -0
- package/dist/services/revalidate/revalidate-service.js +189 -0
- package/oclif.manifest.json +95 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
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
|
+
|
|
1
19
|
## [4.24.2](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.24.1...v4.24.2) (2026-09-03)
|
|
2
20
|
|
|
3
21
|
|
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
|
+
@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
|
+
}
|
|
@@ -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
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
1317
|
+
"version": "4.25.0"
|
|
1224
1318
|
}
|