pi-freeflow 1.31.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,26 @@ 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`;
815
+ }
816
+
817
+ /**
818
+ * Session-pin eligibility: a pinned issuer hoists to first only while it is a
819
+ * live pool member, healthy (no active cooldown), and not parked for a
820
+ * disabled deployment (402 park). Idle-stale pins never reach here — the
821
+ * issuer map releases them on access — so this stays a pure health/presence
822
+ * gate and ordering otherwise behaves exactly as today.
823
+ */
824
+ export function isSessionPinEligible(pin: string, ordered: string[]): boolean {
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;
794
832
  }
795
833
 
796
834
  /**
@@ -811,20 +849,20 @@ export function ewmaBadge(health: RelayHealth | undefined): string {
811
849
  * so a rate-limited IP is never preferred.
812
850
  */
813
851
  export function orderedRelayCandidates(preferredRelay?: string, spreadKey?: string): string[] {
814
- const ordered = getOrderedRelayUrls();
815
- const preferred = (preferredRelay ?? "").trim();
816
- if (preferred) {
817
- const index = ordered.indexOf(preferred);
818
- if (index <= 0) return ordered;
819
- if (!isRelayHealthy(preferred)) return ordered;
820
- return [ordered[index], ...ordered.slice(0, index), ...ordered.slice(index + 1)];
821
- }
822
- if (activeRelayState.mode !== "spread") return ordered;
823
- const healthy = ordered.filter((u) => isRelayHealthy(u));
824
- const cooling = ordered.filter((u) => !isRelayHealthy(u));
825
- if (healthy.length <= 1) return [...healthy, ...cooling];
826
- const k = spreadKey ? stableHash(spreadKey) % healthy.length : spreadCursor++ % healthy.length;
827
- 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];
828
866
  }
829
867
 
830
868
  /** Round-robin cursor for spread mode when no stable key is available. */
@@ -832,12 +870,12 @@ let spreadCursor = 0;
832
870
 
833
871
  /** FNV-1a: stable string -> bucket index for per-conversation sharding. */
834
872
  function stableHash(s: string): number {
835
- let h = 2166136261;
836
- for (let i = 0; i < s.length; i++) {
837
- h ^= s.charCodeAt(i);
838
- h = Math.imul(h, 16777619);
839
- }
840
- 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;
841
879
  }
842
880
 
843
881
  /**
@@ -846,54 +884,54 @@ function stableHash(s: string): number {
846
884
  * or an empty pool with no explicit OFF mode).
847
885
  */
848
886
  export function formatRelayStatusLabel(state: RelayState, targetUrl?: string): string | null {
849
- if (!state || state.hideWidget) {
850
- return null;
851
- }
852
- if (!state.enabled || !state.relays || state.relays.length === 0) {
853
- return state.mode === "off" ? "relay: OFF (direct)" : null;
854
- }
855
- const currentUrl = targetUrl || state.url || state.relays[0]?.url || "";
856
- const label = shortRelayLabel(currentUrl, state.relays);
857
- const total = state.relays.length || 1;
858
- const pos = Math.max(1, state.relays.findIndex((r) => r.url === currentUrl) + 1);
859
- const modeLabel = state.mode === "on" ? "ON" : "AUTO (ON)";
860
- 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}`;
861
899
  }
862
900
 
863
901
  /**
864
902
  * Shared relay-picker item text ("[1] [label] → url" with an active marker).
865
903
  */
866
904
  export function formatRelayPickerItem(r: KnownRelay, idx: number, activeUrl?: string): string {
867
- const isActive = typeof activeUrl === "string" && r.url === activeUrl;
868
- const lbl = r.label ? `[${r.label}] ` : `[${shortRelayLabel(r.url)}] `;
869
- 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}`;
870
908
  }
871
909
 
872
910
  /**
873
911
  * Shared /freeflow flash notification text for the current relay mode/pool state.
874
912
  */
875
913
  export function formatRelayFlash(state: RelayState): string {
876
- const activeLabel = shortRelayLabel(state.url, state.relays);
877
- const activeIdx = Math.max(
878
- 1,
879
- state.relays.findIndex((r) => r.url === state.url) + 1,
880
- );
881
- const total = state.relays.length || 1;
882
- const modeStr = (state.mode || "auto").toUpperCase();
883
- 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)`;
884
922
  }
885
923
 
886
924
  export function updateRelayStatusUi(targetUrl?: string): void {
887
- if (!activeStatusUi?.setStatus) {
888
- return;
889
- }
890
- // Do not update status bar if the user switched to another provider (e.g. Gemini/Claude)
891
- if (!isFreeFlowModelActive) {
892
- activeStatusUi.setStatus("freeflow", undefined);
893
- return;
894
- }
895
- const label = formatRelayStatusLabel(getActiveRelayState(), targetUrl);
896
- 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);
897
935
  }
