@typexim/web-sdk 0.0.6 → 0.0.8-alpha.1

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.
Files changed (45) hide show
  1. package/README.md +10 -3
  2. package/dist/types/enums/sdk.d.ts +0 -1
  3. package/dist/types/interfaces/business.d.ts +45 -1
  4. package/dist/types/interfaces/sdk/api.d.ts +41 -1
  5. package/dist/types/modules/ChatSDK.d.ts +24 -3
  6. package/dist/types/modules/TabSessionCoordinator.d.ts +5 -5
  7. package/dist/types/utils/misc.d.ts +10 -0
  8. package/dist/web-sdk.esm.js +1 -1
  9. package/dist/web-sdk.js +1 -1
  10. package/dist-test/enums/business.js +139 -0
  11. package/dist-test/enums/index.js +2 -0
  12. package/dist-test/enums/sdk.js +24 -0
  13. package/dist-test/events.js +50 -0
  14. package/dist-test/index.js +26 -0
  15. package/dist-test/interfaces/business.js +1 -0
  16. package/dist-test/interfaces/index.js +3 -0
  17. package/dist-test/interfaces/pushMessage.js +1 -0
  18. package/dist-test/interfaces/sdk/api.js +1 -0
  19. package/dist-test/interfaces/sdk/common.js +1 -0
  20. package/dist-test/interfaces/sdk/index.js +2 -0
  21. package/dist-test/modules/Base.js +70 -0
  22. package/dist-test/modules/BusinessWebsocket.js +68 -0
  23. package/dist-test/modules/ChatSDK.js +698 -0
  24. package/dist-test/modules/FeedCache.js +211 -0
  25. package/dist-test/modules/HttpClient.js +104 -0
  26. package/dist-test/modules/Logger.js +73 -0
  27. package/dist-test/modules/PushMessageProcessor.js +437 -0
  28. package/dist-test/modules/ReConnectWebsocket.js +223 -0
  29. package/dist-test/modules/Storage.js +27 -0
  30. package/dist-test/modules/TabSessionCoordinator.js +289 -0
  31. package/dist-test/modules/Version.js +20 -0
  32. package/dist-test/utils/date.js +4 -0
  33. package/dist-test/utils/http.js +13 -0
  34. package/dist-test/utils/makeSign.js +79 -0
  35. package/dist-test/utils/message.js +146 -0
  36. package/dist-test/utils/misc.js +13 -0
  37. package/dist-test/utils/promise.js +10 -0
  38. package/dist-test/utils/sleep.js +3 -0
  39. package/dist-test/utils/websocket.js +24 -0
  40. package/package.json +3 -2
  41. package/test/chat-sdk-independent-login.test.mjs +218 -0
  42. package/test/{chat-sdk-restore-login.test.mjs → chat-sdk-session-restore.test.mjs} +69 -29
  43. package/test/tab-session-coordinator.test.mjs +30 -64
  44. package/dist/types/modules/SingletonLock.d.ts +0 -12
  45. package/test/singleton-lock.test.mjs +0 -75
