@lowdefy/server 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +101 -24
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +60 -0
  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 +224 -39
  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 -7
  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 +16 -4
  30. package/lib/server/log/createHandleError.test.mjs +102 -0
  31. package/lib/server/log/createLogger.js +10 -0
  32. package/lib/server/log/createLogger.test.mjs +117 -0
  33. package/lib/server/log/logRequest.js +12 -4
  34. package/lib/server/log/maskCredentialKeys.js +37 -0
  35. package/lib/server/log/projectErrorForLog.js +61 -0
  36. package/lib/server/log/redactUrlQuery.js +75 -0
  37. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  38. package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
  39. package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
  40. package/lib/server/scrubSecrets.js +21 -0
  41. package/lib/server/sentry/captureSentryError.js +29 -3
  42. package/lib/server/sentry/captureSentryError.test.mjs +128 -0
  43. package/lib/server/sentry/initSentry.js +46 -14
  44. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  45. package/lib/server/sentry/initSentry.test.mjs +365 -0
  46. package/lib/server/sentry/scrubEvent.js +40 -0
  47. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  48. package/lib/server/sentry/sentrySdk.js +23 -0
  49. package/lib/server/sentry/setSentryUser.js +3 -3
  50. package/lowdefy/build.mjs +1 -0
  51. package/lowdefy/build.test.mjs +68 -0
  52. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  53. package/package.json +47 -42
  54. package/package.original.json +47 -42
  55. package/src/app.js +74 -17
  56. package/src/app.test.mjs +118 -0
  57. package/src/html/collectChunkClosure.js +31 -0
  58. package/src/html/collectLazyImports.js +41 -0
  59. package/src/html/collectPageTypesAssets.js +57 -0
  60. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  61. package/src/html/getAssets.js +20 -1
  62. package/src/html/getHomePath.js +29 -0
  63. package/src/html/getPageAssets.js +32 -0
  64. package/src/html/getPageAssets.test.mjs +47 -0
  65. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  66. package/src/html/renderPage.js +80 -19
  67. package/src/html/renderPage.test.mjs +189 -0
  68. package/src/html/template.js +50 -15
  69. package/src/html/template.test.mjs +126 -0
  70. package/src/index.js +28 -19
  71. package/src/initServer.js +61 -0
  72. package/src/initServer.test.mjs +116 -0
  73. package/src/lib/getRequestPath.js +29 -0
  74. package/src/middleware/apiContext.js +68 -13
  75. package/src/middleware/apiContext.test.mjs +178 -0
  76. package/src/middleware/createSameOriginGuard.js +36 -0
  77. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  78. package/src/middleware/errorHandler.js +56 -4
  79. package/src/middleware/errorHandler.test.mjs +440 -0
  80. package/src/middleware/noStore.js +27 -0
  81. package/src/middleware/refuseOtherBuild.js +49 -0
  82. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  83. package/src/middleware/requestTimeout.js +38 -0
  84. package/src/middleware/sentry.js +13 -5
  85. package/src/middleware/stampBuildId.js +30 -0
  86. package/src/middleware/stampBuildId.test.mjs +41 -0
  87. package/src/middleware/stampCaller.js +38 -0
  88. package/src/middleware/stampCaller.test.mjs +64 -0
  89. package/src/routes/agent.js +15 -3
  90. package/src/routes/apiPage.js +54 -9
  91. package/src/routes/apiPage.test.mjs +143 -0
  92. package/src/routes/auth.js +15 -9
  93. package/src/routes/auth.test.mjs +85 -0
  94. package/src/routes/clientError.js +11 -13
  95. package/src/routes/cron.js +3 -1
  96. package/src/routes/cronForward.js +4 -2
  97. package/src/routes/detached.js +22 -9
  98. package/src/routes/endpoints.js +27 -26
  99. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  100. package/src/routes/mcp.js +82 -5
  101. package/src/routes/mcp.test.mjs +215 -0
  102. package/src/routes/mcpMemberToken.test.mjs +125 -0
  103. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  104. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  105. package/src/routes/mountOauthDiscovery.js +44 -0
  106. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  107. package/src/routes/mountPageRoutes.js +29 -0
  108. package/src/routes/mountPageRoutes.test.mjs +56 -0
  109. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  110. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  111. package/src/routes/request.js +5 -3
  112. package/src/routes/requestBody.test.mjs +146 -0
  113. package/src/routes/usage.js +6 -2
  114. package/src/routes/user.js +27 -0
  115. package/src/routes/websocket.js +26 -3
  116. package/src/routes/websocket.test.mjs +106 -0
  117. package/src/routes/wellKnownFallback.js +30 -0
  118. package/vite.config.js +55 -2
  119. package/lib/server/apiWrapper.js +0 -87
