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,68 @@
1
+ /**
2
+ * Where the app is served from, resolved once for the whole codebase.
3
+ *
4
+ * Six modules used to compute this themselves with `new URL("../…",
5
+ * import.meta.url)` — each one hard-coding how deep its own file sits in the
6
+ * tree. That works exactly as long as every module keeps its own file, and
7
+ * stops the moment the deploy bundles `src/ui/*.js` into `src/main.js`: every
8
+ * one of those paths shifts by a directory level, all at once, and nothing
9
+ * throws. `install.js` in particular resolved the app root to the GitHub Pages
10
+ * ROOT rather than to `/RealtimeClipboard/`, which silently breaks the service-worker
11
+ * scope and the PWA install criteria — PRD OI-9, the failure its own comment
12
+ * warned about.
13
+ *
14
+ * A module's depth in the tree is not information any module should depend on.
15
+ * `document.baseURI` is: it is what the browser already resolved every relative
16
+ * href on the page against, so it is right whether the code arrives as forty
17
+ * modules or as one bundle.
18
+ *
19
+ * !! The app root is the ORIGIN root — `new URL("/", …)`, not `new URL(".", …)`.
20
+ * That is a deliberate narrowing: this used to resolve the directory the current
21
+ * page sits in, so the app could be served from a subpath like
22
+ * `user.github.io/RealtimeClipboard/`. Subpath hosting is no longer supported, because
23
+ * src/pages/ publishes its pages one level up from where they sit on disk and
24
+ * therefore links to them root-absolutely — and a root-absolute link is wrong
25
+ * under a subpath by construction. Supporting both would mean two link styles
26
+ * in one site, which is how the depth bugs above happened in the first place.
27
+ *
28
+ * What that buys: the app's HTML no longer has to live at the app root for this
29
+ * to be correct, so a page's depth stops being load-bearing anywhere. What it
30
+ * costs: hosting under a path prefix. docs/SELF-HOSTING.md says so. !!
31
+ */
32
+
33
+ /**
34
+ * Guarded the same way `config.js` guards `location`: `core/` is imported by
35
+ * the node-based tests, where there is no document, and a bare reference would
36
+ * throw at import time and take the whole module graph down with it.
37
+ */
38
+ const BASE = typeof document !== "undefined" && document.baseURI
39
+ ? document.baseURI
40
+ : "http://localhost/";
41
+
42
+ /** The origin root, with trailing slash — `https://realtimeclipboard.com/`. */
43
+ export const APP_ROOT = new URL("/", BASE);
44
+
45
+ /** A file sitting beside `app.html`: `sw.js`, `manifest.webmanifest`, `changelog.json`. */
46
+ export const atRoot = name => new URL(name, APP_ROOT).href;
47
+
48
+ /**
49
+ * A stylesheet under `src/styles/lazy/`, fetched on first open.
50
+ *
51
+ * Lazy sheets stay OUT of the bundle on purpose — a QR modal's stylesheet has
52
+ * no business in the critical path of an app most people never open it in.
53
+ *
54
+ * !! The directory is the whole contract. `styles/` is bundled into main.css
55
+ * and `styles/lazy/` is copied verbatim, so which loader a sheet belongs to is
56
+ * a fact about where it sits rather than about who happens to reference it.
57
+ * tools/build/build.mjs used to recover that by grepping every module for calls to
58
+ * this function; it copies the directory now. Pointing this at `styles/` would
59
+ * make an eager sheet loadable twice, once bundled and once over the wire. !!
60
+ *
61
+ * A sheet cannot be moved into `lazy/` on payload grounds alone. An injected
62
+ * <link> lands AFTER main.css, and main.css ends with mobile.css, which
63
+ * overrides earlier sheets at equal specificity and relies on source order to
64
+ * win. So anything mobile.css restyles has to stay eager whatever it costs —
65
+ * qr.css and history.css share ten classes with it and are the standing
66
+ * example.
67
+ */
68
+ export const lazyStyleHref = name => new URL(`src/styles/lazy/${name}`, APP_ROOT).href;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Single source of truth for session state.
3
+ *
4
+ * Deliberately not reactive. Modules mutate through the setters here and the
5
+ * bus announces the change; nothing observes this object directly. That keeps
6
+ * the data flow one-directional and greppable.
7
+ */
8
+
9
+ import { emit, EV } from "./bus.js";
10
+ import { DEFAULT_SYNC_MODE } from "./config.js";
11
+
12
+ const state = {
13
+ key: null,
14
+ roomHash: null,
15
+ aesKey: null,
16
+ /**
17
+ * Locked session — a PIN outside the link (core/crypto.js).
18
+ *
19
+ * `verified` is a separate fact from `locked` and the difference is the whole
20
+ * honesty of the feature. A wrong PIN does not fail loudly: it derives a
21
+ * different, empty room, which looks exactly like being the first one to
22
+ * arrive. `verified` means something in this room actually decrypted, so we
23
+ * KNOW the PIN is right rather than assuming it.
24
+ */
25
+ locked: false,
26
+ verified: false,
27
+ /**
28
+ * Were we the first device into this room?
29
+ *
30
+ * `null` until the relay's `welcome` answers it — "not yet known" is a third
31
+ * state and must not be spelled `false`, or the lock button would be refused
32
+ * for the fraction of a second before the room reports itself and refused
33
+ * again for the whole of an offline session.
34
+ *
35
+ * Only locking reads it (see canLock). The relay's `existing` count is the
36
+ * source: it is the peers already present at the moment we joined.
37
+ */
38
+ founder: null,
39
+ authToken: null, // proves PIN knowledge to the relay; not a secret
40
+ originId: crypto.randomUUID().slice(0, 8), // this tab, for loop suppression
41
+ peerId: null, // assigned by the relay in `welcome.you`
42
+ connection: "idle", // idle | connecting | connected | reconnecting | offline
43
+ instance: null, // relay instance id — a change means split-brain (OI-3)
44
+ peers: 1,
45
+ tier: "T1", // clipboard capture tier, see clipboard/capture.js
46
+ lastSent: "", // dedupe guard (FR-2.7)
47
+ suppressUntil: 0, // loop-suppression deadline (FR-2.6)
48
+ settings: {
49
+ syncMode: DEFAULT_SYNC_MODE, // live | manual — see config.js
50
+ autowrite: true,
51
+ autoread: true,
52
+ autoaccept: false,
53
+ thumbs: true,
54
+ images: true,
55
+ cursors: true,
56
+ poll: "1s",
57
+ },
58
+ };
59
+
60
+ export const get = () => state;
61
+
62
+ export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
63
+ state.key = key;
64
+ state.roomHash = roomHash ?? state.roomHash;
65
+ state.aesKey = aesKey ?? state.aesKey;
66
+ state.locked = locked;
67
+ state.authToken = authToken;
68
+ // A new key is a new room: whatever we had proved about the old one does not
69
+ // carry over, and claiming otherwise would leave a stale padlock on screen.
70
+ state.verified = false;
71
+ // Likewise "we were first" — asked and answered per room. Left standing, the
72
+ // founder of one session would carry the right to lock into the next one it
73
+ // walked into, which is precisely the device that must not have it.
74
+ state.founder = null;
75
+ emit(EV.KEY_CHANGED, { key, locked });
76
+ emit(EV.LOCK_STATE, { locked, verified: false });
77
+ emit(EV.FOUNDER, { founder: null });
78
+ }
79
+
80
+ /**
81
+ * Record whether this device was the first one into the room.
82
+ *
83
+ * Fed from `welcome.existing` in main.js. Re-answered on every welcome, so a
84
+ * relay restart — which empties every room (OI-13) — hands the title to
85
+ * whoever reconnects into the empty room first, rather than to whoever held it
86
+ * before the room stopped existing.
87
+ */
88
+ export function setFounder(first) {
89
+ const next = first === null ? null : !!first;
90
+ if (state.founder === next) return;
91
+ state.founder = next;
92
+ emit(EV.FOUNDER, { founder: next });
93
+ }
94
+
95
+ /**
96
+ * May THIS device lock the session?
97
+ *
98
+ * Alone, anyone may: there is nobody to be thrown out. With company, only the
99
+ * device that opened the room, because locking is not a setting — it moves the
100
+ * session to a different room and removes everybody else from it (see
101
+ * LOCK.EVICT). A control that lets any arrival do that to the rest is a control
102
+ * for taking a session over, and the person who started it is the one who
103
+ * chose to share the key in the first place.
104
+ *
105
+ * A rule the UI keeps, not one the relay enforces: every device in the room
106
+ * already holds the key, so a modified client could send the goodbye itself.
107
+ * That is not a new power — it could equally read every clip — and the honest
108
+ * description of this is "the app will not help you do it", not "you cannot".
109
+ */
110
+ export function canLock() {
111
+ if (state.locked) return false;
112
+ if (state.peers <= 1) return true;
113
+ return state.founder === true;
114
+ }
115
+
116
+ /**
117
+ * Record that this device can actually read this room.
118
+ *
119
+ * Set from the first thing that decrypts — the beacon replayed in `welcome`, or
120
+ * any real frame. Only ever moves false -> true within a session; setKey resets
121
+ * it, because a different room is a different question.
122
+ */
123
+ export function setVerified() {
124
+ if (!state.locked || state.verified) return;
125
+ state.verified = true;
126
+ emit(EV.LOCK_STATE, { locked: true, verified: true });
127
+ }
128
+
129
+ export function setConnection(connection, detail = "") {
130
+ state.connection = connection;
131
+ emit(EV.CONN_STATE, { state: connection, detail });
132
+ }
133
+
134
+ /**
135
+ * Peer roster.
136
+ *
137
+ * Diffed rather than just counted, so arrivals can be announced. The key is a
138
+ * bearer credential — a device appearing is the one observable moment that
139
+ * tells you someone else has it, and a count quietly going 2 → 3 is not
140
+ * something anyone notices.
141
+ *
142
+ * The first roster after connecting is not announced: those devices were
143
+ * already there, and greeting them as arrivals would cry wolf on every reload.
144
+ */
145
+ let roster = null;
146
+
147
+ export function setPeers(count, list = []) {
148
+ state.peers = count;
149
+
150
+ if (roster === null) {
151
+ roster = new Map(list.map(p => [p.peerId, p.name]));
152
+ } else {
153
+ const now = new Map(list.map(p => [p.peerId, p.name]));
154
+ for (const [id, name] of now) {
155
+ if (!roster.has(id) && id !== state.peerId) emit(EV.PEER_JOINED, { name, id });
156
+ }
157
+ for (const [id, name] of roster) {
158
+ if (!now.has(id)) emit(EV.PEER_LEFT, { name, id });
159
+ }
160
+ roster = now;
161
+ }
162
+
163
+ emit(EV.PEERS_CHANGED, { count, list });
164
+ }
165
+
166
+ /** Forget the roster so a reconnect does not report everyone as newly arrived. */
167
+ export function resetRoster() { roster = null; }
168
+
169
+ /**
170
+ * The relay keeps rooms in process memory, so a changed instance id means we
171
+ * may have landed on a different replica where our peers do not exist. Loud,
172
+ * not silent — a quiet failure here looks exactly like "the network is slow".
173
+ */
174
+ export function setInstance(instance) {
175
+ const previous = state.instance;
176
+ state.instance = instance;
177
+ if (previous && previous !== instance) {
178
+ emit(EV.INSTANCE_CHANGED, { from: previous, to: instance });
179
+ }
180
+ }
181
+
182
+ export function setTier(tier, note = "") {
183
+ state.tier = tier;
184
+ emit(EV.TIER_CHANGED, { tier, note });
185
+ }
186
+
187
+ export function setSetting(name, value) {
188
+ state.settings[name] = value;
189
+ }
190
+
191
+ /** Mute local capture briefly after applying a remote clip (FR-2.6). */
192
+ export function suppress(ms) { state.suppressUntil = Date.now() + ms; }
193
+ export function isSuppressed() { return Date.now() < state.suppressUntil; }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * localStorage, wrapped so a disabled-storage browser degrades instead of
3
+ * throwing. Clipboard *content* never comes near this — only preferences.
4
+ */
5
+
6
+ import { NET, STORAGE_PREFIX, normaliseRelay } from "./config.js";
7
+
8
+ const PREFIX = STORAGE_PREFIX;
9
+
10
+ export function read(name, fallback = null) {
11
+ try {
12
+ const raw = localStorage.getItem(PREFIX + name);
13
+ return raw === null ? fallback : JSON.parse(raw);
14
+ } catch { return fallback; }
15
+ }
16
+
17
+ export function write(name, value) {
18
+ try { localStorage.setItem(PREFIX + name, JSON.stringify(value)); return true; }
19
+ catch { return false; } // private mode, quota, or storage disabled
20
+ }
21
+
22
+ export function remove(name) {
23
+ try { localStorage.removeItem(PREFIX + name); } catch { /* nothing to do */ }
24
+ }
25
+
26
+ export const loadSettings = () => read("settings", null);
27
+ export const saveSettings = s => write("settings", s);
28
+
29
+ /**
30
+ * The relay this device talks to, when it is not the one the build ships with.
31
+ *
32
+ * A preference, not session content, so it belongs here — nothing about a clip
33
+ * or a key is being written. core/config.js READS this key directly at module
34
+ * evaluation, because the URL has to be resolved before anything imports it;
35
+ * this pair is for changing it afterwards.
36
+ *
37
+ * Normalised on the way in as well as on the way out. A value that got into
38
+ * storage malformed would otherwise be re-read as malformed on every launch,
39
+ * and the symptom — every connection refused — looks nothing like its cause.
40
+ */
41
+ export const loadRelayUrl = () => normaliseRelay(read("relayUrl", null));
42
+
43
+ export function saveRelayUrl(url) {
44
+ const clean = normaliseRelay(url);
45
+ if (!clean) { remove("relayUrl"); return null; }
46
+ write("relayUrl", clean);
47
+ return clean;
48
+ }
49
+
50
+ /**
51
+ * The last room, so a relaunch can offer it back (FR-1.7, OI-10).
52
+ *
53
+ * Stores `{key, locked}` since locked sessions exist; it used to be a bare
54
+ * string and still reads one, because an upgrade must not strand somebody in
55
+ * "no room at all" on their first load of the new build.
56
+ *
57
+ * The lock FLAG is remembered here, in localStorage. The PIN is not, and never
58
+ * will be — see saveLock below.
59
+ */
60
+ export function loadLastKey() {
61
+ const saved = read("lastKey", null);
62
+ if (!saved) return null;
63
+ return typeof saved === "string"
64
+ ? { key: saved, locked: false }
65
+ : { key: saved.key ?? null, locked: !!saved.locked };
66
+ }
67
+
68
+ export const saveLastKey = (key, locked = false) => write("lastKey", { key, locked });
69
+
70
+ /**
71
+ * Which transport last worked (see transport/relay.js).
72
+ *
73
+ * Remembered because probing costs the user real seconds of "Connecting…" on
74
+ * every load behind a proxy that blocks WebSockets, and the answer there is the
75
+ * same every time. Expired rather than permanent because it is a fact about the
76
+ * *network*, not the device: a laptop that leaves the office should go back to
77
+ * the faster transport on its own, without anyone knowing there was a setting.
78
+ */
79
+ export function loadTransport() {
80
+ const saved = read("transport", null);
81
+ if (!saved?.mode || !saved.at) return null;
82
+ return Date.now() - saved.at < NET.TRANSPORT_MEMORY_MS ? saved.mode : null;
83
+ }
84
+
85
+ export function saveTransport(mode) {
86
+ if (!mode) return remove("transport");
87
+ write("transport", { mode, at: Date.now() });
88
+ }
89
+
90
+ /**
91
+ * A transport the user picked by hand, which outranks anything measured.
92
+ *
93
+ * Kept apart from the remembered-working one above because they answer
94
+ * different questions — "what worked last time" is an observation and expires;
95
+ * "use HTTP" is an instruction and does not. Merging them would let a
96
+ * successful automatic connection quietly overwrite a deliberate choice.
97
+ */
98
+ export const loadTransportChoice = () => read("transportChoice", null);
99
+
100
+ export function saveTransportChoice(mode) {
101
+ if (!mode) return remove("transportChoice");
102
+ write("transportChoice", mode);
103
+ }
104
+
105
+ /* ------------------------------------------------------------------
106
+ Session-scoped file allowances
107
+
108
+ sessionStorage, not localStorage, and this is the whole point of them: "allow
109
+ everything from this device" is a decision about the session you are in, and
110
+ it has to die with the tab. A permanent version of this setting is a standing
111
+ grant to whoever holds the share key, made once and then forgotten about.
112
+
113
+ Scoped to a room as well as a tab. The key is a bearer credential and rooms
114
+ are named after it, so an allowance granted in one session must not survive
115
+ into another — rotating the key is how someone throws a device out, and it
116
+ would be worth nothing if the allowance came along.
117
+ ------------------------------------------------------------------- */
118
+
119
+ export function loadAllowances(room) {
120
+ if (!room) return [];
121
+ try {
122
+ const saved = JSON.parse(sessionStorage.getItem(PREFIX + "allow") || "null");
123
+ return saved && saved.room === room && Array.isArray(saved.peers) ? saved.peers : [];
124
+ } catch { return []; }
125
+ }
126
+
127
+ export function saveAllowances(room, peers) {
128
+ try {
129
+ if (!room || !peers?.length) sessionStorage.removeItem(PREFIX + "allow");
130
+ else sessionStorage.setItem(PREFIX + "allow", JSON.stringify({ room, peers }));
131
+ return true;
132
+ } catch { return false; }
133
+ }
134
+
135
+ /* ------------------------------------------------------------------
136
+ The locked-session unlock, scoped to this tab
137
+
138
+ Two decisions here, both deliberate.
139
+
140
+ WHAT is stored is the PBKDF2 output, never the PIN. It unlocks exactly the
141
+ same room, so this is not a security improvement in itself — the point is
142
+ that a PIN is a human-chosen secret and humans reuse them. The string the
143
+ user typed should not be sitting in a browser store waiting to be read by
144
+ the next thing that gets to run in this origin. The derived value is useless
145
+ anywhere else, and reading it back skips 600k PBKDF2 iterations, so a refresh
146
+ is instant instead of a second of "Unlocking…".
147
+
148
+ WHERE is sessionStorage, for the same reason the file allowances above use
149
+ it: a refresh is the same session and must not re-prompt, but the tab closing
150
+ is the end of it. localStorage would put the unlock on disk next to the
151
+ plaintext key — at which point the link and the PIN are stored together and
152
+ the second secret has bought nothing.
153
+
154
+ Scoped to the share key, so rotating the key invalidates it automatically.
155
+ ------------------------------------------------------------------- */
156
+
157
+ /**
158
+ * The remembered unlock for a share key, or null.
159
+ *
160
+ * Matched on the KEY rather than on the room, and that is not interchangeable.
161
+ * The room hash of a locked session is derived from the stretched PIN alone, so
162
+ * a record left behind by a previous key would still look perfectly
163
+ * self-consistent and would silently reconnect this tab to a room the current
164
+ * link does not name. The key is the thing that has to agree.
165
+ */
166
+ export function loadLock(key) {
167
+ if (!key) return null;
168
+ try {
169
+ const saved = JSON.parse(sessionStorage.getItem(PREFIX + "lock") || "null");
170
+ return saved && saved.key === key && saved.prk ? saved.prk : null;
171
+ } catch { return null; }
172
+ }
173
+
174
+ export function saveLock(key, prk) {
175
+ try {
176
+ if (!key || !prk) sessionStorage.removeItem(PREFIX + "lock");
177
+ else sessionStorage.setItem(PREFIX + "lock", JSON.stringify({ key, prk }));
178
+ return true;
179
+ } catch { return false; }
180
+ }
181
+
182
+ export const clearLock = () => saveLock(null, null);
@@ -0,0 +1,30 @@
1
+ # src/transport/ — rank 10
2
+
3
+ May import `core/`. May not import `clipboard/`, `files/`, `landing/` or anything in `ui/`.
4
+
5
+ **Nothing above this directory may import `relay.js`, `ws.js` or `sse.js`** — the static check
6
+ fails the commit. `protocol.js` is the one exception, because frame *shapes* are transport-agnostic
7
+ and `files/transfer.js` reads its type constants rather than hand-copying eleven string literals.
8
+
9
+ That boundary is not decoration. It is what let the entire SSE fallback land without a single UI
10
+ file changing, and it is the reason the transport could stay unsettled while everything above it
11
+ was built.
12
+
13
+ ## The shape
14
+
15
+ `relay.js` is the facade. `ws.js` and `sse.js` are channels behind one contract —
16
+ `create → {send, close, isOpen}` — and know nothing but how to move frames.
17
+
18
+ Everything easy to get subtly different between two transports lives in `relay.js` **once**: the
19
+ hello, the 30 s heartbeat, jittered reconnect backoff, the last-clip replay, and which channel is
20
+ live. Adding a third channel should mean adding one file and one line.
21
+
22
+ ## Rules
23
+
24
+ - A new frame type is declared in `protocol.js` and handled in `relay.js`. Nowhere else.
25
+ - The relay learns nothing. It sees a room hash and ciphertext, and is never told anything about
26
+ the visitor. Widening that needs an argument in `docs/PRD.md` first.
27
+ - A blocked WebSocket usually **hangs rather than fails**, so there is no error to react to — only
28
+ `NET.PROBE_MS` notices. Do not replace the probe with an error handler.
29
+ - Nothing here may decide which channel to use on a UI module's behalf. The status-bar picker emits
30
+ `EV.TRANSPORT_SELECT`; `main.js` calls `setTransport()`.
@@ -0,0 +1,16 @@
1
+ # src/transport/
2
+
3
+ Getting frames to the relay and back, over whichever channel the network allows.
4
+
5
+ | File | What it does |
6
+ |---|---|
7
+ | `relay.js` | The facade: protocol, hello, heartbeat, reconnect backoff, and which channel is live |
8
+ | `ws.js` | WebSocket channel — the default |
9
+ | `sse.js` | SSE downstream + POST upstream — the fallback for networks that swallow WebSockets |
10
+ | `protocol.js` | Wire frame shapes, transport-agnostic. See `docs/PRD.md` §6 |
11
+
12
+ The client tries WebSocket, gives it `NET.PROBE_MS` to become usable, and after
13
+ `NET.SWITCH_AFTER` attempts that never do, moves to SSE and says so in the status bar. Nothing
14
+ above this directory can tell which channel is carrying the session.
15
+
16
+ Rules that govern edits here: [CLAUDE.md](CLAUDE.md).
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Wire protocol — PRD §6. Frame shapes live here and nowhere else.
3
+ *
4
+ * Deliberately transport-agnostic: if the SSE+POST fallback is ever needed
5
+ * (PRD §4.3 R3), these same envelopes travel unchanged.
6
+ */
7
+
8
+ export const T = {
9
+ HELLO: "hello",
10
+ CLIP: "clip",
11
+ PING: "ping",
12
+ PONG: "pong",
13
+ WELCOME: "welcome",
14
+ PEERS: "peers",
15
+ ERROR: "error",
16
+ // M7 — WebRTC signalling, forwarded blindly by the relay
17
+ RTC_OFFER: "rtc-offer",
18
+ RTC_ANSWER: "rtc-answer",
19
+ RTC_ICE: "rtc-ice",
20
+ FILE_META: "file-meta",
21
+ FILE_REQ: "file-req",
22
+ };
23
+
24
+ /**
25
+ * `intent` is the collision guard (OI-2). A key we generated connects as
26
+ * "create"; if the welcome reports peers already present, that key is taken and
27
+ * we must regenerate rather than silently join a stranger's clipboard.
28
+ * A key the user typed or followed a link to is always "join".
29
+ */
30
+ export const hello = (intent, originId, name) => ({ t: T.HELLO, intent, originId, name });
31
+
32
+ export const clip = ({ payload, iv, originId }) => ({
33
+ t: T.CLIP, payload, iv, originId, ts: Date.now(),
34
+ });
35
+
36
+ export const ping = () => ({ t: T.PING });
37
+
38
+ export const fileMeta = ({ id, name, size, type, thumb, originId }) => ({
39
+ t: T.FILE_META, id, name, size, type, thumb, originId,
40
+ });
41
+
42
+ export const fileReq = ({ id, to, originId }) => ({
43
+ t: T.FILE_REQ, id, to, originId,
44
+ });
45
+
46
+ export function parse(raw) {
47
+ try { return JSON.parse(raw); }
48
+ catch { return { t: T.ERROR, code: "BAD_JSON" }; }
49
+ }
50
+
51
+ export const ERRORS = {
52
+ TOO_LARGE: "That clip is over the 32 KB limit",
53
+ RATE_LIMITED: "Slow down — too many messages",
54
+ ROOM_FULL: "This session already has the maximum number of devices",
55
+ BAD_JSON: "The relay sent something unreadable",
56
+ NO_STREAM: "The connection expired — reconnecting",
57
+ // Only ever shown after the transport has given up retrying: the usual cause
58
+ // is our own just-closed connection still holding the name, which clears on
59
+ // its own. See relay.js reclaimIdentity().
60
+ PEER_ID_TAKEN: "Another device in this session is using the same id — files may not reach this one",
61
+ // Locked sessions. In practice unreachable from a correct client: a locked
62
+ // room's name is derived from the PIN, so a device that got the PIN wrong is
63
+ // addressing a different room entirely rather than being turned away from
64
+ // this one. It fires if a client's room derivation and key derivation ever
65
+ // disagree — which is a bug, and this is how it surfaces instead of a
66
+ // session that connects and then reads nothing.
67
+ AUTH_FAILED: "This session's PIN does not match the one already in use here",
68
+ };