openzoo 0.20.8 → 0.21.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/lib/proxy.js +14 -2
- package/lib/setup.js +26 -3
- package/lib/tunnel.js +104 -3
- package/package.json +1 -1
package/lib/proxy.js
CHANGED
|
@@ -285,6 +285,7 @@ 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 = [];
|
|
288
289
|
let tunnelError = null;
|
|
289
290
|
|
|
290
291
|
const server = http.createServer(async (req, res) => {
|
|
@@ -730,12 +731,21 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
730
731
|
if (autoTunnel && process.env.OPENZOO_NO_TUNNEL !== '1') {
|
|
731
732
|
(async () => {
|
|
732
733
|
try {
|
|
733
|
-
const { ensureCloudflared,
|
|
734
|
+
const { ensureCloudflared, startHealthyTunnel, superviseTunnel, mintToken } = await import('./tunnel.js');
|
|
734
735
|
const token = mintToken();
|
|
735
736
|
const cap = process.env.OPENZOO_TUNNEL_MAX_USD ? Number(process.env.OPENZOO_TUNNEL_MAX_USD) : Infinity;
|
|
736
737
|
const bin = await ensureCloudflared((m) => log(m));
|
|
737
|
-
|
|
738
|
+
// VERIFIED, not merely printed — and supervised, because a quick tunnel
|
|
739
|
+
// that dies leaves every configured client reconnect-looping at a dead host.
|
|
740
|
+
const { url, proc } = await startHealthyTunnel(bin, config.port, log);
|
|
738
741
|
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
|
+
});
|
|
739
749
|
const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
|
|
740
750
|
process.once('SIGINT', () => { bye(); process.exit(0); });
|
|
741
751
|
process.once('SIGTERM', () => { bye(); process.exit(0); });
|
|
@@ -765,6 +775,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
765
775
|
spent: () => sessionSpent,
|
|
766
776
|
get publicUrl() { return tunnelGate?.publicUrl ?? null; },
|
|
767
777
|
get tunnelToken() { return tunnelGate?.token ?? null; },
|
|
778
|
+
/** Called with the new public URL whenever the tunnel self-heals. */
|
|
779
|
+
onTunnelRebind(fn) { rebindHooks.push(fn); },
|
|
768
780
|
get tunnelError() { return tunnelError; },
|
|
769
781
|
};
|
|
770
782
|
}
|
package/lib/setup.js
CHANGED
|
@@ -20,7 +20,10 @@ import os from 'node:os';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { config } from './config.js';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
writeEditorProviderConfig, editorRunning, quitEditor,
|
|
25
|
+
pinEditorProviderConfig, unpinEditorProviderConfig,
|
|
26
|
+
} from './cursorcfg.js';
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Models offered in the editor's picker — named the way the EDITOR names them.
|
|
@@ -163,10 +166,13 @@ export async function setupEditor(which, target) {
|
|
|
163
166
|
// harness must use; we surface it rather than leaving them to find it.
|
|
164
167
|
let publicUrl = null;
|
|
165
168
|
let tunnelKey = null;
|
|
169
|
+
// Declared out here: the tunnel-rebind hook is registered further down, well
|
|
170
|
+
// outside the block that starts the proxy.
|
|
171
|
+
let started = null;
|
|
166
172
|
if (!(await proxyUp(base))) {
|
|
167
173
|
console.log(`starting proxy on ${base} (+ public tunnel)...`);
|
|
168
174
|
const { startProxy } = await import('./proxy.js');
|
|
169
|
-
|
|
175
|
+
started = await startProxy({ silent: true, autoTunnel: true });
|
|
170
176
|
publicUrl = started?.publicUrl ?? null;
|
|
171
177
|
tunnelKey = started?.tunnelToken ?? null;
|
|
172
178
|
// DO NOT BLOCK ON THE TUNNEL. Everything below (settings, MCP, launching
|
|
@@ -184,7 +190,7 @@ export async function setupEditor(which, target) {
|
|
|
184
190
|
// an unbounded wait once left the editor never launching at all.
|
|
185
191
|
if (!publicUrl) {
|
|
186
192
|
process.stdout.write('waiting for the public tunnel (the editor\'s server cannot reach localhost)');
|
|
187
|
-
for (let i = 0; i <
|
|
193
|
+
for (let i = 0; i < 240 && !started?.publicUrl; i++) { // up to 120s (rungs race; first to SERVE wins)
|
|
188
194
|
await new Promise((r) => setTimeout(r, 500));
|
|
189
195
|
if (i % 4 === 3) process.stdout.write('.');
|
|
190
196
|
}
|
|
@@ -259,6 +265,23 @@ export async function setupEditor(which, target) {
|
|
|
259
265
|
? ' pinned -> yes (the editor re-syncs these from its account; a DB trigger re-applies them)'
|
|
260
266
|
: ` pinned -> NO (${pin?.error || 'unavailable'}) — the editor may revert these on launch`);
|
|
261
267
|
console.log(' verify: send one message, watch for "paid $0.0… · rail solana · tx …" here.');
|
|
268
|
+
// FOLLOW THE TUNNEL. A quick tunnel is ephemeral; when it dies the proxy
|
|
269
|
+
// brings up a new one with a NEW hostname, and an editor still holding the
|
|
270
|
+
// old URL reconnect-loops against a dead host forever (measured: cloudflare
|
|
271
|
+
// 530 / error 1033 on every request). Re-write and re-pin on every rebind so
|
|
272
|
+
// the editor follows without the user restarting anything.
|
|
273
|
+
started?.onTunnelRebind?.(async (next) => {
|
|
274
|
+
const base2 = `${next}/v1`;
|
|
275
|
+
try {
|
|
276
|
+
unpinEditorProviderConfig(target0);
|
|
277
|
+
writeEditorProviderConfig(target0, { baseUrl: base2, models, apiKey: tunnelKey });
|
|
278
|
+
pinEditorProviderConfig(target0, { baseUrl: base2, models });
|
|
279
|
+
console.log(`\nsettings: tunnel healed — openAIBaseUrl -> ${base2}`);
|
|
280
|
+
console.log(' (reload the editor window if a request was in flight)');
|
|
281
|
+
} catch (e) {
|
|
282
|
+
console.log(`\nsettings: tunnel healed to ${base2} but the config rewrite failed (${e.message})`);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
262
285
|
}
|
|
263
286
|
|
|
264
287
|
// 2b. THE BACKEND BLOCK. Pinning the database is not sufficient on its own:
|
package/lib/tunnel.js
CHANGED
|
@@ -91,10 +91,110 @@ 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
|
+
{ args: [], why: 'default (quic, auto edge ip)' },
|
|
109
|
+
{ args: ['--edge-ip-version', '4'], why: 'ipv4 edge (the ipv6 path failed to register)' },
|
|
110
|
+
{ args: ['--protocol', 'http2'], why: 'http2 (quic/udp blocked or lossy)' },
|
|
111
|
+
{ args: ['--edge-ip-version', '4', '--protocol', 'http2'], why: 'ipv4 + http2 (most conservative)' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Does this public URL actually reach OUR proxy? A 200 from /v1/models is proof
|
|
116
|
+
* end to end; 530/1033 is the edge saying it cannot reach the origin. Retries
|
|
117
|
+
* because a fresh tunnel takes a few seconds to propagate.
|
|
118
|
+
*/
|
|
119
|
+
export async function probeTunnel(url, { tries = 8, gapMs = 2500 } = {}) {
|
|
120
|
+
for (let i = 0; i < tries; i++) {
|
|
121
|
+
try {
|
|
122
|
+
const r = await fetch(`${url}/v1/models`, { signal: AbortSignal.timeout(10000) });
|
|
123
|
+
if (r.ok) return true;
|
|
124
|
+
} catch { /* not up yet */ }
|
|
125
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, gapMs));
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Bring up a tunnel that is VERIFIED to serve, walking the rungs above.
|
|
132
|
+
* Returns { url, proc, rung } or throws when every rung fails.
|
|
133
|
+
*/
|
|
134
|
+
export async function startHealthyTunnel(bin, port, log = () => {}) {
|
|
135
|
+
// RACE, DO NOT WALK. Tried serially, four rungs cost up to 60s waiting for a
|
|
136
|
+
// URL plus ~20s of probing EACH — minutes of "waiting for the public tunnel"
|
|
137
|
+
// before the good one is even attempted, and the caller times out first.
|
|
138
|
+
// Quick tunnels are free and independent, so all rungs are started at once and
|
|
139
|
+
// the first one that actually SERVES wins; the losers are killed. Worst case
|
|
140
|
+
// becomes one rung's latency instead of four.
|
|
141
|
+
const losers = [];
|
|
142
|
+
let settled = false;
|
|
143
|
+
const attempts = TUNNEL_RUNGS.map(async (rung) => {
|
|
144
|
+
const started = await startCloudflared(bin, port, log, { args: rung.args });
|
|
145
|
+
losers.push(started.proc);
|
|
146
|
+
const ok = await probeTunnel(started.url, { tries: 6, gapMs: 2000 });
|
|
147
|
+
if (!ok) throw new Error(`${rung.why}: printed ${started.url} but the edge could not reach it`);
|
|
148
|
+
if (settled) throw new Error('another rung won');
|
|
149
|
+
settled = true;
|
|
150
|
+
return { ...started, rung: rung.why };
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
const winner = await Promise.any(attempts);
|
|
154
|
+
log(`tunnel: up via ${winner.rung}`);
|
|
155
|
+
for (const p of losers) { if (p !== winner.proc) { try { p.kill(); } catch { /* gone */ } } }
|
|
156
|
+
return winner;
|
|
157
|
+
} catch (agg) {
|
|
158
|
+
for (const p of losers) { try { p.kill(); } catch { /* gone */ } }
|
|
159
|
+
const why = agg?.errors?.map((e) => e.message).join(' | ') || agg?.message || 'unknown';
|
|
160
|
+
throw new Error(`no cloudflared configuration produced a working tunnel (${why})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Watch a live tunnel and rebuild it when it dies. Quick tunnels are ephemeral;
|
|
166
|
+
* when one drops, the editor is left pointed at a dead host and reconnect-loops,
|
|
167
|
+
* so onRebind is how the caller re-writes the new URL wherever it was published.
|
|
168
|
+
* Returns a stop() function.
|
|
169
|
+
*/
|
|
170
|
+
export function superviseTunnel({ bin, port, url, proc, log = () => {}, onRebind, everyMs = 30000 }) {
|
|
171
|
+
let current = { url, proc };
|
|
172
|
+
let stopped = false;
|
|
173
|
+
let healing = false;
|
|
174
|
+
const timer = setInterval(async () => {
|
|
175
|
+
if (stopped || healing) return;
|
|
176
|
+
// One quick probe; a single blip is not a death sentence.
|
|
177
|
+
if (await probeTunnel(current.url, { tries: 2, gapMs: 3000 })) return;
|
|
178
|
+
healing = true;
|
|
179
|
+
log('tunnel: went unreachable — self-healing');
|
|
180
|
+
try { current.proc?.kill(); } catch { /* already gone */ }
|
|
181
|
+
try {
|
|
182
|
+
const next = await startHealthyTunnel(bin, port, log);
|
|
183
|
+
current = { url: next.url, proc: next.proc };
|
|
184
|
+
log(`tunnel: healed -> ${next.url}`);
|
|
185
|
+
await onRebind?.(next.url);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log(`tunnel: could not heal (${e.message}) — localhost is unaffected; will retry`);
|
|
188
|
+
} finally { healing = false; }
|
|
189
|
+
}, everyMs);
|
|
190
|
+
timer.unref?.();
|
|
191
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
192
|
+
}
|
|
193
|
+
|
|
94
194
|
/** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
|
|
95
|
-
export function startCloudflared(bin, port, log) {
|
|
195
|
+
export function startCloudflared(bin, port, log, { args = [] } = {}) {
|
|
96
196
|
return new Promise((resolve, reject) => {
|
|
97
|
-
const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
|
|
197
|
+
const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate', ...args], {
|
|
98
198
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
199
|
});
|
|
100
200
|
let settled = false;
|
|
@@ -106,7 +206,8 @@ export function startCloudflared(bin, port, log) {
|
|
|
106
206
|
proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
|
|
107
207
|
proc.on('error', reject);
|
|
108
208
|
proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
|
|
109
|
-
|
|
209
|
+
const urlWaitMs = Number(process.env.OPENZOO_TUNNEL_URL_WAIT_MS || 25000);
|
|
210
|
+
setTimeout(() => { if (!settled) { try { proc.kill(); } catch { /* gone */ } reject(new Error(`cloudflared did not produce a URL in ${urlWaitMs}ms`)); } }, urlWaitMs);
|
|
110
211
|
});
|
|
111
212
|
}
|
|
112
213
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
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",
|