@muhkoo/connect 0.15.0-alpha.1 → 0.15.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/blockEngine.worker.js.map +1 -1
- package/dist/browser/index.js +147 -28
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +30 -10
- package/dist/server/index.js +147 -28
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blockEngine.worker.js","sources":["../../src/p2p/worker/protocol.ts","../../src/p2p/worker/blockEngine.ts","../../src/offline/cache/ShardCache.ts","../../src/utilities/bytes.ts","../../src/storage/transport/ShardClient.ts","../../src/p2p/worker/blockEngine.worker.ts"],"sourcesContent":["/**\n * Bitswap-lite wire protocol for peer block exchange. Four frame types over a\n * {@link ../transport/PeerTransport} data channel:\n *\n * WANT(hash) — \"does anyone have this block?\" / \"send it to me\"\n * HAVE(hash) — \"I hold this block\" (announce / reply to a WANT)\n * BLOCK(hash, bytes) — the block itself (verified by hash on receipt)\n * CANCEL(hash) — \"stop, I got it elsewhere\"\n *\n * Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:\n *\n * byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)\n * bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)\n * bytes 33.. : payload (BLOCK only)\n *\n * Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash\n * is the capability check (the receiver re-hashes the payload and drops a\n * mismatch). See {@link ./blockEngine}.\n */\n\nexport enum FrameType {\n WANT = 0,\n HAVE = 1,\n BLOCK = 2,\n CANCEL = 3,\n}\n\nexport interface DecodedFrame {\n type: FrameType;\n /** Lowercase hex SHA-256 (matches `shardHash`). */\n hash: string;\n /** Present only for {@link FrameType.BLOCK}. */\n payload?: Uint8Array;\n}\n\nconst HASH_BYTES = 32;\nconst HEADER = 1 + HASH_BYTES; // type + hash\n\nfunction hexToBytes(hex: string): Uint8Array {\n if (hex.length !== HASH_BYTES * 2) {\n throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);\n }\n const out = new Uint8Array(HASH_BYTES);\n for (let i = 0; i < HASH_BYTES; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);\n return out;\n}\n\nconst HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\nfunction bytesToHex(bytes: Uint8Array): string {\n let s = \"\";\n for (let i = 0; i < bytes.length; i++) s += HEX[bytes[i]];\n return s;\n}\n\nfunction header(type: FrameType, hash: string, extra = 0): { buf: Uint8Array; offset: number } {\n const buf = new Uint8Array(HEADER + extra);\n buf[0] = type;\n buf.set(hexToBytes(hash), 1);\n return { buf, offset: HEADER };\n}\n\nexport function encodeWant(hash: string): Uint8Array {\n return header(FrameType.WANT, hash).buf;\n}\n\nexport function encodeHave(hash: string): Uint8Array {\n return header(FrameType.HAVE, hash).buf;\n}\n\nexport function encodeCancel(hash: string): Uint8Array {\n return header(FrameType.CANCEL, hash).buf;\n}\n\nexport function encodeBlock(hash: string, payload: Uint8Array): Uint8Array {\n const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);\n buf.set(payload, offset);\n return buf;\n}\n\nexport function decode(frame: Uint8Array): DecodedFrame {\n if (frame.length < HEADER) throw new Error(\"protocol: frame too short\");\n const type = frame[0] as FrameType;\n const hash = bytesToHex(frame.subarray(1, HEADER));\n if (type === FrameType.BLOCK) {\n // Copy the payload out so it doesn't alias a transferred/pooled buffer.\n return { type, hash, payload: frame.slice(HEADER) };\n }\n return { type, hash };\n}\n","/**\n * `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks\n * only to a {@link BlockStore} (get/put/has) and an `out` sink that ships\n * encoded frames to a peer (or `\"*\"` to broadcast). The host wires `out` and\n * `handleFrame` to a real transport — on the main thread or inside the Web\n * Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.\n *\n * It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The\n * one trust check is integrity — an inbound BLOCK is re-hashed and dropped on\n * mismatch, so a malicious peer can't poison the store (the requester just\n * falls back to origin).\n */\n\nimport {\n FrameType,\n decode,\n encodeWant,\n encodeHave,\n encodeBlock,\n encodeCancel,\n} from \"./protocol\";\n\nexport interface BlockStore {\n get(hash: string): Promise<Uint8Array | null>;\n put(hash: string, bytes: Uint8Array): Promise<void>;\n has(hash: string): Promise<boolean>;\n}\n\n/** Ship an encoded frame to one peer, or `\"*\"` for every connected peer. */\nexport type OutSink = (target: string | \"*\", frame: Uint8Array) => void;\n\n/** Lowercase hex SHA-256 of `bytes` — injected so tests can stub it. */\nexport type Hasher = (bytes: Uint8Array) => Promise<string>;\n\ninterface PendingWant {\n resolvers: Array<(b: Uint8Array | null) => void>;\n timer: ReturnType<typeof setTimeout>;\n}\n\nexport class BlockEngine {\n private readonly wants = new Map<string, PendingWant>();\n\n constructor(\n private readonly store: BlockStore,\n private readonly out: OutSink,\n private readonly hash: Hasher,\n ) {}\n\n /**\n * Ask the swarm for a block. Resolves with the verified bytes, or `null` on\n * timeout (caller then falls back to origin). Returns immediately if we\n * already hold it; concurrent wants for the same hash share one request.\n */\n want(hash: string, timeoutMs: number): Promise<Uint8Array | null> {\n return new Promise((resolve) => {\n void this.store.get(hash).then((local) => {\n if (local) return resolve(local);\n const existing = this.wants.get(hash);\n if (existing) {\n existing.resolvers.push(resolve);\n return;\n }\n const timer = setTimeout(() => {\n const w = this.wants.get(hash);\n this.wants.delete(hash);\n this.out(\"*\", encodeCancel(hash));\n w?.resolvers.forEach((r) => r(null));\n }, timeoutMs);\n this.wants.set(hash, { resolvers: [resolve], timer });\n this.out(\"*\", encodeWant(hash));\n });\n });\n }\n\n /** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */\n announce(hash: string): void {\n this.out(\"*\", encodeHave(hash));\n }\n\n /** Handle one inbound frame from `peer`. */\n async handleFrame(peer: string, frame: Uint8Array): Promise<void> {\n let f;\n try {\n f = decode(frame);\n } catch {\n return; // malformed — ignore\n }\n switch (f.type) {\n case FrameType.WANT: {\n const bytes = await this.store.get(f.hash);\n if (bytes) this.out(peer, encodeBlock(f.hash, bytes));\n return;\n }\n case FrameType.HAVE: {\n // A peer announced a block we're still waiting for → pull from them.\n if (this.wants.has(f.hash)) this.out(peer, encodeWant(f.hash));\n return;\n }\n case FrameType.BLOCK: {\n const pending = this.wants.get(f.hash);\n if (!pending || !f.payload) return; // unsolicited / empty — ignore\n // Integrity: a peer can't poison the store. Drop a mismatch and\n // keep waiting (the timeout will fall back to origin).\n const got = await this.hash(f.payload);\n if (got !== f.hash) return;\n await this.store.put(f.hash, f.payload);\n this.wants.delete(f.hash);\n clearTimeout(pending.timer);\n this.out(\"*\", encodeCancel(f.hash)); // others can stop sending\n pending.resolvers.forEach((r) => r(f.payload!));\n return;\n }\n case FrameType.CANCEL:\n // We serve WANTs synchronously, so there's nothing in flight to\n // cancel yet. Reserved for chunked-send cancellation later.\n return;\n }\n }\n\n /** Drop all pending waits (resolve null) — on teardown. */\n close(): void {\n for (const [, w] of this.wants) {\n clearTimeout(w.timer);\n w.resolvers.forEach((r) => r(null));\n }\n this.wants.clear();\n }\n}\n\nexport default BlockEngine;\n","/**\n * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are\n * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them\n * is uniquely safe: a cache hit is self-validating, immutable, and shareable\n * across files. The Cache API (rather than IndexedDB) is the right home for\n * large binary blobs — it streams, it's purpose-built for response bodies, and\n * it has its own eviction.\n *\n * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather\n * than the real shard endpoint, so the cache is addressable by hash alone — the\n * sync engine can re-PUT a deferred shard knowing only its hash, without\n * reconstructing the accelerator URL. We never fetch these synthetic URLs; they\n * exist only as cache keys.\n *\n * Every method degrades to a no-op / null where the Cache API is unavailable\n * (Node, Workers, insecure contexts), so callers don't need to branch.\n */\n\nconst CACHE_NAME = \"muhkoo-shards-v1\";\n\nexport class ShardCache {\n static available(): boolean {\n try {\n return typeof caches !== \"undefined\";\n } catch {\n return false;\n }\n }\n\n private key(hash: string): string {\n return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;\n }\n\n private open(): Promise<Cache> {\n return caches.open(CACHE_NAME);\n }\n\n /** Cached shard bytes, or `null` on miss / when unavailable. */\n async get(hash: string): Promise<Uint8Array | null> {\n if (!ShardCache.available()) return null;\n try {\n const cache = await this.open();\n const res = await cache.match(this.key(hash));\n if (!res) return null;\n return new Uint8Array(await res.arrayBuffer());\n } catch {\n return null;\n }\n }\n\n /** Store shard bytes under their hash. Best-effort. */\n async put(hash: string, bytes: Uint8Array): Promise<void> {\n if (!ShardCache.available()) return;\n try {\n const cache = await this.open();\n await cache.put(\n this.key(hash),\n new Response(bytes as BodyInit, {\n headers: { \"content-type\": \"application/octet-stream\" },\n }),\n );\n } catch {\n /* quota / unavailable — degrade to online-only for this shard */\n }\n }\n\n /** Whether a shard is present in the cache. */\n async has(hash: string): Promise<boolean> {\n if (!ShardCache.available()) return false;\n try {\n const cache = await this.open();\n return Boolean(await cache.match(this.key(hash)));\n } catch {\n return false;\n }\n }\n}\n\nexport default ShardCache;\n","/**\n * Byte conversion helpers usable in both Node and the browser.\n *\n * Replaces Node's `Buffer` API for the small set of operations we actually\n * need in the crypto layer: hex/base64/base64url encode + decode, and array\n * concatenation. Lets `DoubleRatchet`, `KeyStore`, etc. compile against\n * platforms without `Buffer` (browsers, CF Workers).\n */\n\nconst HEX_CHARS = \"0123456789abcdef\";\n\n/** Encode a byte array as a lowercase hex string. */\nexport function toHex(bytes: Uint8Array): string {\n let out = \"\";\n for (let i = 0; i < bytes.length; i++) {\n const b = bytes[i];\n out += HEX_CHARS[b >> 4] + HEX_CHARS[b & 0x0f];\n }\n return out;\n}\n\n/** Decode a hex string into a byte array. Accepts upper/lowercase; rejects odd-length input. */\nexport function fromHex(hex: string): Uint8Array<ArrayBuffer> {\n if (hex.length % 2 !== 0) {\n throw new Error(`fromHex: input length must be even (got ${hex.length})`);\n }\n const ab = new ArrayBuffer(hex.length / 2);\n const out = new Uint8Array(ab);\n for (let i = 0; i < out.length; i++) {\n const byte = parseInt(hex.substring(i * 2, i * 2 + 2), 16);\n if (Number.isNaN(byte)) {\n throw new Error(`fromHex: invalid hex character at offset ${i * 2}`);\n }\n out[i] = byte;\n }\n return out;\n}\n\n/** Encode a byte array as a standard base64 string. */\nexport function toBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary);\n}\n\n/** Decode a standard base64 string to bytes. */\nexport function fromBase64(b64: string): Uint8Array<ArrayBuffer> {\n const binary = atob(b64);\n const ab = new ArrayBuffer(binary.length);\n const out = new Uint8Array(ab);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\n/** Encode a byte array as a base64url string (no padding). */\nexport function toBase64Url(bytes: Uint8Array): string {\n return toBase64(bytes).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Decode a base64url string (with or without padding) to bytes. */\nexport function fromBase64Url(b64url: string): Uint8Array<ArrayBuffer> {\n const padded = b64url.replace(/-/g, \"+\").replace(/_/g, \"/\") +\n \"=\".repeat((4 - (b64url.length % 4)) % 4);\n return fromBase64(padded);\n}\n\n/** Concatenate any number of byte arrays into a single Uint8Array. */\nexport function concatBytes(...arrays: (Uint8Array | ArrayBuffer)[]): Uint8Array<ArrayBuffer> {\n let total = 0;\n for (const a of arrays) total += a.byteLength;\n const ab = new ArrayBuffer(total);\n const out = new Uint8Array(ab);\n let offset = 0;\n for (const a of arrays) {\n const view = a instanceof Uint8Array ? a : new Uint8Array(a);\n out.set(view, offset);\n offset += view.length;\n }\n return out;\n}\n\n/** Encode a UTF-8 string as bytes. Thin wrapper around TextEncoder for symmetry. */\nexport function utf8Encode(s: string): Uint8Array<ArrayBuffer> {\n // TextEncoder.encode returns Uint8Array<ArrayBufferLike>; widen to the\n // ArrayBuffer variant explicitly so WebCrypto's BufferSource type accepts it.\n const u8 = new TextEncoder().encode(s);\n const ab = new ArrayBuffer(u8.byteLength);\n const out = new Uint8Array(ab);\n out.set(u8);\n return out;\n}\n\n/** Decode UTF-8 bytes to a string. */\nexport function utf8Decode(bytes: Uint8Array): string {\n return new TextDecoder().decode(bytes);\n}\n","/**\n * HTTP client for the open content-addressed shard store.\n *\n * Each shard is named by the lowercase hex SHA-256 of its ciphertext bytes.\n * The server verifies the hash on PUT, so the address authenticates the\n * contents — there's no benefit to ACLing PUT/GET, and making them open\n * enables cross-user dedup down the line.\n *\n * Wire protocol:\n * PUT /api/shards/:hash body: shard bytes (application/octet-stream)\n * 200 { ok: true, hash, size, dedup: boolean }\n * 400 if hash doesn't match body\n *\n * GET /api/shards/:hash 200 with shard bytes (application/octet-stream)\n * 404 if absent\n *\n * HEAD /api/shards/:hash 200 if present (no body) — used by callers to\n * skip a PUT they'd otherwise dedup-rewrite. The\n * server can ignore the optimization if it wants.\n */\n\nimport { toHex } from \"../../utilities/bytes\";\n\n/**\n * How long concurrent shard reads are gathered before one batch is sent.\n *\n * Long enough to collect what a concurrent directory walk issues at the same\n * moment, short enough that a single read does not feel it.\n */\nconst SHARD_BATCH_WINDOW_MS = 6;\n\n/** Hashes per batch. Must not exceed what the server will accept. */\nconst SHARD_BATCH_MAX = 256;\n\n/** Decode base64 without `atob`'s per-character loop cost on large shards. */\nfunction bytesFromBase64(b64: string): Uint8Array {\n const binary = atob(b64);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\n/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */\nconst HEDGE_MS = 500;\n\n/**\n * A content-addressed byte cache for shards (the offline layer's `ShardCache`\n * satisfies this structurally). Declared here as a minimal interface so the\n * storage layer doesn't import the offline module — keeping the dependency\n * arrow one-way (offline → storage).\n */\nexport interface ShardByteCache {\n get(hash: string): Promise<Uint8Array | null>;\n put(hash: string, bytes: Uint8Array): Promise<void>;\n}\n\n/**\n * A peer block source (the p2p layer's `PeerExchange` satisfies this\n * structurally). Consulted between the local cache and origin: a block fetched\n * from a peer is verified by hash before use. Declared here so the storage layer\n * doesn't import the p2p module (dependency arrow stays p2p → storage).\n */\nexport interface PeerBlockSource {\n getBlock(hash: string, opts?: { timeoutMs?: number }): Promise<Uint8Array | null>;\n announce(hash: string): void;\n}\n\nexport interface ShardClientOptions {\n /** Base URL of the accelerator (e.g. `https://muhkoo-accelerator.workers.dev`). */\n baseUrl: string;\n /**\n * Path prefix for the shard endpoints, joined to `baseUrl` before the\n * hash. Defaults to `/api/shards`, which targets a global shard store\n * (e.g. a dedicated `ShardStoreDO`). Pass a room-scoped value like\n * `/api/spaces/{roomId}/shards` to keep shards bound to a specific\n * `SharedSpaceDO` — that's the path the chat application uses today.\n *\n * Leading slash optional; trailing slash stripped. The final URL is\n * always `${baseUrl}${normalizedPrefix}/${encodeURIComponent(hash)}`.\n */\n pathPrefix?: string;\n /**\n * Optional custom fetch implementation. Defaults to `globalThis.fetch`.\n * Provided so callers can inject retries / auth / instrumentation.\n */\n fetch?: typeof fetch;\n /**\n * Optional offline byte cache. When present, GETs are served cache-first and\n * populated on success, and PUTs write the cache before the network so a\n * just-uploaded file reads back offline. See {@link ShardByteCache}.\n */\n cache?: ShardByteCache;\n /**\n * Coalesce concurrent origin reads into `POST {pathPrefix}/batch`. On by\n * default: it is what keeps opening a project from costing one request per\n * shard. Set false to force one-at-a-time — useful against a server without\n * the route, though that is detected and handled automatically.\n */\n batchShards?: boolean;\n /**\n * Called when a PUT can't reach the network (offline). Lets the caller queue\n * the upload for replay on reconnect; the bytes are already in {@link cache}\n * keyed by `hash`, so only the hash needs recording.\n */\n deferUpload?: (hash: string) => Promise<void>;\n /**\n * Optional peer block source. When present, GETs try peers between the local\n * cache and origin (peer blocks are verified by hash); PUTs announce the new\n * block to peers. Best-effort — any failure falls through to origin.\n */\n peers?: PeerBlockSource;\n /**\n * When true, {@link ShardClient.putShard} returns as soon as the shard is\n * written to {@link cache} and announced to {@link peers} — the origin PUT is\n * fired in the BACKGROUND rather than awaited. This makes a large write's\n * manifest available immediately (so a peer/worker can start pulling the\n * shards over P2P right away) while origin catches up as a durable fallback.\n * On a background-PUT failure the shard is handed to {@link deferUpload} (if\n * present) for replay. Requires a {@link cache} so the bytes survive locally\n * until the background upload lands. No-op without a cache.\n */\n backgroundUpload?: boolean;\n}\n\n/**\n * Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in\n * every runtime this SDK targets.\n */\nexport async function shardHash(bytes: Uint8Array): Promise<string> {\n const subtle = (globalThis.crypto as Crypto | undefined)?.subtle;\n if (!subtle) {\n throw new Error(\"shardHash: `globalThis.crypto.subtle` is unavailable in this runtime.\");\n }\n const digest = await subtle.digest(\"SHA-256\", bytes as BufferSource);\n return toHex(new Uint8Array(digest));\n}\n\nexport class ShardClient {\n private readonly baseUrl: string;\n private readonly pathPrefix: string;\n private readonly fetchFn: typeof fetch;\n private readonly cache?: ShardByteCache;\n /** Hashes waiting for the next batch, each with everyone awaiting it. */\n private pendingShards!: Map<string, Array<{ resolve: (v: Uint8Array | null) => void; reject: (e: unknown) => void }>>;\n private batchTimer!: ReturnType<typeof setTimeout> | null;\n /** False once the server is known not to have the batch route. */\n private batchSupported!: boolean;\n private readonly deferUpload?: (hash: string) => Promise<void>;\n private readonly peers?: PeerBlockSource;\n private readonly backgroundUpload: boolean;\n\n constructor(opts: ShardClientOptions) {\n if (!opts?.baseUrl) throw new Error(\"ShardClient: `baseUrl` is required\");\n // Strip trailing slash so url concatenation stays clean.\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n // Normalize prefix to leading-slash + no-trailing-slash form so the\n // URL builder can blindly append `/{hash}`.\n const rawPrefix = opts.pathPrefix ?? \"/api/shards\";\n const withSlash = rawPrefix.startsWith(\"/\") ? rawPrefix : `/${rawPrefix}`;\n this.pathPrefix = withSlash.replace(/\\/+$/, \"\");\n const f = opts.fetch ?? globalThis.fetch;\n if (typeof f !== \"function\") {\n throw new Error(\"ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.\");\n }\n this.fetchFn = f.bind(globalThis);\n this.cache = opts.cache;\n // Coalescing state. `batchSupported` latches false against a deployment\n // without the batch route, so we degrade once rather than per request.\n this.pendingShards = new Map();\n this.batchTimer = null;\n this.batchSupported = opts.batchShards !== false;\n this.deferUpload = opts.deferUpload;\n this.peers = opts.peers;\n // Background upload needs a local cache to hold the bytes until the PUT\n // lands; without one it would race the reader against a not-yet-uploaded\n // origin, so we quietly ignore the flag when there's no cache.\n this.backgroundUpload = Boolean(opts.backgroundUpload && opts.cache);\n }\n\n /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */\n urlForHash(hash: string): string {\n return `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(hash)}`;\n }\n\n /**\n * Upload a shard. The server verifies SHA-256(body) matches `:hash` and\n * rejects with 400 if not, so the caller is the source of truth on\n * naming. Re-uploading an existing shard is idempotent — the server\n * returns `dedup: true` in that case.\n */\n async putShard(hash: string, bytes: Uint8Array): Promise<{ dedup: boolean }> {\n const url = this.urlForHash(hash);\n // Write the cache first so a just-uploaded file reads back even if the\n // network PUT then fails (offline). The bytes are content-addressed, so\n // this is safe to keep regardless of upload outcome.\n if (this.cache) await this.cache.put(hash, bytes);\n // Tell the swarm we now hold this block, so peers can pull it from us.\n if (this.peers) this.peers.announce(hash);\n // Background mode: the bytes are cached + announced, so return now and let\n // origin catch up asynchronously (a peer can already pull this block). The\n // PUT is enqueued through a bounded worker pool so a big file doesn't fire\n // hundreds of concurrent uploads at once.\n if (this.backgroundUpload) {\n this.enqueueBackgroundUpload(url, hash);\n return { dedup: false };\n }\n let response: Response;\n try {\n response = await this.fetchFn(url, {\n method: \"PUT\",\n headers: { \"content-type\": \"application/octet-stream\" },\n // BufferSource is what fetch's body expects; a Uint8Array satisfies it.\n body: bytes as BodyInit,\n });\n } catch (err) {\n // Network unreachable (offline). If a defer hook is wired, queue the\n // upload for replay and report success — the bytes are cached.\n if (this.deferUpload) {\n await this.deferUpload(hash);\n return { dedup: false };\n }\n throw err;\n }\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);\n }\n let dedup = false;\n try {\n const json = (await response.json()) as { dedup?: boolean };\n dedup = Boolean(json?.dedup);\n } catch {\n // Server returned 200 with no body — treat as fresh upload.\n }\n return { dedup };\n }\n\n // ── bounded background-upload pool (backgroundUpload mode) ──\n private bgActive = 0;\n private readonly bgQueue: Array<() => void> = [];\n private static readonly BG_CONCURRENCY = 6;\n\n /** Queue a shard's origin PUT to run in the background under a concurrency\n * cap. Bytes are re-read from {@link cache} at upload time (not held in the\n * queue) so a large file's backlog doesn't pin a second copy in memory. */\n private enqueueBackgroundUpload(url: string, hash: string): void {\n const run = async () => {\n this.bgActive++;\n try {\n const bytes = this.cache ? await this.cache.get(hash) : null;\n if (bytes) await this.uploadToOrigin(url, hash, bytes);\n } catch {\n // Origin unreachable/failed — queue for replay if we can; otherwise\n // the block is still cached + peer-served, so a later re-PUT covers it.\n if (this.deferUpload) { try { await this.deferUpload(hash); } catch { /* best-effort */ } }\n } finally {\n this.bgActive--;\n const next = this.bgQueue.shift();\n if (next) next();\n }\n };\n if (this.bgActive < ShardClient.BG_CONCURRENCY) void run();\n else this.bgQueue.push(() => void run());\n }\n\n /** PUT the shard bytes to origin, verifying the server accepted them.\n * Used by the background-upload path; throws on network/HTTP failure. */\n private async uploadToOrigin(url: string, hash: string, bytes: Uint8Array): Promise<void> {\n const response = await this.fetchFn(url, {\n method: \"PUT\",\n headers: { \"content-type\": \"application/octet-stream\" },\n body: bytes as BodyInit,\n });\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);\n }\n }\n\n /**\n * Fetch a shard by hash. Throws if the server returns a non-200; returns\n * `null` for 404 so callers can distinguish \"missing\" from \"errored\".\n */\n async getShard(hash: string): Promise<Uint8Array | null> {\n // Cache-first: a hit is content-verified by construction (key == hash).\n if (this.cache) {\n const cached = await this.cache.get(hash);\n if (cached) return cached;\n }\n // No peers → straight to origin.\n if (!this.peers) return this.fetchFromOrigin(hash);\n\n // HEDGED RACE: prefer a peer (saves origin egress) but never let a slow\n // or missing peer stall delivery — give the peer a short head start, then\n // start origin in parallel and take whichever returns the block first.\n const peers = this.peers;\n return new Promise<Uint8Array | null>((resolve, reject) => {\n let done = false;\n let originStarted = false;\n let timer: ReturnType<typeof setTimeout> | null = null;\n const win = (b: Uint8Array | null) => {\n if (done) return;\n done = true;\n if (timer) clearTimeout(timer);\n resolve(b);\n };\n const fail = (e: unknown) => {\n if (!done) { done = true; reject(e); }\n };\n const startOrigin = () => {\n if (originStarted || done) return;\n originStarted = true;\n if (timer) { clearTimeout(timer); timer = null; }\n this.fetchFromOrigin(hash).then(win, fail);\n };\n timer = setTimeout(startOrigin, HEDGE_MS);\n peers.getBlock(hash).then(\n (b) => {\n if (b) {\n if (this.cache) void this.cache.put(hash, b);\n win(b); // peer delivered first\n } else {\n startOrigin(); // peer miss → don't wait out the hedge\n }\n },\n () => startOrigin(), // peer error → origin now\n );\n });\n }\n\n /**\n * Fetch from origin, COALESCING concurrent requests into one batch.\n *\n * A shard is not a file. A project is thousands of small source modules,\n * each split into shards, so reading one at a time made opening a project\n * cost thousands of requests — 2,086 for a 519-file project. Latency, not\n * bytes, is what that spends: Chromium took 18.4s over those requests and\n * Brave, which filters every one, took 37.5s for identical work. Batching\n * collapses whatever is in flight at the same moment into a single POST,\n * which is why it helps most exactly where it hurt most.\n *\n * Callers are unchanged: `getShard` still asks for one shard and gets one\n * shard. The window is a few milliseconds — long enough to gather the reads\n * a concurrent walk issues together, short enough not to be felt by a lone\n * read.\n *\n * Falls back permanently to one-at-a-time if the server has no batch route,\n * so a new SDK keeps working against an older deployment.\n */\n private fetchFromOrigin(hash: string): Promise<Uint8Array | null> {\n if (!this.batchSupported) return this.fetchOneFromOrigin(hash);\n return new Promise<Uint8Array | null>((resolve, reject) => {\n const waiting = this.pendingShards.get(hash);\n if (waiting) { waiting.push({ resolve, reject }); return; }\n this.pendingShards.set(hash, [{ resolve, reject }]);\n if (this.batchTimer === null) {\n this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);\n }\n });\n }\n\n /** Send one batch of whatever has accumulated, and settle those callers. */\n private async flushShardBatch(): Promise<void> {\n this.batchTimer = null;\n if (this.pendingShards.size === 0) return;\n\n const hashes = [...this.pendingShards.keys()].slice(0, SHARD_BATCH_MAX);\n const waiters = hashes.map((h) => {\n const w = this.pendingShards.get(h)!;\n this.pendingShards.delete(h);\n return [h, w] as const;\n });\n // Anything over the cap waits for the next window rather than being\n // dropped or silently truncated.\n if (this.pendingShards.size > 0 && this.batchTimer === null) {\n this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);\n }\n\n let shards: Record<string, string>;\n try {\n const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ hashes }),\n });\n if (res.status === 404 || res.status === 405) {\n // No batch route on this deployment. Stop trying, and serve\n // everyone individually — including the batch we just gathered.\n this.batchSupported = false;\n for (const [h, ws] of waiters) {\n this.fetchOneFromOrigin(h).then(\n (b) => ws.forEach((w) => w.resolve(b)),\n (e) => ws.forEach((w) => w.reject(e)),\n );\n }\n return;\n }\n if (!res.ok) throw new Error(`ShardClient batch failed (${res.status}): ${await safeText(res)}`);\n const parsed: unknown = await res.json().catch(() => null);\n // A 200 is not proof the batch route exists. A catch-all handler, a\n // proxy, or an older deployment can answer this POST with something\n // else entirely, and trusting the shape would resolve every hash to\n // `null` — reads silently returning \"missing\" for shards that are\n // right there. Anything not shaped like a batch reply is treated as\n // \"no batch route\", exactly like a 404.\n const candidate = (parsed as { shards?: unknown } | null)?.shards;\n if (!candidate || typeof candidate !== \"object\") {\n this.batchSupported = false;\n for (const [h, ws] of waiters) {\n this.fetchOneFromOrigin(h).then(\n (b) => ws.forEach((w) => w.resolve(b)),\n (e) => ws.forEach((w) => w.reject(e)),\n );\n }\n return;\n }\n shards = candidate as Record<string, string>;\n } catch (err) {\n // Offline with a cache behaves as it does for a single fetch: report\n // absence so Reed-Solomon recovery can still be attempted.\n for (const [, ws] of waiters) {\n if (this.cache) ws.forEach((w) => w.resolve(null));\n else ws.forEach((w) => w.reject(err));\n }\n return;\n }\n\n for (const [hash, ws] of waiters) {\n const b64 = shards[hash];\n if (typeof b64 !== \"string\") { ws.forEach((w) => w.resolve(null)); continue; }\n let bytes: Uint8Array;\n try { bytes = bytesFromBase64(b64); }\n catch (e) { ws.forEach((w) => w.reject(e)); continue; }\n if (this.cache) void this.cache.put(hash, bytes);\n ws.forEach((w) => w.resolve(bytes));\n }\n }\n\n /** Fetch a shard from origin. Returns `null` for 404 (or offline when a\n * cache is present, so RS recovery can proceed); throws otherwise. */\n private async fetchOneFromOrigin(hash: string): Promise<Uint8Array | null> {\n const url = this.urlForHash(hash);\n let response: Response;\n try {\n response = await this.fetchFn(url, { method: \"GET\" });\n } catch (err) {\n if (this.cache) return null;\n throw err;\n }\n if (response.status === 404) return null;\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);\n }\n const bytes = new Uint8Array(await response.arrayBuffer());\n if (this.cache) void this.cache.put(hash, bytes);\n return bytes;\n }\n\n /**\n * Check whether a shard exists. Lets the caller skip a PUT they'd otherwise\n * dedup-rewrite. The server can implement this as a HEAD or return a small\n * JSON body — we don't depend on either.\n */\n async hasShard(hash: string): Promise<boolean> {\n const url = this.urlForHash(hash);\n const response = await this.fetchFn(url, { method: \"HEAD\" });\n return response.status === 200;\n }\n}\n\nasync function safeText(response: Response): Promise<string> {\n try { return await response.text(); } catch { return \"\"; }\n}\n","/**\n * Dedicated Web Worker entry that hosts a {@link ./blockEngine.BlockEngine}. The\n * blockstore is the Cache-API-backed {@link ../../offline/cache/ShardCache}\n * (the Cache API is available in workers and origin-scoped, so it's the *same*\n * store the main thread reads), and hashing uses {@link\n * ../../storage/transport/ShardClient.shardHash}.\n *\n * Message protocol (see {@link ./engineClient.WorkerEngineHost}):\n * in : {t:\"frame\",peer,data} | {t:\"want\",id,hash,timeout} | {t:\"announce\",hash} | {t:\"close\"}\n * out: {t:\"out\",target,data} | {t:\"want-result\",id,data}\n *\n * Consumers instantiate this with a bundler-resolved URL, e.g.\n * new Worker(new URL(\"@muhkoo/connect/dist/.../blockEngine.worker.js\", import.meta.url), { type: \"module\" })\n * and pass `() => that worker` as `workerFactory` to the client's p2p options.\n */\n\nimport { BlockEngine } from \"./blockEngine\";\nimport { ShardCache } from \"../../offline/cache/ShardCache\";\nimport { shardHash } from \"../../storage/transport/ShardClient\";\n\n/** Minimal worker-scope shape (avoids needing the WebWorker tsconfig lib). */\ninterface WorkerScope {\n onmessage: ((e: MessageEvent) => void) | null;\n postMessage(message: unknown, transfer?: Transferable[]): void;\n}\nconst ctx = self as unknown as WorkerScope;\n\nconst engine = new BlockEngine(\n new ShardCache(),\n (target, frame) => {\n const buf = frame.buffer as ArrayBuffer;\n ctx.postMessage({ t: \"out\", target, data: buf }, [buf]);\n },\n shardHash,\n);\n\nctx.onmessage = (e: MessageEvent) => {\n const m = e.data as\n | { t: \"frame\"; peer: string; data: ArrayBuffer }\n | { t: \"want\"; id: number; hash: string; timeout: number }\n | { t: \"announce\"; hash: string }\n | { t: \"close\" };\n\n if (m.t === \"frame\") {\n void engine.handleFrame(m.peer, new Uint8Array(m.data));\n } else if (m.t === \"want\") {\n void engine.want(m.hash, m.timeout).then((bytes) => {\n const buf = bytes ? (bytes.buffer as ArrayBuffer) : null;\n ctx.postMessage({ t: \"want-result\", id: m.id, data: buf }, buf ? [buf] : []);\n });\n } else if (m.t === \"announce\") {\n engine.announce(m.hash);\n } else if (m.t === \"close\") {\n engine.close();\n }\n};\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"blockEngine.worker.js","sources":["../../src/p2p/worker/protocol.ts","../../src/p2p/worker/blockEngine.ts","../../src/offline/cache/ShardCache.ts","../../src/utilities/bytes.ts","../../src/storage/transport/ShardClient.ts","../../src/p2p/worker/blockEngine.worker.ts"],"sourcesContent":["/**\n * Bitswap-lite wire protocol for peer block exchange. Four frame types over a\n * {@link ../transport/PeerTransport} data channel:\n *\n * WANT(hash) — \"does anyone have this block?\" / \"send it to me\"\n * HAVE(hash) — \"I hold this block\" (announce / reply to a WANT)\n * BLOCK(hash, bytes) — the block itself (verified by hash on receipt)\n * CANCEL(hash) — \"stop, I got it elsewhere\"\n *\n * Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:\n *\n * byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)\n * bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)\n * bytes 33.. : payload (BLOCK only)\n *\n * Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash\n * is the capability check (the receiver re-hashes the payload and drops a\n * mismatch). See {@link ./blockEngine}.\n */\n\nexport enum FrameType {\n WANT = 0,\n HAVE = 1,\n BLOCK = 2,\n CANCEL = 3,\n}\n\nexport interface DecodedFrame {\n type: FrameType;\n /** Lowercase hex SHA-256 (matches `shardHash`). */\n hash: string;\n /** Present only for {@link FrameType.BLOCK}. */\n payload?: Uint8Array;\n}\n\nconst HASH_BYTES = 32;\nconst HEADER = 1 + HASH_BYTES; // type + hash\n\nfunction hexToBytes(hex: string): Uint8Array {\n if (hex.length !== HASH_BYTES * 2) {\n throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);\n }\n const out = new Uint8Array(HASH_BYTES);\n for (let i = 0; i < HASH_BYTES; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);\n return out;\n}\n\nconst HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\nfunction bytesToHex(bytes: Uint8Array): string {\n let s = \"\";\n for (let i = 0; i < bytes.length; i++) s += HEX[bytes[i]];\n return s;\n}\n\nfunction header(type: FrameType, hash: string, extra = 0): { buf: Uint8Array; offset: number } {\n const buf = new Uint8Array(HEADER + extra);\n buf[0] = type;\n buf.set(hexToBytes(hash), 1);\n return { buf, offset: HEADER };\n}\n\nexport function encodeWant(hash: string): Uint8Array {\n return header(FrameType.WANT, hash).buf;\n}\n\nexport function encodeHave(hash: string): Uint8Array {\n return header(FrameType.HAVE, hash).buf;\n}\n\nexport function encodeCancel(hash: string): Uint8Array {\n return header(FrameType.CANCEL, hash).buf;\n}\n\nexport function encodeBlock(hash: string, payload: Uint8Array): Uint8Array {\n const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);\n buf.set(payload, offset);\n return buf;\n}\n\nexport function decode(frame: Uint8Array): DecodedFrame {\n if (frame.length < HEADER) throw new Error(\"protocol: frame too short\");\n const type = frame[0] as FrameType;\n const hash = bytesToHex(frame.subarray(1, HEADER));\n if (type === FrameType.BLOCK) {\n // Copy the payload out so it doesn't alias a transferred/pooled buffer.\n return { type, hash, payload: frame.slice(HEADER) };\n }\n return { type, hash };\n}\n","/**\n * `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks\n * only to a {@link BlockStore} (get/put/has) and an `out` sink that ships\n * encoded frames to a peer (or `\"*\"` to broadcast). The host wires `out` and\n * `handleFrame` to a real transport — on the main thread or inside the Web\n * Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.\n *\n * It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The\n * one trust check is integrity — an inbound BLOCK is re-hashed and dropped on\n * mismatch, so a malicious peer can't poison the store (the requester just\n * falls back to origin).\n */\n\nimport {\n FrameType,\n decode,\n encodeWant,\n encodeHave,\n encodeBlock,\n encodeCancel,\n} from \"./protocol\";\n\nexport interface BlockStore {\n get(hash: string): Promise<Uint8Array | null>;\n put(hash: string, bytes: Uint8Array): Promise<void>;\n has(hash: string): Promise<boolean>;\n}\n\n/** Ship an encoded frame to one peer, or `\"*\"` for every connected peer. */\nexport type OutSink = (target: string | \"*\", frame: Uint8Array) => void;\n\n/** Lowercase hex SHA-256 of `bytes` — injected so tests can stub it. */\nexport type Hasher = (bytes: Uint8Array) => Promise<string>;\n\ninterface PendingWant {\n resolvers: Array<(b: Uint8Array | null) => void>;\n timer: ReturnType<typeof setTimeout>;\n}\n\nexport class BlockEngine {\n private readonly wants = new Map<string, PendingWant>();\n\n constructor(\n private readonly store: BlockStore,\n private readonly out: OutSink,\n private readonly hash: Hasher,\n ) {}\n\n /**\n * Ask the swarm for a block. Resolves with the verified bytes, or `null` on\n * timeout (caller then falls back to origin). Returns immediately if we\n * already hold it; concurrent wants for the same hash share one request.\n */\n want(hash: string, timeoutMs: number): Promise<Uint8Array | null> {\n return new Promise((resolve) => {\n void this.store.get(hash).then((local) => {\n if (local) return resolve(local);\n const existing = this.wants.get(hash);\n if (existing) {\n existing.resolvers.push(resolve);\n return;\n }\n const timer = setTimeout(() => {\n const w = this.wants.get(hash);\n this.wants.delete(hash);\n this.out(\"*\", encodeCancel(hash));\n w?.resolvers.forEach((r) => r(null));\n }, timeoutMs);\n this.wants.set(hash, { resolvers: [resolve], timer });\n this.out(\"*\", encodeWant(hash));\n });\n });\n }\n\n /** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */\n announce(hash: string): void {\n this.out(\"*\", encodeHave(hash));\n }\n\n /** Handle one inbound frame from `peer`. */\n async handleFrame(peer: string, frame: Uint8Array): Promise<void> {\n let f;\n try {\n f = decode(frame);\n } catch {\n return; // malformed — ignore\n }\n switch (f.type) {\n case FrameType.WANT: {\n const bytes = await this.store.get(f.hash);\n if (bytes) this.out(peer, encodeBlock(f.hash, bytes));\n return;\n }\n case FrameType.HAVE: {\n // A peer announced a block we're still waiting for → pull from them.\n if (this.wants.has(f.hash)) this.out(peer, encodeWant(f.hash));\n return;\n }\n case FrameType.BLOCK: {\n const pending = this.wants.get(f.hash);\n if (!pending || !f.payload) return; // unsolicited / empty — ignore\n // Integrity: a peer can't poison the store. Drop a mismatch and\n // keep waiting (the timeout will fall back to origin).\n const got = await this.hash(f.payload);\n if (got !== f.hash) return;\n await this.store.put(f.hash, f.payload);\n this.wants.delete(f.hash);\n clearTimeout(pending.timer);\n this.out(\"*\", encodeCancel(f.hash)); // others can stop sending\n pending.resolvers.forEach((r) => r(f.payload!));\n return;\n }\n case FrameType.CANCEL:\n // We serve WANTs synchronously, so there's nothing in flight to\n // cancel yet. Reserved for chunked-send cancellation later.\n return;\n }\n }\n\n /** Drop all pending waits (resolve null) — on teardown. */\n close(): void {\n for (const [, w] of this.wants) {\n clearTimeout(w.timer);\n w.resolvers.forEach((r) => r(null));\n }\n this.wants.clear();\n }\n}\n\nexport default BlockEngine;\n","/**\n * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are\n * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them\n * is uniquely safe: a cache hit is self-validating, immutable, and shareable\n * across files. The Cache API (rather than IndexedDB) is the right home for\n * large binary blobs — it streams, it's purpose-built for response bodies, and\n * it has its own eviction.\n *\n * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather\n * than the real shard endpoint, so the cache is addressable by hash alone — the\n * sync engine can re-PUT a deferred shard knowing only its hash, without\n * reconstructing the accelerator URL. We never fetch these synthetic URLs; they\n * exist only as cache keys.\n *\n * Every method degrades to a no-op / null where the Cache API is unavailable\n * (Node, Workers, insecure contexts), so callers don't need to branch.\n */\n\nconst CACHE_NAME = \"muhkoo-shards-v1\";\n\nexport class ShardCache {\n static available(): boolean {\n try {\n return typeof caches !== \"undefined\";\n } catch {\n return false;\n }\n }\n\n private key(hash: string): string {\n return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;\n }\n\n private open(): Promise<Cache> {\n return caches.open(CACHE_NAME);\n }\n\n /** Cached shard bytes, or `null` on miss / when unavailable. */\n async get(hash: string): Promise<Uint8Array | null> {\n if (!ShardCache.available()) return null;\n try {\n const cache = await this.open();\n const res = await cache.match(this.key(hash));\n if (!res) return null;\n return new Uint8Array(await res.arrayBuffer());\n } catch {\n return null;\n }\n }\n\n /** Store shard bytes under their hash. Best-effort. */\n async put(hash: string, bytes: Uint8Array): Promise<void> {\n if (!ShardCache.available()) return;\n try {\n const cache = await this.open();\n await cache.put(\n this.key(hash),\n new Response(bytes as BodyInit, {\n headers: { \"content-type\": \"application/octet-stream\" },\n }),\n );\n } catch {\n /* quota / unavailable — degrade to online-only for this shard */\n }\n }\n\n /** Whether a shard is present in the cache. */\n async has(hash: string): Promise<boolean> {\n if (!ShardCache.available()) return false;\n try {\n const cache = await this.open();\n return Boolean(await cache.match(this.key(hash)));\n } catch {\n return false;\n }\n }\n}\n\nexport default ShardCache;\n","/**\n * Byte conversion helpers usable in both Node and the browser.\n *\n * Replaces Node's `Buffer` API for the small set of operations we actually\n * need in the crypto layer: hex/base64/base64url encode + decode, and array\n * concatenation. Lets `DoubleRatchet`, `KeyStore`, etc. compile against\n * platforms without `Buffer` (browsers, CF Workers).\n */\n\nconst HEX_CHARS = \"0123456789abcdef\";\n\n/** Encode a byte array as a lowercase hex string. */\nexport function toHex(bytes: Uint8Array): string {\n let out = \"\";\n for (let i = 0; i < bytes.length; i++) {\n const b = bytes[i];\n out += HEX_CHARS[b >> 4] + HEX_CHARS[b & 0x0f];\n }\n return out;\n}\n\n/** Decode a hex string into a byte array. Accepts upper/lowercase; rejects odd-length input. */\nexport function fromHex(hex: string): Uint8Array<ArrayBuffer> {\n if (hex.length % 2 !== 0) {\n throw new Error(`fromHex: input length must be even (got ${hex.length})`);\n }\n const ab = new ArrayBuffer(hex.length / 2);\n const out = new Uint8Array(ab);\n for (let i = 0; i < out.length; i++) {\n const byte = parseInt(hex.substring(i * 2, i * 2 + 2), 16);\n if (Number.isNaN(byte)) {\n throw new Error(`fromHex: invalid hex character at offset ${i * 2}`);\n }\n out[i] = byte;\n }\n return out;\n}\n\n/** Encode a byte array as a standard base64 string. */\nexport function toBase64(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary);\n}\n\n/** Decode a standard base64 string to bytes. */\nexport function fromBase64(b64: string): Uint8Array<ArrayBuffer> {\n const binary = atob(b64);\n const ab = new ArrayBuffer(binary.length);\n const out = new Uint8Array(ab);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\n/** Encode a byte array as a base64url string (no padding). */\nexport function toBase64Url(bytes: Uint8Array): string {\n return toBase64(bytes).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Decode a base64url string (with or without padding) to bytes. */\nexport function fromBase64Url(b64url: string): Uint8Array<ArrayBuffer> {\n const padded = b64url.replace(/-/g, \"+\").replace(/_/g, \"/\") +\n \"=\".repeat((4 - (b64url.length % 4)) % 4);\n return fromBase64(padded);\n}\n\n/** Concatenate any number of byte arrays into a single Uint8Array. */\nexport function concatBytes(...arrays: (Uint8Array | ArrayBuffer)[]): Uint8Array<ArrayBuffer> {\n let total = 0;\n for (const a of arrays) total += a.byteLength;\n const ab = new ArrayBuffer(total);\n const out = new Uint8Array(ab);\n let offset = 0;\n for (const a of arrays) {\n const view = a instanceof Uint8Array ? a : new Uint8Array(a);\n out.set(view, offset);\n offset += view.length;\n }\n return out;\n}\n\n/** Encode a UTF-8 string as bytes. Thin wrapper around TextEncoder for symmetry. */\nexport function utf8Encode(s: string): Uint8Array<ArrayBuffer> {\n // TextEncoder.encode returns Uint8Array<ArrayBufferLike>; widen to the\n // ArrayBuffer variant explicitly so WebCrypto's BufferSource type accepts it.\n const u8 = new TextEncoder().encode(s);\n const ab = new ArrayBuffer(u8.byteLength);\n const out = new Uint8Array(ab);\n out.set(u8);\n return out;\n}\n\n/** Decode UTF-8 bytes to a string. */\nexport function utf8Decode(bytes: Uint8Array): string {\n return new TextDecoder().decode(bytes);\n}\n","/**\n * HTTP client for the open content-addressed shard store.\n *\n * Each shard is named by the lowercase hex SHA-256 of its ciphertext bytes.\n * The server verifies the hash on PUT, so the address authenticates the\n * contents — there's no benefit to ACLing PUT/GET, and making them open\n * enables cross-user dedup down the line.\n *\n * Wire protocol:\n * PUT /api/shards/:hash body: shard bytes (application/octet-stream)\n * 200 { ok: true, hash, size, dedup: boolean }\n * 400 if hash doesn't match body\n *\n * GET /api/shards/:hash 200 with shard bytes (application/octet-stream)\n * 404 if absent\n *\n * HEAD /api/shards/:hash 200 if present (no body) — used by callers to\n * skip a PUT they'd otherwise dedup-rewrite. The\n * server can ignore the optimization if it wants.\n */\n\nimport { toHex } from \"../../utilities/bytes\";\n\n/**\n * How long concurrent shard reads are gathered before one batch is sent.\n *\n * Long enough to collect what a concurrent directory walk issues at the same\n * moment, short enough that a single read does not feel it.\n */\nconst SHARD_BATCH_WINDOW_MS = 6;\n\n/** Hashes per batch. Must not exceed what the server will accept. */\nconst SHARD_BATCH_MAX = 256;\n\n/**\n * Expected response bytes per batch, which a batch is split to stay under.\n *\n * The server refuses a batch above 24 MiB of shard bytes with a 413, and the\n * reads in a refused batch fail. Counting hashes alone, about 25 one-MiB\n * shards in one window - a few large files read at once - drew it. This\n * leaves the server's ceiling well clear.\n */\nconst SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;\n\n/** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */\nconst SHARD_BATCH_ENTRY_BYTES = 128;\n\n/**\n * What a shard adds to a batch's response: base64 is 4 bytes for every 3.\n *\n * A shard whose size the caller did not give counts as its entry alone, so\n * reads without sizes batch by count, as they always have — kept for the\n * pinned \"many concurrent reads → ONE request\" test\n * (`shard-batching.test.ts`). No SDK read path is size-less today:\n * `FileStorage.readChunk` always passes `shardSize`. The server reads and\n * base64-encodes every shard before it checks the total\n * (`ShardsController.ts`), so a very large size-less batch can fail with a\n * 5xx before the client ever sees a 413. The split in `sendShardBatch` is a\n * best-effort net for that case, not a guarantee.\n */\nfunction batchCost(size: number | undefined): number {\n const bytes = typeof size === \"number\" && Number.isFinite(size) && size > 0 ? size : 0;\n return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;\n}\n\n/** One caller of `getShard`, waiting on a batch. */\ntype ShardWaiter = { resolve: (v: Uint8Array | null) => void; reject: (e: unknown) => void };\n\n/** Decode base64 without `atob`'s per-character loop cost on large shards. */\nfunction bytesFromBase64(b64: string): Uint8Array {\n const binary = atob(b64);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\n/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */\nconst HEDGE_MS = 500;\n\n/**\n * A content-addressed byte cache for shards (the offline layer's `ShardCache`\n * satisfies this structurally). Declared here as a minimal interface so the\n * storage layer doesn't import the offline module — keeping the dependency\n * arrow one-way (offline → storage).\n */\nexport interface ShardByteCache {\n get(hash: string): Promise<Uint8Array | null>;\n put(hash: string, bytes: Uint8Array): Promise<void>;\n}\n\n/**\n * A peer block source (the p2p layer's `PeerExchange` satisfies this\n * structurally). Consulted between the local cache and origin: a block fetched\n * from a peer is verified by hash before use. Declared here so the storage layer\n * doesn't import the p2p module (dependency arrow stays p2p → storage).\n */\nexport interface PeerBlockSource {\n getBlock(hash: string, opts?: { timeoutMs?: number }): Promise<Uint8Array | null>;\n announce(hash: string): void;\n}\n\nexport interface ShardClientOptions {\n /** Base URL of the accelerator (e.g. `https://muhkoo-accelerator.workers.dev`). */\n baseUrl: string;\n /**\n * Path prefix for the shard endpoints, joined to `baseUrl` before the\n * hash. Defaults to `/api/shards`, which targets a global shard store\n * (e.g. a dedicated `ShardStoreDO`). Pass a room-scoped value like\n * `/api/spaces/{roomId}/shards` to keep shards bound to a specific\n * `SharedSpaceDO` — that's the path the chat application uses today.\n *\n * Leading slash optional; trailing slash stripped. The final URL is\n * always `${baseUrl}${normalizedPrefix}/${encodeURIComponent(hash)}`.\n */\n pathPrefix?: string;\n /**\n * Optional custom fetch implementation. Defaults to `globalThis.fetch`.\n * Provided so callers can inject retries / auth / instrumentation.\n */\n fetch?: typeof fetch;\n /**\n * Optional offline byte cache. When present, GETs are served cache-first and\n * populated on success, and PUTs write the cache before the network so a\n * just-uploaded file reads back offline. See {@link ShardByteCache}.\n */\n cache?: ShardByteCache;\n /**\n * Coalesce concurrent origin reads into `POST {pathPrefix}/batch`. On by\n * default: it is what keeps opening a project from costing one request per\n * shard. Set false to force one-at-a-time — useful against a server without\n * the route, though that is detected and handled automatically.\n */\n batchShards?: boolean;\n /**\n * Called when a PUT can't reach the network (offline). Lets the caller queue\n * the upload for replay on reconnect; the bytes are already in {@link cache}\n * keyed by `hash`, so only the hash needs recording.\n */\n deferUpload?: (hash: string) => Promise<void>;\n /**\n * Optional peer block source. When present, GETs try peers between the local\n * cache and origin (peer blocks are verified by hash); PUTs announce the new\n * block to peers. Best-effort — any failure falls through to origin.\n */\n peers?: PeerBlockSource;\n /**\n * When true, {@link ShardClient.putShard} returns as soon as the shard is\n * written to {@link cache} and announced to {@link peers} — the origin PUT is\n * fired in the BACKGROUND rather than awaited. This makes a large write's\n * manifest available immediately (so a peer/worker can start pulling the\n * shards over P2P right away) while origin catches up as a durable fallback.\n * On a background-PUT failure the shard is handed to {@link deferUpload} (if\n * present) for replay. Requires a {@link cache} so the bytes survive locally\n * until the background upload lands. No-op without a cache.\n */\n backgroundUpload?: boolean;\n}\n\n/**\n * Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in\n * every runtime this SDK targets.\n */\nexport async function shardHash(bytes: Uint8Array): Promise<string> {\n const subtle = (globalThis.crypto as Crypto | undefined)?.subtle;\n if (!subtle) {\n throw new Error(\"shardHash: `globalThis.crypto.subtle` is unavailable in this runtime.\");\n }\n const digest = await subtle.digest(\"SHA-256\", bytes as BufferSource);\n return toHex(new Uint8Array(digest));\n}\n\nexport class ShardClient {\n private readonly baseUrl: string;\n private readonly pathPrefix: string;\n private readonly fetchFn: typeof fetch;\n private readonly cache?: ShardByteCache;\n /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */\n private pendingShards!: Map<string, { size?: number; waiters: ShardWaiter[] }>;\n private batchTimer!: ReturnType<typeof setTimeout> | null;\n /** False once the server is known not to have the batch route. */\n private batchSupported!: boolean;\n private readonly deferUpload?: (hash: string) => Promise<void>;\n private readonly peers?: PeerBlockSource;\n private readonly backgroundUpload: boolean;\n\n constructor(opts: ShardClientOptions) {\n if (!opts?.baseUrl) throw new Error(\"ShardClient: `baseUrl` is required\");\n // Strip trailing slash so url concatenation stays clean.\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n // Normalize prefix to leading-slash + no-trailing-slash form so the\n // URL builder can blindly append `/{hash}`.\n const rawPrefix = opts.pathPrefix ?? \"/api/shards\";\n const withSlash = rawPrefix.startsWith(\"/\") ? rawPrefix : `/${rawPrefix}`;\n this.pathPrefix = withSlash.replace(/\\/+$/, \"\");\n const f = opts.fetch ?? globalThis.fetch;\n if (typeof f !== \"function\") {\n throw new Error(\"ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.\");\n }\n this.fetchFn = f.bind(globalThis);\n this.cache = opts.cache;\n // Coalescing state. `batchSupported` latches false against a deployment\n // without the batch route, so we degrade once rather than per request.\n this.pendingShards = new Map();\n this.batchTimer = null;\n this.batchSupported = opts.batchShards !== false;\n this.deferUpload = opts.deferUpload;\n this.peers = opts.peers;\n // Background upload needs a local cache to hold the bytes until the PUT\n // lands; without one it would race the reader against a not-yet-uploaded\n // origin, so we quietly ignore the flag when there's no cache.\n this.backgroundUpload = Boolean(opts.backgroundUpload && opts.cache);\n }\n\n /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */\n urlForHash(hash: string): string {\n return `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(hash)}`;\n }\n\n /**\n * Upload a shard. The server verifies SHA-256(body) matches `:hash` and\n * rejects with 400 if not, so the caller is the source of truth on\n * naming. Re-uploading an existing shard is idempotent — the server\n * returns `dedup: true` in that case.\n */\n async putShard(hash: string, bytes: Uint8Array): Promise<{ dedup: boolean }> {\n const url = this.urlForHash(hash);\n // Write the cache first so a just-uploaded file reads back even if the\n // network PUT then fails (offline). The bytes are content-addressed, so\n // this is safe to keep regardless of upload outcome.\n if (this.cache) await this.cache.put(hash, bytes);\n // Tell the swarm we now hold this block, so peers can pull it from us.\n if (this.peers) this.peers.announce(hash);\n // Background mode: the bytes are cached + announced, so return now and let\n // origin catch up asynchronously (a peer can already pull this block). The\n // PUT is enqueued through a bounded worker pool so a big file doesn't fire\n // hundreds of concurrent uploads at once.\n if (this.backgroundUpload) {\n this.enqueueBackgroundUpload(url, hash);\n return { dedup: false };\n }\n let response: Response;\n try {\n response = await this.fetchFn(url, {\n method: \"PUT\",\n headers: { \"content-type\": \"application/octet-stream\" },\n // BufferSource is what fetch's body expects; a Uint8Array satisfies it.\n body: bytes as BodyInit,\n });\n } catch (err) {\n // Network unreachable (offline). If a defer hook is wired, queue the\n // upload for replay and report success — the bytes are cached.\n if (this.deferUpload) {\n await this.deferUpload(hash);\n return { dedup: false };\n }\n throw err;\n }\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);\n }\n let dedup = false;\n try {\n const json = (await response.json()) as { dedup?: boolean };\n dedup = Boolean(json?.dedup);\n } catch {\n // Server returned 200 with no body — treat as fresh upload.\n }\n return { dedup };\n }\n\n // ── bounded background-upload pool (backgroundUpload mode) ──\n private bgActive = 0;\n private readonly bgQueue: Array<() => void> = [];\n private static readonly BG_CONCURRENCY = 6;\n\n /** Queue a shard's origin PUT to run in the background under a concurrency\n * cap. Bytes are re-read from {@link cache} at upload time (not held in the\n * queue) so a large file's backlog doesn't pin a second copy in memory. */\n private enqueueBackgroundUpload(url: string, hash: string): void {\n const run = async () => {\n this.bgActive++;\n try {\n const bytes = this.cache ? await this.cache.get(hash) : null;\n if (bytes) await this.uploadToOrigin(url, hash, bytes);\n } catch {\n // Origin unreachable/failed — queue for replay if we can; otherwise\n // the block is still cached + peer-served, so a later re-PUT covers it.\n if (this.deferUpload) { try { await this.deferUpload(hash); } catch { /* best-effort */ } }\n } finally {\n this.bgActive--;\n const next = this.bgQueue.shift();\n if (next) next();\n }\n };\n if (this.bgActive < ShardClient.BG_CONCURRENCY) void run();\n else this.bgQueue.push(() => void run());\n }\n\n /** PUT the shard bytes to origin, verifying the server accepted them.\n * Used by the background-upload path; throws on network/HTTP failure. */\n private async uploadToOrigin(url: string, hash: string, bytes: Uint8Array): Promise<void> {\n const response = await this.fetchFn(url, {\n method: \"PUT\",\n headers: { \"content-type\": \"application/octet-stream\" },\n body: bytes as BodyInit,\n });\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);\n }\n }\n\n /**\n * Fetch a shard by hash. Throws if the server returns a non-200; returns\n * `null` for 404 so callers can distinguish \"missing\" from \"errored\".\n *\n * `size`, the shard's byte length when known (a manifest's `shardSize`),\n * lets concurrent reads be batched within what the server will send.\n */\n async getShard(hash: string, opts: { size?: number } = {}): Promise<Uint8Array | null> {\n // Cache-first: a hit is content-verified by construction (key == hash).\n if (this.cache) {\n const cached = await this.cache.get(hash);\n if (cached) return cached;\n }\n // No peers → straight to origin.\n if (!this.peers) return this.fetchFromOrigin(hash, opts.size);\n\n // HEDGED RACE: prefer a peer (saves origin egress) but never let a slow\n // or missing peer stall delivery — give the peer a short head start, then\n // start origin in parallel and take whichever returns the block first.\n const peers = this.peers;\n return new Promise<Uint8Array | null>((resolve, reject) => {\n let done = false;\n let originStarted = false;\n let timer: ReturnType<typeof setTimeout> | null = null;\n const win = (b: Uint8Array | null) => {\n if (done) return;\n done = true;\n if (timer) clearTimeout(timer);\n resolve(b);\n };\n const fail = (e: unknown) => {\n if (!done) { done = true; reject(e); }\n };\n const startOrigin = () => {\n if (originStarted || done) return;\n originStarted = true;\n if (timer) { clearTimeout(timer); timer = null; }\n this.fetchFromOrigin(hash, opts.size).then(win, fail);\n };\n timer = setTimeout(startOrigin, HEDGE_MS);\n peers.getBlock(hash).then(\n (b) => {\n if (b) {\n if (this.cache) void this.cache.put(hash, b);\n win(b); // peer delivered first\n } else {\n startOrigin(); // peer miss → don't wait out the hedge\n }\n },\n () => startOrigin(), // peer error → origin now\n );\n });\n }\n\n /**\n * Fetch from origin, COALESCING concurrent requests into one batch.\n *\n * A shard is not a file. A project is thousands of small source modules,\n * each split into shards, so reading one at a time made opening a project\n * cost thousands of requests — 2,086 for a 519-file project. Latency, not\n * bytes, is what that spends: Chromium took 18.4s over those requests and\n * Brave, which filters every one, took 37.5s for identical work. Batching\n * collapses whatever is in flight at the same moment into a single POST,\n * which is why it helps most exactly where it hurt most.\n *\n * Callers are unchanged: `getShard` still asks for one shard and gets one\n * shard. The window is a few milliseconds — long enough to gather the reads\n * a concurrent walk issues together, short enough not to be felt by a lone\n * read.\n *\n * Falls back permanently to one-at-a-time if the server has no batch route,\n * so a new SDK keeps working against an older deployment.\n */\n private fetchFromOrigin(hash: string, size?: number): Promise<Uint8Array | null> {\n if (!this.batchSupported) return this.fetchOneFromOrigin(hash);\n return new Promise<Uint8Array | null>((resolve, reject) => {\n const pending = this.pendingShards.get(hash);\n if (pending) {\n pending.waiters.push({ resolve, reject });\n pending.size ??= size;\n return;\n }\n this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });\n if (this.batchTimer === null) {\n this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);\n }\n });\n }\n\n /** Send one batch of whatever has accumulated, and settle those callers. */\n private async flushShardBatch(): Promise<void> {\n this.batchTimer = null;\n if (this.pendingShards.size === 0) return;\n\n // As many as fit: at most SHARD_BATCH_MAX hashes, and a response\n // expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not\n // fit is skipped for a smaller one behind it. The first always goes,\n // so a shard over the budget by itself is fetched alone.\n const waiters: Array<readonly [string, ShardWaiter[]]> = [];\n let cost = 0;\n for (const [h, pending] of this.pendingShards) {\n if (waiters.length === SHARD_BATCH_MAX) break;\n const next = batchCost(pending.size);\n if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES) continue;\n waiters.push([h, pending.waiters]);\n cost += next;\n }\n for (const [h] of waiters) this.pendingShards.delete(h);\n // Anything over the cap or the budget waits for the next window\n // rather than being dropped or silently truncated.\n if (this.pendingShards.size > 0 && this.batchTimer === null) {\n this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);\n }\n await this.sendShardBatch(waiters);\n }\n\n /** POST one batch and settle everyone waiting on it. Never throws. */\n private async sendShardBatch(waiters: ReadonlyArray<readonly [string, ShardWaiter[]]>): Promise<void> {\n const hashes = waiters.map(([h]) => h);\n let shards: Record<string, string>;\n try {\n const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ hashes }),\n });\n if (res.status === 413 && waiters.length > 1) {\n // Too large for the server after all: a size was not given,\n // or was wrong. Ask for each half on its own. Halving reaches\n // one hash per batch within log2(n) levels, and one hash the\n // server refuses is an error like any other, below.\n // Release the body first: under undici (the CLI, Node), an\n // unread body holds the connection until garbage collection.\n await res.body?.cancel().catch(() => {});\n const half = Math.ceil(waiters.length / 2);\n await Promise.all([\n this.sendShardBatch(waiters.slice(0, half)),\n this.sendShardBatch(waiters.slice(half)),\n ]);\n return;\n }\n if (res.status === 404 || res.status === 405) {\n // No batch route on this deployment. Stop trying, and serve\n // everyone individually — including the batch we just gathered.\n this.batchSupported = false;\n for (const [h, ws] of waiters) {\n this.fetchOneFromOrigin(h).then(\n (b) => ws.forEach((w) => w.resolve(b)),\n (e) => ws.forEach((w) => w.reject(e)),\n );\n }\n return;\n }\n if (!res.ok) throw new Error(`ShardClient batch failed (${res.status}): ${await safeText(res)}`);\n const parsed: unknown = await res.json().catch(() => null);\n // A 200 is not proof the batch route exists. A catch-all handler, a\n // proxy, or an older deployment can answer this POST with something\n // else entirely, and trusting the shape would resolve every hash to\n // `null` — reads silently returning \"missing\" for shards that are\n // right there. Anything not shaped like a batch reply is treated as\n // \"no batch route\", exactly like a 404.\n const candidate = (parsed as { shards?: unknown } | null)?.shards;\n if (!candidate || typeof candidate !== \"object\") {\n this.batchSupported = false;\n for (const [h, ws] of waiters) {\n this.fetchOneFromOrigin(h).then(\n (b) => ws.forEach((w) => w.resolve(b)),\n (e) => ws.forEach((w) => w.reject(e)),\n );\n }\n return;\n }\n shards = candidate as Record<string, string>;\n } catch (err) {\n // Offline with a cache behaves as it does for a single fetch: report\n // absence so Reed-Solomon recovery can still be attempted.\n for (const [, ws] of waiters) {\n if (this.cache) ws.forEach((w) => w.resolve(null));\n else ws.forEach((w) => w.reject(err));\n }\n return;\n }\n\n for (const [hash, ws] of waiters) {\n const b64 = shards[hash];\n if (typeof b64 !== \"string\") { ws.forEach((w) => w.resolve(null)); continue; }\n let bytes: Uint8Array;\n try { bytes = bytesFromBase64(b64); }\n catch (e) { ws.forEach((w) => w.reject(e)); continue; }\n if (this.cache) void this.cache.put(hash, bytes);\n ws.forEach((w) => w.resolve(bytes));\n }\n }\n\n /** Fetch a shard from origin. Returns `null` for 404 (or offline when a\n * cache is present, so RS recovery can proceed); throws otherwise. */\n private async fetchOneFromOrigin(hash: string): Promise<Uint8Array | null> {\n const url = this.urlForHash(hash);\n let response: Response;\n try {\n response = await this.fetchFn(url, { method: \"GET\" });\n } catch (err) {\n if (this.cache) return null;\n throw err;\n }\n if (response.status === 404) return null;\n if (!response.ok) {\n const text = await safeText(response);\n throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);\n }\n const bytes = new Uint8Array(await response.arrayBuffer());\n if (this.cache) void this.cache.put(hash, bytes);\n return bytes;\n }\n\n /**\n * Check whether a shard exists. Lets the caller skip a PUT they'd otherwise\n * dedup-rewrite. The server can implement this as a HEAD or return a small\n * JSON body — we don't depend on either.\n */\n async hasShard(hash: string): Promise<boolean> {\n const url = this.urlForHash(hash);\n const response = await this.fetchFn(url, { method: \"HEAD\" });\n return response.status === 200;\n }\n}\n\nasync function safeText(response: Response): Promise<string> {\n try { return await response.text(); } catch { return \"\"; }\n}\n","/**\n * Dedicated Web Worker entry that hosts a {@link ./blockEngine.BlockEngine}. The\n * blockstore is the Cache-API-backed {@link ../../offline/cache/ShardCache}\n * (the Cache API is available in workers and origin-scoped, so it's the *same*\n * store the main thread reads), and hashing uses {@link\n * ../../storage/transport/ShardClient.shardHash}.\n *\n * Message protocol (see {@link ./engineClient.WorkerEngineHost}):\n * in : {t:\"frame\",peer,data} | {t:\"want\",id,hash,timeout} | {t:\"announce\",hash} | {t:\"close\"}\n * out: {t:\"out\",target,data} | {t:\"want-result\",id,data}\n *\n * Consumers instantiate this with a bundler-resolved URL, e.g.\n * new Worker(new URL(\"@muhkoo/connect/dist/.../blockEngine.worker.js\", import.meta.url), { type: \"module\" })\n * and pass `() => that worker` as `workerFactory` to the client's p2p options.\n */\n\nimport { BlockEngine } from \"./blockEngine\";\nimport { ShardCache } from \"../../offline/cache/ShardCache\";\nimport { shardHash } from \"../../storage/transport/ShardClient\";\n\n/** Minimal worker-scope shape (avoids needing the WebWorker tsconfig lib). */\ninterface WorkerScope {\n onmessage: ((e: MessageEvent) => void) | null;\n postMessage(message: unknown, transfer?: Transferable[]): void;\n}\nconst ctx = self as unknown as WorkerScope;\n\nconst engine = new BlockEngine(\n new ShardCache(),\n (target, frame) => {\n const buf = frame.buffer as ArrayBuffer;\n ctx.postMessage({ t: \"out\", target, data: buf }, [buf]);\n },\n shardHash,\n);\n\nctx.onmessage = (e: MessageEvent) => {\n const m = e.data as\n | { t: \"frame\"; peer: string; data: ArrayBuffer }\n | { t: \"want\"; id: number; hash: string; timeout: number }\n | { t: \"announce\"; hash: string }\n | { t: \"close\" };\n\n if (m.t === \"frame\") {\n void engine.handleFrame(m.peer, new Uint8Array(m.data));\n } else if (m.t === \"want\") {\n void engine.want(m.hash, m.timeout).then((bytes) => {\n const buf = bytes ? (bytes.buffer as ArrayBuffer) : null;\n ctx.postMessage({ t: \"want-result\", id: m.id, data: buf }, buf ? [buf] : []);\n });\n } else if (m.t === \"announce\") {\n engine.announce(m.hash);\n } else if (m.t === \"close\") {\n engine.close();\n }\n};\n"],"names":[],"mappings":"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"}
|
package/dist/browser/index.js
CHANGED
|
@@ -5862,6 +5862,34 @@ class ReedSolomonCodec {
|
|
|
5862
5862
|
const SHARD_BATCH_WINDOW_MS = 6;
|
|
5863
5863
|
/** Hashes per batch. Must not exceed what the server will accept. */
|
|
5864
5864
|
const SHARD_BATCH_MAX = 256;
|
|
5865
|
+
/**
|
|
5866
|
+
* Expected response bytes per batch, which a batch is split to stay under.
|
|
5867
|
+
*
|
|
5868
|
+
* The server refuses a batch above 24 MiB of shard bytes with a 413, and the
|
|
5869
|
+
* reads in a refused batch fail. Counting hashes alone, about 25 one-MiB
|
|
5870
|
+
* shards in one window - a few large files read at once - drew it. This
|
|
5871
|
+
* leaves the server's ceiling well clear.
|
|
5872
|
+
*/
|
|
5873
|
+
const SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
5874
|
+
/** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */
|
|
5875
|
+
const SHARD_BATCH_ENTRY_BYTES = 128;
|
|
5876
|
+
/**
|
|
5877
|
+
* What a shard adds to a batch's response: base64 is 4 bytes for every 3.
|
|
5878
|
+
*
|
|
5879
|
+
* A shard whose size the caller did not give counts as its entry alone, so
|
|
5880
|
+
* reads without sizes batch by count, as they always have — kept for the
|
|
5881
|
+
* pinned "many concurrent reads → ONE request" test
|
|
5882
|
+
* (`shard-batching.test.ts`). No SDK read path is size-less today:
|
|
5883
|
+
* `FileStorage.readChunk` always passes `shardSize`. The server reads and
|
|
5884
|
+
* base64-encodes every shard before it checks the total
|
|
5885
|
+
* (`ShardsController.ts`), so a very large size-less batch can fail with a
|
|
5886
|
+
* 5xx before the client ever sees a 413. The split in `sendShardBatch` is a
|
|
5887
|
+
* best-effort net for that case, not a guarantee.
|
|
5888
|
+
*/
|
|
5889
|
+
function batchCost(size) {
|
|
5890
|
+
const bytes = typeof size === "number" && Number.isFinite(size) && size > 0 ? size : 0;
|
|
5891
|
+
return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;
|
|
5892
|
+
}
|
|
5865
5893
|
/** Decode base64 without `atob`'s per-character loop cost on large shards. */
|
|
5866
5894
|
function bytesFromBase64(b64) {
|
|
5867
5895
|
const binary = atob(b64);
|
|
@@ -5889,7 +5917,7 @@ class ShardClient {
|
|
|
5889
5917
|
pathPrefix;
|
|
5890
5918
|
fetchFn;
|
|
5891
5919
|
cache;
|
|
5892
|
-
/** Hashes waiting for the next batch, each with everyone awaiting it. */
|
|
5920
|
+
/** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
|
|
5893
5921
|
pendingShards;
|
|
5894
5922
|
batchTimer;
|
|
5895
5923
|
/** False once the server is known not to have the batch route. */
|
|
@@ -6038,8 +6066,11 @@ class ShardClient {
|
|
|
6038
6066
|
/**
|
|
6039
6067
|
* Fetch a shard by hash. Throws if the server returns a non-200; returns
|
|
6040
6068
|
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
6069
|
+
*
|
|
6070
|
+
* `size`, the shard's byte length when known (a manifest's `shardSize`),
|
|
6071
|
+
* lets concurrent reads be batched within what the server will send.
|
|
6041
6072
|
*/
|
|
6042
|
-
async getShard(hash) {
|
|
6073
|
+
async getShard(hash, opts = {}) {
|
|
6043
6074
|
// Cache-first: a hit is content-verified by construction (key == hash).
|
|
6044
6075
|
if (this.cache) {
|
|
6045
6076
|
const cached = await this.cache.get(hash);
|
|
@@ -6048,7 +6079,7 @@ class ShardClient {
|
|
|
6048
6079
|
}
|
|
6049
6080
|
// No peers → straight to origin.
|
|
6050
6081
|
if (!this.peers)
|
|
6051
|
-
return this.fetchFromOrigin(hash);
|
|
6082
|
+
return this.fetchFromOrigin(hash, opts.size);
|
|
6052
6083
|
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
6053
6084
|
// or missing peer stall delivery — give the peer a short head start, then
|
|
6054
6085
|
// start origin in parallel and take whichever returns the block first.
|
|
@@ -6079,7 +6110,7 @@ class ShardClient {
|
|
|
6079
6110
|
clearTimeout(timer);
|
|
6080
6111
|
timer = null;
|
|
6081
6112
|
}
|
|
6082
|
-
this.fetchFromOrigin(hash).then(win, fail);
|
|
6113
|
+
this.fetchFromOrigin(hash, opts.size).then(win, fail);
|
|
6083
6114
|
};
|
|
6084
6115
|
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
6085
6116
|
peers.getBlock(hash).then((b) => {
|
|
@@ -6113,16 +6144,17 @@ class ShardClient {
|
|
|
6113
6144
|
* Falls back permanently to one-at-a-time if the server has no batch route,
|
|
6114
6145
|
* so a new SDK keeps working against an older deployment.
|
|
6115
6146
|
*/
|
|
6116
|
-
fetchFromOrigin(hash) {
|
|
6147
|
+
fetchFromOrigin(hash, size) {
|
|
6117
6148
|
if (!this.batchSupported)
|
|
6118
6149
|
return this.fetchOneFromOrigin(hash);
|
|
6119
6150
|
return new Promise((resolve, reject) => {
|
|
6120
|
-
const
|
|
6121
|
-
if (
|
|
6122
|
-
|
|
6151
|
+
const pending = this.pendingShards.get(hash);
|
|
6152
|
+
if (pending) {
|
|
6153
|
+
pending.waiters.push({ resolve, reject });
|
|
6154
|
+
pending.size ??= size;
|
|
6123
6155
|
return;
|
|
6124
6156
|
}
|
|
6125
|
-
this.pendingShards.set(hash, [{ resolve, reject }]);
|
|
6157
|
+
this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });
|
|
6126
6158
|
if (this.batchTimer === null) {
|
|
6127
6159
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6128
6160
|
}
|
|
@@ -6133,17 +6165,33 @@ class ShardClient {
|
|
|
6133
6165
|
this.batchTimer = null;
|
|
6134
6166
|
if (this.pendingShards.size === 0)
|
|
6135
6167
|
return;
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6168
|
+
// As many as fit: at most SHARD_BATCH_MAX hashes, and a response
|
|
6169
|
+
// expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not
|
|
6170
|
+
// fit is skipped for a smaller one behind it. The first always goes,
|
|
6171
|
+
// so a shard over the budget by itself is fetched alone.
|
|
6172
|
+
const waiters = [];
|
|
6173
|
+
let cost = 0;
|
|
6174
|
+
for (const [h, pending] of this.pendingShards) {
|
|
6175
|
+
if (waiters.length === SHARD_BATCH_MAX)
|
|
6176
|
+
break;
|
|
6177
|
+
const next = batchCost(pending.size);
|
|
6178
|
+
if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES)
|
|
6179
|
+
continue;
|
|
6180
|
+
waiters.push([h, pending.waiters]);
|
|
6181
|
+
cost += next;
|
|
6182
|
+
}
|
|
6183
|
+
for (const [h] of waiters)
|
|
6139
6184
|
this.pendingShards.delete(h);
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
// Anything over the cap waits for the next window rather than being
|
|
6143
|
-
// dropped or silently truncated.
|
|
6185
|
+
// Anything over the cap or the budget waits for the next window
|
|
6186
|
+
// rather than being dropped or silently truncated.
|
|
6144
6187
|
if (this.pendingShards.size > 0 && this.batchTimer === null) {
|
|
6145
6188
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6146
6189
|
}
|
|
6190
|
+
await this.sendShardBatch(waiters);
|
|
6191
|
+
}
|
|
6192
|
+
/** POST one batch and settle everyone waiting on it. Never throws. */
|
|
6193
|
+
async sendShardBatch(waiters) {
|
|
6194
|
+
const hashes = waiters.map(([h]) => h);
|
|
6147
6195
|
let shards;
|
|
6148
6196
|
try {
|
|
6149
6197
|
const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
|
|
@@ -6151,6 +6199,21 @@ class ShardClient {
|
|
|
6151
6199
|
headers: { "Content-Type": "application/json" },
|
|
6152
6200
|
body: JSON.stringify({ hashes }),
|
|
6153
6201
|
});
|
|
6202
|
+
if (res.status === 413 && waiters.length > 1) {
|
|
6203
|
+
// Too large for the server after all: a size was not given,
|
|
6204
|
+
// or was wrong. Ask for each half on its own. Halving reaches
|
|
6205
|
+
// one hash per batch within log2(n) levels, and one hash the
|
|
6206
|
+
// server refuses is an error like any other, below.
|
|
6207
|
+
// Release the body first: under undici (the CLI, Node), an
|
|
6208
|
+
// unread body holds the connection until garbage collection.
|
|
6209
|
+
await res.body?.cancel().catch(() => { });
|
|
6210
|
+
const half = Math.ceil(waiters.length / 2);
|
|
6211
|
+
await Promise.all([
|
|
6212
|
+
this.sendShardBatch(waiters.slice(0, half)),
|
|
6213
|
+
this.sendShardBatch(waiters.slice(half)),
|
|
6214
|
+
]);
|
|
6215
|
+
return;
|
|
6216
|
+
}
|
|
6154
6217
|
if (res.status === 404 || res.status === 405) {
|
|
6155
6218
|
// No batch route on this deployment. Stop trying, and serve
|
|
6156
6219
|
// everyone individually — including the batch we just gathered.
|
|
@@ -6526,7 +6589,8 @@ class FileStorage {
|
|
|
6526
6589
|
const fetched = new Array(total).fill(null);
|
|
6527
6590
|
const fetchInto = async (indexes) => {
|
|
6528
6591
|
await this.runWithConcurrency(indexes.map((i) => async () => {
|
|
6529
|
-
|
|
6592
|
+
// The size keeps a batch of concurrent reads within what the server will send.
|
|
6593
|
+
fetched[i] = await this.shards.getShard(manifest.shardHashes[i], { size: manifest.shardSize });
|
|
6530
6594
|
}));
|
|
6531
6595
|
};
|
|
6532
6596
|
await fetchInto(Array.from({ length: dataCount }, (_, i) => i));
|
|
@@ -6675,7 +6739,7 @@ async function parseOrThrow(response, op) {
|
|
|
6675
6739
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
6676
6740
|
* `package.json` `version`.
|
|
6677
6741
|
*/
|
|
6678
|
-
const VERSION = "0.15.0-alpha.
|
|
6742
|
+
const VERSION = "0.15.0-alpha.2";
|
|
6679
6743
|
|
|
6680
6744
|
/**
|
|
6681
6745
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -15082,11 +15146,19 @@ function nextTurn() {
|
|
|
15082
15146
|
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
15083
15147
|
* write cannot leave the tree half-updated: either the parent names the new
|
|
15084
15148
|
* child or it does not. Two tabs writing different directories never conflict;
|
|
15085
|
-
* two tabs writing the SAME directory
|
|
15086
|
-
*
|
|
15087
|
-
*
|
|
15088
|
-
*
|
|
15149
|
+
* two tabs (or devices) writing the SAME directory are last-writer-wins. A VFS
|
|
15150
|
+
* write sends no HLC and the personal space has no compare-and-swap: it applies
|
|
15151
|
+
* puts of one key in the order they arrive, so whichever arrives last is kept,
|
|
15152
|
+
* and the other tab's edit is lost. Orphaned records (a directory whose parent
|
|
15153
|
+
* entry lost the race) are unreachable rather than corrupting — they cost
|
|
15154
|
+
* storage until a future sweep, which is the trade this shape makes in
|
|
15089
15155
|
* exchange for single-record writes.
|
|
15156
|
+
*
|
|
15157
|
+
* Within ONE tab, writes of a record are queued and sent one at a time, in the
|
|
15158
|
+
* order they were made, and a read that has to fetch a record waits for its
|
|
15159
|
+
* write in flight (see `writing`). That ordering is per namespace instance
|
|
15160
|
+
* only; nothing orders writes across tabs or devices - that would take a
|
|
15161
|
+
* compare-and-swap at the server.
|
|
15090
15162
|
*/
|
|
15091
15163
|
/** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
|
|
15092
15164
|
function assertWritableDepth(path) {
|
|
@@ -15467,7 +15539,9 @@ class VfsNamespace {
|
|
|
15467
15539
|
* that is often the one sent first. Then the earlier write, sealed before
|
|
15468
15540
|
* the later edit was made, undoes it. That is how a file deleted forever
|
|
15469
15541
|
* from the Trash, while the move that put it there was still being
|
|
15470
|
-
* written, came back. So each write waits for the one before it
|
|
15542
|
+
* written, came back. So each write waits for the one before it, and a
|
|
15543
|
+
* read that has to fetch the record waits for its write too: see
|
|
15544
|
+
* `openDirById`.
|
|
15471
15545
|
*/
|
|
15472
15546
|
writing = new Map();
|
|
15473
15547
|
historyLimit;
|
|
@@ -16220,8 +16294,11 @@ class VfsNamespace {
|
|
|
16220
16294
|
* Delete metadata records no longer reachable from the root.
|
|
16221
16295
|
*
|
|
16222
16296
|
* These accumulate because every write touches exactly one record: if two
|
|
16223
|
-
* tabs edit the same directory,
|
|
16224
|
-
*
|
|
16297
|
+
* tabs or devices edit the same directory, the personal space keeps
|
|
16298
|
+
* whichever put arrives last (a VFS write sends no HLC, and the space
|
|
16299
|
+
* applies puts of one key in arrival order), which can leave a child whose
|
|
16300
|
+
* parent entry lost the race. The per-record write queue (`writing`) only
|
|
16301
|
+
* orders writes within one tab. Unreachable is harmless —
|
|
16225
16302
|
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
16226
16303
|
* this reclaims it.
|
|
16227
16304
|
*
|
|
@@ -16379,7 +16456,41 @@ class VfsNamespace {
|
|
|
16379
16456
|
// every concurrent reader to miss and fire its own request for the same
|
|
16380
16457
|
// record — which is how loading one project turned into thousands of
|
|
16381
16458
|
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
16382
|
-
|
|
16459
|
+
// Reads `pending` only after its first `await`: before that, the const is not yet initialised.
|
|
16460
|
+
const load = async () => {
|
|
16461
|
+
// Not while a write of this record is still in flight: see
|
|
16462
|
+
// `writing`. Our own writes echo back over the personal-space
|
|
16463
|
+
// socket, and `watch` drops the record each one names, so a read
|
|
16464
|
+
// can miss with the write still on its way - and the store would
|
|
16465
|
+
// answer with the copy from BEFORE it. An edit made to that copy
|
|
16466
|
+
// is queued behind the write, and then stored over it. So wait
|
|
16467
|
+
// for the latest write queued, which waits on every one before it,
|
|
16468
|
+
// to land or fail - and then for any queued while this read
|
|
16469
|
+
// waited, since the one it waited for may have failed with that
|
|
16470
|
+
// one still on its way. A record with no write in flight is
|
|
16471
|
+
// fetched at once.
|
|
16472
|
+
let awaited;
|
|
16473
|
+
for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
|
|
16474
|
+
awaited = inFlight;
|
|
16475
|
+
await inFlight.catch(() => { });
|
|
16476
|
+
}
|
|
16477
|
+
if (awaited) {
|
|
16478
|
+
// A write that landed put the record it wrote back in the
|
|
16479
|
+
// cache, and the next edit of this directory is made to that.
|
|
16480
|
+
// Answer with it rather than fetch a second copy: two copies
|
|
16481
|
+
// of one record, each edited and written, undo each other's
|
|
16482
|
+
// edits. When the cache holds nothing else - the write failed,
|
|
16483
|
+
// or a change since has dropped the record - ask the store.
|
|
16484
|
+
const now = this.cache.get(id);
|
|
16485
|
+
if (now && now !== pending)
|
|
16486
|
+
return now;
|
|
16487
|
+
// Empty, when the write failed: it dropped what the cache held,
|
|
16488
|
+
// and after an echo that was this very read. Put it back before
|
|
16489
|
+
// asking the store, so a read that comes in meanwhile shares
|
|
16490
|
+
// this copy instead of fetching a second one.
|
|
16491
|
+
if (!now)
|
|
16492
|
+
this.cache.set(id, pending);
|
|
16493
|
+
}
|
|
16383
16494
|
const raw = await this.deps.store.get(dirKey(id));
|
|
16384
16495
|
// An absent record is an EMPTY directory, not an error: the root
|
|
16385
16496
|
// exists before anything has been written to it, and a directory is
|
|
@@ -16405,7 +16516,8 @@ class VfsNamespace {
|
|
|
16405
16516
|
throw err;
|
|
16406
16517
|
}
|
|
16407
16518
|
return this.readRecord(id, key, value, path);
|
|
16408
|
-
}
|
|
16519
|
+
};
|
|
16520
|
+
const pending = load().catch((err) => {
|
|
16409
16521
|
// A failed read must not be remembered, or one blip poisons the
|
|
16410
16522
|
// directory for the life of the session.
|
|
16411
16523
|
if (this.cache.get(id) === pending)
|
|
@@ -16506,7 +16618,14 @@ class VfsNamespace {
|
|
|
16506
16618
|
// change the store never took - a deleted file gone from every
|
|
16507
16619
|
// listing while it is still stored, and a retry told it does not
|
|
16508
16620
|
// exist. Drop it, so the next read asks the store.
|
|
16509
|
-
|
|
16621
|
+
//
|
|
16622
|
+
// Unless a later write of this record is already queued. It
|
|
16623
|
+
// seals the record as it stands when it is sent, this edit
|
|
16624
|
+
// included, so the cache shows what is about to be stored, and
|
|
16625
|
+
// dropping it would only send readers to the store for an older
|
|
16626
|
+
// copy. If that write fails too, it drops the cache itself.
|
|
16627
|
+
if (this.writing.get(dir.id) === write)
|
|
16628
|
+
this.cache.delete(dir.id);
|
|
16510
16629
|
throw err;
|
|
16511
16630
|
}
|
|
16512
16631
|
finally {
|