@lowdefy/server 6.1.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
|
@@ -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
|
+
});
|
package/src/html/template.js
CHANGED
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
<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
|
-
${
|
|
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(
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
20
|
+
import registerServer from '@lowdefy/node-utils/registerServer.js';
|
|
21
|
+
import watchOwner from '@lowdefy/node-utils/watchOwner.js';
|
|
22
22
|
|
|
23
|
-
import
|
|
23
|
+
import initServer from './initServer.js';
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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;
|