openzoo 0.21.3 → 0.21.5
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/lib/proxy.js +2 -14
- package/lib/setup.js +27 -24
- package/lib/tunnel.js +11 -111
- package/package.json +1 -1
package/lib/proxy.js
CHANGED
|
@@ -285,7 +285,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
285
285
|
// origin, not off whether the URL exists yet, so there is no startup window
|
|
286
286
|
// where public traffic slips through ungated.
|
|
287
287
|
let tunnelGate = null;
|
|
288
|
-
const rebindHooks = [];
|
|
289
288
|
let tunnelError = null;
|
|
290
289
|
|
|
291
290
|
const server = http.createServer(async (req, res) => {
|
|
@@ -731,21 +730,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
731
730
|
if (autoTunnel && process.env.OPENZOO_NO_TUNNEL !== '1') {
|
|
732
731
|
(async () => {
|
|
733
732
|
try {
|
|
734
|
-
const { ensureCloudflared,
|
|
733
|
+
const { ensureCloudflared, startCloudflared, mintToken } = await import('./tunnel.js');
|
|
735
734
|
const token = mintToken();
|
|
736
735
|
const cap = process.env.OPENZOO_TUNNEL_MAX_USD ? Number(process.env.OPENZOO_TUNNEL_MAX_USD) : Infinity;
|
|
737
736
|
const bin = await ensureCloudflared((m) => log(m));
|
|
738
|
-
|
|
739
|
-
// that dies leaves every configured client reconnect-looping at a dead host.
|
|
740
|
-
const { url, proc } = await startHealthyTunnel(bin, config.port, log);
|
|
737
|
+
const { url, proc } = await startCloudflared(bin, config.port, log);
|
|
741
738
|
tunnelGate = { token, sessionMaxUsd: cap, publicUrl: url };
|
|
742
|
-
superviseTunnel({
|
|
743
|
-
bin, port: config.port, url, proc, log,
|
|
744
|
-
onRebind: async (next) => {
|
|
745
|
-
tunnelGate.publicUrl = next; // /v1/info and callers follow
|
|
746
|
-
for (const fn of rebindHooks) { try { await fn(next); } catch { /* advisory */ } }
|
|
747
|
-
},
|
|
748
|
-
});
|
|
749
739
|
const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
|
|
750
740
|
process.once('SIGINT', () => { bye(); process.exit(0); });
|
|
751
741
|
process.once('SIGTERM', () => { bye(); process.exit(0); });
|
|
@@ -775,8 +765,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
775
765
|
spent: () => sessionSpent,
|
|
776
766
|
get publicUrl() { return tunnelGate?.publicUrl ?? null; },
|
|
777
767
|
get tunnelToken() { return tunnelGate?.token ?? null; },
|
|
778
|
-
/** Called with the new public URL whenever the tunnel self-heals. */
|
|
779
|
-
onTunnelRebind(fn) { rebindHooks.push(fn); },
|
|
780
768
|
get tunnelError() { return tunnelError; },
|
|
781
769
|
};
|
|
782
770
|
}
|
package/lib/setup.js
CHANGED
|
@@ -195,17 +195,24 @@ export async function setupEditor(which, target) {
|
|
|
195
195
|
// cannot reach localhost), but we already have a rebind hook that rewrites
|
|
196
196
|
// and re-pins the config the moment a URL exists, so the honest design is:
|
|
197
197
|
// launch now with what we have, and follow the tunnel in when it arrives.
|
|
198
|
+
// BOUNDED WAIT, THEN LAUNCH REGARDLESS. The editor's server cannot reach
|
|
199
|
+
// localhost, so the public URL is what belongs in its config — but an
|
|
200
|
+
// unbounded wait is how this command ended up sitting on
|
|
201
|
+
// "waiting for the public tunnel....." and never opening the editor at all.
|
|
202
|
+
// With --edge-ip-version 4 the tunnel comes up in seconds; if it somehow
|
|
203
|
+
// does not, we say so plainly and still launch.
|
|
198
204
|
if (!publicUrl) {
|
|
199
|
-
|
|
200
|
-
for (let i = 0; i <
|
|
205
|
+
process.stdout.write('waiting for the public tunnel');
|
|
206
|
+
for (let i = 0; i < 180 && !started?.publicUrl; i++) { // up to 90s
|
|
201
207
|
await new Promise((r) => setTimeout(r, 500));
|
|
208
|
+
if (i % 4 === 3) process.stdout.write('.');
|
|
202
209
|
}
|
|
210
|
+
console.log('');
|
|
203
211
|
publicUrl = started?.publicUrl ?? null;
|
|
204
212
|
tunnelKey = started?.tunnelToken ?? tunnelKey;
|
|
205
213
|
}
|
|
206
214
|
if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 api_key ${tunnelKey}`);
|
|
207
|
-
else console.log('tunnel:
|
|
208
|
-
+ ' rewritten and re-pinned automatically the moment it is ready.');
|
|
215
|
+
else console.log('tunnel: did not come up in 90s.');
|
|
209
216
|
} else {
|
|
210
217
|
console.log(`proxy already running on ${base}`);
|
|
211
218
|
// A proxy someone else started owns the tunnel; ask it for the public URL.
|
|
@@ -218,8 +225,13 @@ export async function setupEditor(which, target) {
|
|
|
218
225
|
}
|
|
219
226
|
// What the EDITOR is configured with. Localhost only as a last resort, and
|
|
220
227
|
// said out loud, because it will fail with the private-networks error.
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
// NEVER WRITE LOCALHOST INTO THE EDITOR. Its server makes the request, so a
|
|
229
|
+
// private address is unreachable from there and every call returns "Access to
|
|
230
|
+
// private networks is forbidden". Writing it anyway does not degrade
|
|
231
|
+
// gracefully — it overwrites a config that may have been working with one
|
|
232
|
+
// that provably cannot. With no tunnel we leave the existing settings alone
|
|
233
|
+
// and say why.
|
|
234
|
+
const editorBase = publicUrl ? `${publicUrl}/v1` : null;
|
|
223
235
|
|
|
224
236
|
// 2. ENV INTO THE EDITOR. Both vendor shapes, so an OpenAI-compatible pane
|
|
225
237
|
// and an Anthropic-shaped one (Claude Code extension) both route here.
|
|
@@ -247,6 +259,12 @@ export async function setupEditor(which, target) {
|
|
|
247
259
|
// globalStorage sqlite — not an encrypted store, as previously assumed — so
|
|
248
260
|
// the "paste four things into Settings" ritual is unnecessary. Must happen
|
|
249
261
|
// while the editor is CLOSED or it rewrites them from memory on exit.
|
|
262
|
+
if (!editorBase) {
|
|
263
|
+
console.log('settings: NOT touched — a tunnel is required for the editor path and none came up.');
|
|
264
|
+
console.log(' (localhost cannot work here: the editor calls the endpoint from ITS server,');
|
|
265
|
+
console.log(' which answers "Access to private networks is forbidden".)');
|
|
266
|
+
console.log(' your previous settings are left as they were — re-run when the network settles.');
|
|
267
|
+
}
|
|
250
268
|
const picked0 = pickEditor(which);
|
|
251
269
|
const target0 = picked0?.which || which || 'cursor';
|
|
252
270
|
// Quit it FOR the user — a running editor reverts our write on exit.
|
|
@@ -257,7 +275,9 @@ export async function setupEditor(which, target) {
|
|
|
257
275
|
}
|
|
258
276
|
const models = await catalogModels(base);
|
|
259
277
|
let wrote = null;
|
|
260
|
-
|
|
278
|
+
if (editorBase) {
|
|
279
|
+
try { wrote = writeEditorProviderConfig(target0, { baseUrl: editorBase, models, apiKey: tunnelKey }); } catch (e) { wrote = { error: e.message }; }
|
|
280
|
+
}
|
|
261
281
|
if (wrote?.error) {
|
|
262
282
|
console.log(`settings: could not write automatically (${wrote.error}) — set them in Settings → Models`);
|
|
263
283
|
} else if (wrote) {
|
|
@@ -271,23 +291,6 @@ export async function setupEditor(which, target) {
|
|
|
271
291
|
? ' pinned -> yes (the editor re-syncs these from its account; a DB trigger re-applies them)'
|
|
272
292
|
: ` pinned -> NO (${pin?.error || 'unavailable'}) — the editor may revert these on launch`);
|
|
273
293
|
console.log(' verify: send one message, watch for "paid $0.0… · rail solana · tx …" here.');
|
|
274
|
-
// FOLLOW THE TUNNEL. A quick tunnel is ephemeral; when it dies the proxy
|
|
275
|
-
// brings up a new one with a NEW hostname, and an editor still holding the
|
|
276
|
-
// old URL reconnect-loops against a dead host forever (measured: cloudflare
|
|
277
|
-
// 530 / error 1033 on every request). Re-write and re-pin on every rebind so
|
|
278
|
-
// the editor follows without the user restarting anything.
|
|
279
|
-
started?.onTunnelRebind?.(async (next) => {
|
|
280
|
-
const base2 = `${next}/v1`;
|
|
281
|
-
try {
|
|
282
|
-
unpinEditorProviderConfig(target0);
|
|
283
|
-
writeEditorProviderConfig(target0, { baseUrl: base2, models, apiKey: tunnelKey });
|
|
284
|
-
pinEditorProviderConfig(target0, { baseUrl: base2, models });
|
|
285
|
-
console.log(`\nsettings: tunnel healed — openAIBaseUrl -> ${base2}`);
|
|
286
|
-
console.log(' (reload the editor window if a request was in flight)');
|
|
287
|
-
} catch (e) {
|
|
288
|
-
console.log(`\nsettings: tunnel healed to ${base2} but the config rewrite failed (${e.message})`);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
294
|
}
|
|
292
295
|
|
|
293
296
|
// 2b. THE BACKEND BLOCK. Pinning the database is not sufficient on its own:
|
package/lib/tunnel.js
CHANGED
|
@@ -91,117 +91,18 @@ export async function ensureCloudflared(log = console.log) {
|
|
|
91
91
|
return cached;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/**
|
|
95
|
-
* TUNNEL RUNGS, tried in order until one actually SERVES.
|
|
96
|
-
*
|
|
97
|
-
* MEASURED FAILURE: cloudflared came up, printed a URL, and held a single IPv6
|
|
98
|
-
* socket to :443 with NO connection on 7844 — registration never completed — so
|
|
99
|
-
* the edge answered every request with 530 / error 1033 "unable to reach origin"
|
|
100
|
-
* while the process looked perfectly healthy. The URL had already been written
|
|
101
|
-
* into the editor, which then reconnect-looped forever against a dead host.
|
|
102
|
-
*
|
|
103
|
-
* Two lessons, both encoded below: a printed URL is NOT a working tunnel (probe
|
|
104
|
-
* it), and the default QUIC/auto-edge path can fail in a way that a different
|
|
105
|
-
* edge-IP or protocol survives — so cascade instead of giving up.
|
|
106
|
-
*/
|
|
107
|
-
const TUNNEL_RUNGS = [
|
|
108
|
-
// IPv4 FIRST, deliberately. cloudflared prefers IPv6+QUIC to reach the edge
|
|
109
|
-
// whenever the interface merely HAS an IPv6 address — it never checks the
|
|
110
|
-
// route is usable. MEASURED on a box with an IPv6 address and no IPv6 route:
|
|
111
|
-
// failed to dial to edge with quic: write udp6 [::]->[2606:4700:a8::1]:7844:
|
|
112
|
-
// sendmsg: no route to host
|
|
113
|
-
// ...on every retry, so the tunnel registered and immediately died and the
|
|
114
|
-
// edge served 502/530 — which looks exactly like a dead origin or a rate
|
|
115
|
-
// limit, and is neither. Forcing v4 registered first try on the same machine.
|
|
116
|
-
{ args: ['--edge-ip-version', '4'], why: 'ipv4 edge' },
|
|
117
|
-
{ args: [], why: 'default (quic, auto edge ip)' },
|
|
118
|
-
{ args: ['--edge-ip-version', '4', '--protocol', 'http2'], why: 'ipv4 + http2' },
|
|
119
|
-
{ args: ['--protocol', 'http2'], why: 'http2 (quic/udp blocked or lossy)' },
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Does this public URL actually reach OUR proxy? A 200 from /v1/models is proof
|
|
124
|
-
* end to end; 530/1033 is the edge saying it cannot reach the origin. Retries
|
|
125
|
-
* because a fresh tunnel takes a few seconds to propagate.
|
|
126
|
-
*/
|
|
127
|
-
export async function probeTunnel(url, { tries = 8, gapMs = 2500 } = {}) {
|
|
128
|
-
for (let i = 0; i < tries; i++) {
|
|
129
|
-
try {
|
|
130
|
-
const r = await fetch(`${url}/v1/models`, { signal: AbortSignal.timeout(10000) });
|
|
131
|
-
if (r.ok) return true;
|
|
132
|
-
} catch { /* not up yet */ }
|
|
133
|
-
if (i < tries - 1) await new Promise((r) => setTimeout(r, gapMs));
|
|
134
|
-
}
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Bring up a tunnel that is VERIFIED to serve, walking the rungs above.
|
|
140
|
-
* Returns { url, proc, rung } or throws when every rung fails.
|
|
141
|
-
*/
|
|
142
|
-
export async function startHealthyTunnel(bin, port, log = () => {}) {
|
|
143
|
-
// ONE AT A TIME, best rung first. An earlier version raced all four at once;
|
|
144
|
-
// that is four quick tunnels per invocation against a per-IP-limited free
|
|
145
|
-
// service, which is a good way to get throttled into exactly the failure it
|
|
146
|
-
// was meant to dodge. Rung 1 (ipv4) is the measured winner on this network,
|
|
147
|
-
// so the common path costs one tunnel.
|
|
148
|
-
let lastErr = null;
|
|
149
|
-
for (const rung of TUNNEL_RUNGS) {
|
|
150
|
-
let started = null;
|
|
151
|
-
try {
|
|
152
|
-
started = await startCloudflared(bin, port, log, { args: rung.args });
|
|
153
|
-
} catch (e) { lastErr = e; log(`tunnel: ${rung.why} — ${e.message}`); continue; }
|
|
154
|
-
if (await probeTunnel(started.url, { tries: 5, gapMs: 2000 })) {
|
|
155
|
-
log(`tunnel: up via ${rung.why}`);
|
|
156
|
-
return { ...started, rung: rung.why };
|
|
157
|
-
}
|
|
158
|
-
log(`tunnel: ${rung.why} registered but the edge could not reach it — next`);
|
|
159
|
-
try { started.proc.kill(); } catch { /* already gone */ }
|
|
160
|
-
lastErr = new Error(`${rung.why}: edge could not reach the origin`);
|
|
161
|
-
}
|
|
162
|
-
throw lastErr || new Error('no cloudflared configuration produced a working tunnel');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Watch a live tunnel and rebuild it when it dies. Quick tunnels are ephemeral;
|
|
167
|
-
* when one drops, the editor is left pointed at a dead host and reconnect-loops,
|
|
168
|
-
* so onRebind is how the caller re-writes the new URL wherever it was published.
|
|
169
|
-
* Returns a stop() function.
|
|
170
|
-
*/
|
|
171
|
-
export function superviseTunnel({ bin, port, url, proc, log = () => {}, onRebind, everyMs = 30000 }) {
|
|
172
|
-
let current = { url, proc };
|
|
173
|
-
let stopped = false;
|
|
174
|
-
let healing = false;
|
|
175
|
-
const timer = setInterval(async () => {
|
|
176
|
-
if (stopped || healing) return;
|
|
177
|
-
// One quick probe; a single blip is not a death sentence.
|
|
178
|
-
if (await probeTunnel(current.url, { tries: 2, gapMs: 3000 })) return;
|
|
179
|
-
healing = true;
|
|
180
|
-
log('tunnel: went unreachable — self-healing');
|
|
181
|
-
try { current.proc?.kill(); } catch { /* already gone */ }
|
|
182
|
-
try {
|
|
183
|
-
const next = await startHealthyTunnel(bin, port, log);
|
|
184
|
-
current = { url: next.url, proc: next.proc };
|
|
185
|
-
log(`tunnel: healed -> ${next.url}`);
|
|
186
|
-
await onRebind?.(next.url);
|
|
187
|
-
} catch (e) {
|
|
188
|
-
log(`tunnel: could not heal (${e.message}) — localhost is unaffected; will retry`);
|
|
189
|
-
} finally { healing = false; }
|
|
190
|
-
}, everyMs);
|
|
191
|
-
timer.unref?.();
|
|
192
|
-
return () => { stopped = true; clearInterval(timer); };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
94
|
/** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
|
|
196
|
-
export function startCloudflared(bin, port, log
|
|
95
|
+
export function startCloudflared(bin, port, log) {
|
|
197
96
|
return new Promise((resolve, reject) => {
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
97
|
+
// --edge-ip-version 4: cloudflared prefers IPv6+QUIC to reach the edge
|
|
98
|
+
// whenever the interface merely HAS an IPv6 address — it never checks the
|
|
99
|
+
// route works. MEASURED here (address present, no route):
|
|
100
|
+
// failed to dial to edge with quic: write udp6 [::]->[2606:4700:a8::1]:7844:
|
|
101
|
+
// sendmsg: no route to host
|
|
102
|
+
// ...every retry, so the tunnel registered then died and the edge served
|
|
103
|
+
// 502/530 — which reads exactly like a dead origin or a rate limit, and is
|
|
104
|
+
// neither. Forcing v4 registered first try on the same machine.
|
|
105
|
+
const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate', '--edge-ip-version', '4'], {
|
|
205
106
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
206
107
|
});
|
|
207
108
|
let settled = false;
|
|
@@ -213,8 +114,7 @@ export function startCloudflared(bin, port, log, { args = [] } = {}) {
|
|
|
213
114
|
proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
|
|
214
115
|
proc.on('error', reject);
|
|
215
116
|
proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
|
|
216
|
-
|
|
217
|
-
setTimeout(() => { if (!settled) { try { proc.kill(); } catch { /* gone */ } reject(new Error(`cloudflared did not produce a URL in ${urlWaitMs}ms`)); } }, urlWaitMs);
|
|
117
|
+
setTimeout(() => { if (!settled) reject(new Error('cloudflared did not produce a URL in 60s')); }, 60000);
|
|
218
118
|
});
|
|
219
119
|
}
|
|
220
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.5",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|