@karpeleslab/teamclaude 1.0.7 → 1.0.8

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/src/sx.js ADDED
@@ -0,0 +1,218 @@
1
+ // sx.org proxy integration — an IP-based-429 workaround.
2
+ //
3
+ // teamclaude's transient 429s key on the proxy's OUTBOUND IP, not the account,
4
+ // so account failover doesn't help. sx.org is a residential proxy-port provider:
5
+ // with an API key we provision a port and tunnel upstream Anthropic traffic
6
+ // through it, giving a different egress IP. Crucially, TLS terminates END-TO-END
7
+ // at the upstream (we `tls.connect` over the tunnel with the upstream's
8
+ // servername and the default secure cert check) — the sx.org proxy only ever
9
+ // relays ciphertext and cannot see request content.
10
+ //
11
+ // When no API key is configured (or mode is 'off') this module is dormant and the
12
+ // dial paths behave exactly as before — routing is decided per-attempt by
13
+ // useByDefault() / useOn429() / useForConnect(), all false until provisioned.
14
+
15
+ import net from 'node:net';
16
+ import tls from 'node:tls';
17
+
18
+ const CONNECT_TIMEOUT_MS = 30000; // residential exits can be slow to establish
19
+
20
+ // Resolved per call (not at import) so tests can point it at a local mock.
21
+ const sxBase = () => process.env.SX_API_BASE || 'https://api.sx.org';
22
+
23
+ // ── sx.org REST (apiKey is a query param; these hit api.sx.org directly, never
24
+ // the proxy, and are unrelated to Anthropic traffic) ──
25
+ async function sxGet(path, apiKey, params = {}) {
26
+ const url = new URL(sxBase() + path);
27
+ url.searchParams.set('apiKey', apiKey);
28
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
29
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
30
+ return res.json();
31
+ }
32
+
33
+ async function sxPost(path, apiKey, body) {
34
+ const url = new URL(sxBase() + path);
35
+ url.searchParams.set('apiKey', apiKey);
36
+ const res = await fetch(url, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
39
+ body: JSON.stringify(body),
40
+ });
41
+ return res.json();
42
+ }
43
+
44
+ export const SX_MODES = ['off', '429', 'always'];
45
+ const normalizeMode = (m) => (SX_MODES.includes(m) ? m : 'always');
46
+
47
+ // Normalize either API shape into { host, port, username, password, portId }.
48
+ // ports-list: { proxy: "host:port", login, password, id }
49
+ // create-port: { server, port, login, password, id }
50
+ function parsePort(p) {
51
+ let host, port;
52
+ if (typeof p.proxy === 'string' && p.proxy.includes(':')) {
53
+ const i = p.proxy.lastIndexOf(':');
54
+ host = p.proxy.slice(0, i); port = p.proxy.slice(i + 1);
55
+ } else {
56
+ host = p.server; port = p.port;
57
+ }
58
+ return { host, port: parseInt(port, 10), username: p.login, password: p.password, portId: p.id };
59
+ }
60
+
61
+ /**
62
+ * Open a CONNECT tunnel through an HTTP proxy to targetHost:targetPort and
63
+ * resolve with the raw (still-plaintext) socket once the proxy answers 200.
64
+ */
65
+ export function connectThroughProxy({ proxyHost, proxyPort, auth, targetHost, targetPort, timeout = CONNECT_TIMEOUT_MS }) {
66
+ return new Promise((resolve, reject) => {
67
+ // autoSelectFamily (happy-eyeballs) — default on Node 20+ but not 18; set it
68
+ // so a dual-stack proxy host whose IPv6 path is unreachable falls back to IPv4
69
+ // instead of hanging the connect (sx.org returns an IP, but be robust).
70
+ const sock = net.connect({ port: proxyPort, host: proxyHost, autoSelectFamily: true });
71
+ let buf = '';
72
+ const timer = setTimeout(() => fail(new Error(`sx.org proxy CONNECT timed out after ${timeout}ms`)), timeout);
73
+ const cleanup = () => {
74
+ clearTimeout(timer);
75
+ sock.removeListener('data', onData);
76
+ sock.removeListener('error', fail);
77
+ };
78
+ const fail = (err) => { cleanup(); sock.destroy(); reject(err); };
79
+ const onData = (chunk) => {
80
+ buf += chunk.toString('latin1');
81
+ const idx = buf.indexOf('\r\n\r\n');
82
+ if (idx < 0) { if (buf.length > 65536) fail(new Error('sx.org proxy CONNECT response too large')); return; }
83
+ const statusLine = buf.slice(0, buf.indexOf('\r\n'));
84
+ const m = statusLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
85
+ if (!m || m[1] !== '200') { fail(new Error(`sx.org proxy refused CONNECT: ${statusLine}`)); return; }
86
+ cleanup();
87
+ sock.pause(); // stop flowing so the TLS layer we hand it to sees every byte
88
+ const rest = Buffer.from(buf.slice(idx + 4), 'latin1'); // bytes already past the header
89
+ if (rest.length) sock.unshift(rest);
90
+ resolve(sock);
91
+ };
92
+ sock.once('connect', () => {
93
+ const lines = [`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`];
94
+ if (auth) lines.push(`Proxy-Authorization: Basic ${Buffer.from(auth).toString('base64')}`);
95
+ lines.push('Proxy-Connection: keep-alive', '', '');
96
+ sock.write(lines.join('\r\n'));
97
+ });
98
+ sock.on('data', onData);
99
+ sock.once('error', fail);
100
+ });
101
+ }
102
+
103
+ /**
104
+ * CONNECT through `proxy`, then complete a TLS handshake to targetHost so TLS is
105
+ * end-to-end (the proxy sees ciphertext only). Resolves with the TLSSocket after
106
+ * secureConnect. Cert verification stays at its secure default; tests inject a CA
107
+ * via tlsOptions.ca.
108
+ */
109
+ export async function tunnelTls({ proxy, targetHost, targetPort = 443, tlsOptions = {} }) {
110
+ const sock = await connectThroughProxy({
111
+ proxyHost: proxy.host,
112
+ proxyPort: proxy.port,
113
+ auth: proxy.username ? `${proxy.username}:${proxy.password}` : null,
114
+ targetHost,
115
+ targetPort,
116
+ });
117
+ return new Promise((resolve, reject) => {
118
+ const tlsSock = tls.connect({ socket: sock, servername: targetHost, ...tlsOptions });
119
+ const onErr = (err) => { tlsSock.removeListener('secureConnect', onOk); sock.destroy(); reject(err); };
120
+ const onOk = () => { tlsSock.removeListener('error', onErr); resolve(tlsSock); };
121
+ tlsSock.once('secureConnect', onOk);
122
+ tlsSock.once('error', onErr);
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Holds the sx.org credential + the provisioned proxy. Shared in-process by the
128
+ * reverse proxy, the MITM handler, and the TUI so a key change applies live.
129
+ */
130
+ export class SxManager {
131
+ constructor({ log = () => {} } = {}) {
132
+ this.log = log;
133
+ this.apiKey = null;
134
+ this.proxy = null; // { host, port, username, password, portId }
135
+ this.mode = 'always'; // off | 429 | always — how routing decisions are made
136
+ this._rlUntil = 0; // sticky-routing window end (ms) for '429' mode
137
+ }
138
+
139
+ isProvisioned() { return !!(this.apiKey && this.proxy); }
140
+ getProxy() { return this.proxy; }
141
+ getMode() { return this.mode; }
142
+
143
+ // ── routing decisions ──
144
+ // Reverse-proxy first attempt: only 'always' routes pre-emptively.
145
+ useByDefault() { return this.isProvisioned() && this.mode === 'always'; }
146
+ // Reverse-proxy retry after a 429: 'always' and '429' both route (the 429 is
147
+ // IP-based, so a fresh egress IP can clear it).
148
+ useOn429() { return this.isProvisioned() && this.mode !== 'off'; }
149
+ // MITM connect-time (one tunnel carries many requests, so no per-request
150
+ // failover): 'always' routes; '429' routes only inside the sticky window set
151
+ // when a 429 was recently observed.
152
+ useForConnect() {
153
+ if (!this.isProvisioned() || this.mode === 'off') return false;
154
+ return this.mode === 'always' || this.isRecentlyRateLimited();
155
+ }
156
+
157
+ noteRateLimited(seconds = 60) { this._rlUntil = Date.now() + Math.min(Math.max(seconds, 1), 300) * 1000; }
158
+ isRecentlyRateLimited() { return Date.now() < this._rlUntil; }
159
+
160
+ /** Set the API key (+ optional mode) and provision unless mode is 'off'. */
161
+ async configure(apiKey, mode = this.mode) {
162
+ this.mode = normalizeMode(mode);
163
+ if (!apiKey) { this.disable(); return { ok: false, error: 'no API key' }; }
164
+ this.apiKey = apiKey;
165
+ if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode, proxy: null }; }
166
+ return this._ensureProxy();
167
+ }
168
+
169
+ /** Switch mode WITHOUT clearing the key; provision lazily when turning on. */
170
+ async setMode(mode) {
171
+ this.mode = normalizeMode(mode);
172
+ if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode }; }
173
+ if (this.apiKey && !this.proxy) return this._ensureProxy();
174
+ return { ok: true, mode: this.mode, proxy: this.proxy };
175
+ }
176
+
177
+ /** Full deconfigure — forget the key entirely. */
178
+ disable() { this.apiKey = null; this.proxy = null; }
179
+
180
+ async _ensureProxy() {
181
+ try {
182
+ this.proxy = await this.provision();
183
+ this.log(`[TeamClaude] sx.org proxy ready: ${this.proxy.host}:${this.proxy.port}`);
184
+ return { ok: true, mode: this.mode, proxy: this.proxy };
185
+ } catch (err) {
186
+ this.proxy = null;
187
+ this.log(`[TeamClaude] sx.org provisioning failed: ${err.message}`);
188
+ return { ok: false, error: err.message };
189
+ }
190
+ }
191
+
192
+ /** Account balance/traffic, or null on error. */
193
+ async getBalance() {
194
+ if (!this.apiKey) return null;
195
+ try {
196
+ const r = await sxGet('/v2/user/balance', this.apiKey);
197
+ return r?.success ? r : null;
198
+ } catch { return null; }
199
+ }
200
+
201
+ /** Reuse an active port if one exists, else create a residential US one. */
202
+ async provision() {
203
+ if (!this.apiKey) throw new Error('sx.org API key not set');
204
+ const list = await sxGet('/v2/proxy/ports', this.apiKey, { per_page: 50 });
205
+ const proxies = list?.message?.proxies || [];
206
+ const active = proxies.find((p) => p.status === 1 && p.login && p.password && p.proxy);
207
+ if (active) return parsePort(active);
208
+
209
+ const created = await sxPost('/v2/proxy/create-port', this.apiKey, {
210
+ country_code: 'US', proxy_type_id: 1, type_id: 1, // type_id 1 = residential
211
+ });
212
+ if (!created?.success || !created.data) {
213
+ const detail = created?.errors ? JSON.stringify(created.errors) : (created?.message || JSON.stringify(created));
214
+ throw new Error(`sx.org create-port failed: ${detail}`);
215
+ }
216
+ return parsePort(created.data);
217
+ }
218
+ }
package/src/tui.js CHANGED
@@ -115,16 +115,18 @@ function timestamp() {
115
115
  // ── TUI class ────────────────────────────────────────────────
116
116
 
117
117
  export class TUI {
118
- constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
118
+ constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null }) {
119
119
  this.am = accountManager;
120
120
  this.config = config;
121
121
  this.saveConfig = saveConfig;
122
122
  this.syncAccounts = syncAccounts;
123
123
  this.onQuit = onQuit;
124
+ this.sx = sx; // sx.org proxy manager (may be null)
125
+ this.sxBalance = null; // last fetched sx.org balance, for the settings screen
124
126
 
125
127
  this.log = []; // completed activity entries
126
128
  this.active = new Map(); // in-flight requests
127
- this.mode = 'normal'; // normal | select | add | input
129
+ this.mode = 'normal'; // normal | select | add | input | settings
128
130
  this.selAction = null; // switch | remove
129
131
  this.selIdx = 0;
130
132
  this.inputPrompt = '';
@@ -221,6 +223,7 @@ export class TUI {
221
223
  case 'select': this._keySelect(k); break;
222
224
  case 'add': this._keyAdd(k); break;
223
225
  case 'input': this._keyInput(k); break;
226
+ case 'settings': this._keySettings(k); break;
224
227
  }
225
228
  this.render();
226
229
  }
