@jskit-ai/realtime 0.1.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.
@@ -0,0 +1,142 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/realtime",
4
+ version: "0.1.4",
5
+ description: "Thin, generic realtime runtime wrappers for socket.io server and client.",
6
+ options: {
7
+ "realtime-redis-url": {
8
+ required: true,
9
+ allowEmpty: true,
10
+ values: [],
11
+ defaultValue: "",
12
+ promptLabel: "Realtime Redis URL",
13
+ promptHint: "Leave empty to use in-memory socket adapter."
14
+ }
15
+ },
16
+ dependsOn: [
17
+ "@jskit-ai/kernel"
18
+ ],
19
+ capabilities: {
20
+ provides: [
21
+ "runtime.realtime",
22
+ "runtime.realtime.client"
23
+ ],
24
+ requires: []
25
+ },
26
+ runtime: {
27
+ server: {
28
+ providerEntrypoint: "src/server/RealtimeServiceProvider.js",
29
+ providers: [
30
+ {
31
+ entrypoint: "src/server/RealtimeServiceProvider.js",
32
+ export: "RealtimeServiceProvider"
33
+ }
34
+ ]
35
+ },
36
+ client: {
37
+ providers: [
38
+ {
39
+ entrypoint: "src/client/RealtimeClientProvider.js",
40
+ export: "RealtimeClientProvider"
41
+ }
42
+ ]
43
+ }
44
+ },
45
+ metadata: {
46
+ apiSummary: {
47
+ surfaces: [
48
+ {
49
+ subpath: "./server",
50
+ summary: "Exports RealtimeServiceProvider only."
51
+ },
52
+ {
53
+ subpath: "./client",
54
+ summary: "Exports RealtimeClientProvider only."
55
+ },
56
+ {
57
+ subpath: "./client/listeners",
58
+ summary: "Exports client listener registration helpers for provider-level realtime subscriptions."
59
+ },
60
+ {
61
+ subpath: "./client/composables/*",
62
+ summary: "Exports component-level realtime socket composables."
63
+ }
64
+ ],
65
+ containerTokens: {
66
+ server: [
67
+ "runtime.realtime"
68
+ ],
69
+ client: [
70
+ "runtime.realtime.client",
71
+ "runtime.realtime.client.socket",
72
+ "realtime.web.connection.indicator"
73
+ ]
74
+ }
75
+ },
76
+ ui: {
77
+ placements: {
78
+ outlets: [],
79
+ contributions: [
80
+ {
81
+ id: "realtime.connection.indicator",
82
+ host: "shell-layout",
83
+ position: "top-right",
84
+ surfaces: ["*"],
85
+ order: 950,
86
+ componentToken: "realtime.web.connection.indicator",
87
+ source: "mutations.text#realtime-placement-indicator"
88
+ }
89
+ ]
90
+ }
91
+ }
92
+ },
93
+ mutations: {
94
+ dependencies: {
95
+ runtime: {
96
+ "@jskit-ai/kernel": "0.1.4",
97
+ "@socket.io/redis-adapter": "^8.3.0",
98
+ "redis": "^5.8.2",
99
+ "socket.io": "^4.8.3",
100
+ "socket.io-client": "^4.8.3"
101
+ },
102
+ dev: {}
103
+ },
104
+ packageJson: {
105
+ scripts: {}
106
+ },
107
+ procfile: {},
108
+ files: [],
109
+ vite: {
110
+ proxy: [
111
+ {
112
+ id: "realtime-socket-io",
113
+ path: "/socket.io",
114
+ changeOrigin: true,
115
+ ws: true
116
+ }
117
+ ]
118
+ },
119
+ text: [
120
+ {
121
+ file: ".env",
122
+ op: "upsert-env",
123
+ key: "REALTIME_REDIS_URL",
124
+ value: "${option:realtime-redis-url}",
125
+ reason: "Configure optional Redis backplane URL for realtime socket adapter.",
126
+ category: "runtime-config",
127
+ id: "realtime-redis-url"
128
+ },
129
+ {
130
+ op: "append-text",
131
+ file: "src/placement.js",
132
+ position: "bottom",
133
+ skipIfContains: "id: \"realtime.connection.indicator\"",
134
+ value:
135
+ "\naddPlacement({\n id: \"realtime.connection.indicator\",\n host: \"shell-layout\",\n position: \"top-right\",\n surfaces: [\"*\"],\n order: 950,\n componentToken: \"realtime.web.connection.indicator\"\n});\n",
136
+ reason: "Append realtime connection indicator placement into app-owned placement registry.",
137
+ category: "realtime-web",
138
+ id: "realtime-placement-indicator"
139
+ }
140
+ ]
141
+ }
142
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@jskit-ai/realtime",
3
+ "version": "0.1.4",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./server/RealtimeServiceProvider": "./src/server/RealtimeServiceProvider.js",
10
+ "./server/runtime": "./src/server/runtime.js",
11
+ "./server/tokens": "./src/server/tokens.js",
12
+ "./client": "./src/client/RealtimeClientProvider.js",
13
+ "./client/RealtimeClientProvider": "./src/client/RealtimeClientProvider.js",
14
+ "./client/listeners": "./src/client/listeners.js",
15
+ "./client/composables/*": "./src/client/composables/*.js",
16
+ "./client/runtime": "./src/client/runtime.js",
17
+ "./client/tokens": "./src/client/tokens.js"
18
+ },
19
+ "dependencies": {
20
+ "@socket.io/redis-adapter": "^8.3.0",
21
+ "@jskit-ai/kernel": "0.1.4",
22
+ "redis": "^5.8.2",
23
+ "socket.io": "^4.8.3",
24
+ "socket.io-client": "^4.8.3"
25
+ }
26
+ }
@@ -0,0 +1,302 @@
1
+ import { createSocketIoClient, disconnectSocketIoClient } from "./runtime.js";
2
+ import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import {
4
+ CLIENT_MODULE_ENV_TOKEN,
5
+ CLIENT_MODULE_VUE_APP_TOKEN
6
+ } from "@jskit-ai/kernel/client/moduleBootstrap";
7
+ import { resolveClientBootstrapDebugEnabled } from "@jskit-ai/kernel/client";
8
+ import RealtimeConnectionIndicator from "./components/RealtimeConnectionIndicator.js";
9
+ import {
10
+ REALTIME_RUNTIME_CLIENT_TOKEN,
11
+ REALTIME_SOCKET_CLIENT_TOKEN,
12
+ REALTIME_SOCKET_CLIENT_INJECTION_KEY
13
+ } from "./tokens.js";
14
+ import { resolveRealtimeClientListeners } from "./listeners.js";
15
+
16
+ const REALTIME_CONNECTION_INDICATOR_COMPONENT_TOKEN = "realtime.web.connection.indicator";
17
+
18
+ const REALTIME_RUNTIME_CLIENT_API = Object.freeze({
19
+ createSocketIoClient,
20
+ disconnectSocketIoClient
21
+ });
22
+
23
+ function createProviderLogger(app, { debugEnabled = false } = {}) {
24
+ return Object.freeze({
25
+ debug: (...args) => {
26
+ if (debugEnabled !== true) {
27
+ return;
28
+ }
29
+ if (app && typeof app.info === "function") {
30
+ app.info(...args);
31
+ return;
32
+ }
33
+ console.info(...args);
34
+ },
35
+ info: (...args) => {
36
+ if (app && typeof app.info === "function") {
37
+ app.info(...args);
38
+ return;
39
+ }
40
+ console.info(...args);
41
+ },
42
+ warn: (...args) => {
43
+ if (app && typeof app.warn === "function") {
44
+ app.warn(...args);
45
+ return;
46
+ }
47
+ console.warn(...args);
48
+ },
49
+ error: (...args) => {
50
+ if (app && typeof app.error === "function") {
51
+ app.error(...args);
52
+ return;
53
+ }
54
+ console.error(...args);
55
+ }
56
+ });
57
+ }
58
+
59
+ function resolveRealtimeClientConfig(app) {
60
+ const appConfig = app && typeof app.has === "function" && app.has("appConfig") ? normalizeObject(app.make("appConfig")) : {};
61
+ const env = app && typeof app.has === "function" && app.has(CLIENT_MODULE_ENV_TOKEN) ? normalizeObject(app.make(CLIENT_MODULE_ENV_TOKEN)) : {};
62
+ const realtime = normalizeObject(appConfig.realtime);
63
+ const realtimeClient = normalizeObject(appConfig.realtimeClient);
64
+ const url = normalizeText(realtimeClient.url);
65
+ const options = normalizeObject(realtimeClient.options);
66
+ const explicitDebugEnabled =
67
+ typeof realtimeClient.debug === "boolean"
68
+ ? realtimeClient.debug
69
+ : typeof realtime.debug === "boolean"
70
+ ? realtime.debug
71
+ : undefined;
72
+ const hasRealtimeDebugEnvOverride = Object.hasOwn(env, "VITE_REALTIME_DEBUG");
73
+ const debugEnabled = hasRealtimeDebugEnvOverride
74
+ ? resolveClientBootstrapDebugEnabled({
75
+ env,
76
+ debugEnabled: undefined,
77
+ debugEnvKey: "VITE_REALTIME_DEBUG"
78
+ })
79
+ : resolveClientBootstrapDebugEnabled({
80
+ env,
81
+ debugEnabled: explicitDebugEnabled,
82
+ debugEnvKey: "VITE_REALTIME_DEBUG"
83
+ });
84
+
85
+ return Object.freeze({
86
+ url,
87
+ options,
88
+ debugEnabled
89
+ });
90
+ }
91
+
92
+ class RealtimeClientProvider {
93
+ static id = REALTIME_RUNTIME_CLIENT_TOKEN;
94
+
95
+ register(app) {
96
+ if (!app || typeof app.singleton !== "function") {
97
+ throw new Error("RealtimeClientProvider requires application singleton().");
98
+ }
99
+
100
+ app.singleton(REALTIME_RUNTIME_CLIENT_TOKEN, () => REALTIME_RUNTIME_CLIENT_API);
101
+ app.singleton(REALTIME_CONNECTION_INDICATOR_COMPONENT_TOKEN, () => RealtimeConnectionIndicator);
102
+ app.singleton(REALTIME_SOCKET_CLIENT_TOKEN, (scope) => {
103
+ const realtimeRuntime = scope.make(REALTIME_RUNTIME_CLIENT_TOKEN);
104
+ const realtimeClientConfig = resolveRealtimeClientConfig(scope);
105
+ return realtimeRuntime.createSocketIoClient({
106
+ url: realtimeClientConfig.url,
107
+ options: realtimeClientConfig.options
108
+ });
109
+ });
110
+ }
111
+
112
+ boot(app) {
113
+ if (!app || typeof app.make !== "function") {
114
+ throw new Error("RealtimeClientProvider requires application make().");
115
+ }
116
+
117
+ const realtimeClientConfig = resolveRealtimeClientConfig(app);
118
+ const logger = createProviderLogger(app, {
119
+ debugEnabled: realtimeClientConfig.debugEnabled
120
+ });
121
+ const socket = app.make(REALTIME_SOCKET_CLIENT_TOKEN);
122
+ const listeners = resolveRealtimeClientListeners(app);
123
+ const detach = [];
124
+
125
+ logger.debug(
126
+ {
127
+ providerId: RealtimeClientProvider.id,
128
+ listenerCount: listeners.length,
129
+ listeners: listeners.map((listener) => ({
130
+ listenerId: listener.listenerId,
131
+ event: listener.event
132
+ }))
133
+ },
134
+ "Realtime client booted listeners."
135
+ );
136
+
137
+ if (typeof socket.on === "function") {
138
+ const onConnect = () => {
139
+ logger.debug(
140
+ {
141
+ providerId: RealtimeClientProvider.id,
142
+ socketConnected: true
143
+ },
144
+ "Realtime client socket connected."
145
+ );
146
+ };
147
+ const onDisconnect = (reason) => {
148
+ logger.debug(
149
+ {
150
+ providerId: RealtimeClientProvider.id,
151
+ socketConnected: false,
152
+ reason: String(reason || "")
153
+ },
154
+ "Realtime client socket disconnected."
155
+ );
156
+ };
157
+ const onConnectError = (error) => {
158
+ logger.warn(
159
+ {
160
+ providerId: RealtimeClientProvider.id,
161
+ error: String(error?.message || error || "unknown error")
162
+ },
163
+ "Realtime client socket connect error."
164
+ );
165
+ };
166
+
167
+ socket.on("connect", onConnect);
168
+ socket.on("disconnect", onDisconnect);
169
+ socket.on("connect_error", onConnectError);
170
+ detach.push(() => {
171
+ if (typeof socket.off === "function") {
172
+ socket.off("connect", onConnect);
173
+ socket.off("disconnect", onDisconnect);
174
+ socket.off("connect_error", onConnectError);
175
+ }
176
+ });
177
+
178
+ if (realtimeClientConfig.debugEnabled === true && typeof socket.onAny === "function") {
179
+ const onAnyDebug = (eventName, payload) => {
180
+ logger.debug(
181
+ {
182
+ providerId: RealtimeClientProvider.id,
183
+ event: String(eventName || ""),
184
+ payloadScope: payload?.scope || null,
185
+ payloadEntityId: payload?.entityId || null
186
+ },
187
+ "Realtime client received socket event."
188
+ );
189
+ };
190
+ socket.onAny(onAnyDebug);
191
+ detach.push(() => {
192
+ if (typeof socket.offAny === "function") {
193
+ socket.offAny(onAnyDebug);
194
+ }
195
+ });
196
+ }
197
+ }
198
+
199
+ for (const listener of listeners) {
200
+ const invoke = (eventName, payload) => {
201
+ const context = Object.freeze({
202
+ event: eventName,
203
+ payload,
204
+ socket,
205
+ app
206
+ });
207
+
208
+ if (listener.matches && listener.matches(context) !== true) {
209
+ logger.debug(
210
+ {
211
+ listenerId: listener.listenerId,
212
+ event: eventName
213
+ },
214
+ "Realtime client listener skipped event by matches()."
215
+ );
216
+ return;
217
+ }
218
+
219
+ logger.debug(
220
+ {
221
+ listenerId: listener.listenerId,
222
+ event: eventName,
223
+ payloadScope: payload?.scope || null,
224
+ payloadEntityId: payload?.entityId || null
225
+ },
226
+ "Realtime client listener handling event."
227
+ );
228
+
229
+ Promise.resolve(listener.handle(context)).catch((error) => {
230
+ logger.error(
231
+ {
232
+ listenerId: listener.listenerId,
233
+ event: eventName,
234
+ error: String(error?.message || error || "unknown error")
235
+ },
236
+ "Realtime client listener failed."
237
+ );
238
+ });
239
+ };
240
+
241
+ if (listener.event === "*") {
242
+ if (typeof socket.onAny === "function") {
243
+ const onAny = (eventName, payload) => invoke(eventName, payload);
244
+ socket.onAny(onAny);
245
+ detach.push(() => {
246
+ if (typeof socket.offAny === "function") {
247
+ socket.offAny(onAny);
248
+ }
249
+ });
250
+ }
251
+ continue;
252
+ }
253
+
254
+ if (typeof socket.on === "function") {
255
+ const onEvent = (payload) => invoke(listener.event, payload);
256
+ socket.on(listener.event, onEvent);
257
+ detach.push(() => {
258
+ if (typeof socket.off === "function") {
259
+ socket.off(listener.event, onEvent);
260
+ }
261
+ });
262
+ }
263
+ }
264
+
265
+ this.socket = socket;
266
+ this.detach = detach;
267
+
268
+ if (!app.has(CLIENT_MODULE_VUE_APP_TOKEN)) {
269
+ return;
270
+ }
271
+
272
+ const vueApp = app.make(CLIENT_MODULE_VUE_APP_TOKEN);
273
+ if (!vueApp || typeof vueApp.provide !== "function") {
274
+ return;
275
+ }
276
+ vueApp.provide(REALTIME_SOCKET_CLIENT_INJECTION_KEY, socket);
277
+ }
278
+
279
+ shutdown(app) {
280
+ if (Array.isArray(this.detach)) {
281
+ for (const release of this.detach) {
282
+ if (typeof release === "function") {
283
+ try {
284
+ release();
285
+ } catch {}
286
+ }
287
+ }
288
+ }
289
+ this.detach = [];
290
+
291
+ if (!this.socket) {
292
+ return;
293
+ }
294
+
295
+ const runtimeApi =
296
+ app && typeof app.make === "function" ? app.make(REALTIME_RUNTIME_CLIENT_TOKEN) : REALTIME_RUNTIME_CLIENT_API;
297
+ runtimeApi.disconnectSocketIoClient(this.socket);
298
+ this.socket = null;
299
+ }
300
+ }
301
+
302
+ export { RealtimeClientProvider };
@@ -0,0 +1,122 @@
1
+ import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from "vue";
2
+ import { EMPTY_REALTIME_SOCKET, useRealtimeSocket } from "../composables/useRealtimeEvent.js";
3
+
4
+ const ROOT_STYLE = Object.freeze({
5
+ alignItems: "center",
6
+ display: "inline-flex",
7
+ height: "32px",
8
+ justifyContent: "center",
9
+ width: "32px"
10
+ });
11
+
12
+ const DOT_STYLE = Object.freeze({
13
+ borderRadius: "9999px",
14
+ boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
15
+ display: "block",
16
+ height: "10px",
17
+ width: "10px"
18
+ });
19
+
20
+ const SR_ONLY_STYLE = Object.freeze({
21
+ border: "0",
22
+ clip: "rect(0, 0, 0, 0)",
23
+ clipPath: "inset(50%)",
24
+ height: "1px",
25
+ margin: "-1px",
26
+ overflow: "hidden",
27
+ padding: "0",
28
+ position: "absolute",
29
+ whiteSpace: "nowrap",
30
+ width: "1px"
31
+ });
32
+
33
+ function resolveTooltipText({ realtimeAvailable, connected }) {
34
+ if (!realtimeAvailable) {
35
+ return "Realtime is unavailable. Live updates are disabled for this page.";
36
+ }
37
+ if (connected) {
38
+ return "Realtime is connected. Live updates are active.";
39
+ }
40
+ return "Realtime is disconnected. The client will keep trying to reconnect.";
41
+ }
42
+
43
+ const RealtimeConnectionIndicator = defineComponent({
44
+ name: "RealtimeConnectionIndicator",
45
+ setup() {
46
+ const socket = useRealtimeSocket({ required: false });
47
+ const connected = ref(Boolean(socket?.connected));
48
+ const realtimeAvailable = socket !== EMPTY_REALTIME_SOCKET;
49
+ let detach = null;
50
+
51
+ const tooltipText = computed(() => {
52
+ return resolveTooltipText({
53
+ realtimeAvailable,
54
+ connected: connected.value
55
+ });
56
+ });
57
+
58
+ const statusLabel = computed(() => (connected.value ? "Realtime connected" : "Realtime disconnected"));
59
+
60
+ const dotStyle = computed(() => ({
61
+ ...DOT_STYLE,
62
+ backgroundColor: connected.value ? "#22c55e" : "#ef4444"
63
+ }));
64
+
65
+ function syncConnected(nextState) {
66
+ connected.value = Boolean(nextState);
67
+ }
68
+
69
+ function bindSocketStatusListeners() {
70
+ if (!realtimeAvailable || typeof socket.on !== "function") {
71
+ syncConnected(false);
72
+ return;
73
+ }
74
+
75
+ syncConnected(socket.connected);
76
+
77
+ const onConnect = () => syncConnected(true);
78
+ const onDisconnect = () => syncConnected(false);
79
+ const onConnectError = () => syncConnected(false);
80
+
81
+ socket.on("connect", onConnect);
82
+ socket.on("disconnect", onDisconnect);
83
+ socket.on("connect_error", onConnectError);
84
+
85
+ detach = () => {
86
+ if (typeof socket.off === "function") {
87
+ socket.off("connect", onConnect);
88
+ socket.off("disconnect", onDisconnect);
89
+ socket.off("connect_error", onConnectError);
90
+ }
91
+ };
92
+ }
93
+
94
+ onMounted(() => {
95
+ bindSocketStatusListeners();
96
+ });
97
+
98
+ onBeforeUnmount(() => {
99
+ if (typeof detach === "function") {
100
+ detach();
101
+ }
102
+ detach = null;
103
+ });
104
+
105
+ return () =>
106
+ h(
107
+ "span",
108
+ {
109
+ style: ROOT_STYLE,
110
+ title: tooltipText.value,
111
+ "aria-label": statusLabel.value
112
+ },
113
+ [
114
+ h("span", { style: dotStyle.value, "aria-hidden": "true" }),
115
+ h("span", { style: SR_ONLY_STYLE }, statusLabel.value)
116
+ ]
117
+ );
118
+ }
119
+ });
120
+
121
+ export { RealtimeConnectionIndicator };
122
+ export default RealtimeConnectionIndicator;