@dxos/functions 0.5.3-main.2ad0afc → 0.5.3-main.3456876

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 (90) hide show
  1. package/dist/lib/browser/index.mjs +490 -712
  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 +483 -695
  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 +12 -32
  8. package/dist/types/src/handler.d.ts.map +1 -1
  9. package/dist/types/src/index.d.ts +0 -2
  10. package/dist/types/src/index.d.ts.map +1 -1
  11. package/dist/types/src/runtime/dev-server.d.ts +10 -7
  12. package/dist/types/src/runtime/dev-server.d.ts.map +1 -1
  13. package/dist/types/src/runtime/scheduler.d.ts +59 -11
  14. package/dist/types/src/runtime/scheduler.d.ts.map +1 -1
  15. package/dist/types/src/testing/test/handler.d.ts +0 -1
  16. package/dist/types/src/testing/test/handler.d.ts.map +1 -1
  17. package/dist/types/src/types.d.ts +111 -131
  18. package/dist/types/src/types.d.ts.map +1 -1
  19. package/package.json +14 -30
  20. package/schema/functions.json +112 -140
  21. package/src/handler.ts +27 -50
  22. package/src/index.ts +0 -2
  23. package/src/runtime/dev-server.test.ts +35 -15
  24. package/src/runtime/dev-server.ts +24 -41
  25. package/src/runtime/scheduler.test.ts +75 -54
  26. package/src/runtime/scheduler.ts +300 -75
  27. package/src/testing/test/handler.ts +2 -8
  28. package/src/types.ts +40 -58
  29. package/dist/lib/browser/chunk-366QG6IX.mjs +0 -81
  30. package/dist/lib/browser/chunk-366QG6IX.mjs.map +0 -7
  31. package/dist/lib/browser/types.mjs +0 -12
  32. package/dist/lib/browser/types.mjs.map +0 -7
  33. package/dist/lib/node/chunk-3VSJ57ZZ.cjs +0 -97
  34. package/dist/lib/node/chunk-3VSJ57ZZ.cjs.map +0 -7
  35. package/dist/lib/node/types.cjs +0 -33
  36. package/dist/lib/node/types.cjs.map +0 -7
  37. package/dist/types/src/function/function-registry.d.ts +0 -24
  38. package/dist/types/src/function/function-registry.d.ts.map +0 -1
  39. package/dist/types/src/function/function-registry.test.d.ts +0 -2
  40. package/dist/types/src/function/function-registry.test.d.ts.map +0 -1
  41. package/dist/types/src/function/index.d.ts +0 -2
  42. package/dist/types/src/function/index.d.ts.map +0 -1
  43. package/dist/types/src/testing/functions-integration.test.d.ts +0 -2
  44. package/dist/types/src/testing/functions-integration.test.d.ts.map +0 -1
  45. package/dist/types/src/testing/index.d.ts +0 -4
  46. package/dist/types/src/testing/index.d.ts.map +0 -1
  47. package/dist/types/src/testing/setup.d.ts +0 -5
  48. package/dist/types/src/testing/setup.d.ts.map +0 -1
  49. package/dist/types/src/testing/types.d.ts +0 -9
  50. package/dist/types/src/testing/types.d.ts.map +0 -1
  51. package/dist/types/src/testing/util.d.ts +0 -3
  52. package/dist/types/src/testing/util.d.ts.map +0 -1
  53. package/dist/types/src/trigger/index.d.ts +0 -2
  54. package/dist/types/src/trigger/index.d.ts.map +0 -1
  55. package/dist/types/src/trigger/trigger-registry.d.ts +0 -40
  56. package/dist/types/src/trigger/trigger-registry.d.ts.map +0 -1
  57. package/dist/types/src/trigger/trigger-registry.test.d.ts +0 -2
  58. package/dist/types/src/trigger/trigger-registry.test.d.ts.map +0 -1
  59. package/dist/types/src/trigger/type/index.d.ts +0 -5
  60. package/dist/types/src/trigger/type/index.d.ts.map +0 -1
  61. package/dist/types/src/trigger/type/subscription-trigger.d.ts +0 -4
  62. package/dist/types/src/trigger/type/subscription-trigger.d.ts.map +0 -1
  63. package/dist/types/src/trigger/type/timer-trigger.d.ts +0 -4
  64. package/dist/types/src/trigger/type/timer-trigger.d.ts.map +0 -1
  65. package/dist/types/src/trigger/type/webhook-trigger.d.ts +0 -4
  66. package/dist/types/src/trigger/type/webhook-trigger.d.ts.map +0 -1
  67. package/dist/types/src/trigger/type/websocket-trigger.d.ts +0 -13
  68. package/dist/types/src/trigger/type/websocket-trigger.d.ts.map +0 -1
  69. package/dist/types/src/util.d.ts +0 -15
  70. package/dist/types/src/util.d.ts.map +0 -1
  71. package/dist/types/src/util.test.d.ts +0 -2
  72. package/dist/types/src/util.test.d.ts.map +0 -1
  73. package/src/function/function-registry.test.ts +0 -105
  74. package/src/function/function-registry.ts +0 -90
  75. package/src/function/index.ts +0 -5
  76. package/src/testing/functions-integration.test.ts +0 -99
  77. package/src/testing/index.ts +0 -7
  78. package/src/testing/setup.ts +0 -45
  79. package/src/testing/types.ts +0 -9
  80. package/src/testing/util.ts +0 -16
  81. package/src/trigger/index.ts +0 -5
  82. package/src/trigger/trigger-registry.test.ts +0 -255
  83. package/src/trigger/trigger-registry.ts +0 -189
  84. package/src/trigger/type/index.ts +0 -8
  85. package/src/trigger/type/subscription-trigger.ts +0 -80
  86. package/src/trigger/type/timer-trigger.ts +0 -44
  87. package/src/trigger/type/webhook-trigger.ts +0 -47
  88. package/src/trigger/type/websocket-trigger.ts +0 -91
  89. package/src/util.test.ts +0 -43
  90. package/src/util.ts +0 -48
