roxxie-proxy-u-prod 0.2.2 → 0.2.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.
Files changed (41) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{index-CttvagWK.js → index-MRhaNu_Q.js} +195 -135
  3. package/embed.js +1 -1
  4. package/examples/single-page.html +2 -2
  5. package/package.json +42 -43
  6. package/runtime/index.html +3 -3
  7. package/scripts/verify.mjs +2 -2
  8. package/source/packages/adrift-protocol/package.json +2 -2
  9. package/source/packages/adrift-protocol/src/index.ts +11 -0
  10. package/source/packages/chrome/src/Tab/History.test.ts +137 -0
  11. package/source/packages/chrome/src/Tab/History.ts +8 -3
  12. package/source/packages/chrome/src/Tab/Tab.tsx +13 -2
  13. package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
  14. package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
  15. package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
  16. package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
  17. package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
  18. package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
  19. package/source/packages/chrome/src/pages/nodes.ts +108 -0
  20. package/source/packages/chrome/src/proxy/Controller.ts +12 -0
  21. package/source/packages/chrome/src/proxy/ProxyFrame.ts +19 -2
  22. package/source/packages/chrome/src/proxy/cache.test.ts +52 -0
  23. package/source/packages/chrome/src/proxy/cache.ts +55 -11
  24. package/source/packages/chrome/src/proxy/navigation.test.ts +91 -0
  25. package/source/packages/chrome/src/proxy/navigation.ts +97 -0
  26. package/source/packages/chrome/src/proxy/scramjet-config.test.ts +19 -0
  27. package/source/packages/chrome/src/proxy/scramjet-config.ts +26 -0
  28. package/source/packages/chrome/src/proxy/scramjet.ts +5 -18
  29. package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
  30. package/source/packages/chrome/src/services/SettingsService.ts +8 -1
  31. package/source/packages/roxxie-transport/package.json +1 -1
  32. package/source/packages/roxxie-transport/src/connection.ts +18 -1
  33. package/source/packages/roxxie-transport/src/transport.ts +51 -7
  34. package/source/packages/scramjet/packages/core/src/fetch/fetch.ts +7 -0
  35. package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
  36. package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
  37. package/source/packages/scramjet/packages/core/src/shared/rewriters/js.ts +1 -2
  38. package/source/packages/tracker-protocol/package.json +2 -2
  39. package/source/packages/tracker-protocol/src/index.ts +22 -0
  40. package/source/pnpm-lock.yaml +2 -2
  41. package/standalone.html +2 -2
