@dxos/functions 0.3.8-next.f4e0086 → 0.3.9-main.03b62b6

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.
@@ -3,33 +3,38 @@
3
3
  //
4
4
 
5
5
  import express from 'express';
6
+ import { getPort } from 'get-port-please';
6
7
  import type http from 'http';
7
8
  import { join } from 'node:path';
8
- import { getPortPromise } from 'portfinder';
9
9
 
10
10
  import { Trigger } from '@dxos/async';
11
11
  import { type Client } from '@dxos/client';
12
+ import { invariant } from '@dxos/invariant';
12
13
  import { log } from '@dxos/log';
13
14
 
14
15
  import { type FunctionContext, type FunctionHandler, type Response } from '../handler';
15
16
  import { type FunctionDef, type FunctionManifest } from '../manifest';
16
17
 
17
- const DEFAULT_PORT = 7001;
18
-
19
18
  export type DevServerOptions = {
19
+ port?: number;
20
20
  directory: string;
21
21
  manifest: FunctionManifest;
22
+ reload?: boolean;
23
+ dataDir?: string;
22
24
  };
23
25
 
24
26
  /**
25
27
  * Functions dev server provides a local HTTP server for testing functions.
26
28
  */
27
29
  export class DevServer {
30
+ // Function handlers indexed by name (URL path).
28
31
  private readonly _handlers: Record<string, { def: FunctionDef; handler: FunctionHandler<any> }> = {};
29
32
 
30
33
  private _server?: http.Server;
31
34
  private _port?: number;
32
35
  private _registrationId?: string;
36
+ private _proxy?: string;
37
+ private _seq = 0;
33
38
 
34
39
  // prettier-ignore
35
40
  constructor(
@@ -37,12 +42,13 @@ export class DevServer {
37
42
  private readonly _options: DevServerOptions,
38
43
  ) {}
39
44
 
40
- get port() {
41
- return this._port;
45
+ get endpoint() {
46
+ invariant(this._port);
47
+ return `http://localhost:${this._port}`;
42
48
  }
43
49
 
44
- get endpoint() {
45
- return this._port ? `http://localhost:${this._port}` : undefined;
50
+ get proxy() {
51
+ return this._proxy;
46
52
  }
47
53
 
48
54
  get functions() {
@@ -51,20 +57,8 @@ export class DevServer {
51
57
 
52
58
  async initialize() {
53
59
  for (const def of this._options.manifest.functions) {
54
- const { id, endpoint, handler: path } = def;
55
60
  try {
56
- // eslint-disable-next-line @typescript-eslint/no-var-requires
57
- const module = require(join(this._options.directory, path));
58
- const handler = module.default;
59
- if (typeof handler !== 'function') {
60
- throw new Error(`Handler must export default function: ${id}`);
61
- }
62
-
63
- if (this._handlers[endpoint]) {
64
- log.warn(`Function already registered: ${id}`);
65
- }
66
-
67
- this._handlers[endpoint] = { def, handler };
61
+ await this._load(def);
68
62
  } catch (err) {
69
63
  log.error('parsing function (check manifest)', err);
70
64
  }
@@ -75,54 +69,39 @@ export class DevServer {
75
69
  const app = express();
76
70
  app.use(express.json());
77
71
 
78
- app.post('/:endpoint', async (req, res) => {
79
- const { endpoint } = req.params;
80
-
81
- const response: Response = {
82
- status: (code: number) => {
83
- res.statusCode = code;
84
- return response;
85
- },
86
-
87
- succeed: (result = {}) => {
88
- res.end(JSON.stringify(result));
89
- return response;
90
- },
91
- };
92
-
93
- const context: FunctionContext = {
94
- client: this._client,
95
- status: response.status.bind(response),
96
- };
97
-
98
- void (async () => {
99
- try {
100
- log('invoking', { endpoint });
101
- const { handler } = this._handlers[endpoint];
102
- const response = await handler({ context, event: req.body });
103
- log('done', { response });
104
- } catch (err: any) {
105
- res.statusCode = 500;
106
- res.end(err.message);
72
+ app.post('/:name', async (req, res) => {
73
+ const { name } = req.params;
74
+ try {
75
+ if (this._options.reload) {
76
+ const { def } = this._handlers[name];
77
+ await this._load(def, true);
107
78
  }
108
- })();
79
+
80
+ res.statusCode = await this._invoke(name, req.body);
81
+ res.end();
82
+ } catch (err: any) {
83
+ log.catch(err);
84
+ res.statusCode = 500;
85
+ res.end();
86
+ }
109
87
  });
110
88
 
111
- this._port = await getPortPromise({ startPort: DEFAULT_PORT });
89
+ this._port = await getPort({ host: 'localhost', port: 7200, portRange: [7200, 7299] });
112
90
  this._server = app.listen(this._port);
113
91
 
114
- // TODO(burdon): Check plugin is registered.
115
- // TypeError: Cannot read properties of undefined (reading 'register')
116
92
  try {
117
- const { registrationId } = await this._client.services.services.FunctionRegistryService!.register({
118
- endpoint: this.endpoint!,
119
- functions: this.functions.map(({ def: { endpoint } }) => ({ name: endpoint })), // TODO(burdon): Change proto name => id.
93
+ // Register functions.
94
+ const { registrationId, endpoint } = await this._client.services.services.FunctionRegistryService!.register({
95
+ endpoint: this.endpoint,
96
+ functions: this.functions.map(({ def: { name } }) => ({ name })),
120
97
  });
121
98
 
99
+ log.info('registered', { registrationId, endpoint });
122
100
  this._registrationId = registrationId;
101
+ this._proxy = endpoint;
123
102
  } catch (err: any) {
124
103
  await this.stop();
125
- throw new Error('FunctionRegistryService not available; check config (agent.plugins.functions).');
104
+ throw new Error('FunctionRegistryService not available (check plugin is configured).');
126
105
  }
127
106
  }
128
107
 
@@ -133,14 +112,70 @@ export class DevServer {
133
112
  await this._client.services.services.FunctionRegistryService!.unregister({
134
113
  registrationId: this._registrationId,
135
114
  });
115
+
116
+ log.info('unregistered', { registrationId: this._registrationId });
136
117
  this._registrationId = undefined;
118
+ this._proxy = undefined;
137
119
  }
138
120
 
139
121
  trigger.wake();
140
122
  });
141
123
 
142
124
  await trigger.wait();
143
- this._server = undefined;
144
125
  this._port = undefined;
126
+ this._server = undefined;
127
+ }
128
+
129
+ /**
130
+ * Load function.
131
+ */
132
+ private async _load(def: FunctionDef, flush = false) {
133
+ const { id, name, handler } = def;
134
+ const path = join(this._options.directory, handler);
135
+ log.info('loading', { id });
136
+
137
+ // Remove from cache.
138
+ if (flush) {
139
+ Object.keys(require.cache)
140
+ .filter((key) => key.startsWith(path))
141
+ .forEach((key) => delete require.cache[key]);
142
+ }
143
+
144
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
145
+ const module = require(path);
146
+ if (typeof module.default !== 'function') {
147
+ throw new Error(`Handler must export default function: ${id}`);
148
+ }
149
+
150
+ this._handlers[name] = { def, handler: module.default };
151
+ }
152
+
153
+ /**
154
+ * Invoke function handler.
155
+ */
156
+ private async _invoke(name: string, event: any) {
157
+ const seq = ++this._seq;
158
+ const now = Date.now();
159
+
160
+ log.info('req', { seq, name });
161
+ const { handler } = this._handlers[name];
162
+
163
+ const context: FunctionContext = {
164
+ client: this._client,
165
+ dataDir: this._options.dataDir,
166
+ };
167
+
168
+ let statusCode = 200;
169
+ const response: Response = {
170
+ status: (code: number) => {
171
+ statusCode = code;
172
+ return response;
173
+ },
174
+ };
175
+
176
+ await handler({ context, event, response });
177
+ log.info('res', { seq, name, statusCode, duration: Date.now() - now });
178
+
179
+ return statusCode;
145
180
  }
146
181
  }
@@ -3,4 +3,4 @@
3
3
  //
4
4
 
5
5
  export * from './dev-server';
6
- export * from './trigger-manager';
6
+ export * from './scheduler';
@@ -0,0 +1,162 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { CronJob } from 'cron';
6
+
7
+ import { debounce, DeferredTask } from '@dxos/async';
8
+ import { type Client, type PublicKey } from '@dxos/client';
9
+ import { type Space, TextObject } from '@dxos/client/echo';
10
+ import { Context } from '@dxos/context';
11
+ import { Filter, createSubscription, type Query, subscribe } from '@dxos/echo-schema';
12
+ import { invariant } from '@dxos/invariant';
13
+ import { log } from '@dxos/log';
14
+ import { ComplexMap } from '@dxos/util';
15
+
16
+ import { type FunctionDef, type FunctionManifest, type FunctionTrigger } from '../manifest';
17
+
18
+ type SchedulerOptions = {
19
+ endpoint: string;
20
+ };
21
+
22
+ /**
23
+ * Functions scheduler.
24
+ */
25
+ // TODO(burdon): Create tests.
26
+ export class Scheduler {
27
+ // Map of mounted functions.
28
+ private readonly _mounts = new ComplexMap<
29
+ { id: string; spaceKey: PublicKey },
30
+ { ctx: Context; trigger: FunctionTrigger }
31
+ >(({ id, spaceKey }) => `${spaceKey.toHex()}:${id}`);
32
+
33
+ constructor(
34
+ private readonly _client: Client,
35
+ private readonly _manifest: FunctionManifest,
36
+ private readonly _options: SchedulerOptions,
37
+ ) {}
38
+
39
+ async start() {
40
+ this._client.spaces.subscribe(async (spaces) => {
41
+ for (const space of spaces) {
42
+ await space.waitUntilReady();
43
+ for (const trigger of this._manifest.triggers ?? []) {
44
+ await this.mount(new Context(), space, trigger);
45
+ }
46
+ }
47
+ });
48
+ }
49
+
50
+ async stop() {
51
+ for (const { id, spaceKey } of this._mounts.keys()) {
52
+ await this.unmount(id, spaceKey);
53
+ }
54
+ }
55
+
56
+ private async mount(ctx: Context, space: Space, trigger: FunctionTrigger) {
57
+ const key = { id: trigger.function, spaceKey: space.key };
58
+ const def = this._manifest.functions.find((config) => config.id === trigger.function);
59
+ invariant(def, `Function not found: ${trigger.function}`);
60
+
61
+ const exists = this._mounts.get(key);
62
+ if (!exists) {
63
+ this._mounts.set(key, { ctx, trigger });
64
+ log('mount', { space: space.key, trigger });
65
+ if (ctx.disposed) {
66
+ return;
67
+ }
68
+
69
+ // Cron schedule.
70
+ if (trigger.schedule) {
71
+ const task = new DeferredTask(ctx, async () => {
72
+ await this.execFunction(def, {
73
+ space: space.key,
74
+ });
75
+ });
76
+
77
+ // TODO(burdon): Check greater than 30s min (use cron-parser).
78
+ const job = new CronJob(trigger.schedule, () => task.schedule());
79
+
80
+ job.start();
81
+ ctx.onDispose(() => job.stop());
82
+ }
83
+
84
+ // ECHO subscription.
85
+ if (trigger.subscription) {
86
+ const objectIds = new Set<string>();
87
+ const task = new DeferredTask(ctx, async () => {
88
+ await this.execFunction(def, {
89
+ space: space.key,
90
+ objects: Array.from(objectIds),
91
+ });
92
+ });
93
+
94
+ // TODO(burdon): Standardize subscription handles.
95
+ const subscriptions: (() => void)[] = [];
96
+ const subscription = createSubscription(({ added, updated }) => {
97
+ for (const object of added) {
98
+ objectIds.add(object.id);
99
+ }
100
+ for (const object of updated) {
101
+ objectIds.add(object.id);
102
+ }
103
+
104
+ task.schedule();
105
+ });
106
+ subscriptions.push(() => subscription.unsubscribe());
107
+
108
+ const { type, props, deep, delay } = trigger.subscription;
109
+ const update = ({ objects }: Query) => {
110
+ subscription.update(objects);
111
+
112
+ // TODO(burdon): Hack to monitor changes to Document's text object.
113
+ if (deep) {
114
+ log.info('update', { type, deep, objects: objects.length });
115
+ for (const object of objects) {
116
+ const content = object.content;
117
+ if (content instanceof TextObject) {
118
+ subscriptions.push(content[subscribe](debounce(() => subscription.update([object]), 1_000)));
119
+ }
120
+ }
121
+ }
122
+ };
123
+
124
+ // TODO(burdon): [Bug]: all callbacks are fired on the first mutation.
125
+ // TODO(burdon): [Bug]: not updated when document is deleted (either top or hierarchically).
126
+ const query = space.db.query(Filter.typename(type, props));
127
+ subscriptions.push(query.subscribe(delay ? debounce(update, delay * 1_000) : update));
128
+
129
+ ctx.onDispose(() => {
130
+ subscriptions.forEach((unsubscribe) => unsubscribe());
131
+ });
132
+ }
133
+ }
134
+ }
135
+
136
+ private async unmount(id: string, spaceKey: PublicKey) {
137
+ const key = { id, spaceKey };
138
+ const { ctx } = this._mounts.get(key) ?? {};
139
+ if (ctx) {
140
+ this._mounts.delete(key);
141
+ await ctx.dispose();
142
+ }
143
+ }
144
+
145
+ private async execFunction(def: FunctionDef, data: any) {
146
+ try {
147
+ log('request', { function: def.id });
148
+ const response = await fetch(`${this._options.endpoint}/${def.name}`, {
149
+ method: 'POST',
150
+ headers: {
151
+ 'Content-Type': 'application/json',
152
+ },
153
+ body: JSON.stringify(data),
154
+ });
155
+
156
+ // const result = await response.json();
157
+ log('result', { function: def.id, result: response.status });
158
+ } catch (err: any) {
159
+ log.error('error', { function: def.id, error: err.message });
160
+ }
161
+ }
162
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"trigger-manager.d.ts","sourceRoot":"","sources":["../../../../src/runtime/trigger-manager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,MAAM,EAAkB,MAAM,cAAc,CAAC;AAQ3D,OAAO,EAAE,KAAK,gBAAgB,EAAwB,MAAM,aAAa,CAAC;AAG1E,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,cAAc;IASvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,cAAc;IAVjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAGiC;IAEzD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoB;gBAG1B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,gBAAgB,EAC3B,cAAc,EAAE,aAAa;IAG1C,KAAK;IAaL,IAAI;YAMI,KAAK;YAuDL,OAAO;YASP,YAAY;CAqB3B"}
@@ -1,140 +0,0 @@
1
- //
2
- // Copyright 2023 DXOS.org
3
- //
4
-
5
- import { DeferredTask } from '@dxos/async';
6
- import { type Client, type PublicKey } from '@dxos/client';
7
- import type { Query, Space } from '@dxos/client/echo';
8
- import { Context } from '@dxos/context';
9
- import { Filter, createSubscription } from '@dxos/echo-schema';
10
- import { invariant } from '@dxos/invariant';
11
- import { log } from '@dxos/log';
12
- import { ComplexMap } from '@dxos/util';
13
-
14
- import { type FunctionManifest, type FunctionTrigger } from '../manifest';
15
-
16
- // TODO(burdon): Rename.
17
- export type InvokeOptions = {
18
- endpoint: string;
19
- runtime: string;
20
- };
21
-
22
- export class TriggerManager {
23
- private readonly _mounts = new ComplexMap<
24
- { name: string; spaceKey: PublicKey },
25
- { ctx: Context; trigger: FunctionTrigger }
26
- >(({ name, spaceKey }) => `${spaceKey.toHex()}:${name}`);
27
-
28
- private readonly _queries = new Set<Query>();
29
-
30
- constructor(
31
- private readonly _client: Client,
32
- private readonly _manifest: FunctionManifest,
33
- private readonly _invokeOptions: InvokeOptions,
34
- ) {}
35
-
36
- async start() {
37
- // TODO(burdon): Make runtime configurable (via CLI)?
38
- this._client.spaces.subscribe(async (spaces) => {
39
- for (const space of spaces) {
40
- await space.waitUntilReady();
41
- for (const trigger of this._manifest.triggers ?? []) {
42
- // TODO(burdon): New context? Shared?
43
- await this.mount(new Context(), space, trigger);
44
- }
45
- }
46
- });
47
- }
48
-
49
- async stop() {
50
- for (const { name, spaceKey } of this._mounts.keys()) {
51
- await this.unmount(name, spaceKey);
52
- }
53
- }
54
-
55
- private async mount(ctx: Context, space: Space, trigger: FunctionTrigger) {
56
- const key = { name: trigger.function, spaceKey: space.key };
57
- const config = this._manifest.functions.find((config) => config.id === trigger.function);
58
- invariant(config, `Function not found: ${trigger.function}`);
59
- const exists = this._mounts.get(key);
60
- if (!exists) {
61
- this._mounts.set(key, { ctx, trigger });
62
- log('mount', { space: space.key, trigger });
63
- if (ctx.disposed) {
64
- return;
65
- }
66
-
67
- // TODO(burdon): Why DeferredTask? How to pass objectIds to function?
68
- const objectIds = new Set<string>();
69
- const task = new DeferredTask(ctx, async () => {
70
- await this.execFunction(this._invokeOptions, config.endpoint, {
71
- space: space.key,
72
- objects: Array.from(objectIds),
73
- });
74
- });
75
-
76
- let count = 0;
77
- const subscription = createSubscription(({ added, updated }) => {
78
- for (const object of added) {
79
- objectIds.add(object.id);
80
- }
81
- for (const object of updated) {
82
- objectIds.add(object.id);
83
- }
84
-
85
- log('updated', {
86
- trigger,
87
- space: space.key,
88
- objects: objectIds.size,
89
- count,
90
- });
91
-
92
- task.schedule();
93
- count++;
94
- });
95
- // TODO(burdon): DSL for query (replace props).
96
- const query = space.db.query(Filter.typename(trigger.subscription.type, trigger.subscription.props));
97
- this._queries.add(query);
98
- const unsubscribe = query.subscribe(({ objects }) => {
99
- subscription.update(objects);
100
- }, true);
101
-
102
- ctx.onDispose(() => {
103
- subscription.unsubscribe();
104
- unsubscribe();
105
- this._queries.delete(query);
106
- });
107
- }
108
- }
109
-
110
- private async unmount(name: string, spaceKey: PublicKey) {
111
- const key = { name, spaceKey };
112
- const { ctx } = this._mounts.get(key) ?? {};
113
- if (ctx) {
114
- this._mounts.delete(key);
115
- await ctx.dispose();
116
- }
117
- }
118
-
119
- private async execFunction(options: InvokeOptions, functionName: string, data: any) {
120
- const { endpoint, runtime } = options;
121
- invariant(endpoint, 'Missing endpoint');
122
- invariant(runtime, 'Missing runtime');
123
-
124
- try {
125
- log('invoke', { function: functionName });
126
- const url = `${endpoint}/${runtime}/${functionName}`;
127
- const res = await fetch(url, {
128
- method: 'POST',
129
- body: JSON.stringify(data),
130
- headers: {
131
- 'Content-Type': 'application/json',
132
- },
133
- });
134
-
135
- log('result', { function: functionName, result: await res.json() });
136
- } catch (err: any) {
137
- log.error('error', { function: functionName, error: err.message });
138
- }
139
- }
140
- }