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.
@@ -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
+ }