@bounded-sh/core 0.0.41 → 0.0.42

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/index.js CHANGED
@@ -2929,7 +2929,14 @@ async function revokeSession(refreshToken, issuer) {
2929
2929
  await axios.post(`${base}/session/revoke`, {
2930
2930
  refreshToken,
2931
2931
  appId: config.appId,
2932
- }, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 });
2932
+ }, {
2933
+ headers: { 'Content-Type': 'application/json' },
2934
+ timeout: 15000,
2935
+ // Preserve the transport error for programmatic diagnostics while
2936
+ // ensuring AxiosError.toJSON() cannot serialize the live token body
2937
+ // into logs, telemetry, UI state, or crash reports.
2938
+ redact: ['data'],
2939
+ });
2933
2940
  }
2934
2941
  catch (err) {
2935
2942
  throw new SessionRevokeError(revokeFailureMessage(err), err);
@@ -4155,12 +4162,21 @@ class InsufficientBalanceError extends Error {
4155
4162
  this.deficitSol = deficitSol;
4156
4163
  }
4157
4164
  }
4158
- // Cache for get responses
4159
4165
  const getCache = {};
4160
- // Track in-flight requests to coalesce multiple identical requests
4161
4166
  const pendingRequests = {};
4162
4167
  // Default cache TTL (500ms = 0.5 seconds)
4163
4168
  const GET_CACHE_TTL = 500; // Adjust this value as needed (in milliseconds)
