@lowdefy/server-dev 5.5.1 → 6.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 (214) hide show
  1. package/{pages/_app.js → client/App.jsx} +21 -33
  2. package/client/DevStreamContext.js +30 -0
  3. package/client/Inspector.jsx +226 -0
  4. package/{lib/client/Page.js → client/Page.jsx} +4 -8
  5. package/client/Reload.jsx +85 -0
  6. package/client/Routing.jsx +117 -0
  7. package/client/feedback/FeedbackMount.jsx +83 -0
  8. package/client/feedback/FeedbackOverlay.jsx +901 -0
  9. package/client/feedback/captureTabScreenshot.js +84 -0
  10. package/client/feedback/copyToClipboard.js +45 -0
  11. package/client/feedback/drawAnnotationsSvg.js +119 -0
  12. package/client/feedback/elementInspect.js +99 -0
  13. package/client/feedback/feedbackStyles.js +301 -0
  14. package/client/feedback/pinResponsiveImages.js +59 -0
  15. package/client/feedback/sendFeedback.js +39 -0
  16. package/client/feedback/useFeedbackToggle.js +70 -0
  17. package/client/main.jsx +42 -0
  18. package/client/openInEditor/OpenInEditorListener.jsx +268 -0
  19. package/client/openInEditor/findBlockLocation.js +51 -0
  20. package/lib/build/app.js +8 -1
  21. package/lib/build/appMeta.js +8 -1
  22. package/lib/build/auth.js +8 -1
  23. package/lib/build/config.js +8 -1
  24. package/lib/build/i18n.js +8 -1
  25. package/lib/build/logger.js +8 -1
  26. package/lib/build/theme.js +8 -1
  27. package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
  28. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +23 -10
  29. package/lib/client/setPageId.js +6 -5
  30. package/lib/client/utils/useMutateCache.js +14 -1
  31. package/lib/client/utils/usePageConfig.js +45 -43
  32. package/lib/client/utils/usePageConfig.test.mjs +62 -0
  33. package/lib/docs/captureAnnotatedScreenshot.js +88 -0
  34. package/lib/docs/checkpointPaths.js +91 -0
  35. package/lib/docs/checkpointStore.js +240 -0
  36. package/lib/docs/checkpointToMocks.js +65 -0
  37. package/lib/docs/clientErrorStore.js +36 -0
  38. package/lib/docs/configCheckpoints.test.mjs +156 -0
  39. package/lib/docs/createConfigCheckpoint.js +92 -0
  40. package/lib/docs/createDocsMcpServer.js +531 -0
  41. package/lib/docs/createDocsMcpServer.test.mjs +141 -0
  42. package/lib/docs/devMockRegistry.js +68 -0
  43. package/lib/docs/docs.test.mjs +448 -0
  44. package/lib/docs/enrichFeedback.js +71 -0
  45. package/lib/docs/evalOperator.js +47 -0
  46. package/lib/docs/evalOperator.test.mjs +157 -0
  47. package/lib/docs/evalOperatorHeadless.js +87 -0
  48. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  49. package/lib/docs/evalOperatorInTab.js +48 -0
  50. package/lib/docs/findConfig.js +260 -0
  51. package/lib/docs/formatFeedback.js +121 -0
  52. package/lib/docs/formatFeedback.test.mjs +204 -0
  53. package/lib/docs/getAppMap.js +180 -0
  54. package/lib/docs/getBrowser.js +127 -0
  55. package/lib/docs/getBrowser.test.mjs +122 -0
  56. package/lib/docs/getBuildStatus.js +37 -0
  57. package/lib/docs/getCoreDoc.js +63 -0
  58. package/lib/docs/getDocsManifest.js +40 -0
  59. package/lib/docs/getExamples.js +58 -0
  60. package/lib/docs/getOverview.js +115 -0
  61. package/lib/docs/getPageConfig.js +53 -0
  62. package/lib/docs/getPluginDoc.js +55 -0
  63. package/lib/docs/getSchema.js +65 -0
  64. package/lib/docs/inspectState.js +47 -0
  65. package/lib/docs/inspectState.test.mjs +140 -0
  66. package/lib/docs/inspectStateFromTab.js +40 -0
  67. package/lib/docs/inspectStateHeadless.js +84 -0
  68. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  69. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  70. package/lib/docs/listConfigCheckpoints.js +55 -0
  71. package/lib/docs/listPlugins.js +80 -0
  72. package/lib/docs/listTypes.js +105 -0
  73. package/lib/docs/loadState.js +152 -0
  74. package/lib/docs/mapPageBuildErrors.js +28 -0
  75. package/lib/docs/normalizeTypeKind.js +40 -0
  76. package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
  77. package/lib/docs/readPageArtifact.js +26 -0
  78. package/lib/docs/resolvePluginDir.js +49 -0
  79. package/lib/docs/resolveSource.js +48 -0
  80. package/lib/docs/resolveSource.test.mjs +100 -0
  81. package/lib/docs/revertConfigCheckpoint.js +73 -0
  82. package/lib/docs/runRequest.js +141 -0
  83. package/lib/docs/saveAnnotatedScreenshot.js +43 -0
  84. package/lib/docs/scaffoldPage.js +75 -0
  85. package/lib/docs/scaffoldPage.test.mjs +97 -0
  86. package/lib/docs/screenshotPage.js +110 -0
  87. package/lib/docs/screenshotPage.test.mjs +59 -0
  88. package/lib/docs/screenshotPageClip.test.mjs +102 -0
  89. package/lib/docs/searchDocs.js +67 -0
  90. package/lib/docs/setupTestFixtures.mjs +335 -0
  91. package/lib/docs/snapshotState.js +59 -0
  92. package/lib/docs/stateCheckpoints.test.mjs +366 -0
  93. package/lib/docs/tabAvailable.js +28 -0
  94. package/lib/docs/tabChannel.js +119 -0
  95. package/lib/docs/tabChannel.test.mjs +130 -0
  96. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
  97. package/lib/server/auth/getDevSession.js +70 -0
  98. package/lib/server/auth/getDevSession.test.mjs +138 -0
  99. package/lib/server/auth/getHeadlessUser.js +40 -0
  100. package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
  101. package/lib/server/auth/headlessUser.js +29 -0
  102. package/lib/server/auth/resolveHeadlessUser.js +38 -0
  103. package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
  104. package/lib/server/auth/session.js +39 -0
  105. package/lib/server/auth/strategies.js +47 -0
  106. package/lib/server/{apiWrapper.js → createLowdefyContext.js} +63 -57
  107. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  108. package/lib/server/jitPageBuilder.js +125 -12
  109. package/lib/server/log/createHandleError.js +9 -0
  110. package/lib/server/log/createHandleError.test.mjs +126 -0
  111. package/manager/getContext.mjs +3 -7
  112. package/manager/processes/initialBuild.mjs +0 -2
  113. package/manager/processes/installPlugins.mjs +19 -11
  114. package/manager/processes/lowdefyBuild.mjs +29 -7
  115. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  116. package/manager/processes/readDotEnv.mjs +13 -1
  117. package/manager/processes/shutdownServer.mjs +6 -6
  118. package/manager/processes/startProxy.mjs +160 -0
  119. package/manager/processes/startProxy.test.mjs +102 -0
  120. package/manager/processes/startServer.mjs +33 -16
  121. package/manager/processes/startWatchers.mjs +2 -2
  122. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  123. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  124. package/manager/run.mjs +50 -0
  125. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  126. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  127. package/manager/utils/formatNoticeBox.mjs +30 -0
  128. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  129. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  130. package/manager/utils/writeBuildStatus.mjs +39 -0
  131. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  132. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  133. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  134. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  135. package/package.json +61 -47
  136. package/package.original.json +64 -55
  137. package/postcss.config.cjs +1 -0
  138. package/src/app.js +175 -0
  139. package/src/html/renderDevPage.js +86 -0
  140. package/src/lib/getPathSegments.js +25 -0
  141. package/src/lib/safeScriptJson.js +38 -0
  142. package/src/middleware/apiContext.js +32 -0
  143. package/src/middleware/errorHandler.js +48 -0
  144. package/src/middleware/errorHandler.test.mjs +235 -0
  145. package/src/routes/agent.js +64 -0
  146. package/src/routes/apiPage.js +37 -0
  147. package/src/routes/auth.js +49 -0
  148. package/src/routes/auth.test.mjs +84 -0
  149. package/src/routes/clientError.js +57 -0
  150. package/src/routes/cron.js +47 -0
  151. package/src/routes/cronForward.js +47 -0
  152. package/src/routes/detached.js +47 -0
  153. package/src/routes/devInspect.js +111 -0
  154. package/src/routes/devInspect.test.mjs +119 -0
  155. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  156. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  157. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  158. package/src/routes/docs/checkpointsCreate.js +28 -0
  159. package/src/routes/docs/checkpointsList.js +23 -0
  160. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  161. package/src/routes/docs/content.js +31 -0
  162. package/src/routes/docs/evalOperator.js +56 -0
  163. package/src/routes/docs/examples.js +33 -0
  164. package/src/routes/docs/find.js +30 -0
  165. package/src/routes/docs/index.js +23 -0
  166. package/src/routes/docs/inspectState.js +44 -0
  167. package/src/routes/docs/loadState.js +49 -0
  168. package/src/routes/docs/mcp.js +30 -0
  169. package/src/routes/docs/pageConfig.js +36 -0
  170. package/src/routes/docs/parseUserParam.js +52 -0
  171. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  172. package/src/routes/docs/pluginDoc.js +33 -0
  173. package/src/routes/docs/plugins.js +23 -0
  174. package/src/routes/docs/runRequest.js +36 -0
  175. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  176. package/src/routes/docs/screenshot.js +72 -0
  177. package/src/routes/docs/search.js +27 -0
  178. package/src/routes/docs/snapshotState.js +36 -0
  179. package/src/routes/docs/stateCheckpointsList.js +26 -0
  180. package/src/routes/docs/types.js +33 -0
  181. package/src/routes/endpoints.js +71 -0
  182. package/src/routes/feedback.js +110 -0
  183. package/src/routes/feedback.test.mjs +204 -0
  184. package/src/routes/jitPage.js +86 -0
  185. package/src/routes/mcp.js +34 -0
  186. package/{pages/api → src/routes}/ping.js +4 -2
  187. package/src/routes/reload.js +79 -0
  188. package/src/routes/request.js +74 -0
  189. package/src/routes/request.test.mjs +124 -0
  190. package/{pages/api → src/routes}/root.js +4 -4
  191. package/src/routes/usage.js +46 -0
  192. package/src/routes/websocket.js +33 -0
  193. package/src/websocket/devWebSocket.js +78 -0
  194. package/vite.config.js +111 -0
  195. package/.eslintrc.yaml +0 -1
  196. package/lib/client/App.js +0 -80
  197. package/lib/client/Reload.js +0 -60
  198. package/lib/server/compileCss.js +0 -39
  199. package/manager/processes/compileCss.mjs +0 -54
  200. package/manager/processes/nextBuild.mjs +0 -50
  201. package/next.config.js +0 -25
  202. package/pages/_document.js +0 -107
  203. package/pages/api/agent/[...path].js +0 -86
  204. package/pages/api/auth/[...nextauth].js +0 -48
  205. package/pages/api/client-error.js +0 -45
  206. package/pages/api/js/[env].js +0 -42
  207. package/pages/api/page/[...pageId].js +0 -74
  208. package/pages/api/reload.js +0 -53
  209. package/pages/api/request/[...path].js +0 -41
  210. /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
  211. /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
  212. /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
  213. /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
  214. /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
