@speedkit/cli 4.20.3 → 4.20.4

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,10 @@
1
+ ## [4.20.4](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.3...v4.20.4) (2026-08-10)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **onboarding:** detect and repair deployed service worker takeovers ([6d61ae7](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/6d61ae71bab7dc2bbd4926d0c17fc8505d7f523e))
7
+
1
8
  ## [4.20.3](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.2...v4.20.3) (2026-08-07)
2
9
 
3
10
 
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.20.3 linux-x64 node-v22.23.2
24
+ @speedkit/cli/4.20.4 linux-x64 node-v22.23.2
25
25
  $ sk --help [COMMAND]
26
26
  USAGE
27
27
  $ sk COMMAND
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Makes a value safe for Fetch.fulfillRequest: collapse line breaks and strip
3
+ * characters outside latin-1, either of which Chrome rejects as "Invalid header".
4
+ */
5
+ function sanitizeHeaderValue(value) {
6
+ return String(value)
7
+ .replace(/[\r\n]+/g, " ")
8
+ .replace(/[^\x20-\xff\t]/g, "?");
9
+ }
1
10
  const DEFAULT_HEADERS = [
2
11
  { name: "Date", value: new Date().toUTCString() },
3
12
  { name: "Content-Encoding", value: "identity" },
@@ -24,19 +33,28 @@ export class BaqendResponse {
24
33
  : Buffer.from(body).toString("base64");
25
34
  }
26
35
  setHeader(name, value) {
36
+ const sanitized = sanitizeHeaderValue(value);
27
37
  for (const header of this.responseHeaders) {
28
38
  if (header.name === name) {
29
- header.value = value;
39
+ header.value = sanitized;
30
40
  return;
31
41
  }
32
42
  }
33
- this.responseHeaders.push({ name, value });
43
+ this.responseHeaders.push({ name, value: sanitized });
34
44
  }
35
45
  addCustomHeaders(headers) {
46
+ // Fetch.fulfillRequest rejects header values with line breaks or
47
+ // non-latin-1 characters ("Invalid header") — e.g. an error message with
48
+ // a newline placed into x-error left the request hanging in paused state.
49
+ for (const header of headers) {
50
+ header.value = sanitizeHeaderValue(header.value);
51
+ }
36
52
  if (!headers?.find((o) => o.name === "Cache-Control")) {
37
53
  headers.push({
38
54
  name: "Cache-Control",
39
- value: "max-age=0, nocache, nostore",
55
+ // NOTE: was "nocache, nostore" — invalid directive names that browsers
56
+ // ignore, leaving locally served responses cacheable.
57
+ value: "max-age=0, no-cache, no-store",
40
58
  });
41
59
  }
42
60
  const origin = headers?.find((o) => o.name.toLowerCase() === "origin")?.value;
@@ -41,10 +41,17 @@ export class FetchEventHandler {
41
41
  if (response.errorObj) {
42
42
  this.cli.writeError(`error on ${event.request.url}`);
43
43
  this.cli.writeError(`${response.errorObj.message}`);
44
- return;
45
44
  }
46
- this.cli.writeError(`error on ${target.url()}`);
47
- this.cli.writeError(JSON.stringify(response.errorObj));
45
+ else {
46
+ this.cli.writeError(`error on ${target.url()}`);
47
+ this.cli.writeError(JSON.stringify(response.errorObj));
48
+ }
49
+ // A request left in paused state hangs until the browser times out.
50
+ // Fail it explicitly so the page sees a network error instead.
51
+ await safe(cdpSession.send("Fetch.failRequest", {
52
+ requestId: event.requestId,
53
+ errorReason: "Failed",
54
+ }));
48
55
  });
49
56
  }
50
57
  canHandlePattern(fetchEventHandler, url, resourceType, requestStage) {
@@ -11,6 +11,14 @@ export declare class SpeedKitServiceWorkerJs implements FetchRequestPausedEventH
11
11
  private customerConfig;
12
12
  private serviceWorker;
13
13
  readonly patterns: Protocol.Fetch.RequestPattern[];
14
+ /**
15
+ * Timestamp of the last locally served sw.js. Chrome's SW update checker
16
+ * fetches scripts in the browser process, outside CDP Fetch interception —
17
+ * an update that slips through installs the DEPLOYED build. A SW version
18
+ * that activates without a recent local serve is such a takeover; the
19
+ * OnboardingService uses this timestamp to detect and repair it.
20
+ */
21
+ lastServedAt: number;
14
22
  constructor(customerConfig: CustomerConfig, serviceWorker: FileInterface);
15
23
  handle(): Promise<Partial<Protocol.Fetch.FulfillRequestRequest>>;
16
24
  }