4169
+ function getReadCacheEntry(cacheKey, principalValue) {
4170
+ var _a;
4171
+ return (_a = getCache[cacheKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalValue);
4172
+ }
4173
+ function setReadCacheEntry(cacheKey, principalValue, data, expiresAt) {
4174
+ var _a;
4175
+ const bucket = (_a = getCache[cacheKey]) !== null && _a !== void 0 ? _a : [];
4176
+ const reusableEntries = bucket.filter(entry => (entry.principalValue !== principalValue && entry.expiresAt >= Date.now()));
4177
+ reusableEntries.push({ data, expiresAt, principalValue });
4178
+ getCache[cacheKey] = reusableEntries;
4179
+ }
4164
4180
  // Last time we cleaned up the cache
4165
4181
  let lastCacheCleanup = Date.now();
4166
4182
  let uncacheableReadKeyCounter = 0;
@@ -4291,32 +4307,34 @@ async function getReadPrincipalKey(overrides) {
4291
4307
  // makeApiRequest applies overrides.headers AFTER its computed auth header, so
4292
4308
  // caller-supplied Authorization is the real request auth when present.
4293
4309
  if (hasAuthHeader(overrides === null || overrides === void 0 ? void 0 : overrides.headers)) {
4294
- const principal = principalFromAuthValue(authValueFromHeaders(overrides === null || overrides === void 0 ? void 0 : overrides.headers));
4310
+ const authValue = authValueFromHeaders(overrides === null || overrides === void 0 ? void 0 : overrides.headers);
4311
+ const principal = principalFromAuthValue(authValue);
4295
4312
  return principal
4296
- ? { key: `${appId}:${principal}`, cacheable: true }
4297
- : { key: uncacheableReadKey(appId, 'h'), cacheable: false };
4313
+ ? { key: `${appId}:${principal}`, cacheable: true, principalValue: authValue }
4314
+ : { key: uncacheableReadKey(appId, 'h'), cacheable: false, principalValue: null };
4298
4315
  }
4299
4316
  // Per-request auth-header override (wallet client). Key by the exact opaque
4300
4317
  // header it produces, not decoded claims or the unverified _walletAddress hint.
4301
4318
  if (overrides === null || overrides === void 0 ? void 0 : overrides._getAuthHeaders) {
4302
4319
  try {
4303
4320
  const headers = await overrides._getAuthHeaders();
4304
- const principal = principalFromAuthValue(authValueFromHeaders(headers));
4321
+ const authValue = authValueFromHeaders(headers);
4322
+ const principal = principalFromAuthValue(authValue);
4305
4323
  return principal
4306
- ? { key: `${appId}:o${principal}`, cacheable: true }
4307
- : { key: uncacheableReadKey(appId, 'o'), cacheable: false };
4324
+ ? { key: `${appId}:o${principal}`, cacheable: true, principalValue: authValue }
4325
+ : { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
4308
4326
  }
4309
4327
  catch (_a) {
4310
4328
  // If we can't resolve the override identity, do not read/write cache.
4311
- return { key: uncacheableReadKey(appId, 'o'), cacheable: false };
4329
+ return { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
4312
4330
  }
4313
4331
  }
4314
4332
  // Ambient session principal.
4315
4333
  const idToken = await getIdToken(config.isServer);
4316
4334
  const principal = principalFromIdToken$1(idToken);
4317
4335
  return principal
4318
- ? { key: `${appId}:${principal}`, cacheable: true }
4319
- : { key: uncacheableReadKey(appId, 'a'), cacheable: false };
4336
+ ? { key: `${appId}:${principal}`, cacheable: true, principalValue: idToken !== null && idToken !== void 0 ? idToken : null }
4337
+ : { key: uncacheableReadKey(appId, 'a'), cacheable: false, principalValue: null };
4320
4338
  }
4321
4339
  /**
4322
4340
  * Validates that a field name is a safe identifier (alphanumeric, underscores, dots for nested paths).
@@ -4597,6 +4615,7 @@ async function search(path, query, opts = {}) {
4597
4615
  return response.data;
4598
4616
  }
4599
4617
  async function get(path, opts = {}) {
4618
+ var _a, _b;
4600
4619
  try {
4601
4620
  let normalizedPath = path.startsWith("/") ? path.slice(1) : path;
4602
4621
  // Remove last '*' if it exists in the path
@@ -4607,7 +4626,8 @@ async function get(path, opts = {}) {
4607
4626
  throw new Error("Invalid path provided.");
4608
4627
  }
4609
4628
  // Create cache key combining path, prompt, filter, sort, includeSubPaths,
4610
- // shape, limit, cursor — and (H1) the caller's appId + principal fingerprint,
4629
+ // shape, limit, cursor — and (H1) the caller's appId + compact principal
4630
+ // bucket. Exact opaque credential equality is checked inside that bucket,
4611
4631
  // so a private read cached for one user is never served to another in a
4612
4632
  // shared process / SSR worker / browser login-switch. The cache is opt-in
4613
4633
  // and disabled for no-auth reads, which get an uncacheable unique key.
@@ -4622,20 +4642,25 @@ async function get(path, opts = {}) {
4622
4642
  const cacheEnabled = opts.cache === true && !opts.bypassCache && principalKey.cacheable;
4623
4643
  const now = Date.now();
4624
4644
  // Check for valid cache entry when the caller explicitly opted in.
4625
- if (cacheEnabled && getCache[cacheKey] && now < getCache[cacheKey].expiresAt) {
4626
- return getCache[cacheKey].data;
4645
+ const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
4646
+ if (cacheEnabled &&
4647
+ cachedEntry &&
4648
+ now < cachedEntry.expiresAt) {
4649
+ return cachedEntry.data;
4627
4650
  }
4628
4651
  // If we're bypassing cache, we should still coalesce identical requests
4629
4652
  // but need a different key to avoid interfering with regular requests
4630
4653
  const requestKey = opts.bypassCache ? `bypass:${cacheKey}` : cacheKey;
4631
- // Check if there's already an in-flight request for this key
4632
- // Fix for TS error - use hasOwnProperty to explicitly check if key exists
4633
- if (Object.prototype.hasOwnProperty.call(pendingRequests, requestKey)) {
4634
- // Return the existing promise to coalesce requests
4635
- return pendingRequests[requestKey];
4654
+ // The compact principal hash is only an index bucket. Coalesce only when
4655
+ // the exact opaque credential also matches, so collisions stay isolated.
4656
+ const existingPending = (_a = pendingRequests[requestKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalKey.principalValue);
4657
+ if (existingPending) {
4658
+ return existingPending.promise;
4636
4659
  }
4637
4660
  // Create a new request promise and store it
4638
- const requestPromise = (async () => {
4661
+ let requestPromise;
4662
+ requestPromise = (async () => {
4663
+ var _a;
4639
4664
  try {
4640
4665
  const pathIsDocument = normalizedPath.split("/").length % 2 === 0;
4641
4666
  // Cache miss or bypass - proceed with HTTP API request
@@ -4666,10 +4691,7 @@ async function get(path, opts = {}) {
4666
4691
  const responseData = normalizeReadResult(response.data, pathIsDocument);
4667
4692
  // Cache the response only when explicitly requested and principal-bound.
4668
4693
  if (cacheEnabled) {
4669
- getCache[cacheKey] = {
4670
- data: responseData,
4671
- expiresAt: now + GET_CACHE_TTL
4672
- };
4694
+ setReadCacheEntry(cacheKey, principalKey.principalValue, responseData, now + GET_CACHE_TTL);
4673
4695
  // Periodically clean up expired cache entries (every 5 seconds)
4674
4696
  if (now - lastCacheCleanup > 5000) {
4675
4697
  cleanupExpiredCache();
@@ -4680,12 +4702,21 @@ async function get(path, opts = {}) {
4680
4702
  return responseData;
4681
4703
  }
4682
4704
  finally {
4683
- // Remove this request from pendingRequests regardless of success/failure
4684
- delete pendingRequests[requestKey];
4705
+ // Remove only this exact request. A colliding principal can legitimately
4706
+ // have another request in the same compact-hash bucket.
4707
+ const bucket = pendingRequests[requestKey];
4708
+ const index = (_a = bucket === null || bucket === void 0 ? void 0 : bucket.findIndex(entry => entry.promise === requestPromise)) !== null && _a !== void 0 ? _a : -1;
4709
+ if (index >= 0)
4710
+ bucket.splice(index, 1);
4711
+ if ((bucket === null || bucket === void 0 ? void 0 : bucket.length) === 0)
4712
+ delete pendingRequests[requestKey];
4685
4713
  }
4686
4714
  })();
4687
- // Store the promise so other calls can join it
4688
- pendingRequests[requestKey] = requestPromise;
4715
+ // Store the promise so only callers with the exact same credential join it.
4716
+ ((_b = pendingRequests[requestKey]) !== null && _b !== void 0 ? _b : (pendingRequests[requestKey] = [])).push({
4717
+ principalValue: principalKey.principalValue,
4718
+ promise: requestPromise,
4719
+ });
4689
4720
  // Return the promise
4690
4721
  return requestPromise;
4691
4722
  }
@@ -4697,9 +4728,12 @@ async function get(path, opts = {}) {
4697
4728
  function cleanupExpiredCache() {
4698
4729
  const now = Date.now();
4699
4730
  Object.keys(getCache).forEach(key => {
4700
- if (getCache[key] && getCache[key].expiresAt < now) {
4731
+ var _a, _b;
4732
+ const activeEntries = (_b = (_a = getCache[key]) === null || _a === void 0 ? void 0 : _a.filter(entry => entry.expiresAt >= now)) !== null && _b !== void 0 ? _b : [];
4733
+ if (activeEntries.length > 0)
4734
+ getCache[key] = activeEntries;
4735
+ else
4701
4736
  delete getCache[key];
4702
- }
4703
4737
  });
4704
4738
  lastCacheCleanup = now;
4705
4739
  }
@@ -4961,8 +4995,11 @@ async function getMany(paths, opts = {}) {
4961
4995
  for (let i = 0; i < normalizedPaths.length; i++) {
4962
4996
  const normalizedPath = normalizedPaths[i];
4963
4997
  const cacheKey = `${principalKey.key}|${normalizedPath}:`;
4964
- if (cacheEnabled && getCache[cacheKey] && now < getCache[cacheKey].expiresAt) {
4965
- results[i] = { path: normalizedPath, data: getCache[cacheKey].data };
4998
+ const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
4999
+ if (cacheEnabled &&
5000
+ cachedEntry &&
5001
+ now < cachedEntry.expiresAt) {
5002
+ results[i] = { path: normalizedPath, data: cachedEntry.data };
4966
5003
  }
4967
5004
  else {
4968
5005
  uncachedIndices.push(i);
@@ -4999,10 +5036,7 @@ async function getMany(paths, opts = {}) {
4999
5036
  results[originalIndex] = normalizedResult;
5000
5037
  if (!normalizedResult.error && cacheEnabled) {
5001
5038
  const cacheKey = `${principalKey.key}|${normalizedPath}:`;
5002
- getCache[cacheKey] = {
5003
- data: normalizedResult.data,
5004
- expiresAt: now + GET_CACHE_TTL
5005
- };
5039
+ setReadCacheEntry(cacheKey, principalKey.principalValue, normalizedResult.data, now + GET_CACHE_TTL);
5006
5040
  }
5007
5041
  }
5008
5042
  else {
@@ -5569,10 +5603,37 @@ var operations = /*#__PURE__*/Object.freeze({
5569
5603
  // ============ Global State ============
5570
5604
  // One connection per routing target (appId, or appId#room#<collection>/<roomId>)
5571
5605
  const connections = new Map();
5606
+ // Coalesce concurrent first-use/auth-preflight calls for the same routing target.
5607
+ // React commonly mounts several subscriptions in one render; without this map,
5608
+ // every call can pass the empty `connections` check, await auth, and create an
5609
+ // orphan ReconnectingWebSocket before the first call publishes its connection.
5610
+ // Keyed by the identity-bucket lease key (see connectionLeaseKey) so two distinct
5611
+ // wallet tokens never share one in-flight socket even under a compact-hash collision.
5612
+ const connectionFlights = new Map();
5613
+ // A subscriber holds a routing-target lease from connection acquisition until
5614
+ // its callback/subscription is registered. The last old-route unsubscribe must
5615
+ // not close a connection already captured by a new route awaiting auth. Keyed by
5616
+ // the same identity-bucket lease key as connectionFlights.
5617
+ const connectionAcquisitions = new Map();
5618
+ // Compact identity hashes remain useful bounded-size indexes, but collisions
5619
+ // share a bucket instead of sharing authority. Each exact bearer keeps its own
5620
+ // cache entry so collision isolation does not evict another principal's cache.
5572
5621
  const responseCache = new Map();
5573
5622
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
5574
5623
  const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // Refresh token 10 minutes before expiry
5575
5624
  const TOKEN_CHECK_INTERVAL = 5 * 60 * 1000; // Check token freshness every 5 minutes
5625
+ function getResponseCacheEntry(cacheKey, identityToken) {
5626
+ var _a;
5627
+ return (_a = responseCache.get(cacheKey)) === null || _a === void 0 ? void 0 : _a.find(entry => entry.identityToken === identityToken);
5628
+ }
5629
+ function setResponseCacheEntry(cacheKey, identityToken, data) {
5630
+ var _a;
5631
+ const now = Date.now();
5632
+ const bucket = (_a = responseCache.get(cacheKey)) !== null && _a !== void 0 ? _a : [];
5633
+ const reusableEntries = bucket.filter(entry => (entry.identityToken !== identityToken && now - entry.timestamp < CACHE_TTL));
5634
+ reusableEntries.push({ data, timestamp: now, identityToken });
5635
+ responseCache.set(cacheKey, reusableEntries);
5636
+ }
5576
5637
  // ============ WebSocket Config ============
5577
5638
  const BASE_MIN_RECONNECT_DELAY_MS = 1000;
5578
5639
  const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
@@ -5580,6 +5641,10 @@ const MAX_RECONNECT_DELAY_MS = 300000;
5580
5641
  const RECONNECT_DELAY_GROW_FACTOR = 1.8;
5581
5642
  const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
5582
5643
  const WS_AUTH_EXPIRED_CODE = 'auth_expired';
5644
+ // React runs old-route effect cleanup before new-route effects. Keep an
5645
+ // otherwise-idle socket for one short handoff window so the new route can
5646
+ // cancel teardown and reuse it; genuinely idle sockets still close promptly.
5647
+ const IDLE_CONNECTION_CLOSE_GRACE_MS = 1000;
5583
5648
  const WS_CONFIG = {
5584
5649
  // Keep retrying indefinitely so long outages recover without page refresh.
5585
5650
  maxRetries: Infinity,
@@ -5610,12 +5675,12 @@ function hashForKey(value) {
5610
5675
  }
5611
5676
  /**
5612
5677
  * SECURITY (H1): Response-cache keys must be scoped to the caller's identity, not
5613
- * just the path/filter/shape. The `identity` prefix (`<appId>:<principal>`) keeps
5614
- * a private subscription snapshot cached for one user from being delivered to a
5615
- * different user who subscribes with the same options (shared process / SSR worker
5616
- * / browser login-switch; this cache has a 5-minute TTL). Callers must opt in
5617
- * before entries are read or written; no-auth subscriptions never populate an
5618
- * implicit anonymous cache bucket.
5678
+ * just the path/filter/shape. The compact `identity` prefix selects a bucket;
5679
+ * exact opaque bearer equality is required inside it before reuse. Together they
5680
+ * keep a private snapshot from being delivered to a different user with the same
5681
+ * options (shared process / SSR worker / browser login-switch; this cache has a
5682
+ * 5-minute TTL). Callers must opt in before entries are read or written; no-auth
5683
+ * subscriptions never populate an implicit anonymous cache bucket.
5619
5684
  */
5620
5685
  function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
5621
5686
  const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
@@ -5653,16 +5718,16 @@ function livePrincipalFromIdToken(idToken) {
5653
5718
  }
5654
5719
  function authSnapshotFromToken(idToken) {
5655
5720
  return {
5656
- tokenFingerprint: principalFromIdToken(idToken),
5721
+ tokenValue: idToken !== null && idToken !== void 0 ? idToken : null,
5657
5722
  principal: livePrincipalFromIdToken(idToken),
5658
5723
  };
5659
5724
  }
5660
5725
  function connectionMatchesAuthToken(connection, idToken) {
5661
5726
  const current = authSnapshotFromToken(idToken);
5662
- if (!current.tokenFingerprint) {
5663
- return connection.authTokenFingerprint === null && connection.authPrincipal === null;
5727
+ if (!current.tokenValue) {
5728
+ return connection.authTokenValue === null && connection.authPrincipal === null;
5664
5729
  }
5665
- if (connection.authTokenFingerprint && connection.authTokenFingerprint === current.tokenFingerprint) {
5730
+ if (connection.authTokenValue && connection.authTokenValue === current.tokenValue) {
5666
5731
  return true;
5667
5732
  }
5668
5733
  return !!connection.authPrincipal && !!current.principal && connection.authPrincipal === current.principal;
@@ -5671,7 +5736,7 @@ async function canSendAuthenticatedLiveIntent(connection, isServer) {
5671
5736
  const currentToken = await getIdToken(isServer);
5672
5737
  if (!currentToken)
5673
5738
  return false;
5674
- if (!connection.authTokenFingerprint && !connection.authPrincipal)
5739
+ if (!connection.authTokenValue && !connection.authPrincipal)
5675
5740
  return false;
5676
5741
  return connectionMatchesAuthToken(connection, currentToken);
5677
5742
  }
@@ -5684,19 +5749,19 @@ async function getSubscriptionIdentity(effectiveAppId, isServer, overrides) {
5684
5749
  const bearer = bearerFromAuthHeaders(await overrides._getAuthHeaders());
5685
5750
  const principal = principalFromIdToken(bearer);
5686
5751
  return principal
5687
- ? { key: `${effectiveAppId}:o${principal}`, cacheable: true }
5688
- : { key: `${effectiveAppId}:oanon`, cacheable: false };
5752
+ ? { key: `${effectiveAppId}:o${principal}`, cacheable: true, token: bearer }
5753
+ : { key: `${effectiveAppId}:oanon`, cacheable: false, token: null };
5689
5754
  }
5690
5755
  catch (_a) {
5691
5756
  // Couldn't resolve the override identity — do not use response cache.
5692
- return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false };
5757
+ return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false, token: null };
5693
5758
  }
5694
5759
  }
5695
5760
  const idToken = await getIdToken(isServer);
5696
5761
  const principal = principalFromIdToken(idToken);
5697
5762
  return principal
5698
- ? { key: `${effectiveAppId}:${principal}`, cacheable: true }
5699
- : { key: `${effectiveAppId}:anon`, cacheable: false };
5763
+ ? { key: `${effectiveAppId}:${principal}`, cacheable: true, token: idToken !== null && idToken !== void 0 ? idToken : null }
5764
+ : { key: `${effectiveAppId}:anon`, cacheable: false, token: null };
5700
5765
  }
5701
5766
  /** Extract the bare bearer token from a `{ Authorization: 'Bearer <jwt>' }` map. */
5702
5767
  function bearerFromAuthHeaders(headers) {
@@ -5752,7 +5817,7 @@ function failConnectionAuth(connection, error) {
5752
5817
  connection.authFailure = error;
5753
5818
  connection.pendingAuthToken = null;
5754
5819
  connection.pendingAuthSnapshot = null;
5755
- connection.authTokenFingerprint = null;
5820
+ connection.authTokenValue = null;
5756
5821
  connection.authPrincipal = null;
5757
5822
  connection.isConnecting = false;
5758
5823
  connection.isConnected = false;
@@ -5763,6 +5828,7 @@ function failConnectionAuth(connection, error) {
5763
5828
  connection.pendingSubscriptions.clear();
5764
5829
  for (const subscription of connection.subscriptions.values()) {
5765
5830
  notifyErrorCallbacks(subscription, error);
5831
+ rejectSubscriptionRefresh(subscription, error);
5766
5832
  }
5767
5833
  }
5768
5834
  function scheduleTokenRefresh(connection, isServer) {
@@ -5955,7 +6021,136 @@ function replaySubscriptions(connection) {
5955
6021
  sendSubscribe(connection, sub);
5956
6022
  }
5957
6023
  }
5958
- async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey) {
6024
+ /**
6025
+ * Derive the stable identity-bucket lease key used to coalesce (singleflight)
6026
+ * and refcount (acquisition / idle-close) connection use. Ambient app/room
6027
+ * connections key by their routing target; wallet-scoped connections pin the
6028
+ * exact bearer so two distinct tokens never share a flight or a lease, even
6029
+ * when their compact `#id#<principalKey>` hashes collide.
6030
+ */
6031
+ function connectionLeaseKey(appId, routePath, principalKey, principalToken) {
6032
+ const roomKey = roomKeyFromRoutePath(routePath);
6033
+ const base = roomKey ? `${appId}#room#${roomKey}` : appId;
6034
+ if (!principalKey)
6035
+ return base;
6036
+ const bucket = `${base}#id#${principalKey}`;
6037
+ return principalToken ? `${bucket}#tok#${principalToken}` : bucket;
6038
+ }
6039
+ function cancelIdleConnectionClose(connection) {
6040
+ if (!connection.idleCloseTimer)
6041
+ return;
6042
+ clearTimeout(connection.idleCloseTimer);
6043
+ connection.idleCloseTimer = null;
6044
+ }
6045
+ function closeConnectionNow(connection) {
6046
+ if (connections.get(connection.key) !== connection)
6047
+ return;
6048
+ if (!connection.ws)
6049
+ return;
6050
+ cancelIdleConnectionClose(connection);
6051
+ if (connection.tokenRefreshTimer) {
6052
+ clearInterval(connection.tokenRefreshTimer);
6053
+ connection.tokenRefreshTimer = null;
6054
+ }
6055
+ const ws = connection.ws;
6056
+ connection.ws = null;
6057
+ connection.isConnecting = false;
6058
+ connection.isConnected = false;
6059
+ connection.isAuthenticating = false;
6060
+ const closeError = new Error('WebSocket connection closed');
6061
+ for (const subscription of connection.subscriptions.values()) {
6062
+ rejectSubscriptionRefresh(subscription, closeError);
6063
+ }
6064
+ connections.delete(connection.key);
6065
+ try {
6066
+ ws.close();
6067
+ }
6068
+ catch ( /* already closing */_a) { /* already closing */ }
6069
+ }
6070
+ function scheduleConnectionCloseIfIdle(connection) {
6071
+ var _a;
6072
+ if (connections.get(connection.key) !== connection)
6073
+ return;
6074
+ if (connection.subscriptions.size > 0)
6075
+ return;
6076
+ if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
6077
+ return;
6078
+ if (!connection.ws || connection.idleCloseTimer)
6079
+ return;
6080
+ connection.idleCloseTimer = setTimeout(() => {
6081
+ var _a;
6082
+ connection.idleCloseTimer = null;
6083
+ if (connection.subscriptions.size > 0)
6084
+ return;
6085
+ if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
6086
+ return;
6087
+ closeConnectionNow(connection);
6088
+ }, IDLE_CONNECTION_CLOSE_GRACE_MS);
6089
+ }
6090
+ /**
6091
+ * Acquire a connection under a routing-target lease. The lease is refcounted on
6092
+ * the identity-bucket key and held across the caller's auth preflight so a
6093
+ * concurrent old-route teardown cannot close a socket a new route is about to
6094
+ * reuse. The lease is released once the caller has registered its
6095
+ * subscription/callback.
6096
+ */
6097
+ async function acquireConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
6098
+ var _a;
6099
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
6100
+ connectionAcquisitions.set(leaseKey, ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 0) + 1);
6101
+ let released = false;
6102
+ let releaseTimer = null;
6103
+ const release = (connection) => {
6104
+ var _a;
6105
+ if (released)
6106
+ return;
6107
+ released = true;
6108
+ if (releaseTimer) {
6109
+ clearTimeout(releaseTimer);
6110
+ releaseTimer = null;
6111
+ }
6112
+ const remaining = ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 1) - 1;
6113
+ if (remaining > 0)
6114
+ connectionAcquisitions.set(leaseKey, remaining);
6115
+ else
6116
+ connectionAcquisitions.delete(leaseKey);
6117
+ if (connection)
6118
+ scheduleConnectionCloseIfIdle(connection);
6119
+ };
6120
+ try {
6121
+ const connection = await getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken);
6122
+ const releaseConnection = () => release(connection);
6123
+ // Registration is synchronous after the caller's await. This fail-safe
6124
+ // releases on the next task even if an option getter/callback throws
6125
+ // before subscribeV2 reaches its explicit release path.
6126
+ releaseTimer = setTimeout(releaseConnection, 0);
6127
+ return { connection, release: releaseConnection };
6128
+ }
6129
+ catch (error) {
6130
+ release();
6131
+ throw error;
6132
+ }
6133
+ }
6134
+ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
6135
+ // Singleflight: concurrent first-use of the same routing target shares one
6136
+ // in-flight connection rather than each creating an orphan socket. Keyed by
6137
+ // the identity-bucket lease key so distinct wallet tokens never coalesce.
6138
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
6139
+ const activeFlight = connectionFlights.get(leaseKey);
6140
+ if (activeFlight)
6141
+ return activeFlight;
6142
+ const flight = getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey);
6143
+ connectionFlights.set(leaseKey, flight);
6144
+ try {
6145
+ return await flight;
6146
+ }
6147
+ finally {
6148
+ if (connectionFlights.get(leaseKey) === flight) {
6149
+ connectionFlights.delete(leaseKey);
6150
+ }
6151
+ }
6152
+ }
6153
+ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey) {
5959
6154
  attachBrowserReconnectHooksOnce();
5960
6155
  // A per-room subscription gets its OWN connection routed to the room DO; all
5961
6156
  // other subscriptions share the app-level connection (project DO). The worker
@@ -5966,9 +6161,38 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5966
6161
  // keyed by the wallet identity, so its WS authenticates as that wallet and
5967
6162
  // never shares a connection (or token) with any top-level server caller.
5968
6163
  const base = roomKey ? `${appId}#room#${roomKey}` : appId;
5969
- const connKey = principalKey ? `${base}#id#${principalKey}` : base;
5970
- let connection = connections.get(connKey);
6164
+ const identityBucketKey = principalKey
6165
+ ? `${base}#id#${principalKey}`
6166
+ : base;
6167
+ let connKey = identityBucketKey;
6168
+ let connection;
6169
+ if (principalKey && principalToken) {
6170
+ // The 31-bit hash selects a small bucket only. Exact token equality is
6171
+ // authoritative, and a rare collision gets an independent map slot.
6172
+ for (const [candidateKey, candidate] of connections) {
6173
+ if ((candidateKey === identityBucketKey || candidateKey.startsWith(`${identityBucketKey}#collision#`)) &&
6174
+ candidate.connectionIdentityToken === principalToken) {
6175
+ connKey = candidateKey;
6176
+ connection = candidate;
6177
+ break;
6178
+ }
6179
+ }
6180
+ if (!connection && connections.has(connKey)) {
6181
+ let collisionIndex = 1;
6182
+ while (connections.has(`${identityBucketKey}#collision#${collisionIndex}`))
6183
+ collisionIndex += 1;
6184
+ connKey = `${identityBucketKey}#collision#${collisionIndex}`;
6185
+ }
6186
+ }
6187
+ else {
6188
+ connection = connections.get(connKey);
6189
+ }
5971
6190
  if (connection && connection.ws) {
6191
+ // We may reuse this connection; cancel any pending idle-close so it is
6192
+ // not torn down during the auth preflight below. The held acquisition
6193
+ // lease also guards this window, but cancelling here avoids a needless
6194
+ // grace-timer round-trip on the common reuse path.
6195
+ cancelIdleConnectionClose(connection);
5972
6196
  if (connection.authFailure) {
5973
6197
  throw connection.authFailure;
5974
6198
  }
@@ -5984,19 +6208,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5984
6208
  if (connection.isConnected &&
5985
6209
  !connection.isAuthenticating &&
5986
6210
  !connectionMatchesAuthToken(connection, currentAuthToken)) {
5987
- if (connection.tokenRefreshTimer) {
5988
- clearInterval(connection.tokenRefreshTimer);
5989
- connection.tokenRefreshTimer = null;
5990
- }
5991
- try {
5992
- connection.ws.close();
5993
- }
5994
- catch (_a) {
5995
- // Already closing.
5996
- }
5997
- connection.ws = null;
5998
- connection.isConnected = false;
5999
- connections.delete(connection.key);
6211
+ closeConnectionNow(connection);
6000
6212
  }
6001
6213
  else {
6002
6214
  connection.authFailure = null;
@@ -6017,13 +6229,16 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
6017
6229
  isAuthenticating: false,
6018
6230
  appId,
6019
6231
  key: connKey,
6232
+ acquisitionKey: leaseKey,
6020
6233
  routePath: roomKey ? routePath : undefined,
6021
6234
  authTokenProvider,
6235
+ connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
6022
6236
  pendingAuthToken: null,
6023
6237
  pendingAuthSnapshot: null,
6024
- authTokenFingerprint: null,
6238
+ authTokenValue: null,
6025
6239
  authPrincipal: null,
6026
6240
  tokenRefreshTimer: null,
6241
+ idleCloseTimer: null,
6027
6242
  consecutiveAuthFailures: 0,
6028
6243
  authFailure: null,
6029
6244
  };
@@ -6100,7 +6315,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
6100
6315
  }
6101
6316
  else {
6102
6317
  connection.isAuthenticating = false;
6103
- connection.authTokenFingerprint = null;
6318
+ connection.authTokenValue = null;
6104
6319
  connection.authPrincipal = null;
6105
6320
  replaySubscriptions(connection);
6106
6321
  }
@@ -6118,10 +6333,14 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
6118
6333
  // Handle errors
6119
6334
  ws.addEventListener('error', (event) => {
6120
6335
  console.error('[WS v2] WebSocket error:', event);
6336
+ const wsError = new Error('WebSocket error');
6121
6337
  for (const [, pending] of connection.pendingSubscriptions) {
6122
- pending.reject(new Error('WebSocket error'));
6338
+ pending.reject(wsError);
6123
6339
  }
6124
6340
  connection.pendingSubscriptions.clear();
6341
+ for (const subscription of connection.subscriptions.values()) {
6342
+ rejectSubscriptionRefresh(subscription, wsError);
6343
+ }
6125
6344
  });
6126
6345
  // Handle close
6127
6346
  ws.addEventListener('close', () => {
@@ -6145,7 +6364,7 @@ function handleServerMessage(connection, message) {
6145
6364
  switch (message.type) {
6146
6365
  case 'authenticated': {
6147
6366
  connection.isAuthenticating = false;
6148
- connection.authTokenFingerprint = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenFingerprint) !== null && _b !== void 0 ? _b : null;
6367
+ connection.authTokenValue = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenValue) !== null && _b !== void 0 ? _b : null;
6149
6368
  connection.authPrincipal = (_d = (_c = connection.pendingAuthSnapshot) === null || _c === void 0 ? void 0 : _c.principal) !== null && _d !== void 0 ? _d : null;
6150
6369
  replaySubscriptions(connection);
6151
6370
  break;
@@ -6153,15 +6372,49 @@ function handleServerMessage(connection, message) {
6153
6372
  case 'subscribed': {
6154
6373
  const subscription = connection.subscriptions.get(message.subscriptionId);
6155
6374
  if (subscription) {
6375
+ const refresh = subscription.refresh;
6156
6376
  // A data update can race ahead of subscribed during reconnect.
6157
6377
  // If we already received data for this subscription, treat subscribed
6158
6378
  // as an ack only and avoid regressing to an older snapshot.
6159
- if (subscription.lastData === undefined) {
6379
+ if (refresh) {
6380
+ const hadBaseline = subscription.lastData !== undefined;
6381
+ if (subscription.dataRevision === refresh.dataRevisionAtStart) {
6382
+ if (subscription.cache) {
6383
+ const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6384
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6385
+ }
6386
+ subscription.lastData = message.data;
6387
+ subscription.dataRevision += 1;
6388
+ if (hadBaseline) {
6389
+ const callbacks = Array.from(refresh.callbacks)
6390
+ .filter((callback) => subscription.callbacks.includes(callback));
6391
+ notifyCallbackOptions(callbacks, message.data);
6392
+ }
6393
+ else {
6394
+ // The refresh joined an original registration or a
6395
+ // reconnect replay before its first baseline arrived.
6396
+ // Every active callback needs that initial snapshot.
6397
+ notifyCallbacks(subscription, message.data);
6398
+ }
6399
+ }
6400
+ else if (refresh.callbacks.size > 0 && subscription.lastData !== undefined) {
6401
+ // A newer authorized data frame arrived after this refresh
6402
+ // began. Do not regress lastData to the older subscribed
6403
+ // snapshot; use that newer frame for callbacks that joined
6404
+ // after it was broadcast.
6405
+ const callbacks = Array.from(refresh.callbacks)
6406
+ .filter((callback) => subscription.callbacks.includes(callback));
6407
+ notifyCallbackOptions(callbacks, subscription.lastData);
6408
+ }
6409
+ resolveSubscriptionRefresh(subscription);
6410
+ }
6411
+ else if (subscription.lastData === undefined) {
6160
6412
  if (subscription.cache) {
6161
6413
  const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6162
- responseCache.set(cacheKey, { data: message.data, timestamp: Date.now() });
6414
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6163
6415
  }
6164
6416
  subscription.lastData = message.data;
6417
+ subscription.dataRevision += 1;
6165
6418
  notifyCallbacks(subscription, message.data);
6166
6419
  }
6167
6420
  }
@@ -6188,12 +6441,20 @@ function handleServerMessage(connection, message) {
6188
6441
  // Update cache
6189
6442
  if (subscription.cache) {
6190
6443
  const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6191
- responseCache.set(cacheKey, { data: message.data, timestamp: Date.now() });
6444
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6192
6445
  }
6193
6446
  // Store last data
6194
6447
  subscription.lastData = message.data;
6448
+ subscription.dataRevision += 1;
6195
6449
  // Notify callbacks
6196
6450
  notifyCallbacks(subscription, message.data);
6451
+ // A data frame is itself a full, freshly authorized snapshot.
6452
+ // It satisfies every joined callback even if the duplicate
6453
+ // subscribed ACK was lost; resolving here prevents a false
6454
+ // refresh timeout ten seconds after the UI already rendered.
6455
+ if (subscription.refresh) {
6456
+ resolveSubscriptionRefresh(subscription);
6457
+ }
6197
6458
  }
6198
6459
  break;
6199
6460
  }
@@ -6280,6 +6541,7 @@ function handleServerMessage(connection, message) {
6280
6541
  const subscription = connection.subscriptions.get(message.subscriptionId);
6281
6542
  if (subscription) {
6282
6543
  notifyErrorCallbacks(subscription, wsError);
6544
+ rejectSubscriptionRefresh(subscription, wsError);
6283
6545
  }
6284
6546
  }
6285
6547
  break;
@@ -6316,13 +6578,10 @@ function addIdsToSubscriptionData(data) {
6316
6578
  return data.map(withSubscriptionId);
6317
6579
  return withSubscriptionId(data);
6318
6580
  }
6319
- function notifyCallbacks(subscription, data) {
6581
+ function notifyCallbackOptions(callbacks, data) {
6320
6582
  var _a;
6321
6583
  // Attach the bare `id` (leaf doc key) to every delivered row (Bug 1).
6322
6584
  const decorated = addIdsToSubscriptionData(data);
6323
- // Snapshot the callbacks array so that unsubscriptions during
6324
- // notification don't cause callbacks to be skipped.
6325
- const callbacks = subscription.callbacks.slice();
6326
6585
  for (const callback of callbacks) {
6327
6586
  try {
6328
6587
  (_a = callback.onData) === null || _a === void 0 ? void 0 : _a.call(callback, decorated);
@@ -6332,23 +6591,33 @@ function notifyCallbacks(subscription, data) {
6332
6591
  }
6333
6592
  }
6334
6593
  }
6594
+ function notifyCallbacks(subscription, data) {
6595
+ // Snapshot the callbacks array so that unsubscriptions during
6596
+ // notification don't cause callbacks to be skipped.
6597
+ notifyCallbackOptions(subscription.callbacks.slice(), data);
6598
+ }
6599
+ function notifyErrorCallback(callback, error) {
6600
+ let delivered = false;
6601
+ try {
6602
+ const onError = callback.onError;
6603
+ if (!onError)
6604
+ return false;
6605
+ onError(error);
6606
+ delivered = true;
6607
+ }
6608
+ catch (callbackError) {
6609
+ console.error('[WS v2] Error in subscription error callback:', callbackError);
6610
+ }
6611
+ return delivered;
6612
+ }
6335
6613
  function notifyErrorCallbacks(subscription, error) {
6336
6614
  let delivered = false;
6337
6615
  const callbacks = subscription.callbacks.slice();
6338
6616
  for (const callback of callbacks) {
6339
- if (!callback.onError)
6340
- continue;
6341
- delivered = true;
6342
- try {
6343
- callback.onError(error);
6344
- }
6345
- catch (callbackError) {
6346
- console.error('[WS v2] Error in subscription error callback:', callbackError);
6347
- }
6617
+ delivered = notifyErrorCallback(callback, error) || delivered;
6348
6618
  }
6349
- if (delivered) {
6619
+ if (delivered)
6350
6620
  error.__boundedDeliveredToOnError = true;
6351
- }
6352
6621
  return delivered;
6353
6622
  }
6354
6623
  // WebSocket readyState constants
@@ -6379,6 +6648,71 @@ function sendSubscribe(connection, subscription) {
6379
6648
  console.error('[WS v2] Error sending subscribe message:', error);
6380
6649
  }
6381
6650
  }
6651
+ function resolveSubscriptionRefresh(subscription) {
6652
+ const refresh = subscription.refresh;
6653
+ if (!refresh)
6654
+ return;
6655
+ subscription.refresh = null;
6656
+ if (refresh.timer)
6657
+ clearTimeout(refresh.timer);
6658
+ refresh.callbacks.clear();
6659
+ refresh.resolve();
6660
+ }
6661
+ function rejectSubscriptionRefresh(subscription, error) {
6662
+ const refresh = subscription.refresh;
6663
+ if (!refresh)
6664
+ return;
6665
+ subscription.refresh = null;
6666
+ if (refresh.timer)
6667
+ clearTimeout(refresh.timer);
6668
+ refresh.callbacks.clear();
6669
+ refresh.reject(error);
6670
+ }
6671
+ /**
6672
+ * A callback joining an already-registered non-cache subscription cannot be
6673
+ * seeded from `lastData`: that snapshot may predate the callback's auth
6674
+ * preflight. Re-send the same subscription id so the server re-runs read auth
6675
+ * and returns a fresh baseline, without allocating another websocket or
6676
+ * replacing the logical client subscription.
6677
+ */
6678
+ function requestFreshSubscriptionSnapshot(connection, subscription, callback) {
6679
+ let refresh = subscription.refresh;
6680
+ if (!refresh) {
6681
+ let resolve;
6682
+ let reject;
6683
+ const promise = new Promise((resolvePromise, rejectPromise) => {
6684
+ resolve = resolvePromise;
6685
+ reject = rejectPromise;
6686
+ });
6687
+ refresh = {
6688
+ promise,
6689
+ resolve,
6690
+ reject,
6691
+ timer: null,
6692
+ callbacks: new Set(),
6693
+ dataRevisionAtStart: subscription.dataRevision,
6694
+ };
6695
+ subscription.refresh = refresh;
6696
+ refresh.timer = setTimeout(() => {
6697
+ if (subscription.refresh !== refresh)
6698
+ return;
6699
+ rejectSubscriptionRefresh(subscription, new Error('Subscription timeout'));
6700
+ }, 10000);
6701
+ }
6702
+ // Register before sendSubscribe: test/browser transports may synchronously
6703
+ // dispatch the subscribed response from send().
6704
+ refresh.callbacks.add(callback);
6705
+ // Every newly joined callback gets an idempotent same-id resend when the
6706
+ // authenticated socket is ready. Do not suppress this because an initial
6707
+ // confirmation or an earlier refresh is pending: that pending frame may
6708
+ // have been lost or displaced, and sharing it is exactly how an entire
6709
+ // route remount can inherit one doomed timeout. When connecting or
6710
+ // authenticating, replaySubscriptions will send after auth completes.
6711
+ if (connection.isConnected && !connection.isAuthenticating) {
6712
+ sendSubscribe(connection, subscription);
6713
+ }
6714
+ return refresh.promise;
6715
+ }
6382
6716
  function sendUnsubscribe(connection, subscriptionId) {
6383
6717
  if (!connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN) {
6384
6718
  return;
@@ -6427,16 +6761,27 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6427
6761
  : undefined;
6428
6762
  const identityInfo = await getSubscriptionIdentity(effectiveAppId, config.isServer, overrides);
6429
6763
  const identity = identityInfo.key;
6764
+ const identityToken = identityInfo.token;
6430
6765
  const responseCacheEnabled = subscriptionOptions.cache === true && identityInfo.cacheable;
6766
+ // Preserve the existing compact connection-key shape, but use the exact
6767
+ // token as a secondary in-memory equality check within that hash bucket.
6431
6768
  const principalKey = authTokenProvider ? identity : undefined;
6769
+ const principalToken = authTokenProvider
6770
+ ? (identityToken !== null && identityToken !== void 0 ? identityToken : identity)
6771
+ : undefined;
6432
6772
  const cacheKey = responseCacheEnabled
6433
6773
  ? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
6434
6774
  : null;
6435
- // Get or create connection for this routing target (room-scoped when a
6436
- // room route is supplied by the live helper, else the app-level connection).
6775
+ // Acquire a connection for this routing target (room-scoped when a room route
6776
+ // is supplied by the live helper, else the app-level connection). The lease is
6777
+ // held across auth preflight and released once this call registers below, so a
6778
+ // concurrent old-route teardown cannot close a socket we are about to reuse.
6437
6779
  let connection;
6780
+ let releaseConnection;
6438
6781
  try {
6439
- connection = await getOrCreateConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey);
6782
+ const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken);
6783
+ connection = acquisition.connection;
6784
+ releaseConnection = acquisition.release;
6440
6785
  }
6441
6786
  catch (error) {
6442
6787
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6446,73 +6791,130 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6446
6791
  }
6447
6792
  throw err;
6448
6793
  }
6449
- // Deliver cached data immediately if available, but only after connection
6450
- // auth preflight has succeeded. An expired authenticated session must receive
6451
- // an auth error, not a stale private snapshot followed by a failed connect.
6452
- const cachedEntry = cacheKey ? responseCache.get(cacheKey) : undefined;
6453
- if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
6454
- setTimeout(() => {
6455
- var _a;
6456
- (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
6457
- }, 0);
6458
- }
6794
+ const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
6459
6795
  // Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
6460
- const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
6461
- const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
6462
- const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
6463
6796
  let existingSubscription;
6464
- for (const sub of connection.subscriptions.values()) {
6465
- const subShapeKey = sub.shape ? JSON.stringify(sub.shape) : '';
6466
- const subFilterKey = sub.filter ? JSON.stringify(sub.filter) : '';
6467
- const subSortKey = sub.sort ? JSON.stringify(sub.sort) : '';
6468
- // H1: also require the same principal identity — a different logged-in
6469
- // user must not piggy-back on (and inherit lastData from) another user's
6470
- // existing subscription, even with identical path/prompt/shape/filter/sort.
6471
- if (sub.identity === identity && sub.path === normalizedPath && sub.prompt === subscriptionOptions.prompt && subShapeKey === shapeKey && subFilterKey === filterKey && subSortKey === sortKey && sub.includeSubPaths === ((_a = subscriptionOptions.includeSubPaths) !== null && _a !== void 0 ? _a : false) && sub.limit === subscriptionOptions.limit && sub.cursor === subscriptionOptions.cursor) {
6472
- existingSubscription = sub;
6473
- break;
6797
+ try {
6798
+ const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
6799
+ const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
6800
+ const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
6801
+ for (const sub of connection.subscriptions.values()) {
6802
+ const subShapeKey = sub.shape ? JSON.stringify(sub.shape) : '';
6803
+ const subFilterKey = sub.filter ? JSON.stringify(sub.filter) : '';
6804
+ const subSortKey = sub.sort ? JSON.stringify(sub.sort) : '';
6805
+ // H1: also require the same principal identity (compact hash AND exact
6806
+ // token) — a different logged-in user must not piggy-back on (and
6807
+ // inherit lastData from) another user's existing subscription, even
6808
+ // with identical path/prompt/shape/filter/sort.
6809
+ if (sub.identity === identity && sub.identityToken === identityToken && sub.path === normalizedPath && sub.prompt === subscriptionOptions.prompt && subShapeKey === shapeKey && subFilterKey === filterKey && subSortKey === sortKey && sub.includeSubPaths === ((_a = subscriptionOptions.includeSubPaths) !== null && _a !== void 0 ? _a : false) && sub.limit === subscriptionOptions.limit && sub.cursor === subscriptionOptions.cursor) {
6810
+ existingSubscription = sub;
6811
+ break;
6812
+ }
6474
6813
  }
6475
6814
  }
6815
+ catch (error) {
6816
+ releaseConnection();
6817
+ throw error;
6818
+ }
6476
6819
  if (existingSubscription) {
6820
+ let replayData = undefined;
6821
+ let replayOnData;
6822
+ if (responseCacheEnabled) {
6823
+ // Read callback getters before mutating the shared subscription. If
6824
+ // a caller supplied a throwing getter, the acquisition fail-safe can
6825
+ // release cleanly without leaving a ghost callback behind.
6826
+ replayOnData = subscriptionOptions.onData;
6827
+ replayData = existingSubscription.lastData !== undefined
6828
+ ? existingSubscription.lastData
6829
+ : cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL
6830
+ ? cachedEntry.data
6831
+ : undefined;
6832
+ }
6477
6833
  if (responseCacheEnabled) {
6478
6834
  existingSubscription.cache = true;
6479
6835
  }
6480
6836
  // Add callback to existing subscription
6481
6837
  existingSubscription.callbacks.push(subscriptionOptions);
6482
- // Deliver last known data immediately only for explicit, principal-bound
6483
- // cache opt-in. Otherwise joining an existing subscription can replay a
6484
- // stale private snapshot without a fresh server auth result.
6485
- if (responseCacheEnabled &&
6486
- existingSubscription.cache &&
6487
- existingSubscription.lastData !== undefined &&
6488
- subscriptionOptions.onData) {
6489
- setTimeout(() => {
6490
- var _a;
6491
- (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(existingSubscription.lastData));
6492
- }, 0);
6838
+ let refreshPromise = null;
6839
+ if (responseCacheEnabled) {
6840
+ // Explicit, principal-bound cache opt-in may replay exactly one
6841
+ // snapshot. Prefer the active subscription's lastData, falling back
6842
+ // to the short-lived response cache while a reconnect is in flight.
6843
+ if (replayData !== undefined && replayOnData) {
6844
+ setTimeout(() => {
6845
+ replayOnData === null || replayOnData === void 0 ? void 0 : replayOnData(addIdsToSubscriptionData(replayData));
6846
+ }, 0);
6847
+ }
6848
+ }
6849
+ else {
6850
+ // Default (non-cache) subscriptions require a fresh authorized
6851
+ // baseline for every newly joined callback. Reuse the same logical
6852
+ // subscription id and socket, but make the server re-run the read.
6853
+ refreshPromise = requestFreshSubscriptionSnapshot(connection, existingSubscription, subscriptionOptions);
6854
+ }
6855
+ releaseConnection();
6856
+ if (refreshPromise) {
6857
+ try {
6858
+ await refreshPromise;
6859
+ }
6860
+ catch (error) {
6861
+ const err = error instanceof Error ? error : new Error(String(error));
6862
+ let deliveredLocally = false;
6863
+ if (!err.__boundedDeliveredToOnError) {
6864
+ deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
6865
+ }
6866
+ if (!subscriptionOptions.onError) {
6867
+ await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
6868
+ throw err;
6869
+ }
6870
+ if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
6871
+ console.warn('[WS v2] Subscription refresh failed, keeping for reconnect recovery:', error);
6872
+ }
6873
+ }
6493
6874
  }
6494
6875
  return async () => {
6495
6876
  await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
6496
6877
  };
6497
6878
  }
6879
+ // Deliver cached data immediately for a new logical subscription, but only
6880
+ // after connection auth preflight has succeeded. An expired authenticated
6881
+ // session must receive an auth error, not a stale private snapshot followed
6882
+ // by a failed connect.
6883
+ if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
6884
+ setTimeout(() => {
6885
+ var _a;
6886
+ (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
6887
+ }, 0);
6888
+ }
6498
6889
  // Create new subscription
6499
6890
  const subscriptionId = generateSubscriptionId();
6500
- const subscription = {
6501
- subscriptionId,
6502
- path: normalizedPath,
6503
- filter: subscriptionOptions.filter,
6504
- sort: subscriptionOptions.sort,
6505
- prompt: subscriptionOptions.prompt,
6506
- shape: subscriptionOptions.shape,
6507
- limit: subscriptionOptions.limit,
6508
- cursor: subscriptionOptions.cursor,
6509
- includeSubPaths: (_b = subscriptionOptions.includeSubPaths) !== null && _b !== void 0 ? _b : false,
6510
- callbacks: [subscriptionOptions],
6511
- lastData: undefined,
6512
- cache: responseCacheEnabled,
6513
- identity,
6514
- };
6515
- connection.subscriptions.set(subscriptionId, subscription);
6891
+ let subscription;
6892
+ try {
6893
+ subscription = {
6894
+ subscriptionId,
6895
+ path: normalizedPath,
6896
+ filter: subscriptionOptions.filter,
6897
+ sort: subscriptionOptions.sort,
6898
+ prompt: subscriptionOptions.prompt,
6899
+ shape: subscriptionOptions.shape,
6900
+ limit: subscriptionOptions.limit,
6901
+ cursor: subscriptionOptions.cursor,
6902
+ includeSubPaths: (_b = subscriptionOptions.includeSubPaths) !== null && _b !== void 0 ? _b : false,
6903
+ callbacks: [subscriptionOptions],
6904
+ lastData: undefined,
6905
+ dataRevision: 0,
6906
+ refresh: null,
6907
+ cache: responseCacheEnabled,
6908
+ identity,
6909
+ identityToken,
6910
+ };
6911
+ connection.subscriptions.set(subscriptionId, subscription);
6912
+ }
6913
+ catch (error) {
6914
+ releaseConnection();
6915
+ throw error;
6916
+ }
6917
+ releaseConnection();
6516
6918
  // Send subscribe message if connected
6517
6919
  if (connection.isConnected && !connection.isAuthenticating) {
6518
6920
  // Create a promise to wait for subscription confirmation
@@ -6532,15 +6934,23 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6532
6934
  }
6533
6935
  catch (error) {
6534
6936
  const err = error instanceof Error ? error : new Error(String(error));
6937
+ let deliveredLocally = false;
6535
6938
  if (!err.__boundedDeliveredToOnError) {
6536
- notifyErrorCallbacks(subscription, err);
6939
+ // A local confirmation timeout belongs to the caller that
6940
+ // originated it. Joined callbacks may already have a viable
6941
+ // same-id refresh and must not be poisoned by that old attempt.
6942
+ deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
6537
6943
  }
6538
6944
  if (!subscriptionOptions.onError) {
6539
6945
  connection.pendingSubscriptions.delete(subscriptionId);
6540
- connection.subscriptions.delete(subscriptionId);
6946
+ // This registration may have acquired joined callbacks while
6947
+ // its confirmation was pending. Remove only the originating
6948
+ // callback; deleting the whole logical subscription would
6949
+ // orphan those remounted consumers and their refresh flight.
6950
+ await removeCallbackFromSubscription(connection, subscriptionId, subscriptionOptions);
6541
6951
  throw err;
6542
6952
  }
6543
- if (!err.__boundedDeliveredToOnError) {
6953
+ if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
6544
6954
  console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
6545
6955
  }
6546
6956
  }
@@ -6551,12 +6961,14 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6551
6961
  };
6552
6962
  }
6553
6963
  async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
6964
+ var _a;
6554
6965
  const subscription = connection.subscriptions.get(subscriptionId);
6555
6966
  if (!subscription) {
6556
6967
  return;
6557
6968
  }
6558
6969
  // Remove this callback
6559
6970
  subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
6971
+ (_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
6560
6972
  // If there are still callbacks, don't unsubscribe
6561
6973
  if (subscription.callbacks.length > 0) {
6562
6974
  return;
@@ -6578,17 +6990,9 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
6578
6990
  sendUnsubscribe(connection, subscriptionId);
6579
6991
  await unsubscribePromise;
6580
6992
  }
6581
- // If no more subscriptions, close connection
6582
- if (connection.subscriptions.size === 0 && connection.ws) {
6583
- // Clear token refresh timer
6584
- if (connection.tokenRefreshTimer) {
6585
- clearInterval(connection.tokenRefreshTimer);
6586
- connection.tokenRefreshTimer = null;
6587
- }
6588
- connection.ws.close();
6589
- connection.ws = null;
6590
- connections.delete(connection.key);
6591
- }
6993
+ // If no more subscriptions, let the connection close after a short idle
6994
+ // grace so an immediately-following route remount can reclaim the socket.
6995
+ scheduleConnectionCloseIfIdle(connection);
6592
6996
  }
6593
6997
  /**
6594
6998
  * Close all v2 subscriptions.
@@ -6596,6 +7000,11 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
6596
7000
  async function closeAllSubscriptionsV2() {
6597
7001
  const closePromises = [];
6598
7002
  for (const [appId, connection] of connections) {
7003
+ cancelIdleConnectionClose(connection);
7004
+ const closeError = new Error('Subscriptions closed');
7005
+ for (const subscription of connection.subscriptions.values()) {
7006
+ rejectSubscriptionRefresh(subscription, closeError);
7007
+ }
6599
7008
  if (connection.tokenRefreshTimer) {
6600
7009
  clearInterval(connection.tokenRefreshTimer);
6601
7010
  connection.tokenRefreshTimer = null;
@@ -6604,15 +7013,18 @@ async function closeAllSubscriptionsV2() {
6604
7013
  const ws = connection.ws;
6605
7014
  connection.ws = null;
6606
7015
  connection.isConnected = false;
6607
- closePromises.push(new Promise((resolve) => {
6608
- ws.addEventListener('close', () => {
6609
- resolve();
6610
- });
6611
- ws.close();
6612
- }));
7016
+ if (ws.readyState !== WS_READY_STATE_CLOSED) {
7017
+ closePromises.push(new Promise((resolve) => {
7018
+ ws.addEventListener('close', () => {
7019
+ resolve();
7020
+ });
7021
+ ws.close();
7022
+ }));
7023
+ }
6613
7024
  }
6614
7025
  connections.delete(appId);
6615
7026
  }
7027
+ connectionAcquisitions.clear();
6616
7028
  await Promise.all(closePromises);
6617
7029
  }
6618
7030
  /**
@@ -6645,10 +7057,12 @@ function getCachedDataV2(path, prompt) {
6645
7057
  // opt-in. Resolve the caller's identity from an active cache-enabled
6646
7058
  // subscription for this path. No matching sub → null (safe).
6647
7059
  let identity;
7060
+ let identityToken;
6648
7061
  outer: for (const connection of connections.values()) {
6649
7062
  for (const sub of connection.subscriptions.values()) {
6650
7063
  if (sub.cache && sub.path === normalizedPath && sub.prompt === prompt) {
6651
7064
  identity = sub.identity;
7065
+ identityToken = sub.identityToken;
6652
7066
  break outer;
6653
7067
  }
6654
7068
  }
@@ -6657,7 +7071,7 @@ function getCachedDataV2(path, prompt) {
6657
7071
  return null;
6658
7072
  }
6659
7073
  const cacheKey = getCacheKey(path, prompt, undefined, undefined, undefined, undefined, identity);
6660
- const cachedEntry = responseCache.get(cacheKey);
7074
+ const cachedEntry = getResponseCacheEntry(cacheKey, identityToken !== null && identityToken !== void 0 ? identityToken : null);
6661
7075
  if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL) {
6662
7076
  return cachedEntry.data;
6663
7077
  }
@@ -6719,10 +7133,15 @@ async function doReconnectWithNewAuth() {
6719
7133
  if (!connection.ws) {
6720
7134
  continue;
6721
7135
  }
7136
+ cancelIdleConnectionClose(connection);
7137
+ const reconnectError = new Error('Connection reconnecting due to auth change');
6722
7138
  for (const [, pending] of connection.pendingSubscriptions) {
6723
- pending.reject(new Error('Connection reconnecting due to auth change'));
7139
+ pending.reject(reconnectError);
6724
7140
  }
6725
7141
  connection.pendingSubscriptions.clear();
7142
+ for (const subscription of connection.subscriptions.values()) {
7143
+ rejectSubscriptionRefresh(subscription, reconnectError);
7144
+ }
6726
7145
  for (const [, pending] of connection.pendingUnsubscriptions) {
6727
7146
  pending.resolve();
6728
7147
  }
@@ -6782,10 +7201,10 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
6782
7201
  const currentToken = await getIdToken(isServer).catch(() => null);
6783
7202
  if (!currentToken) {
6784
7203
  // Mid-refresh: the socket principal still governs server-side.
6785
- if (!connection.authTokenFingerprint && !connection.authPrincipal)
7204
+ if (!connection.authTokenValue && !connection.authPrincipal)
6786
7205
  return false;
6787
7206
  }
6788
- else if (!connection.authTokenFingerprint && !connection.authPrincipal) {
7207
+ else if (!connection.authTokenValue && !connection.authPrincipal) {
6789
7208
  // Anonymous socket + authenticated user: do not attribute this user's
6790
7209
  // input to the anon principal. Reauth the socket; caller may fall back.
6791
7210
  void maybeReauthRoomConnection(connection);
@@ -6805,7 +7224,7 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
6805
7224
  }
6806
7225
  }
6807
7226
  // Debounced background reauth: when the client's current token no longer
6808
- // matches the connection's auth fingerprint (token rotation), refresh the
7227
+ // matches the connection's authenticated identity (token rotation), refresh the
6809
7228
  // socket auth ONCE rather than degrading every subsequent send.
6810
7229
  const reauthInFlight = new WeakSet();
6811
7230
  async function maybeReauthRoomConnection(connection, isServer) {