@rindle/remote 0.4.4 → 0.6.3

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.
@@ -23,6 +23,11 @@ export interface Transport {
23
23
  * it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
24
24
  export declare class WsTransport implements Transport {
25
25
  private readonly url;
26
+ /** Reads the subprotocols to offer at each (re)connect — in affinity mode, `["rindle.v1", "aff.…"]`
27
+ * with the CURRENT ticket (FOLLOWER-AFFINITY-DESIGN.md §5). Undefined ⇒ offer none (today's
28
+ * single-daemon behavior, byte-identical). Evaluated per connect so a reconnect presents the
29
+ * freshest (or freshly cleared) ticket. */
30
+ private readonly subprotocols?;
26
31
  private ws;
27
32
  private handler;
28
33
  private reconnectHandler;
@@ -41,6 +46,7 @@ export declare class WsTransport implements Transport {
41
46
  private downFired;
42
47
  constructor(url: string, opts?: {
43
48
  downThreshold?: number;
49
+ subprotocols?: () => string[];
44
50
  });
45
51
  private connect;
46
52
  private scheduleReconnect;
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1D,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,gEAAgE;IAChE,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI,CAAC;IACnD;;wEAEoE;IACpE,WAAW,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACxC;;;;wCAIoC;IACpC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACnC,gCAAgC;IAChC,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;mFAGmF;AACnF,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,gBAAgB,CAAwB;IAChD,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,cAAc,CAA4C;IAClE,gGAAgG;IAChG,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC;;2CAEuC;IACvC,OAAO,CAAC,SAAS,CAAS;gBAEd,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAO;IAM9D,OAAO,CAAC,OAAO;IA8Bf,OAAO,CAAC,iBAAiB;IAiBzB,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI;IAK1B,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAIlD,WAAW,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAItC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAIjC,KAAK,IAAI,IAAI;CAQd"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1D,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,CAAC;IAC3B,gEAAgE;IAChE,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI,CAAC;IACnD;;wEAEoE;IACpE,WAAW,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACxC;;;;wCAIoC;IACpC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACnC,gCAAgC;IAChC,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;mFAGmF;AACnF,qBAAa,WAAY,YAAW,SAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B;;;gDAG4C;IAC5C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAiB;IAC/C,OAAO,CAAC,EAAE,CAAY;IACtB,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,gBAAgB,CAAwB;IAChD,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,cAAc,CAA4C;IAClE,gGAAgG;IAChG,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC;;2CAEuC;IACvC,OAAO,CAAC,SAAS,CAAS;gBAEd,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,MAAM,EAAE,CAAA;KAAO;IAO7F,OAAO,CAAC,OAAO;IAiCf,OAAO,CAAC,iBAAiB;IAiBzB,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI;IAK1B,SAAS,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAIlD,WAAW,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAItC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI;IAIjC,KAAK,IAAI,IAAI;CAQd"}
package/dist/transport.js CHANGED
@@ -7,6 +7,11 @@
7
7
  * it reopens and fires `onReconnect` so the source rebuilds its subscriptions. */
8
8
  export class WsTransport {
9
9
  url;
10
+ /** Reads the subprotocols to offer at each (re)connect — in affinity mode, `["rindle.v1", "aff.…"]`
11
+ * with the CURRENT ticket (FOLLOWER-AFFINITY-DESIGN.md §5). Undefined ⇒ offer none (today's
12
+ * single-daemon behavior, byte-identical). Evaluated per connect so a reconnect presents the
13
+ * freshest (or freshly cleared) ticket. */
14
+ subprotocols;
10
15
  ws;
11
16
  handler = () => { };
12
17
  reconnectHandler = () => { };
@@ -26,10 +31,14 @@ export class WsTransport {
26
31
  constructor(url, opts = {}) {
27
32
  this.url = url;
28
33
  this.downThreshold = opts.downThreshold ?? 4;
34
+ this.subprotocols = opts.subprotocols;
29
35
  this.ws = this.connect();
30
36
  }
31
37
  connect() {
32
- const ws = new WebSocket(this.url);
38
+ // Offer the affinity subprotocols (base + current ticket) when configured; otherwise open bare,
39
+ // exactly as before. An empty list is treated as bare (never send `Sec-WebSocket-Protocol: `).
40
+ const protocols = this.subprotocols?.();
41
+ const ws = protocols && protocols.length > 0 ? new WebSocket(this.url, protocols) : new WebSocket(this.url);
33
42
  ws.addEventListener("open", () => {
34
43
  this.open = true;
35
44
  this.attempt = 0;
@@ -1 +1 @@
1
- {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,2FAA2F;AAC3F,gFAAgF;AAuBhF;;;mFAGmF;AACnF,MAAM,OAAO,WAAW;IACL,GAAG,CAAS;IACrB,EAAE,CAAY;IACd,OAAO,GAA6B,GAAG,EAAE,GAAE,CAAC,CAAC;IAC7C,gBAAgB,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IACxC,WAAW,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IAC1B,OAAO,GAAgB,EAAE,CAAC;IACnC,IAAI,GAAG,KAAK,CAAC;IACb,UAAU,GAAG,KAAK,CAAC;IACnB,YAAY,GAAG,KAAK,CAAC;IACrB,OAAO,GAAG,CAAC,CAAC;IACZ,cAAc,CAA4C;IAClE,gGAAgG;IAC/E,aAAa,CAAS;IACvC;;2CAEuC;IAC/B,SAAS,GAAG,KAAK,CAAC;IAE1B,YAAY,GAAW,EAAE,OAAmC,EAAE;QAC5D,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAEO,OAAO;QACb,MAAM,EAAE,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnC,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;YACjB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;YACjB,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,CAAC,6CAA6C;YACrE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;gBACrB,6EAA6E;gBAC7E,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;gBACvB,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO;oBAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;gBACzD,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;YAC1B,CAAC;iBAAM,CAAC;gBACN,qFAAqF;gBACrF,wEAAwE;gBACxE,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;gBACxB,IAAI,CAAC,gBAAgB,EAAE,CAAC;YAC1B,CAAC;QACH,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,EAAgB,EAAE,EAAE;YAClD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAc,CAAC,CAAC;QACjG,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YAChC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC;YAClB,IAAI,CAAC,IAAI,CAAC,YAAY;gBAAE,IAAI,CAAC,iBAAiB,EAAE,CAAC;QACnD,CAAC,CAAC,CAAC;QACH,0EAA0E;QAC1E,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACvC,OAAO,EAAE,CAAC;IACZ,CAAC;IAEO,iBAAiB;QACvB,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS;YAAE,OAAO;QAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACtD,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,gGAAgG;QAChG,wFAAwF;QACxF,uFAAuF;QACvF,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;YAC7E,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;YACtB,IAAI,CAAC,WAAW,EAAE,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,cAAc,GAAG,UAAU,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAChC,IAAI,CAAC,IAAI,CAAC,YAAY;gBAAE,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QACnD,CAAC,EAAE,KAAK,CAAC,CAAC;IACZ,CAAC;IAED,IAAI,CAAC,GAAc;QACjB,IAAI,IAAI,CAAC,IAAI;YAAE,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;;YAC5C,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;IAED,SAAS,CAAC,OAAiC;QACzC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,WAAW,CAAC,OAAmB;QAC7B,IAAI,CAAC,gBAAgB,GAAG,OAAO,CAAC;IAClC,CAAC;IAED,MAAM,CAAC,OAAmB;QACxB,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC;IAC7B,CAAC;IAED,KAAK;QACH,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;QACzB,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;YACtC,YAAY,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YAClC,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAClC,CAAC;QACD,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC;IAClB,CAAC;CACF"}
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,2FAA2F;AAC3F,gFAAgF;AAuBhF;;;mFAGmF;AACnF,MAAM,OAAO,WAAW;IACL,GAAG,CAAS;IAC7B;;;gDAG4C;IAC3B,YAAY,CAAkB;IACvC,EAAE,CAAY;IACd,OAAO,GAA6B,GAAG,EAAE,GAAE,CAAC,CAAC;IAC7C,gBAAgB,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IACxC,WAAW,GAAe,GAAG,EAAE,GAAE,CAAC,CAAC;IAC1B,OAAO,GAAgB,EAAE,CAAC;IACnC,IAAI,GAAG,KAAK,CAAC;IACb,UAAU,GAAG,KAAK,CAAC;IACnB,YAAY,GAAG,KAAK,CAAC;IACrB,OAAO,GAAG,CAAC,CAAC;IACZ,cAAc,CAA4C;IAClE,gGAAgG;IAC/E,aAAa,CAAS;IACvC;;2CAEuC;IAC/B,SAAS,GAAG,KAAK,CAAC;IAE1B,YAAY,GAAW,EAAE,OAAkE,EAAE;QAC3F,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC;QACtC,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAEO,OAAO;QACb,gGAAgG;QAChG,+FAA+F;QAC/F,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;QACxC,MAAM,EAAE,GAAG,SAAS,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC5G,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;YACjB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;YACjB,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,CAAC,6CAA6C;YACrE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;gBACrB,6EAA6E;gBAC7E,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;gBACvB,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO;oBAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;gBACzD,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;YAC1B,CAAC;iBAAM,CAAC;gBACN,qFAAqF;gBACrF,wEAAwE;gBACxE,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;gBACxB,IAAI,CAAC,gBAAgB,EAAE,CAAC;YAC1B,CAAC;QACH,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,EAAgB,EAAE,EAAE;YAClD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAc,CAAC,CAAC;QACjG,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE;YAChC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC;YAClB,IAAI,CAAC,IAAI,CAAC,YAAY;gBAAE,IAAI,CAAC,iBAAiB,EAAE,CAAC;QACnD,CAAC,CAAC,CAAC;QACH,0EAA0E;QAC1E,EAAE,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QACvC,OAAO,EAAE,CAAC;IACZ,CAAC;IAEO,iBAAiB;QACvB,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS;YAAE,OAAO;QAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACtD,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,gGAAgG;QAChG,wFAAwF;QACxF,uFAAuF;QACvF,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;YAC7E,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;YACtB,IAAI,CAAC,WAAW,EAAE,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,cAAc,GAAG,UAAU,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;YAChC,IAAI,CAAC,IAAI,CAAC,YAAY;gBAAE,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QACnD,CAAC,EAAE,KAAK,CAAC,CAAC;IACZ,CAAC;IAED,IAAI,CAAC,GAAc;QACjB,IAAI,IAAI,CAAC,IAAI;YAAE,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;;YAC5C,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;IAED,SAAS,CAAC,OAAiC;QACzC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,WAAW,CAAC,OAAmB;QAC7B,IAAI,CAAC,gBAAgB,GAAG,OAAO,CAAC;IAClC,CAAC;IAED,MAAM,CAAC,OAAmB;QACxB,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC;IAC7B,CAAC;IAED,KAAK;QACH,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;QACzB,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;YACtC,YAAY,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YAClC,IAAI,CAAC,cAAc,GAAG,SAAS,CAAC;QAClC,CAAC;QACD,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC;IAClB,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rindle/remote",
3
- "version": "0.4.4",
3
+ "version": "0.6.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "README.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@rindle/client": "0.4.4"
28
+ "@rindle/client": "0.6.3"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.10.0",
@@ -0,0 +1,137 @@
1
+ // Browser-side follower-affinity ticket transport (FOLLOWER-AFFINITY-DESIGN.md §3, §5). The ticket
2
+ // is OPAQUE here — the browser never mints or verifies it (placement, not authorization; §3, §9),
3
+ // so this module carries no crypto. It is the thin seam three pieces share:
4
+ //
5
+ // - {@link WsTransport} offers the held ticket as a ws subprotocol at each connect (so the Fly
6
+ // edge `fly-replay`s to the pinned follower);
7
+ // - {@link RemoteOptimisticSource} writes the follower's minted ticket from the `{t:"affinity"}`
8
+ // frame, and CLEARS it on a sustained outage (the pinned follower is gone → the next reconnect
9
+ // anycasts to a live one and re-pins, §8);
10
+ // - the lease POST forwards the held ticket as `affinity`, sequenced AFTER a fresh connect's mint
11
+ // frame ({@link AffinityTicketStore.waitForTicket}) so both legs land on the same follower (§4.1).
12
+
13
+ /** The base ws subprotocol the browser offers alongside its `aff.*` ticket; the fleet follower
14
+ * echoes it on the 101 (a strict browser closes a socket whose selected subprotocol it never
15
+ * offered, so the base is always offered in affinity mode). Inlined — NOT imported from
16
+ * `@rindle/affinity`'s `WS_SUBPROTOCOL` — to keep that crate's `node:crypto` out of the browser
17
+ * bundle (the same duplicate-to-stay-bundle-clean discipline the lease wire types use). Keep the
18
+ * two spellings in lock-step. */
19
+ export const WS_SUBPROTOCOL = "rindle.v1";
20
+
21
+ /** A mutable holder for the current opaque affinity ticket, shared by the transport, the source,
22
+ * and the lease POST (see the module header). */
23
+ export interface AffinityTicketStore {
24
+ /** The ticket to offer on the next ws handshake, or undefined — ticketless, so the edge
25
+ * anycasts + mints a fresh one. A persisted/previous-connection ticket may be returned here
26
+ * even while it is NOT yet safe for a lease; see {@link leaseTicket}. */
27
+ get(): string | undefined;
28
+ /** Record a freshly minted/refreshed ticket (from a connection's `{t:"affinity"}` frame). */
29
+ set(ticket: string): void;
30
+ /** Drop the ticket so the next connect goes ticketless (the pinned follower is gone, §8). */
31
+ clear(): void;
32
+ /** Mark the held ticket handshake-only until this connection emits a fresh mint frame. Called
33
+ * before the first connection and every reconnect, after the held ticket was already selected
34
+ * for the ws subprotocol offer. This prevents a restored/rotated/expired persisted ticket from
35
+ * racing an HTTP lease onto an independently-anycast follower. */
36
+ connectionPending(): void;
37
+ /** Resolve with a ticket minted/refreshed on the CURRENT connection, or the next one
38
+ * {@link set}. A persisted ticket deliberately does not resolve this wait. */
39
+ waitForTicket(): Promise<string>;
40
+ /** Obtain the current connection's ticket for an HTTP lease. The first missing-ticket wait is
41
+ * bounded; its timeout removes the waiter and latches ticketless fallback, so later leases
42
+ * return immediately until a mint frame arrives. Concurrent callers share the one timer.
43
+ * `timedOut` is true only for that transition, allowing one warning per fallback episode. */
44
+ leaseTicket(timeoutMs: number): Promise<{ ticket?: string; timedOut: boolean }>;
45
+ }
46
+
47
+ /** Where a store persists the ticket across reloads. The browser backs this with sessionStorage
48
+ * (per-TAB, so two tabs can pin two regions — design §13); tests pass none (pure in-memory). */
49
+ export interface TicketPersistence {
50
+ load(): string | undefined;
51
+ save(ticket: string): void;
52
+ clear(): void;
53
+ }
54
+
55
+ /** Build an {@link AffinityTicketStore}, optionally persisted. Pure/in-memory when `persist` is
56
+ * omitted. */
57
+ export function createAffinityTicketStore(persist?: TicketPersistence): AffinityTicketStore {
58
+ let current = persist?.load();
59
+ // A loaded ticket is useful for the ws handshake, but is not lease-safe until the follower on
60
+ // THIS connection confirms it by minting a fresh frame. The same distinction is re-armed on
61
+ // every reconnect via `connectionPending`.
62
+ let currentConnectionFresh = false;
63
+ let ticketlessFallback = false;
64
+ let waiters: Array<(ticket: string) => void> = [];
65
+ let leaseWait:
66
+ | {
67
+ promise: Promise<{ ticket?: string; timedOut: boolean }>;
68
+ resolve: (result: { ticket?: string; timedOut: boolean }) => void;
69
+ timer: ReturnType<typeof setTimeout>;
70
+ }
71
+ | undefined;
72
+
73
+ const finishLeaseWait = (result: { ticket?: string; timedOut: boolean }): void => {
74
+ const pending = leaseWait;
75
+ if (!pending) return;
76
+ clearTimeout(pending.timer);
77
+ leaseWait = undefined;
78
+ pending.resolve(result);
79
+ };
80
+
81
+ return {
82
+ get: () => current,
83
+ set: (ticket) => {
84
+ current = ticket;
85
+ currentConnectionFresh = true;
86
+ ticketlessFallback = false;
87
+ persist?.save(ticket);
88
+ // Resolve everyone waiting for the (now-arrived) ticket, in FIFO order.
89
+ const pending = waiters;
90
+ waiters = [];
91
+ for (const resolve of pending) resolve(ticket);
92
+ finishLeaseWait({ ticket, timedOut: false });
93
+ },
94
+ clear: () => {
95
+ // Drop the ticket but leave any waiters pending — the next connect's mint frame resolves them
96
+ // (a dead-follower reassignment must still be able to lease once the new ticket arrives).
97
+ current = undefined;
98
+ currentConnectionFresh = false;
99
+ persist?.clear();
100
+ },
101
+ connectionPending: () => {
102
+ currentConnectionFresh = false;
103
+ },
104
+ waitForTicket: () =>
105
+ currentConnectionFresh && current !== undefined
106
+ ? Promise.resolve(current)
107
+ : new Promise<string>((resolve) => waiters.push(resolve)),
108
+ leaseTicket: (timeoutMs) => {
109
+ if (currentConnectionFresh && current !== undefined) {
110
+ return Promise.resolve({ ticket: current, timedOut: false });
111
+ }
112
+ if (ticketlessFallback) return Promise.resolve({ timedOut: false });
113
+ if (leaseWait) return leaseWait.promise;
114
+
115
+ let resolve!: (result: { ticket?: string; timedOut: boolean }) => void;
116
+ const promise = new Promise<{ ticket?: string; timedOut: boolean }>((r) => {
117
+ resolve = r;
118
+ });
119
+ const timer = setTimeout(() => {
120
+ // Clear the shared waiter before resolving callers. A later mint starts a new, fresh
121
+ // episode; no abandoned resolver accumulates per lease while affinity is off.
122
+ leaseWait = undefined;
123
+ ticketlessFallback = true;
124
+ resolve({ timedOut: true });
125
+ }, Math.max(0, timeoutMs));
126
+ leaseWait = { promise, resolve, timer };
127
+ return promise;
128
+ },
129
+ };
130
+ }
131
+
132
+ /** The subprotocol list to offer for one connection: the base protocol always, plus the held ticket
133
+ * (already an `aff.<…>` token) when one exists. */
134
+ export function offerSubprotocols(store: AffinityTicketStore): string[] {
135
+ const ticket = store.get();
136
+ return ticket ? [WS_SUBPROTOCOL, ticket] : [WS_SUBPROTOCOL];
137
+ }
package/src/backend.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type SubscribeResolver,
21
21
  } from "./subscribe.ts";
22
22
  import { WsTransport } from "./transport.ts";
23
+ import { retryDelayMs } from "./query-error.ts";
23
24
  import type { Transport } from "./transport.ts";
24
25
 
25
26
  interface QState {
@@ -31,6 +32,10 @@ interface QState {
31
32
  resubscribing: boolean;
32
33
  /** Monotonic token that cancels stale async lease resolutions. */
33
34
  subscribeTicket: number;
35
+ /** Pending retryable-error re-subscribe timer (FOLLOWER-LAG-SHED §6.3), if any. */
36
+ retryTimer: ReturnType<typeof setTimeout> | undefined;
37
+ /** Consecutive retryable errors without a successful hello — the backoff exponent. */
38
+ retryAttempt: number;
34
39
  }
35
40
 
36
41
  export interface RemoteBackendOptions {
@@ -60,11 +65,13 @@ export class RemoteBackend implements Backend {
60
65
  console.error(`[rindle-remote] flat query ${qid} has no named remote identity`);
61
66
  return;
62
67
  }
63
- this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0 });
68
+ this.subs.set(qid, { remote, subscriber: null, epoch: 0, resubscribing: false, subscribeTicket: 0, retryTimer: undefined, retryAttempt: 0 });
64
69
  this.subscribe(qid, remote);
65
70
  }
66
71
 
67
72
  unregisterQuery(qid: QueryId): void {
73
+ const s = this.subs.get(qid);
74
+ if (s?.retryTimer !== undefined) clearTimeout(s.retryTimer);
68
75
  this.subs.delete(qid);
69
76
  this.transport.send({ t: "unsubscribe", queryId: qid });
70
77
  }
@@ -93,6 +100,12 @@ export class RemoteBackend implements Backend {
93
100
  private subscribe(qid: QueryId, remote: RemoteQuery): void {
94
101
  const s = this.subs.get(qid);
95
102
  if (!s) return;
103
+ // A fresh subscribe (gap recovery, the retry timer itself) supersedes any scheduled
104
+ // retryable-error retry — never leave two subscribe paths racing for one query.
105
+ if (s.retryTimer !== undefined) {
106
+ clearTimeout(s.retryTimer);
107
+ s.retryTimer = undefined;
108
+ }
96
109
  const request = { queryId: qid, remote, mode: "flat" as const };
97
110
  const ticket = ++s.subscribeTicket;
98
111
  const send = (target: ReturnType<typeof defaultSubscribeTarget>) => {
@@ -117,8 +130,7 @@ export class RemoteBackend implements Backend {
117
130
 
118
131
  private onServerMsg(msg: ServerMsg): void {
119
132
  if (msg.t === "queryError") {
120
- this.subs.delete(msg.queryId);
121
- console.error(`[rindle-remote] query ${msg.queryId} subscription rejected: ${msg.message}`);
133
+ this.onQueryError(msg.queryId, msg);
122
134
  return;
123
135
  }
124
136
  // This backend is flat-only; it ignores normalized frames (`nhello`/`nbatch`).
@@ -129,6 +141,36 @@ export class RemoteBackend implements Backend {
129
141
  else this.applyBatch(msg.queryId, s, msg.batch);
130
142
  }
131
143
 
144
+ /** Route a `queryError` by its 101 §5 classification: retryable ⇒ keep the QState (and the
145
+ * rows already folded downstream — 101 §6) and re-subscribe after a jittered backoff
146
+ * honoring `retryAfterMs`; terminal (or pre-classification servers) ⇒ drop the
147
+ * subscription, exactly as before (FOLLOWER-LAG-SHED §6.3). */
148
+ private onQueryError(qid: QueryId, err: { message: string; code?: string; retryable?: boolean; retryAfterMs?: number }): void {
149
+ const s = this.subs.get(qid);
150
+ if (!s) return;
151
+ if (err.retryable !== true) {
152
+ if (s.retryTimer !== undefined) clearTimeout(s.retryTimer);
153
+ this.subs.delete(qid);
154
+ console.error(`[rindle-remote] query ${qid} subscription rejected: ${err.message}`);
155
+ return;
156
+ }
157
+ if (s.retryTimer !== undefined) return; // a retry is already scheduled — don't stack them
158
+ s.subscriber = null; // stop validating the dead epoch; recovery is a fresh seq-0 hydrate
159
+ s.resubscribing = true;
160
+ const delay = retryDelayMs(s.retryAttempt++, err.retryAfterMs);
161
+ console.warn(
162
+ `[rindle-remote] query ${qid} ${err.code ?? "error"} (retryable): re-subscribing in ${delay}ms: ${err.message}`,
163
+ );
164
+ const timer = setTimeout(() => {
165
+ const cur = this.subs.get(qid);
166
+ if (cur !== s) return;
167
+ s.retryTimer = undefined;
168
+ this.subscribe(qid, s.remote);
169
+ }, delay);
170
+ (timer as { unref?: () => void }).unref?.();
171
+ s.retryTimer = timer;
172
+ }
173
+
132
174
  private openSubscriber(qid: QueryId, s: QState, hello: Hello): void {
133
175
  try {
134
176
  // The Subscriber ctor validates the comparator + fingerprint and emits the `hello`
@@ -136,6 +178,7 @@ export class RemoteBackend implements Backend {
136
178
  s.subscriber = new Subscriber(hello, (ev) => this.emitServerEvent(qid, ev));
137
179
  s.epoch = hello.epoch;
138
180
  s.resubscribing = false;
181
+ s.retryAttempt = 0; // a successful hello resets the retryable-error backoff
139
182
  } catch (e) {
140
183
  // A comparator/schema mismatch at hello is unrecoverable (a code-contract divergence) —
141
184
  // leave the view pending and report it; do not loop.
package/src/index.ts CHANGED
@@ -9,6 +9,10 @@ export { RemoteBackend, createRemoteStore } from "./backend.ts";
9
9
  export type { RemoteBackendOptions } from "./backend.ts";
10
10
  export { WsTransport } from "./transport.ts";
11
11
  export type { Transport } from "./transport.ts";
12
+
13
+ // Browser-side follower-affinity ticket transport (FOLLOWER-AFFINITY-DESIGN.md §3, §5).
14
+ export { WS_SUBPROTOCOL, createAffinityTicketStore, offerSubprotocols } from "./affinity.ts";
15
+ export type { AffinityTicketStore, TicketPersistence } from "./affinity.ts";
12
16
  export type {
13
17
  MutationEnvelopeSender,
14
18
  RawMutationSender,