@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
@@ -14,27 +14,21 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { logClientError } from '@lowdefy/api';
17
+ import { isSameOriginRequest, logClientError, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  async function clientErrorHandler(c) {
20
20
  if (c.req.method !== 'POST') {
21
- throw new Error('Only POST requests are supported.');
21
+ // A wrong-method request is client-caused: answer 405 rather than raising a
22
+ // fault that would be logged at error level and answered with a 500.
23
+ return c.json({ error: 'Method not allowed.' }, 405);
22
24
  }
23
25
  const context = c.get('lowdefyContext');
24
26
 
25
- const origin = c.req.header('origin');
26
- if (!origin) {
27
- return c.json({ error: 'Forbidden' }, 403);
28
- }
29
- try {
30
- if (new URL(origin).host !== c.req.header('host')) {
31
- return c.json({ error: 'Forbidden' }, 403);
32
- }
33
- } catch {
27
+ if (!isSameOriginRequest({ getHeader: (name) => c.req.header(name) })) {
34
28
  return c.json({ error: 'Forbidden' }, 403);
35
29
  }
36
30
 
37
- const body = await c.req.json();
31
+ const body = parseRequestBody({ text: await c.req.text() });
38
32
  // Strip received from payload — prod doesn't need it for schema validation
39
33
  if (body?.['~e']) {
40
34
  delete body['~e'].received;
@@ -0,0 +1,28 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ // @lowdefy/e2e-utils compares this with the build directory its config expects, so a
21
+ // Playwright run reuses a running server only when it serves this app's e2e build.
22
+ const buildDirectory = fs.realpathSync(path.join(process.cwd(), 'build'));
23
+
24
+ function e2eIdentityHandler(c) {
25
+ return c.json({ server: 'lowdefy-e2e', buildDirectory });
26
+ }
27
+
28
+ export default e2eIdentityHandler;
@@ -0,0 +1,43 @@
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 fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ import { Hono } from 'hono';
22
+ import { jest } from '@jest/globals';
23
+
24
+ test('GET /api/e2e/identity names the e2e server and the real path of its build directory', async () => {
25
+ const serverDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-e2e-identity-'));
26
+ fs.mkdirSync(path.join(serverDirectory, 'build'));
27
+ const linkedServerDirectory = `${serverDirectory}-link`;
28
+ fs.symlinkSync(serverDirectory, linkedServerDirectory, 'junction');
29
+ jest.spyOn(process, 'cwd').mockReturnValue(linkedServerDirectory);
30
+
31
+ const { default: e2eIdentityHandler } = await import('./e2eIdentity.js');
32
+ const app = new Hono();
33
+ app.get('/api/e2e/identity', e2eIdentityHandler);
34
+ const res = await app.request('/api/e2e/identity');
35
+
36
+ expect(res.status).toBe(200);
37
+ expect(await res.json()).toEqual({
38
+ server: 'lowdefy-e2e',
39
+ buildDirectory: fs.realpathSync(path.join(serverDirectory, 'build')),
40
+ });
41
+ fs.rmSync(linkedServerDirectory);
42
+ fs.rmSync(serverDirectory, { recursive: true });
43
+ });
@@ -14,17 +14,19 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { callEndpoint } from '@lowdefy/api';
17
+ import { callEndpoint, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  async function endpointsHandler(c) {
22
22
  if (c.req.method !== 'POST') {
23
- throw new Error('Only POST requests are supported.');
23
+ // A wrong-method request is client-caused: answer 405 rather than raising a
24
+ // fault that would be logged at error level and answered with a 500.
25
+ return c.json({ error: 'Method not allowed.' }, 405);
24
26
  }
25
27
  const context = c.get('lowdefyContext');
26
28
  const endpointId = getPathSegments(c, '/api/endpoints/').join('/');
27
- const { blockId, payload, pageId } = await c.req.json();
29
+ const { blockId, payload, pageId } = parseRequestBody({ text: await c.req.text() });
28
30
  context.logger.info({ event: 'call_api_endpoint', blockId, endpointId, pageId });
29
31
  const response = await callEndpoint(context, { blockId, endpointId, pageId, payload });
30
32
  return c.json(response);
@@ -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 getRequestPath from '../lib/getRequestPath.js';
18
+
19
+ // `/` (home) and `/404` are fixed routes ahead of the page matcher; every other
20
+ // path renders the page it matches. `.*`, not `.+`: under a basePath,
21
+ // `<basePath>/` arrives with an empty rest, and is the app root like
22
+ // `<basePath>` itself.
23
+ function mountPageRoutes({ app, basePath, renderPage }) {
24
+ app.get('/', (c) => renderPage(c, { path: '', matchedPath: '' }));
25
+ app.get('/404', (c) => renderPage(c, { path: '404', matchedPath: '404', status: 404 }));
26
+ app.get('/:rest{.*}', (c) => renderPage(c, getRequestPath({ c, basePath, prefix: '/' })));
27
+ }
28
+
29
+ export default mountPageRoutes;
@@ -0,0 +1,56 @@
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
+
19
+ import mountPageRoutes from './mountPageRoutes.js';
20
+
21
+ function request({ basePath, path }) {
22
+ const app = basePath === '' ? new Hono() : new Hono().basePath(basePath);
23
+ mountPageRoutes({
24
+ app,
25
+ basePath,
26
+ renderPage: (c, { path: pagePath, matchedPath, status = 200 }) =>
27
+ c.json({ path: pagePath, matchedPath }, status),
28
+ });
29
+ return app.request(path);
30
+ }
31
+
32
+ test.each([
33
+ ['', '/', '', ''],
34
+ ['/app', '/app', '', ''],
35
+ ['/app', '/app/', '', ''],
36
+ ['/app', '/app/invoices', 'invoices', 'invoices'],
37
+ ['', '/support/tickets/1234/', 'support/tickets/1234/', 'support/tickets/1234'],
38
+ ['', '/support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c'],
39
+ ['/app', '/app/tickets//1', 'tickets//1', 'tickets//1'],
40
+ ])(
41
+ 'with basePath "%s", %s renders path "%s" matched as "%s"',
42
+ async (basePath, url, path, matchedPath) => {
43
+ const res = await request({ basePath, path: url });
44
+ expect(res.status).toEqual(200);
45
+ expect(await res.json()).toEqual({ path, matchedPath });
46
+ }
47
+ );
48
+
49
+ test.each([[''], ['/app']])(
50
+ 'with basePath "%s", /404 is a fixed route with status 404',
51
+ async (basePath) => {
52
+ const res = await request({ basePath, path: `${basePath}/404` });
53
+ expect(res.status).toEqual(404);
54
+ expect(await res.json()).toEqual({ path: '404', matchedPath: '404' });
55
+ }
56
+ );
@@ -14,13 +14,15 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { callRequest } from '@lowdefy/api';
17
+ import { callRequest, parseRequestBody } from '@lowdefy/api';
18
18
 
19
19
  import getPathSegments from '../lib/getPathSegments.js';
20
20
 
21
21
  async function requestHandler(c) {
22
22
  if (c.req.method !== 'POST') {
23
- throw new Error('Only POST requests are supported.');
23
+ // A wrong-method request is client-caused: answer 405 rather than raising a
24
+ // fault that would be logged at error level and answered with a 500.
25
+ return c.json({ error: 'Method not allowed.' }, 405);
24
26
  }
25
27
  const context = c.get('lowdefyContext');
26
28
  const segments = getPathSegments(c, '/api/request/');
@@ -29,7 +31,7 @@ async function requestHandler(c) {
29
31
  }
30
32
  const requestId = segments[segments.length - 1];
31
33
  const pageId = segments.slice(0, -1).join('/');
32
- const { actionId, blockId, payload } = await c.req.json();
34
+ const { actionId, blockId, payload } = parseRequestBody({ text: await c.req.text() });
33
35
  context.logger.info({ event: 'call_request', pageId, requestId, blockId, actionId });
34
36
  const response = await callRequest(context, { blockId, pageId, payload, requestId });
35
37
  return c.json(response);
@@ -0,0 +1,69 @@
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
+ import { isSameOriginRequest, parseRequestBody } from '@lowdefy/api';
21
+
22
+ jest.unstable_mockModule('@lowdefy/api', () => ({
23
+ callEndpoint: jest.fn(),
24
+ callRequest: jest.fn(),
25
+ logClientError: jest.fn(),
26
+ isSameOriginRequest,
27
+ parseRequestBody,
28
+ redactErrorResponse: jest.fn(),
29
+ }));
30
+
31
+ const { default: createErrorHandler } = await import('../middleware/errorHandler.js');
32
+ const routes = {
33
+ '/api/client-error': (await import('./clientError.js')).default,
34
+ '/api/endpoints/*': (await import('./endpoints.js')).default,
35
+ '/api/request/*': (await import('./request.js')).default,
36
+ '/api/usage': (await import('./usage.js')).default,
37
+ };
38
+
39
+ const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() };
40
+
41
+ function createApp() {
42
+ const app = new Hono();
43
+ app.use('*', async (c, next) => {
44
+ c.set('lowdefyContext', { logger, handleError: jest.fn() });
45
+ await next();
46
+ });
47
+ Object.entries(routes).forEach(([path, handler]) => app.all(path, handler));
48
+ app.onError(createErrorHandler({ logger }));
49
+ return app;
50
+ }
51
+
52
+ test.each([
53
+ ['/api/endpoints/save', 'nope', {}, 'Request body is not valid JSON.'],
54
+ ['/api/endpoints/save', 'null', {}, 'Request body must be a JSON object.'],
55
+ ['/api/request/orders/load', 'nope', {}, 'Request body is not valid JSON.'],
56
+ ['/api/usage', 'nope', {}, 'Request body is not valid JSON.'],
57
+ [
58
+ '/api/client-error',
59
+ 'nope',
60
+ { host: 'localhost', origin: 'http://localhost' },
61
+ 'Request body is not valid JSON.',
62
+ ],
63
+ ])('POST %s with body %s answers 400 and logs a warning', async (path, body, headers, message) => {
64
+ const res = await createApp().request(path, { method: 'POST', body, headers });
65
+ expect(res.status).toBe(400);
66
+ expect(await res.json()).toEqual({ name: 'UserError', message });
67
+ expect(logger.warn).toHaveBeenCalledTimes(1);
68
+ expect(logger.error).not.toHaveBeenCalled();
69
+ });
@@ -16,7 +16,10 @@
16
16
 
17
17
  async function sessionMockHandler(c) {
18
18
  const context = c.get('lowdefyContext');
19
- return c.json(context.session ?? {});
19
+ if (!context.user) {
20
+ return c.json(null);
21
+ }
22
+ return c.json({ session: { id: 'e2e' }, user: context.user });
20
23
  }
21
24
 
22
25
  export default sessionMockHandler;
@@ -17,14 +17,18 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
+ import { parseRequestBody } from '@lowdefy/api';
21
+
20
22
  const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
21
23
 
22
24
  async function usageHandler(c) {
23
25
  if (c.req.method !== 'POST') {
24
- throw new Error('Only POST requests are supported.');
26
+ // A wrong-method request is client-caused: answer 405 rather than raising a
27
+ // fault that would be logged at error level and answered with a 500.
28
+ return c.json({ error: 'Method not allowed.' }, 405);
25
29
  }
26
30
  const context = c.get('lowdefyContext');
27
- const { user, machine } = await c.req.json();
31
+ const { user, machine } = parseRequestBody({ text: await c.req.text() });
28
32
  const host = c.req.header('host');
29
33
  context.logger.info({ event: 'log_usage', user, machine });
30
34
 
@@ -0,0 +1,27 @@
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 resolved caller for the client - the same resolveAuthentication output
18
+ // the first page load embeds. The base session carries neither roles (read
19
+ // live from the active member row) nor the merged attributes bag, so the
20
+ // client re-syncs from here (UpdateSession) after a session change such as
21
+ // SetActiveOrganization.
22
+ async function userHandler(c) {
23
+ const context = c.get('lowdefyContext');
24
+ return c.json({ user: context.user ?? null });
25
+ }
26
+
27
+ export default userHandler;
@@ -15,18 +15,26 @@
15
15
  */
16
16
 
17
17
  import { upgradeWebSocket } from '@hono/node-server';
18
- import { createChannelRegistry, createWebSocketConnection } from '@lowdefy/api';
18
+ import {
19
+ createChannelRegistry,
20
+ createWebSocketConnection,
21
+ isWebSocketOriginAllowed,
22
+ } from '@lowdefy/api';
19
23
 
20
24
  // One registry per server process — channels and their running source
21
25
  // resolvers are shared across all websocket connections on this instance.
22
26
  const registry = createChannelRegistry();
23
27
 
24
- const websocketHandler = upgradeWebSocket((c) => {
28
+ const upgrade = upgradeWebSocket((c) => {
25
29
  const context = c.get('lowdefyContext');
26
30
  let connection;
27
31
  return {
28
32
  onOpen(event, ws) {
33
+ // No auth engine runs in the e2e server, so the connection keeps the
34
+ // cookie caller it was opened with.
29
35
  connection = createWebSocketConnection(context, {
36
+ closeSocket: ({ code, reason }) => ws.close(code, reason),
37
+ headers: c.req.raw.headers,
30
38
  registry,
31
39
  send: (message) => ws.send(message),
32
40
  });
@@ -38,9 +46,21 @@ const websocketHandler = upgradeWebSocket((c) => {
38
46
  connection?.close();
39
47
  },
40
48
  onError(event) {
41
- context.logger.debug({ event: 'ws_error' }, String(event?.error ?? 'WebSocket error'));
49
+ // A transport fault, not routine traffic: visible at the default level with
50
+ // the error object attached so the cause is not reduced to a string.
51
+ context.logger.warn({ event: 'ws_error', err: event?.error }, 'WebSocket error.');
42
52
  },
43
53
  };
44
54
  });
45
55
 
56
+ // Upgrades are accepted from the app's own pages and from clients that send no
57
+ // Origin; isWebSocketOriginAllowed holds the rule and logs a refusal.
58
+ function websocketHandler(c, next) {
59
+ const context = c.get('lowdefyContext');
60
+ if (!isWebSocketOriginAllowed({ context, getHeader: (name) => c.req.header(name) })) {
61
+ return c.json({ error: 'Forbidden' }, 403);
62
+ }
63
+ return upgrade(c, next);
64
+ }
65
+
46
66
  export default websocketHandler;
@@ -0,0 +1,73 @@
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 { serve } from '@hono/node-server';
19
+ import WebSocket, { WebSocketServer } from 'ws';
20
+
21
+ import websocketHandler from './websocket.js';
22
+
23
+ let server;
24
+ let port;
25
+
26
+ // As src/index.js serves it: a Hono app under a basePath, with a ws server
27
+ // for upgrades.
28
+ beforeAll(async () => {
29
+ const app = new Hono().basePath('/app');
30
+ app.use('*', async (c, next) => {
31
+ c.set('lowdefyContext', {
32
+ config: {},
33
+ rid: 'request',
34
+ logger: { debug: () => {}, warn: () => {} },
35
+ });
36
+ await next();
37
+ });
38
+ app.get('/api/websocket', websocketHandler);
39
+ const wss = new WebSocketServer({ noServer: true });
40
+ await new Promise((resolve) => {
41
+ server = serve({ fetch: app.fetch, port: 0, websocket: { server: wss } }, (info) => {
42
+ port = info.port;
43
+ resolve();
44
+ });
45
+ });
46
+ });
47
+
48
+ afterAll(() => {
49
+ server.close();
50
+ });
51
+
52
+ function open({ headers }) {
53
+ return new Promise((resolve) => {
54
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/app/api/websocket`, { headers });
55
+ socket.once('open', () => {
56
+ socket.close();
57
+ resolve('open');
58
+ });
59
+ socket.once('unexpected-response', (_, response) => {
60
+ socket.terminate();
61
+ resolve(response.statusCode);
62
+ });
63
+ socket.once('error', () => {});
64
+ });
65
+ }
66
+
67
+ test.each([
68
+ ['opens', 'the page is served from the same host', { origin: 'https://app.test' }, 'open'],
69
+ ['opens', 'a client sends no origin', {}, 'open'],
70
+ ['is refused', 'the origin is another site', { origin: 'https://other.test' }, 403],
71
+ ])('a websocket upgrade to the e2e server %s when %s', async (_, __, headers, expected) => {
72
+ expect(await open({ headers: { host: 'app.test', ...headers } })).toEqual(expected);
73
+ });
package/vite.config.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import { defineConfig } from 'vite';
19
19
  import react from '@vitejs/plugin-react';
20
+ import { Features } from 'lightningcss';
20
21
 
21
22
  // basePath from the Lowdefy build — assets are served under it.
22
23
  let basePath = '';
@@ -27,14 +28,53 @@ try {
27
28
  // No build yet (e.g. editor tooling) — default base.
28
29
  }
29
30
 
31
+ // The @lowdefy packages this server depends on. A linked plugin pinned to
32
+ // another release brings its own copies, and the client would then mix
33
+ // versions: the dev server pre-bundles one copy per package for every
34
+ // importer (whichever its scan meets first, so the server's own code can get
35
+ // a plugin's older @lowdefy/helpers without an export it imports), and a
36
+ // production build bundles both, so blocks read module state the client never
37
+ // filled (the HTML enhancements it registers in @lowdefy/block-utils). The
38
+ // server's copies are the ones its client and blocks were released with.
39
+ const lowdefyDependencies = Object.keys(
40
+ JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dependencies ?? {}
41
+ ).filter((name) => name.startsWith('@lowdefy/'));
42
+
43
+ // Vite 8 tree-shakes a `.module.css` imported only for its side effects
44
+ // (`import './style.module.css'`), and its CSS goes with it: `:global(...)` rules in block and
45
+ // plugin stylesheets never reach the built client. Marking CSS modules as side-effectful keeps
46
+ // them, whatever the import style.
47
+ function keepCssModuleSideEffects() {
48
+ return {
49
+ name: 'lowdefy-keep-css-module-side-effects',
50
+ enforce: 'post',
51
+ transform(code, id) {
52
+ if (/\.module\.css($|\?)/.test(id)) {
53
+ return { code, moduleSideEffects: 'no-treeshake' };
54
+ }
55
+ },
56
+ };
57
+ }
58
+
30
59
  export default defineConfig(({ mode }) => ({
31
60
  base: `${basePath}/`,
32
- plugins: [react()],
61
+ plugins: [react(), keepCssModuleSideEffects()],
33
62
  define: {
34
63
  // Vite does not replace process.env.NODE_ENV inside dependencies —
35
64
  // plugin and client code branch on it.
36
65
  'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
37
66
  },
67
+ css: {
68
+ // Vite minifies client CSS with Lightning CSS, which by default lowers
69
+ // light-dark() into --lightningcss-light/--lightningcss-dark variables that
70
+ // only a CSS color-scheme declaration defines. The client sets color-scheme
71
+ // from JavaScript (useDarkMode), so the lowered values never resolve and
72
+ // every light-dark() colour is dropped. Tailwind leaves light-dark()
73
+ // unlowered in its own Lightning CSS pass for the same reason.
74
+ lightningcss: {
75
+ exclude: Features.LightDark,
76
+ },
77
+ },
38
78
  build: {
39
79
  outDir: 'dist/client',
40
80
  manifest: true,
@@ -42,4 +82,20 @@ export default defineConfig(({ mode }) => ({
42
82
  input: 'client/main.jsx',
43
83
  },
44
84
  },
85
+ resolve: {
86
+ // Linked plugin packages (pnpm link: / workspace) resolve their own copies of
87
+ // shared libraries. They must share one React, the antd/X/cssinjs copies
88
+ // whose context providers (App, ConfigProvider, StyleProvider) the client
89
+ // renders, the dayjs whose global locale the client sets, and the server's
90
+ // @lowdefy packages (see lowdefyDependencies).
91
+ dedupe: [
92
+ 'react',
93
+ 'react-dom',
94
+ 'antd',
95
+ '@ant-design/x',
96
+ '@ant-design/cssinjs',
97
+ 'dayjs',
98
+ ...lowdefyDependencies,
99
+ ],
100
+ },
45
101
  }));