@pellux/goodvibes-daemon-sdk 1.6.0 → 1.7.0
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/api-router.d.ts +1 -1
- package/dist/api-router.d.ts.map +1 -1
- package/dist/api-router.js +11 -2
- package/dist/context.d.ts +23 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/control-routes.d.ts.map +1 -1
- package/dist/control-routes.js +43 -0
- package/dist/gateway-rest-routes.d.ts +56 -0
- package/dist/gateway-rest-routes.d.ts.map +1 -0
- package/dist/gateway-rest-routes.js +101 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/integration-route-types.d.ts +8 -1
- package/dist/integration-route-types.d.ts.map +1 -1
- package/dist/memory-record-body.d.ts +8 -3
- package/dist/memory-record-body.d.ts.map +1 -1
- package/dist/memory-record-body.js +27 -3
- package/dist/operator.d.ts +2 -2
- package/dist/operator.js +6 -4
- package/dist/relay-registration.d.ts +58 -0
- package/dist/relay-registration.d.ts.map +1 -0
- package/dist/relay-registration.js +376 -0
- package/dist/relay-server-entry.d.ts +25 -0
- package/dist/relay-server-entry.d.ts.map +1 -0
- package/dist/relay-server-entry.js +91 -0
- package/dist/relay-server.d.ts +87 -0
- package/dist/relay-server.d.ts.map +1 -0
- package/dist/relay-server.js +261 -0
- package/dist/runtime-route-types.d.ts +0 -1
- package/dist/runtime-route-types.d.ts.map +1 -1
- package/dist/runtime-routes.d.ts.map +1 -1
- package/dist/runtime-routes.js +0 -1
- package/package.json +19 -3
package/dist/operator.js
CHANGED
|
@@ -28,8 +28,8 @@ const INVALID_ENCODING_RESPONSE = Response.json({ error: 'INVALID_PATH_ENCODING'
|
|
|
28
28
|
* authenticated session per handler implementation.
|
|
29
29
|
* - STATE-CHANGING routes (service install/start/stop, route bindings,
|
|
30
30
|
* automation jobs, knowledge ingest) — always `withAdmin(context, req, ...)`.
|
|
31
|
-
* - SCHEDULER
|
|
32
|
-
*
|
|
31
|
+
* - SCHEDULER routes (getSchedulerCapacity) — require admin per handler
|
|
32
|
+
* implementation.
|
|
33
33
|
*
|
|
34
34
|
* Dispatcher does not short-circuit unauthenticated requests; all auth
|
|
35
35
|
* enforcement lives in the handler factories (system-routes.ts,
|
|
@@ -488,8 +488,10 @@ async function dispatchOperatorRoutesInner(req, handlers) {
|
|
|
488
488
|
return handlers.deleteBootstrapFile(req);
|
|
489
489
|
if (pathname === '/api/runtime/scheduler' && method === 'GET')
|
|
490
490
|
return handlers.getSchedulerCapacity(req);
|
|
491
|
-
|
|
492
|
-
|
|
491
|
+
// GET /api/runtime/metrics is served by the gateway-REST parity table
|
|
492
|
+
// (gateway-rest-routes.ts → runtime.metrics.get), which dispatchDaemonApiRoutes
|
|
493
|
+
// tries ahead of this dispatcher. The raw handler that once lived here was
|
|
494
|
+
// removed to consolidate on the single gateway-verb mechanism.
|
|
493
495
|
if (pathname === '/api/panels' && method === 'GET')
|
|
494
496
|
return handlers.getPanels();
|
|
495
497
|
if (pathname === '/api/panels/open' && method === 'POST')
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type RelayKeyPair, type RelayPairingPayload } from '@pellux/goodvibes-transport-core/relay';
|
|
2
|
+
/** Structural client WebSocket the daemon uses to dial the relay. */
|
|
3
|
+
export interface RelayClientWebSocket {
|
|
4
|
+
binaryType: string;
|
|
5
|
+
send(data: string | Uint8Array | ArrayBuffer): void;
|
|
6
|
+
close(code?: number, reason?: string): void;
|
|
7
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void;
|
|
8
|
+
}
|
|
9
|
+
/** Header set on every relay-tunneled request so downstream can tell it apart. */
|
|
10
|
+
export declare const RELAY_VIA_HEADER = "x-goodvibes-via-relay";
|
|
11
|
+
/**
|
|
12
|
+
* Whether a request arrived over the relay (vs the trusted LAN). Surfaces and
|
|
13
|
+
* policy hooks use this to show connections as "via relay" and to apply
|
|
14
|
+
* relay-specific controls such as WebAuthn step-up on mutating calls.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isRelayTunneledRequest(req: Request): boolean;
|
|
17
|
+
/** Lifecycle status of the daemon's relay registration. */
|
|
18
|
+
export type RelayRegistrationStatus = 'idle' | 'connecting' | 'registered' | 'reconnecting' | 'stopped';
|
|
19
|
+
export interface RelayRegistrationLogger {
|
|
20
|
+
info(message: string, fields?: Record<string, unknown>): void;
|
|
21
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
22
|
+
error(message: string, fields?: Record<string, unknown>): void;
|
|
23
|
+
}
|
|
24
|
+
/** Options for {@link createRelayDaemonRegistration}. */
|
|
25
|
+
export interface RelayDaemonRegistrationOptions {
|
|
26
|
+
/** The relay URL to dial (wss://…). */
|
|
27
|
+
readonly relayUrl: string;
|
|
28
|
+
/** The unguessable rendezvous id this daemon registers under. */
|
|
29
|
+
readonly rid: string;
|
|
30
|
+
/** The daemon's persistent relay identity key pair. */
|
|
31
|
+
readonly identity: RelayKeyPair;
|
|
32
|
+
/** Base URL used to resolve tunneled request paths into local Requests. */
|
|
33
|
+
readonly localBaseUrl: string;
|
|
34
|
+
/** Replay a reconstructed request against the daemon; returns null if unrouted. */
|
|
35
|
+
readonly dispatch: (req: Request) => Promise<Response | null>;
|
|
36
|
+
/** WebSocket constructor override (defaults to globalThis.WebSocket). */
|
|
37
|
+
readonly webSocketImpl?: (url: string) => RelayClientWebSocket;
|
|
38
|
+
/** Reconnect backoff base delay in ms (default 500). */
|
|
39
|
+
readonly reconnectBaseDelayMs?: number;
|
|
40
|
+
/** Reconnect backoff cap in ms (default 30000). */
|
|
41
|
+
readonly reconnectMaxDelayMs?: number;
|
|
42
|
+
/** Max concurrent event-subscription streams per pipe (default 8). */
|
|
43
|
+
readonly maxStreamsPerPipe?: number;
|
|
44
|
+
/** Bounded per-stream send buffer, in chunks; overflow drops-with-notice (default 256). */
|
|
45
|
+
readonly streamBufferChunks?: number;
|
|
46
|
+
readonly logger?: RelayRegistrationLogger;
|
|
47
|
+
readonly onStatusChange?: (status: RelayRegistrationStatus) => void;
|
|
48
|
+
}
|
|
49
|
+
/** A running daemon-side relay registration. */
|
|
50
|
+
export interface RelayDaemonRegistration {
|
|
51
|
+
start(): void;
|
|
52
|
+
stop(): void;
|
|
53
|
+
readonly status: RelayRegistrationStatus;
|
|
54
|
+
/** Mint a pairing payload a surface can scan to reach this daemon. */
|
|
55
|
+
mintPairing(label?: string): Promise<RelayPairingPayload>;
|
|
56
|
+
}
|
|
57
|
+
export declare function createRelayDaemonRegistration(options: RelayDaemonRegistrationOptions): RelayDaemonRegistration;
|
|
58
|
+
//# sourceMappingURL=relay-registration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-registration.d.ts","sourceRoot":"","sources":["../src/relay-registration.ts"],"names":[],"mappings":"AAaA,OAAO,EAeL,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACzB,MAAM,wCAAwC,CAAC;AAEhD,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,IAAI,CAAC;IACpD,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAC;CAC1G;AAED,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED,2DAA2D;AAC3D,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,YAAY,GAAG,YAAY,GAAG,cAAc,GAAG,SAAS,CAAC;AAExG,MAAM,WAAW,uBAAuB;IACtC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC9D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAChE;AAED,yDAAyD;AACzD,MAAM,WAAW,8BAA8B;IAC7C,uCAAuC;IACvC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,iEAAiE;IACjE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,2EAA2E;IAC3E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IAC9D,yEAAyE;IACzE,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,oBAAoB,CAAC;IAC/D,wDAAwD;IACxD,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,mDAAmD;IACnD,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,sEAAsE;IACtE,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,2FAA2F;IAC3F,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,uBAAuB,CAAC;IAC1C,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,uBAAuB,KAAK,IAAI,CAAC;CACrE;AAED,gDAAgD;AAChD,MAAM,WAAW,uBAAuB;IACtC,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,MAAM,EAAE,uBAAuB,CAAC;IACzC,sEAAsE;IACtE,WAAW,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAC3D;AA8FD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,8BAA8B,GAAG,uBAAuB,CA4Q9G"}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// relay-registration.ts
|
|
2
|
+
//
|
|
3
|
+
// The daemon half of the relay path. It dials the relay OUTBOUND (so the daemon
|
|
4
|
+
// needs no inbound port or public IP), registers under its rendezvous id, and
|
|
5
|
+
// terminates the end-to-end secure channel INSIDE this process. Tunneled HTTP
|
|
6
|
+
// requests are decrypted here and replayed against the daemon's own route
|
|
7
|
+
// dispatcher, exactly as if they had arrived on the local HTTP listener — the
|
|
8
|
+
// relay only ever moved ciphertext.
|
|
9
|
+
//
|
|
10
|
+
// Reconnect uses the same capped exponential-backoff shape as the transport
|
|
11
|
+
// stream reconnector (base 500ms, ×2, cap 30s). The channel keys are per-pipe
|
|
12
|
+
// and ephemeral, so a reconnect simply re-registers and old pipes are dropped.
|
|
13
|
+
import { RELAY_PROTOCOL_VERSION, RelaySecureChannel, createRelayPairingPayload, decodeControlFrame, decodeTunnelFrame, encodeControlFrame, encodeTunnelFrame, encodeUtf8, framePipePayload, relayIdentityPublicKeyBase64Url, respondToHandshake, toBase64Url, unframePipePayload, } from '@pellux/goodvibes-transport-core/relay';
|
|
14
|
+
/** Header set on every relay-tunneled request so downstream can tell it apart. */
|
|
15
|
+
export const RELAY_VIA_HEADER = 'x-goodvibes-via-relay';
|
|
16
|
+
/**
|
|
17
|
+
* Whether a request arrived over the relay (vs the trusted LAN). Surfaces and
|
|
18
|
+
* policy hooks use this to show connections as "via relay" and to apply
|
|
19
|
+
* relay-specific controls such as WebAuthn step-up on mutating calls.
|
|
20
|
+
*/
|
|
21
|
+
export function isRelayTunneledRequest(req) {
|
|
22
|
+
return req.headers.get(RELAY_VIA_HEADER) === '1';
|
|
23
|
+
}
|
|
24
|
+
const SILENT = { info: () => { }, warn: () => { }, error: () => { } };
|
|
25
|
+
function defaultWebSocket(url) {
|
|
26
|
+
const Ctor = globalThis.WebSocket;
|
|
27
|
+
if (!Ctor)
|
|
28
|
+
throw new Error('No WebSocket implementation available; provide options.webSocketImpl.');
|
|
29
|
+
return new Ctor(url);
|
|
30
|
+
}
|
|
31
|
+
function toBytes(data) {
|
|
32
|
+
if (data instanceof ArrayBuffer)
|
|
33
|
+
return new Uint8Array(data.slice(0));
|
|
34
|
+
if (ArrayBuffer.isView(data))
|
|
35
|
+
return new Uint8Array(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const DEFAULT_MAX_STREAMS_PER_PIPE = 8;
|
|
39
|
+
const DEFAULT_STREAM_BUFFER_CHUNKS = 256;
|
|
40
|
+
/**
|
|
41
|
+
* The daemon-side pump for one event subscription: it seals event-source chunks
|
|
42
|
+
* into `stream-data` frames and writes them to the relay socket, applying a
|
|
43
|
+
* bounded buffer so a slow consumer can never make the daemon buffer without
|
|
44
|
+
* limit. When the buffer is full a chunk is DROPPED and counted, and the next
|
|
45
|
+
* successful flush emits a `stream-overflow` notice carrying the dropped count —
|
|
46
|
+
* an honest gap signal, never a silent one. Clean close is explicit in both
|
|
47
|
+
* directions (`stream-close`).
|
|
48
|
+
*/
|
|
49
|
+
class DaemonStreamPump {
|
|
50
|
+
streamId;
|
|
51
|
+
channel;
|
|
52
|
+
pipeIdBytes;
|
|
53
|
+
ws;
|
|
54
|
+
bufferCap;
|
|
55
|
+
onError;
|
|
56
|
+
queue = [];
|
|
57
|
+
dropped = 0;
|
|
58
|
+
seq = 0;
|
|
59
|
+
flushing = false;
|
|
60
|
+
closed = false;
|
|
61
|
+
constructor(streamId, channel, pipeIdBytes, ws, bufferCap, onError) {
|
|
62
|
+
this.streamId = streamId;
|
|
63
|
+
this.channel = channel;
|
|
64
|
+
this.pipeIdBytes = pipeIdBytes;
|
|
65
|
+
this.ws = ws;
|
|
66
|
+
this.bufferCap = bufferCap;
|
|
67
|
+
this.onError = onError;
|
|
68
|
+
}
|
|
69
|
+
enqueue(chunk) {
|
|
70
|
+
if (this.closed)
|
|
71
|
+
return;
|
|
72
|
+
if (this.queue.length >= this.bufferCap) {
|
|
73
|
+
this.dropped += 1;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.queue.push(chunk);
|
|
77
|
+
void this.flush();
|
|
78
|
+
}
|
|
79
|
+
async flush() {
|
|
80
|
+
if (this.flushing)
|
|
81
|
+
return;
|
|
82
|
+
this.flushing = true;
|
|
83
|
+
try {
|
|
84
|
+
while (this.queue.length > 0 && !this.closed) {
|
|
85
|
+
const chunk = this.queue.shift();
|
|
86
|
+
const frame = encodeTunnelFrame({ id: this.streamId, kind: 'stream-data', seq: this.seq++ }, chunk);
|
|
87
|
+
this.ws.send(framePipePayload(this.pipeIdBytes, await this.channel.seal(frame)));
|
|
88
|
+
if (this.dropped > 0 && !this.closed) {
|
|
89
|
+
const dropped = this.dropped;
|
|
90
|
+
this.dropped = 0;
|
|
91
|
+
const notice = encodeTunnelFrame({ id: this.streamId, kind: 'stream-overflow', dropped }, new Uint8Array(0));
|
|
92
|
+
this.ws.send(framePipePayload(this.pipeIdBytes, await this.channel.seal(notice)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
this.onError(error);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.flushing = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async close(reason) {
|
|
104
|
+
if (this.closed)
|
|
105
|
+
return;
|
|
106
|
+
this.closed = true;
|
|
107
|
+
this.queue.length = 0;
|
|
108
|
+
try {
|
|
109
|
+
const frame = encodeTunnelFrame({ id: this.streamId, kind: 'stream-close', ...(reason ? { reason } : {}) }, new Uint8Array(0));
|
|
110
|
+
this.ws.send(framePipePayload(this.pipeIdBytes, await this.channel.seal(frame)));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
this.onError(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function createRelayDaemonRegistration(options) {
|
|
118
|
+
const logger = options.logger ?? SILENT;
|
|
119
|
+
const makeSocket = options.webSocketImpl ?? defaultWebSocket;
|
|
120
|
+
const ridBytes = encodeUtf8(options.rid);
|
|
121
|
+
const baseDelay = options.reconnectBaseDelayMs ?? 500;
|
|
122
|
+
const maxDelay = options.reconnectMaxDelayMs ?? 30_000;
|
|
123
|
+
const maxStreamsPerPipe = options.maxStreamsPerPipe ?? DEFAULT_MAX_STREAMS_PER_PIPE;
|
|
124
|
+
const streamBufferChunks = options.streamBufferChunks ?? DEFAULT_STREAM_BUFFER_CHUNKS;
|
|
125
|
+
let status = 'idle';
|
|
126
|
+
let socket = null;
|
|
127
|
+
let stopped = false;
|
|
128
|
+
let attempt = 0;
|
|
129
|
+
let reconnectTimer = null;
|
|
130
|
+
const channels = new Map();
|
|
131
|
+
// Per-pipe live event subscriptions: pipeKey -> (streamId -> record).
|
|
132
|
+
const pipeStreams = new Map();
|
|
133
|
+
function closePipeStreams(pipeKey) {
|
|
134
|
+
const streams = pipeStreams.get(pipeKey);
|
|
135
|
+
if (!streams)
|
|
136
|
+
return;
|
|
137
|
+
for (const record of streams.values()) {
|
|
138
|
+
record.reader?.cancel().catch(() => { });
|
|
139
|
+
void record.pump.close('pipe-closed');
|
|
140
|
+
}
|
|
141
|
+
pipeStreams.delete(pipeKey);
|
|
142
|
+
}
|
|
143
|
+
function setStatus(next) {
|
|
144
|
+
status = next;
|
|
145
|
+
options.onStatusChange?.(next);
|
|
146
|
+
}
|
|
147
|
+
function scheduleReconnect() {
|
|
148
|
+
if (stopped)
|
|
149
|
+
return;
|
|
150
|
+
setStatus('reconnecting');
|
|
151
|
+
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
|
152
|
+
attempt += 1;
|
|
153
|
+
reconnectTimer = setTimeout(openConnection, delay);
|
|
154
|
+
}
|
|
155
|
+
async function handleDaemonFrame(pipeIdBytes, payload, ws) {
|
|
156
|
+
const pipeKey = toBase64Url(pipeIdBytes);
|
|
157
|
+
const existing = channels.get(pipeKey);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
// First frame on a new pipe is the client's handshake initiation.
|
|
160
|
+
const { keys, message2 } = await respondToHandshake(options.identity, ridBytes, payload);
|
|
161
|
+
channels.set(pipeKey, new RelaySecureChannel(keys, 'daemon'));
|
|
162
|
+
ws.send(framePipePayload(pipeIdBytes, message2));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await serveTunneledFrame(pipeKey, existing, pipeIdBytes, payload, ws);
|
|
166
|
+
}
|
|
167
|
+
async function serveTunneledFrame(pipeKey, channel, pipeIdBytes, sealed, ws) {
|
|
168
|
+
const framed = decodeTunnelFrame(await channel.open(sealed));
|
|
169
|
+
if (!framed)
|
|
170
|
+
return;
|
|
171
|
+
const header = framed.header;
|
|
172
|
+
if (header.kind === 'request') {
|
|
173
|
+
await serveTunneledRequest(header, framed.body, channel, pipeIdBytes, ws);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (header.kind === 'stream-open') {
|
|
177
|
+
await openTunneledStream(pipeKey, header, channel, pipeIdBytes, ws);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (header.kind === 'stream-close') {
|
|
181
|
+
closeTunneledStream(pipeKey, header.id);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Other kinds (response, stream-data, stream-overflow) are daemon → surface
|
|
185
|
+
// only; a surface should never send them. Ignore rather than trust.
|
|
186
|
+
}
|
|
187
|
+
async function serveTunneledRequest(header, body, channel, pipeIdBytes, ws) {
|
|
188
|
+
const request = new Request(new URL(header.path, options.localBaseUrl), {
|
|
189
|
+
method: header.method,
|
|
190
|
+
headers: [...header.headers.map(([k, v]) => [k, v]), [RELAY_VIA_HEADER, '1']],
|
|
191
|
+
...(body.length > 0 ? { body } : {}),
|
|
192
|
+
});
|
|
193
|
+
let response;
|
|
194
|
+
try {
|
|
195
|
+
response = (await options.dispatch(request)) ?? new Response('Not found', { status: 404 });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
logger.error('relay dispatch failed', { error: String(err) });
|
|
199
|
+
response = new Response('Internal error', { status: 500 });
|
|
200
|
+
}
|
|
201
|
+
const bodyBytes = new Uint8Array(await response.arrayBuffer());
|
|
202
|
+
const respHeaders = [];
|
|
203
|
+
response.headers.forEach((value, key) => respHeaders.push([key, value]));
|
|
204
|
+
const out = encodeTunnelFrame({ id: header.id, kind: 'response', status: response.status, headers: respHeaders }, new Uint8Array(bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength)));
|
|
205
|
+
ws.send(framePipePayload(pipeIdBytes, await channel.seal(out)));
|
|
206
|
+
}
|
|
207
|
+
async function openTunneledStream(pipeKey, header, channel, pipeIdBytes, ws) {
|
|
208
|
+
const streams = pipeStreams.get(pipeKey) ?? new Map();
|
|
209
|
+
pipeStreams.set(pipeKey, streams);
|
|
210
|
+
const pump = new DaemonStreamPump(header.id, channel, pipeIdBytes, ws, streamBufferChunks, (error) => logger.error('relay stream send failed', { error: String(error) }));
|
|
211
|
+
// Per-pipe stream cap: refuse a new stream with an immediate close notice.
|
|
212
|
+
if (streams.size >= maxStreamsPerPipe || streams.has(header.id)) {
|
|
213
|
+
await pump.close('stream-limit');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const record = { pump, reader: null };
|
|
217
|
+
streams.set(header.id, record);
|
|
218
|
+
const request = new Request(new URL(header.path, options.localBaseUrl), {
|
|
219
|
+
method: header.method,
|
|
220
|
+
headers: [...header.headers.map(([k, v]) => [k, v]), [RELAY_VIA_HEADER, '1']],
|
|
221
|
+
});
|
|
222
|
+
let response;
|
|
223
|
+
try {
|
|
224
|
+
response = await options.dispatch(request);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
logger.error('relay stream dispatch failed', { error: String(err) });
|
|
228
|
+
response = null;
|
|
229
|
+
}
|
|
230
|
+
if (!response || !response.body) {
|
|
231
|
+
streams.delete(header.id);
|
|
232
|
+
await pump.close('no-stream');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const reader = response.body.getReader();
|
|
236
|
+
record.reader = reader;
|
|
237
|
+
// Pump loop: read the daemon's event source and forward chunks. The await on
|
|
238
|
+
// each read applies natural backpressure to the source; the pump's bounded
|
|
239
|
+
// buffer + overflow notice covers the send side.
|
|
240
|
+
void (async () => {
|
|
241
|
+
try {
|
|
242
|
+
for (;;) {
|
|
243
|
+
const { done, value } = await reader.read();
|
|
244
|
+
if (done)
|
|
245
|
+
break;
|
|
246
|
+
if (value)
|
|
247
|
+
pump.enqueue(new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)));
|
|
248
|
+
}
|
|
249
|
+
await pump.close('source-ended');
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
logger.warn('relay stream source error', { error: String(err) });
|
|
253
|
+
await pump.close('source-error');
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
streams.delete(header.id);
|
|
257
|
+
if (streams.size === 0)
|
|
258
|
+
pipeStreams.delete(pipeKey);
|
|
259
|
+
}
|
|
260
|
+
})();
|
|
261
|
+
}
|
|
262
|
+
function closeTunneledStream(pipeKey, streamId) {
|
|
263
|
+
const streams = pipeStreams.get(pipeKey);
|
|
264
|
+
const record = streams?.get(streamId);
|
|
265
|
+
if (!record)
|
|
266
|
+
return;
|
|
267
|
+
record.reader?.cancel().catch(() => { });
|
|
268
|
+
void record.pump.close('client-unsubscribed');
|
|
269
|
+
streams.delete(streamId);
|
|
270
|
+
if (streams.size === 0)
|
|
271
|
+
pipeStreams.delete(pipeKey);
|
|
272
|
+
}
|
|
273
|
+
function openConnection() {
|
|
274
|
+
if (stopped)
|
|
275
|
+
return;
|
|
276
|
+
reconnectTimer = null;
|
|
277
|
+
setStatus(attempt === 0 ? 'connecting' : 'reconnecting');
|
|
278
|
+
for (const pipeKey of [...pipeStreams.keys()])
|
|
279
|
+
closePipeStreams(pipeKey);
|
|
280
|
+
channels.clear();
|
|
281
|
+
let ws;
|
|
282
|
+
try {
|
|
283
|
+
ws = makeSocket(options.relayUrl);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
logger.error('relay socket construction failed', { error: String(err) });
|
|
287
|
+
scheduleReconnect();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
ws.binaryType = 'arraybuffer';
|
|
291
|
+
socket = ws;
|
|
292
|
+
ws.addEventListener('open', () => {
|
|
293
|
+
ws.send(encodeControlFrame({ t: 'register', role: 'daemon', protocol: RELAY_PROTOCOL_VERSION, rid: options.rid }));
|
|
294
|
+
});
|
|
295
|
+
ws.addEventListener('message', (event) => {
|
|
296
|
+
const data = event.data;
|
|
297
|
+
void (async () => {
|
|
298
|
+
try {
|
|
299
|
+
if (typeof data === 'string') {
|
|
300
|
+
const frame = decodeControlFrame(data);
|
|
301
|
+
if (!frame)
|
|
302
|
+
return;
|
|
303
|
+
if (frame.t === 'registered') {
|
|
304
|
+
attempt = 0;
|
|
305
|
+
setStatus('registered');
|
|
306
|
+
logger.info('relay registered', { rid: options.rid });
|
|
307
|
+
}
|
|
308
|
+
else if (frame.t === 'pipe-close') {
|
|
309
|
+
channels.delete(frame.pipe);
|
|
310
|
+
closePipeStreams(frame.pipe);
|
|
311
|
+
}
|
|
312
|
+
else if (frame.t === 'error') {
|
|
313
|
+
logger.warn('relay error frame', { code: frame.code, message: frame.message });
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const bytes = toBytes(data);
|
|
318
|
+
if (!bytes)
|
|
319
|
+
return;
|
|
320
|
+
const split = unframePipePayload(bytes);
|
|
321
|
+
if (!split)
|
|
322
|
+
return;
|
|
323
|
+
await handleDaemonFrame(split.pipeId.slice(), split.payload.slice(), ws);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
logger.error('relay frame handling failed', { error: String(err) });
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
});
|
|
330
|
+
ws.addEventListener('close', () => {
|
|
331
|
+
socket = null;
|
|
332
|
+
if (!stopped)
|
|
333
|
+
scheduleReconnect();
|
|
334
|
+
});
|
|
335
|
+
ws.addEventListener('error', () => {
|
|
336
|
+
logger.warn('relay websocket error', { rid: options.rid });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
start() {
|
|
341
|
+
if (!stopped && status !== 'idle')
|
|
342
|
+
return;
|
|
343
|
+
stopped = false;
|
|
344
|
+
attempt = 0;
|
|
345
|
+
openConnection();
|
|
346
|
+
},
|
|
347
|
+
stop() {
|
|
348
|
+
stopped = true;
|
|
349
|
+
if (reconnectTimer)
|
|
350
|
+
clearTimeout(reconnectTimer);
|
|
351
|
+
reconnectTimer = null;
|
|
352
|
+
for (const pipeKey of [...pipeStreams.keys()])
|
|
353
|
+
closePipeStreams(pipeKey);
|
|
354
|
+
channels.clear();
|
|
355
|
+
try {
|
|
356
|
+
socket?.close();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// ignore
|
|
360
|
+
}
|
|
361
|
+
socket = null;
|
|
362
|
+
setStatus('stopped');
|
|
363
|
+
},
|
|
364
|
+
get status() {
|
|
365
|
+
return status;
|
|
366
|
+
},
|
|
367
|
+
async mintPairing(label) {
|
|
368
|
+
return createRelayPairingPayload({
|
|
369
|
+
relayUrl: options.relayUrl,
|
|
370
|
+
rid: options.rid,
|
|
371
|
+
daemonPublicKey: await relayIdentityPublicKeyBase64Url(options.identity),
|
|
372
|
+
...(label !== undefined ? { label } : {}),
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { type RelayConnection, type RelayServerLimits, type RelayServerLogger } from './relay-server.js';
|
|
3
|
+
interface RelayConnectionData {
|
|
4
|
+
conn: RelayConnection | null;
|
|
5
|
+
readonly addr: string;
|
|
6
|
+
}
|
|
7
|
+
/** Options for {@link createBunRelayServer}. */
|
|
8
|
+
export interface BunRelayServerOptions {
|
|
9
|
+
/** TCP port to listen on (default 8787). */
|
|
10
|
+
readonly port?: number;
|
|
11
|
+
/** Hostname/interface to bind (default all interfaces). */
|
|
12
|
+
readonly hostname?: string;
|
|
13
|
+
/** Caps and rate limits (merged over DEFAULT_RELAY_LIMITS). */
|
|
14
|
+
readonly limits?: Partial<RelayServerLimits>;
|
|
15
|
+
/** Structured logger (default console). */
|
|
16
|
+
readonly logger?: RelayServerLogger;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Start the relay on Bun. Returns the Bun server handle (call `.stop()` to shut
|
|
20
|
+
* down). The health endpoint `GET /` reports occupancy; everything else is the
|
|
21
|
+
* WebSocket upgrade path.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createBunRelayServer(options?: BunRelayServerOptions): Bun.Server<RelayConnectionData>;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=relay-server-entry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-server-entry.d.ts","sourceRoot":"","sources":["../src/relay-server-entry.ts"],"names":[],"mappings":";AAcA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAE3B,UAAU,mBAAmB;IAC3B,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,gDAAgD;AAChD,MAAM,WAAW,qBAAqB;IACpC,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC7C,2CAA2C;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAaD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,qBAA0B,mCAsDvE"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// relay-server-entry.ts
|
|
3
|
+
//
|
|
4
|
+
// The Bun.serve adapter and standalone entry point for the rendezvous relay.
|
|
5
|
+
// This is what you deploy on a VPS:
|
|
6
|
+
//
|
|
7
|
+
// bun packages/daemon-sdk/dist/relay-server-entry.js
|
|
8
|
+
// or, once published:
|
|
9
|
+
// bunx --bun @pellux/goodvibes-daemon-sdk-relay
|
|
10
|
+
//
|
|
11
|
+
// It binds a WebSocket endpoint and drives the runtime-neutral RelayHub. All the
|
|
12
|
+
// zero-knowledge properties live in the hub and the transport-core relay crypto;
|
|
13
|
+
// this file is only glue.
|
|
14
|
+
import { DEFAULT_RELAY_LIMITS, RelayHub, } from './relay-server.js';
|
|
15
|
+
const consoleLogger = {
|
|
16
|
+
info: (m, f) => console.log(`[relay] ${m}`, f ?? ''),
|
|
17
|
+
warn: (m, f) => console.warn(`[relay] ${m}`, f ?? ''),
|
|
18
|
+
error: (m, f) => console.error(`[relay] ${m}`, f ?? ''),
|
|
19
|
+
};
|
|
20
|
+
function toArrayBufferBytes(message) {
|
|
21
|
+
if (message instanceof ArrayBuffer)
|
|
22
|
+
return new Uint8Array(message.slice(0));
|
|
23
|
+
return new Uint8Array(new Uint8Array(message.buffer, message.byteOffset, message.byteLength));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Start the relay on Bun. Returns the Bun server handle (call `.stop()` to shut
|
|
27
|
+
* down). The health endpoint `GET /` reports occupancy; everything else is the
|
|
28
|
+
* WebSocket upgrade path.
|
|
29
|
+
*/
|
|
30
|
+
export function createBunRelayServer(options = {}) {
|
|
31
|
+
const hub = new RelayHub({
|
|
32
|
+
...(options.limits ? { limits: options.limits } : {}),
|
|
33
|
+
logger: options.logger ?? consoleLogger,
|
|
34
|
+
});
|
|
35
|
+
const port = options.port ?? 8787;
|
|
36
|
+
const server = Bun.serve({
|
|
37
|
+
port,
|
|
38
|
+
...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
|
|
39
|
+
fetch(req, srv) {
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
42
|
+
const addr = srv.requestIP(req)?.address ?? 'unknown';
|
|
43
|
+
const data = { conn: null, addr };
|
|
44
|
+
if (srv.upgrade(req, { data }))
|
|
45
|
+
return undefined;
|
|
46
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
if (url.pathname === '/' || url.pathname === '/healthz') {
|
|
49
|
+
return Response.json({ ok: true, protocol: 1, ...hub.stats() });
|
|
50
|
+
}
|
|
51
|
+
return new Response('Not found', { status: 404 });
|
|
52
|
+
},
|
|
53
|
+
websocket: {
|
|
54
|
+
maxPayloadLength: (options.limits?.maxMessageBytes ?? DEFAULT_RELAY_LIMITS.maxMessageBytes) + 1024,
|
|
55
|
+
open(ws) {
|
|
56
|
+
ws.data.conn = hub.accept({
|
|
57
|
+
send: (data) => {
|
|
58
|
+
ws.send(data);
|
|
59
|
+
},
|
|
60
|
+
close: (code, reason) => {
|
|
61
|
+
ws.close(code, reason);
|
|
62
|
+
},
|
|
63
|
+
}, ws.data.addr);
|
|
64
|
+
},
|
|
65
|
+
message(ws, message) {
|
|
66
|
+
const conn = ws.data.conn;
|
|
67
|
+
if (!conn)
|
|
68
|
+
return;
|
|
69
|
+
if (typeof message === 'string') {
|
|
70
|
+
conn.handleText(message);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
conn.handleBinary(toArrayBufferBytes(message));
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
close(ws) {
|
|
77
|
+
ws.data.conn?.handleClose();
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return server;
|
|
82
|
+
}
|
|
83
|
+
if (import.meta.main) {
|
|
84
|
+
const port = Number(process.env['GOODVIBES_RELAY_PORT'] ?? 8787);
|
|
85
|
+
const hostname = process.env['GOODVIBES_RELAY_HOST'];
|
|
86
|
+
const server = createBunRelayServer({
|
|
87
|
+
port,
|
|
88
|
+
...(hostname ? { hostname } : {}),
|
|
89
|
+
});
|
|
90
|
+
console.log(`[relay] listening on ${server.hostname}:${server.port} (protocol 1)`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type PipeId, type RelayErrorCode, type RendezvousId } from '@pellux/goodvibes-transport-core/relay';
|
|
2
|
+
/** The subset of a WebSocket the hub needs. Keeps the hub runtime-neutral. */
|
|
3
|
+
export interface RelayServerSocket {
|
|
4
|
+
send(data: string | Uint8Array): void;
|
|
5
|
+
close(code?: number, reason?: string): void;
|
|
6
|
+
}
|
|
7
|
+
/** Optional structured logger; defaults to silent. */
|
|
8
|
+
export interface RelayServerLogger {
|
|
9
|
+
info(message: string, fields?: Record<string, unknown>): void;
|
|
10
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
11
|
+
error(message: string, fields?: Record<string, unknown>): void;
|
|
12
|
+
}
|
|
13
|
+
/** Caps and rate limits that keep a public instance from becoming a liability. */
|
|
14
|
+
export interface RelayServerLimits {
|
|
15
|
+
/** Maximum concurrently-registered daemons. */
|
|
16
|
+
readonly maxDaemons: number;
|
|
17
|
+
/** Maximum concurrent client pipes across all daemons. */
|
|
18
|
+
readonly maxPipes: number;
|
|
19
|
+
/** Maximum concurrent client pipes for a single daemon. */
|
|
20
|
+
readonly maxPipesPerDaemon: number;
|
|
21
|
+
/** Maximum bytes in a single forwarded data frame. */
|
|
22
|
+
readonly maxMessageBytes: number;
|
|
23
|
+
/** Maximum register/connect attempts per remote address per rolling minute. */
|
|
24
|
+
readonly maxHandshakesPerMinutePerAddr: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_RELAY_LIMITS: RelayServerLimits;
|
|
27
|
+
/**
|
|
28
|
+
* The runtime-neutral rendezvous core. One instance per relay process. Every
|
|
29
|
+
* connection is represented by a `RelayConnection` returned from `accept`.
|
|
30
|
+
*/
|
|
31
|
+
export declare class RelayHub {
|
|
32
|
+
private readonly daemons;
|
|
33
|
+
private readonly pipes;
|
|
34
|
+
private readonly limits;
|
|
35
|
+
private readonly logger;
|
|
36
|
+
private readonly rateLimiter;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
limits?: Partial<RelayServerLimits>;
|
|
39
|
+
logger?: RelayServerLogger;
|
|
40
|
+
});
|
|
41
|
+
/** Current occupancy — surfaces/monitoring can read this. */
|
|
42
|
+
stats(): {
|
|
43
|
+
readonly daemons: number;
|
|
44
|
+
readonly pipes: number;
|
|
45
|
+
};
|
|
46
|
+
/** Maximum bytes allowed in a single forwarded data frame. */
|
|
47
|
+
get maxMessageBytes(): number;
|
|
48
|
+
/** Begin tracking a freshly-connected socket. */
|
|
49
|
+
accept(socket: RelayServerSocket, remoteAddr: string): RelayConnection;
|
|
50
|
+
/** @internal */
|
|
51
|
+
_rateOk(addr: string): boolean;
|
|
52
|
+
/** @internal Register a daemon under a rendezvous id. Returns an error code or null on success. */
|
|
53
|
+
_register(rid: RendezvousId, socket: RelayServerSocket): RelayErrorCode | null;
|
|
54
|
+
/** @internal Open a client pipe to a registered daemon. */
|
|
55
|
+
_openPipe(rid: RendezvousId, client: RelayServerSocket): {
|
|
56
|
+
pipeId: PipeId;
|
|
57
|
+
pipeIdBytes: Uint8Array<ArrayBuffer>;
|
|
58
|
+
} | RelayErrorCode;
|
|
59
|
+
/** @internal Forward an opaque payload from a client to its daemon (adds pipe prefix). */
|
|
60
|
+
_clientToDaemon(pipeId: PipeId, payload: Uint8Array<ArrayBuffer>): boolean;
|
|
61
|
+
/** @internal Forward an opaque daemon frame ([pipeId][payload]) to the right client. */
|
|
62
|
+
_daemonToClient(frame: Uint8Array<ArrayBuffer>): boolean;
|
|
63
|
+
/** @internal Tear down a single pipe, notifying the surviving peer. */
|
|
64
|
+
_closePipe(pipeId: PipeId, notify: 'client' | 'daemon' | 'both', reason?: string): void;
|
|
65
|
+
/** @internal Remove a daemon registration and close all its pipes. */
|
|
66
|
+
_unregister(rid: RendezvousId): void;
|
|
67
|
+
}
|
|
68
|
+
/** Per-connection state machine. One instance per accepted socket. */
|
|
69
|
+
export declare class RelayConnection {
|
|
70
|
+
private readonly hub;
|
|
71
|
+
private readonly socket;
|
|
72
|
+
private readonly remoteAddr;
|
|
73
|
+
private role;
|
|
74
|
+
private rid;
|
|
75
|
+
private pipeId;
|
|
76
|
+
constructor(hub: RelayHub, socket: RelayServerSocket, remoteAddr: string);
|
|
77
|
+
/** Handle a text (control) frame from this socket. */
|
|
78
|
+
handleText(text: string): void;
|
|
79
|
+
/** Handle a binary (data) frame from this socket. */
|
|
80
|
+
handleBinary(bytes: Uint8Array<ArrayBuffer>): void;
|
|
81
|
+
/** Handle socket closure. */
|
|
82
|
+
handleClose(): void;
|
|
83
|
+
private onRegister;
|
|
84
|
+
private onConnect;
|
|
85
|
+
private fail;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=relay-server.d.ts.map
|