@lowdefy/build 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 (102) hide show
  1. package/dist/build/buildAgents.js +61 -16
  2. package/dist/build/buildApi/buildApi.js +4 -1
  3. package/dist/build/buildApi/buildEndpoint.js +8 -2
  4. package/dist/build/buildApi/buildRoutine/countStepTypes.js +1 -1
  5. package/dist/build/buildApi/buildRoutine/setStepId.js +2 -0
  6. package/dist/build/buildApi/buildRoutine/validateStep.js +61 -0
  7. package/dist/build/buildApi/resolveEndpointSchedules.js +40 -0
  8. package/dist/build/buildApi/validateEndpoint.js +96 -1
  9. package/dist/build/buildApp.js +13 -20
  10. package/dist/build/buildAppMeta.js +138 -0
  11. package/dist/build/buildAuth/buildAgentAuth.js +14 -1
  12. package/dist/build/buildAuth/buildAuth.js +8 -3
  13. package/dist/build/buildAuth/buildAuthPlugins.js +6 -0
  14. package/dist/build/buildAuth/buildAuthStrategies.js +117 -0
  15. package/dist/build/buildAuth/buildWebsocketAuth.js +55 -0
  16. package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
  17. package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
  18. package/dist/build/buildAuth/validateAuthConfig.js +15 -3
  19. package/dist/build/buildEnvironments.js +261 -0
  20. package/dist/build/buildImports/buildImportsDev.js +15 -0
  21. package/dist/build/buildImports/buildImportsProd.js +4 -1
  22. package/dist/build/buildJs/generateClientJsModule.js +27 -0
  23. package/dist/build/buildJs/writeJs.js +3 -5
  24. package/dist/build/buildLogger.js +22 -6
  25. package/dist/build/buildMcp.js +73 -0
  26. package/dist/build/buildModuleDefs.js +90 -48
  27. package/dist/build/buildModules.js +61 -14
  28. package/dist/build/buildNotifications.js +98 -0
  29. package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
  30. package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
  31. package/dist/build/buildPages/buildBlock/buildEvents.js +186 -21
  32. package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
  33. package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
  34. package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
  35. package/dist/build/buildPages/buildBlock/validateBlock.js +7 -0
  36. package/dist/build/buildPages/buildPage.js +27 -3
  37. package/dist/build/buildPages/buildSubscriptions.js +145 -0
  38. package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
  39. package/dist/build/buildPages/validateStateReferences.js +4 -2
  40. package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
  41. package/dist/build/buildRefs/buildRefs.js +21 -12
  42. package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
  43. package/dist/build/buildRefs/deferredRegions.js +80 -0
  44. package/dist/build/buildRefs/deferredRegistry.js +292 -0
  45. package/dist/build/buildRefs/getModuleRefContent.js +21 -6
  46. package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
  47. package/dist/build/buildRefs/parseRefContent.js +24 -0
  48. package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
  49. package/dist/build/buildRefs/walker.js +471 -207
  50. package/dist/build/buildTypes.js +22 -1
  51. package/dist/build/buildWebsockets.js +99 -0
  52. package/dist/build/collectPageContent.js +7 -1
  53. package/dist/build/copyAgentFileSystems.js +2 -2
  54. package/dist/build/full/buildPages.js +16 -0
  55. package/dist/build/full/updateServerPackageJson.js +21 -0
  56. package/dist/build/jit/buildPageJit.js +46 -6
  57. package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
  58. package/dist/build/jit/pageContentKeys.js +1 -0
  59. package/dist/build/jit/shallowBuild.js +84 -3
  60. package/dist/build/jit/writePageJit.js +11 -8
  61. package/dist/build/registerModules.js +125 -24
  62. package/dist/build/validateCallAgentSteps.js +58 -0
  63. package/dist/build/validateConfig.js +6 -1
  64. package/dist/build/validateEnvironmentGuards.js +69 -0
  65. package/dist/build/validateRenderNotificationSteps.js +50 -0
  66. package/dist/build/writeApi.js +98 -1
  67. package/dist/build/writeMcp.js +21 -0
  68. package/dist/build/writeNotifications.js +30 -0
  69. package/dist/build/writePluginImports/importPluginModule.js +39 -0
  70. package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
  71. package/dist/build/writePluginImports/writeAuthImports.js +4 -0
  72. package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
  73. package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
  74. package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
  75. package/dist/build/writePluginImports/writeGlobalsCss.js +20 -10
  76. package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
  77. package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
  78. package/dist/build/writePluginImports/writePluginImports.js +18 -4
  79. package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
  80. package/dist/build/writeWebsockets.js +30 -0
  81. package/dist/createContext.js +6 -1
  82. package/dist/defaultPackages.js +7 -1
  83. package/dist/defaultTypesMap.js +688 -388
  84. package/dist/dynamic/buildDynamicBlocks.js +120 -0
  85. package/dist/index.js +81 -10
  86. package/dist/indexDev.js +4 -0
  87. package/dist/lowdefySchema.js +969 -6
  88. package/dist/scripts/generateDefaultTypes.js +5 -2
  89. package/dist/test-utils/expectTerminates.js +33 -0
  90. package/dist/test-utils/runBuild.js +15 -1
  91. package/dist/test-utils/runBuildForSnapshots.js +37 -4
  92. package/dist/test-utils/testContext.js +8 -2
  93. package/dist/utils/countOperators.js +5 -17
  94. package/dist/utils/createPluginTypesMap.js +21 -0
  95. package/dist/utils/getEnvironmentNames.js +26 -0
  96. package/dist/utils/logCollectedErrors.js +15 -9
  97. package/dist/utils/serializeBuildException.js +29 -0
  98. package/dist/utils/validateCronExpression.js +131 -0
  99. package/dist/utils/writeBuildArtifact.js +5 -2
  100. package/package.json +67 -60
  101. package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
  102. /package/dist/build/{resolveModuleOperators.js → buildRefs/scopeMenuItemIds.js} +0 -0
