@nextclaw/remote 0.1.18 → 0.1.20
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/dist/index.d.ts +1 -1
- package/dist/index.js +383 -5
- package/package.json +5 -3
package/dist/index.d.ts
CHANGED
|
@@ -153,7 +153,7 @@ declare class RemoteRelayBridge {
|
|
|
153
153
|
ensureLocalUiHealthy(): Promise<void>;
|
|
154
154
|
forward(frame: RelayRequestFrame, socket: WebSocket): Promise<void>;
|
|
155
155
|
private createForwardHeaders;
|
|
156
|
-
|
|
156
|
+
requestBridgeCookie(): Promise<string | null>;
|
|
157
157
|
private sendStreamingResponse;
|
|
158
158
|
}
|
|
159
159
|
|
package/dist/index.js
CHANGED
|
@@ -403,6 +403,342 @@ var RemoteRelayBridge = class {
|
|
|
403
403
|
}
|
|
404
404
|
};
|
|
405
405
|
|
|
406
|
+
// src/remote-app.adapter.ts
|
|
407
|
+
import WebSocket2 from "ws";
|
|
408
|
+
|
|
409
|
+
// src/remote-app-stream.ts
|
|
410
|
+
function parseRemoteSseFrame(frame) {
|
|
411
|
+
const lines = frame.split("\n");
|
|
412
|
+
let event = "";
|
|
413
|
+
const dataLines = [];
|
|
414
|
+
for (const raw of lines) {
|
|
415
|
+
const line = raw.trimEnd();
|
|
416
|
+
if (!line || line.startsWith(":")) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (line.startsWith("event:")) {
|
|
420
|
+
event = line.slice(6).trim();
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (line.startsWith("data:")) {
|
|
424
|
+
dataLines.push(line.slice(5).trimStart());
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!event) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const data = dataLines.join("\n");
|
|
431
|
+
if (!data) {
|
|
432
|
+
return { event };
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
return { event, payload: JSON.parse(data) };
|
|
436
|
+
} catch {
|
|
437
|
+
return { event, payload: data };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function readRemoteStreamError(payload, fallback) {
|
|
441
|
+
if (typeof payload === "object" && payload && "error" in payload) {
|
|
442
|
+
const typed = payload;
|
|
443
|
+
if (typed.error?.message) {
|
|
444
|
+
return typed.error.message;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (typeof payload === "string" && payload.trim()) {
|
|
448
|
+
return payload.trim();
|
|
449
|
+
}
|
|
450
|
+
return fallback;
|
|
451
|
+
}
|
|
452
|
+
function processRemoteStreamFrame(params) {
|
|
453
|
+
const frame = parseRemoteSseFrame(params.rawFrame);
|
|
454
|
+
if (!frame) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (frame.event === "final") {
|
|
458
|
+
params.setFinalResult(frame.payload);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (frame.event === "error") {
|
|
462
|
+
throw new Error(readRemoteStreamError(frame.payload, "stream failed"));
|
|
463
|
+
}
|
|
464
|
+
params.onEvent(frame);
|
|
465
|
+
}
|
|
466
|
+
function flushRemoteStreamFrames(params) {
|
|
467
|
+
let boundary = params.bufferState.value.indexOf("\n\n");
|
|
468
|
+
while (boundary !== -1) {
|
|
469
|
+
processRemoteStreamFrame({
|
|
470
|
+
rawFrame: params.bufferState.value.slice(0, boundary),
|
|
471
|
+
onEvent: params.onEvent,
|
|
472
|
+
setFinalResult: params.setFinalResult
|
|
473
|
+
});
|
|
474
|
+
params.bufferState.value = params.bufferState.value.slice(boundary + 2);
|
|
475
|
+
boundary = params.bufferState.value.indexOf("\n\n");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function readRemoteAppStreamResult(params) {
|
|
479
|
+
const reader = params.response.body?.getReader();
|
|
480
|
+
if (!reader) {
|
|
481
|
+
throw new Error("SSE response body unavailable.");
|
|
482
|
+
}
|
|
483
|
+
const decoder = new TextDecoder();
|
|
484
|
+
const bufferState = { value: "" };
|
|
485
|
+
let finalResult = void 0;
|
|
486
|
+
try {
|
|
487
|
+
while (true) {
|
|
488
|
+
const { value, done } = await reader.read();
|
|
489
|
+
if (done) {
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
bufferState.value += decoder.decode(value, { stream: true });
|
|
493
|
+
flushRemoteStreamFrames({
|
|
494
|
+
bufferState,
|
|
495
|
+
onEvent: params.onEvent,
|
|
496
|
+
setFinalResult: (nextValue) => {
|
|
497
|
+
finalResult = nextValue;
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (bufferState.value.trim()) {
|
|
502
|
+
processRemoteStreamFrame({
|
|
503
|
+
rawFrame: bufferState.value,
|
|
504
|
+
onEvent: params.onEvent,
|
|
505
|
+
setFinalResult: (nextValue) => {
|
|
506
|
+
finalResult = nextValue;
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
} finally {
|
|
511
|
+
reader.releaseLock();
|
|
512
|
+
}
|
|
513
|
+
if (finalResult === void 0) {
|
|
514
|
+
throw new Error("stream ended without final event");
|
|
515
|
+
}
|
|
516
|
+
return finalResult;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/remote-app.adapter.ts
|
|
520
|
+
function toWebSocketUrl(origin, path) {
|
|
521
|
+
const normalizedOrigin = origin.replace(/\/$/, "");
|
|
522
|
+
if (normalizedOrigin.startsWith("https://")) {
|
|
523
|
+
return `${normalizedOrigin.replace(/^https:/, "wss:")}${path}`;
|
|
524
|
+
}
|
|
525
|
+
if (normalizedOrigin.startsWith("http://")) {
|
|
526
|
+
return `${normalizedOrigin.replace(/^http:/, "ws:")}${path}`;
|
|
527
|
+
}
|
|
528
|
+
return `${normalizedOrigin}${path}`;
|
|
529
|
+
}
|
|
530
|
+
function readErrorMessage(body, fallback) {
|
|
531
|
+
if (typeof body === "object" && body && "error" in body) {
|
|
532
|
+
const typed = body;
|
|
533
|
+
if (typed.error?.message) {
|
|
534
|
+
return typed.error.message;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (typeof body === "string" && body.trim()) {
|
|
538
|
+
return body.trim();
|
|
539
|
+
}
|
|
540
|
+
return fallback;
|
|
541
|
+
}
|
|
542
|
+
var RemoteAppAdapter = class {
|
|
543
|
+
constructor(localOrigin, platformSocket) {
|
|
544
|
+
this.localOrigin = localOrigin;
|
|
545
|
+
this.platformSocket = platformSocket;
|
|
546
|
+
this.relayBridge = new RemoteRelayBridge(localOrigin);
|
|
547
|
+
}
|
|
548
|
+
relayBridge;
|
|
549
|
+
activeStreams = /* @__PURE__ */ new Map();
|
|
550
|
+
localEventSocket = null;
|
|
551
|
+
eventReconnectTimer = null;
|
|
552
|
+
shuttingDown = false;
|
|
553
|
+
async start() {
|
|
554
|
+
await this.ensureEventSocket();
|
|
555
|
+
}
|
|
556
|
+
stop() {
|
|
557
|
+
this.shuttingDown = true;
|
|
558
|
+
if (this.eventReconnectTimer) {
|
|
559
|
+
clearTimeout(this.eventReconnectTimer);
|
|
560
|
+
this.eventReconnectTimer = null;
|
|
561
|
+
}
|
|
562
|
+
this.localEventSocket?.close();
|
|
563
|
+
this.localEventSocket = null;
|
|
564
|
+
for (const controller of this.activeStreams.values()) {
|
|
565
|
+
controller.abort();
|
|
566
|
+
}
|
|
567
|
+
this.activeStreams.clear();
|
|
568
|
+
}
|
|
569
|
+
async handle(frame) {
|
|
570
|
+
if (frame.type === "client.request") {
|
|
571
|
+
await this.handleRequest(frame);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (frame.type === "client.stream.open") {
|
|
575
|
+
void this.handleStream(frame);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (frame.type === "client.stream.cancel") {
|
|
579
|
+
this.activeStreams.get(frame.streamId)?.abort();
|
|
580
|
+
this.activeStreams.delete(frame.streamId);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async handleRequest(frame) {
|
|
584
|
+
const bridgeCookie = await this.relayBridge.requestBridgeCookie();
|
|
585
|
+
const response = await fetch(new URL(frame.target.path, this.localOrigin), {
|
|
586
|
+
method: frame.target.method,
|
|
587
|
+
headers: this.createJsonHeaders(bridgeCookie),
|
|
588
|
+
body: this.buildRequestBody(frame.target)
|
|
589
|
+
});
|
|
590
|
+
const body = await this.readResponseBody(response);
|
|
591
|
+
this.send({
|
|
592
|
+
type: "client.response",
|
|
593
|
+
clientId: frame.clientId,
|
|
594
|
+
id: frame.id,
|
|
595
|
+
status: response.status,
|
|
596
|
+
body
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async handleStream(frame) {
|
|
600
|
+
const controller = new AbortController();
|
|
601
|
+
this.activeStreams.set(frame.streamId, controller);
|
|
602
|
+
try {
|
|
603
|
+
const response = await this.openStreamResponse(frame, controller);
|
|
604
|
+
if (!response) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const finalResult = await readRemoteAppStreamResult({
|
|
608
|
+
response,
|
|
609
|
+
onEvent: (event) => {
|
|
610
|
+
this.send({
|
|
611
|
+
type: "client.stream.event",
|
|
612
|
+
clientId: frame.clientId,
|
|
613
|
+
streamId: frame.streamId,
|
|
614
|
+
event: event.event,
|
|
615
|
+
payload: event.payload
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
this.send({
|
|
620
|
+
type: "client.stream.end",
|
|
621
|
+
clientId: frame.clientId,
|
|
622
|
+
streamId: frame.streamId,
|
|
623
|
+
result: finalResult
|
|
624
|
+
});
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (controller.signal.aborted) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.send({
|
|
630
|
+
type: "client.stream.error",
|
|
631
|
+
clientId: frame.clientId,
|
|
632
|
+
streamId: frame.streamId,
|
|
633
|
+
message: error instanceof Error ? error.message : String(error)
|
|
634
|
+
});
|
|
635
|
+
} finally {
|
|
636
|
+
this.activeStreams.delete(frame.streamId);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async openStreamResponse(frame, controller) {
|
|
640
|
+
const bridgeCookie = await this.relayBridge.requestBridgeCookie();
|
|
641
|
+
const response = await fetch(new URL(frame.target.path, this.localOrigin), {
|
|
642
|
+
method: frame.target.method,
|
|
643
|
+
headers: this.createStreamHeaders(bridgeCookie),
|
|
644
|
+
body: this.buildRequestBody(frame.target),
|
|
645
|
+
signal: controller.signal
|
|
646
|
+
});
|
|
647
|
+
if (response.ok) {
|
|
648
|
+
return response;
|
|
649
|
+
}
|
|
650
|
+
const errorBody = await this.readResponseBody(response);
|
|
651
|
+
this.send({
|
|
652
|
+
type: "client.stream.error",
|
|
653
|
+
clientId: frame.clientId,
|
|
654
|
+
streamId: frame.streamId,
|
|
655
|
+
message: readErrorMessage(errorBody, `HTTP ${response.status}`)
|
|
656
|
+
});
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
async ensureEventSocket() {
|
|
660
|
+
if (this.localEventSocket && this.localEventSocket.readyState === WebSocket2.OPEN) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const bridgeCookie = await this.relayBridge.requestBridgeCookie();
|
|
664
|
+
await new Promise((resolve, reject) => {
|
|
665
|
+
const socket = new WebSocket2(toWebSocketUrl(this.localOrigin, "/ws"), {
|
|
666
|
+
headers: bridgeCookie ? { Cookie: bridgeCookie } : void 0
|
|
667
|
+
});
|
|
668
|
+
this.localEventSocket = socket;
|
|
669
|
+
socket.on("open", () => resolve());
|
|
670
|
+
socket.on("message", (data) => {
|
|
671
|
+
try {
|
|
672
|
+
const event = JSON.parse(String(data ?? ""));
|
|
673
|
+
this.send({
|
|
674
|
+
type: "client.event",
|
|
675
|
+
event
|
|
676
|
+
});
|
|
677
|
+
} catch (error) {
|
|
678
|
+
console.error("Failed to parse local ui event:", error);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
socket.on("close", () => {
|
|
682
|
+
this.localEventSocket = null;
|
|
683
|
+
if (!this.shuttingDown) {
|
|
684
|
+
this.scheduleEventReconnect();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
socket.on("error", (error) => {
|
|
688
|
+
if (!this.shuttingDown) {
|
|
689
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
scheduleEventReconnect() {
|
|
695
|
+
if (this.eventReconnectTimer) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.eventReconnectTimer = setTimeout(() => {
|
|
699
|
+
this.eventReconnectTimer = null;
|
|
700
|
+
void this.ensureEventSocket().catch(() => void 0);
|
|
701
|
+
}, 3e3);
|
|
702
|
+
}
|
|
703
|
+
buildRequestBody(target) {
|
|
704
|
+
if (target.method === "GET" || target.method === "HEAD") {
|
|
705
|
+
return void 0;
|
|
706
|
+
}
|
|
707
|
+
if (target.body === void 0) {
|
|
708
|
+
return void 0;
|
|
709
|
+
}
|
|
710
|
+
return new TextEncoder().encode(JSON.stringify(target.body));
|
|
711
|
+
}
|
|
712
|
+
createJsonHeaders(bridgeCookie) {
|
|
713
|
+
const headers = new Headers({
|
|
714
|
+
"Content-Type": "application/json"
|
|
715
|
+
});
|
|
716
|
+
if (bridgeCookie) {
|
|
717
|
+
headers.set("cookie", bridgeCookie);
|
|
718
|
+
}
|
|
719
|
+
return headers;
|
|
720
|
+
}
|
|
721
|
+
createStreamHeaders(bridgeCookie) {
|
|
722
|
+
const headers = this.createJsonHeaders(bridgeCookie);
|
|
723
|
+
headers.set("Accept", "text/event-stream");
|
|
724
|
+
return headers;
|
|
725
|
+
}
|
|
726
|
+
async readResponseBody(response) {
|
|
727
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
728
|
+
if (contentType.includes("application/json")) {
|
|
729
|
+
return await response.json();
|
|
730
|
+
}
|
|
731
|
+
const text = await response.text();
|
|
732
|
+
return text;
|
|
733
|
+
}
|
|
734
|
+
send(frame) {
|
|
735
|
+
if (this.platformSocket.readyState !== WebSocket2.OPEN) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.platformSocket.send(JSON.stringify(frame));
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
406
742
|
// src/remote-connector.ts
|
|
407
743
|
var RemoteConnector = class {
|
|
408
744
|
constructor(deps) {
|
|
@@ -414,6 +750,7 @@ var RemoteConnector = class {
|
|
|
414
750
|
async connectOnce(params) {
|
|
415
751
|
return await new Promise((resolve, reject) => {
|
|
416
752
|
const socket = new WebSocket(params.wsUrl);
|
|
753
|
+
const appAdapter = new RemoteAppAdapter(params.localOrigin, socket);
|
|
417
754
|
let settled = false;
|
|
418
755
|
let aborted = false;
|
|
419
756
|
const cleanup = () => {
|
|
@@ -462,14 +799,24 @@ var RemoteConnector = class {
|
|
|
462
799
|
lastError: null
|
|
463
800
|
});
|
|
464
801
|
this.logger.info(`\u2713 Remote connector connected: ${redactWsUrl(params.wsUrl)}`);
|
|
802
|
+
void appAdapter.start().catch((error) => {
|
|
803
|
+
this.logger.error(`Remote event bridge error: ${error instanceof Error ? error.message : String(error)}`);
|
|
804
|
+
});
|
|
465
805
|
});
|
|
466
806
|
socket.addEventListener("message", (event) => {
|
|
467
|
-
this.handleSocketMessage({
|
|
807
|
+
this.handleSocketMessage({
|
|
808
|
+
data: event.data,
|
|
809
|
+
relayBridge: params.relayBridge,
|
|
810
|
+
appAdapter,
|
|
811
|
+
socket
|
|
812
|
+
});
|
|
468
813
|
});
|
|
469
814
|
socket.addEventListener("close", () => {
|
|
815
|
+
appAdapter.stop();
|
|
470
816
|
finishResolve(aborted ? "aborted" : "closed");
|
|
471
817
|
});
|
|
472
818
|
socket.addEventListener("error", () => {
|
|
819
|
+
appAdapter.stop();
|
|
473
820
|
if (aborted) {
|
|
474
821
|
finishResolve("aborted");
|
|
475
822
|
return;
|
|
@@ -485,11 +832,33 @@ var RemoteConnector = class {
|
|
|
485
832
|
return;
|
|
486
833
|
}
|
|
487
834
|
try {
|
|
488
|
-
|
|
835
|
+
if (frame.type === "request") {
|
|
836
|
+
await params.relayBridge.forward(frame, params.socket);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
await params.appAdapter.handle(frame);
|
|
489
840
|
} catch (error) {
|
|
841
|
+
if (frame.type === "request") {
|
|
842
|
+
params.socket.send(JSON.stringify({
|
|
843
|
+
type: "response.error",
|
|
844
|
+
requestId: frame.requestId,
|
|
845
|
+
message: error instanceof Error ? error.message : String(error)
|
|
846
|
+
}));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (frame.type === "client.request") {
|
|
850
|
+
params.socket.send(JSON.stringify({
|
|
851
|
+
type: "client.request.error",
|
|
852
|
+
clientId: frame.clientId,
|
|
853
|
+
id: frame.id,
|
|
854
|
+
message: error instanceof Error ? error.message : String(error)
|
|
855
|
+
}));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
490
858
|
params.socket.send(JSON.stringify({
|
|
491
|
-
type: "
|
|
492
|
-
|
|
859
|
+
type: "client.stream.error",
|
|
860
|
+
clientId: frame.clientId,
|
|
861
|
+
streamId: frame.streamId,
|
|
493
862
|
message: error instanceof Error ? error.message : String(error)
|
|
494
863
|
}));
|
|
495
864
|
}
|
|
@@ -498,7 +867,16 @@ var RemoteConnector = class {
|
|
|
498
867
|
parseRelayFrame(data) {
|
|
499
868
|
try {
|
|
500
869
|
const frame = JSON.parse(String(data ?? ""));
|
|
501
|
-
|
|
870
|
+
if (typeof frame !== "object" || !frame || typeof frame.type !== "string") {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
if (frame.type === "request") {
|
|
874
|
+
return frame;
|
|
875
|
+
}
|
|
876
|
+
if (frame.type === "client.request" || frame.type === "client.stream.open" || frame.type === "client.stream.cancel") {
|
|
877
|
+
return frame;
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
502
880
|
} catch {
|
|
503
881
|
return null;
|
|
504
882
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Remote access runtime for NextClaw device registration, relay bridging, and service-managed connectivity.",
|
|
6
6
|
"type": "module",
|
|
@@ -29,11 +29,13 @@
|
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"commander": "^12.1.0",
|
|
32
|
-
"
|
|
33
|
-
"@nextclaw/server": "0.10.
|
|
32
|
+
"ws": "^8.18.0",
|
|
33
|
+
"@nextclaw/server": "0.10.24",
|
|
34
|
+
"@nextclaw/core": "0.9.8"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@types/node": "^20.17.6",
|
|
38
|
+
"@types/ws": "^8.5.14",
|
|
37
39
|
"prettier": "^3.3.3",
|
|
38
40
|
"tsup": "^8.3.5",
|
|
39
41
|
"typescript": "^5.6.3"
|