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