@lowdefy/server-e2e 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/client/App.jsx +24 -14
  2. package/client/Page.jsx +53 -16
  3. package/client/getShownPage.js +28 -0
  4. package/client/getShownPage.test.mjs +52 -0
  5. package/lib/client/auth/Auth.jsx +30 -4
  6. package/lib/server/auth/{session.js → getUser.js} +8 -7
  7. package/lib/server/log/createLogger.js +3 -0
  8. package/lib/server/log/logRequest.js +1 -3
  9. package/lowdefy/build.mjs +1 -0
  10. package/lowdefy/build.test.mjs +65 -0
  11. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  12. package/lowdefy/createCustomPluginTypesMap.test.mjs +5 -0
  13. package/package.json +36 -35
  14. package/package.original.json +36 -35
  15. package/src/app.js +12 -5
  16. package/src/html/getAssets.js +3 -1
  17. package/src/html/getHomePath.js +29 -0
  18. package/src/html/renderPage.js +29 -17
  19. package/src/html/renderPage.test.mjs +162 -0
  20. package/src/html/template.js +16 -6
  21. package/src/index.js +23 -0
  22. package/src/lib/getRequestPath.js +29 -0
  23. package/src/middleware/apiContext.js +19 -3
  24. package/src/middleware/apiContext.test.mjs +14 -3
  25. package/src/middleware/errorHandler.js +63 -4
  26. package/src/middleware/errorHandler.test.mjs +402 -0
  27. package/src/routes/apiPage.js +14 -9
  28. package/src/routes/apiPage.test.mjs +92 -0
  29. package/src/routes/clientError.js +6 -12
  30. package/src/routes/e2eIdentity.js +28 -0
  31. package/src/routes/e2eIdentity.test.mjs +43 -0
  32. package/src/routes/endpoints.js +5 -3
  33. package/src/routes/mountPageRoutes.js +29 -0
  34. package/src/routes/mountPageRoutes.test.mjs +56 -0
  35. package/src/routes/request.js +5 -3
  36. package/src/routes/requestBody.test.mjs +69 -0
  37. package/src/routes/sessionMock.js +4 -1
  38. package/src/routes/usage.js +6 -2
  39. package/src/routes/user.js +27 -0
  40. package/src/routes/websocket.js +23 -3
  41. package/src/routes/websocket.test.mjs +73 -0
  42. package/vite.config.js +57 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/server-e2e",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy e2e testing server with cookie-based user injection",
6
6
  "homepage": "https://lowdefy.com",
@@ -49,56 +49,57 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@ant-design/cssinjs": "2.1.2",
52
- "@ant-design/x": "2.7.0",
52
+ "@ant-design/x": "2.9.0",
53
53
  "@hono/node-server": "2.0.10",
