@dxos/functions 0.8.2-main.2f9c567 → 0.8.2-main.36232bc
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.
- package/dist/lib/browser/bundler/index.mjs +3 -0
- package/dist/lib/browser/bundler/index.mjs.map +1 -1
- package/dist/lib/browser/chunk-2YE6S7XY.mjs +360 -0
- package/dist/lib/browser/chunk-2YE6S7XY.mjs.map +7 -0
- package/dist/lib/browser/chunk-7CHDHCV3.mjs +482 -0
- package/dist/lib/browser/chunk-7CHDHCV3.mjs.map +7 -0
- package/dist/lib/browser/chunk-LT4LR4VU.mjs +72 -0
- package/dist/lib/browser/chunk-LT4LR4VU.mjs.map +7 -0
- package/dist/lib/browser/chunk-XRCXIG74.mjs +12 -0
- package/dist/lib/browser/chunk-XRCXIG74.mjs.map +7 -0
- package/dist/lib/browser/edge/index.mjs +7 -63
- package/dist/lib/browser/edge/index.mjs.map +4 -4
- package/dist/lib/browser/index.mjs +99 -367
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +670 -0
- package/dist/lib/browser/testing/index.mjs.map +7 -0
- package/dist/lib/browser/types/index.mjs +51 -0
- package/dist/lib/browser/types/index.mjs.map +7 -0
- package/dist/lib/node/bundler/index.cjs +1 -0
- package/dist/lib/node/bundler/index.cjs.map +1 -1
- package/dist/lib/node/chunk-FBIUZ7SD.cjs +496 -0
- package/dist/lib/node/chunk-FBIUZ7SD.cjs.map +7 -0
- package/dist/lib/node/chunk-JEQ2X3Z6.cjs +34 -0
- package/dist/lib/node/chunk-JEQ2X3Z6.cjs.map +7 -0
- package/dist/lib/node/chunk-NXZNXVT3.cjs +94 -0
- package/dist/lib/node/chunk-NXZNXVT3.cjs.map +7 -0
- package/dist/lib/node/chunk-SV5NRE5L.cjs +395 -0
- package/dist/lib/node/chunk-SV5NRE5L.cjs.map +7 -0
- package/dist/lib/node/edge/index.cjs +5 -65
- package/dist/lib/node/edge/index.cjs.map +4 -4
- package/dist/lib/node/index.cjs +94 -382
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +687 -0
- package/dist/lib/node/testing/index.cjs.map +7 -0
- package/dist/lib/node/types/index.cjs +72 -0
- package/dist/lib/node/types/index.cjs.map +7 -0
- package/dist/lib/node-esm/bundler/index.mjs +1 -0
- package/dist/lib/node-esm/bundler/index.mjs.map +1 -1
- package/dist/lib/node-esm/chunk-3XMJFSID.mjs +360 -0
- package/dist/lib/node-esm/chunk-3XMJFSID.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-C6YINTWG.mjs +482 -0
- package/dist/lib/node-esm/chunk-C6YINTWG.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-DHGBFXSZ.mjs +12 -0
- package/dist/lib/node-esm/chunk-DHGBFXSZ.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-O2SXVYU5.mjs +72 -0
- package/dist/lib/node-esm/chunk-O2SXVYU5.mjs.map +7 -0
- package/dist/lib/node-esm/edge/index.mjs +6 -64
- package/dist/lib/node-esm/edge/index.mjs.map +4 -4
- package/dist/lib/node-esm/index.mjs +97 -367
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +670 -0
- package/dist/lib/node-esm/testing/index.mjs.map +7 -0
- package/dist/lib/node-esm/types/index.mjs +51 -0
- package/dist/lib/node-esm/types/index.mjs.map +7 -0
- package/dist/types/src/browser/index.d.ts +2 -0
- package/dist/types/src/browser/index.d.ts.map +1 -0
- package/dist/types/src/edge/index.d.ts.map +1 -1
- package/dist/types/src/function/function-registry.d.ts +25 -0
- package/dist/types/src/function/function-registry.d.ts.map +1 -0
- package/dist/types/src/function/function-registry.test.d.ts +2 -0
- package/dist/types/src/function/function-registry.test.d.ts.map +1 -0
- package/dist/types/src/function/index.d.ts +2 -0
- package/dist/types/src/function/index.d.ts.map +1 -0
- package/dist/types/src/handler.d.ts +61 -12
- package/dist/types/src/handler.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/runtime/dev-server.d.ts +52 -0
- package/dist/types/src/runtime/dev-server.d.ts.map +1 -0
- package/dist/types/src/runtime/dev-server.test.d.ts +2 -0
- package/dist/types/src/runtime/dev-server.test.d.ts.map +1 -0
- package/dist/types/src/runtime/index.d.ts +3 -0
- package/dist/types/src/runtime/index.d.ts.map +1 -0
- package/dist/types/src/runtime/scheduler.d.ts +34 -0
- package/dist/types/src/runtime/scheduler.d.ts.map +1 -0
- package/dist/types/src/runtime/scheduler.test.d.ts +2 -0
- package/dist/types/src/runtime/scheduler.test.d.ts.map +1 -0
- package/dist/types/src/testing/functions-integration.test.d.ts +2 -0
- package/dist/types/src/testing/functions-integration.test.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +5 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/manifest.d.ts +3 -0
- package/dist/types/src/testing/manifest.d.ts.map +1 -0
- package/dist/types/src/testing/plugin-init.d.ts +6 -0
- package/dist/types/src/testing/plugin-init.d.ts.map +1 -0
- package/dist/types/src/testing/setup.d.ts +15 -0
- package/dist/types/src/testing/setup.d.ts.map +1 -0
- package/dist/types/src/testing/test/handler.d.ts +4 -0
- package/dist/types/src/testing/test/handler.d.ts.map +1 -0
- package/dist/types/src/testing/test/index.d.ts +3 -0
- package/dist/types/src/testing/test/index.d.ts.map +1 -0
- package/dist/types/src/testing/types.d.ts +10 -0
- package/dist/types/src/testing/types.d.ts.map +1 -0
- package/dist/types/src/testing/util.d.ts +5 -0
- package/dist/types/src/testing/util.d.ts.map +1 -0
- package/dist/types/src/trigger/index.d.ts +3 -0
- package/dist/types/src/trigger/index.d.ts.map +1 -0
- package/dist/types/src/trigger/trigger-registry.d.ts +38 -0
- package/dist/types/src/trigger/trigger-registry.d.ts.map +1 -0
- package/dist/types/src/trigger/trigger-registry.test.d.ts +2 -0
- package/dist/types/src/trigger/trigger-registry.test.d.ts.map +1 -0
- package/dist/types/src/trigger/type/index.d.ts +3 -0
- package/dist/types/src/trigger/type/index.d.ts.map +1 -0
- package/dist/types/src/trigger/type/subscription-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/subscription-trigger.d.ts.map +1 -0
- package/dist/types/src/trigger/type/timer-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/timer-trigger.d.ts.map +1 -0
- package/dist/types/src/trigger/type/webhook-trigger.d.ts +4 -0
- package/dist/types/src/trigger/type/webhook-trigger.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +5 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/src/types/schema.d.ts +53 -0
- package/dist/types/src/types/schema.d.ts.map +1 -0
- package/dist/types/src/types/trace.d.ts +146 -0
- package/dist/types/src/types/trace.d.ts.map +1 -0
- package/dist/types/src/{types.d.ts → types/types.d.ts} +49 -191
- package/dist/types/src/types/types.d.ts.map +1 -0
- package/dist/types/src/types/url.d.ts.map +1 -0
- package/dist/types/tools/schema.d.ts +2 -0
- package/dist/types/tools/schema.d.ts.map +1 -0
- package/package.json +36 -20
- package/schema/functions.json +211 -0
- package/src/browser/index.ts +5 -0
- package/src/edge/index.ts +0 -4
- package/src/function/function-registry.test.ts +118 -0
- package/src/function/function-registry.ts +104 -0
- package/src/function/index.ts +5 -0
- package/src/handler.ts +118 -14
- package/src/index.ts +5 -4
- package/src/runtime/dev-server.test.ts +79 -0
- package/src/runtime/dev-server.ts +240 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/scheduler.test.ts +152 -0
- package/src/runtime/scheduler.ts +170 -0
- package/src/testing/functions-integration.test.ts +65 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/manifest.ts +15 -0
- package/src/testing/plugin-init.ts +20 -0
- package/src/testing/setup.ts +109 -0
- package/src/testing/test/handler.ts +15 -0
- package/src/testing/test/index.ts +7 -0
- package/src/testing/types.ts +9 -0
- package/src/testing/util.ts +26 -0
- package/src/translations.ts +1 -1
- package/src/trigger/index.ts +6 -0
- package/src/trigger/trigger-registry.test.ts +278 -0
- package/src/trigger/trigger-registry.ts +218 -0
- package/src/trigger/type/index.ts +7 -0
- package/src/trigger/type/subscription-trigger.ts +84 -0
- package/src/trigger/type/timer-trigger.ts +48 -0
- package/src/trigger/type/webhook-trigger.ts +48 -0
- package/src/types/index.ts +8 -0
- package/src/types/schema.ts +46 -0
- package/src/{trace.ts → types/trace.ts} +31 -33
- package/src/types/types.ts +163 -0
- package/dist/types/src/schema.d.ts +0 -57
- package/dist/types/src/schema.d.ts.map +0 -1
- package/dist/types/src/trace.d.ts +0 -148
- package/dist/types/src/trace.d.ts.map +0 -1
- package/dist/types/src/types.d.ts.map +0 -1
- package/dist/types/src/url.d.ts.map +0 -1
- package/src/schema.ts +0 -53
- package/src/types.ts +0 -214
- /package/dist/types/src/{url.d.ts → types/url.d.ts} +0 -0
- /package/src/{url.ts → types/url.ts} +0 -0
package/src/handler.ts
CHANGED
|
@@ -6,10 +6,14 @@ import { Schema as S } from 'effect';
|
|
|
6
6
|
import { type Effect } from 'effect';
|
|
7
7
|
|
|
8
8
|
import { type AIServiceClient } from '@dxos/assistant';
|
|
9
|
-
import type
|
|
9
|
+
import { type Client, PublicKey } from '@dxos/client';
|
|
10
|
+
import { type Space } from '@dxos/client/echo';
|
|
11
|
+
import type { CoreDatabase, EchoDatabase, ReactiveEchoObject } from '@dxos/echo-db';
|
|
10
12
|
import { type HasId } from '@dxos/echo-schema';
|
|
11
13
|
import { type SpaceId, type DXN } from '@dxos/keys';
|
|
14
|
+
import { log } from '@dxos/log';
|
|
12
15
|
import { type QueryResult } from '@dxos/protocols';
|
|
16
|
+
import { isNonNullable } from '@dxos/util';
|
|
13
17
|
|
|
14
18
|
// TODO(burdon): Model after http request. Ref Lambda/OpenFaaS.
|
|
15
19
|
// https://docs.aws.amazon.com/lambda/latest/dg/typescript-handler.html
|
|
@@ -19,18 +23,13 @@ import { type QueryResult } from '@dxos/protocols';
|
|
|
19
23
|
/**
|
|
20
24
|
* Function handler.
|
|
21
25
|
*/
|
|
22
|
-
export type FunctionHandler<TData = {}, TOutput = any> = (params: {
|
|
23
|
-
/**
|
|
24
|
-
* Services and context available to the function.
|
|
25
|
-
*/
|
|
26
|
+
export type FunctionHandler<TData = {}, TMeta = {}, TOutput = any> = (params: {
|
|
26
27
|
context: FunctionContext;
|
|
27
|
-
|
|
28
|
+
event: FunctionEvent<TData, TMeta>;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
* Must match the function's input schema.
|
|
31
|
-
* This will be the payload from the trigger or other data passed into the function in a workflow.
|
|
30
|
+
* @deprecated
|
|
32
31
|
*/
|
|
33
|
-
|
|
32
|
+
response: FunctionResponse;
|
|
34
33
|
}) => TOutput | Promise<TOutput> | Effect.Effect<TOutput, any>;
|
|
35
34
|
|
|
36
35
|
/**
|
|
@@ -45,6 +44,17 @@ export interface FunctionContext {
|
|
|
45
44
|
space: SpaceAPI | undefined;
|
|
46
45
|
|
|
47
46
|
ai: AIServiceClient;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @deprecated
|
|
50
|
+
*/
|
|
51
|
+
// TODO(burdon): Limit access to individual space.
|
|
52
|
+
client: Client;
|
|
53
|
+
/**
|
|
54
|
+
* @deprecated
|
|
55
|
+
*/
|
|
56
|
+
// TODO(burdon): Replace with storage service abstraction.
|
|
57
|
+
dataDir?: string;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface FunctionContextAi {
|
|
@@ -52,6 +62,28 @@ export interface FunctionContextAi {
|
|
|
52
62
|
run(model: string, inputs: any, options?: any): Promise<any>;
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Event payload.
|
|
67
|
+
*/
|
|
68
|
+
// TODO(dmaretskyi): Update type definitions to match the actual payload.
|
|
69
|
+
export type FunctionEvent<TData = {}, TMeta = {}> = {
|
|
70
|
+
data: FunctionEventMeta<TMeta> & TData;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Metadata from trigger.
|
|
75
|
+
*/
|
|
76
|
+
export type FunctionEventMeta<TMeta = {}> = {
|
|
77
|
+
meta: TMeta;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Function response.
|
|
82
|
+
*/
|
|
83
|
+
export type FunctionResponse = {
|
|
84
|
+
status(code: number): FunctionResponse;
|
|
85
|
+
};
|
|
86
|
+
|
|
55
87
|
//
|
|
56
88
|
// API.
|
|
57
89
|
//
|
|
@@ -77,19 +109,27 @@ export interface SpaceAPI {
|
|
|
77
109
|
get queues(): QueuesAPI;
|
|
78
110
|
}
|
|
79
111
|
|
|
112
|
+
// TODO(wittjosiah): Fix this.
|
|
80
113
|
const __assertFunctionSpaceIsCompatibleWithTheClientSpace = () => {
|
|
81
|
-
const _: SpaceAPI = {} as
|
|
114
|
+
// const _: SpaceAPI = {} as Space;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type FunctionDefinition = {
|
|
118
|
+
description?: string;
|
|
119
|
+
inputSchema: S.Schema.AnyNoContext;
|
|
120
|
+
outputSchema?: S.Schema.AnyNoContext;
|
|
121
|
+
handler: FunctionHandler<any>;
|
|
82
122
|
};
|
|
83
123
|
|
|
84
|
-
export type
|
|
124
|
+
export type DefineFunctionParams<T, O = any> = {
|
|
85
125
|
description?: string;
|
|
86
126
|
inputSchema: S.Schema<T, any>;
|
|
87
127
|
outputSchema?: S.Schema<O, any>;
|
|
88
|
-
handler: FunctionHandler<T, O>;
|
|
128
|
+
handler: FunctionHandler<T, any, O>;
|
|
89
129
|
};
|
|
90
130
|
|
|
91
131
|
// TODO(dmaretskyi): Bind input type to function handler.
|
|
92
|
-
export const defineFunction = <T, O>(params:
|
|
132
|
+
export const defineFunction = <T, O>(params: DefineFunctionParams<T, O>): FunctionDefinition => {
|
|
93
133
|
if (!S.isSchema(params.inputSchema)) {
|
|
94
134
|
throw new Error('Input schema must be a valid schema');
|
|
95
135
|
}
|
|
@@ -104,3 +144,67 @@ export const defineFunction = <T, O>(params: FunctionDefinition<T, O>): Function
|
|
|
104
144
|
handler: params.handler,
|
|
105
145
|
};
|
|
106
146
|
};
|
|
147
|
+
|
|
148
|
+
//
|
|
149
|
+
// Subscription utils.
|
|
150
|
+
//
|
|
151
|
+
|
|
152
|
+
export type RawSubscriptionData = {
|
|
153
|
+
spaceKey?: string;
|
|
154
|
+
objects?: string[];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type SubscriptionData = {
|
|
158
|
+
space?: Space;
|
|
159
|
+
objects?: ReactiveEchoObject<any>[];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Handler wrapper for subscription events; extracts space and objects.
|
|
164
|
+
*
|
|
165
|
+
* To test:
|
|
166
|
+
* ```
|
|
167
|
+
* curl -s -X POST -H "Content-Type: application/json" --data '{"space": "0446...1cbb"}' http://localhost:7100/dev/email-extractor
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* NOTE: Get space key from devtools or `dx space list --json`
|
|
171
|
+
*/
|
|
172
|
+
// TODO(burdon): Evolve into plugin definition like Composer.
|
|
173
|
+
export const subscriptionHandler = <TMeta>(
|
|
174
|
+
handler: FunctionHandler<SubscriptionData, TMeta>,
|
|
175
|
+
types?: S.Schema.AnyNoContext[],
|
|
176
|
+
): FunctionHandler<RawSubscriptionData, TMeta> => {
|
|
177
|
+
return async ({ event: { data }, context, response, ...rest }) => {
|
|
178
|
+
const { client } = context;
|
|
179
|
+
const space = data.spaceKey ? client.spaces.get(PublicKey.from(data.spaceKey)) : undefined;
|
|
180
|
+
if (!space) {
|
|
181
|
+
log.error('Invalid space');
|
|
182
|
+
return response.status(500);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
registerTypes(space, types);
|
|
186
|
+
const objects = space
|
|
187
|
+
? data.objects
|
|
188
|
+
?.map<ReactiveEchoObject<any> | undefined>((id) => space!.db.getObjectById(id))
|
|
189
|
+
.filter(isNonNullable)
|
|
190
|
+
: [];
|
|
191
|
+
|
|
192
|
+
if (!!data.spaceKey && !space) {
|
|
193
|
+
log.warn('invalid space', { data });
|
|
194
|
+
} else {
|
|
195
|
+
log.info('handler', { space: space?.key.truncate(), objects: objects?.length });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return handler({ event: { data: { ...data, space, objects } }, context, response, ...rest });
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// TODO(burdon): Evolve types as part of function metadata.
|
|
203
|
+
const registerTypes = (space: Space, types: S.Schema.AnyNoContext[] = []) => {
|
|
204
|
+
const registry = space.db.graph.schemaRegistry;
|
|
205
|
+
for (const type of types) {
|
|
206
|
+
if (!registry.hasSchema(type)) {
|
|
207
|
+
registry.addSchema([type]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
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 './
|
|
7
|
-
export * from './
|
|
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,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 { live } 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 = live(TestType, { title: 'Hello world!' });
|
|
147
|
+
space.db.add(object);
|
|
148
|
+
}, 100);
|
|
149
|
+
|
|
150
|
+
await done.wait();
|
|
151
|
+
});
|
|
152
|
+
});
|