@camerontaylor/paseo-relay 0.8.0-fork.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.
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +17 -0
- package/dist/cloudflare-adapter.d.ts +76 -0
- package/dist/cloudflare-adapter.js +504 -0
- package/dist/crypto.d.ts +30 -0
- package/dist/crypto.js +145 -0
- package/dist/cutover-proxy.d.ts +6 -0
- package/dist/cutover-proxy.js +12 -0
- package/dist/e2ee.d.ts +5 -0
- package/dist/e2ee.js +3 -0
- package/dist/encrypted-channel.d.ts +86 -0
- package/dist/encrypted-channel.js +452 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +11 -0
- package/package.json +54 -0
package/dist/base64.d.ts
ADDED
package/dist/base64.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fromByteArray, toByteArray } from "base64-js";
|
|
2
|
+
export function arrayBufferToBase64(buffer) {
|
|
3
|
+
return fromByteArray(new Uint8Array(buffer));
|
|
4
|
+
}
|
|
5
|
+
export function base64ToArrayBuffer(base64) {
|
|
6
|
+
const normalized = (() => {
|
|
7
|
+
const trimmed = base64.trim();
|
|
8
|
+
const standard = trimmed.replace(/-/g, "+").replace(/_/g, "/");
|
|
9
|
+
const padLen = (4 - (standard.length % 4)) % 4;
|
|
10
|
+
return standard + "=".repeat(padLen);
|
|
11
|
+
})();
|
|
12
|
+
const bytes = toByteArray(normalized);
|
|
13
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
14
|
+
out.set(bytes);
|
|
15
|
+
return out.buffer;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=base64.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Durable Objects adapter for the relay.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Durable Object class that can be deployed to
|
|
5
|
+
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
|
6
|
+
*
|
|
7
|
+
* Each session gets its own Durable Object instance, identified by session ID.
|
|
8
|
+
*
|
|
9
|
+
* Wrangler config:
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "durable_objects": {
|
|
13
|
+
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
|
14
|
+
* },
|
|
15
|
+
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
interface DurableObjectState {
|
|
20
|
+
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
|
|
21
|
+
getWebSockets(tag?: string): WebSocket[];
|
|
22
|
+
}
|
|
23
|
+
interface Env {
|
|
24
|
+
RELAY: DurableObjectNamespace;
|
|
25
|
+
PASEO_RELAY_UPSTREAM?: string;
|
|
26
|
+
}
|
|
27
|
+
interface DurableObjectNamespace {
|
|
28
|
+
idFromName(name: string): DurableObjectId;
|
|
29
|
+
get(id: DurableObjectId): DurableObjectStub;
|
|
30
|
+
}
|
|
31
|
+
interface DurableObjectId {
|
|
32
|
+
toString(): string;
|
|
33
|
+
}
|
|
34
|
+
interface DurableObjectStub {
|
|
35
|
+
fetch(request: Request): Promise<Response>;
|
|
36
|
+
}
|
|
37
|
+
export declare class RelayDurableObject {
|
|
38
|
+
private state;
|
|
39
|
+
private pendingFrames;
|
|
40
|
+
constructor(state: DurableObjectState);
|
|
41
|
+
private createWebSocketPair;
|
|
42
|
+
private requireWebSocketUpgrade;
|
|
43
|
+
private asSwitchingProtocolsResponse;
|
|
44
|
+
private hasServerDataSocket;
|
|
45
|
+
private hasClientSocket;
|
|
46
|
+
private closeExistingServerSockets;
|
|
47
|
+
private handleControlKeepalive;
|
|
48
|
+
private nudgeOrResetControlForConnection;
|
|
49
|
+
private bufferFrame;
|
|
50
|
+
private flushFrames;
|
|
51
|
+
private listConnectedConnectionIds;
|
|
52
|
+
private notifyControls;
|
|
53
|
+
private fetchV1;
|
|
54
|
+
private fetchV2;
|
|
55
|
+
fetch(request: Request): Promise<Response>;
|
|
56
|
+
/**
|
|
57
|
+
* Called when a WebSocket message is received (wakes from hibernation).
|
|
58
|
+
*/
|
|
59
|
+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void;
|
|
60
|
+
/**
|
|
61
|
+
* Called when a WebSocket closes (wakes from hibernation).
|
|
62
|
+
*/
|
|
63
|
+
webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): void;
|
|
64
|
+
/**
|
|
65
|
+
* Called on WebSocket error.
|
|
66
|
+
*/
|
|
67
|
+
webSocketError(ws: WebSocket, error: unknown): void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Worker entry point that routes requests to the appropriate Durable Object.
|
|
71
|
+
*/
|
|
72
|
+
declare const _default: {
|
|
73
|
+
fetch(request: Request, env: Env): Promise<Response>;
|
|
74
|
+
};
|
|
75
|
+
export default _default;
|
|
76
|
+
//# sourceMappingURL=cloudflare-adapter.d.ts.map
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Durable Objects adapter for the relay.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Durable Object class that can be deployed to
|
|
5
|
+
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
|
6
|
+
*
|
|
7
|
+
* Each session gets its own Durable Object instance, identified by session ID.
|
|
8
|
+
*
|
|
9
|
+
* Wrangler config:
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "durable_objects": {
|
|
13
|
+
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
|
14
|
+
* },
|
|
15
|
+
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { createCutoverProxy } from "./cutover-proxy.js";
|
|
20
|
+
const LEGACY_RELAY_VERSION = "1";
|
|
21
|
+
const CURRENT_RELAY_VERSION = "2";
|
|
22
|
+
function resolveRelayVersion(rawValue) {
|
|
23
|
+
if (rawValue == null)
|
|
24
|
+
return LEGACY_RELAY_VERSION;
|
|
25
|
+
const value = rawValue.trim();
|
|
26
|
+
if (!value)
|
|
27
|
+
return LEGACY_RELAY_VERSION;
|
|
28
|
+
if (value === LEGACY_RELAY_VERSION || value === CURRENT_RELAY_VERSION) {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function hasAttachmentMethods(ws) {
|
|
34
|
+
// Type-safe check for attachment methods - required for Cloudflare WebSocket hibernation API
|
|
35
|
+
// Use Reflect to check for methods without type assertions
|
|
36
|
+
return ("serializeAttachment" in ws &&
|
|
37
|
+
"deserializeAttachment" in ws &&
|
|
38
|
+
typeof Reflect.get(ws, "serializeAttachment") === "function" &&
|
|
39
|
+
typeof Reflect.get(ws, "deserializeAttachment") === "function");
|
|
40
|
+
}
|
|
41
|
+
function deserializeAttachment(ws) {
|
|
42
|
+
if (!hasAttachmentMethods(ws))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return ws.deserializeAttachment();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function serializeAttachment(ws, value) {
|
|
52
|
+
if (!hasAttachmentMethods(ws)) {
|
|
53
|
+
throw new Error("WebSocket does not support attachments");
|
|
54
|
+
}
|
|
55
|
+
ws.serializeAttachment(value);
|
|
56
|
+
}
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return typeof value === "object" && value !== null;
|
|
59
|
+
}
|
|
60
|
+
function getString(record, key) {
|
|
61
|
+
const value = record[key];
|
|
62
|
+
return typeof value === "string" ? value : undefined;
|
|
63
|
+
}
|
|
64
|
+
function getGlobalWebSocketPair() {
|
|
65
|
+
// Access WebSocketPair from global scope (Cloudflare Workers runtime)
|
|
66
|
+
// Use Reflect to access global property without type assertions
|
|
67
|
+
const WebSocketPair = Reflect.get(globalThis, "WebSocketPair");
|
|
68
|
+
if (typeof WebSocketPair === "function") {
|
|
69
|
+
return WebSocketPair;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export class RelayDurableObject {
|
|
74
|
+
constructor(state) {
|
|
75
|
+
this.pendingFrames = new Map();
|
|
76
|
+
this.state = state;
|
|
77
|
+
}
|
|
78
|
+
createWebSocketPair() {
|
|
79
|
+
const WebSocketPairCtor = getGlobalWebSocketPair();
|
|
80
|
+
if (!WebSocketPairCtor) {
|
|
81
|
+
throw new Error("WebSocketPair not available in global scope");
|
|
82
|
+
}
|
|
83
|
+
const pair = new WebSocketPairCtor();
|
|
84
|
+
return [pair[0], pair[1]];
|
|
85
|
+
}
|
|
86
|
+
requireWebSocketUpgrade(request) {
|
|
87
|
+
const upgradeHeader = request.headers.get("Upgrade");
|
|
88
|
+
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
|
89
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
asSwitchingProtocolsResponse(client) {
|
|
94
|
+
return new Response(null, {
|
|
95
|
+
status: 101,
|
|
96
|
+
webSocket: client,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
hasServerDataSocket(connectionId) {
|
|
100
|
+
try {
|
|
101
|
+
return this.state.getWebSockets(`server:${connectionId}`).length > 0;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
hasClientSocket(connectionId) {
|
|
108
|
+
try {
|
|
109
|
+
return this.state.getWebSockets(`client:${connectionId}`).length > 0;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
closeExistingServerSockets(args) {
|
|
116
|
+
if (args.isServerControl) {
|
|
117
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
118
|
+
ws.close(1008, "Replaced by new connection");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (args.isServerData) {
|
|
122
|
+
for (const ws of this.state.getWebSockets(`server:${args.resolvedConnectionId}`)) {
|
|
123
|
+
ws.close(1008, "Replaced by new connection");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// COMPAT(relay-json-ping): Old daemons (< v0.1.76) send JSON {type:"ping"} on the control
|
|
128
|
+
// socket and rely on a JSON {type:"pong"} reply to keep controlLastSeenAt fresh. New daemons
|
|
129
|
+
// use WebSocket protocol pings (auto-answered at the edge, DO stays hibernated). Remove this
|
|
130
|
+
// handler once the supported-daemon floor is >= v0.1.76 (target: 2026-11-13).
|
|
131
|
+
handleControlKeepalive(ws, message) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(message);
|
|
134
|
+
const parsedRecord = isRecord(parsed) ? parsed : null;
|
|
135
|
+
if (parsedRecord?.type !== "ping")
|
|
136
|
+
return;
|
|
137
|
+
// Logged so the daemon-side e2e idle test can assert no JSON ping reached the DO
|
|
138
|
+
// (which would indicate a regression to app-level pings that wake the DO).
|
|
139
|
+
console.log("[Relay DO] legacy_json_ping_received");
|
|
140
|
+
try {
|
|
141
|
+
ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// ignore non-JSON control payloads
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
nudgeOrResetControlForConnection(connectionId) {
|
|
152
|
+
// If the daemon's control WS becomes half-open, the DO can't reliably detect it via ws.send errors
|
|
153
|
+
// (Cloudflare may accept writes even if the other side is no longer reading).
|
|
154
|
+
//
|
|
155
|
+
// Instead, observe whether the daemon reacts by opening the per-connection server-data socket.
|
|
156
|
+
// If it doesn't, nudge with a sync message; if still no reaction, force-close the control
|
|
157
|
+
// socket(s) so the daemon reconnects.
|
|
158
|
+
const initialDelayMs = 10000;
|
|
159
|
+
const secondDelayMs = 5000;
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
if (!this.hasClientSocket(connectionId))
|
|
162
|
+
return;
|
|
163
|
+
if (this.hasServerDataSocket(connectionId))
|
|
164
|
+
return;
|
|
165
|
+
// First nudge: send a full sync list.
|
|
166
|
+
this.notifyControls({ type: "sync", connectionIds: this.listConnectedConnectionIds() });
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (!this.hasClientSocket(connectionId))
|
|
169
|
+
return;
|
|
170
|
+
if (this.hasServerDataSocket(connectionId))
|
|
171
|
+
return;
|
|
172
|
+
// Still nothing: assume control is stuck and force a reconnect.
|
|
173
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
174
|
+
try {
|
|
175
|
+
ws.close(1011, "Control unresponsive");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// ignore
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, secondDelayMs);
|
|
182
|
+
}, initialDelayMs);
|
|
183
|
+
}
|
|
184
|
+
bufferFrame(connectionId, message) {
|
|
185
|
+
const existing = this.pendingFrames.get(connectionId) ?? [];
|
|
186
|
+
existing.push(message);
|
|
187
|
+
// Prevent unbounded memory growth if a daemon never connects.
|
|
188
|
+
if (existing.length > 200) {
|
|
189
|
+
existing.splice(0, existing.length - 200);
|
|
190
|
+
}
|
|
191
|
+
this.pendingFrames.set(connectionId, existing);
|
|
192
|
+
}
|
|
193
|
+
flushFrames(connectionId, serverWs) {
|
|
194
|
+
const frames = this.pendingFrames.get(connectionId);
|
|
195
|
+
if (!frames || frames.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
this.pendingFrames.delete(connectionId);
|
|
198
|
+
for (const frame of frames) {
|
|
199
|
+
try {
|
|
200
|
+
serverWs.send(frame);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// If we can't flush, re-buffer and let the daemon re-establish.
|
|
204
|
+
this.bufferFrame(connectionId, frame);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
listConnectedConnectionIds() {
|
|
210
|
+
const out = new Set();
|
|
211
|
+
for (const ws of this.state.getWebSockets("client")) {
|
|
212
|
+
try {
|
|
213
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
214
|
+
const attachment = isRecord(attachmentRaw) ? attachmentRaw : null;
|
|
215
|
+
if (attachment?.role === "client" &&
|
|
216
|
+
typeof attachment.connectionId === "string" &&
|
|
217
|
+
attachment.connectionId) {
|
|
218
|
+
out.add(attachment.connectionId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// ignore
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return Array.from(out);
|
|
226
|
+
}
|
|
227
|
+
notifyControls(message) {
|
|
228
|
+
const text = JSON.stringify(message);
|
|
229
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
230
|
+
try {
|
|
231
|
+
ws.send(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// If the control socket is dead, close it so the daemon can reconnect.
|
|
235
|
+
try {
|
|
236
|
+
ws.close(1011, "Control send failed");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// ignore
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
fetchV1(request, role, serverId) {
|
|
245
|
+
const upgradeError = this.requireWebSocketUpgrade(request);
|
|
246
|
+
if (upgradeError)
|
|
247
|
+
return upgradeError;
|
|
248
|
+
for (const ws of this.state.getWebSockets(role)) {
|
|
249
|
+
ws.close(1008, "Replaced by new connection");
|
|
250
|
+
}
|
|
251
|
+
const [client, server] = this.createWebSocketPair();
|
|
252
|
+
this.state.acceptWebSocket(server, [role]);
|
|
253
|
+
const attachment = {
|
|
254
|
+
serverId,
|
|
255
|
+
role,
|
|
256
|
+
version: LEGACY_RELAY_VERSION,
|
|
257
|
+
connectionId: null,
|
|
258
|
+
createdAt: Date.now(),
|
|
259
|
+
};
|
|
260
|
+
serializeAttachment(server, attachment);
|
|
261
|
+
console.log(`[Relay DO] v1:${role} connected to session ${serverId}`);
|
|
262
|
+
return this.asSwitchingProtocolsResponse(client);
|
|
263
|
+
}
|
|
264
|
+
fetchV2(request, role, serverId, connectionId) {
|
|
265
|
+
const upgradeError = this.requireWebSocketUpgrade(request);
|
|
266
|
+
if (upgradeError)
|
|
267
|
+
return upgradeError;
|
|
268
|
+
// If a client didn't provide a connectionId, the relay assigns one for routing.
|
|
269
|
+
const resolvedConnectionId = role === "client" && !connectionId
|
|
270
|
+
? `conn_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`
|
|
271
|
+
: connectionId;
|
|
272
|
+
const isServerControl = role === "server" && !resolvedConnectionId;
|
|
273
|
+
const isServerData = role === "server" && !!resolvedConnectionId;
|
|
274
|
+
// Close any existing server-side connection with the same identity.
|
|
275
|
+
// - server-control: single per serverId
|
|
276
|
+
// - server-data: single per connectionId
|
|
277
|
+
// - client: many sockets per connectionId are allowed
|
|
278
|
+
this.closeExistingServerSockets({ isServerControl, isServerData, resolvedConnectionId });
|
|
279
|
+
const [client, server] = this.createWebSocketPair();
|
|
280
|
+
const tags = [];
|
|
281
|
+
if (role === "client") {
|
|
282
|
+
tags.push("client", `client:${resolvedConnectionId}`);
|
|
283
|
+
}
|
|
284
|
+
else if (isServerControl) {
|
|
285
|
+
tags.push("server-control");
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
tags.push("server", `server:${resolvedConnectionId}`);
|
|
289
|
+
}
|
|
290
|
+
this.state.acceptWebSocket(server, tags);
|
|
291
|
+
const attachment = {
|
|
292
|
+
serverId,
|
|
293
|
+
role,
|
|
294
|
+
version: CURRENT_RELAY_VERSION,
|
|
295
|
+
connectionId: resolvedConnectionId || null,
|
|
296
|
+
createdAt: Date.now(),
|
|
297
|
+
};
|
|
298
|
+
serializeAttachment(server, attachment);
|
|
299
|
+
let roleSuffix = "";
|
|
300
|
+
if (isServerControl) {
|
|
301
|
+
roleSuffix = "(control)";
|
|
302
|
+
}
|
|
303
|
+
else if (isServerData) {
|
|
304
|
+
roleSuffix = `(data:${resolvedConnectionId})`;
|
|
305
|
+
}
|
|
306
|
+
else if (role === "client") {
|
|
307
|
+
roleSuffix = `(${resolvedConnectionId})`;
|
|
308
|
+
}
|
|
309
|
+
console.log(`[Relay DO] v2:${role}${roleSuffix} connected to session ${serverId}`);
|
|
310
|
+
if (role === "client") {
|
|
311
|
+
this.notifyControls({ type: "connected", connectionId: resolvedConnectionId });
|
|
312
|
+
this.nudgeOrResetControlForConnection(resolvedConnectionId);
|
|
313
|
+
}
|
|
314
|
+
if (isServerControl) {
|
|
315
|
+
// Send current connection list so the daemon can attach existing connections.
|
|
316
|
+
try {
|
|
317
|
+
server.send(JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// ignore
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (isServerData && resolvedConnectionId) {
|
|
324
|
+
this.flushFrames(resolvedConnectionId, server);
|
|
325
|
+
}
|
|
326
|
+
return this.asSwitchingProtocolsResponse(client);
|
|
327
|
+
}
|
|
328
|
+
async fetch(request) {
|
|
329
|
+
const url = new URL(request.url);
|
|
330
|
+
const roleRaw = url.searchParams.get("role");
|
|
331
|
+
const role = roleRaw === "server" || roleRaw === "client" ? roleRaw : null;
|
|
332
|
+
const serverId = url.searchParams.get("serverId");
|
|
333
|
+
const connectionIdRaw = url.searchParams.get("connectionId");
|
|
334
|
+
const connectionId = typeof connectionIdRaw === "string" ? connectionIdRaw.trim() : "";
|
|
335
|
+
const version = resolveRelayVersion(url.searchParams.get("v"));
|
|
336
|
+
if (!role || (role !== "server" && role !== "client")) {
|
|
337
|
+
return new Response("Missing or invalid role parameter", { status: 400 });
|
|
338
|
+
}
|
|
339
|
+
if (!serverId) {
|
|
340
|
+
return new Response("Missing serverId parameter", { status: 400 });
|
|
341
|
+
}
|
|
342
|
+
if (!version) {
|
|
343
|
+
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
|
344
|
+
}
|
|
345
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
346
|
+
return this.fetchV1(request, role, serverId);
|
|
347
|
+
}
|
|
348
|
+
return this.fetchV2(request, role, serverId, connectionId);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Called when a WebSocket message is received (wakes from hibernation).
|
|
352
|
+
*/
|
|
353
|
+
webSocketMessage(ws, message) {
|
|
354
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
355
|
+
if (!isRecord(attachmentRaw)) {
|
|
356
|
+
console.error("[Relay DO] Message from WebSocket without attachment");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const attachment = attachmentRaw;
|
|
360
|
+
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
361
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
362
|
+
const targetRole = attachment.role === "server" ? "client" : "server";
|
|
363
|
+
const targets = this.state.getWebSockets(targetRole);
|
|
364
|
+
for (const target of targets) {
|
|
365
|
+
try {
|
|
366
|
+
target.send(message);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const role = getString(attachment, "role");
|
|
375
|
+
const connectionId = getString(attachment, "connectionId");
|
|
376
|
+
if (!connectionId) {
|
|
377
|
+
// Control channel: support simple app-level keepalive.
|
|
378
|
+
if (typeof message === "string") {
|
|
379
|
+
this.handleControlKeepalive(ws, message);
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (role === "client") {
|
|
384
|
+
const servers = this.state.getWebSockets(`server:${connectionId}`);
|
|
385
|
+
if (servers.length === 0) {
|
|
386
|
+
this.bufferFrame(connectionId, message);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const target of servers) {
|
|
390
|
+
try {
|
|
391
|
+
target.send(message);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
console.error(`[Relay DO] Failed to forward client->server(${connectionId}):`, error);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// server data socket -> client
|
|
400
|
+
const targets = this.state.getWebSockets(`client:${connectionId}`);
|
|
401
|
+
for (const target of targets) {
|
|
402
|
+
try {
|
|
403
|
+
target.send(message);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
console.error(`[Relay DO] Failed to forward server->client(${connectionId}):`, error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Called when a WebSocket closes (wakes from hibernation).
|
|
412
|
+
*/
|
|
413
|
+
webSocketClose(ws, code, reason, _wasClean) {
|
|
414
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
415
|
+
if (!isRecord(attachmentRaw))
|
|
416
|
+
return;
|
|
417
|
+
const attachment = attachmentRaw;
|
|
418
|
+
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
419
|
+
const role = getString(attachment, "role");
|
|
420
|
+
const connectionId = getString(attachment, "connectionId");
|
|
421
|
+
const serverId = getString(attachment, "serverId");
|
|
422
|
+
console.log(`[Relay DO] v${version}:${role ?? "unknown"}${connectionId ? `(${connectionId})` : ""} disconnected from session ${serverId ?? "unknown"} (${code}: ${reason})`);
|
|
423
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (role === "client" && connectionId) {
|
|
427
|
+
const remainingClientSockets = this.state
|
|
428
|
+
.getWebSockets(`client:${connectionId}`)
|
|
429
|
+
.some((socket) => socket !== ws);
|
|
430
|
+
if (remainingClientSockets) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.pendingFrames.delete(connectionId);
|
|
434
|
+
// Last socket for this session closed: now clean up matching server-data socket.
|
|
435
|
+
for (const serverWs of this.state.getWebSockets(`server:${connectionId}`)) {
|
|
436
|
+
try {
|
|
437
|
+
serverWs.close(1001, "Client disconnected");
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// ignore
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.notifyControls({ type: "disconnected", connectionId });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (role === "server" && connectionId) {
|
|
447
|
+
// Force the client to reconnect and re-handshake when the daemon side drops.
|
|
448
|
+
for (const clientWs of this.state.getWebSockets(`client:${connectionId}`)) {
|
|
449
|
+
try {
|
|
450
|
+
clientWs.close(1012, "Server disconnected");
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// ignore
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Called on WebSocket error.
|
|
460
|
+
*/
|
|
461
|
+
webSocketError(ws, error) {
|
|
462
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
463
|
+
const attachment = isRecord(attachmentRaw) ? attachmentRaw : null;
|
|
464
|
+
const role = attachment ? getString(attachment, "role") : undefined;
|
|
465
|
+
console.error(`[Relay DO] WebSocket error for ${role ?? "unknown"}:`, error);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Worker entry point that routes requests to the appropriate Durable Object.
|
|
470
|
+
*/
|
|
471
|
+
export default {
|
|
472
|
+
async fetch(request, env) {
|
|
473
|
+
if (env.PASEO_RELAY_UPSTREAM) {
|
|
474
|
+
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
|
|
475
|
+
}
|
|
476
|
+
const url = new URL(request.url);
|
|
477
|
+
// Health check
|
|
478
|
+
if (url.pathname === "/health") {
|
|
479
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
480
|
+
headers: { "Content-Type": "application/json" },
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Relay endpoint
|
|
484
|
+
if (url.pathname === "/ws") {
|
|
485
|
+
const serverId = url.searchParams.get("serverId");
|
|
486
|
+
if (!serverId) {
|
|
487
|
+
return new Response("Missing serverId parameter", { status: 400 });
|
|
488
|
+
}
|
|
489
|
+
const version = resolveRelayVersion(url.searchParams.get("v"));
|
|
490
|
+
if (!version) {
|
|
491
|
+
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
|
492
|
+
}
|
|
493
|
+
// Route to a version-isolated Durable Object instance.
|
|
494
|
+
const id = env.RELAY.idFromName(`relay-v${version}:${serverId}`);
|
|
495
|
+
const stub = env.RELAY.get(id);
|
|
496
|
+
const normalizedUrl = new URL(request.url);
|
|
497
|
+
normalizedUrl.searchParams.set("v", version);
|
|
498
|
+
const normalizedRequest = new Request(normalizedUrl.toString(), request);
|
|
499
|
+
return stub.fetch(normalizedRequest);
|
|
500
|
+
}
|
|
501
|
+
return new Response("Not found", { status: 404 });
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
//# sourceMappingURL=cloudflare-adapter.js.map
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE crypto primitives using NaCl (tweetnacl).
|
|
3
|
+
*
|
|
4
|
+
* - Key exchange: Curve25519 (nacl.box.before)
|
|
5
|
+
* - Encryption: XSalsa20-Poly1305 (nacl.box.after / open.after)
|
|
6
|
+
*
|
|
7
|
+
* Bundle format (binary):
|
|
8
|
+
* [nonce (24 bytes)] [ciphertext...]
|
|
9
|
+
*
|
|
10
|
+
* The encrypted channel chooses the WebSocket representation. Crypto remains
|
|
11
|
+
* byte-oriented so frame kind is never inferred from plaintext contents.
|
|
12
|
+
*/
|
|
13
|
+
export interface KeyPair {
|
|
14
|
+
publicKey: Uint8Array;
|
|
15
|
+
secretKey: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
export type SharedKey = Uint8Array;
|
|
18
|
+
export declare function generateKeyPair(): KeyPair;
|
|
19
|
+
export declare function exportPublicKey(publicKey: Uint8Array): string;
|
|
20
|
+
export declare function importPublicKey(base64: string): Uint8Array;
|
|
21
|
+
export declare function exportSecretKey(secretKey: Uint8Array): string;
|
|
22
|
+
export declare function importSecretKey(base64: string): Uint8Array;
|
|
23
|
+
export declare function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): SharedKey;
|
|
24
|
+
/**
|
|
25
|
+
* Encrypts data and returns the binary bundle:
|
|
26
|
+
* [nonce (24)] [ciphertext...]
|
|
27
|
+
*/
|
|
28
|
+
export declare function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): ArrayBuffer;
|
|
29
|
+
export declare function decrypt(sharedKey: SharedKey, data: ArrayBuffer): ArrayBuffer;
|
|
30
|
+
//# sourceMappingURL=crypto.d.ts.map
|