@lowdefy/client 6.0.0 → 7.0.0

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 (51) hide show
  1. package/dist/Client.js +54 -17
  2. package/dist/Context.js +30 -12
  3. package/dist/HtmlOverlay.js +127 -0
  4. package/dist/IconProvider.js +24 -0
  5. package/dist/MountEvents.js +3 -3
  6. package/dist/adapters/createRouter.js +9 -3
  7. package/dist/adapters/url.js +1 -7
  8. package/dist/auth/createAuthMethods.js +1034 -41
  9. package/dist/block/Block.js +6 -3
  10. package/dist/block/CategorySwitch.js +6 -0
  11. package/dist/block/Container.js +3 -0
  12. package/dist/block/InputContainer.js +8 -2
  13. package/dist/block/List.js +3 -0
  14. package/dist/block/LoadingBlock.js +6 -12
  15. package/dist/block/LoadingContainer.js +6 -1
  16. package/dist/block/LoadingList.js +6 -1
  17. package/dist/block/loadingBlockMethods.js +26 -0
  18. package/dist/claimCallerReload.js +38 -0
  19. package/dist/createAntdIconConfig.js +91 -0
  20. package/dist/createCallAPI.js +6 -2
  21. package/dist/createCallRequest.js +6 -2
  22. package/dist/createHandleError.js +21 -11
  23. package/dist/createIcon.js +138 -36
  24. package/dist/createPageLifecycleManager.js +172 -0
  25. package/dist/formatIconTitle.js +26 -0
  26. package/dist/getActiveLocale.js +27 -0
  27. package/dist/getContextKey.js +29 -0
  28. package/dist/getMessageIcon.js +43 -0
  29. package/dist/hasOtherRoles.js +30 -0
  30. package/dist/index.js +1 -0
  31. package/dist/initLowdefyContext.js +103 -4
  32. package/dist/isOtherCaller.js +29 -0
  33. package/dist/recordCallerStamp.js +31 -0
  34. package/dist/renderIconNodes.js +46 -0
  35. package/dist/request.js +74 -6
  36. package/dist/setLowdefyUser.js +32 -0
  37. package/dist/setupLink.js +19 -13
  38. package/dist/shouldReloadForAuthError.js +39 -0
  39. package/dist/shouldReloadForBuild.js +44 -0
  40. package/dist/shouldReloadForCaller.js +50 -0
  41. package/dist/storage/clearUserStorage.js +36 -0
  42. package/dist/storage/clearUserStorageOnOwnerChange.js +41 -0
  43. package/dist/storage/createStorage.js +48 -0
  44. package/dist/storage/listUserStorageKeys.js +36 -0
  45. package/dist/storage/readStorageItem.js +31 -0
  46. package/dist/storage/userStorageKey.js +24 -0
  47. package/dist/storage/userStoragePrefix.js +25 -0
  48. package/dist/useDarkMode.js +17 -4
  49. package/dist/useLocale.js +18 -5
  50. package/dist/websocket/createWebSocketClient.js +239 -100
  51. package/package.json +11 -10
@@ -12,31 +12,53 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ const ACK_TIMEOUT_MS = 10 * 1000;
15
+ */ import { decodeServerError } from '@lowdefy/engine';
16
+ import { ServiceError } from '@lowdefy/errors';
17
+ import { type } from '@lowdefy/helpers';
18
+ const ACK_TIMEOUT_MS = 10 * 1000;
16
19
  const IDLE_CLOSE_GRACE_MS = 5 * 1000;
17
20
  const RECONNECT_BASE_MS = 500;
18
21
  const RECONNECT_CAP_MS = 15 * 1000;
