roxxie-proxy-u-prod 0.2.3 → 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 (27) hide show
  1. package/README.md +6 -6
  2. package/dist/assets/{index-BQkXFDet.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 +1 -1
  6. package/runtime/index.html +3 -3
  7. package/scripts/verify.mjs +2 -2
  8. package/source/packages/chrome/src/Tab/History.test.ts +137 -0
  9. package/source/packages/chrome/src/Tab/History.ts +8 -3
  10. package/source/packages/chrome/src/Tab/Tab.tsx +4 -1
  11. package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
  12. package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
  13. package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
  14. package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
  15. package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
  16. package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
  17. package/source/packages/chrome/src/pages/nodes.ts +108 -0
  18. package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
  19. package/source/packages/chrome/src/services/SettingsService.ts +8 -1
  20. package/source/packages/roxxie-transport/package.json +1 -1
  21. package/source/packages/roxxie-transport/src/transport.ts +51 -7
  22. package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
  23. package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
  24. package/source/packages/tracker-protocol/package.json +1 -1
  25. package/source/packages/tracker-protocol/src/index.ts +22 -0
  26. package/source/pnpm-lock.yaml +1 -1
  27. package/standalone.html +2 -2
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ applyUserAgentHeader,
5
+ browserUserAgent,
6
+ resetUserAgentCache,
7
+ } from "../../../scramjet/packages/core/src/fetch/user-agent";
8
+
9
+ /** Minimal stand-in for ScramjetHeaders' case-insensitive storage. */
10
+ function headers(initial: Record<string, string> = {}) {
11
+ const store = new Map<string, string>();
12
+ for (const [name, value] of Object.entries(initial)) {
13
+ store.set(name.toLowerCase(), value);
14
+ }
15
+ return {
16
+ get: (key: string) => store.get(key.toLowerCase()) ?? null,
17
+ set: (key: string, value: string) => {
18
+ store.set(key.toLowerCase(), value);
19
+ },
20
+ raw: store,
21
+ };
22
+ }
23
+
24
+ const FIREFOX =
25
+ "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0";
26
+
27
+ afterEach(() => {
28
+ resetUserAgentCache();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ describe("User-Agent forwarding", () => {
33
+ it("sends the browser's own User-Agent when the request carries none", () => {
34
+ // Without this an origin that content-negotiates on User-Agent serves its
35
+ // non-browser representation: ip.me returns a text/plain IP, not its page.
36
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
37
+ const h = headers();
38
+
39
+ applyUserAgentHeader(h);
40
+
41
+ expect(h.get("user-agent")).toBe(FIREFOX);
42
+ });
43
+
44
+ it("matches the header case-insensitively", () => {
45
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
46
+ const h = headers({ "User-Agent": "PageChosen/1.0" });
47
+
48
+ applyUserAgentHeader(h);
49
+
50
+ expect(h.get("user-agent")).toBe("PageChosen/1.0");
51
+ });
52
+
53
+ it("never overwrites a User-Agent the page set deliberately", () => {
54
+ vi.stubGlobal("navigator", { userAgent: FIREFOX });
55
+ const h = headers({ "user-agent": "CustomBot/2.0" });
56
+
57
+ applyUserAgentHeader(h);
58
+
59
+ expect(h.get("user-agent")).toBe("CustomBot/2.0");
60
+ });
61
+
62
+ it("leaves the header absent rather than inventing one", () => {
63
+ vi.stubGlobal("navigator", { userAgent: "" });
64
+ const h = headers();
65
+
66
+ applyUserAgentHeader(h);
67
+
68
+ expect(h.get("user-agent")).toBeNull();
69
+ });
70
+
71
+ it("tolerates environments with no navigator at all", () => {
72
+ vi.stubGlobal("navigator", undefined);
73
+ const h = headers();
74
+
75
+ expect(() => applyUserAgentHeader(h)).not.toThrow();
76
+ expect(browserUserAgent()).toBeNull();
77
+ });
78
+ });
@@ -24,6 +24,8 @@ export type Settings = {
24
24
  clearHistoryOnExit: boolean;
25
25
  doNotTrack: boolean;
26
26
  extensionsDevMode: boolean;
27
+ /** Node id the user pinned in Advanced settings; empty means automatic. */
28
+ preferredNodeId: string;
27
29
  };
28
30
 
29
31
  export type TabLayoutMode = Settings["tabLayout"];
@@ -44,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = {
44
46
  clearHistoryOnExit: false,
45
47
  doNotTrack: true,
46
48
  extensionsDevMode: false,
49
+ preferredNodeId: "",
47
50
  };
48
51
 
49
52
  export type SettingsServiceState = {
@@ -63,6 +66,7 @@ export type SettingsServiceState = {
63
66
  clearHistoryOnExit: boolean;
64
67
  doNotTrack: boolean;
65
68
  extensionsDevMode: boolean;
69
+ preferredNodeId: string;
66
70
  };
67
71
  };
68
72
 
@@ -72,7 +76,9 @@ export class SettingsService extends Service {
72
76
  constructor(data: SettingsServiceState | null) {
73
77
  super();
74
78
  if (data) {
75
- this.settings = createState(data.settings);
79
+ // Profiles saved before a setting existed omit it entirely, so fill in
80
+ // defaults rather than leaving the state with undefined values.
81
+ this.settings = createState({ ...DEFAULT_SETTINGS, ...data.settings });
76
82
  } else {
77
83
  this.settings = createState(DEFAULT_SETTINGS);
78
84
  }
@@ -102,6 +108,7 @@ export class SettingsService extends Service {
102
108
  clearHistoryOnExit: this.settings.clearHistoryOnExit,
103
109
  doNotTrack: this.settings.doNotTrack,
104
110
  extensionsDevMode: this.settings.extensionsDevMode,
111
+ preferredNodeId: this.settings.preferredNodeId ?? "",
105
112
  },
106
113
  };
107
114
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports",
3
- "version": "6.0.2",
3
+ "version": "6.1.0",
4
4
  "description": "Tracker-discovered WebRTC transport for Roxxie Proxy U",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -78,6 +78,9 @@ export class RoxxieProxyTransport implements ProxyTransport {
78
78
  private readonly nodeCooldowns = new Map<string, number>();
79
79
  private readonly dependencies: RoxxieTransportDependencies;
80
80
  private closed = false;
81
+ /** Node the user pinned in advanced settings, if any. */
82
+ private preferredNodeId: string | undefined;
83
+ private lastCandidates: readonly CandidateNode[] = [];
81
84
 
82
85
  constructor(
83
86
  private readonly source: TrackerNetworkSource,
@@ -243,16 +246,57 @@ export class RoxxieProxyTransport implements ProxyTransport {
243
246
  }
244
247
  }
245
248
 
249
+ /**
250
+ * Nodes the tracker offered on the most recent connection attempt. Used by
251
+ * the settings UI to show what is available without forcing a reconnect.
252
+ */
253
+ get availableNodes(): readonly CandidateNode[] {
254
+ return this.lastCandidates;
255
+ }
256
+
257
+ /** Ask the tracker for the current node list without connecting. */
258
+ async listNodes(): Promise<readonly CandidateNode[]> {
259
+ const document = await this.getDocument();
260
+ const tracker = this.getTracker(document);
261
+ const candidates = [...(await tracker.requestCandidates())];
262
+ this.lastCandidates = candidates;
263
+ return candidates;
264
+ }
265
+
266
+ get preferredNode(): string | undefined {
267
+ return this.preferredNodeId;
268
+ }
269
+
270
+ /**
271
+ * Pin future connections to one node, or pass undefined to go back to
272
+ * automatic selection. Takes effect on the next connection.
273
+ */
274
+ setPreferredNode(nodeId: string | undefined): void {
275
+ this.preferredNodeId = nodeId || undefined;
276
+ }
277
+
246
278
  private async establishConnection(): Promise<RoxxieConnection> {
247
279
  const document = await this.getDocument();
248
280
  const tracker = this.getTracker(document);
249
- const discovered = [...(await tracker.requestCandidates())]
250
- .slice(0, 3)
251
- .sort(
252
- (left, right) =>
253
- left.activeSessions - right.activeSessions ||
254
- left.utilization - right.utilization
255
- );
281
+ const offered = [...(await tracker.requestCandidates())];
282
+ // Keep the full list for the settings UI; only the probe set is trimmed.
283
+ this.lastCandidates = offered;
284
+ const ranked = offered.sort(
285
+ (left, right) =>
286
+ left.activeSessions - right.activeSessions ||
287
+ left.utilization - right.utilization
288
+ );
289
+ // A pinned node has to be selected before the list is trimmed, or picking
290
+ // a lightly-used node outside the top few would silently do nothing.
291
+ const pinned =
292
+ this.preferredNodeId === undefined
293
+ ? []
294
+ : ranked.filter(
295
+ (candidate) => candidate.nodeId === this.preferredNodeId
296
+ );
297
+ // Fall back to automatic selection rather than stranding the user on a node
298
+ // that has gone away since they picked it.
299
+ const discovered = pinned.length > 0 ? pinned : ranked.slice(0, 3);
256
300
  const compatible = discovered.filter(
257
301
  (candidate) => candidate.protocolVersion === PROTOCOL_VERSION
258
302
  );
@@ -13,6 +13,7 @@ import {
13
13
  import { RawHeaders } from "@mercuryworkshop/proxy-transports";
14
14
  import { _URL, _Set } from "@/shared/snapshot";
15
15
  import { createReferrerString } from "./util";
16
+ import { applyUserAgentHeader } from "./user-agent";
16
17
 
17
18
  /**
18
19
  * Headers for security policy features that haven't been emulated yet
@@ -123,6 +124,13 @@ export function rewriteRequestHeaders(
123
124
  ): ScramjetHeaders {
124
125
  const headers = request.initialHeaders.clone();
125
126
 
127
+ // A service worker never sees User-Agent on the request it intercepts, and
128
+ // nothing downstream adds one, so requests reach origins with no User-Agent
129
+ // at all. Servers that content-negotiate on it then serve their non-browser
130
+ // representation - ip.me, for example, answers with a bare text/plain IP
131
+ // instead of its actual page.
132
+ applyUserAgentHeader(headers);
133
+
126
134
  // avoid leaking the scramjet referer
127
135
  headers.delete("Referer");
128
136
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * User-Agent forwarding.
3
+ *
4
+ * A service worker never sees User-Agent on the request it intercepts - the
5
+ * browser strips it before the fetch event - and nothing further down the proxy
6
+ * adds one. Requests therefore reached origins with no User-Agent at all, and
7
+ * servers that content-negotiate on it served their non-browser representation.
8
+ * ip.me, for instance, answers a bare `text/plain` IP address rather than its
9
+ * actual page.
10
+ *
11
+ * Kept free of the rest of the fetch pipeline's imports so it stays testable on
12
+ * its own.
13
+ */
14
+
15
+ /** The minimal surface this needs from ScramjetHeaders. */
16
+ export interface UserAgentHeaders {
17
+ get(key: string): string | null;
18
+ set(key: string, value: string): void;
19
+ }
20
+
21
+ let cachedUserAgent: string | null | undefined;
22
+
23
+ /**
24
+ * The browser's own User-Agent, or null where no navigator is available. Cached
25
+ * because the value cannot change for the life of the worker.
26
+ */
27
+ export function browserUserAgent(): string | null {
28
+ if (cachedUserAgent === undefined) {
29
+ const value =
30
+ typeof navigator === "undefined" ? undefined : navigator.userAgent;
31
+ cachedUserAgent =
32
+ typeof value === "string" && value.length > 0 ? value : null;
33
+ }
34
+ return cachedUserAgent;
35
+ }
36
+
37
+ /** Forces the next read to re-detect the User-Agent. Intended for tests. */
38
+ export function resetUserAgentCache(): void {
39
+ cachedUserAgent = undefined;
40
+ }
41
+
42
+ /**
43
+ * Send the real browser User-Agent, unless the page set one of its own. Passing
44
+ * the browser's string through unchanged keeps a proxied request
45
+ * indistinguishable from a direct one for content negotiation.
46
+ */
47
+ export function applyUserAgentHeader(headers: UserAgentHeaders): void {
48
+ if (headers.get("user-agent")) return;
49
+ const agent = browserUserAgent();
50
+ if (agent) headers.set("User-Agent", agent);
51
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxxie-transports-tracker-protocol",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Browser-safe control-plane contracts for the Roxxie tracker, nodes, and browsers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -109,6 +109,13 @@ export interface CandidateNode {
109
109
  maxSessions: number;
110
110
  utilization: number;
111
111
  protocolVersion: string;
112
+ /**
113
+ * Node agent release, surfaced so operators can see what each node runs.
114
+ * Optional for compatibility with trackers that predate it.
115
+ */
116
+ packageVersion?: string;
117
+ /** Operating system the node runs on, when the tracker reports it. */
118
+ platform?: string;
112
119
  iceServers: RTCIceServerJSON[];
113
120
  }
114
121
 
@@ -668,6 +675,21 @@ export function assertBrowserServerMessage(
668
675
  `candidates[${index}].utilization is invalid`
669
676
  );
670
677
  }
678
+ // Optional: older trackers omit these entirely.
679
+ if (candidate.packageVersion !== undefined) {
680
+ stringValue(
681
+ candidate.packageVersion,
682
+ `candidates[${index}].packageVersion`,
683
+ 32
684
+ );
685
+ }
686
+ if (candidate.platform !== undefined) {
687
+ stringValue(
688
+ candidate.platform,
689
+ `candidates[${index}].platform`,
690
+ 32
691
+ );
692
+ }
671
693
  assertIceServers(
672
694
  candidate.iceServers,
673
695
  `candidates[${index}].iceServers`
@@ -341,7 +341,7 @@ importers:
341
341
  specifier: 6.0.1
342
342
  version: link:../adrift-protocol
343
343
  roxxie-transports-tracker-protocol:
344
- specifier: 1.2.1
344
+ specifier: 1.3.0
345
345
  version: link:../tracker-protocol
346
346
  ws:
347
347
  specifier: 8.21.1
package/standalone.html 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.3/embed.js"
22
- integrity="sha384-yu0TlvCJ9LcKoOG66anQWT/iHEz3uC7rUGLmaDZV9otcFcWsQ6EvX8y2GPuxFsOH"
21
+ src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/embed.js"
22
+ integrity="sha384-o6wwoyCvQbj3evNh2USZSUAOc625OxxX2yUnWs7o2i4NmKGhII4Y9SxvCG03OjAk"
23
23
  crossorigin="anonymous"
24
24
  data-roxxie-target="#roxxie-browser"
25
25
  ></script>