898
936
 
899
937
  // ── Relay export/import codec (relay-pool portability) ─────────────────────
@@ -912,38 +950,38 @@ export const EXPORT_MAX_BYTES = 1_048_576;
912
950
 
913
951
  /** A parsed relay export file envelope. */
914
952
  export interface RelayExportArtifact {
915
- kind: "pi-freeflow/relay-export";
916
- version: 1;
917
- exportedAt: string;
918
- state: {
919
- mode?: RelayMode;
920
- enabled: boolean;
921
- url: string;
922
- relays: KnownRelay[];
923
- hideWidget?: boolean;
924
- };
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
+ };
925
963
  }
926
964
 
927
965
  /** One import candidate entry that was skipped instead of aborting the file. */
928
966
  export interface RelayImportSkipped {
929
- index: number;
930
- url: string;
931
- reason: string;
967
+ index: number;
968
+ url: string;
969
+ reason: string;
932
970
  }
933
971
 
934
972
  /** RelayState subset carried by an import file (pool scope only). */
935
973
  export type RelayImportFragment = Pick<
936
- RelayState,
937
- "mode" | "enabled" | "url" | "relays" | "hideWidget"
974
+ RelayState,
975
+ "mode" | "enabled" | "url" | "relays" | "hideWidget"
938
976
  >;
939
977
 
940
978
  /** Result of planning an import against the current pool. */
941
979
  export interface RelayImportPlanResult {
942
- next: RelayState;
943
- added: number;
944
- updated: number;
945
- removed: number;
946
- skipped: RelayImportSkipped[];
980
+ next: RelayState;
981
+ added: number;
982
+ updated: number;
983
+ removed: number;
984
+ skipped: RelayImportSkipped[];
947
985
  }
948
986
 
949
987
  /** Alias kept for the cross-slice contract name. */
@@ -955,34 +993,34 @@ export type PlanResult = RelayImportPlanResult;
955
993
  * carry them verbatim.
956
994
  */
957
995
  export function buildRelayExport(
958
- state: RelayState,
959
- opts?: { includeSecrets?: boolean },
996
+ state: RelayState,
997
+ opts?: { includeSecrets?: boolean },
960
998
  ): RelayExportArtifact {
961
- const includeSecrets = opts?.includeSecrets === true;
962
- return {
963
- kind: EXPORT_KIND,
964
- version: EXPORT_VERSION,
965
- exportedAt: new Date().toISOString(),
966
- state: {
967
- mode: state.mode,
968
- enabled: state.enabled,
969
- url: state.url,
970
- relays: state.relays.map((r) => {
971
- const out: KnownRelay = { url: r.url };
972
- if (r.label !== undefined) out.label = r.label;
973
- if (r.addedAt !== undefined) out.addedAt = r.addedAt;
974
- if (
975
- includeSecrets &&
976
- typeof r.auth === "string" &&
977
- r.auth.length > 0
978
- ) {
979
- out.auth = r.auth;
980
- }
981
- return out;
982
- }),
983
- hideWidget: state.hideWidget,
984
- },
985
- };
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
+ };
986
1024
  }
987
1025
 
988
1026
  /**
@@ -991,150 +1029,150 @@ export function buildRelayExport(
991
1029
  * problems fail closed to `skipped` so one bad relay never wipes the pool.
992
1030
  */
