@frockbot/applet-sdk 0.3.23 → 0.3.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/applet-sdk",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Authoring SDK for FrockBot Applets: schema-first Durable Object server, TanStack DB client, component kit, linter, and the `applet` CLI.",
@@ -96,6 +96,23 @@ export class AppletTransport {
96
96
  #closed = false;
97
97
  #resyncQueued = false;
98
98
  #txnSeq = 0;
99
+ /**
100
+ * Every socket this transport has opened gets a number, and only the newest
101
+ * one owns the shared connection state. `error` and `close` both fire for a
102
+ * single failure, and a socket the transport has already given up on can
103
+ * still call back later, so a callback carrying a superseded identity is
104
+ * dropped instead of reconnecting or clearing state a live socket owns.
105
+ */
106
+ #socketSeq = 0;
107
+ #currentSocketId = 0;
108
+ /**
109
+ * At most one reconnect is outstanding. The scheduler seam cannot cancel a
110
+ * timer, so a retry carries an identity too: `#pendingRetryId` is cleared or
111
+ * replaced whenever the retry is superseded, and a closure that fires with a
112
+ * stale identity does nothing.
113
+ */
114
+ #retrySeq = 0;
115
+ #pendingRetryId = 0;
99
116
 
100
117
  constructor(options: AppletTransportOptions = {}) {
101
118
  this.#options = {
@@ -116,23 +133,54 @@ export class AppletTransport {
116
133
  return () => this.#listeners.delete(listener);
117
134
  }
118
135
 
119
- /** Open the socket. Called by the dev runner, the tests, and the `init` bridge. */
136
+ /**
137
+ * Open the socket. Called by the dev runner, the tests, and the `init`
138
+ * bridge. Calling it again — a fresh viewer token after the old one expired,
139
+ * say — supersedes whatever socket or pending reconnect is outstanding, so a
140
+ * reconnect never races the connection the caller just asked for.
141
+ */
120
142
  connect(init: AppletInitV1): void {
121
143
  this.#init = init;
122
144
  this.#closed = false;
145
+ this.#reset();
146
+ this.#attempt = 0;
123
147
  this.#open();
124
148
  }
125
149
 
126
150
  close(): void {
127
151
  this.#closed = true;
128
- this.#socket?.close(1000, "closed");
129
- this.#socket = undefined;
152
+ this.#reset(1000, "closed");
130
153
  this.#failPending(new Error("The Applet connection was closed"));
131
154
  this.#setState({ status: "closed" });
132
155
  }
133
156
 
157
+ /**
158
+ * Give up the current socket and any pending reconnect. The socket is closed
159
+ * rather than merely forgotten, so a superseded connection cannot keep
160
+ * receiving frames, and its late `close` arrives with a stale identity.
161
+ */
162
+ #reset(code = 1000, reason = "superseded"): void {
163
+ this.#pendingRetryId = 0;
164
+ const socket = this.#socket;
165
+ this.#socket = undefined;
166
+ this.#currentSocketId = 0;
167
+ this.#synced = false;
168
+ if (socket) {
169
+ try {
170
+ socket.close(code, reason);
171
+ } catch {
172
+ // A socket that is already gone needs no closing.
173
+ }
174
+ }
175
+ }
176
+
134
177
  #open(): void {
135
178
  if (!this.#init || this.#closed) return;
179
+ // Never open while another attempt is current.
180
+ if (this.#socket) return;
181
+ this.#pendingRetryId = 0;
182
+ const id = ++this.#socketSeq;
183
+ this.#currentSocketId = id;
136
184
  this.#setState({
137
185
  status: this.#attempt === 0 ? "connecting" : "reconnecting",
138
186
  });
@@ -140,10 +188,14 @@ export class AppletTransport {
140
188
  url.searchParams.set("token", this.#init.token);
141
189
  const socket = this.#options.socketFactory(url.toString());
142
190
  this.#socket = socket;
143
- socket.onopen = () => this.#handshake();
144
- socket.onmessage = (event) => this.#receive(event.data);
145
- socket.onclose = () => this.#dropped();
146
- socket.onerror = () => this.#dropped();
191
+ socket.onopen = () => {
192
+ if (this.#currentSocketId === id) this.#handshake();
193
+ };
194
+ socket.onmessage = (event) => {
195
+ if (this.#currentSocketId === id) this.#receive(event.data);
196
+ };
197
+ socket.onclose = () => this.#dropped(id);
198
+ socket.onerror = () => this.#dropped(id);
147
199
  }
148
200
 
149
201
  #handshake(): void {
@@ -156,11 +208,18 @@ export class AppletTransport {
156
208
  });
157
209
  }
158
210
 
159
- #dropped(): void {
211
+ /**
212
+ * One failure reaches here twice — `error` then `close` — and a socket the
213
+ * transport already replaced can reach here at any time. Only the socket that
214
+ * still owns the connection schedules a replacement.
215
+ */
216
+ #dropped(id: number): void {
217
+ if (id !== this.#currentSocketId) return;
160
218
  if (this.#closed) return;
161
- this.#socket = undefined;
162
- this.#synced = false;
219
+ this.#reset(1000, "dropped");
163
220
  this.#failPending(new Error("The Applet connection dropped"));
221
+ const retryId = ++this.#retrySeq;
222
+ this.#pendingRetryId = retryId;
164
223
  const delay = Math.min(
165
224
  this.#options.maximumBackoffMs,
166
225
  this.#options.minimumBackoffMs * 2 ** this.#attempt,
@@ -168,7 +227,11 @@ export class AppletTransport {
168
227
  this.#attempt += 1;
169
228
  this.#setState({ status: "reconnecting" });
170
229
  this.#options.schedule(
171
- () => this.#open(),
230
+ () => {
231
+ if (this.#pendingRetryId !== retryId) return;
232
+ this.#pendingRetryId = 0;
233
+ this.#open();
234
+ },
172
235
  delay * (0.5 + Math.random() / 2),
173
236
  );
174
237
  }