@jskit-ai/realtime 0.1.185 → 0.1.187
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 +3 -3
- package/patterns/realtime-application/example/package.json +2 -2
- package/src/client/RealtimeClientProvider.js +2 -0
- package/src/client/components/RealtimeConnectionIndicator.js +13 -6
- package/src/client/connectionRecovery.js +68 -0
- package/test/clientRuntime.test.js +133 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/realtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.187",
|
|
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.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
23
|
+
"@jskit-ai/kernel": "0.1.190",
|
|
24
|
+
"@jskit-ai/shell-web": "0.1.194",
|
|
25
25
|
"vue": "^3.5.13"
|
|
26
26
|
},
|
|
27
27
|
"description": "Thin, generic realtime runtime wrappers for socket.io server and client.",
|
|
@@ -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: "
|
|
10
|
+
height: "48px",
|
|
8
11
|
justifyContent: "center",
|
|
9
|
-
|
|
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 "
|
|
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
|
-
"
|
|
111
|
+
"button",
|
|
108
112
|
{
|
|
109
|
-
|
|
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
|
-
"
|
|
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
|
+
});
|