@dxos/functions 0.8.2-main.fbd8ed0 → 0.8.2-staging.7ac8446

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 (176) hide show
  1. package/dist/lib/browser/bundler/index.mjs +3 -0
  2. package/dist/lib/browser/bundler/index.mjs.map +1 -1
  3. package/dist/lib/browser/chunk-HI7YZO2K.mjs +482 -0
  4. package/dist/lib/browser/chunk-HI7YZO2K.mjs.map +7 -0
  5. package/dist/lib/browser/chunk-LT4LR4VU.mjs +72 -0
  6. package/dist/lib/browser/chunk-LT4LR4VU.mjs.map +7 -0
  7. package/dist/lib/browser/chunk-RVSG6WTL.mjs +358 -0
  8. package/dist/lib/browser/chunk-RVSG6WTL.mjs.map +7 -0
  9. package/dist/lib/browser/chunk-XRCXIG74.mjs +12 -0
  10. package/dist/lib/browser/chunk-XRCXIG74.mjs.map +7 -0
  11. package/dist/lib/browser/edge/index.mjs +7 -63
  12. package/dist/lib/browser/edge/index.mjs.map +4 -4
  13. package/dist/lib/browser/index.mjs +101 -371
  14. package/dist/lib/browser/index.mjs.map +4 -4
  15. package/dist/lib/browser/meta.json +1 -1
  16. package/dist/lib/browser/testing/index.mjs +670 -0
  17. package/dist/lib/browser/testing/index.mjs.map +7 -0
  18. package/dist/lib/browser/types/index.mjs +49 -0
  19. package/dist/lib/browser/types/index.mjs.map +7 -0
  20. package/dist/lib/node/bundler/index.cjs +1 -0
  21. package/dist/lib/node/bundler/index.cjs.map +1 -1
  22. package/dist/lib/node/chunk-DSUGRAAL.cjs +392 -0
  23. package/dist/lib/node/chunk-DSUGRAAL.cjs.map +7 -0
  24. package/dist/lib/node/chunk-JEQ2X3Z6.cjs +34 -0
  25. package/dist/lib/node/chunk-JEQ2X3Z6.cjs.map +7 -0
  26. package/dist/lib/node/chunk-NXZNXVT3.cjs +94 -0
  27. package/dist/lib/node/chunk-NXZNXVT3.cjs.map +7 -0
  28. package/dist/lib/node/chunk-RXMCVAMJ.cjs +496 -0
  29. package/dist/lib/node/chunk-RXMCVAMJ.cjs.map +7 -0
  30. package/dist/lib/node/edge/index.cjs +5 -65
  31. package/dist/lib/node/edge/index.cjs.map +4 -4
  32. package/dist/lib/node/index.cjs +93 -383
  33. package/dist/lib/node/index.cjs.map +4 -4
  34. package/dist/lib/node/meta.json +1 -1
  35. package/dist/lib/node/testing/index.cjs +687 -0
  36. package/dist/lib/node/testing/index.cjs.map +7 -0
  37. package/dist/lib/node/types/index.cjs +70 -0
  38. package/dist/lib/node/types/index.cjs.map +7 -0
  39. package/dist/lib/node-esm/bundler/index.mjs +1 -0
  40. package/dist/lib/node-esm/bundler/index.mjs.map +1 -1
  41. package/dist/lib/node-esm/chunk-DHGBFXSZ.mjs +12 -0
  42. package/dist/lib/node-esm/chunk-DHGBFXSZ.mjs.map +7 -0
  43. package/dist/lib/node-esm/chunk-HBD2FZXO.mjs +358 -0
  44. package/dist/lib/node-esm/chunk-HBD2FZXO.mjs.map +7 -0
  45. package/dist/lib/node-esm/chunk-O2SXVYU5.mjs +72 -0
  46. package/dist/lib/node-esm/chunk-O2SXVYU5.mjs.map +7 -0
  47. package/dist/lib/node-esm/chunk-SQSJO5HI.mjs +482 -0
  48. package/dist/lib/node-esm/chunk-SQSJO5HI.mjs.map +7 -0
  49. package/dist/lib/node-esm/edge/index.mjs +6 -64
  50. package/dist/lib/node-esm/edge/index.mjs.map +4 -4
  51. package/dist/lib/node-esm/index.mjs +99 -371
  52. package/dist/lib/node-esm/index.mjs.map +4 -4
  53. package/dist/lib/node-esm/meta.json +1 -1
  54. package/dist/lib/node-esm/testing/index.mjs +670 -0
  55. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  56. package/dist/lib/node-esm/types/index.mjs +49 -0
  57. package/dist/lib/node-esm/types/index.mjs.map +7 -0
  58. package/dist/types/src/browser/index.d.ts +2 -0
  59. package/dist/types/src/browser/index.d.ts.map +1 -0
  60. package/dist/types/src/bundler/bundler.d.ts.map +1 -1
  61. package/dist/types/src/edge/functions.d.ts +3 -3
  62. package/dist/types/src/edge/functions.d.ts.map +1 -1
  63. package/dist/types/src/edge/index.d.ts.map +1 -1
  64. package/dist/types/src/function/function-registry.d.ts +25 -0
  65. package/dist/types/src/function/function-registry.d.ts.map +1 -0
  66. package/dist/types/src/function/function-registry.test.d.ts +2 -0
  67. package/dist/types/src/function/function-registry.test.d.ts.map +1 -0
  68. package/dist/types/src/function/index.d.ts +2 -0
  69. package/dist/types/src/function/index.d.ts.map +1 -0
  70. package/dist/types/src/handler.d.ts +66 -18
  71. package/dist/types/src/handler.d.ts.map +1 -1
  72. package/dist/types/src/index.d.ts +3 -3
  73. package/dist/types/src/index.d.ts.map +1 -1
  74. package/dist/types/src/runtime/dev-server.d.ts +52 -0
  75. package/dist/types/src/runtime/dev-server.d.ts.map +1 -0
  76. package/dist/types/src/runtime/dev-server.test.d.ts +2 -0
  77. package/dist/types/src/runtime/dev-server.test.d.ts.map +1 -0
  78. package/dist/types/src/runtime/index.d.ts +3 -0
  79. package/dist/types/src/runtime/index.d.ts.map +1 -0
  80. package/dist/types/src/runtime/scheduler.d.ts +34 -0
  81. package/dist/types/src/runtime/scheduler.d.ts.map +1 -0
  82. package/dist/types/src/runtime/scheduler.test.d.ts +2 -0
  83. package/dist/types/src/runtime/scheduler.test.d.ts.map +1 -0
  84. package/dist/types/src/testing/functions-integration.test.d.ts +2 -0
  85. package/dist/types/src/testing/functions-integration.test.d.ts.map +1 -0
  86. package/dist/types/src/testing/index.d.ts +5 -0
  87. package/dist/types/src/testing/index.d.ts.map +1 -0
  88. package/dist/types/src/testing/manifest.d.ts +3 -0
  89. package/dist/types/src/testing/manifest.d.ts.map +1 -0
  90. package/dist/types/src/testing/plugin-init.d.ts +6 -0
  91. package/dist/types/src/testing/plugin-init.d.ts.map +1 -0
  92. package/dist/types/src/testing/setup.d.ts +15 -0
  93. package/dist/types/src/testing/setup.d.ts.map +1 -0
  94. package/dist/types/src/testing/test/handler.d.ts +4 -0
  95. package/dist/types/src/testing/test/handler.d.ts.map +1 -0
  96. package/dist/types/src/testing/test/index.d.ts +3 -0
  97. package/dist/types/src/testing/test/index.d.ts.map +1 -0
  98. package/dist/types/src/testing/types.d.ts +10 -0
  99. package/dist/types/src/testing/types.d.ts.map +1 -0
  100. package/dist/types/src/testing/util.d.ts +5 -0
  101. package/dist/types/src/testing/util.d.ts.map +1 -0
  102. package/dist/types/src/translations.d.ts +1 -2
  103. package/dist/types/src/translations.d.ts.map +1 -1
  104. package/dist/types/src/trigger/index.d.ts +3 -0
  105. package/dist/types/src/trigger/index.d.ts.map +1 -0
  106. package/dist/types/src/trigger/trigger-registry.d.ts +38 -0
  107. package/dist/types/src/trigger/trigger-registry.d.ts.map +1 -0
  108. package/dist/types/src/trigger/trigger-registry.test.d.ts +2 -0
  109. package/dist/types/src/trigger/trigger-registry.test.d.ts.map +1 -0
  110. package/dist/types/src/trigger/type/index.d.ts +3 -0
  111. package/dist/types/src/trigger/type/index.d.ts.map +1 -0
  112. package/dist/types/src/trigger/type/subscription-trigger.d.ts +4 -0
  113. package/dist/types/src/trigger/type/subscription-trigger.d.ts.map +1 -0
  114. package/dist/types/src/trigger/type/timer-trigger.d.ts +4 -0
  115. package/dist/types/src/trigger/type/timer-trigger.d.ts.map +1 -0
  116. package/dist/types/src/trigger/type/webhook-trigger.d.ts +4 -0
  117. package/dist/types/src/trigger/type/webhook-trigger.d.ts.map +1 -0
  118. package/dist/types/src/types/index.d.ts +5 -0
  119. package/dist/types/src/types/index.d.ts.map +1 -0
  120. package/dist/types/src/types/schema.d.ts +53 -0
  121. package/dist/types/src/types/schema.d.ts.map +1 -0
  122. package/dist/types/src/types/trace.d.ts +146 -0
  123. package/dist/types/src/types/trace.d.ts.map +1 -0
  124. package/dist/types/src/types/types.d.ts +265 -0
  125. package/dist/types/src/types/types.d.ts.map +1 -0
  126. package/dist/types/src/{url.d.ts → types/url.d.ts} +0 -6
  127. package/dist/types/src/types/url.d.ts.map +1 -0
  128. package/dist/types/tools/schema.d.ts +2 -0
  129. package/dist/types/tools/schema.d.ts.map +1 -0
  130. package/dist/types/tsconfig.tsbuildinfo +1 -1
  131. package/package.json +35 -20
  132. package/schema/functions.json +211 -0
  133. package/src/browser/index.ts +5 -0
  134. package/src/edge/functions.ts +4 -7
  135. package/src/edge/index.ts +0 -4
  136. package/src/function/function-registry.test.ts +118 -0
  137. package/src/function/function-registry.ts +104 -0
  138. package/src/function/index.ts +5 -0
  139. package/src/handler.ts +124 -23
  140. package/src/index.ts +5 -4
  141. package/src/runtime/dev-server.test.ts +79 -0
  142. package/src/runtime/dev-server.ts +240 -0
  143. package/src/runtime/index.ts +6 -0
  144. package/src/runtime/scheduler.test.ts +152 -0
  145. package/src/runtime/scheduler.ts +170 -0
  146. package/src/testing/functions-integration.test.ts +65 -0
  147. package/src/testing/index.ts +8 -0
  148. package/src/testing/manifest.ts +15 -0
  149. package/src/testing/plugin-init.ts +20 -0
  150. package/src/testing/setup.ts +109 -0
  151. package/src/testing/test/handler.ts +15 -0
  152. package/src/testing/test/index.ts +7 -0
  153. package/src/testing/types.ts +9 -0
  154. package/src/testing/util.ts +26 -0
  155. package/src/translations.ts +1 -1
  156. package/src/trigger/index.ts +6 -0
  157. package/src/trigger/trigger-registry.test.ts +278 -0
  158. package/src/trigger/trigger-registry.ts +218 -0
  159. package/src/trigger/type/index.ts +7 -0
  160. package/src/trigger/type/subscription-trigger.ts +84 -0
  161. package/src/trigger/type/timer-trigger.ts +48 -0
  162. package/src/trigger/type/webhook-trigger.ts +48 -0
  163. package/src/types/index.ts +8 -0
  164. package/src/types/schema.ts +46 -0
  165. package/src/{trace.ts → types/trace.ts} +31 -33
  166. package/src/types/types.ts +163 -0
  167. package/src/{url.ts → types/url.ts} +0 -5
  168. package/dist/types/src/schema.d.ts +0 -57
  169. package/dist/types/src/schema.d.ts.map +0 -1
  170. package/dist/types/src/trace.d.ts +0 -148
  171. package/dist/types/src/trace.d.ts.map +0 -1
  172. package/dist/types/src/types.d.ts +0 -407
  173. package/dist/types/src/types.d.ts.map +0 -1
  174. package/dist/types/src/url.d.ts.map +0 -1
  175. package/src/schema.ts +0 -53
  176. package/src/types.ts +0 -210