@@ -9,6 +9,14 @@ export class SpeedKitServiceWorkerJs {
9
9
  customerConfig;
10
10
  serviceWorker;
11
11
  patterns;
12
+ /**
13
+ * Timestamp of the last locally served sw.js. Chrome's SW update checker
14
+ * fetches scripts in the browser process, outside CDP Fetch interception —
15
+ * an update that slips through installs the DEPLOYED build. A SW version
16
+ * that activates without a recent local serve is such a takeover; the
17
+ * OnboardingService uses this timestamp to detect and repair it.
18
+ */
19
+ lastServedAt = 0;
12
20
  constructor(customerConfig, serviceWorker) {
13
21
  this.customerConfig = customerConfig;
14
22
  this.serviceWorker = serviceWorker;
@@ -20,6 +28,7 @@ export class SpeedKitServiceWorkerJs {
20
28
  ];
21
29
  }
22
30
  async handle() {
31
+ this.lastServedAt = Date.now();
23
32
  return new BaqendResponse(this.serviceWorker.getContent(), "text/javascript; charset=utf-8");
24
33
  }
25
34
  }
@@ -16,6 +16,7 @@ export declare const CDP_SESSION: {
16
16
  EVENTS: {
17
17
  SERVICE_WORKER: {
18
18
  REGISTRATION_UPDATED: string;
19
+ VERSION_UPDATED: string;
19
20
  };
20
21
  };
21
22
  MESSAGE: {
@@ -41,6 +41,7 @@ export const CDP_SESSION = {
41
41
  EVENTS: {
42
42
  SERVICE_WORKER: {
43
43
  REGISTRATION_UPDATED: "ServiceWorker.workerRegistrationUpdated",
44
+ VERSION_UPDATED: "ServiceWorker.workerVersionUpdated",
44
45
  },
45
46
  },
46
47
  MESSAGE: {
@@ -83,9 +83,10 @@ 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, fileWatcher);
86
+ const speedKitServiceWorkerJs = new SpeedKitServiceWorkerJs(customerConfig, files.getByName(INTEGRATION_FILES.CUSTOM.SW));
87
+ const fetchHandler = await this.getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher, speedKitServiceWorkerJs);
87
88
  const extensionBridge = new ExtensionBridge(cli);
88
- return new OnboardingService(browserContext, customerConfig, fetchHandler, fileWatcher, cli, messageHandler, extensionBridge);
89
+ return new OnboardingService(browserContext, customerConfig, fetchHandler, fileWatcher, cli, messageHandler, extensionBridge, speedKitServiceWorkerJs);
89
90
  }
90
91
  async prepareMessagehandler(athenaClient, files, customerConfig) {
91
92
  const messageHandler = new DevtoolsExtensionApi();
@@ -145,7 +146,7 @@ export class OnboardingServiceFactory {
145
146
  const configApiContext = new ConfigApiContext(app);
146
147
  return new ConfigApiServiceFactory(configApiContext).getService();
147
148
  }
148
- async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher) {
149
+ async getFetchEventHandler(files, customerConfig, documentHandler, cache, cli, browserContext, athenaClient, messageHandler, fileWatcher, speedKitServiceWorkerJs) {
149
150
  const configApi = this.getConfigApi(customerConfig.app);
150
151
  const serverConfig = await this.getSpeedKitServerConfig(configApi, cli);
151
152
  const agent = this.getCrawlerAgent(customerConfig, cli);
@@ -162,7 +163,7 @@ export class OnboardingServiceFactory {
162
163
  new SpeedKitInstallHtml(customerConfig, browserContext, new InstallSpeedKitHtmlTemplate()),
163
164
  new SpeedKitInstallJs(customerConfig, files.getByName(INTEGRATION_FILES.BUILD.INSTALL)),
164
165
  new CustomerServiceWorkerJs(customerConfig),
165
- new SpeedKitServiceWorkerJs(customerConfig, files.getByName(INTEGRATION_FILES.CUSTOM.SW)),
166
+ speedKitServiceWorkerJs,
166
167
  new DashboardRequest(new Dashboard(customerConfig, parameterQueryBuilder, athenaClient, requestDiffService, new DiffAgainstCurrentPage(DiffService, documentHandler, crawler, cli), cache)),
167
168
  ];
168
169
  if (this.context.local) {
@@ -1,6 +1,7 @@
1
1
  import { CustomerConfig } from "../integration-api/index.js";
2
2
  import { BrowserContext } from "./onboarding-model.js";
3
3
  import { FetchEventHandler } from "./fetch-event-handler.js";
4
+ import { SpeedKitServiceWorkerJs } from "./fetch-events/speed-kit-service-worker-js.js";
4
5
  import { FileWatcher } from "./file-events/file-watcher.js";
5
6
  import { CliService } from "../cli/index.js";
6
7
  import { DevtoolsExtensionApi } from "./browser/extension/devtools-extension-api.js";
@@ -16,8 +17,11 @@ export declare class OnboardingService {
16
17
  private cli;
17
18
  private developmentToolsMessages;
18
19
  private extensionBridge;
20
+ private speedKitServiceWorkerJs;
19
21
  private reinstallOnNextNavigate;
20
- constructor(browserContext: BrowserContext, customerConfig: CustomerConfig, fetchEventHandler: FetchEventHandler, fileWatcher: FileWatcher, cli: CliService, developmentToolsMessages: DevtoolsExtensionApi, extensionBridge: ExtensionBridge);
22
+ private lastForeignSwReinstall;
23
+ private readonly activatedSwVersions;
24
+ constructor(browserContext: BrowserContext, customerConfig: CustomerConfig, fetchEventHandler: FetchEventHandler, fileWatcher: FileWatcher, cli: CliService, developmentToolsMessages: DevtoolsExtensionApi, extensionBridge: ExtensionBridge, speedKitServiceWorkerJs: SpeedKitServiceWorkerJs);
21
25
  /**
22
26
  * Prepare the puppeteer browser, wire event listeners, navigate to the
23
27
  * install page, then block until either the caller aborts the signal
@@ -37,6 +41,26 @@ export declare class OnboardingService {
37
41
  private attachExtensionBridge;
38
42
  private findPage;
39
43
  private prepareCleanState;
44
+ /**
45
+ * Detects when the DEPLOYED service worker takes over the local session and
46
+ * repairs it by reinstalling through the install page.
47
+ *
48
+ * Chrome's SW update checker fetches the wrapper and its imported sw.js in
49
+ * the browser process, where CDP Fetch interception does not apply — those
50
+ * fetches reach production. Because the production wrapper differs from the
51
+ * locally served one, any update check that slips through installs the
52
+ * deployed build. With static routing (`activateStaticRouting`), that is no
53
+ * longer harmless: routes are frozen at install time from the deployed
54
+ * config, so pages only enabled locally bypass the SW entirely (symptom:
55
+ * `responseCause: "SwBooting"` on every navigation of those pages).
56
+ *
57
+ * Only page-initiated `register()` script fetches are interceptable, so a
58
+ * version that activates without `SpeedKitServiceWorkerJs` having served
59
+ * sw.js just before must be a takeover — reinstall via the install page,
60
+ * which goes through the intercepted path again.
61
+ */
62
+ private registerForeignSwGuard;
63
+ private isForeignSwVersion;
40
64
  private reinstallSpeedKit;
41
65
  private registerFileWatcher;
42
66
  private registerReinstallSpeedKitOnNavigate;
@@ -1,6 +1,11 @@
1
1
  import puppeteerExtra from "puppeteer-extra";
2
2
  import { BROWSER_EVENTS, CDP_SESSION, } from "./onboarding-model.js";
3
3
  import { safe } from "../../helpers/safe.js";
4
+ // A SW version activating later than this after the last locally served sw.js
5
+ // cannot originate from our interception — it is a deployed-build takeover.
6
+ const LOCAL_SW_SERVE_GRACE_MS = 10_000;
7
+ // Takeovers can retrigger on every update check — don't reinstall in a tight loop.
8
+ const FOREIGN_SW_REINSTALL_COOLDOWN_MS = 30_000;
4
9
  /**
5
10
  * Represents the OnboardingService which is responsible for setting up the browser to test a config using Puppeteer.
6
11
  */
@@ -12,8 +17,11 @@ export class OnboardingService {
12
17
  cli;
13
18
  developmentToolsMessages;
14
19
  extensionBridge;
20
+ speedKitServiceWorkerJs;
15
21
  reinstallOnNextNavigate = false;
16
- constructor(browserContext, customerConfig, fetchEventHandler, fileWatcher, cli, developmentToolsMessages, extensionBridge) {
22
+ lastForeignSwReinstall = 0;
23
+ activatedSwVersions = new Set();
24
+ constructor(browserContext, customerConfig, fetchEventHandler, fileWatcher, cli, developmentToolsMessages, extensionBridge, speedKitServiceWorkerJs) {
17
25
  this.browserContext = browserContext;
18
26
  this.customerConfig = customerConfig;
19
27
  this.fetchEventHandler = fetchEventHandler;
@@ -21,6 +29,7 @@ export class OnboardingService {
21
29
  this.cli = cli;
22
30
  this.developmentToolsMessages = developmentToolsMessages;
23
31
  this.extensionBridge = extensionBridge;
32
+ this.speedKitServiceWorkerJs = speedKitServiceWorkerJs;
24
33
  }
25
34
  /**
26
35
  * Prepare the puppeteer browser, wire event listeners, navigate to the
@@ -102,6 +111,7 @@ export class OnboardingService {
102
111
  });
103
112
  await cdpSession.send("Emulation.clearDeviceMetricsOverride");
104
113
  await cdpSession.send("Emulation.clearGeolocationOverride");
114
+ this.registerForeignSwGuard(page, cdpSession);
105
115
  safe(() => {
106
116
  cdpSession.on(CDP_SESSION.EVENTS.SERVICE_WORKER.REGISTRATION_UPDATED, ({ registrations, }) => {
107
117
  for (const registration of registrations) {
@@ -116,6 +126,65 @@ export class OnboardingService {
116
126
  });
117
127
  });
118
128
  }
129
+ /**
130
+ * Detects when the DEPLOYED service worker takes over the local session and
131
+ * repairs it by reinstalling through the install page.
132
+ *
133
+ * Chrome's SW update checker fetches the wrapper and its imported sw.js in
134
+ * the browser process, where CDP Fetch interception does not apply — those
135
+ * fetches reach production. Because the production wrapper differs from the
136
+ * locally served one, any update check that slips through installs the
137
+ * deployed build. With static routing (`activateStaticRouting`), that is no
138
+ * longer harmless: routes are frozen at install time from the deployed
139
+ * config, so pages only enabled locally bypass the SW entirely (symptom:
140
+ * `responseCause: "SwBooting"` on every navigation of those pages).
141
+ *
142
+ * Only page-initiated `register()` script fetches are interceptable, so a
143
+ * version that activates without `SpeedKitServiceWorkerJs` having served
144
+ * sw.js just before must be a takeover — reinstall via the install page,
145
+ * which goes through the intercepted path again.
146
+ */
147
+ registerForeignSwGuard(page, cdpSession) {
148
+ cdpSession.on(CDP_SESSION.EVENTS.SERVICE_WORKER.VERSION_UPDATED, async ({ versions, }) => {
149
+ for (const version of versions) {
150
+ if (!this.isForeignSwVersion(version)) {
151
+ continue;
152
+ }
153
+ this.cli.writeWarning(`${this.cli.style.yellow("Deployed Service Worker took over")} (version ${version.versionId}) — an update check bypassed the local interception. Reinstalling the local build.`);
154
+ this.developmentToolsMessages.addMessage({
155
+ id: "serviceWorker",
156
+ data: `Deployed Service Worker took over (version ${version.versionId}); reinstalling the local build`,
157
+ });
158
+ if (Date.now() - this.lastForeignSwReinstall <
159
+ FOREIGN_SW_REINSTALL_COOLDOWN_MS) {
160
+ continue;
161
+ }
162
+ this.lastForeignSwReinstall = Date.now();
163
+ await safe(this.reinstallSpeedKit(page, new URL(page.url()).pathname));
164
+ }
165
+ });
166
+ }
167
+ isForeignSwVersion(version) {
168
+ if (version.status !== "activated") {
169
+ return false;
170
+ }
171
+ // Only guard the Speed Kit wrapper; ignore the empty SWs on disabledScopes
172
+ // and any site-own service workers.
173
+ if (!version.scriptURL.includes(this.customerConfig.swPath)) {
174
+ return false;
175
+ }
176
+ if (version.scriptURL.includes("empty=1")) {
177
+ return false;
178
+ }
179
+ // Activation events repeat per version (runningStatus changes) — handle each version once.
180
+ if (this.activatedSwVersions.has(version.versionId)) {
181
+ return false;
182
+ }
183
+ this.activatedSwVersions.add(version.versionId);
184
+ // A locally served sw.js right before the activation means we installed it.
185
+ return (Date.now() - this.speedKitServiceWorkerJs.lastServedAt >
186
+ LOCAL_SW_SERVE_GRACE_MS);
187
+ }
119
188
  async reinstallSpeedKit(page, pathname) {
120
189
  const url = new URL(page.url());
121
190
  let target = url.href;
@@ -1001,5 +1001,5 @@
1001
1001
  ]
1002
1002
  }
1003
1003
  },
1004
- "version": "4.20.3"
1004
+ "version": "4.20.4"
1005
1005
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@speedkit/cli",
3
3
  "description": "Speed Kit CLI",
4
- "version": "4.20.3",
4
+ "version": "4.20.4",
5
5
  "author": {
6
6
  "name": "Baqend.com",
7
7
  "email": "info@baqend.com"