@lowdefy/engine 5.6.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Actions.js CHANGED
@@ -15,6 +15,15 @@
15
15
  */ import { ActionError, ConfigError, UserError } from '@lowdefy/errors';
16
16
  import { type } from '@lowdefy/helpers';
17
17
  import getActionMethods from './actions/getActionMethods.js';
18
+ import { isStopChain } from './stopChain.js';
19
+ const CONTROL_KEYS = [
20
+ ':if',
21
+ ':switch',
22
+ ':return'
23
+ ];
24
+ function isControl(item) {
25
+ return type.isObject(item) && CONTROL_KEYS.some((key)=>key in item);
26
+ }
18
27
  let Actions = class Actions {
19
28
  logActionError({ error, action }) {
20
29
  const handleError = this.context._internal.lowdefy._internal.handleError;
@@ -52,10 +61,38 @@ let Actions = class Actions {
52
61
  this.logActionError(err);
53
62
  }
54
63
  }
55
- async callActionLoop({ actions, arrayIndices, block, event, progress, responses }) {
56
- for (const [index, action] of actions.entries()){
64
+ // Returns true when a ':return' control or an action ended the list, so callers can
65
+ // end the event.
66
+ async callActionLoop({ actions, arrayIndices, block, controls, counters, event, progress, responses }) {
67
+ for (const [position, action] of actions.entries()){
68
+ if (isControl(action)) {
69
+ const returned = await this.callControl({
70
+ arrayIndices,
71
+ block,
72
+ control: action,
73
+ controls,
74
+ counters,
75
+ event,
76
+ progress,
77
+ responses
78
+ });
79
+ if (returned === true) {
80
+ this.recordSkippedActions({
81
+ actions: actions.slice(position + 1),
82
+ counters,
83
+ responses
84
+ });
85
+ return true;
86
+ }
87
+ continue;
88
+ }
89
+ const index = counters.action;
90
+ counters.action += 1;
57
91
  try {
58
92
  if (action.async === true) {
93
+ // Fire and forget - the response is never awaited here, so an 'async: true'
94
+ // action cannot end the chain even if it returns a stopChain marker. The
95
+ // following steps have already run by the time it resolves.
59
96
  this.callAsyncAction({
60
97
  action,
61
98
  arrayIndices,
@@ -76,6 +113,18 @@ let Actions = class Actions {
76
113
  responses
77
114
  });
78
115
  responses[action.id] = response;
116
+ // An action that navigated the browser away ends the chain, reported as a
117
+ // success. Same halt the ':return' control uses above - remaining steps are
118
+ // recorded as skipped, and callActions is never entered through its catch,
119
+ // so 'catch:' actions do not run and no error message displays.
120
+ if (response.stoppedChain === true) {
121
+ this.recordSkippedActions({
122
+ actions: actions.slice(position + 1),
123
+ counters,
124
+ responses
125
+ });
126
+ return true;
127
+ }
79
128
  }
80
129
  } catch (err) {
81
130
  // err is already {error, action, index} from callAction
@@ -83,26 +132,242 @@ let Actions = class Actions {
83
132
  throw err;
84
133
  }
85
134
  }
135
+ return false;
136
+ }
137
+ async callControl({ arrayIndices, block, control, controls, counters, event, progress, responses }) {
138
+ const index = counters.control;
139
+ counters.control += 1;
140
+ if (':return' in control) {
141
+ const value = this.evaluateControlValue({
142
+ arrayIndices,
143
+ block,
144
+ event,
145
+ input: control[':return'],
146
+ node: control,
147
+ responses
148
+ });
149
+ controls.push({
150
+ index,
151
+ type: ':return',
152
+ taken: value
153
+ });
154
+ return true;
155
+ }
156
+ if (':if' in control) {
157
+ const condition = this.evaluateControlValue({
158
+ arrayIndices,
159
+ block,
160
+ event,
161
+ input: control[':if'],
162
+ node: control,
163
+ responses
164
+ });
165
+ // JS truthiness, matching the routine ':if' - not skip's strict === true.
166
+ if (condition) {
167
+ controls.push({
168
+ index,
169
+ type: ':if',
170
+ taken: 'then'
171
+ });
172
+ const returned = await this.callActionLoop({
173
+ actions: control[':then'],
174
+ arrayIndices,
175
+ block,
176
+ controls,
177
+ counters,
178
+ event,
179
+ progress,
180
+ responses
181
+ });
182
+ this.recordSkippedActions({
183
+ actions: control[':else'] ?? [],
184
+ counters,
185
+ responses
186
+ });
187
+ return returned;
188
+ }
189
+ controls.push({
190
+ index,
191
+ type: ':if',
192
+ taken: 'else'
193
+ });
194
+ this.recordSkippedActions({
195
+ actions: control[':then'],
196
+ counters,
197
+ responses
198
+ });
199
+ return this.callActionLoop({
200
+ actions: control[':else'] ?? [],
201
+ arrayIndices,
202
+ block,
203
+ controls,
204
+ counters,
205
+ event,
206
+ progress,
207
+ responses
208
+ });
209
+ }
210
+ // ':switch' - cases are evaluated in order and lazily: the first truthy ':case' wins,
211
+ // later cases are never evaluated, matching the routine ':switch'.
212
+ let matched = false;
213
+ let returned = false;
214
+ for (const [casePosition, caseObject] of control[':switch'].entries()){
215
+ if (!matched) {
216
+ const condition = this.evaluateControlValue({
217
+ arrayIndices,
218
+ block,
219
+ event,
220
+ input: caseObject[':case'],
221
+ node: caseObject,
222
+ responses
223
+ });
224
+ if (condition) {
225
+ matched = true;
226
+ controls.push({
227
+ index,
228
+ type: ':switch',
229
+ taken: casePosition
230
+ });
231
+ returned = await this.callActionLoop({
232
+ actions: caseObject[':then'],
233
+ arrayIndices,
234
+ block,
235
+ controls,
236
+ counters,
237
+ event,
238
+ progress,
239
+ responses
240
+ });
241
+ continue;
242
+ }
243
+ }
244
+ this.recordSkippedActions({
245
+ actions: caseObject[':then'],
246
+ counters,
247
+ responses
248
+ });
249
+ }
250
+ if (matched) {
251
+ this.recordSkippedActions({
252
+ actions: control[':default'] ?? [],
253
+ counters,
254
+ responses
255
+ });
256
+ return returned;
257
+ }
258
+ controls.push({
259
+ index,
260
+ type: ':switch',
261
+ taken: 'default'
262
+ });
263
+ return this.callActionLoop({
264
+ actions: control[':default'] ?? [],
265
+ arrayIndices,
266
+ block,
267
+ controls,
268
+ counters,
269
+ event,
270
+ progress,
271
+ responses
272
+ });
273
+ }
274
+ evaluateControlValue({ arrayIndices, block, event, input, node, responses }) {
275
+ const { output, errors: parserErrors } = this.context._internal.parser.parse({
276
+ actions: responses,
277
+ event,
278
+ arrayIndices,
279
+ input,
280
+ location: block.blockId
281
+ });
282
+ if (parserErrors.length > 0) {
283
+ const error = parserErrors[0];
284
+ // Report against the nearest node's '~k' when the operator carries none.
285
+ if (type.isNone(error.configKey)) {
286
+ error.configKey = node['~k'];
287
+ }
288
+ // Controls are anonymous - no responses entry, so only {error} is thrown.
289
+ throw {
290
+ error
291
+ };
292
+ }
293
+ return output;
294
+ }
295
+ // Records actions the chain does not execute for a control-flow reason as skipped,
296
+ // without parsing their operators. Controls record no responses entry, but still
297
+ // consume a control index so reached controls keep their depth-first numbering.
298
+ recordSkippedActions({ actions, counters, responses }) {
299
+ for (const action of actions){
300
+ if (isControl(action)) {
301
+ counters.control += 1;
302
+ if (':if' in action) {
303
+ this.recordSkippedActions({
304
+ actions: action[':then'],
305
+ counters,
306
+ responses
307
+ });
308
+ this.recordSkippedActions({
309
+ actions: action[':else'] ?? [],
310
+ counters,
311
+ responses
312
+ });
313
+ }
314
+ if (':switch' in action) {
315
+ action[':switch'].forEach((caseObject)=>{
316
+ this.recordSkippedActions({
317
+ actions: caseObject[':then'],
318
+ counters,
319
+ responses
320
+ });
321
+ });
322
+ this.recordSkippedActions({
323
+ actions: action[':default'] ?? [],
324
+ counters,
325
+ responses
326
+ });
327
+ }
328
+ continue;
329
+ }
330
+ responses[action.id] = {
331
+ type: action.type,
332
+ skipped: true,
333
+ index: counters.action
334
+ };
335
+ counters.action += 1;
336
+ }
86
337
  }
87
338
  async callActions({ actions, arrayIndices, block, catchActions, event, eventName, progress }) {
88
339
  const startTimestamp = new Date();
89
340
  const responses = {};
341
+ // Only events with controls gain a 'controls' array - flat chains keep their result shape.
342
+ const hasControls = actions.some(isControl) || catchActions.some(isControl);
343
+ const controls = hasControls ? [] : undefined;
344
+ const counters = {
345
+ action: 0,
346
+ control: 0
347
+ };
90
348
  try {
91
349
  await this.callActionLoop({
92
350
  actions,
93
351
  arrayIndices,
94
352
  block,
353
+ controls,
354
+ counters,
95
355
  event,
96
356
  responses,
97
357
  progress
98
358
  });
99
359
  } catch (error) {
100
360
  this.logActionError(error);
361
+ // Catch actions restart action numbering, matching flat-chain history; the control
362
+ // counter continues so every control entry keeps a unique index within the event.
363
+ counters.action = 0;
101
364
  try {
102
365
  await this.callActionLoop({
103
366
  actions: catchActions,
104
367
  arrayIndices,
105
368
  block,
369
+ controls,
370
+ counters,
106
371
  event,
107
372
  responses,
108
373
  progress
@@ -112,6 +377,9 @@ let Actions = class Actions {
112
377
  return {
113
378
  blockId: block.blockId,
114
379
  bounced: false,
380
+ ...controls && {
381
+ controls
382
+ },
115
383
  endTimestamp: new Date(),
116
384
  error,
117
385
  errorCatch,
@@ -125,6 +393,9 @@ let Actions = class Actions {
125
393
  return {
126
394
  blockId: block.blockId,
127
395
  bounced: false,
396
+ ...controls && {
397
+ controls
398
+ },
128
399
  endTimestamp: new Date(),
129
400
  error,
130
401
  event,
@@ -137,6 +408,9 @@ let Actions = class Actions {
137
408
  return {
138
409
  blockId: block.blockId,
139
410
  bounced: false,
411
+ ...controls && {
412
+ controls
413
+ },
140
414
  endTimestamp: new Date(),
141
415
  event,
142
416
  eventName,
@@ -251,6 +525,17 @@ let Actions = class Actions {
251
525
  message: messages.success,
252
526
  status: 'success'
253
527
  });
528
+ // Unwrap here, after the success message: an action that ends the chain is a
529
+ // success like any other. Only the inner value reaches '_actions.<id>.response',
530
+ // so the marker never becomes app-visible state.
531
+ if (isStopChain(response)) {
532
+ return {
533
+ type: action.type,
534
+ response: response.response,
535
+ index,
536
+ stoppedChain: true
537
+ };
538
+ }
254
539
  return {
255
540
  type: action.type,
256
541
  response,
@@ -273,6 +558,7 @@ let Actions = class Actions {
273
558
  this.callAction = this.callAction.bind(this);
274
559
  this.callActionLoop = this.callActionLoop.bind(this);
275
560
  this.callActions = this.callActions.bind(this);
561
+ this.callControl = this.callControl.bind(this);
276
562
  this.displayMessage = this.displayMessage.bind(this);
277
563
  this.logActionError = this.logActionError.bind(this);
278
564
  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
  };
@@ -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
@@ -21,5 +21,6 @@ import getHomePathname from './getHomePathname.js';
21
21
  import Requests from './Requests.js';
22
22
  import resolveTarget from './resolveTarget.js';
23
23
  import State from './State.js';
24
- export { Actions, Slots, createLink, Events, getHomePathname, Requests, resolveTarget, State };
24
+ import stopChain from './stopChain.js';
25
+ export { Actions, Slots, createLink, Events, getHomePathname, Requests, resolveTarget, State, stopChain };
25
26
  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.0.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.0.0",
34
+ "@lowdefy/helpers": "6.0.0",
35
+ "@lowdefy/operators": "6.0.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.0.0",
40
+ "@lowdefy/build": "6.0.0",
41
+ "@lowdefy/operators-js": "6.0.0",
42
+ "@lowdefy/operators-mql": "6.0.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
  },