js-bao-wss-client 2.2.0-alpha.3 → 2.2.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/JsBaoClient.d.ts +191 -1
- package/dist/JsBaoClient.js +424 -1
- package/dist/api/functionsApi.d.ts +21 -0
- package/dist/browser.umd.js +424 -1
- package/package.json +2 -2
package/dist/JsBaoClient.d.ts
CHANGED
|
@@ -93,7 +93,7 @@ export type { DocumentDebugSnapshot, DocumentPermission, LocalDocumentEntry, Loc
|
|
|
93
93
|
export type { LogLevel } from "./internal/logger";
|
|
94
94
|
export type { RequestOptions } from "./internal/httpClient";
|
|
95
95
|
export type { LocksAPI } from "./api/locksApi";
|
|
96
|
-
export type { FunctionsAPI, FunctionInvokeOptions, FunctionInvokeResult, FunctionInvokeStatus, FunctionStartOptions, FunctionStartResult, FunctionRunRef, } from "./api/functionsApi";
|
|
96
|
+
export type { FunctionsAPI, FunctionInvokeOptions, FunctionInvokeResult, FunctionInvokeStatus, FunctionInvokeLimits, FunctionStartOptions, FunctionStartResult, FunctionRunRef, } from "./api/functionsApi";
|
|
97
97
|
export type { LockHandle, LockContention, AcquireResponse, AcquireOptions, BlockingAcquireOptions, ReleaseResult, RenewResult, LockStatus, LockListEntry, LockListResult, } from "./api/locksApi";
|
|
98
98
|
export type { ResourceMetadataAPI } from "./api/resourceMetadataApi";
|
|
99
99
|
export type { ResourceMetadataReadResult, ResourceMetadataWriteResult, ResourceMetadataBatchRequestItem, ResourceMetadataBatchParams, ResourceMetadataBatchCategoryResult, ResourceMetadataBatchResourceResult, ResourceMetadataBatchResult, ResourceMetadataListEntry, ResourceMetadataListResult, ResourceMetadataDeleteResult, ResourceMetadataResolveParams, ResourceMetadataResolveResult, } from "./api/resourceMetadataApi";
|
|
@@ -884,6 +884,62 @@ export interface DirectMessageEvent {
|
|
|
884
884
|
/** When the platform sent it, ISO 8601. */
|
|
885
885
|
sentAt: string;
|
|
886
886
|
}
|
|
887
|
+
/**
|
|
888
|
+
* A message a server function published to a channel this client has joined.
|
|
889
|
+
*
|
|
890
|
+
* Emitted over the `channelMessage` event channel
|
|
891
|
+
* (`client.on("channelMessage", ...)`) for every channel this client holds a
|
|
892
|
+
* live membership in — the `channel` field says which. `payload` is the
|
|
893
|
+
* function's own value, passed through unread by the platform.
|
|
894
|
+
*
|
|
895
|
+
* Like {@link DirectMessageEvent} it is a LIVE frame with no durable record
|
|
896
|
+
* behind it, and `functionKey` is its only attribution: a function publishing
|
|
897
|
+
* from a database-change trigger runs as the app and acts for no user.
|
|
898
|
+
*/
|
|
899
|
+
export interface ChannelMessageEvent {
|
|
900
|
+
type: "channel.message";
|
|
901
|
+
channel: string;
|
|
902
|
+
payload: unknown;
|
|
903
|
+
functionKey: string;
|
|
904
|
+
/** When the platform sent it, ISO 8601. */
|
|
905
|
+
sentAt: string;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* A live channel membership, returned by
|
|
909
|
+
* {@link JsBaoClient.subscribeToChannel}.
|
|
910
|
+
*
|
|
911
|
+
* `expiresAt` is epoch milliseconds and is the membership's whole lifetime:
|
|
912
|
+
* expiry is the only revocation a channel grant has, so past it the server
|
|
913
|
+
* stops delivering even though this socket stays open. Renewing means asking
|
|
914
|
+
* the authorizing function for another grant and subscribing again — which
|
|
915
|
+
* replaces this membership rather than adding one.
|
|
916
|
+
*/
|
|
917
|
+
export interface ChannelSubscription {
|
|
918
|
+
channel: string;
|
|
919
|
+
expiresAt: number;
|
|
920
|
+
/** Leave the channel. Idempotent. */
|
|
921
|
+
unsubscribe: () => void;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* A channel subscription the server refused when nothing was waiting on it.
|
|
925
|
+
*
|
|
926
|
+
* Emitted over the `channelSubscribeFailed` event channel
|
|
927
|
+
* (`client.on("channelSubscribeFailed", ...)`). The case this exists for is
|
|
928
|
+
* RECONNECT: after the socket comes back the client presents each held grant
|
|
929
|
+
* again, and a grant that expired while the connection was down is refused with
|
|
930
|
+
* no pending call to reject. The registration for that channel — and only that
|
|
931
|
+
* channel — is dropped, and this is how an app hears about it, so it can ask
|
|
932
|
+
* its authorizing function for a fresh grant and subscribe again.
|
|
933
|
+
*
|
|
934
|
+
* A refusal that answers a {@link JsBaoClient.subscribeToChannel} call is
|
|
935
|
+
* reported by rejecting that promise instead, so a failure is never announced
|
|
936
|
+
* twice. `message` is the server's uniform refusal: an expired, tampered,
|
|
937
|
+
* cross-app or cross-user grant are deliberately indistinguishable.
|
|
938
|
+
*/
|
|
939
|
+
export interface ChannelSubscribeFailedEvent {
|
|
940
|
+
channel: string;
|
|
941
|
+
message: string;
|
|
942
|
+
}
|
|
887
943
|
/**
|
|
888
944
|
* Payload of the `workflowStatus` event (`client.on("workflowStatus", ...)`).
|
|
889
945
|
* Delivered in real time when a workflow run started by this user reaches a
|
|
@@ -1562,6 +1618,8 @@ export interface JsBaoEvents {
|
|
|
1562
1618
|
meUpdateFailed: MeUpdateFailedEvent;
|
|
1563
1619
|
notification: NotificationEvent;
|
|
1564
1620
|
directMessage: DirectMessageEvent;
|
|
1621
|
+
channelMessage: ChannelMessageEvent;
|
|
1622
|
+
channelSubscribeFailed: ChannelSubscribeFailedEvent;
|
|
1565
1623
|
workflowStatus: WorkflowStatusEvent;
|
|
1566
1624
|
workflowStarted: WorkflowStartedEvent;
|
|
1567
1625
|
syncPerf: {
|
|
@@ -1919,6 +1977,50 @@ export declare class JsBaoClient extends Observable<any> {
|
|
|
1919
1977
|
/** Registry of active database subscriptions — routes inbound `db.change`
|
|
1920
1978
|
* frames to the right callback and drives reconnect re-subscribe. */
|
|
1921
1979
|
private dbSubscriptions;
|
|
1980
|
+
/**
|
|
1981
|
+
* Channel memberships this client is holding, `channel → grant` (#3184).
|
|
1982
|
+
*
|
|
1983
|
+
* The GRANT is stored, not just the channel, because a membership is keyed
|
|
1984
|
+
* by a connection: after a reconnect the server has no record of it, and the
|
|
1985
|
+
* only thing that re-establishes one is presenting the credential again. A
|
|
1986
|
+
* grant that has expired in the meantime is refused, and the refusal drops
|
|
1987
|
+
* exactly that channel's registration.
|
|
1988
|
+
*/
|
|
1989
|
+
private channelGrants;
|
|
1990
|
+
/**
|
|
1991
|
+
* Subscribes waiting for their own channel's ack (D3184-007).
|
|
1992
|
+
*
|
|
1993
|
+
* `sent` records whether the frame this call is waiting on ever reached the
|
|
1994
|
+
* wire — R3184-002. A subscribe made before the socket opened registers its
|
|
1995
|
+
* grant and nudges the connection, but sends nothing; the open handler has
|
|
1996
|
+
* to send THAT attempt rather than queue a second one behind it, or the
|
|
1997
|
+
* caller waits out the full 20 s timeout on a connection that came up
|
|
1998
|
+
* immediately.
|
|
1999
|
+
*/
|
|
2000
|
+
private pendingChannelSubscribes;
|
|
2001
|
+
/**
|
|
2002
|
+
* How many times this channel has been left — R3184-005.
|
|
2003
|
+
*
|
|
2004
|
+
* A subscribe queued behind an in-flight one is a continuation that has not
|
|
2005
|
+
* run yet, and `unsubscribeFromChannel` cannot cancel a `.then`. Without a
|
|
2006
|
+
* generation, leaving a channel while a renewal was queued would run that
|
|
2007
|
+
* renewal afterwards: the registration would come back, a subscribe would go
|
|
2008
|
+
* out, and the client would be delivered to on a channel it had explicitly
|
|
2009
|
+
* left. Each attempt captures the count it was created under and does
|
|
2010
|
+
* nothing if it has moved.
|
|
2011
|
+
*/
|
|
2012
|
+
private channelEpochs;
|
|
2013
|
+
/**
|
|
2014
|
+
* One subscribe at a time per channel — CR3184-004.
|
|
2015
|
+
*
|
|
2016
|
+
* The server answers a subscribe with a frame that names the CHANNEL and
|
|
2017
|
+
* nothing finer, which is what lets subscriptions to DIFFERENT channels be
|
|
2018
|
+
* told apart. Two requests for the SAME channel cannot be: the first answer
|
|
2019
|
+
* would settle both, so an expired grant racing a renewal would reject the
|
|
2020
|
+
* renewal too and drop a registration the server had accepted. Chaining them
|
|
2021
|
+
* keeps every answer attributable to the request that asked for it.
|
|
2022
|
+
*/
|
|
2023
|
+
private channelSubscribeChain;
|
|
1922
2024
|
/** Sub-API for managing documents (list, create, get, delete, share).
|
|
1923
2025
|
* @group Sub-APIs */
|
|
1924
2026
|
documents: DocumentsAPI;
|
|
@@ -3072,6 +3174,94 @@ export declare class JsBaoClient extends Observable<any> {
|
|
|
3072
3174
|
/** Check if an offline grant is stored locally.
|
|
3073
3175
|
* @group Offline & Sync */
|
|
3074
3176
|
hasOfflineGrantStored(): Promise<boolean>;
|
|
3177
|
+
/**
|
|
3178
|
+
* Join a channel a server function authorized.
|
|
3179
|
+
*
|
|
3180
|
+
* `grant` is the token `ctx.channels.authorize` handed back: a signed,
|
|
3181
|
+
* short-lived credential naming this app, this channel and this user. The
|
|
3182
|
+
* promise resolves on the server's ack FOR THIS CHANNEL, so concurrent
|
|
3183
|
+
* subscribes cannot resolve each other, and rejects on the server's uniform
|
|
3184
|
+
* refusal — an expired, tampered, cross-app or cross-user grant are
|
|
3185
|
+
* deliberately indistinguishable, so the rejection says only that the grant
|
|
3186
|
+
* was not accepted.
|
|
3187
|
+
*
|
|
3188
|
+
* Calling it again with a fresh grant RENEWS the membership: expiry is the
|
|
3189
|
+
* only revocation a channel has, so an app that wants a long-lived channel
|
|
3190
|
+
* re-invokes its authorizing function before `expiresAt` and subscribes
|
|
3191
|
+
* again. Frames arrive as `client.on("channelMessage", …)`.
|
|
3192
|
+
*
|
|
3193
|
+
* Two calls for the SAME channel run one after the other, so a renewal
|
|
3194
|
+
* issued while an earlier subscribe is still in flight is answered on its own
|
|
3195
|
+
* merits rather than by whichever frame arrives first.
|
|
3196
|
+
*
|
|
3197
|
+
* @group Realtime
|
|
3198
|
+
*/
|
|
3199
|
+
subscribeToChannel(channel: string, grant: string): Promise<ChannelSubscription>;
|
|
3200
|
+
/** The generation `unsubscribeFromChannel` bumps — R3184-005. */
|
|
3201
|
+
private channelEpoch;
|
|
3202
|
+
/**
|
|
3203
|
+
* Re-present a stored grant after a reconnect — through the SAME chain an
|
|
3204
|
+
* explicit subscribe uses (SO3184-006).
|
|
3205
|
+
*
|
|
3206
|
+
* The reconnect pass used to write straight to the socket, which put an
|
|
3207
|
+
* attempt on the wire that the per-channel bookkeeping knew nothing about.
|
|
3208
|
+
* The server's answer names the channel and nothing finer, so a refusal of
|
|
3209
|
+
* the re-issued (possibly expired) grant would settle a RENEWAL the app made
|
|
3210
|
+
* in the meantime: the fresh subscribe would reject and its registration
|
|
3211
|
+
* would be deleted, while the server had accepted it and was delivering to
|
|
3212
|
+
* it. Chaining keeps every answer attributable to the request that asked for
|
|
3213
|
+
* it, exactly as `subscribeToChannel` does.
|
|
3214
|
+
*
|
|
3215
|
+
* Nobody is waiting on this one, so its refusal is announced as
|
|
3216
|
+
* `channelSubscribeFailed` (CR3184-005) rather than rejecting a promise.
|
|
3217
|
+
*/
|
|
3218
|
+
private reissueChannelSubscribe;
|
|
3219
|
+
/**
|
|
3220
|
+
* Give up on every subscribe waiting for an answer, because the socket that
|
|
3221
|
+
* would have carried it is gone (SO3184-006).
|
|
3222
|
+
*
|
|
3223
|
+
* The GRANTS stay: the socket failed, not the credential, and the reconnect
|
|
3224
|
+
* pass presents each one again. What must not stay is a pending attempt —
|
|
3225
|
+
* its answer can never arrive, and leaving it in the per-channel chain would
|
|
3226
|
+
* hold the re-issue behind it for the full 20 s timeout.
|
|
3227
|
+
*/
|
|
3228
|
+
private abortPendingChannelSubscribes;
|
|
3229
|
+
private startChannelSubscribe;
|
|
3230
|
+
/**
|
|
3231
|
+
* Put the subscribes that never reached the wire onto it — R3184-002.
|
|
3232
|
+
*
|
|
3233
|
+
* A call made while the socket was down registered its grant, created its
|
|
3234
|
+
* promise and sent nothing. On open its frame is sent for the attempt that
|
|
3235
|
+
* is already waiting, so the ack settles the original call. Channels with
|
|
3236
|
+
* nothing pending go the other way, through the ordinary re-issue.
|
|
3237
|
+
*
|
|
3238
|
+
* Returns the channels it sent for, so the reconnect pass does not queue a
|
|
3239
|
+
* second attempt for them.
|
|
3240
|
+
*/
|
|
3241
|
+
private flushUnsentChannelSubscribes;
|
|
3242
|
+
/**
|
|
3243
|
+
* Leave a channel. Idempotent, and safe on a closed socket: the
|
|
3244
|
+
* registration goes either way, so a reconnect does not bring it back.
|
|
3245
|
+
*
|
|
3246
|
+
* A subscribe still in flight, or queued behind one, is cancelled with it:
|
|
3247
|
+
* leaving means leaving, so a queued renewal cannot put the membership back
|
|
3248
|
+
* afterwards, and a `subscribeToChannel` promise still waiting for its ack
|
|
3249
|
+
* rejects rather than resolving with a subscription to a channel this client
|
|
3250
|
+
* has already left.
|
|
3251
|
+
*
|
|
3252
|
+
* @group Realtime
|
|
3253
|
+
*/
|
|
3254
|
+
unsubscribeFromChannel(channel: string): void;
|
|
3255
|
+
private sendChannelSubscribe;
|
|
3256
|
+
/**
|
|
3257
|
+
* Settle every call waiting on one channel — with the ack, or with the
|
|
3258
|
+
* refusal. Only that channel's waiters and only that channel's
|
|
3259
|
+
* registration: a client with several subscriptions must not lose the ones
|
|
3260
|
+
* that worked (D3184-007).
|
|
3261
|
+
*/
|
|
3262
|
+
private settleChannelSubscribe;
|
|
3263
|
+
private handleChannelSubscribed;
|
|
3264
|
+
private handleChannelMessage;
|
|
3075
3265
|
/**
|
|
3076
3266
|
* Internal entry point for DatabasesAPI.subscribe(). Ensures the WS is
|
|
3077
3267
|
* connected, registers the callback with the registry, sends the
|
package/dist/JsBaoClient.js
CHANGED
|
@@ -547,6 +547,50 @@ export class JsBaoClient extends Observable {
|
|
|
547
547
|
this.serviceWorkerBridgeLastToken = null;
|
|
548
548
|
this.pendingSelfRemovalDocs = new Set();
|
|
549
549
|
this.pendingSelfRemovalTimers = new Map();
|
|
550
|
+
/**
|
|
551
|
+
* Channel memberships this client is holding, `channel → grant` (#3184).
|
|
552
|
+
*
|
|
553
|
+
* The GRANT is stored, not just the channel, because a membership is keyed
|
|
554
|
+
* by a connection: after a reconnect the server has no record of it, and the
|
|
555
|
+
* only thing that re-establishes one is presenting the credential again. A
|
|
556
|
+
* grant that has expired in the meantime is refused, and the refusal drops
|
|
557
|
+
* exactly that channel's registration.
|
|
558
|
+
*/
|
|
559
|
+
this.channelGrants = new Map();
|
|
560
|
+
/**
|
|
561
|
+
* Subscribes waiting for their own channel's ack (D3184-007).
|
|
562
|
+
*
|
|
563
|
+
* `sent` records whether the frame this call is waiting on ever reached the
|
|
564
|
+
* wire — R3184-002. A subscribe made before the socket opened registers its
|
|
565
|
+
* grant and nudges the connection, but sends nothing; the open handler has
|
|
566
|
+
* to send THAT attempt rather than queue a second one behind it, or the
|
|
567
|
+
* caller waits out the full 20 s timeout on a connection that came up
|
|
568
|
+
* immediately.
|
|
569
|
+
*/
|
|
570
|
+
this.pendingChannelSubscribes = new Map();
|
|
571
|
+
/**
|
|
572
|
+
* How many times this channel has been left — R3184-005.
|
|
573
|
+
*
|
|
574
|
+
* A subscribe queued behind an in-flight one is a continuation that has not
|
|
575
|
+
* run yet, and `unsubscribeFromChannel` cannot cancel a `.then`. Without a
|
|
576
|
+
* generation, leaving a channel while a renewal was queued would run that
|
|
577
|
+
* renewal afterwards: the registration would come back, a subscribe would go
|
|
578
|
+
* out, and the client would be delivered to on a channel it had explicitly
|
|
579
|
+
* left. Each attempt captures the count it was created under and does
|
|
580
|
+
* nothing if it has moved.
|
|
581
|
+
*/
|
|
582
|
+
this.channelEpochs = new Map();
|
|
583
|
+
/**
|
|
584
|
+
* One subscribe at a time per channel — CR3184-004.
|
|
585
|
+
*
|
|
586
|
+
* The server answers a subscribe with a frame that names the CHANNEL and
|
|
587
|
+
* nothing finer, which is what lets subscriptions to DIFFERENT channels be
|
|
588
|
+
* told apart. Two requests for the SAME channel cannot be: the first answer
|
|
589
|
+
* would settle both, so an expired grant racing a renewal would reject the
|
|
590
|
+
* renewal too and drop a registration the server had accepted. Chaining them
|
|
591
|
+
* keeps every answer attributable to the request that asked for it.
|
|
592
|
+
*/
|
|
593
|
+
this.channelSubscribeChain = new Map();
|
|
550
594
|
this.workflowApplyHandlers = new Map();
|
|
551
595
|
this.applyingTokenFromController = false;
|
|
552
596
|
this.controllerPreviousToken = null;
|
|
@@ -5402,6 +5446,33 @@ export class JsBaoClient extends Observable {
|
|
|
5402
5446
|
error: err instanceof Error ? err.message : String(err),
|
|
5403
5447
|
});
|
|
5404
5448
|
}
|
|
5449
|
+
// #3184 — the same reconnect problem, one credential heavier. A channel
|
|
5450
|
+
// membership is a `ConnectionMapping` row keyed by the connection that is
|
|
5451
|
+
// now gone, so every held grant has to be presented again. A grant that
|
|
5452
|
+
// expired while the socket was down is refused, the refusal drops only that
|
|
5453
|
+
// channel's registration, and — since nothing is waiting on a re-issue —
|
|
5454
|
+
// it is announced as `channelSubscribeFailed` (CR3184-005).
|
|
5455
|
+
try {
|
|
5456
|
+
if (this.isWebSocketOpen()) {
|
|
5457
|
+
// R3184-002 first: a subscribe made while the socket was down is
|
|
5458
|
+
// already waiting for an ack and has sent nothing. Its frame goes now,
|
|
5459
|
+
// and the re-issue pass below skips it — a second attempt would have
|
|
5460
|
+
// to queue behind the first, which can then only end in its timeout.
|
|
5461
|
+
const flushed = this.flushUnsentChannelSubscribes();
|
|
5462
|
+
// A SNAPSHOT: the re-issues below register their own grants as they go.
|
|
5463
|
+
for (const [channel, held] of [...this.channelGrants]) {
|
|
5464
|
+
if (flushed.has(channel))
|
|
5465
|
+
continue;
|
|
5466
|
+
this.reissueChannelSubscribe(channel, held.grant);
|
|
5467
|
+
logger.debug("[channel] re-subscribed on reconnect", { channel });
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5471
|
+
catch (err) {
|
|
5472
|
+
logger.debug("[channel] reconnect re-subscribe pass failed", {
|
|
5473
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5474
|
+
});
|
|
5475
|
+
}
|
|
5405
5476
|
logger.log(`[CONNECT] Evaluating sync for ${this.docManager.getOpenDocCount()} open documents`);
|
|
5406
5477
|
this.docManager.forEachOpenDoc((documentId, _doc) => {
|
|
5407
5478
|
const startMode = this.docManager.getStartNetworkMode(documentId) || "afterIndexedDb";
|
|
@@ -6561,7 +6632,18 @@ export class JsBaoClient extends Observable {
|
|
|
6561
6632
|
try {
|
|
6562
6633
|
const data = JSON.parse(messageData);
|
|
6563
6634
|
logger.log(`🔄 Processing type: ${data.type}`);
|
|
6564
|
-
if (data.type === "error") {
|
|
6635
|
+
if (data.type === "error" && data.context === "channel.subscribe") {
|
|
6636
|
+
// #3184 — a refused subscribe is a scoped answer to one request, not a
|
|
6637
|
+
// fault on the connection, so it settles that channel's pending call
|
|
6638
|
+
// instead of being emitted as a connection error. The frame echoes the
|
|
6639
|
+
// channel precisely so this can find the right one.
|
|
6640
|
+
const channel = typeof data.channel === "string" ? data.channel : "";
|
|
6641
|
+
logger.warn("Channel subscribe refused", { channel });
|
|
6642
|
+
if (channel) {
|
|
6643
|
+
this.settleChannelSubscribe(channel, new Error(`subscribeToChannel: ${data.message || "the grant was not accepted"}`));
|
|
6644
|
+
}
|
|
6645
|
+
}
|
|
6646
|
+
else if (data.type === "error") {
|
|
6565
6647
|
logger.error("Server error:", data.message, {
|
|
6566
6648
|
documentId: data.documentId,
|
|
6567
6649
|
messageType: data.messageType,
|
|
@@ -6889,6 +6971,17 @@ export class JsBaoClient extends Observable {
|
|
|
6889
6971
|
else if (data.type === "direct.message") {
|
|
6890
6972
|
this.handleDirectMessage(data);
|
|
6891
6973
|
}
|
|
6974
|
+
else if (data.type === "channel.subscribed") {
|
|
6975
|
+
this.handleChannelSubscribed(data);
|
|
6976
|
+
}
|
|
6977
|
+
else if (data.type === "channel.unsubscribed") {
|
|
6978
|
+
// Nothing to settle: `unsubscribeFromChannel` has already dropped the
|
|
6979
|
+
// registration, and the ack exists so a client CAN wait for it.
|
|
6980
|
+
logger.debug("[channel] unsubscribed", { channel: data?.channel });
|
|
6981
|
+
}
|
|
6982
|
+
else if (data.type === "channel.message") {
|
|
6983
|
+
this.handleChannelMessage(data);
|
|
6984
|
+
}
|
|
6892
6985
|
else if (data.type === "workflowStatus") {
|
|
6893
6986
|
this.handleWorkflowStatusMessage(data);
|
|
6894
6987
|
}
|
|
@@ -6972,6 +7065,10 @@ export class JsBaoClient extends Observable {
|
|
|
6972
7065
|
}
|
|
6973
7066
|
catch { }
|
|
6974
7067
|
this.clearAllSyncWatchdogs("ws-close");
|
|
7068
|
+
// #3184 / SO3184-006 — the answers these were waiting for cannot arrive on
|
|
7069
|
+
// a socket that is gone. The held grants survive; the reconnect pass
|
|
7070
|
+
// presents them again.
|
|
7071
|
+
this.abortPendingChannelSubscribes("the connection closed");
|
|
6975
7072
|
this.docManager.clearPendingSyncOperations();
|
|
6976
7073
|
this.docManager.forEachOpenDoc((documentId, _doc) => this._updateSynced(documentId, false));
|
|
6977
7074
|
for (const documentId of this.docManager.listAwarenessDocIds()) {
|
|
@@ -7311,6 +7408,23 @@ export class JsBaoClient extends Observable {
|
|
|
7311
7408
|
catch { }
|
|
7312
7409
|
try {
|
|
7313
7410
|
this.dbSubscriptions?.clear();
|
|
7411
|
+
this.channelGrants.clear();
|
|
7412
|
+
this.channelSubscribeChain.clear();
|
|
7413
|
+
// R3184-005 — every queued continuation belongs to a generation that is
|
|
7414
|
+
// over, so none of them re-registers a channel on the way down.
|
|
7415
|
+
for (const channel of [
|
|
7416
|
+
...this.channelEpochs.keys(),
|
|
7417
|
+
...this.pendingChannelSubscribes.keys(),
|
|
7418
|
+
]) {
|
|
7419
|
+
this.channelEpochs.set(channel, this.channelEpoch(channel) + 1);
|
|
7420
|
+
}
|
|
7421
|
+
for (const [channel, waiting] of this.pendingChannelSubscribes) {
|
|
7422
|
+
for (const entry of waiting) {
|
|
7423
|
+
clearTimeout(entry.timer);
|
|
7424
|
+
entry.reject(new Error(`subscribeToChannel: client destroyed while joining '${channel}'`));
|
|
7425
|
+
}
|
|
7426
|
+
}
|
|
7427
|
+
this.pendingChannelSubscribes.clear();
|
|
7314
7428
|
}
|
|
7315
7429
|
catch { }
|
|
7316
7430
|
super.destroy();
|
|
@@ -8256,6 +8370,315 @@ export class JsBaoClient extends Observable {
|
|
|
8256
8370
|
async hasOfflineGrantStored() {
|
|
8257
8371
|
return this.auth.hasOfflineGrantStored();
|
|
8258
8372
|
}
|
|
8373
|
+
/**
|
|
8374
|
+
* Join a channel a server function authorized.
|
|
8375
|
+
*
|
|
8376
|
+
* `grant` is the token `ctx.channels.authorize` handed back: a signed,
|
|
8377
|
+
* short-lived credential naming this app, this channel and this user. The
|
|
8378
|
+
* promise resolves on the server's ack FOR THIS CHANNEL, so concurrent
|
|
8379
|
+
* subscribes cannot resolve each other, and rejects on the server's uniform
|
|
8380
|
+
* refusal — an expired, tampered, cross-app or cross-user grant are
|
|
8381
|
+
* deliberately indistinguishable, so the rejection says only that the grant
|
|
8382
|
+
* was not accepted.
|
|
8383
|
+
*
|
|
8384
|
+
* Calling it again with a fresh grant RENEWS the membership: expiry is the
|
|
8385
|
+
* only revocation a channel has, so an app that wants a long-lived channel
|
|
8386
|
+
* re-invokes its authorizing function before `expiresAt` and subscribes
|
|
8387
|
+
* again. Frames arrive as `client.on("channelMessage", …)`.
|
|
8388
|
+
*
|
|
8389
|
+
* Two calls for the SAME channel run one after the other, so a renewal
|
|
8390
|
+
* issued while an earlier subscribe is still in flight is answered on its own
|
|
8391
|
+
* merits rather than by whichever frame arrives first.
|
|
8392
|
+
*
|
|
8393
|
+
* @group Realtime
|
|
8394
|
+
*/
|
|
8395
|
+
subscribeToChannel(channel, grant) {
|
|
8396
|
+
if (!channel || typeof channel !== "string") {
|
|
8397
|
+
return Promise.reject(new Error("subscribeToChannel: channel is required"));
|
|
8398
|
+
}
|
|
8399
|
+
if (!grant || typeof grant !== "string") {
|
|
8400
|
+
return Promise.reject(new Error("subscribeToChannel: a channel grant is required"));
|
|
8401
|
+
}
|
|
8402
|
+
// CR3184-004 — queued behind whatever this channel is already waiting on,
|
|
8403
|
+
// however that one ends. The previous call's outcome is its caller's;
|
|
8404
|
+
// this one only needs the wire to itself.
|
|
8405
|
+
//
|
|
8406
|
+
// R3184-005 — and only while the app still wants this channel. An
|
|
8407
|
+
// `unsubscribeFromChannel` between queueing and running means this
|
|
8408
|
+
// continuation must not resurrect the membership it just left.
|
|
8409
|
+
const epoch = this.channelEpoch(channel);
|
|
8410
|
+
const previous = this.channelSubscribeChain.get(channel);
|
|
8411
|
+
const attempt = (previous ? previous.catch(() => { }) : Promise.resolve())
|
|
8412
|
+
.then(() => {
|
|
8413
|
+
if (this.channelEpoch(channel) !== epoch) {
|
|
8414
|
+
throw new Error(`subscribeToChannel: '${channel}' was left before this subscribe ran`);
|
|
8415
|
+
}
|
|
8416
|
+
return this.startChannelSubscribe(channel, grant);
|
|
8417
|
+
});
|
|
8418
|
+
this.channelSubscribeChain.set(channel, attempt.catch(() => { }));
|
|
8419
|
+
return attempt;
|
|
8420
|
+
}
|
|
8421
|
+
/** The generation `unsubscribeFromChannel` bumps — R3184-005. */
|
|
8422
|
+
channelEpoch(channel) {
|
|
8423
|
+
return this.channelEpochs.get(channel) ?? 0;
|
|
8424
|
+
}
|
|
8425
|
+
/**
|
|
8426
|
+
* Re-present a stored grant after a reconnect — through the SAME chain an
|
|
8427
|
+
* explicit subscribe uses (SO3184-006).
|
|
8428
|
+
*
|
|
8429
|
+
* The reconnect pass used to write straight to the socket, which put an
|
|
8430
|
+
* attempt on the wire that the per-channel bookkeeping knew nothing about.
|
|
8431
|
+
* The server's answer names the channel and nothing finer, so a refusal of
|
|
8432
|
+
* the re-issued (possibly expired) grant would settle a RENEWAL the app made
|
|
8433
|
+
* in the meantime: the fresh subscribe would reject and its registration
|
|
8434
|
+
* would be deleted, while the server had accepted it and was delivering to
|
|
8435
|
+
* it. Chaining keeps every answer attributable to the request that asked for
|
|
8436
|
+
* it, exactly as `subscribeToChannel` does.
|
|
8437
|
+
*
|
|
8438
|
+
* Nobody is waiting on this one, so its refusal is announced as
|
|
8439
|
+
* `channelSubscribeFailed` (CR3184-005) rather than rejecting a promise.
|
|
8440
|
+
*/
|
|
8441
|
+
reissueChannelSubscribe(channel, grant) {
|
|
8442
|
+
const epoch = this.channelEpoch(channel);
|
|
8443
|
+
const previous = this.channelSubscribeChain.get(channel);
|
|
8444
|
+
const attempt = (previous ? previous.catch(() => { }) : Promise.resolve())
|
|
8445
|
+
.then(() => {
|
|
8446
|
+
if (this.channelEpoch(channel) !== epoch) {
|
|
8447
|
+
throw new Error(`subscribeToChannel: '${channel}' was left before this subscribe ran`);
|
|
8448
|
+
}
|
|
8449
|
+
return this.startChannelSubscribe(channel, grant);
|
|
8450
|
+
});
|
|
8451
|
+
this.channelSubscribeChain.set(channel, attempt.catch(() => { }));
|
|
8452
|
+
attempt.catch((error) => {
|
|
8453
|
+
// A re-issue the app cancelled is not a failure to announce.
|
|
8454
|
+
if (this.channelEpoch(channel) !== epoch)
|
|
8455
|
+
return;
|
|
8456
|
+
// A re-issue the socket carried away is not a refusal: keep the
|
|
8457
|
+
// registration so the next reconnect presents the grant again, and say
|
|
8458
|
+
// nothing. Only the SERVER's answer removes a membership.
|
|
8459
|
+
if (!this.isWebSocketOpen()) {
|
|
8460
|
+
if (!this.channelGrants.has(channel)) {
|
|
8461
|
+
this.channelGrants.set(channel, { grant, expiresAt: 0 });
|
|
8462
|
+
}
|
|
8463
|
+
return;
|
|
8464
|
+
}
|
|
8465
|
+
try {
|
|
8466
|
+
this.emit("channelSubscribeFailed", [
|
|
8467
|
+
{
|
|
8468
|
+
channel,
|
|
8469
|
+
message: error instanceof Error ? error.message : String(error),
|
|
8470
|
+
},
|
|
8471
|
+
]);
|
|
8472
|
+
}
|
|
8473
|
+
catch (err) {
|
|
8474
|
+
logger.debug("[channel] subscribe-failed emit failed", {
|
|
8475
|
+
channel,
|
|
8476
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8477
|
+
});
|
|
8478
|
+
}
|
|
8479
|
+
});
|
|
8480
|
+
}
|
|
8481
|
+
/**
|
|
8482
|
+
* Give up on every subscribe waiting for an answer, because the socket that
|
|
8483
|
+
* would have carried it is gone (SO3184-006).
|
|
8484
|
+
*
|
|
8485
|
+
* The GRANTS stay: the socket failed, not the credential, and the reconnect
|
|
8486
|
+
* pass presents each one again. What must not stay is a pending attempt —
|
|
8487
|
+
* its answer can never arrive, and leaving it in the per-channel chain would
|
|
8488
|
+
* hold the re-issue behind it for the full 20 s timeout.
|
|
8489
|
+
*/
|
|
8490
|
+
abortPendingChannelSubscribes(reason) {
|
|
8491
|
+
const pending = [...this.pendingChannelSubscribes];
|
|
8492
|
+
this.pendingChannelSubscribes.clear();
|
|
8493
|
+
this.channelSubscribeChain.clear();
|
|
8494
|
+
for (const [channel, waiting] of pending) {
|
|
8495
|
+
for (const entry of waiting) {
|
|
8496
|
+
clearTimeout(entry.timer);
|
|
8497
|
+
entry.reject(new Error(`subscribeToChannel: ${reason} while joining '${channel}'`));
|
|
8498
|
+
}
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
8501
|
+
startChannelSubscribe(channel, grant) {
|
|
8502
|
+
// Registered BEFORE the send, so a socket that drops between the two
|
|
8503
|
+
// re-issues this subscribe on reconnect rather than losing it.
|
|
8504
|
+
this.channelGrants.set(channel, { grant, expiresAt: 0 });
|
|
8505
|
+
return new Promise((resolve, reject) => {
|
|
8506
|
+
const waiting = this.pendingChannelSubscribes.get(channel) ?? [];
|
|
8507
|
+
const entry = {
|
|
8508
|
+
resolve,
|
|
8509
|
+
reject,
|
|
8510
|
+
timer: setTimeout(() => {
|
|
8511
|
+
this.settleChannelSubscribe(channel, new Error(`subscribeToChannel: no answer for '${channel}' within 20s`));
|
|
8512
|
+
}, 20000),
|
|
8513
|
+
grant,
|
|
8514
|
+
sent: false,
|
|
8515
|
+
};
|
|
8516
|
+
waiting.push(entry);
|
|
8517
|
+
this.pendingChannelSubscribes.set(channel, waiting);
|
|
8518
|
+
if (this.isWebSocketOpen()) {
|
|
8519
|
+
entry.sent = true;
|
|
8520
|
+
this.sendChannelSubscribe(channel, grant);
|
|
8521
|
+
}
|
|
8522
|
+
else {
|
|
8523
|
+
// Nothing went out. `flushUnsentChannelSubscribes` sends it the moment
|
|
8524
|
+
// the socket opens (R3184-002) — queueing a re-issue behind it instead
|
|
8525
|
+
// would leave this call waiting for its own 20 s timeout, because the
|
|
8526
|
+
// re-issue cannot start until this attempt settles.
|
|
8527
|
+
try {
|
|
8528
|
+
this.wsManager.connect();
|
|
8529
|
+
}
|
|
8530
|
+
catch { }
|
|
8531
|
+
}
|
|
8532
|
+
});
|
|
8533
|
+
}
|
|
8534
|
+
/**
|
|
8535
|
+
* Put the subscribes that never reached the wire onto it — R3184-002.
|
|
8536
|
+
*
|
|
8537
|
+
* A call made while the socket was down registered its grant, created its
|
|
8538
|
+
* promise and sent nothing. On open its frame is sent for the attempt that
|
|
8539
|
+
* is already waiting, so the ack settles the original call. Channels with
|
|
8540
|
+
* nothing pending go the other way, through the ordinary re-issue.
|
|
8541
|
+
*
|
|
8542
|
+
* Returns the channels it sent for, so the reconnect pass does not queue a
|
|
8543
|
+
* second attempt for them.
|
|
8544
|
+
*/
|
|
8545
|
+
flushUnsentChannelSubscribes() {
|
|
8546
|
+
const flushed = new Set();
|
|
8547
|
+
for (const [channel, waiting] of this.pendingChannelSubscribes) {
|
|
8548
|
+
const unsent = waiting.filter((entry) => !entry.sent);
|
|
8549
|
+
if (unsent.length === 0)
|
|
8550
|
+
continue;
|
|
8551
|
+
// The newest attempt's grant: it is the one the caller most recently
|
|
8552
|
+
// asked for, and `channelGrants` already holds it for the reconnect pass.
|
|
8553
|
+
const grant = unsent[unsent.length - 1].grant;
|
|
8554
|
+
for (const entry of unsent)
|
|
8555
|
+
entry.sent = true;
|
|
8556
|
+
flushed.add(channel);
|
|
8557
|
+
this.sendChannelSubscribe(channel, grant);
|
|
8558
|
+
logger.debug("[channel] sent a subscribe held back by a closed socket", {
|
|
8559
|
+
channel,
|
|
8560
|
+
});
|
|
8561
|
+
}
|
|
8562
|
+
return flushed;
|
|
8563
|
+
}
|
|
8564
|
+
/**
|
|
8565
|
+
* Leave a channel. Idempotent, and safe on a closed socket: the
|
|
8566
|
+
* registration goes either way, so a reconnect does not bring it back.
|
|
8567
|
+
*
|
|
8568
|
+
* A subscribe still in flight, or queued behind one, is cancelled with it:
|
|
8569
|
+
* leaving means leaving, so a queued renewal cannot put the membership back
|
|
8570
|
+
* afterwards, and a `subscribeToChannel` promise still waiting for its ack
|
|
8571
|
+
* rejects rather than resolving with a subscription to a channel this client
|
|
8572
|
+
* has already left.
|
|
8573
|
+
*
|
|
8574
|
+
* @group Realtime
|
|
8575
|
+
*/
|
|
8576
|
+
unsubscribeFromChannel(channel) {
|
|
8577
|
+
this.channelGrants.delete(channel);
|
|
8578
|
+
this.channelSubscribeChain.delete(channel);
|
|
8579
|
+
// Bumped BEFORE the pending calls are settled: a rejection handler that
|
|
8580
|
+
// subscribes again should be starting a new generation, not racing this one.
|
|
8581
|
+
this.channelEpochs.set(channel, this.channelEpoch(channel) + 1);
|
|
8582
|
+
const waiting = this.pendingChannelSubscribes.get(channel);
|
|
8583
|
+
if (waiting && waiting.length > 0) {
|
|
8584
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
8585
|
+
for (const entry of waiting) {
|
|
8586
|
+
clearTimeout(entry.timer);
|
|
8587
|
+
entry.reject(new Error(`subscribeToChannel: '${channel}' was left while joining`));
|
|
8588
|
+
}
|
|
8589
|
+
}
|
|
8590
|
+
else {
|
|
8591
|
+
// Nothing was waiting, so there is nothing to reject and no
|
|
8592
|
+
// `channelSubscribeFailed` to announce: leaving is what the app asked for.
|
|
8593
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
8594
|
+
}
|
|
8595
|
+
try {
|
|
8596
|
+
if (this.isWebSocketOpen()) {
|
|
8597
|
+
this.ws?.send(JSON.stringify({ type: "channel.unsubscribe", channel }));
|
|
8598
|
+
}
|
|
8599
|
+
}
|
|
8600
|
+
catch (err) {
|
|
8601
|
+
logger.debug("[channel] unsubscribe send failed", {
|
|
8602
|
+
channel,
|
|
8603
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8604
|
+
});
|
|
8605
|
+
}
|
|
8606
|
+
}
|
|
8607
|
+
sendChannelSubscribe(channel, grant) {
|
|
8608
|
+
try {
|
|
8609
|
+
this.ws?.send(JSON.stringify({ type: "channel.subscribe", channel, grant }));
|
|
8610
|
+
}
|
|
8611
|
+
catch (err) {
|
|
8612
|
+
logger.debug("[channel] subscribe send failed", {
|
|
8613
|
+
channel,
|
|
8614
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8615
|
+
});
|
|
8616
|
+
}
|
|
8617
|
+
}
|
|
8618
|
+
/**
|
|
8619
|
+
* Settle every call waiting on one channel — with the ack, or with the
|
|
8620
|
+
* refusal. Only that channel's waiters and only that channel's
|
|
8621
|
+
* registration: a client with several subscriptions must not lose the ones
|
|
8622
|
+
* that worked (D3184-007).
|
|
8623
|
+
*/
|
|
8624
|
+
settleChannelSubscribe(channel, outcome) {
|
|
8625
|
+
const waiting = this.pendingChannelSubscribes.get(channel);
|
|
8626
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
8627
|
+
if (outcome instanceof Error) {
|
|
8628
|
+
this.channelGrants.delete(channel);
|
|
8629
|
+
if (!waiting || waiting.length === 0) {
|
|
8630
|
+
// CR3184-005 — nobody asked for this one, so a rejected promise cannot
|
|
8631
|
+
// report it. That is the reconnect re-issue: the client presented a
|
|
8632
|
+
// stored grant on its own initiative and the server refused it, and
|
|
8633
|
+
// silently dropping the registration would leave the app believing it
|
|
8634
|
+
// was still in a channel it had just been removed from.
|
|
8635
|
+
try {
|
|
8636
|
+
this.emit("channelSubscribeFailed", [
|
|
8637
|
+
{ channel, message: outcome.message },
|
|
8638
|
+
]);
|
|
8639
|
+
}
|
|
8640
|
+
catch (err) {
|
|
8641
|
+
logger.debug("[channel] subscribe-failed emit failed", {
|
|
8642
|
+
channel,
|
|
8643
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8644
|
+
});
|
|
8645
|
+
}
|
|
8646
|
+
}
|
|
8647
|
+
}
|
|
8648
|
+
for (const entry of waiting ?? []) {
|
|
8649
|
+
clearTimeout(entry.timer);
|
|
8650
|
+
if (outcome instanceof Error) {
|
|
8651
|
+
entry.reject(outcome);
|
|
8652
|
+
}
|
|
8653
|
+
else {
|
|
8654
|
+
entry.resolve({
|
|
8655
|
+
channel,
|
|
8656
|
+
expiresAt: outcome.expiresAt,
|
|
8657
|
+
unsubscribe: () => this.unsubscribeFromChannel(channel),
|
|
8658
|
+
});
|
|
8659
|
+
}
|
|
8660
|
+
}
|
|
8661
|
+
}
|
|
8662
|
+
handleChannelSubscribed(data) {
|
|
8663
|
+
const channel = typeof data?.channel === "string" ? data.channel : "";
|
|
8664
|
+
if (!channel)
|
|
8665
|
+
return;
|
|
8666
|
+
const expiresAt = Number(data?.expiresAt) || 0;
|
|
8667
|
+
const stored = this.channelGrants.get(channel);
|
|
8668
|
+
if (stored)
|
|
8669
|
+
this.channelGrants.set(channel, { ...stored, expiresAt });
|
|
8670
|
+
this.settleChannelSubscribe(channel, { expiresAt });
|
|
8671
|
+
}
|
|
8672
|
+
handleChannelMessage(data) {
|
|
8673
|
+
try {
|
|
8674
|
+
this.emit("channelMessage", [data]);
|
|
8675
|
+
}
|
|
8676
|
+
catch (err) {
|
|
8677
|
+
// A frame nobody is listening for is not an error, exactly as for
|
|
8678
|
+
// `directMessage`: an unknown-to-this-app channel must be inert.
|
|
8679
|
+
logger.debug("[channel] client emit failed", { error: err });
|
|
8680
|
+
}
|
|
8681
|
+
}
|
|
8259
8682
|
/**
|
|
8260
8683
|
* Internal entry point for DatabasesAPI.subscribe(). Ensures the WS is
|
|
8261
8684
|
* connected, registers the callback with the registry, sends the
|
|
@@ -8,6 +8,19 @@ import type { JsBaoClient, ClaimApplyResult, ConfirmApplyResult, ReleaseApplyRes
|
|
|
8
8
|
*/
|
|
9
9
|
/** How a synchronous invocation settled. */
|
|
10
10
|
export type FunctionInvokeStatus = "completed" | "failed" | "timeout";
|
|
11
|
+
/**
|
|
12
|
+
* The resolved platform ceilings an invocation ran under.
|
|
13
|
+
*
|
|
14
|
+
* `[function.limits]` clamped element-wise to the platform's own values, so a
|
|
15
|
+
* function may lower a ceiling and never raise one. Reported because the
|
|
16
|
+
* declared value and the enforced value are not always the same number: the
|
|
17
|
+
* platform enforces `cpuMs` with a minimum of its own.
|
|
18
|
+
*/
|
|
19
|
+
export interface FunctionInvokeLimits {
|
|
20
|
+
cpuMs: number;
|
|
21
|
+
subRequests: number;
|
|
22
|
+
ratePerMinute: number;
|
|
23
|
+
}
|
|
11
24
|
/**
|
|
12
25
|
* The invoke envelope.
|
|
13
26
|
*
|
|
@@ -24,6 +37,14 @@ export interface FunctionInvokeResult<TOutput = unknown> {
|
|
|
24
37
|
/** Present when `status` is `"failed"`. */
|
|
25
38
|
error?: string;
|
|
26
39
|
errorCode?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The ceilings the sandbox ran under.
|
|
42
|
+
*
|
|
43
|
+
* Present on every outcome the sandbox produced; absent from a refusal that
|
|
44
|
+
* never reached it, which has no run to describe — and those refusals are
|
|
45
|
+
* HTTP errors, so a caller holding this envelope has one.
|
|
46
|
+
*/
|
|
47
|
+
limits?: FunctionInvokeLimits;
|
|
27
48
|
}
|
|
28
49
|
/**
|
|
29
50
|
* What to send with an invocation. Every field is optional — a function that
|
package/dist/browser.umd.js
CHANGED
|
@@ -16419,6 +16419,50 @@
|
|
|
16419
16419
|
this.serviceWorkerBridgeLastToken = null;
|
|
16420
16420
|
this.pendingSelfRemovalDocs = new Set();
|
|
16421
16421
|
this.pendingSelfRemovalTimers = new Map();
|
|
16422
|
+
/**
|
|
16423
|
+
* Channel memberships this client is holding, `channel → grant` (#3184).
|
|
16424
|
+
*
|
|
16425
|
+
* The GRANT is stored, not just the channel, because a membership is keyed
|
|
16426
|
+
* by a connection: after a reconnect the server has no record of it, and the
|
|
16427
|
+
* only thing that re-establishes one is presenting the credential again. A
|
|
16428
|
+
* grant that has expired in the meantime is refused, and the refusal drops
|
|
16429
|
+
* exactly that channel's registration.
|
|
16430
|
+
*/
|
|
16431
|
+
this.channelGrants = new Map();
|
|
16432
|
+
/**
|
|
16433
|
+
* Subscribes waiting for their own channel's ack (D3184-007).
|
|
16434
|
+
*
|
|
16435
|
+
* `sent` records whether the frame this call is waiting on ever reached the
|
|
16436
|
+
* wire — R3184-002. A subscribe made before the socket opened registers its
|
|
16437
|
+
* grant and nudges the connection, but sends nothing; the open handler has
|
|
16438
|
+
* to send THAT attempt rather than queue a second one behind it, or the
|
|
16439
|
+
* caller waits out the full 20 s timeout on a connection that came up
|
|
16440
|
+
* immediately.
|
|
16441
|
+
*/
|
|
16442
|
+
this.pendingChannelSubscribes = new Map();
|
|
16443
|
+
/**
|
|
16444
|
+
* How many times this channel has been left — R3184-005.
|
|
16445
|
+
*
|
|
16446
|
+
* A subscribe queued behind an in-flight one is a continuation that has not
|
|
16447
|
+
* run yet, and `unsubscribeFromChannel` cannot cancel a `.then`. Without a
|
|
16448
|
+
* generation, leaving a channel while a renewal was queued would run that
|
|
16449
|
+
* renewal afterwards: the registration would come back, a subscribe would go
|
|
16450
|
+
* out, and the client would be delivered to on a channel it had explicitly
|
|
16451
|
+
* left. Each attempt captures the count it was created under and does
|
|
16452
|
+
* nothing if it has moved.
|
|
16453
|
+
*/
|
|
16454
|
+
this.channelEpochs = new Map();
|
|
16455
|
+
/**
|
|
16456
|
+
* One subscribe at a time per channel — CR3184-004.
|
|
16457
|
+
*
|
|
16458
|
+
* The server answers a subscribe with a frame that names the CHANNEL and
|
|
16459
|
+
* nothing finer, which is what lets subscriptions to DIFFERENT channels be
|
|
16460
|
+
* told apart. Two requests for the SAME channel cannot be: the first answer
|
|
16461
|
+
* would settle both, so an expired grant racing a renewal would reject the
|
|
16462
|
+
* renewal too and drop a registration the server had accepted. Chaining them
|
|
16463
|
+
* keeps every answer attributable to the request that asked for it.
|
|
16464
|
+
*/
|
|
16465
|
+
this.channelSubscribeChain = new Map();
|
|
16422
16466
|
this.workflowApplyHandlers = new Map();
|
|
16423
16467
|
this.applyingTokenFromController = false;
|
|
16424
16468
|
this.controllerPreviousToken = null;
|
|
@@ -21274,6 +21318,33 @@
|
|
|
21274
21318
|
error: err instanceof Error ? err.message : String(err),
|
|
21275
21319
|
});
|
|
21276
21320
|
}
|
|
21321
|
+
// #3184 — the same reconnect problem, one credential heavier. A channel
|
|
21322
|
+
// membership is a `ConnectionMapping` row keyed by the connection that is
|
|
21323
|
+
// now gone, so every held grant has to be presented again. A grant that
|
|
21324
|
+
// expired while the socket was down is refused, the refusal drops only that
|
|
21325
|
+
// channel's registration, and — since nothing is waiting on a re-issue —
|
|
21326
|
+
// it is announced as `channelSubscribeFailed` (CR3184-005).
|
|
21327
|
+
try {
|
|
21328
|
+
if (this.isWebSocketOpen()) {
|
|
21329
|
+
// R3184-002 first: a subscribe made while the socket was down is
|
|
21330
|
+
// already waiting for an ack and has sent nothing. Its frame goes now,
|
|
21331
|
+
// and the re-issue pass below skips it — a second attempt would have
|
|
21332
|
+
// to queue behind the first, which can then only end in its timeout.
|
|
21333
|
+
const flushed = this.flushUnsentChannelSubscribes();
|
|
21334
|
+
// A SNAPSHOT: the re-issues below register their own grants as they go.
|
|
21335
|
+
for (const [channel, held] of [...this.channelGrants]) {
|
|
21336
|
+
if (flushed.has(channel))
|
|
21337
|
+
continue;
|
|
21338
|
+
this.reissueChannelSubscribe(channel, held.grant);
|
|
21339
|
+
logger.debug("[channel] re-subscribed on reconnect", { channel });
|
|
21340
|
+
}
|
|
21341
|
+
}
|
|
21342
|
+
}
|
|
21343
|
+
catch (err) {
|
|
21344
|
+
logger.debug("[channel] reconnect re-subscribe pass failed", {
|
|
21345
|
+
error: err instanceof Error ? err.message : String(err),
|
|
21346
|
+
});
|
|
21347
|
+
}
|
|
21277
21348
|
logger.log(`[CONNECT] Evaluating sync for ${this.docManager.getOpenDocCount()} open documents`);
|
|
21278
21349
|
this.docManager.forEachOpenDoc((documentId, _doc) => {
|
|
21279
21350
|
const startMode = this.docManager.getStartNetworkMode(documentId) || "afterIndexedDb";
|
|
@@ -22433,7 +22504,18 @@
|
|
|
22433
22504
|
try {
|
|
22434
22505
|
const data = JSON.parse(messageData);
|
|
22435
22506
|
logger.log(`🔄 Processing type: ${data.type}`);
|
|
22436
|
-
if (data.type === "error") {
|
|
22507
|
+
if (data.type === "error" && data.context === "channel.subscribe") {
|
|
22508
|
+
// #3184 — a refused subscribe is a scoped answer to one request, not a
|
|
22509
|
+
// fault on the connection, so it settles that channel's pending call
|
|
22510
|
+
// instead of being emitted as a connection error. The frame echoes the
|
|
22511
|
+
// channel precisely so this can find the right one.
|
|
22512
|
+
const channel = typeof data.channel === "string" ? data.channel : "";
|
|
22513
|
+
logger.warn("Channel subscribe refused", { channel });
|
|
22514
|
+
if (channel) {
|
|
22515
|
+
this.settleChannelSubscribe(channel, new Error(`subscribeToChannel: ${data.message || "the grant was not accepted"}`));
|
|
22516
|
+
}
|
|
22517
|
+
}
|
|
22518
|
+
else if (data.type === "error") {
|
|
22437
22519
|
logger.error("Server error:", data.message, {
|
|
22438
22520
|
documentId: data.documentId,
|
|
22439
22521
|
messageType: data.messageType,
|
|
@@ -22761,6 +22843,17 @@
|
|
|
22761
22843
|
else if (data.type === "direct.message") {
|
|
22762
22844
|
this.handleDirectMessage(data);
|
|
22763
22845
|
}
|
|
22846
|
+
else if (data.type === "channel.subscribed") {
|
|
22847
|
+
this.handleChannelSubscribed(data);
|
|
22848
|
+
}
|
|
22849
|
+
else if (data.type === "channel.unsubscribed") {
|
|
22850
|
+
// Nothing to settle: `unsubscribeFromChannel` has already dropped the
|
|
22851
|
+
// registration, and the ack exists so a client CAN wait for it.
|
|
22852
|
+
logger.debug("[channel] unsubscribed", { channel: data?.channel });
|
|
22853
|
+
}
|
|
22854
|
+
else if (data.type === "channel.message") {
|
|
22855
|
+
this.handleChannelMessage(data);
|
|
22856
|
+
}
|
|
22764
22857
|
else if (data.type === "workflowStatus") {
|
|
22765
22858
|
this.handleWorkflowStatusMessage(data);
|
|
22766
22859
|
}
|
|
@@ -22844,6 +22937,10 @@
|
|
|
22844
22937
|
}
|
|
22845
22938
|
catch { }
|
|
22846
22939
|
this.clearAllSyncWatchdogs("ws-close");
|
|
22940
|
+
// #3184 / SO3184-006 — the answers these were waiting for cannot arrive on
|
|
22941
|
+
// a socket that is gone. The held grants survive; the reconnect pass
|
|
22942
|
+
// presents them again.
|
|
22943
|
+
this.abortPendingChannelSubscribes("the connection closed");
|
|
22847
22944
|
this.docManager.clearPendingSyncOperations();
|
|
22848
22945
|
this.docManager.forEachOpenDoc((documentId, _doc) => this._updateSynced(documentId, false));
|
|
22849
22946
|
for (const documentId of this.docManager.listAwarenessDocIds()) {
|
|
@@ -23180,6 +23277,23 @@
|
|
|
23180
23277
|
catch { }
|
|
23181
23278
|
try {
|
|
23182
23279
|
this.dbSubscriptions?.clear();
|
|
23280
|
+
this.channelGrants.clear();
|
|
23281
|
+
this.channelSubscribeChain.clear();
|
|
23282
|
+
// R3184-005 — every queued continuation belongs to a generation that is
|
|
23283
|
+
// over, so none of them re-registers a channel on the way down.
|
|
23284
|
+
for (const channel of [
|
|
23285
|
+
...this.channelEpochs.keys(),
|
|
23286
|
+
...this.pendingChannelSubscribes.keys(),
|
|
23287
|
+
]) {
|
|
23288
|
+
this.channelEpochs.set(channel, this.channelEpoch(channel) + 1);
|
|
23289
|
+
}
|
|
23290
|
+
for (const [channel, waiting] of this.pendingChannelSubscribes) {
|
|
23291
|
+
for (const entry of waiting) {
|
|
23292
|
+
clearTimeout(entry.timer);
|
|
23293
|
+
entry.reject(new Error(`subscribeToChannel: client destroyed while joining '${channel}'`));
|
|
23294
|
+
}
|
|
23295
|
+
}
|
|
23296
|
+
this.pendingChannelSubscribes.clear();
|
|
23183
23297
|
}
|
|
23184
23298
|
catch { }
|
|
23185
23299
|
super.destroy();
|
|
@@ -24125,6 +24239,315 @@
|
|
|
24125
24239
|
async hasOfflineGrantStored() {
|
|
24126
24240
|
return this.auth.hasOfflineGrantStored();
|
|
24127
24241
|
}
|
|
24242
|
+
/**
|
|
24243
|
+
* Join a channel a server function authorized.
|
|
24244
|
+
*
|
|
24245
|
+
* `grant` is the token `ctx.channels.authorize` handed back: a signed,
|
|
24246
|
+
* short-lived credential naming this app, this channel and this user. The
|
|
24247
|
+
* promise resolves on the server's ack FOR THIS CHANNEL, so concurrent
|
|
24248
|
+
* subscribes cannot resolve each other, and rejects on the server's uniform
|
|
24249
|
+
* refusal — an expired, tampered, cross-app or cross-user grant are
|
|
24250
|
+
* deliberately indistinguishable, so the rejection says only that the grant
|
|
24251
|
+
* was not accepted.
|
|
24252
|
+
*
|
|
24253
|
+
* Calling it again with a fresh grant RENEWS the membership: expiry is the
|
|
24254
|
+
* only revocation a channel has, so an app that wants a long-lived channel
|
|
24255
|
+
* re-invokes its authorizing function before `expiresAt` and subscribes
|
|
24256
|
+
* again. Frames arrive as `client.on("channelMessage", …)`.
|
|
24257
|
+
*
|
|
24258
|
+
* Two calls for the SAME channel run one after the other, so a renewal
|
|
24259
|
+
* issued while an earlier subscribe is still in flight is answered on its own
|
|
24260
|
+
* merits rather than by whichever frame arrives first.
|
|
24261
|
+
*
|
|
24262
|
+
* @group Realtime
|
|
24263
|
+
*/
|
|
24264
|
+
subscribeToChannel(channel, grant) {
|
|
24265
|
+
if (!channel || typeof channel !== "string") {
|
|
24266
|
+
return Promise.reject(new Error("subscribeToChannel: channel is required"));
|
|
24267
|
+
}
|
|
24268
|
+
if (!grant || typeof grant !== "string") {
|
|
24269
|
+
return Promise.reject(new Error("subscribeToChannel: a channel grant is required"));
|
|
24270
|
+
}
|
|
24271
|
+
// CR3184-004 — queued behind whatever this channel is already waiting on,
|
|
24272
|
+
// however that one ends. The previous call's outcome is its caller's;
|
|
24273
|
+
// this one only needs the wire to itself.
|
|
24274
|
+
//
|
|
24275
|
+
// R3184-005 — and only while the app still wants this channel. An
|
|
24276
|
+
// `unsubscribeFromChannel` between queueing and running means this
|
|
24277
|
+
// continuation must not resurrect the membership it just left.
|
|
24278
|
+
const epoch = this.channelEpoch(channel);
|
|
24279
|
+
const previous = this.channelSubscribeChain.get(channel);
|
|
24280
|
+
const attempt = (previous ? previous.catch(() => { }) : Promise.resolve())
|
|
24281
|
+
.then(() => {
|
|
24282
|
+
if (this.channelEpoch(channel) !== epoch) {
|
|
24283
|
+
throw new Error(`subscribeToChannel: '${channel}' was left before this subscribe ran`);
|
|
24284
|
+
}
|
|
24285
|
+
return this.startChannelSubscribe(channel, grant);
|
|
24286
|
+
});
|
|
24287
|
+
this.channelSubscribeChain.set(channel, attempt.catch(() => { }));
|
|
24288
|
+
return attempt;
|
|
24289
|
+
}
|
|
24290
|
+
/** The generation `unsubscribeFromChannel` bumps — R3184-005. */
|
|
24291
|
+
channelEpoch(channel) {
|
|
24292
|
+
return this.channelEpochs.get(channel) ?? 0;
|
|
24293
|
+
}
|
|
24294
|
+
/**
|
|
24295
|
+
* Re-present a stored grant after a reconnect — through the SAME chain an
|
|
24296
|
+
* explicit subscribe uses (SO3184-006).
|
|
24297
|
+
*
|
|
24298
|
+
* The reconnect pass used to write straight to the socket, which put an
|
|
24299
|
+
* attempt on the wire that the per-channel bookkeeping knew nothing about.
|
|
24300
|
+
* The server's answer names the channel and nothing finer, so a refusal of
|
|
24301
|
+
* the re-issued (possibly expired) grant would settle a RENEWAL the app made
|
|
24302
|
+
* in the meantime: the fresh subscribe would reject and its registration
|
|
24303
|
+
* would be deleted, while the server had accepted it and was delivering to
|
|
24304
|
+
* it. Chaining keeps every answer attributable to the request that asked for
|
|
24305
|
+
* it, exactly as `subscribeToChannel` does.
|
|
24306
|
+
*
|
|
24307
|
+
* Nobody is waiting on this one, so its refusal is announced as
|
|
24308
|
+
* `channelSubscribeFailed` (CR3184-005) rather than rejecting a promise.
|
|
24309
|
+
*/
|
|
24310
|
+
reissueChannelSubscribe(channel, grant) {
|
|
24311
|
+
const epoch = this.channelEpoch(channel);
|
|
24312
|
+
const previous = this.channelSubscribeChain.get(channel);
|
|
24313
|
+
const attempt = (previous ? previous.catch(() => { }) : Promise.resolve())
|
|
24314
|
+
.then(() => {
|
|
24315
|
+
if (this.channelEpoch(channel) !== epoch) {
|
|
24316
|
+
throw new Error(`subscribeToChannel: '${channel}' was left before this subscribe ran`);
|
|
24317
|
+
}
|
|
24318
|
+
return this.startChannelSubscribe(channel, grant);
|
|
24319
|
+
});
|
|
24320
|
+
this.channelSubscribeChain.set(channel, attempt.catch(() => { }));
|
|
24321
|
+
attempt.catch((error) => {
|
|
24322
|
+
// A re-issue the app cancelled is not a failure to announce.
|
|
24323
|
+
if (this.channelEpoch(channel) !== epoch)
|
|
24324
|
+
return;
|
|
24325
|
+
// A re-issue the socket carried away is not a refusal: keep the
|
|
24326
|
+
// registration so the next reconnect presents the grant again, and say
|
|
24327
|
+
// nothing. Only the SERVER's answer removes a membership.
|
|
24328
|
+
if (!this.isWebSocketOpen()) {
|
|
24329
|
+
if (!this.channelGrants.has(channel)) {
|
|
24330
|
+
this.channelGrants.set(channel, { grant, expiresAt: 0 });
|
|
24331
|
+
}
|
|
24332
|
+
return;
|
|
24333
|
+
}
|
|
24334
|
+
try {
|
|
24335
|
+
this.emit("channelSubscribeFailed", [
|
|
24336
|
+
{
|
|
24337
|
+
channel,
|
|
24338
|
+
message: error instanceof Error ? error.message : String(error),
|
|
24339
|
+
},
|
|
24340
|
+
]);
|
|
24341
|
+
}
|
|
24342
|
+
catch (err) {
|
|
24343
|
+
logger.debug("[channel] subscribe-failed emit failed", {
|
|
24344
|
+
channel,
|
|
24345
|
+
error: err instanceof Error ? err.message : String(err),
|
|
24346
|
+
});
|
|
24347
|
+
}
|
|
24348
|
+
});
|
|
24349
|
+
}
|
|
24350
|
+
/**
|
|
24351
|
+
* Give up on every subscribe waiting for an answer, because the socket that
|
|
24352
|
+
* would have carried it is gone (SO3184-006).
|
|
24353
|
+
*
|
|
24354
|
+
* The GRANTS stay: the socket failed, not the credential, and the reconnect
|
|
24355
|
+
* pass presents each one again. What must not stay is a pending attempt —
|
|
24356
|
+
* its answer can never arrive, and leaving it in the per-channel chain would
|
|
24357
|
+
* hold the re-issue behind it for the full 20 s timeout.
|
|
24358
|
+
*/
|
|
24359
|
+
abortPendingChannelSubscribes(reason) {
|
|
24360
|
+
const pending = [...this.pendingChannelSubscribes];
|
|
24361
|
+
this.pendingChannelSubscribes.clear();
|
|
24362
|
+
this.channelSubscribeChain.clear();
|
|
24363
|
+
for (const [channel, waiting] of pending) {
|
|
24364
|
+
for (const entry of waiting) {
|
|
24365
|
+
clearTimeout(entry.timer);
|
|
24366
|
+
entry.reject(new Error(`subscribeToChannel: ${reason} while joining '${channel}'`));
|
|
24367
|
+
}
|
|
24368
|
+
}
|
|
24369
|
+
}
|
|
24370
|
+
startChannelSubscribe(channel, grant) {
|
|
24371
|
+
// Registered BEFORE the send, so a socket that drops between the two
|
|
24372
|
+
// re-issues this subscribe on reconnect rather than losing it.
|
|
24373
|
+
this.channelGrants.set(channel, { grant, expiresAt: 0 });
|
|
24374
|
+
return new Promise((resolve, reject) => {
|
|
24375
|
+
const waiting = this.pendingChannelSubscribes.get(channel) ?? [];
|
|
24376
|
+
const entry = {
|
|
24377
|
+
resolve,
|
|
24378
|
+
reject,
|
|
24379
|
+
timer: setTimeout(() => {
|
|
24380
|
+
this.settleChannelSubscribe(channel, new Error(`subscribeToChannel: no answer for '${channel}' within 20s`));
|
|
24381
|
+
}, 20000),
|
|
24382
|
+
grant,
|
|
24383
|
+
sent: false,
|
|
24384
|
+
};
|
|
24385
|
+
waiting.push(entry);
|
|
24386
|
+
this.pendingChannelSubscribes.set(channel, waiting);
|
|
24387
|
+
if (this.isWebSocketOpen()) {
|
|
24388
|
+
entry.sent = true;
|
|
24389
|
+
this.sendChannelSubscribe(channel, grant);
|
|
24390
|
+
}
|
|
24391
|
+
else {
|
|
24392
|
+
// Nothing went out. `flushUnsentChannelSubscribes` sends it the moment
|
|
24393
|
+
// the socket opens (R3184-002) — queueing a re-issue behind it instead
|
|
24394
|
+
// would leave this call waiting for its own 20 s timeout, because the
|
|
24395
|
+
// re-issue cannot start until this attempt settles.
|
|
24396
|
+
try {
|
|
24397
|
+
this.wsManager.connect();
|
|
24398
|
+
}
|
|
24399
|
+
catch { }
|
|
24400
|
+
}
|
|
24401
|
+
});
|
|
24402
|
+
}
|
|
24403
|
+
/**
|
|
24404
|
+
* Put the subscribes that never reached the wire onto it — R3184-002.
|
|
24405
|
+
*
|
|
24406
|
+
* A call made while the socket was down registered its grant, created its
|
|
24407
|
+
* promise and sent nothing. On open its frame is sent for the attempt that
|
|
24408
|
+
* is already waiting, so the ack settles the original call. Channels with
|
|
24409
|
+
* nothing pending go the other way, through the ordinary re-issue.
|
|
24410
|
+
*
|
|
24411
|
+
* Returns the channels it sent for, so the reconnect pass does not queue a
|
|
24412
|
+
* second attempt for them.
|
|
24413
|
+
*/
|
|
24414
|
+
flushUnsentChannelSubscribes() {
|
|
24415
|
+
const flushed = new Set();
|
|
24416
|
+
for (const [channel, waiting] of this.pendingChannelSubscribes) {
|
|
24417
|
+
const unsent = waiting.filter((entry) => !entry.sent);
|
|
24418
|
+
if (unsent.length === 0)
|
|
24419
|
+
continue;
|
|
24420
|
+
// The newest attempt's grant: it is the one the caller most recently
|
|
24421
|
+
// asked for, and `channelGrants` already holds it for the reconnect pass.
|
|
24422
|
+
const grant = unsent[unsent.length - 1].grant;
|
|
24423
|
+
for (const entry of unsent)
|
|
24424
|
+
entry.sent = true;
|
|
24425
|
+
flushed.add(channel);
|
|
24426
|
+
this.sendChannelSubscribe(channel, grant);
|
|
24427
|
+
logger.debug("[channel] sent a subscribe held back by a closed socket", {
|
|
24428
|
+
channel,
|
|
24429
|
+
});
|
|
24430
|
+
}
|
|
24431
|
+
return flushed;
|
|
24432
|
+
}
|
|
24433
|
+
/**
|
|
24434
|
+
* Leave a channel. Idempotent, and safe on a closed socket: the
|
|
24435
|
+
* registration goes either way, so a reconnect does not bring it back.
|
|
24436
|
+
*
|
|
24437
|
+
* A subscribe still in flight, or queued behind one, is cancelled with it:
|
|
24438
|
+
* leaving means leaving, so a queued renewal cannot put the membership back
|
|
24439
|
+
* afterwards, and a `subscribeToChannel` promise still waiting for its ack
|
|
24440
|
+
* rejects rather than resolving with a subscription to a channel this client
|
|
24441
|
+
* has already left.
|
|
24442
|
+
*
|
|
24443
|
+
* @group Realtime
|
|
24444
|
+
*/
|
|
24445
|
+
unsubscribeFromChannel(channel) {
|
|
24446
|
+
this.channelGrants.delete(channel);
|
|
24447
|
+
this.channelSubscribeChain.delete(channel);
|
|
24448
|
+
// Bumped BEFORE the pending calls are settled: a rejection handler that
|
|
24449
|
+
// subscribes again should be starting a new generation, not racing this one.
|
|
24450
|
+
this.channelEpochs.set(channel, this.channelEpoch(channel) + 1);
|
|
24451
|
+
const waiting = this.pendingChannelSubscribes.get(channel);
|
|
24452
|
+
if (waiting && waiting.length > 0) {
|
|
24453
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
24454
|
+
for (const entry of waiting) {
|
|
24455
|
+
clearTimeout(entry.timer);
|
|
24456
|
+
entry.reject(new Error(`subscribeToChannel: '${channel}' was left while joining`));
|
|
24457
|
+
}
|
|
24458
|
+
}
|
|
24459
|
+
else {
|
|
24460
|
+
// Nothing was waiting, so there is nothing to reject and no
|
|
24461
|
+
// `channelSubscribeFailed` to announce: leaving is what the app asked for.
|
|
24462
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
24463
|
+
}
|
|
24464
|
+
try {
|
|
24465
|
+
if (this.isWebSocketOpen()) {
|
|
24466
|
+
this.ws?.send(JSON.stringify({ type: "channel.unsubscribe", channel }));
|
|
24467
|
+
}
|
|
24468
|
+
}
|
|
24469
|
+
catch (err) {
|
|
24470
|
+
logger.debug("[channel] unsubscribe send failed", {
|
|
24471
|
+
channel,
|
|
24472
|
+
error: err instanceof Error ? err.message : String(err),
|
|
24473
|
+
});
|
|
24474
|
+
}
|
|
24475
|
+
}
|
|
24476
|
+
sendChannelSubscribe(channel, grant) {
|
|
24477
|
+
try {
|
|
24478
|
+
this.ws?.send(JSON.stringify({ type: "channel.subscribe", channel, grant }));
|
|
24479
|
+
}
|
|
24480
|
+
catch (err) {
|
|
24481
|
+
logger.debug("[channel] subscribe send failed", {
|
|
24482
|
+
channel,
|
|
24483
|
+
error: err instanceof Error ? err.message : String(err),
|
|
24484
|
+
});
|
|
24485
|
+
}
|
|
24486
|
+
}
|
|
24487
|
+
/**
|
|
24488
|
+
* Settle every call waiting on one channel — with the ack, or with the
|
|
24489
|
+
* refusal. Only that channel's waiters and only that channel's
|
|
24490
|
+
* registration: a client with several subscriptions must not lose the ones
|
|
24491
|
+
* that worked (D3184-007).
|
|
24492
|
+
*/
|
|
24493
|
+
settleChannelSubscribe(channel, outcome) {
|
|
24494
|
+
const waiting = this.pendingChannelSubscribes.get(channel);
|
|
24495
|
+
this.pendingChannelSubscribes.delete(channel);
|
|
24496
|
+
if (outcome instanceof Error) {
|
|
24497
|
+
this.channelGrants.delete(channel);
|
|
24498
|
+
if (!waiting || waiting.length === 0) {
|
|
24499
|
+
// CR3184-005 — nobody asked for this one, so a rejected promise cannot
|
|
24500
|
+
// report it. That is the reconnect re-issue: the client presented a
|
|
24501
|
+
// stored grant on its own initiative and the server refused it, and
|
|
24502
|
+
// silently dropping the registration would leave the app believing it
|
|
24503
|
+
// was still in a channel it had just been removed from.
|
|
24504
|
+
try {
|
|
24505
|
+
this.emit("channelSubscribeFailed", [
|
|
24506
|
+
{ channel, message: outcome.message },
|
|
24507
|
+
]);
|
|
24508
|
+
}
|
|
24509
|
+
catch (err) {
|
|
24510
|
+
logger.debug("[channel] subscribe-failed emit failed", {
|
|
24511
|
+
channel,
|
|
24512
|
+
error: err instanceof Error ? err.message : String(err),
|
|
24513
|
+
});
|
|
24514
|
+
}
|
|
24515
|
+
}
|
|
24516
|
+
}
|
|
24517
|
+
for (const entry of waiting ?? []) {
|
|
24518
|
+
clearTimeout(entry.timer);
|
|
24519
|
+
if (outcome instanceof Error) {
|
|
24520
|
+
entry.reject(outcome);
|
|
24521
|
+
}
|
|
24522
|
+
else {
|
|
24523
|
+
entry.resolve({
|
|
24524
|
+
channel,
|
|
24525
|
+
expiresAt: outcome.expiresAt,
|
|
24526
|
+
unsubscribe: () => this.unsubscribeFromChannel(channel),
|
|
24527
|
+
});
|
|
24528
|
+
}
|
|
24529
|
+
}
|
|
24530
|
+
}
|
|
24531
|
+
handleChannelSubscribed(data) {
|
|
24532
|
+
const channel = typeof data?.channel === "string" ? data.channel : "";
|
|
24533
|
+
if (!channel)
|
|
24534
|
+
return;
|
|
24535
|
+
const expiresAt = Number(data?.expiresAt) || 0;
|
|
24536
|
+
const stored = this.channelGrants.get(channel);
|
|
24537
|
+
if (stored)
|
|
24538
|
+
this.channelGrants.set(channel, { ...stored, expiresAt });
|
|
24539
|
+
this.settleChannelSubscribe(channel, { expiresAt });
|
|
24540
|
+
}
|
|
24541
|
+
handleChannelMessage(data) {
|
|
24542
|
+
try {
|
|
24543
|
+
this.emit("channelMessage", [data]);
|
|
24544
|
+
}
|
|
24545
|
+
catch (err) {
|
|
24546
|
+
// A frame nobody is listening for is not an error, exactly as for
|
|
24547
|
+
// `directMessage`: an unknown-to-this-app channel must be inert.
|
|
24548
|
+
logger.debug("[channel] client emit failed", { error: err });
|
|
24549
|
+
}
|
|
24550
|
+
}
|
|
24128
24551
|
/**
|
|
24129
24552
|
* Internal entry point for DatabasesAPI.subscribe(). Ensures the WS is
|
|
24130
24553
|
* connected, registers the callback with the registry, sends the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "js-bao-wss-client",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.4",
|
|
4
4
|
"description": "Client library for js-bao-wss Yjs WebSocket service",
|
|
5
5
|
"author": "Primitive LLC",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"lib0": "^0.2.0",
|
|
38
38
|
"yjs": "^13.6.0",
|
|
39
|
-
"js-bao": ">=0.6.
|
|
39
|
+
"js-bao": ">=0.6.1",
|
|
40
40
|
"better-sqlite3": "^11.0.0",
|
|
41
41
|
"y-sqlite3": "^0.1.0",
|
|
42
42
|
"react": ">=17.0.0",
|