@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.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
|
-
}, {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4606
|
-
|
|
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
|
-
//
|
|
4612
|
-
//
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4664
|
-
|
|
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
|
|
4668
|
-
pendingRequests[requestKey] =
|
|
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
|
-
|
|
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
|
-
|
|
4945
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -5012,10 +5046,27 @@ async function getMany(paths, opts = {}) {
|
|
|
5012
5046
|
return results;
|
|
5013
5047
|
}
|
|
5014
5048
|
async function runQuery(absolutePath, queryName, queryArgs, opts) {
|
|
5015
|
-
|
|
5016
|
-
|
|
5049
|
+
var _a;
|
|
5050
|
+
const rows = await runQueryManyRows([{ absolutePath, queryName, queryArgs }], opts);
|
|
5051
|
+
const row = rows[0];
|
|
5052
|
+
// The worker disambiguates a computed null from a failed/undeclared query
|
|
5053
|
+
// with a per-row `error` string — surface it instead of returning undefined.
|
|
5054
|
+
if ((row === null || row === void 0 ? void 0 : row.error) != null) {
|
|
5055
|
+
const err = new Error(String(row.error));
|
|
5056
|
+
err.isQueryError = true;
|
|
5057
|
+
err.queryName = queryName;
|
|
5058
|
+
err.path = absolutePath;
|
|
5059
|
+
throw err;
|
|
5060
|
+
}
|
|
5061
|
+
return (_a = row === null || row === void 0 ? void 0 : row.result) !== null && _a !== void 0 ? _a : null;
|
|
5017
5062
|
}
|
|
5018
5063
|
async function runQueryMany(many, opts) {
|
|
5064
|
+
// Batch shape stays an array of result values (errored rows map to null) so
|
|
5065
|
+
// existing callers keep working; use runQuery for per-row error surfacing.
|
|
5066
|
+
const rows = await runQueryManyRows(many, opts);
|
|
5067
|
+
return rows.map((row) => { var _a; return (_a = row === null || row === void 0 ? void 0 : row.result) !== null && _a !== void 0 ? _a : null; });
|
|
5068
|
+
}
|
|
5069
|
+
async function runQueryManyRows(many, opts) {
|
|
5019
5070
|
var _a, _b, _c, _d;
|
|
5020
5071
|
try {
|
|
5021
5072
|
let queries = [];
|
|
@@ -5037,7 +5088,7 @@ async function runQueryMany(many, opts) {
|
|
|
5037
5088
|
if (!Array.isArray(rows)) {
|
|
5038
5089
|
throw new Error(`queries endpoint returned an unexpected response shape (status ${response.status})`);
|
|
5039
5090
|
}
|
|
5040
|
-
return rows
|
|
5091
|
+
return rows;
|
|
5041
5092
|
}
|
|
5042
5093
|
catch (error) {
|
|
5043
5094
|
throw error;
|
|
@@ -5075,6 +5126,11 @@ async function runExpressionMany(many) {
|
|
|
5075
5126
|
if (queryResult.trace) {
|
|
5076
5127
|
result.trace = queryResult.trace;
|
|
5077
5128
|
}
|
|
5129
|
+
// Pass through the server's per-row failure so callers can distinguish
|
|
5130
|
+
// a computed null from a failed row.
|
|
5131
|
+
if (queryResult.error != null) {
|
|
5132
|
+
result.error = String(queryResult.error);
|
|
5133
|
+
}
|
|
5078
5134
|
return result;
|
|
5079
5135
|
});
|
|
5080
5136
|
}
|
|
@@ -5527,10 +5583,37 @@ var operations = /*#__PURE__*/Object.freeze({
|
|
|
5527
5583
|
// ============ Global State ============
|
|
5528
5584
|
// One connection per routing target (appId, or appId#room#<collection>/<roomId>)
|
|
5529
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.
|
|
5530
5601
|
const responseCache = new Map();
|
|
5531
5602
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
5532
5603
|
const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // Refresh token 10 minutes before expiry
|
|
5533
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
|
+
}
|
|
5534
5617
|
// ============ WebSocket Config ============
|
|
5535
5618
|
const BASE_MIN_RECONNECT_DELAY_MS = 1000;
|
|
5536
5619
|
const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
|
|
@@ -5538,6 +5621,10 @@ const MAX_RECONNECT_DELAY_MS = 300000;
|
|
|
5538
5621
|
const RECONNECT_DELAY_GROW_FACTOR = 1.8;
|
|
5539
5622
|
const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
|
|
5540
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;
|
|
5541
5628
|
const WS_CONFIG = {
|
|
5542
5629
|
// Keep retrying indefinitely so long outages recover without page refresh.
|
|
5543
5630
|
maxRetries: Infinity,
|
|
@@ -5568,12 +5655,12 @@ function hashForKey(value) {
|
|
|
5568
5655
|
}
|
|
5569
5656
|
/**
|
|
5570
5657
|
* SECURITY (H1): Response-cache keys must be scoped to the caller's identity, not
|
|
5571
|
-
* just the path/filter/shape. The `identity` prefix
|
|
5572
|
-
*
|
|
5573
|
-
*
|
|
5574
|
-
* / browser login-switch; this cache has a
|
|
5575
|
-
* before entries are read or written; no-auth
|
|
5576
|
-
* 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.
|
|
5577
5664
|
*/
|
|
5578
5665
|
function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
|
|
5579
5666
|
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
@@ -5611,16 +5698,16 @@ function livePrincipalFromIdToken(idToken) {
|
|
|
5611
5698
|
}
|
|
5612
5699
|
function authSnapshotFromToken(idToken) {
|
|
5613
5700
|
return {
|
|
5614
|
-
|
|
5701
|
+
tokenValue: idToken !== null && idToken !== void 0 ? idToken : null,
|
|
5615
5702
|
principal: livePrincipalFromIdToken(idToken),
|
|
5616
5703
|
};
|
|
5617
5704
|
}
|
|
5618
5705
|
function connectionMatchesAuthToken(connection, idToken) {
|
|
5619
5706
|
const current = authSnapshotFromToken(idToken);
|
|
5620
|
-
if (!current.
|
|
5621
|
-
return connection.
|
|
5707
|
+
if (!current.tokenValue) {
|
|
5708
|
+
return connection.authTokenValue === null && connection.authPrincipal === null;
|
|
5622
5709
|
}
|
|
5623
|
-
if (connection.
|
|
5710
|
+
if (connection.authTokenValue && connection.authTokenValue === current.tokenValue) {
|
|
5624
5711
|
return true;
|
|
5625
5712
|
}
|
|
5626
5713
|
return !!connection.authPrincipal && !!current.principal && connection.authPrincipal === current.principal;
|
|
@@ -5629,7 +5716,7 @@ async function canSendAuthenticatedLiveIntent(connection, isServer) {
|
|
|
5629
5716
|
const currentToken = await getIdToken(isServer);
|
|
5630
5717
|
if (!currentToken)
|
|
5631
5718
|
return false;
|
|
5632
|
-
if (!connection.
|
|
5719
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
5633
5720
|
return false;
|
|
5634
5721
|
return connectionMatchesAuthToken(connection, currentToken);
|
|
5635
5722
|
}
|
|
@@ -5642,19 +5729,19 @@ async function getSubscriptionIdentity(effectiveAppId, isServer, overrides) {
|
|
|
5642
5729
|
const bearer = bearerFromAuthHeaders(await overrides._getAuthHeaders());
|
|
5643
5730
|
const principal = principalFromIdToken(bearer);
|
|
5644
5731
|
return principal
|
|
5645
|
-
? { key: `${effectiveAppId}:o${principal}`, cacheable: true }
|
|
5646
|
-
: { key: `${effectiveAppId}:oanon`, cacheable: false };
|
|
5732
|
+
? { key: `${effectiveAppId}:o${principal}`, cacheable: true, token: bearer }
|
|
5733
|
+
: { key: `${effectiveAppId}:oanon`, cacheable: false, token: null };
|
|
5647
5734
|
}
|
|
5648
5735
|
catch (_a) {
|
|
5649
5736
|
// Couldn't resolve the override identity — do not use response cache.
|
|
5650
|
-
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false };
|
|
5737
|
+
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false, token: null };
|
|
5651
5738
|
}
|
|
5652
5739
|
}
|
|
5653
5740
|
const idToken = await getIdToken(isServer);
|
|
5654
5741
|
const principal = principalFromIdToken(idToken);
|
|
5655
5742
|
return principal
|
|
5656
|
-
? { key: `${effectiveAppId}:${principal}`, cacheable: true }
|
|
5657
|
-
: { 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 };
|
|
5658
5745
|
}
|
|
5659
5746
|
/** Extract the bare bearer token from a `{ Authorization: 'Bearer <jwt>' }` map. */
|
|
5660
5747
|
function bearerFromAuthHeaders(headers) {
|
|
@@ -5710,7 +5797,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5710
5797
|
connection.authFailure = error;
|
|
5711
5798
|
connection.pendingAuthToken = null;
|
|
5712
5799
|
connection.pendingAuthSnapshot = null;
|
|
5713
|
-
connection.
|
|
5800
|
+
connection.authTokenValue = null;
|
|
5714
5801
|
connection.authPrincipal = null;
|
|
5715
5802
|
connection.isConnecting = false;
|
|
5716
5803
|
connection.isConnected = false;
|
|
@@ -5721,6 +5808,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5721
5808
|
connection.pendingSubscriptions.clear();
|
|
5722
5809
|
for (const subscription of connection.subscriptions.values()) {
|
|
5723
5810
|
notifyErrorCallbacks(subscription, error);
|
|
5811
|
+
rejectSubscriptionRefresh(subscription, error);
|
|
5724
5812
|
}
|
|
5725
5813
|
}
|
|
5726
5814
|
function scheduleTokenRefresh(connection, isServer) {
|
|
@@ -5913,7 +6001,136 @@ function replaySubscriptions(connection) {
|
|
|
5913
6001
|
sendSubscribe(connection, sub);
|
|
5914
6002
|
}
|
|
5915
6003
|
}
|
|
5916
|
-
|
|
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) {
|
|
5917
6134
|
attachBrowserReconnectHooksOnce();
|
|
5918
6135
|
// A per-room subscription gets its OWN connection routed to the room DO; all
|
|
5919
6136
|
// other subscriptions share the app-level connection (project DO). The worker
|
|
@@ -5924,9 +6141,38 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5924
6141
|
// keyed by the wallet identity, so its WS authenticates as that wallet and
|
|
5925
6142
|
// never shares a connection (or token) with any top-level server caller.
|
|
5926
6143
|
const base = roomKey ? `${appId}#room#${roomKey}` : appId;
|
|
5927
|
-
const
|
|
5928
|
-
|
|
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
|
+
}
|
|
5929
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);
|
|
5930
6176
|
if (connection.authFailure) {
|
|
5931
6177
|
throw connection.authFailure;
|
|
5932
6178
|
}
|
|
@@ -5942,19 +6188,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5942
6188
|
if (connection.isConnected &&
|
|
5943
6189
|
!connection.isAuthenticating &&
|
|
5944
6190
|
!connectionMatchesAuthToken(connection, currentAuthToken)) {
|
|
5945
|
-
|
|
5946
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
5947
|
-
connection.tokenRefreshTimer = null;
|
|
5948
|
-
}
|
|
5949
|
-
try {
|
|
5950
|
-
connection.ws.close();
|
|
5951
|
-
}
|
|
5952
|
-
catch (_a) {
|
|
5953
|
-
// Already closing.
|
|
5954
|
-
}
|
|
5955
|
-
connection.ws = null;
|
|
5956
|
-
connection.isConnected = false;
|
|
5957
|
-
connections.delete(connection.key);
|
|
6191
|
+
closeConnectionNow(connection);
|
|
5958
6192
|
}
|
|
5959
6193
|
else {
|
|
5960
6194
|
connection.authFailure = null;
|
|
@@ -5975,13 +6209,16 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5975
6209
|
isAuthenticating: false,
|
|
5976
6210
|
appId,
|
|
5977
6211
|
key: connKey,
|
|
6212
|
+
acquisitionKey: leaseKey,
|
|
5978
6213
|
routePath: roomKey ? routePath : undefined,
|
|
5979
6214
|
authTokenProvider,
|
|
6215
|
+
connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
|
|
5980
6216
|
pendingAuthToken: null,
|
|
5981
6217
|
pendingAuthSnapshot: null,
|
|
5982
|
-
|
|
6218
|
+
authTokenValue: null,
|
|
5983
6219
|
authPrincipal: null,
|
|
5984
6220
|
tokenRefreshTimer: null,
|
|
6221
|
+
idleCloseTimer: null,
|
|
5985
6222
|
consecutiveAuthFailures: 0,
|
|
5986
6223
|
authFailure: null,
|
|
5987
6224
|
};
|
|
@@ -6058,7 +6295,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6058
6295
|
}
|
|
6059
6296
|
else {
|
|
6060
6297
|
connection.isAuthenticating = false;
|
|
6061
|
-
connection.
|
|
6298
|
+
connection.authTokenValue = null;
|
|
6062
6299
|
connection.authPrincipal = null;
|
|
6063
6300
|
replaySubscriptions(connection);
|
|
6064
6301
|
}
|
|
@@ -6076,10 +6313,14 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6076
6313
|
// Handle errors
|
|
6077
6314
|
ws.addEventListener('error', (event) => {
|
|
6078
6315
|
console.error('[WS v2] WebSocket error:', event);
|
|
6316
|
+
const wsError = new Error('WebSocket error');
|
|
6079
6317
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6080
|
-
pending.reject(
|
|
6318
|
+
pending.reject(wsError);
|
|
6081
6319
|
}
|
|
6082
6320
|
connection.pendingSubscriptions.clear();
|
|
6321
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
6322
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6323
|
+
}
|
|
6083
6324
|
});
|
|
6084
6325
|
// Handle close
|
|
6085
6326
|
ws.addEventListener('close', () => {
|
|
@@ -6103,7 +6344,7 @@ function handleServerMessage(connection, message) {
|
|
|
6103
6344
|
switch (message.type) {
|
|
6104
6345
|
case 'authenticated': {
|
|
6105
6346
|
connection.isAuthenticating = false;
|
|
6106
|
-
connection.
|
|
6347
|
+
connection.authTokenValue = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenValue) !== null && _b !== void 0 ? _b : null;
|
|
6107
6348
|
connection.authPrincipal = (_d = (_c = connection.pendingAuthSnapshot) === null || _c === void 0 ? void 0 : _c.principal) !== null && _d !== void 0 ? _d : null;
|
|
6108
6349
|
replaySubscriptions(connection);
|
|
6109
6350
|
break;
|
|
@@ -6111,15 +6352,49 @@ function handleServerMessage(connection, message) {
|
|
|
6111
6352
|
case 'subscribed': {
|
|
6112
6353
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6113
6354
|
if (subscription) {
|
|
6355
|
+
const refresh = subscription.refresh;
|
|
6114
6356
|
// A data update can race ahead of subscribed during reconnect.
|
|
6115
6357
|
// If we already received data for this subscription, treat subscribed
|
|
6116
6358
|
// as an ack only and avoid regressing to an older snapshot.
|
|
6117
|
-
if (
|
|
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) {
|
|
6118
6392
|
if (subscription.cache) {
|
|
6119
6393
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6120
|
-
|
|
6394
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6121
6395
|
}
|
|
6122
6396
|
subscription.lastData = message.data;
|
|
6397
|
+
subscription.dataRevision += 1;
|
|
6123
6398
|
notifyCallbacks(subscription, message.data);
|
|
6124
6399
|
}
|
|
6125
6400
|
}
|
|
@@ -6146,12 +6421,20 @@ function handleServerMessage(connection, message) {
|
|
|
6146
6421
|
// Update cache
|
|
6147
6422
|
if (subscription.cache) {
|
|
6148
6423
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6149
|
-
|
|
6424
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6150
6425
|
}
|
|
6151
6426
|
// Store last data
|
|
6152
6427
|
subscription.lastData = message.data;
|
|
6428
|
+
subscription.dataRevision += 1;
|
|
6153
6429
|
// Notify callbacks
|
|
6154
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
|
+
}
|
|
6155
6438
|
}
|
|
6156
6439
|
break;
|
|
6157
6440
|
}
|
|
@@ -6238,6 +6521,7 @@ function handleServerMessage(connection, message) {
|
|
|
6238
6521
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6239
6522
|
if (subscription) {
|
|
6240
6523
|
notifyErrorCallbacks(subscription, wsError);
|
|
6524
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6241
6525
|
}
|
|
6242
6526
|
}
|
|
6243
6527
|
break;
|
|
@@ -6274,13 +6558,10 @@ function addIdsToSubscriptionData(data) {
|
|
|
6274
6558
|
return data.map(withSubscriptionId);
|
|
6275
6559
|
return withSubscriptionId(data);
|
|
6276
6560
|
}
|
|
6277
|
-
function
|
|
6561
|
+
function notifyCallbackOptions(callbacks, data) {
|
|
6278
6562
|
var _a;
|
|
6279
6563
|
// Attach the bare `id` (leaf doc key) to every delivered row (Bug 1).
|
|
6280
6564
|
const decorated = addIdsToSubscriptionData(data);
|
|
6281
|
-
// Snapshot the callbacks array so that unsubscriptions during
|
|
6282
|
-
// notification don't cause callbacks to be skipped.
|
|
6283
|
-
const callbacks = subscription.callbacks.slice();
|
|
6284
6565
|
for (const callback of callbacks) {
|
|
6285
6566
|
try {
|
|
6286
6567
|
(_a = callback.onData) === null || _a === void 0 ? void 0 : _a.call(callback, decorated);
|
|
@@ -6290,23 +6571,33 @@ function notifyCallbacks(subscription, data) {
|
|
|
6290
6571
|
}
|
|
6291
6572
|
}
|
|
6292
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
|
+
}
|
|
6293
6593
|
function notifyErrorCallbacks(subscription, error) {
|
|
6294
6594
|
let delivered = false;
|
|
6295
6595
|
const callbacks = subscription.callbacks.slice();
|
|
6296
6596
|
for (const callback of callbacks) {
|
|
6297
|
-
|
|
6298
|
-
continue;
|
|
6299
|
-
delivered = true;
|
|
6300
|
-
try {
|
|
6301
|
-
callback.onError(error);
|
|
6302
|
-
}
|
|
6303
|
-
catch (callbackError) {
|
|
6304
|
-
console.error('[WS v2] Error in subscription error callback:', callbackError);
|
|
6305
|
-
}
|
|
6597
|
+
delivered = notifyErrorCallback(callback, error) || delivered;
|
|
6306
6598
|
}
|
|
6307
|
-
if (delivered)
|
|
6599
|
+
if (delivered)
|
|
6308
6600
|
error.__boundedDeliveredToOnError = true;
|
|
6309
|
-
}
|
|
6310
6601
|
return delivered;
|
|
6311
6602
|
}
|
|
6312
6603
|
// WebSocket readyState constants
|
|
@@ -6337,6 +6628,71 @@ function sendSubscribe(connection, subscription) {
|
|
|
6337
6628
|
console.error('[WS v2] Error sending subscribe message:', error);
|
|
6338
6629
|
}
|
|
6339
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
|
+
}
|
|
6340
6696
|
function sendUnsubscribe(connection, subscriptionId) {
|
|
6341
6697
|
if (!connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN) {
|
|
6342
6698
|
return;
|
|
@@ -6385,16 +6741,27 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6385
6741
|
: undefined;
|
|
6386
6742
|
const identityInfo = await getSubscriptionIdentity(effectiveAppId, config.isServer, overrides);
|
|
6387
6743
|
const identity = identityInfo.key;
|
|
6744
|
+
const identityToken = identityInfo.token;
|
|
6388
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.
|
|
6389
6748
|
const principalKey = authTokenProvider ? identity : undefined;
|
|
6749
|
+
const principalToken = authTokenProvider
|
|
6750
|
+
? (identityToken !== null && identityToken !== void 0 ? identityToken : identity)
|
|
6751
|
+
: undefined;
|
|
6390
6752
|
const cacheKey = responseCacheEnabled
|
|
6391
6753
|
? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
|
|
6392
6754
|
: null;
|
|
6393
|
-
//
|
|
6394
|
-
//
|
|
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.
|
|
6395
6759
|
let connection;
|
|
6760
|
+
let releaseConnection;
|
|
6396
6761
|
try {
|
|
6397
|
-
|
|
6762
|
+
const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken);
|
|
6763
|
+
connection = acquisition.connection;
|
|
6764
|
+
releaseConnection = acquisition.release;
|
|
6398
6765
|
}
|
|
6399
6766
|
catch (error) {
|
|
6400
6767
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6404,73 +6771,130 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6404
6771
|
}
|
|
6405
6772
|
throw err;
|
|
6406
6773
|
}
|
|
6407
|
-
|
|
6408
|
-
// auth preflight has succeeded. An expired authenticated session must receive
|
|
6409
|
-
// an auth error, not a stale private snapshot followed by a failed connect.
|
|
6410
|
-
const cachedEntry = cacheKey ? responseCache.get(cacheKey) : undefined;
|
|
6411
|
-
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
|
|
6412
|
-
setTimeout(() => {
|
|
6413
|
-
var _a;
|
|
6414
|
-
(_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
|
|
6415
|
-
}, 0);
|
|
6416
|
-
}
|
|
6774
|
+
const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
|
|
6417
6775
|
// Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
|
|
6418
|
-
const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
|
|
6419
|
-
const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
|
|
6420
|
-
const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
|
|
6421
6776
|
let existingSubscription;
|
|
6422
|
-
|
|
6423
|
-
const
|
|
6424
|
-
const
|
|
6425
|
-
const
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
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
|
+
}
|
|
6432
6793
|
}
|
|
6433
6794
|
}
|
|
6795
|
+
catch (error) {
|
|
6796
|
+
releaseConnection();
|
|
6797
|
+
throw error;
|
|
6798
|
+
}
|
|
6434
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
|
+
}
|
|
6435
6813
|
if (responseCacheEnabled) {
|
|
6436
6814
|
existingSubscription.cache = true;
|
|
6437
6815
|
}
|
|
6438
6816
|
// Add callback to existing subscription
|
|
6439
6817
|
existingSubscription.callbacks.push(subscriptionOptions);
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
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
|
+
}
|
|
6451
6854
|
}
|
|
6452
6855
|
return async () => {
|
|
6453
6856
|
await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
|
|
6454
6857
|
};
|
|
6455
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
|
+
}
|
|
6456
6869
|
// Create new subscription
|
|
6457
6870
|
const subscriptionId = generateSubscriptionId();
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
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();
|
|
6474
6898
|
// Send subscribe message if connected
|
|
6475
6899
|
if (connection.isConnected && !connection.isAuthenticating) {
|
|
6476
6900
|
// Create a promise to wait for subscription confirmation
|
|
@@ -6490,15 +6914,23 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6490
6914
|
}
|
|
6491
6915
|
catch (error) {
|
|
6492
6916
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
6917
|
+
let deliveredLocally = false;
|
|
6493
6918
|
if (!err.__boundedDeliveredToOnError) {
|
|
6494
|
-
|
|
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);
|
|
6495
6923
|
}
|
|
6496
6924
|
if (!subscriptionOptions.onError) {
|
|
6497
6925
|
connection.pendingSubscriptions.delete(subscriptionId);
|
|
6498
|
-
|
|
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);
|
|
6499
6931
|
throw err;
|
|
6500
6932
|
}
|
|
6501
|
-
if (!err.__boundedDeliveredToOnError) {
|
|
6933
|
+
if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
|
|
6502
6934
|
console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
|
|
6503
6935
|
}
|
|
6504
6936
|
}
|
|
@@ -6509,12 +6941,14 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6509
6941
|
};
|
|
6510
6942
|
}
|
|
6511
6943
|
async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
|
|
6944
|
+
var _a;
|
|
6512
6945
|
const subscription = connection.subscriptions.get(subscriptionId);
|
|
6513
6946
|
if (!subscription) {
|
|
6514
6947
|
return;
|
|
6515
6948
|
}
|
|
6516
6949
|
// Remove this callback
|
|
6517
6950
|
subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
|
|
6951
|
+
(_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
|
|
6518
6952
|
// If there are still callbacks, don't unsubscribe
|
|
6519
6953
|
if (subscription.callbacks.length > 0) {
|
|
6520
6954
|
return;
|
|
@@ -6536,17 +6970,9 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6536
6970
|
sendUnsubscribe(connection, subscriptionId);
|
|
6537
6971
|
await unsubscribePromise;
|
|
6538
6972
|
}
|
|
6539
|
-
// If no more subscriptions, close
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
if (connection.tokenRefreshTimer) {
|
|
6543
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
6544
|
-
connection.tokenRefreshTimer = null;
|
|
6545
|
-
}
|
|
6546
|
-
connection.ws.close();
|
|
6547
|
-
connection.ws = null;
|
|
6548
|
-
connections.delete(connection.key);
|
|
6549
|
-
}
|
|
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);
|
|
6550
6976
|
}
|
|
6551
6977
|
/**
|
|
6552
6978
|
* Close all v2 subscriptions.
|
|
@@ -6554,6 +6980,11 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6554
6980
|
async function closeAllSubscriptionsV2() {
|
|
6555
6981
|
const closePromises = [];
|
|
6556
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
|
+
}
|
|
6557
6988
|
if (connection.tokenRefreshTimer) {
|
|
6558
6989
|
clearInterval(connection.tokenRefreshTimer);
|
|
6559
6990
|
connection.tokenRefreshTimer = null;
|
|
@@ -6562,15 +6993,18 @@ async function closeAllSubscriptionsV2() {
|
|
|
6562
6993
|
const ws = connection.ws;
|
|
6563
6994
|
connection.ws = null;
|
|
6564
6995
|
connection.isConnected = false;
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
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
|
+
}
|
|
6571
7004
|
}
|
|
6572
7005
|
connections.delete(appId);
|
|
6573
7006
|
}
|
|
7007
|
+
connectionAcquisitions.clear();
|
|
6574
7008
|
await Promise.all(closePromises);
|
|
6575
7009
|
}
|
|
6576
7010
|
/**
|
|
@@ -6603,10 +7037,12 @@ function getCachedDataV2(path, prompt) {
|
|
|
6603
7037
|
// opt-in. Resolve the caller's identity from an active cache-enabled
|
|
6604
7038
|
// subscription for this path. No matching sub → null (safe).
|
|
6605
7039
|
let identity;
|
|
7040
|
+
let identityToken;
|
|
6606
7041
|
outer: for (const connection of connections.values()) {
|
|
6607
7042
|
for (const sub of connection.subscriptions.values()) {
|
|
6608
7043
|
if (sub.cache && sub.path === normalizedPath && sub.prompt === prompt) {
|
|
6609
7044
|
identity = sub.identity;
|
|
7045
|
+
identityToken = sub.identityToken;
|
|
6610
7046
|
break outer;
|
|
6611
7047
|
}
|
|
6612
7048
|
}
|
|
@@ -6615,7 +7051,7 @@ function getCachedDataV2(path, prompt) {
|
|
|
6615
7051
|
return null;
|
|
6616
7052
|
}
|
|
6617
7053
|
const cacheKey = getCacheKey(path, prompt, undefined, undefined, undefined, undefined, identity);
|
|
6618
|
-
const cachedEntry =
|
|
7054
|
+
const cachedEntry = getResponseCacheEntry(cacheKey, identityToken !== null && identityToken !== void 0 ? identityToken : null);
|
|
6619
7055
|
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL) {
|
|
6620
7056
|
return cachedEntry.data;
|
|
6621
7057
|
}
|
|
@@ -6677,10 +7113,15 @@ async function doReconnectWithNewAuth() {
|
|
|
6677
7113
|
if (!connection.ws) {
|
|
6678
7114
|
continue;
|
|
6679
7115
|
}
|
|
7116
|
+
cancelIdleConnectionClose(connection);
|
|
7117
|
+
const reconnectError = new Error('Connection reconnecting due to auth change');
|
|
6680
7118
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6681
|
-
pending.reject(
|
|
7119
|
+
pending.reject(reconnectError);
|
|
6682
7120
|
}
|
|
6683
7121
|
connection.pendingSubscriptions.clear();
|
|
7122
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
7123
|
+
rejectSubscriptionRefresh(subscription, reconnectError);
|
|
7124
|
+
}
|
|
6684
7125
|
for (const [, pending] of connection.pendingUnsubscriptions) {
|
|
6685
7126
|
pending.resolve();
|
|
6686
7127
|
}
|
|
@@ -6740,10 +7181,10 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6740
7181
|
const currentToken = await getIdToken(isServer).catch(() => null);
|
|
6741
7182
|
if (!currentToken) {
|
|
6742
7183
|
// Mid-refresh: the socket principal still governs server-side.
|
|
6743
|
-
if (!connection.
|
|
7184
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
6744
7185
|
return false;
|
|
6745
7186
|
}
|
|
6746
|
-
else if (!connection.
|
|
7187
|
+
else if (!connection.authTokenValue && !connection.authPrincipal) {
|
|
6747
7188
|
// Anonymous socket + authenticated user: do not attribute this user's
|
|
6748
7189
|
// input to the anon principal. Reauth the socket; caller may fall back.
|
|
6749
7190
|
void maybeReauthRoomConnection(connection);
|
|
@@ -6763,7 +7204,7 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6763
7204
|
}
|
|
6764
7205
|
}
|
|
6765
7206
|
// Debounced background reauth: when the client's current token no longer
|
|
6766
|
-
// matches the connection's
|
|
7207
|
+
// matches the connection's authenticated identity (token rotation), refresh the
|
|
6767
7208
|
// socket auth ONCE rather than degrading every subsequent send.
|
|
6768
7209
|
const reauthInFlight = new WeakSet();
|
|
6769
7210
|
async function maybeReauthRoomConnection(connection, isServer) {
|