@lowdefy/engine 5.5.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Actions.js +288 -2
- package/dist/Block.js +22 -1
- package/dist/State.js +3 -3
- package/dist/WebSockets.js +242 -0
- package/dist/actions/createPublish.js +23 -0
- package/dist/actions/createSubscribe.js +25 -0
- package/dist/actions/createUnsubscribe.js +22 -0
- package/dist/actions/getActionMethods.js +6 -0
- package/dist/actions/getFromObject.js +14 -8
- package/dist/createLink.js +46 -38
- package/dist/getContext.js +27 -4
- package/dist/getHomePathname.js +34 -0
- package/dist/index.js +4 -1
- package/dist/resolveTarget.js +139 -0
- package/dist/stopChain.js +41 -0
- package/package.json +10 -10
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
|
-
|
|
56
|
-
|
|
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
|
@@ -247,6 +247,9 @@ let Block = class Block {
|
|
|
247
247
|
if (beforeVisible !== this.visibleEval.output) {
|
|
248
248
|
repeat.value = true;
|
|
249
249
|
}
|
|
250
|
+
if (this.isList() && !this.isVisible()) {
|
|
251
|
+
this.captureHiddenValue();
|
|
252
|
+
}
|
|
250
253
|
if (this.visibleEval.output !== false) {
|
|
251
254
|
this.propertiesEval = this.parse(this.properties);
|
|
252
255
|
this.requiredEval = this.parse(this.required);
|
|
@@ -347,8 +350,23 @@ let Block = class Block {
|
|
|
347
350
|
visibleEval: this.visibleEval
|
|
348
351
|
});
|
|
349
352
|
});
|
|
353
|
+
_define_property(this, "captureHiddenValue", ()=>{
|
|
354
|
+
const stateValue = get(this.context.state, this.blockId);
|
|
355
|
+
if (type.isUndefined(stateValue)) return;
|
|
356
|
+
this.hiddenValue = serializer.copy(stateValue);
|
|
357
|
+
});
|
|
358
|
+
_define_property(this, "restoreHiddenValue", ()=>{
|
|
359
|
+
if (type.isUndefined(this.hiddenValue)) return;
|
|
360
|
+
if (type.isUndefined(get(this.context.state, this.blockId))) {
|
|
361
|
+
this.context._internal.State.set(this.blockId, this.hiddenValue);
|
|
362
|
+
}
|
|
363
|
+
this.hiddenValue = undefined;
|
|
364
|
+
});
|
|
350
365
|
_define_property(this, "updateState", (toSet)=>{
|
|
351
366
|
if (!this.isVisible()) return;
|
|
367
|
+
if (this.isList()) {
|
|
368
|
+
this.restoreHiddenValue();
|
|
369
|
+
}
|
|
352
370
|
if (this.isContainer() || this.isList()) {
|
|
353
371
|
if (this.subSlots && this.subSlots.length > 0) {
|
|
354
372
|
this.loopSubSlots((subSlotsClass)=>subSlotsClass.updateState());
|
|
@@ -424,7 +442,10 @@ let Block = class Block {
|
|
|
424
442
|
value: type.isNone(this.value) ? null : this.value,
|
|
425
443
|
visible: this.visibleEval.output
|
|
426
444
|
};
|
|
427
|
-
|
|
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]?.();
|
|
428
449
|
});
|
|
429
450
|
const { id, blockId, class: blockClass, events, layout, loading, properties, required, skeleton, style, validate, visible, type: blockType, slots } = blockConfig;
|
|
430
451
|
this.context = context;
|
package/dist/State.js
CHANGED
|
@@ -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 { unset, get, serializer, set, swap, type } from '@lowdefy/helpers';
|
|
15
|
+
*/ import { unset, get, joinPath, serializer, set, splitPath, swap, type } from '@lowdefy/helpers';
|
|
16
16
|
let State = class State {
|
|
17
17
|
resetState() {
|
|
18
18
|
Object.keys(this.context.state).forEach((key)=>{
|
|
@@ -35,9 +35,9 @@ let State = class State {
|
|
|
35
35
|
del(field) {
|
|
36
36
|
unset(this.context.state, field);
|
|
37
37
|
// remove all empty objects from state as an effect of deleted values
|
|
38
|
-
const fields = field
|
|
38
|
+
const fields = splitPath(field);
|
|
39
39
|
if (fields.length > 1) {
|
|
40
|
-
const parent = fields.slice(0,
|
|
40
|
+
const parent = joinPath(fields.slice(0, -1));
|
|
41
41
|
const parentValue = get(this.context.state, parent);
|
|
42
42
|
if (type.isObject(parentValue) && Object.keys(parentValue).length === 0) {
|
|
43
43
|
this.del(parent);
|
|
@@ -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
|
};
|
|
@@ -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 { applyArrayIndices, get, serializer, type } from '@lowdefy/helpers';
|
|
15
|
+
*/ import { ReservedKeyError, applyArrayIndices, get, serializer, type } from '@lowdefy/helpers';
|
|
16
16
|
const getFromObject = ({ arrayIndices, location, method, object, params })=>{
|
|
17
17
|
if (params === true) params = {
|
|
18
18
|
all: true
|
|
@@ -23,20 +23,26 @@ const getFromObject = ({ arrayIndices, location, method, object, params })=>{
|
|
|
23
23
|
if (!type.isObject(params)) {
|
|
24
24
|
throw new Error(`Method Error: ${method} params must be of type string, integer, boolean or object at ${location}.`);
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
const defaultValue = get(params, 'default', {
|
|
27
27
|
default: null,
|
|
28
28
|
copy: true
|
|
29
29
|
});
|
|
30
|
+
if (params.key === null) return defaultValue;
|
|
30
31
|
if (params.all === true) return serializer.copy(object);
|
|
31
32
|
if (!type.isString(params.key) && !type.isInt(params.key)) {
|
|
32
33
|
throw new Error(`Method Error: ${method} params.key must be of type string or integer at ${location}.`);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
default:
|
|
35
|
+
try {
|
|
36
|
+
return get(object, applyArrayIndices(arrayIndices, params.key), {
|
|
37
|
+
default: defaultValue,
|
|
37
38
|
copy: true
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// Tier 2: a runtime read of an app-developer keypath. There is no loud failure that helps here —
|
|
42
|
+
// a thrown error in the browser is worse than the default, and the reserved rule's job (refusing
|
|
43
|
+
// the read) is already done. Author-written identifiers fail loudly at build instead.
|
|
44
|
+
if (error instanceof ReservedKeyError) return defaultValue;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
41
47
|
};
|
|
42
48
|
export default getFromObject;
|
package/dist/createLink.js
CHANGED
|
@@ -12,60 +12,68 @@
|
|
|
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
|
|
15
|
+
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
import resolveTarget from './resolveTarget.js';
|
|
16
17
|
function createLink({ backLink, disabledLink, lowdefy, newOriginLink, noLink, sameOriginLink }) {
|
|
17
18
|
function link(props) {
|
|
18
19
|
if (props.disabled === true) {
|
|
19
20
|
return disabledLink(props);
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
!props.pageId,
|
|
23
|
-
!props.back,
|
|
24
|
-
!props.home,
|
|
25
|
-
!props.href,
|
|
26
|
-
!props.url
|
|
27
|
-
].filter((v)=>!v).length > 1) {
|
|
28
|
-
throw new Error(`Invalid Link: To avoid ambiguity, only one of 'back', 'home', 'href', 'pageId' or 'url' can be defined.`);
|
|
29
|
-
}
|
|
22
|
+
// back has no pathname to resolve and cannot carry input or urlQuery.
|
|
30
23
|
if (props.back === true) {
|
|
31
|
-
// Cannot set input or urlQuery on back
|
|
32
24
|
return backLink(props);
|
|
33
25
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const pathname = `/${lowdefy.home.configured ? '' : lowdefy.home.pageId}`;
|
|
37
|
-
return sameOriginLink({
|
|
38
|
-
...props,
|
|
39
|
-
pathname,
|
|
40
|
-
query,
|
|
41
|
-
setInput: ()=>{
|
|
42
|
-
lowdefy.inputs[`page:${lowdefy.home.pageId}`] = props.input ?? {};
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
if (type.isString(props.pageId)) {
|
|
47
|
-
return sameOriginLink({
|
|
48
|
-
...props,
|
|
49
|
-
pathname: `/${props.pageId}`,
|
|
50
|
-
query,
|
|
51
|
-
setInput: ()=>{
|
|
52
|
-
lowdefy.inputs[`page:${props.pageId}`] = props.input ?? {};
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
}
|
|
26
|
+
// href is an HTML-attribute passthrough the <Link> component reads, not a
|
|
27
|
+
// navigation target, so it never enters the grammar resolver.
|
|
56
28
|
if (type.isString(props.href)) {
|
|
57
29
|
return newOriginLink(props);
|
|
58
30
|
}
|
|
59
|
-
|
|
60
|
-
|
|
31
|
+
const target = resolveTarget({
|
|
32
|
+
lowdefy,
|
|
33
|
+
target: {
|
|
34
|
+
home: props.home,
|
|
35
|
+
pageId: props.pageId,
|
|
36
|
+
url: props.url,
|
|
37
|
+
urlQuery: props.urlQuery
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
if (type.isNone(target)) {
|
|
41
|
+
return noLink(props);
|
|
42
|
+
}
|
|
43
|
+
if (target.kind === 'external') {
|
|
44
|
+
// The resolver's href is the whole URL with any query already folded in,
|
|
45
|
+
// so it is passed as the url prop the callback reads with an empty query.
|
|
61
46
|
return newOriginLink({
|
|
62
47
|
...props,
|
|
63
|
-
url:
|
|
64
|
-
query
|
|
48
|
+
url: target.href,
|
|
49
|
+
query: ''
|
|
65
50
|
});
|
|
66
51
|
}
|
|
67
|
-
return
|
|
52
|
+
return sameOriginLink({
|
|
53
|
+
...props,
|
|
54
|
+
pathname: target.pathname,
|
|
55
|
+
query: target.query,
|
|
56
|
+
setInput: getSetInput({
|
|
57
|
+
lowdefy,
|
|
58
|
+
props
|
|
59
|
+
})
|
|
60
|
+
});
|
|
68
61
|
}
|
|
69
62
|
return link;
|
|
70
63
|
}
|
|
64
|
+
// A page-kind url names no page, so it seeds no input - writing
|
|
65
|
+
// inputs['page:undefined'] is the bug family a no-op setInput avoids.
|
|
66
|
+
function getSetInput({ lowdefy, props }) {
|
|
67
|
+
if (props.home === true) {
|
|
68
|
+
return ()=>{
|
|
69
|
+
lowdefy.inputs[`page:${lowdefy.home.pageId}`] = props.input ?? {};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (type.isString(props.pageId)) {
|
|
73
|
+
return ()=>{
|
|
74
|
+
lowdefy.inputs[`page:${props.pageId}`] = props.input ?? {};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return ()=>{};
|
|
78
|
+
}
|
|
71
79
|
export default createLink;
|
package/dist/getContext.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: {
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
// The single reading of `lowdefy.home` for every 'home' target - the Link
|
|
17
|
+
// grammar in createLink and the post-auth callbackUrl ladder in the client both
|
|
18
|
+
// resolve through here. Returns undefined when the app names no home page:
|
|
19
|
+
// getHomeAndMenus resolves pageId to null for an app with no homePageId whose
|
|
20
|
+
// authorized menu yields no link, and each caller decides what no-home means
|
|
21
|
+
// for it (an invalid link, no callback target) rather than interpolating the
|
|
22
|
+
// missing value into a path.
|
|
23
|
+
function getHomePathname({ lowdefy }) {
|
|
24
|
+
// A configured homePageId is served at the app root - the server resolves `/`
|
|
25
|
+
// to that page, so the pageId is deliberately not in the path.
|
|
26
|
+
if (lowdefy.home?.configured === true) {
|
|
27
|
+
return '/';
|
|
28
|
+
}
|
|
29
|
+
if (type.isString(lowdefy.home?.pageId)) {
|
|
30
|
+
return `/${lowdefy.home.pageId}`;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
export default getHomePathname;
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,10 @@ import Slots from './Slots.js';
|
|
|
17
17
|
import createLink from './createLink.js';
|
|
18
18
|
import Events from './Events.js';
|
|
19
19
|
import getContext from './getContext.js';
|
|
20
|
+
import getHomePathname from './getHomePathname.js';
|
|
20
21
|
import Requests from './Requests.js';
|
|
22
|
+
import resolveTarget from './resolveTarget.js';
|
|
21
23
|
import State from './State.js';
|
|
22
|
-
|
|
24
|
+
import stopChain from './stopChain.js';
|
|
25
|
+
export { Actions, Slots, createLink, Events, getHomePathname, Requests, resolveTarget, State, stopChain };
|
|
23
26
|
export default getContext;
|
|
@@ -0,0 +1,139 @@
|
|
|
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, urlQuery as urlQueryFn } from '@lowdefy/helpers';
|
|
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
|
+
}
|
|
25
|
+
// Classifies a `url` grammar value into a page or external target. basePath is
|
|
26
|
+
// stripped here, never applied - the single application boundary is createUrl.
|
|
27
|
+
function classifyUrl({ lowdefy, url, query }) {
|
|
28
|
+
// The leading-slash test runs before the colon-less test: `/2fa` is an
|
|
29
|
+
// app-relative page and colon-less, and the colon-less branch would wrongly
|
|
30
|
+
// give it an `https://` scheme and parse it as an off-app origin.
|
|
31
|
+
if (url.startsWith('/')) {
|
|
32
|
+
const questionMark = url.indexOf('?');
|
|
33
|
+
const pathname = questionMark === -1 ? url : url.slice(0, questionMark);
|
|
34
|
+
const ownQuery = questionMark === -1 ? '' : url.slice(questionMark + 1);
|
|
35
|
+
return {
|
|
36
|
+
kind: 'page',
|
|
37
|
+
pathname,
|
|
38
|
+
query: combineQuery(ownQuery, query)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// A colon-less value like `example.com` is a schemeless hostname, not a path -
|
|
42
|
+
// prepend `https://` so the URL parser reads it as an absolute URL rather than
|
|
43
|
+
// the app-relative path `/example.com`. Confined to here by the leading-slash
|
|
44
|
+
// test above, so a colon-bearing path like `/path:1` never reaches it.
|
|
45
|
+
const value = url.includes(':') ? url : `https://${url}`;
|
|
46
|
+
const origin = lowdefy._internal?.globals?.window?.location?.origin;
|
|
47
|
+
// No window (SSR, tests): a `url` that reaches origin classification cannot be
|
|
48
|
+
// placed, so it resolves to nothing rather than dereferencing a missing window.
|
|
49
|
+
if (type.isNone(origin)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const parsed = new URL(value, origin);
|
|
53
|
+
const basePath = lowdefy.basePath ?? '';
|
|
54
|
+
if (parsed.origin === origin) {
|
|
55
|
+
const insideBasePath = basePath === '' || parsed.pathname.startsWith(basePath);
|
|
56
|
+
if (insideBasePath) {
|
|
57
|
+
// Strip basePath so the router does not re-apply it: an absolute
|
|
58
|
+
// `https://myapp.com/app/reports` under basePath `/app` already carries the
|
|
59
|
+
// prefix, and without stripping router.push would push `/app/app/reports`.
|
|
60
|
+
const pathname = parsed.pathname.startsWith(basePath) ? parsed.pathname.slice(basePath.length) : parsed.pathname;
|
|
61
|
+
return {
|
|
62
|
+
kind: 'page',
|
|
63
|
+
pathname,
|
|
64
|
+
query: combineQuery(parsed.search.replace(/^\?/, ''), query)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Same origin but outside basePath (a marketing page at the origin root while
|
|
68
|
+
// the app lives at `/app`) is a whole URL - routing it would 404 in `/app`.
|
|
69
|
+
return externalTarget({
|
|
70
|
+
parsed,
|
|
71
|
+
query
|
|
72
|
+
});
|
|
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}`;
|
|
85
|
+
return {
|
|
86
|
+
kind: 'external',
|
|
87
|
+
href
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// The single resolver of the navigation grammar { home, pageId, url, urlQuery }
|
|
91
|
+
// for every reader. Returns a discriminated, un-prefixed target - never a string,
|
|
92
|
+
// never basePath-prefixed - so the page/external distinction is data the consumer
|
|
93
|
+
// reads rather than a shape it guesses from a leading slash.
|
|
94
|
+
function resolveTarget({ lowdefy, target, name = 'Link' }) {
|
|
95
|
+
if (!type.isObject(target)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const { home, pageId, url, urlQuery } = target;
|
|
99
|
+
const defined = [
|
|
100
|
+
home,
|
|
101
|
+
pageId,
|
|
102
|
+
url
|
|
103
|
+
].filter((value)=>value);
|
|
104
|
+
if (defined.length > 1) {
|
|
105
|
+
throw new Error(`Invalid ${name}: To avoid ambiguity, only one of 'home', 'pageId' or 'url' can be defined.`);
|
|
106
|
+
}
|
|
107
|
+
const query = type.isNone(urlQuery) ? '' : `${urlQueryFn.stringify(urlQuery)}`;
|
|
108
|
+
if (home === true) {
|
|
109
|
+
const pathname = getHomePathname({
|
|
110
|
+
lowdefy
|
|
111
|
+
});
|
|
112
|
+
// An app whose home config names no page has no resolvable home - propagate
|
|
113
|
+
// getHomePathname's undefined rather than building the literal "/undefined".
|
|
114
|
+
if (type.isNone(pathname)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: 'page',
|
|
119
|
+
pathname,
|
|
120
|
+
query
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (type.isString(pageId)) {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'page',
|
|
126
|
+
pathname: `/${pageId}`,
|
|
127
|
+
query
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (type.isString(url)) {
|
|
131
|
+
return classifyUrl({
|
|
132
|
+
lowdefy,
|
|
133
|
+
url,
|
|
134
|
+
query
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
export default resolveTarget;
|
|
@@ -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": "
|
|
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": "
|
|
34
|
-
"@lowdefy/helpers": "
|
|
35
|
-
"@lowdefy/operators": "
|
|
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": "
|
|
40
|
-
"@lowdefy/build": "
|
|
41
|
-
"@lowdefy/operators-js": "
|
|
42
|
-
"@lowdefy/operators-mql": "
|
|
43
|
-
"@swc/cli": "0.8.
|
|
44
|
-
"@swc/core": "1.15.
|
|
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
|
},
|