realtimeclipboard 0.3.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/LICENSE +21 -0
- package/README.md +243 -0
- package/cli/CLAUDE.md +28 -0
- package/cli/README.md +24 -0
- package/cli/realtimeclipboard.mjs +303 -0
- package/package.json +67 -0
- package/src/README.md +24 -0
- package/src/core/CLAUDE.md +31 -0
- package/src/core/README.md +18 -0
- package/src/core/bus.js +84 -0
- package/src/core/config.js +414 -0
- package/src/core/crypto.js +215 -0
- package/src/core/device.js +55 -0
- package/src/core/history.js +208 -0
- package/src/core/keys.js +150 -0
- package/src/core/paths.js +68 -0
- package/src/core/state.js +193 -0
- package/src/core/storage.js +182 -0
- package/src/transport/CLAUDE.md +30 -0
- package/src/transport/README.md +16 -0
- package/src/transport/protocol.js +68 -0
- package/src/transport/relay.js +423 -0
- package/src/transport/sse.js +244 -0
- package/src/transport/ws.js +76 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay connection: protocol, liveness, and which transport carries it.
|
|
3
|
+
*
|
|
4
|
+
* Everything above this file (main.js, and through it the whole app) sees one
|
|
5
|
+
* interface — connect / send / close / isOpen / setFrameHandler — and never
|
|
6
|
+
* learns how the bytes actually leave the machine. That boundary was designed
|
|
7
|
+
* in from the start (PRD §4.3, docs/ARCHITECTURE.md §3) against the risk that
|
|
8
|
+
* the deployed relay would not pass WebSocket upgrades. It turned out to be
|
|
9
|
+
* needed for a different reason: the *client's* network.
|
|
10
|
+
*
|
|
11
|
+
* On a managed corporate network — the primary deployment context (PRD §5.4) —
|
|
12
|
+
* a TLS-inspecting proxy may refuse the HTTP Upgrade that a WebSocket needs, or
|
|
13
|
+
* accept the connection and then swallow it, leaving a socket that never opens
|
|
14
|
+
* and never closes. So the transport is not a build-time decision:
|
|
15
|
+
*
|
|
16
|
+
* 1. Try WebSocket. Give it NET.PROBE_MS to become usable.
|
|
17
|
+
* 2. Two consecutive attempts that never become usable is a policy, not a
|
|
18
|
+
* flaky moment — switch to SSE+POST, which is plain HTTP and needs no
|
|
19
|
+
* Upgrade, and say so out loud.
|
|
20
|
+
* 3. If that is blocked too, keep alternating rather than giving up on one:
|
|
21
|
+
* a network that refuses both is a different problem, and the user is told
|
|
22
|
+
* exactly that instead of watching "Reconnecting" forever.
|
|
23
|
+
*
|
|
24
|
+
* The choice is remembered (storage.js), so someone behind that proxy pays the
|
|
25
|
+
* probe once rather than on every load, and re-probes when the memory expires
|
|
26
|
+
* or they move to another network.
|
|
27
|
+
*
|
|
28
|
+
* The two channels live in ws.js and sse.js and implement one contract. All the
|
|
29
|
+
* protocol handling, heartbeat and backoff is here, once, for both.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { RELAY_URL, NET, TRANSPORT } from "../core/config.js";
|
|
33
|
+
import { emit, EV } from "../core/bus.js";
|
|
34
|
+
import * as state from "../core/state.js";
|
|
35
|
+
import * as storage from "../core/storage.js";
|
|
36
|
+
import * as proto from "./protocol.js";
|
|
37
|
+
import * as wsChannel from "./ws.js";
|
|
38
|
+
import * as sseChannel from "./sse.js";
|
|
39
|
+
|
|
40
|
+
const CHANNELS = {
|
|
41
|
+
[TRANSPORT.WS]: wsChannel,
|
|
42
|
+
[TRANSPORT.SSE]: sseChannel,
|
|
43
|
+
};
|
|
44
|
+
const OTHER = {
|
|
45
|
+
[TRANSPORT.WS]: TRANSPORT.SSE,
|
|
46
|
+
[TRANSPORT.SSE]: TRANSPORT.WS,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** What the status bar appends. The default transport needs no announcement. */
|
|
50
|
+
const NOTE = {
|
|
51
|
+
[TRANSPORT.WS]: "",
|
|
52
|
+
[TRANSPORT.SSE]: "HTTP fallback",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Non-transport frames are handed up; the caller decrypts. */
|
|
56
|
+
let onFrame = () => {};
|
|
57
|
+
export const setFrameHandler = fn => { onFrame = fn; };
|
|
58
|
+
|
|
59
|
+
let channel = null;
|
|
60
|
+
let session = null; // {roomHash, intent, url, name}
|
|
61
|
+
let wantOpen = false;
|
|
62
|
+
let opened = false; // has the CURRENT attempt become usable?
|
|
63
|
+
let mode = TRANSPORT.WS;
|
|
64
|
+
let announced = null; // last transport reported on the bus
|
|
65
|
+
let stuck = false; // both transports have failed; cleared by any success
|
|
66
|
+
let unsupported = false; // the relay is reachable but predates the fallback
|
|
67
|
+
let forced = null; // a transport the user pinned by hand; null = auto
|
|
68
|
+
let failures = { [TRANSPORT.WS]: 0, [TRANSPORT.SSE]: 0 };
|
|
69
|
+
let backoff = NET.BACKOFF_MIN_MS;
|
|
70
|
+
let heartbeat = null;
|
|
71
|
+
let probe = null;
|
|
72
|
+
let pingSentAt = 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Which attempt is the current one.
|
|
76
|
+
*
|
|
77
|
+
* Rejoining, rotating the key and recovering from a collision all close the
|
|
78
|
+
* connection and open a new one — while a reconnect timer from the old one may
|
|
79
|
+
* still be pending. Without a generation to compare against, that timer wakes
|
|
80
|
+
* up, sees `wantOpen`, and opens a second connection alongside the live one:
|
|
81
|
+
* the user is in the room twice, sees their own clips echoed back, and one of
|
|
82
|
+
* the two sockets is unreachable by anything. Every callback and timer below
|
|
83
|
+
* captures this and does nothing if it has moved on.
|
|
84
|
+
*/
|
|
85
|
+
let epoch = 0;
|
|
86
|
+
|
|
87
|
+
/** Which transport is live right now. For the UI and for debugging. */
|
|
88
|
+
export const transport = () => mode;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `auth` is a locked session's admission token (core/crypto.js `deriveLocked`).
|
|
92
|
+
* It is HKDF output, not the PIN and not the key — the relay can check that two
|
|
93
|
+
* peers agree without being able to reverse it into either. Absent for open
|
|
94
|
+
* sessions, and a relay that predates it ignores the parameter.
|
|
95
|
+
*/
|
|
96
|
+
export function connect({ roomHash, intent = "join", url = RELAY_URL, name = "", auth = null }) {
|
|
97
|
+
channel?.close(1000); // a second connect() replaces, never stacks
|
|
98
|
+
session = { roomHash, intent, url, name, auth };
|
|
99
|
+
wantOpen = true;
|
|
100
|
+
forced = storage.loadTransportChoice();
|
|
101
|
+
failures = { [TRANSPORT.WS]: 0, [TRANSPORT.SSE]: 0 };
|
|
102
|
+
backoff = NET.BACKOFF_MIN_MS;
|
|
103
|
+
mode = preferred();
|
|
104
|
+
start();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function send(obj) {
|
|
108
|
+
return channel?.send(obj) ?? false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function close() {
|
|
112
|
+
wantOpen = false;
|
|
113
|
+
epoch++; // anything still pending belongs to a dead session
|
|
114
|
+
stopTimers();
|
|
115
|
+
channel?.close(1000);
|
|
116
|
+
channel = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const isOpen = () => channel?.isOpen() === true;
|
|
120
|
+
|
|
121
|
+
/* ------------------------------------------------------------------
|
|
122
|
+
transport selection
|
|
123
|
+
------------------------------------------------------------------- */
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where to start.
|
|
127
|
+
*
|
|
128
|
+
* A hand-picked transport wins outright, and never expires — it is an
|
|
129
|
+
* instruction, not a measurement. Failing over would undo it, so a forced
|
|
130
|
+
* transport keeps retrying itself and the status bar says it is locked.
|
|
131
|
+
*
|
|
132
|
+
* Failing that, a remembered choice: on a network that blocks WebSockets,
|
|
133
|
+
* re-probing costs NET.PROBE_MS of "Connecting…" on every single load and the
|
|
134
|
+
* answer is the same every time. storage.js expires that memory on its own, so
|
|
135
|
+
* moving off that network re-probes rather than pinning the user to the slower
|
|
136
|
+
* transport forever.
|
|
137
|
+
*/
|
|
138
|
+
function preferred() {
|
|
139
|
+
if (forced && CHANNELS[forced]?.available()) return forced;
|
|
140
|
+
const remembered = storage.loadTransport();
|
|
141
|
+
if (remembered && CHANNELS[remembered]?.available()) return remembered;
|
|
142
|
+
return wsChannel.available() ? TRANSPORT.WS : TRANSPORT.SSE;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pin the transport, or hand it back to the app.
|
|
147
|
+
*
|
|
148
|
+
* Exists because "it works but it is on the slow one" and "it should be on the
|
|
149
|
+
* slow one and is not" both need an answer a user can act on, and because
|
|
150
|
+
* reproducing a blocked network to test the fallback otherwise means finding a
|
|
151
|
+
* blocked network. `null` means auto.
|
|
152
|
+
*/
|
|
153
|
+
export function setTransport(choice) {
|
|
154
|
+
const next = choice === TRANSPORT.WS || choice === TRANSPORT.SSE ? choice : null;
|
|
155
|
+
if (next === forced) return;
|
|
156
|
+
|
|
157
|
+
forced = next;
|
|
158
|
+
storage.saveTransportChoice(forced);
|
|
159
|
+
announced = null; // the lock changed; the UI needs to hear it
|
|
160
|
+
|
|
161
|
+
if (!wantOpen || !session) return announce();
|
|
162
|
+
|
|
163
|
+
// Reconnect now rather than at the next drop: the point of choosing is to
|
|
164
|
+
// see the choice take effect.
|
|
165
|
+
channel?.close(1000);
|
|
166
|
+
channel = null;
|
|
167
|
+
stopTimers();
|
|
168
|
+
epoch++;
|
|
169
|
+
stuck = false;
|
|
170
|
+
failures = { [TRANSPORT.WS]: 0, [TRANSPORT.SSE]: 0 };
|
|
171
|
+
backoff = NET.BACKOFF_MIN_MS;
|
|
172
|
+
mode = preferred();
|
|
173
|
+
announce();
|
|
174
|
+
start();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const transportChoice = () => forced;
|
|
178
|
+
|
|
179
|
+
function switchTransport() {
|
|
180
|
+
// A forced transport does not fail over. Someone asked for this one
|
|
181
|
+
// specifically, and silently moving them off it is exactly the behaviour the
|
|
182
|
+
// switch was added to be able to override.
|
|
183
|
+
if (forced) return;
|
|
184
|
+
|
|
185
|
+
const next = OTHER[mode];
|
|
186
|
+
if (!CHANNELS[next].available()) return;
|
|
187
|
+
|
|
188
|
+
mode = next;
|
|
189
|
+
failures[next] = 0;
|
|
190
|
+
backoff = NET.BACKOFF_MIN_MS; // the switch is a fresh start, not a retry
|
|
191
|
+
|
|
192
|
+
// Loud, never silent — the same rule the file layer follows when a P2P
|
|
193
|
+
// transfer falls back to the relay (FR-7.6). The fallback is slower and the
|
|
194
|
+
// user is entitled to know which pipe their clipboard is going down.
|
|
195
|
+
emit(EV.TOAST, next === TRANSPORT.SSE
|
|
196
|
+
? "WebSocket blocked — switched to the HTTP fallback"
|
|
197
|
+
: "Retrying the WebSocket connection");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Neither transport is getting through: that is a network problem, not lag. */
|
|
201
|
+
function blocked() {
|
|
202
|
+
return failures[TRANSPORT.WS] >= NET.SWITCH_AFTER
|
|
203
|
+
&& failures[TRANSPORT.SSE] >= NET.SWITCH_AFTER;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function announce() {
|
|
207
|
+
const key = `${stuck ? `blocked:${unsupported}` : mode}:${forced}`;
|
|
208
|
+
if (key === announced) return; // don't re-raise a banner on every reconnect
|
|
209
|
+
announced = key;
|
|
210
|
+
emit(EV.TRANSPORT, {
|
|
211
|
+
mode: stuck ? null : mode,
|
|
212
|
+
label: CHANNELS[mode].LABEL,
|
|
213
|
+
blocked: stuck,
|
|
214
|
+
// What the picker in the status bar shows as selected. `mode` is what is
|
|
215
|
+
// actually carrying frames right now; this is what the user asked for, and
|
|
216
|
+
// on auto they are not the same question.
|
|
217
|
+
forced,
|
|
218
|
+
// "the relay is old" and "the network is blocking us" present identically
|
|
219
|
+
// and have opposite fixes, so the UI is given the difference rather than a
|
|
220
|
+
// single sentence that has to cover both.
|
|
221
|
+
unsupported,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------
|
|
226
|
+
one attempt
|
|
227
|
+
------------------------------------------------------------------- */
|
|
228
|
+
|
|
229
|
+
function start() {
|
|
230
|
+
stopTimers();
|
|
231
|
+
opened = false;
|
|
232
|
+
state.setConnection("connecting", detail());
|
|
233
|
+
|
|
234
|
+
const gen = ++epoch;
|
|
235
|
+
const current = fn => (...args) => { if (gen === epoch) fn(...args); };
|
|
236
|
+
|
|
237
|
+
channel = CHANNELS[mode].create({
|
|
238
|
+
url: session.url,
|
|
239
|
+
roomHash: session.roomHash,
|
|
240
|
+
auth: session.auth,
|
|
241
|
+
onOpen: current(up),
|
|
242
|
+
onFrame: current(handle),
|
|
243
|
+
onDown: current(down),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// A blocked transport does not always fail — it hangs. An intercepting proxy
|
|
247
|
+
// can accept the connection, drop the Upgrade (or buffer the event stream)
|
|
248
|
+
// and then do nothing at all, with no error to react to. Without this timer
|
|
249
|
+
// the app sits on "Connecting…" indefinitely and the fallback never runs,
|
|
250
|
+
// which is the whole failure this file exists to handle.
|
|
251
|
+
probe = setTimeout(() => {
|
|
252
|
+
if (opened || gen !== epoch) return;
|
|
253
|
+
channel?.close(4000); // deliberate: the channel will not call down()
|
|
254
|
+
channel = null;
|
|
255
|
+
down({ code: 4000, reason: "no response" });
|
|
256
|
+
}, NET.PROBE_MS);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function up() {
|
|
260
|
+
clearTimeout(probe);
|
|
261
|
+
probe = null;
|
|
262
|
+
|
|
263
|
+
opened = true;
|
|
264
|
+
stuck = false;
|
|
265
|
+
unsupported = false;
|
|
266
|
+
idRetries = 0;
|
|
267
|
+
backoff = NET.BACKOFF_MIN_MS;
|
|
268
|
+
failures[mode] = 0;
|
|
269
|
+
|
|
270
|
+
// Only an automatic success is evidence. A forced transport working proves
|
|
271
|
+
// nothing about what this network allows — it proves someone clicked it — and
|
|
272
|
+
// recording it would mean pinning HTTP once left "Automatic" choosing HTTP
|
|
273
|
+
// for the next twelve hours, which is the opposite of handing control back.
|
|
274
|
+
if (!forced) storage.saveTransport(mode);
|
|
275
|
+
|
|
276
|
+
state.setConnection("connected", detail());
|
|
277
|
+
announce();
|
|
278
|
+
|
|
279
|
+
send(proto.hello(session.intent, state.get().originId, session.name));
|
|
280
|
+
|
|
281
|
+
heartbeat = setInterval(() => {
|
|
282
|
+
pingSentAt = performance.now();
|
|
283
|
+
send(proto.ping());
|
|
284
|
+
}, NET.HEARTBEAT_MS);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function down({ code, reason }) {
|
|
288
|
+
stopTimers();
|
|
289
|
+
channel = null;
|
|
290
|
+
if (mode === TRANSPORT.SSE) unsupported = reason === sseChannel.NO_FALLBACK;
|
|
291
|
+
|
|
292
|
+
if (!wantOpen) return state.setConnection("idle");
|
|
293
|
+
|
|
294
|
+
if (opened) {
|
|
295
|
+
// Always rejoin: a reconnect is never a "create", or a transient drop would
|
|
296
|
+
// look like a key collision and needlessly rotate the user's key.
|
|
297
|
+
session = { ...session, intent: "join" };
|
|
298
|
+
} else {
|
|
299
|
+
failures[mode] += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 1012 = "service restart". Every push to main redeploys the relay and closes
|
|
303
|
+
// every live socket with this code (OI-13) — observed for real during the M0
|
|
304
|
+
// idle test. It is a planned, short outage rather than a fault, so reset the
|
|
305
|
+
// backoff and come back promptly instead of treating it like a flaky network
|
|
306
|
+
// and waiting out a doubled delay.
|
|
307
|
+
if (code === 1012) backoff = NET.BACKOFF_MIN_MS;
|
|
308
|
+
|
|
309
|
+
// Read before switching: switchTransport() zeroes the incoming transport's
|
|
310
|
+
// counter, so asking afterwards always says "not blocked" and the user would
|
|
311
|
+
// never be told that nothing at all is getting through.
|
|
312
|
+
if (!opened && failures[mode] >= NET.SWITCH_AFTER) {
|
|
313
|
+
if (blocked()) {
|
|
314
|
+
stuck = true;
|
|
315
|
+
storage.saveTransport(null); // neither works; don't pin the next load to one
|
|
316
|
+
}
|
|
317
|
+
switchTransport();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Jitter keeps a restart from producing a synchronised reconnect stampede
|
|
321
|
+
// from every client at the same instant.
|
|
322
|
+
const wait = Math.round(backoff * (0.8 + Math.random() * 0.4));
|
|
323
|
+
backoff = Math.min(backoff * 2, NET.BACKOFF_MAX_MS);
|
|
324
|
+
|
|
325
|
+
announce();
|
|
326
|
+
|
|
327
|
+
if (stuck) {
|
|
328
|
+
state.setConnection("offline", "relay unreachable");
|
|
329
|
+
} else {
|
|
330
|
+
state.setConnection("reconnecting",
|
|
331
|
+
code === 1012 ? "relay restarting" : detail(`${(wait / 1000).toFixed(1)}s`));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const gen = epoch;
|
|
335
|
+
setTimeout(() => { if (wantOpen && gen === epoch) start(); }, wait);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function stopTimers() {
|
|
339
|
+
clearInterval(heartbeat);
|
|
340
|
+
clearTimeout(probe);
|
|
341
|
+
heartbeat = probe = null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Status-bar text: whatever the state wants to say, plus the transport.
|
|
346
|
+
*
|
|
347
|
+
* "locked" is not decoration. A pinned transport does not fail over, so a user
|
|
348
|
+
* who pinned WebSocket on a network that blocks it would otherwise watch an
|
|
349
|
+
* endless reconnect with no hint that they are the reason.
|
|
350
|
+
*/
|
|
351
|
+
const detail = (extra = "") =>
|
|
352
|
+
[extra, NOTE[mode], forced ? "locked" : ""].filter(Boolean).join(" · ");
|
|
353
|
+
|
|
354
|
+
/* ------------------------------------------------------------------
|
|
355
|
+
protocol
|
|
356
|
+
------------------------------------------------------------------- */
|
|
357
|
+
|
|
358
|
+
let idRetries = 0;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Our own id is still held — by the connection we just closed.
|
|
362
|
+
*
|
|
363
|
+
* A rejoin, a key rotation or a collision recovery reconnects within
|
|
364
|
+
* milliseconds, and the relay only drops a peer once it *notices* the old
|
|
365
|
+
* transport is gone. Reconnect faster than that and we meet our own ghost: the
|
|
366
|
+
* relay keeps us under a provisional id, while the files layer goes on
|
|
367
|
+
* addressing frames to our originId — so transfers to this device quietly stop
|
|
368
|
+
* resolving. Ask again a moment later, by which time the ghost has been reaped.
|
|
369
|
+
*/
|
|
370
|
+
function reclaimIdentity() {
|
|
371
|
+
if (idRetries >= 3) return emit(EV.TOAST, proto.ERRORS.PEER_ID_TAKEN);
|
|
372
|
+
const gen = epoch;
|
|
373
|
+
const attempt = ++idRetries;
|
|
374
|
+
setTimeout(() => {
|
|
375
|
+
if (!isOpen() || gen !== epoch) return;
|
|
376
|
+
send(proto.hello("join", state.get().originId, session.name));
|
|
377
|
+
}, 600 * attempt);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function handle(msg) {
|
|
381
|
+
switch (msg.t) {
|
|
382
|
+
case proto.T.WELCOME:
|
|
383
|
+
state.setInstance(msg.instance);
|
|
384
|
+
if (msg.you) state.get().peerId = msg.you;
|
|
385
|
+
state.setPeers(msg.peers ?? 1, msg.list ?? []);
|
|
386
|
+
// `existing > 0` on a create means the generated key is taken (OI-2).
|
|
387
|
+
if (session.intent === "create" && msg.existing > 0) {
|
|
388
|
+
emit(EV.KEY_COLLISION, { existing: msg.existing });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// A room's last clip is replayed to late joiners, so a device that
|
|
392
|
+
// arrives mid-session is immediately in sync (FR-3.3).
|
|
393
|
+
if (msg.last) onFrame(msg.last);
|
|
394
|
+
|
|
395
|
+
// What the room looked like at the moment we arrived. main.js needs both
|
|
396
|
+
// halves and can infer neither: a locked session plants its beacon only
|
|
397
|
+
// into a room that is empty AND has no retained clip, because the beacon
|
|
398
|
+
// is a clip and would otherwise overwrite the very thing the line above
|
|
399
|
+
// exists to deliver. Emitted after the replay so a listener that reacts
|
|
400
|
+
// to it cannot race the last clip.
|
|
401
|
+
emit(EV.ROOM_STATE, { existing: msg.existing ?? 0, hasLast: msg.last != null });
|
|
402
|
+
break;
|
|
403
|
+
|
|
404
|
+
case proto.T.PEERS:
|
|
405
|
+
state.setPeers(msg.count, msg.list ?? []);
|
|
406
|
+
break;
|
|
407
|
+
|
|
408
|
+
case proto.T.PONG:
|
|
409
|
+
emit(EV.CONN_STATE, {
|
|
410
|
+
state: "connected",
|
|
411
|
+
detail: detail(`${Math.round(performance.now() - pingSentAt)} ms`),
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
|
|
415
|
+
case proto.T.ERROR:
|
|
416
|
+
if (msg.code === "PEER_ID_TAKEN") return reclaimIdentity();
|
|
417
|
+
emit(EV.TOAST, proto.ERRORS[msg.code] || `Relay error: ${msg.code}`);
|
|
418
|
+
break;
|
|
419
|
+
|
|
420
|
+
default:
|
|
421
|
+
onFrame(msg);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE + POST channel — the fallback for networks that will not pass WebSockets.
|
|
3
|
+
*
|
|
4
|
+
* This is PRD §4.3 R3, fallback 1, and PRD §5.4: a TLS-inspecting corporate
|
|
5
|
+
* proxy may accept the TCP connection on 443 and then quietly refuse (or worse,
|
|
6
|
+
* silently swallow) the HTTP Upgrade that a WebSocket needs. Server-Sent Events
|
|
7
|
+
* are an ordinary long-lived `GET` returning `text/event-stream`, and sends are
|
|
8
|
+
* an ordinary `POST` — no Upgrade anywhere, so what gets through a proxy is a
|
|
9
|
+
* plain HTTP response body.
|
|
10
|
+
*
|
|
11
|
+
* It implements exactly the contract in transport/ws.js, carries exactly the
|
|
12
|
+
* envelopes in transport/protocol.js, and relay.js cannot tell which of the two
|
|
13
|
+
* it is holding beyond the label it prints.
|
|
14
|
+
*
|
|
15
|
+
* Two things SSE does not give us for free, handled here:
|
|
16
|
+
*
|
|
17
|
+
* 1. It is one-directional. Upstream frames go out as POSTs to /pub, which
|
|
18
|
+
* costs one extra round trip per send versus a WebSocket. Sends are
|
|
19
|
+
* coalesced (see flush) so a burst — trickle-ICE candidates, file chunks,
|
|
20
|
+
* cursor moves — becomes one request rather than thirty.
|
|
21
|
+
*
|
|
22
|
+
* Worth knowing if you ever self-host behind HTTP/1.1: a browser allows
|
|
23
|
+
* six connections per host there, and an event stream holds one of them
|
|
24
|
+
* open for as long as the tab lives — so half a dozen tabs on the same
|
|
25
|
+
* relay can starve each other. Over HTTP/2, which is what any modern host
|
|
26
|
+
* (and Cloudflare, which fronts this one) serves, they are multiplexed
|
|
27
|
+
* onto one connection and the limit does not apply.
|
|
28
|
+
*
|
|
29
|
+
* 2. It has no connection identity. The stream is the session, so the relay
|
|
30
|
+
* issues a `sid` on the welcome frame and every POST names it. Without
|
|
31
|
+
* that, a POST has no way to say which of eight peers in the room it came
|
|
32
|
+
* from — the relay can only trust the connection a frame arrived on
|
|
33
|
+
* (see main.py `_forward`), and for this transport that connection is the
|
|
34
|
+
* stream, not the POST.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { NET } from "../core/config.js";
|
|
38
|
+
import * as proto from "./protocol.js";
|
|
39
|
+
|
|
40
|
+
export const LABEL = "HTTP stream";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The relay answered, but not on /sse — it is running a build from before the
|
|
44
|
+
* fallback existed.
|
|
45
|
+
*
|
|
46
|
+
* Worth telling apart from a blocked network, because it looks identical from
|
|
47
|
+
* here and the fix is the opposite one. It is also the likeliest way to meet
|
|
48
|
+
* this path: deploy the frontend ahead of the relay and every client that falls
|
|
49
|
+
* back lands on a route that is not there yet.
|
|
50
|
+
*/
|
|
51
|
+
export const NO_FALLBACK = "relay has no fallback endpoint";
|
|
52
|
+
|
|
53
|
+
export const available = () =>
|
|
54
|
+
typeof EventSource !== "undefined" && typeof fetch === "function";
|
|
55
|
+
|
|
56
|
+
/** How long to wait before retrying a POST that failed to reach the relay. */
|
|
57
|
+
const RETRY_MS = 400;
|
|
58
|
+
|
|
59
|
+
/** A POST that never answers must not wedge the outbox behind it. */
|
|
60
|
+
const POST_TIMEOUT_MS = 15_000;
|
|
61
|
+
|
|
62
|
+
export function create({ url, roomHash, auth = null, onOpen, onFrame, onDown }) {
|
|
63
|
+
const base = url.replace(/^ws/i, "http").replace(/\/+$/, "");
|
|
64
|
+
|
|
65
|
+
let sid = null; // issued by the relay on `welcome`; null until then
|
|
66
|
+
let done = false;
|
|
67
|
+
let outbox = []; // serialised frames queued but not yet in flight
|
|
68
|
+
let inflight = false;
|
|
69
|
+
let retried = false;
|
|
70
|
+
let retryTimer = null;
|
|
71
|
+
|
|
72
|
+
const finish = (code, reason) => {
|
|
73
|
+
if (done) return;
|
|
74
|
+
done = true;
|
|
75
|
+
clearTimeout(retryTimer);
|
|
76
|
+
try { es.close(); } catch { /* already gone */ }
|
|
77
|
+
onDown({ code, reason });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// `?a=` — a locked session's admission token, checked at join on both
|
|
81
|
+
// transports so the fallback is not the lenient way in (see transport/ws.js).
|
|
82
|
+
const es = new EventSource(
|
|
83
|
+
`${base}/sse/${roomHash}${auth ? `?a=${encodeURIComponent(auth)}` : ""}`);
|
|
84
|
+
|
|
85
|
+
es.onmessage = e => {
|
|
86
|
+
const msg = proto.parse(e.data);
|
|
87
|
+
|
|
88
|
+
// The welcome frame doubles as the handshake for this transport. Note that
|
|
89
|
+
// "open" here means *the welcome arrived*, not "the GET was accepted": a
|
|
90
|
+
// proxy that buffers the response body accepts the request happily and then
|
|
91
|
+
// delivers nothing, which is indistinguishable from a working stream until
|
|
92
|
+
// you insist on seeing a frame come out of it. relay.js gives every channel
|
|
93
|
+
// NET.PROBE_MS to reach this line, so that proxy is caught and fallen back
|
|
94
|
+
// from like any other block.
|
|
95
|
+
if (msg.t === proto.T.WELCOME && msg.sid && !sid) {
|
|
96
|
+
sid = msg.sid;
|
|
97
|
+
delete msg.sid; // transport plumbing; the protocol layer never sees it
|
|
98
|
+
onOpen();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onFrame(msg);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
es.onerror = () => {
|
|
105
|
+
// EventSource reconnects on its own, and that is precisely what must not
|
|
106
|
+
// happen: the relay would accept the new stream as a brand-new peer with a
|
|
107
|
+
// new sid, while we carry on POSTing under the old one — a ghost in the
|
|
108
|
+
// roster and every send rejected. Reconnection is relay.js's job, with its
|
|
109
|
+
// backoff and its rejoin, so end the stream here and report it.
|
|
110
|
+
if (sid) return finish(4001, "stream dropped");
|
|
111
|
+
|
|
112
|
+
// Nothing has come back at all, and EventSource will not say why — no
|
|
113
|
+
// status, no body, and a 404 with no CORS header on it reads in the console
|
|
114
|
+
// as a cross-origin block. So ask the relay something simpler: if /health
|
|
115
|
+
// answers, the host is reachable and it is the route that is missing.
|
|
116
|
+
classify().then(reason => finish(4000, reason));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
async function classify() {
|
|
120
|
+
const guard = deadline(5000);
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`${base}/health`, { signal: guard.signal });
|
|
123
|
+
return res.ok ? NO_FALLBACK : `relay error ${res.status}`;
|
|
124
|
+
} catch {
|
|
125
|
+
return "stream refused";
|
|
126
|
+
} finally {
|
|
127
|
+
guard.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Take the next POST's worth of frames.
|
|
133
|
+
*
|
|
134
|
+
* Bounded on both count and bytes so one flush cannot exceed what the relay
|
|
135
|
+
* accepts in a body — a file transfer on the relay-chunk path (FR-7.6) can
|
|
136
|
+
* queue hundreds of 32 KB frames in a moment.
|
|
137
|
+
*/
|
|
138
|
+
function take() {
|
|
139
|
+
const batch = [];
|
|
140
|
+
let bytes = 0;
|
|
141
|
+
while (outbox.length && batch.length < NET.POST_MAX_FRAMES) {
|
|
142
|
+
const next = outbox[0];
|
|
143
|
+
if (batch.length && bytes + next.length > NET.POST_MAX_BYTES) break;
|
|
144
|
+
bytes += next.length;
|
|
145
|
+
batch.push(outbox.shift());
|
|
146
|
+
}
|
|
147
|
+
return batch;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function flush() {
|
|
151
|
+
if (inflight || done || !sid || !outbox.length) return;
|
|
152
|
+
inflight = true;
|
|
153
|
+
const batch = take();
|
|
154
|
+
|
|
155
|
+
let res = null;
|
|
156
|
+
const guard = deadline();
|
|
157
|
+
try {
|
|
158
|
+
res = await fetch(`${base}/pub/${roomHash}?sid=${encodeURIComponent(sid)}`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
// text/plain keeps this a CORS "simple request". application/json would
|
|
161
|
+
// make every single send a preflighted pair — an extra OPTIONS round
|
|
162
|
+
// trip per clip, on the transport already chosen for being the slower
|
|
163
|
+
// one. The relay parses the body by shape, not by this header.
|
|
164
|
+
headers: { "Content-Type": "text/plain;charset=UTF-8" },
|
|
165
|
+
// One frame per line. JSON.stringify escapes every literal newline, so
|
|
166
|
+
// a frame can never contain one and the split is unambiguous.
|
|
167
|
+
body: batch.join("\n"),
|
|
168
|
+
signal: guard.signal,
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
// Did not reach the relay at all. Retry once — a clip the user copied is
|
|
172
|
+
// worth a second attempt — then drop it rather than build an unbounded
|
|
173
|
+
// queue of stale frames behind a network that is gone. The stream is what
|
|
174
|
+
// reports the session dead; a failed POST on its own does not.
|
|
175
|
+
if (!retried) {
|
|
176
|
+
retried = true;
|
|
177
|
+
outbox = batch.concat(outbox);
|
|
178
|
+
retryTimer = setTimeout(() => { inflight = false; flush(); }, RETRY_MS);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
retried = false;
|
|
182
|
+
inflight = false;
|
|
183
|
+
console.warn(`[realtimeclipboard] dropped ${batch.length} frame(s): relay unreachable`);
|
|
184
|
+
return flush();
|
|
185
|
+
} finally {
|
|
186
|
+
guard.clear();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
retried = false;
|
|
190
|
+
inflight = false;
|
|
191
|
+
|
|
192
|
+
// The stream backing this sid is gone, so there is nowhere for the relay to
|
|
193
|
+
// answer even if it accepted the frame. Reopening is the only fix.
|
|
194
|
+
if (res.status === 404 || res.status === 410) {
|
|
195
|
+
return finish(4404, "session expired");
|
|
196
|
+
}
|
|
197
|
+
// Anything else the relay dislikes (too large, rate limited) it also says
|
|
198
|
+
// on the stream as a normal error frame, which the UI already surfaces.
|
|
199
|
+
if (!res.ok) console.warn(`[realtimeclipboard] relay rejected a send: HTTP ${res.status}`);
|
|
200
|
+
|
|
201
|
+
if (outbox.length) flush();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
label: LABEL,
|
|
206
|
+
|
|
207
|
+
// Usable means "the relay has answered and we know who we are". Before the
|
|
208
|
+
// welcome there is no sid, so there is nowhere to send.
|
|
209
|
+
isOpen: () => !done && sid !== null,
|
|
210
|
+
|
|
211
|
+
send(obj) {
|
|
212
|
+
if (done || !sid) return false;
|
|
213
|
+
outbox.push(JSON.stringify(obj));
|
|
214
|
+
flush();
|
|
215
|
+
return true;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
close() {
|
|
219
|
+
done = true;
|
|
220
|
+
clearTimeout(retryTimer);
|
|
221
|
+
outbox = [];
|
|
222
|
+
// Ending the stream is what tells the relay we left: it notices the
|
|
223
|
+
// response body being cancelled and drops us from the roster.
|
|
224
|
+
try { es.close(); } catch { /* already gone */ }
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A deadline that stops being armed once the request it guards has finished.
|
|
231
|
+
*
|
|
232
|
+
* Deliberately not AbortSignal.timeout(): that cannot be cancelled, so it fires
|
|
233
|
+
* whether or not the fetch already succeeded, and aborting a settled request
|
|
234
|
+
* still records it as cancelled. Every POST in a session then shows up red in
|
|
235
|
+
* the network tab as `net::ERR_ABORTED` — some seconds after it returned 204 —
|
|
236
|
+
* which is a very convincing way for working code to look broken, and exactly
|
|
237
|
+
* the sort of false trail this transport does not need another of.
|
|
238
|
+
*/
|
|
239
|
+
function deadline(ms = POST_TIMEOUT_MS) {
|
|
240
|
+
if (typeof AbortController === "undefined") return { signal: undefined, clear() {} };
|
|
241
|
+
const ctrl = new AbortController();
|
|
242
|
+
const timer = setTimeout(() => ctrl.abort(), ms);
|
|
243
|
+
return { signal: ctrl.signal, clear: () => clearTimeout(timer) };
|
|
244
|
+
}
|