@lowdefy/server 6.1.0 → 7.1.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 +2 -0
  44. package/lowdefy/build.test.mjs +88 -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
@@ -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,42 +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 checkEnvironmentGuards from '@lowdefy/node-utils/checkEnvironmentGuards.js';
20
+ import registerServer from '@lowdefy/node-utils/registerServer.js';
21
+ import watchOwner from '@lowdefy/node-utils/watchOwner.js';
22
22
 
23
- import initSentryServer from '../lib/server/sentry/initSentry.js';
23
+ import initServer from './initServer.js';
24
24
 
25
- // Auth.js v5 reads AUTH_URL but not NEXTAUTH_URL — alias the v4 variable
26
- // before any auth config loads so existing deployments keep working.
27
- if (process.env.NEXTAUTH_URL && !process.env.AUTH_URL) {
28
- process.env.AUTH_URL = process.env.NEXTAUTH_URL;
29
- }
30
-
31
- const sentryEnabled = initSentryServer();
32
-
33
- // Import after Sentry init so instrumentation observes the module graph.
34
- const { default: createApp } = await import('./app.js');
35
- const { default: createLogger } = await import('../lib/server/log/createLogger.js');
25
+ const { createApp, logger, sentry } = await initServer();
36
26
  const { default: appMeta } = await import('../lib/build/appMeta.js');
37
- const { default: config } = await import('../lib/build/config.js');
38
-
39
- // The build checked the current environment's guards, but an image built once can be started with
40
- // different variables, so they are checked again before the server takes any traffic.
41
- checkEnvironmentGuards({
42
- name: config.environment,
43
- guards: config.environments?.[config.environment]?.guards,
44
- });
45
27
 
46
28
  const app = createApp();
47
- const logger = createLogger({ server: 'lowdefy' });
48
29
  const port = Number(process.env.PORT ?? 3000);
49
30
 
50
- if (sentryEnabled) {
51
- logger.info('Sentry enabled: server');
52
- }
53
-
54
31
  // Handles /api/websocket upgrades via serve({ websocket }). 256 KiB max frame,
55
32
  // aligned with Vercel's documented default for WebSocket functions.
56
33
  const wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
@@ -62,6 +39,14 @@ const server = serve({ fetch: app.fetch, port, websocket: { server: wss } }, (in
62
39
  { port: info.port, lowdefy_version: appMeta.lowdefyVersion },
63
40
  `Lowdefy server listening on http://localhost:${info.port}`
64
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
+ });
65
50
  });
66
51
 
67
52
  // Container runtimes send SIGTERM and escalate to SIGKILL after a grace period
@@ -79,8 +64,12 @@ function shutdown() {
79
64
  // Drop idle keep-alive connections so close() only waits on in-flight requests.
80
65
  server.closeIdleConnections?.();
81
66
  server.close(() => {
82
- // Flush queued Sentry events; resolves immediately when Sentry is not enabled.
83
- 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));
84
73
  });
85
74
  // In-flight requests that outlive the grace period would otherwise hold the
86
75
  // process open until SIGKILL — exit first so shutdown stays clean.
@@ -89,6 +78,16 @@ function shutdown() {
89
78
 
90
79
  process.on('SIGINT', shutdown);
91
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
+ });
92
91
 
93
92
  // Vercel's Node.js builder consumes the exported server — the same pattern
94
93
  // Vercel documents for WebSocket support with Hono on Fluid compute.
@@ -0,0 +1,61 @@
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 checkEnvironmentGuards from '@lowdefy/node-utils/checkEnvironmentGuards.js';
18
+
19
+ import initSentryServer from '../lib/server/sentry/initSentry.js';
20
+
21
+ // The startup every production entry runs before it serves: the Node server (src/index.js) and the
22
+ // Vercel function entry that `lowdefy vercel-output` generates. Keeping it in one place stops the
23
+ // entries drifting apart. The build artifacts are read relative to process.cwd(), so an entry that
24
+ // has to change directory imports this module dynamically, after the chdir. A serverless entry
25
+ // passes serverless: true, so Sentry reports errors only unless the app turns tracing on.
26
+ async function initServer({ serverless = false } = {}) {
27
+ const sentry = await initSentryServer({ serverless });
28
+
29
+ // The build checked the current environment's guards, but an image built once can be started
30
+ // with different variables, so they are checked again before the server takes any traffic — and
31
+ // before the app loads, so a misconfigured start fails without importing every plugin under the
32
+ // wrong variables. The failure is sent to Sentry here because the Vercel launcher catches errors
33
+ // thrown while it imports the function entry, so no global handler would report it, and the
34
+ // instance can be torn down before a queued event is sent.
35
+ const { default: config } = await import('../lib/build/config.js');
36
+ try {
37
+ checkEnvironmentGuards({
38
+ name: config.environment,
39
+ guards: config.environments?.[config.environment]?.guards,
40
+ });
41
+ } catch (error) {
42
+ if (sentry) {
43
+ sentry.captureException(error);
44
+ await sentry.flush(2000);
45
+ }
46
+ throw error;
47
+ }
48
+
49
+ // Import after Sentry init so instrumentation observes the module graph.
50
+ const { default: createApp } = await import('./app.js');
51
+ const { default: createLogger } = await import('../lib/server/log/createLogger.js');
52
+
53
+ const logger = createLogger({ server: 'lowdefy' });
54
+ if (sentry) {
55
+ logger.info('Sentry enabled: server');
56
+ }
57
+
58
+ return { createApp, logger, sentry };
59
+ }
60
+
61
+ export default initServer;