@lowdefy/server-dev 5.6.0 → 6.1.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 (218) 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/clientBuildImports.test.mjs +61 -0
  8. package/client/feedback/FeedbackMount.jsx +83 -0
  9. package/client/feedback/FeedbackOverlay.jsx +901 -0
  10. package/client/feedback/captureTabScreenshot.js +84 -0
  11. package/client/feedback/copyToClipboard.js +45 -0
  12. package/client/feedback/drawAnnotationsSvg.js +119 -0
  13. package/client/feedback/elementInspect.js +99 -0
  14. package/client/feedback/feedbackStyles.js +301 -0
  15. package/client/feedback/pinResponsiveImages.js +59 -0
  16. package/client/feedback/sendFeedback.js +39 -0
  17. package/client/feedback/useFeedbackToggle.js +70 -0
  18. package/client/main.jsx +42 -0
  19. package/client/openInEditor/OpenInEditorListener.jsx +268 -0
  20. package/client/openInEditor/findBlockLocation.js +51 -0
  21. package/lib/build/app.js +8 -1
  22. package/lib/build/appMeta.js +8 -1
  23. package/lib/build/auth.js +8 -1
  24. package/lib/build/config.js +8 -1
  25. package/lib/build/i18n.js +8 -1
  26. package/lib/build/logger.js +8 -1
  27. package/lib/build/theme.js +8 -1
  28. package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
  29. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +22 -10
  30. package/lib/client/setPageId.js +6 -5
  31. package/lib/client/utils/useMutateCache.js +14 -1
  32. package/lib/client/utils/usePageConfig.js +45 -43
  33. package/lib/client/utils/usePageConfig.test.mjs +62 -0
  34. package/lib/docs/captureAnnotatedScreenshot.js +88 -0
  35. package/lib/docs/checkpointPaths.js +91 -0
  36. package/lib/docs/checkpointStore.js +240 -0
  37. package/lib/docs/checkpointToMocks.js +65 -0
  38. package/lib/docs/clientErrorStore.js +36 -0
  39. package/lib/docs/configCheckpoints.test.mjs +156 -0
  40. package/lib/docs/createConfigCheckpoint.js +92 -0
  41. package/lib/docs/createDocsMcpServer.js +531 -0
  42. package/lib/docs/createDocsMcpServer.test.mjs +141 -0
  43. package/lib/docs/devMockRegistry.js +68 -0
  44. package/lib/docs/docs.test.mjs +448 -0
  45. package/lib/docs/enrichFeedback.js +71 -0
  46. package/lib/docs/evalOperator.js +47 -0
  47. package/lib/docs/evalOperator.test.mjs +157 -0
  48. package/lib/docs/evalOperatorHeadless.js +87 -0
  49. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  50. package/lib/docs/evalOperatorInTab.js +48 -0
  51. package/lib/docs/findConfig.js +260 -0
  52. package/lib/docs/formatFeedback.js +121 -0
  53. package/lib/docs/formatFeedback.test.mjs +204 -0
  54. package/lib/docs/getAppMap.js +180 -0
  55. package/lib/docs/getBrowser.js +127 -0
  56. package/lib/docs/getBrowser.test.mjs +122 -0
  57. package/lib/docs/getBuildStatus.js +37 -0
  58. package/lib/docs/getCoreDoc.js +63 -0
  59. package/lib/docs/getDocsManifest.js +40 -0
  60. package/lib/docs/getExamples.js +58 -0
  61. package/lib/docs/getOverview.js +115 -0
  62. package/lib/docs/getPageConfig.js +53 -0
  63. package/lib/docs/getPluginDoc.js +55 -0
  64. package/lib/docs/getSchema.js +65 -0
  65. package/lib/docs/inspectState.js +47 -0
  66. package/lib/docs/inspectState.test.mjs +140 -0
  67. package/lib/docs/inspectStateFromTab.js +40 -0
  68. package/lib/docs/inspectStateHeadless.js +84 -0
  69. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  70. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  71. package/lib/docs/listConfigCheckpoints.js +55 -0
  72. package/lib/docs/listPlugins.js +80 -0
  73. package/lib/docs/listTypes.js +105 -0
  74. package/lib/docs/loadState.js +152 -0
  75. package/lib/docs/mapPageBuildErrors.js +28 -0
  76. package/lib/docs/normalizeTypeKind.js +40 -0
  77. package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
  78. package/lib/docs/readPageArtifact.js +26 -0
  79. package/lib/docs/resolvePluginDir.js +49 -0
  80. package/lib/docs/resolveSource.js +48 -0
  81. package/lib/docs/resolveSource.test.mjs +100 -0
  82. package/lib/docs/revertConfigCheckpoint.js +73 -0
  83. package/lib/docs/runRequest.js +141 -0
  84. package/lib/docs/saveAnnotatedScreenshot.js +43 -0
  85. package/lib/docs/scaffoldPage.js +75 -0
  86. package/lib/docs/scaffoldPage.test.mjs +97 -0
  87. package/lib/docs/screenshotPage.js +110 -0
  88. package/lib/docs/screenshotPage.test.mjs +59 -0
  89. package/lib/docs/screenshotPageClip.test.mjs +102 -0
  90. package/lib/docs/searchDocs.js +67 -0
  91. package/lib/docs/setupTestFixtures.mjs +335 -0
  92. package/lib/docs/snapshotState.js +59 -0
  93. package/lib/docs/stateCheckpoints.test.mjs +366 -0
  94. package/lib/docs/tabAvailable.js +28 -0
  95. package/lib/docs/tabChannel.js +119 -0
  96. package/lib/docs/tabChannel.test.mjs +130 -0
  97. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +9 -4
  98. package/lib/server/auth/getDevSession.js +70 -0
  99. package/lib/server/auth/getDevSession.test.mjs +138 -0
  100. package/lib/server/auth/getHeadlessUser.js +40 -0
  101. package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
  102. package/lib/server/auth/headlessUser.js +29 -0
  103. package/lib/server/auth/resolveHeadlessUser.js +38 -0
  104. package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
  105. package/lib/server/auth/session.js +39 -0
  106. package/lib/server/auth/strategies.js +47 -0
  107. package/lib/server/createLowdefyContext.js +128 -0
  108. package/lib/server/createLowdefyContext.test.mjs +117 -0
  109. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  110. package/lib/server/jitPageBuilder.js +125 -12
  111. package/lib/server/log/createHandleError.test.mjs +126 -0
  112. package/lib/server/log/createLogger.test.mjs +62 -0
  113. package/lib/server/scrubSecrets.js +23 -0
  114. package/manager/getContext.mjs +3 -7
  115. package/manager/processes/initialBuild.mjs +0 -2
  116. package/manager/processes/installPlugins.mjs +19 -11
  117. package/manager/processes/lowdefyBuild.mjs +29 -7
  118. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  119. package/manager/processes/readDotEnv.mjs +13 -1
  120. package/manager/processes/shutdownServer.mjs +6 -6
  121. package/manager/processes/startProxy.mjs +160 -0
  122. package/manager/processes/startProxy.test.mjs +102 -0
  123. package/manager/processes/startServer.mjs +33 -16
  124. package/manager/processes/startWatchers.mjs +2 -2
  125. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  126. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  127. package/manager/run.mjs +50 -0
  128. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  129. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  130. package/manager/utils/formatNoticeBox.mjs +30 -0
  131. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  132. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  133. package/manager/utils/writeBuildStatus.mjs +39 -0
  134. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  135. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  136. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  137. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  138. package/package.json +62 -49
  139. package/package.original.json +65 -52
  140. package/postcss.config.cjs +1 -0
  141. package/src/app.js +175 -0
  142. package/src/html/renderDevPage.js +86 -0
  143. package/src/lib/getPathSegments.js +25 -0
  144. package/src/lib/safeScriptJson.js +38 -0
  145. package/src/middleware/apiContext.js +32 -0
  146. package/src/middleware/errorHandler.js +48 -0
  147. package/src/middleware/errorHandler.test.mjs +247 -0
  148. package/src/routes/agent.js +64 -0
  149. package/src/routes/apiPage.js +37 -0
  150. package/src/routes/auth.js +49 -0
  151. package/src/routes/auth.test.mjs +84 -0
  152. package/src/routes/clientError.js +57 -0
  153. package/src/routes/cron.js +47 -0
  154. package/src/routes/cronForward.js +47 -0
  155. package/src/routes/detached.js +47 -0
  156. package/src/routes/devInspect.js +111 -0
  157. package/src/routes/devInspect.test.mjs +119 -0
  158. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  159. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  160. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  161. package/src/routes/docs/checkpointsCreate.js +28 -0
  162. package/src/routes/docs/checkpointsList.js +23 -0
  163. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  164. package/src/routes/docs/content.js +31 -0
  165. package/src/routes/docs/evalOperator.js +56 -0
  166. package/src/routes/docs/examples.js +33 -0
  167. package/src/routes/docs/find.js +30 -0
  168. package/src/routes/docs/index.js +23 -0
  169. package/src/routes/docs/inspectState.js +44 -0
  170. package/src/routes/docs/loadState.js +49 -0
  171. package/src/routes/docs/mcp.js +30 -0
  172. package/src/routes/docs/pageConfig.js +36 -0
  173. package/src/routes/docs/parseUserParam.js +52 -0
  174. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  175. package/src/routes/docs/pluginDoc.js +33 -0
  176. package/src/routes/docs/plugins.js +23 -0
  177. package/src/routes/docs/runRequest.js +36 -0
  178. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  179. package/src/routes/docs/screenshot.js +72 -0
  180. package/src/routes/docs/search.js +27 -0
  181. package/src/routes/docs/snapshotState.js +36 -0
  182. package/src/routes/docs/stateCheckpointsList.js +26 -0
  183. package/src/routes/docs/types.js +33 -0
  184. package/src/routes/endpoints.js +71 -0
  185. package/src/routes/feedback.js +110 -0
  186. package/src/routes/feedback.test.mjs +204 -0
  187. package/src/routes/jitPage.js +86 -0
  188. package/src/routes/mcp.js +34 -0
  189. package/{pages/api → src/routes}/ping.js +4 -2
  190. package/src/routes/reload.js +79 -0
  191. package/src/routes/request.js +74 -0
  192. package/src/routes/request.test.mjs +124 -0
  193. package/{pages/api → src/routes}/root.js +4 -4
  194. package/src/routes/usage.js +46 -0
  195. package/src/routes/websocket.js +33 -0
  196. package/src/websocket/devWebSocket.js +78 -0
  197. package/vite.config.js +111 -0
  198. package/.eslintrc.yaml +0 -1
  199. package/lib/client/App.js +0 -80
  200. package/lib/client/Reload.js +0 -60
  201. package/lib/server/apiWrapper.js +0 -109
  202. package/lib/server/compileCss.js +0 -39
  203. package/manager/processes/compileCss.mjs +0 -54
  204. package/manager/processes/nextBuild.mjs +0 -50
  205. package/next.config.js +0 -25
  206. package/pages/_document.js +0 -109
  207. package/pages/api/agent/[...path].js +0 -86
  208. package/pages/api/auth/[...nextauth].js +0 -48
  209. package/pages/api/client-error.js +0 -45
  210. package/pages/api/js/[env].js +0 -42
  211. package/pages/api/page/[...pageId].js +0 -74
  212. package/pages/api/reload.js +0 -53
  213. package/pages/api/request/[...path].js +0 -41
  214. /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
  215. /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
  216. /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
  217. /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
  218. /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
