@lowdefy/server 6.1.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.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
package/client/App.jsx
CHANGED
|
@@ -17,19 +17,21 @@
|
|
|
17
17
|
import React, { useCallback, useRef } from 'react';
|
|
18
18
|
|
|
19
19
|
import { ErrorBoundary } from '@lowdefy/block-utils';
|
|
20
|
-
import { useDarkMode, useLocale } from '@lowdefy/client';
|
|
20
|
+
import { IconProvider, useDarkMode, useLocale } from '@lowdefy/client';
|
|
21
21
|
import { StyleProvider } from '@ant-design/cssinjs';
|
|
22
22
|
import { App as AntdApp, theme as antdTheme } from 'antd';
|
|
23
23
|
import { XProvider } from '@ant-design/x';
|
|
24
24
|
|
|
25
|
-
import { serializer } from '@lowdefy/helpers';
|
|
25
|
+
import { appStorageKey, serializer } from '@lowdefy/helpers';
|
|
26
26
|
|
|
27
27
|
import antdLocaleLoaders from '../build/i18n/antdLocales.js';
|
|
28
|
+
import appMeta from '../build/appMeta.json';
|
|
28
29
|
import antdXLocaleLoaders from '../build/i18n/antdXLocales.js';
|
|
29
30
|
import dayjsLocaleMap from '../build/i18n/dayjsLocales.js';
|
|
30
31
|
import rawLoggerConfig from '../build/logger.json';
|
|
31
32
|
import Auth from '../lib/client/auth/Auth.jsx';
|
|
32
33
|
import createLogUsage from '../lib/client/createLogUsage.js';
|
|
34
|
+
import getCspNonce from '../lib/client/getCspNonce.js';
|
|
33
35
|
import initSentryClient from '../lib/client/sentry/initSentryClient.js';
|
|
34
36
|
import setSentryUser from '../lib/client/sentry/setSentryUser.js';
|
|
35
37
|
import Page from './Page.jsx';
|
|
@@ -37,6 +39,10 @@ import Page from './Page.jsx';
|
|
|
37
39
|
// Deserialize to restore arrays (e.g. sentry.userFields) from ~arr markers.
|
|
38
40
|
const loggerConfig = serializer.deserialize(rawLoggerConfig);
|
|
39
41
|
|
|
42
|
+
// antd and antd X put this nonce on the style tags they add, so the page's
|
|
43
|
+
// Content-Security-Policy applies them. Empty (no nonce) when the policy is off.
|
|
44
|
+
const csp = { nonce: getCspNonce() };
|
|
45
|
+
|
|
40
46
|
function ThemeTokenResolver({ lowdefyRef, children }) {
|
|
41
47
|
const { token } = antdTheme.useToken();
|
|
42
48
|
if (!lowdefyRef.current.theme) {
|
|
@@ -47,7 +53,7 @@ function ThemeTokenResolver({ lowdefyRef, children }) {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
function App({ config }) {
|
|
50
|
-
const { rootConfig,
|
|
56
|
+
const { rootConfig, user } = config;
|
|
51
57
|
|
|
52
58
|
// Sentry DSN arrives at runtime via the embedded config (the server reads
|
|
53
59
|
// SENTRY_DSN per process) — no build-time env inlining.
|
|
@@ -57,14 +63,22 @@ function App({ config }) {
|
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
const usageDataRef = useRef({});
|
|
60
|
-
const lowdefyRef = useRef({
|
|
66
|
+
const lowdefyRef = useRef({
|
|
67
|
+
// Sent with every request and endpoint call so the server refuses calls
|
|
68
|
+
// from a tab still running an earlier build (@lowdefy/client request.js).
|
|
69
|
+
buildId: appMeta.buildId,
|
|
70
|
+
eventCallback: createLogUsage({ basePath: config.basePath ?? '', usageDataRef }),
|
|
71
|
+
});
|
|
61
72
|
if (rootConfig?.theme) {
|
|
62
73
|
lowdefyRef.current.theme = rootConfig.theme;
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
// Device preferences, kept per app (the same key the HTML template's dark mode script reads).
|
|
77
|
+
const storageScope = { basePath: config.basePath ?? '', slug: rootConfig.lowdefyApp?.slug };
|
|
65
78
|
const { algorithm, token, components } = useDarkMode({
|
|
66
79
|
antd: lowdefyRef.current.theme?.antd,
|
|
67
80
|
configDarkMode: lowdefyRef.current.theme?.darkMode,
|
|
81
|
+
storageKey: appStorageKey({ ...storageScope, name: 'lowdefy_darkMode' }),
|
|
68
82
|
});
|
|
69
83
|
|
|
70
84
|
const {
|
|
@@ -76,6 +90,7 @@ function App({ config }) {
|
|
|
76
90
|
antdLocaleLoaders,
|
|
77
91
|
antdXLocaleLoaders,
|
|
78
92
|
dayjsLocaleMap,
|
|
93
|
+
storageKey: appStorageKey({ ...storageScope, name: 'lowdefy_locale' }),
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
if (rootConfig?.i18n?.defaultLocale) {
|
|
@@ -108,6 +123,7 @@ function App({ config }) {
|
|
|
108
123
|
return (
|
|
109
124
|
<StyleProvider layer>
|
|
110
125
|
<XProvider
|
|
126
|
+
csp={csp}
|
|
111
127
|
locale={mergedLocale}
|
|
112
128
|
form={
|
|
113
129
|
antdLocale?.Form?.defaultValidateMessages
|
|
@@ -123,23 +139,27 @@ function App({ config }) {
|
|
|
123
139
|
algorithm,
|
|
124
140
|
}}
|
|
125
141
|
>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
user
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
{/* Message, Notification and ConfirmModal render in the App.useApp()
|
|
143
|
+
holders here, above the Client's icon provider. */}
|
|
144
|
+
<IconProvider icons={rootConfig.theme.icons}>
|
|
145
|
+
<AntdApp>
|
|
146
|
+
<ThemeTokenResolver lowdefyRef={lowdefyRef}>
|
|
147
|
+
<ErrorBoundary fullPage onError={handleError}>
|
|
148
|
+
<Auth user={user}>
|
|
149
|
+
{(auth) => {
|
|
150
|
+
usageDataRef.current.user = auth.user?.id;
|
|
151
|
+
// Set Sentry user context when auth changes
|
|
152
|
+
setSentryUser({
|
|
153
|
+
user: auth.user,
|
|
154
|
+
sentryConfig: loggerConfig.sentry,
|
|
155
|
+
});
|
|
156
|
+
return <Page auth={auth} config={config} lowdefy={lowdefyRef.current} />;
|
|
157
|
+
}}
|
|
158
|
+
</Auth>
|
|
159
|
+
</ErrorBoundary>
|
|
160
|
+
</ThemeTokenResolver>
|
|
161
|
+
</AntdApp>
|
|
162
|
+
</IconProvider>
|
|
143
163
|
</XProvider>
|
|
144
164
|
</StyleProvider>
|
|
145
165
|
);
|
package/client/Page.jsx
CHANGED
|
@@ -14,28 +14,58 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
17
|
+
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
|
18
18
|
|
|
19
19
|
import Client from '@lowdefy/client';
|
|
20
20
|
import createRouter from '@lowdefy/client/adapters/createRouter.js';
|
|
21
21
|
import createLinkComponent from '@lowdefy/client/adapters/Link.js';
|
|
22
22
|
import { createUrl } from '@lowdefy/client/adapters/url.js';
|
|
23
23
|
import Head from '@lowdefy/client/adapters/Head.js';
|
|
24
|
+
import recordCallerStamp from '@lowdefy/client/recordCallerStamp.js';
|
|
25
|
+
import shouldReloadForBuild from '@lowdefy/client/shouldReloadForBuild.js';
|
|
26
|
+
import shouldReloadForCaller from '@lowdefy/client/shouldReloadForCaller.js';
|
|
27
|
+
import { buildPagePath } from '@lowdefy/helpers';
|
|
24
28
|
|
|
25
|
-
import actions from '../build/plugins/actions.js';
|
|
26
29
|
import blockMetas from '../build/plugins/blockMetas.json';
|
|
27
|
-
import blocks from '../build/plugins/blocks.js';
|
|
28
|
-
import icons from '../build/plugins/icons.js';
|
|
29
|
-
import operators from '../build/plugins/operators/client.js';
|
|
30
30
|
import jsMap from '../build/plugins/operators/clientJsMap.js';
|
|
31
31
|
import appMeta from '../build/appMeta.json';
|
|
32
32
|
|
|
33
|
-
import
|
|
33
|
+
import getShownPage from './getShownPage.js';
|
|
34
|
+
import loadAllIcons from './loadAllIcons.js';
|
|
35
|
+
import loadPageTypes from './loadPageTypes.js';
|
|
36
|
+
import types from './types.js';
|
|
37
|
+
|
|
38
|
+
// The path to fetch for a navigation. The app root shows the home page, and
|
|
39
|
+
// /api/page/ with an empty path matches no page, so it fetches the home page's
|
|
40
|
+
// own path.
|
|
41
|
+
function getFetchPath({ path, rootConfig }) {
|
|
42
|
+
if (path !== '') {
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
const { home, pagePaths } = rootConfig;
|
|
46
|
+
return buildPagePath({
|
|
47
|
+
pageId: home.pageId,
|
|
48
|
+
path: pagePaths[home.pageId],
|
|
49
|
+
pathParams: home.pathParams,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
34
52
|
|
|
35
53
|
// Replaces lib/client/Page.js. The first page renders from the config
|
|
36
|
-
// embedded in the HTML; SPA navigations fetch /api/page
|
|
54
|
+
// embedded in the HTML; SPA navigations fetch /api/page/<path> and swap the
|
|
55
|
+
// page. The server matches the path and answers with the page it shows, the
|
|
56
|
+
// values it carries and the path it matched.
|
|
37
57
|
function Page({ auth, config, lowdefy }) {
|
|
38
|
-
const [
|
|
58
|
+
const [page, setPage] = useState({
|
|
59
|
+
matchedPath: config.matchedPath,
|
|
60
|
+
pageConfig: config.pageConfig,
|
|
61
|
+
pathParams: config.pathParams,
|
|
62
|
+
});
|
|
63
|
+
// The navigation listener is subscribed once, so it reads the shown page
|
|
64
|
+
// through a ref rather than the state its closure captured.
|
|
65
|
+
const pageRef = useRef(page);
|
|
66
|
+
// A re-render with the same config is how the page context picks up a new
|
|
67
|
+
// URL (getContext updates a memoized context on every render).
|
|
68
|
+
const [, rerender] = useReducer((count) => count + 1, 0);
|
|
39
69
|
|
|
40
70
|
const routerRef = useRef(null);
|
|
41
71
|
if (!routerRef.current) {
|
|
@@ -53,26 +83,47 @@ function Page({ auth, config, lowdefy }) {
|
|
|
53
83
|
const latestNavRef = useRef(0);
|
|
54
84
|
|
|
55
85
|
useEffect(() => {
|
|
56
|
-
const unsubscribe = router.subscribe(async ({
|
|
86
|
+
const unsubscribe = router.subscribe(async ({ path, search }) => {
|
|
57
87
|
const token = ++latestNavRef.current;
|
|
58
|
-
|
|
88
|
+
// The path the shown instance was matched on shows the same page and
|
|
89
|
+
// values whatever the query, so it needs no fetch. Another spelling of
|
|
90
|
+
// the same values fetches and lands on the same instance. Dynamic pages
|
|
91
|
+
// re-resolve per navigation.
|
|
92
|
+
if (path === pageRef.current.matchedPath && pageRef.current.pageConfig.dynamic !== true) {
|
|
93
|
+
rerender();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const fetchPath = getFetchPath({ path, rootConfig: config.rootConfig });
|
|
59
97
|
try {
|
|
60
98
|
// Forward the current query string so server-side Dynamic block
|
|
61
99
|
// resolution sees the same urlQuery as an initial HTML load.
|
|
62
|
-
const res = await fetch(
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
const res = await fetch(`${router.basePath}/api/page/${fetchPath}${search}`);
|
|
101
|
+
if (res.status === 401 || res.status === 403) {
|
|
102
|
+
// 401: logged-out navigation to a protected page. 403: authorised but
|
|
103
|
+
// second factor not yet enrolled. Both carry a { redirect } and full
|
|
104
|
+
// load away so the destination can return here afterwards.
|
|
105
|
+
const { redirect } = await res.json();
|
|
106
|
+
if (token !== latestNavRef.current) return;
|
|
107
|
+
window.location.assign(
|
|
108
|
+
redirect ?? createUrl({ basePath: router.basePath, pathname: '/404' })
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
65
112
|
if (!res.ok) {
|
|
66
113
|
if (token !== latestNavRef.current) return;
|
|
67
|
-
if (
|
|
114
|
+
if (path !== '404') {
|
|
68
115
|
router.replace({ pathname: '/404' });
|
|
69
116
|
}
|
|
70
117
|
return;
|
|
71
118
|
}
|
|
72
|
-
const
|
|
119
|
+
const response = await res.json();
|
|
73
120
|
if (token !== latestNavRef.current) return;
|
|
74
121
|
if (
|
|
75
|
-
shouldReloadForBuild({
|
|
122
|
+
shouldReloadForBuild({
|
|
123
|
+
bundleBuildId: appMeta.buildId,
|
|
124
|
+
serverBuildId: response.buildId,
|
|
125
|
+
window,
|
|
126
|
+
})
|
|
76
127
|
) {
|
|
77
128
|
// The app was redeployed after this bundle loaded, so the config may
|
|
78
129
|
// reference _js functions and plugins this bundle does not carry.
|
|
@@ -81,15 +132,28 @@ function Page({ auth, config, lowdefy }) {
|
|
|
81
132
|
window.location.reload();
|
|
82
133
|
return;
|
|
83
134
|
}
|
|
84
|
-
|
|
135
|
+
// The server answered as another caller than this page renders (a
|
|
136
|
+
// sign-in or organization switch in another tab), or with other roles:
|
|
137
|
+
// reload so the page renders the server's caller instead of mixing the
|
|
138
|
+
// two. A second difference within the reload interval throws to the
|
|
139
|
+
// catch below, whose full page load also renders the server's caller.
|
|
140
|
+
const serverCaller = res.headers.get('x-lowdefy-caller');
|
|
141
|
+
if (shouldReloadForCaller({ serverCaller, user: lowdefy.user, window })) {
|
|
142
|
+
window.location.reload();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
recordCallerStamp({ lowdefy, serverCaller });
|
|
146
|
+
// A failed chunk load falls to the catch below: a full page load.
|
|
147
|
+
await loadPageTypes({ pageConfig: response.pageConfig });
|
|
148
|
+
if (token !== latestNavRef.current) return;
|
|
149
|
+
const nextPage = getShownPage({ path, response });
|
|
150
|
+
pageRef.current = nextPage;
|
|
151
|
+
setPage(nextPage);
|
|
85
152
|
} catch (error) {
|
|
86
153
|
// Network failure on SPA navigation — fall back to a full page load.
|
|
87
154
|
if (token !== latestNavRef.current) return;
|
|
88
155
|
window.location.assign(
|
|
89
|
-
createUrl({
|
|
90
|
-
basePath: router.basePath,
|
|
91
|
-
pathname: targetPageId === config.rootConfig.home.pageId ? '/' : `/${targetPageId}`,
|
|
92
|
-
})
|
|
156
|
+
createUrl({ basePath: router.basePath, pathname: `/${path}`, query: search.slice(1) })
|
|
93
157
|
);
|
|
94
158
|
}
|
|
95
159
|
});
|
|
@@ -101,18 +165,18 @@ function Page({ auth, config, lowdefy }) {
|
|
|
101
165
|
auth={auth}
|
|
102
166
|
Components={{ Head, Link }}
|
|
103
167
|
config={{
|
|
104
|
-
pageConfig,
|
|
168
|
+
pageConfig: page.pageConfig,
|
|
105
169
|
rootConfig: config.rootConfig,
|
|
106
170
|
}}
|
|
107
171
|
jsMap={jsMap}
|
|
172
|
+
loadAllIcons={loadAllIcons}
|
|
108
173
|
lowdefy={lowdefy}
|
|
174
|
+
matchedPath={page.matchedPath}
|
|
175
|
+
pathParams={page.pathParams}
|
|
109
176
|
router={router}
|
|
110
177
|
types={{
|
|
111
|
-
|
|
178
|
+
...types,
|
|
112
179
|
blockMetas,
|
|
113
|
-
blocks,
|
|
114
|
-
icons,
|
|
115
|
-
operators,
|
|
116
180
|
}}
|
|
117
181
|
window={window}
|
|
118
182
|
/>
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import RELOADED_FOR_TYPES_KEY from './reloadedForTypesKey.js';
|
|
18
|
+
|
|
19
|
+
// The first page's types loaded, so a reload this tab made for them worked. A
|
|
20
|
+
// later failure at the same URL (a deploy since) may reload once again.
|
|
21
|
+
function clearReloadedForTypes({ window }) {
|
|
22
|
+
try {
|
|
23
|
+
window.sessionStorage.removeItem(RELOADED_FOR_TYPES_KEY);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// Storage disabled: nothing was recorded.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default clearReloadedForTypes;
|
|
@@ -30,12 +30,10 @@ const allowed = new Set([
|
|
|
30
30
|
'i18n/antdLocales.js',
|
|
31
31
|
'i18n/antdXLocales.js',
|
|
32
32
|
'i18n/dayjsLocales.js',
|
|
33
|
-
'plugins/actions.js',
|
|
34
33
|
'plugins/blockMetas.json',
|
|
35
|
-
'plugins/blocks.js',
|
|
36
|
-
'plugins/icons.js',
|
|
37
|
-
'plugins/operators/client.js',
|
|
38
34
|
'plugins/operators/clientJsMap.js',
|
|
35
|
+
// Type-set hashes and chunk loaders only - never page ids or config.
|
|
36
|
+
'plugins/pageTypes.js',
|
|
39
37
|
]);
|
|
40
38
|
|
|
41
39
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -51,9 +49,10 @@ function listSourceFiles(dir) {
|
|
|
51
49
|
test('client code imports only client-safe build artifacts', () => {
|
|
52
50
|
const imported = ['client', 'lib/client'].flatMap((dir) =>
|
|
53
51
|
listSourceFiles(path.join(root, dir)).flatMap((file) =>
|
|
54
|
-
[...fs.readFileSync(file, 'utf8').matchAll(/from '[^']*\/build\/([^']+)'/g)].map(
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
[...fs.readFileSync(file, 'utf8').matchAll(/from '[^']*\/build\/([^']+)'/g)].map((match) => ({
|
|
53
|
+
file: path.relative(root, file),
|
|
54
|
+
artifact: match[1],
|
|
55
|
+
}))
|
|
57
56
|
)
|
|
58
57
|
);
|
|
59
58
|
expect(imported.length).toBeGreaterThan(0);
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
// The page a navigation to `path` shows, from the page response. At the app root the client
|
|
18
|
+
// fetches the home page's own path, but the shown instance is matched on the root, as on a first
|
|
19
|
+
// load there, so a later navigation to the root (a query change, the home link) needs no fetch.
|
|
20
|
+
function getShownPage({ path, response }) {
|
|
21
|
+
const { pageConfig, pathParams } = response;
|
|
22
|
+
if (path === '') {
|
|
23
|
+
return { matchedPath: '', pageConfig, pathParams };
|
|
24
|
+
}
|
|
25
|
+
return { matchedPath: response.matchedPath, pageConfig, pathParams };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default getShownPage;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import getShownPage from './getShownPage.js';
|
|
18
|
+
|
|
19
|
+
const pageConfig = { pageId: 'ticket', path: 'tickets/{space}/{ticket_id}' };
|
|
20
|
+
|
|
21
|
+
test('getShownPage keeps the path the server matched', () => {
|
|
22
|
+
expect(
|
|
23
|
+
getShownPage({
|
|
24
|
+
path: 'tickets/a%2Bb/1',
|
|
25
|
+
response: {
|
|
26
|
+
pageId: 'ticket',
|
|
27
|
+
pathParams: { space: 'a+b', ticket_id: '1' },
|
|
28
|
+
matchedPath: 'tickets/a%2Bb/1',
|
|
29
|
+
pageConfig,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
).toEqual({
|
|
33
|
+
matchedPath: 'tickets/a%2Bb/1',
|
|
34
|
+
pageConfig,
|
|
35
|
+
pathParams: { space: 'a+b', ticket_id: '1' },
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('getShownPage matches the home page fetched for the app root on the root', () => {
|
|
40
|
+
const homeConfig = { pageId: 'users', path: 'admin/users' };
|
|
41
|
+
expect(
|
|
42
|
+
getShownPage({
|
|
43
|
+
path: '',
|
|
44
|
+
response: {
|
|
45
|
+
pageId: 'users',
|
|
46
|
+
pathParams: {},
|
|
47
|
+
matchedPath: 'admin/users',
|
|
48
|
+
pageConfig: homeConfig,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
).toEqual({ matchedPath: '', pageConfig: homeConfig, pathParams: {} });
|
|
52
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { icons } from '../build/plugins/pageTypes.js';
|
|
18
|
+
|
|
19
|
+
import types from './types.js';
|
|
20
|
+
|
|
21
|
+
let loading = null;
|
|
22
|
+
|
|
23
|
+
// Each page loads the icons its config names. A name that only arrives at
|
|
24
|
+
// runtime - from state, a request, HTML built from data, theme.icons.include -
|
|
25
|
+
// may be outside that set, so the first icon that misses loads every icon the
|
|
26
|
+
// app bundles, once per tab. Every caller shares the one load; a failed load
|
|
27
|
+
// is logged here and cleared, so the next miss tries again.
|
|
28
|
+
function loadAllIcons() {
|
|
29
|
+
if (loading === null) {
|
|
30
|
+
loading = icons().then(
|
|
31
|
+
(module) => {
|
|
32
|
+
Object.assign(types.icons, module.default);
|
|
33
|
+
},
|
|
34
|
+
(error) => {
|
|
35
|
+
loading = null;
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.error(error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return loading;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default loadAllIcons;
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import pageTypes, { allTypes } from '../build/plugins/pageTypes.js';
|
|
18
|
+
|
|
19
|
+
import loadAllIcons from './loadAllIcons.js';
|
|
20
|
+
import types from './types.js';
|
|
21
|
+
|
|
22
|
+
// Loads the plugin code one page uses: its type set with its icons (a chunk
|
|
23
|
+
// shared by every page with the same set), and the full type set when Dynamic
|
|
24
|
+
// content reaches outside the page's own types. Dynamic content that names an
|
|
25
|
+
// icon outside the page's set, or types outside it (whose blocks draw their
|
|
26
|
+
// own icons), loads every icon before the first render.
|
|
27
|
+
async function loadPageTypes({ pageConfig }) {
|
|
28
|
+
const loaders = [pageTypes[pageConfig.typesKey]().then((module) => module.default)];
|
|
29
|
+
if (pageConfig.loadAllTypes === true) {
|
|
30
|
+
loaders.push(allTypes());
|
|
31
|
+
}
|
|
32
|
+
if (pageConfig.loadAllTypes === true || pageConfig.loadAllIcons === true) {
|
|
33
|
+
// loadAllIcons merges into types.icons itself, so it adds no type set here.
|
|
34
|
+
loaders.push(loadAllIcons().then(() => ({})));
|
|
35
|
+
}
|
|
36
|
+
const loaded = await Promise.all(loaders);
|
|
37
|
+
loaded.forEach((typeSet) => {
|
|
38
|
+
Object.entries(typeSet).forEach(([category, entries]) => {
|
|
39
|
+
Object.assign(types[category], entries);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default loadPageTypes;
|
package/client/main.jsx
CHANGED
|
@@ -23,6 +23,9 @@ import React from 'react';
|
|
|
23
23
|
import { createRoot } from 'react-dom/client';
|
|
24
24
|
|
|
25
25
|
import App from './App.jsx';
|
|
26
|
+
import clearReloadedForTypes from './clearReloadedForTypes.js';
|
|
27
|
+
import loadPageTypes from './loadPageTypes.js';
|
|
28
|
+
import shouldReloadForTypes from './shouldReloadForTypes.js';
|
|
26
29
|
|
|
27
30
|
import '../build/globals.css';
|
|
28
31
|
|
|
@@ -36,4 +39,22 @@ if (import.meta.hot) {
|
|
|
36
39
|
import.meta.hot.data.root = root;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
// The first page renders once its plugin code has loaded. The HTML preloads
|
|
43
|
+
// those chunks, so this await overlaps the main bundle's own download.
|
|
44
|
+
async function render() {
|
|
45
|
+
try {
|
|
46
|
+
await loadPageTypes({ pageConfig: config.pageConfig });
|
|
47
|
+
clearReloadedForTypes({ window });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (shouldReloadForTypes({ window })) {
|
|
50
|
+
window.location.reload();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Render anyway: missing blocks surface through the full-page ErrorBoundary.
|
|
54
|
+
// eslint-disable-next-line no-console
|
|
55
|
+
console.error(error);
|
|
56
|
+
}
|
|
57
|
+
root.render(<App config={config} />);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render();
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
// sessionStorage key holding the URL this tab last reloaded after the first
|
|
18
|
+
// page's types failed to load.
|
|
19
|
+
const RELOADED_FOR_TYPES_KEY = 'lowdefy.reloadedForTypes';
|
|
20
|
+
|
|
21
|
+
export default RELOADED_FOR_TYPES_KEY;
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
*/
|
|
16
|
+
|
|
17
|
+
import RELOADED_FOR_TYPES_KEY from './reloadedForTypesKey.js';
|
|
18
|
+
|
|
19
|
+
// True the first time the first page's type chunks fail to load at a URL — a
|
|
20
|
+
// network blip, or HTML served from an older deploy whose chunks are gone — so
|
|
21
|
+
// the tab reloads once. A second failure at the same URL renders instead of
|
|
22
|
+
// looping, and clears the record so a later visit may retry, as does a load
|
|
23
|
+
// that succeeds (clearReloadedForTypes).
|
|
24
|
+
function shouldReloadForTypes({ window }) {
|
|
25
|
+
try {
|
|
26
|
+
if (window.sessionStorage.getItem(RELOADED_FOR_TYPES_KEY) === window.location.href) {
|
|
27
|
+
window.sessionStorage.removeItem(RELOADED_FOR_TYPES_KEY);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
window.sessionStorage.setItem(RELOADED_FOR_TYPES_KEY, window.location.href);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Storage disabled: without the record the loop guard cannot work.
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default shouldReloadForTypes;
|