esque-bridge 0.6.0 → 0.6.2

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.
Files changed (2) hide show
  1. package/index.js +161 -13
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -584,14 +584,103 @@ function openPreviewTunnel(port) {
584
584
  });
585
585
  }
586
586
 
587
+ // Strip ANSI color codes bundlers embed in their error output.
588
+ function stripAnsi(s) {
589
+ return String(s).replace(/\[[0-9;]*m/g, '');
590
+ }
591
+
592
+ // Reduce a bundler error (Metro's JSON payload, or raw stderr text) to a
593
+ // short, human-readable summary: the first few meaningful lines, no stack
594
+ // trace, no code frame, no ANSI, project paths shortened. This is what the
595
+ // phone shows in place of a white screen, so keep it tight.
596
+ function summarizeBuildError(status, body) {
597
+ let msg = '';
598
+ try {
599
+ const j = JSON.parse(body);
600
+ msg = j.message || (j.errors && j.errors[0] && j.errors[0].description) || '';
601
+ } catch {
602
+ msg = body;
603
+ }
604
+ msg = stripAnsi(msg).trim();
605
+ const head = [];
606
+ for (const l of msg.split('\n').map((x) => x.trimEnd()).filter(Boolean)) {
607
+ if (/^\s*at\s/.test(l)) break; // stack trace
608
+ if (/^\s*>?\s*\d+\s*\|/.test(l)) break; // code frame ("> 1 |")
609
+ head.push(l);
610
+ if (head.length >= 5) break;
611
+ }
612
+ let out = head.join('\n').split(`${WORKDIR}/`).join('').trim();
613
+ if (out.length > 600) out = out.slice(0, 600) + '…';
614
+ return out || `Build failed (HTTP ${status}).`;
615
+ }
616
+
617
+ // Fallback for compile errors an HTTP probe can't see (e.g. Vite/Next inject
618
+ // an in-page overlay but still log the failure to stderr): scan the captured
619
+ // dev-server output for a known failure signature.
620
+ function scanOutputForError(out) {
621
+ if (!out) return null;
622
+ const lines = stripAnsi(out).split('\n');
623
+ const sig =
624
+ /(unable to resolve|cannot find module|module not found|failed to compile|could not resolve|\bERROR in\b|SyntaxError|Transform failed|\[ERROR\])/i;
625
+ for (let i = 0; i < lines.length; i++) {
626
+ if (sig.test(lines[i])) {
627
+ let s = lines.slice(i, i + 4).map((l) => l.trim()).filter(Boolean).join('\n');
628
+ if (s.length > 600) s = s.slice(0, 600) + '…';
629
+ return s.split(`${WORKDIR}/`).join('');
630
+ }
631
+ }
632
+ return null;
633
+ }
634
+
635
+ // Fetch a path on the local dev server with a hard timeout.
636
+ async function fetchLocal(port, path, ms) {
637
+ const ctrl = new AbortController();
638
+ const timer = setTimeout(() => ctrl.abort(), ms);
639
+ try {
640
+ const r = await fetch(`http://127.0.0.1:${port}${path}`, { signal: ctrl.signal });
641
+ return { status: r.status, body: await r.text() };
642
+ } catch {
643
+ return null;
644
+ } finally {
645
+ clearTimeout(timer);
646
+ }
647
+ }
648
+
649
+ // Probe a freshly-started dev server for a build failure that would otherwise
650
+ // render as a blank screen on the phone. Metro builds the web bundle lazily,
651
+ // so we trigger it ourselves and catch fast failures (missing deps, syntax
652
+ // errors return in <2s) with a short timeout — we deliberately DON'T wait out
653
+ // a slow-but-healthy compile. Returns a short error string, or null if it
654
+ // looks fine / is still compiling.
655
+ async function probeBuildError(port) {
656
+ const root = await fetchLocal(port, '/', 5000);
657
+ if (!root) return null; // couldn't reach it — don't cry wolf
658
+ if (root.status >= 500) return summarizeBuildError(root.status, root.body);
659
+
660
+ // Expo/Metro: the HTML points at a *.bundle script. Fetching it forces the
661
+ // compile and surfaces the react-native-web class of error as a 500.
662
+ const m = root.body.match(/<script[^>]+src="([^"]*\.bundle[^"]*)"/i);
663
+ if (m) {
664
+ const b = await fetchLocal(port, m[1].replace(/&amp;/g, '&'), 5000);
665
+ if (b && b.status >= 500) return summarizeBuildError(b.status, b.body);
666
+ }
667
+
668
+ return scanOutputForError(preview && preview.output);
669
+ }
670
+
587
671
  async function startPreview(cmd, port) {
588
672
  killPreview();
589
673
  console.log(`[preview] starting: ${cmd} (port ${port})`);
590
674
  const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
591
- proc.stdout.on('data', (d) => process.stdout.write(`[preview] ${d}`));
592
- proc.stderr.on('data', (d) => process.stdout.write(`[preview] ${d}`));
675
+ preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null, output: '', buildError: null };
676
+ // Keep a rolling tail of output so the error scanner has something to read.
677
+ const capture = (d) => {
678
+ process.stdout.write(`[preview] ${d}`);
679
+ if (preview) preview.output = (preview.output + d).slice(-16000);
680
+ };
681
+ proc.stdout.on('data', capture);
682
+ proc.stderr.on('data', capture);
593
683
  proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
594
- preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null };
595
684
 
596
685
  const up = await waitForPort(port, 60000);
