@lowdefy/api 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/context/createAuthorize.js +4 -1
- package/dist/context/resolveStrategyCaller.js +56 -0
- package/dist/index.js +16 -3
- package/dist/response/buildEndpointResult.js +33 -0
- package/dist/response/normalizeErrorSources.js +64 -0
- package/dist/response/omitErrorProps.js +51 -0
- package/dist/response/redactErrorResponse.js +30 -0
- package/dist/response/redactResponse.js +31 -0
- package/dist/routes/agent/authorizeAgent.js +39 -0
- package/dist/routes/agent/callAgent.js +7 -180
- package/dist/routes/agent/prepareAgent.js +173 -0
- package/dist/routes/auth/createLogger.js +2 -5
- package/dist/routes/auth/createPrefixedCookies.js +52 -0
- package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
- package/dist/routes/auth/resolveCookies.js +37 -0
- package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
- package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
- package/dist/routes/endpoints/addStepResult.js +12 -2
- package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
- package/dist/routes/endpoints/callEndpoint.js +32 -13
- package/dist/routes/endpoints/control/controlReject.js +3 -1
- package/dist/routes/endpoints/control/controlSetState.js +13 -2
- package/dist/routes/endpoints/control/controlThrow.js +4 -1
- package/dist/routes/endpoints/findSchedule.js +35 -0
- package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
- package/dist/routes/endpoints/getEndpointConfig.js +9 -3
- package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
- package/dist/routes/endpoints/handleAgentCall.js +83 -0
- package/dist/routes/endpoints/handleEndpointCall.js +49 -1
- package/dist/routes/endpoints/handleRenderNotification.js +189 -0
- package/dist/routes/endpoints/handleValidateSchema.js +3 -1
- package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
- package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
- package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
- package/dist/routes/endpoints/runRoutine.js +15 -1
- package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
- package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
- package/dist/routes/endpoints/scheduleBackground.js +48 -0
- package/dist/routes/mcp/createMcpServer.js +160 -0
- package/dist/routes/notifications/derivePreview.js +30 -0
- package/dist/routes/notifications/getNotificationConfig.js +32 -0
- package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
- package/dist/routes/notifications/resolveThemeLogo.js +35 -0
- package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
- package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
- package/dist/routes/page/dynamic/validateFragment.js +104 -0
- package/dist/routes/page/getPageConfig.js +16 -6
- package/dist/routes/request/callRequest.js +2 -1
- package/dist/routes/websocket/authorizeWebsocket.js +27 -0
- package/dist/routes/websocket/createChannelRegistry.js +269 -0
- package/dist/routes/websocket/createWebSocketConnection.js +131 -0
- package/dist/routes/websocket/getWebsocketConfig.js +30 -0
- package/dist/routes/websocket/getWebsocketResolver.js +33 -0
- package/dist/routes/websocket/prepareChannel.js +72 -0
- package/dist/test/testContext.js +10 -3
- package/package.json +12 -10
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
import { ConfigError } from '@lowdefy/errors';
|
|
17
|
+
import createEvaluateOperators from '../../../context/createEvaluateOperators.js';
|
|
18
|
+
import invokeEndpoint from '../../endpoints/invokeEndpoint.js';
|
|
19
|
+
import unescapeOperators from './unescapeOperators.js';
|
|
20
|
+
import validateFragment from './validateFragment.js';
|
|
21
|
+
const MAX_DYNAMIC_DEPTH = 5;
|
|
22
|
+
function collectDynamicBlocks(block, found) {
|
|
23
|
+
if (block.type === 'Dynamic') {
|
|
24
|
+
// A Dynamic block's content comes from resolution; its fallback slot is
|
|
25
|
+
// resolved separately when a failure activates it.
|
|
26
|
+
found.push(block);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
Object.values(block.slots ?? {}).forEach((slot)=>{
|
|
30
|
+
(slot.blocks ?? []).forEach((child)=>collectDynamicBlocks(child, found));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function setResolvedContent(block, blocks) {
|
|
34
|
+
if (!block.slots) {
|
|
35
|
+
block.slots = {};
|
|
36
|
+
}
|
|
37
|
+
block.slots.content = {
|
|
38
|
+
...block.slots.content ?? {},
|
|
39
|
+
blocks
|
|
40
|
+
};
|
|
41
|
+
delete block.slots.fallback;
|
|
42
|
+
delete block.properties.endpointId;
|
|
43
|
+
delete block.properties.params;
|
|
44
|
+
delete block.properties.required;
|
|
45
|
+
delete block.properties.types;
|
|
46
|
+
}
|
|
47
|
+
async function resolveBlocks(context, { blocks, depth, shared }) {
|
|
48
|
+
const found = [];
|
|
49
|
+
blocks.forEach((block)=>collectDynamicBlocks(block, found));
|
|
50
|
+
await Promise.all(found.map((block)=>resolveDynamicBlock(context, {
|
|
51
|
+
block,
|
|
52
|
+
depth,
|
|
53
|
+
shared
|
|
54
|
+
})));
|
|
55
|
+
}
|
|
56
|
+
async function resolveDynamicBlock(context, { block, depth, shared }) {
|
|
57
|
+
const { logger } = context;
|
|
58
|
+
const { endpointId, params, required } = block.properties;
|
|
59
|
+
try {
|
|
60
|
+
if (depth >= MAX_DYNAMIC_DEPTH) {
|
|
61
|
+
throw new ConfigError(`Dynamic block "${block.blockId}" on page "${shared.pageId}" exceeded the maximum dynamic nesting depth of ${MAX_DYNAMIC_DEPTH}.`, {
|
|
62
|
+
configKey: block['~k']
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const { error, response, status } = await invokeEndpoint(context, {
|
|
66
|
+
endpointId,
|
|
67
|
+
payload: {
|
|
68
|
+
blockId: block.blockId,
|
|
69
|
+
pageId: shared.pageId,
|
|
70
|
+
params: params ?? {},
|
|
71
|
+
urlQuery: shared.urlQuery ?? {}
|
|
72
|
+
},
|
|
73
|
+
endpointDepth: 0
|
|
74
|
+
});
|
|
75
|
+
if ([
|
|
76
|
+
'error',
|
|
77
|
+
'reject'
|
|
78
|
+
].includes(status)) {
|
|
79
|
+
throw error ?? new ConfigError(`Dynamic block "${block.blockId}" on page "${shared.pageId}" endpoint "${endpointId}" failed with status "${status}".`, {
|
|
80
|
+
configKey: block['~k']
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (!type.isObject(response) || !type.isArray(response.blocks)) {
|
|
84
|
+
throw new ConfigError(`Dynamic block "${block.blockId}" on page "${shared.pageId}" endpoint "${endpointId}" must return an object with a "blocks" array.`, {
|
|
85
|
+
received: response,
|
|
86
|
+
configKey: block['~k']
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Unescape before building so operator counting validates the real
|
|
90
|
+
// (client-evaluated) operators against the bundle.
|
|
91
|
+
const { blocks, callApiActionRefs, requestActionRefs, warnings } = shared.buildDynamicBlocks({
|
|
92
|
+
blocks: unescapeOperators(response.blocks),
|
|
93
|
+
pageId: shared.pageId,
|
|
94
|
+
dynamicBlockId: block.blockId,
|
|
95
|
+
idPrefix: block.id,
|
|
96
|
+
types: shared.types,
|
|
97
|
+
blockMetas: shared.blockMetas
|
|
98
|
+
});
|
|
99
|
+
warnings.forEach((warning)=>{
|
|
100
|
+
logger.warn({
|
|
101
|
+
event: 'dynamic_block_warning',
|
|
102
|
+
blockId: block.blockId,
|
|
103
|
+
pageId: shared.pageId
|
|
104
|
+
}, warning.message);
|
|
105
|
+
});
|
|
106
|
+
await validateFragment(context, {
|
|
107
|
+
blocks,
|
|
108
|
+
blockSchemas: shared.blockSchemas,
|
|
109
|
+
callApiActionRefs,
|
|
110
|
+
dynamicBlockId: block.blockId,
|
|
111
|
+
pageId: shared.pageId,
|
|
112
|
+
pageRequests: shared.pageRequests,
|
|
113
|
+
requestActionRefs
|
|
114
|
+
});
|
|
115
|
+
await resolveBlocks(context, {
|
|
116
|
+
blocks,
|
|
117
|
+
depth: depth + 1,
|
|
118
|
+
shared
|
|
119
|
+
});
|
|
120
|
+
setResolvedContent(block, blocks);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (required === true) {
|
|
123
|
+
throw new ConfigError(`Dynamic block "${block.blockId}" on page "${shared.pageId}" failed to resolve: ${error.message}`, {
|
|
124
|
+
configKey: block['~k'],
|
|
125
|
+
cause: error
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
logger.error({
|
|
129
|
+
event: 'dynamic_block_error',
|
|
130
|
+
blockId: block.blockId,
|
|
131
|
+
endpointId,
|
|
132
|
+
pageId: shared.pageId,
|
|
133
|
+
err: error
|
|
134
|
+
}, `Dynamic block "${block.blockId}" on page "${shared.pageId}" failed to resolve: ${error.message}`);
|
|
135
|
+
const fallbackBlocks = block.slots?.fallback?.blocks ?? [];
|
|
136
|
+
setResolvedContent(block, fallbackBlocks);
|
|
137
|
+
await resolveBlocks(context, {
|
|
138
|
+
blocks: fallbackBlocks,
|
|
139
|
+
depth: depth + 1,
|
|
140
|
+
shared
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function resolveDynamicContent(context, { pageConfig, urlQuery }) {
|
|
145
|
+
// Loaded lazily so apps without dynamic pages never load the build package.
|
|
146
|
+
const { default: buildDynamicBlocks } = await import('@lowdefy/build/dynamic');
|
|
147
|
+
const [types, blockMetas, blockSchemas] = await Promise.all([
|
|
148
|
+
context.readConfigFile('types.json'),
|
|
149
|
+
context.readConfigFile('plugins/blockMetas.json'),
|
|
150
|
+
context.readConfigFile('plugins/blockSchemas.json')
|
|
151
|
+
]);
|
|
152
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
153
|
+
const shared = {
|
|
154
|
+
blockMetas: blockMetas ?? {},
|
|
155
|
+
blockSchemas: blockSchemas ?? {},
|
|
156
|
+
buildDynamicBlocks,
|
|
157
|
+
pageId: pageConfig.pageId,
|
|
158
|
+
pageRequests: pageConfig.requests ?? [],
|
|
159
|
+
types: types ?? {},
|
|
160
|
+
urlQuery
|
|
161
|
+
};
|
|
162
|
+
// The page root block itself can be a Dynamic block.
|
|
163
|
+
await resolveBlocks(context, {
|
|
164
|
+
blocks: [
|
|
165
|
+
pageConfig
|
|
166
|
+
],
|
|
167
|
+
depth: 0,
|
|
168
|
+
shared
|
|
169
|
+
});
|
|
170
|
+
return pageConfig;
|
|
171
|
+
}
|
|
172
|
+
export default resolveDynamicContent;
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
// One extra leading underscore defers operator evaluation by one level — the
|
|
17
|
+
// same convention _function bodies use for __args. Shared operators like
|
|
18
|
+
// _state are registered on the server, so a plain `_state` in a routine's
|
|
19
|
+
// :return evaluates there (against empty routine state). Authors write
|
|
20
|
+
// `__state` instead: it survives the server evaluation untouched, and this
|
|
21
|
+
// unescape strips one underscore so the client evaluates the real operator.
|
|
22
|
+
function unescapeOperators(value) {
|
|
23
|
+
if (type.isArray(value)) {
|
|
24
|
+
return value.map(unescapeOperators);
|
|
25
|
+
}
|
|
26
|
+
if (!type.isObject(value)) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
const result = {};
|
|
30
|
+
Object.keys(value).forEach((key)=>{
|
|
31
|
+
const unescapedKey = key.startsWith('__') ? key.slice(1) : key;
|
|
32
|
+
result[unescapedKey] = unescapeOperators(value[key]);
|
|
33
|
+
});
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
export default unescapeOperators;
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { validate } from '@lowdefy/ajv';
|
|
16
|
+
import { getOperatorType, type } from '@lowdefy/helpers';
|
|
17
|
+
import { ConfigError } from '@lowdefy/errors';
|
|
18
|
+
function isOperatorObject(value) {
|
|
19
|
+
return getOperatorType(value) !== null;
|
|
20
|
+
}
|
|
21
|
+
function escapePointerSegment(segment) {
|
|
22
|
+
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
23
|
+
}
|
|
24
|
+
function collectOperatorPaths(value, path, paths) {
|
|
25
|
+
if (type.isArray(value)) {
|
|
26
|
+
value.forEach((item, index)=>collectOperatorPaths(item, `${path}/${index}`, paths));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!type.isObject(value)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (isOperatorObject(value)) {
|
|
33
|
+
paths.push(path);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
Object.keys(value).forEach((key)=>{
|
|
37
|
+
if (key.startsWith('~')) return;
|
|
38
|
+
collectOperatorPaths(value[key], `${path}/${escapePointerSegment(key)}`, paths);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// A schema violation at or under an operator node cannot be judged before the
|
|
42
|
+
// operator evaluates on the client — { _state: columns } may legitimately sit
|
|
43
|
+
// where the schema wants an array. Violations on operator-free paths stand.
|
|
44
|
+
function validateBlockProperties(block, { blockSchemas, dynamicBlockId, pageId }) {
|
|
45
|
+
const properties = block.properties;
|
|
46
|
+
if (!type.isObject(properties) || isOperatorObject(properties)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Block schemas validate the whole pre-build block shape; the plugin's
|
|
50
|
+
// properties schema sits at schema.properties.properties.
|
|
51
|
+
const propertiesSchema = blockSchemas[block.type]?.properties?.properties;
|
|
52
|
+
if (type.isNone(propertiesSchema)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const result = validate({
|
|
56
|
+
schema: propertiesSchema,
|
|
57
|
+
data: properties,
|
|
58
|
+
returnErrors: true
|
|
59
|
+
});
|
|
60
|
+
if (result.valid) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const operatorPaths = [];
|
|
64
|
+
collectOperatorPaths(properties, '', operatorPaths);
|
|
65
|
+
const errors = result.errors.filter((error)=>!operatorPaths.some((path)=>error.instancePath === path || error.instancePath.startsWith(`${path}/`)));
|
|
66
|
+
if (errors.length > 0) {
|
|
67
|
+
const messages = errors.map((error)=>`properties${error.instancePath || ''} ${error.message}`);
|
|
68
|
+
throw new ConfigError(`Dynamic block "${dynamicBlockId}" on page "${pageId}" resolved block "${block.blockId}" (${block.type}) has invalid properties:\n${messages.map((message)=>` - ${message}`).join('\n')}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function walkBlocks(blocks, callback) {
|
|
72
|
+
blocks.forEach((block)=>{
|
|
73
|
+
callback(block);
|
|
74
|
+
Object.values(block.slots ?? {}).forEach((slot)=>{
|
|
75
|
+
walkBlocks(slot.blocks ?? [], callback);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function validateFragment(context, { blocks, blockSchemas, callApiActionRefs, dynamicBlockId, pageId, pageRequests, requestActionRefs }) {
|
|
80
|
+
walkBlocks(blocks, (block)=>{
|
|
81
|
+
validateBlockProperties(block, {
|
|
82
|
+
blockSchemas,
|
|
83
|
+
dynamicBlockId,
|
|
84
|
+
pageId
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
// Request actions can only reference requests defined statically on the page —
|
|
88
|
+
// request artifacts are written at build time.
|
|
89
|
+
const pageRequestIds = new Set(pageRequests.map((request)=>request.requestId));
|
|
90
|
+
requestActionRefs.forEach(({ requestId, blockId, eventId })=>{
|
|
91
|
+
if (!pageRequestIds.has(requestId)) {
|
|
92
|
+
throw new ConfigError(`Dynamic block "${dynamicBlockId}" on page "${pageId}" resolved content references request "${requestId}" on event "${eventId}" on block "${blockId}" which is not defined on the page.`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// CallAPI refs fail resolution instead of the user's click — same checks the
|
|
96
|
+
// HTTP endpoint route applies.
|
|
97
|
+
await Promise.all(callApiActionRefs.map(async ({ endpointId, blockId, eventId })=>{
|
|
98
|
+
const endpointConfig = await context.readConfigFile(`api/${endpointId}.json`);
|
|
99
|
+
if (!endpointConfig || endpointConfig.type === 'InternalApi') {
|
|
100
|
+
throw new ConfigError(`Dynamic block "${dynamicBlockId}" on page "${pageId}" resolved content has a CallAPI action on event "${eventId}" on block "${blockId}" targeting endpoint "${endpointId}" which does not exist or is not accessible from client pages.`);
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
export default validateFragment;
|
|
@@ -13,14 +13,24 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
import resolveDynamicContent from './dynamic/resolveDynamicContent.js';
|
|
17
|
+
async function getPageConfig(context, { pageId, urlQuery }) {
|
|
18
|
+
const pageConfig = await context.readConfigFile(`pages/${pageId}.json`);
|
|
19
|
+
if (pageConfig && context.authorize(pageConfig)) {
|
|
19
20
|
// eslint-disable-next-line no-unused-vars
|
|
20
21
|
const { auth, ...rest } = pageConfig;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
if (rest.dynamic !== true) {
|
|
23
|
+
// Use serializer.serialize to ensure ~k keys (non-enumerable after deserialize)
|
|
24
|
+
// are made enumerable again for JSON transfer to client
|
|
25
|
+
return serializer.serialize(rest);
|
|
26
|
+
}
|
|
27
|
+
// readConfigFile caches parsed artifacts — deep copy before resolution so
|
|
28
|
+
// one request's resolved content never reaches another via the cache.
|
|
29
|
+
const resolved = await resolveDynamicContent(context, {
|
|
30
|
+
pageConfig: serializer.copy(rest),
|
|
31
|
+
urlQuery
|
|
32
|
+
});
|
|
33
|
+
return serializer.serialize(resolved);
|
|
24
34
|
}
|
|
25
35
|
return null;
|
|
26
36
|
}
|
|
@@ -24,6 +24,7 @@ import getRequestConfig from './getRequestConfig.js';
|
|
|
24
24
|
import getRequestResolver from './getRequestResolver.js';
|
|
25
25
|
import validateSchemas from './validateSchemas.js';
|
|
26
26
|
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
27
|
+
import redactResponse from '../../response/redactResponse.js';
|
|
27
28
|
async function callRequest(context, { blockId, pageId, payload, requestId }) {
|
|
28
29
|
const { logger } = context;
|
|
29
30
|
context.blockId = blockId;
|
|
@@ -93,7 +94,7 @@ async function callRequest(context, { blockId, pageId, payload, requestId }) {
|
|
|
93
94
|
id: requestConfig.id,
|
|
94
95
|
success: true,
|
|
95
96
|
type: requestConfig.type,
|
|
96
|
-
response:
|
|
97
|
+
response: redactResponse(context, response)
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
export default callRequest;
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { ConfigError } from '@lowdefy/errors';
|
|
16
|
+
function authorizeWebsocket({ authorize, logger }, { websocketConfig }) {
|
|
17
|
+
if (!authorize(websocketConfig)) {
|
|
18
|
+
logger.debug({
|
|
19
|
+
event: 'debug_websocket_authorize',
|
|
20
|
+
authorized: false,
|
|
21
|
+
auth_config: websocketConfig.auth
|
|
22
|
+
});
|
|
23
|
+
// Same message as a missing websocket so channel existence does not leak.
|
|
24
|
+
throw new ConfigError(`Websocket "${websocketConfig.websocketId}" does not exist.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export default authorizeWebsocket;
|
|
@@ -0,0 +1,269 @@
|
|
|
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 crypto from 'crypto';
|
|
16
|
+
import { serializer } from '@lowdefy/helpers';
|
|
17
|
+
import { ConfigError, PluginError, ServiceError } from '@lowdefy/errors';
|
|
18
|
+
import prepareChannel from './prepareChannel.js';
|
|
19
|
+
const MAX_RETRIES = 5;
|
|
20
|
+
const RETRY_BASE_MS = 1000;
|
|
21
|
+
const HEALTHY_RESET_MS = 60 * 1000;
|
|
22
|
+
// One registry per server process. The unit of sharing is the evaluated
|
|
23
|
+
// subscription: subscribers whose evaluated connection and websocket
|
|
24
|
+
// properties are identical join the same channel entry and share one running
|
|
25
|
+
// source resolver. Sources start on the first subscriber and stop when the
|
|
26
|
+
// last one leaves.
|
|
27
|
+
function createChannelRegistry() {
|
|
28
|
+
const channels = new Map();
|
|
29
|
+
function getChannelKey({ connectionProperties, properties, websocketId }) {
|
|
30
|
+
const hash = crypto.createHash('sha1').update(serializer.serializeToString({
|
|
31
|
+
connectionProperties,
|
|
32
|
+
properties
|
|
33
|
+
})).digest('base64');
|
|
34
|
+
return `${websocketId}:${hash}`;
|
|
35
|
+
}
|
|
36
|
+
function broadcast(channel, frame) {
|
|
37
|
+
const message = JSON.stringify(frame);
|
|
38
|
+
channel.subscribers.forEach((subscriber)=>{
|
|
39
|
+
subscriber.send(message);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function wrapResolverError({ channel, error }) {
|
|
43
|
+
if (!error.configKey) {
|
|
44
|
+
error.configKey = channel.websocketConfig['~k'];
|
|
45
|
+
}
|
|
46
|
+
if (error.isLowdefyError) {
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
if (ServiceError.isServiceError(error)) {
|
|
50
|
+
return new ServiceError(undefined, {
|
|
51
|
+
cause: error,
|
|
52
|
+
service: channel.websocketConfig.connectionId ?? channel.websocketConfig.type,
|
|
53
|
+
configKey: channel.websocketConfig['~k']
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return new PluginError(error.message, {
|
|
57
|
+
cause: error,
|
|
58
|
+
typeName: channel.websocketConfig.type,
|
|
59
|
+
received: channel.properties,
|
|
60
|
+
location: channel.websocketConfig.websocketId,
|
|
61
|
+
configKey: channel.websocketConfig['~k']
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function removeChannel(channel) {
|
|
65
|
+
if (channel.restartTimer) {
|
|
66
|
+
clearTimeout(channel.restartTimer);
|
|
67
|
+
channel.restartTimer = null;
|
|
68
|
+
}
|
|
69
|
+
channel.abortController?.abort();
|
|
70
|
+
channels.delete(channel.key);
|
|
71
|
+
}
|
|
72
|
+
function handleResolverError({ channel, error, startedAt }) {
|
|
73
|
+
if (channel.abortController?.signal.aborted) {
|
|
74
|
+
// Errors thrown while winding down are expected (closed cursors, etc).
|
|
75
|
+
channel.logger.debug({
|
|
76
|
+
event: 'ws_source_stopped_error_ignored',
|
|
77
|
+
websocketId: channel.websocketId
|
|
78
|
+
}, error.message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const wrapped = wrapResolverError({
|
|
82
|
+
channel,
|
|
83
|
+
error
|
|
84
|
+
});
|
|
85
|
+
channel.logger.debug({
|
|
86
|
+
err: wrapped
|
|
87
|
+
}, wrapped.message);
|
|
88
|
+
channel.context.handleError(wrapped);
|
|
89
|
+
broadcast(channel, {
|
|
90
|
+
type: 'error',
|
|
91
|
+
websocketId: channel.websocketId,
|
|
92
|
+
message: wrapped.message
|
|
93
|
+
});
|
|
94
|
+
// A run that stayed healthy resets the backoff window.
|
|
95
|
+
if (Date.now() - startedAt > HEALTHY_RESET_MS) {
|
|
96
|
+
channel.retryCount = 0;
|
|
97
|
+
}
|
|
98
|
+
if (channel.retryCount >= MAX_RETRIES) {
|
|
99
|
+
channel.logger.error({
|
|
100
|
+
event: 'ws_source_max_retries',
|
|
101
|
+
websocketId: channel.websocketId
|
|
102
|
+
});
|
|
103
|
+
removeChannel(channel);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const delay = RETRY_BASE_MS * 2 ** channel.retryCount;
|
|
107
|
+
channel.retryCount += 1;
|
|
108
|
+
channel.restartTimer = setTimeout(()=>{
|
|
109
|
+
channel.restartTimer = null;
|
|
110
|
+
// Subscribers may all have left while the restart was pending.
|
|
111
|
+
if (channel.subscribers.size === 0) {
|
|
112
|
+
removeChannel(channel);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
channel.logger.info({
|
|
116
|
+
event: 'ws_source_restart',
|
|
117
|
+
websocketId: channel.websocketId,
|
|
118
|
+
retryCount: channel.retryCount
|
|
119
|
+
});
|
|
120
|
+
startResolver(channel);
|
|
121
|
+
}, delay);
|
|
122
|
+
}
|
|
123
|
+
function startResolver(channel) {
|
|
124
|
+
const abortController = new AbortController();
|
|
125
|
+
channel.abortController = abortController;
|
|
126
|
+
const startedAt = Date.now();
|
|
127
|
+
function publish({ data }) {
|
|
128
|
+
if (abortController.signal.aborted) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
broadcast(channel, {
|
|
132
|
+
type: 'message',
|
|
133
|
+
websocketId: channel.websocketId,
|
|
134
|
+
payload: serializer.serialize({
|
|
135
|
+
data
|
|
136
|
+
})
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
channel.publish = publish;
|
|
140
|
+
Promise.resolve().then(()=>channel.resolver({
|
|
141
|
+
connection: channel.connectionProperties,
|
|
142
|
+
properties: channel.properties,
|
|
143
|
+
publish,
|
|
144
|
+
signal: abortController.signal,
|
|
145
|
+
logger: channel.logger
|
|
146
|
+
})).catch((error)=>handleResolverError({
|
|
147
|
+
channel,
|
|
148
|
+
error,
|
|
149
|
+
startedAt
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
async function subscribe(context, { websocketId, payload, subscriber }) {
|
|
153
|
+
const { connectionProperties, properties, websocketConfig, websocketResolver } = await prepareChannel(context, {
|
|
154
|
+
websocketId,
|
|
155
|
+
payload
|
|
156
|
+
});
|
|
157
|
+
// One subscription per websocketId per connection — a re-subscribe (e.g.
|
|
158
|
+
// with a new payload) replaces the previous one.
|
|
159
|
+
if (subscriber.subscriptions.has(websocketId)) {
|
|
160
|
+
unsubscribe({
|
|
161
|
+
websocketId,
|
|
162
|
+
subscriber
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const key = getChannelKey({
|
|
166
|
+
connectionProperties,
|
|
167
|
+
properties,
|
|
168
|
+
websocketId
|
|
169
|
+
});
|
|
170
|
+
let channel = channels.get(key);
|
|
171
|
+
if (!channel) {
|
|
172
|
+
channel = {
|
|
173
|
+
key,
|
|
174
|
+
websocketId,
|
|
175
|
+
websocketConfig,
|
|
176
|
+
resolver: websocketResolver,
|
|
177
|
+
connectionProperties,
|
|
178
|
+
properties,
|
|
179
|
+
subscribers: new Set(),
|
|
180
|
+
abortController: null,
|
|
181
|
+
restartTimer: null,
|
|
182
|
+
retryCount: 0,
|
|
183
|
+
publish: null,
|
|
184
|
+
context,
|
|
185
|
+
logger: context.logger
|
|
186
|
+
};
|
|
187
|
+
channels.set(key, channel);
|
|
188
|
+
startResolver(channel);
|
|
189
|
+
}
|
|
190
|
+
channel.subscribers.add(subscriber);
|
|
191
|
+
subscriber.subscriptions.set(websocketId, key);
|
|
192
|
+
context.logger.debug({
|
|
193
|
+
event: 'ws_subscribe',
|
|
194
|
+
websocketId,
|
|
195
|
+
subscribers: channel.subscribers.size
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function unsubscribe({ websocketId, subscriber }) {
|
|
199
|
+
const key = subscriber.subscriptions.get(websocketId);
|
|
200
|
+
subscriber.subscriptions.delete(websocketId);
|
|
201
|
+
if (!key) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const channel = channels.get(key);
|
|
205
|
+
if (!channel) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
channel.subscribers.delete(subscriber);
|
|
209
|
+
if (channel.subscribers.size === 0) {
|
|
210
|
+
removeChannel(channel);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function unsubscribeAll({ subscriber }) {
|
|
214
|
+
[
|
|
215
|
+
...subscriber.subscriptions.keys()
|
|
216
|
+
].forEach((websocketId)=>{
|
|
217
|
+
unsubscribe({
|
|
218
|
+
websocketId,
|
|
219
|
+
subscriber
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async function publish(context, { websocketId, payload }) {
|
|
224
|
+
// Publish identity is evaluated without a subscription payload — channels
|
|
225
|
+
// that fragment on _payload/_user in properties are not publish targets.
|
|
226
|
+
const { connectionProperties, properties, websocketConfig, websocketResolver } = await prepareChannel(context, {
|
|
227
|
+
websocketId,
|
|
228
|
+
payload: {}
|
|
229
|
+
});
|
|
230
|
+
if (websocketResolver.meta?.publish !== true || properties.publish !== true) {
|
|
231
|
+
throw new ConfigError(`Websocket "${websocketId}" does not allow publishing.`, {
|
|
232
|
+
configKey: websocketConfig['~k']
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const key = getChannelKey({
|
|
236
|
+
connectionProperties,
|
|
237
|
+
properties,
|
|
238
|
+
websocketId
|
|
239
|
+
});
|
|
240
|
+
const channel = channels.get(key);
|
|
241
|
+
// No channel means no subscribers on this instance — the publish is
|
|
242
|
+
// accepted and simply reaches nobody here.
|
|
243
|
+
if (!channel?.publish) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const data = serializer.deserialize(payload);
|
|
247
|
+
if (typeof websocketResolver.onPublish === 'function') {
|
|
248
|
+
await websocketResolver.onPublish({
|
|
249
|
+
data,
|
|
250
|
+
properties,
|
|
251
|
+
publish: channel.publish,
|
|
252
|
+
user: context.user
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
channel.publish({
|
|
257
|
+
data
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
publish,
|
|
262
|
+
subscribe,
|
|
263
|
+
unsubscribe,
|
|
264
|
+
unsubscribeAll,
|
|
265
|
+
// Exposed for tests and shutdown.
|
|
266
|
+
channels
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export default createChannelRegistry;
|