pi-freeflow 1.32.0 → 1.32.1

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.
@@ -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
- 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;
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
- raw: string,
83
+ raw: string,
64
84
  ): { ok: true; url: URL } | { ok: false; reason: string } {
65
- const unsafe = process.env[ALLOW_UNSAFE_RELAY_ENV] === "1";
66
- const trimmed = (raw || "").trim();
67
- if (!trimmed) return { ok: false, reason: "Relay URL cannot be empty" };
68
- let url: URL;
69
- try {
70
- url = new URL(trimmed);
71
- } catch {
72
- return { ok: false, reason: "invalid URL" };
73
- }
74
- if (!unsafe) {
75
- if (url.protocol !== "https:") {
76
- return { ok: false, reason: "only https relay URLs are allowed" };
77
- }
78
- } else if (url.protocol !== "https:" && url.protocol !== "http:") {
79
- return { ok: false, reason: "only http/https relay URLs are allowed" };
80
- }
81
- if (url.username || url.password) {
82
- return { ok: false, reason: "credentials are not allowed in relay URLs" };
83
- }
84
- if (!unsafe) {
85
- if (isPrivateRelayHostname(url.hostname)) {
86
- return { ok: false, reason: "private/loopback relay hosts are not allowed" };
87
- }
88
- if (url.port && url.port !== "443") {
89
- return { ok: false, reason: "non-default relay ports are not allowed" };
90
- }
91
- }
92
- return { ok: true, url };
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
- try {
101
- const s = JSON.parse(raw);
102
- const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
103
- if (relays.length === 0) {
104
- return null;
105
- }
106
- const mode: RelayMode =
107
- s?.mode === "on" || s?.mode === "off" || s?.mode === "auto" || s?.mode === "spread"
108
- ? s.mode
109
- : "auto";
110
- return {
111
- mode,
112
- enabled:
113
- mode === "on"
114
- ? true
115
- : mode === "off"
116
- ? false
117
- : s?.enabled !== false && relays.length > 0,
118
- url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
119
- relays,
120
- hideWidget: s?.hideWidget === true,
121
- };
122
- } catch {
123
- return null;
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
- try {
134
- const dir = path.dirname(RELAY_STATE_FILE);
135
- const prefix = `${path.basename(RELAY_STATE_FILE)}.`;
136
- const cutoff = Date.now() - 60 * 60 * 1000;
137
- for (const entry of fs.readdirSync(dir)) {
138
- if (!entry.startsWith(prefix) || !entry.endsWith(".tmp")) {
139
- continue;
140
- }
141
- const full = path.join(dir, entry);
142
- try {
143
- if (fs.statSync(full).mtimeMs < cutoff) {
144
- fs.rmSync(full, { force: true });
145
- }
146
- } catch {}
147
- }
148
- } catch {}
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
- try {
157
- const bak = parseRelayState(fs.readFileSync(`${RELAY_STATE_FILE}.bak`, "utf8"));
158
- if (bak) {
159
- logWarn(`relay state ${description} — recovered from .bak`, { relays: bak.relays.length });
160
- saveRelayState(bak); // heal the main file so recovery is sticky
161
- return bak;
162
- }
163
- } catch {}
164
- return null;
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
- try {
177
- cleanupStaleTmpFiles();
178
- if (!fs.existsSync(RELAY_STATE_FILE)) {
179
- // Main file missing (deleted outright, never healed after a wipe):
180
- // heal from .bak when the backup still holds relays — losing the
181
- // pool to a missing main is just as bad as a corrupt one.
182
- const healed = recoverFromBackup("main state file missing");
183
- if (healed) {
184
- return healed;
185
- }
186
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
187
- }
188
- const raw = fs.readFileSync(RELAY_STATE_FILE, "utf8");
189
- const main = parseRelayState(raw);
190
- if (main) {
191
- return main;
192
- }
193
- // Try backup before giving up — user data loss is worse than stale data
194
- const bak = recoverFromBackup("unusable");
195
- if (bak) {
196
- return bak;
197
- }
198
- logWarn("relay state file unusable and no valid backup — starting fresh", {
199
- path: RELAY_STATE_FILE,
200
- });
201
- // parse returns null on an empty pool by design (wipe-protection above);
202
- // preserve an explicit hide so hide-with-zero-relays survives reload.
203
- let hide = false;
204
- try {
205
- hide = (JSON.parse(raw) as RelayState)?.hideWidget === true;
206
- } catch {}
207
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: hide };
208
- } catch {
209
- return { mode: "auto", enabled: true, url: "", relays: [], hideWidget: false };
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
- try {
220
- const dir = path.dirname(RELAY_STATE_FILE);
221
- if (!fs.existsSync(dir)) {
222
- fs.mkdirSync(dir, { recursive: true });
223
- }
224
- // Backup current file before overwrite for recovery
225
- try {
226
- if (fs.existsSync(RELAY_STATE_FILE)) {
227
- const cur = fs.readFileSync(RELAY_STATE_FILE, "utf8");
228
- // Only backup non-empty states
229
- const parsed = JSON.parse(cur);
230
- if (Array.isArray(parsed?.relays) && parsed.relays.length > 0) {
231
- fs.writeFileSync(`${RELAY_STATE_FILE}.bak`, cur, "utf8");
232
- }
233
- }
234
- } catch {}
235
- const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
236
- fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
237
- renameWithRetry(tmpPath, RELAY_STATE_FILE);
238
- } catch (e) {
239
- logWarn("Could not persist relay state", { error: String(e) });
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
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, Date.now() + ms);
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
- const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
255
- for (let attempt = 1; ; attempt++) {
256
- try {
257
- fs.renameSync(from, to);
258
- return;
259
- } catch (e) {
260
- const code = (e as NodeJS.ErrnoException | null)?.code;
261
- if (!code || !retryable[code] || attempt >= 3) {
262
- throw e;
263
- }
264
- sleepSync(50);
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
- s: RelayState,
274
- url: string,
275
- label?: string,
294
+ s: RelayState,
295
+ url: string,
296
+ label?: string,
276
297
  ): KnownRelay {
277
- const cleanUrl = (url || "").trim();
278
- if (!cleanUrl) {
279
- throw new Error("Relay URL cannot be empty");
280
- }
281
- const check = validateRelayUrl(cleanUrl);
282
- if (!check.ok) {
283
- throw new Error(`Relay URL rejected: ${check.reason}`);
284
- }
285
- const cleanLabel = (label || "").trim() || undefined;
286
- const existing = s.relays.find((r) => r.url === cleanUrl);
287
- if (existing) {
288
- if (cleanLabel && cleanLabel !== "manual") {
289
- existing.label = cleanLabel;
290
- }
291
- return existing;
292
- }
293
- const newRelay: KnownRelay = {
294
- url: cleanUrl,
295
- label: cleanLabel && cleanLabel !== "manual" ? cleanLabel : undefined,
296
- addedAt: new Date().toISOString(),
297
- };
298
- s.relays.push(newRelay);
299
- return newRelay;
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
- s: RelayState,
307
- identifier: string | number,
308
- label: string,
327
+ s: RelayState,
328
+ identifier: string | number,
329
+ label: string,
309
330
  ): KnownRelay | null {
310
- const relay = findRelay(s, identifier);
311
- if (!relay) return null;
312
- const cleanLabel = (label || "").trim();
313
- relay.label = cleanLabel || undefined;
314
- return relay;
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
- s: RelayState,
323
- identifier: string | number,
343
+ s: RelayState,
344
+ identifier: string | number,
324
345
  ): KnownRelay | undefined {
325
- if (typeof identifier === "number") {
326
- const idx = identifier === 0 ? 0 : identifier - 1;
327
- return s.relays[idx];
328
- }
329
- const str = String(identifier || "").trim();
330
- if (!str) return undefined;
331
-
332
- // 1-based index (e.g. "1", "2")
333
- if (/^\d+$/.test(str)) {
334
- const num = Number.parseInt(str, 10);
335
- if (num >= 1 && num <= s.relays.length) {
336
- return s.relays[num - 1];
337
- }
338
- }
339
-
340
- // Exact URL match
341
- const exactUrl = s.relays.find((r) => r.url === str);
342
- if (exactUrl) return exactUrl;
343
-
344
- // Case-insensitive label match
345
- const byLabel = s.relays.find(
346
- (r) => r.label && r.label.toLowerCase() === str.toLowerCase(),
347
- );
348
- if (byLabel) return byLabel;
349
-
350
- // Partial URL match
351
- return s.relays.find((r) => r.url.toLowerCase().includes(str.toLowerCase()));
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
- s.relays = s.relays.filter((r) => r.url !== url);
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
- return loadRelayState();
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
- consecutiveFailures: number;
376
- lastFailureTime: number;
377
- cooldownUntil: number;
378
- lastStatus?: number;
379
- lastError?: string;
380
- /** most recent successful response latency in ms */
381
- lastLatencyMs?: number;
382
- /** total successful responses recorded while keeping the health record */
383
- successCount?: number;
384
- /** total failures recorded since the health record was created */
385
- failureCount?: number;
386
- /** timestamp of the most recent HTTP 429 (if any) */
387
- last429At?: number;
388
- /** coarse per-relay headers-latency EWMA (Phase 1 smart-routing signal) */
389
- ewmaMs?: number;
390
- /** samples feeding ewmaMs (floor-gates challenger selection) */
391
- ewmaSamples?: number;
392
- /** slow-start flag: set on failure, cleared on next success */
393
- recovering?: boolean;
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
- if (!url) return;
421
- const clean = url.trim();
422
- if (typeof latencyMs === "number" && Number.isFinite(latencyMs)) {
423
- const prev = relayHealthMap.get(clean);
424
- const sample = Math.round(latencyMs);
425
- const ewmaMs = prev?.ewmaMs === undefined ? sample : Math.round(RELAY_EWMA_ALPHA * sample + (1 - RELAY_EWMA_ALPHA) * prev.ewmaMs);
426
- const ewmaSamples = (prev?.ewmaSamples ?? 0) + 1;
427
- const record: RelayHealth = prev
428
- ? { ...prev, consecutiveFailures: 0, lastFailureTime: 0, cooldownUntil: 0, lastLatencyMs: sample, successCount: (prev?.successCount ?? 0) + 1, ewmaMs, ewmaSamples, recovering: false }
429
- : { consecutiveFailures: 0, lastFailureTime: 0, cooldownUntil: 0, lastLatencyMs: sample, successCount: 1, ewmaMs: sample, ewmaSamples: 1 };
430
- relayHealthMap.set(clean, record);
431
- return;
432
- }
433
- relayHealthMap.delete(clean);
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
- if (!url) return;
441
- const clean = url.trim();
442
- const prev = relayHealthMap.get(clean) || {
443
- consecutiveFailures: 0,
444
- lastFailureTime: 0,
445
- cooldownUntil: 0,
446
- };
447
- const consecutive = prev.consecutiveFailures + 1;
448
- const now = Date.now();
449
- let cooldownMs = 30_000; // 30s default for socket/network/502/503
450
-
451
- if (status === 429) {
452
- cooldownMs = 90_000;
453
- // 60-second sliding window: record the event, then warn only when the
454
- // observed rate crosses >5/min (transition into a burst episode).
455
- // One warning per episode beats both spamming (old 10-min cooldown
456
- // re-warned on every boundary during a long burst) and blindness (a
457
- // fresh burst inside the cooldown stayed silent).
458
- const cutoff = now - RELAY_429_BURST_WINDOW_MS;
459
- while (recent429Timestamps.length > 0 && recent429Timestamps[0] <= cutoff) {
460
- recent429Timestamps.shift();
461
- }
462
- const beforeBurst = recent429Timestamps.length;
463
- recent429Timestamps.push(now);
464
- if (beforeBurst <= RELAY_429_BURST_THRESHOLD && recent429Timestamps.length > RELAY_429_BURST_THRESHOLD) {
465
- logWarn("relay 429 burst >5/min, consider adding egress", { relay: clean });
466
- last429Warn = now;
467
- }
468
- } else if (status === 504) {
469
- cooldownMs = 60_000;
470
- } else if (status === 402) {
471
- // Disabled Vercel deployment (DEPLOYMENT_DISABLED): a redeploy-sized
472
- // outage, not a transient blip — park the relay for ~6h (the 4x
473
- // escalation cap below still applies) instead of retrying every 30s.
474
- // Only reached via the gated 402-disabled roll branch in relayFetch;
475
- // generic quota/payment 402s never mark a failure.
476
- cooldownMs = 6 * 60 * 60 * 1_000;
477
- } else if (status && status >= 500) {
478
- cooldownMs = 45_000;
479
- }
480
-
481
- // Escalate with consecutive failures so chronic offenders back off up to
482
- // 4x their base cooldown instead of re-hammering at a fixed interval.
483
- const multiplier = Math.min(4, consecutive);
484
- relayHealthMap.set(clean, {
485
- ...prev,
486
- consecutiveFailures: consecutive,
487
- lastFailureTime: now,
488
- cooldownUntil: now + cooldownMs * multiplier,
489
- lastStatus: status,
490
- lastError: error,
491
- failureCount: (prev.failureCount ?? 0) + 1,
492
- recovering: true,
493
- last429At: status === 429 ? now : prev.last429At || undefined,
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
- if (!url) return true;
502
- const clean = url.trim();
503
- const health = relayHealthMap.get(clean);
504
- if (!health) return true;
505
- return Date.now() >= health.cooldownUntil;
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
- return relayHealthMap.get(url.trim());
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
- relayHealthMap.clear();
520
- recent429Timestamps.length = 0;
521
- last429Warn = 0;
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
- recent429Timestamps.length = 0;
526
- last429Warn = 0;
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
- try {
537
- return fs.statSync(RELAY_STATE_FILE).mtimeMs;
538
- } catch {
539
- return -1;
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
- const currentMtime = currentDiskStateMtimeMs();
550
- if (currentMtime !== lastKnownStateMtimeMs) {
551
- activeRelayState = resolveRelayState();
552
- lastKnownStateMtimeMs = currentMtime;
553
- }
554
- return activeRelayState;
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
- activeRelayState = s;
562
- if (persist) {
563
- saveRelayState(s);
564
- }
565
- lastKnownStateMtimeMs = currentDiskStateMtimeMs();
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
- try {
580
- fs.writeFileSync(RELAY_STATE_LOCK_FILE, String(process.pid), { flag: "wx" });
581
- return true;
582
- } catch (e) {
583
- const code = (e as NodeJS.ErrnoException | null)?.code;
584
- if (code !== "EEXIST") {
585
- // Lock cannot be created for an unrelated reason (permissions etc.) —
586
- // proceed unlocked; the atomic tmp+rename save already prevents torn files.
587
- return false;
588
- }
589
- // Held or stale: take over locks older than LOCK_STALE_MS so a crashed
590
- // process cannot wedge the state file forever.
591
- try {
592
- const age = Date.now() - fs.statSync(RELAY_STATE_LOCK_FILE).mtimeMs;
593
- if (age > LOCK_STALE_MS) {
594
- fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true });
595
- return tryAcquireRelayLock();
596
- }
597
- } catch {
598
- return tryAcquireRelayLock(); // lock vanished between stat and here
599
- }
600
- return false;
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
- const locked = tryAcquireRelayLock();
617
- if (!locked) {
618
- logWarn("relay state lock held — proceeding without lock", {
619
- lock: RELAY_STATE_LOCK_FILE,
620
- });
621
- }
622
- try {
623
- const next = updater(loadRelayState());
624
- saveRelayState(next);
625
- setActiveRelayState(next, false);
626
- return next;
627
- } finally {
628
- if (locked) {
629
- try { fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true }); } catch {}
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
- activeStatusUi = ui;
659
+ activeStatusUi = ui;
639
660
  }
640
661
 
641
662
  export function setFreeFlowModelActive(active: boolean): void {
642
- isFreeFlowModelActive = active;
643
- if (!active && activeStatusUi?.setStatus) {
644
- activeStatusUi.setStatus("freeflow", undefined);
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
- return activeStatusUi;
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
- const pool = relays || activeRelayState.relays;
660
- try {
661
- const hit = pool.find((r) => r.url === url);
662
- if (hit?.label?.trim() && hit.label.trim() !== "manual") {
663
- return hit.label.trim();
664
- }
665
- const u = new URL(url);
666
- const host = u.host;
667
- // IP address (e.g. 192.168.1.5:8080 or 10.0.0.1)
668
- if (/^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/.test(host)) {
669
- return host;
670
- }
671
- const parts = host.split(".");
672
- if (parts.length >= 3) {
673
- // E.g. "my-relay" from "my-relay.workers.dev" or "my-app.vercel.app"
674
- return parts[0];
675
- }
676
- return parts[0] || host;
677
- } catch {
678
- return url.slice(0, 18);
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
- const mtime = currentDiskStateMtimeMs();
694
- if (mtime !== lastKnownStateMtimeMs) {
695
- activeRelayState = loadRelayState();
696
- lastKnownStateMtimeMs = mtime;
697
- }
698
-
699
- if (activeRelayState.relays && activeRelayState.relays.length > 0) {
700
- const active = (activeRelayState.url || "").trim();
701
- let activeIdx = activeRelayState.relays.findIndex((r) => r.url === active);
702
- if (activeIdx < 0) {
703
- activeIdx = 0;
704
- }
705
- // Sticky primary until error — keep active relay as primary for all requests,
706
- // only rolling to next healthy on 429/5xx or cooldown. Avoids per-request
707
- // rotation that sprays load; successive requests reuse same egress IP.
708
- const totalRelays = activeRelayState.relays.length;
709
- const startIdx = activeIdx;
710
- const rawOrdered: string[] = [];
711
- for (let i = 0; i < totalRelays; i++) {
712
- const r = activeRelayState.relays[(startIdx + i) % totalRelays];
713
- if (r?.url?.trim()) {
714
- rawOrdered.push(r.url.trim());
715
- }
716
- }
717
-
718
- // Partition into healthy candidates first, degraded/cooling candidates at the tail
719
- const healthy = rawOrdered.filter((u) => isRelayHealthy(u));
720
- const cooling = rawOrdered.filter((u) => !isRelayHealthy(u));
721
- // Vercel last resort within each partition: non-Vercel first, Vercel tail.
722
- const vercelFlag = new Map<string, boolean>();
723
- for (const u of rawOrdered) {
724
- let isVercel = false;
725
- try {
726
- isVercel = new URL(u).hostname.toLowerCase().endsWith(".vercel.app");
727
- } catch {
728
- isVercel = false;
729
- }
730
- vercelFlag.set(u, isVercel);
731
- }
732
- // Phase 1 challenger: latency preference applies WITHIN the healthy-nonVercel
733
- // band only — Vercel failover, cooling tail, and spread sharding below are untouched.
734
- const ordered = [
735
- ...applyEwmaChallenger(healthy.filter((u) => !vercelFlag.get(u))),
736
- ...healthy.filter((u) => vercelFlag.get(u)),
737
- ...cooling.filter((u) => !vercelFlag.get(u)),
738
- ...cooling.filter((u) => vercelFlag.get(u)),
739
- ];
740
-
741
- return ordered;
742
- }
743
- return [];
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
- if (band.length <= 1) return band;
758
- const head = band[0];
759
- const rest = band.slice(1);
760
- const floored = (u: string): boolean => (relayHealthMap.get(u)?.ewmaSamples ?? 0) >= RELAY_EWMA_FLOOR;
761
- const measured = rest.filter(floored);
762
- const unknown = rest.filter((u) => !floored(u));
763
- const headEwma = relayHealthMap.get(head)?.ewmaMs;
764
- if (headEwma !== undefined && Number.isFinite(headEwma)) {
765
- let challenger: string | undefined;
766
- let best = Number.POSITIVE_INFINITY;
767
- for (const u of measured) {
768
- if (relayHealthMap.get(u)?.recovering) continue;
769
- const ewma = relayHealthMap.get(u)?.ewmaMs;
770
- if (ewma === undefined || !Number.isFinite(ewma)) continue;
771
- if (ewma < best) {
772
- best = ewma;
773
- challenger = u;
774
- }
775
- }
776
- const margin = Math.max(RELAY_EWMA_MARGIN_MS, RELAY_EWMA_MARGIN_RATIO * headEwma);
777
- if (challenger !== undefined && headEwma - best > margin) {
778
- return [challenger, ...band.filter((u) => u !== challenger)];
779
- }
780
- }
781
- return [head, ...measured, ...unknown];
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
- const ms = health?.ewmaMs;
792
- if (ms === undefined || !Number.isFinite(ms) || (health?.ewmaSamples ?? 0) <= 0) return "";
793
- return ` ~${Math.round(ms)}ms avg`;
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
- const clean = (pin ?? "").trim();
805
- if (!clean) return false;
806
- if (!ordered.includes(clean)) return false;
807
- if (!isRelayHealthy(clean)) return false;
808
- const health = getRelayHealth(clean);
809
- if (health?.lastStatus === 402 && Date.now() < health.cooldownUntil) return false;
810
- return true;
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
- const ordered = getOrderedRelayUrls();
832
- const preferred = (preferredRelay ?? "").trim();
833
- if (preferred) {
834
- const index = ordered.indexOf(preferred);
835
- if (index <= 0) return ordered;
836
- if (!isSessionPinEligible(preferred, ordered)) return ordered;
837
- return [ordered[index], ...ordered.slice(0, index), ...ordered.slice(index + 1)];
838
- }
839
- if (activeRelayState.mode !== "spread") return ordered;
840
- const healthy = ordered.filter((u) => isRelayHealthy(u));
841
- const cooling = ordered.filter((u) => !isRelayHealthy(u));
842
- if (healthy.length <= 1) return [...healthy, ...cooling];
843
- const k = spreadKey ? stableHash(spreadKey) % healthy.length : spreadCursor++ % healthy.length;
844
- return [...healthy.slice(k), ...healthy.slice(0, k), ...cooling];
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
- let h = 2166136261;
853
- for (let i = 0; i < s.length; i++) {
854
- h ^= s.charCodeAt(i);
855
- h = Math.imul(h, 16777619);
856
- }
857
- return h >>> 0;
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
- if (!state || state.hideWidget) {
867
- return null;
868
- }
869
- if (!state.enabled || !state.relays || state.relays.length === 0) {
870
- return state.mode === "off" ? "relay: OFF (direct)" : null;
871
- }
872
- const currentUrl = targetUrl || state.url || state.relays[0]?.url || "";
873
- const label = shortRelayLabel(currentUrl, state.relays);
874
- const total = state.relays.length || 1;
875
- const pos = Math.max(1, state.relays.findIndex((r) => r.url === currentUrl) + 1);
876
- const modeLabel = state.mode === "on" ? "ON" : "AUTO (ON)";
877
- return `relay: ${modeLabel} | ${label} ${pos}/${total}`;
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
- const isActive = typeof activeUrl === "string" && r.url === activeUrl;
885
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url)}] `;
886
- return `${isActive ? "★ " : ""}[${idx + 1}] ${lbl}→ ${r.url}`;
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
- const activeLabel = shortRelayLabel(state.url, state.relays);
894
- const activeIdx = Math.max(
895
- 1,
896
- state.relays.findIndex((r) => r.url === state.url) + 1,
897
- );
898
- const total = state.relays.length || 1;
899
- const modeStr = (state.mode || "auto").toUpperCase();
900
- return `Relay mode: ${modeStr} (${state.enabled ? "ON" : "OFF"})${state.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${state.relays.length} (auto-fallback rolling)`;
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
- if (!activeStatusUi?.setStatus) {
905
- return;
906
- }
907
- // Do not update status bar if the user switched to another provider (e.g. Gemini/Claude)
908
- if (!isFreeFlowModelActive) {
909
- activeStatusUi.setStatus("freeflow", undefined);
910
- return;
911
- }
912
- const label = formatRelayStatusLabel(getActiveRelayState(), targetUrl);
913
- activeStatusUi.setStatus("freeflow", label ?? undefined);
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
- kind: "pi-freeflow/relay-export";
933
- version: 1;
934
- exportedAt: string;
935
- state: {
936
- mode?: RelayMode;
937
- enabled: boolean;
938
- url: string;
939
- relays: KnownRelay[];
940
- hideWidget?: boolean;
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
- index: number;
947
- url: string;
948
- reason: string;
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
- RelayState,
954
- "mode" | "enabled" | "url" | "relays" | "hideWidget"
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
- next: RelayState;
960
- added: number;
961
- updated: number;
962
- removed: number;
963
- skipped: RelayImportSkipped[];
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
- state: RelayState,
976
- opts?: { includeSecrets?: boolean },
996
+ state: RelayState,
997
+ opts?: { includeSecrets?: boolean },
977
998
  ): RelayExportArtifact {
978
- const includeSecrets = opts?.includeSecrets === true;
979
- return {
980
- kind: EXPORT_KIND,
981
- version: EXPORT_VERSION,
982
- exportedAt: new Date().toISOString(),
983
- state: {
984
- mode: state.mode,
985
- enabled: state.enabled,
986
- url: state.url,
987
- relays: state.relays.map((r) => {
988
- const out: KnownRelay = { url: r.url };
989
- if (r.label !== undefined) out.label = r.label;
990
- if (r.addedAt !== undefined) out.addedAt = r.addedAt;
991
- if (
992
- includeSecrets &&
993
- typeof r.auth === "string" &&
994
- r.auth.length > 0
995
- ) {
996
- out.auth = r.auth;
997
- }
998
- return out;
999
- }),
1000
- hideWidget: state.hideWidget,
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
- fragment: RelayImportFragment;
1012
- skipped: RelayImportSkipped[];
1013
- warnings: string[];
1032
+ fragment: RelayImportFragment;
1033
+ skipped: RelayImportSkipped[];
1034
+ warnings: string[];
1014
1035
  } {
1015
- if (raw.length > EXPORT_MAX_BYTES) {
1016
- throw new Error(
1017
- `Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
1018
- );
1019
- }
1020
- let doc: unknown;
1021
- try {
1022
- doc = JSON.parse(raw);
1023
- } catch {
1024
- throw new Error("Relay import file is not valid JSON");
1025
- }
1026
- const envelope =
1027
- typeof doc === "object" && doc !== null
1028
- ? (doc as Record<string, unknown>)
1029
- : null;
1030
- if (envelope?.kind !== EXPORT_KIND) {
1031
- throw new Error("File is not a relay export (bad kind marker)");
1032
- }
1033
- if (
1034
- typeof envelope.version !== "number" ||
1035
- !Number.isInteger(envelope.version) ||
1036
- envelope.version !== EXPORT_VERSION
1037
- ) {
1038
- throw new Error(
1039
- `Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
1040
- );
1041
- }
1042
- const rawState =
1043
- typeof envelope.state === "object" && envelope.state !== null
1044
- ? (envelope.state as Record<string, unknown>)
1045
- : null;
1046
- if (!rawState || !Array.isArray(rawState.relays)) {
1047
- throw new Error("File is not a relay export (state pool is missing)");
1048
- }
1049
-
1050
- const warnings: string[] = [];
1051
- const skipped: RelayImportSkipped[] = [];
1052
-
1053
- const rawMode = rawState.mode;
1054
- const mode: RelayMode =
1055
- rawMode === "on" || rawMode === "off" || rawMode === "auto" || rawMode === "spread"
1056
- ? rawMode
1057
- : "auto";
1058
- if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto" && rawMode !== "spread") {
1059
- warnings.push("Unknown relay mode in file; using automatic mode.");
1060
- }
1061
-
1062
- const rawEnabled = rawState.enabled;
1063
- const enabled =
1064
- typeof rawEnabled === "boolean"
1065
- ? rawEnabled
1066
- : mode === "on"
1067
- ? true
1068
- : mode === "off"
1069
- ? false
1070
- : (rawState.relays as unknown[]).length > 0;
1071
-
1072
- const rawUrl = rawState.url;
1073
- let url = "";
1074
- if (typeof rawUrl === "string" && rawUrl.trim()) {
1075
- const trimmed = rawUrl.trim();
1076
- if (validateRelayUrl(trimmed).ok) {
1077
- url = trimmed;
1078
- } else {
1079
- warnings.push("Active relay address in file is not usable; ignoring it.");
1080
- }
1081
- }
1082
-
1083
- const hideWidget = rawState.hideWidget === true;
1084
- const relays: KnownRelay[] = [];
1085
- const seen = new Set<string>();
1086
- const entries = rawState.relays as unknown[];
1087
- for (let index = 0; index < entries.length; index++) {
1088
- const entry = entries[index];
1089
- if (typeof entry !== "object" || entry === null) {
1090
- skipped.push({ index, url: "", reason: "entry is not an object" });
1091
- continue;
1092
- }
1093
- const record = entry as Record<string, unknown>;
1094
- const trimmedUrl =
1095
- typeof record.url === "string" ? record.url.trim() : "";
1096
- if (!trimmedUrl) {
1097
- skipped.push({ index, url: "", reason: "missing relay address" });
1098
- continue;
1099
- }
1100
- const check = validateRelayUrl(trimmedUrl);
1101
- if (!check.ok) {
1102
- skipped.push({ index, url: trimmedUrl, reason: check.reason });
1103
- continue;
1104
- }
1105
- // Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
1106
- if (seen.has(trimmedUrl)) {
1107
- skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
1108
- continue;
1109
- }
1110
- seen.add(trimmedUrl);
1111
- const candidate: KnownRelay = { url: trimmedUrl };
1112
- if (typeof record.label === "string") {
1113
- const cleanLabel = record.label.trim();
1114
- if (cleanLabel && cleanLabel !== "manual") {
1115
- if (cleanLabel.length > 64) {
1116
- candidate.label = cleanLabel.slice(0, 64);
1117
- warnings.push(
1118
- `Short name for ${trimmedUrl} truncated to 64 characters.`,
1119
- );
1120
- } else {
1121
- candidate.label = cleanLabel;
1122
- }
1123
- }
1124
- }
1125
- if (
1126
- typeof record.addedAt === "string" &&
1127
- Number.isNaN(Date.parse(record.addedAt)) === false
1128
- ) {
1129
- candidate.addedAt = record.addedAt;
1130
- }
1131
- if (
1132
- typeof record.auth === "string" &&
1133
- record.auth.length > 0
1134
- ) {
1135
- candidate.auth = record.auth;
1136
- } else if (record.auth !== undefined) {
1137
- warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
1138
- }
1139
- relays.push(candidate);
1140
- }
1141
-
1142
- return {
1143
- fragment: { mode, enabled, url, relays, hideWidget },
1144
- skipped,
1145
- warnings,
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
- const out: KnownRelay = { url: r.url };
1151
- if (r.label !== undefined) out.label = r.label;
1152
- if (r.addedAt !== undefined) out.addedAt = r.addedAt;
1153
- if (r.auth !== undefined) out.auth = r.auth;
1154
- return out;
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
- current: RelayState,
1164
- fragment: RelayImportFragment,
1165
- opts: { mode: "merge" | "replace" },
1184
+ current: RelayState,
1185
+ fragment: RelayImportFragment,
1186
+ opts: { mode: "merge" | "replace" },
1166
1187
  ): RelayImportPlanResult {
1167
- if (opts.mode === "replace") {
1168
- const installed: KnownRelay[] = [];
1169
- const seen = new Set<string>();
1170
- const skipped: RelayImportSkipped[] = [];
1171
- const source = fragment.relays ?? [];
1172
- for (let index = 0; index < source.length; index++) {
1173
- const entry = source[index];
1174
- const trimmedUrl =
1175
- typeof entry?.url === "string" ? entry.url.trim() : "";
1176
- if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
1177
- skipped.push({
1178
- index,
1179
- url: trimmedUrl,
1180
- reason: "relay address is not usable",
1181
- });
1182
- continue;
1183
- }
1184
- if (seen.has(trimmedUrl)) {
1185
- skipped.push({
1186
- index,
1187
- url: trimmedUrl,
1188
- reason: "duplicate relay address",
1189
- });
1190
- continue;
1191
- }
1192
- seen.add(trimmedUrl);
1193
- installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
1194
- }
1195
- // Empty file pool installs nothing: fail closed here (not just in the
1196
- // command handler) so no caller can turn zero usable addresses into a wipe.
1197
- if (installed.length === 0) {
1198
- return {
1199
- next: { ...current, relays: current.relays.map(copyRelayEntry) },
1200
- added: 0,
1201
- updated: 0,
1202
- removed: 0,
1203
- skipped,
1204
- };
1205
- }
1206
- const installedUrls = new Set(installed.map((r) => r.url));
1207
- const active =
1208
- fragment.url && installedUrls.has(fragment.url)
1209
- ? fragment.url
1210
- : (installed[0]?.url ?? "");
1211
- return {
1212
- next: {
1213
- mode: fragment.mode ?? "auto",
1214
- enabled: fragment.enabled,
1215
- url: active,
1216
- relays: installed,
1217
- hideWidget: fragment.hideWidget === true,
1218
- },
1219
- added: installed.length,
1220
- updated: 0,
1221
- removed: Math.max(0, current.relays.length - installed.length),
1222
- skipped,
1223
- };
1224
- }
1225
-
1226
- const next: RelayState = {
1227
- ...current,
1228
- relays: current.relays.map(copyRelayEntry),
1229
- };
1230
- let added = 0;
1231
- let updated = 0;
1232
- const skipped: RelayImportSkipped[] = [];
1233
- const source = fragment.relays ?? [];
1234
- for (let index = 0; index < source.length; index++) {
1235
- const entry = source[index];
1236
- const trimmedUrl =
1237
- typeof entry?.url === "string" ? entry.url.trim() : "";
1238
- if (!trimmedUrl) {
1239
- skipped.push({ index, url: "", reason: "missing relay address" });
1240
- continue;
1241
- }
1242
- let relay: KnownRelay | undefined;
1243
- try {
1244
- const already = next.relays.some((r) => r.url === trimmedUrl);
1245
- relay = ensureRelay(next, trimmedUrl, entry.label);
1246
- if (already) {
1247
- updated += 1;
1248
- } else {
1249
- added += 1;
1250
- }
1251
- } catch (err) {
1252
- skipped.push({
1253
- index,
1254
- url: trimmedUrl,
1255
- reason: err instanceof Error ? err.message : "relay address rejected",
1256
- });
1257
- continue;
1258
- }
1259
- // File password overwrites; an absent file password keeps the stored one.
1260
- if (typeof entry.auth === "string" && entry.auth.length > 0) {
1261
- relay.auth = entry.auth;
1262
- }
1263
- if (
1264
- typeof entry.addedAt === "string" &&
1265
- Number.isNaN(Date.parse(entry.addedAt)) === false
1266
- ) {
1267
- relay.addedAt = entry.addedAt;
1268
- }
1269
- }
1270
- const mergedUrls = new Set(next.relays.map((r) => r.url));
1271
- if (!mergedUrls.has(next.url)) {
1272
- next.url =
1273
- fragment.url && mergedUrls.has(fragment.url)
1274
- ? fragment.url
1275
- : (next.relays[0]?.url ?? "");
1276
- }
1277
- return { next, added, updated, removed: 0, skipped };
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
  }