@@ -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
+ });
@@ -0,0 +1,26 @@
1
+ import {
2
+ defaultConfig,
3
+ type ScramjetConfig,
4
+ } from "@mercuryworkshop/scramjet/bundled-wasm";
5
+
6
+ /**
7
+ * Configuration shared by the service-worker rewriter and every injected
8
+ * page/worker runtime. Keep this on the production defaults: the development
9
+ * preset deliberately turns parser recovery off and enables debug work that
10
+ * is both noisy and expensive on large production bundles.
11
+ */
12
+ export function makeScramjetConfig(): ScramjetConfig {
13
+ return {
14
+ ...defaultConfig,
15
+ flags: {
16
+ ...defaultConfig.flags,
17
+ allowInvalidJs: true,
18
+ captureErrors: false,
19
+ debugSourceURL: false,
20
+ debugTrampolines: false,
21
+ rewriterLogs: false,
22
+ sourcemaps: false,
23
+ },
24
+ maskedfiles: ["inject.js", "scramjet.wasm.js"],
25
+ };
26
+ }
@@ -1,8 +1,5 @@
1
1
  import {
2
- defaultConfig,
3
- defaultConfigDev,
4
2
  ScramjetFetchHandler,
5
- type ScramjetConfig,
6
3
  type ScramjetFetchRequest,
7
4
  type ScramjetInterface,
8
5
  unrewriteUrl,
@@ -17,21 +14,11 @@ import { RpcHelper } from "@mercuryworkshop/rpc";
17
14
  import scramjetWASM from "../../../scramjet/packages/core/dist/scramjet.wasm?url";
18
15
  import injectScript from "../../../inject/dist/inject.js?url";
19
16
  import { isIsolated } from ".";
17
+ import { makeScramjetConfig } from "./scramjet-config";
20
18
 
21
19
  export const virtualWasmPath = "scramjet.wasm.js";
22
20
  export const virtualInjectPath = "inject.js";
23
21
 
24
- function makeConfig(): ScramjetConfig {
25
- return {
26
- ...defaultConfig,
27
- flags: {
28
- ...defaultConfigDev.flags,
29
- captureErrors: false,
30
- },
31
- maskedfiles: ["inject.js", "scramjet.wasm.js"],
32
- };
33
- }
34
-
35
22
  function base64Encode(str: string): string {
36
23
  return btoa(
37
24
  new Uint8Array(new TextEncoder().encode(str))
@@ -327,7 +314,7 @@ export function renderErrorPage(controller: Controller, error: Error): string {
327
314
  $injectLoadError({
328
315
  id: "${contextId}",
329
316
  sequence: ${JSON.stringify(findSequence(top!, self)!)},
330
- config: ${JSON.stringify(makeConfig())},
317
+ config: ${JSON.stringify(makeScramjetConfig())},
331
318
  cookies: ${JSON.stringify(profileService.cookieJar.dump())},
332
319
  codecEncode: ${codecEncode.toString()},
333
320
  codecDecode: ${codecDecode.toString()},
@@ -361,7 +348,7 @@ export function createFetchHandler(controller: Controller) {
361
348
  $injectLoad({
362
349
  id: "${contextId}",
363
350
  sequence: ${JSON.stringify(findSequence(top!, self)!)},
364
- config: ${JSON.stringify(makeConfig())},
351
+ config: ${JSON.stringify(makeScramjetConfig())},
365
352
  cookies: ${JSON.stringify(profileService.cookieJar.dump())},
366
353
  codecEncode: ${codecEncode.toString()},
367
354
  codecDecode: ${codecDecode.toString()},
@@ -391,7 +378,7 @@ export function createFetchHandler(controller: Controller) {
391
378
  // workers don't have a document, so initHeaders/history are empty.
392
379
  const injectLoad = `
393
380
  $injectLoad({
394
- config: ${JSON.stringify(makeConfig())},
381
+ config: ${JSON.stringify(makeScramjetConfig())},
395
382
  cookies: null,
396
383
  codecEncode: ${codecEncode.toString()},
397
384
  codecDecode: ${codecDecode.toString()},
@@ -419,7 +406,7 @@ export function createFetchHandler(controller: Controller) {
419
406
  codecDecode,
420
407
  },
421
408
  cookieJar: profileService.cookieJar,
422
- config: makeConfig(),
409
+ config: makeScramjetConfig(),
423
410
  prefix: controller.prefix,
424
411
  },
425
412
  async fetchDataUrl(dataUrl: string) {
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ applyUserAgentHeader,
5
+ browserUserAgent,
6
+ resetUserAgentCache,
7
+ } from "../../../scramjet/packages/core/src/fetch/user-agent";
8
+
9
+ /** Minimal stand-in for ScramjetHeaders' case-insensitive storage. */
10
+ function headers(initial: Record<string, string> = {}) {
11
+ const store = new Map<string, string>();
12
+ for (const [name, value] of Object.entries(initial)) {
13
+ store.set(name.toLowerCase(), value);
14
+ }
15
+ return {
16
+ get: (key: string) => store.get(key.toLowerCase()) ?? null,
17
+ set: (key: string, value: string) => {
18
+ store.set(key.toLowerCase(), value);
19
+ },
20
+ raw: store,
21
+ };
22
+ }
23
+
24
+ const FIREFOX =
25
+ "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0";
26
+
27
+ afterEach(() => {
28
+ resetUserAgentCache();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ describe("User-Agent forwarding", () => {
33
+ it("sends the browser's own User-Agent when the request carries none", () => {
34
+ // Without this an origin that content-negotiates on User-Agent serves its
35
+ // non-browser representation: ip.me returns a text/plain IP, not its page.
36
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
37
+ const h = headers();
38
+
39
+ applyUserAgentHeader(h);
40
+
41
+ expect(h.get("user-agent")).toBe(FIREFOX);
42
+ });
43
+
44
+ it("matches the header case-insensitively", () => {
45
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
46
+ const h = headers({ "User-Agent": "PageChosen/1.0" });
47
+
48
+ applyUserAgentHeader(h);
49
+
50
+ expect(h.get("user-agent")).toBe("PageChosen/1.0");
51
+ });
52
+
53
+ it("never overwrites a User-Agent the page set deliberately", () => {
54
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
55
+ const h = headers({ "user-agent": "CustomBot/2.0" });
56
+
57
+ applyUserAgentHeader(h);
58
+
59
+ expect(h.get("user-agent")).toBe("CustomBot/2.0");
60
+ });
61
+
62
+ it("leaves the header absent rather than inventing one", () => {
63
+ vi.stubGlobal("navigator", { userAgent: "" });
64
+ const h = headers();
65
+
66
+ applyUserAgentHeader(h);
67
+
68
+ expect(h.get("user-agent")).toBeNull();
69
+ });
70
+
71
+ it("tolerates environments with no navigator at all", () => {
72
+ vi.stubGlobal("navigator", undefined);
73
+ const h = headers();
74
+
75
+ expect(() => applyUserAgentHeader(h)).not.toThrow();
76
+ expect(browserUserAgent()).toBeNull();
77
+ });
78
+ });
@@ -24,6 +24,8 @@ export type Settings = {
24
24
  clearHistoryOnExit: boolean;
25
25
  doNotTrack: boolean;
26
26
  extensionsDevMode: boolean;
27
+ /** Node id the user pinned in Advanced settings; empty means automatic. */
28
+ preferredNodeId: string;
27
29
  };
28
30
 
29
31
  export type TabLayoutMode = Settings["tabLayout"];
@@ -44,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = {
44
46
  clearHistoryOnExit: false,
45
47
  doNotTrack: true,
46
48
  extensionsDevMode: false,
49
+ preferredNodeId: "",
47
50
  };
48
51
 
49
52
  export type SettingsServiceState = {
@@ -63,6 +66,7 @@ export type SettingsServiceState = {
63
66
  clearHistoryOnExit: boolean;
64
67
  doNotTrack: boolean;
65
68
  extensionsDevMode: boolean;
69
+ preferredNodeId: string;
66
70
  };
67
71
  };
68
72
 
@@ -72,7 +76,9 @@ export class SettingsService extends Service {
72
76
  constructor(data: SettingsServiceState | null) {
73
77
  super();
74
78
  if (data) {
75
- this.settings = createState(data.settings);
79
+ // Profiles saved before a setting existed omit it entirely, so fill in
80
+ // defaults rather than leaving the state with undefined values.
81
+ this.settings = createState({ ...DEFAULT_SETTINGS, ...data.settings });
76
82
  } else {
77
83
  this.settings = createState(DEFAULT_SETTINGS);
78
84
  }
@@ -102,6 +108,7 @@ export class SettingsService extends Service {
102
108
  clearHistoryOnExit: this.settings.clearHistoryOnExit,
103
109
  doNotTrack: this.settings.doNotTrack,
104
110
  extensionsDevMode: this.settings.extensionsDevMode,
111
+ preferredNodeId: this.settings.preferredNodeId ?? "",
105
112
  },
106
113
  };
107
114
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "description": "Tracker-discovered WebRTC transport for Roxxie Proxy U",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -534,7 +534,10 @@ export class RoxxieConnection {
534
534
  else if (this.sockets.has(frame.sequence)) {
535
535
  this.handleSocketFrame(frame.sequence, frame.operation, frame.payload);
536
536
  } else if (this.ignoredHTTP.has(frame.sequence)) {
537
- if (frame.operation === S2CRequestTypes.HTTPResponseEnd) {
537
+ if (
538
+ frame.operation === S2CRequestTypes.HTTPResponseEnd ||
539
+ frame.operation === S2CRequestTypes.HTTPResponseError
540
+ ) {
538
541
  this.ignoredHTTP.delete(frame.sequence);
539
542
  } else if (
540
543
  frame.operation !== S2CRequestTypes.HTTPResponseStart &&
@@ -618,6 +621,20 @@ export class RoxxieConnection {
618
621
  pending.removeAbort?.();
619
622
  this.http.delete(sequence);
620
623
  break;
624
+ case S2CRequestTypes.HTTPResponseError:
625
+ if (!pending.responseStarted || pending.controller === undefined) {
626
+ throw new Error("Node failed HTTP response before response headers");
627
+ }
628
+ // The node could not deliver the whole body. Erroring the stream
629
+ // keeps the partial bytes from being parsed or cached as if they
630
+ // were a complete response. Only this request is affected, so the
631
+ // session stays up.
632
+ pending.controller.error(
633
+ new Error("Proxy node could not deliver the complete response body")
634
+ );
635
+ pending.removeAbort?.();
636
+ this.http.delete(sequence);
637
+ break;
621
638
  default:
622
639
  throw new Error(`Node sent invalid HTTP operation ${operation}`);
623
640
  }
@@ -78,6 +78,9 @@ export class RoxxieProxyTransport implements ProxyTransport {
78
78
  private readonly nodeCooldowns = new Map<string, number>();
79
79
  private readonly dependencies: RoxxieTransportDependencies;
80
80
  private closed = false;
81
+ /** Node the user pinned in advanced settings, if any. */
82
+ private preferredNodeId: string | undefined;
83
+ private lastCandidates: readonly CandidateNode[] = [];
81
84
 
82
85
  constructor(
83
86
  private readonly source: TrackerNetworkSource,
@@ -243,16 +246,57 @@ export class RoxxieProxyTransport implements ProxyTransport {
243
246
  }
244
247
  }
245
248
 
249
+ /**
250
+ * Nodes the tracker offered on the most recent connection attempt. Used by
251
+ * the settings UI to show what is available without forcing a reconnect.
252
+ */
253
+ get availableNodes(): readonly CandidateNode[] {
254
+ return this.lastCandidates;
255
+ }
256
+
257
+ /** Ask the tracker for the current node list without connecting. */
258
+ async listNodes(): Promise<readonly CandidateNode[]> {
259
+ const document = await this.getDocument();
260
+ const tracker = this.getTracker(document);
261
+ const candidates = [...(await tracker.requestCandidates())];
262
+ this.lastCandidates = candidates;
263
+ return candidates;
264
+ }
265
+
266
+ get preferredNode(): string | undefined {
267
+ return this.preferredNodeId;
268
+ }
269
+
270
+ /**
271
+ * Pin future connections to one node, or pass undefined to go back to
272
+ * automatic selection. Takes effect on the next connection.
273
+ */
274
+ setPreferredNode(nodeId: string | undefined): void {
275
+ this.preferredNodeId = nodeId || undefined;
276
+ }
277
+
246
278
  private async establishConnection(): Promise<RoxxieConnection> {
247
279
  const document = await this.getDocument();
248
280
  const tracker = this.getTracker(document);
249
- const discovered = [...(await tracker.requestCandidates())]
250
- .slice(0, 3)
251
- .sort(
252
- (left, right) =>
253
- left.activeSessions - right.activeSessions ||
254
- left.utilization - right.utilization
255
- );
281
+ const offered = [...(await tracker.requestCandidates())];
282
+ // Keep the full list for the settings UI; only the probe set is trimmed.
283
+ this.lastCandidates = offered;
284
+ const ranked = offered.sort(
285
+ (left, right) =>
286
+ left.activeSessions - right.activeSessions ||
287
+ left.utilization - right.utilization
288
+ );
289
+ // A pinned node has to be selected before the list is trimmed, or picking
290
+ // a lightly-used node outside the top few would silently do nothing.
291
+ const pinned =
292
+ this.preferredNodeId === undefined
293
+ ? []
294
+ : ranked.filter(
295
+ (candidate) => candidate.nodeId === this.preferredNodeId
296
+ );
297
+ // Fall back to automatic selection rather than stranding the user on a node
298
+ // that has gone away since they picked it.
299
+ const discovered = pinned.length > 0 ? pinned : ranked.slice(0, 3);
256
300
  const compatible = discovered.filter(
257
301
  (candidate) => candidate.protocolVersion === PROTOCOL_VERSION
258
302
  );
@@ -151,6 +151,13 @@ export async function doHandleFetch(
151
151
  if (response.body && !isRedirect(response)) {
152
152
  responseBody = await rewriteBody(handler, request, parsed, response);
153
153
 
154
+ // The service worker constructs a new Response around this body. HTML,
155
+ // JavaScript, CSS, and worker rewrites change its byte length, while even
156
+ // pass-through streams are no longer the native network response that
157
+ // supplied Content-Length. Firefox treats a mismatched retained length as
158
+ // corrupted content and can truncate modules/documents at that boundary.
159
+ responseHeaders.delete("content-length");
160
+
154
161
  // After rewriting HTML, the body is a JS string which will be encoded as
155
162
  // UTF-8 by the Response constructor. Normalize the Content-Type charset so
156
163
  // the browser doesn't try to decode UTF-8 bytes with the original encoding.
@@ -13,6 +13,7 @@ import {
13
13
  import { RawHeaders } from "@mercuryworkshop/proxy-transports";
14
14
  import { _URL, _Set } from "@/shared/snapshot";
15
15
  import { createReferrerString } from "./util";
16
+ import { applyUserAgentHeader } from "./user-agent";
16
17
 
17
18
  /**
18
19
  * Headers for security policy features that haven't been emulated yet
@@ -123,6 +124,13 @@ export function rewriteRequestHeaders(
123
124
  ): ScramjetHeaders {
124
125
  const headers = request.initialHeaders.clone();
125
126
 
127
+ // A service worker never sees User-Agent on the request it intercepts, and
128
+ // nothing downstream adds one, so requests reach origins with no User-Agent
129
+ // at all. Servers that content-negotiate on it then serve their non-browser
130
+ // representation - ip.me, for example, answers with a bare text/plain IP
131
+ // instead of its actual page.
132
+ applyUserAgentHeader(headers);
133
+
126
134
  // avoid leaking the scramjet referer
127
135
  headers.delete("Referer");
128
136
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * User-Agent forwarding.
3
+ *
4
+ * A service worker never sees User-Agent on the request it intercepts - the
5
+ * browser strips it before the fetch event - and nothing further down the proxy
6
+ * adds one. Requests therefore reached origins with no User-Agent at all, and
7
+ * servers that content-negotiate on it served their non-browser representation.
8
+ * ip.me, for instance, answers a bare `text/plain` IP address rather than its
9
+ * actual page.
10
+ *
11
+ * Kept free of the rest of the fetch pipeline's imports so it stays testable on
12
+ * its own.
13
+ */
14
+
15
+ /** The minimal surface this needs from ScramjetHeaders. */
16
+ export interface UserAgentHeaders {
17
+ get(key: string): string | null;
18
+ set(key: string, value: string): void;
19
+ }
20
+
21
+ let cachedUserAgent: string | null | undefined;
22
+
23
+ /**
24
+ * The browser's own User-Agent, or null where no navigator is available. Cached
25
+ * because the value cannot change for the life of the worker.
26
+ */
27
+ export function browserUserAgent(): string | null {
28
+ if (cachedUserAgent === undefined) {
29
+ const value =
30
+ typeof navigator === "undefined" ? undefined : navigator.userAgent;
31
+ cachedUserAgent =
32
+ typeof value === "string" && value.length > 0 ? value : null;
33
+ }
34
+ return cachedUserAgent;
35
+ }
36
+
37
+ /** Forces the next read to re-detect the User-Agent. Intended for tests. */
38
+ export function resetUserAgentCache(): void {
39
+ cachedUserAgent = undefined;
40
+ }
41
+
42
+ /**
43
+ * Send the real browser User-Agent, unless the page set one of its own. Passing
44
+ * the browser's string through unchanged keeps a proxied request
45
+ * indistinguishable from a direct one for content negotiation.
46
+ */
47
+ export function applyUserAgentHeader(headers: UserAgentHeaders): void {
48
+ if (headers.get("user-agent")) return;
49
+ const agent = browserUserAgent();
50
+ if (agent) headers.set("User-Agent", agent);
51
+ }
@@ -146,8 +146,7 @@ export function rewriteJs(
146
146
  dbg.warn(
147
147
  "failed rewriting js for",
148
148
  url || "(unknown)",
149
- err.message,
150
- typeof js !== "string" ? TextDecoder_decode(js) : js
149
+ err.message
151
150
  );
152
151
  if (flagEnabled("allowInvalidJs", context, meta.base)) {
153
152
  return js;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports-tracker-protocol",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Browser-safe control-plane contracts for the Roxxie tracker, nodes, and browsers",
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"