@@ -0,0 +1,117 @@
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 'fs';
18
+ import path from 'path';
19
+ import { fileURLToPath } from 'url';
20
+
21
+ import { jest } from '@jest/globals';
22
+ import { projectCaughtError } from '@lowdefy/helpers';
23
+ import { operatorsServer } from '@lowdefy/operators-js';
24
+
25
+ const secret = 'planted-dev-secret';
26
+ process.env.LOWDEFY_SECRET_TEST = secret;
27
+
28
+ jest.unstable_mockModule('@lowdefy/api', () => ({ createApiContext: jest.fn() }));
29
+ jest.unstable_mockModule('../build/appMeta.js', () => ({ default: {} }));
30
+ jest.unstable_mockModule('../build/config.js', () => ({ default: {} }));
31
+ jest.unstable_mockModule('../build/i18n.js', () => ({ default: {} }));
32
+ jest.unstable_mockModule('./log/createHandleError.js', () => ({
33
+ default: jest.fn(() => jest.fn()),
34
+ }));
35
+ jest.unstable_mockModule('./log/createLogger.js', () => ({
36
+ default: jest.fn(() => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() })),
37
+ }));
38
+ jest.unstable_mockModule('./fileCache.js', () => ({ default: {} }));
39
+ jest.unstable_mockModule('./auth/session.js', () => ({ default: jest.fn(async () => undefined) }));
40
+ jest.unstable_mockModule('./auth/strategies.js', () => ({ default: jest.fn(async () => null) }));
41
+ jest.unstable_mockModule('./log/logRequest.js', () => ({ default: jest.fn() }));
42
+
43
+ // createLowdefyContext statically imports the app build's plugin artifacts,
44
+ // which only exist in a built app (build/** is gitignored). Materialize the
45
+ // minimal set it needs — same fixture strategy as auth/getDevSession.test.mjs
46
+ // — so the import resolves; clean up only what this test created.
47
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
48
+ const pluginsDir = path.resolve(dirname, '../../build/plugins');
49
+ const fixtures = [
50
+ { file: 'agents.js', content: 'export default {};\n' },
51
+ { file: 'connections.js', content: 'export default {};\n' },
52
+ {
53
+ file: 'notifications.js',
54
+ content: 'export default {};\nexport const interpolateProperties = () => {};\nexport const renderEmail = () => {};\n',
55
+ },
56
+ { file: path.join('operators', 'server.js'), content: 'export default {};\n' },
57
+ { file: path.join('operators', 'serverJsMap.js'), content: 'export default {};\n' },
58
+ { file: 'websockets.js', content: 'export default {};\n' },
59
+ ].map(({ file, content }) => ({ absolutePath: path.join(pluginsDir, file), content }));
60
+
61
+ const createdFixtures = [];
62
+ fixtures.forEach(({ absolutePath, content }) => {
63
+ if (!fs.existsSync(absolutePath)) {
64
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
65
+ fs.writeFileSync(absolutePath, content);
66
+ createdFixtures.push(absolutePath);
67
+ }
68
+ });
69
+
70
+ // A checkout that has built an app already holds real artifacts here, which import plugins this
71
+ // package does not depend on, so the stubs are also mocked over whatever is on disk.
72
+ fixtures.forEach(({ absolutePath }) => {
73
+ jest.unstable_mockModule(absolutePath, () => ({
74
+ default: {},
75
+ interpolateProperties: () => {},
76
+ renderEmail: () => {},
77
+ }));
78
+ });
79
+
80
+ afterAll(() => {
81
+ createdFixtures.forEach((absolutePath) => fs.rmSync(absolutePath, { force: true }));
82
+ });
83
+
84
+ const { default: createLowdefyContext } = await import('./createLowdefyContext.js');
85
+
86
+ function createHonoContext({ path: reqPath = '/api/request/foo' } = {}) {
87
+ return {
88
+ req: {
89
+ header: (name) => (name ? undefined : {}),
90
+ path: reqPath,
91
+ method: 'POST',
92
+ url: `http://localhost${reqPath}`,
93
+ },
94
+ };
95
+ }
96
+
97
+ test('createLowdefyContext sets mode to dev', async () => {
98
+ const context = await createLowdefyContext({ c: createHonoContext() });
99
+ expect(context.mode).toEqual('dev');
100
+ });
101
+
102
+ test('createLowdefyContext scrubSecrets redacts a planted secret', async () => {
103
+ const context = await createLowdefyContext({ c: createHonoContext() });
104
+ expect(context.scrubSecrets(`token ${secret} end`)).toEqual('token [REDACTED] end');
105
+ });
106
+
107
+ // controlTry builds the value `_error` reads from context.scrubSecrets, so a dev context has to
108
+ // scrub a caught message the same way the production server does.
109
+ test('_error on a dev context reads a caught message with a planted secret scrubbed', async () => {
110
+ const context = await createLowdefyContext({ c: createHonoContext() });
111
+ const error = projectCaughtError(new Error(`Connect failed with ${secret}.`), {
112
+ scrub: context.scrubSecrets,
113
+ });
114
+ expect(operatorsServer._error({ error, location: 'test', params: 'message' })).toEqual(
115
+ 'Connect failed with [REDACTED].'
116
+ );
117
+ });
@@ -0,0 +1,105 @@
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 { getPageJitEnrichment } from './jitPageBuilder.js';
18
+
19
+ // A stub of cachedBuildContext. getPageJitEnrichment reads jsMap.client and
20
+ // dynamicIconData off it; production omits buildContext so it defaults to the
21
+ // module-private cachedBuildContext.
22
+ function buildContext({ client = {}, dynamicIconData = {} } = {}) {
23
+ return { jsMap: { client, server: {} }, dynamicIconData };
24
+ }
25
+
26
+ test('getPageJitEnrichment scopes string-form, object-form, and args-nested _js hashes', () => {
27
+ const client = {
28
+ hashString: 'return args.x + 1;',
29
+ hashFn: 'return 2;',
30
+ hashNested: 'return 3;',
31
+ hashUnused: 'return 4;', // present in the map but not referenced by the page
32
+ };
33
+ const pageConfig = {
34
+ id: 'p',
35
+ blocks: [
36
+ { properties: { value: { _js: 'hashString' } } },
37
+ // object form; the args carry another _js node that must be descended into
38
+ { properties: { compute: { _js: { fn: 'hashFn', args: { seed: { _js: 'hashNested' } } } } } },
39
+ // referenced but absent from the client map — excluded, no crash
40
+ { properties: { missing: { _js: 'hashAbsent' } } },
41
+ ],
42
+ };
43
+
44
+ const { jsEntries } = getPageJitEnrichment({
45
+ pageConfig,
46
+ buildContext: buildContext({ client }),
47
+ });
48
+
49
+ expect(jsEntries).toContain("'hashString'");
50
+ expect(jsEntries).toContain("'hashFn'");
51
+ expect(jsEntries).toContain("'hashNested'");
52
+ expect(jsEntries).not.toContain("'hashUnused'");
53
+ expect(jsEntries).not.toContain("'hashAbsent'");
54
+ });
55
+
56
+ test('getPageJitEnrichment discovers an icon name that appears only inside a _js source', () => {
57
+ const client = {
58
+ // The icon name lives only in the function body, in double quotes — the
59
+ // served pageConfig has this replaced by the hash, so scanning the config
60
+ // alone would miss it. Only FiZap is a real dynamic icon.
61
+ hashIcon: 'return on ? "FiZap" : "FiZapOff";',
62
+ };
63
+ const pageConfig = {
64
+ id: 'p',
65
+ blocks: [{ properties: { icon: { _js: 'hashIcon' } } }],
66
+ };
67
+
68
+ const { dynamicIcons } = getPageJitEnrichment({
69
+ pageConfig,
70
+ buildContext: buildContext({ client, dynamicIconData: { FiZap: { tag: 'svg' } } }),
71
+ });
72
+
73
+ expect(dynamicIcons).toEqual({ FiZap: { tag: 'svg' } });
74
+ });
75
+
76
+ test('getPageJitEnrichment scopes icons referenced directly in the page config', () => {
77
+ const pageConfig = { id: 'p', blocks: [{ properties: { icon: 'FiStar' } }] };
78
+
79
+ const { dynamicIcons } = getPageJitEnrichment({
80
+ pageConfig,
81
+ buildContext: buildContext({
82
+ dynamicIconData: { FiStar: { tag: 'svg' }, FiOther: { tag: 'svg' } },
83
+ }),
84
+ });
85
+
86
+ expect(dynamicIcons).toEqual({ FiStar: { tag: 'svg' } });
87
+ });
88
+
89
+ test('getPageJitEnrichment returns {} when there is no build context', () => {
90
+ expect(getPageJitEnrichment({ pageConfig: { id: 'p' }, buildContext: null })).toEqual({});
91
+ });
92
+
93
+ test('getPageJitEnrichment omits both fields when the page has no dynamic _js or icons', () => {
94
+ const pageConfig = { id: 'p', blocks: [{ properties: { title: 'Hello' } }] };
95
+
96
+ const enrichment = getPageJitEnrichment({
97
+ pageConfig,
98
+ buildContext: buildContext({
99
+ client: { hashUnused: 'return 1;' },
100
+ dynamicIconData: { FiZap: {} },
101
+ }),
102
+ });
103
+
104
+ expect(enrichment).toEqual({ jsEntries: undefined, dynamicIcons: undefined });
105
+ });
@@ -16,10 +16,16 @@
16
16
 
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
- import { serializer } from '@lowdefy/helpers';
20
- import { buildPageJit, createContext, makeId } from '@lowdefy/build/dev';
19
+ import { serializer, type } from '@lowdefy/helpers';
20
+ import {
21
+ buildPageJit,
22
+ createContext,
23
+ generateClientJsModule,
24
+ hydrateDeferredRecords,
25
+ iconPackages,
26
+ makeId,
27
+ } from '@lowdefy/build/dev';
21
28
 
