@lowdefy/client 5.6.0 → 6.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.
package/dist/Client.js CHANGED
@@ -24,7 +24,29 @@ const Client = ({ auth, Components, config: rawConfig, jsMap, lowdefy, resetCont
24
24
  reset: false,
25
25
  setReset: ()=>undefined
26
26
  }, router, stage, types, window })=>{
27
- const config = serializer.deserialize(rawConfig);
27
+ // Memoize on the fetched config objects, not the per-render wrapper — the
28
+ // engine memoizes dynamic page contexts by config object identity, so the
29
+ // deserialized config must be stable across re-renders and only change when
30
+ // a new page fetch delivers new config.
31
+ const config = React.useMemo(()=>serializer.deserialize(rawConfig), [
32
+ rawConfig.pageConfig,
33
+ rawConfig.rootConfig
34
+ ]);
35
+ // Dynamic pages rebuild their engine context per fetch — remount the page
36
+ // tree with the rebuilt context instead of reconciling mounted blocks
37
+ // against it, which React can silently drop. Static pages keep their
38
+ // context and tree across navigations, so their key stays stable.
39
+ const buildRef = React.useRef({
40
+ config: null,
41
+ build: 0
42
+ });
43
+ if (buildRef.current.config !== config) {
44
+ buildRef.current = {
45
+ config,
46
+ build: buildRef.current.build + 1
47
+ };
48
+ }
49
+ const contextKey = config.pageConfig.dynamic === true ? `${config.pageConfig.id}:${buildRef.current.build}` : config.pageConfig.id;
28
50
  initLowdefyContext({
29
51
  auth,
30
52
  Components,
@@ -51,7 +73,7 @@ const Client = ({ auth, Components, config: rawConfig, jsMap, lowdefy, resetCont
51
73
  }
52
74
  }
53
75
  }), /*#__PURE__*/ React.createElement(Context, {
54
- key: config.pageConfig.id,
76
+ key: contextKey,
55
77
  config: config.pageConfig,
56
78
  jsMap: jsMap,
57
79
  lowdefy: lowdefy,
package/dist/Context.js CHANGED
@@ -26,6 +26,15 @@ const ShortcutEffect = ({ context })=>{
26
26
  ]);
27
27
  return null;
28
28
  };
29
+ const WebSocketsEffect = ({ context })=>{
30
+ useEffect(()=>{
31
+ context._internal.WebSockets.subscribeAll();
32
+ return ()=>context._internal.WebSockets.unsubscribeAll();
33
+ }, [
34
+ context
35
+ ]);
36
+ return null;
37
+ };
29
38
  const Context = ({ children, config, jsMap, lowdefy, resetContext })=>{
30
39
  const context = getContext({
31
40
  config,
@@ -53,6 +62,8 @@ const Context = ({ children, config, jsMap, lowdefy, resetContext })=>{
53
62
  if (loadingOnInit) return '';
54
63
  return /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(ShortcutEffect, {
55
64
  context: context
65
+ }), /*#__PURE__*/ React.createElement(WebSocketsEffect, {
66
+ context: context
56
67
  }), children(context));
57
68
  });
58
69
  };
@@ -0,0 +1,32 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import React, { useEffect } from 'react';
16
+ // Replaces next/head for the single use in @lowdefy/client's Head.js:
17
+ // <Component><title>{properties.title}</title></Component>.
18
+ // Extracts the title text and sets document.title; renders nothing.
19
+ function Head({ children }) {
20
+ useEffect(()=>{
21
+ React.Children.forEach(children, (child)=>{
22
+ if (child?.type === 'title') {
23
+ const text = React.Children.toArray(child.props.children).join('');
24
+ if (text) {
25
+ document.title = text;
26
+ }
27
+ }
28
+ });
29
+ });
30
+ return null;
31
+ }
32
+ export default Head;
@@ -0,0 +1,63 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import React from 'react';
16
+ import { type } from '@lowdefy/helpers';
17
+ import { createUrl } from './url.js';
18
+ // Replaces next/link for the contract used by @lowdefy/client's
19
+ // createLinkComponent: href as { pathname, query } or string, replace,
20
+ // scroll, onClick fired before navigation. Modified clicks (new tab,
21
+ // middle click, download) fall through to native browser handling.
22
+ function createLinkComponent({ router }) {
23
+ function Link({ children, href, onClick, replace, scroll, ...props }) {
24
+ const hrefObject = type.isString(href) ? {
25
+ pathname: href
26
+ } : href ?? {};
27
+ const { pathname, query } = hrefObject;
28
+ const url = createUrl({
29
+ basePath: router.basePath,
30
+ pathname,
31
+ query
32
+ });
33
+ function handleClick(event) {
34
+ if (onClick) {
35
+ onClick(event);
36
+ }
37
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || props.target === '_blank') {
38
+ return;
39
+ }
40
+ event.preventDefault();
41
+ if (replace) {
42
+ router.replace({
43
+ pathname,
44
+ query,
45
+ scroll
46
+ });
47
+ } else {
48
+ router.push({
49
+ pathname,
50
+ query,
51
+ scroll
52
+ });
53
+ }
54
+ }
55
+ return /*#__PURE__*/ React.createElement("a", {
56
+ ...props,
57
+ href: url,
58
+ onClick: handleClick
59
+ }, children);
60
+ }
61
+ return Link;
62
+ }
63
+ export default createLinkComponent;
@@ -0,0 +1,115 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { createUrl, parsePageId } from './url.js';
16
+ // History-API router backing the @lowdefy/client router contract:
17
+ // push({ pathname, query }), back(), basePath — plus subscribe() for the
18
+ // page component and sessionStorage-backed scroll restoration.
19
+ function createRouter({ basePath = '', window }) {
20
+ const listeners = new Set();
21
+ let entryKey = window.history.state?.lowdefyKey ?? `k${Date.now().toString(36)}`;
22
+ if ('scrollRestoration' in window.history) {
23
+ window.history.scrollRestoration = 'manual';
24
+ }
25
+ if (!window.history.state?.lowdefyKey) {
26
+ window.history.replaceState({
27
+ lowdefyKey: entryKey
28
+ }, '', window.location.href);
29
+ }
30
+ function readScrollPositions() {
31
+ try {
32
+ return JSON.parse(window.sessionStorage.getItem('lowdefy_scroll') ?? '{}');
33
+ } catch (e) {
34
+ return {};
35
+ }
36
+ }
37
+ function saveScrollPosition() {
38
+ try {
39
+ const positions = readScrollPositions();
40
+ positions[entryKey] = {
41
+ x: window.scrollX,
42
+ y: window.scrollY
43
+ };
44
+ window.sessionStorage.setItem('lowdefy_scroll', JSON.stringify(positions));
45
+ } catch (e) {
46
+ // sessionStorage unavailable (private mode) — scroll restoration degrades gracefully.
47
+ }
48
+ }
49
+ function restoreScrollPosition() {
50
+ const position = readScrollPositions()[entryKey];
51
+ window.requestAnimationFrame(()=>{
52
+ window.scrollTo(position?.x ?? 0, position?.y ?? 0);
53
+ });
54
+ }
55
+ function getLocation() {
56
+ return {
57
+ pageId: parsePageId(window.location.href, basePath),
58
+ pathname: window.location.pathname,
59
+ search: window.location.search
60
+ };
61
+ }
62
+ function notify() {
63
+ const location = getLocation();
64
+ listeners.forEach((listener)=>listener(location));
65
+ }
66
+ function navigate({ forceReload, pathname, query, replace = false, scroll = true }) {
67
+ const url = createUrl({
68
+ basePath,
69
+ pathname,
70
+ query
71
+ });
72
+ if (forceReload) {
73
+ window.location.assign(url);
74
+ return;
75
+ }
76
+ saveScrollPosition();
77
+ entryKey = `k${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
78
+ if (replace) {
79
+ window.history.replaceState({
80
+ lowdefyKey: entryKey
81
+ }, '', url);
82
+ } else {
83
+ window.history.pushState({
84
+ lowdefyKey: entryKey
85
+ }, '', url);
86
+ }
87
+ notify();
88
+ if (scroll) {
89
+ window.requestAnimationFrame(()=>{
90
+ window.scrollTo(0, 0);
91
+ });
92
+ }
93
+ }
94
+ window.addEventListener('popstate', (event)=>{
95
+ saveScrollPosition();
96
+ entryKey = event.state?.lowdefyKey ?? `k${Date.now().toString(36)}`;
97
+ notify();
98
+ restoreScrollPosition();
99
+ });
100
+ return {
101
+ basePath,
102
+ back: ()=>window.history.back(),
103
+ getLocation,
104
+ push: async (args)=>navigate(args),
105
+ replace: async (args)=>navigate({
106
+ ...args,
107
+ replace: true
108
+ }),
109
+ subscribe: (listener)=>{
110
+ listeners.add(listener);
111
+ return ()=>listeners.delete(listener);
112
+ }
113
+ };
114
+ }
115
+ export default createRouter;
@@ -0,0 +1,35 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ function serializeQuery(query) {
17
+ if (type.isNone(query) || query === '') {
18
+ return '';
19
+ }
20
+ if (type.isString(query)) {
21
+ return query;
22
+ }
23
+ return new URLSearchParams(query).toString();
24
+ }
25
+ function createUrl({ basePath = '', pathname, query }) {
26
+ const serializedQuery = serializeQuery(query);
27
+ return `${basePath}${pathname}${serializedQuery ? `?${serializedQuery}` : ''}`;
28
+ }
29
+ function parsePageId(url, basePath = '') {
30
+ const pathname = new URL(url, 'http://localhost').pathname;
31
+ const stripped = basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
32
+ const pageId = stripped.replace(/^\//, '').replace(/\/$/, '');
33
+ return pageId === '' ? null : pageId;
34
+ }
35
+ export { createUrl, parsePageId, serializeQuery };
@@ -19,7 +19,7 @@ import MountEvents from '../MountEvents.js';
19
19
  const Block = ({ block, Blocks, context, lowdefy, parentLoading })=>{
20
20
  const [updates, setUpdate] = useState(0);
21
21
  const loggedErrorsRef = useRef(new Set());
22
- lowdefy._internal.updaters[block.id] = ()=>setUpdate(updates + 1);
22
+ context._internal.updaters[block.id] = ()=>setUpdate(updates + 1);
23
23
  const handleError = (error)=>{
24
24
  if (lowdefy._internal.handleError) {
25
25
  lowdefy._internal.handleError(error);
@@ -1,6 +1,21 @@
1
- import React from 'react';
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import React from 'react';
2
16
  import { createLink } from '@lowdefy/engine';
3
17
  import { type } from '@lowdefy/helpers';
18
+ import { createUrl } from './adapters/url.js';
4
19
  const createLinkComponent = (lowdefy, Link)=>{
5
20
  const { window } = lowdefy._internal.globals;
6
21
  const backLink = ({ ariaLabel, children, className, id, onClick = ()=>{}, rel, style })=>/*#__PURE__*/ React.createElement("a", {
@@ -37,7 +52,11 @@ const createLinkComponent = (lowdefy, Link)=>{
37
52
  "aria-label": ariaLabel,
38
53
  className: className,
39
54
  style: style,
40
- href: `${window.location.origin}${lowdefy.basePath}${pathname}${query ? `?${query}` : ''}`,
55
+ href: `${window.location.origin}${createUrl({
56
+ basePath: lowdefy.basePath,
57
+ pathname,
58
+ query
59
+ })}`,
41
60
  rel: rel ?? 'noopener noreferrer',
42
61
  target: "_blank",
43
62
  onClick: async (...params)=>{
@@ -16,6 +16,7 @@
16
16
  import createCallAPI from './createCallAPI.js';
17
17
  import createAuthMethods from './auth/createAuthMethods.js';
18
18
  import createCallRequest from './createCallRequest.js';
19
+ import createWebSocketClient from './websocket/createWebSocketClient.js';
19
20
  import createIcon from './createIcon.js';
20
21
  import createShortcutBadge from './createShortcutBadge.js';
21
22
  import createLinkComponent from './createLinkComponent.js';
@@ -50,8 +51,7 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
50
51
  },
51
52
  dispatch: ()=>undefined
52
53
  },
53
- router,
54
- updaters: {}
54
+ router
55
55
  };
56
56
  lowdefy.apiResponses = {};
57
57
  lowdefy.basePath = router.basePath;
@@ -63,6 +63,7 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
63
63
  lowdefy._internal.callAPI = createCallAPI(lowdefy);
64
64
  lowdefy._internal.auth = createAuthMethods(lowdefy, auth);
65
65
  lowdefy._internal.callRequest = createCallRequest(lowdefy);
66
+ lowdefy._internal.websocketClient = createWebSocketClient(lowdefy);
66
67
  lowdefy._internal.components.Link = createLinkComponent(lowdefy, Components.Link);
67
68
  lowdefy._internal.link = setupLink(lowdefy);
68
69
  lowdefy._internal.translate = (key, values)=>translate({
@@ -70,7 +71,6 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
70
71
  values,
71
72
  i18n: lowdefy.i18n
72
73
  });
73
- lowdefy._internal.updateBlock = (blockId)=>lowdefy._internal.updaters[blockId] && lowdefy._internal.updaters[blockId]();
74
74
  lowdefy._internal.logger = createBrowserLogger();
75
75
  lowdefy._internal.handleError = createHandleError(lowdefy);
76
76
  lowdefy._internal.components.handleError = lowdefy._internal.handleError;
package/dist/setupLink.js CHANGED
@@ -13,14 +13,23 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { createLink } from '@lowdefy/engine';
16
+ import { createUrl } from './adapters/url.js';
16
17
  function setupLink(lowdefy) {
17
18
  const { router } = lowdefy._internal;
18
19
  const { window } = lowdefy._internal.globals;
19
20
  const backLink = ()=>router.back();
20
21
  const disabledLink = ()=>{};
21
- const newOriginLink = ({ url, query, newTab })=>{
22
+ // `href` wins over `url`, and is used verbatim — the same precedence the anchor
23
+ // renderer applies (createLinkComponent.js). createLink only ever sets one of
24
+ // them, and it gives `url` a protocol before we see it while leaving `href`
25
+ // untouched, which is what makes `href` the way to link somewhere `url` cannot
26
+ // reach: a root-relative path, a fragment, a scheme-less same-origin target.
27
+ // urlQuery is not appended to an `href` for that reason — it is passed through
28
+ // as written, query string included.
29
+ const newOriginLink = ({ href, url, query, newTab })=>{
30
+ const target = href ?? `${url}${query ? `?${query}` : ''}`;
22
31
  if (newTab) {
23
- const handle = window.open(`${url}${query ? `?${query}` : ''}`, '_blank');
32
+ const handle = window.open(target, '_blank');
24
33
  if (!handle) {
25
34
  lowdefy._internal.displayMessage({
26
35
  content: lowdefy._internal.translate('client.popupBlocked'),
@@ -31,18 +40,26 @@ function setupLink(lowdefy) {
31
40
  }
32
41
  return handle.focus();
33
42
  }
34
- return window.location.assign(`${url}${query ? `?${query}` : ''}`);
43
+ return window.location.assign(target);
35
44
  };
36
- const sameOriginLink = ({ newTab, pathname, query, setInput })=>{
45
+ // `replace` swaps the current history entry instead of pushing one and
46
+ // `scroll` (default true) controls the router's scroll-to-top - together they
47
+ // let a same-page Link reflect state into the url without moving the page.
48
+ const sameOriginLink = ({ newTab, pathname, query, replace, scroll, setInput })=>{
37
49
  if (newTab) {
38
- return window.open(`${window.location.origin}${lowdefy.basePath}${pathname}${query ? `?${query}` : ''}`, '_blank').focus();
39
- } else {
40
- setInput();
41
- return router.push({
50
+ return window.open(`${window.location.origin}${createUrl({
51
+ basePath: lowdefy.basePath,
42
52
  pathname,
43
53
  query
44
- });
54
+ })}`, '_blank').focus();
45
55
  }
56
+ setInput();
57
+ const navigate = replace ? router.replace : router.push;
58
+ return navigate({
59
+ pathname,
60
+ query,
61
+ scroll
62
+ });
46
63
  };
47
64
  const noLink = ()=>{
48
65
  throw new Error(`Invalid Link.`);
@@ -121,7 +121,7 @@ function useDarkMode({ antd, configDarkMode }) {
121
121
  darkComponents
122
122
  ]);
123
123
  // Keep the <html> background and color-scheme in sync with the resolved mode.
124
- // The _document.js pre-hydration inline script sets initial values before
124
+ // The server templates' pre-hydration inline script sets initial values before
125
125
  // hydration; this effect takes over once React is active and updates on every
126
126
  // dark/light toggle.
127
127
  const darkBg = darkToken?.colorBgLayout;
@@ -0,0 +1,273 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ const ACK_TIMEOUT_MS = 10 * 1000;
16
+ const IDLE_CLOSE_GRACE_MS = 5 * 1000;
17
+ const RECONNECT_BASE_MS = 500;
18
+ const RECONNECT_CAP_MS = 15 * 1000;
19
+ // One websocket per browser tab, shared by all channels, created lazily on
20
+ // the first subscribe or publish. Connections are ephemeral in production
21
+ // (serverless platforms close them at function max duration), so reconnect
22
+ // with backoff and resubscribe is the steady state, not an edge case.
23
+ function createWebSocketClient(lowdefy) {
24
+ const { window } = lowdefy._internal.globals;
25
+ // websocketId → { payload, handlers }
26
+ 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
30
+ const pendingPublishes = new Map();
31
+ // Frames queued while the socket is (re)connecting.
32
+ const sendQueue = [];
33
+ let socket = null;
34
+ let openPromise = null;
35
+ let reconnectAttempt = 0;
36
+ let reconnectTimer = null;
37
+ let idleTimer = null;
38
+ let closedByClient = false;
39
+ let publishCounter = 0;
40
+ function url() {
41
+ const { location } = window;
42
+ const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
43
+ return `${scheme}://${location.host}${lowdefy.basePath ?? ''}/api/websocket`;
44
+ }
45
+ function clearIdleTimer() {
46
+ if (idleTimer) {
47
+ clearTimeout(idleTimer);
48
+ idleTimer = null;
49
+ }
50
+ }
51
+ function scheduleIdleClose() {
52
+ clearIdleTimer();
53
+ if (subscriptions.size > 0) {
54
+ return;
55
+ }
56
+ // Grace period so rapid page navigation doesn't thrash connections.
57
+ idleTimer = setTimeout(()=>{
58
+ if (subscriptions.size === 0 && socket) {
59
+ closedByClient = true;
60
+ socket.close();
61
+ socket = null;
62
+ openPromise = null;
63
+ }
64
+ }, IDLE_CLOSE_GRACE_MS);
65
+ }
66
+ function send(frame) {
67
+ const message = JSON.stringify(frame);
68
+ if (socket && socket.readyState === window.WebSocket.OPEN) {
69
+ socket.send(message);
70
+ return;
71
+ }
72
+ sendQueue.push(message);
73
+ connect();
74
+ }
75
+ function flushQueue() {
76
+ while(sendQueue.length > 0 && socket && socket.readyState === window.WebSocket.OPEN){
77
+ socket.send(sendQueue.shift());
78
+ }
79
+ }
80
+ function resubscribeAll() {
81
+ subscriptions.forEach(({ payload }, websocketId)=>{
82
+ socket.send(JSON.stringify({
83
+ type: 'subscribe',
84
+ websocketId,
85
+ payload
86
+ }));
87
+ });
88
+ }
89
+ function handleFrame(frame) {
90
+ const { message, payload, requestId, websocketId } = frame;
91
+ const subscription = subscriptions.get(websocketId);
92
+ switch(frame.type){
93
+ case 'message':
94
+ subscription?.handlers.onMessage(payload);
95
+ return;
96
+ 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();
105
+ return;
106
+ }
107
+ case 'unsubscribed':
108
+ return;
109
+ 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;
118
+ }
119
+ case 'error':
120
+ {
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);
127
+ return;
128
+ }
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);
135
+ return;
136
+ }
137
+ if (subscription) {
138
+ subscription.handlers.onError(error.message);
139
+ return;
140
+ }
141
+ lowdefy._internal.logger.warn(error);
142
+ return;
143
+ }
144
+ default:
145
+ }
146
+ }
147
+ function handleClose() {
148
+ socket = null;
149
+ openPromise = null;
150
+ subscriptions.forEach(({ handlers })=>{
151
+ handlers.onDisconnected();
152
+ });
153
+ if (closedByClient) {
154
+ closedByClient = false;
155
+ return;
156
+ }
157
+ if (subscriptions.size === 0 && sendQueue.length === 0) {
158
+ return;
159
+ }
160
+ // Capped exponential backoff with full jitter.
161
+ const base = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_CAP_MS);
162
+ const delay = Math.random() * base;
163
+ reconnectAttempt += 1;
164
+ reconnectTimer = setTimeout(()=>{
165
+ reconnectTimer = null;
166
+ connect();
167
+ }, delay);
168
+ }
169
+ function connect() {
170
+ if (openPromise) {
171
+ return openPromise;
172
+ }
173
+ clearIdleTimer();
174
+ openPromise = new Promise((resolve, reject)=>{
175
+ const ws = new window.WebSocket(url());
176
+ ws.onopen = ()=>{
177
+ socket = ws;
178
+ reconnectAttempt = 0;
179
+ resubscribeAll();
180
+ flushQueue();
181
+ resolve();
182
+ };
183
+ ws.onmessage = (event)=>{
184
+ let frame;
185
+ try {
186
+ frame = JSON.parse(event.data);
187
+ } catch (e) {
188
+ return;
189
+ }
190
+ handleFrame(frame);
191
+ };
192
+ ws.onclose = ()=>{
193
+ if (socket !== ws) {
194
+ // Never opened — treat as a failed connect and retry.
195
+ openPromise = null;
196
+ reject(new Error('WebSocket connection failed.'));
197
+ }
198
+ handleClose();
199
+ };
200
+ ws.onerror = ()=>{
201
+ // onclose always follows onerror — reconnect is handled there.
202
+ };
203
+ });
204
+ // Connection errors surface through subscribe/publish ack timeouts.
205
+ openPromise.catch(()=>{});
206
+ return openPromise;
207
+ }
208
+ function subscribe({ handlers, payload, websocketId }) {
209
+ clearIdleTimer();
210
+ subscriptions.set(websocketId, {
211
+ handlers,
212
+ payload
213
+ });
214
+ 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',
227
+ websocketId,
228
+ payload
229
+ });
230
+ });
231
+ }
232
+ function unsubscribe({ websocketId }) {
233
+ if (!subscriptions.has(websocketId)) {
234
+ return;
235
+ }
236
+ subscriptions.delete(websocketId);
237
+ if (socket && socket.readyState === window.WebSocket.OPEN) {
238
+ socket.send(JSON.stringify({
239
+ type: 'unsubscribe',
240
+ websocketId
241
+ }));
242
+ }
243
+ scheduleIdleClose();
244
+ }
245
+ function publish({ payload, websocketId }) {
246
+ publishCounter += 1;
247
+ const requestId = `p${publishCounter}`;
248
+ return new Promise((resolve, reject)=>{
249
+ const timer = setTimeout(()=>{
250
+ pendingPublishes.delete(requestId);
251
+ reject(new Error(`Publish to "${websocketId}" timed out.`));
252
+ }, ACK_TIMEOUT_MS);
253
+ pendingPublishes.set(requestId, {
254
+ resolve,
255
+ reject,
256
+ timer
257
+ });
258
+ send({
259
+ type: 'publish',
260
+ websocketId,
261
+ requestId,
262
+ payload
263
+ });
264
+ });
265
+ }
266
+ return {
267
+ connect,
268
+ publish,
269
+ subscribe,
270
+ unsubscribe
271
+ };
272
+ }
273
+ export default createWebSocketClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/client",
3
- "version": "5.6.0",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy Client",
6
6
  "homepage": "https://lowdefy.com",
