@dxos/functions 0.5.3-main.c3feabc → 0.5.3-main.cb47aab

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