@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.mjs CHANGED
@@ -2909,7 +2909,14 @@ async function revokeSession(refreshToken, issuer) {
2909
2909
  await axios.post(`${base}/session/revoke`, {
2910
2910
  refreshToken,
2911
2911
  appId: config.appId,
2912
- }, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 });
2912
+ }, {
2913
+ headers: { 'Content-Type': 'application/json' },
2914
+ timeout: 15000,
2915
+ // Preserve the transport error for programmatic diagnostics while
2916
+ // ensuring AxiosError.toJSON() cannot serialize the live token body
2917
+ // into logs, telemetry, UI state, or crash reports.
2918
+ redact: ['data'],
2919
+ });
2913
2920
  }
2914
2921
  catch (err) {
2915
2922
  throw new SessionRevokeError(revokeFailureMessage(err), err);
@@ -4135,12 +4142,21 @@ class InsufficientBalanceError extends Error {
4135
4142
  this.deficitSol = deficitSol;
4136
4143
  }
4137
4144
  }
4138
- // Cache for get responses
4139
4145
  const getCache = {};
4140
- // Track in-flight requests to coalesce multiple identical requests
4141
4146
  const pendingRequests = {};
4142
4147
  // Default cache TTL (500ms = 0.5 seconds)
4143
4148
  const GET_CACHE_TTL = 500; // Adjust this value as needed (in milliseconds)
