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.
@@ -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
@@ -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
@@ -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",
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.0",
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",