roxxie-proxy-u-prod 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/assets/{index-CttvagWK.js → index-BQkXFDet.js} +101 -101
- 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/Tab.tsx +9 -1
- 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/roxxie-transport/package.json +1 -1
- package/source/packages/roxxie-transport/src/connection.ts +18 -1
- package/source/packages/scramjet/packages/core/src/fetch/fetch.ts +7 -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/pnpm-lock.yaml +2 -2
- package/standalone.html +2 -2
package/embed.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<div id="roxxie-browser"></div>
|
|
19
19
|
<script
|
|
20
20
|
defer
|
|
21
|
-
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
22
|
-
integrity="sha384-
|
|
21
|
+
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/embed.js"
|
|
22
|
+
integrity="sha384-yu0TlvCJ9LcKoOG66anQWT/iHEz3uC7rUGLmaDZV9otcFcWsQ6EvX8y2GPuxFsOH"
|
|
23
23
|
crossorigin="anonymous"
|
|
24
24
|
data-roxxie-target="#roxxie-browser"
|
|
25
25
|
></script>
|
package/package.json
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
2
|
+
"name": "roxxie-proxy-u-prod",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Production CDN assets and embed loader for Roxxie Proxy U",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "embed.js",
|
|
8
|
+
"browser": "embed.js",
|
|
9
|
+
"jsdelivr": "embed.js",
|
|
10
|
+
"unpkg": "embed.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"runtime",
|
|
14
|
+
"examples",
|
|
15
|
+
"scripts",
|
|
16
|
+
"source",
|
|
17
|
+
"third_party",
|
|
18
|
+
"embed.js",
|
|
19
|
+
"standalone.html",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"SOURCE.md",
|
|
22
|
+
"THIRD_PARTY_NOTICES.md",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"playwright": "1.59.1"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"roxxie",
|
|
33
|
+
"browser",
|
|
34
|
+
"proxy",
|
|
35
|
+
"webrtc",
|
|
36
|
+
"embed"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node scripts/build.mjs",
|
|
40
|
+
"smoke": "node scripts/smoke.mjs",
|
|
41
|
+
"verify": "node scripts/verify.mjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/runtime/index.html
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
5
|
+
<link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/favicon.png" />
|
|
6
6
|
<meta name="theme-color" content="#222230" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>Roxxie Proxy U</title>
|
|
9
9
|
|
|
10
10
|
<!--ssr-head-->
|
|
11
|
-
<script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
12
|
-
<link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
11
|
+
<script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-BQkXFDet.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.3/dist/assets/index-EeRhLAof.css">
|
|
13
13
|
</head>
|
|
14
14
|
|
|
15
15
|
<body>
|
package/scripts/verify.mjs
CHANGED
|
@@ -118,8 +118,8 @@ if (
|
|
|
118
118
|
throw new Error("Start screen must remain flat and glow-free");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
if (manifest.version !== "0.2.
|
|
122
|
-
throw new Error("This production release must be version 0.2.
|
|
121
|
+
if (manifest.version !== "0.2.3") {
|
|
122
|
+
throw new Error("This production release must be version 0.2.3");
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const embedBytes = await readFile(join(packageRoot, "embed.js"));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roxxie-transports-adrift-protocol",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Browser-safe wire types and codecs for Roxxie Proxy U transports",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"test": "npm run build && node --test dist/test/*.test.js"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
|
@@ -47,6 +47,17 @@ export const S2CRequestTypes = {
|
|
|
47
47
|
WSDataEnd: 8,
|
|
48
48
|
WSError: 9,
|
|
49
49
|
Pong: 10,
|
|
50
|
+
/**
|
|
51
|
+
* Terminates a response whose body was cut short after the headers were
|
|
52
|
+
* already delivered. `HTTPResponseEnd` means "the body is complete", so it
|
|
53
|
+
* must never be used for a truncated stream: the client cannot tell a
|
|
54
|
+
* partial body from a whole one and will happily parse and cache it.
|
|
55
|
+
*
|
|
56
|
+
* Purely additive, so `PROTOCOL_VERSION` stays at 6.0. A node that sends
|
|
57
|
+
* this to an older client makes that client fail the session loudly, which
|
|
58
|
+
* is strictly better than the silent corruption it replaces.
|
|
59
|
+
*/
|
|
60
|
+
HTTPResponseError: 11,
|
|
50
61
|
} as const;
|
|
51
62
|
|
|
52
63
|
export type ObjectValue<T> = T[keyof T];
|
|
@@ -62,7 +62,7 @@ export class Tab extends StatefulClass {
|
|
|
62
62
|
this.url ??= new URL(`${INTERNAL_URL_PROTOCOL}//newtab`);
|
|
63
63
|
this.id ??= uuid("tab-");
|
|
64
64
|
|
|
65
|
-
this.frame = new ProxyFrame();
|
|
65
|
+
this.frame = new ProxyFrame(() => this.commitExternalNavigation());
|
|
66
66
|
this.history = new History(this, history);
|
|
67
67
|
this.own(this.history);
|
|
68
68
|
this.waitForInit = new Promise((resolve) => {
|
|
@@ -135,6 +135,7 @@ export class Tab extends StatefulClass {
|
|
|
135
135
|
|
|
136
136
|
// only caller should be history.ts for this
|
|
137
137
|
_directnavigate(url: URL) {
|
|
138
|
+
this.frame.cancelPendingNavigation();
|
|
138
139
|
this.url = url;
|
|
139
140
|
this.icon = defaultFaviconUrl;
|
|
140
141
|
if (url.protocol == INTERNAL_URL_PROTOCOL) {
|
|
@@ -188,6 +189,13 @@ export class Tab extends StatefulClass {
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
initialLoad() {
|
|
192
|
+
this.commitExternalNavigation();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private commitExternalNavigation() {
|
|
196
|
+
// A late response from a page we have already left must not hide a newly
|
|
197
|
+
// selected internal page.
|
|
198
|
+
if (this.url.protocol === INTERNAL_URL_PROTOCOL) return;
|
|
191
199
|
this.initResolve();
|
|
192
200
|
this.internalpage = null;
|
|
193
201
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { HttpCachePlugin } from "./cache";
|
|
16
16
|
import { adBlockPlugin } from "..";
|
|
17
17
|
import { runtimeAssetUrl } from "../runtime-base.ts";
|
|
18
|
+
import { navigationCommitTracker } from "./navigation";
|
|
18
19
|
|
|
19
20
|
export function makeId(): string {
|
|
20
21
|
return Math.random().toString(36).substring(2, 10);
|
|
@@ -69,6 +70,12 @@ export class Controller {
|
|
|
69
70
|
};
|
|
70
71
|
|
|
71
72
|
const fetchresp = await handlefetch(request, this);
|
|
73
|
+
navigationCommitTracker.observe({
|
|
74
|
+
destination: data.destination,
|
|
75
|
+
requestUrl: data.rawUrl,
|
|
76
|
+
status: fetchresp.status,
|
|
77
|
+
location: fetchresp.headers.get("location"),
|
|
78
|
+
});
|
|
72
79
|
|
|
73
80
|
const response: TransferResponse = {
|
|
74
81
|
status: fetchresp.status,
|
|
@@ -87,6 +94,11 @@ export class Controller {
|
|
|
87
94
|
return [response, transfer];
|
|
88
95
|
} catch (e: any) {
|
|
89
96
|
console.error("Error in controller fetch:", e);
|
|
97
|
+
navigationCommitTracker.observe({
|
|
98
|
+
destination: data.destination,
|
|
99
|
+
requestUrl: data.rawUrl,
|
|
100
|
+
status: 500,
|
|
101
|
+
});
|
|
90
102
|
return [
|
|
91
103
|
{
|
|
92
104
|
status: 500,
|
|
@@ -1,23 +1,40 @@
|
|
|
1
1
|
import { rewriteUrl } from "@mercuryworkshop/scramjet/bundled";
|
|
2
2
|
import { Controller, controllerForURL } from "./Controller";
|
|
3
|
+
import { navigationCommitTracker } from "./navigation";
|
|
3
4
|
|
|
4
5
|
export class ProxyFrame {
|
|
5
6
|
frame: HTMLIFrameElement;
|
|
6
7
|
controller: Controller | null = null;
|
|
7
|
-
|
|
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
|
|
|
@@ -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
|
+
});
|