993
1031
  export function parseRelayImport(raw: string): {
994
- fragment: RelayImportFragment;
995
- skipped: RelayImportSkipped[];
996
- warnings: string[];
1032
+ fragment: RelayImportFragment;
1033
+ skipped: RelayImportSkipped[];
1034
+ warnings: string[];
997
1035
  } {
998
- if (raw.length > EXPORT_MAX_BYTES) {
999
- throw new Error(
1000
- `Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
1001
- );
1002
- }
1003
- let doc: unknown;
1004
- try {
1005
- doc = JSON.parse(raw);
1006
- } catch {
1007
- throw new Error("Relay import file is not valid JSON");
1008
- }
1009
- const envelope =
1010
- typeof doc === "object" && doc !== null
1011
- ? (doc as Record<string, unknown>)
1012
- : null;
1013
- if (envelope?.kind !== EXPORT_KIND) {
1014
- throw new Error("File is not a relay export (bad kind marker)");
1015
- }
1016
- if (
1017
- typeof envelope.version !== "number" ||
1018
- !Number.isInteger(envelope.version) ||
1019
- envelope.version !== EXPORT_VERSION
1020
- ) {
1021
- throw new Error(
1022
- `Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
1023
- );
1024
- }
1025
- const rawState =
1026
- typeof envelope.state === "object" && envelope.state !== null
1027
- ? (envelope.state as Record<string, unknown>)
1028
- : null;
1029
- if (!rawState || !Array.isArray(rawState.relays)) {
1030
- throw new Error("File is not a relay export (state pool is missing)");
1031
- }
1032
-
1033
- const warnings: string[] = [];
1034
- const skipped: RelayImportSkipped[] = [];
1035
-
1036
- const rawMode = rawState.mode;
1037
- const mode: RelayMode =
1038
- rawMode === "on" || rawMode === "off" || rawMode === "auto" || rawMode === "spread"
1039
- ? rawMode
1040
- : "auto";
1041
- if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto" && rawMode !== "spread") {
1042
- warnings.push("Unknown relay mode in file; using automatic mode.");
1043
- }
1044
-
1045
- const rawEnabled = rawState.enabled;
1046
- const enabled =
1047
- typeof rawEnabled === "boolean"
1048
- ? rawEnabled
1049
- : mode === "on"
1050
- ? true
1051
- : mode === "off"
1052
- ? false
1053
- : (rawState.relays as unknown[]).length > 0;
1054
-
1055
- const rawUrl = rawState.url;
1056
- let url = "";
1057
- if (typeof rawUrl === "string" && rawUrl.trim()) {
1058
- const trimmed = rawUrl.trim();
1059
- if (validateRelayUrl(trimmed).ok) {
1060
- url = trimmed;
1061
- } else {
1062
- warnings.push("Active relay address in file is not usable; ignoring it.");
1063
- }
1064
- }
1065
-
1066
- const hideWidget = rawState.hideWidget === true;
1067
- const relays: KnownRelay[] = [];
1068
- const seen = new Set<string>();
1069
- const entries = rawState.relays as unknown[];
1070
- for (let index = 0; index < entries.length; index++) {
1071
- const entry = entries[index];
1072
- if (typeof entry !== "object" || entry === null) {
1073
- skipped.push({ index, url: "", reason: "entry is not an object" });
1074
- continue;
1075
- }
1076
- const record = entry as Record<string, unknown>;
1077
- const trimmedUrl =
1078
- typeof record.url === "string" ? record.url.trim() : "";
1079
- if (!trimmedUrl) {
1080
- skipped.push({ index, url: "", reason: "missing relay address" });
1081
- continue;
1082
- }
1083
- const check = validateRelayUrl(trimmedUrl);
1084
- if (!check.ok) {
1085
- skipped.push({ index, url: trimmedUrl, reason: check.reason });
1086
- continue;
1087
- }
1088
- // Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
1089
- if (seen.has(trimmedUrl)) {
1090
- skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
1091
- continue;
1092
- }
1093
- seen.add(trimmedUrl);
1094
- const candidate: KnownRelay = { url: trimmedUrl };
1095
- if (typeof record.label === "string") {
1096
- const cleanLabel = record.label.trim();
1097
- if (cleanLabel && cleanLabel !== "manual") {
1098
- if (cleanLabel.length > 64) {
1099
- candidate.label = cleanLabel.slice(0, 64);
1100
- warnings.push(
1101
- `Short name for ${trimmedUrl} truncated to 64 characters.`,
1102
- );
1103
- } else {
1104
- candidate.label = cleanLabel;
1105
- }
1106
- }
1107
- }
1108
- if (
1109
- typeof record.addedAt === "string" &&
1110
- Number.isNaN(Date.parse(record.addedAt)) === false
1111
- ) {
1112
- candidate.addedAt = record.addedAt;
1113
- }
1114
- if (
1115
- typeof record.auth === "string" &&
1116
- record.auth.length > 0
1117
- ) {
1118
- candidate.auth = record.auth;
1119
- } else if (record.auth !== undefined) {
1120
- warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
1121
- }
1122
- relays.push(candidate);
1123
- }
1124
-
1125
- return {
1126
- fragment: { mode, enabled, url, relays, hideWidget },
1127
- skipped,
1128
- warnings,
1129
- };
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
+ };
1130
1168
  }
1131
1169
 
1132
1170
  function copyRelayEntry(r: KnownRelay): KnownRelay {
1133
- const out: KnownRelay = { url: r.url };
1134
- if (r.label !== undefined) out.label = r.label;
1135
- if (r.addedAt !== undefined) out.addedAt = r.addedAt;
1136
- if (r.auth !== undefined) out.auth = r.auth;
1137
- 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;
1138
1176
  }
1139
1177
 
