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.
- package/README.md +6 -6
- package/dist/assets/{index-CttvagWK.js → index-MRhaNu_Q.js} +195 -135
- package/embed.js +1 -1
- package/examples/single-page.html +2 -2
- package/package.json +42 -43
- package/runtime/index.html +3 -3
- package/scripts/verify.mjs +2 -2
- package/source/packages/adrift-protocol/package.json +2 -2
- package/source/packages/adrift-protocol/src/index.ts +11 -0
- package/source/packages/chrome/src/Tab/History.test.ts +137 -0
- package/source/packages/chrome/src/Tab/History.ts +8 -3
- package/source/packages/chrome/src/Tab/Tab.tsx +13 -2
- package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
- package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
- package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
- package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
- package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
- package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
- package/source/packages/chrome/src/pages/nodes.ts +108 -0
- package/source/packages/chrome/src/proxy/Controller.ts +12 -0
- package/source/packages/chrome/src/proxy/ProxyFrame.ts +19 -2
- package/source/packages/chrome/src/proxy/cache.test.ts +52 -0
- package/source/packages/chrome/src/proxy/cache.ts +55 -11
- package/source/packages/chrome/src/proxy/navigation.test.ts +91 -0
- package/source/packages/chrome/src/proxy/navigation.ts +97 -0
- package/source/packages/chrome/src/proxy/scramjet-config.test.ts +19 -0
- package/source/packages/chrome/src/proxy/scramjet-config.ts +26 -0
- package/source/packages/chrome/src/proxy/scramjet.ts +5 -18
- package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
- package/source/packages/chrome/src/services/SettingsService.ts +8 -1
- package/source/packages/roxxie-transport/package.json +1 -1
- package/source/packages/roxxie-transport/src/connection.ts +18 -1
- package/source/packages/roxxie-transport/src/transport.ts +51 -7
- package/source/packages/scramjet/packages/core/src/fetch/fetch.ts +7 -0
- package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
- package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
- package/source/packages/scramjet/packages/core/src/shared/rewriters/js.ts +1 -2
- package/source/packages/tracker-protocol/package.json +2 -2
- package/source/packages/tracker-protocol/src/index.ts +22 -0
- package/source/pnpm-lock.yaml +2 -2
- 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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 (
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
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": ">=
|
|
21
|
+
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|