@speedkit/cli 4.15.1 → 4.17.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,17 @@
1
+ # [4.17.0](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.16.0...v4.17.0) (2026-07-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * extend autocomplete for fish ([b9d1fcb](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/b9d1fcb2fb921ad91895b69aad8f02206ca09cbf))
7
+
8
+ # [4.16.0](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.15.1...v4.16.0) (2026-07-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * **onboarding:** refetch last 10 cached entries after fileChange ([779c6af](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/779c6af13e371af2b6d04621301921920139ca80))
14
+
1
15
  ## [4.15.1](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.15.0...v4.15.1) (2026-07-08)
2
16
 
3
17
 
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.15.1 linux-x64 node-v22.23.1
24
+ @speedkit/cli/4.17.0 linux-x64 node-v22.23.1
25
25
  $ sk --help [COMMAND]
26
26
  USAGE
27
27
  $ sk COMMAND
@@ -67,6 +67,7 @@ By either...
67
67
  <!-- commands -->
68
68
  * [`sk auto-prewarm CUSTOMERPATH`](#sk-auto-prewarm-customerpath)
69
69
  * [`sk autocomplete [SHELL]`](#sk-autocomplete-shell)
70
+ * [`sk autocomplete fish`](#sk-autocomplete-fish)
70
71
  * [`sk build-parameter-query CUSTOMERPATH`](#sk-build-parameter-query-customerpath)
71
72
  * [`sk build-prewarm-query CUSTOMERPATH`](#sk-build-prewarm-query-customerpath)
72
73
  * [`sk config edit`](#sk-config-edit)
@@ -146,6 +147,26 @@ EXAMPLES
146
147
 
147
148
  _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.2.49/src/commands/autocomplete/index.ts)_
148
149
 
150
+ ## `sk autocomplete fish`
151
+
152
+ Generate fish shell completions and install them to ~/.config/fish/completions.
153
+
154
+ ```
155
+ USAGE
156
+ $ sk autocomplete fish [-p]
157
+
158
+ FLAGS
159
+ -p, --print Print the completion script to stdout instead of installing it
160
+
161
+ DESCRIPTION
162
+ Generate fish shell completions and install them to ~/.config/fish/completions.
163
+
164
+ EXAMPLES
165
+ $ sk autocomplete fish
166
+
167
+ $ sk autocomplete fish --print > sk.fish
168
+ ```
169
+
149
170
  ## `sk build-parameter-query CUSTOMERPATH`
150
171
 
151
172
  Build a parameter query that can be executed in Athena
@@ -0,0 +1,19 @@
1
+ import { Command } from "@oclif/core";
2
+ /**
3
+ * Fish is not supported by @oclif/plugin-autocomplete (only bash, zsh and
4
+ * powershell), so this command generates a fish completion script from the
5
+ * oclif command manifest and installs it to ~/.config/fish/completions,
6
+ * where fish picks it up automatically.
7
+ */
8
+ export default class AutocompleteFish extends Command {
9
+ static description: string;
10
+ static examples: string[];
11
+ static flags: {
12
+ print: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<void>;
15
+ private generateCompletionScript;
16
+ private topicDescription;
17
+ private summarize;
18
+ private escape;
19
+ }
@@ -0,0 +1,140 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Fish is not supported by @oclif/plugin-autocomplete (only bash, zsh and
7
+ * powershell), so this command generates a fish completion script from the
8
+ * oclif command manifest and installs it to ~/.config/fish/completions,
9
+ * where fish picks it up automatically.
10
+ */
11
+ export default class AutocompleteFish extends Command {
12
+ static description = "Generate fish shell completions and install them to ~/.config/fish/completions.";
13
+ static examples = [
14
+ "$ sk autocomplete fish",
15
+ "$ sk autocomplete fish --print > sk.fish",
16
+ ];
17
+ static flags = {
18
+ print: Flags.boolean({
19
+ char: "p",
20
+ description: "Print the completion script to stdout instead of installing it",
21
+ }),
22
+ };
23
+ async run() {
24
+ const { flags } = await this.parse(AutocompleteFish);
25
+ const script = this.generateCompletionScript();
26
+ if (flags.print) {
27
+ this.log(script);
28
+ return;
29
+ }
30
+ const completionsDir = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "fish", "completions");
31
+ const completionFile = path.join(completionsDir, `${this.config.bin}.fish`);
32
+ await mkdir(completionsDir, { recursive: true });
33
+ await writeFile(completionFile, script);
34
+ this.log(`Fish completions written to ${completionFile}`);
35
+ this.log("New fish sessions pick them up automatically. Rerun this command after updating the CLI.");
36
+ }
37
+ generateCompletionScript() {
38
+ const { bin } = this.config;
39
+ const fn = bin.replaceAll("-", "_");
40
+ const commands = this.config.commands
41
+ .filter((command) => !command.hidden)
42
+ .sort((a, b) => a.id.localeCompare(b.id));
43
+ const lines = [
44
+ `# Fish completions for ${bin} — generated by \`${bin} autocomplete fish\`. Do not edit by hand.`,
45
+ "",
46
+ `function __${fn}_at_level`,
47
+ " test (count (commandline -opc)) -eq $argv[1]",
48
+ "end",
49
+ "",
50
+ `function __${fn}_using_command`,
51
+ " set -l cmd (commandline -opc)",
52
+ " set -e cmd[1]",
53
+ " set -l expected (string split ' ' -- $argv[1])",
54
+ " test (count $cmd) -ge (count $expected); or return 1",
55
+ " for i in (seq (count $expected))",
56
+ ' test "$cmd[$i]" = "$expected[$i]"; or return 1',
57
+ " end",
58
+ "end",
59
+ "",
60
+ ];
61
+ // Command and topic name completions, e.g. `sk dep<TAB>` or `sk config <TAB>`.
62
+ const seen = new Set();
63
+ for (const command of commands) {
64
+ const tokens = command.id.split(":");
65
+ for (let depth = 1; depth <= tokens.length; depth++) {
66
+ const token = tokens[depth - 1];
67
+ const prefix = tokens.slice(0, depth - 1);
68
+ const key = `${prefix.join(" ")} ${token}`;
69
+ if (seen.has(key))
70
+ continue;
71
+ seen.add(key);
72
+ const description = depth === tokens.length
73
+ ? this.summarize(command.summary ?? command.description)
74
+ : this.topicDescription([...prefix, token].join(":"));
75
+ const condition = depth === 1
76
+ ? `__${fn}_at_level 1`
77
+ : `__${fn}_at_level ${depth}; and __${fn}_using_command '${prefix.join(" ")}'`;
78
+ let entry = `complete -f -c ${bin} -n "${condition}" -a ${token}`;
79
+ if (description)
80
+ entry += ` -d '${this.escape(description)}'`;
81
+ lines.push(entry);
82
+ }
83
+ }
84
+ // Flag and positional-argument completions per command.
85
+ for (const command of commands) {
86
+ const tokens = command.id.split(":");
87
+ const commandPath = tokens.join(" ");
88
+ // Don't offer a parent command's flags while a subcommand is being used,
89
+ // e.g. `sk autocomplete fish` must not suggest `--refresh-cache`.
90
+ const childPaths = new Set(commands
91
+ .filter((other) => other.id.startsWith(`${command.id}:`))
92
+ .map((other) => other.id
93
+ .split(":")
94
+ .slice(0, tokens.length + 1)
95
+ .join(" ")));
96
+ const condition = [
97
+ `__${fn}_using_command '${commandPath}'`,
98
+ ...[...childPaths].map((child) => `and not __${fn}_using_command '${child}'`),
99
+ ].join("; ");
100
+ for (const [name, flag] of Object.entries(command.flags ?? {})) {
101
+ if (flag.hidden)
102
+ continue;
103
+ let entry = `complete -c ${bin} -n "${condition}" -l ${name}`;
104
+ if (flag.char)
105
+ entry += ` -s ${flag.char}`;
106
+ if (flag.type === "option") {
107
+ entry += flag.options?.length
108
+ ? ` -rfa '${flag.options.join(" ")}'`
109
+ : " -r";
110
+ }
111
+ const description = this.summarize(flag.summary ?? flag.description);
112
+ if (description)
113
+ entry += ` -d '${this.escape(description)}'`;
114
+ lines.push(entry);
115
+ }
116
+ // Args with a fixed option set, e.g. the SHELL arg of `sk autocomplete`.
117
+ Object.values(command.args ?? {}).forEach((arg, index) => {
118
+ if (arg.hidden || !arg.options?.length)
119
+ return;
120
+ const level = tokens.length + 1 + index;
121
+ lines.push(`complete -f -c ${bin} -n "__${fn}_at_level ${level}; and ${condition}" -a '${arg.options.join(" ")}'`);
122
+ });
123
+ }
124
+ return lines.join("\n") + "\n";
125
+ }
126
+ topicDescription(topicName) {
127
+ const topic = this.config.topics.find(({ name }) => name === topicName);
128
+ return this.summarize(topic?.description);
129
+ }
130
+ summarize(text) {
131
+ // Plugin descriptions may contain ejs templates, e.g. "Display help for <%= config.bin %>."
132
+ return (text ?? "")
133
+ .split("\n")[0]
134
+ .replaceAll("<%= config.bin %>", this.config.bin)
135
+ .trim();
136
+ }
137
+ escape(text) {
138
+ return text.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
139
+ }
140
+ }
@@ -7,6 +7,11 @@ export declare class BaqendResponse implements Partial<Protocol.Fetch.FulfillReq
7
7
  private readonly contentType;
8
8
  private readonly contentLength;
9
9
  constructor(body: string | Buffer, contentType?: string, headers?: Protocol.Fetch.HeaderEntry[], status?: number);
10
+ /**
11
+ * Epoch ms parsed from the `last-modified` header (set when the response was
12
+ * built), or 0 if absent/unparseable. Used to order cache entries by recency.
13
+ */
14
+ getLastModified(): number;
10
15
  setHeader(name: string, value: string): void;
11
16
  protected addCustomHeaders(headers?: Protocol.Fetch.HeaderEntry[]): Protocol.Fetch.HeaderEntry[];
12
17
  }
@@ -23,6 +23,15 @@ export class BaqendResponse {
23
23
  ? body.toString("base64")
24
24
  : Buffer.from(body).toString("base64");
25
25
  }
26
+ /**
27
+ * Epoch ms parsed from the `last-modified` header (set when the response was
28
+ * built), or 0 if absent/unparseable. Used to order cache entries by recency.
29
+ */
30
+ getLastModified() {
31
+ const header = this.responseHeaders.find((h) => h.name.toLowerCase() === "last-modified");
32
+ const timestamp = header ? Date.parse(header.value) : NaN;
33
+ return Number.isNaN(timestamp) ? 0 : timestamp;
34
+ }
26
35
  setHeader(name, value) {
27
36
  for (const header of this.responseHeaders) {
28
37
  if (header.name === name) {
@@ -1,21 +1,20 @@
1
1
  import { CDPSession } from "puppeteer";
2
2
  import { Protocol } from "puppeteer-core";
3
- import { CliService } from "../../cli/index.js";
4
3
  import { CustomerConfig } from "../../integration-api/index.js";
5
4
  import { AbortResponse } from "../browser/abort-response.js";
6
5
  import { FetchRequestPausedEventHandler } from "../onboarding-model.js";
7
- import { Cache } from "../request-cache/cache.js";
6
+ import { VirtualOrestesApp } from "../virtual-orestes-app/index.js";
8
7
  /**
9
8
  * Intercepts v1/rum/pi requests,
10
- * if request contains changeDetection, clear dh-cache for respective url
9
+ * if request contains changeDetection, refresh dh-cache for respective url
10
+ * in the background
11
11
  *
12
12
  * @implements FetchRequestPausedEventHandler
13
13
  */
14
14
  export declare class SpeedKitRumPiRequest implements FetchRequestPausedEventHandler {
15
15
  private customerConfig;
16
- private cache;
17
- private cli;
16
+ private orestesApp;
18
17
  readonly patterns: Protocol.Fetch.RequestPattern[];
19
- constructor(customerConfig: CustomerConfig, cache: Cache, cli: CliService);
18
+ constructor(customerConfig: CustomerConfig, orestesApp: VirtualOrestesApp);
20
19
  handle(event: Protocol.Fetch.RequestPausedEvent, _: CDPSession): Promise<AbortResponse | Partial<Protocol.Fetch.FulfillRequestRequest> | null>;
21
20
  }
@@ -1,19 +1,18 @@
1
1
  import { safe } from "../../../helpers/safe.js";
2
2
  /**
3
3
  * Intercepts v1/rum/pi requests,
4
- * if request contains changeDetection, clear dh-cache for respective url
4
+ * if request contains changeDetection, refresh dh-cache for respective url
5
+ * in the background
5
6
  *
6
7
  * @implements FetchRequestPausedEventHandler
7
8
  */
8
9
  export class SpeedKitRumPiRequest {
9
10
  customerConfig;
10
- cache;
11
- cli;
11
+ orestesApp;
12
12
  patterns;
13
- constructor(customerConfig, cache, cli) {
13
+ constructor(customerConfig, orestesApp) {
14
14
  this.customerConfig = customerConfig;
15
- this.cache = cache;
16
- this.cli = cli;
15
+ this.orestesApp = orestesApp;
17
16
  this.patterns = [
18
17
  {
19
18
  requestStage: "Request",
@@ -33,7 +32,7 @@ export class SpeedKitRumPiRequest {
33
32
  return;
34
33
  }
35
34
  const changeDetection = result.data.changeDetection;
36
- this.cache.removeEntry(changeDetection.url);
37
- this.cli.write(`changeDetection triggered for url: ${changeDetection.url} - cleared from dh-cache`);
35
+ // refresh in the background - do not block the intercepted rum/pi request
36
+ void this.orestesApp.refreshUrl(changeDetection.url);
38
37
  }
39
38
  }
@@ -4,6 +4,7 @@ import { DocumentHandlerServer } from "../../document-handler-runtime/document-h
4
4
  import { Cache } from "../request-cache/cache.js";
5
5
  import { ConfigFileInterface, CustomerConfig, FileListInterface } from "../../integration-api/index.js";
6
6
  import { BrowserContext } from "../onboarding-model.js";
7
+ import { VirtualOrestesApp } from "../virtual-orestes-app/index.js";
7
8
  /**
8
9
  * Add different callbacks that will be executed if a local file is changed.
9
10
  */
@@ -14,7 +15,18 @@ export declare class FileWatcher {
14
15
  private documentHandler;
15
16
  private browserContext;
16
17
  private cache;
18
+ private cacheRefresher?;
17
19
  constructor(cli: CliService, customerConfig: CustomerConfig, files: FileListInterface, documentHandler: DocumentHandlerServer, browserContext: BrowserContext, cache: Cache);
20
+ setCacheRefresher(cacheRefresher: VirtualOrestesApp): void;
21
+ /**
22
+ * Drop the dh-cache after a config change. When a cache refresher is wired
23
+ * (local mode) the most recent entries are re-warmed in the background so the
24
+ * developer's current pages stay instant and fresh; otherwise the cache is
25
+ * simply cleared.
26
+ *
27
+ * @private
28
+ */
29
+ private clearOrRefreshCache;
18
30
  /**
19
31
  * will perform various actions based on the changed file
20
32
  *
@@ -12,6 +12,9 @@ export class FileWatcher {
12
12
  documentHandler;
13
13
  browserContext;
14
14
  cache;
15
+ // Set in local mode (see OnboardingServiceFactory). When present, config
16
+ // changes re-warm the dh-cache in the background instead of just clearing it.
17
+ cacheRefresher;
15
18
  constructor(cli, customerConfig, files, documentHandler, browserContext, cache) {
16
19
  this.cli = cli;
17
20
  this.customerConfig = customerConfig;
@@ -20,6 +23,25 @@ export class FileWatcher {
20
23
  this.browserContext = browserContext;
21
24
  this.cache = cache;
22
25
  }
26
+ setCacheRefresher(cacheRefresher) {
27
+ this.cacheRefresher = cacheRefresher;
28
+ }
29
+ /**
30
+ * Drop the dh-cache after a config change. When a cache refresher is wired
31
+ * (local mode) the most recent entries are re-warmed in the background so the
32
+ * developer's current pages stay instant and fresh; otherwise the cache is
33
+ * simply cleared.
34
+ *
35
+ * @private
36
+ */
37
+ clearOrRefreshCache() {
38
+ if (this.cacheRefresher) {
39
+ // fire-and-forget: refresh clears the cache first, then re-warms
40
+ void this.cacheRefresher.refreshRecent(10);
41
+ return;
42
+ }
43
+ this.cache.clear();
44
+ }
23
45
  /**
24
46
  * will perform various actions based on the changed file
25
47
  *
@@ -157,7 +179,7 @@ export class FileWatcher {
157
179
  this.cli.writeWarning(`changed file: ${file.name}`);
158
180
  await this.safeClearPageBrowsersCacheStorage(page);
159
181
  await this.documentHandler.buildDocumentHandler();
160
- this.cache.clear();
182
+ this.clearOrRefreshCache();
161
183
  await installResource.buildContent();
162
184
  }
163
185
  /**
@@ -170,7 +192,7 @@ export class FileWatcher {
170
192
  async onChangeDynamicStyleCallback(file, page) {
171
193
  this.cli.writeWarning(`changed file: ${file.name}`);
172
194
  await this.documentHandler.buildDocumentHandler();
173
- this.cache.clear();
195
+ this.clearOrRefreshCache();
174
196
  const escapedStyles = encodeURI(file.getContent());
175
197
  const evaluateResult = await safe(race(Promise.all([
176
198
  page.evaluate(this.updateStylesInlineFunction, escapedStyles),
@@ -83,7 +83,7 @@ export class OnboardingServiceFactory {
83
83
  const fileWatcher = new FileWatcher(cli, customerConfig, files, documentHandler, browserContext, cache);
84
84
  const athenaClient = (await this.getAthenaClient(customerConfig.app)) || null;
85
85
  const messageHandler = await this.prepareMessagehandler(athenaClient, files, customerConfig);
86
- const fetchHandler = await this.getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler);
86
+ const fetchHandler = await this.getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher);
87
87
  const extensionBridge = new ExtensionBridge(cli);
88
88
  return new OnboardingService(browserContext, customerConfig, fetchHandler, fileWatcher, cli, messageHandler, extensionBridge);
89
89
  }
@@ -145,7 +145,7 @@ export class OnboardingServiceFactory {
145
145
  const configApiContext = new ConfigApiContext(app);
146
146
  return new ConfigApiServiceFactory(configApiContext).getService();
147
147
  }
148
- async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler) {
148
+ async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher) {
149
149
  const configApi = this.getConfigApi(customerConfig.app);
150
150
  const serverConfig = await this.getSpeedKitServerConfig(configApi, cli);
151
151
  const agent = this.getCrawlerAgent(customerConfig, cli);
@@ -167,7 +167,9 @@ export class OnboardingServiceFactory {
167
167
  ];
168
168
  if (this.context.local) {
169
169
  const orestesApp = new VirtualOrestesApp(customerConfig, crawler, documentHandler, cache, cli, messageHandler);
170
- handlers.push(new SpeedKitAssetRequest(customerConfig, orestesApp), new SpeedKitRumPiRequest(customerConfig, cache, cli));
170
+ // let config-file changes re-warm the dh-cache in the background
171
+ fileWatcher.setCacheRefresher(orestesApp);
172
+ handlers.push(new SpeedKitAssetRequest(customerConfig, orestesApp), new SpeedKitRumPiRequest(customerConfig, orestesApp));
171
173
  }
172
174
  return new FetchEventHandler(handlers, customerConfig, cli, configApi);
173
175
  }
@@ -16,6 +16,36 @@ export declare class VirtualOrestesApp {
16
16
  private localCacheErrors;
17
17
  constructor(customerConfig: CustomerConfig, crawler: Crawler, documentHandler: DocumentHandlerServer, cache: Cache, cli: CliService, developmentToolsMessages: DevtoolsExtensionApi);
18
18
  fetchAssetResponse(event: Protocol.Fetch.RequestPausedEvent): Promise<Partial<Protocol.Fetch.FulfillRequestRequest> | AbortResponse>;
19
+ /**
20
+ * Re-warm all cached entries for the given origin url in the background.
21
+ *
22
+ * Used when Speed Kit reports a changeDetection for a url: instead of just
23
+ * dropping the entry (which forces a cache-miss on the next request), we
24
+ * rebuild every cached variation of that url in place so the next request is
25
+ * an instant, fresh HIT. Fire-and-forget by design.
26
+ *
27
+ * @param originUrl the origin url reported by changeDetection
28
+ */
29
+ refreshUrl(originUrl: string): Promise<void>;
30
+ /**
31
+ * Clear the whole dh-cache, then re-warm the most recently cached entries in
32
+ * the background (ordered by their `last-modified` header). Used as a
33
+ * replacement for a bare cache clear on config changes: stale entries are
34
+ * dropped, but the last `limit` pages are rebuilt proactively so the
35
+ * developer's current pages stay instant and fresh.
36
+ *
37
+ * @param limit how many of the most recently cached entries to re-warm
38
+ */
39
+ refreshRecent(limit?: number): Promise<void>;
40
+ /**
41
+ * Rebuild a single cache entry (by its full asset request url), replacing the
42
+ * previously cached response. On failure the stale entry is dropped so we
43
+ * never keep serving outdated content.
44
+ *
45
+ * @param requestUrl the full asset request url used as the cache key
46
+ */
47
+ private refreshEntry;
48
+ private buildResponse;
19
49
  private getResponseText;
20
50
  private localCacheErrorKey;
21
51
  private recordLocalCacheError;
@@ -28,15 +28,78 @@ export class VirtualOrestesApp {
28
28
  this.developmentToolsMessages = developmentToolsMessages;
29
29
  }
30
30
  async fetchAssetResponse(event) {
31
- const originUrl = this.getAssetUrl(event.request.url);
32
- const UrlObject = new URL(event.request.url);
33
- const variation = UrlObject.searchParams.has("bqvariation")
34
- ? UrlObject.searchParams.get("bqvariation").trim()
35
- : "default";
36
31
  const cachedResponse = this.getCachedResponse(event.request.url);
37
32
  if (cachedResponse instanceof BaqendResponse) {
38
33
  return cachedResponse;
39
34
  }
35
+ return this.buildResponse(event.request.url);
36
+ }
37
+ /**
38
+ * Re-warm all cached entries for the given origin url in the background.
39
+ *
40
+ * Used when Speed Kit reports a changeDetection for a url: instead of just
41
+ * dropping the entry (which forces a cache-miss on the next request), we
42
+ * rebuild every cached variation of that url in place so the next request is
43
+ * an instant, fresh HIT. Fire-and-forget by design.
44
+ *
45
+ * @param originUrl the origin url reported by changeDetection
46
+ */
47
+ async refreshUrl(originUrl) {
48
+ const matchingKeys = [...this.cache.getAll()]
49
+ .map(([key]) => key)
50
+ .filter((key) => key.includes(originUrl));
51
+ if (matchingKeys.length === 0) {
52
+ this.cli.write(`changeDetection for url: ${originUrl} - nothing cached to refresh`);
53
+ return;
54
+ }
55
+ this.cli.write(`changeDetection triggered for url: ${originUrl} - refreshing dh-cache in background`);
56
+ for (const key of matchingKeys) {
57
+ await this.refreshEntry(key);
58
+ }
59
+ }
60
+ /**
61
+ * Clear the whole dh-cache, then re-warm the most recently cached entries in
62
+ * the background (ordered by their `last-modified` header). Used as a
63
+ * replacement for a bare cache clear on config changes: stale entries are
64
+ * dropped, but the last `limit` pages are rebuilt proactively so the
65
+ * developer's current pages stay instant and fresh.
66
+ *
67
+ * @param limit how many of the most recently cached entries to re-warm
68
+ */
69
+ async refreshRecent(limit = 10) {
70
+ const recentKeys = [...this.cache.getAll()]
71
+ .sort(([, a], [, b]) => b.getLastModified() - a.getLastModified())
72
+ .slice(0, limit)
73
+ .map(([key]) => key);
74
+ this.cache.clear();
75
+ if (recentKeys.length === 0) {
76
+ return;
77
+ }
78
+ this.cli.write(`refreshing ${recentKeys.length} recent dh-cache entries in background`);
79
+ for (const key of recentKeys) {
80
+ await this.refreshEntry(key);
81
+ }
82
+ }
83
+ /**
84
+ * Rebuild a single cache entry (by its full asset request url), replacing the
85
+ * previously cached response. On failure the stale entry is dropped so we
86
+ * never keep serving outdated content.
87
+ *
88
+ * @param requestUrl the full asset request url used as the cache key
89
+ */
90
+ async refreshEntry(requestUrl) {
91
+ const result = await safe(this.buildResponse(requestUrl));
92
+ if (result.success === false) {
93
+ this.cache.removeEntry(requestUrl);
94
+ this.cli.writeError(`failed to refresh dh-cache entry ${requestUrl}: ${result.error}`);
95
+ }
96
+ }
97
+ async buildResponse(requestUrl) {
98
+ const originUrl = this.getAssetUrl(requestUrl);
99
+ const UrlObject = new URL(requestUrl);
100
+ const variation = UrlObject.searchParams.has("bqvariation")
101
+ ? UrlObject.searchParams.get("bqvariation").trim()
102
+ : "default";
40
103
  const response = await this.crawler.fetchRemote(originUrl, variation);
41
104
  // todo check if we can handle more then htmlResponses
42
105
  // only handle html responses
@@ -49,21 +112,21 @@ export class VirtualOrestesApp {
49
112
  const customHeaders = this.prepareCustomHeaders(originUrl, hasNoStoreFlag);
50
113
  // CDN Redirect
51
114
  if (originUrl !== response.url) {
52
- return this.handleCdnRedirect(customHeaders, response, event);
115
+ return this.handleCdnRedirect(customHeaders, response, requestUrl);
53
116
  }
54
117
  if (response.status > 300 && response.status < 400) {
55
- return this.returnRedirect(customHeaders, response, event);
118
+ return this.returnRedirect(customHeaders, response, requestUrl);
56
119
  }
57
120
  const { responseContent, textEncoding } = await this.getResponseText(response);
58
121
  if (!this.isHtmlContentHeader(response?.headers) ||
59
122
  !this.isValidHtmlContent(responseContent)) {
60
123
  const customResponse = new BaqendResponse(responseContent, `text/html; charset=${textEncoding}`, customHeaders);
61
- this.cache.addEntry(event.request.url, customResponse);
124
+ this.cache.addEntry(requestUrl, customResponse);
62
125
  return customResponse;
63
126
  }
64
127
  const customResponse = await safe(this.documentHandler.transform(`text/html;charset=${textEncoding}`, responseContent, variation, originUrl, this.convertResponseHeadersToOrestesFormat(response)));
65
128
  if (customResponse.success === false) {
66
- this.recordLocalCacheError(originUrl, event.request.url, variation, customResponse);
129
+ this.recordLocalCacheError(originUrl, requestUrl, variation, customResponse);
67
130
  return this.returnErrorResponse(customResponse, customHeaders);
68
131
  }
69
132
  this.clearLocalCacheError(originUrl, variation);
@@ -88,7 +151,7 @@ export class VirtualOrestesApp {
88
151
  const baqendResponse = new BaqendResponse(Buffer.isBuffer(skResponse.body)
89
152
  ? skResponse.body
90
153
  : iconv.encode(skResponse.body, textEncoding), skResponse.headers["content-type"] ?? `text/html;charset=${textEncoding}`, [...customHeaders, ...originHeaders]);
91
- this.cache.addEntry(event.request.url, baqendResponse);
154
+ this.cache.addEntry(requestUrl, baqendResponse);
92
155
  return baqendResponse;
93
156
  }
94
157
  async getResponseText(response) {
@@ -159,16 +222,16 @@ export class VirtualOrestesApp {
159
222
  });
160
223
  return convertedResponseHeaders;
161
224
  }
162
- returnRedirect(customHeaders, response, event) {
225
+ returnRedirect(customHeaders, response, requestUrl) {
163
226
  customHeaders.push({
164
227
  name: "location",
165
228
  value: response?.headers.get("location") || "",
166
229
  });
167
230
  const redirectResponse = new BaqendResponse("", null, customHeaders, response.status);
168
- this.cache.addEntry(event.request.url, redirectResponse);
231
+ this.cache.addEntry(requestUrl, redirectResponse);
169
232
  return redirectResponse;
170
233
  }
171
- handleCdnRedirect(customHeaders, response, event) {
234
+ handleCdnRedirect(customHeaders, response, requestUrl) {
172
235
  customHeaders.push({
173
236
  name: "x-debug-response-url",
174
237
  value: response.url,
@@ -181,7 +244,7 @@ export class VirtualOrestesApp {
181
244
  });
182
245
  serverTimingHeader.value += ",error;desc=202";
183
246
  const redirectResponse = new BaqendResponse("", null, customHeaders, 400);
184
- this.cache.addEntry(event.request.url, redirectResponse);
247
+ this.cache.addEntry(requestUrl, redirectResponse);
185
248
  return redirectResponse;
186
249
  }
187
250
  getAssetUrl(requestUrl) {
@@ -822,6 +822,39 @@
822
822
  "wpt-launcher.js"
823
823
  ]
824
824
  },
825
+ "autocomplete:fish": {
826
+ "aliases": [],
827
+ "args": {},
828
+ "description": "Generate fish shell completions and install them to ~/.config/fish/completions.",
829
+ "examples": [
830
+ "$ sk autocomplete fish",
831
+ "$ sk autocomplete fish --print > sk.fish"
832
+ ],
833
+ "flags": {
834
+ "print": {
835
+ "char": "p",
836
+ "description": "Print the completion script to stdout instead of installing it",
837
+ "name": "print",
838
+ "allowNo": false,
839
+ "type": "boolean"
840
+ }
841
+ },
842
+ "hasDynamicHelp": false,
843
+ "hiddenAliases": [],
844
+ "id": "autocomplete:fish",
845
+ "pluginAlias": "@speedkit/cli",
846
+ "pluginName": "@speedkit/cli",
847
+ "pluginType": "core",
848
+ "strict": true,
849
+ "enableJsonFlag": false,
850
+ "isESM": true,
851
+ "relativePath": [
852
+ "dist",
853
+ "commands",
854
+ "autocomplete",
855
+ "fish.js"
856
+ ]
857
+ },
825
858
  "config:edit": {
826
859
  "aliases": [],
827
860
  "args": {},
@@ -847,5 +880,5 @@
847
880
  ]
848
881
  }
849
882
  },
850
- "version": "4.15.1"
883
+ "version": "4.17.0"
851
884
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@speedkit/cli",
3
3
  "description": "Speed Kit CLI",
4
- "version": "4.15.1",
4
+ "version": "4.17.0",
5
5
  "author": {
6
6
  "name": "Baqend.com",
7
7
  "email": "info@baqend.com"