From b7bf11c077bdd165400cf8d80c5cd1ad4248d854 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 11:28:31 +0200 Subject: refactor(client): split transport.js into classic scripts transport.js keeps the core (connection, reconnect, leases, dispatch). Chat, media, admin, upload and device methods move, cut as text, into transport-*.js scripts that hand a class of their own to extendTransport, which copies each method onto MeshBayTransport.prototype; the codec, roster checks, node pins and the rewrap fan-out move as they were. Both shells load them after transport.js. Every prototype member, class property and top-level function has the same source text as before. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/transport.js | 2371 +------------------- 1 file changed, 122 insertions(+), 2249 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 817f182..202db94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -14,6 +14,10 @@ * const index = await transport.fetchIndex(); * const chunk = await transport.fetchChunk(fileId, 0); * transport.close(); + * + * This file is the core — connection, reconnection, leases, dispatch. The rest + * of MeshBayTransport's methods, and the free functions they use, are in the + * transport-*.js scripts the shell loads after it (see "Parts" at the end). */ async function _pkFromSk(skPkcs8B64) { @@ -35,23 +39,6 @@ async function _pkEdFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } -// 48 KB is what fits comfortably in one SCTP message across stacks; the window is -// what makes the rate independent of the round trip. 32 × 48 KB = 1.5 MB in -// flight, which saturates any path up to roughly 100 Mb/s at 100 ms. -const UPLOAD_CHUNK_SIZE = 48 * 1024; -const UPLOAD_WINDOW = 32; -// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather -// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in -// meshbay_common/protocol.py; the node writes nothing and answers with -// `resume_from`, and one that predates it refuses the index, which reads as -// "start from the beginning". -const UPLOAD_PROBE_INDEX = -1; -// How long to wait for that answer before assuming there is none. A node that -// answers neither the probe nor its refusal must not leave an upload waiting -// for ever, and starting over is always safe. -const UPLOAD_PROBE_TIMEOUT_MS = 5000; -const UPLOAD_BUFFER_HIGH = 1024 * 1024; - // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -119,7 +106,7 @@ function _aborted() { } // Every request type that goes through the two-step admin_challenge / -// admin_response flow (_authorizeAdminOp below) — one entry per +// admin_response flow (_authorizeAdminOp, transport-admin.js) — one entry per // `_authorizeAdminOp(msg, expectedOp, ...)` call site. Found live: enabling // the Music app and then saving its root folder in the same Settings visit // (the new merged Directories section makes this a natural, fast @@ -1339,60 +1326,6 @@ class MeshBayTransport { } } - /** - * Pair this browser with the node using a one-time code (M3, and the same - * substitution as H3). - * - * The node has no way to know which key belongs to its operator unless someone - * tells it locally — asking the hub would let the hub name itself node - * administrator. The code comes from `meshbay-node operator pair`, over SSH, and - * the hub never sees it. - */ - async pairOperator(userId, code) { - if (!this._connected) throw new Error('Not connected to the node'); - if (!userId) throw new Error('Missing user id'); - if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) { - throw new Error('Identity keys unavailable in this browser — sign in again'); - } - if (!this._nonceNode || !this.nodePk) { - throw new Error('Handshake incomplete — reconnect and retry'); - } - - const C = window.MeshBayCrypto; - // Both public keys are derived from OUR OWN secret keys, never read back from - // the hub: signing a public key the directory handed us would reintroduce the - // substitution this whole mechanism exists to close. - const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); - const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); - const ts = Math.floor(Date.now() / 1000); - - // group_id is empty: operator authority is node-wide, not per group. - const transcript = C.joinTranscript( - this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript); - - const resp = await this._sendAndWait({ - type: 'join_request', - v: '0.1', - group_id: '', - pk_ed25519: pkEdB64, - pk_x25519: pkXB64, - code: code || '', - ts, - sig, - }); - - if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused'); - if (resp.type !== 'join_result' || !resp.ok) { - const reason = resp.reason || 'unknown'; - const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`); - err.reason = reason; - throw err; - } - this.memberRole = 'operator'; - return resp; - } - /** * The full index. Resolves with the sealed payload already opened — * `_applyIndexMessage` does that before it hands the message to whoever is @@ -1452,1642 +1385,136 @@ class MeshBayTransport { } /** - * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7). - * Keyed by the entry's own `id` (its content hash) — never a path: a - * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so - * two files sharing a folder (any multi-episode season) would resolve to - * whichever entry the node's index happened to return first (found live - * via the Music app's identical bug, 2026-08-25 — see apps/video_meta.py's - * `_do_media_meta_request`). - * `confidence: 0` (no tmdb_id, no fields) means no confident match — - * the caller falls back to a thumbnail-only card (§4.1), not an error. - */ - async fetchMediaMeta(fileId) { - const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * Unfurl a URL pasted in chat. The node fetches it (the browser cannot — - * CSP and CORS — and would leak every reader's IP), parses an OpenGraph - * card, and caches any image in its thumb store; `image_thumb_hash` then - * rides the normal file_req path like a poster. `ok: false` means "no - * preview" (blocked, unreachable, not HTML) — the caller just shows the - * bare link. Keyed by url: a message with several links fires one each. - */ - async fetchLinkPreview(url) { - const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7's - * per-season view) — a show's own tmdb_meta is one static field that does - * not necessarily describe every season alike, found live: a 3-season - * show whose overview read as season-3-specific for every season. - * Keyed like media_meta_req: a season-tab bar can fire a request per tab - * before the previous one lands, and matching by arrival order would hand - * one season's data to a different season's tab whenever two responses - * reordered. - */ - async fetchSeasonMeta(tmdbId, season) { - const msg = await this._sendAndWait({ - type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, - }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * Raw TMDB search candidates for an operator correcting a wrong automatic - * match — unlike fetchMediaMeta, this never collapses to one best guess: - * a human picks from several, so several is the point. Read-only, not an - * admin op: it looks nothing up in this node's own state and changes - * nothing, so it needs no signature (mirrors why media_meta_req isn't - * signed either). - */ - async searchTmdb(mediaType, query) { - const msg = await this._sendAndWait({ - type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, - }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * Correct a wrong automatic TMDB match. Signed like - * setTmdbConfig: it replaces what every member sees for a show/movie, - * node-wide (media_cache is shared, not per-viewer) — an unsigned - * override would let any member vandalize another show's metadata. - * Applies to every file sharing the representative one's display_title, - * not just the file the operator happened to be looking at (apps/ - * video_meta.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path - * — same reasoning as fetchMediaMeta above. - */ - async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) { - const msg = await this._sendAndWait({ - type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`; - return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); - } - return msg; - } - - /** - * Drop one file's cached TMDB match so it re-resolves with the node's - * current matcher (V13) — the one-click alternative to the full - * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. + * Liveness on this already-open channel. Resolves with the round trip in ms, + * rejects on timeout — a DataChannel whose peer vanished without closing + * still reads as connected, and nothing else here notices until a real + * request hangs. */ - async rematchTmdbMatch(fileId, signFn) { - const msg = await this._sendAndWait({ - type: 'tmdb_rematch', v: '0.7', file_id: fileId, - }); + async ping(timeoutMs = 5000) { + const token = Math.random().toString(36).slice(2); + const started = performance.now(); + const msg = await this._sendAndWait({ type: 'ping', v: '0.2', token }, timeoutMs); if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); - } - return msg; + return Math.round(performance.now() - started); } - /** - * Set/clear a custom TMDB API token, and/or set the language TMDB is - * queried in (e.g. "fr-FR") — one for the whole node, since both are one - * operator's shared credential/cache, not a per-group concern (see - * setTmdbEnabled below for the per-group on/off switch). Signed like - * setAppsEnabled/updateRoot — an unsigned change would let any - * member alter outbound third-party network traffic the operator never - * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears - * a previously-set custom token; omit it (undefined/null), like - * `language`, to leave whatever is stored unchanged. - */ - async setTmdbConfig(token, language, signFn) { - const msg = await this._sendAndWait({ - type: 'tmdb_config', v: '0.7', - token: token === undefined ? null : token, - language: language === undefined ? null : language, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // Must match the node's subject byte-for-byte (apps/video_meta.py - // _do_tmdb_config) — the token itself is never part of the subject - // (it would end up in the audit log in plaintext), only whether one - // was supplied. The language is not a secret, so it appears as-is. - const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; - return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); - } - return msg; - } + // + // Identity keys are per node, so a browser and a desktop client are two keys + // on one account here. A new one is admitted by a key this node already + // pinned — never by the hub, which holds no user keys and so cannot + // countersign anything. See docs/MESHBAY_DESIGN.md §3.3. - /** - * Whether TMDB lookups run for this group at all — per-group (2026-08-24, - * used to be node-wide): a real media-library group and a test/demo group - * on the same node need not share the decision to spend TMDB quota and - * make outbound requests. Signed like the rest — it decides whether - * this group's members' Videos tab ever makes outbound TMDB traffic. - */ - async setTmdbEnabled(enabled, signFn) { - const msg = await this._sendAndWait({ - type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // Must match the node's subject byte-for-byte (apps/video_meta.py - // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's - // lowercase. - const subject = enabled ? 'True' : 'False'; - return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn); - } - return msg; - } + // + // MNP 3.1, docs/playlists.md §8.1. The node stores bytes it cannot read and + // hands them back; `user_id` is never sent, because the node takes it from + // the authenticated session and would be wrong to take it from here. + // + // `blob_enc` is a Uint8Array and goes on the wire as msgpack `bin`, not + // base64: a playlist runs to hundreds of kilobytes and base64 is a third of + // every write. None of these needs an entry in the `ack` fallback at the + // bottom of _handleMessage — that branch exists for nodes too old to stamp + // `req_id`, and no node old enough to skip it knows these messages at all. - async setAppDirectories(appKey, directories, signFn) { - const clean = [...new Set( - (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), - )].sort(); - const msg = await this._sendAndWait({ - type: 'app_directories', v: '1.1', app: appKey, directories: clean, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn); - } - return msg; - } + get gekRaw() { return this._gekRaw; } /** - * Where chat attachments are written. + * Give an automatic reconnect already in progress (see _reconnectLoop) a + * bounded chance to land before giving up. * - * Its own message rather than `setAppDirectories('chat', ...)`: this one is - * a destination, and the node refuses a read-only root for it. A caller - * reaching for the generic form would get a refusal it has no reason to - * expect, so the difference is in the name. + * _sendAndWait does this internally for every request that goes through + * it, so most callers never need this directly. It exists for the ones + * that check `transport.connected` themselves before doing anything else — + * music-player.js's fetchTrackBlob is the one this was written for: found + * live throwing "Transport not connected" on the track *after* a + * screen-lock reconnect had already been under way for a while, because + * that check ran, saw `connected` still false, and threw before the + * reconnect it only had to wait a few seconds for got the chance to finish. + * A no-op — returns immediately — when nothing is being reconnected, + * including once one has already succeeded, so it is safe to call + * unconditionally ahead of such a check. */ - async setChatDirectory(path, signFn) { - const clean = (path || '').replace(/^\/+|\/+$/g, ''); - const msg = await this._sendAndWait({ - type: 'chat_directory', v: '1.1', path: clean, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn); - } - return msg; + async waitForReconnect(timeoutMs = 6000) { + if (!this._reconnectPromise) return; + await Promise.race([ + this._reconnectPromise.catch(() => {}), + new Promise((r) => setTimeout(r, timeoutMs)), + ]); } - /** Whether the node unfurls links members post in this group's chat. */ - async setChatLinkPreview(enabled, signFn) { - const msg = await this._sendAndWait({ - type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn); - } - return msg; + close() { + // Must be set before pc.close() below: that close() itself can drive the + // pc to "closed" synchronously, and the connectionstatechange handler + // only skips reconnecting because of this flag, not because "closed" is + // absent from its own trigger condition. + this._closed = true; + document.removeEventListener('visibilitychange', this._onVisibilityWake); + this._wakeReconnect(); + if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } + if (this._channel) this._channel.close(); + if (this._pc) this._pc.close(); + this._connected = false; + for (const [, p] of this._pending) p.reject(new Error('Transport closed')); + this._pending.clear(); } - /** - * Whether this group's files appear in members' cross-group Search. - * Presentation only — opening the group lists everything regardless. - */ - async setSearchListed(listed, signFn) { - const msg = await this._sendAndWait({ - type: 'search_listed', v: '3.0', listed: Boolean(listed), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'search_listed', listed ? 'on' : 'off', signFn); - } - return msg; - } + // ── Internal ────────────────────────────────────────────────────────────── /** - * Open a new chat epoch by hand. Operator only, and signed. + * Queue one sealed index message for opening. * - * Not a switch — there is nothing to turn on. The removals that matter open - * an epoch by themselves; this is the operator saying "move the key anyway", - * the same instruction as `rotateGek` and signed for the same reason. - */ - async rotateChatEpoch(signFn) { - // This connection's own group, not a parameter. Every settings pane takes - // the same props by design (`test_app_settings_plugin.py`), so reaching for - // a `groupId` here would make the loop that renders them conditional — and - // the transport already knows which group it is connected to. - const groupId = this._groupId || ''; - const msg = await this._sendAndWait({ - type: 'chat_epoch', v: '2.0', group_id: groupId, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp(msg, 'chat_epoch', groupId, signFn); - } - return msg; - } - - /** - * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8) - * — same shape as fetchMediaMeta, minus a season/episode concept: - * album-level (release), resolved from the track's own artist/album - * fields already in the index. Keyed by the track's own `id` (content - * hash), not a path — a path names the *folder* a track is in, and an - * album is one folder with many tracks in it; three unrelated albums - * shared one folder's track's cover before this fix (found live, - * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz - * off for this group, or nothing configured) — the caller falls back to - * the embedded/no cover it already had, not an error. - */ - async fetchMusicMeta(fileId) { - const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId }); - if (msg.type === 'error') throw new Error(msg.detail); - return msg; - } - - /** - * Server-side transcode of a Music-app file the browser's own