@jskit-ai/realtime 0.1.186 → 0.1.188

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": "@jskit-ai/realtime",
3
- "version": "0.1.186",
3
+ "version": "0.1.188",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -20,8 +20,8 @@
20
20
  "socket.io-client": "^4.8.3"
21
21
  },
22
22
  "peerDependencies": {
23
- "@jskit-ai/kernel": "0.1.189",
24
- "@jskit-ai/shell-web": "0.1.193",
23
+ "@jskit-ai/kernel": "0.1.191",
24
+ "@jskit-ai/shell-web": "0.1.195",
25
25
  "vue": "^3.5.13"
26
26
  },
27
27
  "description": "Thin, generic realtime runtime wrappers for socket.io server and client.",
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@jskit-ai/realtime": "0.1.186",
6
- "@jskit-ai/shell-web": "0.1.193"
5
+ "@jskit-ai/realtime": "0.1.188",
6
+ "@jskit-ai/shell-web": "0.1.195"
7
7
  }
8
8
  }
@@ -4,6 +4,7 @@ import { createProviderLogger } from "@jskit-ai/kernel/shared/support/providerLo
4
4
  import { getClientAppConfig, resolveClientBootstrapDebugEnabled, resolveMobileConfig } from "@jskit-ai/kernel/client";
5
5
  import RealtimeConnectionIndicator from "./components/RealtimeConnectionIndicator.js";
6
6
  import { createSocketIoClient, disconnectSocketIoClient } from "./runtime.js";
7
+ import { attachSocketConnectionRecovery } from "./connectionRecovery.js";
7
8
 