597
686
  if (!up) { console.log('[preview] dev server never opened the port'); return null; }
@@ -603,7 +692,14 @@ async function startPreview(cmd, port) {
603
692
  preview.tunnel = t.tunnel || null;
604
693
  preview.tunnelProc = t.tunnelProc || null;
605
694
  console.log(`[preview] live at ${t.url} (${t.kind})`);
606
- return t.url;
695
+
696
+ // Before handing the URL to the phone, make sure the page will actually
697
+ // render — a dev server can be "up" while its bundle fails to compile.
698
+ const buildError = await probeBuildError(port);
699
+ if (preview) preview.buildError = buildError;
700
+ if (buildError) console.error(`[preview] ⚠ web build is failing:\n${buildError}`);
701
+
702
+ return { url: t.url, buildError };
607
703
  }
608
704
 
609
705
  // Tolerate leading markdown punctuation / code-fence backticks the agent
@@ -619,11 +715,23 @@ async function applyPreview(text) {
619
715
  if (!m) return text;
620
716
  const cmd = m[1].trim();
621
717
  const port = Number(m[2]);
622
- let url = null;
623
- try { url = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
624
- const replacement = url
625
- ? `🔗 Live preview: ${url}`
626
- : `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
718
+ let result = null;
719
+ try { result = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
720
+ let replacement;
721
+ if (!result) {
722
+ replacement = `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
723
+ } else if (result.buildError) {
724
+ // Surface the failure as plain text WITHOUT the URL — the app turns any
725
+ // URL in a message into a "Preview Build" pill, and we don't want a pill
726
+ // that just opens a blank page. The error tells the user (and the agent,
727
+ // who sees this reply) exactly what to fix.
728
+ replacement =
729
+ `⚠️ Preview build failed — the web bundle didn't compile:\n\n` +
730
+ `${result.buildError}\n\n` +
731
+ `Fix that and I'll preview again. (Dev server is still running locally on port ${port}.)`;
732
+ } else {
733
+ replacement = `🔗 Live preview: ${result.url}`;
734
+ }
627
735
  return text.replace(PREVIEW_RE, replacement);
628
736
  }
629
737
 
@@ -778,6 +886,43 @@ function confirmWorkdir(dir) {
778
886
  });
779
887
  }
780
888
 
889
+ // Bind the local HTTP server to the first free port at/after `startPort`.
890
+ // EADDRINUSE on the default port almost always means a previous esque-bridge
891
+ // is still running (or another app grabbed 3030). Rather than crash with a raw
892
+ // Node stack trace, we step to the next port and tell the user. The pairing
893
+ // URL travels over the tunnel, so the exact local port doesn't matter to the
894
+ // app. Returns { server, port } for the port we actually bound.
895
+ function listenOnFreePort(app, startPort, maxTries = 25) {
896
+ return new Promise((resolve, reject) => {
897
+ let attempts = 0;
898
+ const attempt = (port) => {
899
+ const server = app.listen(port);
900
+ const onError = (err) => {
901
+ server.removeListener('listening', onListening);
902
+ if (err && err.code === 'EADDRINUSE' && attempts < maxTries) {
903
+ if (attempts === 0) {
904
+ console.error(
905
+ `\n Port ${startPort} is busy — another Esque bridge may already be running.`,
906
+ );
907
+ console.error(' Stepping to the next free port…');
908
+ }
909
+ attempts += 1;
910
+ attempt(port + 1);
911
+ } else {
912
+ reject(err);
913
+ }
914
+ };
915
+ const onListening = () => {
916
+ server.removeListener('error', onError);
917
+ resolve({ server, port });
918
+ };
919
+ server.once('error', onError);
920
+ server.once('listening', onListening);
921
+ };
922
+ attempt(startPort);
923
+ });
924
+ }
925
+
781
926
  async function main() {
782
927
  if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
783
928
  console.error(`workdir does not exist: ${WORKDIR}`);
@@ -812,15 +957,18 @@ async function main() {
812
957
  process.exit(1);
813
958
  }
814
959
 
815
- await new Promise((resolve) => app.listen(PORT, resolve));
960
+ const { port: boundPort } = await listenOnFreePort(app, PORT);
961
+ if (boundPort !== PORT) {
962
+ console.log(` ✓ Using port ${boundPort} instead (${PORT} was taken).`);
963
+ }
816
964
 
817
965
  let tunnel;
818
966
  try {
819
- tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
967
+ tunnel = await localtunnel({ port: boundPort, subdomain: LT_SUBDOMAIN });
820
968
  } catch (err) {
821
969
  console.error('Failed to open localtunnel:', err.message);
822
970
  console.error('If localtunnel.me is blocked, try cloudflared:');
823
- console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
971
+ console.error(` cloudflared tunnel --url http://localhost:${boundPort}`);
824
972
  process.exit(1);
825
973
  }
826
974
 
@@ -836,7 +984,7 @@ async function main() {
836
984
  qrcode.generate(pairUrl, { small: true });
837
985
  console.log('');
838
986
  console.log(` Agent ${adapter.label} (${AGENT_TYPE})`);
839
- console.log(` Local http://localhost:${PORT}`);
987
+ console.log(` Local http://localhost:${boundPort}`);
840
988
  console.log(` Tunnel ${tunnel.url}`);
841
989
  console.log(` Workdir ${WORKDIR}`);
842
990
  console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.0",
4
- "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
3
+ "version": "0.6.2",
4
+ "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
5
5
  "bin": {
6
6
  "esque-bridge": "index.js"
7
7
  },