orbit-codex-bridge 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,218 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import WebSocket from 'ws';
6
+
7
+ const maxBody = 12 * 1024 * 1024;
8
+ const isLoopback = url => ['localhost','127.0.0.1','[::1]'].includes(url.hostname);
9
+
10
+ export function createRelayAgent(options = {}) {
11
+ const relay = new URL(options.relayUrl || process.env.ORBIT_RELAY_URL || '');
12
+ const local = new URL(options.localUrl || process.env.ORBIT_LOCAL_URL || 'http://127.0.0.1:4318');
13
+ const token = options.agentToken || process.env.ORBIT_RELAY_AGENT_TOKEN || '';
14
+ const deviceId = options.deviceId || process.env.ORBIT_RELAY_DEVICE_ID || '';
15
+ const onReady = options.onReady || (() => {});
16
+ const onError = options.onError || (() => {});
17
+ const onState = options.onState || (() => {});
18
+ const heartbeatInterval = options.heartbeatInterval ?? 15000;
19
+ const heartbeatTimeout = options.heartbeatTimeout ?? 45000;
20
+ const reconnectDelay = options.reconnectDelay ?? 1000;
21
+ if (heartbeatInterval <= 0 || heartbeatTimeout <= heartbeatInterval || reconnectDelay < 0) throw new Error('连接心跳参数无效。');
22
+ if (relay.username || relay.password || relay.search || relay.hash || relay.pathname !== '/agent'
23
+ || !(relay.protocol === 'wss:' || relay.protocol === 'ws:' && isLoopback(relay))) {
24
+ throw new Error('ORBIT_RELAY_URL 必须为 wss://域名/agent;仅本地测试允许 ws。');
25
+ }
26
+ if (!isLoopback(local) || !['http:','https:'].includes(local.protocol) || local.username || local.password
27
+ || local.pathname !== '/' || local.search || local.hash) throw new Error('ORBIT_LOCAL_URL 必须指向本机 Orbit 根地址。');
28
+ if (!/^[a-zA-Z0-9_-]{32,100}$/.test(token) || !/^[a-zA-Z0-9_-]{8,100}$/.test(deviceId)) {
29
+ throw new Error('请从管理后台签发并配置此电脑的设备 ID 和独立代理 Token。');
30
+ }
31
+ let socket = null;
32
+ let stopped = true;
33
+ let reconnectTimer;
34
+ let readyTimer;
35
+ let heartbeatTimer;
36
+ let retry = 0;
37
+ const bridges = new Map();
38
+ const requests = new Set();
39
+
40
+ function send(packet) {
41
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(packet));
42
+ }
43
+
44
+ function clearLocalConnections() {
45
+ for (const bridge of bridges.values()) bridge.socket.terminate();
46
+ bridges.clear();
47
+ for (const request of requests) request.destroy(new Error('云端连接已断开,未自动重发。'));
48
+ requests.clear();
49
+ }
50
+
51
+ function localRequest(message, cloudSocket) {
52
+ if (typeof message.path !== 'string' || !message.path.startsWith('/api/') || message.path.startsWith('//')
53
+ || !['GET','POST'].includes(message.method)) return cloudSocket.close(1008,'Invalid local target');
54
+ const target = new URL(message.path,local);
55
+ if (target.origin !== local.origin) return cloudSocket.close(1008,'Invalid local target');
56
+ const body = Buffer.from(message.body || '', 'base64');
57
+ if (body.length > maxBody || requests.size >= 100) return;
58
+ const transport = target.protocol === 'https:' ? https : http;
59
+ let finished = false;
60
+ const respond = (statusCode,headers,responseBody) => {
61
+ if (finished) return;
62
+ finished = true;
63
+ if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) {
64
+ cloudSocket.send(JSON.stringify({type:'http-response',requestId:message.requestId,statusCode,headers,body:responseBody.toString('base64')}));
65
+ }
66
+ };
67
+ const headers = {};
68
+ for (const name of ['accept','content-type','cookie','user-agent']) {
69
+ const value = message.headers?.[name];
70
+ if (typeof value === 'string') headers[name] = value;
71
+ }
72
+ headers.host = local.host;
73
+ headers.origin = local.origin;
74
+ const request = transport.request(target,{method:message.method,headers,timeout:25000},response => {
75
+ const chunks = [];
76
+ let size = 0;
77
+ response.on('data',chunk => {
78
+ size += chunk.length;
79
+ if (size > maxBody) request.destroy(new Error('本机响应过大。'));
80
+ else chunks.push(chunk);
81
+ });
82
+ response.on('error',error => request.destroy(error));
83
+ response.on('end',() => {
84
+ requests.delete(request);
85
+ respond(response.statusCode || 502,{
86
+ 'content-type':response.headers['content-type'] || 'application/json',
87
+ ...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
88
+ },Buffer.concat(chunks));
89
+ });
90
+ });
91
+ requests.add(request);
92
+ request.on('timeout',() => request.destroy(new Error('本机 Orbit 响应超时。')));
93
+ request.on('error',error => {
94
+ requests.delete(request);
95
+ respond(502,{'content-type':'application/json'},Buffer.from(JSON.stringify({error:error.message})));
96
+ });
97
+ request.end(body);
98
+ }
99
+
100
+ function openLocalSocket(message, cloudSocket) {
101
+ if (message.path !== '/socket' || typeof message.bridgeId !== 'string' || bridges.size >= 100) return;
102
+ const target = new URL('/socket',local);
103
+ target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
104
+ const localSocket = new WebSocket(target,{
105
+ headers:{
106
+ Cookie:message.headers?.cookie || '', Origin:local.origin,
107
+ Host:local.host
108
+ },
109
+ maxPayload:maxBody,handshakeTimeout:8000
110
+ });
111
+ const bridge = {socket:localSocket,queue:[],queuedBytes:0};
112
+ bridges.set(message.bridgeId,bridge);
113
+ const emit = (type,extra={}) => {
114
+ if (socket === cloudSocket && cloudSocket.readyState === WebSocket.OPEN) send({type,bridgeId:message.bridgeId,...extra});
115
+ };
116
+ localSocket.on('open',() => {
117
+ emit('ws-opened');
118
+ for (const packet of bridge.queue) localSocket.send(packet.data,{binary:packet.binary});
119
+ bridge.queue = []; bridge.queuedBytes = 0;
120
+ });
121
+ localSocket.on('message',(data,isBinary) => emit('ws-data',{dataType:isBinary?'binary':'text',data:Buffer.from(data).toString('base64')}));
122
+ localSocket.on('error',() => emit('ws-error'));
123
+ localSocket.on('close',() => {
124
+ if (bridges.get(message.bridgeId) === bridge) bridges.delete(message.bridgeId);
125
+ emit('ws-close',{code:1000,reason:'Local socket closed'});
126
+ });
127
+ }
128
+
129
+ function connect() {
130
+ if (stopped) return;
131
+ const cloudSocket = new WebSocket(relay,{maxPayload:18*1024*1024,handshakeTimeout:10000});
132
+ socket = cloudSocket;
133
+ onState({status:'connecting'});
134
+ readyTimer = setTimeout(() => cloudSocket.terminate(),15000);
135
+ let pendingPing = null;
136
+ let pingSentAt = 0;
137
+ cloudSocket.on('open',() => {
138
+ cloudSocket.send(JSON.stringify({type:'agent-auth',token,deviceId}));
139
+ heartbeatTimer = setInterval(() => {
140
+ if (socket !== cloudSocket || cloudSocket.readyState !== WebSocket.OPEN) return;
141
+ if (pendingPing !== null) {
142
+ if (Date.now() - pingSentAt >= heartbeatTimeout) {
143
+ onError(new Error('云端心跳超时,正在自动重连。'));
144
+ cloudSocket.terminate();
145
+ }
146
+ return;
147
+ }
148
+ pendingPing = `${Date.now()}`;
149
+ pingSentAt = Date.now();
150
+ cloudSocket.ping(pendingPing);
151
+ },heartbeatInterval);
152
+ });
153
+ cloudSocket.on('pong',data => {
154
+ if (data.toString() === pendingPing) pendingPing = null;
155
+ });
156
+ cloudSocket.on('error',error => { if (!stopped) onError(error); });
157
+ cloudSocket.on('message',raw => {
158
+ if (socket !== cloudSocket) return;
159
+ let message;
160
+ try { message = JSON.parse(raw.toString()); } catch { cloudSocket.close(1003,'Invalid relay message'); return; }
161
+ if (!message || typeof message !== 'object') return;
162
+ if (message.type === 'agent-ready') {
163
+ clearTimeout(readyTimer);retry = 0;onState({status:'online'});onReady(message);return;
164
+ }
165
+ if (message.type === 'http-request') return localRequest(message,cloudSocket);
166
+ if (message.type === 'ws-open') return openLocalSocket(message,cloudSocket);
167
+ const bridge = bridges.get(message.bridgeId);
168
+ if (!bridge) return;
169
+ if (message.type === 'ws-data' && typeof message.data === 'string') {
170
+ const packet = {data:Buffer.from(message.data,'base64'),binary:message.dataType === 'binary'};
171
+ if (bridge.socket.readyState === WebSocket.OPEN) bridge.socket.send(packet.data,{binary:packet.binary});
172
+ else if (bridge.socket.readyState === WebSocket.CONNECTING) {
173
+ bridge.queuedBytes += packet.data.length;
174
+ if (bridge.queuedBytes > 1024*1024) bridge.socket.terminate();
175
+ else bridge.queue.push(packet);
176
+ }
177
+ } else if (message.type === 'ws-close') {
178
+ bridges.delete(message.bridgeId);
179
+ if (bridge.socket.readyState === WebSocket.OPEN) bridge.socket.close(1000,'Cloud connection closed');
180
+ else bridge.socket.terminate();
181
+ }
182
+ });
183
+ cloudSocket.on('close',(code,reason) => {
184
+ if (socket !== cloudSocket) return;
185
+ socket = null;clearTimeout(readyTimer);clearInterval(heartbeatTimer);clearLocalConnections();
186
+ if (!stopped) {
187
+ if (code === 1008 || code === 1012 && reason.toString() === 'Replaced by a newer agent') {
188
+ stopped = true;
189
+ const detail = code === 1008 ? '电脑凭证被拒绝,请检查账号或重新登录。' : '连接已由另一个 Orbit 实例接管。';
190
+ onState({status:'blocked',reason:detail});
191
+ onError(new Error(detail));
192
+ return;
193
+ }
194
+ const delay = Math.min(30000,reconnectDelay * 2 ** Math.min(5,retry++));
195
+ onState({status:'reconnecting',retryIn:delay,reason:reason.toString() || `连接断开 (${code})`});
196
+ reconnectTimer = setTimeout(connect,delay);
197
+ }
198
+ });
199
+ }
200
+
201
+ return {
202
+ start() { if (!stopped) return; stopped = false;connect(); },
203
+ stop() {
204
+ stopped = true;clearTimeout(reconnectTimer);clearTimeout(readyTimer);clearInterval(heartbeatTimer);
205
+ const previous = socket;socket = null;previous?.terminate();clearLocalConnections();
206
+ }
207
+ };
208
+ }
209
+
210
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
211
+ const agent = createRelayAgent({
212
+ onReady:message => console.log(`Orbit agent connected: ${message.deviceId}`),
213
+ onError:error => console.error(`Orbit agent connection: ${error.message}`)
214
+ });
215
+ agent.start();
216
+ process.once('SIGINT',() => agent.stop());
217
+ process.once('SIGTERM',() => agent.stop());
218
+ }