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,225 @@
|
|
|
1
|
+
import { css, type FC } from "dreamland/core";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@components/Button";
|
|
4
|
+
import { roxxieTransport } from "../proxy/roxxie";
|
|
5
|
+
import { settingsService } from "..";
|
|
6
|
+
import {
|
|
7
|
+
AUTOMATIC_NODE,
|
|
8
|
+
buildNodeRows,
|
|
9
|
+
nodeSummary,
|
|
10
|
+
resolvePreferredNode,
|
|
11
|
+
type NodeRow,
|
|
12
|
+
} from "./nodes";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Exit-node picker. Lists the nodes the tracker is currently offering, what
|
|
16
|
+
* version of the node software each is running, and lets the user pin one
|
|
17
|
+
* instead of letting the browser choose by load and latency.
|
|
18
|
+
*/
|
|
19
|
+
export function AdvancedSettings(
|
|
20
|
+
this: FC<
|
|
21
|
+
Record<string, never>,
|
|
22
|
+
{
|
|
23
|
+
rows: NodeRow[];
|
|
24
|
+
loading: boolean;
|
|
25
|
+
error: string | null;
|
|
26
|
+
selected: string;
|
|
27
|
+
}
|
|
28
|
+
>
|
|
29
|
+
) {
|
|
30
|
+
this.rows = [];
|
|
31
|
+
this.loading = false;
|
|
32
|
+
this.error = null;
|
|
33
|
+
this.selected = settingsService.settings.preferredNodeId || AUTOMATIC_NODE;
|
|
34
|
+
|
|
35
|
+
const refresh = async () => {
|
|
36
|
+
if (!roxxieTransport) {
|
|
37
|
+
this.error = "The proxy transport is not running yet.";
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.loading = true;
|
|
41
|
+
this.error = null;
|
|
42
|
+
try {
|
|
43
|
+
const candidates = await roxxieTransport.listNodes();
|
|
44
|
+
this.rows = buildNodeRows(
|
|
45
|
+
candidates,
|
|
46
|
+
roxxieTransport.connectedNode?.node.nodeId
|
|
47
|
+
);
|
|
48
|
+
// A pin left over from a node that has since disappeared reads as
|
|
49
|
+
// automatic rather than showing a selection that cannot be honoured.
|
|
50
|
+
this.selected = resolvePreferredNode(
|
|
51
|
+
settingsService.settings.preferredNodeId,
|
|
52
|
+
this.rows
|
|
53
|
+
);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
this.error =
|
|
56
|
+
error instanceof Error ? error.message : "Could not reach the tracker.";
|
|
57
|
+
} finally {
|
|
58
|
+
this.loading = false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const choose = (nodeId: string) => {
|
|
63
|
+
this.selected = nodeId;
|
|
64
|
+
settingsService.settings.preferredNodeId = nodeId;
|
|
65
|
+
roxxieTransport?.setPreferredNode(nodeId || undefined);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
void refresh();
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section class="setting-section">
|
|
72
|
+
<div class="section-header">
|
|
73
|
+
<h2>Exit Nodes</h2>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="section-content">
|
|
76
|
+
<div class="setting-group">
|
|
77
|
+
<h4>Node Selection</h4>
|
|
78
|
+
<p class="setting-description">
|
|
79
|
+
Traffic leaves through one of these community nodes. Automatic picks
|
|
80
|
+
the least loaded node with the best latency. Choosing a node applies
|
|
81
|
+
to the next connection.
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
<div class="node-actions">
|
|
85
|
+
<Button
|
|
86
|
+
variant="secondary"
|
|
87
|
+
on:click={() => void refresh()}
|
|
88
|
+
disabled={use(this.loading)}
|
|
89
|
+
>
|
|
90
|
+
{use(this.loading).map((l) => (l ? "Refreshing…" : "Refresh"))}
|
|
91
|
+
</Button>
|
|
92
|
+
{use(this.selected).map(
|
|
93
|
+
(selected) =>
|
|
94
|
+
selected !== AUTOMATIC_NODE && (
|
|
95
|
+
<Button
|
|
96
|
+
variant="secondary"
|
|
97
|
+
on:click={() => choose(AUTOMATIC_NODE)}
|
|
98
|
+
>
|
|
99
|
+
Use automatic
|
|
100
|
+
</Button>
|
|
101
|
+
)
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{use(this.error).map(
|
|
106
|
+
(error) => error && <p class="node-error">{error}</p>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<div class="node-list">
|
|
110
|
+
<label class="node-option" class:selected={use(this.selected).map(
|
|
111
|
+
(s) => s === AUTOMATIC_NODE
|
|
112
|
+
)}>
|
|
113
|
+
<input
|
|
114
|
+
type="radio"
|
|
115
|
+
name="roxxie-node"
|
|
116
|
+
checked={use(this.selected).map((s) => s === AUTOMATIC_NODE)}
|
|
117
|
+
on:change={() => choose(AUTOMATIC_NODE)}
|
|
118
|
+
/>
|
|
119
|
+
<span class="node-text">
|
|
120
|
+
<span class="node-name">Automatic</span>
|
|
121
|
+
<span class="node-meta">
|
|
122
|
+
Pick the fastest available node for me
|
|
123
|
+
</span>
|
|
124
|
+
</span>
|
|
125
|
+
</label>
|
|
126
|
+
|
|
127
|
+
{use(this.rows).map((rows) =>
|
|
128
|
+
rows.length === 0 && !this.loading ? (
|
|
129
|
+
<p class="node-empty">No nodes are available right now.</p>
|
|
130
|
+
) : (
|
|
131
|
+
rows.map((row) => (
|
|
132
|
+
<label
|
|
133
|
+
class="node-option"
|
|
134
|
+
class:selected={use(this.selected).map(
|
|
135
|
+
(s) => s === row.nodeId
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
<input
|
|
139
|
+
type="radio"
|
|
140
|
+
name="roxxie-node"
|
|
141
|
+
checked={use(this.selected).map((s) => s === row.nodeId)}
|
|
142
|
+
on:change={() => choose(row.nodeId)}
|
|
143
|
+
/>
|
|
144
|
+
<span class="node-text">
|
|
145
|
+
<span class="node-name">
|
|
146
|
+
{row.name}
|
|
147
|
+
{row.connected && (
|
|
148
|
+
<span class="node-badge">connected</span>
|
|
149
|
+
)}
|
|
150
|
+
</span>
|
|
151
|
+
<span class="node-meta">{nodeSummary(row)}</span>
|
|
152
|
+
<span class="node-id">{row.nodeId}</span>
|
|
153
|
+
</span>
|
|
154
|
+
</label>
|
|
155
|
+
))
|
|
156
|
+
)
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</section>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
AdvancedSettings.style = css`
|
|
166
|
+
:scope .node-actions {
|
|
167
|
+
display: flex;
|
|
168
|
+
gap: var(--space-sm);
|
|
169
|
+
margin-block: var(--space-sm);
|
|
170
|
+
}
|
|
171
|
+
:scope .node-list {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-direction: column;
|
|
174
|
+
gap: var(--space-xs);
|
|
175
|
+
}
|
|
176
|
+
:scope .node-option {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: flex-start;
|
|
179
|
+
gap: var(--space-sm);
|
|
180
|
+
padding: var(--space-sm);
|
|
181
|
+
border: 1px solid var(--popup_border);
|
|
182
|
+
border-radius: var(--radius-md, 8px);
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
}
|
|
185
|
+
:scope .node-option.selected {
|
|
186
|
+
border-color: var(--tab_line);
|
|
187
|
+
}
|
|
188
|
+
:scope .node-text {
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
gap: 2px;
|
|
192
|
+
min-width: 0;
|
|
193
|
+
}
|
|
194
|
+
:scope .node-name {
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: var(--space-xs);
|
|
199
|
+
}
|
|
200
|
+
:scope .node-badge {
|
|
201
|
+
font-size: 0.75em;
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
padding: 1px 6px;
|
|
204
|
+
border-radius: 999px;
|
|
205
|
+
background: var(--tab_line);
|
|
206
|
+
color: var(--toolbar);
|
|
207
|
+
}
|
|
208
|
+
:scope .node-meta {
|
|
209
|
+
opacity: 0.8;
|
|
210
|
+
font-size: 0.9em;
|
|
211
|
+
}
|
|
212
|
+
:scope .node-id {
|
|
213
|
+
opacity: 0.5;
|
|
214
|
+
font-size: 0.8em;
|
|
215
|
+
font-family: monospace;
|
|
216
|
+
overflow-wrap: anywhere;
|
|
217
|
+
}
|
|
218
|
+
:scope .node-error {
|
|
219
|
+
color: var(--error, #e57373);
|
|
220
|
+
}
|
|
221
|
+
:scope .node-empty,
|
|
222
|
+
:scope .setting-description {
|
|
223
|
+
opacity: 0.8;
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
iconRefresh,
|
|
27
27
|
} from "../icons";
|
|
28
28
|
import { settingsService } from "..";
|
|
29
|
+
import { AdvancedSettings } from "./AdvancedSettings";
|
|
29
30
|
|
|
30
31
|
function ThemePreview(this: FC<{ theme: (typeof THEMES)[number] }>) {
|
|
31
32
|
const theme = this.theme;
|
|
@@ -701,6 +702,7 @@ export function SettingsPage(
|
|
|
701
702
|
{button("appearance", iconBrush, "Appearance")}
|
|
702
703
|
{button("search", iconSearch, "Search")}
|
|
703
704
|
{button("privacy", iconPrivacy, "Privacy & Security")}
|
|
705
|
+
{button("advanced", iconOptions, "Advanced Settings")}
|
|
704
706
|
{/* {button("extensions", iconExtension, "Extensions")} */}
|
|
705
707
|
{button("about", iconAbout, "About")}
|
|
706
708
|
</nav>
|
|
@@ -1303,6 +1305,15 @@ export function SettingsPage(
|
|
|
1303
1305
|
) : null
|
|
1304
1306
|
)}
|
|
1305
1307
|
|
|
1308
|
+
{/* Advanced Tab */}
|
|
1309
|
+
{use(this.selected).map((selected) =>
|
|
1310
|
+
selected === "advanced" ? (
|
|
1311
|
+
<div class="settings-tab">
|
|
1312
|
+
<AdvancedSettings />
|
|
1313
|
+
</div>
|
|
1314
|
+
) : null
|
|
1315
|
+
)}
|
|
1316
|
+
|
|
1306
1317
|
{/* About Tab */}
|
|
1307
1318
|
{use(this.selected).map((selected) =>
|
|
1308
1319
|
selected === "about" ? (
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AUTOMATIC_NODE,
|
|
5
|
+
type CandidateNode,
|
|
6
|
+
buildNodeRows,
|
|
7
|
+
nodeSummary,
|
|
8
|
+
resolvePreferredNode,
|
|
9
|
+
toNodeRow,
|
|
10
|
+
versionLabel,
|
|
11
|
+
} from "./nodes";
|
|
12
|
+
|
|
13
|
+
function candidate(overrides: Partial<CandidateNode> = {}): CandidateNode {
|
|
14
|
+
return {
|
|
15
|
+
nodeId: "018f1f5e-7b2a-7c35-8b1d-2a4d9f8e6c10",
|
|
16
|
+
name: "Roxxie community node",
|
|
17
|
+
activeSessions: 1,
|
|
18
|
+
maxSessions: 8,
|
|
19
|
+
utilization: 0.125,
|
|
20
|
+
protocolVersion: "6.0",
|
|
21
|
+
packageVersion: "0.5.1",
|
|
22
|
+
platform: "linux",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("advanced settings node list", () => {
|
|
28
|
+
it("surfaces the node software version", () => {
|
|
29
|
+
const row = toNodeRow(candidate());
|
|
30
|
+
expect(row.version).toBe("0.5.1");
|
|
31
|
+
expect(versionLabel(row)).toBe("v0.5.1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("says so plainly when a tracker does not report a version", () => {
|
|
35
|
+
// Trackers older than the version field omit it; showing "v undefined"
|
|
36
|
+
// would be worse than admitting we do not know.
|
|
37
|
+
const row = toNodeRow(candidate({ packageVersion: undefined }));
|
|
38
|
+
expect(row.version).toBeNull();
|
|
39
|
+
expect(versionLabel(row)).toBe("version unknown");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("marks which node is currently connected", () => {
|
|
43
|
+
const row = toNodeRow(candidate(), candidate().nodeId);
|
|
44
|
+
expect(row.connected).toBe(true);
|
|
45
|
+
expect(toNodeRow(candidate(), "someone-else").connected).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders load as a clamped percentage", () => {
|
|
49
|
+
expect(toNodeRow(candidate({ utilization: 0 })).loadPercent).toBe(0);
|
|
50
|
+
expect(toNodeRow(candidate({ utilization: 0.5 })).loadPercent).toBe(50);
|
|
51
|
+
expect(toNodeRow(candidate({ utilization: 1 })).loadPercent).toBe(100);
|
|
52
|
+
expect(toNodeRow(candidate({ utilization: 2 })).loadPercent).toBe(100);
|
|
53
|
+
expect(toNodeRow(candidate({ utilization: -1 })).loadPercent).toBe(0);
|
|
54
|
+
expect(toNodeRow(candidate({ utilization: Number.NaN })).loadPercent).toBe(
|
|
55
|
+
0
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("lists the least loaded node first", () => {
|
|
60
|
+
const rows = buildNodeRows([
|
|
61
|
+
candidate({ nodeId: "c", name: "C", utilization: 0.9 }),
|
|
62
|
+
candidate({ nodeId: "a", name: "A", utilization: 0.1 }),
|
|
63
|
+
candidate({ nodeId: "b", name: "B", utilization: 0.5 }),
|
|
64
|
+
]);
|
|
65
|
+
expect(rows.map((row) => row.nodeId)).toEqual(["a", "b", "c"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("orders ties stably so the list does not reshuffle", () => {
|
|
69
|
+
const rows = buildNodeRows([
|
|
70
|
+
candidate({ nodeId: "z", name: "Same", utilization: 0.2 }),
|
|
71
|
+
candidate({ nodeId: "a", name: "Same", utilization: 0.2 }),
|
|
72
|
+
]);
|
|
73
|
+
expect(rows.map((row) => row.nodeId)).toEqual(["a", "z"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("summarises a node in one line", () => {
|
|
77
|
+
expect(nodeSummary(toNodeRow(candidate()))).toBe(
|
|
78
|
+
"v0.5.1 · linux · 1/8 sessions · 13% load"
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps a pin that still matches an offered node", () => {
|
|
83
|
+
const rows = buildNodeRows([candidate({ nodeId: "keep" })]);
|
|
84
|
+
expect(resolvePreferredNode("keep", rows)).toBe("keep");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("falls back to automatic when the pinned node is gone", () => {
|
|
88
|
+
// Otherwise the user would be stuck pointing at a node that no longer
|
|
89
|
+
// exists with no obvious way back.
|
|
90
|
+
const rows = buildNodeRows([candidate({ nodeId: "present" })]);
|
|
91
|
+
expect(resolvePreferredNode("vanished", rows)).toBe(AUTOMATIC_NODE);
|
|
92
|
+
expect(resolvePreferredNode("", rows)).toBe(AUTOMATIC_NODE);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a tracker candidate this page renders. Declared structurally so
|
|
3
|
+
* the settings page does not need a direct dependency on the tracker protocol
|
|
4
|
+
* package.
|
|
5
|
+
*/
|
|
6
|
+
export type CandidateNode = {
|
|
7
|
+
nodeId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
activeSessions: number;
|
|
10
|
+
maxSessions: number;
|
|
11
|
+
utilization: number;
|
|
12
|
+
protocolVersion: string;
|
|
13
|
+
packageVersion?: string | undefined;
|
|
14
|
+
platform?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A node as presented in Advanced settings. */
|
|
18
|
+
export type NodeRow = {
|
|
19
|
+
nodeId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** Node agent release, or null when the tracker does not report one. */
|
|
22
|
+
version: string | null;
|
|
23
|
+
platform: string | null;
|
|
24
|
+
activeSessions: number;
|
|
25
|
+
maxSessions: number;
|
|
26
|
+
/** 0-100, rounded, clamped. */
|
|
27
|
+
loadPercent: number;
|
|
28
|
+
protocolVersion: string;
|
|
29
|
+
connected: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Value used in the picker to mean "let the browser choose". */
|
|
33
|
+
export const AUTOMATIC_NODE = "";
|
|
34
|
+
|
|
35
|
+
function clampPercent(value: number): number {
|
|
36
|
+
if (!Number.isFinite(value)) return 0;
|
|
37
|
+
return Math.min(100, Math.max(0, Math.round(value * 100)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toNodeRow(
|
|
41
|
+
candidate: CandidateNode,
|
|
42
|
+
connectedNodeId?: string
|
|
43
|
+
): NodeRow {
|
|
44
|
+
return {
|
|
45
|
+
nodeId: candidate.nodeId,
|
|
46
|
+
name: candidate.name,
|
|
47
|
+
// Trackers predating version reporting simply omit it.
|
|
48
|
+
version: candidate.packageVersion ?? null,
|
|
49
|
+
platform: candidate.platform ?? null,
|
|
50
|
+
activeSessions: candidate.activeSessions,
|
|
51
|
+
maxSessions: candidate.maxSessions,
|
|
52
|
+
loadPercent: clampPercent(candidate.utilization),
|
|
53
|
+
protocolVersion: candidate.protocolVersion,
|
|
54
|
+
connected: candidate.nodeId === connectedNodeId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Least loaded first, so the ordering matches how a node would be picked
|
|
60
|
+
* automatically. Ties fall back to name then id to keep the list from
|
|
61
|
+
* reshuffling between refreshes.
|
|
62
|
+
*/
|
|
63
|
+
export function sortNodeRows(rows: readonly NodeRow[]): NodeRow[] {
|
|
64
|
+
return [...rows].sort(
|
|
65
|
+
(left, right) =>
|
|
66
|
+
left.loadPercent - right.loadPercent ||
|
|
67
|
+
left.activeSessions - right.activeSessions ||
|
|
68
|
+
left.name.localeCompare(right.name) ||
|
|
69
|
+
left.nodeId.localeCompare(right.nodeId)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildNodeRows(
|
|
74
|
+
candidates: readonly CandidateNode[],
|
|
75
|
+
connectedNodeId?: string
|
|
76
|
+
): NodeRow[] {
|
|
77
|
+
return sortNodeRows(
|
|
78
|
+
candidates.map((candidate) => toNodeRow(candidate, connectedNodeId))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Version text for display, distinguishing "unknown" from a real version. */
|
|
83
|
+
export function versionLabel(row: NodeRow): string {
|
|
84
|
+
return row.version === null ? "version unknown" : `v${row.version}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Short summary line shown under each node's name. */
|
|
88
|
+
export function nodeSummary(row: NodeRow): string {
|
|
89
|
+
const parts = [versionLabel(row)];
|
|
90
|
+
if (row.platform) parts.push(row.platform);
|
|
91
|
+
parts.push(`${row.activeSessions}/${row.maxSessions} sessions`);
|
|
92
|
+
parts.push(`${row.loadPercent}% load`);
|
|
93
|
+
return parts.join(" · ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a saved preference still refers to a node the tracker is offering.
|
|
98
|
+
* A stale pin is treated as automatic rather than leaving the user stuck.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePreferredNode(
|
|
101
|
+
preferredNodeId: string,
|
|
102
|
+
rows: readonly NodeRow[]
|
|
103
|
+
): string {
|
|
104
|
+
if (!preferredNodeId) return AUTOMATIC_NODE;
|
|
105
|
+
return rows.some((row) => row.nodeId === preferredNodeId)
|
|
106
|
+
? preferredNodeId
|
|
107
|
+
: AUTOMATIC_NODE;
|
|
108
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
355
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|