@dxos/functions 0.1.52 → 0.1.53-main.032dce6

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.
@@ -0,0 +1,131 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import express from 'express';
6
+ import http from 'http';
7
+ import { join } from 'node:path';
8
+ import { getPortPromise } from 'portfinder';
9
+
10
+ import { Trigger } from '@dxos/async';
11
+ import { Client } from '@dxos/client';
12
+ import { log } from '@dxos/log';
13
+
14
+ import { FunctionContext, FunctionHandler, FunctionsManifest, Response } from '../function';
15
+
16
+ const DEFAULT_PORT = 7000;
17
+
18
+ export type DevServerOptions = {
19
+ directory: string;
20
+ manifest: FunctionsManifest;
21
+ };
22
+
23
+ /**
24
+ * Functions dev server provides a local HTTP server for testing functions.
25
+ */
26
+ export class DevServer {
27
+ private readonly _functionHandlers: Record<string, FunctionHandler> = {};
28
+
29
+ private _server?: http.Server;
30
+ private _port?: number;
31
+ private _registrationId?: string;
32
+
33
+ // prettier-ignore
34
+ constructor(
35
+ private readonly _client: Client,
36
+ private readonly _options: DevServerOptions
37
+ ) {}
38
+
39
+ get port() {
40
+ return this._port;
41
+ }
42
+
43
+ get endpoint() {
44
+ return this._port ? `http://localhost:${this._port}` : undefined;
45
+ }
46
+
47
+ get functions() {
48
+ return Object.keys(this._functionHandlers);
49
+ }
50
+
51
+ async initialize() {
52
+ for (const [name, _] of Object.entries(this._options.manifest.functions)) {
53
+ try {
54
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
55
+ const module = require(join(this._options.directory, name));
56
+ const handler = module.default;
57
+ if (typeof handler !== 'function') {
58
+ throw new Error(`Handler must export default function: ${name}`);
59
+ }
60
+
61
+ this._functionHandlers[name] = handler;
62
+ } catch (err) {
63
+ log.error('parsing function (check functions.yml manifest)', err);
64
+ }
65
+ }
66
+ }
67
+
68
+ async start() {
69
+ const app = express();
70
+ app.use(express.json());
71
+
72
+ app.post('/:functionName', async (req, res) => {
73
+ const functionName = req.params.functionName;
74
+ log('invoke', { function: functionName, data: req.body });
75
+
76
+ const builder: Response = {
77
+ status: (code: number) => {
78
+ res.statusCode = code;
79
+ return builder;
80
+ },
81
+ succeed: (result = {}) => {
82
+ res.end(JSON.stringify(result));
83
+ return builder;
84
+ },
85
+ };
86
+
87
+ const context: FunctionContext = {
88
+ client: this._client,
89
+ status: builder.status.bind(builder),
90
+ };
91
+
92
+ void (async () => {
93
+ try {
94
+ await this._functionHandlers[functionName](req.body, context);
95
+ } catch (err: any) {
96
+ res.statusCode = 500;
97
+ res.end(err.message);
98
+ }
99
+ })();
100
+ });
101
+
102
+ this._port = await getPortPromise({ startPort: DEFAULT_PORT });
103
+ this._server = app.listen(this._port);
104
+
105
+ // TODO(burdon): Check plugin is registered.
106
+ // TypeError: Cannot read properties of undefined (reading 'register')
107
+ const { registrationId } = await this._client.services.services.FunctionRegistryService!.register({
108
+ endpoint: this.endpoint!,
109
+ functions: this.functions.map((name) => ({ name })),
110
+ });
111
+ this._registrationId = registrationId;
112
+ }
113
+
114
+ async stop() {
115
+ const trigger = new Trigger();
116
+ this._server?.close(async () => {
117
+ if (this._registrationId) {
118
+ await this._client.services.services.FunctionRegistryService!.unregister({
119
+ registrationId: this._registrationId,
120
+ });
121
+ this._registrationId = undefined;
122
+ }
123
+
124
+ trigger.wake();
125
+ });
126
+
127
+ await trigger.wait();
128
+ this._server = undefined;
129
+ this._port = undefined;
130
+ }
131
+ }
@@ -2,93 +2,5 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import express from 'express';
6
- import { readdir } from 'node:fs/promises';
7
- import { extname, join } from 'node:path';
8
- import { getPortPromise } from 'portfinder';
9
-
10
- import { Client } from '@dxos/client';
11
- import { log } from '@dxos/log';
12
-
13
- import { FunctionContext, FunctionHandler, Reply } from '../interface';
14
-
15
- const FUNCTION_EXTENSIONS = ['.js', '.ts'];
16
-
17
- export type FunctionsRuntimeParams = {
18
- client: Client;
19
- functionsDirectory: string;
20
- };
21
-
22
- export const runFunctions = async (options: FunctionsRuntimeParams) => {
23
- const files = await readdir(options.functionsDirectory);
24
-
25
- const functionHandlers: Record<string, FunctionHandler> = {};
26
-
27
- for (const file of files) {
28
- if (!FUNCTION_EXTENSIONS.some((ext) => extname(file) === ext)) {
29
- continue;
30
- }
31
-
32
- try {
33
- // eslint-disable-next-line @typescript-eslint/no-var-requires
34
- const module = require(join(options.functionsDirectory, file));
35
- const handler = module.default;
36
- if (typeof handler !== 'function') {
37
- throw new Error(`Function ${file} does not export a default function`);
38
- }
39
-
40
- const functionName = file.slice(0, -extname(file).length);
41
-
42
- functionHandlers[functionName] = handler;
43
- } catch (e) {
44
- console.error(e);
45
- }
46
- }
47
-
48
- const port = await getPortPromise({ startPort: 7000 });
49
-
50
- const app = express();
51
- app.use(express.json());
52
-
53
- app.post('/:functionName', async (req, res) => {
54
- const functionName = req.params.functionName;
55
-
56
- const replyBuilder: Reply = {
57
- status: (code: number) => {
58
- res.statusCode = code;
59
- return replyBuilder;
60
- },
61
- succeed: (result: any) => {
62
- res.end(JSON.stringify(result));
63
- return replyBuilder;
64
- },
65
- };
66
- const context: FunctionContext = {
67
- client: options.client,
68
- status: replyBuilder.status.bind(replyBuilder),
69
- };
70
-
71
- void (async () => {
72
- try {
73
- await functionHandlers[functionName](req.body, context);
74
- } catch (err: any) {
75
- res.statusCode = 500;
76
- res.end(err.message);
77
- }
78
- })();
79
- });
80
- app.listen(port);
81
-
82
- const functionNames = Object.keys(functionHandlers);
83
- const { registrationId } = await options.client.services.services.FunctionRegistryService!.register({
84
- endpoint: `http://localhost:${port}`,
85
- functions: functionNames.map((name) => ({ name })),
86
- });
87
-
88
- process.on('SIGINT', async () => {
89
- await options.client.services.services.FunctionRegistryService!.unregister({ registrationId });
90
- process.exit();
91
- });
92
-
93
- log.info('functions runtime started', { port, functionNames, registrationId });
94
- };
5
+ export * from './dev-server';
6
+ export * from './trigger-manager';
@@ -0,0 +1,143 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import assert from 'node:assert';
6
+
7
+ import { DeferredTask } from '@dxos/async';
8
+ import { Client, PublicKey, Space } from '@dxos/client';
9
+ import { Context } from '@dxos/context';
10
+ import { createSubscription } from '@dxos/echo-schema';
11
+ import { log } from '@dxos/log';
12
+ import { ComplexMap } from '@dxos/util';
13
+
14
+ import { FunctionTrigger } from '../function';
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
+ constructor(
29
+ private readonly _client: Client,
30
+ private readonly _triggers: FunctionTrigger[],
31
+ private readonly _invokeOptions: InvokeOptions,
32
+ ) {}
33
+
34
+ async start() {
35
+ // TODO(burdon): Make runtime configurable (via CLI)?
36
+ this._client.spaces.subscribe(async (spaces) => {
37
+ for (const space of spaces) {
38
+ await space.waitUntilReady();
39
+ for (const trigger of this._triggers) {
40
+ // TODO(burdon): New context? Shared?
41
+ await this.mount(new Context(), trigger, space);
42
+ }
43
+ }
44
+ });
45
+ }
46
+
47
+ async stop() {
48
+ for (const { name, spaceKey } of this._mounts.keys()) {
49
+ await this.unmount(name, spaceKey);
50
+ }
51
+ }
52
+
53
+ private async mount(ctx: Context, trigger: FunctionTrigger, space: Space) {
54
+ const key = { name: trigger.function, spaceKey: space.key };
55
+ const exists = this._mounts.get(key);
56
+ if (!exists) {
57
+ this._mounts.set(key, { ctx, trigger });
58
+ if (ctx.disposed) {
59
+ return;
60
+ }
61
+
62
+ // TODO(burdon): Factor out subscription/result delta.
63
+
64
+ let count = 0;
65
+ const objectIds = new Set<string>();
66
+ const task = new DeferredTask(ctx, async () => {
67
+ const updatedObjects = Array.from(objectIds);
68
+ objectIds.clear();
69
+
70
+ await this.invokeFunction(this._invokeOptions, trigger.function, {
71
+ space: space.key,
72
+ objects: updatedObjects,
73
+ });
74
+ });
75
+
76
+ // TODO(burdon): Removed?
77
+ const selection = 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.info('updated', {
86
+ space: space.key,
87
+ objects: objectIds.size,
88
+ added: added.length,
89
+ updated: updated.length,
90
+ count,
91
+ });
92
+ if (count++) {
93
+ task.schedule();
94
+ }
95
+ });
96
+
97
+ ctx.onDispose(() => selection.unsubscribe());
98
+
99
+ const query = space.db.query({ ...trigger.subscription.props, '@type': trigger.subscription.type });
100
+ const unsubscribe = query.subscribe(({ objects }) => {
101
+ selection.update(objects);
102
+ });
103
+
104
+ // Trigger first update, but don't schedule task.
105
+ // selection.update(query.objects);
106
+
107
+ ctx.onDispose(unsubscribe);
108
+
109
+ log.info('mounted', { space: space.key, trigger });
110
+ }
111
+ }
112
+
113
+ private async unmount(name: string, spaceKey: PublicKey) {
114
+ const key = { name, spaceKey };
115
+ const { ctx } = this._mounts.get(key) ?? {};
116
+ if (ctx) {
117
+ this._mounts.delete(key);
118
+ await ctx.dispose();
119
+ }
120
+ }
121
+
122
+ private async invokeFunction(options: InvokeOptions, functionName: string, data: any) {
123
+ const { endpoint, runtime } = options;
124
+ assert(endpoint, 'Missing endpoint');
125
+ assert(runtime, 'Missing runtime');
126
+
127
+ try {
128
+ log('invoke', { function: functionName });
129
+ const url = `${endpoint}/${runtime}/${functionName}`;
130
+ const res = await fetch(url, {
131
+ method: 'POST',
132
+ body: JSON.stringify(data),
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ },
136
+ });
137
+
138
+ log('result', { function: functionName, result: await res.json() });
139
+ } catch (err: any) {
140
+ log.error('error', { function: functionName, error: err.message });
141
+ }
142
+ }
143
+ }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=functions.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"functions.test.d.ts","sourceRoot":"","sources":["../../../src/functions.test.ts"],"names":[],"mappings":""}
@@ -1,13 +0,0 @@
1
- import { Client } from '@dxos/client';
2
- export interface FunctionContext {
3
- client: Client;
4
- status(code: number): Reply;
5
- }
6
- export interface Reply {
7
- status(code: number): Reply;
8
- succeed(data: any): Reply;
9
- }
10
- export interface FunctionHandler {
11
- (event: any, context: FunctionContext): Promise<Reply>;
12
- }
13
- //# sourceMappingURL=interface.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/interface.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IAEf,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC;CAC7B;AAED,MAAM,WAAW,KAAK;IACpB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC;IAC5B,OAAO,CAAC,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,CAAC,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;CACxD"}
package/src/interface.ts DELETED
@@ -1,20 +0,0 @@
1
- //
2
- // Copyright 2023 DXOS.org
3
- //
4
-
5
- import { Client } from '@dxos/client';
6
-
7
- export interface FunctionContext {
8
- client: Client;
9
-
10
- status(code: number): Reply;
11
- }
12
-
13
- export interface Reply {
14
- status(code: number): Reply;
15
- succeed(data: any): Reply;
16
- }
17
-
18
- export interface FunctionHandler {
19
- (event: any, context: FunctionContext): Promise<Reply>;
20
- }
File without changes