@dxos/functions 0.5.3-main.bc67fdb → 0.5.3-main.bfb5bca
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/lib/browser/chunk-4D4I3YMJ.mjs +86 -0
- package/dist/lib/browser/chunk-4D4I3YMJ.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +752 -480
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/types.mjs +14 -0
- package/dist/lib/browser/types.mjs.map +7 -0
- package/dist/lib/node/chunk-3UYUR5N5.cjs +103 -0
- package/dist/lib/node/chunk-3UYUR5N5.cjs.map +7 -0
- package/dist/lib/node/index.cjs +739 -473
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/types.cjs +35 -0
- package/dist/lib/node/types.cjs.map +7 -0
- package/dist/types/src/browser/index.d.ts +2 -0
- package/dist/types/src/browser/index.d.ts.map +1 -0
- package/dist/types/src/function/function-registry.d.ts +24 -0
- package/dist/types/src/function/function-registry.d.ts.map +1 -0
- package/dist/types/src/function/function-registry.test.d.ts +2 -0
- package/dist/types/src/function/function-registry.test.d.ts.map +1 -0
- package/dist/types/src/function/index.d.ts +2 -0
- package/dist/types/src/function/index.d.ts.map +1 -0
- package/dist/types/src/handler.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/runtime/dev-server.d.ts +7 -10
- package/dist/types/src/runtime/dev-server.d.ts.map +1 -1
- package/dist/types/src/runtime/scheduler.d.ts +11 -59
- package/dist/types/src/runtime/scheduler.d.ts.map +1 -1
- package/dist/types/src/testing/functions-integration.test.d.ts +2 -0
- package/dist/types/src/testing/functions-integration.test.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +4 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/setup.d.ts +5 -0
- package/dist/types/src/testing/setup.d.ts.map +1 -0
- package/dist/types/src/testing/test/handler.d.ts +1 -0
- package/dist/types/src/testing/test/handler.d.ts.map +1 -1
- package/dist/types/src/testing/types.d.ts +9 -0
- package/dist/types/src/testing/types.d.ts.map +1 -0
- package/dist/types/src/testing/util.d.ts +3 -0
- package/dist/types/src/testing/util.d.ts.map +1 -0
- package/dist/types/src/trigger/index.d.ts +2 -0
- package/dist/types/src/trigger/index.d.ts.map +1 -0
- package/dist/types/src/trigger/trigger-registry.d.ts +37 -0
- package/dist/types/src/trigger/trigger-registry.d.ts.map +1 -0
- package/dist/types/src/trigger/trigger-registry.test.d.ts +2 -0
- package/dist/types/src/trigger/trigger-registry.test.d.ts.map +1 -0
- package/dist/types/src/trigger/type/index.d.ts +5 -0
- package/dist/types/src/trigger/type/index.d.ts.map +1 -0
- package/dist/types/src/trigger/type/subscription-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/subscription-trigger.d.ts.map +1 -0
- package/dist/types/src/trigger/type/timer-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/timer-trigger.d.ts.map +1 -0
- package/dist/types/src/trigger/type/webhook-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/webhook-trigger.d.ts.map +1 -0
- package/dist/types/src/trigger/type/websocket-trigger.d.ts +13 -0
- package/dist/types/src/trigger/type/websocket-trigger.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +152 -119
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +30 -14
- package/schema/functions.json +144 -116
- package/src/browser/index.ts +5 -0
- package/src/function/function-registry.test.ts +105 -0
- package/src/function/function-registry.ts +90 -0
- package/src/function/index.ts +5 -0
- package/src/index.ts +2 -0
- package/src/runtime/dev-server.test.ts +15 -35
- package/src/runtime/dev-server.ts +38 -21
- package/src/runtime/scheduler.test.ts +59 -75
- package/src/runtime/scheduler.ts +70 -297
- package/src/testing/functions-integration.test.ts +100 -0
- package/src/testing/index.ts +7 -0
- package/src/testing/setup.ts +43 -0
- package/src/testing/test/handler.ts +8 -2
- package/src/testing/types.ts +9 -0
- package/src/testing/util.ts +16 -0
- package/src/trigger/index.ts +5 -0
- package/src/trigger/trigger-registry.test.ts +272 -0
- package/src/trigger/trigger-registry.ts +211 -0
- package/src/trigger/type/index.ts +8 -0
- package/src/trigger/type/subscription-trigger.ts +86 -0
- package/src/trigger/type/timer-trigger.ts +45 -0
- package/src/trigger/type/webhook-trigger.ts +48 -0
- package/src/trigger/type/websocket-trigger.ts +92 -0
- package/src/types.ts +82 -54
package/src/runtime/scheduler.ts
CHANGED
|
@@ -2,22 +2,16 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { CronJob } from 'cron';
|
|
6
|
-
import { getPort } from 'get-port-please';
|
|
7
|
-
import http from 'node:http';
|
|
8
5
|
import path from 'node:path';
|
|
9
|
-
import WebSocket from 'ws';
|
|
10
6
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { type Client, type PublicKey } from '@dxos/client';
|
|
14
|
-
import { createSubscription, Filter, getAutomergeObjectCore, type Query, type Space } from '@dxos/client/echo';
|
|
7
|
+
import { Mutex } from '@dxos/async';
|
|
8
|
+
import { type Space } from '@dxos/client/echo';
|
|
15
9
|
import { Context } from '@dxos/context';
|
|
16
|
-
import { invariant } from '@dxos/invariant';
|
|
17
10
|
import { log } from '@dxos/log';
|
|
18
|
-
import { ComplexMap } from '@dxos/util';
|
|
19
11
|
|
|
12
|
+
import { type FunctionRegistry } from '../function';
|
|
20
13
|
import { type FunctionEventMeta } from '../handler';
|
|
14
|
+
import { type TriggerRegistry } from '../trigger';
|
|
21
15
|
import { type FunctionDef, type FunctionManifest, type FunctionTrigger } from '../types';
|
|
22
16
|
|
|
23
17
|
export type Callback = (data: any) => Promise<void | number>;
|
|
@@ -31,101 +25,92 @@ export type SchedulerOptions = {
|
|
|
31
25
|
* The scheduler triggers function execution based on various triggers.
|
|
32
26
|
*/
|
|
33
27
|
export class Scheduler {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{ ctx: Context; trigger: FunctionTrigger }
|
|
38
|
-
>(({ spaceKey, id }) => `${spaceKey.toHex()}:${id}`);
|
|
28
|
+
private _ctx = createContext();
|
|
29
|
+
|
|
30
|
+
private readonly _functionUriToCallMutex = new Map<string, Mutex>();
|
|
39
31
|
|
|
40
32
|
constructor(
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
public readonly functions: FunctionRegistry,
|
|
34
|
+
public readonly triggers: TriggerRegistry,
|
|
43
35
|
private readonly _options: SchedulerOptions = {},
|
|
44
|
-
) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
36
|
+
) {
|
|
37
|
+
this.functions.registered.on(async ({ space, added }) => {
|
|
38
|
+
await this._safeActivateTriggers(space, this.triggers.getInactiveTriggers(space), added);
|
|
39
|
+
});
|
|
40
|
+
this.triggers.registered.on(async ({ space, triggers }) => {
|
|
41
|
+
await this._safeActivateTriggers(space, triggers, this.functions.getFunctions(space));
|
|
42
|
+
});
|
|
51
43
|
}
|
|
52
44
|
|
|
53
45
|
async start() {
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await this.mount(new Context(), space, trigger);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
});
|
|
46
|
+
await this._ctx.dispose();
|
|
47
|
+
this._ctx = createContext();
|
|
48
|
+
await this.functions.open(this._ctx);
|
|
49
|
+
await this.triggers.open(this._ctx);
|
|
62
50
|
}
|
|
63
51
|
|
|
64
52
|
async stop() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
await this._ctx.dispose();
|
|
54
|
+
await this.functions.close();
|
|
55
|
+
await this.triggers.close();
|
|
68
56
|
}
|
|
69
57
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const def = this._manifest.functions.find((config) => config.id === trigger.function);
|
|
76
|
-
invariant(def, `Function not found: ${trigger.function}`);
|
|
77
|
-
|
|
78
|
-
// TODO(burdon): Currently supports only one trigger declaration per function.
|
|
79
|
-
const exists = this._mounts.get(key);
|
|
80
|
-
if (!exists) {
|
|
81
|
-
this._mounts.set(key, { ctx, trigger });
|
|
82
|
-
log('mount', { space: space.key, trigger });
|
|
83
|
-
if (ctx.disposed) {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
// Triggers types.
|
|
89
|
-
//
|
|
58
|
+
// TODO(burdon): Remove and update registries directly.
|
|
59
|
+
public async register(space: Space, manifest: FunctionManifest) {
|
|
60
|
+
await this.functions.register(space, manifest.functions);
|
|
61
|
+
await this.triggers.register(space, manifest);
|
|
62
|
+
}
|
|
90
63
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
64
|
+
private async _safeActivateTriggers(
|
|
65
|
+
space: Space,
|
|
66
|
+
triggers: FunctionTrigger[],
|
|
67
|
+
functions: FunctionDef[],
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const mountTasks = triggers.map((trigger) => {
|
|
70
|
+
return this.activate(space, functions, trigger);
|
|
71
|
+
});
|
|
72
|
+
await Promise.all(mountTasks).catch(log.catch);
|
|
73
|
+
}
|
|
94
74
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
75
|
+
private async activate(space: Space, functions: FunctionDef[], trigger: FunctionTrigger) {
|
|
76
|
+
const definition = functions.find((def) => def.uri === trigger.function);
|
|
77
|
+
if (!definition) {
|
|
78
|
+
log.info('function is not found for trigger', { trigger });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
98
81
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
82
|
+
await this.triggers.activate(space, trigger, async (args) => {
|
|
83
|
+
const mutex = this._functionUriToCallMutex.get(definition.uri) ?? new Mutex();
|
|
84
|
+
this._functionUriToCallMutex.set(definition.uri, mutex);
|
|
102
85
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
86
|
+
log.info('function triggered, waiting for mutex', { uri: definition.uri });
|
|
87
|
+
return mutex.executeSynchronized(() => {
|
|
88
|
+
log.info('mutex acquired', { uri: definition.uri });
|
|
89
|
+
return this._execFunction(definition, trigger, {
|
|
90
|
+
meta: trigger.meta ?? {},
|
|
91
|
+
data: { ...args, spaceKey: space.key },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
const key = { id, spaceKey };
|
|
111
|
-
const { ctx } = this._mounts.get(key) ?? {};
|
|
112
|
-
if (ctx) {
|
|
113
|
-
this._mounts.delete(key);
|
|
114
|
-
await ctx.dispose();
|
|
115
|
-
}
|
|
96
|
+
log('activated trigger', { space: space.key, trigger });
|
|
116
97
|
}
|
|
117
98
|
|
|
118
|
-
private async _execFunction<TData, TMeta>(
|
|
99
|
+
private async _execFunction<TData, TMeta>(
|
|
100
|
+
def: FunctionDef,
|
|
101
|
+
trigger: FunctionTrigger,
|
|
102
|
+
{ data, meta }: { data: TData; meta?: TMeta },
|
|
103
|
+
): Promise<number> {
|
|
119
104
|
let status = 0;
|
|
120
105
|
try {
|
|
121
106
|
// TODO(burdon): Pass in Space key (common context)?
|
|
122
|
-
const payload = Object.assign({},
|
|
107
|
+
const payload = Object.assign({}, meta && ({ meta } satisfies FunctionEventMeta<TMeta>), data);
|
|
123
108
|
|
|
124
109
|
const { endpoint, callback } = this._options;
|
|
125
110
|
if (endpoint) {
|
|
126
111
|
// TODO(burdon): Move out of scheduler (generalize as callback).
|
|
127
|
-
const url = path.join(endpoint, def.
|
|
128
|
-
log.info('exec', { function: def.
|
|
112
|
+
const url = path.join(endpoint, def.route);
|
|
113
|
+
log.info('exec', { function: def.uri, url, triggerType: trigger.spec.type });
|
|
129
114
|
const response = await fetch(url, {
|
|
130
115
|
method: 'POST',
|
|
131
116
|
headers: {
|
|
@@ -136,7 +121,7 @@ export class Scheduler {
|
|
|
136
121
|
|
|
137
122
|
status = response.status;
|
|
138
123
|
} else if (callback) {
|
|
139
|
-
log.info('exec', { function: def.
|
|
124
|
+
log.info('exec', { function: def.uri });
|
|
140
125
|
status = (await callback(payload)) ?? 200;
|
|
141
126
|
}
|
|
142
127
|
|
|
@@ -146,226 +131,14 @@ export class Scheduler {
|
|
|
146
131
|
}
|
|
147
132
|
|
|
148
133
|
// const result = await response.json();
|
|
149
|
-
log.info('done', { function: def.
|
|
134
|
+
log.info('done', { function: def.uri, status });
|
|
150
135
|
} catch (err: any) {
|
|
151
|
-
log.error('error', { function: def.
|
|
136
|
+
log.error('error', { function: def.uri, error: err.message });
|
|
152
137
|
status = 500;
|
|
153
138
|
}
|
|
154
139
|
|
|
155
140
|
return status;
|
|
156
141
|
}
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
// Triggers
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Cron timer.
|
|
164
|
-
*/
|
|
165
|
-
private async _createTimer(ctx: Context, space: Space, def: FunctionDef, trigger: FunctionTrigger) {
|
|
166
|
-
log.info('timer', { space: space.key, trigger });
|
|
167
|
-
const spec = trigger.timer!;
|
|
168
|
-
|
|
169
|
-
const task = new DeferredTask(ctx, async () => {
|
|
170
|
-
await this._execFunction(def, trigger, { spaceKey: space.key });
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
let last = 0;
|
|
174
|
-
let run = 0;
|
|
175
|
-
// https://www.npmjs.com/package/cron#constructor
|
|
176
|
-
const job = CronJob.from({
|
|
177
|
-
cronTime: spec.cron,
|
|
178
|
-
runOnInit: false,
|
|
179
|
-
onTick: () => {
|
|
180
|
-
// TODO(burdon): Check greater than 30s (use cron-parser).
|
|
181
|
-
const now = Date.now();
|
|
182
|
-
const delta = last ? now - last : 0;
|
|
183
|
-
last = now;
|
|
184
|
-
|
|
185
|
-
run++;
|
|
186
|
-
log.info('tick', { space: space.key.truncate(), count: run, delta });
|
|
187
|
-
task.schedule();
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
job.start();
|
|
192
|
-
ctx.onDispose(() => job.stop());
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Webhook.
|
|
197
|
-
*/
|
|
198
|
-
private async _createWebhook(ctx: Context, space: Space, def: FunctionDef, trigger: FunctionTrigger) {
|
|
199
|
-
log.info('webhook', { space: space.key, trigger });
|
|
200
|
-
const spec = trigger.webhook!;
|
|
201
|
-
|
|
202
|
-
// TODO(burdon): Enable POST hook with payload.
|
|
203
|
-
const server = http.createServer(async (req, res) => {
|
|
204
|
-
if (req.method !== spec.method) {
|
|
205
|
-
res.statusCode = 405;
|
|
206
|
-
return res.end();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
res.statusCode = await this._execFunction(def, trigger, { spaceKey: space.key });
|
|
210
|
-
res.end();
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// TODO(burdon): Not used.
|
|
214
|
-
// const DEF_PORT_RANGE = { min: 7500, max: 7599 };
|
|
215
|
-
// const portRange = Object.assign({}, trigger.port, DEF_PORT_RANGE) as WebhookTrigger['port'];
|
|
216
|
-
const port = await getPort({
|
|
217
|
-
random: true,
|
|
218
|
-
// portRange: [portRange!.min, portRange!.max],
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// TODO(burdon): Update trigger object with actual port.
|
|
222
|
-
server.listen(port, () => {
|
|
223
|
-
log.info('started webhook', { port });
|
|
224
|
-
spec.port = port;
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
ctx.onDispose(() => {
|
|
228
|
-
server.close();
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Websocket.
|
|
234
|
-
* NOTE: The port must be unique, so the same hook cannot be used for multiple spaces.
|
|
235
|
-
*/
|
|
236
|
-
private async _createWebsocket(
|
|
237
|
-
ctx: Context,
|
|
238
|
-
space: Space,
|
|
239
|
-
def: FunctionDef,
|
|
240
|
-
trigger: FunctionTrigger,
|
|
241
|
-
options: {
|
|
242
|
-
retryDelay: number;
|
|
243
|
-
maxAttempts: number;
|
|
244
|
-
} = {
|
|
245
|
-
retryDelay: 2,
|
|
246
|
-
maxAttempts: 5,
|
|
247
|
-
},
|
|
248
|
-
) {
|
|
249
|
-
log.info('websocket', { space: space.key, trigger });
|
|
250
|
-
const spec = trigger.websocket!;
|
|
251
|
-
const { url, init } = spec;
|
|
252
|
-
|
|
253
|
-
let ws: WebSocket;
|
|
254
|
-
for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
|
|
255
|
-
const open = new Trigger<boolean>();
|
|
256
|
-
|
|
257
|
-
ws = new WebSocket(url);
|
|
258
|
-
Object.assign(ws, {
|
|
259
|
-
onopen: () => {
|
|
260
|
-
log.info('opened', { url });
|
|
261
|
-
if (spec.init) {
|
|
262
|
-
ws.send(new TextEncoder().encode(JSON.stringify(init)));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
open.wake(true);
|
|
266
|
-
},
|
|
267
|
-
|
|
268
|
-
onclose: (event) => {
|
|
269
|
-
log.info('closed', { url, code: event.code });
|
|
270
|
-
// Reconnect if server closes (e.g., CF restart).
|
|
271
|
-
// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
|
272
|
-
if (event.code === 1006) {
|
|
273
|
-
setTimeout(async () => {
|
|
274
|
-
log.info(`reconnecting in ${options.retryDelay}s...`, { url });
|
|
275
|
-
await this._createWebsocket(ctx, space, def, trigger, options);
|
|
276
|
-
}, options.retryDelay * 1_000);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
open.wake(false);
|
|
280
|
-
},
|
|
281
|
-
|
|
282
|
-
onerror: (event) => {
|
|
283
|
-
log.catch(event.error, { url });
|
|
284
|
-
},
|
|
285
|
-
|
|
286
|
-
onmessage: async (event) => {
|
|
287
|
-
try {
|
|
288
|
-
const data = JSON.parse(new TextDecoder().decode(event.data as Uint8Array));
|
|
289
|
-
await this._execFunction(def, trigger, { spaceKey: space.key, data });
|
|
290
|
-
} catch (err) {
|
|
291
|
-
log.catch(err, { url });
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
} satisfies Partial<WebSocket>);
|
|
295
|
-
|
|
296
|
-
const isOpen = await open.wait();
|
|
297
|
-
if (isOpen) {
|
|
298
|
-
break;
|
|
299
|
-
} else {
|
|
300
|
-
const wait = Math.pow(attempt, 2) * options.retryDelay;
|
|
301
|
-
if (attempt < options.maxAttempts) {
|
|
302
|
-
log.warn(`failed to connect; trying again in ${wait}s`, { attempt });
|
|
303
|
-
await sleep(wait * 1_000);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
ctx.onDispose(() => {
|
|
309
|
-
ws?.close();
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* ECHO subscription.
|
|
315
|
-
*/
|
|
316
|
-
private async _createSubscription(ctx: Context, space: Space, def: FunctionDef, trigger: FunctionTrigger) {
|
|
317
|
-
log.info('subscription', { space: space.key, trigger });
|
|
318
|
-
const spec = trigger.subscription!;
|
|
319
|
-
|
|
320
|
-
const objectIds = new Set<string>();
|
|
321
|
-
const task = new DeferredTask(ctx, async () => {
|
|
322
|
-
await this._execFunction(def, trigger, { spaceKey: space.key, objects: Array.from(objectIds) });
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
// TODO(burdon): Don't fire initially?
|
|
326
|
-
// TODO(burdon): Create queue. Only allow one invocation per trigger at a time?
|
|
327
|
-
const subscriptions: (() => void)[] = [];
|
|
328
|
-
const subscription = createSubscription(({ added, updated }) => {
|
|
329
|
-
log.info('updated', { added: added.length, updated: updated.length });
|
|
330
|
-
for (const object of added) {
|
|
331
|
-
objectIds.add(object.id);
|
|
332
|
-
}
|
|
333
|
-
for (const object of updated) {
|
|
334
|
-
objectIds.add(object.id);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
task.schedule();
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
subscriptions.push(() => subscription.unsubscribe());
|
|
341
|
-
|
|
342
|
-
// TODO(burdon): Disable trigger if keeps failing.
|
|
343
|
-
const { filter, options: { deep, delay } = {} } = spec;
|
|
344
|
-
const update = ({ objects }: Query) => {
|
|
345
|
-
subscription.update(objects);
|
|
346
|
-
|
|
347
|
-
// TODO(burdon): Hack to monitor changes to Document's text object.
|
|
348
|
-
if (deep) {
|
|
349
|
-
log.info('update', { objects: objects.length });
|
|
350
|
-
for (const object of objects) {
|
|
351
|
-
const content = object.content;
|
|
352
|
-
if (content instanceof TextV0Type) {
|
|
353
|
-
subscriptions.push(
|
|
354
|
-
getAutomergeObjectCore(content).updates.on(debounce(() => subscription.update([object]), 1_000)),
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
// TODO(burdon): Is Filter.or implemented?
|
|
362
|
-
// TODO(burdon): [Bug]: all callbacks are fired on the first mutation.
|
|
363
|
-
// TODO(burdon): [Bug]: not updated when document is deleted (either top or hierarchically).
|
|
364
|
-
const query = space.db.query(Filter.or(filter.map(({ type, props }) => Filter.typename(type, props))));
|
|
365
|
-
subscriptions.push(query.subscribe(delay ? debounce(update, delay) : update));
|
|
366
|
-
|
|
367
|
-
ctx.onDispose(() => {
|
|
368
|
-
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
142
|
}
|
|
143
|
+
|
|
144
|
+
const createContext = () => new Context({ name: 'FunctionScheduler' });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { expect } from 'chai';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
import { Trigger, waitForCondition } from '@dxos/async';
|
|
9
|
+
import { type Client } from '@dxos/client';
|
|
10
|
+
import { create, type Space } from '@dxos/client/echo';
|
|
11
|
+
import { performInvitation, TestBuilder } from '@dxos/client/testing';
|
|
12
|
+
import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
13
|
+
import { describe, test } from '@dxos/test';
|
|
14
|
+
|
|
15
|
+
import { setTestCallHandler } from './test/handler';
|
|
16
|
+
import { FunctionRegistry } from '../function';
|
|
17
|
+
import { DevServer, Scheduler } from '../runtime';
|
|
18
|
+
import { createFunctionRuntime, createInitializedClients, TestType } from '../testing';
|
|
19
|
+
import { TriggerRegistry } from '../trigger';
|
|
20
|
+
import { FunctionDef, FunctionTrigger } from '../types';
|
|
21
|
+
|
|
22
|
+
describe('functions e2e', () => {
|
|
23
|
+
let testBuilder: TestBuilder;
|
|
24
|
+
before(async () => {
|
|
25
|
+
testBuilder = new TestBuilder();
|
|
26
|
+
});
|
|
27
|
+
after(async () => {
|
|
28
|
+
await testBuilder.destroy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a function gets triggered in response to another peer object creations', async () => {
|
|
32
|
+
// TODO(burdon): Create builder pattern.
|
|
33
|
+
const functionRuntime = await createFunctionRuntime(testBuilder);
|
|
34
|
+
const devServer = await startDevServer(functionRuntime);
|
|
35
|
+
const scheduler = await startScheduler(functionRuntime, devServer);
|
|
36
|
+
|
|
37
|
+
const app = (await createInitializedClients(testBuilder, 1))[0];
|
|
38
|
+
const space = await app.spaces.create();
|
|
39
|
+
await inviteMember(space, functionRuntime);
|
|
40
|
+
|
|
41
|
+
const uri = 'example.com/function/test';
|
|
42
|
+
space.db.add(create(FunctionDef, { uri, route: '/test', handler: 'test' }));
|
|
43
|
+
const triggerMeta: FunctionTrigger['meta'] = { name: 'DXOS' };
|
|
44
|
+
space.db.add(
|
|
45
|
+
create(FunctionTrigger, {
|
|
46
|
+
function: uri,
|
|
47
|
+
enabled: true,
|
|
48
|
+
meta: triggerMeta,
|
|
49
|
+
spec: {
|
|
50
|
+
type: 'subscription',
|
|
51
|
+
filter: [{ type: TestType.typename }],
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const called = new Trigger<any>();
|
|
57
|
+
setTestCallHandler(async (args) => {
|
|
58
|
+
called.wake(args.event.data);
|
|
59
|
+
return args.response.status(200);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await waitTriggersReplicated(space, scheduler);
|
|
63
|
+
const addedObject = space.db.add(create(TestType, { title: '42' }));
|
|
64
|
+
|
|
65
|
+
const callArgs = await called.wait();
|
|
66
|
+
expect(callArgs.meta).to.deep.eq(triggerMeta);
|
|
67
|
+
expect(callArgs.objects).to.deep.eq([addedObject.id]);
|
|
68
|
+
expect(callArgs.spaceKey).to.eq(space.key.toHex());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const waitTriggersReplicated = async (space: Space, scheduler: Scheduler) => {
|
|
72
|
+
await waitForCondition({ condition: () => scheduler.triggers.getActiveTriggers(space).length > 0 });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// TODO(burdon): Factor out utils to builder pattern.
|
|
76
|
+
|
|
77
|
+
const startScheduler = async (client: Client, devServer: DevServer) => {
|
|
78
|
+
const functionRegistry = new FunctionRegistry(client);
|
|
79
|
+
const triggerRegistry = new TriggerRegistry(client);
|
|
80
|
+
const scheduler = new Scheduler(functionRegistry, triggerRegistry, { endpoint: devServer.endpoint });
|
|
81
|
+
await scheduler.start();
|
|
82
|
+
testBuilder.ctx.onDispose(() => scheduler.stop());
|
|
83
|
+
return scheduler;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const startDevServer = async (client: Client) => {
|
|
87
|
+
const functionRegistry = new FunctionRegistry(client);
|
|
88
|
+
const server = new DevServer(client, functionRegistry, {
|
|
89
|
+
baseDir: path.join(__dirname, '../testing'),
|
|
90
|
+
});
|
|
91
|
+
await server.start();
|
|
92
|
+
testBuilder.ctx.onDispose(() => server.stop());
|
|
93
|
+
return server;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const inviteMember = async (host: Space, guest: Client) => {
|
|
97
|
+
const [{ invitation: hostInvitation }] = await Promise.all(performInvitation({ host, guest: guest.spaces }));
|
|
98
|
+
expect(hostInvitation?.state).to.eq(Invitation.State.SUCCESS);
|
|
99
|
+
};
|
|
100
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { FunctionsPlugin } from '@dxos/agent';
|
|
6
|
+
import { Client, Config } from '@dxos/client';
|
|
7
|
+
import { type TestBuilder } from '@dxos/client/testing';
|
|
8
|
+
import { range } from '@dxos/util';
|
|
9
|
+
|
|
10
|
+
import { TestType } from './types';
|
|
11
|
+
import { FunctionDef, FunctionTrigger } from '../types';
|
|
12
|
+
|
|
13
|
+
// TODO(burdon): Extend/wrap TestBuilder.
|
|
14
|
+
|
|
15
|
+
export const createInitializedClients = async (testBuilder: TestBuilder, count: number = 1, config?: Config) => {
|
|
16
|
+
const clients = range(count).map(() => new Client({ config, services: testBuilder.createLocalClientServices() }));
|
|
17
|
+
testBuilder.ctx.onDispose(() => Promise.all(clients.map((client) => client.destroy())));
|
|
18
|
+
return Promise.all(
|
|
19
|
+
clients.map(async (client, index) => {
|
|
20
|
+
await client.initialize();
|
|
21
|
+
await client.halo.createIdentity({ displayName: `Peer ${index}` });
|
|
22
|
+
client.addSchema(FunctionDef, FunctionTrigger, TestType);
|
|
23
|
+
return client;
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const createFunctionRuntime = async (testBuilder: TestBuilder): Promise<Client> => {
|
|
29
|
+
const config = new Config({
|
|
30
|
+
runtime: {
|
|
31
|
+
agent: {
|
|
32
|
+
plugins: [{ id: 'dxos.org/agent/plugin/functions', config: { port: 8080 } }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const [client] = await createInitializedClients(testBuilder, 1, config);
|
|
38
|
+
const plugin = new FunctionsPlugin();
|
|
39
|
+
await plugin.initialize({ client, clientServices: client.services });
|
|
40
|
+
await plugin.open();
|
|
41
|
+
testBuilder.ctx.onDispose(() => plugin.close());
|
|
42
|
+
return client;
|
|
43
|
+
};
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { type FunctionHandler } from '../../handler';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
let callHandler: FunctionHandler<any> = async ({ response }) => response.status(200);
|
|
8
|
+
|
|
9
|
+
export const setTestCallHandler = (handler: FunctionHandler<any>) => {
|
|
10
|
+
callHandler = handler;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const handler: FunctionHandler<any> = async (args) => {
|
|
14
|
+
return callHandler(args);
|
|
9
15
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Filter, type Space } from '@dxos/client/echo';
|
|
6
|
+
import { invariant } from '@dxos/invariant';
|
|
7
|
+
|
|
8
|
+
import { FunctionTrigger } from '../types';
|
|
9
|
+
|
|
10
|
+
export const triggerWebhook = async (space: Space, uri: string) => {
|
|
11
|
+
const trigger = (
|
|
12
|
+
await space.db.query(Filter.schema(FunctionTrigger, (t: FunctionTrigger) => t.function === uri)).run()
|
|
13
|
+
).objects[0];
|
|
14
|
+
invariant(trigger.spec.type === 'webhook');
|
|
15
|
+
void fetch(`http://localhost:${trigger.spec.port}`);
|
|
16
|
+
};
|