roxxie-proxy-u-prod 0.2.3 → 0.2.5
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-CSCDo1rk.js +3669 -0
- package/dist/assets/inject-CDw4wJ3e.js +43 -0
- package/embed.js +1 -1
- package/examples/single-page.html +2 -2
- package/package.json +1 -1
- package/runtime/index.html +3 -3
- package/scripts/verify.mjs +2 -2
- 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 +4 -1
- 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/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/transport.ts +51 -7
- package/source/packages/scramjet/packages/core/dist/scramjet.js +14 -13582
- package/source/packages/scramjet/packages/core/dist/scramjet.js.map +1 -1
- package/source/packages/scramjet/packages/core/dist/scramjet.mjs +14 -13632
- package/source/packages/scramjet/packages/core/dist/scramjet.mjs.map +1 -1
- package/source/packages/scramjet/packages/core/dist/scramjet_bundled.js +14 -13584
- package/source/packages/scramjet/packages/core/dist/scramjet_bundled.js.map +1 -1
- package/source/packages/scramjet/packages/core/dist/scramjet_bundled.mjs +14 -13634
- package/source/packages/scramjet/packages/core/dist/scramjet_bundled.mjs.map +1 -1
- package/source/packages/scramjet/packages/core/dist/types/fetch/user-agent.d.ts +31 -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/tracker-protocol/package.json +1 -1
- package/source/packages/tracker-protocol/src/index.ts +22 -0
- package/source/pnpm-lock.yaml +1 -1
- package/standalone.html +2 -2
- package/dist/assets/index-BQkXFDet.js +0 -3613
- package/dist/assets/inject-xtH4w5z9.js +0 -43
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
);
|