openclaw-navigator 5.5.1 → 5.5.3

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/cli.mjs +155 -9
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * openclaw-navigator v5.5.1
4
+ * openclaw-navigator v5.5.3
5
5
  *
6
6
  * One-command bridge + tunnel for the Navigator browser.
7
7
  * Starts a local bridge, creates a Cloudflare tunnel automatically,
@@ -1330,25 +1330,37 @@ function handleRequest(req, res) {
1330
1330
  }
1331
1331
 
1332
1332
  // ── Fallback: proxy unmatched paths → OC Web UI ──────────────────────
1333
- // Catches /login, /_next/*, /static/*, /favicon.ico, etc.
1333
+ // Catches /api/*, /login, /_next/*, /static/*, /favicon.ico, etc.
1334
1334
  // MUST apply the same cookie/redirect/forwarded-header treatment as /ui/*
1335
1335
  // or login won't work (session cookies get lost through the tunnel).
1336
+ //
1337
+ // SSE safety: If the web UI returns text/event-stream but the browser
1338
+ // expects JSON (e.g. /api/agents), we collect the SSE events and return
1339
+ // them as a JSON array. This prevents "data: ... is not valid JSON" errors.
1336
1340
  {
1337
1341
  const targetURL = `${path}${url.search}`;
1338
1342
  const incomingHost = req.headers.host || "localhost";
1339
1343
 
1344
+ // Encourage JSON responses from the web UI's API routes
1345
+ const proxyHeaders = {
1346
+ ...req.headers,
1347
+ host: `127.0.0.1:${ocUIPort}`,
1348
+ "x-forwarded-host": incomingHost,
1349
+ "x-forwarded-proto": activeTunnelURL ? "https" : "http",
1350
+ "x-forwarded-for": req.socket.remoteAddress || "127.0.0.1",
1351
+ };
1352
+
1353
+ // For /api/* requests, prefer JSON over SSE
1354
+ if (path.startsWith("/api/")) {
1355
+ proxyHeaders["accept"] = "application/json, text/plain, */*";
1356
+ }
1357
+
1340
1358
  const proxyOpts = {
1341
1359
  hostname: "127.0.0.1",
1342
1360
  port: ocUIPort,
1343
1361
  path: targetURL,
1344
1362
  method: req.method,
1345
- headers: {
1346
- ...req.headers,
1347
- host: `127.0.0.1:${ocUIPort}`,
1348
- "x-forwarded-host": incomingHost,
1349
- "x-forwarded-proto": activeTunnelURL ? "https" : "http",
1350
- "x-forwarded-for": req.socket.remoteAddress || "127.0.0.1",
1351
- },
1363
+ headers: proxyHeaders,
1352
1364
  };
1353
1365
 
1354
1366
  const proxyReq = httpRequest(proxyOpts, (proxyRes) => {
@@ -1377,6 +1389,140 @@ function handleRequest(req, res) {
1377
1389
  );
1378
1390
  }
1379
1391
 
1392
+ // ── SSE→JSON conversion for non-streaming /api/* endpoints ──────
1393
+ // Some API endpoints (e.g. /api/agents) return SSE when the frontend
1394
+ // expects JSON. We convert those to JSON. But STREAMING endpoints
1395
+ // like /api/chat must pass through as SSE so the frontend can read
1396
+ // the real-time token stream via EventSource/fetch streaming.
1397
+ const contentType = (proxyRes.headers["content-type"] || "").toLowerCase();
1398
+ const isSSE = contentType.includes("text/event-stream");
1399
+ const isStreamingEndpoint = path.startsWith("/api/chat") || path.startsWith("/api/stream");
1400
+
1401
+ if (isSSE && path.startsWith("/api/") && !isStreamingEndpoint) {
1402
+ console.log(` ${DIM}SSE→JSON: converting ${path}${RESET}`);
1403
+ let buffer = "";
1404
+ const items = [];
1405
+
1406
+ proxyRes.setEncoding("utf-8");
1407
+ proxyRes.on("data", (chunk) => {
1408
+ buffer += chunk;
1409
+ const lines = buffer.split("\n");
1410
+ buffer = lines.pop() || "";
1411
+ for (const line of lines) {
1412
+ if (line.startsWith("data: ")) {
1413
+ const raw = line.slice(6).trim();
1414
+ if (raw === "[DONE]" || !raw) {
1415
+ continue;
1416
+ }
1417
+ try {
1418
+ items.push(JSON.parse(raw));
1419
+ } catch {
1420
+ // Non-JSON SSE data — wrap as text
1421
+ if (raw) {
1422
+ items.push({ text: raw });
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+ });
1428
+
1429
+ proxyRes.on("end", () => {
1430
+ // Process remaining buffer
1431
+ if (buffer.startsWith("data: ")) {
1432
+ const raw = buffer.slice(6).trim();
1433
+ if (raw && raw !== "[DONE]") {
1434
+ try {
1435
+ items.push(JSON.parse(raw));
1436
+ } catch {
1437
+ if (raw) {
1438
+ items.push({ text: raw });
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ // Return as JSON — single object if 1 item, array if multiple
1445
+ const result = items.length === 1 ? items[0] : items;
1446
+ headers["content-type"] = "application/json";
1447
+ delete headers["content-length"];
1448
+ delete headers["transfer-encoding"];
1449
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
1450
+ res.end(JSON.stringify(result));
1451
+ });
1452
+
1453
+ proxyRes.on("error", () => {
1454
+ sendJSON(res, 502, { ok: false, error: "SSE stream error" });
1455
+ });
1456
+ return;
1457
+ }
1458
+
1459
+ // ── SSE passthrough for streaming endpoints (/api/chat) ──────────
1460
+ // Let the SSE stream through to the browser AND tap it to broadcast
1461
+ // chunks via our WebSocket (so Navigator's sidepane chat gets them).
1462
+ if (isSSE && isStreamingEndpoint) {
1463
+ console.log(` ${DIM}SSE passthrough + WS tap: ${path}${RESET}`);
1464
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
1465
+
1466
+ let fullText = "";
1467
+ proxyRes.on("data", (chunk) => {
1468
+ // Forward the chunk to the browser immediately
1469
+ res.write(chunk);
1470
+
1471
+ // Also parse and broadcast to WebSocket
1472
+ const text = chunk.toString("utf-8");
1473
+ for (const line of text.split("\n")) {
1474
+ if (line.startsWith("data: ")) {
1475
+ const raw = line.slice(6).trim();
1476
+ if (raw === "[DONE]" || !raw) {
1477
+ continue;
1478
+ }
1479
+ try {
1480
+ const evt = JSON.parse(raw);
1481
+ const delta =
1482
+ evt.choices?.[0]?.delta?.content ||
1483
+ evt.delta?.text ||
1484
+ evt.text ||
1485
+ evt.content ||
1486
+ "";
1487
+ if (delta) {
1488
+ fullText += delta;
1489
+ broadcastToWS({
1490
+ type: "chat.delta",
1491
+ text: fullText,
1492
+ delta,
1493
+ sessionKey: "main",
1494
+ timestamp: Date.now(),
1495
+ });
1496
+ }
1497
+ } catch {
1498
+ /* non-JSON SSE event — skip */
1499
+ }
1500
+ }
1501
+ }
1502
+ });
1503
+
1504
+ proxyRes.on("end", () => {
1505
+ // Stream ended — broadcast final message via WebSocket
1506
+ if (fullText) {
1507
+ broadcastToWS({
1508
+ type: "chat.final",
1509
+ text: fullText,
1510
+ content: fullText,
1511
+ sessionKey: "main",
1512
+ role: "assistant",
1513
+ timestamp: Date.now(),
1514
+ });
1515
+ }
1516
+ res.end();
1517
+ });
1518
+
1519
+ proxyRes.on("error", () => {
1520
+ res.end();
1521
+ });
1522
+ return;
1523
+ }
1524
+
1525
+ // Non-SSE responses — stream through as-is
1380
1526
  res.writeHead(proxyRes.statusCode ?? 502, headers);
1381
1527
  proxyRes.pipe(res, { end: true });
1382
1528
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.5.1",
3
+ "version": "5.5.3",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",