54
- "@lowdefy/actions-core": "6.1.0",
55
- "@lowdefy/api": "6.1.0",
56
- "@lowdefy/block-utils": "6.1.0",
57
- "@lowdefy/blocks-aggrid": "6.1.0",
58
- "@lowdefy/blocks-antd": "6.1.0",
59
- "@lowdefy/blocks-antd-x": "6.1.0",
60
- "@lowdefy/blocks-basic": "6.1.0",
61
- "@lowdefy/blocks-echarts": "6.1.0",
62
- "@lowdefy/blocks-loaders": "6.1.0",
63
- "@lowdefy/blocks-markdown": "6.1.0",
64
- "@lowdefy/blocks-tiptap": "6.1.0",
65
- "@lowdefy/client": "6.1.0",
66
- "@lowdefy/connection-axios-http": "6.1.0",
67
- "@lowdefy/connection-mongodb": "6.1.0",
68
- "@lowdefy/errors": "6.1.0",
69
- "@lowdefy/helpers": "6.1.0",
70
- "@lowdefy/layout": "6.1.0",
71
- "@lowdefy/logger": "6.1.0",
72
- "@lowdefy/node-utils": "6.1.0",
73
- "@lowdefy/operators-js": "6.1.0",
74
- "@lowdefy/operators-nunjucks": "6.1.0",
75
- "@lowdefy/operators-uuid": "6.1.0",
76
- "@lowdefy/websockets-core": "6.1.0",
77
- "@tailwindcss/postcss": "4.3.1",
54
+ "@lowdefy/actions-core": "7.0.0",
55
+ "@lowdefy/api": "7.0.0",
56
+ "@lowdefy/block-utils": "7.0.0",
57
+ "@lowdefy/blocks-aggrid": "7.0.0",
58
+ "@lowdefy/blocks-antd": "7.0.0",
59
+ "@lowdefy/blocks-antd-x": "7.0.0",
60
+ "@lowdefy/blocks-basic": "7.0.0",
61
+ "@lowdefy/blocks-captcha": "7.0.0",
62
+ "@lowdefy/blocks-echarts": "7.0.0",
63
+ "@lowdefy/blocks-loaders": "7.0.0",
64
+ "@lowdefy/blocks-markdown": "7.0.0",
65
+ "@lowdefy/blocks-table": "7.0.0",
66
+ "@lowdefy/blocks-tiptap": "7.0.0",
67
+ "@lowdefy/client": "7.0.0",
68
+ "@lowdefy/connection-axios-http": "7.0.0",
69
+ "@lowdefy/connection-mongodb": "7.0.0",
70
+ "@lowdefy/errors": "7.0.0",
71
+ "@lowdefy/helpers": "7.0.0",
72
+ "@lowdefy/layout": "7.0.0",
73
+ "@lowdefy/logger": "7.0.0",
74
+ "@lowdefy/node-utils": "7.0.0",
75
+ "@lowdefy/operators-js": "7.0.0",
76
+ "@lowdefy/operators-nunjucks": "7.0.0",
77
+ "@lowdefy/operators-uuid": "7.0.0",
78
+ "@lowdefy/websockets-core": "7.0.0",
79
+ "@tailwindcss/postcss": "4.3.3",
78
80
  "@vitejs/plugin-react": "6.0.2",
79
- "antd": "6.3.1",
81
+ "antd": "6.6.5",
80
82
  "dayjs": "1.11.20",
81
- "hono": "4.13.5",
82
- "pino": "10.3.1",
83
+ "hono": "4.13.7",
84
+ "lightningcss": "1.32.0",
83
85
  "react": "18.2.0",
84
86
  "react-dom": "18.2.0",
85
- "react-icons": "5.6.0",
86
- "tailwindcss": "4.3.1",
87
+ "tailwindcss": "4.3.3",
87
88
  "uuid": "14.0.0",
88
89
  "vite": "8.0.16",
89
90
  "ws": "8.21.0"
90
91
  },
91
92
  "devDependencies": {
92
93
  "@jest/globals": "28.1.3",
93
- "@lowdefy/build": "6.1.0",
94
- "@tailwindcss/postcss": "4.3.1",
94
+ "@lowdefy/build": "7.0.0",
95
+ "@tailwindcss/postcss": "4.3.3",
95
96
  "jest": "28.1.3",
96
- "tailwindcss": "4.3.1",
97
+ "tailwindcss": "4.3.3",
97
98
  "yaml": "2.9.0",
98
99
  "yargs": "18.0.0"
99
100
  },
100
101
  "engines": {
101
- "node": ">=22"
102
+ "node": ">=24"
102
103
  },