@@ -0,0 +1,218 @@
1
+ import assert from 'node:assert/strict';
2
+ import { access, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { test } from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const channels = new Set();
8
+ const storageListeners = new Set();
9
+ const storageData = new Map();
10
+
11
+ class MockBroadcastChannel {
12
+ constructor(name) {
13
+ this.name = name;
14
+ this.onmessage = null;
15
+ channels.add(this);
16
+ }
17
+
18
+ postMessage(message) {
19
+ for (const channel of channels) {
20
+ if (channel !== this && channel.name === this.name && channel.onmessage) {
21
+ channel.onmessage({ data: message });
22
+ }
23
+ }
24
+ }
25
+
26
+ close() {
27
+ channels.delete(this);
28
+ }
29
+ }
30
+
31
+ class MockWebSocket {
32
+ static OPEN = 1;
33
+
34
+ constructor() {
35
+ this.OPEN = 1;
36
+ this.readyState = 1;
37
+ setTimeout(() => this.onopen?.(), 0);
38
+ }
39
+
40
+ send() {}
41
+
42
+ close() {}
43
+ }
44
+
45
+ function installBrowserMocks() {
46
+ channels.clear();
47
+ storageListeners.clear();
48
+ storageData.clear();
49
+
50
+ Object.defineProperty(globalThis, 'navigator', {
51
+ configurable: true,
52
+ value: {},
53
+ });
54
+ globalThis.BroadcastChannel = MockBroadcastChannel;
55
+ globalThis.WebSocket = MockWebSocket;
56
+ globalThis.window = {
57
+ addEventListener: () => {},
58
+ removeEventListener: () => {},
59
+ };
60
+ globalThis.localStorage = {
61
+ getItem: (key) => storageData.get(key) ?? null,
62
+ setItem: (key, value) => {
63
+ storageData.set(key, value);
64
+ },
65
+ removeItem: (key) => storageData.delete(key),
66
+ };
67
+ }
68
+
69
+ function createSdk(ChatSDK) {
70
+ const sdk = new ChatSDK({
71
+ httpHost: 'https://example.test',
72
+ websocketHost: 'wss://example.test',
73
+ });
74
+
75
+ sdk.httpClient.get = async () => ({
76
+ ok: true,
77
+ data: null,
78
+ });
79
+
80
+ return sdk;
81
+ }
82
+
83
+ async function patchDistTestImports(dirUrl) {
84
+ const entries = await readdir(dirUrl, { withFileTypes: true });
85
+
86
+ await Promise.all(
87
+ entries.map(async (entry) => {
88
+ const entryUrl = new URL(
89
+ `${entry.name}${entry.isDirectory() ? '/' : ''}`,
90
+ dirUrl,
91
+ );
92
+
93
+ if (entry.isDirectory()) {
94
+ await patchDistTestImports(entryUrl);
95
+ return;
96
+ }
97
+
98
+ if (!entry.name.endsWith('.js')) {
99
+ return;
100
+ }
101
+
102
+ const source = await readFile(entryUrl, 'utf8');
103
+ const patched = await patchRelativeImports(source, entryUrl);
104
+
105
+ if (patched !== source) {
106
+ await writeFile(entryUrl, patched);
107
+ }
108
+ }),
109
+ );
110
+ }
111
+
112
+ async function pathExists(path) {
113
+ try {
114
+ await access(path);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ async function patchRelativeImports(source, sourceUrl) {
122
+ const sourcePath = fileURLToPath(sourceUrl);
123
+ const sourceDir = dirname(sourcePath);
124
+ const importRegex =
125
+ /(?<prefix>from\s+['"])(?<specifier>\.\.?\/[^'"]+)(?<suffix>['"])/g;
126
+ const matches = [...source.matchAll(importRegex)];
127
+ let patched = source;
128
+
129
+ for (const match of matches) {
130
+ const { prefix, specifier, suffix } = match.groups;
131
+
132
+ if (specifier.endsWith('.js')) {
133
+ continue;
134
+ }
135
+
136
+ const targetPath = resolve(sourceDir, specifier);
137
+ let nextSpecifier = specifier;
138
+
139
+ if (await pathExists(`${targetPath}.js`)) {
140
+ nextSpecifier = `${specifier}.js`;
141
+ } else if (await pathExists(resolve(targetPath, 'index.js'))) {
142
+ nextSpecifier = `${specifier}/index.js`;
143
+ }
144
+
145
+ if (nextSpecifier !== specifier) {
146
+ patched = patched.replaceAll(
147
+ `${prefix}${specifier}${suffix}`,
148
+ `${prefix}${nextSpecifier}${suffix}`,
149
+ );
150
+ }
151
+ }
152
+
153
+ return patched;
154
+ }
155
+
156
+ test('two SDK instances can login independently without a cross-tab lock', async () => {
157
+ installBrowserMocks();
158
+
159
+ await patchDistTestImports(new URL('../dist-test/', import.meta.url));
160
+ const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
161
+
162
+ const firstSdk = createSdk(ChatSDK);
163
+ const secondSdk = createSdk(ChatSDK);
164
+
165
+ let sessionLoginCalls = 0;
166
+
167
+ const mockPost = async (url, body) => {
168
+ if (url === '/device_api/device_id') {
169
+ return {
170
+ ok: true,
171
+ data: 'device-shared',
172
+ };
173
+ }
174
+
175
+ if (url === '/user/session/login') {
176
+ sessionLoginCalls += 1;
177
+ return {
178
+ ok: true,
179
+ data: {
180
+ id: `user-${body.userCode}`,
181
+ key: `secret-${body.userCode}`,
182
+ },
183
+ };
184
+ }
185
+
186
+ return {
187
+ ok: false,
188
+ code: -1,
189
+ msg: `unexpected post ${url}`,
190
+ };
191
+ };
192
+
193
+ firstSdk.httpClient.post = mockPost;
194
+ secondSdk.httpClient.post = mockPost;
195
+
196
+ try {
197
+ // Wait for auto-restore timeouts so login does not share an in-flight restore.
198
+ await new Promise((resolve) => setTimeout(resolve, 20));
199
+
200
+ const firstRes = await firstSdk.login({ user_code: 'code-a' });
201
+ const secondRes = await secondSdk.login({ user_code: 'code-a' });
202
+
203
+ assert.equal(firstRes.ok, true);
204
+ assert.equal(secondRes.ok, true);
205
+ assert.equal(sessionLoginCalls, 2);
206
+ assert.equal(firstSdk.context.isLogin, true);
207
+ assert.equal(firstSdk.context.isReady, true);
208
+ assert.equal(secondSdk.context.isLogin, true);
209
+ assert.equal(secondSdk.context.isReady, true);
210
+ assert.equal(firstSdk.context.loginUser?.sessionId, 'code-a');
211
+ assert.equal(secondSdk.context.loginUser?.sessionId, 'code-a');
212
+ } finally {
213
+ firstSdk.afterLogout();
214
+ secondSdk.afterLogout();
215
+ firstSdk.tabSessionCoordinator.destroy();
216
+ secondSdk.tabSessionCoordinator.destroy();
217
+ }
218
+ });
@@ -47,25 +47,9 @@ function installBrowserMocks() {
47
47
  storageListeners.clear();
48
48
  storageData.clear();
49
49
 
50
- let lockHeld = false;
51
50
  Object.defineProperty(globalThis, 'navigator', {
52
51
  configurable: true,
53
- value: {
54
- locks: {
55
- request: async (_name, options, callback) => {
56
- if (lockHeld && options?.ifAvailable) {
57
- return callback(null);
58
- }
59
-
60
- lockHeld = true;
61
- try {
62
- return await callback({});
63
- } finally {
64
- lockHeld = false;
65
- }
66
- },
67
- },
68
- },
52
+ value: {},
69
53
  });
70
54
  globalThis.BroadcastChannel = MockBroadcastChannel;
71
55
  globalThis.WebSocket = MockWebSocket;
@@ -177,7 +161,7 @@ async function patchRelativeImports(source, sourceUrl) {
177
161
  return patched;
178
162
  }
179
163
 
180
- test('login restores an active tab session before calling session login API', async () => {
164
+ test('new tab restores neighbor session without login API and without kicking neighbor', async () => {
181
165
  installBrowserMocks();
182
166
 
183
167
  await patchDistTestImports(new URL('../dist-test/', import.meta.url));
@@ -192,10 +176,9 @@ test('login restores an active tab session before calling session login API', as
192
176
  secretKey: 'secret-1',
193
177
  sessionId: 'session-1',
194
178
  };
195
- await oldSdk.singletonLock.requestLock();
196
179
 
197
- const newSdk = createSdk(ChatSDK);
198
180
  let sessionLoginApiCalled = false;
181
+ const newSdk = createSdk(ChatSDK);
199
182
  newSdk.httpClient.post = async (url) => {
200
183
  if (url === '/user/session/login') {
201
184
  sessionLoginApiCalled = true;
@@ -208,25 +191,82 @@ test('login restores an active tab session before calling session login API', as
208
191
  };
209
192
 
210
193
  try {
211
- const res = await newSdk.login({
212
- user_code: 'session-1',
213
- });
194
+ // Wait for auto-restore scheduled in init.
195
+ await new Promise((resolve) => setTimeout(resolve, 20));
214
196
 
215
- assert.equal(res.ok, true);
216
- assert.equal(sessionLoginApiCalled, false);
217
197
  assert.equal(newSdk.context.isLogin, true);
218
198
  assert.equal(newSdk.context.isReady, true);
199
+ assert.equal(sessionLoginApiCalled, false);
219
200
  assert.deepEqual(newSdk.context.loginUser, {
220
201
  userId: 'user-1',
221
202
  secretKey: 'secret-1',
222
203
  sessionId: 'session-1',
223
204
  });
224
- assert.equal(oldSdk.context.isLogin, false);
225
- assert.equal(oldSdk.context.isReady, false);
205
+ assert.equal(oldSdk.context.isLogin, true);
206
+ assert.equal(oldSdk.context.isReady, true);
207
+
208
+ const loginRes = await newSdk.login({ user_code: 'session-1' });
209
+ assert.equal(loginRes.ok, true);
210
+ assert.equal(sessionLoginApiCalled, false);
226
211
  } finally {
227
- await oldSdk.afterLogout();
228
- await newSdk.afterLogout();
212
+ oldSdk.afterLogout();
213
+ newSdk.afterLogout();
229
214
  oldSdk.tabSessionCoordinator.destroy();
230
215
  newSdk.tabSessionCoordinator.destroy();
231
216
  }
232
217
  });
218
+
219
+ test('logout on one tab clears login state on sibling tabs without calling logout API again', async () => {
220
+ installBrowserMocks();
221
+
222
+ await patchDistTestImports(new URL('../dist-test/', import.meta.url));
223
+ const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
224
+
225
+ const firstSdk = createSdk(ChatSDK);
226
+ firstSdk.context.deviceId = 'device-1';
227
+ firstSdk.context.isLogin = true;
228
+ firstSdk.context.isReady = true;
229
+ firstSdk.context.loginUser = {
230
+ userId: 'user-1',
231
+ secretKey: 'secret-1',
232
+ sessionId: 'session-1',
233
+ };
234
+
235
+ const secondSdk = createSdk(ChatSDK);
236
+ await new Promise((resolve) => setTimeout(resolve, 20));
237
+
238
+ assert.equal(secondSdk.context.isLogin, true);
239
+
240
+ let logoutApiCalls = 0;
241
+ firstSdk.httpClient.get = async (url) => {
242
+ if (url === '/user/logout') {
243
+ logoutApiCalls += 1;
244
+ return { ok: true, data: null };
245
+ }
246
+ return { ok: true, data: null };
247
+ };
248
+ secondSdk.httpClient.get = async (url) => {
249
+ if (url === '/user/logout') {
250
+ logoutApiCalls += 1;
251
+ return { ok: true, data: null };
252
+ }
253
+ return { ok: true, data: null };
254
+ };
255
+
256
+ try {
257
+ const res = await firstSdk.logout();
258
+ await new Promise((resolve) => setTimeout(resolve, 0));
259
+
260
+ assert.equal(res.ok, true);
261
+ assert.equal(logoutApiCalls, 1);
262
+ assert.equal(firstSdk.context.isLogin, false);
263
+ assert.equal(firstSdk.context.isReady, false);
264
+ assert.equal(secondSdk.context.isLogin, false);
265
+ assert.equal(secondSdk.context.isReady, false);
266
+ } finally {
267
+ firstSdk.afterLogout();
268
+ secondSdk.afterLogout();
269
+ firstSdk.tabSessionCoordinator.destroy();
270
+ secondSdk.tabSessionCoordinator.destroy();
271
+ }
272
+ });
@@ -53,54 +53,6 @@ function installBrowserMocks() {
53
53
  };
54
54
  }
55
55
 
56
- test('a new tab takeover request only notifies the old tab', async () => {
57
- installBrowserMocks();
58
-
59
- let oldTabKicked = 0;
60
- let newTabKicked = 0;
61
- const oldTab = new TabSessionCoordinator({
62
- onTakeoverRequest: () => {
63
- oldTabKicked += 1;
64
- },
65
- });
66
- const newTab = new TabSessionCoordinator({
67
- onTakeoverRequest: () => {
68
- newTabKicked += 1;
69
- },
70
- });
71
-
72
- newTab.requestTakeover();
73
- await new Promise((resolve) => setTimeout(resolve, 0));
74
-
75
- assert.equal(oldTabKicked, 1);
76
- assert.equal(newTabKicked, 0);
77
-
78
- oldTab.destroy();
79
- newTab.destroy();
80
- });
81
-
82
- test('destroyed coordinators ignore later takeover requests', async () => {
83
- installBrowserMocks();
84
-
85
- let oldTabKicked = 0;
86
- const oldTab = new TabSessionCoordinator({
87
- onTakeoverRequest: () => {
88
- oldTabKicked += 1;
89
- },
90
- });
91
- const newTab = new TabSessionCoordinator({
92
- onTakeoverRequest: () => {},
93
- });
94
-
95
- oldTab.destroy();
96
- newTab.requestTakeover();
97
- await new Promise((resolve) => setTimeout(resolve, 0));
98
-
99
- assert.equal(oldTabKicked, 0);
100
-
101
- newTab.destroy();
102
- });
103
-
104
56
  test('a new tab can request the current login session from an old tab', async () => {
105
57
  installBrowserMocks();
106
58
 
@@ -114,11 +66,8 @@ test('a new tab can request the current login session from an old tab', async ()
114
66
  };
115
67
  const oldTab = new TabSessionCoordinator({
116
68
  getCurrentSession: () => session,
117
- onTakeoverRequest: () => {},
118
- });
119
- const newTab = new TabSessionCoordinator({
120
- onTakeoverRequest: () => {},
121
69
  });
70
+ const newTab = new TabSessionCoordinator({});
122
71
 
123
72
  const restoredSession = await newTab.requestCurrentSession(50);
124
73
 
@@ -141,11 +90,8 @@ test('login session responses are not persisted through localStorage', async ()
141
90
  };
142
91
  const oldTab = new TabSessionCoordinator({
143
92
  getCurrentSession: () => session,
144
- onTakeoverRequest: () => {},
145
- });
146
- const newTab = new TabSessionCoordinator({
147
- onTakeoverRequest: () => {},
148
93
  });
94
+ const newTab = new TabSessionCoordinator({});
149
95
 
150
96
  const restoredSession = await newTab.requestCurrentSession(50);
151
97
  const storedMessages = [...storageData.values()].map((value) =>
@@ -185,11 +131,8 @@ test('duplicate session requests from fallback channels are answered once', asyn
185
131
  sessionRequestCount += 1;
186
132
  return session;
187
133
  },
188
- onTakeoverRequest: () => {},
189
- });
190
- const newTab = new TabSessionCoordinator({
191
- onTakeoverRequest: () => {},
192
134
  });
135
+ const newTab = new TabSessionCoordinator({});
193
136
 
194
137
  await newTab.requestCurrentSession(50);
195
138
 
@@ -204,11 +147,8 @@ test('requesting a login session returns null when no tab is logged in', async (
204
147
 
205
148
  const oldTab = new TabSessionCoordinator({
206
149
  getCurrentSession: () => null,
207
- onTakeoverRequest: () => {},
208
- });
209
- const newTab = new TabSessionCoordinator({
210
- onTakeoverRequest: () => {},
211
150
  });
151
+ const newTab = new TabSessionCoordinator({});
212
152
 
213
153
  const restoredSession = await newTab.requestCurrentSession(1);
214
154
 
@@ -217,3 +157,29 @@ test('requesting a login session returns null when no tab is logged in', async (
217
157
  oldTab.destroy();
218
158
  newTab.destroy();
219
159
  });
160
+
161
+ test('logout broadcast notifies other tabs once', async () => {
162
+ installBrowserMocks();
163
+
164
+ let oldTabLogout = 0;
165
+ let newTabLogout = 0;
166
+ const oldTab = new TabSessionCoordinator({
167
+ onLogoutBroadcast: () => {
168
+ oldTabLogout += 1;
169
+ },
170
+ });
171
+ const newTab = new TabSessionCoordinator({
172
+ onLogoutBroadcast: () => {
173
+ newTabLogout += 1;
174
+ },
175
+ });
176
+
177
+ newTab.publishLogout();
178
+ await new Promise((resolve) => setTimeout(resolve, 0));
179
+
180
+ assert.equal(oldTabLogout, 1);
181
+ assert.equal(newTabLogout, 0);
182
+
183
+ oldTab.destroy();
184
+ newTab.destroy();
185
+ });
@@ -1,12 +0,0 @@
1
- export type SingletonLockRequestOptions = {
2
- timeoutMs?: number;
3
- retryIntervalMs?: number;
4
- };
5
- export default class SingletonLock {
6
- private obtained;
7
- private abortLockObtain;
8
- constructor();
9
- requestLock(options?: SingletonLockRequestOptions): Promise<boolean>;
10
- private requestLockOnce;
11
- releaseLock(): void;
12
- }
@@ -1,75 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
-
4
- import SingletonLock from '../dist-test/modules/SingletonLock.js';
5
-
6
- function installMockLocks(sequence) {
7
- let callIndex = 0;
8
- Object.defineProperty(globalThis, 'navigator', {
9
- configurable: true,
10
- value: {
11
- locks: {
12
- request: (_name, _options, callback) => {
13
- const available = sequence[Math.min(callIndex, sequence.length - 1)];
14
- callIndex += 1;
15
- Promise.resolve().then(() => callback(available ? {} : null));
16
- },
17
- },
18
- },
19
- });
20
- }
21
-
22
- function installNavigator(value) {
23
- Object.defineProperty(globalThis, 'navigator', {
24
- configurable: true,
25
- value,
26
- });
27
- }
28
-
29
- test('requestLock retries until an old tab releases the singleton lock', async () => {
30
- installMockLocks([false, false, true]);
31
-
32
- const lock = new SingletonLock();
33
- const obtained = await lock.requestLock({
34
- retryIntervalMs: 1,
35
- timeoutMs: 50,
36
- });
37
-
38
- assert.equal(obtained, true);
39
- });
40
-
41
- test('requestLock gives up when the old tab does not release in time', async () => {
42
- installMockLocks([false]);
43
-
44
- const lock = new SingletonLock();
45
- const obtained = await lock.requestLock({
46
- retryIntervalMs: 1,
47
- timeoutMs: 5,
48
- });
49
-
50
- assert.equal(obtained, false);
51
- });
52
-
53
- test('requestLock fails closed when Web Locks is unavailable', async () => {
54
- installNavigator({});
55
-
56
- const lock = new SingletonLock();
57
- const obtained = await lock.requestLock();
58
-
59
- assert.equal(obtained, false);
60
- });
61
-
62
- test('requestLock fails closed when Web Locks throws', async () => {
63
- installNavigator({
64
- locks: {
65
- request: () => {
66
- throw new Error('locks unavailable');
67
- },
68
- },
69
- });
70
-
71
- const lock = new SingletonLock();
72
- const obtained = await lock.requestLock();
73
-
74
- assert.equal(obtained, false);
75
- });