@muhkoo/connect 0.14.0-alpha.2 → 0.14.0-alpha.4

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/connect.d.ts CHANGED
@@ -1452,6 +1452,13 @@ interface ShardClientOptions {
1452
1452
  * just-uploaded file reads back offline. See {@link ShardByteCache}.
1453
1453
  */
1454
1454
  cache?: ShardByteCache;
1455
+ /**
1456
+ * Coalesce concurrent origin reads into `POST {pathPrefix}/batch`. On by
1457
+ * default: it is what keeps opening a project from costing one request per
1458
+ * shard. Set false to force one-at-a-time — useful against a server without
1459
+ * the route, though that is detected and handled automatically.
1460
+ */
1461
+ batchShards?: boolean;
1455
1462
  /**
1456
1463
  * Called when a PUT can't reach the network (offline). Lets the caller queue
1457
1464
  * the upload for replay on reconnect; the bytes are already in {@link cache}
@@ -1486,6 +1493,11 @@ declare class ShardClient {
1486
1493
  private readonly pathPrefix;
1487
1494
  private readonly fetchFn;
1488
1495
  private readonly cache?;
1496
+ /** Hashes waiting for the next batch, each with everyone awaiting it. */
1497
+ private pendingShards;
1498
+ private batchTimer;
1499
+ /** False once the server is known not to have the batch route. */
1500
+ private batchSupported;
1489
1501
  private readonly deferUpload?;
1490
1502
  private readonly peers?;
1491
1503
  private readonly backgroundUpload;
@@ -1516,9 +1528,31 @@ declare class ShardClient {
1516
1528
  * `null` for 404 so callers can distinguish "missing" from "errored".
1517
1529
  */
1518
1530
  getShard(hash: string): Promise<Uint8Array | null>;
1531
+ /**
1532
+ * Fetch from origin, COALESCING concurrent requests into one batch.
1533
+ *
1534
+ * A shard is not a file. A project is thousands of small source modules,
1535
+ * each split into shards, so reading one at a time made opening a project
1536
+ * cost thousands of requests — 2,086 for a 519-file project. Latency, not
1537
+ * bytes, is what that spends: Chromium took 18.4s over those requests and
1538
+ * Brave, which filters every one, took 37.5s for identical work. Batching
1539
+ * collapses whatever is in flight at the same moment into a single POST,
1540
+ * which is why it helps most exactly where it hurt most.
1541
+ *
1542
+ * Callers are unchanged: `getShard` still asks for one shard and gets one
1543
+ * shard. The window is a few milliseconds — long enough to gather the reads
1544
+ * a concurrent walk issues together, short enough not to be felt by a lone
1545
+ * read.
1546
+ *
1547
+ * Falls back permanently to one-at-a-time if the server has no batch route,
1548
+ * so a new SDK keeps working against an older deployment.
1549
+ */
1550
+ private fetchFromOrigin;
1551
+ /** Send one batch of whatever has accumulated, and settle those callers. */
1552
+ private flushShardBatch;
1519
1553
  /** Fetch a shard from origin. Returns `null` for 404 (or offline when a
1520
1554
  * cache is present, so RS recovery can proceed); throws otherwise. */
1521
- private fetchFromOrigin;
1555
+ private fetchOneFromOrigin;
1522
1556
  /**
1523
1557
  * Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
1524
1558
  * dedup-rewrite. The server can implement this as a HEAD or return a small
@@ -3235,7 +3235,7 @@ function base64UrlDecode(s) {
3235
3235
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
3236
3236
  * `package.json` `version`.
3237
3237
  */
3238
- const VERSION = "0.14.0-alpha.2";
3238
+ const VERSION = "0.14.0-alpha.4";
3239
3239
 
3240
3240
  /**
3241
3241
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -7207,6 +7207,23 @@ class ReedSolomonCodec {
7207
7207
  * skip a PUT they'd otherwise dedup-rewrite. The
7208
7208
  * server can ignore the optimization if it wants.
7209
7209
  */
7210
+ /**
7211
+ * How long concurrent shard reads are gathered before one batch is sent.
7212
+ *
7213
+ * Long enough to collect what a concurrent directory walk issues at the same
7214
+ * moment, short enough that a single read does not feel it.
7215
+ */
7216
+ const SHARD_BATCH_WINDOW_MS = 6;
7217
+ /** Hashes per batch. Must not exceed what the server will accept. */
7218
+ const SHARD_BATCH_MAX = 256;
7219
+ /** Decode base64 without `atob`'s per-character loop cost on large shards. */
7220
+ function bytesFromBase64(b64) {
7221
+ const binary = atob(b64);
7222
+ const out = new Uint8Array(binary.length);
7223
+ for (let i = 0; i < binary.length; i++)
7224
+ out[i] = binary.charCodeAt(i);
7225
+ return out;
7226
+ }
7210
7227
  /** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
7211
7228
  const HEDGE_MS = 500;
7212
7229
  /**
@@ -7226,6 +7243,11 @@ class ShardClient {
7226
7243
  pathPrefix;
7227
7244
  fetchFn;
7228
7245
  cache;
7246
+ /** Hashes waiting for the next batch, each with everyone awaiting it. */
7247
+ pendingShards;
7248
+ batchTimer;
7249
+ /** False once the server is known not to have the batch route. */
7250
+ batchSupported;
7229
7251
  deferUpload;
7230
7252
  peers;
7231
7253
  backgroundUpload;
@@ -7245,6 +7267,11 @@ class ShardClient {
7245
7267
  }
7246
7268
  this.fetchFn = f.bind(globalThis);
7247
7269
  this.cache = opts.cache;
7270
+ // Coalescing state. `batchSupported` latches false against a deployment
7271
+ // without the batch route, so we degrade once rather than per request.
7272
+ this.pendingShards = new Map();
7273
+ this.batchTimer = null;
7274
+ this.batchSupported = opts.batchShards !== false;
7248
7275
  this.deferUpload = opts.deferUpload;
7249
7276
  this.peers = opts.peers;
7250
7277
  // Background upload needs a local cache to hold the bytes until the PUT
@@ -7421,9 +7448,124 @@ class ShardClient {
7421
7448
  }, () => startOrigin());
7422
7449
  });
