@shardflux/cli 0.15.2 → 0.17.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 +144 -14
- package/README.md +92 -7
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +15 -1
- package/dist/agent-commands.js +84 -23
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +13 -3
- package/dist/agent-target-commands.js +129 -23
- package/dist/agent-targets.d.ts +5 -0
- package/dist/agent-targets.js +14 -5
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +5 -1
- package/dist/args.js +74 -11
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +3 -0
- package/dist/codex-cloud.js +17 -5
- package/dist/command-center-actions.js +25 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +54 -6
- package/dist/conversations.d.ts +31 -3
- package/dist/conversations.js +53 -10
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +73 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +14 -4
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -2
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +5 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +38 -8
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-mode.d.ts +121 -0
- package/dist/t3-mode.js +654 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +206 -0
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +125 -0
- package/dist/worktree-twin-engine.js +615 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +313 -0
- package/dist/worktree-twin.js +843 -0
- package/package.json +3 -3
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local face of the account's hosted CLIProxyAPI: an HTTP server on 127.0.0.1:8317 (where a local cli-proxy used to
|
|
3
|
+
* listen) that forwards every request, streaming both ways, and every WebSocket upgrade to the hub's port callback URL
|
|
4
|
+
* (`<callback_url><path>`). Tools keep their config: ANTHROPIC_BASE_URL=http://127.0.0.1:8317, Codex
|
|
5
|
+
* base_url http://127.0.0.1:8317/v1, and management calls with their own key.
|
|
6
|
+
*
|
|
7
|
+
* The edge wakes a sleeping hub on the request. When the edge itself answers (header X-Shardflux-Reason) for a request
|
|
8
|
+
* the hub never received (waking took too long, no capacity for a moment, CLIProxyAPI not running), the request is sent
|
|
9
|
+
* again, POST bodies included (kept up to 32 MiB); `port_not_listening` first starts CLIProxyAPI in the hub again
|
|
10
|
+
* (`repair`). Nothing secret is logged: no callback URL, header, query string or body.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer, request as httpRequest, Agent as HttpAgent, STATUS_CODES } from 'node:http';
|
|
13
|
+
import { request as httpsRequest, Agent as HttpsAgent } from 'node:https';
|
|
14
|
+
import { statSync } from 'node:fs';
|
|
15
|
+
import { connect } from 'node:net';
|
|
16
|
+
import { CLI_VERSION } from "./args.js";
|
|
17
|
+
import { CLI_PROXY_PORT, hubInfoPath, readHubInfo } from "./cli-proxy-hub.js";
|
|
18
|
+
/** Edge refusals of a request the hub never received: safe to send again, whatever the method. */
|
|
19
|
+
const NEVER_DELIVERED = new Set(['wake_timeout', 'host_capacity', 'wake_failed', 'dependency_unavailable', 'port_not_listening']);
|
|
20
|
+
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
21
|
+
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade'];
|
|
22
|
+
export const REPLAY_LIMIT = 32 * 1024 * 1024;
|
|
23
|
+
/** The forwarder answers this path itself (paths under /__shardflux/ are reserved by the edge, so no hub route is lost). */
|
|
24
|
+
export const IDENTITY_PATH = '/__shardflux/forwarder';
|
|
25
|
+
/** The hub info file, re-read when it changes (a replaced callback URL takes effect on the next request). */
|
|
26
|
+
export function hubInfoSource(dir) {
|
|
27
|
+
let stamp = '';
|
|
28
|
+
let info = null;
|
|
29
|
+
return () => {
|
|
30
|
+
let next;
|
|
31
|
+
try {
|
|
32
|
+
const s = statSync(hubInfoPath(dir));
|
|
33
|
+
next = `${s.mtimeMs}:${s.size}:${s.ino}`;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
if (next !== stamp) {
|
|
39
|
+
const read = readHubInfo(dir);
|
|
40
|
+
if (read)
|
|
41
|
+
info = read;
|
|
42
|
+
stamp = next;
|
|
43
|
+
}
|
|
44
|
+
return info;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function stripHopByHop(headers, keepUpgrade = false) {
|
|
48
|
+
const listed = String(headers.connection ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const name = k.toLowerCase();
|
|
54
|
+
if ((HOP_BY_HOP.includes(name) || listed.includes(name)) && !(keepUpgrade && (name === 'upgrade' || name === 'connection')))
|
|
55
|
+
continue;
|
|
56
|
+
out[name] = v;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const pathOnly = (url) => (url ?? '/').split('?')[0];
|
|
61
|
+
/** Whether something accepts TCP connections on host:port. */
|
|
62
|
+
function answers(host, port) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const socket = connect({ host, port });
|
|
65
|
+
const done = (v) => { socket.destroy(); resolve(v); };
|
|
66
|
+
socket.setTimeout(1000, () => done(false));
|
|
67
|
+
socket.once('connect', () => done(true));
|
|
68
|
+
socket.once('error', () => done(false));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function errorBody(code, message) {
|
|
72
|
+
return JSON.stringify({ error: { code, message } });
|
|
73
|
+
}
|
|
74
|
+
export class CliProxyForwarder {
|
|
75
|
+
#o;
|
|
76
|
+
#renewing = null;
|
|
77
|
+
#renewedAt = 0;
|
|
78
|
+
#server;
|
|
79
|
+
#https = new HttpsAgent({ keepAlive: true });
|
|
80
|
+
#http = new HttpAgent({ keepAlive: true });
|
|
81
|
+
#repairing = null;
|
|
82
|
+
#closed = false;
|
|
83
|
+
#bindTimer = null;
|
|
84
|
+
#wakeWatch;
|
|
85
|
+
/** Both ends of every open WebSocket (or other upgrade). */
|
|
86
|
+
#tunnels = new Set();
|
|
87
|
+
constructor(opts) {
|
|
88
|
+
this.#o = {
|
|
89
|
+
host: '127.0.0.1', port: CLI_PROXY_PORT, retryBudgetMs: 60_000, bindRetryMs: 5_000, replayLimit: REPLAY_LIMIT,
|
|
90
|
+
log: () => { }, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), ...opts,
|
|
91
|
+
};
|
|
92
|
+
// After this computer sleeps, pooled connections to the edge are dead without a reset; drop them on wake.
|
|
93
|
+
let tick = Date.now();
|
|
94
|
+
this.#wakeWatch = setInterval(() => {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (now - tick > 15_000)
|
|
97
|
+
this.dropIdleConnections();
|
|
98
|
+
tick = now;
|
|
99
|
+
}, 5_000).unref();
|
|
100
|
+
this.#server = createServer((req, res) => void this.#handle(req, res));
|
|
101
|
+
this.#server.on('upgrade', (req, socket, head) => void this.#upgrade(req, socket, head));
|
|
102
|
+
// Long streams (SSE, Responses) must not be cut by the server's own timeouts.
|
|
103
|
+
this.#server.requestTimeout = 0;
|
|
104
|
+
this.#server.headersTimeout = 60_000;
|
|
105
|
+
this.#server.keepAliveTimeout = 65_000;
|
|
106
|
+
}
|
|
107
|
+
get address() {
|
|
108
|
+
const a = this.#server.address();
|
|
109
|
+
return a && typeof a === 'object' ? { host: a.address, port: a.port } : null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Listens on host:port. While another program holds the port (also one listening on all interfaces, which a
|
|
113
|
+
* loopback bind would not notice), logs once and tries again every bindRetryMs (until close()); resolves once
|
|
114
|
+
* listening.
|
|
115
|
+
*/
|
|
116
|
+
listen() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
let told = false;
|
|
119
|
+
const busy = () => {
|
|
120
|
+
if (!told)
|
|
121
|
+
this.#o.log(`${this.#o.host}:${this.#o.port} is in use by another program; waiting for it to be free`);
|
|
122
|
+
told = true;
|
|
123
|
+
this.#bindTimer = setTimeout(() => void attempt(), this.#o.bindRetryMs);
|
|
124
|
+
};
|
|
125
|
+
const attempt = async () => {
|
|
126
|
+
if (this.#closed)
|
|
127
|
+
return resolve();
|
|
128
|
+
if (this.#o.port !== 0 && (await answers(this.#o.host, this.#o.port)))
|
|
129
|
+
return busy();
|
|
130
|
+
const onError = (err) => {
|
|
131
|
+
this.#server.off('listening', onListening);
|
|
132
|
+
if (err.code === 'EADDRINUSE')
|
|
133
|
+
return busy();
|
|
134
|
+
reject(err);
|
|
135
|
+
};
|
|
136
|
+
const onListening = () => {
|
|
137
|
+
this.#server.off('error', onError);
|
|
138
|
+
this.#o.log(`forwarding ${this.#o.host}:${this.address?.port ?? this.#o.port} to the hosted CLIProxyAPI`);
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
this.#server.once('error', onError);
|
|
142
|
+
this.#server.once('listening', onListening);
|
|
143
|
+
this.#server.listen(this.#o.port, this.#o.host);
|
|
144
|
+
};
|
|
145
|
+
void attempt();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Closes the idle keep-alive connections to the edge (the next request opens a fresh one). */
|
|
149
|
+
dropIdleConnections() {
|
|
150
|
+
for (const agent of [this.#https, this.#http]) {
|
|
151
|
+
for (const list of Object.values(agent.freeSockets))
|
|
152
|
+
for (const socket of list ?? [])
|
|
153
|
+
socket.destroy();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async close() {
|
|
157
|
+
this.#closed = true;
|
|
158
|
+
clearInterval(this.#wakeWatch);
|
|
159
|
+
if (this.#bindTimer)
|
|
160
|
+
clearTimeout(this.#bindTimer);
|
|
161
|
+
// Upgraded connections (WebSockets) are no longer the HTTP server's: end them here.
|
|
162
|
+
for (const socket of this.#tunnels)
|
|
163
|
+
socket.destroy();
|
|
164
|
+
this.#https.destroy();
|
|
165
|
+
this.#http.destroy();
|
|
166
|
+
if (!this.#server.listening)
|
|
167
|
+
return;
|
|
168
|
+
await new Promise((r) => {
|
|
169
|
+
this.#server.close(() => r());
|
|
170
|
+
this.#server.closeAllConnections();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
#target(info, url) {
|
|
174
|
+
const base = new URL(info.callback_url);
|
|
175
|
+
const rest = (url ?? '/').replace(/^\/+/, '');
|
|
176
|
+
return new URL(base.pathname + rest, base.origin);
|
|
177
|
+
}
|
|
178
|
+
#send(info, req, body, rest, upgrade, signal) {
|
|
179
|
+
const target = this.#target(info, req.url);
|
|
180
|
+
const https = target.protocol === 'https:';
|
|
181
|
+
const headers = stripHopByHop(req.headers, upgrade);
|
|
182
|
+
headers.host = target.host;
|
|
183
|
+
const gone = () => Object.assign(new Error('the client closed the connection'), { code: 'ECANCELED', connected: false });
|
|
184
|
+
if (signal.aborted)
|
|
185
|
+
return Promise.resolve({ error: gone() });
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
let done = false;
|
|
188
|
+
let connected = false;
|
|
189
|
+
const finish = (a) => { if (!done) {
|
|
190
|
+
done = true;
|
|
191
|
+
resolve(a);
|
|
192
|
+
} };
|
|
193
|
+
const out = (https ? httpsRequest : httpRequest)(target, {
|
|
194
|
+
method: req.method, headers, agent: upgrade ? false : https ? this.#https : this.#http,
|
|
195
|
+
});
|
|
196
|
+
// The client went away: end the request to the hub too and free its socket (never back to the pool).
|
|
197
|
+
const abort = () => out.destroy(gone());
|
|
198
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
199
|
+
out.once('close', () => signal.removeEventListener('abort', abort));
|
|
200
|
+
// Connected once TCP (and TLS) is up, or at once on a pooled socket: an error before that sent nothing.
|
|
201
|
+
out.on('socket', (s) => {
|
|
202
|
+
s.setNoDelay?.(true);
|
|
203
|
+
if (out.reusedSocket)
|
|
204
|
+
connected = true;
|
|
205
|
+
else
|
|
206
|
+
s.once(https ? 'secureConnect' : 'connect', () => { connected = true; });
|
|
207
|
+
});
|
|
208
|
+
out.on('response', (res) => finish({ res }));
|
|
209
|
+
out.on('upgrade', (res, socket, head) => finish({ upgrade: { res, socket, head } }));
|
|
210
|
+
out.on('error', (err) => finish({ error: Object.assign(err, { connected }) }));
|
|
211
|
+
if (body)
|
|
212
|
+
for (const chunk of body)
|
|
213
|
+
out.write(chunk);
|
|
214
|
+
if (rest) {
|
|
215
|
+
rest.pipe(out);
|
|
216
|
+
rest.on('error', () => out.destroy());
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
out.end();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Reads the request body for replay; past REPLAY_LIMIT the rest streams and the request cannot be sent again. */
|
|
224
|
+
async #readBody(req) {
|
|
225
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
226
|
+
if (!req.headers['content-length'] && !req.headers['transfer-encoding'])
|
|
227
|
+
return { chunks: [], replayable: true };
|
|
228
|
+
}
|
|
229
|
+
const chunks = [];
|
|
230
|
+
let size = 0;
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const onData = (c) => {
|
|
233
|
+
chunks.push(c);
|
|
234
|
+
size += c.length;
|
|
235
|
+
if (size > this.#o.replayLimit) {
|
|
236
|
+
req.off('data', onData);
|
|
237
|
+
req.off('end', onEnd);
|
|
238
|
+
req.pause();
|
|
239
|
+
resolve({ chunks, replayable: false });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const onEnd = () => resolve({ chunks, replayable: true });
|
|
243
|
+
req.on('data', onData);
|
|
244
|
+
req.once('end', onEnd);
|
|
245
|
+
req.once('error', reject);
|
|
246
|
+
req.once('close', () => { if (!req.complete)
|
|
247
|
+
reject(new Error('the client closed the connection')); });
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/** Why `a` is worth another attempt (for the log), or null. A replayable request is one whose body is still held. */
|
|
251
|
+
#retryable(a, method, replayable, renewed = false) {
|
|
252
|
+
if (!replayable)
|
|
253
|
+
return null;
|
|
254
|
+
const res = a.res;
|
|
255
|
+
if (res) {
|
|
256
|
+
const reason = res.headers['x-shardflux-reason'];
|
|
257
|
+
// The hub's port could not hand it to CLIProxyAPI while it started: never received.
|
|
258
|
+
if (res.statusCode === 503 && res.headers['x-hub-status'] === 'starting')
|
|
259
|
+
return 'CLIProxyAPI starting in the hub';
|
|
260
|
+
// The callback URL was revoked (its API key was): renewed once, then sent again; it was never received.
|
|
261
|
+
if (reason === 'callback_invalid' && this.#o.renew && !renewed)
|
|
262
|
+
return reason;
|
|
263
|
+
if (typeof reason !== 'string' || (res.statusCode ?? 0) < 500)
|
|
264
|
+
return null;
|
|
265
|
+
return NEVER_DELIVERED.has(reason) || (reason === 'upstream_error' && IDEMPOTENT.has(method)) ? reason : null;
|
|
266
|
+
}
|
|
267
|
+
const err = a.error;
|
|
268
|
+
if (!err)
|
|
269
|
+
return null;
|
|
270
|
+
// Only a failure before the connection was up provably sent nothing (DNS, connect, TLS). Anything later, a reset
|
|
271
|
+
// of a reused keep-alive socket included, may have reached the hub: only GET, HEAD and OPTIONS are sent again.
|
|
272
|
+
const undelivered = err.connected === false;
|
|
273
|
+
return undelivered || IDEMPOTENT.has(method) ? (err.code ?? 'network error') : null;
|
|
274
|
+
}
|
|
275
|
+
/** Renews the hub's callback URLs (at most once a minute, shared by every waiting request); returns its duration. */
|
|
276
|
+
async #renew() {
|
|
277
|
+
if (!this.#o.renew || (!this.#renewing && Date.now() - this.#renewedAt < 60_000))
|
|
278
|
+
return 0;
|
|
279
|
+
const t0 = Date.now();
|
|
280
|
+
this.#renewing ??= this.#o.renew().then(() => this.#o.log('the hub’s address was renewed (the API key that created it was revoked); VMs get it with your machine'), (err) => this.#o.log(`could not renew the hub’s address: ${err instanceof Error ? err.message : String(err)}`)).finally(() => { this.#renewedAt = Date.now(); this.#renewing = null; });
|
|
281
|
+
await this.#renewing;
|
|
282
|
+
return Date.now() - t0;
|
|
283
|
+
}
|
|
284
|
+
/** Starts CLIProxyAPI in the hub again (one repair at a time, shared by every waiting request); returns its duration. */
|
|
285
|
+
async #repair() {
|
|
286
|
+
if (!this.#o.repair)
|
|
287
|
+
return 0;
|
|
288
|
+
const t0 = Date.now();
|
|
289
|
+
this.#repairing ??= this.#o.repair().then(() => this.#o.log('CLIProxyAPI is running in the hub again'), (err) => this.#o.log(`could not start CLIProxyAPI in the hub again: ${err instanceof Error ? err.message : String(err)}`)).finally(() => { this.#repairing = null; });
|
|
290
|
+
await this.#repairing;
|
|
291
|
+
return Date.now() - t0;
|
|
292
|
+
}
|
|
293
|
+
async #attempts(req, upgrade, signal) {
|
|
294
|
+
const method = req.method ?? 'GET';
|
|
295
|
+
const read = upgrade ? { chunks: [], replayable: true } : await this.#readBody(req);
|
|
296
|
+
let delay = 1000;
|
|
297
|
+
let budget = this.#o.retryBudgetMs;
|
|
298
|
+
let renewed = false;
|
|
299
|
+
const started = Date.now();
|
|
300
|
+
const waitOrGone = (ms) => new Promise((resolve) => {
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
return resolve();
|
|
303
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
304
|
+
void this.#o.sleep(ms).then(resolve);
|
|
305
|
+
});
|
|
306
|
+
for (let n = 1;; n++) {
|
|
307
|
+
const info = this.#o.hub();
|
|
308
|
+
if (!info)
|
|
309
|
+
return { status: 503, body: errorBody('service_unavailable', 'This computer is not connected to a hosted CLIProxyAPI. Connect it with: shard cli-proxy import') };
|
|
310
|
+
const attempt = await this.#send(info, req, read.chunks, read.replayable ? null : req, upgrade, signal);
|
|
311
|
+
const reason = this.#retryable(attempt, method, read.replayable, renewed);
|
|
312
|
+
if (!reason || this.#closed || signal.aborted || Date.now() - started > budget)
|
|
313
|
+
return { attempt };
|
|
314
|
+
attempt.res?.resume();
|
|
315
|
+
const after = Number(attempt.res?.headers['retry-after']);
|
|
316
|
+
this.#o.log(`${method} ${pathOnly(req.url)}: ${reason}; sending it again (attempt ${n + 1})`);
|
|
317
|
+
if (reason === 'port_not_listening')
|
|
318
|
+
budget += await this.#repair();
|
|
319
|
+
else if (reason === 'callback_invalid') {
|
|
320
|
+
renewed = true;
|
|
321
|
+
// Sent with a URL another request already replaced: only sent again.
|
|
322
|
+
if (this.#o.hub()?.callback_url === info.callback_url)
|
|
323
|
+
budget += await this.#renew();
|
|
324
|
+
}
|
|
325
|
+
else
|
|
326
|
+
await waitOrGone(Number.isFinite(after) && after > 0 ? Math.min(after * 1000, 10_000) : delay);
|
|
327
|
+
delay = Math.min(delay * 2, 8000);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async #handle(req, res) {
|
|
331
|
+
if (pathOnly(req.url) === IDENTITY_PATH) {
|
|
332
|
+
const info = this.#o.hub();
|
|
333
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ forwarder: 'shardflux', kind: 'mac', version: CLI_VERSION, hub: info ? info.workspace_id : null }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
req.socket.setNoDelay(true);
|
|
337
|
+
// Before any attempt: a client that goes away aborts the request to the hub (and any wait between attempts).
|
|
338
|
+
const gone = new AbortController();
|
|
339
|
+
res.once('close', () => { if (!res.writableFinished)
|
|
340
|
+
gone.abort(); });
|
|
341
|
+
let outcome;
|
|
342
|
+
try {
|
|
343
|
+
outcome = await this.#attempts(req, false, gone.signal);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
if (!res.headersSent && !gone.signal.aborted)
|
|
347
|
+
res.writeHead(400).end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (gone.signal.aborted) {
|
|
351
|
+
if ('attempt' in outcome)
|
|
352
|
+
outcome.attempt.res?.destroy();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if ('status' in outcome) {
|
|
356
|
+
res.writeHead(outcome.status, { 'content-type': 'application/json' }).end(outcome.body);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const { attempt } = outcome;
|
|
360
|
+
if (!attempt.res) {
|
|
361
|
+
this.#o.log(`${req.method} ${pathOnly(req.url)}: the hub could not be reached (${attempt.error?.code ?? 'error'})`);
|
|
362
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(errorBody('bad_gateway', 'The hosted CLIProxyAPI could not be reached.'));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const up = attempt.res;
|
|
366
|
+
res.writeHead(up.statusCode ?? 502, up.statusMessage, stripHopByHop(up.headers));
|
|
367
|
+
res.flushHeaders();
|
|
368
|
+
up.pipe(res);
|
|
369
|
+
up.on('error', () => res.destroy());
|
|
370
|
+
res.on('close', () => { if (!res.writableFinished)
|
|
371
|
+
up.destroy(); });
|
|
372
|
+
}
|
|
373
|
+
async #upgrade(req, socket, head) {
|
|
374
|
+
socket.on('error', () => socket.destroy());
|
|
375
|
+
this.#tunnels.add(socket);
|
|
376
|
+
socket.once('close', () => this.#tunnels.delete(socket));
|
|
377
|
+
const gone = new AbortController();
|
|
378
|
+
socket.once('close', () => gone.abort());
|
|
379
|
+
let outcome;
|
|
380
|
+
try {
|
|
381
|
+
outcome = await this.#attempts(req, true, gone.signal);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
socket.destroy();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const closed = gone.signal.aborted;
|
|
388
|
+
const raw = (status, message, headers) => {
|
|
389
|
+
const lines = [`HTTP/1.1 ${status} ${message ?? STATUS_CODES[status] ?? ''}`];
|
|
390
|
+
for (const [k, v] of Object.entries(headers))
|
|
391
|
+
for (const value of Array.isArray(v) ? v : [v])
|
|
392
|
+
if (value !== undefined)
|
|
393
|
+
lines.push(`${k}: ${value}`);
|
|
394
|
+
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
395
|
+
};
|
|
396
|
+
if ('status' in outcome) {
|
|
397
|
+
socket.end(raw(outcome.status, undefined, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(outcome.body), connection: 'close' }) + outcome.body);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const { attempt } = outcome;
|
|
401
|
+
if (attempt.upgrade) {
|
|
402
|
+
const { res, socket: upstream, head: upHead } = attempt.upgrade;
|
|
403
|
+
this.#tunnels.add(upstream);
|
|
404
|
+
upstream.once('close', () => this.#tunnels.delete(upstream));
|
|
405
|
+
if (closed || this.#closed) {
|
|
406
|
+
upstream.destroy();
|
|
407
|
+
socket.destroy();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
socket.write(raw(res.statusCode ?? 101, res.statusMessage, { ...res.headers }));
|
|
411
|
+
if (upHead.length)
|
|
412
|
+
socket.write(upHead);
|
|
413
|
+
if (head.length)
|
|
414
|
+
upstream.write(head);
|
|
415
|
+
upstream.on('error', () => socket.destroy());
|
|
416
|
+
socket.on('error', () => upstream.destroy());
|
|
417
|
+
upstream.on('close', () => socket.destroy());
|
|
418
|
+
socket.on('close', () => upstream.destroy());
|
|
419
|
+
upstream.pipe(socket);
|
|
420
|
+
socket.pipe(upstream);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (attempt.res) {
|
|
424
|
+
const up = attempt.res;
|
|
425
|
+
socket.write(raw(up.statusCode ?? 502, up.statusMessage, { ...stripHopByHop(up.headers), connection: 'close' }));
|
|
426
|
+
up.pipe(socket);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const body = errorBody('bad_gateway', 'The hosted CLIProxyAPI could not be reached.');
|
|
430
|
+
socket.end(raw(502, undefined, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), connection: 'close' }) + body);
|
|
431
|
+
}
|
|
432
|
+
}
|