8
9
  const REALTIME_RUNTIME_CLIENT_API = Object.freeze({
9
10
  createSocketIoClient,
@@ -59,6 +60,7 @@ function createRealtimeClient({ config, loggerInput } = {}) {
59
60
  initialized = true;
60
61
 
61
62
  if (typeof socket.on === "function") {
63
+ detach.push(attachSocketConnectionRecovery(socket));
62
64
  const onConnect = () => logger.debug({ socketConnected: true }, "Realtime client socket connected.");
63
65
  const onDisconnect = (reason) =>
64
66
  logger.debug(
@@ -3,10 +3,14 @@ import { EMPTY_REALTIME_SOCKET, useRealtimeSocket } from "../composables/useReal
3
3
 
4
4
  const ROOT_STYLE = Object.freeze({
5
5
  alignItems: "center",
6
+ background: "transparent",
7
+ border: "0",
8
+ borderRadius: "50%",
6
9
  display: "inline-flex",
7
- height: "32px",
10
+ height: "48px",
8
11
  justifyContent: "center",
9
- width: "32px"
12
+ padding: "0",
13
+ width: "48px"
10
14
  });
11
15
 
12
16
  const DOT_STYLE = Object.freeze({
@@ -37,7 +41,7 @@ function resolveTooltipText({ realtimeAvailable, connected }) {
37
41
  if (connected) {
38
42
  return "Realtime is connected. Live updates are active.";
39
43
  }
40
- return "Realtime is disconnected. The client will keep trying to reconnect.";
44
+ return "Live updates are disconnected. Click to reconnect. If your session expired, sign in again.";
41
45
  }
42
46
 
43
47
  const RealtimeConnectionIndicator = defineComponent({
@@ -104,11 +108,14 @@ const RealtimeConnectionIndicator = defineComponent({
104
108
 
105
109
  return () =>
106
110
  h(
107
- "span",
111
+ "button",
108
112
  {
109
- style: ROOT_STYLE,
113
+ disabled: !realtimeAvailable || connected.value,
114
+ onClick: () => socket.connect?.(),
115
+ style: { ...ROOT_STYLE, cursor: realtimeAvailable && !connected.value ? "pointer" : "default" },
110
116
  title: tooltipText.value,
111
- "aria-label": statusLabel.value
117
+ type: "button",
118
+ "aria-label": realtimeAvailable && !connected.value ? "Reconnect live updates" : statusLabel.value
112
119
  },
113
120
  [
114
121
  h("span", { style: dotStyle.value, "aria-hidden": "true" }),
@@ -0,0 +1,68 @@
1
+ function attachSocketConnectionRecovery(socket) {
2
+ let timer = null;
3
+ let delay = 1_000;
4
+ let recovering = false;
5
+ const document = globalThis.document;
6
+ const window = globalThis.window;
7
+
8
+ function clearRetry() {
9
+ clearTimeout(timer);
10
+ timer = null;
11
+ }
12
+
13
+ function canRetry() {
14
+ return recovering && !socket.connected && !socket.active &&
15
+ socket.io.reconnection() &&
16
+ !document?.hidden && window?.navigator?.onLine !== false;
17
+ }
18
+
19
+ function scheduleRetry() {
20
+ if (timer !== null || !canRetry()) return;
21
+ timer = setTimeout(() => {
22
+ timer = null;
23
+ if (canRetry()) socket.connect();
24
+ }, delay);
25
+ delay = Math.min(delay * 2, 30_000);
26
+ }
27
+
28
+ function onConnect() {
29
+ clearRetry();
30
+ recovering = false;
31
+ delay = 1_000;
32
+ }
33
+
34
+ function onDisconnect(reason) {
35
+ clearRetry();
36
+ // Socket.IO retries transport failures itself, but not a server disconnect.
37
+ recovering = reason === "io server disconnect";
38
+ scheduleRetry();
39
+ }
40
+
41
+ function onConnectError() {
42
+ recovering = true;
43
+ scheduleRetry();
44
+ }
45
+
46
+ function onAvailable() {
47
+ if (!canRetry()) return;
48
+ clearRetry();
49
+ socket.connect();
50
+ }
51
+
52
+ socket.on("connect", onConnect);
53
+ socket.on("disconnect", onDisconnect);
54
+ socket.on("connect_error", onConnectError);
55
+ document?.addEventListener("visibilitychange", onAvailable);
56
+ window?.addEventListener("online", onAvailable);
57
+ return () => {
58
+ recovering = false;
59
+ clearRetry();
60
+ socket.off("connect", onConnect);
61
+ socket.off("disconnect", onDisconnect);
62
+ socket.off("connect_error", onConnectError);
63
+ document?.removeEventListener("visibilitychange", onAvailable);
64
+ window?.removeEventListener("online", onAvailable);
65
+ };
66
+ }
67
+
68
+ export { attachSocketConnectionRecovery };
@@ -1,7 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { EventEmitter, once } from "node:events";
4
+ import { createServer } from "node:http";
5
+ import { Server } from "socket.io";
3
6
 
4
7
  import { createSocketIoClient, disconnectSocketIoClient } from "../src/client/runtime.js";
8
+ import { attachSocketConnectionRecovery } from "../src/client/connectionRecovery.js";
5
9
 
6
10
  test("createSocketIoClient calls connect with fixed socket path", () => {
7
11
  const calls = [];
@@ -79,3 +83,132 @@ test("disconnectSocketIoClient is a no-op for missing socket", () => {
79
83
  disconnectSocketIoClient({});
80
84
  assert.equal(true, true);
81
85
  });
86
+
87
+ function recoverySocket(t) {
88
+ t.mock.timers.enable({ apis: ["setTimeout"] });
89
+ const socket = Object.assign(new EventEmitter(), {
90
+ active: false,
91
+ connected: false,
92
+ connect: t.mock.fn(),
93
+ io: { reconnection: () => true }
94
+ });
95
+ const detach = attachSocketConnectionRecovery(socket);
96
+ t.after(detach);
97
+ return { socket, detach };
98
+ }
99
+
100
+ test("server disconnect recovery backs off middleware rejection and resets after connection", (t) => {
101
+ const { socket } = recoverySocket(t);
102
+ socket.emit("disconnect", "io server disconnect");
103
+ for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].entries()) {
104
+ t.mock.timers.tick(delay - 1);
105
+ assert.equal(socket.connect.mock.callCount(), index);
106
+ t.mock.timers.tick(1);
107
+ assert.equal(socket.connect.mock.callCount(), index + 1);
108
+ socket.emit("connect_error", new Error("Authentication required"));
109
+ }
110
+ socket.connected = true;
111
+ socket.emit("connect");
112
+ t.mock.timers.tick(30_000);
113
+ assert.equal(socket.connect.mock.callCount(), 7);
114
+ socket.connected = false;
115
+ socket.emit("disconnect", "io server disconnect");
116
+ t.mock.timers.tick(1_000);
117
+ assert.equal(socket.connect.mock.callCount(), 8);
118
+ });
119
+
120
+ test("recovery leaves transport retries and intentional disconnects to Socket.IO", (t) => {
121
+ const { socket, detach } = recoverySocket(t);
122
+ socket.active = true;
123
+ socket.emit("disconnect", "transport close");
124
+ socket.emit("connect_error", new Error("Temporary transport error"));
125
+ t.mock.timers.tick(30_000);
126
+ assert.equal(socket.connect.mock.callCount(), 0);
127
+ socket.active = false;
128
+ socket.emit("disconnect", "io server disconnect");
129
+ socket.emit("disconnect", "io client disconnect");
130
+ t.mock.timers.tick(30_000);
131
+ assert.equal(socket.connect.mock.callCount(), 0);
132
+ socket.emit("disconnect", "io server disconnect");
133
+ detach();
134
+ t.mock.timers.tick(30_000);
135
+ assert.equal(socket.connect.mock.callCount(), 0);
136
+ assert.equal(socket.eventNames().length, 0);
137
+ });
138
+
139
+ test("recovery respects reconnection disabled at startup or while a retry is pending", (t) => {
140
+ const { socket } = recoverySocket(t);
141
+ const client = createSocketIoClient({ options: { autoConnect: false, reconnection: false } });
142
+ socket.io = client.io;
143
+ t.after(() => client.disconnect());
144
+ socket.emit("disconnect", "io server disconnect");
145
+ socket.emit("connect_error", new Error("Rejected"));
146
+ t.mock.timers.tick(30_000);
147
+ assert.equal(socket.connect.mock.callCount(), 0);
148
+ socket.io.reconnection(true);
149
+ socket.emit("connect_error", new Error("Rejected"));
150
+ socket.io.reconnection(false);
151
+ t.mock.timers.tick(30_000);
152
+ assert.equal(socket.connect.mock.callCount(), 0);
153
+ });
154
+
155
+ test("hidden or offline pages resume recovery when available and remove their listeners on teardown", (t) => {
156
+ const previousDocument = globalThis.document;
157
+ const previousWindow = globalThis.window;
158
+ const document = Object.assign(new EventTarget(), { hidden: true });
159
+ const window = Object.assign(new EventTarget(), { navigator: { onLine: true } });
160
+ globalThis.document = document;
161
+ globalThis.window = window;
162
+ t.after(() => {
163
+ if (previousDocument === undefined) delete globalThis.document;
164
+ else globalThis.document = previousDocument;
165
+ if (previousWindow === undefined) delete globalThis.window;
166
+ else globalThis.window = previousWindow;
167
+ });
168
+ const { socket, detach } = recoverySocket(t);
169
+ socket.emit("disconnect", "io server disconnect");
170
+ t.mock.timers.tick(30_000);
171
+ assert.equal(socket.connect.mock.callCount(), 0);
172
+ document.hidden = false;
173
+ window.navigator.onLine = false;
174
+ document.dispatchEvent(new Event("visibilitychange"));
175
+ assert.equal(socket.connect.mock.callCount(), 0);
176
+ window.navigator.onLine = true;
177
+ window.dispatchEvent(new Event("online"));
178
+ assert.equal(socket.connect.mock.callCount(), 1);
179
+ socket.emit("connect_error", new Error("Temporary failure"));
180
+ detach();
181
+ window.dispatchEvent(new Event("online"));
182
+ document.dispatchEvent(new Event("visibilitychange"));
183
+ t.mock.timers.tick(30_000);
184
+ assert.equal(socket.connect.mock.callCount(), 1);
185
+ });
186
+
187
+ test("a real server-forced disconnect reconnects through a fresh authenticated handshake", async (t) => {
188
+ const http = createServer();
189
+ const io = new Server(http);
190
+ let handshakes = 0;
191
+ io.use((_socket, next) => {
192
+ handshakes += 1;
193
+ if (handshakes === 2) return next(new Error("Temporary authentication lookup failure"));
194
+ next();
195
+ });
196
+ http.listen(0, "127.0.0.1");
197
+ await once(http, "listening");
198
+ const client = createSocketIoClient({ url: `http://127.0.0.1:${http.address().port}` });
199
+ const detach = attachSocketConnectionRecovery(client);
200
+ t.after(async () => {
201
+ detach();
202
+ client.disconnect();
203
+ await new Promise((resolve) => io.close(resolve));
204
+ });
205
+ await once(client, "connect", { signal: AbortSignal.timeout(5_000) });
206
+ const before = client.id;
207
+ const disconnected = once(client, "disconnect");
208
+ io.sockets.sockets.get(before).disconnect(true);
209
+ assert.equal((await disconnected)[0], "io server disconnect");
210
+ assert.equal(client.active, false);
211
+ await once(client, "connect", { signal: AbortSignal.timeout(8_000) });
212
+ assert.equal(handshakes, 3);
213
+ assert.notEqual(client.id, before);
214
+ });