@@ -238,6 +241,63 @@ export class TUI {
238
241
  }
239
242
  else if (k === 'a') { this.mode = 'add'; }
240
243
  else if (k === 'R') { this._doSync(); }
244
+ else if (k === 'g' && this.sx) { this.mode = 'settings'; this._loadSxBalance(); }
245
+ }
246
+
247
+ _keySettings(k) {
248
+ if (k === 't') {
249
+ this.mode = 'input';
250
+ this.inputPrompt = 'Switch threshold % (1-100)';
251
+ this.inputBuf = '';
252
+ this.inputCb = v => { if (v) this._doSetThreshold(v.trim()); };
253
+ }
254
+ else if (k === 'p') {
255
+ this.mode = 'input';
256
+ this.inputPrompt = 'Quota probe seconds (0=off, min 30)';
257
+ this.inputBuf = '';
258
+ this.inputCb = v => { if (v) this._doSetProbe(v.trim()); };
259
+ }
260
+ else if (k === 'k') {
261
+ this.mode = 'input';
262
+ this.inputPrompt = 'sx.org API key';
263
+ this.inputBuf = '';
264
+ this.inputCb = v => { if (v) this._doSetSxKey(v.trim()); };
265
+ }
266
+ else if (k === 'm') { this._doCycleSxMode(); }
267
+ else if (k === 'x') { this._doClearSxKey(); }
268
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
269
+ }
270
+
271
+ async _doSetThreshold(input) {
272
+ const pct = Number(input);
273
+ if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
274
+ this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
275
+ }
276
+ const v = Math.round(pct) / 100;
277
+ this.config.switchThreshold = v;
278
+ this.am.switchThreshold = v; // apply to the running rotation immediately
279
+ try { await this.saveConfig(this.config); }
280
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
281
+ this._addLog(`Switch threshold set to ${Math.round(v * 100)}%`);
282
+ this.mode = 'settings';
283
+ if (this.running) this.render();
284
+ }
285
+
286
+ async _doSetProbe(input) {
287
+ let secs = parseInt(input, 10);
288
+ if (Number.isNaN(secs) || secs < 0) {
289
+ this._addLog('Invalid interval — enter 0 (off) or seconds'); this.mode = 'settings'; if (this.running) this.render(); return;
290
+ }
291
+ if (secs > 0 && secs < 30) secs = 30; // match the CLI minimum (don't hammer the usage endpoint)
292
+ this.config.quotaProbeSeconds = secs;
293
+ try { await this.saveConfig(this.config); }
294
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
295
+ // syncAccounts re-reads disk config and reschedules the running prober live.
296
+ try { await this.syncAccounts(); }
297
+ catch (e) { this._addLog(`Reload failed: ${e.message}`); }
298
+ this._addLog(secs > 0 ? `Quota probe every ${secs}s` : 'Quota probe disabled');
299
+ this.mode = 'settings';
300
+ if (this.running) this.render();
241
301
  }
