roxxie-proxy-u-prod 0.2.2 → 0.2.3

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/embed.js CHANGED
@@ -3,7 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  var DEFAULT_SHELL_URL =
6
- "https://unpkg.com/roxxie-proxy-u-prod@0.2.2/runtime/index.html";
6
+ "https://unpkg.com/roxxie-proxy-u-prod@0.2.3/runtime/index.html";
7
7
  var loadingScript = document.currentScript;
8
8
 
9
9
  function resolveContainer(target) {
@@ -18,8 +18,8 @@
18
18
  <div id="roxxie-browser"></div>
19
19
  <script
20
20
  defer
21
- src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.2/embed.js"
22
- integrity="sha384-3QVF5CQiWk0SKqCDSEAoupN0KXK0bxo9qaZeS5Tj8/8mO8J719dv+fvmrqmdthH2"
21
+ src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/embed.js"
22
+ integrity="sha384-yu0TlvCJ9LcKoOG66anQWT/iHEz3uC7rUGLmaDZV9otcFcWsQ6EvX8y2GPuxFsOH"
23
23
  crossorigin="anonymous"
24
24
  data-roxxie-target="#roxxie-browser"
25
25
  ></script>
package/package.json CHANGED
@@ -1,44 +1,43 @@
1
1
  {
2
- "name": "roxxie-proxy-u-prod",
3
- "version": "0.2.2",
4
- "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
- "license": "AGPL-3.0-only",
6
- "private": false,
7
- "main": "embed.js",
8
- "browser": "embed.js",
9
- "jsdelivr": "embed.js",
10
- "unpkg": "embed.js",
11
- "files": [
12
- "dist",
13
- "runtime",
14
- "examples",
15
- "scripts",
16
- "source",
17
- "third_party",
18
- "embed.js",
19
- "standalone.html",
20
- "LICENSE",
21
- "SOURCE.md",
22
- "THIRD_PARTY_NOTICES.md",
23
- "README.md"
24
- ],
25
- "scripts": {
26
- "build": "node scripts/build.mjs",
27
- "smoke": "node scripts/smoke.mjs",
28
- "verify": "node scripts/verify.mjs",
29
- "prepack": "npm run verify"
30
- },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
- "devDependencies": {
35
- "playwright": "1.59.1"
36
- },
37
- "keywords": [
38
- "roxxie",
39
- "browser",
40
- "proxy",
41
- "webrtc",
42
- "embed"
43
- ]
44
- }
2
+ "name": "roxxie-proxy-u-prod",
3
+ "version": "0.2.3",
4
+ "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
+ "license": "AGPL-3.0-only",
6
+ "private": false,
7
+ "main": "embed.js",
8
+ "browser": "embed.js",
9
+ "jsdelivr": "embed.js",
10
+ "unpkg": "embed.js",
11
+ "files": [
12
+ "dist",
13
+ "runtime",
14
+ "examples",
15
+ "scripts",
16
+ "source",
17
+ "third_party",
18
+ "embed.js",
19
+ "standalone.html",
20
+ "LICENSE",
21
+ "SOURCE.md",
22
+ "THIRD_PARTY_NOTICES.md",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "devDependencies": {
29
+ "playwright": "1.59.1"
30
+ },
31
+ "keywords": [
32
+ "roxxie",
33
+ "browser",
34
+ "proxy",
35
+ "webrtc",
36
+ "embed"
37
+ ],
38
+ "scripts": {
39
+ "build": "node scripts/build.mjs",
40
+ "smoke": "node scripts/smoke.mjs",
41
+ "verify": "node scripts/verify.mjs"
42
+ }
43
+ }
@@ -2,14 +2,14 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.2/dist/favicon.png" />
5
+ <link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/favicon.png" />
6
6
  <meta name="theme-color" content="#222230" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>Roxxie Proxy U</title>
9
9
 
10
10
  <!--ssr-head-->
11
- <script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.2/dist/assets/index-CttvagWK.js"></script>
12
- <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.2/dist/assets/index-EeRhLAof.css">
11
+ <script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-BQkXFDet.js"></script>
12
+ <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-EeRhLAof.css">
13
13
  </head>
14
14
 
15
15
  <body>
@@ -118,8 +118,8 @@ if (
118
118
  throw new Error("Start screen must remain flat and glow-free");
119
119
  }
120
120
 
121
- if (manifest.version !== "0.2.2") {
122
- throw new Error("This production release must be version 0.2.2");
121
+ if (manifest.version !== "0.2.3") {
122
+ throw new Error("This production release must be version 0.2.3");
123
123
  }
124
124
 
125
125
  const embedBytes = await readFile(join(packageRoot, "embed.js"));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports-adrift-protocol",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Browser-safe wire types and codecs for Roxxie Proxy U transports",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -18,7 +18,7 @@
18
18
  "test": "npm run build && node --test dist/test/*.test.js"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=18"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
@@ -47,6 +47,17 @@ export const S2CRequestTypes = {
47
47
  WSDataEnd: 8,
48
48
  WSError: 9,
49
49
  Pong: 10,
50
+ /**
51
+ * Terminates a response whose body was cut short after the headers were
52
+ * already delivered. `HTTPResponseEnd` means "the body is complete", so it
53
+ * must never be used for a truncated stream: the client cannot tell a
54
+ * partial body from a whole one and will happily parse and cache it.
55
+ *
56
+ * Purely additive, so `PROTOCOL_VERSION` stays at 6.0. A node that sends
57
+ * this to an older client makes that client fail the session loudly, which
58
+ * is strictly better than the silent corruption it replaces.
59
+ */
60
+ HTTPResponseError: 11,
50
61
  } as const;
51
62
 
52
63
  export type ObjectValue<T> = T[keyof T];
@@ -62,7 +62,7 @@ export class Tab extends StatefulClass {
62
62
  this.url ??= new URL(`${INTERNAL_URL_PROTOCOL}//newtab`);
63
63
  this.id ??= uuid("tab-");
64
64
 
65
- this.frame = new ProxyFrame();
65
+ this.frame = new ProxyFrame(() => this.commitExternalNavigation());
66
66
  this.history = new History(this, history);
67
67
  this.own(this.history);
68
68
  this.waitForInit = new Promise((resolve) => {
@@ -135,6 +135,7 @@ export class Tab extends StatefulClass {
135
135
 
136
136
  // only caller should be history.ts for this
137
137
  _directnavigate(url: URL) {
138
+ this.frame.cancelPendingNavigation();
138
139
  this.url = url;
139
140
  this.icon = defaultFaviconUrl;
140
141
  if (url.protocol == INTERNAL_URL_PROTOCOL) {
@@ -188,6 +189,13 @@ export class Tab extends StatefulClass {
188
189
  }
189
190
 
190
191
  initialLoad() {
192
+ this.commitExternalNavigation();
193
+ }
194
+
195
+ private commitExternalNavigation() {
196
+ // A late response from a page we have already left must not hide a newly
197
+ // selected internal page.
198
+ if (this.url.protocol === INTERNAL_URL_PROTOCOL) return;
191
199
  this.initResolve();
192
200
  this.internalpage = null;
193
201
  }
@@ -15,6 +15,7 @@ import {
15
15
  import { HttpCachePlugin } from "./cache";
16
16
  import { adBlockPlugin } from "..";
17
17
  import { runtimeAssetUrl } from "../runtime-base.ts";
18
+ import { navigationCommitTracker } from "./navigation";
18
19
 
19
20
  export function makeId(): string {
20
21
  return Math.random().toString(36).substring(2, 10);
@@ -69,6 +70,12 @@ export class Controller {
69
70
  };
70
71
 
71
72
  const fetchresp = await handlefetch(request, this);
73
+ navigationCommitTracker.observe({
74
+ destination: data.destination,
75
+ requestUrl: data.rawUrl,
76
+ status: fetchresp.status,
77
+ location: fetchresp.headers.get("location"),
78
+ });
72
79
 
73
80
  const response: TransferResponse = {
74
81
  status: fetchresp.status,
@@ -87,6 +94,11 @@ export class Controller {
87
94
  return [response, transfer];
88
95
  } catch (e: any) {
89
96
  console.error("Error in controller fetch:", e);
97
+ navigationCommitTracker.observe({
98
+ destination: data.destination,
99
+ requestUrl: data.rawUrl,
100
+ status: 500,
101
+ });
90
102
  return [
91
103
  {
92
104
  status: 500,
@@ -1,23 +1,40 @@
1
1
  import { rewriteUrl } from "@mercuryworkshop/scramjet/bundled";
2
2
  import { Controller, controllerForURL } from "./Controller";
3
+ import { navigationCommitTracker } from "./navigation";
3
4
 
4
5
  export class ProxyFrame {
5
6
  frame: HTMLIFrameElement;
6
7
  controller: Controller | null = null;
7
- constructor() {
8
+ private cancelPendingCommit: (() => void) | null = null;
9
+
10
+ constructor(private onNavigationCommit: () => void) {
8
11
  this.frame = document.createElement("iframe");
9
12
  }
10
13
 
11
14
  async go(url: URL) {
15
+ this.cancelPendingNavigation();
12
16
  let controller = await controllerForURL(url);
13
17
  this.controller = controller;
14
18
 
15
19
  const prefix = controller.prefix;
16
20
 
17
- this.frame.src = rewriteUrl(url, controller.fetchHandler.context, {
21
+ const rewrittenUrl = rewriteUrl(url, controller.fetchHandler.context, {
18
22
  origin: prefix, // origin/base don't matter here because we're always sending an absolute URL
19
23
  base: prefix,
20
24
  });
25
+ this.cancelPendingCommit = navigationCommitTracker.begin(
26
+ rewrittenUrl,
27
+ () => {
28
+ this.cancelPendingCommit = null;
29
+ this.onNavigationCommit();
30
+ }
31
+ );
32
+ this.frame.src = rewrittenUrl;
33
+ }
34
+
35
+ cancelPendingNavigation() {
36
+ this.cancelPendingCommit?.();
37
+ this.cancelPendingCommit = null;
21
38
  }
22
39
 
23
40
  reload() {
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ CACHE_NAME,
5
+ hasCompleteBufferedBody,
6
+ upstreamContentLength,
7
+ } from "./cache";
8
+
9
+ describe("HTTP cache response integrity", () => {
10
+ it("rotates the cache namespace after the incomplete-body bug", () => {
11
+ expect(CACHE_NAME).toBe("scramjet-http-cache-v3");
12
+ });
13
+
14
+ it("parses only one safe canonical Content-Length", () => {
15
+ expect(upstreamContentLength(new Headers())).toBeNull();
16
+ expect(
17
+ upstreamContentLength(new Headers({ "content-length": "1176872" }))
18
+ ).toBe(1_176_872);
19
+ expect(
20
+ upstreamContentLength(new Headers({ "content-length": "01" }))
21
+ ).toBeNull();
22
+ expect(
23
+ upstreamContentLength(new Headers({ "content-length": "1, 1" }))
24
+ ).toBeNull();
25
+ expect(
26
+ upstreamContentLength(
27
+ new Headers({ "content-length": "9007199254740992" })
28
+ )
29
+ ).toBeNull();
30
+ });
31
+
32
+ it("rejects a truncated buffered GET body", () => {
33
+ const headers = new Headers({ "content-length": "1176872" });
34
+ expect(
35
+ hasCompleteBufferedBody("GET", headers, new ArrayBuffer(861_092))
36
+ ).toBe(false);
37
+ expect(
38
+ hasCompleteBufferedBody("GET", headers, new ArrayBuffer(1_176_872))
39
+ ).toBe(true);
40
+ });
41
+
42
+ it("does not apply GET body-size semantics to HEAD", () => {
43
+ const headers = new Headers({ "content-length": "1176872" });
44
+ expect(hasCompleteBufferedBody("HEAD", headers, null)).toBe(true);
45
+ });
46
+
47
+ it("allows transfer-delimited GET bodies without Content-Length", () => {
48
+ expect(
49
+ hasCompleteBufferedBody("GET", new Headers(), new ArrayBuffer(123))
50
+ ).toBe(true);
51
+ });
52
+ });
@@ -57,7 +57,7 @@ import {
57
57
  } from "@mercuryworkshop/scramjet/bundled";
58
58
  import { BareResponse } from "@mercuryworkshop/proxy-transports";
59
59
 
60
- export const CACHE_NAME = "scramjet-http-cache-v2";
60
+ export const CACHE_NAME = "scramjet-http-cache-v3";
61
61
 
62
62
  /** Header recording when this entry entered the cache (ms since epoch). */
63
63
  const STORED_AT_HEADER = "x-sj-cached-at";
@@ -223,6 +223,33 @@ function nativeHeadersFromRaw(
223
223
  return h;
224
224
  }
225
225
 
226
+ /**
227
+ * Parse a single, valid HTTP Content-Length value. Invalid, duplicated, or
228
+ * unsafe values are ignored rather than guessed at.
229
+ */
230
+ export function upstreamContentLength(headers: Headers): number | null {
231
+ const raw = headers.get("content-length");
232
+ if (raw === null || !/^(?:0|[1-9][0-9]*)$/u.test(raw)) return null;
233
+
234
+ const parsed = Number(raw);
235
+ return Number.isSafeInteger(parsed) ? parsed : null;
236
+ }
237
+
238
+ /**
239
+ * Whether a buffered response is complete according to a valid upstream
240
+ * Content-Length. Only GET is checked: a HEAD response legitimately has no
241
+ * body even when it advertises the size a corresponding GET would have.
242
+ */
243
+ export function hasCompleteBufferedBody(
244
+ method: string,
245
+ headers: Headers,
246
+ body: ArrayBuffer | null
247
+ ): boolean {
248
+ if (method !== "GET") return true;
249
+ const expected = upstreamContentLength(headers);
250
+ return expected === null || expected === (body?.byteLength ?? 0);
251
+ }
252
+
226
253
  /** Strip our internal bookkeeping from a stored Response's headers. */
227
254
  function strippedHeadersFromStored(stored: Response): Headers {
228
255
  const out = new Headers();
@@ -351,9 +378,11 @@ export class HttpCachePlugin extends Plugin {
351
378
  if (props.earlyResponse) return;
352
379
 
353
380
  const cache = await this.openCache();
354
- const stored = await cache.match(
355
- buildCacheKeyRequest(ctx.parsed.url.href, req.initialHeaders)
381
+ const cacheKey = buildCacheKeyRequest(
382
+ ctx.parsed.url.href,
383
+ req.initialHeaders
356
384
  );
385
+ const stored = await cache.match(cacheKey);
357
386
  if (!stored) {
358
387
  return;
359
388
  }
@@ -409,6 +438,14 @@ export class HttpCachePlugin extends Plugin {
409
438
 
410
439
  const isNullBody = NULL_BODY_STATUSES.has(stored.status);
411
440
  const earlyBody = isNullBody ? null : await stored.arrayBuffer();
441
+ if (!hasCompleteBufferedBody(req.method, stored.headers, earlyBody)) {
442
+ try {
443
+ await cache.delete(cacheKey);
444
+ } catch {
445
+ console.warn("[scramjet-http-cache] invalid entry delete failed");
446
+ }
447
+ return;
448
+ }
412
449
 
413
450
  const earlyResponse = BareResponse.fromNativeResponse(
414
451
  new Response(earlyBody, {
@@ -446,6 +483,11 @@ export class HttpCachePlugin extends Plugin {
446
483
  props.response
447
484
  );
448
485
  props.response = replacement;
486
+ if (!hasCompleteBufferedBody(req.method, headers, bodyBuffer)) {
487
+ throw new TypeError(
488
+ "Upstream response body does not match its Content-Length"
489
+ );
490
+ }
449
491
 
450
492
  const cacheKey = buildCacheKeyRequest(
451
493
  ctx.parsed.url.href,
@@ -458,14 +500,16 @@ export class HttpCachePlugin extends Plugin {
458
500
  props.response.rawHeaders
459
501
  );
460
502
 
461
- try {
462
- const cache = await this.openCache();
463
- await cache.put(cacheKey, toStore);
464
- } catch (err) {
465
- // Cache.put can fail on opaque or oddly-headered responses;
466
- // don't let a cache write failure break the actual fetch.
467
- console.warn("[scramjet-http-cache] cache.put failed:", err);
468
- }
503
+ // The body has already been buffered and the pipeline has an independent
504
+ // replacement response. Persisting that copy must not hold up rewriting
505
+ // and rendering the live response.
506
+ void this.openCache()
507
+ .then(async (cache) => cache.put(cacheKey, toStore))
508
+ .catch(() => {
509
+ // Cache.put can fail on opaque or oddly-headered responses; don't
510
+ // let a cache write failure break the actual fetch.
511
+ console.warn("[scramjet-http-cache] cache.put failed");
512
+ });
469
513
  });
470
514
  }
471
515
 
@@ -0,0 +1,91 @@
1
+ /* SPDX-License-Identifier: AGPL-3.0-only */
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ NavigationCommitTracker,
7
+ isCommittableNavigationStatus,
8
+ } from "./navigation";
9
+
10
+ describe("NavigationCommitTracker", () => {
11
+ it("commits a successful external document without waiting for HTML injection", () => {
12
+ const tracker = new NavigationCommitTracker();
13
+ const commit = vi.fn();
14
+ tracker.begin("https://proxy.test/runtime/ip.me#ignored", commit);
15
+
16
+ tracker.observe({
17
+ destination: "iframe",
18
+ requestUrl: "https://proxy.test/runtime/ip.me",
19
+ status: 200,
20
+ });
21
+
22
+ expect(commit).toHaveBeenCalledOnce();
23
+ });
24
+
25
+ it("waits through redirects, including a controller-origin change", () => {
26
+ const tracker = new NavigationCommitTracker();
27
+ const commit = vi.fn();
28
+ tracker.begin("https://first-controller.test/start", commit);
29
+
30
+ tracker.observe({
31
+ destination: "iframe",
32
+ requestUrl: "https://first-controller.test/start",
33
+ status: 302,
34
+ location: "https://second-controller.test/final",
35
+ });
36
+ expect(commit).not.toHaveBeenCalled();
37
+
38
+ tracker.observe({
39
+ destination: "iframe",
40
+ requestUrl: "https://second-controller.test/final",
41
+ status: 200,
42
+ });
43
+ expect(commit).toHaveBeenCalledOnce();
44
+ });
45
+
46
+ it("does not reveal a frame for subresources or failed/empty navigations", () => {
47
+ for (const status of [204, 205, 404, 500]) {
48
+ const tracker = new NavigationCommitTracker();
49
+ const commit = vi.fn();
50
+ tracker.begin("https://proxy.test/page", commit);
51
+
52
+ tracker.observe({
53
+ destination: "script",
54
+ requestUrl: "https://proxy.test/page",
55
+ status: 200,
56
+ });
57
+ expect(commit).not.toHaveBeenCalled();
58
+
59
+ tracker.observe({
60
+ destination: "document",
61
+ requestUrl: "https://proxy.test/page",
62
+ status,
63
+ });
64
+ expect(commit).not.toHaveBeenCalled();
65
+ }
66
+ });
67
+
68
+ it("supports cancelling a navigation before its response arrives", () => {
69
+ const tracker = new NavigationCommitTracker();
70
+ const commit = vi.fn();
71
+ const cancel = tracker.begin("https://proxy.test/slow", commit);
72
+ cancel();
73
+
74
+ tracker.observe({
75
+ destination: "iframe",
76
+ requestUrl: "https://proxy.test/slow",
77
+ status: 200,
78
+ });
79
+ expect(commit).not.toHaveBeenCalled();
80
+ });
81
+ });
82
+
83
+ describe("isCommittableNavigationStatus", () => {
84
+ it("matches browser document replacement semantics for successful responses", () => {
85
+ expect(isCommittableNavigationStatus(200)).toBe(true);
86
+ expect(isCommittableNavigationStatus(206)).toBe(true);
87
+ expect(isCommittableNavigationStatus(204)).toBe(false);
88
+ expect(isCommittableNavigationStatus(205)).toBe(false);
89
+ expect(isCommittableNavigationStatus(302)).toBe(false);
90
+ });
91
+ });
@@ -0,0 +1,97 @@
1
+ /* SPDX-License-Identifier: AGPL-3.0-only */
2
+
3
+ export type NavigationResponseSignal = {
4
+ destination: RequestDestination;
5
+ requestUrl: string;
6
+ status: number;
7
+ location?: string | null;
8
+ };
9
+
10
+ type PendingNavigation = {
11
+ id: number;
12
+ expectedRequestUrl: string;
13
+ commit: () => void;
14
+ };
15
+
16
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
17
+
18
+ function networkUrl(url: string, base?: string): string {
19
+ const parsed = new URL(url, base);
20
+ parsed.hash = "";
21
+ return parsed.href;
22
+ }
23
+
24
+ export function isNavigationDestination(
25
+ destination: RequestDestination
26
+ ): boolean {
27
+ return destination === "document" || destination === "iframe";
28
+ }
29
+
30
+ export function isCommittableNavigationStatus(status: number): boolean {
31
+ // A 204 or 205 response aborts a browser navigation rather than replacing the
32
+ // current document. Treat the remaining successful responses as commits.
33
+ return status >= 200 && status < 300 && status !== 204 && status !== 205;
34
+ }
35
+
36
+ /**
37
+ * Correlates a frame navigation started by the browser UI with the response
38
+ * returned by a Scramjet controller. This is intentionally independent of the
39
+ * response MIME type: non-HTML documents never execute the injected load RPC.
40
+ */
41
+ export class NavigationCommitTracker {
42
+ private nextId = 1;
43
+ private pending: PendingNavigation[] = [];
44
+
45
+ begin(requestUrl: string, commit: () => void): () => void {
46
+ const navigation: PendingNavigation = {
47
+ id: this.nextId++,
48
+ expectedRequestUrl: networkUrl(requestUrl),
49
+ commit,
50
+ };
51
+ this.pending.push(navigation);
52
+
53
+ return () => {
54
+ this.remove(navigation.id);
55
+ };
56
+ }
57
+
58
+ observe(signal: NavigationResponseSignal): void {
59
+ if (!isNavigationDestination(signal.destination)) return;
60
+
61
+ let requestUrl: string;
62
+ try {
63
+ requestUrl = networkUrl(signal.requestUrl);
64
+ } catch {
65
+ return;
66
+ }
67
+
68
+ const navigation = this.pending.find(
69
+ (candidate) => candidate.expectedRequestUrl === requestUrl
70
+ );
71
+ if (!navigation) return;
72
+
73
+ if (REDIRECT_STATUSES.has(signal.status) && signal.location) {
74
+ try {
75
+ navigation.expectedRequestUrl = networkUrl(
76
+ signal.location,
77
+ requestUrl
78
+ );
79
+ return;
80
+ } catch {
81
+ // An invalid redirect cannot produce a successful document commit.
82
+ }
83
+ }
84
+
85
+ this.remove(navigation.id);
86
+ if (isCommittableNavigationStatus(signal.status)) {
87
+ navigation.commit();
88
+ }
89
+ }
90
+
91
+ private remove(id: number): void {
92
+ const index = this.pending.findIndex((navigation) => navigation.id === id);
93
+ if (index !== -1) this.pending.splice(index, 1);
94
+ }
95
+ }
96
+
97
+ export const navigationCommitTracker = new NavigationCommitTracker();
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { makeScramjetConfig } from "./scramjet-config";
4
+
5
+ describe("production Scramjet configuration", () => {
6
+ it("recovers unsupported scripts without enabling debug overhead", () => {
7
+ const config = makeScramjetConfig();
8
+
9
+ expect(config.flags).toMatchObject({
10
+ allowInvalidJs: true,
11
+ captureErrors: false,
12
+ debugSourceURL: false,
13
+ debugTrampolines: false,
14
+ rewriterLogs: false,
15
+ sourcemaps: false,
16
+ });
17
+ expect(config.maskedfiles).toEqual(["inject.js", "scramjet.wasm.js"]);
18
+ });
19
+ });