@@ -35,26 +35,26 @@
35
35
  "dependencies": {
36
36
  "@ant-design/icons": "6.1.0",
37
37
  "antd": "6.3.1",
38
- "@lowdefy/block-utils": "5.6.0",
39
- "@lowdefy/engine": "5.6.0",
40
- "@lowdefy/errors": "5.6.0",
41
- "@lowdefy/helpers": "5.6.0",
42
- "@lowdefy/layout": "5.6.0",
43
- "@lowdefy/logger": "5.6.0",
44
- "dayjs": "1.11.19",
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",
44
+ "dayjs": "1.11.20",
45
45
  "react": "18.2.0",
46
46
  "react-dom": "18.2.0",
47
47
  "tinykeys": "^3.0.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@jest/globals": "28.1.3",
51
- "@lowdefy/jest-yaml-transform": "5.6.0",
52
- "@swc/cli": "0.8.0",
53
- "@swc/core": "1.15.18",
51
+ "@lowdefy/jest-yaml-transform": "6.0.0",
52
+ "@swc/cli": "0.8.1",
53
+ "@swc/core": "1.15.32",
54
54
  "@swc/jest": "0.2.39",
55
55
  "@testing-library/dom": "8.19.1",
56
56
  "@testing-library/react": "13.4.0",
57
- "@testing-library/user-event": "14.4.3",
57
+ "@testing-library/user-event": "14.6.1",
58
58
  "copyfiles": "2.4.1",
59
59
  "jest": "28.1.3",
60
60
  "jest-environment-jsdom": "28.1.3",