242
302
 
243
303
  _keySelect(k) {
@@ -296,6 +356,57 @@ export class TUI {
296
356
  }
297
357
  }
298
358
 
359
+ // ── sx.org settings ────────────────────────────────
360
+
361
+ _loadSxBalance() {
362
+ this.sxBalance = null;
363
+ if (!this.sx?.apiKey) return;
364
+ this.sx.getBalance()
365
+ .then(b => { this.sxBalance = b; if (this.running) this.render(); })
366
+ .catch(() => {});
367
+ }
368
+
369
+ _sxModeLabel(m) { return m === 'always' ? 'always' : m === '429' ? 'on 429 only' : 'off'; }
370
+
371
+ async _doSetSxKey(key) {
372
+ const mode = this.config.sx?.mode || 'always';
373
+ this.config.sx = { apiKey: key, mode };
374
+ try { await this.saveConfig(this.config); }
375
+ catch (e) { this._addLog(`Failed to save sx.org key: ${e.message}`); }
376
+ this._addLog('sx.org: configuring...');
377
+ const r = await this.sx.configure(key, mode);
378
+ if (r.ok && r.proxy) this._addLog(`sx.org key saved — proxy ${r.proxy.host}:${r.proxy.port} (mode: ${this._sxModeLabel(mode)})`);
379
+ else if (r.ok) this._addLog(`sx.org key saved (mode: ${this._sxModeLabel(mode)})`);
380
+ else this._addLog(`sx.org error: ${r.error}`);
381
+ this._loadSxBalance();
382
+ this.mode = 'settings';
383
+ if (this.running) this.render();
384
+ }
385
+
386
+ // Cycle off → on-429 → always. Keeps the API key, so the user can disable
387
+ // sx.org without deconfiguring it.
388
+ async _doCycleSxMode() {
389
+ const order = ['off', '429', 'always'];
390
+ const next = order[(order.indexOf(this.sx.getMode()) + 1) % order.length];
391
+ this.config.sx = { ...(this.config.sx || {}), mode: next };
392
+ try { await this.saveConfig(this.config); }
393
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
394
+ const r = await this.sx.setMode(next);
395
+ this._addLog(`sx.org mode: ${this._sxModeLabel(next)}${r.ok ? '' : ` — ${r.error}`}`);
396
+ if (next !== 'off') this._loadSxBalance();
397
+ if (this.running) this.render();
398
+ }
399
+
400
+ async _doClearSxKey() {
401
+ this.config.sx = null;
402
+ try { await this.saveConfig(this.config); }
403
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
404
+ this.sx.disable();
405
+ this.sxBalance = null;
406
+ this._addLog('sx.org key cleared');
407
+ if (this.running) this.render();
408
+ }
409
+
299
410
  async _doImport() {
300
411
  try {
301
412
  this._addLog('Importing credentials...');
@@ -439,6 +550,10 @@ export class TUI {
439
550
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
440
551
  lines.push(' ' + dim('─'.repeat(W - 2)));
441
552
 
553
+ const footerH = 2;
554
+ if (this.mode === 'settings') {
555
+ this._renderSettings(lines);
556
+ } else {
442
557
  // ── Accounts
443
558
  if (this.am.accounts.length === 0) {
444
559
  lines.push('');
@@ -472,11 +587,11 @@ export class TUI {
472
587
  }
473
588
 
474
589
  // Completed log
475
- const footerH = 2;
476
590
  const space = Math.max(0, H - lines.length - footerH);
477
591
  for (let i = 0; i < space && i < this.log.length; i++) {
478
592
  lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
479
593
  }
594
+ } // end non-settings body
480
595
 
481
596
  // Pad to fill
482
597
  while (lines.length < H - footerH) lines.push('');
@@ -529,7 +644,7 @@ export class TUI {
529
644
  const q = a.quota;
530
645
  let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
531
646
 
532
- if (q.unified5h != null || q.unified7d != null) {
647
+ if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
533
648
  r1 = q.unified5h;
534
649
  r2 = q.unified7d;
535
650
  t1 = q.unified5hReset;
@@ -548,14 +663,60 @@ export class TUI {
548
663
  let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
549
664
  if (showBoth) {
550
665
  line += ` ${l2} ${bar(r2, bw, t2)}`;
666
+ // Sonnet weekly bar — only shown when the usage probe has populated it.
667
+ if (q.unified7dSonnet != null) {
668
+ line += ` S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset)}`;
669
+ }
551
670
  }
552
671
  return line;
553
672
  }
554
673
 
674
+ _renderSettings(lines) {
675
+ lines.push('');
676
+ // ── Rotation
677
+ const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
678
+ lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
679
+ lines.push(` Switch at: ${green(`${Math.round(thr * 100)}%`)} ${dim('utilization')}`);
680
+ lines.push('');
681
+ // ── Quota probe
682
+ const probe = this.config.quotaProbeSeconds || 0;
683
+ lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
684
+ lines.push(` Interval: ${probe > 0 ? green(`${probe}s`) : gray('off (passive)')}`);
685
+ lines.push('');
686
+ // ── sx.org
687
+ lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
688
+ lines.push('');
689
+ if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
690
+ const key = this.config.sx?.apiKey;
691
+ const masked = key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
692
+ const mode = this.sx.getMode();
693
+ const modeStr = mode === 'always' ? green('always')
694
+ : mode === '429' ? cyan('on 429 only')
695
+ : gray('off');
696
+ const p = this.sx.getProxy?.();
697
+ const proxyStr = mode === 'off' ? gray('—')
698
+ : this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
699
+ : key ? yellow('not provisioned')
700
+ : gray('no key');
701
+ const b = this.sxBalance;
702
+ lines.push(` Mode: ${modeStr}`);
703
+ lines.push(` API key: ${masked}`);
704
+ lines.push(` Proxy: ${proxyStr}`);
705
+ lines.push(` Balance: ${b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')}`);
706
+ lines.push('');
707
+ lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
708
+ lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
709
+ lines.push(dim(' off never use sx.org (API key is kept)'));
710
+ lines.push('');
711
+ lines.push(dim(' TLS stays end-to-end; residential traffic is metered by sx.org.'));
712
+ }
713
+
555
714
  _renderFooter() {
556
715
  switch (this.mode) {
557
716
  case 'normal':
558
- return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('q')}uit`;
717
+ return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
718
+ case 'settings':
719
+ return ` ${bold('t')} threshold ${bold('p')} probe ${bold('m')} sx-mode ${bold('k')} sx-key ${bold('x')} clear-key ${bold('Esc')} back`;
559
720
  case 'select': {
560
721
  const act = this.selAction === 'switch' ? 'switch'
561
722
  : this.selAction === 'toggle' ? 'enable/disable'
@@ -0,0 +1,85 @@
1
+ // Zero-dependency `fetch` shim that routes upstream requests through the sx.org
2
+ // proxy when it is enabled. With sx disabled it IS global fetch (byte-for-byte
3
+ // the same behavior), so the default path is unchanged.
4
+ //
5
+ // Node's global fetch can't use a CONNECT proxy without `undici` (a dependency —
6
+ // and "zero dependencies" is a project feature), so when sx is enabled we issue
7
+ // the request with `https.request` over a tunneled TLS socket and return a small
8
+ // object exposing exactly the fetch-Response surface src/server.js relies on:
9
+ // `status`, `headers.get()/.entries()`, `text()`, `arrayBuffer()`, and `body`
10
+ // (a web ReadableStream, so streamResponse()'s getReader()/cancel() is untouched).
11
+
12
+ import https from 'node:https';
13
+ import { Readable } from 'node:stream';
14
+ import { tunnelTls } from './sx.js';
15
+
16
+ // `useProxy` is decided by the caller (it varies per attempt — e.g. direct first,
17
+ // then via sx after a 429). With it false, or sx unprovisioned, this is plain fetch.
18
+ export function upstreamFetch(url, opts = {}, sx = null, useProxy = false) {
19
+ if (!sx || !useProxy || !sx.isProvisioned()) return fetch(url, opts);
20
+ return proxiedFetch(url, opts, sx);
21
+ }
22
+
23
+ function proxiedFetch(url, opts, sx) {
24
+ const u = new URL(url);
25
+ const proxy = sx.getProxy();
26
+
27
+ // Custom agent: every socket is a TLS connection tunneled through sx.org.
28
+ const agent = new https.Agent({ keepAlive: true });
29
+ agent.createConnection = (_options, cb) => {
30
+ // sx.tlsOptions is undefined in production (system CAs verify api.anthropic.com);
31
+ // tests inject a CA here to reach a self-signed upstream.
32
+ tunnelTls({ proxy, targetHost: u.hostname, targetPort: Number(u.port) || 443, tlsOptions: sx.tlsOptions || {} })
33
+ .then((sock) => cb(null, sock))
34
+ .catch((err) => cb(err));
35
+ return undefined; // socket delivered asynchronously via cb
36
+ };
37
+
38
+ return new Promise((resolve, reject) => {
39
+ const req = https.request(
40
+ u,
41
+ { method: opts.method || 'GET', headers: opts.headers || {}, agent },
42
+ (res) => resolve(makeResponse(res)),
43
+ );
44
+ req.once('error', reject);
45
+
46
+ const body = opts.body;
47
+ const method = (opts.method || 'GET').toUpperCase();
48
+ if (body == null || method === 'GET' || method === 'HEAD') req.end();
49
+ else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) req.end(Buffer.from(body));
50
+ else req.end(String(body));
51
+ });
52
+ }
53
+
54
+ // Wrap a Node IncomingMessage as the subset of a fetch Response that server.js uses.
55
+ function makeResponse(res) {
56
+ const web = Readable.toWeb(res); // single web stream — one consumer either way
57
+ const collect = async () => {
58
+ const chunks = [];
59
+ const reader = web.getReader();
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done) break;
63
+ chunks.push(Buffer.from(value));
64
+ }
65
+ return Buffer.concat(chunks);
66
+ };
67
+ return {
68
+ status: res.statusCode,
69
+ headers: makeHeaders(res.headers),
70
+ body: web,
71
+ async text() { return (await collect()).toString('utf8'); },
72
+ async arrayBuffer() { const b = await collect(); return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); },
73
+ };
74
+ }
75
+
76
+ // res.headers already has lowercased keys; values are string | string[] (set-cookie).
77
+ function makeHeaders(h) {
78
+ const flat = (v) => (Array.isArray(v) ? v.join(', ') : v);
79
+ const entries = function* () { for (const [k, v] of Object.entries(h)) yield [k, flat(v)]; };
80
+ return {
81
+ get: (name) => { const v = h[name.toLowerCase()]; return v == null ? null : flat(v); },
82
+ entries,
83
+ [Symbol.iterator]: entries,
84
+ };
85
+ }