pi-freeflow 1.32.0 → 1.32.2
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/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/src/health.ts +157 -150
- package/src/proxy.ts +64 -1
- package/src/relay-state.ts +889 -868
- package/src/relay.ts +61 -3
- package/src/stream-pipe.ts +307 -62
package/src/relay-state.ts
CHANGED
|
@@ -18,38 +18,58 @@ import type { ExtensionUIContext, KnownRelay, RelayMode, RelayState } from "./ty
|
|
|
18
18
|
* isPrivateHostname discipline embedded in the Vercel/Cloudflare/Deno templates.
|
|
19
19
|
*/
|
|
20
20
|
function isPrivateRelayHostname(hostname: string): boolean {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
21
|
+
let host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
22
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1);
|
|
23
|
+
if (!host) return true;
|
|
24
|
+
if (
|
|
25
|
+
host === "localhost" ||
|
|
26
|
+
host === "0.0.0.0" ||
|
|
27
|
+
host === "127.0.0.1" ||
|
|
28
|
+
host.endsWith(".localhost") ||
|
|
29
|
+
host.endsWith(".local") ||
|
|
30
|
+
host.endsWith(".internal")
|
|
31
|
+
) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// IPv6 loopback / unspecified / any ::-prefixed form
|
|
35
|
+
if (host.startsWith("::")) return true;
|
|
36
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
37
|
+
if (v4) {
|
|
38
|
+
const a = Number(v4[1]);
|
|
39
|
+
const b = Number(v4[2]);
|
|
40
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
41
|
+
if (a === 169 && b === 254) return true;
|
|
42
|
+
if (a === 192 && b === 168) return true;
|
|
43
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
44
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (host.includes(":")) {
|
|
48
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true; // ULA fc00::/7
|
|
49
|
+
if (/^fe[89ab]/.test(host)) return true; // link-local fe80::/10
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Private mesh-VPN peer addresses (shared CGNAT 100.64.0.0/10, mesh ULA prefix,
|
|
57
|
+
* or mesh MagicDNS suffix). The mesh network layer provides end-to-end
|
|
58
|
+
* authentication and encryption, making direct HTTP and custom ports on mesh
|
|
59
|
+
* peers safe.
|
|
60
|
+
*/
|
|
61
|
+
function isMeshPeerHost(hostname: string): boolean {
|
|
62
|
+
let host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
63
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1);
|
|
64
|
+
if (host.endsWith(".ts.net")) return true;
|
|
65
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
66
|
+
if (v4) {
|
|
67
|
+
const a = Number(v4[1]);
|
|
68
|
+
const b = Number(v4[2]);
|
|
69
|
+
return a === 100 && b >= 64 && b <= 127;
|
|
70
|
+
}
|
|
71
|
+
if (host.startsWith("fd7a:115c:a1e0:")) return true;
|
|
72
|
+
return false;
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/**
|
|
@@ -60,36 +80,37 @@ function isPrivateRelayHostname(hostname: string): boolean {
|
|
|
60
80
|
* Returns { ok: true, url } or { ok: false, reason } — never throws.
|
|
61
81
|
*/
|
|
62
82
|
export function validateRelayUrl(
|
|
63
|
-
|
|
83
|
+
raw: string,
|
|
64
84
|
): { ok: true; url: URL } | { ok: false; reason: string } {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
const unsafe = process.env[ALLOW_UNSAFE_RELAY_ENV] === "1";
|
|
86
|
+
const trimmed = (raw || "").trim();
|
|
87
|
+
if (!trimmed) return { ok: false, reason: "Relay URL cannot be empty" };
|
|
88
|
+
let url: URL;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(trimmed);
|
|
91
|
+
} catch {
|
|
92
|
+
return { ok: false, reason: "invalid URL" };
|
|
93
|
+
}
|
|
94
|
+
const isMeshPeer = isMeshPeerHost(url.hostname);
|
|
95
|
+
if (!unsafe && !isMeshPeer) {
|
|
96
|
+
if (url.protocol !== "https:") {
|
|
97
|
+
return { ok: false, reason: "only https relay URLs are allowed" };
|
|
98
|
+
}
|
|
99
|
+
} else if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
100
|
+
return { ok: false, reason: "only http/https relay URLs are allowed" };
|
|
101
|
+
}
|
|
102
|
+
if (url.username || url.password) {
|
|
103
|
+
return { ok: false, reason: "credentials are not allowed in relay URLs" };
|
|
104
|
+
}
|
|
105
|
+
if (!unsafe && !isMeshPeer) {
|
|
106
|
+
if (isPrivateRelayHostname(url.hostname)) {
|
|
107
|
+
return { ok: false, reason: "private/loopback relay hosts are not allowed" };
|
|
108
|
+
}
|
|
109
|
+
if (url.port && url.port !== "443") {
|
|
110
|
+
return { ok: false, reason: "non-default relay ports are not allowed" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, url };
|
|
93
114
|
}
|
|
94
115
|
/**
|
|
95
116
|
* Parse a relay-state JSON blob into a usable state.
|
|
@@ -97,31 +118,31 @@ export function validateRelayUrl(
|
|
|
97
118
|
* either makes the state unusable for routing.
|
|
98
119
|
*/
|
|
99
120
|
function parseRelayState(raw: string): RelayState | null {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
try {
|
|
122
|
+
const s = JSON.parse(raw);
|
|
123
|
+
const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
|
|
124
|
+
if (relays.length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const mode: RelayMode =
|
|
128
|
+
s?.mode === "on" || s?.mode === "off" || s?.mode === "auto" || s?.mode === "spread"
|
|
129
|
+
? s.mode
|
|
130
|
+
: "auto";
|
|
131
|
+
return {
|
|
132
|
+
mode,
|
|
133
|
+
enabled:
|
|
134
|
+
mode === "on"
|
|
135
|
+
? true
|
|
136
|
+
: mode === "off"
|
|
137
|
+
? false
|
|
138
|
+
: s?.enabled !== false && relays.length > 0,
|
|
139
|
+
url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
|
|
140
|
+
relays,
|
|
141
|
+
hideWidget: s?.hideWidget === true,
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
/**
|
|
@@ -130,22 +151,22 @@ function parseRelayState(raw: string): RelayState | null {
|
|
|
130
151
|
* sibling process is never disturbed.
|
|
131
152
|
*/
|
|
132
153
|
function cleanupStaleTmpFiles(): void {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
try {
|
|
155
|
+
const dir = path.dirname(RELAY_STATE_FILE);
|
|
156
|
+
const prefix = `${path.basename(RELAY_STATE_FILE)}.`;
|
|
157
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
158
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
159
|
+
if (!entry.startsWith(prefix) || !entry.endsWith(".tmp")) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const full = path.join(dir, entry);
|
|
163
|
+
try {
|
|
164
|
+
if (fs.statSync(full).mtimeMs < cutoff) {
|
|
165
|
+
fs.rmSync(full, { force: true });
|
|
166
|
+
}
|
|
167
|
+
} catch { }
|
|
168
|
+
}
|
|
169
|
+
} catch { }
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
/**
|
|
@@ -153,15 +174,15 @@ function cleanupStaleTmpFiles(): void {
|
|
|
153
174
|
* recovery is sticky. Returns null when no usable backup exists.
|
|
154
175
|
*/
|
|
155
176
|
function recoverFromBackup(description: string): RelayState | null {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
try {
|
|
178
|
+
const bak = parseRelayState(fs.readFileSync(`${RELAY_STATE_FILE}.bak`, "utf8"));
|
|
179
|
+
if (bak) {
|
|
180
|
+
logWarn(`relay state ${description} — recovered from .bak`, { relays: bak.relays.length });
|
|
181
|
+
saveRelayState(bak); // heal the main file so recovery is sticky
|
|
182
|
+
return bak;
|
|
183
|
+
}
|
|
184
|
+
} catch { }
|
|
185
|
+
return null;
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
/**
|
|
@@ -173,41 +194,41 @@ function recoverFromBackup(description: string): RelayState | null {
|
|
|
173
194
|
* load.
|
|
174
195
|
*/
|
|
175
196
|
export function loadRelayState(): RelayState {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
197
|
+
try {
|
|
198
|
+
cleanupStaleTmpFiles();
|
|
199
|
+
if (!fs.existsSync(RELAY_STATE_FILE)) {
|
|
200
|
+
// Main file missing (deleted outright, never healed after a wipe):
|
|
201
|
+
// heal from .bak when the backup still holds relays — losing the
|
|
202
|
+
// pool to a missing main is just as bad as a corrupt one.
|
|
203
|
+
const healed = recoverFromBackup("main state file missing");
|
|
204
|
+
if (healed) {
|
|
205
|
+
return healed;
|
|
206
|
+
}
|
|
207
|
+
return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
|
|
208
|
+
}
|
|
209
|
+
const raw = fs.readFileSync(RELAY_STATE_FILE, "utf8");
|
|
210
|
+
const main = parseRelayState(raw);
|
|
211
|
+
if (main) {
|
|
212
|
+
return main;
|
|
213
|
+
}
|
|
214
|
+
// Try backup before giving up — user data loss is worse than stale data
|
|
215
|
+
const bak = recoverFromBackup("unusable");
|
|
216
|
+
if (bak) {
|
|
217
|
+
return bak;
|
|
218
|
+
}
|
|
219
|
+
logWarn("relay state file unusable and no valid backup — starting fresh", {
|
|
220
|
+
path: RELAY_STATE_FILE,
|
|
221
|
+
});
|
|
222
|
+
// parse returns null on an empty pool by design (wipe-protection above);
|
|
223
|
+
// preserve an explicit hide so hide-with-zero-relays survives reload.
|
|
224
|
+
let hide = false;
|
|
225
|
+
try {
|
|
226
|
+
hide = (JSON.parse(raw) as RelayState)?.hideWidget === true;
|
|
227
|
+
} catch { }
|
|
228
|
+
return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: hide };
|
|
229
|
+
} catch {
|
|
230
|
+
return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
|
|
231
|
+
}
|
|
211
232
|
}
|
|
212
233
|
|
|
213
234
|
/**
|
|
@@ -216,33 +237,33 @@ export function loadRelayState(): RelayState {
|
|
|
216
237
|
* corrupt or empty save can always be recovered by loadRelayState.
|
|
217
238
|
*/
|
|
218
239
|
export function saveRelayState(s: RelayState): void {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
try {
|
|
241
|
+
const dir = path.dirname(RELAY_STATE_FILE);
|
|
242
|
+
if (!fs.existsSync(dir)) {
|
|
243
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
// Backup current file before overwrite for recovery
|
|
246
|
+
try {
|
|
247
|
+
if (fs.existsSync(RELAY_STATE_FILE)) {
|
|
248
|
+
const cur = fs.readFileSync(RELAY_STATE_FILE, "utf8");
|
|
249
|
+
// Only backup non-empty states
|
|
250
|
+
const parsed = JSON.parse(cur);
|
|
251
|
+
if (Array.isArray(parsed?.relays) && parsed.relays.length > 0) {
|
|
252
|
+
fs.writeFileSync(`${RELAY_STATE_FILE}.bak`, cur, "utf8");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch { }
|
|
256
|
+
const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
|
|
257
|
+
fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
|
|
258
|
+
renameWithRetry(tmpPath, RELAY_STATE_FILE);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
logWarn("Could not persist relay state", { error: String(e) });
|
|
261
|
+
}
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
/** Block synchronously for ms without burning CPU. */
|
|
244
265
|
function sleepSync(ms: number): void {
|
|
245
|
-
|
|
266
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, Date.now() + ms);
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
/**
|
|
@@ -251,67 +272,67 @@ function sleepSync(ms: number): void {
|
|
|
251
272
|
* Three attempts with a fixed 50ms backoff; any other error fails immediately.
|
|
252
273
|
*/
|
|
253
274
|
function renameWithRetry(from: string, to: string): void {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
275
|
+
const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
|
|
276
|
+
for (let attempt = 1; ; attempt++) {
|
|
277
|
+
try {
|
|
278
|
+
fs.renameSync(from, to);
|
|
279
|
+
return;
|
|
280
|
+
} catch (e) {
|
|
281
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
282
|
+
if (!code || !retryable[code] || attempt >= 3) {
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
sleepSync(50);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
267
288
|
}
|
|
268
289
|
|
|
269
290
|
/**
|
|
270
291
|
* Deduplicate and add or update a relay URL in the known relay list.
|
|
271
292
|
*/
|
|
272
293
|
export function ensureRelay(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
294
|
+
s: RelayState,
|
|
295
|
+
url: string,
|
|
296
|
+
label?: string,
|
|
276
297
|
): KnownRelay {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
const cleanUrl = (url || "").trim();
|
|
299
|
+
if (!cleanUrl) {
|
|
300
|
+
throw new Error("Relay URL cannot be empty");
|
|
301
|
+
}
|
|
302
|
+
const check = validateRelayUrl(cleanUrl);
|
|
303
|
+
if (!check.ok) {
|
|
304
|
+
throw new Error(`Relay URL rejected: ${check.reason}`);
|
|
305
|
+
}
|
|
306
|
+
const cleanLabel = (label || "").trim() || undefined;
|
|
307
|
+
const existing = s.relays.find((r) => r.url === cleanUrl);
|
|
308
|
+
if (existing) {
|
|
309
|
+
if (cleanLabel && cleanLabel !== "manual") {
|
|
310
|
+
existing.label = cleanLabel;
|
|
311
|
+
}
|
|
312
|
+
return existing;
|
|
313
|
+
}
|
|
314
|
+
const newRelay: KnownRelay = {
|
|
315
|
+
url: cleanUrl,
|
|
316
|
+
label: cleanLabel && cleanLabel !== "manual" ? cleanLabel : undefined,
|
|
317
|
+
addedAt: new Date().toISOString(),
|
|
318
|
+
};
|
|
319
|
+
s.relays.push(newRelay);
|
|
320
|
+
return newRelay;
|
|
300
321
|
}
|
|
301
322
|
|
|
302
323
|
/**
|
|
303
324
|
* Set or update short name / label for a relay by URL, index, or existing label.
|
|
304
325
|
*/
|
|
305
326
|
export function setRelayLabel(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
s: RelayState,
|
|
328
|
+
identifier: string | number,
|
|
329
|
+
label: string,
|
|
309
330
|
): KnownRelay | null {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
331
|
+
const relay = findRelay(s, identifier);
|
|
332
|
+
if (!relay) return null;
|
|
333
|
+
const cleanLabel = (label || "").trim();
|
|
334
|
+
relay.label = cleanLabel || undefined;
|
|
335
|
+
return relay;
|
|
315
336
|
}
|
|
316
337
|
|
|
317
338
|
/**
|
|
@@ -319,43 +340,43 @@ export function setRelayLabel(
|
|
|
319
340
|
* short name / label, or URL.
|
|
320
341
|
*/
|
|
321
342
|
export function findRelay(
|
|
322
|
-
|
|
323
|
-
|
|
343
|
+
s: RelayState,
|
|
344
|
+
identifier: string | number,
|
|
324
345
|
): KnownRelay | undefined {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
346
|
+
if (typeof identifier === "number") {
|
|
347
|
+
const idx = identifier === 0 ? 0 : identifier - 1;
|
|
348
|
+
return s.relays[idx];
|
|
349
|
+
}
|
|
350
|
+
const str = String(identifier || "").trim();
|
|
351
|
+
if (!str) return undefined;
|
|
352
|
+
|
|
353
|
+
// 1-based index (e.g. "1", "2")
|
|
354
|
+
if (/^\d+$/.test(str)) {
|
|
355
|
+
const num = Number.parseInt(str, 10);
|
|
356
|
+
if (num >= 1 && num <= s.relays.length) {
|
|
357
|
+
return s.relays[num - 1];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Exact URL match
|
|
362
|
+
const exactUrl = s.relays.find((r) => r.url === str);
|
|
363
|
+
if (exactUrl) return exactUrl;
|
|
364
|
+
|
|
365
|
+
// Case-insensitive label match
|
|
366
|
+
const byLabel = s.relays.find(
|
|
367
|
+
(r) => r.label && r.label.toLowerCase() === str.toLowerCase(),
|
|
368
|
+
);
|
|
369
|
+
if (byLabel) return byLabel;
|
|
370
|
+
|
|
371
|
+
// Partial URL match
|
|
372
|
+
return s.relays.find((r) => r.url.toLowerCase().includes(str.toLowerCase()));
|
|
352
373
|
}
|
|
353
374
|
|
|
354
375
|
/**
|
|
355
376
|
* Remove a relay URL from the known relay list.
|
|
356
377
|
*/
|
|
357
378
|
export function removeRelay(s: RelayState, url: string): void {
|
|
358
|
-
|
|
379
|
+
s.relays = s.relays.filter((r) => r.url !== url);
|
|
359
380
|
}
|
|
360
381
|
|
|
361
382
|
/**
|
|
@@ -364,7 +385,7 @@ export function removeRelay(s: RelayState, url: string): void {
|
|
|
364
385
|
* was dead code and is removed; loadRelayState already returns usable state.
|
|
365
386
|
*/
|
|
366
387
|
export function resolveRelayState(): RelayState {
|
|
367
|
-
|
|
388
|
+
return loadRelayState();
|
|
368
389
|
}
|
|
369
390
|
|
|
370
391
|
let activeRelayState: RelayState = resolveRelayState();
|
|
@@ -372,25 +393,25 @@ let activeStatusUi: ExtensionUIContext | null = null;
|
|
|
372
393
|
let isFreeFlowModelActive = true;
|
|
373
394
|
|
|
374
395
|
export interface RelayHealth {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
396
|
+
consecutiveFailures: number;
|
|
397
|
+
lastFailureTime: number;
|
|
398
|
+
cooldownUntil: number;
|
|
399
|
+
lastStatus?: number;
|
|
400
|
+
lastError?: string;
|
|
401
|
+
/** most recent successful response latency in ms */
|
|
402
|
+
lastLatencyMs?: number;
|
|
403
|
+
/** total successful responses recorded while keeping the health record */
|
|
404
|
+
successCount?: number;
|
|
405
|
+
/** total failures recorded since the health record was created */
|
|
406
|
+
failureCount?: number;
|
|
407
|
+
/** timestamp of the most recent HTTP 429 (if any) */
|
|
408
|
+
last429At?: number;
|
|
409
|
+
/** coarse per-relay headers-latency EWMA (Phase 1 smart-routing signal) */
|
|
410
|
+
ewmaMs?: number;
|
|
411
|
+
/** samples feeding ewmaMs (floor-gates challenger selection) */
|
|
412
|
+
ewmaSamples?: number;
|
|
413
|
+
/** slow-start flag: set on failure, cleared on next success */
|
|
414
|
+
recovering?: boolean;
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
const relayHealthMap = new Map<string, RelayHealth>();
|
|
@@ -417,113 +438,113 @@ export const RELAY_EWMA_MARGIN_RATIO = 0.25;
|
|
|
417
438
|
* recorded); without one the record is deleted entirely.
|
|
418
439
|
*/
|
|
419
440
|
export function markRelaySuccess(url: string, latencyMs?: number): void {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
441
|
+
if (!url) return;
|
|
442
|
+
const clean = url.trim();
|
|
443
|
+
if (typeof latencyMs === "number" && Number.isFinite(latencyMs)) {
|
|
444
|
+
const prev = relayHealthMap.get(clean);
|
|
445
|
+
const sample = Math.round(latencyMs);
|
|
446
|
+
const ewmaMs = prev?.ewmaMs === undefined ? sample : Math.round(RELAY_EWMA_ALPHA * sample + (1 - RELAY_EWMA_ALPHA) * prev.ewmaMs);
|
|
447
|
+
const ewmaSamples = (prev?.ewmaSamples ?? 0) + 1;
|
|
448
|
+
const record: RelayHealth = prev
|
|
449
|
+
? { ...prev, consecutiveFailures: 0, lastFailureTime: 0, cooldownUntil: 0, lastLatencyMs: sample, successCount: (prev?.successCount ?? 0) + 1, ewmaMs, ewmaSamples, recovering: false }
|
|
450
|
+
: { consecutiveFailures: 0, lastFailureTime: 0, cooldownUntil: 0, lastLatencyMs: sample, successCount: 1, ewmaMs: sample, ewmaSamples: 1 };
|
|
451
|
+
relayHealthMap.set(clean, record);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
relayHealthMap.delete(clean);
|
|
434
455
|
}
|
|
435
456
|
|
|
436
457
|
/**
|
|
437
458
|
* Mark a relay as degraded with temporary cooldown on failure/429/timeout/socket error.
|
|
438
459
|
*/
|
|
439
460
|
export function markRelayFailure(url: string, status?: number, error?: string): void {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
461
|
+
if (!url) return;
|
|
462
|
+
const clean = url.trim();
|
|
463
|
+
const prev = relayHealthMap.get(clean) || {
|
|
464
|
+
consecutiveFailures: 0,
|
|
465
|
+
lastFailureTime: 0,
|
|
466
|
+
cooldownUntil: 0,
|
|
467
|
+
};
|
|
468
|
+
const consecutive = prev.consecutiveFailures + 1;
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
let cooldownMs = 30_000; // 30s default for socket/network/502/503
|
|
471
|
+
|
|
472
|
+
if (status === 429) {
|
|
473
|
+
cooldownMs = 90_000;
|
|
474
|
+
// 60-second sliding window: record the event, then warn only when the
|
|
475
|
+
// observed rate crosses >5/min (transition into a burst episode).
|
|
476
|
+
// One warning per episode beats both spamming (old 10-min cooldown
|
|
477
|
+
// re-warned on every boundary during a long burst) and blindness (a
|
|
478
|
+
// fresh burst inside the cooldown stayed silent).
|
|
479
|
+
const cutoff = now - RELAY_429_BURST_WINDOW_MS;
|
|
480
|
+
while (recent429Timestamps.length > 0 && recent429Timestamps[0] <= cutoff) {
|
|
481
|
+
recent429Timestamps.shift();
|
|
482
|
+
}
|
|
483
|
+
const beforeBurst = recent429Timestamps.length;
|
|
484
|
+
recent429Timestamps.push(now);
|
|
485
|
+
if (beforeBurst <= RELAY_429_BURST_THRESHOLD && recent429Timestamps.length > RELAY_429_BURST_THRESHOLD) {
|
|
486
|
+
logWarn("relay 429 burst >5/min, consider adding egress", { relay: clean });
|
|
487
|
+
last429Warn = now;
|
|
488
|
+
}
|
|
489
|
+
} else if (status === 504) {
|
|
490
|
+
cooldownMs = 60_000;
|
|
491
|
+
} else if (status === 402) {
|
|
492
|
+
// Disabled Vercel deployment (DEPLOYMENT_DISABLED): a redeploy-sized
|
|
493
|
+
// outage, not a transient blip — park the relay for ~6h (the 4x
|
|
494
|
+
// escalation cap below still applies) instead of retrying every 30s.
|
|
495
|
+
// Only reached via the gated 402-disabled roll branch in relayFetch;
|
|
496
|
+
// generic quota/payment 402s never mark a failure.
|
|
497
|
+
cooldownMs = 6 * 60 * 60 * 1_000;
|
|
498
|
+
} else if (status && status >= 500) {
|
|
499
|
+
cooldownMs = 45_000;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Escalate with consecutive failures so chronic offenders back off up to
|
|
503
|
+
// 4x their base cooldown instead of re-hammering at a fixed interval.
|
|
504
|
+
const multiplier = Math.min(4, consecutive);
|
|
505
|
+
relayHealthMap.set(clean, {
|
|
506
|
+
...prev,
|
|
507
|
+
consecutiveFailures: consecutive,
|
|
508
|
+
lastFailureTime: now,
|
|
509
|
+
cooldownUntil: now + cooldownMs * multiplier,
|
|
510
|
+
lastStatus: status,
|
|
511
|
+
lastError: error,
|
|
512
|
+
failureCount: (prev.failureCount ?? 0) + 1,
|
|
513
|
+
recovering: true,
|
|
514
|
+
last429At: status === 429 ? now : prev.last429At || undefined,
|
|
515
|
+
});
|
|
495
516
|
}
|
|
496
517
|
|
|
497
518
|
/**
|
|
498
519
|
* Check if a relay is currently healthy (not in active cooldown).
|
|
499
520
|
*/
|
|
500
521
|
export function isRelayHealthy(url: string): boolean {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
522
|
+
if (!url) return true;
|
|
523
|
+
const clean = url.trim();
|
|
524
|
+
const health = relayHealthMap.get(clean);
|
|
525
|
+
if (!health) return true;
|
|
526
|
+
return Date.now() >= health.cooldownUntil;
|
|
506
527
|
}
|
|
507
528
|
|
|
508
529
|
/**
|
|
509
530
|
* Get current health snapshot for a relay.
|
|
510
531
|
*/
|
|
511
532
|
export function getRelayHealth(url: string): RelayHealth | undefined {
|
|
512
|
-
|
|
533
|
+
return relayHealthMap.get(url.trim());
|
|
513
534
|
}
|
|
514
535
|
|
|
515
536
|
/**
|
|
516
537
|
* Reset all in-memory relay health records.
|
|
517
538
|
*/
|
|
518
539
|
export function resetAllRelayHealth(): void {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
540
|
+
relayHealthMap.clear();
|
|
541
|
+
recent429Timestamps.length = 0;
|
|
542
|
+
last429Warn = 0;
|
|
522
543
|
}
|
|
523
544
|
/** Test-only: reset 429 warn throttle */
|
|
524
545
|
export function _reset429WarnForTest(): void {
|
|
525
|
-
|
|
526
|
-
|
|
546
|
+
recent429Timestamps.length = 0;
|
|
547
|
+
last429Warn = 0;
|
|
527
548
|
}
|
|
528
549
|
/**
|
|
529
550
|
* Mtime of the on-disk state file at the moment we last read or wrote it.
|
|
@@ -533,11 +554,11 @@ export function _reset429WarnForTest(): void {
|
|
|
533
554
|
let lastKnownStateMtimeMs = -1;
|
|
534
555
|
|
|
535
556
|
function currentDiskStateMtimeMs(): number {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
557
|
+
try {
|
|
558
|
+
return fs.statSync(RELAY_STATE_FILE).mtimeMs;
|
|
559
|
+
} catch {
|
|
560
|
+
return -1;
|
|
561
|
+
}
|
|
541
562
|
}
|
|
542
563
|
|
|
543
564
|
lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
@@ -546,23 +567,23 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
|
546
567
|
* Get active in-memory relay state
|
|
547
568
|
*/
|
|
548
569
|
export function getActiveRelayState(): RelayState {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
570
|
+
const currentMtime = currentDiskStateMtimeMs();
|
|
571
|
+
if (currentMtime !== lastKnownStateMtimeMs) {
|
|
572
|
+
activeRelayState = resolveRelayState();
|
|
573
|
+
lastKnownStateMtimeMs = currentMtime;
|
|
574
|
+
}
|
|
575
|
+
return activeRelayState;
|
|
555
576
|
}
|
|
556
577
|
|
|
557
578
|
/**
|
|
558
579
|
* Set active in-memory relay state and persist to disk
|
|
559
580
|
*/
|
|
560
581
|
export function setActiveRelayState(s: RelayState, persist = true): void {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
582
|
+
activeRelayState = s;
|
|
583
|
+
if (persist) {
|
|
584
|
+
saveRelayState(s);
|
|
585
|
+
}
|
|
586
|
+
lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
566
587
|
}
|
|
567
588
|
|
|
568
589
|
// ── CAS state mutation ──────────────────────────────────────────────
|
|
@@ -576,29 +597,29 @@ const RELAY_STATE_LOCK_FILE = `${RELAY_STATE_FILE}.lock`;
|
|
|
576
597
|
const LOCK_STALE_MS = 30_000;
|
|
577
598
|
|
|
578
599
|
function tryAcquireRelayLock(): boolean {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
600
|
+
try {
|
|
601
|
+
fs.writeFileSync(RELAY_STATE_LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
602
|
+
return true;
|
|
603
|
+
} catch (e) {
|
|
604
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
605
|
+
if (code !== "EEXIST") {
|
|
606
|
+
// Lock cannot be created for an unrelated reason (permissions etc.) —
|
|
607
|
+
// proceed unlocked; the atomic tmp+rename save already prevents torn files.
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
// Held or stale: take over locks older than LOCK_STALE_MS so a crashed
|
|
611
|
+
// process cannot wedge the state file forever.
|
|
612
|
+
try {
|
|
613
|
+
const age = Date.now() - fs.statSync(RELAY_STATE_LOCK_FILE).mtimeMs;
|
|
614
|
+
if (age > LOCK_STALE_MS) {
|
|
615
|
+
fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true });
|
|
616
|
+
return tryAcquireRelayLock();
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
return tryAcquireRelayLock(); // lock vanished between stat and here
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
602
623
|
}
|
|
603
624
|
|
|
604
625
|
/**
|
|
@@ -613,42 +634,42 @@ function tryAcquireRelayLock(): boolean {
|
|
|
613
634
|
* active state).
|
|
614
635
|
*/
|
|
615
636
|
export function withRelayState(updater: (s: RelayState) => RelayState): RelayState {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
637
|
+
const locked = tryAcquireRelayLock();
|
|
638
|
+
if (!locked) {
|
|
639
|
+
logWarn("relay state lock held — proceeding without lock", {
|
|
640
|
+
lock: RELAY_STATE_LOCK_FILE,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const next = updater(loadRelayState());
|
|
645
|
+
saveRelayState(next);
|
|
646
|
+
setActiveRelayState(next, false);
|
|
647
|
+
return next;
|
|
648
|
+
} finally {
|
|
649
|
+
if (locked) {
|
|
650
|
+
try { fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true }); } catch { }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
632
653
|
}
|
|
633
654
|
|
|
634
655
|
/**
|
|
635
656
|
* Register the Pi extension UI context for status line updates
|
|
636
657
|
*/
|
|
637
658
|
export function setStatusUi(ui: ExtensionUIContext | null): void {
|
|
638
|
-
|
|
659
|
+
activeStatusUi = ui;
|
|
639
660
|
}
|
|
640
661
|
|
|
641
662
|
export function setFreeFlowModelActive(active: boolean): void {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
663
|
+
isFreeFlowModelActive = active;
|
|
664
|
+
if (!active && activeStatusUi?.setStatus) {
|
|
665
|
+
activeStatusUi.setStatus("freeflow", undefined);
|
|
666
|
+
}
|
|
646
667
|
}
|
|
647
668
|
/**
|
|
648
669
|
* Get the current Pi extension UI context
|
|
649
670
|
*/
|
|
650
671
|
export function getStatusUi(): ExtensionUIContext | null {
|
|
651
|
-
|
|
672
|
+
return activeStatusUi;
|
|
652
673
|
}
|
|
653
674
|
|
|
654
675
|
/**
|
|
@@ -656,27 +677,27 @@ export function getStatusUi(): ExtensionUIContext | null {
|
|
|
656
677
|
* Prefers user-configured label/short name; falls back to clean domain/subdomain.
|
|
657
678
|
*/
|
|
658
679
|
export function shortRelayLabel(url: string, relays?: KnownRelay[]): string {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
+
const pool = relays || activeRelayState.relays;
|
|
681
|
+
try {
|
|
682
|
+
const hit = pool.find((r) => r.url === url);
|
|
683
|
+
if (hit?.label?.trim() && hit.label.trim() !== "manual") {
|
|
684
|
+
return hit.label.trim();
|
|
685
|
+
}
|
|
686
|
+
const u = new URL(url);
|
|
687
|
+
const host = u.host;
|
|
688
|
+
// IP address (e.g. 192.168.1.5:8080 or 10.0.0.1)
|
|
689
|
+
if (/^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/.test(host)) {
|
|
690
|
+
return host;
|
|
691
|
+
}
|
|
692
|
+
const parts = host.split(".");
|
|
693
|
+
if (parts.length >= 3) {
|
|
694
|
+
// E.g. "my-relay" from "my-relay.workers.dev" or "my-app.vercel.app"
|
|
695
|
+
return parts[0];
|
|
696
|
+
}
|
|
697
|
+
return parts[0] || host;
|
|
698
|
+
} catch {
|
|
699
|
+
return url.slice(0, 18);
|
|
700
|
+
}
|
|
680
701
|
}
|
|
681
702
|
|
|
682
703
|
/**
|
|
@@ -690,57 +711,57 @@ export function shortRelayLabel(url: string, relays?: KnownRelay[]): string {
|
|
|
690
711
|
* relay burns paid-adjacent budget. Vercel stays as failover.
|
|
691
712
|
*/
|
|
692
713
|
export function getOrderedRelayUrls(): string[] {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
714
|
+
const mtime = currentDiskStateMtimeMs();
|
|
715
|
+
if (mtime !== lastKnownStateMtimeMs) {
|
|
716
|
+
activeRelayState = loadRelayState();
|
|
717
|
+
lastKnownStateMtimeMs = mtime;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (activeRelayState.relays && activeRelayState.relays.length > 0) {
|
|
721
|
+
const active = (activeRelayState.url || "").trim();
|
|
722
|
+
let activeIdx = activeRelayState.relays.findIndex((r) => r.url === active);
|
|
723
|
+
if (activeIdx < 0) {
|
|
724
|
+
activeIdx = 0;
|
|
725
|
+
}
|
|
726
|
+
// Sticky primary until error — keep active relay as primary for all requests,
|
|
727
|
+
// only rolling to next healthy on 429/5xx or cooldown. Avoids per-request
|
|
728
|
+
// rotation that sprays load; successive requests reuse same egress IP.
|
|
729
|
+
const totalRelays = activeRelayState.relays.length;
|
|
730
|
+
const startIdx = activeIdx;
|
|
731
|
+
const rawOrdered: string[] = [];
|
|
732
|
+
for (let i = 0; i < totalRelays; i++) {
|
|
733
|
+
const r = activeRelayState.relays[(startIdx + i) % totalRelays];
|
|
734
|
+
if (r?.url?.trim()) {
|
|
735
|
+
rawOrdered.push(r.url.trim());
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Partition into healthy candidates first, degraded/cooling candidates at the tail
|
|
740
|
+
const healthy = rawOrdered.filter((u) => isRelayHealthy(u));
|
|
741
|
+
const cooling = rawOrdered.filter((u) => !isRelayHealthy(u));
|
|
742
|
+
// Vercel last resort within each partition: non-Vercel first, Vercel tail.
|
|
743
|
+
const vercelFlag = new Map<string, boolean>();
|
|
744
|
+
for (const u of rawOrdered) {
|
|
745
|
+
let isVercel = false;
|
|
746
|
+
try {
|
|
747
|
+
isVercel = new URL(u).hostname.toLowerCase().endsWith(".vercel.app");
|
|
748
|
+
} catch {
|
|
749
|
+
isVercel = false;
|
|
750
|
+
}
|
|
751
|
+
vercelFlag.set(u, isVercel);
|
|
752
|
+
}
|
|
753
|
+
// Phase 1 challenger: latency preference applies WITHIN the healthy-nonVercel
|
|
754
|
+
// band only — Vercel failover, cooling tail, and spread sharding below are untouched.
|
|
755
|
+
const ordered = [
|
|
756
|
+
...applyEwmaChallenger(healthy.filter((u) => !vercelFlag.get(u))),
|
|
757
|
+
...healthy.filter((u) => vercelFlag.get(u)),
|
|
758
|
+
...cooling.filter((u) => !vercelFlag.get(u)),
|
|
759
|
+
...cooling.filter((u) => vercelFlag.get(u)),
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
return ordered;
|
|
763
|
+
}
|
|
764
|
+
return [];
|
|
744
765
|
}
|
|
745
766
|
|
|
746
767
|
/**
|
|
@@ -754,31 +775,31 @@ export function getOrderedRelayUrls(): string[] {
|
|
|
754
775
|
* sticky order. Cooling/Vercel/spread/affinity handling is untouched.
|
|
755
776
|
*/
|
|
756
777
|
function applyEwmaChallenger(band: string[]): string[] {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
778
|
+
if (band.length <= 1) return band;
|
|
779
|
+
const head = band[0];
|
|
780
|
+
const rest = band.slice(1);
|
|
781
|
+
const floored = (u: string): boolean => (relayHealthMap.get(u)?.ewmaSamples ?? 0) >= RELAY_EWMA_FLOOR;
|
|
782
|
+
const measured = rest.filter(floored);
|
|
783
|
+
const unknown = rest.filter((u) => !floored(u));
|
|
784
|
+
const headEwma = relayHealthMap.get(head)?.ewmaMs;
|
|
785
|
+
if (headEwma !== undefined && Number.isFinite(headEwma)) {
|
|
786
|
+
let challenger: string | undefined;
|
|
787
|
+
let best = Number.POSITIVE_INFINITY;
|
|
788
|
+
for (const u of measured) {
|
|
789
|
+
if (relayHealthMap.get(u)?.recovering) continue;
|
|
790
|
+
const ewma = relayHealthMap.get(u)?.ewmaMs;
|
|
791
|
+
if (ewma === undefined || !Number.isFinite(ewma)) continue;
|
|
792
|
+
if (ewma < best) {
|
|
793
|
+
best = ewma;
|
|
794
|
+
challenger = u;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const margin = Math.max(RELAY_EWMA_MARGIN_MS, RELAY_EWMA_MARGIN_RATIO * headEwma);
|
|
798
|
+
if (challenger !== undefined && headEwma - best > margin) {
|
|
799
|
+
return [challenger, ...band.filter((u) => u !== challenger)];
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return [head, ...measured, ...unknown];
|
|
782
803
|
}
|
|
783
804
|
|
|
784
805
|
/**
|
|
@@ -788,9 +809,9 @@ function applyEwmaChallenger(band: string[]): string[] {
|
|
|
788
809
|
* in commands.ts, left for the command layer).
|
|
789
810
|
*/
|
|
790
811
|
export function ewmaBadge(health: RelayHealth | undefined): string {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
812
|
+
const ms = health?.ewmaMs;
|
|
813
|
+
if (ms === undefined || !Number.isFinite(ms) || (health?.ewmaSamples ?? 0) <= 0) return "";
|
|
814
|
+
return ` ~${Math.round(ms)}ms avg`;
|
|
794
815
|
}
|
|
795
816
|
|
|
796
817
|
/**
|
|
@@ -801,13 +822,13 @@ export function ewmaBadge(health: RelayHealth | undefined): string {
|
|
|
801
822
|
* gate and ordering otherwise behaves exactly as today.
|
|
802
823
|
*/
|
|
803
824
|
export function isSessionPinEligible(pin: string, ordered: string[]): boolean {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
825
|
+
const clean = (pin ?? "").trim();
|
|
826
|
+
if (!clean) return false;
|
|
827
|
+
if (!ordered.includes(clean)) return false;
|
|
828
|
+
if (!isRelayHealthy(clean)) return false;
|
|
829
|
+
const health = getRelayHealth(clean);
|
|
830
|
+
if (health?.lastStatus === 402 && Date.now() < health.cooldownUntil) return false;
|
|
831
|
+
return true;
|
|
811
832
|
}
|
|
812
833
|
|
|
813
834
|
/**
|
|
@@ -828,20 +849,20 @@ export function isSessionPinEligible(pin: string, ordered: string[]): boolean {
|
|
|
828
849
|
* so a rate-limited IP is never preferred.
|
|
829
850
|
*/
|
|
830
851
|
export function orderedRelayCandidates(preferredRelay?: string, spreadKey?: string): string[] {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
852
|
+
const ordered = getOrderedRelayUrls();
|
|
853
|
+
const preferred = (preferredRelay ?? "").trim();
|
|
854
|
+
if (preferred) {
|
|
855
|
+
const index = ordered.indexOf(preferred);
|
|
856
|
+
if (index <= 0) return ordered;
|
|
857
|
+
if (!isSessionPinEligible(preferred, ordered)) return ordered;
|
|
858
|
+
return [ordered[index], ...ordered.slice(0, index), ...ordered.slice(index + 1)];
|
|
859
|
+
}
|
|
860
|
+
if (activeRelayState.mode !== "spread") return ordered;
|
|
861
|
+
const healthy = ordered.filter((u) => isRelayHealthy(u));
|
|
862
|
+
const cooling = ordered.filter((u) => !isRelayHealthy(u));
|
|
863
|
+
if (healthy.length <= 1) return [...healthy, ...cooling];
|
|
864
|
+
const k = spreadKey ? stableHash(spreadKey) % healthy.length : spreadCursor++ % healthy.length;
|
|
865
|
+
return [...healthy.slice(k), ...healthy.slice(0, k), ...cooling];
|
|
845
866
|
}
|
|
846
867
|
|
|
847
868
|
/** Round-robin cursor for spread mode when no stable key is available. */
|
|
@@ -849,12 +870,12 @@ let spreadCursor = 0;
|
|
|
849
870
|
|
|
850
871
|
/** FNV-1a: stable string -> bucket index for per-conversation sharding. */
|
|
851
872
|
function stableHash(s: string): number {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
873
|
+
let h = 2166136261;
|
|
874
|
+
for (let i = 0; i < s.length; i++) {
|
|
875
|
+
h ^= s.charCodeAt(i);
|
|
876
|
+
h = Math.imul(h, 16777619);
|
|
877
|
+
}
|
|
878
|
+
return h >>> 0;
|
|
858
879
|
}
|
|
859
880
|
|
|
860
881
|
/**
|
|
@@ -863,54 +884,54 @@ function stableHash(s: string): number {
|
|
|
863
884
|
* or an empty pool with no explicit OFF mode).
|
|
864
885
|
*/
|
|
865
886
|
export function formatRelayStatusLabel(state: RelayState, targetUrl?: string): string | null {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
887
|
+
if (!state || state.hideWidget) {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
if (!state.enabled || !state.relays || state.relays.length === 0) {
|
|
891
|
+
return state.mode === "off" ? "relay: OFF (direct)" : null;
|
|
892
|
+
}
|
|
893
|
+
const currentUrl = targetUrl || state.url || state.relays[0]?.url || "";
|
|
894
|
+
const label = shortRelayLabel(currentUrl, state.relays);
|
|
895
|
+
const total = state.relays.length || 1;
|
|
896
|
+
const pos = Math.max(1, state.relays.findIndex((r) => r.url === currentUrl) + 1);
|
|
897
|
+
const modeLabel = state.mode === "on" ? "ON" : "AUTO (ON)";
|
|
898
|
+
return `relay: ${modeLabel} | ${label} ${pos}/${total}`;
|
|
878
899
|
}
|
|
879
900
|
|
|
880
901
|
/**
|
|
881
902
|
* Shared relay-picker item text ("[1] [label] → url" with an active marker).
|
|
882
903
|
*/
|
|
883
904
|
export function formatRelayPickerItem(r: KnownRelay, idx: number, activeUrl?: string): string {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
905
|
+
const isActive = typeof activeUrl === "string" && r.url === activeUrl;
|
|
906
|
+
const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url)}] `;
|
|
907
|
+
return `${isActive ? "★ " : ""}[${idx + 1}] ${lbl}→ ${r.url}`;
|
|
887
908
|
}
|
|
888
909
|
|
|
889
910
|
/**
|
|
890
911
|
* Shared /freeflow flash notification text for the current relay mode/pool state.
|
|
891
912
|
*/
|
|
892
913
|
export function formatRelayFlash(state: RelayState): string {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
914
|
+
const activeLabel = shortRelayLabel(state.url, state.relays);
|
|
915
|
+
const activeIdx = Math.max(
|
|
916
|
+
1,
|
|
917
|
+
state.relays.findIndex((r) => r.url === state.url) + 1,
|
|
918
|
+
);
|
|
919
|
+
const total = state.relays.length || 1;
|
|
920
|
+
const modeStr = (state.mode || "auto").toUpperCase();
|
|
921
|
+
return `Relay mode: ${modeStr} (${state.enabled ? "ON" : "OFF"})${state.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${state.relays.length} (auto-fallback rolling)`;
|
|
901
922
|
}
|
|
902
923
|
|
|
903
924
|
export function updateRelayStatusUi(targetUrl?: string): void {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
925
|
+
if (!activeStatusUi?.setStatus) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// Do not update status bar if the user switched to another provider (e.g. Gemini/Claude)
|
|
929
|
+
if (!isFreeFlowModelActive) {
|
|
930
|
+
activeStatusUi.setStatus("freeflow", undefined);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const label = formatRelayStatusLabel(getActiveRelayState(), targetUrl);
|
|
934
|
+
activeStatusUi.setStatus("freeflow", label ?? undefined);
|
|
914
935
|
}
|
|
915
936
|
|
|
916
937
|
// ── Relay export/import codec (relay-pool portability) ─────────────────────
|
|
@@ -929,38 +950,38 @@ export const EXPORT_MAX_BYTES = 1_048_576;
|
|
|
929
950
|
|
|
930
951
|
/** A parsed relay export file envelope. */
|
|
931
952
|
export interface RelayExportArtifact {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
953
|
+
kind: "pi-freeflow/relay-export";
|
|
954
|
+
version: 1;
|
|
955
|
+
exportedAt: string;
|
|
956
|
+
state: {
|
|
957
|
+
mode?: RelayMode;
|
|
958
|
+
enabled: boolean;
|
|
959
|
+
url: string;
|
|
960
|
+
relays: KnownRelay[];
|
|
961
|
+
hideWidget?: boolean;
|
|
962
|
+
};
|
|
942
963
|
}
|
|
943
964
|
|
|
944
965
|
/** One import candidate entry that was skipped instead of aborting the file. */
|
|
945
966
|
export interface RelayImportSkipped {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
967
|
+
index: number;
|
|
968
|
+
url: string;
|
|
969
|
+
reason: string;
|
|
949
970
|
}
|
|
950
971
|
|
|
951
972
|
/** RelayState subset carried by an import file (pool scope only). */
|
|
952
973
|
export type RelayImportFragment = Pick<
|
|
953
|
-
|
|
954
|
-
|
|
974
|
+
RelayState,
|
|
975
|
+
"mode" | "enabled" | "url" | "relays" | "hideWidget"
|
|
955
976
|
>;
|
|
956
977
|
|
|
957
978
|
/** Result of planning an import against the current pool. */
|
|
958
979
|
export interface RelayImportPlanResult {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
980
|
+
next: RelayState;
|
|
981
|
+
added: number;
|
|
982
|
+
updated: number;
|
|
983
|
+
removed: number;
|
|
984
|
+
skipped: RelayImportSkipped[];
|
|
964
985
|
}
|
|
965
986
|
|
|
966
987
|
/** Alias kept for the cross-slice contract name. */
|
|
@@ -972,34 +993,34 @@ export type PlanResult = RelayImportPlanResult;
|
|
|
972
993
|
* carry them verbatim.
|
|
973
994
|
*/
|
|
974
995
|
export function buildRelayExport(
|
|
975
|
-
|
|
976
|
-
|
|
996
|
+
state: RelayState,
|
|
997
|
+
opts?: { includeSecrets?: boolean },
|
|
977
998
|
): RelayExportArtifact {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
999
|
+
const includeSecrets = opts?.includeSecrets === true;
|
|
1000
|
+
return {
|
|
1001
|
+
kind: EXPORT_KIND,
|
|
1002
|
+
version: EXPORT_VERSION,
|
|
1003
|
+
exportedAt: new Date().toISOString(),
|
|
1004
|
+
state: {
|
|
1005
|
+
mode: state.mode,
|
|
1006
|
+
enabled: state.enabled,
|
|
1007
|
+
url: state.url,
|
|
1008
|
+
relays: state.relays.map((r) => {
|
|
1009
|
+
const out: KnownRelay = { url: r.url };
|
|
1010
|
+
if (r.label !== undefined) out.label = r.label;
|
|
1011
|
+
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
1012
|
+
if (
|
|
1013
|
+
includeSecrets &&
|
|
1014
|
+
typeof r.auth === "string" &&
|
|
1015
|
+
r.auth.length > 0
|
|
1016
|
+
) {
|
|
1017
|
+
out.auth = r.auth;
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}),
|
|
1021
|
+
hideWidget: state.hideWidget,
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1003
1024
|
}
|
|
1004
1025
|
|
|
1005
1026
|
/**
|
|
@@ -1008,150 +1029,150 @@ export function buildRelayExport(
|
|
|
1008
1029
|
* problems fail closed to `skipped` so one bad relay never wipes the pool.
|
|
1009
1030
|
*/
|
|
1010
1031
|
export function parseRelayImport(raw: string): {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1032
|
+
fragment: RelayImportFragment;
|
|
1033
|
+
skipped: RelayImportSkipped[];
|
|
1034
|
+
warnings: string[];
|
|
1014
1035
|
} {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1036
|
+
if (raw.length > EXPORT_MAX_BYTES) {
|
|
1037
|
+
throw new Error(
|
|
1038
|
+
`Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
let doc: unknown;
|
|
1042
|
+
try {
|
|
1043
|
+
doc = JSON.parse(raw);
|
|
1044
|
+
} catch {
|
|
1045
|
+
throw new Error("Relay import file is not valid JSON");
|
|
1046
|
+
}
|
|
1047
|
+
const envelope =
|
|
1048
|
+
typeof doc === "object" && doc !== null
|
|
1049
|
+
? (doc as Record<string, unknown>)
|
|
1050
|
+
: null;
|
|
1051
|
+
if (envelope?.kind !== EXPORT_KIND) {
|
|
1052
|
+
throw new Error("File is not a relay export (bad kind marker)");
|
|
1053
|
+
}
|
|
1054
|
+
if (
|
|
1055
|
+
typeof envelope.version !== "number" ||
|
|
1056
|
+
!Number.isInteger(envelope.version) ||
|
|
1057
|
+
envelope.version !== EXPORT_VERSION
|
|
1058
|
+
) {
|
|
1059
|
+
throw new Error(
|
|
1060
|
+
`Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
const rawState =
|
|
1064
|
+
typeof envelope.state === "object" && envelope.state !== null
|
|
1065
|
+
? (envelope.state as Record<string, unknown>)
|
|
1066
|
+
: null;
|
|
1067
|
+
if (!rawState || !Array.isArray(rawState.relays)) {
|
|
1068
|
+
throw new Error("File is not a relay export (state pool is missing)");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const warnings: string[] = [];
|
|
1072
|
+
const skipped: RelayImportSkipped[] = [];
|
|
1073
|
+
|
|
1074
|
+
const rawMode = rawState.mode;
|
|
1075
|
+
const mode: RelayMode =
|
|
1076
|
+
rawMode === "on" || rawMode === "off" || rawMode === "auto" || rawMode === "spread"
|
|
1077
|
+
? rawMode
|
|
1078
|
+
: "auto";
|
|
1079
|
+
if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto" && rawMode !== "spread") {
|
|
1080
|
+
warnings.push("Unknown relay mode in file; using automatic mode.");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const rawEnabled = rawState.enabled;
|
|
1084
|
+
const enabled =
|
|
1085
|
+
typeof rawEnabled === "boolean"
|
|
1086
|
+
? rawEnabled
|
|
1087
|
+
: mode === "on"
|
|
1088
|
+
? true
|
|
1089
|
+
: mode === "off"
|
|
1090
|
+
? false
|
|
1091
|
+
: (rawState.relays as unknown[]).length > 0;
|
|
1092
|
+
|
|
1093
|
+
const rawUrl = rawState.url;
|
|
1094
|
+
let url = "";
|
|
1095
|
+
if (typeof rawUrl === "string" && rawUrl.trim()) {
|
|
1096
|
+
const trimmed = rawUrl.trim();
|
|
1097
|
+
if (validateRelayUrl(trimmed).ok) {
|
|
1098
|
+
url = trimmed;
|
|
1099
|
+
} else {
|
|
1100
|
+
warnings.push("Active relay address in file is not usable; ignoring it.");
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const hideWidget = rawState.hideWidget === true;
|
|
1105
|
+
const relays: KnownRelay[] = [];
|
|
1106
|
+
const seen = new Set<string>();
|
|
1107
|
+
const entries = rawState.relays as unknown[];
|
|
1108
|
+
for (let index = 0; index < entries.length; index++) {
|
|
1109
|
+
const entry = entries[index];
|
|
1110
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1111
|
+
skipped.push({ index, url: "", reason: "entry is not an object" });
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const record = entry as Record<string, unknown>;
|
|
1115
|
+
const trimmedUrl =
|
|
1116
|
+
typeof record.url === "string" ? record.url.trim() : "";
|
|
1117
|
+
if (!trimmedUrl) {
|
|
1118
|
+
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const check = validateRelayUrl(trimmedUrl);
|
|
1122
|
+
if (!check.ok) {
|
|
1123
|
+
skipped.push({ index, url: trimmedUrl, reason: check.reason });
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
// Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
|
|
1127
|
+
if (seen.has(trimmedUrl)) {
|
|
1128
|
+
skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
seen.add(trimmedUrl);
|
|
1132
|
+
const candidate: KnownRelay = { url: trimmedUrl };
|
|
1133
|
+
if (typeof record.label === "string") {
|
|
1134
|
+
const cleanLabel = record.label.trim();
|
|
1135
|
+
if (cleanLabel && cleanLabel !== "manual") {
|
|
1136
|
+
if (cleanLabel.length > 64) {
|
|
1137
|
+
candidate.label = cleanLabel.slice(0, 64);
|
|
1138
|
+
warnings.push(
|
|
1139
|
+
`Short name for ${trimmedUrl} truncated to 64 characters.`,
|
|
1140
|
+
);
|
|
1141
|
+
} else {
|
|
1142
|
+
candidate.label = cleanLabel;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (
|
|
1147
|
+
typeof record.addedAt === "string" &&
|
|
1148
|
+
Number.isNaN(Date.parse(record.addedAt)) === false
|
|
1149
|
+
) {
|
|
1150
|
+
candidate.addedAt = record.addedAt;
|
|
1151
|
+
}
|
|
1152
|
+
if (
|
|
1153
|
+
typeof record.auth === "string" &&
|
|
1154
|
+
record.auth.length > 0
|
|
1155
|
+
) {
|
|
1156
|
+
candidate.auth = record.auth;
|
|
1157
|
+
} else if (record.auth !== undefined) {
|
|
1158
|
+
warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
|
|
1159
|
+
}
|
|
1160
|
+
relays.push(candidate);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
fragment: { mode, enabled, url, relays, hideWidget },
|
|
1165
|
+
skipped,
|
|
1166
|
+
warnings,
|
|
1167
|
+
};
|
|
1147
1168
|
}
|
|
1148
1169
|
|
|
1149
1170
|
function copyRelayEntry(r: KnownRelay): KnownRelay {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1171
|
+
const out: KnownRelay = { url: r.url };
|
|
1172
|
+
if (r.label !== undefined) out.label = r.label;
|
|
1173
|
+
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
1174
|
+
if (r.auth !== undefined) out.auth = r.auth;
|
|
1175
|
+
return out;
|
|
1155
1176
|
}
|
|
1156
1177
|
|
|
1157
1178
|
/**
|
|
@@ -1160,119 +1181,119 @@ function copyRelayEntry(r: KnownRelay): KnownRelay {
|
|
|
1160
1181
|
* keeps the current mode/switch/widget; replace installs the file pool fresh.
|
|
1161
1182
|
*/
|
|
1162
1183
|
export function planRelayImport(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1184
|
+
current: RelayState,
|
|
1185
|
+
fragment: RelayImportFragment,
|
|
1186
|
+
opts: { mode: "merge" | "replace" },
|
|
1166
1187
|
): RelayImportPlanResult {
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1188
|
+
if (opts.mode === "replace") {
|
|
1189
|
+
const installed: KnownRelay[] = [];
|
|
1190
|
+
const seen = new Set<string>();
|
|
1191
|
+
const skipped: RelayImportSkipped[] = [];
|
|
1192
|
+
const source = fragment.relays ?? [];
|
|
1193
|
+
for (let index = 0; index < source.length; index++) {
|
|
1194
|
+
const entry = source[index];
|
|
1195
|
+
const trimmedUrl =
|
|
1196
|
+
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1197
|
+
if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
|
|
1198
|
+
skipped.push({
|
|
1199
|
+
index,
|
|
1200
|
+
url: trimmedUrl,
|
|
1201
|
+
reason: "relay address is not usable",
|
|
1202
|
+
});
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (seen.has(trimmedUrl)) {
|
|
1206
|
+
skipped.push({
|
|
1207
|
+
index,
|
|
1208
|
+
url: trimmedUrl,
|
|
1209
|
+
reason: "duplicate relay address",
|
|
1210
|
+
});
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
seen.add(trimmedUrl);
|
|
1214
|
+
installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
|
|
1215
|
+
}
|
|
1216
|
+
// Empty file pool installs nothing: fail closed here (not just in the
|
|
1217
|
+
// command handler) so no caller can turn zero usable addresses into a wipe.
|
|
1218
|
+
if (installed.length === 0) {
|
|
1219
|
+
return {
|
|
1220
|
+
next: { ...current, relays: current.relays.map(copyRelayEntry) },
|
|
1221
|
+
added: 0,
|
|
1222
|
+
updated: 0,
|
|
1223
|
+
removed: 0,
|
|
1224
|
+
skipped,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
const installedUrls = new Set(installed.map((r) => r.url));
|
|
1228
|
+
const active =
|
|
1229
|
+
fragment.url && installedUrls.has(fragment.url)
|
|
1230
|
+
? fragment.url
|
|
1231
|
+
: (installed[0]?.url ?? "");
|
|
1232
|
+
return {
|
|
1233
|
+
next: {
|
|
1234
|
+
mode: fragment.mode ?? "auto",
|
|
1235
|
+
enabled: fragment.enabled,
|
|
1236
|
+
url: active,
|
|
1237
|
+
relays: installed,
|
|
1238
|
+
hideWidget: fragment.hideWidget === true,
|
|
1239
|
+
},
|
|
1240
|
+
added: installed.length,
|
|
1241
|
+
updated: 0,
|
|
1242
|
+
removed: Math.max(0, current.relays.length - installed.length),
|
|
1243
|
+
skipped,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const next: RelayState = {
|
|
1248
|
+
...current,
|
|
1249
|
+
relays: current.relays.map(copyRelayEntry),
|
|
1250
|
+
};
|
|
1251
|
+
let added = 0;
|
|
1252
|
+
let updated = 0;
|
|
1253
|
+
const skipped: RelayImportSkipped[] = [];
|
|
1254
|
+
const source = fragment.relays ?? [];
|
|
1255
|
+
for (let index = 0; index < source.length; index++) {
|
|
1256
|
+
const entry = source[index];
|
|
1257
|
+
const trimmedUrl =
|
|
1258
|
+
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1259
|
+
if (!trimmedUrl) {
|
|
1260
|
+
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
let relay: KnownRelay | undefined;
|
|
1264
|
+
try {
|
|
1265
|
+
const already = next.relays.some((r) => r.url === trimmedUrl);
|
|
1266
|
+
relay = ensureRelay(next, trimmedUrl, entry.label);
|
|
1267
|
+
if (already) {
|
|
1268
|
+
updated += 1;
|
|
1269
|
+
} else {
|
|
1270
|
+
added += 1;
|
|
1271
|
+
}
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
skipped.push({
|
|
1274
|
+
index,
|
|
1275
|
+
url: trimmedUrl,
|
|
1276
|
+
reason: err instanceof Error ? err.message : "relay address rejected",
|
|
1277
|
+
});
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
// File password overwrites; an absent file password keeps the stored one.
|
|
1281
|
+
if (typeof entry.auth === "string" && entry.auth.length > 0) {
|
|
1282
|
+
relay.auth = entry.auth;
|
|
1283
|
+
}
|
|
1284
|
+
if (
|
|
1285
|
+
typeof entry.addedAt === "string" &&
|
|
1286
|
+
Number.isNaN(Date.parse(entry.addedAt)) === false
|
|
1287
|
+
) {
|
|
1288
|
+
relay.addedAt = entry.addedAt;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const mergedUrls = new Set(next.relays.map((r) => r.url));
|
|
1292
|
+
if (!mergedUrls.has(next.url)) {
|
|
1293
|
+
next.url =
|
|
1294
|
+
fragment.url && mergedUrls.has(fragment.url)
|
|
1295
|
+
? fragment.url
|
|
1296
|
+
: (next.relays[0]?.url ?? "");
|
|
1297
|
+
}
|
|
1298
|
+
return { next, added, updated, removed: 0, skipped };
|
|
1278
1299
|
}
|