@@ -6,43 +6,40 @@ import { expect } from 'chai';
6
6
  import WebSocket from 'ws';
7
7
 
8
8
  import { Trigger } from '@dxos/async';
9
- import { type Client } from '@dxos/client';
9
+ import { Client } from '@dxos/client';
10
10
  import { TestBuilder } from '@dxos/client/testing';
11
- import { create } from '@dxos/echo-schema';
11
+ import { create, S, TypedObject } from '@dxos/echo-schema';
12
12
  import { describe, test } from '@dxos/test';
13
13
 
14
- import { Scheduler, type SchedulerOptions } from './scheduler';
15
- import { FunctionRegistry } from '../function';
16
- import { createInitializedClients, TestType, triggerWebhook } from '../testing';
17
- import { TriggerRegistry } from '../trigger';
18
- import { type FunctionManifest } from '../types';
14
+ import { Scheduler } from './scheduler';
15
+ import { type FunctionManifest, type WebhookTrigger } from '../types';
19
16
 
20
17
  // TODO(burdon): Test we can add and remove triggers.
21
18
  describe('scheduler', () => {
22
- let testBuilder: TestBuilder;
23
19
  let client: Client;
24
20
  before(async () => {
25
- testBuilder = new TestBuilder();
26
- client = (await createInitializedClients(testBuilder, 1))[0];
21
+ const testBuilder = new TestBuilder();
22
+ client = new Client({ services: testBuilder.createLocalClientServices() });
23
+ await client.initialize();
24
+ await client.halo.createIdentity();
27
25
  });
28
26
  after(async () => {
29
- await testBuilder.destroy();
27
+ await client.destroy();
30
28
  });
31
29
 
32
30
  test('timer', async () => {
33
31
  const manifest: FunctionManifest = {
34
32
  functions: [
35
33
  {
36
- uri: 'example.com/function/test',
37
- route: '/test',
34
+ id: 'example.com/function/test',
35
+ path: '/test',
38
36
  handler: 'test',
39
37
  },
40
38
  ],
41
39
  triggers: [
42
40
  {
43
41
  function: 'example.com/function/test',
44
- spec: {
45
- type: 'timer',
42
+ timer: {
46
43
  cron: '0/1 * * * * *', // Every 1s.
47
44
  },
48
45
  },
@@ -51,13 +48,18 @@ describe('scheduler', () => {
51
48
 
52
49
  let count = 0;
53
50
  const done = new Trigger();
54
- const scheduler = createScheduler(async () => {
55
- if (++count === 3) {
56
- done.wake();
57
- }
51
+ const scheduler = new Scheduler(client, manifest, {
52
+ callback: async () => {
53
+ if (++count === 3) {
54
+ done.wake();
55
+ }
56
+ },
58
57
  });
59
- await scheduler.register(client.spaces.default, manifest);
58
+
60
59
  await scheduler.start();
60
+ after(async () => {
61
+ await scheduler.stop();
62
+ });
61
63
 
62
64
  await done.wait({ timeout: 5_000 });
63
65
  expect(count).to.equal(3);
@@ -67,16 +69,15 @@ describe('scheduler', () => {
67
69
  const manifest: FunctionManifest = {
68
70
  functions: [
69
71
  {
70
- uri: 'example.com/function/test',
71
- route: '/test',
72
+ id: 'example.com/function/test',
73
+ path: '/test',
72
74
  handler: 'test',
73
75
  },
74
76
  ],
75
77
  triggers: [
76
78
  {
77
79
  function: 'example.com/function/test',
78
- spec: {
79
- type: 'webhook',
80
+ webhook: {
80
81
  method: 'GET',
81
82
  },
82
83
  },
@@ -84,14 +85,23 @@ describe('scheduler', () => {
84
85
  };
85
86
 
86
87
  const done = new Trigger();
87
- const scheduler = createScheduler(async () => {
88
- done.wake();
88
+ const scheduler = new Scheduler(client, manifest, {
89
+ callback: async () => {
90
+ done.wake();
91
+ },
89
92
  });
90
- const space = await client.spaces.create();
91
- await scheduler.register(space, manifest);
93
+
92
94
  await scheduler.start();
95
+ after(async () => {
96
+ await scheduler.stop();
97
+ });
93
98
 
94
- setTimeout(async () => triggerWebhook(space, manifest.functions![0].uri));
99
+ setTimeout(() => {
100
+ const mount: WebhookTrigger = scheduler.mounts.find(
101
+ (mount) => mount.function === 'example.com/function/test',
102
+ )!.webhook!;
103
+ void fetch(`http://localhost:${mount.port}`);
104
+ });
95
105
 
96
106
  await done.wait();
97
107
  });
@@ -100,16 +110,15 @@ describe('scheduler', () => {
100
110
  const manifest: FunctionManifest = {
101
111
  functions: [
102
112
  {
103
- uri: 'example.com/function/test',
104
- route: '/test',
113
+ id: 'example.com/function/test',
114
+ path: '/test',
105
115
  handler: 'test',
106
116
  },
107
117
  ],
108
118
  triggers: [
109
119
  {
110
120
  function: 'example.com/function/test',
111
- spec: {
112
- type: 'websocket',
121
+ websocket: {
113
122
  // url: 'https://hub.dxos.network/api/mailbox/test',
114
123
  url: 'http://localhost:8081',
115
124
  init: {
@@ -121,11 +130,16 @@ describe('scheduler', () => {
121
130
  };
122
131
 
123
132
  const done = new Trigger();
124
- const scheduler = createScheduler(async () => {
125
- done.wake();
133
+ const scheduler = new Scheduler(client, manifest, {
134
+ callback: async (data) => {
135
+ done.wake();
136
+ },
126
137
  });
127
- await scheduler.register(client.spaces.default, manifest);
138
+
128
139
  await scheduler.start();
140
+ after(async () => {
141
+ await scheduler.stop();
142
+ });
129
143
 
130
144
  // Test server.
131
145
  setTimeout(() => {
@@ -143,20 +157,29 @@ describe('scheduler', () => {
143
157
  });
144
158
 
145
159
  test('subscription', async () => {
160
+ class TestType extends TypedObject({ typename: 'example.com/type/Test', version: '0.1.0' })({
161
+ title: S.string,
162
+ }) {}
163
+ client.addSchema(TestType);
164
+
146
165
  const manifest: FunctionManifest = {
147
166
  functions: [
148
167
  {
149
- uri: 'example.com/function/test',
150
- route: '/test',
168
+ id: 'example.com/function/test',
169
+ path: '/test',
151
170
  handler: 'test',
152
171
  },
153
172
  ],
154
173
  triggers: [
155
174
  {
156
175
  function: 'example.com/function/test',
157
- spec: {
158
- type: 'subscription',
159
- filter: [{ type: TestType.typename }],
176
+ subscription: {
177
+ spaceKey: client.spaces.default.key.toHex(),
178
+ filter: [
179
+ {
180
+ type: TestType.typename,
181
+ },
182
+ ],
160
183
  },
161
184
  },
162
185
  ],
@@ -164,14 +187,20 @@ describe('scheduler', () => {
164
187
 
165
188
  let count = 0;
166
189
  const done = new Trigger();
167
- const scheduler = createScheduler(async () => {
168
- if (++count === 2) {
169
- done.wake();
170
- }
190
+ const scheduler = new Scheduler(client, manifest, {
191
+ callback: async () => {
192
+ if (++count === 2) {
193
+ done.wake();
194
+ }
195
+ },
171
196
  });
172
- await scheduler.register(client.spaces.default, manifest);
197
+
173
198
  await scheduler.start();
199
+ after(async () => {
200
+ await scheduler.stop();
201
+ });
174
202
 
203
+ // TODO(burdon): Query for Expando?
175
204
  setTimeout(() => {
176
205
  const space = client.spaces.default;
177
206
  const object = create(TestType, { title: 'Hello world!' });
@@ -180,12 +209,4 @@ describe('scheduler', () => {
180
209
 
181
210
  await done.wait();
182
211
  });
183
-
184
- const createScheduler = (callback: SchedulerOptions['callback']) => {
185
- const scheduler = new Scheduler(new FunctionRegistry(client), new TriggerRegistry(client), { callback });
186
- after(async () => {
187
- await scheduler.stop();
188
- });
189
- return scheduler;
190
- };
191
212
  });
@@ -2,17 +2,30 @@
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';
5
8
  import path from 'node:path';
9
+ import WebSocket from 'ws';
6
10
 
7
- import { Mutex } from '@dxos/async';
8
- import { type Space } from '@dxos/client/echo';
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';
9
15
  import { Context } from '@dxos/context';
16
+ import { invariant } from '@dxos/invariant';
10
17
  import { log } from '@dxos/log';
18
+ import { ComplexMap } from '@dxos/util';
11
19
 
12
- import { type FunctionRegistry } from '../function';
13
- import { type FunctionEventMeta } from '../handler';
14
- import { type TriggerRegistry } from '../trigger';
15
- import { type FunctionDef, type FunctionManifest, type FunctionTrigger } from '../types';
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';
16
29
 
17
30
  export type Callback = (data: any) => Promise<void | number>;
18
31
 
@@ -25,99 +38,111 @@ export type SchedulerOptions = {
25
38
  * The scheduler triggers function execution based on various triggers.
26
39
  */
27
40
  export class Scheduler {
28
- private _ctx = createContext();
29
-
30
- private readonly _callMutex = new Mutex();
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}`);
31
46
 
32
47
  constructor(
33
- public readonly functions: FunctionRegistry,
34
- public readonly triggers: TriggerRegistry,
48
+ private readonly _client: Client,
49
+ private readonly _manifest: FunctionManifest,
35
50
  private readonly _options: SchedulerOptions = {},
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
+ ) {}
52
+
53
+ get mounts() {
54
+ return Array.from(this._mounts.values()).reduce<FunctionTrigger[]>((acc, { trigger }) => {
55
+ acc.push(trigger);
56
+ return acc;
57
+ }, []);
43
58
  }
44
59
 
45
60
  async start() {
46
- await this._ctx.dispose();
47
- this._ctx = createContext();
48
- await this.functions.open(this._ctx);
49
- await this.triggers.open(this._ctx);
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
+ });
50
69
  }
51
70
 
52
71
  async stop() {
53
- await this._ctx.dispose();
54
- await this.functions.close();
55
- await this.triggers.close();
72
+ for (const { id, spaceKey } of this._mounts.keys()) {
73
+ await this.unmount(id, spaceKey);
74
+ }
56
75
  }
57
76
 
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
- }
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}`);
63
84
 
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
- }
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
+ }
74
93
 
75
- private async activate(space: Space, functions: FunctionDef[], fnTrigger: FunctionTrigger) {
76
- const definition = functions.find((def) => def.uri === fnTrigger.function);
77
- if (!definition) {
78
- log.info('function is not found for trigger', { fnTrigger });
79
- return;
80
- }
94
+ //
95
+ // Triggers types.
96
+ //
81
97
 
82
- await this.triggers.activate({ space }, fnTrigger, async (args) => {
83
- return this._callMutex.executeSynchronized(() => {
84
- return this._execFunction(definition, fnTrigger, {
85
- meta: fnTrigger.meta,
86
- data: { ...args, spaceKey: space.key },
87
- });
88
- });
89
- });
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
+ }
90
109
 
91
- log('activated trigger', { space: space.key, trigger: fnTrigger });
110
+ if (trigger.subscription) {
111
+ await this._createSubscription(ctx, space, def, trigger.subscription);
112
+ }
113
+ }
92
114
  }
93
115
 
94
- private async _execFunction<TData, TMeta>(
95
- def: FunctionDef,
96
- trigger: FunctionTrigger,
97
- { data, meta }: { data: TData; meta?: TMeta },
98
- ): Promise<number> {
99
- let status = 0;
100
- try {
101
- // TODO(burdon): Pass in Space key (common context)?
102
- const payload = Object.assign({}, meta && ({ meta } satisfies FunctionEventMeta<TMeta>), data);
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();
122
+ }
123
+ }
103
124
 
125
+ // TODO(burdon): Pass in Space key (common context).
126
+ private async _execFunction(def: FunctionDef, data: any): Promise<number> {
127
+ try {
128
+ let status = 0;
104
129
  const { endpoint, callback } = this._options;
105
130
  if (endpoint) {
106
131
  // TODO(burdon): Move out of scheduler (generalize as callback).
107
- const url = path.join(endpoint, def.route);
108
- log.info('exec', { function: def.uri, url, triggerType: trigger.spec.type });
132
+ const url = path.join(endpoint, def.path);
133
+ log.info('exec', { function: def.id, url });
109
134
  const response = await fetch(url, {
110
135
  method: 'POST',
111
136
  headers: {
112
137
  'Content-Type': 'application/json',
113
138
  },
114
- body: JSON.stringify(payload),
139
+ body: JSON.stringify(data),
115
140
  });
116
141
 
117
142
  status = response.status;
118
143
  } else if (callback) {
119
- log.info('exec', { function: def.uri });
120
- status = (await callback(payload)) ?? 200;
144
+ log.info('exec', { function: def.id });
145
+ status = (await callback(data)) ?? 200;
121
146
  }
122
147
 
123
148
  // Check errors.
@@ -126,14 +151,214 @@ export class Scheduler {
126
151
  }
127
152
 
128
153
  // const result = await response.json();
129
- log.info('done', { function: def.uri, status });
154
+ log.info('done', { function: def.id, status });
155
+ return status;
130
156
  } catch (err: any) {
131
- log.error('error', { function: def.uri, error: err.message });
132
- status = 500;
157
+ log.error('error', { function: def.id, error: err.message });
158
+ return 500;
133
159
  }
160
+ }
161
+
162
+ //
163
+ // Triggers
164
+ //
134
165
 
135
- return status;
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
+
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());
136
197
  }
137
- }
138
198
 
139
- const createContext = () => new Context({ name: 'FunctionScheduler' });
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
+ });
363
+ }
364
+ }
@@ -4,12 +4,6 @@
4
4
 
5
5
  import { type FunctionHandler } from '../../handler';
6
6
 
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);
7
+ export const handler: FunctionHandler<any> = async ({ response }) => {
8
+ return response.status(200);
15
9
  };