22
- import compileCss from './compileCss.js';
23
29
  import createLogger from './log/createLogger.js';
24
30
  import PageCache from './pageCache.mjs';
25
31
  import readBuildApiArtifacts from './readBuildApiArtifacts.mjs';
@@ -36,10 +42,10 @@ let cachedRegistry = null;
36
42
  let cachedBuildContext = null;
37
43
  let lastInvalidationMtime = null;
38
44
 
39
- // Frozen snapshot of icon imports from the initial build (what's actually in the Next.js bundle).
45
+ // Frozen snapshot of icon imports from the initial build (what's actually in the client bundle).
40
46
  // Module-level so it persists across context resets (skeleton rebuilds update iconImports.json
41
- // with newly discovered icons, but those aren't in the bundle until the next nextBuild).
42
- // Only resets when the server process restarts (which happens after every nextBuild).
47
+ // with newly discovered icons, but those aren't in the bundle until a server restart).
48
+ // Only resets when the server process restarts.
43
49
  let bundledIconImports = null;
44
50
 
45
51
  function readJsonFile(filePath) {
@@ -92,10 +98,10 @@ function getBuildContext(buildDirectory, configDirectory) {
92
98
  const keyMap = readJsonFile(path.join(buildDirectory, 'keyMap.json')) ?? {};
93
99
  const jsMap = readJsonFile(path.join(buildDirectory, 'jsMap.json')) ?? { client: {}, server: {} };
94
100
  const connectionIds = readJsonFile(path.join(buildDirectory, 'connectionIds.json')) ?? [];
101
+ const websocketIds = readJsonFile(path.join(buildDirectory, 'websocketIds.json')) ?? [];
95
102
 
96
103
  const customTypesMap = readJsonFile(path.join(buildDirectory, 'customTypesMap.json')) ?? {};
97
- const customMessagesMap =
98
- readJsonFile(path.join(buildDirectory, 'customMessagesMap.json')) ?? {};
104
+ const customMessagesMap = readJsonFile(path.join(buildDirectory, 'customMessagesMap.json')) ?? {};
99
105
 
100
106
  cachedBuildContext = createContext({
101
107
  customMessagesMap,
@@ -109,7 +115,7 @@ function getBuildContext(buildDirectory, configDirectory) {
109
115
  stage: 'dev',
110
116
  });
111
117
 
112
- // Restore refMap, keyMap, jsMap, and connectionIds from skeleton build
118
+ // Restore refMap, keyMap, jsMap, connectionIds, and websocketIds from skeleton build
113
119
  Object.assign(cachedBuildContext.refMap, refMap);
114
120
  Object.assign(cachedBuildContext.keyMap, keyMap);
115
121
  cachedBuildContext.jsMap.client = jsMap.client ?? {};
@@ -117,6 +123,9 @@ function getBuildContext(buildDirectory, configDirectory) {
117
123
  for (const id of connectionIds) {
118
124
  cachedBuildContext.connectionIds.add(id);
119
125
  }
126
+ for (const id of websocketIds) {
127
+ cachedBuildContext.websocketIds.add(id);
128
+ }
120
129
 
121
130
  // Load installed packages snapshot from skeleton build for missing-package detection
122
131
  const installedPluginPackages =
@@ -129,15 +138,27 @@ function getBuildContext(buildDirectory, configDirectory) {
129
138
  Object.assign(cachedBuildContext.modules, modules);
130
139
  }
131
140
 
141
+ // Hydrate the deferred-record registry — module component bodies referenced
142
+ // by '~deferred' placeholders in modules.json live here. readJsonFile runs
143
+ // the marker-restoring reviver, so record-body ~r/~l markers survive.
144
+ const deferredRecords = readJsonFile(path.join(buildDirectory, 'deferredRecords.json'));
145
+ if (deferredRecords) {
146
+ hydrateDeferredRecords(cachedBuildContext, deferredRecords);
147
+ }
148
+
149
+ // Restore app metadata so JIT page builds resolve _app / _build.app against
150
+ // the same metadata the skeleton build computed.
151
+ cachedBuildContext.appMeta = readJsonFile(path.join(buildDirectory, 'appMeta.json')) ?? null;
152
+
132
153
  // Restore api endpoint configs so JIT CallAPI validation (validateCallApiRefs in
133
154
  // buildPageJit) can resolve endpointIds. Without this the dev context has no
134
155
  // components.api and every CallAPI action is flagged as a non-existent endpoint.
135
156
  cachedBuildContext.components = { api: readBuildApiArtifacts(buildDirectory) };
136
157
 
137
158
  // Use the frozen icon imports from the initial build for JIT detection.
138
- // This represents what's actually in the Next.js bundle — not what shallowBuild
159
+ // This represents what's actually in the client bundle — not what shallowBuild
139
160
  // discovers on subsequent rebuilds (those icons aren't bundled yet).
140
- // bundledIconImports is module-level and only resets on server restart (after nextBuild).
161
+ // bundledIconImports is module-level and only resets on server restart.
141
162
  if (!bundledIconImports) {
142
163
  bundledIconImports = readJsonFile(path.join(buildDirectory, 'iconImports.json')) ?? [];
143
164
  }
@@ -190,7 +211,18 @@ async function buildPageIfNeeded({ pageId, buildDirectory, configDirectory }) {
190
211
  return result;
191
212
  }
192
213
  pageCache.markCompiled(pageId);
193
- await compileCss(buildDirectory);
214
+ // Touch the candidates file so Vite's CSS pipeline re-runs Tailwind for
215
+ // classes the JIT build discovered — globals.css imports it. This import
216
+ // is the ONLY recompile trigger: the tailwind .html scan inputs are
217
+ // excluded from Vite's watcher (their .html change events would force
218
+ // full browser reloads). Only touched when the build actually changed
219
+ // tailwind content, so unchanged rebuilds cause no CSS recompile.
220
+ if (result?._tailwindChanged) {
221
+ fs.writeFileSync(
222
+ path.join(buildDirectory, 'tailwind-candidates.css'),
223
+ `/* Generated by Lowdefy build — rewritten on page changes to trigger CSS recompilation */\n/* ${Date.now()} */\n`
224
+ );
225
+ }
194
226
  jitLogger.info(
195
227
  { spin: 'succeed', color: 'white' },
196
228
  `Built page "${pageId}" in ${formatDuration(Date.now() - startTime)}.`
@@ -201,4 +233,85 @@ async function buildPageIfNeeded({ pageId, buildDirectory, configDirectory }) {
201
233
  }
202
234
  }
203
235
 
236
+ // Collect every client _js hash the page references. jsMapParser reduces a _js
237
+ // operator to either { _js: "<hash>" } or { _js: { fn: "<hash>", args } }, and
238
+ // keeps args verbatim — so a _js nested inside another's args survives as its
239
+ // own node and must be descended into, or its hash is dropped and the client
240
+ // throws "_js function not found".
241
+ function collectJsHashes(node, hashes) {
242
+ if (type.isArray(node)) {
243
+ for (const item of node) collectJsHashes(item, hashes);
244
+ return;
245
+ }
246
+ if (!type.isObject(node)) return;
247
+ if (Object.prototype.hasOwnProperty.call(node, '_js')) {
248
+ const inner = node._js;
249
+ if (type.isString(inner)) {
250
+ hashes.add(inner);
251
+ return;
252
+ }
253
+ if (type.isObject(inner) && type.isString(inner.fn)) {
254
+ hashes.add(inner.fn);
255
+ collectJsHashes(inner.args, hashes);
256
+ return;
257
+ }
258
+ return;
259
+ }
260
+ for (const value of Object.values(node)) collectJsHashes(value, hashes);
261
+ }
262
+
263
+ // Icons are discovered by detectMissingIcons on the pre-parse page, so a name
264
+ // appearing only inside a _js body is real but is a hash in the served config.
265
+ // Reproduce that surface: scan the served config plus the page's own client _js
266
+ // source strings, and keep only names present in dynamicIconData (which holds
267
+ // only JIT-discovered icons — static ones are already in the client bundle).
268
+ function scopeDynamicIcons({ pageConfig, scopedJsMap, dynamicIconData }) {
269
+ if (Object.keys(dynamicIconData).length === 0) return undefined;
270
+ const scanText = [JSON.stringify(pageConfig), ...Object.values(scopedJsMap)].join('\n');
271
+ const found = {};
272
+ for (const regex of Object.values(iconPackages)) {
273
+ for (const match of scanText.matchAll(regex)) {
274
+ const name = match[1];
275
+ if (dynamicIconData[name] && !found[name]) {
276
+ found[name] = dynamicIconData[name];
277
+ }
278
+ }
279
+ }
280
+ return Object.keys(found).length > 0 ? found : undefined;
281
+ }
282
+
283
+ // Scope this page's JIT-discovered enrichment out of the persistent build
284
+ // context so jitPageHandler can fold it into the page-config response the client
285
+ // already awaits — removing the two secondary fetches that stalled first paint.
286
+ // buildContext defaults to the module-private cachedBuildContext (re-read on
287
+ // every call, so it tracks invalidation resets); tests pass a stub.
288
+ export function getPageJitEnrichment({ pageConfig, buildContext = cachedBuildContext }) {
289
+ // No build context (before the first build, or after an invalidation reset)
290
+ // means nothing JIT-discovered to fold — the page serves what the static
291
+ // client bundle already carries.
292
+ if (!buildContext) return {};
293
+
294
+ const clientJsMap = buildContext.jsMap.client ?? {};
295
+ const hashes = new Set();
296
+ collectJsHashes(pageConfig, hashes);
297
+
298
+ const scopedJsMap = {};
299
+ for (const hash of hashes) {
300
+ if (Object.prototype.hasOwnProperty.call(clientJsMap, hash)) {
301
+ scopedJsMap[hash] = clientJsMap[hash];
302
+ }
303
+ }
304
+
305
+ const jsEntries =
306
+ Object.keys(scopedJsMap).length > 0 ? generateClientJsModule(scopedJsMap) : undefined;
307
+
308
+ const dynamicIcons = scopeDynamicIcons({
309
+ pageConfig,
310
+ scopedJsMap,
311
+ dynamicIconData: buildContext.dynamicIconData ?? {},
312
+ });
313
+
314
+ return { jsEntries, dynamicIcons };
315
+ }
316
+
204
317
  export default buildPageIfNeeded;
@@ -0,0 +1,126 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+ import { ConfigError, LowdefyInternalError } from '@lowdefy/errors';
19
+
20
+ import createHandleError from './createHandleError.js';
21
+
22
+ // This sink is the sole writer of `error.handled`. Two callers read the flag and
23
+ // neither can set it for itself: runRoutine's catch guards on `!error.handled` to
24
+ // avoid logging a nested error twice, and the browser's handleError skips the
25
+ // round-trip to /api/client-error when it is set. runRoutine deliberately gave up
26
+ // its own assignment on the strength of this function, so the tests below pin the
27
+ // assignment and — more importantly — its position ahead of everything that can
28
+ // throw.
29
+
30
+ const keyMap = { key_1: { key: 'root.pages[0:home]', '~r': 'ref_1', '~l': 5 } };
31
+ const refMap = { ref_1: { path: 'pages/home.yaml' } };
32
+
33
+ function testContext({ logger, readConfigFile, configDirectory } = {}) {
34
+ return {
35
+ configDirectory,
36
+ logger: logger ?? { error: jest.fn() },
37
+ readConfigFile:
38
+ readConfigFile ??
39
+ jest.fn(async (path) => {
40
+ if (path === 'keyMap.json') return keyMap;
41
+ if (path === 'refMap.json') return refMap;
42
+ return null;
43
+ }),
44
+ };
45
+ }
46
+
47
+ beforeEach(() => {
48
+ console.error = jest.fn();
49
+ });
50
+
51
+ test('handleError marks the error handled and resolves its location', async () => {
52
+ const context = testContext();
53
+ const handleError = createHandleError({ context });
54
+ const error = new ConfigError('Bad config.', { configKey: 'key_1' });
55
+
56
+ await handleError(error);
57
+
58
+ expect(error.handled).toBe(true);
59
+ expect(error.source).toBe('pages/home.yaml:5');
60
+ expect(error.config).toBe('root.pages[0:home]');
61
+ expect(context.logger.error).toHaveBeenCalledWith(error);
62
+ });
63
+
64
+ test('handleError marks a LowdefyInternalError handled even though it never gets a source', async () => {
65
+ // The Decision 7 case. Location resolution is deliberately skipped for an
66
+ // internal error, so `source` stays undefined - which is why the client's
67
+ // already-logged gate reads `handled` and not `source`. Keyed on source, this
68
+ // error was POSTed back to /api/client-error and logged a second time.
69
+ const context = testContext();
70
+ const handleError = createHandleError({ context });
71
+ const error = new LowdefyInternalError('Unexpected condition.');
72
+
73
+ await handleError(error);
74
+
75
+ expect(error.handled).toBe(true);
76
+ expect(error.source).toBeUndefined();
77
+ expect(context.readConfigFile).not.toHaveBeenCalled();
78
+ expect(context.logger.error).toHaveBeenCalledWith(error);
79
+ });
80
+
81
+ test('handleError marks the error handled before it logs, so a throwing logger cannot skip the flag', async () => {
82
+ const logger = {
83
+ error: jest.fn(() => {
84
+ throw new Error('Log drain unreachable.');
85
+ }),
86
+ };
87
+ const context = testContext({ logger });
88
+ const handleError = createHandleError({ context });
89
+ const error = new ConfigError('Bad config.', { configKey: 'key_1' });
90
+
91
+ await handleError(error);
92
+
93
+ // The sink swallows its own failure - reaching it is what makes the error
94
+ // already-logged, so the flag must survive the log call failing. Were it set
95
+ // after the log, this error would cross the wire unflagged and be logged a
96
+ // second time from the browser.
97
+ expect(error.handled).toBe(true);
98
+ expect(logger.error).toHaveBeenCalled();
99
+ expect(console.error).toHaveBeenCalled();
100
+ });
101
+
102
+ test('handleError marks the error handled when location resolution rejects', async () => {
103
+ const readConfigFile = jest.fn(async () => {
104
+ throw new Error('keyMap.json unreadable.');
105
+ });
106
+ const context = testContext({ readConfigFile });
107
+ const handleError = createHandleError({ context });
108
+ const error = new ConfigError('Bad config.', { configKey: 'key_1' });
109
+
110
+ await handleError(error);
111
+
112
+ expect(error.handled).toBe(true);
113
+ // ConfigError's constructor initialises source to null; resolution failing
114
+ // leaves it there rather than assigning.
115
+ expect(error.source).toBeNull();
116
+ });
117
+
118
+ test('handleError marks a plain Error handled', async () => {
119
+ const context = testContext();
120
+ const handleError = createHandleError({ context });
121
+ const error = new Error('Something broke.');
122
+
123
+ await handleError(error);
124
+
125
+ expect(error.handled).toBe(true);
126
+ });
@@ -0,0 +1,62 @@
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
+ import { createNodeLogger } from '@lowdefy/logger/node';
19
+ import { serializer } from '@lowdefy/helpers';
20
+
21
+ const secret = 'planted-dev-secret-value';
22
+
23
+ const lines = [];
24
+ const destination = {
25
+ write(line) {
26
+ lines.push(line);
27
+ },
28
+ };
29
+
30
+ // The CLI's dev terminal is parsed from this logger's output, so it must keep the full default
31
+ // serialization; route the real logger to memory to read the exact emitted line.
32
+ jest.unstable_mockModule('@lowdefy/logger/node', () => ({
33
+ createNodeLogger: (options) => createNodeLogger({ ...options, destination }),
34
+ }));
35
+
36
+ process.env.LOWDEFY_SECRET_API_KEY = secret;
37
+
38
+ const { default: createLogger } = await import('./createLogger.js');
39
+
40
+ function createAxiosError() {
41
+ const error = new Error(`Request failed for key ${secret}`);
42
+ error.name = 'AxiosError';
43
+ error.code = 'ERR_BAD_REQUEST';
44
+ error.config = {
45
+ auth: { username: 'svc-user', password: 'planted-config-password' },
46
+ params: { api_key: 'planted-config-param' },
47
+ headers: { Authorization: 'Bearer planted-config-header' },
48
+ };
49
+ return error;
50
+ }
51
+
52
+ test('dev logger writes an axios error with full default serialization and no scrub', () => {
53
+ const error = createAxiosError();
54
+ createLogger().error({ err: error }, error.message);
55
+ expect(lines).toHaveLength(1);
56
+ const { err, msg } = JSON.parse(lines[0]);
57
+ expect(err).toEqual(JSON.parse(JSON.stringify(serializer.serialize(error)['~e'])));
58
+ expect(err.config.auth.password).toBe('planted-config-password');
59
+ expect(err.config.params.api_key).toBe('planted-config-param');
60
+ expect(err.config.headers.Authorization).toBe('Bearer planted-config-header');
61
+ expect(msg).toBe(`Request failed for key ${secret}`);
62
+ });
@@ -0,0 +1,23 @@
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 { createSecretScrubber, getSecretsFromEnv } from '@lowdefy/node-utils';
18
+
19
+ // Dev logs stay unscrubbed, but _error reads this off the context like every
20
+ // other server package, so a thrown message behaves identically in every mode.
21
+ const scrubSecrets = createSecretScrubber({ secrets: getSecretsFromEnv() });
22
+
23
+ export default scrubSecrets;
@@ -22,18 +22,16 @@ import { hideBin } from 'yargs/helpers';
22
22
  import pino from 'pino';
23
23
  import { createNodeLogger } from '@lowdefy/logger/node';
24
24
  import checkMockUserWarning from './processes/checkMockUserWarning.mjs';
25
- import compileCss from './processes/compileCss.mjs';
26
25
  import initialBuild from './processes/initialBuild.mjs';
27
26
  import installPlugins from './processes/installPlugins.mjs';
28
27
  import lowdefyBuild from './processes/lowdefyBuild.mjs';
29
- import nextBuild from './processes/nextBuild.mjs';
30
28
  import readDotEnv from './processes/readDotEnv.mjs';
31
29
  import reloadClients from './processes/reloadClients.mjs';
32
30
  import restartServer from './processes/restartServer.mjs';
33
31
  import shutdownServer from './processes/shutdownServer.mjs';
34
32
  import startWatchers from './processes/startWatchers.mjs';
35
33
 
36
- import getNextBin from './utils/getNextBin.mjs';
34
+ import getViteBin from './utils/getViteBin.mjs';
37
35
 
38
36
  const argv = yargs(hideBin(process.argv)).array('watch').array('watchIgnore').argv;
39
37
 
@@ -42,7 +40,7 @@ async function getContext() {
42
40
 
43
41
  const context = {
44
42
  bin: {
45
- next: getNextBin(),
43
+ vite: getViteBin(),
46
44
  },
47
45
  directories: {
48
46
  build: path.resolve(process.cwd(), './build'),
@@ -56,7 +54,7 @@ async function getContext() {
56
54
  destination: pino.destination({ dest: 1, sync: true }),
57
55
  }),
58
56
  options: {
59
- port: argv.port ?? env.PORT ?? 3000,
57
+ port: Number(argv.port ?? env.PORT ?? 3000),
60
58
  refResolver: argv.refResolver ?? env.LOWDEFY_BUILD_REF_RESOLVER,
61
59
  watch:
62
60
  argv.watch ?? env.LOWDEFY_SERVER_DEV_WATCH ? JSON.parse(env.LOWDEFY_SERVER_DEV_WATCH) : [],
@@ -87,8 +85,6 @@ async function getContext() {
87
85
  }
88
86
  };
89
87
 
90
- context.compileCss = compileCss(context);
91
- context.nextBuild = nextBuild(context);
92
88
  context.readDotEnv = readDotEnv(context);
93
89
  context.reloadClients = reloadClients(context);
94
90
  context.restartServer = restartServer(context);