package/src/handler.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { Schema } from 'effect';
5
+ import { Schema as S } from 'effect';
6
6
  import { type Effect } from 'effect';
7
7
 
8
- import { type AIServiceClient } from '@dxos/assistant';
9
- // import { type Space } from '@dxos/client/echo';
10
- import type { CoreDatabase, EchoDatabase } from '@dxos/echo-db';
8
+ import { type Client, PublicKey } from '@dxos/client';
9
+ import { type Space, type SpaceId } from '@dxos/client/echo';
10
+ import type { CoreDatabase, EchoDatabase, ReactiveEchoObject } from '@dxos/echo-db';
11
11
  import { type HasId } from '@dxos/echo-schema';
12
- import { type SpaceId, type DXN } from '@dxos/keys';
12
+ import { type DXN } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
13
14
  import { type QueryResult } from '@dxos/protocols';
15
+ import { isNonNullable } from '@dxos/util';
14
16
 
15
17
  // TODO(burdon): Model after http request. Ref Lambda/OpenFaaS.
16
18
  // https://docs.aws.amazon.com/lambda/latest/dg/typescript-handler.html
@@ -20,18 +22,13 @@ import { type QueryResult } from '@dxos/protocols';
20
22
  /**
21
23
  * Function handler.
22
24
  */
