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.
- package/index.js +161 -13
- 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(/&/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
|
|
592
|
-
|
|
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
|
-
|
|
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
|
|
623
|
-
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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:
|
|
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:${
|
|
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:${
|
|
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.
|
|
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
|
},
|