@lowdefy/engine 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.
package/dist/Actions.js CHANGED
@@ -13,29 +13,40 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ActionError, ConfigError, UserError } from '@lowdefy/errors';
16
- import { type } from '@lowdefy/helpers';
16
+ import { projectCaughtError, type } from '@lowdefy/helpers';
17
17
  import getActionMethods from './actions/getActionMethods.js';
18
+ import { isDecodedServerError } from './decodeServerError.js';
19
+ import { isStopChain } from './stopChain.js';
20
+ const CONTROL_KEYS = [
21
+ ':if',
22
+ ':switch',
23
+ ':return'
24
+ ];
25
+ function isControl(item) {
26
+ return type.isObject(item) && CONTROL_KEYS.some((key)=>key in item);
27
+ }
18
28
  let Actions = class Actions {
19
29
  logActionError({ error, action }) {
20
30
  const handleError = this.context._internal.lowdefy._internal.handleError;
21
- const actionId = action?.id || '';
22
- // Deduplicate by error message + action id
23
- const errorKey = `${error?.message || ''}:${actionId}`;
24
- if (this.loggedActionErrors.has(errorKey)) {
25
- return;
26
- }
27
- this.loggedActionErrors.add(errorKey);
28
- // User-facing errors log to browser console only, never to terminal
31
+ // User-facing errors log to browser console only, never to terminal. This line
32
+ // bypasses handleError, so it dedups here.
29
33
  if (error instanceof UserError) {
34
+ const errorKey = `${error.message}:${action?.id || ''}`;
35
+ if (this.loggedActionErrors.has(errorKey)) {
36
+ return;
37
+ }
38
+ this.loggedActionErrors.add(errorKey);
30
39
  this.context._internal.lowdefy._internal.logger.error(error);
31
40
  return;
32
41
  }
33
- // Lowdefy errors - use handleError (-> terminal)
42
+ // Not deduped here: every server failure carries the same generic message, so a
43
+ // message + action key would hide each later failure of the action. handleError
44
+ // dedups on the message it displays plus the server's requestId.
34
45
  if (handleError) {
35
46
  handleError(error);
36
47
  }
37
48
  }
38
- async callAsyncAction({ action, arrayIndices, block, event, index, responses }) {
49
+ async callAsyncAction({ action, arrayIndices, block, event, index, parseScope, responses }) {
39
50
  try {
40
51
  const response = await this.callAction({
41
52
  action,
@@ -43,6 +54,7 @@ let Actions = class Actions {
43
54
  block,
44
55
  event,
45
56
  index,
57
+ parseScope,
46
58
  responses
47
59
  });
48
60
  responses[action.id] = response;
@@ -52,16 +64,46 @@ let Actions = class Actions {
52
64
  this.logActionError(err);
53
65
  }
54
66
  }
55
- async callActionLoop({ actions, arrayIndices, block, event, progress, responses }) {
56
- for (const [index, action] of actions.entries()){
67
+ // Returns true when a ':return' control or an action ended the list, so callers can
68
+ // end the event.
69
+ async callActionLoop({ actions, arrayIndices, block, controls, counters, event, parseScope, progress, responses }) {
70
+ for (const [position, action] of actions.entries()){
71
+ if (isControl(action)) {
72
+ const returned = await this.callControl({
73
+ arrayIndices,
74
+ block,
75
+ control: action,
76
+ controls,
77
+ counters,
78
+ event,
79
+ parseScope,
80
+ progress,
81
+ responses
82
+ });
83
+ if (returned === true) {
84
+ this.recordSkippedActions({
85
+ actions: actions.slice(position + 1),
86
+ counters,
87
+ responses
88
+ });
89
+ return true;
90
+ }
91
+ continue;
92
+ }
93
+ const index = counters.action;
94
+ counters.action += 1;
57
95
  try {
58
96
  if (action.async === true) {
97
+ // Fire and forget - the response is never awaited here, so an 'async: true'
98
+ // action cannot end the chain even if it returns a stopChain marker. The
99
+ // following steps have already run by the time it resolves.
59
100
  this.callAsyncAction({
60
101
  action,
61
102
  arrayIndices,
62
103
  block,
63
104
  event,
64
105
  index,
106
+ parseScope,
65
107
  progress,
66
108
  responses
67
109
  });
@@ -72,10 +114,23 @@ let Actions = class Actions {
72
114
  block,
73
115
  event,
74
116
  index,
117
+ parseScope,
75
118
  progress,
76
119
  responses
77
120
  });
78
121
  responses[action.id] = response;
122
+ // An action that navigated the browser away ends the chain, reported as a
123
+ // success. Same halt the ':return' control uses above - remaining steps are
124
+ // recorded as skipped, and callActions is never entered through its catch,
125
+ // so 'catch:' actions do not run and no error message displays.
126
+ if (response.stoppedChain === true) {
127
+ this.recordSkippedActions({
128
+ actions: actions.slice(position + 1),
129
+ counters,
130
+ responses
131
+ });
132
+ return true;
133
+ }
79
134
  }
80
135
  } catch (err) {
81
136
  // err is already {error, action, index} from callAction
@@ -83,27 +138,264 @@ let Actions = class Actions {
83
138
  throw err;
84
139
  }
85
140
  }
141
+ return false;
142
+ }
143
+ async callControl({ arrayIndices, block, control, controls, counters, event, parseScope, progress, responses }) {
144
+ const index = counters.control;
145
+ counters.control += 1;
146
+ if (':return' in control) {
147
+ const value = this.evaluateControlValue({
148
+ arrayIndices,
149
+ block,
150
+ event,
151
+ input: control[':return'],
152
+ node: control,
153
+ parseScope,
154
+ responses
155
+ });
156
+ controls.push({
157
+ index,
158
+ type: ':return',
159
+ taken: value
160
+ });
161
+ return true;
162
+ }
163
+ if (':if' in control) {
164
+ const condition = this.evaluateControlValue({
165
+ arrayIndices,
166
+ block,
167
+ event,
168
+ input: control[':if'],
169
+ node: control,
170
+ parseScope,
171
+ responses
172
+ });
173
+ // JS truthiness, matching the routine ':if' - not skip's strict === true.
174
+ if (condition) {
175
+ controls.push({
176
+ index,
177
+ type: ':if',
178
+ taken: 'then'
179
+ });
180
+ const returned = await this.callActionLoop({
181
+ actions: control[':then'],
182
+ arrayIndices,
183
+ block,
184
+ controls,
185
+ counters,
186
+ event,
187
+ parseScope,
188
+ progress,
189
+ responses
190
+ });
191
+ this.recordSkippedActions({
192
+ actions: control[':else'] ?? [],
193
+ counters,
194
+ responses
195
+ });
196
+ return returned;
197
+ }
198
+ controls.push({
199
+ index,
200
+ type: ':if',
201
+ taken: 'else'
202
+ });
203
+ this.recordSkippedActions({
204
+ actions: control[':then'],
205
+ counters,
206
+ responses
207
+ });
208
+ return this.callActionLoop({
209
+ actions: control[':else'] ?? [],
210
+ arrayIndices,
211
+ block,
212
+ controls,
213
+ counters,
214
+ event,
215
+ parseScope,
216
+ progress,
217
+ responses
218
+ });
219
+ }
220
+ // ':switch' - cases are evaluated in order and lazily: the first truthy ':case' wins,
221
+ // later cases are never evaluated, matching the routine ':switch'.
222
+ let matched = false;
223
+ let returned = false;
224
+ for (const [casePosition, caseObject] of control[':switch'].entries()){
225
+ if (!matched) {
226
+ const condition = this.evaluateControlValue({
227
+ arrayIndices,
228
+ block,
229
+ event,
230
+ input: caseObject[':case'],
231
+ node: caseObject,
232
+ parseScope,
233
+ responses
234
+ });
235
+ if (condition) {
236
+ matched = true;
237
+ controls.push({
238
+ index,
239
+ type: ':switch',
240
+ taken: casePosition
241
+ });
242
+ returned = await this.callActionLoop({
243
+ actions: caseObject[':then'],
244
+ arrayIndices,
245
+ block,
246
+ controls,
247
+ counters,
248
+ event,
249
+ parseScope,
250
+ progress,
251
+ responses
252
+ });
253
+ continue;
254
+ }
255
+ }
256
+ this.recordSkippedActions({
257
+ actions: caseObject[':then'],
258
+ counters,
259
+ responses
260
+ });
261
+ }
262
+ if (matched) {
263
+ this.recordSkippedActions({
264
+ actions: control[':default'] ?? [],
265
+ counters,
266
+ responses
267
+ });
268
+ return returned;
269
+ }
270
+ controls.push({
271
+ index,
272
+ type: ':switch',
273
+ taken: 'default'
274
+ });
275
+ return this.callActionLoop({
276
+ actions: control[':default'] ?? [],
277
+ arrayIndices,
278
+ block,
279
+ controls,
280
+ counters,
281
+ event,
282
+ parseScope,
283
+ progress,
284
+ responses
285
+ });
286
+ }
287
+ evaluateControlValue({ arrayIndices, block, event, input, node, parseScope, responses }) {
288
+ const { output, errors: parserErrors } = this.context._internal.parser.parse({
289
+ ...parseScope,
290
+ actions: responses,
291
+ event,
292
+ arrayIndices,
293
+ input,
294
+ location: block.blockId
295
+ });
296
+ if (parserErrors.length > 0) {
297
+ const error = parserErrors[0];
298
+ // Report against the nearest node's '~k' when the operator carries none.
299
+ if (type.isNone(error.configKey)) {
300
+ error.configKey = node['~k'];
301
+ }
302
+ // Controls are anonymous - no responses entry, so only {error} is thrown.
303
+ throw {
304
+ error
305
+ };
306
+ }
307
+ return output;
308
+ }
309
+ // Records actions the chain does not execute for a control-flow reason as skipped,
310
+ // without parsing their operators. Controls record no responses entry, but still
311
+ // consume a control index so reached controls keep their depth-first numbering.
312
+ recordSkippedActions({ actions, counters, responses }) {
313
+ for (const action of actions){
314
+ if (isControl(action)) {
315
+ counters.control += 1;
316
+ if (':if' in action) {
317
+ this.recordSkippedActions({
318
+ actions: action[':then'],
319
+ counters,
320
+ responses
321
+ });
322
+ this.recordSkippedActions({
323
+ actions: action[':else'] ?? [],
324
+ counters,
325
+ responses
326
+ });
327
+ }
328
+ if (':switch' in action) {
329
+ action[':switch'].forEach((caseObject)=>{
330
+ this.recordSkippedActions({
331
+ actions: caseObject[':then'],
332
+ counters,
333
+ responses
334
+ });
335
+ });
336
+ this.recordSkippedActions({
337
+ actions: action[':default'] ?? [],
338
+ counters,
339
+ responses
340
+ });
341
+ }
342
+ continue;
343
+ }
344
+ responses[action.id] = {
345
+ type: action.type,
346
+ skipped: true,
347
+ index: counters.action
348
+ };
349
+ counters.action += 1;
350
+ }
86
351
  }
87
352
  async callActions({ actions, arrayIndices, block, catchActions, event, eventName, progress }) {
88
353
  const startTimestamp = new Date();
89
354
  const responses = {};
355
+ // Only events with controls gain a 'controls' array - flat chains keep their result shape.
356
+ const hasControls = actions.some(isControl) || catchActions.some(isControl);
357
+ const controls = hasControls ? [] : undefined;
358
+ const counters = {
359
+ action: 0,
360
+ control: 0
361
+ };
90
362
  try {
91
363
  await this.callActionLoop({
92
364
  actions,
93
365
  arrayIndices,
94
366
  block,
367
+ controls,
368
+ counters,
95
369
  event,
370
+ parseScope: {
371
+ error: null
372
+ },
96
373
  responses,
97
374
  progress
98
375
  });
99
376
  } catch (error) {
100
377
  this.logActionError(error);
378
+ // A server error is already the wire shape, generic unless the author wrote
379
+ // it. A client-side error was built in the browser from data it already
380
+ // holds, so it keeps its real message - only its fields are narrowed.
381
+ const thrown = error.error;
382
+ const caught = isDecodedServerError(thrown) ? thrown : Object.assign(projectCaughtError(thrown), {
383
+ actionId: error.action?.id
384
+ });
385
+ // Catch actions restart action numbering, matching flat-chain history; the control
386
+ // counter continues so every control entry keeps a unique index within the event.
387
+ counters.action = 0;
101
388
  try {
102
389
  await this.callActionLoop({
103
390
  actions: catchActions,
104
391
  arrayIndices,
105
392
  block,
393
+ controls,
394
+ counters,
106
395
  event,
396
+ parseScope: {
397
+ error: caught
398
+ },
107
399
  responses,
108
400
  progress
109
401
  });
@@ -112,6 +404,9 @@ let Actions = class Actions {
112
404
  return {
113
405
  blockId: block.blockId,
114
406
  bounced: false,
407
+ ...controls && {
408
+ controls
409
+ },
115
410
  endTimestamp: new Date(),
116
411
  error,
117
412
  errorCatch,
@@ -125,6 +420,9 @@ let Actions = class Actions {
125
420
  return {
126
421
  blockId: block.blockId,
127
422
  bounced: false,
423
+ ...controls && {
424
+ controls
425
+ },
128
426
  endTimestamp: new Date(),
129
427
  error,
130
428
  event,
@@ -137,6 +435,9 @@ let Actions = class Actions {
137
435
  return {
138
436
  blockId: block.blockId,
139
437
  bounced: false,
438
+ ...controls && {
439
+ controls
440
+ },
140
441
  endTimestamp: new Date(),
141
442
  event,
142
443
  eventName,
@@ -145,7 +446,7 @@ let Actions = class Actions {
145
446
  success: true
146
447
  };
147
448
  }
148
- async callAction({ action, arrayIndices, block, event, index, progress, responses }) {
449
+ async callAction({ action, arrayIndices, block, event, index, parseScope, progress, responses }) {
149
450
  if (!this.actions[action.type]) {
150
451
  const error = new ConfigError(`Invalid action type "${action.type}" at "${block.blockId}".`, {
151
452
  configKey: action['~k']
@@ -157,6 +458,7 @@ let Actions = class Actions {
157
458
  };
158
459
  }
159
460
  const { output: parsedAction, errors: parserErrors } = this.context._internal.parser.parse({
461
+ ...parseScope,
160
462
  actions: responses,
161
463
  event,
162
464
  arrayIndices,
@@ -217,6 +519,7 @@ let Actions = class Actions {
217
519
  type: action.type
218
520
  };
219
521
  const { output: parsedMessages, errors: parserErrors } = this.context._internal.parser.parse({
522
+ ...parseScope,
220
523
  actions: responses,
221
524
  event,
222
525
  arrayIndices,
@@ -251,6 +554,17 @@ let Actions = class Actions {
251
554
  message: messages.success,
252
555
  status: 'success'
253
556
  });
557
+ // Unwrap here, after the success message: an action that ends the chain is a
558
+ // success like any other. Only the inner value reaches '_actions.<id>.response',
559
+ // so the marker never becomes app-visible state.
560
+ if (isStopChain(response)) {
561
+ return {
562
+ type: action.type,
563
+ response: response.response,
564
+ index,
565
+ stoppedChain: true
566
+ };
567
+ }
254
568
  return {
255
569
  type: action.type,
256
570
  response,
@@ -273,6 +587,7 @@ let Actions = class Actions {
273
587
  this.callAction = this.callAction.bind(this);
274
588
  this.callActionLoop = this.callActionLoop.bind(this);
275
589
  this.callActions = this.callActions.bind(this);
590
+ this.callControl = this.callControl.bind(this);
276
591
  this.displayMessage = this.displayMessage.bind(this);
277
592
  this.logActionError = this.logActionError.bind(this);
278
593
  this.actions = context._internal.lowdefy._internal.actions;
package/dist/Block.js CHANGED
@@ -442,7 +442,10 @@ let Block = class Block {
442
442
  value: type.isNone(this.value) ? null : this.value,
443
443
  visible: this.visibleEval.output
444
444
  };
445
- this.context._internal.lowdefy._internal.updateBlock(this.id);
445
+ // Updaters register per context — a context under construction has none,
446
+ // so construction-time evals never setState mounted components from a
447
+ // previous context.
448
+ this.context._internal.updaters[this.id]?.();
446
449
  });
447
450
  const { id, blockId, class: blockClass, events, layout, loading, properties, required, skeleton, style, validate, visible, type: blockType, slots } = blockConfig;
448
451
  this.context = context;
@@ -0,0 +1,242 @@
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 { serializer, type } from '@lowdefy/helpers';
16
+ import Events from './Events.js';
17
+ const DEFAULT_MAX_MESSAGES = 100;
18
+ const DEFAULT_THROTTLE_RENDER = 250;
19
+ const MIN_THROTTLE_RENDER = 100;
20
+ let WebSockets = class WebSockets {
21
+ client() {
22
+ return this.context._internal.lowdefy._internal.websocketClient;
23
+ }
24
+ initChannelState(websocketId) {
25
+ this.context.websockets[websocketId] = {
26
+ connected: false,
27
+ error: null,
28
+ lastMessage: null,
29
+ messageCount: 0,
30
+ messages: []
31
+ };
32
+ }
33
+ getEvents(websocketId, config) {
34
+ if (!this.subscriptionEvents[websocketId]) {
35
+ this.subscriptionEvents[websocketId] = new Events({
36
+ arrayIndices: [],
37
+ block: {
38
+ blockId: `subscription:${websocketId}`,
39
+ events: config.events ?? {}
40
+ },
41
+ context: this.context
42
+ });
43
+ }
44
+ return this.subscriptionEvents[websocketId];
45
+ }
46
+ flush({ websocketId, maxMessages }) {
47
+ const buffer = this.buffers[websocketId];
48
+ const channel = this.context.websockets[websocketId];
49
+ if (!buffer || buffer.length === 0 || !channel) {
50
+ return;
51
+ }
52
+ const batch = buffer.splice(0, buffer.length);
53
+ channel.messages.push(...batch);
54
+ if (channel.messages.length > maxMessages) {
55
+ channel.messages.splice(0, channel.messages.length - maxMessages);
56
+ }
57
+ channel.lastMessage = batch[batch.length - 1];
58
+ channel.messageCount += batch.length;
59
+ this.context._internal.update();
60
+ this.subscriptionEvents[websocketId]?.triggerEvent({
61
+ name: 'onMessage',
62
+ event: {
63
+ messages: batch
64
+ }
65
+ });
66
+ }
67
+ handleMessage({ websocketId, maxMessages, serializedPayload, throttleMs }) {
68
+ if (!this.active.has(websocketId)) {
69
+ return;
70
+ }
71
+ const data = serializer.deserialize(serializedPayload)?.data;
72
+ this.buffers[websocketId].push(data);
73
+ // Leading-edge throttle: the first message in a window renders
74
+ // immediately, the rest batch until the window closes.
75
+ if (!this.flushTimers[websocketId]) {
76
+ this.flush({
77
+ websocketId,
78
+ maxMessages
79
+ });
80
+ const arm = ()=>{
81
+ this.flushTimers[websocketId] = setTimeout(()=>{
82
+ if ((this.buffers[websocketId] ?? []).length > 0) {
83
+ this.flush({
84
+ websocketId,
85
+ maxMessages
86
+ });
87
+ arm();
88
+ return;
89
+ }
90
+ this.flushTimers[websocketId] = null;
91
+ }, throttleMs);
92
+ };
93
+ arm();
94
+ }
95
+ }
96
+ async subscribe({ actions, arrayIndices, event, websocketId }) {
97
+ if (!type.isString(websocketId)) {
98
+ throw new Error('Subscribe requires a websocketId.');
99
+ }
100
+ if (this.active.has(websocketId)) {
101
+ return;
102
+ }
103
+ const config = this.subscriptionConfig[websocketId] ?? {
104
+ client: {},
105
+ events: {},
106
+ payload: {},
107
+ websocketId
108
+ };
109
+ if (!this.context.websockets[websocketId]) {
110
+ this.initChannelState(websocketId);
111
+ }
112
+ const { output: payload, errors: parserErrors } = this.context._internal.parser.parse({
113
+ actions,
114
+ arrayIndices,
115
+ event,
116
+ input: config.payload ?? {},
117
+ location: `subscription:${websocketId}`
118
+ });
119
+ if (parserErrors.length > 0) {
120
+ throw parserErrors[0];
121
+ }
122
+ const events = this.getEvents(websocketId, config);
123
+ const channel = this.context.websockets[websocketId];
124
+ const throttleMs = Math.max(config.client?.throttleRender ?? DEFAULT_THROTTLE_RENDER, MIN_THROTTLE_RENDER);
125
+ const maxMessages = config.client?.maxMessages ?? DEFAULT_MAX_MESSAGES;
126
+ this.active.add(websocketId);
127
+ this.buffers[websocketId] = [];
128
+ try {
129
+ await this.client().subscribe({
130
+ websocketId,
131
+ payload: serializer.serialize(payload),
132
+ handlers: {
133
+ onConnected: ()=>{
134
+ channel.connected = true;
135
+ channel.error = null;
136
+ this.context._internal.update();
137
+ events.triggerEvent({
138
+ name: 'onSubscribe',
139
+ event: {}
140
+ });
141
+ },
142
+ onDisconnected: ()=>{
143
+ channel.connected = false;
144
+ this.context._internal.update();
145
+ },
146
+ onError: (message)=>{
147
+ channel.error = {
148
+ message
149
+ };
150
+ this.context._internal.update();
151
+ events.triggerEvent({
152
+ name: 'onError',
153
+ event: {
154
+ message
155
+ }
156
+ });
157
+ },
158
+ onMessage: (serializedPayload)=>{
159
+ this.handleMessage({
160
+ websocketId,
161
+ maxMessages,
162
+ serializedPayload,
163
+ throttleMs
164
+ });
165
+ }
166
+ }
167
+ });
168
+ } catch (error) {
169
+ this.active.delete(websocketId);
170
+ channel.error = {
171
+ message: error.message
172
+ };
173
+ this.context._internal.update();
174
+ throw error;
175
+ }
176
+ }
177
+ subscribeAll() {
178
+ Object.keys(this.subscriptionConfig).forEach((websocketId)=>{
179
+ this.subscribe({
180
+ websocketId
181
+ }).catch((error)=>{
182
+ this.context._internal.lowdefy._internal.handleError(error);
183
+ });
184
+ });
185
+ }
186
+ unsubscribe({ websocketId }) {
187
+ if (!type.isString(websocketId)) {
188
+ throw new Error('Unsubscribe requires a websocketId.');
189
+ }
190
+ if (!this.active.has(websocketId)) {
191
+ return;
192
+ }
193
+ this.active.delete(websocketId);
194
+ if (this.flushTimers[websocketId]) {
195
+ clearTimeout(this.flushTimers[websocketId]);
196
+ this.flushTimers[websocketId] = null;
197
+ }
198
+ this.buffers[websocketId] = [];
199
+ this.client().unsubscribe({
200
+ websocketId
201
+ });
202
+ this.initChannelState(websocketId);
203
+ this.context._internal.update();
204
+ }
205
+ unsubscribeAll() {
206
+ [
207
+ ...this.active
208
+ ].forEach((websocketId)=>{
209
+ this.unsubscribe({
210
+ websocketId
211
+ });
212
+ });
213
+ }
214
+ async publish({ payload, websocketId }) {
215
+ if (!type.isString(websocketId)) {
216
+ throw new Error('Publish requires a websocketId.');
217
+ }
218
+ await this.client().publish({
219
+ websocketId,
220
+ payload: serializer.serialize(payload ?? {})
221
+ });
222
+ }
223
+ constructor(context){
224
+ this.context = context;
225
+ this.subscriptionConfig = {};
226
+ this.subscriptionEvents = {};
227
+ this.buffers = {};
228
+ this.flushTimers = {};
229
+ this.active = new Set();
230
+ this.publish = this.publish.bind(this);
231
+ this.subscribe = this.subscribe.bind(this);
232
+ this.subscribeAll = this.subscribeAll.bind(this);
233
+ this.unsubscribe = this.unsubscribe.bind(this);
234
+ this.unsubscribeAll = this.unsubscribeAll.bind(this);
235
+ this.context.websockets = {};
236
+ (this.context._internal.rootBlock.subscriptions ?? []).forEach((subscription)=>{
237
+ this.subscriptionConfig[subscription.websocketId] = subscription;
238
+ this.initChannelState(subscription.websocketId);
239
+ });
240
+ }
241
+ };
242
+ export default WebSockets;
@@ -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
+ */ function createPublish({ context }) {
16
+ return function publish({ payload, websocketId }) {
17
+ return context._internal.WebSockets.publish({
18
+ payload,
19
+ websocketId
20
+ });
21
+ };
22
+ }
23
+ export default createPublish;
@@ -0,0 +1,25 @@
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
+ */ function createSubscribe({ actions, arrayIndices, context, event }) {
16
+ return function subscribe({ websocketId }) {
17
+ return context._internal.WebSockets.subscribe({
18
+ actions,
19
+ arrayIndices,
20
+ event,
21
+ websocketId
22
+ });
23
+ };
24
+ }
25
+ export default createSubscribe;
@@ -0,0 +1,22 @@
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
+ */ function createUnsubscribe({ context }) {
16
+ return function unsubscribe({ websocketId }) {
17
+ return context._internal.WebSockets.unsubscribe({
18
+ websocketId
19
+ });
20
+ };
21
+ }
22
+ export default createUnsubscribe;
@@ -29,12 +29,15 @@ import createLink from './createLink.js';
29
29
  import createLogin from './createLogin.js';
30
30
  import createLogout from './createLogout.js';
31
31
  import createDisplayMessage from './createDisplayMessage.js';
32
+ import createPublish from './createPublish.js';
32
33
  import createRequest from './createRequest.js';
33
34
  import createReset from './createReset.js';
34
35
  import createResetValidation from './createResetValidation.js';
35
36
  import createSetGlobal from './createSetGlobal.js';
36
37
  import createSetState from './createSetState.js';
38
+ import createSubscribe from './createSubscribe.js';
37
39
  import createTranslate from './createTranslate.js';
40
+ import createUnsubscribe from './createUnsubscribe.js';
38
41
  import createUpdateSession from './createUpdateSession.js';
39
42
  import createValidate from './createValidate.js';
40
43
  function getActionMethods(props) {
@@ -56,12 +59,15 @@ function getActionMethods(props) {
56
59
  link: createLink(props),
57
60
  login: createLogin(props),
58
61
  logout: createLogout(props),
62
+ publish: createPublish(props),
59
63
  request: createRequest(props),
60
64
  reset: createReset(props),
61
65
  resetValidation: createResetValidation(props),
62
66
  setGlobal: createSetGlobal(props),
63
67
  setState: createSetState(props),
68
+ subscribe: createSubscribe(props),
64
69
  translate: createTranslate(props),
70
+ unsubscribe: createUnsubscribe(props),
65
71
  updateSession: createUpdateSession(props),
66
72
  validate: createValidate(props)
67
73
  };
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { serializer } from '@lowdefy/helpers';
16
+ import decodeServerError from './decodeServerError.js';
16
17
  async function callAPIHandler(context, { blockId, params }) {
17
18
  if (!context._internal.lowdefy.apiResponses[params.endpointId]) {
18
19
  context._internal.lowdefy.apiResponses[params.endpointId] = [];
@@ -55,7 +56,10 @@ async function callAPIHandler(context, { blockId, params }) {
55
56
  throw error;
56
57
  }
57
58
  const { error, response, status, success } = apiResponse;
58
- api.error = serializer.deserialize(error);
59
+ // Decoded once so the stored and the thrown error are the same object: the
60
+ // dev error is keyed by that object.
61
+ const decodedError = decodeServerError(error);
62
+ api.error = decodedError;
59
63
  api.loading = false;
60
64
  api.response = serializer.deserialize(response);
61
65
  api.status = status;
@@ -64,7 +68,7 @@ async function callAPIHandler(context, { blockId, params }) {
64
68
  api.responseTime = api.endTimestamp - api.startTimestamp;
65
69
  context._internal.update();
66
70
  if (!success) {
67
- throw serializer.deserialize(error);
71
+ throw decodedError;
68
72
  }
69
73
  return api;
70
74
  }
@@ -0,0 +1,43 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { serializer, type } from '@lowdefy/helpers';
16
+ // The decoded error is stored as `request.error` / `api.error` and rethrown,
17
+ // and config reads it through `_actions`, `_request_details` and `_error`. Any
18
+ // property on it - enumerable or not - is one operator change away from config,
19
+ // so the dev server's full error is kept beside it here, keyed by the error
20
+ // itself, where only the dev tools look.
21
+ const devErrors = new WeakMap();
22
+ // A catch list's `_error` passes a server error through as decoded, but
23
+ // projects a client-side error. The two can share a name and class, so the
24
+ // engine tells them apart by identity.
25
+ const decoded = new WeakSet();
26
+ function decodeServerError(payload) {
27
+ if (type.isNone(payload)) return payload;
28
+ const { devError, ...rest } = payload;
29
+ const error = serializer.deserialize(rest);
30
+ if (!type.isNone(devError)) {
31
+ devErrors.set(error, serializer.deserialize(devError));
32
+ }
33
+ decoded.add(error);
34
+ return error;
35
+ }
36
+ function getDevError(error) {
37
+ return devErrors.get(error);
38
+ }
39
+ function isDecodedServerError(error) {
40
+ return decoded.has(error);
41
+ }
42
+ export { getDevError, isDecodedServerError };
43
+ export default decodeServerError;
@@ -17,8 +17,9 @@ import Actions from './Actions.js';
17
17
  import Slots from './Slots.js';
18
18
  import Requests from './Requests.js';
19
19
  import State from './State.js';
20
+ import WebSockets from './WebSockets.js';
20
21
  const blockData = (config)=>{
21
- const { slots, blockId, blocks, events, field, id, layout, pageId, properties, requests, required, style, type, validate, visible } = config;
22
+ const { slots, blockId, blocks, events, field, id, layout, pageId, properties, requests, required, style, subscriptions, type, validate, visible } = config;
22
23
  const result = {
23
24
  slots,
24
25
  blockId,
@@ -32,6 +33,7 @@ const blockData = (config)=>{
32
33
  requests,
33
34
  required,
34
35
  style,
36
+ subscriptions,
35
37
  type,
36
38
  validate,
37
39
  visible
@@ -55,12 +57,24 @@ function getContext({ config, jsMap = {}, lowdefy, resetContext = {
55
57
  throw new Error('A page must be provided to get context.');
56
58
  }
57
59
  const { id } = config;
58
- if (lowdefy.contexts[id] && !resetContext.reset) {
60
+ // Dynamic pages are server-resolved per request — a context memoized across
61
+ // navigations would render the previous request's content. Rebuild when a
62
+ // new config object arrives (a fresh fetch), but stay memoized across
63
+ // re-renders of the same config: getContext runs in the render body, so
64
+ // rebuilding per render would loop.
65
+ const sameDynamicConfig = config.dynamic !== true || lowdefy.contexts[id]?._internal.pageConfig === config;
66
+ if (lowdefy.contexts[id] && !resetContext.reset && sameDynamicConfig) {
59
67
  // memoize context if already created, eg between page transitions, unless the reset flag is raised
60
68
  lowdefy.contexts[id]._internal.update();
61
69
  return lowdefy.contexts[id];
62
70
  }
63
- resetContext.setReset(false); // lower context reset flag.
71
+ // Lower the context reset flag — only when raised: setReset is a React
72
+ // state setter on the Reload component, and getContext runs in the render
73
+ // body, so skip the redundant cross-component setState on rebuilds where
74
+ // the flag is already down.
75
+ if (resetContext.reset) {
76
+ resetContext.setReset(false);
77
+ }
64
78
  if (!lowdefy.inputs[id]) {
65
79
  lowdefy.inputs[id] = {};
66
80
  }
@@ -73,8 +87,16 @@ function getContext({ config, jsMap = {}, lowdefy, resetContext = {
73
87
  state: {},
74
88
  _internal: {
75
89
  lowdefy,
90
+ // Config object reference for dynamic page memoization — identity marks
91
+ // which fetch this context was built from.
92
+ pageConfig: config,
76
93
  rootBlock: blockData(config),
77
- update: ()=>{}
94
+ update: ()=>{},
95
+ // React updaters register here per block id when the context's Block
96
+ // components mount — scoped per context so rebuilding over a live
97
+ // context (dynamic page navigation, reset) never notifies the previous
98
+ // context's still-mounted components.
99
+ updaters: {}
78
100
  }
79
101
  };
80
102
  const _internal = ctx._internal;
@@ -85,6 +107,7 @@ function getContext({ config, jsMap = {}, lowdefy, resetContext = {
85
107
  _internal.State = new State(ctx);
86
108
  _internal.Actions = new Actions(ctx);
87
109
  _internal.Requests = new Requests(ctx);
110
+ _internal.WebSockets = new WebSockets(ctx);
88
111
  _internal.RootSlots = new Slots({
89
112
  slots: {
90
113
  root: {
package/dist/index.js CHANGED
@@ -15,11 +15,13 @@
15
15
  */ import Actions from './Actions.js';
16
16
  import Slots from './Slots.js';
17
17
  import createLink from './createLink.js';
18
+ import decodeServerError, { getDevError } from './decodeServerError.js';
18
19
  import Events from './Events.js';
19
20
  import getContext from './getContext.js';
20
21
  import getHomePathname from './getHomePathname.js';
21
22
  import Requests from './Requests.js';
22
23
  import resolveTarget from './resolveTarget.js';
23
24
  import State from './State.js';
24
- export { Actions, Slots, createLink, Events, getHomePathname, Requests, resolveTarget, State };
25
+ import stopChain from './stopChain.js';
26
+ export { Actions, Slots, createLink, decodeServerError, Events, getDevError, getHomePathname, Requests, resolveTarget, State, stopChain };
25
27
  export default getContext;
@@ -14,6 +14,14 @@
14
14
  limitations under the License.
15
15
  */ import { type, urlQuery as urlQueryFn } from '@lowdefy/helpers';
16
16
  import getHomePathname from './getHomePathname.js';
17
+ // The target's own urlQuery combines with any query the url string already
18
+ // carries, matching the grammar semantics createLink resolved before.
19
+ function combineQuery(ownQuery, query) {
20
+ return [
21
+ ownQuery,
22
+ query
23
+ ].filter((part)=>part !== '').join('&');
24
+ }
17
25
  // Classifies a `url` grammar value into a page or external target. basePath is
18
26
  // stripped here, never applied - the single application boundary is createUrl.
19
27
  function classifyUrl({ lowdefy, url, query }) {
@@ -24,16 +32,10 @@ function classifyUrl({ lowdefy, url, query }) {
24
32
  const questionMark = url.indexOf('?');
25
33
  const pathname = questionMark === -1 ? url : url.slice(0, questionMark);
26
34
  const ownQuery = questionMark === -1 ? '' : url.slice(questionMark + 1);
27
- // The target's own urlQuery combines with any query the string carries,
28
- // matching the grammar semantics createLink resolves today.
29
- const combined = [
30
- ownQuery,
31
- query
32
- ].filter((part)=>part !== '').join('&');
33
35
  return {
34
36
  kind: 'page',
35
37
  pathname,
36
- query: combined
38
+ query: combineQuery(ownQuery, query)
37
39
  };
38
40
  }
39
41
  // A colon-less value like `example.com` is a schemeless hostname, not a path -
@@ -59,19 +61,30 @@ function classifyUrl({ lowdefy, url, query }) {
59
61
  return {
60
62
  kind: 'page',
61
63
  pathname,
62
- query: parsed.search.replace(/^\?/, '')
64
+ query: combineQuery(parsed.search.replace(/^\?/, ''), query)
63
65
  };
64
66
  }
65
67
  // Same origin but outside basePath (a marketing page at the origin root while
66
68
  // the app lives at `/app`) is a whole URL - routing it would 404 in `/app`.
67
- return {
68
- kind: 'external',
69
- href: parsed.href
70
- };
69
+ return externalTarget({
70
+ parsed,
71
+ query
72
+ });
71
73
  }
74
+ return externalTarget({
75
+ parsed,
76
+ query
77
+ });
78
+ }
79
+ // An external target is handed on as one finished href, so the target's own
80
+ // urlQuery has to be folded in here - the consumer has no separate query to
81
+ // append once the value is a whole URL.
82
+ function externalTarget({ parsed, query }) {
83
+ const search = combineQuery(parsed.search.replace(/^\?/, ''), query);
84
+ const href = `${parsed.origin}${parsed.pathname}${search === '' ? '' : `?${search}`}${parsed.hash}`;
72
85
  return {
73
86
  kind: 'external',
74
- href: parsed.href
87
+ href
75
88
  };
76
89
  }
77
90
  // The single resolver of the navigation grammar { home, pageId, url, urlQuery }
@@ -0,0 +1,41 @@
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 { type } from '@lowdefy/helpers';
16
+ // A marker an action returns to end its event chain, reported as a success
17
+ // rather than an error. The only callers are engine actions that have
18
+ // navigated the browser away, where every remaining step in the chain would
19
+ // act on a page that is being replaced - and one of those steps competing
20
+ // with the navigation is the defect this exists to close.
21
+ //
22
+ // Not an app-facing control: ':return' is how app config ends a chain, and it
23
+ // is unchanged. There is deliberately no CONTROL_KEYS entry here.
24
+ //
25
+ // A symbol, not a string key: the wrapper is only ever inspected by callAction
26
+ // one frame later, and a symbol cannot collide with a field of an action's own
27
+ // response the way a string marker could. It also cannot survive JSON
28
+ // serialization, which is correct - this is an in-process control signal,
29
+ // never something to persist or send over the wire.
30
+ const STOP_CHAIN = Symbol('lowdefyStopChain');
31
+ function stopChain(response) {
32
+ return {
33
+ [STOP_CHAIN]: true,
34
+ response
35
+ };
36
+ }
37
+ function isStopChain(value) {
38
+ return type.isObject(value) && value[STOP_CHAIN] === true;
39
+ }
40
+ export { STOP_CHAIN, isStopChain };
41
+ export default stopChain;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/engine",
3
- "version": "5.6.0",
3
+ "version": "6.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -30,18 +30,18 @@
30
30
  "dist/*"
31
31
  ],
32
32
  "dependencies": {
33
- "@lowdefy/errors": "5.6.0",
34
- "@lowdefy/helpers": "5.6.0",
35
- "@lowdefy/operators": "5.6.0"
33
+ "@lowdefy/errors": "6.1.0",
34
+ "@lowdefy/helpers": "6.1.0",
35
+ "@lowdefy/operators": "6.1.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@jest/globals": "28.1.3",
39
- "@lowdefy/actions-core": "5.6.0",
40
- "@lowdefy/build": "5.6.0",
41
- "@lowdefy/operators-js": "5.6.0",
42
- "@lowdefy/operators-mql": "5.6.0",
43
- "@swc/cli": "0.8.0",
44
- "@swc/core": "1.15.18",
39
+ "@lowdefy/actions-core": "6.1.0",
40
+ "@lowdefy/build": "6.1.0",
41
+ "@lowdefy/operators-js": "6.1.0",
42
+ "@lowdefy/operators-mql": "6.1.0",
43
+ "@swc/cli": "0.8.1",
44
+ "@swc/core": "1.15.32",
45
45
  "@swc/jest": "0.2.39",
46
46
  "jest": "28.1.3"
47
47
  },