@@ -14,64 +14,125 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { getPageConfig, getRootConfig } from '@lowdefy/api';
17
+ import { createContentSecurityPolicy, getPageConfig, getRootConfig } from '@lowdefy/api';
18
+ import { type } from '@lowdefy/helpers';
18
19
 
19
20
  import appJson from '../../lib/build/app.js';
21
+ import authJson from '../../lib/build/auth.js';
20
22
  import lowdefyConfig from '../../lib/build/config.js';
23
+ import securityJson from '../../lib/build/security.js';
21
24
  import themeConfig from '../../lib/build/theme.js';
22
25
  import getAssets from './getAssets.js';
26
+ import getHomePath from './getHomePath.js';
27
+ import getPageAssets from './getPageAssets.js';
23
28
  import template from './template.js';
24
29
 
25
30
  const basePath = lowdefyConfig.basePath ?? '';
26
31
 
27
- // Replaces pages/[[...pageId]].js getServerSideProps and pages/404.js
28
- // getStaticProps. The home redirect logic lives here, not at the route level.
29
- async function renderPage(c, { pageId, status = 200 }) {
32
+ // Renders the page a request path matches. path (basePath and the leading "/"
33
+ // removed, still encoded) is empty for the app root, which serves the home
34
+ // page: the configured homePageId, else a redirect to the first menu link the
35
+ // user may see.
36
+ async function renderPage(c, { path, matchedPath, status = 200 }) {
30
37
  const context = c.get('lowdefyContext');
31
- const { logger, session } = context;
38
+ const { logger, user } = context;
32
39
 
33
- let resolvedPageId = pageId;
34
40
  const rootConfig = await getRootConfig(context);
35
41
 
36
- if (!resolvedPageId) {
42
+ let pagePath = path;
43
+ if (path === '') {
37
44
  const { home } = rootConfig;
45
+ pagePath = await getHomePath({ context, home });
38
46
  if (home.configured === false) {
39
- logger.info({ event: 'redirect_to_homepage', pageId: home.pageId });
40
- return c.redirect(`${basePath}/${home.pageId}`, 302);
47
+ logger.info({ event: 'redirect_to_homepage', pageId: home.pageId, path: pagePath });
48
+ return c.redirect(`${basePath}/${pagePath}`, 302);
41
49
  }
42
- resolvedPageId = home.pageId;
43
50
  }
44
51
 
45
- const pageConfig = await getPageConfig(context, {
46
- pageId: resolvedPageId,
52
+ const result = await getPageConfig(context, {
53
+ path: pagePath,
47
54
  urlQuery: c.req.query(),
48
55
  });
56
+ const { pageId, pathParams } = result;
49
57
 
50
- if (!pageConfig) {
51
- if (resolvedPageId === '404') {
58
+ // A logged-out human gets a login screen with a callbackUrl back to the
59
+ // requested page; not-found and wrong-roles both stay opaque (/404), so
60
+ // page navigation never reveals who may access a page.
61
+ if (result.status === 'unauthenticated') {
62
+ const url = new URL(c.req.url);
63
+ const callbackUrl = `${url.pathname}${url.search}`;
64
+ logger.info({ event: 'redirect_unauthenticated', pageId, path: pagePath });
65
+ return c.redirect(
66
+ `${basePath}${authJson.authPages.signIn}?callbackUrl=${encodeURIComponent(callbackUrl)}`,
67
+ 302
68
+ );
69
+ }
70
+
71
+ // An authorised caller who has not enrolled a second factor under
72
+ // auth.twoFactor.required. The callbackUrl brings them back to the page they
73
+ // asked for once they enrol. Without this branch they would fall through to the
74
+ // /404 redirect below on every page - fail-closed, and indistinguishable from a
75
+ // broken app.
76
+ if (result.status === 'enrol_required') {
77
+ const url = new URL(c.req.url);
78
+ const callbackUrl = `${url.pathname}${url.search}`;
79
+ logger.info({ event: 'redirect_two_factor_enrol', pageId, path: pagePath });
80
+ return c.redirect(
81
+ `${basePath}${authJson.authPages.twoFactorEnrol}?callbackUrl=${encodeURIComponent(
82
+ callbackUrl
83
+ )}`,
84
+ 302
85
+ );
86
+ }
87
+
88
+ if (result.status !== 'ok') {
89
+ if (pagePath === '404') {
52
90
  // No 404 page in the build — return a plain 404 rather than redirecting in a loop.
53
91
  return c.text('Page not found.', 404);
54
92
  }
55
- logger.info({ event: 'redirect_page_not_found', pageId: resolvedPageId });
93
+ logger.info({ event: 'redirect_page_not_found', pageId, path: pagePath });
56
94
  return c.redirect(`${basePath}/404`, 302);
57
95
  }
58
96
 
59
- logger.info({ event: 'page_view', pageId: resolvedPageId });
97
+ const { pageConfig } = result;
98
+
99
+ logger.info({ event: 'page_view', pageId, path: pagePath });
100
+
101
+ // Off unless config.security.contentSecurityPolicy is set. Each render gets its own nonce.
102
+ let nonce = null;
103
+ const { contentSecurityPolicy } = securityJson;
104
+ if (!type.isNull(contentSecurityPolicy)) {
105
+ const policy = createContentSecurityPolicy({
106
+ policy: contentSecurityPolicy,
107
+ requestUrl: c.req.url,
108
+ allowWebSocket: contentSecurityPolicy.websockets,
109
+ });
110
+ c.header(policy.name, policy.value);
111
+ nonce = policy.nonce;
112
+ }
60
113
 
114
+ const assets = getAssets();
61
115
  const html = template({
62
116
  appendBody: appJson.html?.appendBody ?? '',
63
117
  appendHead: appJson.html?.appendHead ?? '',
64
- assets: getAssets(),
118
+ assets,
119
+ pageAssets: getPageAssets({ assets, pageConfig }),
65
120
  basePath,
121
+ nonce,
66
122
  config: {
67
123
  basePath,
124
+ matchedPath,
68
125
  pageConfig,
126
+ pageId,
127
+ pathParams,
69
128
  rootConfig,
70
129
  sentryDsn: process.env.SENTRY_DSN ?? null,
71
- session: session ?? null,
130
+ user: user ?? null,
72
131
  },
132
+ slug: rootConfig.lowdefyApp?.slug,
73
133
  themeConfig,
74
- title: pageConfig.properties?.title ?? resolvedPageId,
134
+ // A title written as an operator is evaluated on the client, which replaces this one.
135
+ title: type.isString(pageConfig.properties?.title) ? pageConfig.properties.title : pageId,
75
136
  });
76
137
 
77
138
  return c.html(html, status);
@@ -0,0 +1,189 @@
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 { Hono } from 'hono';
18
+ import { jest } from '@jest/globals';
19
+
20
+ const { default: getPageConfig } = await import('@lowdefy/api/routes/page/getPageConfig.js');
21
+
22
+ const mockGetRootConfig = jest.fn();
23
+ jest.unstable_mockModule('@lowdefy/api', () => ({
24
+ createContentSecurityPolicy: jest.fn(),
25
+ getPageConfig,
26
+ getRootConfig: mockGetRootConfig,
27
+ }));
28
+
29
+ jest.unstable_mockModule('../../lib/build/app.js', () => ({ default: {} }));
30
+ jest.unstable_mockModule('../../lib/build/auth.js', () => ({
31
+ default: { authPages: { signIn: '/auth/login', twoFactorEnrol: '/two-factor-enrol' } },
32
+ }));
33
+ jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: { basePath: '' } }));
34
+ jest.unstable_mockModule('../../lib/build/security.js', () => ({
35
+ default: { contentSecurityPolicy: null },
36
+ }));
37
+ jest.unstable_mockModule('../../lib/build/theme.js', () => ({ default: {} }));
38
+ jest.unstable_mockModule('./getAssets.js', () => ({ default: () => ({}) }));
39
+ jest.unstable_mockModule('./getPageAssets.js', () => ({ default: () => ({}) }));
40
+ jest.unstable_mockModule('./template.js', () => ({
41
+ default: ({ config, title }) => JSON.stringify({ config, title }),
42
+ }));
43
+
44
+ const { default: renderPage } = await import('./renderPage.js');
45
+ const { default: mountPageRoutes } = await import('../routes/mountPageRoutes.js');
46
+
47
+ const routes = [
48
+ { pageId: '404', path: '404' },
49
+ { pageId: 'home', path: 'home' },
50
+ { pageId: 'state-title', path: 'state-title' },
51
+ { pageId: 'ticket', path: '{space}/tickets/{ticket_id}' },
52
+ { pageId: 'section', path: '{section}' },
53
+ ];
54
+
55
+ const pages = {
56
+ 404: { id: '404' },
57
+ home: { id: 'home' },
58
+ 'state-title': { id: 'state-title', properties: { title: { _state: 'title' } } },
59
+ ticket: { id: 'ticket', properties: { title: 'Ticket' } },
60
+ section: { id: 'section' },
61
+ };
62
+
63
+ function createApp({
64
+ outcome = 'allow',
65
+ pagesProtectedByDefault = false,
66
+ routeTable = routes,
67
+ user = { id: 'u1' },
68
+ } = {}) {
69
+ const app = new Hono();
70
+ app.use('*', async (c, next) => {
71
+ c.set('lowdefyContext', {
72
+ authEnforcement: { pagesProtectedByDefault },
73
+ authorizeOutcome: () => outcome,
74
+ logger: { info: jest.fn() },
75
+ readConfigFile: async (file) => {
76
+ if (file === 'routes.json') return routeTable;
77
+ return pages[file.slice('pages/'.length, -'.json'.length)] ?? null;
78
+ },
79
+ user,
80
+ });
81
+ await next();
82
+ });
83
+ mountPageRoutes({ app, basePath: '', renderPage });
84
+ return app;
85
+ }
86
+
87
+ beforeEach(() => {
88
+ mockGetRootConfig.mockResolvedValue({
89
+ home: { configured: true, pageId: 'home', pathParams: {} },
90
+ });
91
+ });
92
+
93
+ async function readConfig(res) {
94
+ return JSON.parse(await res.text()).config;
95
+ }
96
+
97
+ test('renderPage embeds pageId, pathParams and matchedPath for a patterned page', async () => {
98
+ const res = await createApp().request('/support/tickets/1234');
99
+ expect(res.status).toEqual(200);
100
+ const config = await readConfig(res);
101
+ expect(config.pageId).toEqual('ticket');
102
+ expect(config.pathParams).toEqual({ space: 'support', ticket_id: '1234' });
103
+ expect(config.matchedPath).toEqual('support/tickets/1234');
104
+ expect(config.pageConfig).toEqual({ id: 'ticket', properties: { title: 'Ticket' } });
105
+ });
106
+
107
+ test('renderPage decodes an encoded "/" in a value once and keeps matchedPath encoded', async () => {
108
+ const res = await createApp().request('/support/tickets/a%2Fb/');
109
+ const config = await readConfig(res);
110
+ expect(config.pathParams).toEqual({ space: 'support', ticket_id: 'a/b' });
111
+ expect(config.matchedPath).toEqual('support/tickets/a%2Fb');
112
+ });
113
+
114
+ test('renderPage redirects an unmatched path to /404', async () => {
115
+ const res = await createApp().request('/support/tickets');
116
+ expect(res.status).toEqual(302);
117
+ expect(res.headers.get('location')).toEqual('/404');
118
+ });
119
+
120
+ test('renderPage redirects an unmatched path to sign-in with the path and query under pagesProtectedByDefault', async () => {
121
+ const res = await createApp({ pagesProtectedByDefault: true, user: null }).request(
122
+ '/support/tickets?tab=2'
123
+ );
124
+ expect(res.status).toEqual(302);
125
+ expect(res.headers.get('location')).toEqual(
126
+ `/auth/login?callbackUrl=${encodeURIComponent('/support/tickets?tab=2')}`
127
+ );
128
+ });
129
+
130
+ test('renderPage redirects a protected patterned page to sign-in with the path and query', async () => {
131
+ const res = await createApp({ outcome: 'deny', user: null }).request(
132
+ '/support/tickets/1234?tab=2'
133
+ );
134
+ expect(res.headers.get('location')).toEqual(
135
+ `/auth/login?callbackUrl=${encodeURIComponent('/support/tickets/1234?tab=2')}`
136
+ );
137
+ });
138
+
139
+ test('renderPage serves a page without path at its id', async () => {
140
+ const res = await createApp().request('/home');
141
+ const config = await readConfig(res);
142
+ expect(config.pageId).toEqual('home');
143
+ expect(config.pathParams).toEqual({});
144
+ expect(config.matchedPath).toEqual('home');
145
+ });
146
+
147
+ test('renderPage serves the configured home page at /', async () => {
148
+ const res = await createApp().request('/');
149
+ expect(res.status).toEqual(200);
150
+ const config = await readConfig(res);
151
+ expect(config.pageId).toEqual('home');
152
+ expect(config.matchedPath).toEqual('');
153
+ });
154
+
155
+ test('renderPage redirects / to the home menu link built from its pathParams', async () => {
156
+ mockGetRootConfig.mockResolvedValue({
157
+ home: {
158
+ configured: false,
159
+ pageId: 'ticket',
160
+ pathParams: { space: 'support', ticket_id: '12 34' },
161
+ },
162
+ });
163
+ const res = await createApp().request('/');
164
+ expect(res.status).toEqual(302);
165
+ expect(res.headers.get('location')).toEqual('/support/tickets/12%2034');
166
+ });
167
+
168
+ test('renderPage serves /404 as a fixed route with status 404', async () => {
169
+ const res = await createApp().request('/404');
170
+ expect(res.status).toEqual(404);
171
+ const config = await readConfig(res);
172
+ expect(config.pageId).toEqual('404');
173
+ });
174
+
175
+ test('renderPage returns a plain 404 when the build has no 404 page', async () => {
176
+ const res = await createApp({ routeTable: [{ pageId: 'home', path: 'home' }] }).request('/404');
177
+ expect(res.status).toEqual(404);
178
+ expect(await res.text()).toEqual('Page not found.');
179
+ });
180
+
181
+ test('renderPage serves the page title written as a string', async () => {
182
+ const res = await createApp().request('/support/tickets/1234');
183
+ expect(JSON.parse(await res.text()).title).toEqual('Ticket');
184
+ });
185
+
186
+ test('renderPage serves the page id as the title when the title is an operator', async () => {
187
+ const res = await createApp().request('/state-title');
188
+ expect(JSON.parse(await res.text()).title).toEqual('state-title');
189
+ });
@@ -14,6 +14,9 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { addNonceToElements } from '@lowdefy/api';
18
+ import { appStorageKey, type } from '@lowdefy/helpers';
19
+
17
20
  import safeScriptJson from '../lib/safeScriptJson.js';
18
21
 
19
22
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
@@ -27,19 +30,22 @@ function escapeHtml(value) {
27
30
  // prependQueue to inject <style> tags at the top of <head>, which would
28
31
  // otherwise make @layer antd the first (lowest priority) layer.
29
32
  // MutationObserver fires before paint, so the browser never sees the wrong
30
- // cascade order.
31
- const layerOrderScript = `(function(){var s=document.createElement("style");s.id="__lf-layer-order";s.textContent="@layer theme, base, antd, components, utilities;";document.head.prepend(s);new MutationObserver(function(){if(document.head.firstChild!==s)document.head.prepend(s)}).observe(document.head,{childList:true})})();`;
33
+ // cascade order. The style element takes the script's own nonce, which a
34
+ // Content-Security-Policy needs to apply it.
35
+ const layerOrderScript = `(function(){var s=document.createElement("style");s.id="__lf-layer-order";var n=document.currentScript&&document.currentScript.nonce;if(n)s.nonce=n;s.textContent="@layer theme, base, antd, components, utilities;";document.head.prepend(s);new MutationObserver(function(){if(document.head.firstChild!==s)document.head.prepend(s)}).observe(document.head,{childList:true})})();`;
32
36
 
33
37
  // Synchronous pre-hydration script setting the <html> background and
34
38
  // color-scheme — prevents a mode-mismatch flash of the page and of browser
35
39
  // chrome (scrollbars, form controls) on page load. Mirrors useDarkMode.js
36
- // resolution order: configDarkMode → localStorage → prefers-color-scheme.
37
- function darkModeScript({ configColorMode, darkBg, lightBg }) {
40
+ // resolution order: configDarkMode → localStorage → prefers-color-scheme. The preference is
41
+ // kept under the app's storage key; a value under the bare key from before keys were kept per
42
+ // app is read until useDarkMode moves it.
43
+ function darkModeScript({ configColorMode, darkBg, lightBg, storageKey }) {
38
44
  return `(function(){var c=${safeScriptJson(configColorMode)};var db=${safeScriptJson(
39
45
  darkBg
40
- )};var lb=${safeScriptJson(
41
- lightBg
42
- )};var d;if(c==="dark")d=true;else if(c==="light")d=false;else{try{var p=localStorage.getItem("lowdefy_darkMode");if(p==="dark")d=true;else if(p==="light")d=false;else d=window.matchMedia("(prefers-color-scheme:dark)").matches}catch(e){d=window.matchMedia("(prefers-color-scheme:dark)").matches}}var bg=d?db:lb;document.documentElement.style.colorScheme=d?"dark":"light";if(bg)document.documentElement.style.backgroundColor=bg})();`;
46
+ )};var lb=${safeScriptJson(lightBg)};var k=${safeScriptJson(
47
+ storageKey
48
+ )};var d;if(c==="dark")d=true;else if(c==="light")d=false;else{try{var p=localStorage.getItem(k);if(p===null)p=localStorage.getItem("lowdefy_darkMode");if(p==="dark")d=true;else if(p==="light")d=false;else d=window.matchMedia("(prefers-color-scheme:dark)").matches}catch(e){d=window.matchMedia("(prefers-color-scheme:dark)").matches}}var bg=d?db:lb;document.documentElement.style.colorScheme=d?"dark":"light";if(bg)document.documentElement.style.backgroundColor=bg})();`;
43
49
  }
44
50
 
45
51
  function template({
@@ -48,6 +54,9 @@ function template({
48
54
  assets,
49
55
  basePath = '',
50
56
  config,
57
+ nonce = null,
58
+ pageAssets,
59
+ slug,
51
60
  themeConfig = {},
52
61
  title,
53
62
  }) {
@@ -58,12 +67,29 @@ function template({
58
67
  const darkBg = themeConfig?.antd?.darkToken?.colorBgLayout ?? '#000';
59
68
  const lightBg = themeConfig?.antd?.lightToken?.colorBgLayout ?? '';
60
69
 
61
- const cssLinks = assets.css
70
+ // The page's plugin chunks are preloaded with the main entry, so the first
71
+ // render's wait for them overlaps the main bundle's download.
72
+ const cssLinks = [...assets.css, ...pageAssets.css]
62
73
  .map((file) => `<link rel="stylesheet" href="${basePath}/${file}" />`)
63
74
  .join('\n ');
64
- const modulePreloads = assets.imports
75
+ const modulePreloads = [...assets.imports, ...pageAssets.js]
65
76
  .map((file) => `<link rel="modulepreload" href="${basePath}/${file}" />`)
66
77
  .join('\n ');
78
+ // Lazy block implementations load on mount; prefetch fills the cache at idle
79
+ // priority. crossorigin matches the CORS mode of the module and stylesheet
80
+ // requests Vite makes on mount, so they are served from that cache.
81
+ const prefetches = pageAssets.prefetch
82
+ .map((file) => `<link rel="prefetch" href="${basePath}/${file}" crossorigin />`)
83
+ .join('\n ');
84
+
85
+ // With a Content-Security-Policy, every script and style element Lowdefy writes
86
+ // or the app appends carries the render's nonce. The csp-nonce meta tag is
87
+ // where the client (antd's style tags) and Vite read it.
88
+ const hasNonce = !type.isNull(nonce);
89
+ const nonceAttribute = hasNonce ? ` nonce="${nonce}"` : '';
90
+ const nonceMeta = hasNonce ? `<meta property="csp-nonce" nonce="${nonce}" />` : '';
91
+ const head = hasNonce ? addNonceToElements({ html: appendHead, nonce }) : appendHead;
92
+ const body = hasNonce ? addNonceToElements({ html: appendBody, nonce }) : appendBody;
67
93
 
68
94
  // appendHead and appendBody are intentionally raw HTML from app config
69
95
  // (analytics snippets, font links) — they must not be escaped. The config
@@ -72,8 +98,14 @@ function template({
72
98
  <html class="lowdefy">
73
99
  <head>
74
100
  <meta charset="utf-8" />
75
- <script>${layerOrderScript}</script>
76
- <script>${darkModeScript({ configColorMode, darkBg, lightBg })}</script>
101
+ ${nonceMeta}
102
+ <script${nonceAttribute}>${layerOrderScript}</script>
103
+ <script${nonceAttribute}>${darkModeScript({
104
+ configColorMode,
105
+ darkBg,
106
+ lightBg,
107
+ storageKey: appStorageKey({ basePath, name: 'lowdefy_darkMode', slug }),
108
+ })}</script>
77
109
  <link rel="manifest" href="${basePath}/manifest.webmanifest" />
78
110
  <link rel="icon" type="image/svg+xml" href="${basePath}/icon.svg" />
79
111
  <link rel="apple-touch-icon" href="${basePath}/apple-touch-icon.png" />
@@ -81,13 +113,16 @@ function template({
81
113
  ${title ? `<title>${escapeHtml(title)}</title>` : ''}
82
114
  ${cssLinks}
83
115
  ${modulePreloads}
84
- ${appendHead}
116
+ ${prefetches}
117
+ ${head}
85
118
  </head>
86
119
  <body>
87
120
  <div id="root"></div>
88
- <script id="__LOWDEFY_CONFIG__" type="application/json">${safeScriptJson(config)}</script>
89
- ${appendBody}
90
- <script type="module" src="${basePath}/${assets.js}"></script>
121
+ <script id="__LOWDEFY_CONFIG__" type="application/json"${nonceAttribute}>${safeScriptJson(
122
+ config
123
+ )}</script>
124
+ ${body}
125
+ <script type="module" src="${basePath}/${assets.js}"${nonceAttribute}></script>
91
126
  </body>
92
127
  </html>`;
93
128
  }
@@ -0,0 +1,126 @@
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 template from './template.js';
18
+
19
+ const assets = {
20
+ js: 'assets/main.js',
21
+ css: ['assets/main.css'],
22
+ imports: ['assets/react.js'],
23
+ };
24
+
25
+ test('template preloads the page types chunks and links their stylesheets', () => {
26
+ const html = template({
27
+ assets,
28
+ basePath: '/app',
29
+ config: { pageConfig: {} },
30
+ pageAssets: {
31
+ js: ['assets/aaaa.js', 'assets/antd.js'],
32
+ css: ['assets/antd.css'],
33
+ prefetch: [],
34
+ },
35
+ });
36
+ expect(html).toContain('<link rel="stylesheet" href="/app/assets/main.css" />');
37
+ expect(html).toContain('<link rel="stylesheet" href="/app/assets/antd.css" />');
38
+ expect(html).toContain('<link rel="modulepreload" href="/app/assets/react.js" />');
39
+ expect(html).toContain('<link rel="modulepreload" href="/app/assets/aaaa.js" />');
40
+ expect(html).toContain('<link rel="modulepreload" href="/app/assets/antd.js" />');
41
+ expect(html).toContain('<script type="module" src="/app/assets/main.js"></script>');
42
+ });
43
+
44
+ test('template prefetches the page lazy block chunks after its preloads', () => {
45
+ const html = template({
46
+ assets,
47
+ basePath: '/app',
48
+ config: { pageConfig: {} },
49
+ pageAssets: {
50
+ js: ['assets/aaaa.js'],
51
+ css: [],
52
+ prefetch: ['assets/AgentChat.lazy.js', 'assets/AgentChat.css'],
53
+ },
54
+ });
55
+ expect(html).toContain(
56
+ '<link rel="prefetch" href="/app/assets/AgentChat.lazy.js" crossorigin />'
57
+ );
58
+ expect(html).toContain('<link rel="prefetch" href="/app/assets/AgentChat.css" crossorigin />');
59
+ expect(html).not.toContain('<link rel="modulepreload" href="/app/assets/AgentChat.lazy.js" />');
60
+ expect(html.indexOf('rel="prefetch"')).toBeGreaterThan(
61
+ html.indexOf('<link rel="modulepreload" href="/app/assets/aaaa.js" />')
62
+ );
63
+ });
64
+
65
+ test('template writes no prefetch links for a page without lazy blocks', () => {
66
+ const html = template({
67
+ assets,
68
+ config: { pageConfig: {} },
69
+ pageAssets: { js: ['assets/aaaa.js'], css: [], prefetch: [] },
70
+ });
71
+ expect(html).not.toContain('rel="prefetch"');
72
+ });
73
+
74
+ test('template dark mode script reads the preference under the app key', () => {
75
+ const html = template({
76
+ assets,
77
+ basePath: '/app',
78
+ config: { pageConfig: {} },
79
+ pageAssets: { js: [], css: [], prefetch: [] },
80
+ slug: 'sales',
81
+ });
82
+ expect(html).toContain('var k="lowdefy:sales:/app:lowdefy_darkMode";');
83
+ expect(html).toContain(
84
+ 'var p=localStorage.getItem(k);if(p===null)p=localStorage.getItem("lowdefy_darkMode");'
85
+ );
86
+ });
87
+
88
+ const pageAssets = { js: [], css: [], prefetch: [] };
89
+
90
+ test('template puts the nonce on every script element, the appended ones included', () => {
91
+ const html = template({
92
+ appendHead: '<script src="https://cdn.example.com/a.js"></script><style>p{}</style>',
93
+ appendBody: '<script>window.b = 1;</script>',
94
+ assets,
95
+ config: { pageConfig: {} },
96
+ nonce: 'bm9uY2U=',
97
+ pageAssets,
98
+ });
99
+ const scripts = html.match(/<script\b[^>]*>/g);
100
+ expect(scripts.length).toEqual(6);
101
+ scripts.forEach((tag) => expect(tag).toContain(' nonce="bm9uY2U="'));
102
+ expect(html).toContain('<style nonce="bm9uY2U=">p{}</style>');
103
+ expect(html).toContain('<meta property="csp-nonce" nonce="bm9uY2U=" />');
104
+ });
105
+
106
+ test('template has the layer order style take the nonce of its script', () => {
107
+ const html = template({ assets, config: { pageConfig: {} }, nonce: 'bm9uY2U=', pageAssets });
108
+ expect(html).toContain(
109
+ 'var n=document.currentScript&&document.currentScript.nonce;if(n)s.nonce=n;'
110
+ );
111
+ });
112
+
113
+ test('template writes no nonce without a Content-Security-Policy', () => {
114
+ const html = template({
115
+ appendHead: '<script src="https://cdn.example.com/a.js"></script>',
116
+ appendBody: '<script>window.b = 1;</script>',
117
+ assets,
118
+ config: { pageConfig: {} },
119
+ nonce: null,
120
+ pageAssets,
121
+ });
122
+ expect(html).not.toContain(' nonce="');
123
+ expect(html).not.toContain('csp-nonce');
124
+ expect(html).toContain('<script src="https://cdn.example.com/a.js"></script>');
125
+ expect(html).toContain('<script>window.b = 1;</script>');
126
+ });
package/src/index.js CHANGED
@@ -15,32 +15,19 @@
15
15
  */
16
16
 
17
17
  import { serve } from '@hono/node-server';
18
- import * as Sentry from '@sentry/node';
19
18
  import { WebSocketServer } from 'ws';
20
19
 
21
- import initSentryServer from '../lib/server/sentry/initSentry.js';
20
+ import registerServer from '@lowdefy/node-utils/registerServer.js';
21
+ import watchOwner from '@lowdefy/node-utils/watchOwner.js';
22
22
 
23
- // Auth.js v5 reads AUTH_URL but not NEXTAUTH_URL — alias the v4 variable
24
- // before any auth config loads so existing deployments keep working.
25
- if (process.env.NEXTAUTH_URL && !process.env.AUTH_URL) {
26
- process.env.AUTH_URL = process.env.NEXTAUTH_URL;
27
- }
28
-
29
- const sentryEnabled = initSentryServer();
23
+ import initServer from './initServer.js';
30
24
 
31
- // Import after Sentry init so instrumentation observes the module graph.
32
- const { default: createApp } = await import('./app.js');
33
- const { default: createLogger } = await import('../lib/server/log/createLogger.js');
25
+ const { createApp, logger, sentry } = await initServer();
34
26
  const { default: appMeta } = await import('../lib/build/appMeta.js');
35
27
 
36
28
  const app = createApp();
37
- const logger = createLogger({ server: 'lowdefy' });
38
29
  const port = Number(process.env.PORT ?? 3000);
39
30
 
40
- if (sentryEnabled) {
41
- logger.info('Sentry enabled: server');
42
- }
43
-
44
31
  // Handles /api/websocket upgrades via serve({ websocket }). 256 KiB max frame,
45
32
  // aligned with Vercel's documented default for WebSocket functions.
46
33
  const wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
@@ -52,6 +39,14 @@ const server = serve({ fetch: app.fetch, port, websocket: { server: wss } }, (in
52
39
  { port: info.port, lowdefy_version: appMeta.lowdefyVersion },
53
40
  `Lowdefy server listening on http://localhost:${info.port}`
54
41
  );
42
+ // Only when the CLI asks (it sets the registry directory), so a server run
43
+ // directly - Docker, Vercel, systemd - records nothing.
44
+ registerServer({
45
+ kind: 'server',
46
+ port: info.port,
47
+ configDirectory: process.env.LOWDEFY_DIRECTORY_CONFIG,
48
+ logger,
49
+ });
55
50
  });
56
51
 
57
52
  // Container runtimes send SIGTERM and escalate to SIGKILL after a grace period
@@ -69,8 +64,12 @@ function shutdown() {
69
64
  // Drop idle keep-alive connections so close() only waits on in-flight requests.
70
65
  server.closeIdleConnections?.();
71
66
  server.close(() => {
72
- // Flush queued Sentry events; resolves immediately when Sentry is not enabled.
73
- Sentry.close(2000).finally(() => process.exit(0));
67
+ if (!sentry) {
68
+ process.exit(0);
69
+ return;
70
+ }
71
+ // Send queued Sentry events before exiting.
72
+ sentry.close(2000).finally(() => process.exit(0));
74
73
  });
75
74
  // In-flight requests that outlive the grace period would otherwise hold the
76
75
  // process open until SIGKILL — exit first so shutdown stays clean.
@@ -79,6 +78,16 @@ function shutdown() {
79
78
 
80
79
  process.on('SIGINT', shutdown);
81
80
  process.on('SIGTERM', shutdown);
81
+ process.on('SIGHUP', shutdown);
82
+
83
+ // A server started by the CLI, a test runner or a script stops when that
84
+ // owner stops, however it ends. Inert unless the spawner set its variables.
85
+ watchOwner({
86
+ onExit: ({ reason }) => {
87
+ logger.info({ reason }, 'The process that started this server is gone. Shutting down.');
88
+ shutdown();
89
+ },
90
+ });
82
91
 
83
92
  // Vercel's Node.js builder consumes the exported server — the same pattern
84
93
  // Vercel documents for WebSocket support with Hono on Fluid compute.