roxxie-proxy-u-prod 0.2.5 → 0.3.0

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/embed.js CHANGED
@@ -3,7 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  var DEFAULT_SHELL_URL =
6
- "https://unpkg.com/roxxie-proxy-u-prod@0.2.5/runtime/index.html";
6
+ "https://unpkg.com/roxxie-proxy-u-prod@0.3.0/runtime/index.html";
7
7
  var loadingScript = document.currentScript;
8
8
 
9
9
  function resolveContainer(target) {
@@ -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.5/embed.js"
22
- integrity="sha384-3fyAZcakc565/Ft23hCqQX1ztazzOiLIr+0aU2iffd1tXw5AIRrYpPFWfE5/MXj8"
21
+ src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.3.0/embed.js"
22
+ integrity="sha384-njIFCNZAx6YwA02XL4JGMzcsiVqHuTRWutGa/G1NzqEe0t0V+gWPNEe0XCTde9Q3"
23
23
  crossorigin="anonymous"
24
24
  data-roxxie-target="#roxxie-browser"
25
25
  ></script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-proxy-u-prod",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Production CDN assets and embed loader for Roxxie Proxy U",
5
5
  "license": "AGPL-3.0-only",
6
6
  "private": false,
@@ -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/dist/favicon.png" />
5
+ <link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.3.0/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.5/dist/assets/index-CSCDo1rk.js"></script>
12
- <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.5/dist/assets/index-EeRhLAof.css">
11
+ <script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.3.0/dist/assets/index-C8me8aPT.js"></script>
12
+ <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.3.0/dist/assets/index-EeRhLAof.css">
13
13
  </head>
14
14
 
15
15
  <body>
@@ -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.5") {
122
- throw new Error("This production release must be version 0.2.5");
121
+ if (manifest.version !== "0.3.0") {
122
+ throw new Error("This production release must be version 0.3.0");
123
123
  }
124
124
 
125
125
  const embedBytes = await readFile(join(packageRoot, "embed.js"));
@@ -135,3 +135,33 @@ describe("tab history", () => {
135
135
  expect(restored.canGoForward()).toBe(true);
136
136
  });
137
137
  });
138
+
139
+ describe("hostile history input", () => {
140
+ it("ignores a non-finite delta instead of stranding the tab", () => {
141
+ const { history } = makeHistory();
142
+ history.push(A);
143
+ history.push(B);
144
+
145
+ // history.go() with no argument used to make index NaN, after which every
146
+ // lookup returned undefined and the tab navigated to /undefined.
147
+ const state = history.go(Number.NaN as number);
148
+
149
+ expect(Number.isFinite(history.index)).toBe(true);
150
+ expect(state).toBeDefined();
151
+ expect(state.url.href).toBe(B.href);
152
+ });
153
+
154
+ it("keeps index finite across repeated bad deltas", () => {
155
+ const { history, tab } = makeHistory();
156
+ history.push(A);
157
+ history.push(B);
158
+ history.push(C);
159
+
160
+ for (const bad of [undefined, null, "x", Number.NaN, Infinity]) {
161
+ history.go(bad as unknown as number);
162
+ expect(Number.isFinite(history.index)).toBe(true);
163
+ expect(history.states[history.index]).toBeDefined();
164
+ }
165
+ expect(tab.navigated.some((href) => href.includes("undefined"))).toBe(false);
166
+ });
167
+ });
@@ -150,7 +150,9 @@ export class History extends StatefulClass {
150
150
  }
