openzoo 0.21.2 → 0.21.4

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 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, startHealthyTunnel, superviseTunnel, mintToken } = await import('./tunnel.js');
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
- // 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);
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
@@ -188,9 +188,22 @@ export async function setupEditor(which, target) {
188
188
  // (see the header of lib/tunnel.js). So the public URL is a HARD dependency
189
189
  // of the editor path, and we wait for it — bounded, with progress, because
190
190
  // an unbounded wait once left the editor never launching at all.
191
+ // NEVER BLOCK THE LAUNCH ON THE TUNNEL. Waiting inline is what made this
192
+ // command sit on "waiting for the public tunnel....." with nothing else
193
+ // happening — and if the tunnel was slow or failing, the editor never opened
194
+ // at all. The tunnel is genuinely required for the EDITOR path (its server
195
+ // cannot reach localhost), but we already have a rebind hook that rewrites
196
+ // and re-pins the config the moment a URL exists, so the honest design is:
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.
191
204
  if (!publicUrl) {
192
- process.stdout.write('waiting for the public tunnel (the editor\'s server cannot reach localhost)');
193
- for (let i = 0; i < 240 && !started?.publicUrl; i++) { // up to 120s (rungs race; first to SERVE wins)
205
+ process.stdout.write('waiting for the public tunnel');
206
+ for (let i = 0; i < 60 && !started?.publicUrl; i++) { // up to 30s
194
207
  await new Promise((r) => setTimeout(r, 500));
195
208
  if (i % 4 === 3) process.stdout.write('.');
196
209
  }
@@ -199,7 +212,9 @@ export async function setupEditor(which, target) {
199
212
  tunnelKey = started?.tunnelToken ?? tunnelKey;
200
213
  }
201
214
  if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 api_key ${tunnelKey}`);
202
- else console.log('tunnel: NOT up — the editor will not be able to reach the proxy\n (OPENZOO_NO_TUNNEL=1 to skip; terminal harnesses still work on localhost)');
215
+ else console.log('tunnel: did not come up in 30s launching anyway on localhost;\n'
216
+ + ' the editor will say "Access to private networks is forbidden"\n'
217
+ + ' until you re-run this command.');
203
218
  } else {
204
219
  console.log(`proxy already running on ${base}`);
205
220
  // A proxy someone else started owns the tunnel; ask it for the public URL.
@@ -265,23 +280,6 @@ export async function setupEditor(which, target) {
265
280
  ? ' pinned -> yes (the editor re-syncs these from its account; a DB trigger re-applies them)'
266
281
  : ` pinned -> NO (${pin?.error || 'unavailable'}) — the editor may revert these on launch`);
267
282
  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
- });
285
283
  }
286
284
 
287
285
  // 2b. THE BACKEND BLOCK. Pinning the database is not sufficient on its own:
package/lib/tunnel.js CHANGED
@@ -91,124 +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
- // RACE, DO NOT WALK. Tried serially, four rungs cost up to 60s waiting for a
144
- // URL plus ~20s of probing EACH — minutes of "waiting for the public tunnel"
145
- // before the good one is even attempted, and the caller times out first.
146
- // Quick tunnels are free and independent, so all rungs are started at once and
147
- // the first one that actually SERVES wins; the losers are killed. Worst case
148
- // becomes one rung's latency instead of four.
149
- const losers = [];
150
- let settled = false;
151
- const attempts = TUNNEL_RUNGS.map(async (rung) => {
152
- const started = await startCloudflared(bin, port, log, { args: rung.args });
153
- losers.push(started.proc);
154
- const ok = await probeTunnel(started.url, { tries: 6, gapMs: 2000 });
155
- if (!ok) throw new Error(`${rung.why}: printed ${started.url} but the edge could not reach it`);
156
- if (settled) throw new Error('another rung won');
157
- settled = true;
158
- return { ...started, rung: rung.why };
159
- });
160
- try {
161
- const winner = await Promise.any(attempts);
162
- log(`tunnel: up via ${winner.rung}`);
163
- for (const p of losers) { if (p !== winner.proc) { try { p.kill(); } catch { /* gone */ } } }
164
- return winner;
165
- } catch (agg) {
166
- for (const p of losers) { try { p.kill(); } catch { /* gone */ } }
167
- const why = agg?.errors?.map((e) => e.message).join(' | ') || agg?.message || 'unknown';
168
- throw new Error(`no cloudflared configuration produced a working tunnel (${why})`);
169
- }
170
- }
171
-
172
- /**
173
- * Watch a live tunnel and rebuild it when it dies. Quick tunnels are ephemeral;
174
- * when one drops, the editor is left pointed at a dead host and reconnect-loops,
175
- * so onRebind is how the caller re-writes the new URL wherever it was published.
176
- * Returns a stop() function.
177
- */
178
- export function superviseTunnel({ bin, port, url, proc, log = () => {}, onRebind, everyMs = 30000 }) {
179
- let current = { url, proc };
180
- let stopped = false;
181
- let healing = false;
182
- const timer = setInterval(async () => {
183
- if (stopped || healing) return;
184
- // One quick probe; a single blip is not a death sentence.
185
- if (await probeTunnel(current.url, { tries: 2, gapMs: 3000 })) return;
186
- healing = true;
187
- log('tunnel: went unreachable — self-healing');
188
- try { current.proc?.kill(); } catch { /* already gone */ }
189
- try {
190
- const next = await startHealthyTunnel(bin, port, log);
191
- current = { url: next.url, proc: next.proc };
192
- log(`tunnel: healed -> ${next.url}`);
193
- await onRebind?.(next.url);
194
- } catch (e) {
195
- log(`tunnel: could not heal (${e.message}) — localhost is unaffected; will retry`);
196
- } finally { healing = false; }
197
- }, everyMs);
198
- timer.unref?.();
199
- return () => { stopped = true; clearInterval(timer); };
200
- }
201
-
202
94
  /** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
203
- export function startCloudflared(bin, port, log, { args = [] } = {}) {
95
+ export function startCloudflared(bin, port, log) {
204
96
  return new Promise((resolve, reject) => {
205
- // 127.0.0.1, NOT localhost. MEASURED: the proxy binds IPv4 only, while
206
- // `localhost` resolves to ::1 FIRST on macOScloudflared dialled the IPv6
207
- // loopback, got connection-refused, and reported "Unable to reach the origin
208
- // service" (502) on a tunnel that had registered with Cloudflare perfectly.
209
- // Every rung failed identically, which is what made it look like a network
210
- // or rate-limit problem when it was a loopback address-family mismatch.
211
- const proc = spawn(bin, ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate', ...args], {
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'], {
212
106
  stdio: ['ignore', 'pipe', 'pipe'],
213
107
  });
214
108
  let settled = false;
@@ -220,8 +114,7 @@ export function startCloudflared(bin, port, log, { args = [] } = {}) {
220
114
  proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
221
115
  proc.on('error', reject);
222
116
  proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
223
- const urlWaitMs = Number(process.env.OPENZOO_TUNNEL_URL_WAIT_MS || 25000);
224
- 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);
225
118
  });
226
119
  }
227
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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",