@bounded-sh/core 0.0.40 → 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/client/operations.d.ts +2 -0
- package/dist/index.js +614 -173
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +614 -173
- package/dist/index.mjs.map +1 -1
- package/package.json +65 -66
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
|
-
}, {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4626
|
-
|
|
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
|
-
//
|
|
4632
|
-
//
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4684
|
-
|
|
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
|
|
4688
|
-
pendingRequests[requestKey] =
|
|
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
|
-
|
|
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
|
-
|
|
4965
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -5032,10 +5066,27 @@ async function getMany(paths, opts = {}) {
|
|
|
5032
5066
|
return results;
|
|
5033
5067
|
}
|
|
5034
5068
|
async function runQuery(absolutePath, queryName, queryArgs, opts) {
|
|
5035
|
-
|
|
5036
|
-
|
|
5069
|
+
var _a;
|
|
5070
|
+
const rows = await runQueryManyRows([{ absolutePath, queryName, queryArgs }], opts);
|
|
5071
|
+
const row = rows[0];
|
|
5072
|
+
// The worker disambiguates a computed null from a failed/undeclared query
|
|
5073
|
+
// with a per-row `error` string — surface it instead of returning undefined.
|
|
5074
|
+
if ((row === null || row === void 0 ? void 0 : row.error) != null) {
|
|
5075
|
+
const err = new Error(String(row.error));
|
|
5076
|
+
err.isQueryError = true;
|
|
5077
|
+
err.queryName = queryName;
|
|
5078
|
+
err.path = absolutePath;
|
|
5079
|
+
throw err;
|
|
5080
|
+
}
|
|
5081
|
+
return (_a = row === null || row === void 0 ? void 0 : row.result) !== null && _a !== void 0 ? _a : null;
|
|
5037
5082
|
}
|
|
5038
5083
|
async function runQueryMany(many, opts) {
|
|
5084
|
+
// Batch shape stays an array of result values (errored rows map to null) so
|
|
5085
|
+
// existing callers keep working; use runQuery for per-row error surfacing.
|
|
5086
|
+
const rows = await runQueryManyRows(many, opts);
|
|
5087
|
+
return rows.map((row) => { var _a; return (_a = row === null || row === void 0 ? void 0 : row.result) !== null && _a !== void 0 ? _a : null; });
|
|
5088
|
+
}
|
|
5089
|
+
async function runQueryManyRows(many, opts) {
|
|
5039
5090
|
var _a, _b, _c, _d;
|
|
5040
5091
|
try {
|
|
5041
5092
|
let queries = [];
|
|
@@ -5057,7 +5108,7 @@ async function runQueryMany(many, opts) {
|
|
|
5057
5108
|
if (!Array.isArray(rows)) {
|
|
5058
5109
|
throw new Error(`queries endpoint returned an unexpected response shape (status ${response.status})`);
|
|
5059
5110
|
}
|
|
5060
|
-
return rows
|
|
5111
|
+
return rows;
|
|
5061
5112
|
}
|
|
5062
5113
|
catch (error) {
|
|
5063
5114
|
throw error;
|
|
@@ -5095,6 +5146,11 @@ async function runExpressionMany(many) {
|
|
|
5095
5146
|
if (queryResult.trace) {
|
|
5096
5147
|
result.trace = queryResult.trace;
|
|
5097
5148
|
}
|
|
5149
|
+
// Pass through the server's per-row failure so callers can distinguish
|
|
5150
|
+
// a computed null from a failed row.
|
|
5151
|
+
if (queryResult.error != null) {
|
|
5152
|
+
result.error = String(queryResult.error);
|
|
5153
|
+
}
|
|
5098
5154
|
return result;
|
|
5099
5155
|
});
|
|
5100
5156
|
}
|
|
@@ -5547,10 +5603,37 @@ var operations = /*#__PURE__*/Object.freeze({
|
|
|
5547
5603
|
// ============ Global State ============
|
|
5548
5604
|
// One connection per routing target (appId, or appId#room#<collection>/<roomId>)
|
|
5549
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.
|
|
5550
5621
|
const responseCache = new Map();
|
|
5551
5622
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
5552
5623
|
const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // Refresh token 10 minutes before expiry
|
|
5553
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
|
+
}
|
|
5554
5637
|
// ============ WebSocket Config ============
|
|
5555
5638
|
const BASE_MIN_RECONNECT_DELAY_MS = 1000;
|
|
5556
5639
|
const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
|
|
@@ -5558,6 +5641,10 @@ const MAX_RECONNECT_DELAY_MS = 300000;
|
|
|
5558
5641
|
const RECONNECT_DELAY_GROW_FACTOR = 1.8;
|
|
5559
5642
|
const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
|
|
5560
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;
|
|
5561
5648
|
const WS_CONFIG = {
|
|
5562
5649
|
// Keep retrying indefinitely so long outages recover without page refresh.
|
|
5563
5650
|
maxRetries: Infinity,
|
|
@@ -5588,12 +5675,12 @@ function hashForKey(value) {
|
|
|
5588
5675
|
}
|
|
5589
5676
|
/**
|
|
5590
5677
|
* SECURITY (H1): Response-cache keys must be scoped to the caller's identity, not
|
|
5591
|
-
* just the path/filter/shape. The `identity` prefix
|
|
5592
|
-
*
|
|
5593
|
-
*
|
|
5594
|
-
* / browser login-switch; this cache has a
|
|
5595
|
-
* before entries are read or written; no-auth
|
|
5596
|
-
* 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.
|
|
5597
5684
|
*/
|
|
5598
5685
|
function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
|
|
5599
5686
|
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
@@ -5631,16 +5718,16 @@ function livePrincipalFromIdToken(idToken) {
|
|
|
5631
5718
|
}
|
|
5632
5719
|
function authSnapshotFromToken(idToken) {
|
|
5633
5720
|
return {
|
|
5634
|
-
|
|
5721
|
+
tokenValue: idToken !== null && idToken !== void 0 ? idToken : null,
|
|
5635
5722
|
principal: livePrincipalFromIdToken(idToken),
|
|
5636
5723
|
};
|
|
5637
5724
|
}
|
|
5638
5725
|
function connectionMatchesAuthToken(connection, idToken) {
|
|
5639
5726
|
const current = authSnapshotFromToken(idToken);
|
|
5640
|
-
if (!current.
|
|
5641
|
-
return connection.
|
|
5727
|
+
if (!current.tokenValue) {
|
|
5728
|
+
return connection.authTokenValue === null && connection.authPrincipal === null;
|
|
5642
5729
|
}
|
|
5643
|
-
if (connection.
|
|
5730
|
+
if (connection.authTokenValue && connection.authTokenValue === current.tokenValue) {
|
|
5644
5731
|
return true;
|
|
5645
5732
|
}
|
|
5646
5733
|
return !!connection.authPrincipal && !!current.principal && connection.authPrincipal === current.principal;
|
|
@@ -5649,7 +5736,7 @@ async function canSendAuthenticatedLiveIntent(connection, isServer) {
|
|
|
5649
5736
|
const currentToken = await getIdToken(isServer);
|
|
5650
5737
|
if (!currentToken)
|
|
5651
5738
|
return false;
|
|
5652
|
-
if (!connection.
|
|
5739
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
5653
5740
|
return false;
|
|
5654
5741
|
return connectionMatchesAuthToken(connection, currentToken);
|
|
5655
5742
|
}
|
|
@@ -5662,19 +5749,19 @@ async function getSubscriptionIdentity(effectiveAppId, isServer, overrides) {
|
|
|
5662
5749
|
const bearer = bearerFromAuthHeaders(await overrides._getAuthHeaders());
|
|
5663
5750
|
const principal = principalFromIdToken(bearer);
|
|
5664
5751
|
return principal
|
|
5665
|
-
? { key: `${effectiveAppId}:o${principal}`, cacheable: true }
|
|
5666
|
-
: { key: `${effectiveAppId}:oanon`, cacheable: false };
|
|
5752
|
+
? { key: `${effectiveAppId}:o${principal}`, cacheable: true, token: bearer }
|
|
5753
|
+
: { key: `${effectiveAppId}:oanon`, cacheable: false, token: null };
|
|
5667
5754
|
}
|
|
5668
5755
|
catch (_a) {
|
|
5669
5756
|
// Couldn't resolve the override identity — do not use response cache.
|
|
5670
|
-
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false };
|
|
5757
|
+
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false, token: null };
|
|
5671
5758
|
}
|
|
5672
5759
|
}
|
|
5673
5760
|
const idToken = await getIdToken(isServer);
|
|
5674
5761
|
const principal = principalFromIdToken(idToken);
|
|
5675
5762
|
return principal
|
|
5676
|
-
? { key: `${effectiveAppId}:${principal}`, cacheable: true }
|
|
5677
|
-
: { 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 };
|
|
5678
5765
|
}
|
|
5679
5766
|
/** Extract the bare bearer token from a `{ Authorization: 'Bearer <jwt>' }` map. */
|
|
5680
5767
|
function bearerFromAuthHeaders(headers) {
|
|
@@ -5730,7 +5817,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5730
5817
|
connection.authFailure = error;
|
|
5731
5818
|
connection.pendingAuthToken = null;
|
|
5732
5819
|
connection.pendingAuthSnapshot = null;
|
|
5733
|
-
connection.
|
|
5820
|
+
connection.authTokenValue = null;
|
|
5734
5821
|
connection.authPrincipal = null;
|
|
5735
5822
|
connection.isConnecting = false;
|
|
5736
5823
|
connection.isConnected = false;
|
|
@@ -5741,6 +5828,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5741
5828
|
connection.pendingSubscriptions.clear();
|
|
5742
5829
|
for (const subscription of connection.subscriptions.values()) {
|
|
5743
5830
|
notifyErrorCallbacks(subscription, error);
|
|
5831
|
+
rejectSubscriptionRefresh(subscription, error);
|
|
5744
5832
|
}
|
|
5745
5833
|
}
|
|
5746
5834
|
function scheduleTokenRefresh(connection, isServer) {
|
|
@@ -5933,7 +6021,136 @@ function replaySubscriptions(connection) {
|
|
|
5933
6021
|
sendSubscribe(connection, sub);
|
|
5934
6022
|
}
|
|
5935
6023
|
}
|
|
5936
|
-
|
|
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) {
|
|
5937
6154
|
attachBrowserReconnectHooksOnce();
|
|
5938
6155
|
// A per-room subscription gets its OWN connection routed to the room DO; all
|
|
5939
6156
|
// other subscriptions share the app-level connection (project DO). The worker
|
|
@@ -5944,9 +6161,38 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5944
6161
|
// keyed by the wallet identity, so its WS authenticates as that wallet and
|
|
5945
6162
|
// never shares a connection (or token) with any top-level server caller.
|
|
5946
6163
|
const base = roomKey ? `${appId}#room#${roomKey}` : appId;
|
|
5947
|
-
const
|
|
5948
|
-
|
|
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
|
+
}
|
|
5949
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);
|
|
5950
6196
|
if (connection.authFailure) {
|
|
5951
6197
|
throw connection.authFailure;
|
|
5952
6198
|
}
|
|
@@ -5962,19 +6208,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5962
6208
|
if (connection.isConnected &&
|
|
5963
6209
|
!connection.isAuthenticating &&
|
|
5964
6210
|
!connectionMatchesAuthToken(connection, currentAuthToken)) {
|
|
5965
|
-
|
|
5966
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
5967
|
-
connection.tokenRefreshTimer = null;
|
|
5968
|
-
}
|
|
5969
|
-
try {
|
|
5970
|
-
connection.ws.close();
|
|
5971
|
-
}
|
|
5972
|
-
catch (_a) {
|
|
5973
|
-
// Already closing.
|
|
5974
|
-
}
|
|
5975
|
-
connection.ws = null;
|
|
5976
|
-
connection.isConnected = false;
|
|
5977
|
-
connections.delete(connection.key);
|
|
6211
|
+
closeConnectionNow(connection);
|
|
5978
6212
|
}
|
|
5979
6213
|
else {
|
|
5980
6214
|
connection.authFailure = null;
|
|
@@ -5995,13 +6229,16 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5995
6229
|
isAuthenticating: false,
|
|
5996
6230
|
appId,
|
|
5997
6231
|
key: connKey,
|
|
6232
|
+
acquisitionKey: leaseKey,
|
|
5998
6233
|
routePath: roomKey ? routePath : undefined,
|
|
5999
6234
|
authTokenProvider,
|
|
6235
|
+
connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
|
|
6000
6236
|
pendingAuthToken: null,
|
|
6001
6237
|
pendingAuthSnapshot: null,
|
|
6002
|
-
|
|
6238
|
+
authTokenValue: null,
|
|
6003
6239
|
authPrincipal: null,
|
|
6004
6240
|
tokenRefreshTimer: null,
|
|
6241
|
+
idleCloseTimer: null,
|
|
6005
6242
|
consecutiveAuthFailures: 0,
|
|
6006
6243
|
authFailure: null,
|
|
6007
6244
|
};
|
|
@@ -6078,7 +6315,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6078
6315
|
}
|
|
6079
6316
|
else {
|
|
6080
6317
|
connection.isAuthenticating = false;
|
|
6081
|
-
connection.
|
|
6318
|
+
connection.authTokenValue = null;
|
|
6082
6319
|
connection.authPrincipal = null;
|
|
6083
6320
|
replaySubscriptions(connection);
|
|
6084
6321
|
}
|
|
@@ -6096,10 +6333,14 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6096
6333
|
// Handle errors
|
|
6097
6334
|
ws.addEventListener('error', (event) => {
|
|
6098
6335
|
console.error('[WS v2] WebSocket error:', event);
|
|
6336
|
+
const wsError = new Error('WebSocket error');
|
|
6099
6337
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6100
|
-
pending.reject(
|
|
6338
|
+
pending.reject(wsError);
|
|
6101
6339
|
}
|
|
6102
6340
|
connection.pendingSubscriptions.clear();
|
|
6341
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
6342
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6343
|
+
}
|
|
6103
6344
|
});
|
|
6104
6345
|
// Handle close
|
|
6105
6346
|
ws.addEventListener('close', () => {
|
|
@@ -6123,7 +6364,7 @@ function handleServerMessage(connection, message) {
|
|
|
6123
6364
|
switch (message.type) {
|
|
6124
6365
|
case 'authenticated': {
|
|
6125
6366
|
connection.isAuthenticating = false;
|
|
6126
|
-
connection.
|
|
6367
|
+
connection.authTokenValue = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenValue) !== null && _b !== void 0 ? _b : null;
|
|
6127
6368
|
connection.authPrincipal = (_d = (_c = connection.pendingAuthSnapshot) === null || _c === void 0 ? void 0 : _c.principal) !== null && _d !== void 0 ? _d : null;
|
|
6128
6369
|
replaySubscriptions(connection);
|
|
6129
6370
|
break;
|
|
@@ -6131,15 +6372,49 @@ function handleServerMessage(connection, message) {
|
|
|
6131
6372
|
case 'subscribed': {
|
|
6132
6373
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6133
6374
|
if (subscription) {
|
|
6375
|
+
const refresh = subscription.refresh;
|
|
6134
6376
|
// A data update can race ahead of subscribed during reconnect.
|
|
6135
6377
|
// If we already received data for this subscription, treat subscribed
|
|
6136
6378
|
// as an ack only and avoid regressing to an older snapshot.
|
|
6137
|
-
if (
|
|
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) {
|
|
6138
6412
|
if (subscription.cache) {
|
|
6139
6413
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6140
|
-
|
|
6414
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6141
6415
|
}
|
|
6142
6416
|
subscription.lastData = message.data;
|
|
6417
|
+
subscription.dataRevision += 1;
|
|
6143
6418
|
notifyCallbacks(subscription, message.data);
|
|
6144
6419
|
}
|
|
6145
6420
|
}
|
|
@@ -6166,12 +6441,20 @@ function handleServerMessage(connection, message) {
|
|
|
6166
6441
|
// Update cache
|
|
6167
6442
|
if (subscription.cache) {
|
|
6168
6443
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6169
|
-
|
|
6444
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6170
6445
|
}
|
|
6171
6446
|
// Store last data
|
|
6172
6447
|
subscription.lastData = message.data;
|
|
6448
|
+
subscription.dataRevision += 1;
|
|
6173
6449
|
// Notify callbacks
|
|
6174
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
|
+
}
|
|
6175
6458
|
}
|
|
6176
6459
|
break;
|
|
6177
6460
|
}
|
|
@@ -6258,6 +6541,7 @@ function handleServerMessage(connection, message) {
|
|
|
6258
6541
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6259
6542
|
if (subscription) {
|
|
6260
6543
|
notifyErrorCallbacks(subscription, wsError);
|
|
6544
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6261
6545
|
}
|
|
6262
6546
|
}
|
|
6263
6547
|
break;
|
|
@@ -6294,13 +6578,10 @@ function addIdsToSubscriptionData(data) {
|
|
|
6294
6578
|
return data.map(withSubscriptionId);
|
|
6295
6579
|
return withSubscriptionId(data);
|
|
6296
6580
|
}
|
|
6297
|
-
function
|
|
6581
|
+
function notifyCallbackOptions(callbacks, data) {
|
|
6298
6582
|
var _a;
|
|
6299
6583
|
// Attach the bare `id` (leaf doc key) to every delivered row (Bug 1).
|
|
6300
6584
|
const decorated = addIdsToSubscriptionData(data);
|
|
6301
|
-
// Snapshot the callbacks array so that unsubscriptions during
|
|
6302
|
-
// notification don't cause callbacks to be skipped.
|
|
6303
|
-
const callbacks = subscription.callbacks.slice();
|
|
6304
6585
|
for (const callback of callbacks) {
|
|
6305
6586
|
try {
|
|
6306
6587
|
(_a = callback.onData) === null || _a === void 0 ? void 0 : _a.call(callback, decorated);
|
|
@@ -6310,23 +6591,33 @@ function notifyCallbacks(subscription, data) {
|
|
|
6310
6591
|
}
|
|
6311
6592
|
}
|
|
6312
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
|
+
}
|
|
6313
6613
|
function notifyErrorCallbacks(subscription, error) {
|
|
6314
6614
|
let delivered = false;
|
|
6315
6615
|
const callbacks = subscription.callbacks.slice();
|
|
6316
6616
|
for (const callback of callbacks) {
|
|
6317
|
-
|
|
6318
|
-
continue;
|
|
6319
|
-
delivered = true;
|
|
6320
|
-
try {
|
|
6321
|
-
callback.onError(error);
|
|
6322
|
-
}
|
|
6323
|
-
catch (callbackError) {
|
|
6324
|
-
console.error('[WS v2] Error in subscription error callback:', callbackError);
|
|
6325
|
-
}
|
|
6617
|
+
delivered = notifyErrorCallback(callback, error) || delivered;
|
|
6326
6618
|
}
|
|
6327
|
-
if (delivered)
|
|
6619
|
+
if (delivered)
|
|
6328
6620
|
error.__boundedDeliveredToOnError = true;
|
|
6329
|
-
}
|
|
6330
6621
|
return delivered;
|
|
6331
6622
|
}
|
|
6332
6623
|
// WebSocket readyState constants
|
|
@@ -6357,6 +6648,71 @@ function sendSubscribe(connection, subscription) {
|
|
|
6357
6648
|
console.error('[WS v2] Error sending subscribe message:', error);
|
|
6358
6649
|
}
|
|
6359
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
|
+
}
|
|
6360
6716
|
function sendUnsubscribe(connection, subscriptionId) {
|
|
6361
6717
|
if (!connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN) {
|
|
6362
6718
|
return;
|
|
@@ -6405,16 +6761,27 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6405
6761
|
: undefined;
|
|
6406
6762
|
const identityInfo = await getSubscriptionIdentity(effectiveAppId, config.isServer, overrides);
|
|
6407
6763
|
const identity = identityInfo.key;
|
|
6764
|
+
const identityToken = identityInfo.token;
|
|
6408
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.
|
|
6409
6768
|
const principalKey = authTokenProvider ? identity : undefined;
|
|
6769
|
+
const principalToken = authTokenProvider
|
|
6770
|
+
? (identityToken !== null && identityToken !== void 0 ? identityToken : identity)
|
|
6771
|
+
: undefined;
|
|
6410
6772
|
const cacheKey = responseCacheEnabled
|
|
6411
6773
|
? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
|
|
6412
6774
|
: null;
|
|
6413
|
-
//
|
|
6414
|
-
//
|
|
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.
|
|
6415
6779
|
let connection;
|
|
6780
|
+
let releaseConnection;
|
|
6416
6781
|
try {
|
|
6417
|
-
|
|
6782
|
+
const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken);
|
|
6783
|
+
connection = acquisition.connection;
|
|
6784
|
+
releaseConnection = acquisition.release;
|
|
6418
6785
|
}
|
|
6419
6786
|
catch (error) {
|
|
6420
6787
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6424,73 +6791,130 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6424
6791
|
}
|
|
6425
6792
|
throw err;
|
|
6426
6793
|
}
|
|
6427
|
-
|
|
6428
|
-
// auth preflight has succeeded. An expired authenticated session must receive
|
|
6429
|
-
// an auth error, not a stale private snapshot followed by a failed connect.
|
|
6430
|
-
const cachedEntry = cacheKey ? responseCache.get(cacheKey) : undefined;
|
|
6431
|
-
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
|
|
6432
|
-
setTimeout(() => {
|
|
6433
|
-
var _a;
|
|
6434
|
-
(_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
|
|
6435
|
-
}, 0);
|
|
6436
|
-
}
|
|
6794
|
+
const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
|
|
6437
6795
|
// Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
|
|
6438
|
-
const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
|
|
6439
|
-
const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
|
|
6440
|
-
const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
|
|
6441
6796
|
let existingSubscription;
|
|
6442
|
-
|
|
6443
|
-
const
|
|
6444
|
-
const
|
|
6445
|
-
const
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
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
|
+
}
|
|
6452
6813
|
}
|
|
6453
6814
|
}
|
|
6815
|
+
catch (error) {
|
|
6816
|
+
releaseConnection();
|
|
6817
|
+
throw error;
|
|
6818
|
+
}
|
|
6454
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
|
+
}
|
|
6455
6833
|
if (responseCacheEnabled) {
|
|
6456
6834
|
existingSubscription.cache = true;
|
|
6457
6835
|
}
|
|
6458
6836
|
// Add callback to existing subscription
|
|
6459
6837
|
existingSubscription.callbacks.push(subscriptionOptions);
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
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
|
+
}
|
|
6471
6874
|
}
|
|
6472
6875
|
return async () => {
|
|
6473
6876
|
await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
|
|
6474
6877
|
};
|
|
6475
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
|
+
}
|
|
6476
6889
|
// Create new subscription
|
|
6477
6890
|
const subscriptionId = generateSubscriptionId();
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
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();
|
|
6494
6918
|
// Send subscribe message if connected
|
|
6495
6919
|
if (connection.isConnected && !connection.isAuthenticating) {
|
|
6496
6920
|
// Create a promise to wait for subscription confirmation
|
|
@@ -6510,15 +6934,23 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6510
6934
|
}
|
|
6511
6935
|
catch (error) {
|
|
6512
6936
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
6937
|
+
let deliveredLocally = false;
|
|
6513
6938
|
if (!err.__boundedDeliveredToOnError) {
|
|
6514
|
-
|
|
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);
|
|
6515
6943
|
}
|
|
6516
6944
|
if (!subscriptionOptions.onError) {
|
|
6517
6945
|
connection.pendingSubscriptions.delete(subscriptionId);
|
|
6518
|
-
|
|
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);
|
|
6519
6951
|
throw err;
|
|
6520
6952
|
}
|
|
6521
|
-
if (!err.__boundedDeliveredToOnError) {
|
|
6953
|
+
if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
|
|
6522
6954
|
console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
|
|
6523
6955
|
}
|
|
6524
6956
|
}
|
|
@@ -6529,12 +6961,14 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6529
6961
|
};
|
|
6530
6962
|
}
|
|
6531
6963
|
async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
|
|
6964
|
+
var _a;
|
|
6532
6965
|
const subscription = connection.subscriptions.get(subscriptionId);
|
|
6533
6966
|
if (!subscription) {
|
|
6534
6967
|
return;
|
|
6535
6968
|
}
|
|
6536
6969
|
// Remove this callback
|
|
6537
6970
|
subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
|
|
6971
|
+
(_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
|
|
6538
6972
|
// If there are still callbacks, don't unsubscribe
|
|
6539
6973
|
if (subscription.callbacks.length > 0) {
|
|
6540
6974
|
return;
|
|
@@ -6556,17 +6990,9 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6556
6990
|
sendUnsubscribe(connection, subscriptionId);
|
|
6557
6991
|
await unsubscribePromise;
|
|
6558
6992
|
}
|
|
6559
|
-
// If no more subscriptions, close
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
if (connection.tokenRefreshTimer) {
|
|
6563
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
6564
|
-
connection.tokenRefreshTimer = null;
|
|
6565
|
-
}
|
|
6566
|
-
connection.ws.close();
|
|
6567
|
-
connection.ws = null;
|
|
6568
|
-
connections.delete(connection.key);
|
|
6569
|
-
}
|
|
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);
|
|
6570
6996
|
}
|
|
6571
6997
|
/**
|
|
6572
6998
|
* Close all v2 subscriptions.
|
|
@@ -6574,6 +7000,11 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6574
7000
|
async function closeAllSubscriptionsV2() {
|
|
6575
7001
|
const closePromises = [];
|
|
6576
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
|
+
}
|
|
6577
7008
|
if (connection.tokenRefreshTimer) {
|
|
6578
7009
|
clearInterval(connection.tokenRefreshTimer);
|
|
6579
7010
|
connection.tokenRefreshTimer = null;
|
|
@@ -6582,15 +7013,18 @@ async function closeAllSubscriptionsV2() {
|
|
|
6582
7013
|
const ws = connection.ws;
|
|
6583
7014
|
connection.ws = null;
|
|
6584
7015
|
connection.isConnected = false;
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
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
|
+
}
|
|
6591
7024
|
}
|
|
6592
7025
|
connections.delete(appId);
|
|
6593
7026
|
}
|
|
7027
|
+
connectionAcquisitions.clear();
|
|
6594
7028
|
await Promise.all(closePromises);
|
|
6595
7029
|
}
|
|
6596
7030
|
/**
|
|
@@ -6623,10 +7057,12 @@ function getCachedDataV2(path, prompt) {
|
|
|
6623
7057
|
// opt-in. Resolve the caller's identity from an active cache-enabled
|
|
6624
7058
|
// subscription for this path. No matching sub → null (safe).
|
|
6625
7059
|
let identity;
|
|
7060
|
+
let identityToken;
|
|
6626
7061
|
outer: for (const connection of connections.values()) {
|
|
6627
7062
|
for (const sub of connection.subscriptions.values()) {
|
|
6628
7063
|
if (sub.cache && sub.path === normalizedPath && sub.prompt === prompt) {
|
|
6629
7064
|
identity = sub.identity;
|
|
7065
|
+
identityToken = sub.identityToken;
|
|
6630
7066
|
break outer;
|
|
6631
7067
|
}
|
|
6632
7068
|
}
|
|
@@ -6635,7 +7071,7 @@ function getCachedDataV2(path, prompt) {
|
|
|
6635
7071
|
return null;
|
|
6636
7072
|
}
|
|
6637
7073
|
const cacheKey = getCacheKey(path, prompt, undefined, undefined, undefined, undefined, identity);
|
|
6638
|
-
const cachedEntry =
|
|
7074
|
+
const cachedEntry = getResponseCacheEntry(cacheKey, identityToken !== null && identityToken !== void 0 ? identityToken : null);
|
|
6639
7075
|
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL) {
|
|
6640
7076
|
return cachedEntry.data;
|
|
6641
7077
|
}
|
|
@@ -6697,10 +7133,15 @@ async function doReconnectWithNewAuth() {
|
|
|
6697
7133
|
if (!connection.ws) {
|
|
6698
7134
|
continue;
|
|
6699
7135
|
}
|
|
7136
|
+
cancelIdleConnectionClose(connection);
|
|
7137
|
+
const reconnectError = new Error('Connection reconnecting due to auth change');
|
|
6700
7138
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6701
|
-
pending.reject(
|
|
7139
|
+
pending.reject(reconnectError);
|
|
6702
7140
|
}
|
|
6703
7141
|
connection.pendingSubscriptions.clear();
|
|
7142
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
7143
|
+
rejectSubscriptionRefresh(subscription, reconnectError);
|
|
7144
|
+
}
|
|
6704
7145
|
for (const [, pending] of connection.pendingUnsubscriptions) {
|
|
6705
7146
|
pending.resolve();
|
|
6706
7147
|
}
|
|
@@ -6760,10 +7201,10 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6760
7201
|
const currentToken = await getIdToken(isServer).catch(() => null);
|
|
6761
7202
|
if (!currentToken) {
|
|
6762
7203
|
// Mid-refresh: the socket principal still governs server-side.
|
|
6763
|
-
if (!connection.
|
|
7204
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
6764
7205
|
return false;
|
|
6765
7206
|
}
|
|
6766
|
-
else if (!connection.
|
|
7207
|
+
else if (!connection.authTokenValue && !connection.authPrincipal) {
|
|
6767
7208
|
// Anonymous socket + authenticated user: do not attribute this user's
|
|
6768
7209
|
// input to the anon principal. Reauth the socket; caller may fall back.
|
|
6769
7210
|
void maybeReauthRoomConnection(connection);
|
|
@@ -6783,7 +7224,7 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6783
7224
|
}
|
|
6784
7225
|
}
|
|
6785
7226
|
// Debounced background reauth: when the client's current token no longer
|
|
6786
|
-
// matches the connection's
|
|
7227
|
+
// matches the connection's authenticated identity (token rotation), refresh the
|
|
6787
7228
|
// socket auth ONCE rather than degrading every subsequent send.
|
|
6788
7229
|
const reauthInFlight = new WeakSet();
|
|
6789
7230
|
async function maybeReauthRoomConnection(connection, isServer) {
|