151
151
  go(delta: number, navigate: boolean = true): HistoryState {
152
152
  const current = this.current();
153
- this.index += delta;
153
+ // A non-finite delta would turn index into NaN and strand the tab on an
154
+ // undefined state.
155
+ this.index += Number.isFinite(delta) ? Math.trunc(delta) : 0;
154
156
  if (this.index < 0) {
155
157
  this.index = 0;
156
158
  } else if (this.index >= this.states.length) {
@@ -8,6 +8,7 @@ import "./style.css";
8
8
  import.meta.hot?.accept(() => location.reload());
9
9
 
10
10
  import {
11
+ applyPreferredNode,
11
12
  connectRoxxieTransport,
12
13
  installRoxxieTransport,
13
14
  subscribeToRoxxieStatus,
@@ -145,6 +146,10 @@ const prepareBrowser = createRetryableTask(async () => {
145
146
  await puter.auth.signIn();
146
147
  }
147
148
  await loadServices();
149
+ // Re-apply a node pinned in Advanced settings. Settings only exist once
150
+ // loadServices has run, and without this the preference is dropped on every
151
+ // reload so node selection silently does nothing.
152
+ applyPreferredNode(settingsService.settings.preferredNodeId);
148
153
  // The bundled EasyList/uBlock-compatible blocker must be ready before any
149
154
  // restored tab or first navigation is allowed to run.
150
155
  await bundledAdBlocker.ready;
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * Mirrors the resolution the injected History emulator performs before sending
5
+ * pushState/replaceState across the RPC boundary. Kept in step with
6
+ * packages/inject/src/emulators/history.ts.
7
+ */
8
+ function resolveStateUrl(raw: unknown, base: string | URL): string {
9
+ const current = new URL(String(base)).href;
10
+ if (raw === undefined || raw === null) return current;
11
+ try {
12
+ return new URL(String(raw), current).href;
13
+ } catch {
14
+ return current;
15
+ }
16
+ }
17
+
18
+ function normalizeDelta(raw: unknown): number {
19
+ const delta = Number(raw);
20
+ return Number.isFinite(delta) ? Math.trunc(delta) : 0;
21
+ }
22
+
23
+ const PAGE = "https://example.com/app/page";
24
+
25
+ describe("pushState url resolution", () => {
26
+ it("keeps the current url when the page omits one", () => {
27
+ // This is the /undefined bug: new URL(undefined, base) stringifies to the
28
+ // literal "undefined", so pushState(state, "") navigated to /undefined.
29
+ expect(resolveStateUrl(undefined, PAGE)).toBe(PAGE);
30
+ expect(resolveStateUrl(null, PAGE)).toBe(PAGE);
31
+ });
32
+
33
+ it("never produces a path of /undefined or /null", () => {
34
+ for (const raw of [undefined, null]) {
35
+ const resolved = resolveStateUrl(raw, PAGE);
36
+ expect(resolved).not.toContain("/undefined");
37
+ expect(resolved).not.toContain("/null");
38
+ }
39
+ });
40
+
41
+ it("resolves a relative url against the current page", () => {
42
+ expect(resolveStateUrl("other", PAGE)).toBe(
43
+ "https://example.com/app/other"
44
+ );
45
+ expect(resolveStateUrl("/root", PAGE)).toBe("https://example.com/root");
46
+ expect(resolveStateUrl("?q=1", PAGE)).toBe(
47
+ "https://example.com/app/page?q=1"
48
+ );
49
+ });
50
+
51
+ it("accepts an absolute same-origin url", () => {
52
+ expect(resolveStateUrl("https://example.com/x", PAGE)).toBe(
53
+ "https://example.com/x"
54
+ );
55
+ });
56
+
57
+ it("keeps the current url when the page supplies an unusable one", () => {
58
+ expect(resolveStateUrl("http://[", PAGE)).toBe(PAGE);
59
+ });
60
+
61
+ it("treats an empty string as the current document, per spec", () => {
62
+ expect(resolveStateUrl("", PAGE)).toBe(PAGE);
63
+ });
64
+ });
65
+
66
+ describe("history.go delta", () => {
67
+ it("treats a missing delta as no movement", () => {
68
+ // history.go() with no argument must not make the index NaN.
69
+ expect(normalizeDelta(undefined)).toBe(0);
70
+ expect(normalizeDelta(null)).toBe(0);
71
+ expect(normalizeDelta("nonsense")).toBe(0);
72
+ expect(normalizeDelta(Number.NaN)).toBe(0);
73
+ expect(normalizeDelta(Number.POSITIVE_INFINITY)).toBe(0);
74
+ });
75
+
76
+ it("passes real movement through", () => {
77
+ expect(normalizeDelta(-1)).toBe(-1);
78
+ expect(normalizeDelta(2)).toBe(2);
79
+ expect(normalizeDelta("-3")).toBe(-3);
80
+ expect(normalizeDelta(1.9)).toBe(1);
81
+ });
82
+ });
@@ -35,6 +35,15 @@ export function installRoxxieTransport(): RoxxieProxyTransport {
35
35
  return installed;
36
36
  }
37
37
 
38
+ /**
39
+ * Re-apply a node pin saved in settings. Without this the preference is
40
+ * forgotten on every reload and the browser silently reverts to picking a node
41
+ * automatically.
42
+ */
43
+ export function applyPreferredNode(nodeId: string | undefined): void {
44
+ roxxieTransport?.setPreferredNode(nodeId || undefined);
45
+ }
46
+
38
47
  /**
39
48
  * Resolves only after tracker discovery, WebRTC, the Adrift handshake, and the
40
49
  * node RTT probe have all succeeded. Concurrent Start clicks share one attempt.
@@ -70,6 +70,21 @@ function findSequence(
70
70
  }
71
71
  }
72
72
 
73
+
74
+ /**
75
+ * Parse a url supplied by a page, falling back to the tab's current url. A page
76
+ * can legitimately call pushState/replaceState with no url at all, and treating
77
+ * that as a navigation target sent the tab to /undefined.
78
+ */
79
+ function parsedOr(raw: unknown, fallback: URL): URL | null {
80
+ if (raw === undefined || raw === null || raw === "") return fallback;
81
+ try {
82
+ return new URL(String(raw), fallback);
83
+ } catch {
84
+ return fallback;
85
+ }
86
+ }
87
+
73
88
  export function reduceSequence(sequence: FrameSequence): Window | null {
74
89
  return sequence.reduce<Window | null>((win, idx) => {
75
90
  if (!win) return null;
@@ -135,21 +150,24 @@ class ProxyFrameContext {
135
150
  );
136
151
  },
137
152
  history_go: async ({ delta }) => {
138
- if (tab) {
139
- console.error("hist go" + delta);
140
- tab.history.go(delta);
141
- }
153
+ if (!tab) return;
154
+ const steps = Number(delta);
155
+ if (!Number.isFinite(steps) || steps === 0) return;
156
+ tab.history.go(Math.trunc(steps));
142
157
  },
143
158
  history_pushState: async ({ url, title, state }) => {
144
- if (tab) {
145
- console.error("hist push", url);
146
- tab.history.push(new URL(url), title, state, false, true);
147
- }
159
+ if (!tab) return;
160
+ // A page can reach this with no usable url; keeping the current one
161
+ // matches the spec and avoids navigating to a bogus entry.
162
+ const target = parsedOr(url, tab.url);
163
+ if (!target) return;
164
+ tab.history.push(target, title, state, false, true);
148
165
  },
149
166
  history_replaceState: async ({ url, title, state }) => {
150
- if (tab) {
151
- tab.history.replace(new URL(url), title, state, false);
152
- }
167
+ if (!tab) return;
168
+ const target = parsedOr(url, tab.url);
169
+ if (!target) return;
170
+ tab.history.replace(target, title, state, false);
153
171
  },
154
172
  newtab: async ({ url }) => {
155
173
  const tab = tabsService.newTab(new URL(url));
@@ -1,5 +1,30 @@
1
1
  import { ExecutionContextWrapper } from "../context";
2
2
 
3
+ /**
4
+ * The url argument of pushState/replaceState is optional. When it is omitted
5
+ * or null the spec keeps the document's URL unchanged - but `new URL(undefined,
6
+ * base)` stringifies to the literal "undefined", which navigated the tab to
7
+ * /undefined. Single-page apps call pushState(state, "") constantly, so this
8
+ * fired on ordinary browsing.
9
+ */
10
+ function resolveStateUrl(raw: unknown, base: string | URL): string {
11
+ const current = new URL(String(base)).href;
12
+ if (raw === undefined || raw === null) return current;
13
+ try {
14
+ return new URL(String(raw), current).href;
15
+ } catch {
16
+ // A url the page cannot navigate to leaves the current one in place,
17
+ // matching what the browser does with an invalid pushState target.
18
+ return current;
19
+ }
20
+ }
21
+
22
+ /** history.go(delta) treats a missing or non-finite delta as 0. */
23
+ function normalizeDelta(raw: unknown): number {
24
+ const delta = Number(raw);
25
+ return Number.isFinite(delta) ? Math.trunc(delta) : 0;
26
+ }
27
+
3
28
  export function setupHistoryEmulation({
4
29
  client,
5
30
  rpc,
@@ -12,7 +37,7 @@ export function setupHistoryEmulation({
12
37
  rpc.call("history_pushState", {
13
38
  state: ctx.args[0],
14
39
  title: ctx.args[1],
15
- url: new URL(ctx.args[2], relevantclient.url).href,
40
+ url: resolveStateUrl(ctx.args[2], relevantclient.url),
16
41
  });
17
42
  },
18
43
  });
@@ -23,7 +48,7 @@ export function setupHistoryEmulation({
23
48
  rpc.call("history_replaceState", {
24
49
  state: ctx.args[0],
25
50
  title: ctx.args[1],
26
- url: new URL(ctx.args[2], relevantclient.url).href,
51
+ url: resolveStateUrl(ctx.args[2], relevantclient.url),
27
52
  });
28
53
  },
29
54
  });
@@ -43,7 +68,9 @@ export function setupHistoryEmulation({
43
68
  });
44
69
  client.Proxy("History.prototype.go", {
45
70
  apply(ctx) {
46
- rpc.call("history_go", { delta: ctx.args[0] });
71
+ // history.go() with no argument (or a non-numeric one) means "reload the
72
+ // current entry". Forwarding undefined made the delta arithmetic NaN.
73
+ rpc.call("history_go", { delta: normalizeDelta(ctx.args[0]) });
47
74
 
48
75
  ctx.return(undefined);
49
76
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Tracker-discovered WebRTC transport for Roxxie Proxy U",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -268,11 +268,34 @@ export class RoxxieProxyTransport implements ProxyTransport {
268
268
  }
269
269
 
270
270
  /**
271
- * Pin future connections to one node, or pass undefined to go back to
272
- * automatic selection. Takes effect on the next connection.
271
+ * Pin connections to one node, or pass undefined for automatic selection.
272
+ *
273
+ * If this changes which node should be serving, the current connection is
274
+ * dropped so the choice takes effect immediately - leaving the session on
275
+ * the old node makes the setting look like it does nothing.
273
276
  */
274
277
  setPreferredNode(nodeId: string | undefined): void {
275
- this.preferredNodeId = nodeId || undefined;
278
+ const next = nodeId || undefined;
279
+ if (next === this.preferredNodeId) return;
280
+ this.preferredNodeId = next;
281
+ const current = this._connectedNode?.node.nodeId;
282
+ if (current !== undefined && next !== undefined && current !== next) {
283
+ this.reconnect();
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Drop the current node connection so the next request selects again. Unlike
289
+ * close(), the transport stays usable.
290
+ */
291
+ reconnect(): void {
292
+ if (this.closed) return;
293
+ const connection = this.connection;
294
+ this.connection = undefined;
295
+ this.connectionPromise = undefined;
296
+ this._connectedNode = undefined;
297
+ connection?.close(new Error("Reselecting Roxxie node"));
298
+ this.publish({ state: "idle" });
276
299
  }
277
300
 
278
301
  private async establishConnection(): Promise<RoxxieConnection> {