103
104
  "publishConfig": {
104
105
  "access": "public"
package/src/app.js CHANGED
@@ -17,18 +17,22 @@
17
17
  import { Hono } from 'hono';
18
18
  import { compress } from 'hono/compress';
19
19
  import { serveStatic } from '@hono/node-server/serve-static';
20
+ import { runInCredentialScope } from '@lowdefy/node-utils';
20
21
 
21
22
  import apiContext from './middleware/apiContext.js';
22
23
  import apiPageHandler from './routes/apiPage.js';
23
24
  import clientErrorHandler from './routes/clientError.js';
24
25
  import createErrorHandler from './middleware/errorHandler.js';
25
26
  import createLogger from '../lib/server/log/createLogger.js';
27
+ import e2eIdentityHandler from './routes/e2eIdentity.js';
26
28
  import endpointsHandler from './routes/endpoints.js';
29
+ import mountPageRoutes from './routes/mountPageRoutes.js';
27
30
  import lowdefyConfig from '../lib/build/config.js';
28
31
  import renderPage from './html/renderPage.js';
29
32
  import requestHandler from './routes/request.js';
30
33
  import sessionMockHandler from './routes/sessionMock.js';
31
34
  import usageHandler from './routes/usage.js';
35
+ import userHandler from './routes/user.js';
32
36
  import websocketHandler from './routes/websocket.js';
33
37
 
34
38
  const basePath = lowdefyConfig.basePath ?? '';
@@ -37,6 +41,10 @@ function createApp() {
37
41
  const app = basePath ? new Hono().basePath(basePath) : new Hono();
38
42
  const logger = createLogger({ server: 'lowdefy' });
39
43
 
44
+ // Each request runs in its own credential scope: what it marks with _credential is scrubbed
45
+ // from every log line written while it is handled.
46
+ app.use('*', (c, next) => runInCredentialScope(next));
47
+
40
48
  app.use('*', async (c, next) => {
41
49
  if (c.req.path.includes('/api/websocket')) {
42
50
  return next();
@@ -45,15 +53,16 @@ function createApp() {
45
53
  });
46
54
 
47
55
  app.use('/api/*', apiContext());
48
- // Mock session endpoint — reads the lowdefy_e2e_user cookie; also the
49
- // e2e harness health check (e2e-utils polls /api/auth/session).
56
+ // Mock session endpoint — reads the lowdefy_e2e_user cookie.
50
57
  app.get('/api/auth/session', sessionMockHandler);
58
+ app.get('/api/e2e/identity', e2eIdentityHandler);
51
59
  app.all('/api/request/*', requestHandler);
52
60
  app.all('/api/endpoints/*', endpointsHandler);
53
61
  app.all('/api/client-error', clientErrorHandler);
54
62
  app.all('/api/usage', usageHandler);
55
63
  app.get('/api/websocket', websocketHandler);
56
64
  app.get('/api/page/*', apiPageHandler);
65
+ app.get('/api/user', userHandler);
57
66
 
58
67
  // Vite build output (includes public/ via Vite's publicDir copy). Falls
59
68
  // through to the page routes when no file matches.
@@ -66,9 +75,7 @@ function createApp() {
66
75
  );
67
76
 
68
77
  app.use('/*', apiContext());
69
- app.get('/', (c) => renderPage(c, { pageId: '' }));
70
- app.get('/404', (c) => renderPage(c, { pageId: '404', status: 404 }));
71
- app.get('/:rest{.+}', (c) => renderPage(c, { pageId: c.req.param('rest') }));
78
+ mountPageRoutes({ app, basePath, renderPage });
72
79
 
73
80
  app.onError(createErrorHandler({ basePath, logger }));
74
81
 
@@ -17,6 +17,8 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
+ import { LowdefyInternalError } from '@lowdefy/errors';
21
+
20
22
  // The Vite manifest is read once at startup — deploys start a new server
21
23
  // process after `vite build` completes, so assets never change at runtime.
22
24
  // In-place deploys must build before restarting (documented).
@@ -30,7 +32,7 @@ function getAssets() {
30
32
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
31
33
  const entry = manifest['client/main.jsx'];
32
34
  if (!entry) {
33
- throw new Error(
35
+ throw new LowdefyInternalError(
34
36
  'Vite manifest has no "client/main.jsx" entry. Run the client build before starting the server.'
35
37
  );
36
38
  }
@@ -0,0 +1,29 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { buildPagePath } from '@lowdefy/helpers';
18
+
19
+ // The path (no leading "/", no basePath) of the home page: the configured
20
+ // homePageId, or the first menu link the user may see with its pathParams.
21
+ // The pattern comes from the route table, which lists every page whatever the
22
+ // caller may open.
23
+ async function getHomePath({ context, home }) {
24
+ const routes = await context.readConfigFile('routes.json');
25
+ const route = routes.find(({ pageId }) => pageId === home.pageId);
26
+ return buildPagePath({ pageId: home.pageId, path: route?.path, pathParams: home.pathParams });
27
+ }
28
+
29
+ export default getHomePath;
@@ -15,48 +15,55 @@
15
15
  */
16
16
 
17
17
  import { getPageConfig, getRootConfig } from '@lowdefy/api';
18
+ import { type } from '@lowdefy/helpers';
18
19
 
19
20
  import appJson from '../../lib/build/app.js';
20
21
  import lowdefyConfig from '../../lib/build/config.js';
21
22
  import themeConfig from '../../lib/build/theme.js';
22
23
  import getAssets from './getAssets.js';
24
+ import getHomePath from './getHomePath.js';
23
25
  import template from './template.js';
24
26
 
25
27
  const basePath = lowdefyConfig.basePath ?? '';
26
28
 
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 }) {
29
+ // Renders the page a request path matches. path (basePath and the leading "/"
30
+ // removed, still encoded) is empty for the app root, which serves the home
31
+ // page: the configured homePageId, else a redirect to the first menu link the
32
+ // user may see.
33
+ async function renderPage(c, { path, matchedPath, status = 200 }) {
30
34
  const context = c.get('lowdefyContext');
31
- const { logger, session } = context;
35
+ const { logger, user } = context;
32
36
 
33
- let resolvedPageId = pageId;
34
37
  const rootConfig = await getRootConfig(context);
35
38
 
36
- if (!resolvedPageId) {
39
+ let pagePath = path;
40
+ if (path === '') {
37
41
  const { home } = rootConfig;
42
+ pagePath = await getHomePath({ context, home });
38
43
  if (home.configured === false) {
39
- logger.info({ event: 'redirect_to_homepage', pageId: home.pageId });
40
- return c.redirect(`${basePath}/${home.pageId}`, 302);
44
+ logger.info({ event: 'redirect_to_homepage', pageId: home.pageId, path: pagePath });
45
+ return c.redirect(`${basePath}/${pagePath}`, 302);
41
46
  }
42
- resolvedPageId = home.pageId;
43
47
  }
44
48
 
45
- const pageConfig = await getPageConfig(context, {
46
- pageId: resolvedPageId,
49
+ const result = await getPageConfig(context, {
50
+ path: pagePath,
47
51
  urlQuery: c.req.query(),
48
52
  });
53
+ const { pageId, pathParams } = result;
49
54
 
50
- if (!pageConfig) {
51
- if (resolvedPageId === '404') {
55
+ if (result.status !== 'ok') {
56
+ if (pagePath === '404') {
52
57
  // No 404 page in the build — return a plain 404 rather than redirecting in a loop.
53
58
  return c.text('Page not found.', 404);
54
59
  }
55
- logger.info({ event: 'redirect_page_not_found', pageId: resolvedPageId });
60
+ logger.info({ event: 'redirect_page_not_found', pageId, path: pagePath });
56
61
  return c.redirect(`${basePath}/404`, 302);
57
62
  }
58
63
 
59
- logger.info({ event: 'page_view', pageId: resolvedPageId });
64
+ const { pageConfig } = result;
65
+
66
+ logger.info({ event: 'page_view', pageId, path: pagePath });
60
67
 
61
68
  const html = template({
62
69
  appendBody: appJson.html?.appendBody ?? '',
@@ -65,12 +72,17 @@ async function renderPage(c, { pageId, status = 200 }) {
65
72
  basePath,
66
73
  config: {
67
74
  basePath,
75
+ matchedPath,
68
76
  pageConfig,
77
+ pageId,
78
+ pathParams,
69
79
  rootConfig,
70
- session: session ?? null,
80
+ user: user ?? null,
71
81
  },
82
+ slug: rootConfig.lowdefyApp?.slug,
72
83
  themeConfig,
73
- title: pageConfig.properties?.title ?? resolvedPageId,
84
+ // A title written as an operator is evaluated on the client, which replaces this one.
85
+ title: type.isString(pageConfig.properties?.title) ? pageConfig.properties.title : pageId,
74
86
  });
75
87
 
76
88
  return c.html(html, status);
@@ -0,0 +1,162 @@
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
+ getPageConfig,
25
+ getRootConfig: mockGetRootConfig,
26
+ }));
27
+
28
+ jest.unstable_mockModule('../../lib/build/app.js', () => ({ default: {} }));
29
+ jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: { basePath: '' } }));
30
+ jest.unstable_mockModule('../../lib/build/theme.js', () => ({ default: {} }));
31
+ jest.unstable_mockModule('./getAssets.js', () => ({ default: () => ({}) }));
32
+ jest.unstable_mockModule('./template.js', () => ({
33
+ default: ({ config, title }) => JSON.stringify({ config, title }),
34
+ }));
35
+
36
+ const { default: renderPage } = await import('./renderPage.js');
37
+ const { default: mountPageRoutes } = await import('../routes/mountPageRoutes.js');
38
+
39
+ const routes = [
40
+ { pageId: '404', path: '404' },
41
+ { pageId: 'home', path: 'home' },
42
+ { pageId: 'state-title', path: 'state-title' },
43
+ { pageId: 'ticket', path: '{space}/tickets/{ticket_id}' },
44
+ { pageId: 'section', path: '{section}' },
45
+ ];
46
+
47
+ const pages = {
48
+ 404: { id: '404' },
49
+ home: { id: 'home' },
50
+ 'state-title': { id: 'state-title', properties: { title: { _state: 'title' } } },
51
+ ticket: { id: 'ticket', properties: { title: 'Ticket' } },
52
+ section: { id: 'section' },
53
+ };
54
+
55
+ function createApp({
56
+ outcome = 'allow',
57
+ pagesProtectedByDefault = false,
58
+ routeTable = routes,
59
+ user = { id: 'u1' },
60
+ } = {}) {
61
+ const app = new Hono();
62
+ app.use('*', async (c, next) => {
63
+ c.set('lowdefyContext', {
64
+ authEnforcement: { pagesProtectedByDefault },
65
+ authorizeOutcome: () => outcome,
66
+ logger: { info: jest.fn() },
67
+ readConfigFile: async (file) => {
68
+ if (file === 'routes.json') return routeTable;
69
+ return pages[file.slice('pages/'.length, -'.json'.length)] ?? null;
70
+ },
71
+ user,
72
+ });
73
+ await next();
74
+ });
75
+ mountPageRoutes({ app, basePath: '', renderPage });
76
+ return app;
77
+ }
78
+
79
+ beforeEach(() => {
80
+ mockGetRootConfig.mockResolvedValue({
81
+ home: { configured: true, pageId: 'home', pathParams: {} },
82
+ });
83
+ });
84
+
85
+ async function readConfig(res) {
86
+ return JSON.parse(await res.text()).config;
87
+ }
88
+
89
+ test('renderPage embeds pageId, pathParams and matchedPath for a patterned page', async () => {
90
+ const res = await createApp().request('/support/tickets/1234');
91
+ expect(res.status).toEqual(200);
92
+ const config = await readConfig(res);
93
+ expect(config.pageId).toEqual('ticket');
94
+ expect(config.pathParams).toEqual({ space: 'support', ticket_id: '1234' });
95
+ expect(config.matchedPath).toEqual('support/tickets/1234');
96
+ expect(config.pageConfig).toEqual({ id: 'ticket', properties: { title: 'Ticket' } });
97
+ });
98
+
99
+ test('renderPage decodes an encoded "/" in a value once and keeps matchedPath encoded', async () => {
100
+ const res = await createApp().request('/support/tickets/a%2Fb/');
101
+ const config = await readConfig(res);
102
+ expect(config.pathParams).toEqual({ space: 'support', ticket_id: 'a/b' });
103
+ expect(config.matchedPath).toEqual('support/tickets/a%2Fb');
104
+ });
105
+
106
+ test('renderPage redirects an unmatched path to /404', async () => {
107
+ const res = await createApp().request('/support/tickets');
108
+ expect(res.status).toEqual(302);
109
+ expect(res.headers.get('location')).toEqual('/404');
110
+ });
111
+
112
+ test('renderPage serves a page without path at its id', async () => {
113
+ const res = await createApp().request('/home');
114
+ const config = await readConfig(res);
115
+ expect(config.pageId).toEqual('home');
116
+ expect(config.pathParams).toEqual({});
117
+ expect(config.matchedPath).toEqual('home');
118
+ });
119
+
120
+ test('renderPage serves the configured home page at /', async () => {
121
+ const res = await createApp().request('/');
122
+ expect(res.status).toEqual(200);
123
+ const config = await readConfig(res);
124
+ expect(config.pageId).toEqual('home');
125
+ expect(config.matchedPath).toEqual('');
126
+ });
127
+
128
+ test('renderPage redirects / to the home menu link built from its pathParams', async () => {
129
+ mockGetRootConfig.mockResolvedValue({
130
+ home: {
131
+ configured: false,
132
+ pageId: 'ticket',
133
+ pathParams: { space: 'support', ticket_id: '12 34' },
134
+ },
135
+ });
136
+ const res = await createApp().request('/');
137
+ expect(res.status).toEqual(302);
138
+ expect(res.headers.get('location')).toEqual('/support/tickets/12%2034');
139
+ });
140
+
141
+ test('renderPage serves /404 as a fixed route with status 404', async () => {
142
+ const res = await createApp().request('/404');
143
+ expect(res.status).toEqual(404);
144
+ const config = await readConfig(res);
145
+ expect(config.pageId).toEqual('404');
146
+ });
147
+
148
+ test('renderPage returns a plain 404 when the build has no 404 page', async () => {
149
+ const res = await createApp({ routeTable: [{ pageId: 'home', path: 'home' }] }).request('/404');
150
+ expect(res.status).toEqual(404);
151
+ expect(await res.text()).toEqual('Page not found.');
152
+ });
153
+
154
+ test('renderPage serves the page title written as a string', async () => {
155
+ const res = await createApp().request('/support/tickets/1234');
156
+ expect(JSON.parse(await res.text()).title).toEqual('Ticket');
157
+ });
158
+
159
+ test('renderPage serves the page id as the title when the title is an operator', async () => {
160
+ const res = await createApp().request('/state-title');
161
+ expect(JSON.parse(await res.text()).title).toEqual('state-title');
162
+ });
@@ -14,6 +14,8 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { appStorageKey } from '@lowdefy/helpers';
18
+
17
19
  import safeScriptJson from '../lib/safeScriptJson.js';
18
20
 
19
21
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
@@ -33,13 +35,15 @@ const layerOrderScript = `(function(){var s=document.createElement("style");s.id
33
35
  // Synchronous pre-hydration script setting the <html> background and
34
36
  // color-scheme — prevents a mode-mismatch flash of the page and of browser
35
37
  // chrome (scrollbars, form controls) on page load. Mirrors useDarkMode.js
36
- // resolution order: configDarkMode → localStorage → prefers-color-scheme.
37
- function darkModeScript({ configColorMode, darkBg, lightBg }) {
38
+ // resolution order: configDarkMode → localStorage → prefers-color-scheme. The preference is
39
+ // kept under the app's storage key; a value under the bare key from before keys were kept per
40
+ // app is read until useDarkMode moves it.
41
+ function darkModeScript({ configColorMode, darkBg, lightBg, storageKey }) {
38
42
  return `(function(){var c=${safeScriptJson(configColorMode)};var db=${safeScriptJson(
39
43
  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})();`;
44
+ )};var lb=${safeScriptJson(lightBg)};var k=${safeScriptJson(
45
+ storageKey
46
+ )};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
47
  }
44
48
 
45
49
  function template({
@@ -48,6 +52,7 @@ function template({
48
52
  assets,
49
53
  basePath = '',
50
54
  config,
55
+ slug,
51
56
  themeConfig = {},
52
57
  title,
53
58
  }) {
@@ -73,7 +78,12 @@ function template({
73
78
  <head>
74
79
  <meta charset="utf-8" />
75
80
  <script>${layerOrderScript}</script>
76
- <script>${darkModeScript({ configColorMode, darkBg, lightBg })}</script>
81
+ <script>${darkModeScript({
82
+ configColorMode,
83
+ darkBg,
84
+ lightBg,
85
+ storageKey: appStorageKey({ basePath, name: 'lowdefy_darkMode', slug }),
86
+ })}</script>
77
87
  <link rel="manifest" href="${basePath}/manifest.webmanifest" />
78
88
  <link rel="icon" type="image/svg+xml" href="${basePath}/icon.svg" />
79
89
  <link rel="apple-touch-icon" href="${basePath}/apple-touch-icon.png" />
package/src/index.js CHANGED
@@ -17,6 +17,9 @@
17
17
  import { serve } from '@hono/node-server';
18
18
  import { WebSocketServer } from 'ws';
19
19
 
20
+ import registerServer from '@lowdefy/node-utils/registerServer.js';
21
+ import watchOwner from '@lowdefy/node-utils/watchOwner.js';
22
+
20
23
  import createApp from './app.js';
21
24
  import createLogger from '../lib/server/log/createLogger.js';
22
25
 
@@ -28,13 +31,33 @@ const wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
28
31
 
29
32
  const server = serve({ fetch: app.fetch, port, websocket: { server: wss } }, (info) => {
30
33
  logger.info(`Lowdefy e2e server listening on http://localhost:${info.port}`);
34
+ // Only when the CLI asks (it sets the registry directory).
35
+ registerServer({
36
+ kind: 'server',
37
+ port: info.port,
38
+ configDirectory: process.env.LOWDEFY_DIRECTORY_CONFIG,
39
+ logger,
40
+ });
31
41
  });
32
42
 
43
+ let shuttingDown = false;
33
44
  function shutdown() {
45
+ if (shuttingDown) {
46
+ return;
47
+ }
48
+ shuttingDown = true;
49
+ server.closeIdleConnections?.();
34
50
  server.close(() => {
35
51
  process.exit(0);
36
52
  });
53
+ // A test browser holding a connection open must not keep the server alive.
54
+ setTimeout(() => process.exit(1), 5000).unref();
37
55
  }
38
56
 
39
57
  process.on('SIGINT', shutdown);
40
58
  process.on('SIGTERM', shutdown);
59
+ process.on('SIGHUP', shutdown);
60
+
61
+ // A server started by a test runner stops when that runner stops, however it
62
+ // ends. Inert unless the spawner set its variables.
63
+ watchOwner({ onExit: shutdown });
@@ -0,0 +1,29 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The rest of the request path after basePath and prefix, still encoded: the
18
+ // page matcher decodes each segment once, and Hono's c.req.path is partly
19
+ // decoded, so it is taken from the request URL. path goes to the matcher as it
20
+ // arrived; matchedPath is path with one trailing "/" removed, the form the
21
+ // client compares its navigations with.
22
+ function getRequestPath({ c, basePath, prefix }) {
23
+ const pathname = new URL(c.req.url).pathname.slice(basePath.length);
24
+ const path = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : '';
25
+ const matchedPath = path.endsWith('/') ? path.slice(0, -1) : path;
26
+ return { path, matchedPath };
27
+ }
28
+
29
+ export default getRequestPath;
@@ -15,23 +15,25 @@
15
15
  */
16
16
 
17
17
  import path from 'node:path';
18
- import { createApiContext } from '@lowdefy/api';
18
+ import { createApiContext, createRequestSignal, normalizeInjectedCaller } from '@lowdefy/api';
19
19
  import { v4 as uuid } from 'uuid';
20
20
 
21
21
  import agents from '../../build/plugins/agents.js';
22
22
  import appMeta from '../../lib/build/appMeta.js';
23
+ import authJson from '../../lib/build/auth.js';
23
24
  import config from '../../lib/build/config.js';
24
25
  import connections from '../../build/plugins/connections.js';
25
26
  import createHandleError from '../../lib/server/log/createHandleError.js';
26
27
  import createLogger from '../../lib/server/log/createLogger.js';
27
28
  import fileCache from '../../lib/server/fileCache.js';
28
29
  import getE2eSecrets from '../../lib/server/getE2eSecrets.js';
29
- import getSession from '../../lib/server/auth/session.js';
30
+ import getUser from '../../lib/server/auth/getUser.js';
30
31
  import i18nConfig from '../../lib/build/i18n.js';
31
32
  import jsMap from '../../build/plugins/operators/serverJsMap.js';
32
33
  import logRequest from '../../lib/server/log/logRequest.js';
33
34
  import operators from '../../build/plugins/operators/server.js';
34
35
  import scrubSecrets from '../../lib/server/scrubSecrets.js';
36
+ import steps from '../../build/plugins/steps.js';
35
37
  import websockets from '../../build/plugins/websockets.js';
36
38
 
37
39
  const secrets = getE2eSecrets();
@@ -70,11 +72,25 @@ function apiContext() {
70
72
  },
71
73
  scrubSecrets,
72
74
  secrets,
75
+ // Aborts when the client disconnects before the response is sent.
76
+ signal: createRequestSignal({ clientSignal: c.req.raw.signal }),
77
+ steps,
73
78
  websockets,
74
79
  };
75
80
  context.handleError = createHandleError({ context });
76
- context.session = getSession(c);
81
+ // The cookie user is a pre-resolved caller substituting for
82
+ // resolveAuthentication - normalizeInjectedCaller floors it to the
83
+ // resolved-caller shape. An absent cookie stays a logged-out (null) caller.
84
+ const user = getUser(c);
85
+ context.user = user ? normalizeInjectedCaller(user) : null;
77
86
  createApiContext(context);
87
+ // No auth engine runs in the e2e server, so createApiContext retains no
88
+ // organization binding. Derive the policy from the built auth config so
89
+ // the tenant wall's policy gate still engages for a tenant app; pinned
90
+ // stays null (no engine means no seeded organization row).
91
+ if (authJson.organizations) {
92
+ context.organization = { policy: authJson.organizations.policy, pinned: null };
93
+ }
78
94
  logRequest({ context });
79
95
  c.set('lowdefyContext', context);
80
96
  return next();