@@ -23,7 +23,43 @@ const BROWSER_DEFAULT_SHORTCUTS = new Set([
23
23
  'mod+q',
24
24
  'mod+l'
25
25
  ]);
26
- function checkAction(action, { blockId, callApiActionRefs, checkDuplicateActionId, eventId, linkActionRefs, pageId, requestActionRefs, typeCounters }) {
26
+ const CONTROL_KEYS = [
27
+ ':if',
28
+ ':switch',
29
+ ':return'
30
+ ];
31
+ const ACTION_KEYS_NOT_ALLOWED_ON_CONTROLS = [
32
+ 'id',
33
+ 'skip',
34
+ 'messages'
35
+ ];
36
+ const CONTROL_ALLOWED_KEYS = {
37
+ ':if': [
38
+ ':if',
39
+ ':then',
40
+ ':else'
41
+ ],
42
+ ':return': [
43
+ ':return'
44
+ ],
45
+ ':switch': [
46
+ ':switch',
47
+ ':default'
48
+ ]
49
+ };
50
+ const CASE_ALLOWED_KEYS = [
51
+ ':case',
52
+ ':then'
53
+ ];
54
+ // '~'-prefixed keys are build meta markers (~k, ~r, ~l, ~ignoreBuildChecks) and
55
+ // never user config - tolerate them by prefix, as the rest of the build does.
56
+ function isMetaKey(key) {
57
+ return key.startsWith('~');
58
+ }
59
+ function isControl(item) {
60
+ return type.isObject(item) && CONTROL_KEYS.some((key)=>key in item);
61
+ }
62
+ function checkAction(action, { blockId, callApiActionRefs, checkDuplicateActionId, eventId, linkActionRefs, pageId, requestActionRefs, typeCounters, websocketActionRefs }) {
27
63
  const configKey = action['~k'];
28
64
  if (type.isUndefined(action.id)) {
29
65
  throw new ConfigError(`Action id missing on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
@@ -108,6 +144,142 @@ function checkAction(action, { blockId, callApiActionRefs, checkDuplicateActionI
108
144
  });
109
145
  }
110
146
  }
147
+ // Collect static Subscribe/Unsubscribe/Publish action references for validation
148
+ if ([
149
+ 'Subscribe',
150
+ 'Unsubscribe'
151
+ ].includes(action.type) && type.isString(action.params)) {
152
+ (websocketActionRefs ?? []).push({
153
+ websocketId: action.params,
154
+ action,
155
+ actionType: action.type,
156
+ blockId,
157
+ eventId,
158
+ sourcePageId: pageId
159
+ });
160
+ }
161
+ if (action.type === 'Publish' && type.isObject(action.params) && type.isString(action.params.websocketId)) {
162
+ (websocketActionRefs ?? []).push({
163
+ websocketId: action.params.websocketId,
164
+ action,
165
+ actionType: action.type,
166
+ blockId,
167
+ eventId,
168
+ sourcePageId: pageId
169
+ });
170
+ }
171
+ }
172
+ function checkBranchList({ list, listName, configKey }, ctx) {
173
+ const { blockId, eventId, pageId } = ctx;
174
+ if (!type.isArray(list)) {
175
+ throw new ConfigError(`Control "${listName}" must be an array on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
176
+ received: list,
177
+ configKey
178
+ });
179
+ }
180
+ checkActionList(list, ctx);
181
+ }
182
+ function checkControl(control, ctx) {
183
+ const { blockId, eventId, pageId } = ctx;
184
+ const configKey = control['~k'];
185
+ const controlKeys = CONTROL_KEYS.filter((key)=>key in control);
186
+ if (controlKeys.length > 1) {
187
+ const keyList = controlKeys.map((key)=>`"${key}"`).join(', ');
188
+ throw new ConfigError(`Control has more than one control key (${keyList}) on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
189
+ received: controlKeys,
190
+ configKey
191
+ });
192
+ }
193
+ const [controlKey] = controlKeys;
194
+ const invalidKeys = Object.keys(control).filter((key)=>!CONTROL_ALLOWED_KEYS[controlKey].includes(key) && !isMetaKey(key));
195
+ if (invalidKeys.length > 0) {
196
+ // Action keys do nothing on a control - call out the likely mistake directly.
197
+ if (ACTION_KEYS_NOT_ALLOWED_ON_CONTROLS.includes(invalidKeys[0])) {
198
+ throw new ConfigError(`Control "${controlKey}" can not have action property "${invalidKeys[0]}" on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
199
+ received: invalidKeys,
200
+ configKey
201
+ });
202
+ }
203
+ throw new ConfigError(`Control "${controlKey}" has invalid key "${invalidKeys[0]}" on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
204
+ received: invalidKeys,
205
+ configKey
206
+ });
207
+ }
208
+ if (controlKey === ':if') {
209
+ if (type.isNone(control[':then'])) {
210
+ throw new ConfigError(`Control ":if" requires a ":then" list on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
211
+ configKey
212
+ });
213
+ }
214
+ checkBranchList({
215
+ list: control[':then'],
216
+ listName: ':then',
217
+ configKey
218
+ }, ctx);
219
+ if (!type.isNone(control[':else'])) {
220
+ checkBranchList({
221
+ list: control[':else'],
222
+ listName: ':else',
223
+ configKey
224
+ }, ctx);
225
+ }
226
+ }
227
+ if (controlKey === ':switch') {
228
+ if (!type.isArray(control[':switch'])) {
229
+ throw new ConfigError(`Control ":switch" must be an array of case objects on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
230
+ received: control[':switch'],
231
+ configKey
232
+ });
233
+ }
234
+ control[':switch'].forEach((caseObject)=>{
235
+ if (!type.isObject(caseObject)) {
236
+ throw new ConfigError(`Control ":switch" case must be an object on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
237
+ received: caseObject,
238
+ configKey
239
+ });
240
+ }
241
+ const caseConfigKey = caseObject['~k'] ?? configKey;
242
+ if (!(':case' in caseObject)) {
243
+ throw new ConfigError(`Control ":switch" case requires a ":case" condition on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
244
+ configKey: caseConfigKey
245
+ });
246
+ }
247
+ if (type.isNone(caseObject[':then'])) {
248
+ throw new ConfigError(`Control ":case" requires a ":then" list on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
249
+ configKey: caseConfigKey
250
+ });
251
+ }
252
+ const invalidCaseKeys = Object.keys(caseObject).filter((key)=>!CASE_ALLOWED_KEYS.includes(key) && !isMetaKey(key));
253
+ if (invalidCaseKeys.length > 0) {
254
+ throw new ConfigError(`Control ":switch" case has invalid key "${invalidCaseKeys[0]}" on event "${eventId}" on block "${blockId}" on page "${pageId}".`, {
255
+ received: invalidCaseKeys,
256
+ configKey: caseConfigKey
257
+ });
258
+ }
259
+ checkBranchList({
260
+ list: caseObject[':then'],
261
+ listName: ':then',
262
+ configKey: caseConfigKey
263
+ }, ctx);
264
+ });
265
+ if (!type.isNone(control[':default'])) {
266
+ checkBranchList({
267
+ list: control[':default'],
268
+ listName: ':default',
269
+ configKey
270
+ }, ctx);
271
+ }
272
+ }
273
+ // ':return' takes a value, not a list - nothing more to validate.
274
+ }
275
+ function checkActionList(list, ctx) {
276
+ list.forEach((item)=>{
277
+ if (isControl(item)) {
278
+ checkControl(item, ctx);
279
+ } else {
280
+ checkAction(item, ctx);
281
+ }
282
+ });
111
283
  }
