@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.
- package/{pages/_app.js → client/App.jsx} +21 -33
- package/client/DevStreamContext.js +30 -0
- package/client/Inspector.jsx +226 -0
- package/{lib/client/Page.js → client/Page.jsx} +4 -8
- package/client/Reload.jsx +85 -0
- package/client/Routing.jsx +117 -0
- package/client/feedback/FeedbackMount.jsx +83 -0
- package/client/feedback/FeedbackOverlay.jsx +901 -0
- package/client/feedback/captureTabScreenshot.js +84 -0
- package/client/feedback/copyToClipboard.js +45 -0
- package/client/feedback/drawAnnotationsSvg.js +119 -0
- package/client/feedback/elementInspect.js +99 -0
- package/client/feedback/feedbackStyles.js +301 -0
- package/client/feedback/pinResponsiveImages.js +59 -0
- package/client/feedback/sendFeedback.js +39 -0
- package/client/feedback/useFeedbackToggle.js +70 -0
- package/client/main.jsx +42 -0
- package/client/openInEditor/OpenInEditorListener.jsx +268 -0
- package/client/openInEditor/findBlockLocation.js +51 -0
- package/lib/build/app.js +8 -1
- package/lib/build/appMeta.js +8 -1
- package/lib/build/auth.js +8 -1
- package/lib/build/config.js +8 -1
- package/lib/build/i18n.js +8 -1
- package/lib/build/logger.js +8 -1
- package/lib/build/theme.js +8 -1
- package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
- package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +23 -10
- package/lib/client/setPageId.js +6 -5
- package/lib/client/utils/useMutateCache.js +14 -1
- package/lib/client/utils/usePageConfig.js +45 -43
- package/lib/client/utils/usePageConfig.test.mjs +62 -0
- package/lib/docs/captureAnnotatedScreenshot.js +88 -0
- package/lib/docs/checkpointPaths.js +91 -0
- package/lib/docs/checkpointStore.js +240 -0
- package/lib/docs/checkpointToMocks.js +65 -0
- package/lib/docs/clientErrorStore.js +36 -0
- package/lib/docs/configCheckpoints.test.mjs +156 -0
- package/lib/docs/createConfigCheckpoint.js +92 -0
- package/lib/docs/createDocsMcpServer.js +531 -0
- package/lib/docs/createDocsMcpServer.test.mjs +141 -0
- package/lib/docs/devMockRegistry.js +68 -0
- package/lib/docs/docs.test.mjs +448 -0
- package/lib/docs/enrichFeedback.js +71 -0
- package/lib/docs/evalOperator.js +47 -0
- package/lib/docs/evalOperator.test.mjs +157 -0
- package/lib/docs/evalOperatorHeadless.js +87 -0
- package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
- package/lib/docs/evalOperatorInTab.js +48 -0
- package/lib/docs/findConfig.js +260 -0
- package/lib/docs/formatFeedback.js +121 -0
- package/lib/docs/formatFeedback.test.mjs +204 -0
- package/lib/docs/getAppMap.js +180 -0
- package/lib/docs/getBrowser.js +127 -0
- package/lib/docs/getBrowser.test.mjs +122 -0
- package/lib/docs/getBuildStatus.js +37 -0
- package/lib/docs/getCoreDoc.js +63 -0
- package/lib/docs/getDocsManifest.js +40 -0
- package/lib/docs/getExamples.js +58 -0
- package/lib/docs/getOverview.js +115 -0
- package/lib/docs/getPageConfig.js +53 -0
- package/lib/docs/getPluginDoc.js +55 -0
- package/lib/docs/getSchema.js +65 -0
- package/lib/docs/inspectState.js +47 -0
- package/lib/docs/inspectState.test.mjs +140 -0
- package/lib/docs/inspectStateFromTab.js +40 -0
- package/lib/docs/inspectStateHeadless.js +84 -0
- package/lib/docs/inspectStateHeadless.test.mjs +60 -0
- package/lib/docs/isWriteRequestsAllowed.js +50 -0
- package/lib/docs/listConfigCheckpoints.js +55 -0
- package/lib/docs/listPlugins.js +80 -0
- package/lib/docs/listTypes.js +105 -0
- package/lib/docs/loadState.js +152 -0
- package/lib/docs/mapPageBuildErrors.js +28 -0
- package/lib/docs/normalizeTypeKind.js +40 -0
- package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
- package/lib/docs/readPageArtifact.js +26 -0
- package/lib/docs/resolvePluginDir.js +49 -0
- package/lib/docs/resolveSource.js +48 -0
- package/lib/docs/resolveSource.test.mjs +100 -0
- package/lib/docs/revertConfigCheckpoint.js +73 -0
- package/lib/docs/runRequest.js +141 -0
- package/lib/docs/saveAnnotatedScreenshot.js +43 -0
- package/lib/docs/scaffoldPage.js +75 -0
- package/lib/docs/scaffoldPage.test.mjs +97 -0
- package/lib/docs/screenshotPage.js +110 -0
- package/lib/docs/screenshotPage.test.mjs +59 -0
- package/lib/docs/screenshotPageClip.test.mjs +102 -0
- package/lib/docs/searchDocs.js +67 -0
- package/lib/docs/setupTestFixtures.mjs +335 -0
- package/lib/docs/snapshotState.js +59 -0
- package/lib/docs/stateCheckpoints.test.mjs +366 -0
- package/lib/docs/tabAvailable.js +28 -0
- package/lib/docs/tabChannel.js +119 -0
- package/lib/docs/tabChannel.test.mjs +130 -0
- package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +7 -4
- package/lib/server/auth/getDevSession.js +70 -0
- package/lib/server/auth/getDevSession.test.mjs +138 -0
- package/lib/server/auth/getHeadlessUser.js +40 -0
- package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
- package/lib/server/auth/headlessUser.js +29 -0
- package/lib/server/auth/resolveHeadlessUser.js +38 -0
- package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
- package/lib/server/auth/session.js +39 -0
- package/lib/server/auth/strategies.js +47 -0
- package/lib/server/{apiWrapper.js → createLowdefyContext.js} +63 -57
- package/lib/server/getPageJitEnrichment.test.mjs +105 -0
- package/lib/server/jitPageBuilder.js +125 -12
- package/lib/server/log/createHandleError.js +9 -0
- package/lib/server/log/createHandleError.test.mjs +126 -0
- package/manager/getContext.mjs +3 -7
- package/manager/processes/initialBuild.mjs +0 -2
- package/manager/processes/installPlugins.mjs +19 -11
- package/manager/processes/lowdefyBuild.mjs +29 -7
- package/manager/processes/lowdefyBuild.test.mjs +102 -0
- package/manager/processes/readDotEnv.mjs +13 -1
- package/manager/processes/shutdownServer.mjs +6 -6
- package/manager/processes/startProxy.mjs +160 -0
- package/manager/processes/startProxy.test.mjs +102 -0
- package/manager/processes/startServer.mjs +33 -16
- package/manager/processes/startWatchers.mjs +2 -2
- package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
- package/manager/run.mjs +50 -0
- package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
- package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
- package/manager/utils/formatNoticeBox.mjs +30 -0
- package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
- package/manager/utils/updatePageTailwindCss.mjs +7 -1
- package/manager/utils/writeBuildStatus.mjs +39 -0
- package/manager/utils/writeBuildStatus.test.mjs +76 -0
- package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
- package/manager/watchers/moduleBuildWatcher.mjs +0 -1
- package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
- package/package.json +61 -47
- package/package.original.json +64 -55
- package/postcss.config.cjs +1 -0
- package/src/app.js +175 -0
- package/src/html/renderDevPage.js +86 -0
- package/src/lib/getPathSegments.js +25 -0
- package/src/lib/safeScriptJson.js +38 -0
- package/src/middleware/apiContext.js +32 -0
- package/src/middleware/errorHandler.js +48 -0
- package/src/middleware/errorHandler.test.mjs +235 -0
- package/src/routes/agent.js +64 -0
- package/src/routes/apiPage.js +37 -0
- package/src/routes/auth.js +49 -0
- package/src/routes/auth.test.mjs +84 -0
- package/src/routes/clientError.js +57 -0
- package/src/routes/cron.js +47 -0
- package/src/routes/cronForward.js +47 -0
- package/src/routes/detached.js +47 -0
- package/src/routes/devInspect.js +111 -0
- package/src/routes/devInspect.test.mjs +119 -0
- package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
- package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
- package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
- package/src/routes/docs/checkpointsCreate.js +28 -0
- package/src/routes/docs/checkpointsList.js +23 -0
- package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
- package/src/routes/docs/content.js +31 -0
- package/src/routes/docs/evalOperator.js +56 -0
- package/src/routes/docs/examples.js +33 -0
- package/src/routes/docs/find.js +30 -0
- package/src/routes/docs/index.js +23 -0
- package/src/routes/docs/inspectState.js +44 -0
- package/src/routes/docs/loadState.js +49 -0
- package/src/routes/docs/mcp.js +30 -0
- package/src/routes/docs/pageConfig.js +36 -0
- package/src/routes/docs/parseUserParam.js +52 -0
- package/src/routes/docs/parseUserParam.test.mjs +45 -0
- package/src/routes/docs/pluginDoc.js +33 -0
- package/src/routes/docs/plugins.js +23 -0
- package/src/routes/docs/runRequest.js +36 -0
- package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
- package/src/routes/docs/screenshot.js +72 -0
- package/src/routes/docs/search.js +27 -0
- package/src/routes/docs/snapshotState.js +36 -0
- package/src/routes/docs/stateCheckpointsList.js +26 -0
- package/src/routes/docs/types.js +33 -0
- package/src/routes/endpoints.js +71 -0
- package/src/routes/feedback.js +110 -0
- package/src/routes/feedback.test.mjs +204 -0
- package/src/routes/jitPage.js +86 -0
- package/src/routes/mcp.js +34 -0
- package/{pages/api → src/routes}/ping.js +4 -2
- package/src/routes/reload.js +79 -0
- package/src/routes/request.js +74 -0
- package/src/routes/request.test.mjs +124 -0
- package/{pages/api → src/routes}/root.js +4 -4
- package/src/routes/usage.js +46 -0
- package/src/routes/websocket.js +33 -0
- package/src/websocket/devWebSocket.js +78 -0
- package/vite.config.js +111 -0
- package/.eslintrc.yaml +0 -1
- package/lib/client/App.js +0 -80
- package/lib/client/Reload.js +0 -60
- package/lib/server/compileCss.js +0 -39
- package/manager/processes/compileCss.mjs +0 -54
- package/manager/processes/nextBuild.mjs +0 -50
- package/next.config.js +0 -25
- package/pages/_document.js +0 -107
- package/pages/api/agent/[...path].js +0 -86
- package/pages/api/auth/[...nextauth].js +0 -48
- package/pages/api/client-error.js +0 -45
- package/pages/api/js/[env].js +0 -42
- package/pages/api/page/[...pageId].js +0 -74
- package/pages/api/reload.js +0 -53
- package/pages/api/request/[...path].js +0 -41
- /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
- /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
- /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
- /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
- /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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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({
|
|
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.
|
|
19
|
+
if (context.devServer) {
|
|
20
20
|
context.logger.debug(
|
|
21
|
-
`Existing
|
|
21
|
+
`Existing dev server with pid ${context.devServer.pid}, killed: ${context.devServer.killed}`
|
|
22
22
|
);
|
|
23
|
-
if (!context.
|
|
23
|
+
if (!context.devServer.killed) {
|
|
24
24
|
context.logger.info({ spin: 'start' }, 'Shutting down server...');
|
|
25
|
-
context.
|
|
25
|
+
context.devServer.kill();
|
|
26
26
|
context.logger.debug(
|
|
27
|
-
`Killed
|
|
27
|
+
`Killed dev server with pid ${context.devServer.pid}, killed: ${context.devServer.killed}`
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
|
-
context.
|
|
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.
|
|
22
|
+
const port = context.internalPort;
|
|
21
23
|
return function stdErrLineHandler(line) {
|
|
22
24
|
if (line.includes('EADDRINUSE')) {
|
|
23
25
|
context.logger.error(
|
|
24
|
-
`
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
context.logger.debug(`
|
|
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
|
-
|
|
75
|
+
devServer.on('error', (error) => {
|
|
59
76
|
context.logger.error(error);
|
|
60
77
|
});
|
|
61
|
-
context.
|
|
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
|
|
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
|
-
|
|
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;
|