1140
1178
  /**
@@ -1143,119 +1181,119 @@ function copyRelayEntry(r: KnownRelay): KnownRelay {
1143
1181
  * keeps the current mode/switch/widget; replace installs the file pool fresh.
1144
1182
  */
1145
1183
  export function planRelayImport(
1146
- current: RelayState,
1147
- fragment: RelayImportFragment,
1148
- opts: { mode: "merge" | "replace" },
1184
+ current: RelayState,
1185
+ fragment: RelayImportFragment,
1186
+ opts: { mode: "merge" | "replace" },
1149
1187
  ): RelayImportPlanResult {
1150
- if (opts.mode === "replace") {
1151
- const installed: KnownRelay[] = [];
1152
- const seen = new Set<string>();
1153
- const skipped: RelayImportSkipped[] = [];
1154
- const source = fragment.relays ?? [];
1155
- for (let index = 0; index < source.length; index++) {
1156
- const entry = source[index];
1157
- const trimmedUrl =
1158
- typeof entry?.url === "string" ? entry.url.trim() : "";
1159
- if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
1160
- skipped.push({
1161
- index,
1162
- url: trimmedUrl,
1163
- reason: "relay address is not usable",
1164
- });
1165
- continue;
1166
- }
1167
- if (seen.has(trimmedUrl)) {
1168
- skipped.push({
1169
- index,
1170
- url: trimmedUrl,
1171
- reason: "duplicate relay address",
1172
- });
1173
- continue;
1174
- }
1175
- seen.add(trimmedUrl);
1176
- installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
1177
- }
1178
- // Empty file pool installs nothing: fail closed here (not just in the
1179
- // command handler) so no caller can turn zero usable addresses into a wipe.
1180
- if (installed.length === 0) {
1181
- return {
1182
- next: { ...current, relays: current.relays.map(copyRelayEntry) },
1183
- added: 0,
1184
- updated: 0,
1185
- removed: 0,
1186
- skipped,
1187
- };
1188
- }
1189
- const installedUrls = new Set(installed.map((r) => r.url));
1190
- const active =
1191
- fragment.url && installedUrls.has(fragment.url)
1192
- ? fragment.url
1193
- : (installed[0]?.url ?? "");
1194
- return {
1195
- next: {
1196
- mode: fragment.mode ?? "auto",
1197
- enabled: fragment.enabled,
1198
- url: active,
1199
- relays: installed,
1200
- hideWidget: fragment.hideWidget === true,
1201
- },
1202
- added: installed.length,
1203
- updated: 0,
1204
- removed: Math.max(0, current.relays.length - installed.length),
1205
- skipped,
1206
- };
1207
- }
1208
-
1209
- const next: RelayState = {
1210
- ...current,
1211
- relays: current.relays.map(copyRelayEntry),
1212
- };
1213
- let added = 0;
1214
- let updated = 0;
1215
- const skipped: RelayImportSkipped[] = [];
1216
- const source = fragment.relays ?? [];
1217
- for (let index = 0; index < source.length; index++) {
1218
- const entry = source[index];
1219
- const trimmedUrl =
1220
- typeof entry?.url === "string" ? entry.url.trim() : "";
1221
- if (!trimmedUrl) {
1222
- skipped.push({ index, url: "", reason: "missing relay address" });
1223
- continue;
1224
- }
1225
- let relay: KnownRelay | undefined;
1226
- try {
1227
- const already = next.relays.some((r) => r.url === trimmedUrl);
1228
- relay = ensureRelay(next, trimmedUrl, entry.label);
1229
- if (already) {
1230
- updated += 1;
1231
- } else {
1232
- added += 1;
1233
- }
1234
- } catch (err) {
1235
- skipped.push({
1236
- index,
1237
- url: trimmedUrl,
1238
- reason: err instanceof Error ? err.message : "relay address rejected",
1239
- });
1240
- continue;
1241
- }
1242
- // File password overwrites; an absent file password keeps the stored one.
1243
- if (typeof entry.auth === "string" && entry.auth.length > 0) {
1244
- relay.auth = entry.auth;
1245
- }
1246
- if (
1247
- typeof entry.addedAt === "string" &&
1248
- Number.isNaN(Date.parse(entry.addedAt)) === false
1249
- ) {
1250
- relay.addedAt = entry.addedAt;
1251
- }
1252
- }
1253
- const mergedUrls = new Set(next.relays.map((r) => r.url));
1254
- if (!mergedUrls.has(next.url)) {
1255
- next.url =
1256
- fragment.url && mergedUrls.has(fragment.url)
1257
- ? fragment.url
1258
- : (next.relays[0]?.url ?? "");
1259
- }
1260
- 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 };
1261
1299
  }