112
284
  function buildEvents(block, pageContext) {
113
285
  if (block.events) {
@@ -143,26 +315,19 @@ function buildEvents(block, pageContext) {
143
315
  const checkDuplicateActionId = createCheckDuplicateId({
144
316
  message: 'Duplicate actionId "{{ id }}" on event "{{ eventId }}" on block "{{ blockId }}" on page "{{ pageId }}".'
145
317
  });
146
- block.events[key].try.map((action)=>checkAction(action, {
147
- eventId: key,
148
- blockId: block.blockId,
149
- callApiActionRefs: pageContext.callApiActionRefs,
150
- typeCounters: pageContext.typeCounters,
151
- pageId: pageContext.pageId,
152
- linkActionRefs: pageContext.linkActionRefs,
153
- requestActionRefs: pageContext.requestActionRefs,
154
- checkDuplicateActionId
155
- }));
156
- block.events[key].catch.map((action)=>checkAction(action, {
157
- eventId: key,
158
- blockId: block.blockId,
159
- callApiActionRefs: pageContext.callApiActionRefs,
160
- typeCounters: pageContext.typeCounters,
161
- pageId: pageContext.pageId,
162
- linkActionRefs: pageContext.linkActionRefs,
163
- requestActionRefs: pageContext.requestActionRefs,
164
- checkDuplicateActionId
165
- }));
318
+ const actionContext = {
319
+ eventId: key,
320
+ blockId: block.blockId,
321
+ callApiActionRefs: pageContext.callApiActionRefs,
322
+ typeCounters: pageContext.typeCounters,
323
+ pageId: pageContext.pageId,
324
+ linkActionRefs: pageContext.linkActionRefs,
325
+ requestActionRefs: pageContext.requestActionRefs,
326
+ websocketActionRefs: pageContext.websocketActionRefs,
327
+ checkDuplicateActionId
328
+ };
329
+ checkActionList(block.events[key].try, actionContext);
330
+ checkActionList(block.events[key].catch, actionContext);
166
331
  // Validate shortcut strings and collect refs for duplicate detection
167
332
  if (type.isObject(block.events[key]) && !type.isNone(block.events[key].shortcut)) {
168
333
  const shortcuts = type.isArray(block.events[key].shortcut) ? block.events[key].shortcut : [
@@ -75,6 +75,15 @@ function buildRequest(request, pageContext) {
75
75
  pageContext.requests.push(request);
76
76
  }
77
77
  function buildRequests(block, pageContext) {
78
+ // Runtime-resolved dynamic content cannot define requests — request
79
+ // artifacts are written at build time, so a runtime request would have no
80
+ // server-side properties to execute.
81
+ if (pageContext.forbidRequests === true && !type.isNone(block.requests)) {
82
+ throw new ConfigError(`Dynamic content must not define requests — found "requests" on block "${block.blockId}" on page "${pageContext.pageId}". Reference requests defined statically on the page instead.`, {
83
+ received: block.requests,
84
+ configKey: block['~k']
85
+ });
86
+ }
78
87
  (block.requests || []).forEach((request)=>{
79
88
  buildRequest(request, pageContext);
80
89
  });
@@ -12,7 +12,7 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import { type } from '@lowdefy/helpers';
15
+ */ import { getOperatorType, type } from '@lowdefy/helpers';
16
16
  import { ConfigError } from '@lowdefy/errors';
17
17
  const breakpointKeys = new Set([
18
18
  'xs',
@@ -22,17 +22,8 @@ const breakpointKeys = new Set([
22
22
  'xl',
23
23
  '2xl'
24
24
  ]);
25
- // Keys that look like operators (single key starting with _) but are not.
26
- const KNOWN_NON_OPERATORS = new Set([
27
- '_id'
28
- ]);
29
25
  function isOperator(value) {
30
- const nonTildeKeys = Object.keys(value).filter((k)=>!k.startsWith('~'));
31
- if (nonTildeKeys.length !== 1) return false;
32
- const key = nonTildeKeys[0];
33
- const [op] = key.split('.');
34
- const operator = op.replace(/^(_+)/gm, '_');
35
- return operator.length > 1 && operator[0] === '_' && !KNOWN_NON_OPERATORS.has(operator);
26
+ return getOperatorType(value) !== null;
36
27
  }
37
28
  function stripDotPrefix(key) {
38
29
  return key.startsWith('.') ? key.slice(1) : key;
@@ -13,14 +13,17 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ConfigError } from '@lowdefy/errors';
16
- function setBlockId(block, { pageId, blockIdCounter }) {
16
+ function setBlockId(block, { pageId, blockIdCounter, blockIdPrefix }) {
17
17
  block.blockId = block.id;
18
18
  if (block.blockId === pageId && blockIdCounter.getCount(block.blockId) > 0) {
19
19
  throw new ConfigError(`Block id "${block.blockId}" on page "${pageId}" collides with the page id. A block cannot have the same id as its page.`, {
20
20
  configKey: block['~k']
21
21
  });
22
22
  }
23
- block.id = `block:${pageId}:${block.blockId}:${blockIdCounter.getCount(block.blockId)}`;
23
+ // blockIdPrefix namespaces runtime-built dynamic content ids under the
24
+ // resolving Dynamic block's id so they can never collide with static ids.
25
+ const prefix = blockIdPrefix ?? `block:${pageId}`;
26
+ block.id = `${prefix}:${block.blockId}:${blockIdCounter.getCount(block.blockId)}`;
24
27
  blockIdCounter.increment(block.blockId);
25
28
  }
26
29
  export default setBlockId;
@@ -63,5 +63,12 @@ function validateBlock(block, { pageId }, parentConfigKey) {
63
63
  });
64
64
  }
65
65
  }
66
+ // Page subscriptions are extracted before block building — any subscriptions
67
+ // key reaching here is defined on a nested block, where it has no effect.
68
+ if (!type.isNone(block.subscriptions)) {
69
+ throw new ConfigError(`Subscriptions are only allowed on the page, not on block "${block.id}" on page "${pageId}".`, {
70
+ configKey
71
+ });
72
+ }
66
73
  }
67
74
  export default validateBlock;
@@ -15,6 +15,7 @@
15
15
  */ import { type } from '@lowdefy/helpers';
16
16
  import { ConfigError, ConfigWarning } from '@lowdefy/errors';
17
17
  import buildBlock from './buildBlock/buildBlock.js';
18
+ import buildSubscriptions from './buildSubscriptions.js';
18
19
  import collectExceptions from '../../utils/collectExceptions.js';
19
20
  import createCheckDuplicateId from '../../utils/createCheckDuplicateId.js';
20
21
  import validateId from '../../utils/validateId.js';
@@ -54,10 +55,16 @@ function buildPage({ page, index, context, checkDuplicatePageId }) {
54
55
  const requests = [];
55
56
  const requestActionRefs = [];
56
57
  const shortcutRefs = [];
57
- buildBlock(page, {
58
+ // Extract subscriptions before block building — validateBlock rejects the
59
+ // subscriptions key on nested blocks, so the page root must not carry it.
60
+ const subscriptions = page.subscriptions;
61
+ delete page.subscriptions;
62
+ const pageContext = {
58
63
  auth: page.auth,
59
64
  blockIdCounter: createCounter(),
60
65
  callApiActionRefs: context.callApiActionRefs ?? [],
66
+ websocketActionRefs: context.websocketActionRefs ?? [],
67
+ dynamicBlockRefs: context.dynamicBlockRefs ?? [],
61
68
  checkDuplicateRequestId: createCheckDuplicateId({
62
69
  message: 'Duplicate requestId "{{ id }}" on page "{{ pageId }}".'
63
70
  }),
@@ -68,9 +75,26 @@ function buildPage({ page, index, context, checkDuplicatePageId }) {
68
75
  shortcutRefs,
69
76
  linkActionRefs: context.linkActionRefs,
70
77
  typeCounters: context.typeCounters
71
- });
78
+ };
79
+ buildBlock(page, pageContext);
72
80
  // set page.id since buildBlock sets id as well.
73
81
  page.id = `page:${page.pageId}`;
82
+ // Flag pages with Dynamic blocks so the server can skip resolution
83
+ // (and the deep copy it requires) for static pages with one property read.
84
+ if (pageContext.hasDynamicBlocks === true) {
85
+ page.dynamic = true;
86
+ }
87
+ page.subscriptions = subscriptions;
88
+ buildSubscriptions(page, {
89
+ callApiActionRefs: context.callApiActionRefs ?? [],
90
+ context,
91
+ linkActionRefs: context.linkActionRefs,
92
+ pageId: page.pageId,
93
+ requestActionRefs,
94
+ shortcutRefs,
95
+ typeCounters: context.typeCounters,
96
+ websocketActionRefs: context.websocketActionRefs ?? []
97
+ });
74
98
  // Validate that all Request actions reference defined requests
75
99
  validateRequestReferences({
76
100
  requestActionRefs,
@@ -79,7 +103,7 @@ function buildPage({ page, index, context, checkDuplicatePageId }) {
79
103
  context
80
104
  });
81
105
  // Warn on duplicate shortcuts within the page
82
- const seenShortcuts = {};
106
+ const seenShortcuts = Object.create(null);
83
107
  shortcutRefs.forEach(({ shortcut, blockId, eventId, configKey })=>{
84
108
  if (seenShortcuts[shortcut]) {
85
109
  context.handleWarning(new ConfigWarning(`Duplicate shortcut "${shortcut}" on event "${eventId}" on block "${blockId}" on page "${page.pageId}" — already defined on block "${seenShortcuts[shortcut].blockId}".`, {
@@ -0,0 +1,145 @@
1
+ /* eslint-disable no-param-reassign */ /*
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
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError, ConfigWarning } from '@lowdefy/errors';
17
+ import buildEvents from './buildBlock/buildEvents.js';
18
+ import countOperators from '../../utils/countOperators.js';
19
+ import createCheckDuplicateId from '../../utils/createCheckDuplicateId.js';
20
+ const SUBSCRIPTION_EVENTS = [
21
+ 'onMessage',
22
+ 'onSubscribe',
23
+ 'onError'
24
+ ];
25
+ const DEFAULT_MAX_MESSAGES = 100;
26
+ const DEFAULT_THROTTLE_RENDER = 250;
27
+ const MIN_THROTTLE_RENDER = 100;
28
+ function buildSubscription({ subscription, pageContext }) {
29
+ const { context, pageId } = pageContext;
30
+ const configKey = subscription['~k'];
31
+ if (type.isUndefined(subscription.websocketId)) {
32
+ throw new ConfigError(`Subscription websocketId missing at page "${pageId}".`, {
33
+ configKey
34
+ });
35
+ }
36
+ if (!type.isString(subscription.websocketId)) {
37
+ throw new ConfigError(`Subscription websocketId is not a string at page "${pageId}".`, {
38
+ received: subscription.websocketId,
39
+ configKey
40
+ });
41
+ }
42
+ pageContext.checkDuplicateSubscriptionId({
43
+ id: subscription.websocketId,
44
+ configKey,
45
+ pageId
46
+ });
47
+ if (!context.websocketIds.has(subscription.websocketId)) {
48
+ throw new ConfigError(`Subscription "${subscription.websocketId}" at page "${pageId}" references a websocket which does not exist.`, {
49
+ configKey
50
+ });
51
+ }
52
+ if (type.isNone(subscription.payload)) {
53
+ subscription.payload = {};
54
+ }
55
+ if (!type.isObject(subscription.payload)) {
56
+ throw new ConfigError(`Subscription "${subscription.websocketId}" at page "${pageId}" payload should be an object.`, {
57
+ received: subscription.payload,
58
+ configKey
59
+ });
60
+ }
61
+ if (type.isNone(subscription.client)) {
62
+ subscription.client = {};
63
+ }
64
+ if (!type.isObject(subscription.client)) {
65
+ throw new ConfigError(`Subscription "${subscription.websocketId}" at page "${pageId}" client should be an object.`, {
66
+ received: subscription.client,
67
+ configKey
68
+ });
69
+ }
70
+ if (type.isNone(subscription.client.maxMessages)) {
71
+ subscription.client.maxMessages = DEFAULT_MAX_MESSAGES;
72
+ }
73
+ if (!type.isInt(subscription.client.maxMessages) || subscription.client.maxMessages < 1) {
74
+ throw new ConfigError(`Subscription "${subscription.websocketId}" at page "${pageId}" client.maxMessages should be a positive integer.`, {
75
+ received: subscription.client.maxMessages,
76
+ configKey
77
+ });
78
+ }
79
+ if (type.isNone(subscription.client.throttleRender)) {
80
+ subscription.client.throttleRender = DEFAULT_THROTTLE_RENDER;
81
+ }
82
+ if (!type.isNumber(subscription.client.throttleRender)) {
83
+ throw new ConfigError(`Subscription "${subscription.websocketId}" at page "${pageId}" client.throttleRender should be a number.`, {
84
+ received: subscription.client.throttleRender,
85
+ configKey
86
+ });
87
+ }
88
+ if (subscription.client.throttleRender < MIN_THROTTLE_RENDER) {
89
+ context.handleWarning(new ConfigWarning(`Subscription "${subscription.websocketId}" at page "${pageId}" client.throttleRender is below the minimum of ${MIN_THROTTLE_RENDER}ms and will be clamped.`, {
90
+ configKey
91
+ }));
92
+ subscription.client.throttleRender = MIN_THROTTLE_RENDER;
93
+ }
94
+ if (type.isNone(subscription.events)) {
95
+ subscription.events = {};
96
+ }
97
+ Object.keys(subscription.events).forEach((eventName)=>{
98
+ if (eventName.startsWith('~')) return;
99
+ if (!SUBSCRIPTION_EVENTS.includes(eventName)) {
100
+ context.handleWarning(new ConfigWarning(`Subscription "${subscription.websocketId}" at page "${pageId}" has event "${eventName}" which will never fire. Supported events: ${SUBSCRIPTION_EVENTS.join(', ')}.`, {
101
+ configKey: subscription.events[eventName]?.['~k'] ?? configKey
102
+ }));
103
+ }
104
+ });
105
+ // Reuse the block event builder — normalizes actions to { try, catch },
106
+ // validates action ids, counts action types, and collects Request action
107
+ // refs so subscription events can trigger page requests.
108
+ buildEvents({
109
+ blockId: `subscription:${subscription.websocketId}`,
110
+ events: subscription.events,
111
+ '~k': configKey
112
+ }, pageContext);
113
+ // Subscriptions are extracted from the page before buildBlock runs, so
114
+ // countBlockOperators never sees them — count client operators here.
115
+ // Payload and event action params are both evaluated client-side.
116
+ countOperators({
117
+ events: subscription.events,
118
+ payload: subscription.payload
119
+ }, {
120
+ counter: pageContext.typeCounters.operators.client
121
+ });
122
+ subscription.pageId = pageId;
123
+ subscription.id = `subscription:${pageId}:${subscription.websocketId}`;
124
+ }
125
+ function buildSubscriptions(page, pageContext) {
126
+ if (type.isNone(page.subscriptions)) {
127
+ page.subscriptions = [];
128
+ }
129
+ if (!type.isArray(page.subscriptions)) {
130
+ throw new ConfigError(`Subscriptions is not an array at page "${pageContext.pageId}".`, {
131
+ received: page.subscriptions,
132
+ configKey: page['~k']
133
+ });
134
+ }
135
+ pageContext.checkDuplicateSubscriptionId = createCheckDuplicateId({
136
+ message: 'Duplicate subscription "{{ id }}" on page "{{ pageId }}".'
137
+ });
138
+ page.subscriptions.forEach((subscription)=>{
139
+ buildSubscription({
140
+ subscription,
141
+ pageContext
142
+ });
143
+ });
144
+ }
145
+ export default buildSubscriptions;
@@ -0,0 +1,30 @@
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
+ */ import { ConfigWarning } from '@lowdefy/errors';
16
+ function validateDynamicBlockRefs({ dynamicBlockRefs, endpointConfigs, context }) {
17
+ // Both Api and InternalApi endpoints are valid — resolution is an in-process
18
+ // call, so InternalApi is the recommended resolver type.
19
+ const existingEndpointIds = new Set(endpointConfigs.map((config)=>config.endpointId));
20
+ dynamicBlockRefs.forEach(({ endpointId, block, sourcePageId })=>{
21
+ if (!existingEndpointIds.has(endpointId)) {
22
+ context.handleWarning(new ConfigWarning(`Dynamic block "${block.blockId}" on page "${sourcePageId}" references non-existent endpoint "${endpointId}".`, {
23
+ configKey: block['~k'],
24
+ prodError: true,
25
+ checkSlug: 'dynamic-endpoint-refs'
26
+ }));
27
+ }
28
+ });
29
+ }
30
+ export default validateDynamicBlockRefs;
@@ -68,10 +68,12 @@ function validateStateReferences({ page, context }) {
68
68
  stateRefs.forEach((configKey, topLevelKey)=>{
69
69
  // Skip if state key is from an input block or SetState action
70
70
  if (blockIds.has(topLevelKey) || setStateKeys.has(topLevelKey)) return;
71
- const message = `_state references "${topLevelKey}" on page "${page.pageId}", ` + `but no input block with id "${topLevelKey}" exists on this page. ` + `State keys are created from input block ids. ` + `Check for typos, add an input block with this id, or initialize the state with SetState.`;
71
+ const message = `_state references "${topLevelKey}" on page "${page.pageId}", ` + `but no input block with id "${topLevelKey}" exists on this page. ` + `State keys are created from input block ids. ` + `Check for typos, add an input block with this id, or initialize the state with SetState. ` + `If the state is set at runtime (for example by a custom action calling setState), ` + `suppress this check with "~ignoreBuildChecks: [state-refs]" on the reference.`;
72
+ // This check is a heuristic: state written at runtime — custom actions
73
+ // calling setState, dynamic SetState wrappers — is statically invisible,
74
+ // so a miss here must never fail a production build (no prodError).
72
75
  context.handleWarning(new ConfigWarning(message, {
73
76
  configKey,
74
- prodError: true,
75
77
  checkSlug: 'state-refs'
76
78
  }));
77
79
  });
@@ -0,0 +1,30 @@
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
+ */ import { ConfigWarning } from '@lowdefy/errors';
16
+ function validateWebsocketRefs({ websocketActionRefs, websocketIds, context }) {
17
+ websocketActionRefs.forEach(({ websocketId, action, actionType, sourcePageId })=>{
18
+ if (action.skip === true) {
19
+ return;
20
+ }
21
+ if (!websocketIds.has(websocketId)) {
22
+ context.handleWarning(new ConfigWarning(`${actionType} action on page "${sourcePageId}" references non-existent websocket "${websocketId}".`, {
23
+ configKey: action['~k'],
24
+ prodError: true,
25
+ checkSlug: 'websocket-refs'
26
+ }));
27
+ }
28
+ });
29
+ }
30
+ export default validateWebsocketRefs;
@@ -16,7 +16,6 @@
16
16
  import { resolve, WalkContext } from './walker.js';
17
17
  import getRefContent from './getRefContent.js';
18
18
  import makeRefDefinition from './makeRefDefinition.js';
19
- import evaluateStaticOperators from './evaluateStaticOperators.js';
20
19
  import collectDynamicIdentifiers from '../collectDynamicIdentifiers.js';
21
20
  import validateOperatorsDynamic from '../validateOperatorsDynamic.js';
22
21
  import isPageContentPath from '../jit/isPageContentPath.js';
@@ -30,6 +29,9 @@ const dynamicIdentifiers = collectDynamicIdentifiers({
30
29
  async function buildRefs({ context, shallowOptions }) {
31
30
  context.unresolvedRefVars = context.unresolvedRefVars ?? {};
32
31
  const refDef = makeRefDefinition('lowdefy.yaml', null, context.refMap);
32
+ // Stash for Phase 3.5 (precomputeRuntimeOperators) so it can resolve error
33
+ // source file paths without creating a new makeId entry.
34
+ context.rootRefDef = refDef;
33
35
  const ctx = new WalkContext({
34
36
  buildContext: context,
35
37
  refId: refDef.id,
@@ -42,24 +44,31 @@ async function buildRefs({ context, shallowOptions }) {
42
44
  ] : []),
43
45
  operators,
44
46
  env: process.env,
47
+ lowdefyApp: context.appMeta,
45
48
  dynamicIdentifiers,
46
- shouldStop: shallowOptions ? // JIT can re-resolve them from source files. Inline pages (defined
47
- // directly in lowdefy.yaml) live in the root ref and have no separate
48
- // source file — their content must be preserved for buildShallowPages.
49
- (path, refId)=>isPageContentPath(path) && refId !== refDef.id : null
49
+ shouldStop: (path, refId)=>{
50
+ // Module entry vars/connections were already resolved by parseLowdefyYaml
51
+ // (same predicate) and live on context.modules — preserve the raw blobs so
52
+ // the app pass doesn't pull and walk their refs a second time. buildModules
53
+ // reads entry ids only and deletes components.modules before testSchema.
54
+ if (/^modules\.\d+\.vars$/.test(path)) return 'preserve';
55
+ if (/^modules\.\d+\.connections$/.test(path)) return 'preserve';
56
+ if (shallowOptions) {
57
+ // Strip page content (blocks, events, etc.) from ref-backed pages so
58
+ // JIT can re-resolve them from source files. Inline pages (defined
59
+ // directly in lowdefy.yaml) live in the root ref and have no separate
60
+ // source file — their content must be preserved for buildShallowPages.
61
+ return isPageContentPath(path) && refId !== refDef.id;
62
+ }
63
+ return null;
64
+ }
50
65
  });
51
66
  const content = await getRefContent({
52
67
  context,
53
68
  refDef,
54
69
  referencedFrom: null
55
70
  });
56
- let components = await resolve(content, ctx);
57
- // Evaluate static operators (_sum, _if, etc.) that don't depend on runtime data
58
- components = evaluateStaticOperators({
59
- context,
60
- input: components,
61
- refDef
62
- });
71
+ const components = await resolve(content, ctx);
63
72
  return components ?? {};
64
73
  }
65
74
  export default buildRefs;