@@ -14,21 +14,29 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { spawnProcess } from '@lowdefy/node-utils';
17
+ import { installIfPackageJsonChanged, spawnProcess } from '@lowdefy/node-utils';
18
18
 
19
- function installPlugins({ logger, packageManagerCmd }) {
19
+ function installPlugins({ directories, logger, packageManagerCmd }) {
20
20
  return async () => {
21
- logger.info({ spin: 'start' }, 'Installing plugins...');
22
- await spawnProcess({
23
- processOptions: {
24
- // https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2#command-injection-via-args-parameter-of-child_processspawn-without-shell-option-enabled-on-windows-cve-2024-27980---high
25
- shell: process.platform === 'win32',
21
+ const installed = await installIfPackageJsonChanged({
22
+ directory: directories.server,
23
+ install: async () => {
24
+ logger.info({ spin: 'start' }, 'Installing plugins...');
25
+ await spawnProcess({
26
+ processOptions: {
27
+ // https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2#command-injection-via-args-parameter-of-child_processspawn-without-shell-option-enabled-on-windows-cve-2024-27980---high
28
+ shell: process.platform === 'win32',
29
+ },
30
+ command: packageManagerCmd,
31
+ args: ['install', '--no-frozen-lockfile'],
32
+ stdOutLineHandler: (line) => logger.debug(line),
33
+ });
34
+ logger.info('Installed plugins.');
26
35
  },
27
- command: packageManagerCmd,
28
- args: ['install', '--no-frozen-lockfile'],
29
- stdOutLineHandler: (line) => logger.debug(line),
30
36
  });
31
- logger.info('Installed plugins.');
37
+ if (!installed) {
38
+ logger.debug('Plugin dependencies unchanged, skipped install.');
39
+ }
32
40
  };
33
41
  }
34
42
 
@@ -14,9 +14,10 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { shallowBuild } from '@lowdefy/build/dev';
17
+ import { serializeBuildException, shallowBuild } from '@lowdefy/build/dev';
18
18
  import createCustomPluginMessagesMap from '../utils/createCustomPluginMessagesMap.mjs';
19
19
  import createCustomPluginTypesMap from '../utils/createCustomPluginTypesMap.mjs';
20
+ import writeBuildStatus from '../utils/writeBuildStatus.mjs';
20
21
 
21
22
  function formatDuration(ms) {
22
23
  if (ms < 1000) return `${ms}ms`;
@@ -30,13 +31,34 @@ function lowdefyBuild({ directories, logger, options }) {
30
31
  const customTypesMap = await createCustomPluginTypesMap({ directories, logger });
31
32
  const customMessagesMap = await createCustomPluginMessagesMap({ directories, logger });
32
33
 
33
- const result = await shallowBuild({
34
- customMessagesMap,
35
- customTypesMap,
34
+ let result;
35
+ try {
36
+ result = await shallowBuild({
37
+ customMessagesMap,
38
+ customTypesMap,
39
+ directories,
40
+ logger,
41
+ refResolver: options.refResolver,
42
+ stage: 'dev',
43
+ });
44
+ } catch (error) {
45
+ // Write the artifact after the attempt fails - the build may have
46
+ // cleaned the build directory before failing, so this must not run
47
+ // before the attempt completes.
48
+ await writeBuildStatus({
49
+ directories,
50
+ status: 'error',
51
+ errors: error.errors ?? [{ message: error.message }],
52
+ warnings: error.warnings ?? [],
53
+ });
54
+ throw error;
55
+ }
56
+
57
+ await writeBuildStatus({
36
58
  directories,
37
- logger,
38
- refResolver: options.refResolver,
39
- stage: 'dev',
59
+ status: 'ok',
60
+ errors: [],
61
+ warnings: (result.context.warnings ?? []).map(serializeBuildException),
40
62
  });
41
63
 
42
64
  // Return result so getContext can store registries
@@ -0,0 +1,102 @@
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 { jest } from '@jest/globals';
18
+
19
+ const mockShallowBuild = jest.fn();
20
+ const mockSerializeBuildException = jest.fn((exception) => ({ message: exception.message }));
21
+ const mockWriteBuildStatus = jest.fn();
22
+ const mockCreateCustomPluginTypesMap = jest.fn().mockResolvedValue({});
23
+ const mockCreateCustomPluginMessagesMap = jest.fn().mockResolvedValue({});
24
+
25
+ jest.unstable_mockModule('@lowdefy/build/dev', () => ({
26
+ shallowBuild: mockShallowBuild,
27
+ serializeBuildException: mockSerializeBuildException,
28
+ }));
29
+ jest.unstable_mockModule('../utils/writeBuildStatus.mjs', () => ({
30
+ default: mockWriteBuildStatus,
31
+ }));
32
+ jest.unstable_mockModule('../utils/createCustomPluginTypesMap.mjs', () => ({
33
+ default: mockCreateCustomPluginTypesMap,
34
+ }));
35
+ jest.unstable_mockModule('../utils/createCustomPluginMessagesMap.mjs', () => ({
36
+ default: mockCreateCustomPluginMessagesMap,
37
+ }));
38
+
39
+ const { default: lowdefyBuild } = await import('./lowdefyBuild.mjs');
40
+
41
+ function createContext() {
42
+ return {
43
+ directories: { build: '/app/build' },
44
+ logger: { info: jest.fn() },
45
+ options: { refResolver: undefined },
46
+ };
47
+ }
48
+
49
+ test('lowdefyBuild writes an ok buildStatus.json after a successful build', async () => {
50
+ const context = createContext();
51
+ const warning = { message: 'Deprecated feature used' };
52
+ mockShallowBuild.mockResolvedValue({
53
+ components: {},
54
+ pageRegistry: {},
55
+ context: { warnings: [warning] },
56
+ });
57
+
58
+ const build = lowdefyBuild(context);
59
+ await build();
60
+
61
+ expect(mockWriteBuildStatus).toHaveBeenCalledWith({
62
+ directories: context.directories,
63
+ status: 'ok',
64
+ errors: [],
65
+ warnings: [{ message: 'Deprecated feature used' }],
66
+ });
67
+ expect(mockSerializeBuildException).toHaveBeenCalledWith(warning, 0, [warning]);
68
+ });
69
+
70
+ test('lowdefyBuild writes an error buildStatus.json and rethrows when the build fails', async () => {
71
+ const context = createContext();
72
+ const buildError = new Error('Build failed with 1 error(s). See above for details.');
73
+ buildError.errors = [{ message: 'Bad config' }];
74
+ buildError.warnings = [{ message: 'Deprecated feature used' }];
75
+ mockShallowBuild.mockRejectedValue(buildError);
76
+
77
+ const build = lowdefyBuild(context);
78
+ await expect(build()).rejects.toBe(buildError);
79
+
80
+ expect(mockWriteBuildStatus).toHaveBeenCalledWith({
81
+ directories: context.directories,
82
+ status: 'error',
83
+ errors: [{ message: 'Bad config' }],
84
+ warnings: [{ message: 'Deprecated feature used' }],
85
+ });
86
+ });
87
+
88
+ test('lowdefyBuild falls back to the raw error message when the thrown error has no errors array', async () => {
89
+ const context = createContext();
90
+ const buildError = new Error('Build failed due to internal error. See above for details.');
91
+ mockShallowBuild.mockRejectedValue(buildError);
92
+
93
+ const build = lowdefyBuild(context);
94
+ await expect(build()).rejects.toBe(buildError);
95
+
96
+ expect(mockWriteBuildStatus).toHaveBeenCalledWith({
97
+ directories: context.directories,
98
+ status: 'error',
99
+ errors: [{ message: 'Build failed due to internal error. See above for details.' }],
100
+ warnings: [],
101
+ });
102
+ });
@@ -18,8 +18,20 @@ import path from 'path';
18
18
  import dotenv from 'dotenv';
19
19
 
20
20
  function readDotEnv(context) {
21
+ // dotenv.config() never overwrites env vars that are already set, so on
22
+ // the first load the real shell environment wins over .env (standard
23
+ // dotenv behavior). On reloads triggered by the .env watcher the previous
24
+ // load has already populated process.env, so without override the changed
25
+ // values would never take effect and the server would restart with stale
26
+ // env.
27
+ let loadedOnce = false;
21
28
  return () => {
22
- dotenv.config({ path: path.join(context.directories.config, '.env'), silent: true });
29
+ dotenv.config({
30
+ path: path.join(context.directories.config, '.env'),
31
+ silent: true,
32
+ override: loadedOnce,
33
+ });
34
+ loadedOnce = true;
23
35
  };
24
36
  }
25
37
 
@@ -16,18 +16,18 @@
16
16
 
17
17
  function shutdownServer(context) {
18
18
  return () => {
19
- if (context.nextServer) {
19
+ if (context.devServer) {
20
20
  context.logger.debug(
21
- `Existing next server with pid ${context.nextServer.pid}, killed: ${context.nextServer.killed}`
21
+ `Existing dev server with pid ${context.devServer.pid}, killed: ${context.devServer.killed}`
22
22
  );
23
- if (!context.nextServer.killed) {
23
+ if (!context.devServer.killed) {
24
24
  context.logger.info({ spin: 'start' }, 'Shutting down server...');
25
- context.nextServer.kill();
25
+ context.devServer.kill();
26
26
  context.logger.debug(
27
- `Killed next server with pid ${context.nextServer.pid}, killed: ${context.nextServer.killed}`
27
+ `Killed dev server with pid ${context.devServer.pid}, killed: ${context.devServer.killed}`
28
28
  );
29
29
  }
30
- context.nextServer = null;
30
+ context.devServer = null;
31
31
  }
32
32
  };
33
33
  }
@@ -0,0 +1,160 @@
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 http from 'node:http';
18
+ import net from 'node:net';
19
+
20
+ /*
21
+ The manager owns the public port; the Vite child listens on an internal
22
+ loopback port and every restart replaces only the child. Before this proxy the
23
+ child bound the public port directly, so each restart (js module change, .env
24
+ change, plugin install) dropped the TCP listener for the whole Vite boot —
25
+ long-lived clients (MCP agents on /lowdefy-docs/mcp, the reload SSE stream,
26
+ HMR websockets) saw ECONNREFUSED and gave up; coding agents in particular
27
+ latch the failure and need a manual reconnect. Holding the listener here turns
28
+ a restart into a briefly-slow request instead of a dead port.
29
+
30
+ While the child is down, requests and upgrades wait for it to come back
31
+ (probing every RETRY_MS up to HOLD_MS) rather than failing fast. HOLD_MS
32
+ covers a Vite respawn with headroom; a child that stays down longer than that
33
+ answers 503 so callers are not held forever. In-flight streams cannot survive
34
+ a child exit — those sockets close and the client reconnects into the hold.
35
+ */
36
+
37
+ const RETRY_MS = 250;
38
+ const HOLD_MS = 30000;
39
+
40
+ function probeChild(port) {
41
+ return new Promise((resolve) => {
42
+ const socket = net.connect({ host: '127.0.0.1', port });
43
+ socket.once('connect', () => {
44
+ socket.destroy();
45
+ resolve(true);
46
+ });
47
+ socket.once('error', () => {
48
+ socket.destroy();
49
+ resolve(false);
50
+ });
51
+ });
52
+ }
53
+
54
+ async function waitForChild({ port }) {
55
+ const deadline = Date.now() + HOLD_MS;
56
+ for (;;) {
57
+ if (await probeChild(port)) return true;
58
+ if (Date.now() > deadline) return false;
59
+ await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
60
+ }
61
+ }
62
+
63
+ function forwardRequest(context, req, res) {
64
+ // Wait for a live child BEFORE piping the request body — the body stream can
65
+ // only be consumed once, so retrying after a failed proxy request would need
66
+ // full-body buffering. A probe-then-forward race (child dies between the
67
+ // probe and the connect) surfaces as one 502, which the client's next
68
+ // attempt resolves through the hold.
69
+ waitForChild({ port: context.internalPort }).then((up) => {
70
+ if (req.destroyed) return;
71
+ if (!up) {
72
+ res.writeHead(503, { 'content-type': 'application/json' });
73
+ res.end(JSON.stringify({ message: 'Lowdefy dev server is restarting.' }));
74
+ return;
75
+ }
76
+ const proxyReq = http.request({
77
+ host: '127.0.0.1',
78
+ port: context.internalPort,
79
+ method: req.method,
80
+ path: req.url,
81
+ headers: req.headers,
82
+ });
83
+ proxyReq.on('response', (proxyRes) => {
84
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
85
+ // flushHeaders so SSE endpoints (reload stream, MCP notifications)
86
+ // reach the client before the first event.
87
+ res.flushHeaders?.();
88
+ proxyRes.pipe(res);
89
+ });
90
+ proxyReq.on('error', () => {
91
+ if (res.headersSent) {
92
+ res.destroy();
93
+ return;
94
+ }
95
+ res.writeHead(502, { 'content-type': 'application/json' });
96
+ res.end(JSON.stringify({ message: 'Lowdefy dev server connection dropped.' }));
97
+ });
98
+ req.pipe(proxyReq);
99
+ req.on('error', () => proxyReq.destroy());
100
+ // A client that goes away mid-response (a closed browser tab holding the
101
+ // reload SSE stream, an agent dropping its MCP stream) must reach the
102
+ // child as an aborted request — pipe() only unpipes, so without this the
103
+ // child keeps the stream open and, for SSE, its tab registered forever.
104
+ res.on('close', () => {
105
+ if (!res.writableFinished) proxyReq.destroy();
106
+ });
107
+ });
108
+ }
109
+
110
+ function forwardUpgrade(context, req, socket, head) {
111
+ waitForChild({ port: context.internalPort }).then((up) => {
112
+ if (socket.destroyed) return;
113
+ if (!up) {
114
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nconnection: close\r\n\r\n');
115
+ return;
116
+ }
117
+ const upstream = net.connect({ host: '127.0.0.1', port: context.internalPort }, () => {
118
+ // Replay the upgrade request verbatim (rawHeaders preserves order and
119
+ // case) and splice the sockets — protocol-agnostic, so Vite HMR and app
120
+ // websockets both pass through untouched.
121
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
122
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
123
+ lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
124
+ }
125
+ upstream.write(lines.join('\r\n') + '\r\n\r\n');
126
+ if (head?.length) upstream.write(head);
127
+ upstream.pipe(socket);
128
+ socket.pipe(upstream);
129
+ });
130
+ upstream.on('error', () => socket.destroy());
131
+ socket.on('error', () => upstream.destroy());
132
+ upstream.on('close', () => socket.destroy());
133
+ socket.on('close', () => upstream.destroy());
134
+ });
135
+ }
136
+
137
+ function startProxy(context) {
138
+ if (context.proxyServer) return Promise.resolve();
139
+ const proxy = http.createServer((req, res) => forwardRequest(context, req, res));
140
+ proxy.on('upgrade', (req, socket, head) => forwardUpgrade(context, req, socket, head));
141
+ // Long-lived streams (SSE, MCP) must not be reaped by the default 5-minute
142
+ // request timeout; keep the proxy transparent.
143
+ proxy.requestTimeout = 0;
144
+ proxy.headersTimeout = 60000;
145
+ context.proxyServer = proxy;
146
+ return new Promise((resolve, reject) => {
147
+ proxy.once('error', reject);
148
+ // 'localhost' for parity with the Vite default host the child used to bind
149
+ // — the dev server stays loopback-only, not exposed on the LAN.
150
+ proxy.listen(context.options.port, 'localhost', () => {
151
+ proxy.removeListener('error', reject);
152
+ context.logger.debug(
153
+ `Proxy listening on port ${context.options.port}, forwarding to ${context.internalPort}.`
154
+ );
155
+ resolve();
156
+ });
157
+ });
158
+ }
159
+
160
+ export default startProxy;
@@ -0,0 +1,102 @@
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 http from 'node:http';
18
+ import net from 'node:net';
19
+
20
+ import { jest } from '@jest/globals';
21
+
22
+ const { default: startProxy } = await import('./startProxy.mjs');
23
+
24
+ function listen(server) {
25
+ return new Promise((resolve) => {
26
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
27
+ });
28
+ }
29
+
30
+ function close(server) {
31
+ return new Promise((resolve) => {
32
+ server.closeAllConnections?.();
33
+ server.close(() => resolve());
34
+ });
35
+ }
36
+
37
+ function getFreePort() {
38
+ return new Promise((resolve) => {
39
+ const server = net.createServer();
40
+ server.listen(0, '127.0.0.1', () => {
41
+ const { port } = server.address();
42
+ server.close(() => resolve(port));
43
+ });
44
+ });
45
+ }
46
+
47
+ let child;
48
+ let context;
49
+
50
+ afterEach(async () => {
51
+ if (context?.proxyServer) await close(context.proxyServer);
52
+ if (child) await close(child);
53
+ child = undefined;
54
+ context = undefined;
55
+ });
56
+
57
+ async function startChildAndProxy(handler) {
58
+ child = http.createServer(handler);
59
+ const internalPort = await listen(child);
60
+ context = {
61
+ internalPort,
62
+ options: { port: await getFreePort() },
63
+ logger: { debug: jest.fn() },
64
+ };
65
+ await startProxy(context);
66
+ return context.options.port;
67
+ }
68
+
69
+ test('startProxy forwards a request to the child and relays its response', async () => {
70
+ const port = await startChildAndProxy((req, res) => {
71
+ res.writeHead(200, { 'content-type': 'application/json' });
72
+ res.end(JSON.stringify({ path: req.url }));
73
+ });
74
+ const response = await fetch(`http://localhost:${port}/api/ping?x=1`);
75
+ expect(response.status).toBe(200);
76
+ expect(await response.json()).toEqual({ path: '/api/ping?x=1' });
77
+ });
78
+
79
+ test('startProxy aborts the child request when the client drops a streaming response', async () => {
80
+ let upstreamClosed;
81
+ const upstreamClosedPromise = new Promise((resolve) => {
82
+ upstreamClosed = resolve;
83
+ });
84
+ const port = await startChildAndProxy((req, res) => {
85
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
86
+ res.flushHeaders();
87
+ res.write('event: tab\ndata: {}\n\n');
88
+ // Never ends — like the reload SSE stream, it lives until the client goes.
89
+ req.on('close', () => upstreamClosed('closed'));
90
+ });
91
+
92
+ const clientRequest = http.get(`http://localhost:${port}/api/reload`);
93
+ const clientResponse = await new Promise((resolve) => clientRequest.on('response', resolve));
94
+ await new Promise((resolve) => clientResponse.once('data', resolve));
95
+ clientRequest.destroy();
96
+
97
+ const outcome = await Promise.race([
98
+ upstreamClosedPromise,
99
+ new Promise((resolve) => setTimeout(() => resolve('still open'), 2000)),
100
+ ]);
101
+ expect(outcome).toBe('closed');
102
+ });
@@ -16,12 +16,14 @@
16
16
 
17
17
  import { spawn } from 'child_process';
18
18
 
19
+ import warnAuthUrlPortMismatch from './warnAuthUrlPortMismatch.mjs';
20
+
19
21
  function createStdErrLineHandler({ context }) {
20
- const port = context.options.port;
22
+ const port = context.internalPort;
21
23
  return function stdErrLineHandler(line) {
22
24
  if (line.includes('EADDRINUSE')) {
23
25
  context.logger.error(
24
- `Port ${port} is already in use. Stop the other process or use a different port with --port.`
26
+ `Internal port ${port} is already in use. Stop the other process or use a different port with --port.`
25
27
  );
26
28
  return;
27
29
  }
@@ -31,18 +33,33 @@ function createStdErrLineHandler({ context }) {
31
33
 
32
34
  function startServer(context) {
33
35
  context.shutdownServer();
36
+ warnAuthUrlPortMismatch({ context });
34
37
 
35
- const nextServer = spawn('node', [context.bin.next, 'start'], {
36
- stdio: ['ignore', 'inherit', 'pipe'],
37
- env: {
38
- ...process.env,
39
- LOWDEFY_DIRECTORY_CONFIG: context.directories.config,
40
- PORT: context.options.port,
41
- },
42
- });
38
+ // The child binds context.internalPort on loopback; the manager's proxy owns
39
+ // the public context.options.port (see startProxy.mjs) so a restart never
40
+ // drops the listener that browsers, SSE reload streams and MCP agents hold.
41
+ const devServer = spawn(
42
+ 'node',
43
+ [
44
+ context.bin.vite,
45
+ '--host',
46
+ '127.0.0.1',
47
+ '--port',
48
+ String(context.internalPort),
49
+ '--strictPort',
50
+ ],
51
+ {
52
+ stdio: ['ignore', 'inherit', 'pipe'],
53
+ env: {
54
+ ...process.env,
55
+ LOWDEFY_DIRECTORY_CONFIG: context.directories.config,
56
+ PORT: context.internalPort,
57
+ },
58
+ }
59
+ );
43
60
 
44
61
  const stdErrLineHandler = createStdErrLineHandler({ context });
45
- nextServer.stderr.on('data', (data) => {
62
+ devServer.stderr.on('data', (data) => {
46
63
  data
47
64
  .toString('utf8')
48
65
  .split('\n')
@@ -51,14 +68,14 @@ function startServer(context) {
51
68
  });
52
69
  });
53
70
 
54
- context.logger.debug(`Started next server with pid ${nextServer.pid}.`);
55
- nextServer.on('exit', (code, signal) => {
56
- context.logger.debug(`nextServer exit ${nextServer.pid}, signal: ${signal}, code: ${code}`);
71
+ context.logger.debug(`Started dev server with pid ${devServer.pid}.`);
72
+ devServer.on('exit', (code, signal) => {
73
+ context.logger.debug(`devServer exit ${devServer.pid}, signal: ${signal}, code: ${code}`);
57
74
  });
58
- nextServer.on('error', (error) => {
75
+ devServer.on('error', (error) => {
59
76
  context.logger.error(error);
60
77
  });
61
- context.nextServer = nextServer;
78
+ context.devServer = devServer;
62
79
  }
63
80
 
64
81
  export default startServer;
@@ -17,7 +17,7 @@
17
17
  import envWatcher from '../watchers/envWatcher.mjs';
18
18
  import lowdefyBuildWatcher from '../watchers/lowdefyBuildWatcher.mjs';
19
19
  import moduleBuildWatcher from '../watchers/moduleBuildWatcher.mjs';
20
- import nextBuildWatcher from '../watchers/nextBuildWatcher.mjs';
20
+ import serverArtifactWatcher from '../watchers/serverArtifactWatcher.mjs';
21
21
 
22
22
  function startWatchers(context) {
23
23
  return async () => {
@@ -25,7 +25,7 @@ function startWatchers(context) {
25
25
  envWatcher(context),
26
26
  lowdefyBuildWatcher(context),
27
27
  moduleBuildWatcher(context),
28
- nextBuildWatcher(context),
28
+ serverArtifactWatcher(context),
29
29
  ]);
30
30
  };
31
31
  }
@@ -0,0 +1,47 @@
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
+ // Auth.js derives the app origin from request headers (trustHost), so an unset
18
+ // AUTH_URL works on whatever port the dev server binds to. But when AUTH_URL (or
19
+ // the v4 fallback NEXTAUTH_URL) is set, @auth/core pins the origin to it verbatim
20
+ // — OAuth callbacks and sign-in redirects then target that URL. A pinned URL on a
21
+ // mismatched port is left untouched but warned about.
22
+ function warnAuthUrlPortMismatch({ context }) {
23
+ const { port } = context.options;
24
+ const name = process.env.AUTH_URL ? 'AUTH_URL' : 'NEXTAUTH_URL';
25
+ const current = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL;
26
+
27
+ if (!current) return;
28
+
29
+ let currentPort;
30
+ try {
31
+ const url = new URL(current);
32
+ currentPort = url.port || (url.protocol === 'https:' ? '443' : '80');
33
+ } catch (_) {
34
+ context.logger.warn(`${name} (${current}) is not a valid URL.`);
35
+ return;
36
+ }
37
+
38
+ if (currentPort !== String(port)) {
39
+ context.logger.warn(
40
+ `${name} (${current}) does not match the dev server port ${port}. ` +
41
+ `Auth sign-in callbacks and redirects will target ${current}. ` +
42
+ `Update ${name} or unset it to derive the origin from the request.`
43
+ );
44
+ }
45
+ }
46
+
47
+ export default warnAuthUrlPortMismatch;