@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.
Files changed (113) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +90 -26
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +6 -7
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +222 -36
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +7 -0
  30. package/lib/server/log/createHandleError.test.mjs +17 -0
  31. package/lib/server/log/createLogger.test.mjs +15 -0
  32. package/lib/server/log/logRequest.js +5 -3
  33. package/lib/server/log/projectErrorForLog.js +6 -0
  34. package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
  35. package/lib/server/sentry/captureSentryError.js +17 -3
  36. package/lib/server/sentry/captureSentryError.test.mjs +37 -13
  37. package/lib/server/sentry/initSentry.js +39 -18
  38. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  39. package/lib/server/sentry/initSentry.test.mjs +132 -4
  40. package/lib/server/sentry/scrubEvent.js +12 -1
  41. package/lib/server/sentry/sentrySdk.js +23 -0
  42. package/lib/server/sentry/setSentryUser.js +3 -3
  43. package/lowdefy/build.mjs +1 -0
  44. package/lowdefy/build.test.mjs +68 -0
  45. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  46. package/package.json +42 -36
  47. package/package.original.json +42 -36
  48. package/src/app.js +74 -17
  49. package/src/app.test.mjs +118 -0
  50. package/src/html/collectChunkClosure.js +31 -0
  51. package/src/html/collectLazyImports.js +41 -0
  52. package/src/html/collectPageTypesAssets.js +57 -0
  53. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  54. package/src/html/getAssets.js +20 -1
  55. package/src/html/getHomePath.js +29 -0
  56. package/src/html/getPageAssets.js +32 -0
  57. package/src/html/getPageAssets.test.mjs +47 -0
  58. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  59. package/src/html/renderPage.js +80 -19
  60. package/src/html/renderPage.test.mjs +189 -0
  61. package/src/html/template.js +50 -15
  62. package/src/html/template.test.mjs +126 -0
  63. package/src/index.js +28 -29
  64. package/src/initServer.js +61 -0
  65. package/src/initServer.test.mjs +116 -0
  66. package/src/lib/getRequestPath.js +29 -0
  67. package/src/middleware/apiContext.js +65 -13
  68. package/src/middleware/apiContext.test.mjs +66 -6
  69. package/src/middleware/createSameOriginGuard.js +36 -0
  70. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  71. package/src/middleware/errorHandler.js +56 -4
  72. package/src/middleware/errorHandler.test.mjs +440 -0
  73. package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
  74. package/src/middleware/refuseOtherBuild.js +49 -0
  75. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  76. package/src/middleware/requestTimeout.js +38 -0
  77. package/src/middleware/sentry.js +13 -5
  78. package/src/middleware/stampBuildId.js +30 -0
  79. package/src/middleware/stampBuildId.test.mjs +41 -0
  80. package/src/middleware/stampCaller.js +38 -0
  81. package/src/middleware/stampCaller.test.mjs +64 -0
  82. package/src/routes/agent.js +15 -3
  83. package/src/routes/apiPage.js +51 -9
  84. package/src/routes/apiPage.test.mjs +99 -15
  85. package/src/routes/auth.js +15 -9
  86. package/src/routes/auth.test.mjs +85 -0
  87. package/src/routes/clientError.js +11 -13
  88. package/src/routes/cron.js +3 -1
  89. package/src/routes/cronForward.js +3 -1
  90. package/src/routes/detached.js +22 -9
  91. package/src/routes/endpoints.js +27 -26
  92. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  93. package/src/routes/mcp.js +82 -5
  94. package/src/routes/mcp.test.mjs +215 -0
  95. package/src/routes/mcpMemberToken.test.mjs +125 -0
  96. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  97. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  98. package/src/routes/mountOauthDiscovery.js +44 -0
  99. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  100. package/src/routes/mountPageRoutes.js +29 -0
  101. package/src/routes/mountPageRoutes.test.mjs +56 -0
  102. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  103. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  104. package/src/routes/request.js +5 -3
  105. package/src/routes/requestBody.test.mjs +146 -0
  106. package/src/routes/usage.js +6 -2
  107. package/src/routes/user.js +27 -0
  108. package/src/routes/websocket.js +26 -3
  109. package/src/routes/websocket.test.mjs +106 -0
  110. package/src/routes/wellKnownFallback.js +30 -0
  111. package/vite.config.js +55 -2
  112. package/client/shouldReloadForBuild.js +0 -48
  113. 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, session } = config;
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({ eventCallback: createLogUsage({ usageDataRef }) });
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
- <AntdApp>
127
- <ThemeTokenResolver lowdefyRef={lowdefyRef}>
128
- <ErrorBoundary fullPage onError={handleError}>
129
- <Auth session={session}>
130
- {(auth) => {
131
- usageDataRef.current.user = auth.session?.hashed_id;
132
- // Set Sentry user context when auth changes
133
- setSentryUser({
134
- user: auth.session,
135
- sentryConfig: loggerConfig.sentry,
136
- });
137
- return <Page auth={auth} config={config} lowdefy={lowdefyRef.current} />;
138
- }}
139
- </Auth>
140
- </ErrorBoundary>
141
- </ThemeTokenResolver>
142
- </AntdApp>
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 shouldReloadForBuild from './shouldReloadForBuild.js';
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/* and swap pageConfig.
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 [pageConfig, setPageConfig] = useState(config.pageConfig);
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 ({ pageId }) => {
86
+ const unsubscribe = router.subscribe(async ({ path, search }) => {
57
87
  const token = ++latestNavRef.current;
58
- const targetPageId = pageId ?? config.rootConfig.home.pageId;
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
- `${router.basePath}/api/page/${targetPageId}${window.location.search}`
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 (targetPageId !== '404') {
114
+ if (path !== '404') {
68
115
  router.replace({ pathname: '/404' });
69
116
  }
70
117
  return;
71
118
  }
72
- const { buildId, pageConfig: nextPageConfig } = await res.json();
119
+ const response = await res.json();
73
120
  if (token !== latestNavRef.current) return;
74
121
  if (
75
- shouldReloadForBuild({ bundleBuildId: appMeta.buildId, serverBuildId: buildId, window })
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
- setPageConfig(nextPageConfig);
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
- actions,
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
- (match) => ({ file: path.relative(root, file), artifact: match[1] })
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
- root.render(<App config={config} />);
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;