4149
+ function getReadCacheEntry(cacheKey, principalValue) {
4150
+ var _a;
4151
+ return (_a = getCache[cacheKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalValue);
4152
+ }
4153
+ function setReadCacheEntry(cacheKey, principalValue, data, expiresAt) {
4154
+ var _a;
4155
+ const bucket = (_a = getCache[cacheKey]) !== null && _a !== void 0 ? _a : [];
4156
+ const reusableEntries = bucket.filter(entry => (entry.principalValue !== principalValue && entry.expiresAt >= Date.now()));
4157
+ reusableEntries.push({ data, expiresAt, principalValue });
4158
+ getCache[cacheKey] = reusableEntries;
4159
+ }
4144
4160
  // Last time we cleaned up the cache
4145
4161
  let lastCacheCleanup = Date.now();
4146
4162
  let uncacheableReadKeyCounter = 0;
@@ -4271,32 +4287,34 @@ async function getReadPrincipalKey(overrides) {
4271
4287
  // makeApiRequest applies overrides.headers AFTER its computed auth header, so
4272
4288
  // caller-supplied Authorization is the real request auth when present.
4273
4289
  if (hasAuthHeader(overrides === null || overrides === void 0 ? void 0 : overrides.headers)) {
4274
- const principal = principalFromAuthValue(authValueFromHeaders(overrides === null || overrides === void 0 ? void 0 : overrides.headers));
4290
+ const authValue = authValueFromHeaders(overrides === null || overrides === void 0 ? void 0 : overrides.headers);
4291
+ const principal = principalFromAuthValue(authValue);
4275
4292
  return principal
4276
- ? { key: `${appId}:${principal}`, cacheable: true }
4277
- : { key: uncacheableReadKey(appId, 'h'), cacheable: false };
4293
+ ? { key: `${appId}:${principal}`, cacheable: true, principalValue: authValue }
4294
+ : { key: uncacheableReadKey(appId, 'h'), cacheable: false, principalValue: null };
4278
4295
  }
4279
4296
  // Per-request auth-header override (wallet client). Key by the exact opaque
4280
4297
  // header it produces, not decoded claims or the unverified _walletAddress hint.
4281
4298
  if (overrides === null || overrides === void 0 ? void 0 : overrides._getAuthHeaders) {
4282
4299
  try {
4283
4300
  const headers = await overrides._getAuthHeaders();
4284
- const principal = principalFromAuthValue(authValueFromHeaders(headers));
4301
+ const authValue = authValueFromHeaders(headers);
4302
+ const principal = principalFromAuthValue(authValue);
4285
4303
  return principal
4286
- ? { key: `${appId}:o${principal}`, cacheable: true }
4287
- : { key: uncacheableReadKey(appId, 'o'), cacheable: false };
4304
+ ? { key: `${appId}:o${principal}`, cacheable: true, principalValue: authValue }
4305
+ : { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
4288
4306
  }
4289
4307
  catch (_a) {
4290
4308
  // If we can't resolve the override identity, do not read/write cache.
4291
- return { key: uncacheableReadKey(appId, 'o'), cacheable: false };
4309
+ return { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
4292
4310
  }
4293
4311
  }
4294
4312
  // Ambient session principal.
4295
4313
  const idToken = await getIdToken(config.isServer);
4296
4314
  const principal = principalFromIdToken$1(idToken);
4297
4315
  return principal
4298
- ? { key: `${appId}:${principal}`, cacheable: true }
4299
- : { key: uncacheableReadKey(appId, 'a'), cacheable: false };
4316
+ ? { key: `${appId}:${principal}`, cacheable: true, principalValue: idToken !== null && idToken !== void 0 ? idToken : null }
4317
+ : { key: uncacheableReadKey(appId, 'a'), cacheable: false, principalValue: null };
4300
4318
  }
4301
4319
  /**
4302
4320
  * Validates that a field name is a safe identifier (alphanumeric, underscores, dots for nested paths).
@@ -4577,6 +4595,7 @@ async function search(path, query, opts = {}) {
4577
4595
  return response.data;
4578
4596
  }
4579
4597
  async function get(path, opts = {}) {
4598
+ var _a, _b;
4580
4599
  try {
4581
4600
  let normalizedPath = path.startsWith("/") ? path.slice(1) : path;
4582
4601
  // Remove last '*' if it exists in the path
@@ -4587,7 +4606,8 @@ async function get(path, opts = {}) {
4587
4606
  throw new Error("Invalid path provided.");
4588
4607
  }
4589
4608
  // Create cache key combining path, prompt, filter, sort, includeSubPaths,
4590
- // shape, limit, cursor — and (H1) the caller's appId + principal fingerprint,
4609
+ // shape, limit, cursor — and (H1) the caller's appId + compact principal
4610
+ // bucket. Exact opaque credential equality is checked inside that bucket,
4591
4611
  // so a private read cached for one user is never served to another in a
4592
4612
  // shared process / SSR worker / browser login-switch. The cache is opt-in
4593
4613
  // and disabled for no-auth reads, which get an uncacheable unique key.
@@ -4602,20 +4622,25 @@ async function get(path, opts = {}) {
4602
4622
  const cacheEnabled = opts.cache === true && !opts.bypassCache && principalKey.cacheable;
4603
4623
  const now = Date.now();
4604
4624
  // Check for valid cache entry when the caller explicitly opted in.
4605
- if (cacheEnabled && getCache[cacheKey] && now < getCache[cacheKey].expiresAt) {
4606
- return getCache[cacheKey].data;
4625
+ const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
4626
+ if (cacheEnabled &&
4627
+ cachedEntry &&
4628
+ now < cachedEntry.expiresAt) {
4629
+ return cachedEntry.data;
4607
4630
  }
4608
4631
  // If we're bypassing cache, we should still coalesce identical requests
4609
4632
  // but need a different key to avoid interfering with regular requests
4610
4633
  const requestKey = opts.bypassCache ? `bypass:${cacheKey}` : cacheKey;
4611
- // Check if there's already an in-flight request for this key
4612
- // Fix for TS error - use hasOwnProperty to explicitly check if key exists
4613
- if (Object.prototype.hasOwnProperty.call(pendingRequests, requestKey)) {
4614
- // Return the existing promise to coalesce requests
4615
- return pendingRequests[requestKey];
4634
+ // The compact principal hash is only an index bucket. Coalesce only when
4635
+ // the exact opaque credential also matches, so collisions stay isolated.
4636
+ const existingPending = (_a = pendingRequests[requestKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalKey.principalValue);
4637
+ if (existingPending) {
4638
+ return existingPending.promise;
4616
4639
  }
4617
4640
  // Create a new request promise and store it
4618
- const requestPromise = (async () => {
4641
+ let requestPromise;
4642
+ requestPromise = (async () => {
4643
+ var _a;
4619
4644
  try {
4620
4645
  const pathIsDocument = normalizedPath.split("/").length % 2 === 0;
4621
4646
  // Cache miss or bypass - proceed with HTTP API request
@@ -4646,10 +4671,7 @@ async function get(path, opts = {}) {
4646
4671
  const responseData = normalizeReadResult(response.data, pathIsDocument);
4647
4672
  // Cache the response only when explicitly requested and principal-bound.
4648
4673
  if (cacheEnabled) {
4649
- getCache[cacheKey] = {
4650
- data: responseData,
4651
- expiresAt: now + GET_CACHE_TTL
4652
- };
4674
+ setReadCacheEntry(cacheKey, principalKey.principalValue, responseData, now + GET_CACHE_TTL);
4653
4675
  // Periodically clean up expired cache entries (every 5 seconds)
4654
4676
  if (now - lastCacheCleanup > 5000) {
4655
4677
  cleanupExpiredCache();
@@ -4660,12 +4682,21 @@ async function get(path, opts = {}) {
4660
4682
  return responseData;
4661
4683
  }
4662
4684
  finally {
4663
- // Remove this request from pendingRequests regardless of success/failure
4664
- delete pendingRequests[requestKey];
4685
+ // Remove only this exact request. A colliding principal can legitimately
4686
+ // have another request in the same compact-hash bucket.
4687
+ const bucket = pendingRequests[requestKey];
4688
+ const index = (_a = bucket === null || bucket === void 0 ? void 0 : bucket.findIndex(entry => entry.promise === requestPromise)) !== null && _a !== void 0 ? _a : -1;
4689
+ if (index >= 0)
4690
+ bucket.splice(index, 1);
4691
+ if ((bucket === null || bucket === void 0 ? void 0 : bucket.length) === 0)
4692
+ delete pendingRequests[requestKey];
4665
4693
  }
4666
4694
  })();
4667
- // Store the promise so other calls can join it
4668
- pendingRequests[requestKey] = requestPromise;
4695
+ // Store the promise so only callers with the exact same credential join it.
4696
+ ((_b = pendingRequests[requestKey]) !== null && _b !== void 0 ? _b : (pendingRequests[requestKey] = [])).push({
4697
+ principalValue: principalKey.principalValue,
4698
+ promise: requestPromise,
4699
+ });
4669
4700
  // Return the promise
4670
4701
  return requestPromise;
4671
4702
  }
@@ -4677,9 +4708,12 @@ async function get(path, opts = {}) {
4677
4708
  function cleanupExpiredCache() {
4678
4709
  const now = Date.now();
4679
4710
  Object.keys(getCache).forEach(key => {
4680
- if (getCache[key] && getCache[key].expiresAt < now) {
4711
+ var _a, _b;
4712
+ const activeEntries = (_b = (_a = getCache[key]) === null || _a === void 0 ? void 0 : _a.filter(entry => entry.expiresAt >= now)) !== null && _b !== void 0 ? _b : [];
4713
+ if (activeEntries.length > 0)
4714
+ getCache[key] = activeEntries;
4715
+ else
4681
4716
  delete getCache[key];
4682
- }
4683
4717
  });
4684
4718
  lastCacheCleanup = now;
4685
4719
  }
@@ -4941,8 +4975,11 @@ async function getMany(paths, opts = {}) {
4941
4975
  for (let i = 0; i < normalizedPaths.length; i++) {
4942
4976
  const normalizedPath = normalizedPaths[i];
4943
4977
  const cacheKey = `${principalKey.key}|${normalizedPath}:`;
4944
- if (cacheEnabled && getCache[cacheKey] && now < getCache[cacheKey].expiresAt) {
4945
- results[i] = { path: normalizedPath, data: getCache[cacheKey].data };
4978
+ const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
4979
+ if (cacheEnabled &&
4980
+ cachedEntry &&
4981
+ now < cachedEntry.expiresAt) {
4982
+ results[i] = { path: normalizedPath, data: cachedEntry.data };
4946
4983
  }
4947
4984
  else {
4948
4985
  uncachedIndices.push(i);
@@ -4979,10 +5016,7 @@ async function getMany(paths, opts = {}) {
4979
5016
  results[originalIndex] = normalizedResult;
4980
5017
  if (!normalizedResult.error && cacheEnabled) {
4981
5018
  const cacheKey = `${principalKey.key}|${normalizedPath}:`;
4982
- getCache[cacheKey] = {
4983
- data: normalizedResult.data,
4984
- expiresAt: now + GET_CACHE_TTL
4985
- };
5019
+ setReadCacheEntry(cacheKey, principalKey.principalValue, normalizedResult.data, now + GET_CACHE_TTL);
4986
5020
  }
4987
5021
  }
4988
5022
  else {
@@ -5549,10 +5583,37 @@ var operations = /*#__PURE__*/Object.freeze({
5549
5583
  // ============ Global State ============
5550
5584
  // One connection per routing target (appId, or appId#room#<collection>/<roomId>)
5551
5585
  const connections = new Map();
5586
+ // Coalesce concurrent first-use/auth-preflight calls for the same routing target.
5587
+ // React commonly mounts several subscriptions in one render; without this map,
5588
+ // every call can pass the empty `connections` check, await auth, and create an
5589
+ // orphan ReconnectingWebSocket before the first call publishes its connection.
5590
+ // Keyed by the identity-bucket lease key (see connectionLeaseKey) so two distinct
5591
+ // wallet tokens never share one in-flight socket even under a compact-hash collision.
5592
+ const connectionFlights = new Map();
5593
+ // A subscriber holds a routing-target lease from connection acquisition until
5594
+ // its callback/subscription is registered. The last old-route unsubscribe must
5595
+ // not close a connection already captured by a new route awaiting auth. Keyed by
5596
+ // the same identity-bucket lease key as connectionFlights.
5597
+ const connectionAcquisitions = new Map();
5598
+ // Compact identity hashes remain useful bounded-size indexes, but collisions
5599
+ // share a bucket instead of sharing authority. Each exact bearer keeps its own
5600
+ // cache entry so collision isolation does not evict another principal's cache.
5552
5601
  const responseCache = new Map();
5553
5602
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
5554
5603
  const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // Refresh token 10 minutes before expiry
5555
5604
  const TOKEN_CHECK_INTERVAL = 5 * 60 * 1000; // Check token freshness every 5 minutes
5605
+ function getResponseCacheEntry(cacheKey, identityToken) {
5606
+ var _a;
5607
+ return (_a = responseCache.get(cacheKey)) === null || _a === void 0 ? void 0 : _a.find(entry => entry.identityToken === identityToken);
5608
+ }
5609
+ function setResponseCacheEntry(cacheKey, identityToken, data) {
5610
+ var _a;
5611
+ const now = Date.now();
5612
+ const bucket = (_a = responseCache.get(cacheKey)) !== null && _a !== void 0 ? _a : [];
5613
+ const reusableEntries = bucket.filter(entry => (entry.identityToken !== identityToken && now - entry.timestamp < CACHE_TTL));
5614
+ reusableEntries.push({ data, timestamp: now, identityToken });
5615
+ responseCache.set(cacheKey, reusableEntries);
5616
+ }
5556
5617
  // ============ WebSocket Config ============
5557
5618
  const BASE_MIN_RECONNECT_DELAY_MS = 1000;
5558
5619
  const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
@@ -5560,6 +5621,10 @@ const MAX_RECONNECT_DELAY_MS = 300000;
5560
5621
  const RECONNECT_DELAY_GROW_FACTOR = 1.8;
5561
5622
  const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
5562
5623
  const WS_AUTH_EXPIRED_CODE = 'auth_expired';
5624
+ // React runs old-route effect cleanup before new-route effects. Keep an
5625
+ // otherwise-idle socket for one short handoff window so the new route can
5626
+ // cancel teardown and reuse it; genuinely idle sockets still close promptly.
5627
+ const IDLE_CONNECTION_CLOSE_GRACE_MS = 1000;
5563
5628
  const WS_CONFIG = {
5564
5629
  // Keep retrying indefinitely so long outages recover without page refresh.
5565
5630
  maxRetries: Infinity,
@@ -5590,12 +5655,12 @@ function hashForKey(value) {
5590
5655
  }
5591
5656
  /**
5592
5657
  * SECURITY (H1): Response-cache keys must be scoped to the caller's identity, not
5593
- * just the path/filter/shape. The `identity` prefix (`<appId>:<principal>`) keeps
5594
- * a private subscription snapshot cached for one user from being delivered to a
5595
- * different user who subscribes with the same options (shared process / SSR worker
5596
- * / browser login-switch; this cache has a 5-minute TTL). Callers must opt in
5597
- * before entries are read or written; no-auth subscriptions never populate an
5598
- * implicit anonymous cache bucket.
5658
+ * just the path/filter/shape. The compact `identity` prefix selects a bucket;
5659
+ * exact opaque bearer equality is required inside it before reuse. Together they
5660
+ * keep a private snapshot from being delivered to a different user with the same
5661
+ * options (shared process / SSR worker / browser login-switch; this cache has a
5662
+ * 5-minute TTL). Callers must opt in before entries are read or written; no-auth
5663
+ * subscriptions never populate an implicit anonymous cache bucket.
5599
5664
  */
5600
5665
  function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
5601
5666
  const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
@@ -5633,16 +5698,16 @@ function livePrincipalFromIdToken(idToken) {
5633
5698
  }
5634
5699
  function authSnapshotFromToken(idToken) {
5635
5700
  return {
5636
- tokenFingerprint: principalFromIdToken(idToken),
5701
+ tokenValue: idToken !== null && idToken !== void 0 ? idToken : null,
5637
5702
  principal: livePrincipalFromIdToken(idToken),
5638
5703
  };
5639
5704
  }
5640
5705
  function connectionMatchesAuthToken(connection, idToken) {
5641
5706
  const current = authSnapshotFromToken(idToken);
5642
- if (!current.tokenFingerprint) {
5643
- return connection.authTokenFingerprint === null && connection.authPrincipal === null;
5707
+ if (!current.tokenValue) {
5708
+ return connection.authTokenValue === null && connection.authPrincipal === null;
5644
5709
  }
5645
- if (connection.authTokenFingerprint && connection.authTokenFingerprint === current.tokenFingerprint) {
5710
+ if (connection.authTokenValue && connection.authTokenValue === current.tokenValue) {
5646
5711
  return true;
5647
5712
  }
5648
5713
  return !!connection.authPrincipal && !!current.principal && connection.authPrincipal === current.principal;
@@ -5651,7 +5716,7 @@ async function canSendAuthenticatedLiveIntent(connection, isServer) {
5651
5716
  const currentToken = await getIdToken(isServer);
5652
5717
  if (!currentToken)
5653
5718
  return false;
5654
- if (!connection.authTokenFingerprint && !connection.authPrincipal)
5719
+ if (!connection.authTokenValue && !connection.authPrincipal)
5655
5720
  return false;
5656
5721
  return connectionMatchesAuthToken(connection, currentToken);
5657
5722
  }
@@ -5664,19 +5729,19 @@ async function getSubscriptionIdentity(effectiveAppId, isServer, overrides) {
5664
5729
  const bearer = bearerFromAuthHeaders(await overrides._getAuthHeaders());
5665
5730
  const principal = principalFromIdToken(bearer);
5666
5731
  return principal
5667
- ? { key: `${effectiveAppId}:o${principal}`, cacheable: true }
5668
- : { key: `${effectiveAppId}:oanon`, cacheable: false };
5732
+ ? { key: `${effectiveAppId}:o${principal}`, cacheable: true, token: bearer }
5733
+ : { key: `${effectiveAppId}:oanon`, cacheable: false, token: null };
5669
5734
  }
5670
5735
  catch (_a) {
5671
5736
  // Couldn't resolve the override identity — do not use response cache.
5672
- return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false };
5737
+ return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false, token: null };
5673
5738
  }
5674
5739
  }
5675
5740
  const idToken = await getIdToken(isServer);
5676
5741
  const principal = principalFromIdToken(idToken);
5677
5742
  return principal
5678
- ? { key: `${effectiveAppId}:${principal}`, cacheable: true }
5679
- : { key: `${effectiveAppId}:anon`, cacheable: false };
5743
+ ? { key: `${effectiveAppId}:${principal}`, cacheable: true, token: idToken !== null && idToken !== void 0 ? idToken : null }
5744
+ : { key: `${effectiveAppId}:anon`, cacheable: false, token: null };
5680
5745
  }
5681
5746
  /** Extract the bare bearer token from a `{ Authorization: 'Bearer <jwt>' }` map. */
5682
5747
  function bearerFromAuthHeaders(headers) {
@@ -5732,7 +5797,7 @@ function failConnectionAuth(connection, error) {
5732
5797
  connection.authFailure = error;
5733
5798
  connection.pendingAuthToken = null;
5734
5799
  connection.pendingAuthSnapshot = null;
5735
- connection.authTokenFingerprint = null;
5800
+ connection.authTokenValue = null;
5736
5801
  connection.authPrincipal = null;
5737
5802
  connection.isConnecting = false;
5738
5803
  connection.isConnected = false;
@@ -5743,6 +5808,7 @@ function failConnectionAuth(connection, error) {
5743
5808
  connection.pendingSubscriptions.clear();
5744
5809
  for (const subscription of connection.subscriptions.values()) {
5745
5810
  notifyErrorCallbacks(subscription, error);
5811
+ rejectSubscriptionRefresh(subscription, error);
5746
5812
  }
5747
5813
  }
5748
5814
  function scheduleTokenRefresh(connection, isServer) {
@@ -5935,7 +6001,136 @@ function replaySubscriptions(connection) {
5935
6001
  sendSubscribe(connection, sub);
5936
6002
  }
5937
6003
  }
5938
- async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey) {
6004
+ /**
6005
+ * Derive the stable identity-bucket lease key used to coalesce (singleflight)
6006
+ * and refcount (acquisition / idle-close) connection use. Ambient app/room
6007
+ * connections key by their routing target; wallet-scoped connections pin the
6008
+ * exact bearer so two distinct tokens never share a flight or a lease, even
6009
+ * when their compact `#id#<principalKey>` hashes collide.
6010
+ */
6011
+ function connectionLeaseKey(appId, routePath, principalKey, principalToken) {
6012
+ const roomKey = roomKeyFromRoutePath(routePath);
6013
+ const base = roomKey ? `${appId}#room#${roomKey}` : appId;
6014
+ if (!principalKey)
6015
+ return base;
6016
+ const bucket = `${base}#id#${principalKey}`;
6017
+ return principalToken ? `${bucket}#tok#${principalToken}` : bucket;
6018
+ }
6019
+ function cancelIdleConnectionClose(connection) {
6020
+ if (!connection.idleCloseTimer)
6021
+ return;
6022
+ clearTimeout(connection.idleCloseTimer);
6023
+ connection.idleCloseTimer = null;
6024
+ }
6025
+ function closeConnectionNow(connection) {
6026
+ if (connections.get(connection.key) !== connection)
6027
+ return;
6028
+ if (!connection.ws)
6029
+ return;
6030
+ cancelIdleConnectionClose(connection);
6031
+ if (connection.tokenRefreshTimer) {
6032
+ clearInterval(connection.tokenRefreshTimer);
6033
+ connection.tokenRefreshTimer = null;
6034
+ }
6035
+ const ws = connection.ws;
6036
+ connection.ws = null;
6037
+ connection.isConnecting = false;
6038
+ connection.isConnected = false;
6039
+ connection.isAuthenticating = false;
6040
+ const closeError = new Error('WebSocket connection closed');
6041
+ for (const subscription of connection.subscriptions.values()) {
6042
+ rejectSubscriptionRefresh(subscription, closeError);
6043
+ }
6044
+ connections.delete(connection.key);
6045
+ try {
6046
+ ws.close();
6047
+ }
6048
+ catch ( /* already closing */_a) { /* already closing */ }
6049
+ }
6050
+ function scheduleConnectionCloseIfIdle(connection) {
6051
+ var _a;
6052
+ if (connections.get(connection.key) !== connection)
6053
+ return;
6054
+ if (connection.subscriptions.size > 0)
6055
+ return;
6056
+ if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
6057
+ return;
6058
+ if (!connection.ws || connection.idleCloseTimer)
6059
+ return;
6060
+ connection.idleCloseTimer = setTimeout(() => {
6061
+ var _a;
6062
+ connection.idleCloseTimer = null;
6063
+ if (connection.subscriptions.size > 0)
6064
+ return;
6065
+ if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
6066
+ return;
6067
+ closeConnectionNow(connection);
6068
+ }, IDLE_CONNECTION_CLOSE_GRACE_MS);
6069
+ }
6070
+ /**
6071
+ * Acquire a connection under a routing-target lease. The lease is refcounted on
6072
+ * the identity-bucket key and held across the caller's auth preflight so a
6073
+ * concurrent old-route teardown cannot close a socket a new route is about to
6074
+ * reuse. The lease is released once the caller has registered its
6075
+ * subscription/callback.
6076
+ */
6077
+ async function acquireConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
6078
+ var _a;
6079
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
6080
+ connectionAcquisitions.set(leaseKey, ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 0) + 1);
6081
+ let released = false;
6082
+ let releaseTimer = null;
6083
+ const release = (connection) => {
6084
+ var _a;
6085
+ if (released)
6086
+ return;
6087
+ released = true;
6088
+ if (releaseTimer) {
6089
+ clearTimeout(releaseTimer);
6090
+ releaseTimer = null;
6091
+ }
6092
+ const remaining = ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 1) - 1;
6093
+ if (remaining > 0)
6094
+ connectionAcquisitions.set(leaseKey, remaining);
6095
+ else
6096
+ connectionAcquisitions.delete(leaseKey);
6097
+ if (connection)
6098
+ scheduleConnectionCloseIfIdle(connection);
6099
+ };
6100
+ try {
6101
+ const connection = await getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken);
6102
+ const releaseConnection = () => release(connection);
6103
+ // Registration is synchronous after the caller's await. This fail-safe
6104
+ // releases on the next task even if an option getter/callback throws
6105
+ // before subscribeV2 reaches its explicit release path.
6106
+ releaseTimer = setTimeout(releaseConnection, 0);
6107
+ return { connection, release: releaseConnection };
6108
+ }
6109
+ catch (error) {
6110
+ release();
6111
+ throw error;
6112
+ }
6113
+ }
6114
+ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
6115
+ // Singleflight: concurrent first-use of the same routing target shares one
6116
+ // in-flight connection rather than each creating an orphan socket. Keyed by
6117
+ // the identity-bucket lease key so distinct wallet tokens never coalesce.
6118
+ const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
6119
+ const activeFlight = connectionFlights.get(leaseKey);
6120
+ if (activeFlight)
6121
+ return activeFlight;
6122
+ const flight = getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey);
6123
+ connectionFlights.set(leaseKey, flight);
6124
+ try {
6125
+ return await flight;
6126
+ }
6127
+ finally {
6128
+ if (connectionFlights.get(leaseKey) === flight) {
6129
+ connectionFlights.delete(leaseKey);
6130
+ }
6131
+ }
6132
+ }
6133
+ async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey) {
5939
6134
  attachBrowserReconnectHooksOnce();
5940
6135
  // A per-room subscription gets its OWN connection routed to the room DO; all
5941
6136
  // other subscriptions share the app-level connection (project DO). The worker
@@ -5946,9 +6141,38 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5946
6141
  // keyed by the wallet identity, so its WS authenticates as that wallet and
5947
6142
  // never shares a connection (or token) with any top-level server caller.
5948
6143
  const base = roomKey ? `${appId}#room#${roomKey}` : appId;
5949
- const connKey = principalKey ? `${base}#id#${principalKey}` : base;
5950
- let connection = connections.get(connKey);
6144
+ const identityBucketKey = principalKey
6145
+ ? `${base}#id#${principalKey}`
6146
+ : base;
6147
+ let connKey = identityBucketKey;
6148
+ let connection;
6149
+ if (principalKey && principalToken) {
6150
+ // The 31-bit hash selects a small bucket only. Exact token equality is
6151
+ // authoritative, and a rare collision gets an independent map slot.
6152
+ for (const [candidateKey, candidate] of connections) {
6153
+ if ((candidateKey === identityBucketKey || candidateKey.startsWith(`${identityBucketKey}#collision#`)) &&
6154
+ candidate.connectionIdentityToken === principalToken) {
6155
+ connKey = candidateKey;
6156
+ connection = candidate;
6157
+ break;
6158
+ }
6159
+ }
6160
+ if (!connection && connections.has(connKey)) {
6161
+ let collisionIndex = 1;
6162
+ while (connections.has(`${identityBucketKey}#collision#${collisionIndex}`))
6163
+ collisionIndex += 1;
6164
+ connKey = `${identityBucketKey}#collision#${collisionIndex}`;
6165
+ }
6166
+ }
6167
+ else {
6168
+ connection = connections.get(connKey);
6169
+ }
5951
6170
  if (connection && connection.ws) {
6171
+ // We may reuse this connection; cancel any pending idle-close so it is
6172
+ // not torn down during the auth preflight below. The held acquisition
6173
+ // lease also guards this window, but cancelling here avoids a needless
6174
+ // grace-timer round-trip on the common reuse path.
6175
+ cancelIdleConnectionClose(connection);
5952
6176
  if (connection.authFailure) {
5953
6177
  throw connection.authFailure;
5954
6178
  }
@@ -5964,19 +6188,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5964
6188
  if (connection.isConnected &&
5965
6189
  !connection.isAuthenticating &&
5966
6190
  !connectionMatchesAuthToken(connection, currentAuthToken)) {
5967
- if (connection.tokenRefreshTimer) {
5968
- clearInterval(connection.tokenRefreshTimer);
5969
- connection.tokenRefreshTimer = null;
5970
- }
5971
- try {
5972
- connection.ws.close();
5973
- }
5974
- catch (_a) {
5975
- // Already closing.
5976
- }
5977
- connection.ws = null;
5978
- connection.isConnected = false;
5979
- connections.delete(connection.key);
6191
+ closeConnectionNow(connection);
5980
6192
  }
5981
6193
  else {
5982
6194
  connection.authFailure = null;
@@ -5997,13 +6209,16 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
5997
6209
  isAuthenticating: false,
5998
6210
  appId,
5999
6211
  key: connKey,
6212
+ acquisitionKey: leaseKey,
6000
6213
  routePath: roomKey ? routePath : undefined,
6001
6214
  authTokenProvider,
6215
+ connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
6002
6216
  pendingAuthToken: null,
6003
6217
  pendingAuthSnapshot: null,
6004
- authTokenFingerprint: null,
6218
+ authTokenValue: null,
6005
6219
  authPrincipal: null,
6006
6220
  tokenRefreshTimer: null,
6221
+ idleCloseTimer: null,
6007
6222
  consecutiveAuthFailures: 0,
6008
6223
  authFailure: null,
6009
6224
  };
@@ -6080,7 +6295,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
6080
6295
  }
6081
6296
  else {
6082
6297
  connection.isAuthenticating = false;
6083
- connection.authTokenFingerprint = null;
6298
+ connection.authTokenValue = null;
6084
6299
  connection.authPrincipal = null;
6085
6300
  replaySubscriptions(connection);
6086
6301
  }
@@ -6098,10 +6313,14 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
6098
6313
  // Handle errors
6099
6314
  ws.addEventListener('error', (event) => {
6100
6315
  console.error('[WS v2] WebSocket error:', event);
6316
+ const wsError = new Error('WebSocket error');
6101
6317
  for (const [, pending] of connection.pendingSubscriptions) {
6102
- pending.reject(new Error('WebSocket error'));
6318
+ pending.reject(wsError);
6103
6319
  }
6104
6320
  connection.pendingSubscriptions.clear();
6321
+ for (const subscription of connection.subscriptions.values()) {
6322
+ rejectSubscriptionRefresh(subscription, wsError);
6323
+ }
6105
6324
  });
6106
6325
  // Handle close
6107
6326
  ws.addEventListener('close', () => {
@@ -6125,7 +6344,7 @@ function handleServerMessage(connection, message) {
6125
6344
  switch (message.type) {
6126
6345
  case 'authenticated': {
6127
6346
  connection.isAuthenticating = false;
6128
- connection.authTokenFingerprint = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenFingerprint) !== null && _b !== void 0 ? _b : null;
6347
+ connection.authTokenValue = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenValue) !== null && _b !== void 0 ? _b : null;
6129
6348
  connection.authPrincipal = (_d = (_c = connection.pendingAuthSnapshot) === null || _c === void 0 ? void 0 : _c.principal) !== null && _d !== void 0 ? _d : null;
6130
6349
  replaySubscriptions(connection);
6131
6350
  break;
@@ -6133,15 +6352,49 @@ function handleServerMessage(connection, message) {
6133
6352
  case 'subscribed': {
6134
6353
  const subscription = connection.subscriptions.get(message.subscriptionId);
6135
6354
  if (subscription) {
6355
+ const refresh = subscription.refresh;
6136
6356
  // A data update can race ahead of subscribed during reconnect.
6137
6357
  // If we already received data for this subscription, treat subscribed
6138
6358
  // as an ack only and avoid regressing to an older snapshot.
6139
- if (subscription.lastData === undefined) {
6359
+ if (refresh) {
6360
+ const hadBaseline = subscription.lastData !== undefined;
6361
+ if (subscription.dataRevision === refresh.dataRevisionAtStart) {
6362
+ if (subscription.cache) {
6363
+ const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6364
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6365
+ }
6366
+ subscription.lastData = message.data;
6367
+ subscription.dataRevision += 1;
6368
+ if (hadBaseline) {
6369
+ const callbacks = Array.from(refresh.callbacks)
6370
+ .filter((callback) => subscription.callbacks.includes(callback));
6371
+ notifyCallbackOptions(callbacks, message.data);
6372
+ }
6373
+ else {
6374
+ // The refresh joined an original registration or a
6375
+ // reconnect replay before its first baseline arrived.
6376
+ // Every active callback needs that initial snapshot.
6377
+ notifyCallbacks(subscription, message.data);
6378
+ }
6379
+ }
6380
+ else if (refresh.callbacks.size > 0 && subscription.lastData !== undefined) {
6381
+ // A newer authorized data frame arrived after this refresh
6382
+ // began. Do not regress lastData to the older subscribed
6383
+ // snapshot; use that newer frame for callbacks that joined
6384
+ // after it was broadcast.
6385
+ const callbacks = Array.from(refresh.callbacks)
6386
+ .filter((callback) => subscription.callbacks.includes(callback));
6387
+ notifyCallbackOptions(callbacks, subscription.lastData);
6388
+ }
6389
+ resolveSubscriptionRefresh(subscription);
6390
+ }
6391
+ else if (subscription.lastData === undefined) {
6140
6392
  if (subscription.cache) {
6141
6393
  const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6142
- responseCache.set(cacheKey, { data: message.data, timestamp: Date.now() });
6394
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6143
6395
  }
6144
6396
  subscription.lastData = message.data;
6397
+ subscription.dataRevision += 1;
6145
6398
  notifyCallbacks(subscription, message.data);
6146
6399
  }
6147
6400
  }
@@ -6168,12 +6421,20 @@ function handleServerMessage(connection, message) {
6168
6421
  // Update cache
6169
6422
  if (subscription.cache) {
6170
6423
  const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
6171
- responseCache.set(cacheKey, { data: message.data, timestamp: Date.now() });
6424
+ setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
6172
6425
  }
6173
6426
  // Store last data
6174
6427
  subscription.lastData = message.data;
6428
+ subscription.dataRevision += 1;
6175
6429
  // Notify callbacks
6176
6430
  notifyCallbacks(subscription, message.data);
6431
+ // A data frame is itself a full, freshly authorized snapshot.
6432
+ // It satisfies every joined callback even if the duplicate
6433
+ // subscribed ACK was lost; resolving here prevents a false
6434
+ // refresh timeout ten seconds after the UI already rendered.
6435
+ if (subscription.refresh) {
6436
+ resolveSubscriptionRefresh(subscription);
6437
+ }
6177
6438
  }
6178
6439
  break;
6179
6440
  }
@@ -6260,6 +6521,7 @@ function handleServerMessage(connection, message) {
6260
6521
  const subscription = connection.subscriptions.get(message.subscriptionId);
6261
6522
  if (subscription) {
6262
6523
  notifyErrorCallbacks(subscription, wsError);
6524
+ rejectSubscriptionRefresh(subscription, wsError);
6263
6525
  }
6264
6526
  }
6265
6527
  break;
@@ -6296,13 +6558,10 @@ function addIdsToSubscriptionData(data) {
6296
6558
  return data.map(withSubscriptionId);
6297
6559
  return withSubscriptionId(data);
6298
6560
  }
6299
- function notifyCallbacks(subscription, data) {
6561
+ function notifyCallbackOptions(callbacks, data) {
6300
6562
  var _a;
6301
6563
  // Attach the bare `id` (leaf doc key) to every delivered row (Bug 1).
6302
6564
  const decorated = addIdsToSubscriptionData(data);
6303
- // Snapshot the callbacks array so that unsubscriptions during
6304
- // notification don't cause callbacks to be skipped.
6305
- const callbacks = subscription.callbacks.slice();
6306
6565
  for (const callback of callbacks) {
6307
6566
  try {
6308
6567
  (_a = callback.onData) === null || _a === void 0 ? void 0 : _a.call(callback, decorated);
@@ -6312,23 +6571,33 @@ function notifyCallbacks(subscription, data) {
6312
6571
  }
6313
6572
  }
6314
6573
  }
6574
+ function notifyCallbacks(subscription, data) {
6575
+ // Snapshot the callbacks array so that unsubscriptions during
6576
+ // notification don't cause callbacks to be skipped.
6577
+ notifyCallbackOptions(subscription.callbacks.slice(), data);
6578
+ }
6579
+ function notifyErrorCallback(callback, error) {
6580
+ let delivered = false;
6581
+ try {
6582
+ const onError = callback.onError;
6583
+ if (!onError)
6584
+ return false;
6585
+ onError(error);
6586
+ delivered = true;
6587
+ }
6588
+ catch (callbackError) {
6589
+ console.error('[WS v2] Error in subscription error callback:', callbackError);
6590
+ }
6591
+ return delivered;
6592
+ }
6315
6593
  function notifyErrorCallbacks(subscription, error) {
6316
6594
  let delivered = false;
6317
6595
  const callbacks = subscription.callbacks.slice();
6318
6596
  for (const callback of callbacks) {
6319
- if (!callback.onError)
6320
- continue;
6321
- delivered = true;
6322
- try {
6323
- callback.onError(error);
6324
- }
6325
- catch (callbackError) {
6326
- console.error('[WS v2] Error in subscription error callback:', callbackError);
6327
- }
6597
+ delivered = notifyErrorCallback(callback, error) || delivered;
6328
6598
  }
6329
- if (delivered) {
6599
+ if (delivered)
6330
6600
  error.__boundedDeliveredToOnError = true;
6331
- }
6332
6601
  return delivered;
6333
6602
  }
6334
6603
  // WebSocket readyState constants
@@ -6359,6 +6628,71 @@ function sendSubscribe(connection, subscription) {
6359
6628
  console.error('[WS v2] Error sending subscribe message:', error);
6360
6629
  }
6361
6630
  }
6631
+ function resolveSubscriptionRefresh(subscription) {
6632
+ const refresh = subscription.refresh;
6633
+ if (!refresh)
6634
+ return;
6635
+ subscription.refresh = null;
6636
+ if (refresh.timer)
6637
+ clearTimeout(refresh.timer);
6638
+ refresh.callbacks.clear();
6639
+ refresh.resolve();
6640
+ }
6641
+ function rejectSubscriptionRefresh(subscription, error) {
6642
+ const refresh = subscription.refresh;
6643
+ if (!refresh)
6644
+ return;
6645
+ subscription.refresh = null;
6646
+ if (refresh.timer)
6647
+ clearTimeout(refresh.timer);
6648
+ refresh.callbacks.clear();
6649
+ refresh.reject(error);
6650
+ }
6651
+ /**
6652
+ * A callback joining an already-registered non-cache subscription cannot be
6653
+ * seeded from `lastData`: that snapshot may predate the callback's auth
6654
+ * preflight. Re-send the same subscription id so the server re-runs read auth
6655
+ * and returns a fresh baseline, without allocating another websocket or
6656
+ * replacing the logical client subscription.
6657
+ */
6658
+ function requestFreshSubscriptionSnapshot(connection, subscription, callback) {
6659
+ let refresh = subscription.refresh;
6660
+ if (!refresh) {
6661
+ let resolve;
6662
+ let reject;
6663
+ const promise = new Promise((resolvePromise, rejectPromise) => {
6664
+ resolve = resolvePromise;
6665
+ reject = rejectPromise;
6666
+ });
6667
+ refresh = {
6668
+ promise,
6669
+ resolve,
6670
+ reject,
6671
+ timer: null,
6672
+ callbacks: new Set(),
6673
+ dataRevisionAtStart: subscription.dataRevision,
6674
+ };
6675
+ subscription.refresh = refresh;
6676
+ refresh.timer = setTimeout(() => {
6677
+ if (subscription.refresh !== refresh)
6678
+ return;
6679
+ rejectSubscriptionRefresh(subscription, new Error('Subscription timeout'));
6680
+ }, 10000);
6681
+ }
6682
+ // Register before sendSubscribe: test/browser transports may synchronously
6683
+ // dispatch the subscribed response from send().
6684
+ refresh.callbacks.add(callback);
6685
+ // Every newly joined callback gets an idempotent same-id resend when the
6686
+ // authenticated socket is ready. Do not suppress this because an initial
6687
+ // confirmation or an earlier refresh is pending: that pending frame may
6688
+ // have been lost or displaced, and sharing it is exactly how an entire
6689
+ // route remount can inherit one doomed timeout. When connecting or
6690
+ // authenticating, replaySubscriptions will send after auth completes.
6691
+ if (connection.isConnected && !connection.isAuthenticating) {
6692
+ sendSubscribe(connection, subscription);
6693
+ }
6694
+ return refresh.promise;
6695
+ }
6362
6696
  function sendUnsubscribe(connection, subscriptionId) {
6363
6697
  if (!connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN) {
6364
6698
  return;
@@ -6407,16 +6741,27 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6407
6741
  : undefined;
6408
6742
  const identityInfo = await getSubscriptionIdentity(effectiveAppId, config.isServer, overrides);
6409
6743
  const identity = identityInfo.key;
6744
+ const identityToken = identityInfo.token;
6410
6745
  const responseCacheEnabled = subscriptionOptions.cache === true && identityInfo.cacheable;
6746
+ // Preserve the existing compact connection-key shape, but use the exact
6747
+ // token as a secondary in-memory equality check within that hash bucket.
6411
6748
  const principalKey = authTokenProvider ? identity : undefined;
6749
+ const principalToken = authTokenProvider
6750
+ ? (identityToken !== null && identityToken !== void 0 ? identityToken : identity)
6751
+ : undefined;
6412
6752
  const cacheKey = responseCacheEnabled
6413
6753
  ? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
6414
6754
  : null;
6415
- // Get or create connection for this routing target (room-scoped when a
6416
- // room route is supplied by the live helper, else the app-level connection).
6755
+ // Acquire a connection for this routing target (room-scoped when a room route
6756
+ // is supplied by the live helper, else the app-level connection). The lease is
6757
+ // held across auth preflight and released once this call registers below, so a
6758
+ // concurrent old-route teardown cannot close a socket we are about to reuse.
6417
6759
  let connection;
6760
+ let releaseConnection;
6418
6761
  try {
6419
- connection = await getOrCreateConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey);
6762
+ const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken);
6763
+ connection = acquisition.connection;
6764
+ releaseConnection = acquisition.release;
6420
6765
  }
6421
6766
  catch (error) {
6422
6767
  const err = error instanceof Error ? error : new Error(String(error));
@@ -6426,73 +6771,130 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6426
6771
  }
6427
6772
  throw err;
6428
6773
  }
6429
- // Deliver cached data immediately if available, but only after connection
6430
- // auth preflight has succeeded. An expired authenticated session must receive
6431
- // an auth error, not a stale private snapshot followed by a failed connect.
6432
- const cachedEntry = cacheKey ? responseCache.get(cacheKey) : undefined;
6433
- if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
6434
- setTimeout(() => {
6435
- var _a;
6436
- (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
6437
- }, 0);
6438
- }
6774
+ const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
6439
6775
  // Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
6440
- const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
6441
- const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
6442
- const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
6443
6776
  let existingSubscription;
6444
- for (const sub of connection.subscriptions.values()) {
6445
- const subShapeKey = sub.shape ? JSON.stringify(sub.shape) : '';
6446
- const subFilterKey = sub.filter ? JSON.stringify(sub.filter) : '';
6447
- const subSortKey = sub.sort ? JSON.stringify(sub.sort) : '';
6448
- // H1: also require the same principal identity — a different logged-in
6449
- // user must not piggy-back on (and inherit lastData from) another user's
6450
- // existing subscription, even with identical path/prompt/shape/filter/sort.
6451
- 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) {
6452
- existingSubscription = sub;
6453
- break;
6777
+ try {
6778
+ const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
6779
+ const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
6780
+ const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
6781
+ for (const sub of connection.subscriptions.values()) {
6782
+ const subShapeKey = sub.shape ? JSON.stringify(sub.shape) : '';
6783
+ const subFilterKey = sub.filter ? JSON.stringify(sub.filter) : '';
6784
+ const subSortKey = sub.sort ? JSON.stringify(sub.sort) : '';
6785
+ // H1: also require the same principal identity (compact hash AND exact
6786
+ // token) — a different logged-in user must not piggy-back on (and
6787
+ // inherit lastData from) another user's existing subscription, even
6788
+ // with identical path/prompt/shape/filter/sort.
6789
+ 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) {
6790
+ existingSubscription = sub;
6791
+ break;
6792
+ }
6454
6793
  }
6455
6794
  }
6795
+ catch (error) {
6796
+ releaseConnection();
6797
+ throw error;
6798
+ }
6456
6799
  if (existingSubscription) {
6800
+ let replayData = undefined;
6801
+ let replayOnData;
6802
+ if (responseCacheEnabled) {
6803
+ // Read callback getters before mutating the shared subscription. If
6804
+ // a caller supplied a throwing getter, the acquisition fail-safe can
6805
+ // release cleanly without leaving a ghost callback behind.
6806
+ replayOnData = subscriptionOptions.onData;
6807
+ replayData = existingSubscription.lastData !== undefined
6808
+ ? existingSubscription.lastData
6809
+ : cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL
6810
+ ? cachedEntry.data
6811
+ : undefined;
6812
+ }
6457
6813
  if (responseCacheEnabled) {
6458
6814
  existingSubscription.cache = true;
6459
6815
  }
6460
6816
  // Add callback to existing subscription
6461
6817
  existingSubscription.callbacks.push(subscriptionOptions);
6462
- // Deliver last known data immediately only for explicit, principal-bound
6463
- // cache opt-in. Otherwise joining an existing subscription can replay a
6464
- // stale private snapshot without a fresh server auth result.
6465
- if (responseCacheEnabled &&
6466
- existingSubscription.cache &&
6467
- existingSubscription.lastData !== undefined &&
6468
- subscriptionOptions.onData) {
6469
- setTimeout(() => {
6470
- var _a;
6471
- (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(existingSubscription.lastData));
6472
- }, 0);
6818
+ let refreshPromise = null;
6819
+ if (responseCacheEnabled) {
6820
+ // Explicit, principal-bound cache opt-in may replay exactly one
6821
+ // snapshot. Prefer the active subscription's lastData, falling back
6822
+ // to the short-lived response cache while a reconnect is in flight.
6823
+ if (replayData !== undefined && replayOnData) {
6824
+ setTimeout(() => {
6825
+ replayOnData === null || replayOnData === void 0 ? void 0 : replayOnData(addIdsToSubscriptionData(replayData));
6826
+ }, 0);
6827
+ }
6828
+ }
6829
+ else {
6830
+ // Default (non-cache) subscriptions require a fresh authorized
6831
+ // baseline for every newly joined callback. Reuse the same logical
6832
+ // subscription id and socket, but make the server re-run the read.
6833
+ refreshPromise = requestFreshSubscriptionSnapshot(connection, existingSubscription, subscriptionOptions);
6834
+ }
6835
+ releaseConnection();
6836
+ if (refreshPromise) {
6837
+ try {
6838
+ await refreshPromise;
6839
+ }
6840
+ catch (error) {
6841
+ const err = error instanceof Error ? error : new Error(String(error));
6842
+ let deliveredLocally = false;
6843
+ if (!err.__boundedDeliveredToOnError) {
6844
+ deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
6845
+ }
6846
+ if (!subscriptionOptions.onError) {
6847
+ await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
6848
+ throw err;
6849
+ }
6850
+ if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
6851
+ console.warn('[WS v2] Subscription refresh failed, keeping for reconnect recovery:', error);
6852
+ }
6853
+ }
6473
6854
  }
6474
6855
  return async () => {
6475
6856
  await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
6476
6857
  };
6477
6858
  }
6859
+ // Deliver cached data immediately for a new logical subscription, but only
6860
+ // after connection auth preflight has succeeded. An expired authenticated
6861
+ // session must receive an auth error, not a stale private snapshot followed
6862
+ // by a failed connect.
6863
+ if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
6864
+ setTimeout(() => {
6865
+ var _a;
6866
+ (_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
6867
+ }, 0);
6868
+ }
6478
6869
  // Create new subscription
6479
6870
  const subscriptionId = generateSubscriptionId();
6480
- const subscription = {
6481
- subscriptionId,
6482
- path: normalizedPath,
6483
- filter: subscriptionOptions.filter,
6484
- sort: subscriptionOptions.sort,
6485
- prompt: subscriptionOptions.prompt,
6486
- shape: subscriptionOptions.shape,
6487
- limit: subscriptionOptions.limit,
6488
- cursor: subscriptionOptions.cursor,
6489
- includeSubPaths: (_b = subscriptionOptions.includeSubPaths) !== null && _b !== void 0 ? _b : false,
6490
- callbacks: [subscriptionOptions],
6491
- lastData: undefined,
6492
- cache: responseCacheEnabled,
6493
- identity,
6494
- };
6495
- connection.subscriptions.set(subscriptionId, subscription);
6871
+ let subscription;
6872
+ try {
6873
+ subscription = {
6874
+ subscriptionId,
6875
+ path: normalizedPath,
6876
+ filter: subscriptionOptions.filter,
6877
+ sort: subscriptionOptions.sort,
6878
+ prompt: subscriptionOptions.prompt,
6879
+ shape: subscriptionOptions.shape,
6880
+ limit: subscriptionOptions.limit,
6881
+ cursor: subscriptionOptions.cursor,
6882
+ includeSubPaths: (_b = subscriptionOptions.includeSubPaths) !== null && _b !== void 0 ? _b : false,
6883
+ callbacks: [subscriptionOptions],
6884
+ lastData: undefined,
6885
+ dataRevision: 0,
6886
+ refresh: null,
6887
+ cache: responseCacheEnabled,
6888
+ identity,
6889
+ identityToken,
6890
+ };
6891
+ connection.subscriptions.set(subscriptionId, subscription);
6892
+ }
6893
+ catch (error) {
6894
+ releaseConnection();
6895
+ throw error;
6896
+ }
6897
+ releaseConnection();
6496
6898
  // Send subscribe message if connected
6497
6899
  if (connection.isConnected && !connection.isAuthenticating) {
6498
6900
  // Create a promise to wait for subscription confirmation
@@ -6512,15 +6914,23 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6512
6914
  }
6513
6915
  catch (error) {
6514
6916
  const err = error instanceof Error ? error : new Error(String(error));
6917
+ let deliveredLocally = false;
6515
6918
  if (!err.__boundedDeliveredToOnError) {
6516
- notifyErrorCallbacks(subscription, err);
6919
+ // A local confirmation timeout belongs to the caller that
6920
+ // originated it. Joined callbacks may already have a viable
6921
+ // same-id refresh and must not be poisoned by that old attempt.
6922
+ deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
6517
6923
  }
6518
6924
  if (!subscriptionOptions.onError) {
6519
6925
  connection.pendingSubscriptions.delete(subscriptionId);
6520
- connection.subscriptions.delete(subscriptionId);
6926
+ // This registration may have acquired joined callbacks while
6927
+ // its confirmation was pending. Remove only the originating
6928
+ // callback; deleting the whole logical subscription would
6929
+ // orphan those remounted consumers and their refresh flight.
6930
+ await removeCallbackFromSubscription(connection, subscriptionId, subscriptionOptions);
6521
6931
  throw err;
6522
6932
  }
6523
- if (!err.__boundedDeliveredToOnError) {
6933
+ if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
6524
6934
  console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
6525
6935
  }
6526
6936
  }
@@ -6531,12 +6941,14 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
6531
6941
  };
6532
6942
  }
6533
6943
  async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
6944
+ var _a;
6534
6945
  const subscription = connection.subscriptions.get(subscriptionId);
6535
6946
  if (!subscription) {
6536
6947
  return;
6537
6948
  }
6538
6949
  // Remove this callback
6539
6950
  subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
6951
+ (_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
6540
6952
  // If there are still callbacks, don't unsubscribe
6541
6953
  if (subscription.callbacks.length > 0) {
6542
6954
  return;
@@ -6558,17 +6970,9 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
6558
6970
  sendUnsubscribe(connection, subscriptionId);
6559
6971
  await unsubscribePromise;
6560
6972
  }
6561
- // If no more subscriptions, close connection
6562
- if (connection.subscriptions.size === 0 && connection.ws) {
6563
- // Clear token refresh timer
6564
- if (connection.tokenRefreshTimer) {
6565
- clearInterval(connection.tokenRefreshTimer);
6566
- connection.tokenRefreshTimer = null;
6567
- }
6568
- connection.ws.close();
6569
- connection.ws = null;
6570
- connections.delete(connection.key);
6571
- }
6973
+ // If no more subscriptions, let the connection close after a short idle
6974
+ // grace so an immediately-following route remount can reclaim the socket.
6975
+ scheduleConnectionCloseIfIdle(connection);
6572
6976
  }
6573
6977
  /**
6574
6978
  * Close all v2 subscriptions.
@@ -6576,6 +6980,11 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
6576
6980
  async function closeAllSubscriptionsV2() {
6577
6981
  const closePromises = [];
6578
6982
  for (const [appId, connection] of connections) {
6983
+ cancelIdleConnectionClose(connection);
6984
+ const closeError = new Error('Subscriptions closed');
6985
+ for (const subscription of connection.subscriptions.values()) {
6986
+ rejectSubscriptionRefresh(subscription, closeError);
6987
+ }
6579
6988
  if (connection.tokenRefreshTimer) {
6580
6989
  clearInterval(connection.tokenRefreshTimer);
6581
6990
  connection.tokenRefreshTimer = null;
@@ -6584,15 +6993,18 @@ async function closeAllSubscriptionsV2() {
6584
6993
  const ws = connection.ws;
6585
6994
  connection.ws = null;
6586
6995
  connection.isConnected = false;
6587
- closePromises.push(new Promise((resolve) => {
6588
- ws.addEventListener('close', () => {
6589
- resolve();
6590
- });
6591
- ws.close();
6592
- }));
6996
+ if (ws.readyState !== WS_READY_STATE_CLOSED) {
6997
+ closePromises.push(new Promise((resolve) => {
6998
+ ws.addEventListener('close', () => {
6999
+ resolve();
7000
+ });
7001
+ ws.close();
7002
+ }));
7003
+ }
6593
7004
  }
6594
7005
  connections.delete(appId);
6595
7006
  }
7007
+ connectionAcquisitions.clear();
6596
7008
  await Promise.all(closePromises);
6597
7009
  }
6598
7010
  /**
@@ -6625,10 +7037,12 @@ function getCachedDataV2(path, prompt) {
6625
7037
  // opt-in. Resolve the caller's identity from an active cache-enabled
6626
7038
  // subscription for this path. No matching sub → null (safe).
6627
7039
  let identity;
7040
+ let identityToken;
6628
7041
  outer: for (const connection of connections.values()) {
6629
7042
  for (const sub of connection.subscriptions.values()) {
6630
7043
  if (sub.cache && sub.path === normalizedPath && sub.prompt === prompt) {
6631
7044
  identity = sub.identity;
7045
+ identityToken = sub.identityToken;
6632
7046
  break outer;
6633
7047
  }
6634
7048
  }
@@ -6637,7 +7051,7 @@ function getCachedDataV2(path, prompt) {
6637
7051
  return null;
6638
7052
  }
6639
7053
  const cacheKey = getCacheKey(path, prompt, undefined, undefined, undefined, undefined, identity);
6640
- const cachedEntry = responseCache.get(cacheKey);
7054
+ const cachedEntry = getResponseCacheEntry(cacheKey, identityToken !== null && identityToken !== void 0 ? identityToken : null);
6641
7055
  if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL) {
6642
7056
  return cachedEntry.data;
6643
7057
  }
@@ -6699,10 +7113,15 @@ async function doReconnectWithNewAuth() {
6699
7113
  if (!connection.ws) {
6700
7114
  continue;
6701
7115
  }
7116
+ cancelIdleConnectionClose(connection);
7117
+ const reconnectError = new Error('Connection reconnecting due to auth change');
6702
7118
  for (const [, pending] of connection.pendingSubscriptions) {
6703
- pending.reject(new Error('Connection reconnecting due to auth change'));
7119
+ pending.reject(reconnectError);
6704
7120
  }
6705
7121
  connection.pendingSubscriptions.clear();
7122
+ for (const subscription of connection.subscriptions.values()) {
7123
+ rejectSubscriptionRefresh(subscription, reconnectError);
7124
+ }
6706
7125
  for (const [, pending] of connection.pendingUnsubscriptions) {
6707
7126
  pending.resolve();
6708
7127
  }
@@ -6762,10 +7181,10 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
6762
7181
  const currentToken = await getIdToken(isServer).catch(() => null);
6763
7182
  if (!currentToken) {
6764
7183
  // Mid-refresh: the socket principal still governs server-side.
6765
- if (!connection.authTokenFingerprint && !connection.authPrincipal)
7184
+ if (!connection.authTokenValue && !connection.authPrincipal)
6766
7185
  return false;
6767
7186
  }
6768
- else if (!connection.authTokenFingerprint && !connection.authPrincipal) {
7187
+ else if (!connection.authTokenValue && !connection.authPrincipal) {
6769
7188
  // Anonymous socket + authenticated user: do not attribute this user's
6770
7189
  // input to the anon principal. Reauth the socket; caller may fall back.
6771
7190
  void maybeReauthRoomConnection(connection);
@@ -6785,7 +7204,7 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
6785
7204
  }
6786
7205
  }
6787
7206
  // Debounced background reauth: when the client's current token no longer
6788
- // matches the connection's auth fingerprint (token rotation), refresh the
7207
+ // matches the connection's authenticated identity (token rotation), refresh the
6789
7208
  // socket auth ONCE rather than degrading every subsequent send.
6790
7209
  const reauthInFlight = new WeakSet();
6791
7210
  async function maybeReauthRoomConnection(connection, isServer) {