23
- export type FunctionHandler<TData = {}, TOutput = any> = (params: {
24
- /**
25
- * Services and context available to the function.
26
- */
25
+ export type FunctionHandler<TData = {}, TMeta = {}, TOutput = any> = (params: {
27
26
  context: FunctionContext;
28
-
27
+ event: FunctionEvent<TData, TMeta>;
29
28
  /**
30
- * Data passed as the input to the function.
31
- * Must match the function's input schema.
32
- * This will be the payload from the trigger or other data passed into the function in a workflow.
29
+ * @deprecated
33
30
  */
34
- data: TData;
31
+ response: FunctionResponse;
35
32
  }) => TOutput | Promise<TOutput> | Effect.Effect<TOutput, any>;
36
33
 
37
34
  /**
@@ -45,7 +42,18 @@ export interface FunctionContext {
45
42
  */
46
43
  space: SpaceAPI | undefined;
47
44
 
48
- ai: AIServiceClient;
45
+ ai: FunctionContextAi;
46
+
47
+ /**
48
+ * @deprecated
49
+ */
50
+ // TODO(burdon): Limit access to individual space.
51
+ client: Client;
52
+ /**
53
+ * @deprecated
54
+ */
55
+ // TODO(burdon): Replace with storage service abstraction.
56
+ dataDir?: string;
49
57
  }
50
58
 
51
59
  export interface FunctionContextAi {
@@ -53,6 +61,28 @@ export interface FunctionContextAi {
53
61
  run(model: string, inputs: any, options?: any): Promise<any>;
54
62
  }
55
63
 
64
+ /**
65
+ * Event payload.
66
+ */
67
+ // TODO(dmaretskyi): Update type definitions to match the actual payload.
68
+ export type FunctionEvent<TData = {}, TMeta = {}> = {
69
+ data: FunctionEventMeta<TMeta> & TData;
70
+ };
71
+
72
+ /**
73
+ * Metadata from trigger.
74
+ */
75
+ export type FunctionEventMeta<TMeta = {}> = {
76
+ meta: TMeta;
77
+ };
78
+
79
+ /**
80
+ * Function response.
81
+ */
82
+ export type FunctionResponse = {
83
+ status(code: number): FunctionResponse;
84
+ };
85
+
56
86
  //
57
87
  // API.
58
88
  //
@@ -78,21 +108,28 @@ export interface SpaceAPI {
78
108
  get queues(): QueuesAPI;
79
109
  }
80
110
 
81
- // TODO(wittjosiah): Queues are incompatible.
111
+ // TODO(wittjosiah): Fix this.
82
112
  const __assertFunctionSpaceIsCompatibleWithTheClientSpace = () => {
83
113
  // const _: SpaceAPI = {} as Space;
84
114
  };
85
115
 
86
- export type FunctionDefinition<T = {}, O = any> = {
116
+ export type FunctionDefinition = {
117
+ description?: string;
118
+ inputSchema: S.Schema.AnyNoContext;
119
+ outputSchema?: S.Schema.AnyNoContext;
120
+ handler: FunctionHandler<any>;
121
+ };
122
+
123
+ export type DefineFunctionParams<T, O = any> = {
87
124
  description?: string;
88
- inputSchema: Schema.Schema<T, any>;
89
- outputSchema?: Schema.Schema<O, any>;
90
- handler: FunctionHandler<T, O>;
125
+ inputSchema: S.Schema<T, any>;
126
+ outputSchema?: S.Schema<O, any>;
127
+ handler: FunctionHandler<T, any, O>;
91
128
  };
92
129
 
93
130
  // TODO(dmaretskyi): Bind input type to function handler.
94
- export const defineFunction = <T, O>(params: FunctionDefinition<T, O>): FunctionDefinition<T, O> => {
95
- if (!Schema.isSchema(params.inputSchema)) {
131
+ export const defineFunction = <T, O>(params: DefineFunctionParams<T, O>): FunctionDefinition => {
132
+ if (!S.isSchema(params.inputSchema)) {
96
133
  throw new Error('Input schema must be a valid schema');
97
134
  }
98
135
  if (typeof params.handler !== 'function') {
@@ -102,7 +139,71 @@ export const defineFunction = <T, O>(params: FunctionDefinition<T, O>): Function
102
139
  return {
103
140
  description: params.description,
104
141
  inputSchema: params.inputSchema,
105
- outputSchema: params.outputSchema ?? Schema.Any,
142
+ outputSchema: params.outputSchema ?? S.Any,
106
143
  handler: params.handler,
107
144
  };
108
145
  };
146
+
147
+ //
148
+ // Subscription utils.
149
+ //
150
+
151
+ export type RawSubscriptionData = {
152
+ spaceKey?: string;
153
+ objects?: string[];
154
+ };
155
+
156
+ export type SubscriptionData = {
157
+ space?: Space;
158
+ objects?: ReactiveEchoObject<any>[];
159
+ };
160
+
161
+ /**
162
+ * Handler wrapper for subscription events; extracts space and objects.
163
+ *
164
+ * To test:
165
+ * ```
166
+ * curl -s -X POST -H "Content-Type: application/json" --data '{"space": "0446...1cbb"}' http://localhost:7100/dev/email-extractor
167
+ * ```
168
+ *
169
+ * NOTE: Get space key from devtools or `dx space list --json`
170
+ */
171
+ // TODO(burdon): Evolve into plugin definition like Composer.
172
+ export const subscriptionHandler = <TMeta>(
173
+ handler: FunctionHandler<SubscriptionData, TMeta>,
174
+ types?: S.Schema.AnyNoContext[],
175
+ ): FunctionHandler<RawSubscriptionData, TMeta> => {
176
+ return async ({ event: { data }, context, response, ...rest }) => {
177
+ const { client } = context;
178
+ const space = data.spaceKey ? client.spaces.get(PublicKey.from(data.spaceKey)) : undefined;
179
+ if (!space) {
180
+ log.error('Invalid space');
181
+ return response.status(500);
182
+ }
183
+
184
+ registerTypes(space, types);
185
+ const objects = space
186
+ ? data.objects
187
+ ?.map<ReactiveEchoObject<any> | undefined>((id) => space!.db.getObjectById(id))
188
+ .filter(isNonNullable)
189
+ : [];
190
+
191
+ if (!!data.spaceKey && !space) {
192
+ log.warn('invalid space', { data });
193
+ } else {
194
+ log.info('handler', { space: space?.key.truncate(), objects: objects?.length });
195
+ }
196
+
197
+ return handler({ event: { data: { ...data, space, objects } }, context, response, ...rest });
198
+ };
199
+ };
200
+
201
+ // TODO(burdon): Evolve types as part of function metadata.
202
+ const registerTypes = (space: Space, types: S.Schema.AnyNoContext[] = []) => {
203
+ const registry = space.db.graph.schemaRegistry;
204
+ for (const type of types) {
205
+ if (!registry.hasSchema(type)) {
206
+ registry.addSchema([type]);
207
+ }
208
+ }
209
+ };
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  //
2
- // Copyright 2024 DXOS.org
2
+ // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ export * from './edge';
6
+ export * from './function';
5
7
  export * from './handler';
6
- export * from './schema';
7
- export * from './trace';
8
+ // export * from './runtime';
9
+ export * from './trigger';
8
10
  export * from './types';
9
- export * from './url';
@@ -0,0 +1,79 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { getRandomPort } from 'get-port-please';
6
+ import path from 'node:path';
7
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
8
+
9
+ import { sleep, waitForCondition } from '@dxos/async';
10
+ import { type Client } from '@dxos/client';
11
+ import { TestBuilder } from '@dxos/client/testing';
12
+
13
+ import { DevServer } from './dev-server';
14
+ import { FunctionRegistry } from '../function';
15
+ import { createFunctionRuntime, testFunctionManifest } from '../testing';
16
+ import { initFunctionsPlugin } from '../testing/plugin-init';
17
+
18
+ // TODO(wittjosiah): Doesn't work in vitest.
19
+ describe.skip('dev server', () => {
20
+ let client: Client;
21
+ let testBuilder: TestBuilder;
22
+
23
+ beforeAll(async () => {
24
+ testBuilder = new TestBuilder();
25
+ client = await createFunctionRuntime(testBuilder, initFunctionsPlugin);
26
+ expect(client.services.services.FunctionRegistryService).to.exist;
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await testBuilder.destroy();
31
+ });
32
+
33
+ test('function registry open after dev server started', async () => {
34
+ const { space, registry, server } = await setupTest();
35
+ await registry.register(space, testFunctionManifest.functions);
36
+ await server.start();
37
+ await registry.open();
38
+ await waitForCondition({ condition: () => server.functions.length > 0 });
39
+ await expectTestFunctionInvocable(server);
40
+ });
41
+
42
+ test('function registry open before dev server started', async () => {
43
+ const { space, registry, server } = await setupTest();
44
+ await registry.register(space, testFunctionManifest.functions);
45
+ await registry.open();
46
+ await server.start();
47
+ await waitForCondition({ condition: () => server.functions.length > 0 });
48
+ await expectTestFunctionInvocable(server);
49
+ });
50
+
51
+ test('unsubscribes from functions after stopped', async () => {
52
+ const { space, registry, server } = await setupTest();
53
+ await registry.register(space, testFunctionManifest.functions);
54
+ await server.start();
55
+ await server.stop();
56
+ await registry.open();
57
+ await sleep(20);
58
+ expect(server.functions.length).to.eq(0);
59
+ });
60
+
61
+ const expectTestFunctionInvocable = async (server: DevServer) => {
62
+ const seq = server.stats.seq;
63
+ await server.invoke('test', {});
64
+ expect(server.stats.seq).to.eq(seq + 1);
65
+ };
66
+
67
+ const setupTest = async () => {
68
+ const registry = new FunctionRegistry(client);
69
+ const server = new DevServer(client, registry, {
70
+ baseDir: path.join(__dirname, '../testing'),
71
+ port: await getRandomPort('127.0.0.1'),
72
+ });
73
+ const space = await client.spaces.create();
74
+ // TODO(burdon): Doesn't shut down cleanly.
75
+ // Error: invariant violation [this._client.services.services.FunctionRegistryService]
76
+ testBuilder.ctx.onDispose(() => server.stop());
77
+ return { registry, server, space };
78
+ };
79
+ });
@@ -0,0 +1,240 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import express from 'express';
6
+ import { getPort } from 'get-port-please';
7
+ import type http from 'http';
8
+ import { join } from 'node:path';
9
+
10
+ import { asyncTimeout, Event, Trigger } from '@dxos/async';
11
+ import { type Client } from '@dxos/client';
12
+ import { Context } from '@dxos/context';
13
+ import { invariant } from '@dxos/invariant';
14
+ import { log } from '@dxos/log';
15
+
16
+ import { type FunctionRegistry } from '../function';
17
+ import { type FunctionContext, type FunctionEvent, type FunctionHandler, type FunctionResponse } from '../handler';
18
+ import { type FunctionDef } from '../types';
19
+
20
+ const FN_TIMEOUT = 20_000;
21
+
22
+ export type DevServerOptions = {
23
+ baseDir: string;
24
+ port?: number;
25
+ reload?: boolean;
26
+ dataDir?: string;
27
+ };
28
+
29
+ /**
30
+ * Functions dev server provides a local HTTP server for loading and invoking functions.
31
+ * Functions are executed in the context of an authenticated client.
32
+ */
33
+ export class DevServer {
34
+ private _ctx = createContext();
35
+
36
+ // Function handlers indexed by name (URL path).
37
+ private readonly _handlers: Record<string, { def: FunctionDef; handler: FunctionHandler<any> }> = {};
38
+
39
+ private _server?: http.Server;
40
+ private _port?: number;
41
+ private _functionServiceRegistration?: string;
42
+ private _proxy?: string;
43
+ private _seq = 0;
44
+
45
+ public readonly update = new Event<number>();
46
+
47
+ constructor(
48
+ private readonly _client: Client,
49
+ private readonly _functionsRegistry: FunctionRegistry,
50
+ private readonly _options: DevServerOptions,
51
+ ) {}
52
+
53
+ get stats() {
54
+ return {
55
+ seq: this._seq,
56
+ };
57
+ }
58
+
59
+ get endpoint() {
60
+ invariant(this._port);
61
+ return `http://localhost:${this._port}`;
62
+ }
63
+
64
+ get proxy() {
65
+ return this._proxy;
66
+ }
67
+
68
+ get functions() {
69
+ return Object.values(this._handlers);
70
+ }
71
+
72
+ async start() {
73
+ invariant(!this._server);
74
+ log.info('starting...');
75
+ this._ctx = createContext();
76
+
77
+ // TODO(burdon): Change to hono.
78
+ const app = express();
79
+ app.use(express.json());
80
+
81
+ app.post('/:path', async (req, res) => {
82
+ const { path } = req.params;
83
+ try {
84
+ log.info('calling', { path });
85
+ if (this._options.reload) {
86
+ const { def } = this._handlers['/' + path];
87
+ await this._load(def, true);
88
+ }
89
+
90
+ // TODO(burdon): Get function context.
91
+ res.statusCode = await asyncTimeout(this.invoke('/' + path, req.body), FN_TIMEOUT);
92
+ res.end();
93
+ } catch (err: any) {
94
+ log.catch(err);
95
+ res.statusCode = 500;
96
+ res.end();
97
+ }
98
+ });
99
+
100
+ this._port = this._options.port ?? (await getPort({ host: 'localhost', port: 7200, portRange: [7200, 7299] }));
101
+ this._server = app.listen(this._port);
102
+
103
+ try {
104
+ // Register functions.
105
+ const { registrationId, endpoint } = await this._client.services.services.FunctionRegistryService!.register({
106
+ endpoint: this.endpoint,
107
+ });
108
+
109
+ log.info('registered', { endpoint });
110
+ this._proxy = endpoint;
111
+ this._functionServiceRegistration = registrationId;
112
+
113
+ // Open after registration, so that it can be updated with the list of function definitions.
114
+ await this._handleNewFunctions(this._functionsRegistry.getUniqueByUri());
115
+ this._ctx.onDispose(this._functionsRegistry.registered.on(({ added }) => this._handleNewFunctions(added)));
116
+ } catch (err: any) {
117
+ await this.stop();
118
+ throw new Error('FunctionRegistryService not available (check plugin is configured).');
119
+ }
120
+
121
+ log.info('started', { port: this._port });
122
+ }
123
+
124
+ async stop() {
125
+ if (!this._server) {
126
+ return;
127
+ }
128
+
129
+ log.info('stopping...');
130
+ await this._ctx.dispose();
131
+
132
+ const trigger = new Trigger();
133
+ this._server.close(async () => {
134
+ log.info('server stopped');
135
+ try {
136
+ if (this._functionServiceRegistration) {
137
+ invariant(this._client.services.services.FunctionRegistryService);
138
+ await this._client.services.services.FunctionRegistryService.unregister({
139
+ registrationId: this._functionServiceRegistration,
140
+ });
141
+
142
+ log.info('unregistered', { registrationId: this._functionServiceRegistration });
143
+ this._functionServiceRegistration = undefined;
144
+ this._proxy = undefined;
145
+ }
146
+
147
+ trigger.wake();
148
+ } catch (err) {
149
+ trigger.throw(err as Error);
150
+ }
151
+ });
152
+
153
+ await trigger.wait();
154
+ this._port = undefined;
155
+ this._server = undefined;
156
+ log.info('stopped');
157
+ }
158
+
159
+ private async _handleNewFunctions(newFunctions: FunctionDef[]) {
160
+ newFunctions.forEach((def) => this._load(def));
161
+ await this._safeUpdateRegistration();
162
+ log('new functions loaded', { newFunctions });
163
+ }
164
+
165
+ /**
166
+ * Load function.
167
+ */
168
+ private async _load(def: FunctionDef, force?: boolean | undefined) {
169
+ const { uri, route, handler } = def;
170
+ const filePath = join(this._options.baseDir, handler);
171
+ log.info('loading', { uri, force });
172
+
173
+ // Remove from cache.
174
+ if (force) {
175
+ Object.keys(require.cache)
176
+ .filter((key) => key.startsWith(filePath))
177
+ .forEach((key) => {
178
+ delete require.cache[key];
179
+ });
180
+ }
181
+
182
+ // TODO(burdon): Import types.
183
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
184
+ const module = require(filePath);
185
+ if (typeof module.default !== 'function') {
186
+ throw new Error(`Handler must export default function: ${uri}`);
187
+ }
188
+
189
+ this._handlers[route] = { def, handler: module.default };
190
+ }
191
+
192
+ private async _safeUpdateRegistration(): Promise<void> {
193
+ invariant(this._functionServiceRegistration);
194
+ try {
195
+ await this._client.services.services.FunctionRegistryService!.updateRegistration({
196
+ registrationId: this._functionServiceRegistration,
197
+ functions: this.functions.map(({ def: { id, route } }) => ({ id, route })),
198
+ });
199
+ } catch (err) {
200
+ log.catch(err);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Invoke function.
206
+ */
207
+ public async invoke(path: string, data: any): Promise<number> {
208
+ const seq = ++this._seq;
209
+ const now = Date.now();
210
+
211
+ log.info('req', { seq, path });
212
+ const statusCode = await this._invoke(path, { data });
213
+
214
+ log.info('res', { seq, path, statusCode, duration: Date.now() - now });
215
+ this.update.emit(statusCode);
216
+ return statusCode;
217
+ }
218
+
219
+ private async _invoke(path: string, event: FunctionEvent) {
220
+ const { handler } = this._handlers[path] ?? {};
221
+ invariant(handler, `invalid path: ${path}`);
222
+ const context: FunctionContext = {
223
+ client: this._client,
224
+ dataDir: this._options.dataDir,
225
+ } as any;
226
+
227
+ let statusCode = 200;
228
+ const response: FunctionResponse = {
229
+ status: (code: number) => {
230
+ statusCode = code;
231
+ return response;
232
+ },
233
+ };
234
+
235
+ await handler({ context, event, response });
236
+ return statusCode;
237
+ }
238
+ }
239
+
240
+ const createContext = () => new Context({ name: 'DevServer' });
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './dev-server';
6
+ export * from './scheduler';
@@ -0,0 +1,152 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
6
+
7
+ import { Trigger } from '@dxos/async';
8
+ import { type Client } from '@dxos/client';
9
+ import { TestBuilder } from '@dxos/client/testing';
10
+ import { create } from '@dxos/live-object';
11
+
12
+ import { Scheduler, type SchedulerOptions } from './scheduler';
13
+ import { FunctionRegistry } from '../function';
14
+ import { createInitializedClients, TestType, triggerWebhook } from '../testing';
15
+ import { TriggerRegistry } from '../trigger';
16
+ import { TriggerKind, type FunctionManifest } from '../types';
17
+
18
+ // TODO(burdon): Test we can add and remove triggers.
19
+ // Flaky: https://cloud.nx.app/runs/uqhKOBA6JQ/task/functions%3Atest
20
+ describe.skip('scheduler', () => {
21
+ let testBuilder: TestBuilder;
22
+ let client: Client;
23
+
24
+ beforeAll(async () => {
25
+ testBuilder = new TestBuilder();
26
+ client = (await createInitializedClients(testBuilder, 1))[0];
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await testBuilder.destroy();
31
+ });
32
+
33
+ const createScheduler = (callback: SchedulerOptions['callback']) => {
34
+ const scheduler = new Scheduler(new FunctionRegistry(client), new TriggerRegistry(client), { callback });
35
+
36
+ afterAll(async () => {
37
+ await scheduler.stop();
38
+ });
39
+
40
+ return scheduler;
41
+ };
42
+
43
+ test('timer', async () => {
44
+ const manifest: FunctionManifest = {
45
+ functions: [
46
+ {
47
+ uri: 'example.com/function/test',
48
+ route: '/test',
49
+ handler: 'test',
50
+ },
51
+ ],
52
+ triggers: [
53
+ {
54
+ function: 'example.com/function/test',
55
+ enabled: true,
56
+ spec: {
57
+ type: TriggerKind.Timer,
58
+ cron: '0/1 * * * * *', // Every 1s.
59
+ },
60
+ },
61
+ ],
62
+ };
63
+
64
+ let count = 0;
65
+ const done = new Trigger();
66
+ const scheduler = createScheduler(async () => {
67
+ if (++count === 3) {
68
+ done.wake();
69
+ }
70
+ });
71
+ await scheduler.register(client.spaces.default, manifest);
72
+ await scheduler.start();
73
+
74
+ await done.wait({ timeout: 5_000 });
75
+ expect(count).to.equal(3);
76
+ });
77
+
78
+ // Flaky.
79
+ test.skip('webhook', async () => {
80
+ const manifest: FunctionManifest = {
81
+ functions: [
82
+ {
83
+ uri: 'example.com/function/test',
84
+ route: '/test',
85
+ handler: 'test',
86
+ },
87
+ ],
88
+ triggers: [
89
+ {
90
+ function: 'example.com/function/test',
91
+ enabled: true,
92
+ spec: {
93
+ type: TriggerKind.Webhook,
94
+ method: 'GET',
95
+ },
96
+ },
97
+ ],
98
+ };
99
+
100
+ const done = new Trigger();
101
+ const scheduler = createScheduler(async () => {
102
+ done.wake();
103
+ });
104
+ const space = await client.spaces.create();
105
+ await scheduler.register(space, manifest);
106
+ await scheduler.start();
107
+
108
+ setTimeout(async () => triggerWebhook(space, manifest.functions![0].uri));
109
+
110
+ await done.wait();
111
+ });
112
+
113
+ test('subscription', async () => {
114
+ const manifest: FunctionManifest = {
115
+ functions: [
116
+ {
117
+ uri: 'example.com/function/test',
118
+ route: '/test',
119
+ handler: 'test',
120
+ },
121
+ ],
122
+ triggers: [
123
+ {
124
+ function: 'example.com/function/test',
125
+ enabled: true,
126
+ spec: {
127
+ type: TriggerKind.Subscription,
128
+ filter: { type: TestType.typename },
129
+ },
130
+ },
131
+ ],
132
+ };
133
+
134
+ let count = 0;
135
+ const done = new Trigger();
136
+ const scheduler = createScheduler(async () => {
137
+ if (++count === 1) {
138
+ done.wake();
139
+ }
140
+ });
141
+ await scheduler.register(client.spaces.default, manifest);
142
+ await scheduler.start();
143
+
144
+ setTimeout(() => {
145
+ const space = client.spaces.default;
146
+ const object = create(TestType, { title: 'Hello world!' });
147
+ space.db.add(object);
148
+ }, 100);
149
+
150
+ await done.wait();
151
+ });
152
+ });