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,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
- 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