@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 +24 -2
- package/dist/Context.js +11 -0
- package/dist/adapters/Head.js +32 -0
- package/dist/adapters/Link.js +63 -0
- package/dist/adapters/createRouter.js +115 -0
- package/dist/adapters/url.js +35 -0
- package/dist/block/Block.js +1 -1
- package/dist/createLinkComponent.js +21 -2
- package/dist/initLowdefyContext.js +3 -3
- package/dist/setupLink.js +26 -9
- package/dist/useDarkMode.js +1 -1
- package/dist/websocket/createWebSocketClient.js +273 -0
- package/package.json +12 -12
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
|
-
|
|
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:
|
|
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 };
|
package/dist/block/Block.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}${
|
|
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
|
-
|
|
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(
|
|
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(
|
|
43
|
+
return window.location.assign(target);
|
|
35
44
|
};
|
|
36
|
-
|
|
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}${
|
|
39
|
-
|
|
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.`);
|
package/dist/useDarkMode.js
CHANGED
|
@@ -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
|
|
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": "
|
|
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": "
|
|
39
|
-
"@lowdefy/engine": "
|
|
40
|
-
"@lowdefy/errors": "
|
|
41
|
-
"@lowdefy/helpers": "
|
|
42
|
-
"@lowdefy/layout": "
|
|
43
|
-
"@lowdefy/logger": "
|
|
44
|
-
"dayjs": "1.11.
|
|
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": "
|
|
52
|
-
"@swc/cli": "0.8.
|
|
53
|
-
"@swc/core": "1.15.
|
|
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.
|
|
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",
|