pi-freeflow 1.44.0 → 1.45.0
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 +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
package/src/relay-state.ts
CHANGED
|
@@ -46,6 +46,7 @@ function isPrivateRelayHostname(hostname: string): boolean {
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
if (host.includes(":")) {
|
|
49
|
+
if (host.startsWith("64:ff9b:") || host.startsWith("2002:")) return true;
|
|
49
50
|
if (host.startsWith("fc") || host.startsWith("fd")) return true; // ULA fc00::/7
|
|
50
51
|
if (/^fe[89ab]/.test(host)) return true; // link-local fe80::/10
|
|
51
52
|
return false;
|
|
@@ -611,7 +612,8 @@ const RELAY_STATE_LOCK_FILE = `${RELAY_STATE_FILE}.lock`;
|
|
|
611
612
|
/** A lock older than this is presumed abandoned (crashed holder) and taken over. */
|
|
612
613
|
const LOCK_STALE_MS = 30_000;
|
|
613
614
|
|
|
614
|
-
function tryAcquireRelayLock(): boolean {
|
|
615
|
+
export function tryAcquireRelayLock(depth = 0): boolean {
|
|
616
|
+
if (depth >= 3) return false;
|
|
615
617
|
try {
|
|
616
618
|
fs.writeFileSync(RELAY_STATE_LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
617
619
|
return true;
|
|
@@ -630,9 +632,12 @@ function tryAcquireRelayLock(): boolean {
|
|
|
630
632
|
stale = Date.now() - fs.statSync(RELAY_STATE_LOCK_FILE).mtimeMs > LOCK_STALE_MS;
|
|
631
633
|
} catch {
|
|
632
634
|
// The lock vanished between the failed create and the stat — retry once.
|
|
633
|
-
return tryAcquireRelayLock();
|
|
635
|
+
return tryAcquireRelayLock(depth + 1);
|
|
636
|
+
}
|
|
637
|
+
if (!stale) {
|
|
638
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
639
|
+
return tryAcquireRelayLock(depth + 1);
|
|
634
640
|
}
|
|
635
|
-
if (!stale) return false;
|
|
636
641
|
try {
|
|
637
642
|
fs.rmSync(RELAY_STATE_LOCK_FILE, { force: true });
|
|
638
643
|
} catch {
|
|
@@ -640,7 +645,7 @@ function tryAcquireRelayLock(): boolean {
|
|
|
640
645
|
// the proxy request path.
|
|
641
646
|
return false;
|
|
642
647
|
}
|
|
643
|
-
return tryAcquireRelayLock();
|
|
648
|
+
return tryAcquireRelayLock(depth + 1);
|
|
644
649
|
}
|
|
645
650
|
}
|
|
646
651
|
|
|
@@ -964,366 +969,4 @@ export function updateRelayStatusUi(targetUrl?: string): void {
|
|
|
964
969
|
activeStatusUi.setStatus("freeflow", label ?? undefined);
|
|
965
970
|
}
|
|
966
971
|
|
|
967
|
-
|
|
968
|
-
// Pure in-memory codec for `/freeflow export` + `/freeflow import`. These
|
|
969
|
-
// functions never touch disk, never prompt, and never validate the live pool:
|
|
970
|
-
// the command layer parses 100% in memory, then commits via one CAS write.
|
|
971
|
-
|
|
972
|
-
/** Envelope marker identifying a pi-freeflow relay export file. */
|
|
973
|
-
export const EXPORT_KIND = "pi-freeflow/relay-export" as const;
|
|
974
|
-
/** Current relay export file version. Bump on any envelope breaking change. */
|
|
975
|
-
export const EXPORT_VERSION = 1;
|
|
976
|
-
/** Default file name used by `/freeflow export` when no path is given. */
|
|
977
|
-
export const EXPORT_DEFAULT_FILENAME = "freeflow-relays.json";
|
|
978
|
-
/** Hard cap on accepted import file size (1 MiB). */
|
|
979
|
-
export const EXPORT_MAX_BYTES = 1_048_576;
|
|
980
|
-
|
|
981
|
-
/** A parsed relay export file envelope. */
|
|
982
|
-
export interface RelayExportArtifact {
|
|
983
|
-
kind: "pi-freeflow/relay-export";
|
|
984
|
-
version: 1;
|
|
985
|
-
exportedAt: string;
|
|
986
|
-
state: {
|
|
987
|
-
mode?: RelayMode;
|
|
988
|
-
enabled: boolean;
|
|
989
|
-
url: string;
|
|
990
|
-
relays: KnownRelay[];
|
|
991
|
-
hideWidget?: boolean;
|
|
992
|
-
};
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/** One import candidate entry that was skipped instead of aborting the file. */
|
|
996
|
-
export interface RelayImportSkipped {
|
|
997
|
-
index: number;
|
|
998
|
-
url: string;
|
|
999
|
-
reason: string;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
/** RelayState subset carried by an import file (pool scope only). */
|
|
1003
|
-
export type RelayImportFragment = Pick<
|
|
1004
|
-
RelayState,
|
|
1005
|
-
"mode" | "enabled" | "url" | "relays" | "hideWidget"
|
|
1006
|
-
>;
|
|
1007
|
-
|
|
1008
|
-
/** Result of planning an import against the current pool. */
|
|
1009
|
-
export interface RelayImportPlanResult {
|
|
1010
|
-
next: RelayState;
|
|
1011
|
-
added: number;
|
|
1012
|
-
updated: number;
|
|
1013
|
-
removed: number;
|
|
1014
|
-
skipped: RelayImportSkipped[];
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/** Alias kept for the cross-slice contract name. */
|
|
1018
|
-
export type PlanResult = RelayImportPlanResult;
|
|
1019
|
-
|
|
1020
|
-
/**
|
|
1021
|
-
* Build an export artifact from live relay state. Passwords are stripped by
|
|
1022
|
-
* default (the key is absent, not null); pass `{ includeSecrets: true }` to
|
|
1023
|
-
* carry them verbatim.
|
|
1024
|
-
*/
|
|
1025
|
-
export function buildRelayExport(
|
|
1026
|
-
state: RelayState,
|
|
1027
|
-
opts?: { includeSecrets?: boolean },
|
|
1028
|
-
): RelayExportArtifact {
|
|
1029
|
-
const includeSecrets = opts?.includeSecrets === true;
|
|
1030
|
-
return {
|
|
1031
|
-
kind: EXPORT_KIND,
|
|
1032
|
-
version: EXPORT_VERSION,
|
|
1033
|
-
exportedAt: new Date().toISOString(),
|
|
1034
|
-
state: {
|
|
1035
|
-
mode: state.mode,
|
|
1036
|
-
enabled: state.enabled,
|
|
1037
|
-
url: state.url,
|
|
1038
|
-
relays: state.relays.map((r) => {
|
|
1039
|
-
const out: KnownRelay = { url: r.url };
|
|
1040
|
-
if (r.label !== undefined) out.label = r.label;
|
|
1041
|
-
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
1042
|
-
if (
|
|
1043
|
-
includeSecrets &&
|
|
1044
|
-
typeof r.auth === "string" &&
|
|
1045
|
-
r.auth.length > 0
|
|
1046
|
-
) {
|
|
1047
|
-
out.auth = r.auth;
|
|
1048
|
-
}
|
|
1049
|
-
return out;
|
|
1050
|
-
}),
|
|
1051
|
-
hideWidget: state.hideWidget,
|
|
1052
|
-
},
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
/**
|
|
1057
|
-
* Parse raw import file text into a relay fragment. Throws on envelope
|
|
1058
|
-
* failure (oversize, bad JSON, wrong kind/version, missing pool); per-entry
|
|
1059
|
-
* problems fail closed to `skipped` so one bad relay never wipes the pool.
|
|
1060
|
-
*/
|
|
1061
|
-
export function parseRelayImport(raw: string): {
|
|
1062
|
-
fragment: RelayImportFragment;
|
|
1063
|
-
skipped: RelayImportSkipped[];
|
|
1064
|
-
warnings: string[];
|
|
1065
|
-
} {
|
|
1066
|
-
if (raw.length > EXPORT_MAX_BYTES) {
|
|
1067
|
-
throw new Error(
|
|
1068
|
-
`Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
let doc: unknown;
|
|
1072
|
-
try {
|
|
1073
|
-
doc = JSON.parse(raw);
|
|
1074
|
-
} catch {
|
|
1075
|
-
throw new Error("Relay import file is not valid JSON");
|
|
1076
|
-
}
|
|
1077
|
-
const envelope =
|
|
1078
|
-
typeof doc === "object" && doc !== null
|
|
1079
|
-
? (doc as Record<string, unknown>)
|
|
1080
|
-
: null;
|
|
1081
|
-
if (envelope?.kind !== EXPORT_KIND) {
|
|
1082
|
-
throw new Error("File is not a relay export (bad kind marker)");
|
|
1083
|
-
}
|
|
1084
|
-
if (
|
|
1085
|
-
typeof envelope.version !== "number" ||
|
|
1086
|
-
!Number.isInteger(envelope.version) ||
|
|
1087
|
-
envelope.version !== EXPORT_VERSION
|
|
1088
|
-
) {
|
|
1089
|
-
throw new Error(
|
|
1090
|
-
`Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
|
|
1091
|
-
);
|
|
1092
|
-
}
|
|
1093
|
-
const rawState =
|
|
1094
|
-
typeof envelope.state === "object" && envelope.state !== null
|
|
1095
|
-
? (envelope.state as Record<string, unknown>)
|
|
1096
|
-
: null;
|
|
1097
|
-
if (!rawState || !Array.isArray(rawState.relays)) {
|
|
1098
|
-
throw new Error("File is not a relay export (state pool is missing)");
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
const warnings: string[] = [];
|
|
1102
|
-
const skipped: RelayImportSkipped[] = [];
|
|
1103
|
-
|
|
1104
|
-
const rawMode = rawState.mode;
|
|
1105
|
-
const mode: RelayMode =
|
|
1106
|
-
rawMode === "on" || rawMode === "off" || rawMode === "auto" || rawMode === "spread"
|
|
1107
|
-
? rawMode
|
|
1108
|
-
: "auto";
|
|
1109
|
-
if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto" && rawMode !== "spread") {
|
|
1110
|
-
warnings.push("Unknown relay mode in file; using automatic mode.");
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const rawEnabled = rawState.enabled;
|
|
1114
|
-
const enabled =
|
|
1115
|
-
typeof rawEnabled === "boolean"
|
|
1116
|
-
? rawEnabled
|
|
1117
|
-
: mode === "on"
|
|
1118
|
-
? true
|
|
1119
|
-
: mode === "off"
|
|
1120
|
-
? false
|
|
1121
|
-
: (rawState.relays as unknown[]).length > 0;
|
|
1122
|
-
|
|
1123
|
-
const rawUrl = rawState.url;
|
|
1124
|
-
let url = "";
|
|
1125
|
-
if (typeof rawUrl === "string" && rawUrl.trim()) {
|
|
1126
|
-
const trimmed = rawUrl.trim();
|
|
1127
|
-
if (validateRelayUrl(trimmed).ok) {
|
|
1128
|
-
url = trimmed;
|
|
1129
|
-
} else {
|
|
1130
|
-
warnings.push("Active relay address in file is not usable; ignoring it.");
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
const hideWidget = rawState.hideWidget === true;
|
|
1135
|
-
const relays: KnownRelay[] = [];
|
|
1136
|
-
const seen = new Set<string>();
|
|
1137
|
-
const entries = rawState.relays as unknown[];
|
|
1138
|
-
for (let index = 0; index < entries.length; index++) {
|
|
1139
|
-
const entry = entries[index];
|
|
1140
|
-
if (typeof entry !== "object" || entry === null) {
|
|
1141
|
-
skipped.push({ index, url: "", reason: "entry is not an object" });
|
|
1142
|
-
continue;
|
|
1143
|
-
}
|
|
1144
|
-
const record = entry as Record<string, unknown>;
|
|
1145
|
-
const trimmedUrl =
|
|
1146
|
-
typeof record.url === "string" ? record.url.trim() : "";
|
|
1147
|
-
if (!trimmedUrl) {
|
|
1148
|
-
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
1149
|
-
continue;
|
|
1150
|
-
}
|
|
1151
|
-
const check = validateRelayUrl(trimmedUrl);
|
|
1152
|
-
if (!check.ok) {
|
|
1153
|
-
skipped.push({ index, url: trimmedUrl, reason: check.reason });
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
// Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
|
|
1157
|
-
if (seen.has(trimmedUrl)) {
|
|
1158
|
-
skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
|
|
1159
|
-
continue;
|
|
1160
|
-
}
|
|
1161
|
-
seen.add(trimmedUrl);
|
|
1162
|
-
const candidate: KnownRelay = { url: trimmedUrl };
|
|
1163
|
-
if (typeof record.label === "string") {
|
|
1164
|
-
const cleanLabel = record.label.trim();
|
|
1165
|
-
if (cleanLabel && cleanLabel !== "manual") {
|
|
1166
|
-
if (cleanLabel.length > 64) {
|
|
1167
|
-
candidate.label = cleanLabel.slice(0, 64);
|
|
1168
|
-
warnings.push(
|
|
1169
|
-
`Short name for ${trimmedUrl} truncated to 64 characters.`,
|
|
1170
|
-
);
|
|
1171
|
-
} else {
|
|
1172
|
-
candidate.label = cleanLabel;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
if (
|
|
1177
|
-
typeof record.addedAt === "string" &&
|
|
1178
|
-
Number.isNaN(Date.parse(record.addedAt)) === false
|
|
1179
|
-
) {
|
|
1180
|
-
candidate.addedAt = record.addedAt;
|
|
1181
|
-
}
|
|
1182
|
-
if (
|
|
1183
|
-
typeof record.auth === "string" &&
|
|
1184
|
-
record.auth.length > 0
|
|
1185
|
-
) {
|
|
1186
|
-
candidate.auth = record.auth;
|
|
1187
|
-
} else if (record.auth !== undefined) {
|
|
1188
|
-
warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
|
|
1189
|
-
}
|
|
1190
|
-
relays.push(candidate);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
return {
|
|
1194
|
-
fragment: { mode, enabled, url, relays, hideWidget },
|
|
1195
|
-
skipped,
|
|
1196
|
-
warnings,
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
function copyRelayEntry(r: KnownRelay): KnownRelay {
|
|
1201
|
-
const out: KnownRelay = { url: r.url };
|
|
1202
|
-
if (r.label !== undefined) out.label = r.label;
|
|
1203
|
-
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
1204
|
-
if (r.auth !== undefined) out.auth = r.auth;
|
|
1205
|
-
return out;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* Plan an import against the current pool without touching disk or prompting.
|
|
1210
|
-
* Merge folds the file pool into the current one via ensureRelay semantics and
|
|
1211
|
-
* keeps the current mode/switch/widget; replace installs the file pool fresh.
|
|
1212
|
-
*/
|
|
1213
|
-
export function planRelayImport(
|
|
1214
|
-
current: RelayState,
|
|
1215
|
-
fragment: RelayImportFragment,
|
|
1216
|
-
opts: { mode: "merge" | "replace" },
|
|
1217
|
-
): RelayImportPlanResult {
|
|
1218
|
-
if (opts.mode === "replace") {
|
|
1219
|
-
const installed: KnownRelay[] = [];
|
|
1220
|
-
const seen = new Set<string>();
|
|
1221
|
-
const skipped: RelayImportSkipped[] = [];
|
|
1222
|
-
const source = fragment.relays ?? [];
|
|
1223
|
-
for (let index = 0; index < source.length; index++) {
|
|
1224
|
-
const entry = source[index];
|
|
1225
|
-
const trimmedUrl =
|
|
1226
|
-
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1227
|
-
if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
|
|
1228
|
-
skipped.push({
|
|
1229
|
-
index,
|
|
1230
|
-
url: trimmedUrl,
|
|
1231
|
-
reason: "relay address is not usable",
|
|
1232
|
-
});
|
|
1233
|
-
continue;
|
|
1234
|
-
}
|
|
1235
|
-
if (seen.has(trimmedUrl)) {
|
|
1236
|
-
skipped.push({
|
|
1237
|
-
index,
|
|
1238
|
-
url: trimmedUrl,
|
|
1239
|
-
reason: "duplicate relay address",
|
|
1240
|
-
});
|
|
1241
|
-
continue;
|
|
1242
|
-
}
|
|
1243
|
-
seen.add(trimmedUrl);
|
|
1244
|
-
installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
|
|
1245
|
-
}
|
|
1246
|
-
// Empty file pool installs nothing: fail closed here (not just in the
|
|
1247
|
-
// command handler) so no caller can turn zero usable addresses into a wipe.
|
|
1248
|
-
if (installed.length === 0) {
|
|
1249
|
-
return {
|
|
1250
|
-
next: { ...current, relays: current.relays.map(copyRelayEntry) },
|
|
1251
|
-
added: 0,
|
|
1252
|
-
updated: 0,
|
|
1253
|
-
removed: 0,
|
|
1254
|
-
skipped,
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
|
-
const installedUrls = new Set(installed.map((r) => r.url));
|
|
1258
|
-
const active =
|
|
1259
|
-
fragment.url && installedUrls.has(fragment.url)
|
|
1260
|
-
? fragment.url
|
|
1261
|
-
: (installed[0]?.url ?? "");
|
|
1262
|
-
return {
|
|
1263
|
-
next: {
|
|
1264
|
-
mode: fragment.mode ?? "auto",
|
|
1265
|
-
enabled: fragment.enabled,
|
|
1266
|
-
url: active,
|
|
1267
|
-
relays: installed,
|
|
1268
|
-
hideWidget: fragment.hideWidget === true,
|
|
1269
|
-
},
|
|
1270
|
-
added: installed.length,
|
|
1271
|
-
updated: 0,
|
|
1272
|
-
removed: Math.max(0, current.relays.length - installed.length),
|
|
1273
|
-
skipped,
|
|
1274
|
-
};
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const next: RelayState = {
|
|
1278
|
-
...current,
|
|
1279
|
-
relays: current.relays.map(copyRelayEntry),
|
|
1280
|
-
};
|
|
1281
|
-
let added = 0;
|
|
1282
|
-
let updated = 0;
|
|
1283
|
-
const skipped: RelayImportSkipped[] = [];
|
|
1284
|
-
const source = fragment.relays ?? [];
|
|
1285
|
-
for (let index = 0; index < source.length; index++) {
|
|
1286
|
-
const entry = source[index];
|
|
1287
|
-
const trimmedUrl =
|
|
1288
|
-
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1289
|
-
if (!trimmedUrl) {
|
|
1290
|
-
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
1291
|
-
continue;
|
|
1292
|
-
}
|
|
1293
|
-
let relay: KnownRelay | undefined;
|
|
1294
|
-
try {
|
|
1295
|
-
const already = next.relays.some((r) => r.url === trimmedUrl);
|
|
1296
|
-
relay = ensureRelay(next, trimmedUrl, entry.label);
|
|
1297
|
-
if (already) {
|
|
1298
|
-
updated += 1;
|
|
1299
|
-
} else {
|
|
1300
|
-
added += 1;
|
|
1301
|
-
}
|
|
1302
|
-
} catch (err) {
|
|
1303
|
-
skipped.push({
|
|
1304
|
-
index,
|
|
1305
|
-
url: trimmedUrl,
|
|
1306
|
-
reason: err instanceof Error ? err.message : "relay address rejected",
|
|
1307
|
-
});
|
|
1308
|
-
continue;
|
|
1309
|
-
}
|
|
1310
|
-
// File password overwrites; an absent file password keeps the stored one.
|
|
1311
|
-
if (typeof entry.auth === "string" && entry.auth.length > 0) {
|
|
1312
|
-
relay.auth = entry.auth;
|
|
1313
|
-
}
|
|
1314
|
-
if (
|
|
1315
|
-
typeof entry.addedAt === "string" &&
|
|
1316
|
-
Number.isNaN(Date.parse(entry.addedAt)) === false
|
|
1317
|
-
) {
|
|
1318
|
-
relay.addedAt = entry.addedAt;
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
const mergedUrls = new Set(next.relays.map((r) => r.url));
|
|
1322
|
-
if (!mergedUrls.has(next.url)) {
|
|
1323
|
-
next.url =
|
|
1324
|
-
fragment.url && mergedUrls.has(fragment.url)
|
|
1325
|
-
? fragment.url
|
|
1326
|
-
: (next.relays[0]?.url ?? "");
|
|
1327
|
-
}
|
|
1328
|
-
return { next, added, updated, removed: 0, skipped };
|
|
1329
|
-
}
|
|
972
|
+
export * from "./relay-codec.ts";
|
package/src/relay.ts
CHANGED
|
@@ -401,50 +401,97 @@ export async function relayFetch(
|
|
|
401
401
|
fetchA.catch(() => {});
|
|
402
402
|
fetchB.catch(() => {});
|
|
403
403
|
interface HedgedWin { url: string; response: Response; }
|
|
404
|
+
let hedgeBAbortedByWinner = false;
|
|
405
|
+
let bProducedOutcome = false;
|
|
406
|
+
const isCallerAborted = () => Boolean(opts.signal?.aborted);
|
|
407
|
+
const isNonWinning = (status: number): boolean =>
|
|
408
|
+
isRetriableStatus(status) || status === 403 || status === 401 || status === 402;
|
|
404
409
|
try {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
410
|
+
const { promise: winnerPromise, resolve, reject } = Promise.withResolvers<HedgedWin>();
|
|
411
|
+
let firstBad: HedgedWin | null = null;
|
|
412
|
+
let firstError: unknown = null;
|
|
413
|
+
let settledCount = 0;
|
|
414
|
+
const onResponse = (legUrl: string, response: Response) => {
|
|
415
|
+
settledCount += 1;
|
|
416
|
+
if (legUrl === nextUrl) {
|
|
417
|
+
bProducedOutcome = true;
|
|
418
|
+
}
|
|
419
|
+
if (!isNonWinning(response.status)) {
|
|
420
|
+
if (firstBad) {
|
|
421
|
+
firstBad.response.body?.cancel().catch(() => {});
|
|
422
|
+
markRelayFailure(firstBad.url, firstBad.response.status);
|
|
423
|
+
}
|
|
424
|
+
resolve({ url: legUrl, response });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!firstBad) {
|
|
428
|
+
if (settledCount >= 2) {
|
|
429
|
+
resolve({ url: legUrl, response });
|
|
430
|
+
} else {
|
|
431
|
+
firstBad = { url: legUrl, response };
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (legUrl === nextUrl) {
|
|
436
|
+
markRelayFailure(nextUrl, response.status);
|
|
437
|
+
response.body?.cancel().catch(() => {});
|
|
438
|
+
} else {
|
|
439
|
+
markRelayFailure(primaryUrl, response.status);
|
|
440
|
+
response.body?.cancel().catch(() => {});
|
|
441
|
+
}
|
|
442
|
+
resolve(firstBad);
|
|
443
|
+
};
|
|
444
|
+
const onError = (legUrl: string, err: unknown) => {
|
|
445
|
+
settledCount += 1;
|
|
446
|
+
if (legUrl === nextUrl && !hedgeBAbortedByWinner) {
|
|
447
|
+
bProducedOutcome = true;
|
|
448
|
+
}
|
|
449
|
+
if (firstBad) {
|
|
450
|
+
if (legUrl === nextUrl && !hedgeBAbortedByWinner && !isCallerAborted()) {
|
|
451
|
+
markRelayFailure(nextUrl, 0, (err as Error)?.message || String(err));
|
|
452
|
+
}
|
|
453
|
+
resolve(firstBad);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (firstError === null) {
|
|
457
|
+
firstError = err;
|
|
458
|
+
}
|
|
459
|
+
if (settledCount >= 2) {
|
|
460
|
+
reject(firstError);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
fetchA.then(
|
|
464
|
+
(r) => onResponse(primaryUrl, r),
|
|
465
|
+
(e) => onError(primaryUrl, e),
|
|
466
|
+
);
|
|
467
|
+
fetchB.then(
|
|
468
|
+
(r) => onResponse(nextUrl, r),
|
|
469
|
+
(e) => onError(nextUrl, e),
|
|
470
|
+
);
|
|
471
|
+
const winner = await winnerPromise;
|
|
422
472
|
if (winner.url === nextUrl) {
|
|
423
473
|
hedgeCtlA.abort();
|
|
424
474
|
targetUrl = nextUrl;
|
|
425
475
|
attemptStart = hedgeStart;
|
|
426
476
|
} else {
|
|
477
|
+
hedgeBAbortedByWinner = true;
|
|
427
478
|
hedgeCtlB.abort();
|
|
428
479
|
}
|
|
429
480
|
res = winner.response;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
481
|
+
if (bProducedOutcome) {
|
|
482
|
+
hedgeSkipNext = true;
|
|
483
|
+
}
|
|
433
484
|
} catch (hedgeErr) {
|
|
434
|
-
// Both hedged attempts failed without usable headers. Caller
|
|
435
|
-
// abort must propagate unmarked (the outer catch rethrows);
|
|
436
|
-
// otherwise record the next relay here and let the outer
|
|
437
|
-
// catch record the primary.
|
|
438
485
|
if ((hedgeErr as Error)?.name === "AbortError" && opts.signal?.aborted) {
|
|
439
486
|
throw hedgeErr;
|
|
440
487
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
488
|
+
if (bProducedOutcome) {
|
|
489
|
+
markRelayFailure(nextUrl, 0, (hedgeErr as Error)?.message || String(hedgeErr));
|
|
490
|
+
reportAbandoned(
|
|
491
|
+
(hedgeErr as Error)?.name === "AbortError" ? "header-timeout" : "network-error",
|
|
492
|
+
);
|
|
493
|
+
hedgeSkipNext = true;
|
|
494
|
+
}
|
|
448
495
|
throw hedgeErr;
|
|
449
496
|
}
|
|
450
497
|
}
|