22
+ // A connection that stays open this long resets the reconnect backoff. A
23
+ // server that accepts connections and drops them straight away keeps backing
24
+ // off instead of being retried every half second.
25
+ const STABLE_CONNECTION_MS = 10 * 1000;
26
+ // The server closes with this code when the socket's caller signed out or
27
+ // changed (user, organization or roles). The client reconnects straight away,
28
+ // which authenticates afresh, and resubscribes as the new caller.
29
+ const CALLER_CHANGED_CODE = 4401;
30
+ // A connection closed for a caller change again within this long of opening
31
+ // reconnects with the normal backoff, so a server that keeps closing for that
32
+ // reason does not get a tight reconnect loop.
33
+ const CALLER_CHANGE_REPEAT_MS = 30 * 1000;
19
34
  // One websocket per browser tab, shared by all channels, created lazily on
20
35
  // the first subscribe or publish. Connections are ephemeral in production
21
36
  // (serverless platforms close them at function max duration), so reconnect
22
37
  // with backoff and resubscribe is the steady state, not an edge case.
23
38
  function createWebSocketClient(lowdefy) {
24
39
  const { window } = lowdefy._internal.globals;
25
- // websocketId → { payload, handlers }
40
+ // websocketId → { websocketId, payload, handlers, requestId, ackTimer, pending }
41
+ // requestId names the last subscribe frame sent, which the server echoes in
42
+ // its reply. ackTimer runs while that frame waits for its ack. pending holds
43
+ // the subscribe promise's { resolve, reject } until the first ack, then is
44
+ // null.
26
45
  const subscriptions = new Map();
27
- // websocketId → { resolve, reject, timer } for pending subscribe acks
28
- const pendingSubscribes = new Map();
29
- // requestId → { resolve, reject, timer } for pending publish acks
46
+ // requestId → { frame, sent, resolve, reject, timer } for publishes waiting
47
+ // for their ack. A publish made while disconnected is sent when the
48
+ // connection opens, unless it timed out first.
30
49
  const pendingPublishes = new Map();
31
- // Frames queued while the socket is (re)connecting.
32
- const sendQueue = [];
33
50
  let socket = null;
34
51
  let openPromise = null;
35
52
  let reconnectAttempt = 0;
36
53
  let reconnectTimer = null;
37
54
  let idleTimer = null;
38
- let closedByClient = false;
55
+ let stableTimer = null;
56
+ // When the current connection opened, and whether it was opened by the
57
+ // reconnect that follows a caller change.
58
+ let openedAt = null;
59
+ let reconnectingAfterCallerChange = false;
39
60
  let publishCounter = 0;
61
+ let subscribeCounter = 0;
40
62
  function url() {
41
63
  const { location } = window;
42
64
  const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
@@ -48,114 +70,207 @@ function createWebSocketClient(lowdefy) {
48
70
  idleTimer = null;
49
71
  }
50
72
  }
73
+ function isIdle() {
74
+ return subscriptions.size === 0 && pendingPublishes.size === 0;
75
+ }
51
76
  function scheduleIdleClose() {
52
77
  clearIdleTimer();
53
- if (subscriptions.size > 0) {
78
+ if (!isIdle()) {
54
79
  return;
55
80
  }
56
81
  // Grace period so rapid page navigation doesn't thrash connections.
57
82
  idleTimer = setTimeout(()=>{
58
- if (subscriptions.size === 0 && socket) {
59
- closedByClient = true;
83
+ if (isIdle() && socket) {
84
+ // The close event arrives after the closing handshake, when a new
85
+ // socket may already be connecting or open. This socket is finished
86
+ // with, so its events must not reach the client state that now
87
+ // belongs to the new socket.
88
+ socket.onclose = null;
89
+ socket.onmessage = null;
90
+ clearTimeout(stableTimer);
91
+ reconnectingAfterCallerChange = false;
60
92
  socket.close();
61
93
  socket = null;
62
94
  openPromise = null;
63
95
  }
64
96
  }, IDLE_CLOSE_GRACE_MS);
65
97
  }
66
- function send(frame) {
67
- const message = JSON.stringify(frame);
68
- if (socket && socket.readyState === window.WebSocket.OPEN) {
69
- socket.send(message);
98
+ function isSocketOpen() {
99
+ return socket !== null && socket.readyState === window.WebSocket.OPEN;
100
+ }
101
+ function sendPublish(pending) {
102
+ pending.sent = true;
103
+ socket.send(JSON.stringify(pending.frame));
104
+ }
105
+ function hasUnsentPublishes() {
106
+ return [
107
+ ...pendingPublishes.values()
108
+ ].some((pending)=>!pending.sent);
109
+ }
110
+ function sendUnsentPublishes() {
111
+ pendingPublishes.forEach((pending)=>{
112
+ if (!pending.sent) {
113
+ sendPublish(pending);
114
+ }
115
+ });
116
+ }
117
+ function clearAckTimer(subscription) {
118
+ clearTimeout(subscription.ackTimer);
119
+ subscription.ackTimer = null;
120
+ }
121
+ function handleAckTimeout(subscription) {
122
+ subscription.ackTimer = null;
123
+ const error = new ServiceError(`Subscribe to "${subscription.websocketId}" timed out.`, {
124
+ service: 'WebSocket'
125
+ });
126
+ if (subscription.pending) {
127
+ subscriptions.delete(subscription.websocketId);
128
+ // The server handles the frame late, or not yet, and would open the feed
129
+ // for a caller that has given up on it.
130
+ if (isSocketOpen()) {
131
+ socket.send(JSON.stringify({
132
+ type: 'unsubscribe',
133
+ websocketId: subscription.websocketId
134
+ }));
135
+ }
136
+ scheduleIdleClose();
137
+ subscription.pending.reject(error);
70
138
  return;
71
139
  }
72
- sendQueue.push(message);
73
- connect();
140
+ subscription.handlers.onError(error.message);
141
+ }
142
+ // Every path that removes a subscription from the map or answers its
143
+ // subscribe clears this timer, so a timer that fires always belongs to the
144
+ // subscription currently in the map.
145
+ function startAckTimer(subscription) {
146
+ clearAckTimer(subscription);
147
+ subscription.ackTimer = setTimeout(()=>handleAckTimeout(subscription), ACK_TIMEOUT_MS);
148
+ }
149
+ function sendSubscribe(subscription) {
150
+ const { payload, websocketId } = subscription;
151
+ subscribeCounter += 1;
152
+ subscription.requestId = `s${subscribeCounter}`;
153
+ startAckTimer(subscription);
154
+ socket.send(JSON.stringify({
155
+ type: 'subscribe',
156
+ websocketId,
157
+ payload,
158
+ requestId: subscription.requestId
159
+ }));
160
+ }
161
+ // A reply to an earlier subscribe frame for the same feed, one that a newer
162
+ // subscribe or a resubscribe replaced, does not answer this subscription. A
163
+ // server that does not echo requestId answers by websocketId alone.
164
+ function answersSubscription(subscription, requestId) {
165
+ return type.isNone(requestId) || requestId === subscription.requestId;
166
+ }
167
+ function settlePublish(requestId) {
168
+ const pending = pendingPublishes.get(requestId);
169
+ clearTimeout(pending.timer);
170
+ pendingPublishes.delete(requestId);
171
+ scheduleIdleClose();
172
+ return pending;
74
173
  }
75
- function flushQueue() {
76
- while(sendQueue.length > 0 && socket && socket.readyState === window.WebSocket.OPEN){
77
- socket.send(sendQueue.shift());
174
+ // A caller that unsubscribed, or whose subscribe was replaced by a newer one
175
+ // to the same feed, no longer wants this subscription, so its subscribe
176
+ // promise settles without an error.
177
+ function releaseSubscription(subscription) {
178
+ clearAckTimer(subscription);
179
+ if (subscription.pending) {
180
+ subscription.pending.resolve();
181
+ subscription.pending = null;
78
182
  }
79
183
  }
80
184
  function resubscribeAll() {
81
- subscriptions.forEach(({ payload }, websocketId)=>{
82
- socket.send(JSON.stringify({
83
- type: 'subscribe',
84
- websocketId,
85
- payload
86
- }));
87
- });
185
+ subscriptions.forEach(sendSubscribe);
88
186
  }
89
187
  function handleFrame(frame) {
90
- const { message, payload, requestId, websocketId } = frame;
188
+ const { error: errorPayload, message, payload, requestId, websocketId } = frame;
91
189
  const subscription = subscriptions.get(websocketId);
92
190
  switch(frame.type){
93
191
  case 'message':
94
192
  subscription?.handlers.onMessage(payload);
95
193
  return;
96
194
  case 'subscribed':
97
- {
98
- const pending = pendingSubscribes.get(websocketId);
99
- if (pending) {
100
- clearTimeout(pending.timer);
101
- pendingSubscribes.delete(websocketId);
102
- pending.resolve();
103
- }
104
- subscription?.handlers.onConnected();
195
+ if (!subscription || !answersSubscription(subscription, requestId)) {
105
196
  return;
106
197
  }
198
+ clearAckTimer(subscription);
199
+ if (subscription.pending) {
200
+ subscription.pending.resolve();
201
+ subscription.pending = null;
202
+ }
203
+ subscription.handlers.onConnected();
204
+ return;
107
205
  case 'unsubscribed':
108
206
  return;
109
207
  case 'published':
110
- {
111
- const pending = pendingPublishes.get(requestId);
112
- if (pending) {
113
- clearTimeout(pending.timer);
114
- pendingPublishes.delete(requestId);
115
- pending.resolve();
116
- }
117
- return;
208
+ if (pendingPublishes.has(requestId)) {
209
+ settlePublish(requestId).resolve();
118
210
  }
211
+ return;
119
212
  case 'error':
120
213
  {
121
- const error = new Error(message ?? 'WebSocket error.');
122
- if (requestId && pendingPublishes.has(requestId)) {
123
- const pending = pendingPublishes.get(requestId);
124
- clearTimeout(pending.timer);
125
- pendingPublishes.delete(requestId);
126
- pending.reject(error);
214
+ // Reply frames carry the error payload; broadcasts and frame-format
215
+ // errors carry only a message string.
216
+ const error = errorPayload ? decodeServerError(errorPayload) : new ServiceError(message ?? 'WebSocket error.', {
217
+ service: 'WebSocket'
218
+ });
219
+ if (!type.isNone(requestId) && pendingPublishes.has(requestId)) {
220
+ settlePublish(requestId).reject(error);
127
221
  return;
128
222
  }
129
- if (websocketId && pendingSubscribes.has(websocketId)) {
130
- const pending = pendingSubscribes.get(websocketId);
131
- clearTimeout(pending.timer);
132
- pendingSubscribes.delete(websocketId);
133
- subscriptions.delete(websocketId);
134
- pending.reject(error);
223
+ if (!subscription) {
224
+ lowdefy._internal.logger.warn(error);
135
225
  return;
136
226
  }
137
- if (subscription) {
227
+ // A broadcast from the feed's source carries no requestId.
228
+ if (type.isNone(requestId)) {
138
229
  subscription.handlers.onError(error.message);
139
230
  return;
140
231
  }
141
- lowdefy._internal.logger.warn(error);
232
+ if (requestId !== subscription.requestId) {
233
+ return;
234
+ }
235
+ clearAckTimer(subscription);
236
+ if (subscription.pending) {
237
+ subscriptions.delete(websocketId);
238
+ scheduleIdleClose();
239
+ subscription.pending.reject(error);
240
+ return;
241
+ }
242
+ subscription.handlers.onError(error.message);
142
243
  return;
143
244
  }
144
245
  default:
145
246
  }
146
247
  }
147
- function handleClose() {
248
+ function handleClose(event) {
148
249
  socket = null;
149
250
  openPromise = null;
150
- subscriptions.forEach(({ handlers })=>{
151
- handlers.onDisconnected();
251
+ clearTimeout(stableTimer);
252
+ subscriptions.forEach((subscription)=>{
253
+ // A resubscribe lost with the connection is sent again, with a new ack
254
+ // timer, when the connection reopens. A subscribe whose caller still
255
+ // waits keeps its timer, so it fails if no connection opens in time.
256
+ if (!subscription.pending) {
257
+ clearAckTimer(subscription);
258
+ }
259
+ subscription.handlers.onDisconnected();
152
260
  });
153
- if (closedByClient) {
154
- closedByClient = false;
261
+ if (subscriptions.size === 0 && !hasUnsentPublishes()) {
155
262
  return;
156
263
  }
157
- if (subscriptions.size === 0 && sendQueue.length === 0) {
158
- return;
264
+ // A caller change is not a failing server, so the first reconnect after
265
+ // one does not back off.
266
+ if (event.code === CALLER_CHANGED_CODE) {
267
+ const repeated = reconnectingAfterCallerChange && Date.now() - openedAt < CALLER_CHANGE_REPEAT_MS;
268
+ if (!repeated) {
269
+ reconnectAttempt = 0;
270
+ }
271
+ reconnectingAfterCallerChange = true;
272
+ } else {
273
+ reconnectingAfterCallerChange = false;
159
274
  }
160
275
  // Capped exponential backoff with full jitter.
161
276
  const base = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_CAP_MS);
@@ -175,9 +290,15 @@ function createWebSocketClient(lowdefy) {
175
290
  const ws = new window.WebSocket(url());
176
291
  ws.onopen = ()=>{
177
292
  socket = ws;
178
- reconnectAttempt = 0;
293
+ openedAt = Date.now();
294
+ stableTimer = setTimeout(()=>{
295
+ reconnectAttempt = 0;
296
+ }, STABLE_CONNECTION_MS);
179
297
  resubscribeAll();
180
- flushQueue();
298
+ sendUnsentPublishes();
299
+ // Everything that asked for this connection may have finished while
300
+ // it was opening.
301
+ scheduleIdleClose();
181
302
  resolve();
182
303
  };
183
304
  ws.onmessage = (event)=>{
@@ -189,13 +310,15 @@ function createWebSocketClient(lowdefy) {
189
310
  }
190
311
  handleFrame(frame);
191
312
  };
192
- ws.onclose = ()=>{
313
+ ws.onclose = (event)=>{
193
314
  if (socket !== ws) {
194
315
  // Never opened — treat as a failed connect and retry.
195
316
  openPromise = null;
196
- reject(new Error('WebSocket connection failed.'));
317
+ reject(new ServiceError('Connection failed.', {
318
+ service: 'WebSocket'
319
+ }));
197
320
  }
198
- handleClose();
321
+ handleClose(event);
199
322
  };
200
323
  ws.onerror = ()=>{
201
324
  // onclose always follows onerror — reconnect is handled there.
@@ -207,34 +330,41 @@ function createWebSocketClient(lowdefy) {
207
330
  }
208
331
  function subscribe({ handlers, payload, websocketId }) {
209
332
  clearIdleTimer();
210
- subscriptions.set(websocketId, {
211
- handlers,
212
- payload
213
- });
333
+ const previous = subscriptions.get(websocketId);
334
+ if (previous) {
335
+ releaseSubscription(previous);
336
+ }
214
337
  return new Promise((resolve, reject)=>{
215
- const timer = setTimeout(()=>{
216
- pendingSubscribes.delete(websocketId);
217
- subscriptions.delete(websocketId);
218
- reject(new Error(`Subscribe to "${websocketId}" timed out.`));
219
- }, ACK_TIMEOUT_MS);
220
- pendingSubscribes.set(websocketId, {
221
- resolve,
222
- reject,
223
- timer
224
- });
225
- send({
226
- type: 'subscribe',
338
+ const subscription = {
227
339
  websocketId,
228
- payload
229
- });
340
+ payload,
341
+ handlers,
342
+ ackTimer: null,
343
+ pending: {
344
+ resolve,
345
+ reject
346
+ }
347
+ };
348
+ subscriptions.set(websocketId, subscription);
349
+ if (isSocketOpen()) {
350
+ sendSubscribe(subscription);
351
+ return;
352
+ }
353
+ // The frame is sent by resubscribeAll when the connection opens, which
354
+ // restarts the ack timer. Until then the timer bounds the wait for a
355
+ // connection.
356
+ startAckTimer(subscription);
357
+ connect();
230
358
  });
231
359
  }
232
360
  function unsubscribe({ websocketId }) {
233
- if (!subscriptions.has(websocketId)) {
361
+ const subscription = subscriptions.get(websocketId);
362
+ if (!subscription) {
234
363
  return;
235
364
  }
365
+ releaseSubscription(subscription);
236
366
  subscriptions.delete(websocketId);
237
- if (socket && socket.readyState === window.WebSocket.OPEN) {
367
+ if (isSocketOpen()) {
238
368
  socket.send(JSON.stringify({
239
369
  type: 'unsubscribe',
240
370
  websocketId
@@ -243,24 +373,33 @@ function createWebSocketClient(lowdefy) {
243
373
  scheduleIdleClose();
244
374
  }
245
375
  function publish({ payload, websocketId }) {
376
+ clearIdleTimer();
246
377
  publishCounter += 1;
247
378
  const requestId = `p${publishCounter}`;
248
379
  return new Promise((resolve, reject)=>{
249
380
  const timer = setTimeout(()=>{
250
- pendingPublishes.delete(requestId);
251
- reject(new Error(`Publish to "${websocketId}" timed out.`));
381
+ settlePublish(requestId).reject(new ServiceError(`Publish to "${websocketId}" timed out.`, {
382
+ service: 'WebSocket'
383
+ }));
252
384
  }, ACK_TIMEOUT_MS);
253
- pendingPublishes.set(requestId, {
385
+ const pending = {
386
+ frame: {
387
+ type: 'publish',
388
+ websocketId,
389
+ requestId,
390
+ payload
391
+ },
392
+ sent: false,
254
393
  resolve,
255
394
  reject,
256
395
  timer
257
- });
258
- send({
259
- type: 'publish',
260
- websocketId,
261
- requestId,
262
- payload
263
- });
396
+ };
397
+ pendingPublishes.set(requestId, pending);
398
+ if (isSocketOpen()) {
399
+ sendPublish(pending);
400
+ return;
401
+ }
402
+ connect();
264
403
  });
265
404
  }
266
405
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/client",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy Client",
6
6
  "homepage": "https://lowdefy.com",
@@ -33,22 +33,23 @@
33
33
  "dist/*"
34
34
  ],
35
35
  "dependencies": {
36
- "@ant-design/icons": "6.1.0",
37
- "antd": "6.3.1",
38
- "@lowdefy/block-utils": "6.0.0",
39
- "@lowdefy/engine": "6.0.0",
40
- "@lowdefy/errors": "6.0.0",
41
- "@lowdefy/helpers": "6.0.0",
42
- "@lowdefy/layout": "6.0.0",
43
- "@lowdefy/logger": "6.0.0",
36
+ "@ant-design/icons": "6.3.4",
37
+ "@lowdefy/block-utils": "7.0.0",
38
+ "@lowdefy/engine": "7.0.0",
39
+ "@lowdefy/errors": "7.0.0",
40
+ "@lowdefy/helpers": "7.0.0",
41
+ "@lowdefy/layout": "7.0.0",
42
+ "@lowdefy/logger": "7.0.0",
43
+ "antd": "6.6.5",
44
44
  "dayjs": "1.11.20",
45
+ "lucide-react": "1.48.0",
45
46
  "react": "18.2.0",
46
47
  "react-dom": "18.2.0",
47
48
  "tinykeys": "^3.0.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@jest/globals": "28.1.3",
51
- "@lowdefy/jest-yaml-transform": "6.0.0",
52
+ "@lowdefy/jest-yaml-transform": "7.0.0",
52
53
  "@swc/cli": "0.8.1",
53
54
  "@swc/core": "1.15.32",
54
55
  "@swc/jest": "0.2.39",