7423
7450
  }
7451
+ /**
7452
+ * Fetch from origin, COALESCING concurrent requests into one batch.
7453
+ *
7454
+ * A shard is not a file. A project is thousands of small source modules,
7455
+ * each split into shards, so reading one at a time made opening a project
7456
+ * cost thousands of requests — 2,086 for a 519-file project. Latency, not
7457
+ * bytes, is what that spends: Chromium took 18.4s over those requests and
7458
+ * Brave, which filters every one, took 37.5s for identical work. Batching
7459
+ * collapses whatever is in flight at the same moment into a single POST,
7460
+ * which is why it helps most exactly where it hurt most.
7461
+ *
7462
+ * Callers are unchanged: `getShard` still asks for one shard and gets one
7463
+ * shard. The window is a few milliseconds — long enough to gather the reads
7464
+ * a concurrent walk issues together, short enough not to be felt by a lone
7465
+ * read.
7466
+ *
7467
+ * Falls back permanently to one-at-a-time if the server has no batch route,
7468
+ * so a new SDK keeps working against an older deployment.
7469
+ */
7470
+ fetchFromOrigin(hash) {
7471
+ if (!this.batchSupported)
7472
+ return this.fetchOneFromOrigin(hash);
7473
+ return new Promise((resolve, reject) => {
7474
+ const waiting = this.pendingShards.get(hash);
7475
+ if (waiting) {
7476
+ waiting.push({ resolve, reject });
7477
+ return;
7478
+ }
7479
+ this.pendingShards.set(hash, [{ resolve, reject }]);
7480
+ if (this.batchTimer === null) {
7481
+ this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
7482
+ }
7483
+ });
7484
+ }
7485
+ /** Send one batch of whatever has accumulated, and settle those callers. */
7486
+ async flushShardBatch() {
7487
+ this.batchTimer = null;
7488
+ if (this.pendingShards.size === 0)
7489
+ return;
7490
+ const hashes = [...this.pendingShards.keys()].slice(0, SHARD_BATCH_MAX);
7491
+ const waiters = hashes.map((h) => {
7492
+ const w = this.pendingShards.get(h);
7493
+ this.pendingShards.delete(h);
7494
+ return [h, w];
7495
+ });
7496
+ // Anything over the cap waits for the next window rather than being
7497
+ // dropped or silently truncated.
7498
+ if (this.pendingShards.size > 0 && this.batchTimer === null) {
7499
+ this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
7500
+ }
7501
+ let shards;
7502
+ try {
7503
+ const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
7504
+ method: "POST",
7505
+ headers: { "Content-Type": "application/json" },
7506
+ body: JSON.stringify({ hashes }),
7507
+ });
7508
+ if (res.status === 404 || res.status === 405) {
7509
+ // No batch route on this deployment. Stop trying, and serve
7510
+ // everyone individually — including the batch we just gathered.
7511
+ this.batchSupported = false;
7512
+ for (const [h, ws] of waiters) {
7513
+ this.fetchOneFromOrigin(h).then((b) => ws.forEach((w) => w.resolve(b)), (e) => ws.forEach((w) => w.reject(e)));
7514
+ }
7515
+ return;
7516
+ }
7517
+ if (!res.ok)
7518
+ throw new Error(`ShardClient batch failed (${res.status}): ${await safeText(res)}`);
7519
+ const parsed = await res.json().catch(() => null);
7520
+ // A 200 is not proof the batch route exists. A catch-all handler, a
7521
+ // proxy, or an older deployment can answer this POST with something
7522
+ // else entirely, and trusting the shape would resolve every hash to
7523
+ // `null` — reads silently returning "missing" for shards that are
7524
+ // right there. Anything not shaped like a batch reply is treated as
7525
+ // "no batch route", exactly like a 404.
7526
+ const candidate = parsed?.shards;
7527
+ if (!candidate || typeof candidate !== "object") {
7528
+ this.batchSupported = false;
7529
+ for (const [h, ws] of waiters) {
7530
+ this.fetchOneFromOrigin(h).then((b) => ws.forEach((w) => w.resolve(b)), (e) => ws.forEach((w) => w.reject(e)));
7531
+ }
7532
+ return;
7533
+ }
7534
+ shards = candidate;
7535
+ }
7536
+ catch (err) {
7537
+ // Offline with a cache behaves as it does for a single fetch: report
7538
+ // absence so Reed-Solomon recovery can still be attempted.
7539
+ for (const [, ws] of waiters) {
7540
+ if (this.cache)
7541
+ ws.forEach((w) => w.resolve(null));
7542
+ else
7543
+ ws.forEach((w) => w.reject(err));
7544
+ }
7545
+ return;
7546
+ }
7547
+ for (const [hash, ws] of waiters) {
7548
+ const b64 = shards[hash];
7549
+ if (typeof b64 !== "string") {
7550
+ ws.forEach((w) => w.resolve(null));
7551
+ continue;
7552
+ }
7553
+ let bytes;
7554
+ try {
7555
+ bytes = bytesFromBase64(b64);
7556
+ }
7557
+ catch (e) {
7558
+ ws.forEach((w) => w.reject(e));
7559
+ continue;
7560
+ }
7561
+ if (this.cache)
7562
+ void this.cache.put(hash, bytes);
7563
+ ws.forEach((w) => w.resolve(bytes));
7564
+ }
7565
+ }
7424
7566
  /** Fetch a shard from origin. Returns `null` for 404 (or offline when a
7425
7567
  * cache is present, so RS recovery can proceed); throws otherwise. */
7426
- async fetchFromOrigin(hash) {
7568
+ async fetchOneFromOrigin(hash) {
7427
7569
  const url = this.urlForHash(hash);
7428
7570
  let response;
7429
7571
  try {