@lowdefy/api 5.6.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/createAuthorize.js +4 -1
- package/dist/context/createEvaluateOperators.js +3 -1
- package/dist/context/getCurrentEnvironment.js +27 -0
- package/dist/context/resolveStrategyCaller.js +56 -0
- package/dist/index.js +13 -2
- package/dist/response/buildEndpointResult.js +3 -1
- package/dist/response/createWireProjection.js +88 -0
- package/dist/response/normalizeErrorSources.js +10 -13
- package/dist/response/redactErrorResponse.js +42 -8
- package/dist/response/redactResponse.js +10 -10
- package/dist/routes/agent/callAgent.js +7 -184
- package/dist/routes/agent/prepareAgent.js +179 -0
- package/dist/routes/auth/createLogger.js +2 -5
- package/dist/routes/auth/createPrefixedCookies.js +52 -0
- package/dist/routes/auth/defaultAuthUrl.js +31 -0
- package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +31 -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/authorizeApiEndpoint.js +8 -2
- package/dist/routes/endpoints/callEndpoint.js +27 -3
- package/dist/routes/endpoints/control/controlFor.js +4 -7
- package/dist/routes/endpoints/control/controlIf.js +4 -8
- package/dist/routes/endpoints/control/controlLog.js +6 -14
- package/dist/routes/endpoints/control/controlParallelFor.js +4 -7
- package/dist/routes/endpoints/control/controlReject.js +16 -16
- package/dist/routes/endpoints/control/controlReturn.js +4 -9
- package/dist/routes/endpoints/control/controlSetState.js +4 -8
- package/dist/routes/endpoints/control/controlSwitch.js +4 -8
- package/dist/routes/endpoints/control/controlThrow.js +21 -20
- package/dist/routes/endpoints/control/controlTry.js +20 -10
- package/dist/routes/endpoints/evaluateRoutineOperators.js +26 -0
- package/dist/routes/endpoints/findSchedule.js +35 -0
- package/dist/routes/endpoints/forwardScheduledEndpoint.js +111 -0
- package/dist/routes/endpoints/getEndpointConfig.js +9 -3
- package/dist/routes/endpoints/getEnvironmentSchedules.js +27 -0
- package/dist/routes/endpoints/handleAgentCall.js +80 -0
- package/dist/routes/endpoints/handleEndpointCall.js +53 -8
- package/dist/routes/endpoints/handleRenderNotification.js +196 -0
- package/dist/routes/endpoints/handleRequest.js +1 -5
- package/dist/routes/endpoints/handleValidateSchema.js +14 -10
- package/dist/routes/endpoints/invokeEndpoint.js +1 -0
- package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
- package/dist/routes/endpoints/operatorScope.js +27 -0
- package/dist/routes/endpoints/resolveCronEnvironment.js +38 -0
- package/dist/routes/endpoints/runDetachedEndpoint.js +67 -0
- package/dist/routes/endpoints/runRoutine.js +12 -0
- package/dist/routes/endpoints/runScheduledEndpoint.js +105 -0
- package/dist/routes/endpoints/runWebhookEndpoint.js +84 -0
- package/dist/routes/endpoints/scheduleBackground.js +48 -0
- package/dist/routes/endpoints/test/runTest.js +17 -2
- package/dist/routes/mcp/createMcpServer.js +178 -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 +9 -3
- package/dist/routes/request/callRequestResolver.js +6 -0
- package/dist/routes/request/evaluateOperators.js +8 -12
- package/dist/routes/websocket/authorizeWebsocket.js +27 -0
- package/dist/routes/websocket/createChannelRegistry.js +283 -0
- package/dist/routes/websocket/createWebSocketConnection.js +143 -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 +7 -3
- package/package.json +12 -10
- package/dist/response/omitErrorProps.js +0 -51
|
@@ -0,0 +1,283 @@
|
|
|
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 createWireProjection from '../../response/createWireProjection.js';
|
|
19
|
+
import prepareChannel from './prepareChannel.js';
|
|
20
|
+
const MAX_RETRIES = 5;
|
|
21
|
+
const RETRY_BASE_MS = 1000;
|
|
22
|
+
const HEALTHY_RESET_MS = 60 * 1000;
|
|
23
|
+
// One registry per server process. The unit of sharing is the evaluated
|
|
24
|
+
// subscription: subscribers whose evaluated connection and websocket
|
|
25
|
+
// properties are identical join the same channel entry and share one running
|
|
26
|
+
// source resolver. Sources start on the first subscriber and stop when the
|
|
27
|
+
// last one leaves.
|
|
28
|
+
function createChannelRegistry() {
|
|
29
|
+
const channels = new Map();
|
|
30
|
+
function getChannelKey({ connectionProperties, properties, websocketId }) {
|
|
31
|
+
const hash = crypto.createHash('sha1').update(serializer.serializeToString({
|
|
32
|
+
connectionProperties,
|
|
33
|
+
properties
|
|
34
|
+
})).digest('base64');
|
|
35
|
+
return `${websocketId}:${hash}`;
|
|
36
|
+
}
|
|
37
|
+
function broadcast(channel, frame) {
|
|
38
|
+
const message = JSON.stringify(frame);
|
|
39
|
+
channel.subscribers.forEach((subscriber)=>{
|
|
40
|
+
subscriber.send(message);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Config reads a broadcast error only as a message string, so it gets the wire
|
|
44
|
+
// message alone - no requestId, since no single request raised it, and no
|
|
45
|
+
// devError, since the dev terminal already logs it in full. The channel's
|
|
46
|
+
// context belongs to whoever subscribed first, so the message is translated
|
|
47
|
+
// with each subscriber's own locale.
|
|
48
|
+
function broadcastError(channel, error) {
|
|
49
|
+
channel.subscribers.forEach((subscriber)=>{
|
|
50
|
+
const { message } = createWireProjection({
|
|
51
|
+
i18n: subscriber.i18n
|
|
52
|
+
})(error);
|
|
53
|
+
subscriber.send(JSON.stringify({
|
|
54
|
+
type: 'error',
|
|
55
|
+
websocketId: channel.websocketId,
|
|
56
|
+
message
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function wrapResolverError({ channel, error }) {
|
|
61
|
+
if (!error.configKey) {
|
|
62
|
+
error.configKey = channel.websocketConfig['~k'];
|
|
63
|
+
}
|
|
64
|
+
if (error.isLowdefyError) {
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
67
|
+
if (ServiceError.isServiceError(error)) {
|
|
68
|
+
return new ServiceError(undefined, {
|
|
69
|
+
cause: error,
|
|
70
|
+
service: channel.websocketConfig.connectionId ?? channel.websocketConfig.type,
|
|
71
|
+
configKey: channel.websocketConfig['~k']
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return new PluginError(error.message, {
|
|
75
|
+
cause: error,
|
|
76
|
+
typeName: channel.websocketConfig.type,
|
|
77
|
+
received: channel.properties,
|
|
78
|
+
location: channel.websocketConfig.websocketId,
|
|
79
|
+
configKey: channel.websocketConfig['~k']
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function removeChannel(channel) {
|
|
83
|
+
if (channel.restartTimer) {
|
|
84
|
+
clearTimeout(channel.restartTimer);
|
|
85
|
+
channel.restartTimer = null;
|
|
86
|
+
}
|
|
87
|
+
channel.abortController?.abort();
|
|
88
|
+
channels.delete(channel.key);
|
|
89
|
+
}
|
|
90
|
+
function handleResolverError({ channel, error, startedAt }) {
|
|
91
|
+
if (channel.abortController?.signal.aborted) {
|
|
92
|
+
// Errors thrown while winding down are expected (closed cursors, etc).
|
|
93
|
+
channel.logger.debug({
|
|
94
|
+
event: 'ws_source_stopped_error_ignored',
|
|
95
|
+
websocketId: channel.websocketId
|
|
96
|
+
}, error.message);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const wrapped = wrapResolverError({
|
|
100
|
+
channel,
|
|
101
|
+
error
|
|
102
|
+
});
|
|
103
|
+
channel.logger.debug({
|
|
104
|
+
err: wrapped
|
|
105
|
+
}, wrapped.message);
|
|
106
|
+
channel.context.handleError(wrapped);
|
|
107
|
+
broadcastError(channel, wrapped);
|
|
108
|
+
// A run that stayed healthy resets the backoff window.
|
|
109
|
+
if (Date.now() - startedAt > HEALTHY_RESET_MS) {
|
|
110
|
+
channel.retryCount = 0;
|
|
111
|
+
}
|
|
112
|
+
if (channel.retryCount >= MAX_RETRIES) {
|
|
113
|
+
channel.logger.error({
|
|
114
|
+
event: 'ws_source_max_retries',
|
|
115
|
+
websocketId: channel.websocketId
|
|
116
|
+
});
|
|
117
|
+
removeChannel(channel);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const delay = RETRY_BASE_MS * 2 ** channel.retryCount;
|
|
121
|
+
channel.retryCount += 1;
|
|
122
|
+
channel.restartTimer = setTimeout(()=>{
|
|
123
|
+
channel.restartTimer = null;
|
|
124
|
+
// Subscribers may all have left while the restart was pending.
|
|
125
|
+
if (channel.subscribers.size === 0) {
|
|
126
|
+
removeChannel(channel);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
channel.logger.info({
|
|
130
|
+
event: 'ws_source_restart',
|
|
131
|
+
websocketId: channel.websocketId,
|
|
132
|
+
retryCount: channel.retryCount
|
|
133
|
+
});
|
|
134
|
+
startResolver(channel);
|
|
135
|
+
}, delay);
|
|
136
|
+
}
|
|
137
|
+
function startResolver(channel) {
|
|
138
|
+
const abortController = new AbortController();
|
|
139
|
+
channel.abortController = abortController;
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
function publish({ data }) {
|
|
142
|
+
if (abortController.signal.aborted) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
broadcast(channel, {
|
|
146
|
+
type: 'message',
|
|
147
|
+
websocketId: channel.websocketId,
|
|
148
|
+
payload: serializer.serialize({
|
|
149
|
+
data
|
|
150
|
+
})
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
channel.publish = publish;
|
|
154
|
+
Promise.resolve().then(()=>channel.resolver({
|
|
155
|
+
connection: channel.connectionProperties,
|
|
156
|
+
properties: channel.properties,
|
|
157
|
+
publish,
|
|
158
|
+
signal: abortController.signal,
|
|
159
|
+
logger: channel.logger
|
|
160
|
+
})).catch((error)=>handleResolverError({
|
|
161
|
+
channel,
|
|
162
|
+
error,
|
|
163
|
+
startedAt
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
async function subscribe(context, { websocketId, payload, subscriber }) {
|
|
167
|
+
const { connectionProperties, properties, websocketConfig, websocketResolver } = await prepareChannel(context, {
|
|
168
|
+
websocketId,
|
|
169
|
+
payload
|
|
170
|
+
});
|
|
171
|
+
// One subscription per websocketId per connection — a re-subscribe (e.g.
|
|
172
|
+
// with a new payload) replaces the previous one.
|
|
173
|
+
if (subscriber.subscriptions.has(websocketId)) {
|
|
174
|
+
unsubscribe({
|
|
175
|
+
websocketId,
|
|
176
|
+
subscriber
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const key = getChannelKey({
|
|
180
|
+
connectionProperties,
|
|
181
|
+
properties,
|
|
182
|
+
websocketId
|
|
183
|
+
});
|
|
184
|
+
let channel = channels.get(key);
|
|
185
|
+
if (!channel) {
|
|
186
|
+
channel = {
|
|
187
|
+
key,
|
|
188
|
+
websocketId,
|
|
189
|
+
websocketConfig,
|
|
190
|
+
resolver: websocketResolver,
|
|
191
|
+
connectionProperties,
|
|
192
|
+
properties,
|
|
193
|
+
subscribers: new Set(),
|
|
194
|
+
abortController: null,
|
|
195
|
+
restartTimer: null,
|
|
196
|
+
retryCount: 0,
|
|
197
|
+
publish: null,
|
|
198
|
+
context,
|
|
199
|
+
logger: context.logger
|
|
200
|
+
};
|
|
201
|
+
channels.set(key, channel);
|
|
202
|
+
startResolver(channel);
|
|
203
|
+
}
|
|
204
|
+
channel.subscribers.add(subscriber);
|
|
205
|
+
subscriber.subscriptions.set(websocketId, key);
|
|
206
|
+
context.logger.debug({
|
|
207
|
+
event: 'ws_subscribe',
|
|
208
|
+
websocketId,
|
|
209
|
+
subscribers: channel.subscribers.size
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function unsubscribe({ websocketId, subscriber }) {
|
|
213
|
+
const key = subscriber.subscriptions.get(websocketId);
|
|
214
|
+
subscriber.subscriptions.delete(websocketId);
|
|
215
|
+
if (!key) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const channel = channels.get(key);
|
|
219
|
+
if (!channel) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
channel.subscribers.delete(subscriber);
|
|
223
|
+
if (channel.subscribers.size === 0) {
|
|
224
|
+
removeChannel(channel);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function unsubscribeAll({ subscriber }) {
|
|
228
|
+
[
|
|
229
|
+
...subscriber.subscriptions.keys()
|
|
230
|
+
].forEach((websocketId)=>{
|
|
231
|
+
unsubscribe({
|
|
232
|
+
websocketId,
|
|
233
|
+
subscriber
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function publish(context, { websocketId, payload }) {
|
|
238
|
+
// Publish identity is evaluated without a subscription payload — channels
|
|
239
|
+
// that fragment on _payload/_user in properties are not publish targets.
|
|
240
|
+
const { connectionProperties, properties, websocketConfig, websocketResolver } = await prepareChannel(context, {
|
|
241
|
+
websocketId,
|
|
242
|
+
payload: {}
|
|
243
|
+
});
|
|
244
|
+
if (websocketResolver.meta?.publish !== true || properties.publish !== true) {
|
|
245
|
+
throw new ConfigError(`Websocket "${websocketId}" does not allow publishing.`, {
|
|
246
|
+
configKey: websocketConfig['~k']
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const key = getChannelKey({
|
|
250
|
+
connectionProperties,
|
|
251
|
+
properties,
|
|
252
|
+
websocketId
|
|
253
|
+
});
|
|
254
|
+
const channel = channels.get(key);
|
|
255
|
+
// No channel means no subscribers on this instance — the publish is
|
|
256
|
+
// accepted and simply reaches nobody here.
|
|
257
|
+
if (!channel?.publish) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const data = serializer.deserialize(payload);
|
|
261
|
+
if (typeof websocketResolver.onPublish === 'function') {
|
|
262
|
+
await websocketResolver.onPublish({
|
|
263
|
+
data,
|
|
264
|
+
properties,
|
|
265
|
+
publish: channel.publish,
|
|
266
|
+
user: context.user
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
channel.publish({
|
|
271
|
+
data
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
publish,
|
|
276
|
+
subscribe,
|
|
277
|
+
unsubscribe,
|
|
278
|
+
unsubscribeAll,
|
|
279
|
+
// Exposed for tests and shutdown.
|
|
280
|
+
channels
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export default createChannelRegistry;
|
|
@@ -0,0 +1,143 @@
|
|
|
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 redactErrorResponse from '../../response/redactErrorResponse.js';
|
|
17
|
+
// Wraps one client websocket connection: parses frames, dispatches
|
|
18
|
+
// subscribe/unsubscribe/publish to the channel registry, and answers every
|
|
19
|
+
// frame with an ack or an error so client actions never hang.
|
|
20
|
+
function createWebSocketConnection(context, { registry, send }) {
|
|
21
|
+
const { logger } = context;
|
|
22
|
+
const subscriber = {
|
|
23
|
+
id: context.rid,
|
|
24
|
+
subscriptions: new Map(),
|
|
25
|
+
send,
|
|
26
|
+
// A shared channel broadcasts to every subscriber, so each one carries its
|
|
27
|
+
// own locale for the error message it is sent.
|
|
28
|
+
i18n: context.i18n
|
|
29
|
+
};
|
|
30
|
+
function sendError({ message, requestId, websocketId }) {
|
|
31
|
+
send(JSON.stringify({
|
|
32
|
+
type: 'error',
|
|
33
|
+
websocketId,
|
|
34
|
+
requestId,
|
|
35
|
+
message
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
function sendErrorPayload({ error, requestId, websocketId }) {
|
|
39
|
+
send(JSON.stringify({
|
|
40
|
+
type: 'error',
|
|
41
|
+
websocketId,
|
|
42
|
+
requestId,
|
|
43
|
+
error: redactErrorResponse(context, error)
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
async function handleFrame(frame) {
|
|
47
|
+
const { payload, requestId, websocketId } = frame;
|
|
48
|
+
if (!type.isString(websocketId)) {
|
|
49
|
+
sendError({
|
|
50
|
+
message: 'Frame "websocketId" should be a string.',
|
|
51
|
+
requestId
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
switch(frame.type){
|
|
56
|
+
case 'subscribe':
|
|
57
|
+
await registry.subscribe(context, {
|
|
58
|
+
websocketId,
|
|
59
|
+
payload,
|
|
60
|
+
subscriber
|
|
61
|
+
});
|
|
62
|
+
send(JSON.stringify({
|
|
63
|
+
type: 'subscribed',
|
|
64
|
+
websocketId
|
|
65
|
+
}));
|
|
66
|
+
return;
|
|
67
|
+
case 'unsubscribe':
|
|
68
|
+
registry.unsubscribe({
|
|
69
|
+
websocketId,
|
|
70
|
+
subscriber
|
|
71
|
+
});
|
|
72
|
+
send(JSON.stringify({
|
|
73
|
+
type: 'unsubscribed',
|
|
74
|
+
websocketId
|
|
75
|
+
}));
|
|
76
|
+
return;
|
|
77
|
+
case 'publish':
|
|
78
|
+
await registry.publish(context, {
|
|
79
|
+
websocketId,
|
|
80
|
+
payload
|
|
81
|
+
});
|
|
82
|
+
send(JSON.stringify({
|
|
83
|
+
type: 'published',
|
|
84
|
+
websocketId,
|
|
85
|
+
requestId
|
|
86
|
+
}));
|
|
87
|
+
return;
|
|
88
|
+
default:
|
|
89
|
+
// Unknown frame types are ignored for forward compatibility.
|
|
90
|
+
logger.debug({
|
|
91
|
+
event: 'ws_unknown_frame',
|
|
92
|
+
frameType: frame.type
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function handleMessage(raw) {
|
|
97
|
+
let frame;
|
|
98
|
+
try {
|
|
99
|
+
frame = JSON.parse(raw);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
sendError({
|
|
102
|
+
message: 'Invalid frame — expected JSON.'
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!type.isObject(frame)) {
|
|
107
|
+
sendError({
|
|
108
|
+
message: 'Invalid frame — expected an object.'
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await handleFrame(frame);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.debug({
|
|
116
|
+
err: error
|
|
117
|
+
}, error.message);
|
|
118
|
+
context.handleError(error);
|
|
119
|
+
sendErrorPayload({
|
|
120
|
+
error,
|
|
121
|
+
requestId: frame.requestId,
|
|
122
|
+
websocketId: frame.websocketId
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function close() {
|
|
127
|
+
registry.unsubscribeAll({
|
|
128
|
+
subscriber
|
|
129
|
+
});
|
|
130
|
+
logger.debug({
|
|
131
|
+
event: 'ws_disconnect'
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
logger.debug({
|
|
135
|
+
event: 'ws_connect'
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
close,
|
|
139
|
+
handleMessage,
|
|
140
|
+
subscriber
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export default createWebSocketConnection;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import { ConfigError } from '@lowdefy/errors';
|
|
16
|
+
async function getWebsocketConfig({ logger, readConfigFile }, { websocketId }) {
|
|
17
|
+
const websocketConfig = await readConfigFile(`websockets/${websocketId}.json`);
|
|
18
|
+
if (!websocketConfig) {
|
|
19
|
+
const err = new ConfigError(`Websocket "${websocketId}" does not exist.`);
|
|
20
|
+
logger.debug({
|
|
21
|
+
params: {
|
|
22
|
+
websocketId
|
|
23
|
+
},
|
|
24
|
+
err
|
|
25
|
+
}, err.message);
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
return websocketConfig;
|
|
29
|
+
}
|
|
30
|
+
export default getWebsocketConfig;
|
|
@@ -0,0 +1,33 @@
|
|
|
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 getWebsocketResolver({ logger, websockets }, { websocketConfig }) {
|
|
17
|
+
const websocketResolver = (websockets ?? {})[websocketConfig.type];
|
|
18
|
+
if (!websocketResolver) {
|
|
19
|
+
const err = new ConfigError(`Websocket type "${websocketConfig.type}" can not be found.`, {
|
|
20
|
+
configKey: websocketConfig['~k']
|
|
21
|
+
});
|
|
22
|
+
logger.debug({
|
|
23
|
+
params: {
|
|
24
|
+
id: websocketConfig.websocketId,
|
|
25
|
+
type: websocketConfig.type
|
|
26
|
+
},
|
|
27
|
+
err
|
|
28
|
+
}, err.message);
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
return websocketResolver;
|
|
32
|
+
}
|
|
33
|
+
export default getWebsocketResolver;
|
|
@@ -0,0 +1,72 @@
|
|
|
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 authorizeWebsocket from './authorizeWebsocket.js';
|
|
17
|
+
import getConnection from '../connections/getConnection.js';
|
|
18
|
+
import getConnectionConfig from '../connections/getConnectionConfig.js';
|
|
19
|
+
import getWebsocketConfig from './getWebsocketConfig.js';
|
|
20
|
+
import getWebsocketResolver from './getWebsocketResolver.js';
|
|
21
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
22
|
+
// Shared preparation for subscribe and publish frames: load config, authorize,
|
|
23
|
+
// resolve the websocket type, and evaluate server operators per subscription.
|
|
24
|
+
// Properties are evaluated with the subscriber's payload and user, so the same
|
|
25
|
+
// websocket definition can produce user- or filter-specific channels. The
|
|
26
|
+
// evaluated result is the channel identity — subscribers whose evaluation is
|
|
27
|
+
// identical share one running source.
|
|
28
|
+
async function prepareChannel(context, { websocketId, payload }) {
|
|
29
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
30
|
+
const websocketConfig = await getWebsocketConfig(context, {
|
|
31
|
+
websocketId
|
|
32
|
+
});
|
|
33
|
+
authorizeWebsocket(context, {
|
|
34
|
+
websocketConfig
|
|
35
|
+
});
|
|
36
|
+
const websocketResolver = getWebsocketResolver(context, {
|
|
37
|
+
websocketConfig
|
|
38
|
+
});
|
|
39
|
+
let connectionProperties = null;
|
|
40
|
+
if (!type.isNone(websocketConfig.connectionId)) {
|
|
41
|
+
const connectionConfig = await getConnectionConfig(context, {
|
|
42
|
+
connectionId: websocketConfig.connectionId,
|
|
43
|
+
configKey: websocketConfig['~k']
|
|
44
|
+
});
|
|
45
|
+
// Validates the connection type exists — resolver lookup is on the
|
|
46
|
+
// websocket type itself, not nested on the connection.
|
|
47
|
+
getConnection(context, {
|
|
48
|
+
connectionConfig
|
|
49
|
+
});
|
|
50
|
+
connectionProperties = context.evaluateOperators({
|
|
51
|
+
input: connectionConfig.properties ?? {},
|
|
52
|
+
location: connectionConfig.connectionId,
|
|
53
|
+
payload,
|
|
54
|
+
state: {},
|
|
55
|
+
steps: {}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const properties = context.evaluateOperators({
|
|
59
|
+
input: websocketConfig.properties ?? {},
|
|
60
|
+
location: websocketConfig.websocketId,
|
|
61
|
+
payload,
|
|
62
|
+
state: {},
|
|
63
|
+
steps: {}
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
connectionProperties,
|
|
67
|
+
properties,
|
|
68
|
+
websocketConfig,
|
|
69
|
+
websocketResolver
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export default prepareChannel;
|
package/dist/test/testContext.js
CHANGED
|
@@ -18,13 +18,14 @@ function testContext({ appMeta = {}, config = {}, configDirectory, connections =
|
|
|
18
18
|
error: ()=>{},
|
|
19
19
|
info: ()=>{},
|
|
20
20
|
warn: ()=>{}
|
|
21
|
-
}, operators = {
|
|
21
|
+
}, mode = 'prod', operators = {
|
|
22
22
|
_test: ()=>'test'
|
|
23
|
-
}, readConfigFile, secrets = {}, session } = {}) {
|
|
23
|
+
}, readConfigFile, scrubSecrets = (value)=>value, secrets = {}, session, system } = {}) {
|
|
24
24
|
return {
|
|
25
25
|
appMeta,
|
|
26
26
|
authorize: createAuthorize({
|
|
27
|
-
session
|
|
27
|
+
session,
|
|
28
|
+
system
|
|
28
29
|
}),
|
|
29
30
|
config,
|
|
30
31
|
configDirectory,
|
|
@@ -38,11 +39,14 @@ function testContext({ appMeta = {}, config = {}, configDirectory, connections =
|
|
|
38
39
|
},
|
|
39
40
|
headers,
|
|
40
41
|
logger,
|
|
42
|
+
mode,
|
|
41
43
|
operators,
|
|
42
44
|
readConfigFile,
|
|
45
|
+
scrubSecrets,
|
|
43
46
|
secrets,
|
|
44
47
|
session,
|
|
45
48
|
steps: {},
|
|
49
|
+
system,
|
|
46
50
|
user: session?.user
|
|
47
51
|
};
|
|
48
52
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -34,18 +34,20 @@
|
|
|
34
34
|
"dist/*"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@lowdefy/ajv": "
|
|
38
|
-
"@lowdefy/
|
|
39
|
-
"@lowdefy/
|
|
40
|
-
"@lowdefy/
|
|
41
|
-
"@lowdefy/
|
|
42
|
-
"@lowdefy/
|
|
43
|
-
"@lowdefy/operators
|
|
37
|
+
"@lowdefy/ajv": "6.1.0",
|
|
38
|
+
"@lowdefy/build": "6.1.0",
|
|
39
|
+
"@lowdefy/errors": "6.1.0",
|
|
40
|
+
"@lowdefy/helpers": "6.1.0",
|
|
41
|
+
"@lowdefy/node-utils": "6.1.0",
|
|
42
|
+
"@lowdefy/nunjucks": "6.1.0",
|
|
43
|
+
"@lowdefy/operators": "6.1.0",
|
|
44
|
+
"@lowdefy/operators-js": "6.1.0",
|
|
45
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@jest/globals": "28.1.3",
|
|
47
|
-
"@swc/cli": "0.8.
|
|
48
|
-
"@swc/core": "1.15.
|
|
49
|
+
"@swc/cli": "0.8.1",
|
|
50
|
+
"@swc/core": "1.15.32",
|
|
49
51
|
"@swc/jest": "0.2.39",
|
|
50
52
|
"jest": "28.1.3"
|
|
51
53
|
},
|
|
@@ -1,51 +0,0 @@
|
|
|
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 { UserError } from '@lowdefy/errors';
|
|
16
|
-
import { type } from '@lowdefy/helpers';
|
|
17
|
-
// The client-bound error policy: which fields of an error may cross the wire.
|
|
18
|
-
// Passed to serializer.serialize as `omitErrorProps`, so extractErrorProps applies
|
|
19
|
-
// it at EVERY error node the walk emits - the cause chain, an Error-valued own
|
|
20
|
-
// property, and an Error nested inside a plain object or array. The policy is
|
|
21
|
-
// stated against the emitter rather than against a response shape, so it cannot
|
|
22
|
-
// become depth-limited.
|
|
23
|
-
//
|
|
24
|
-
// `received` is not merely "may be sensitive": on the request path
|
|
25
|
-
// callRequestResolver sets it to the EVALUATED request properties, so a _secret
|
|
26
|
-
// resolved into a request header is in it. `stack` exposes server internals,
|
|
27
|
-
// including absolute node_modules paths. Both are unbounded runtime data nobody
|
|
28
|
-
// chose, in every environment - the full value stays in the server log, which is
|
|
29
|
-
// where a developer on their own machine reads it.
|
|
30
|
-
const ALWAYS_OMITTED = [
|
|
31
|
-
'received',
|
|
32
|
-
'stack'
|
|
33
|
-
];
|
|
34
|
-
const OMITTED_WITH_CAUSE = [
|
|
35
|
-
...ALWAYS_OMITTED,
|
|
36
|
-
'cause'
|
|
37
|
-
];
|
|
38
|
-
function omitErrorProps(error) {
|
|
39
|
-
// An Error cause is the trace the browser renders (name + message per level),
|
|
40
|
-
// so it is always kept - fields are taken from causes, causes are never pruned.
|
|
41
|
-
// A non-Error cause is internal server config: the whole endpoint routine, a
|
|
42
|
-
// control node, ajv errors. UserError is the exception, the one class whose
|
|
43
|
-
// payload the author wrote for the client.
|
|
44
|
-
//
|
|
45
|
-
// type.isError is `instanceof Error`, the same test extractErrorProps uses to
|
|
46
|
-
// decide which branch emits the cause - the two must agree.
|
|
47
|
-
if (type.isError(error.cause)) return ALWAYS_OMITTED;
|
|
48
|
-
if (error instanceof UserError) return ALWAYS_OMITTED;
|
|
49
|
-
return OMITTED_WITH_CAUSE;
|
|
50
|
-
}
|
|
51
|
-
export default omitErrorProps;
|