@everystack/events 0.1.0

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,160 @@
1
+ import type { WebSocketHandlerOptions } from '../types';
2
+
3
+ interface WebSocketEvent {
4
+ requestContext: {
5
+ connectionId: string;
6
+ routeKey: string;
7
+ };
8
+ queryStringParameters?: Record<string, string> | null;
9
+ body?: string | null;
10
+ }
11
+
12
+ interface WebSocketResult {
13
+ statusCode: number;
14
+ body?: string;
15
+ }
16
+
17
+ // --- SQL helpers ---
18
+ // These create tagged template objects compatible with Drizzle's db.execute(sql`...`)
19
+ // We use raw string queries passed to execute() for simplicity
20
+
21
+ interface DbLike {
22
+ execute(query: unknown): Promise<unknown>;
23
+ }
24
+
25
+ function rawSql(strings: TemplateStringsArray, ...values: unknown[]) {
26
+ // Build a { sql, params } object that Drizzle's execute can handle
27
+ // However, Drizzle's sql tagged template is the proper way.
28
+ // For handler use, we'll pass pre-built query objects.
29
+ let text = '';
30
+ const params: unknown[] = [];
31
+ for (let i = 0; i < strings.length; i++) {
32
+ text += strings[i];
33
+ if (i < values.length) {
34
+ params.push(values[i]);
35
+ text += `$${params.length}`;
36
+ }
37
+ }
38
+ return { sql: text, params };
39
+ }
40
+
41
+ // --- $connect ---
42
+
43
+ export function createConnectHandler(options: {
44
+ db: DbLike;
45
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
46
+ }) {
47
+ const { db, verifyToken } = options;
48
+
49
+ return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
50
+ const connectionId = event.requestContext.connectionId;
51
+ const token = event.queryStringParameters?.token;
52
+
53
+ if (!token) {
54
+ return { statusCode: 401 };
55
+ }
56
+
57
+ const payload = await verifyToken(token);
58
+ if (!payload) {
59
+ return { statusCode: 401 };
60
+ }
61
+
62
+ const channels = event.queryStringParameters?.subscribe?.split(',') ?? [];
63
+
64
+ // Persist for cold path recovery
65
+ await db.execute(
66
+ rawSql`INSERT INTO ws_connections (connection_id, channels) VALUES (${connectionId}, ${channels}) ON CONFLICT (connection_id) DO UPDATE SET channels = EXCLUDED.channels, last_ping_at = now()`
67
+ );
68
+
69
+ // Notify running listener (hot path)
70
+ await db.execute(
71
+ rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
72
+ action: 'connect',
73
+ connectionId,
74
+ channels,
75
+ })})`
76
+ );
77
+
78
+ return { statusCode: 200 };
79
+ };
80
+ }
81
+
82
+ // --- $disconnect ---
83
+
84
+ export function createDisconnectHandler(options: { db: DbLike }) {
85
+ const { db } = options;
86
+
87
+ return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
88
+ const connectionId = event.requestContext.connectionId;
89
+
90
+ await db.execute(
91
+ rawSql`DELETE FROM ws_connections WHERE connection_id = ${connectionId}`
92
+ );
93
+
94
+ await db.execute(
95
+ rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
96
+ action: 'disconnect',
97
+ connectionId,
98
+ })})`
99
+ );
100
+
101
+ return { statusCode: 200 };
102
+ };
103
+ }
104
+
105
+ // --- $default ---
106
+
107
+ export function createDefaultHandler(options: { db: DbLike }) {
108
+ const { db } = options;
109
+
110
+ return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
111
+ const connectionId = event.requestContext.connectionId;
112
+ const body = event.body ? JSON.parse(event.body) : {};
113
+
114
+ switch (body.action) {
115
+ case 'subscribe': {
116
+ const channels: string[] = body.channels ?? [];
117
+ await db.execute(
118
+ rawSql`UPDATE ws_connections SET channels = channels || ${channels}::text[], last_ping_at = now() WHERE connection_id = ${connectionId}`
119
+ );
120
+ await db.execute(
121
+ rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
122
+ action: 'subscribe',
123
+ connectionId,
124
+ channels,
125
+ })})`
126
+ );
127
+ break;
128
+ }
129
+
130
+ case 'unsubscribe': {
131
+ const channels: string[] = body.channels ?? [];
132
+ // Remove all channels in a single UPDATE using array subtraction
133
+ await db.execute(
134
+ rawSql`UPDATE ws_connections SET channels = (SELECT array_agg(elem) FROM unnest(channels) AS elem WHERE elem != ALL(${channels}::text[])) WHERE connection_id = ${connectionId}`
135
+ );
136
+ await db.execute(
137
+ rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
138
+ action: 'unsubscribe',
139
+ connectionId,
140
+ channels,
141
+ })})`
142
+ );
143
+ break;
144
+ }
145
+
146
+ case 'ping': {
147
+ await db.execute(
148
+ rawSql`UPDATE ws_connections SET last_ping_at = now() WHERE connection_id = ${connectionId}`
149
+ );
150
+ break;
151
+ }
152
+
153
+ default:
154
+ // Unknown action — no-op
155
+ break;
156
+ }
157
+
158
+ return { statusCode: 200 };
159
+ };
160
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export { createListener, createListenerInternal } from './listener';
2
+ export { generateTriggerSQL, generateEventActionsTriggerSQL } from './triggers';
3
+ export { matchEventActions, needsRowFetch, filterByTenant, applyFilter, resolveTemplate, createActionCache } from './actions';
4
+ export { dispatchActions, dispatchRpc, dispatchJob, dispatchCachePurge } from './dispatchers';
5
+ export { createConnectHandler, createDisconnectHandler, createDefaultHandler } from './handler/index';
6
+ export { createEventsClient, EventsProvider, useEventsClient, useSignal, useSubscription } from './client/index';
7
+ export type {
8
+ EventPayload,
9
+ EventSignal,
10
+ EventsClient,
11
+ EventsClientOptions,
12
+ ListenerOptions,
13
+ ListenerRoutes,
14
+ WebSocketRoute,
15
+ LambdaRoute,
16
+ SubAction,
17
+ WebSocketHandlerOptions,
18
+ JobHandler,
19
+ JobHandlerOptions,
20
+ EventAction,
21
+ EventActionType,
22
+ EventOperation,
23
+ EventActionsOptions,
24
+ RpcActionConfig,
25
+ JobActionConfig,
26
+ CachePurgeActionConfig,
27
+ WebhookActionConfig,
28
+ } from './types';
@@ -0,0 +1,473 @@
1
+ import type { EventPayload, EventAction, EventActionsOptions, ListenerOptions, ListenerRoutes, SubAction } from './types';
2
+ import { matchEventActions, createActionCache } from './actions';
3
+ import { dispatchActions } from './dispatchers';
4
+ import type { KvsClient } from './dispatchers';
5
+
6
+ // --- Subscription state (in-memory) ---
7
+
8
+ interface SubscriptionState {
9
+ // channel → Set<connectionId>
10
+ channels: Map<string, Set<string>>;
11
+ // connectionId → Set<channel> (reverse map for cleanup)
12
+ connections: Map<string, Set<string>>;
13
+ }
14
+
15
+ function createSubscriptionState(): SubscriptionState {
16
+ return {
17
+ channels: new Map(),
18
+ connections: new Map(),
19
+ };
20
+ }
21
+
22
+ function addSubscription(state: SubscriptionState, channel: string, connectionId: string): void {
23
+ let conns = state.channels.get(channel);
24
+ if (!conns) {
25
+ conns = new Set();
26
+ state.channels.set(channel, conns);
27
+ }
28
+ conns.add(connectionId);
29
+
30
+ let chans = state.connections.get(connectionId);
31
+ if (!chans) {
32
+ chans = new Set();
33
+ state.connections.set(connectionId, chans);
34
+ }
35
+ chans.add(channel);
36
+ }
37
+
38
+ function removeConnection(state: SubscriptionState, connectionId: string): void {
39
+ const chans = state.connections.get(connectionId);
40
+ if (chans) {
41
+ for (const channel of chans) {
42
+ const conns = state.channels.get(channel);
43
+ if (conns) {
44
+ conns.delete(connectionId);
45
+ if (conns.size === 0) state.channels.delete(channel);
46
+ }
47
+ }
48
+ state.connections.delete(connectionId);
49
+ }
50
+ }
51
+
52
+ function removeSubscription(state: SubscriptionState, channel: string, connectionId: string): void {
53
+ const conns = state.channels.get(channel);
54
+ if (conns) {
55
+ conns.delete(connectionId);
56
+ if (conns.size === 0) state.channels.delete(channel);
57
+ }
58
+
59
+ const chans = state.connections.get(connectionId);
60
+ if (chans) {
61
+ chans.delete(channel);
62
+ if (chans.size === 0) state.connections.delete(connectionId);
63
+ }
64
+ }
65
+
66
+ // --- Event processing ---
67
+
68
+ async function fanOut(
69
+ state: SubscriptionState,
70
+ event: EventPayload,
71
+ apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> },
72
+ ): Promise<void> {
73
+ const channel = `table:${event.table}`;
74
+ const connectionIds = state.channels.get(channel);
75
+ if (!connectionIds?.size) return;
76
+
77
+ const signal = JSON.stringify({
78
+ channel,
79
+ table: event.table,
80
+ op: event.op,
81
+ id: event.id,
82
+ ts: Date.now(),
83
+ });
84
+
85
+ const ids = [...connectionIds];
86
+ const results = await Promise.allSettled(
87
+ ids.map((id) =>
88
+ apigw.postToConnection({
89
+ ConnectionId: id,
90
+ Data: Buffer.from(signal),
91
+ })
92
+ )
93
+ );
94
+
95
+ for (let i = 0; i < results.length; i++) {
96
+ const r = results[i];
97
+ if (r.status === 'rejected' && r.reason?.$metadata?.httpStatusCode === 410) {
98
+ removeConnection(state, ids[i]);
99
+ }
100
+ }
101
+ }
102
+
103
+ async function invokeLambda(
104
+ lambdaClient: { invoke(params: unknown): Promise<unknown> },
105
+ functionName: string,
106
+ event: EventPayload,
107
+ ): Promise<void> {
108
+ await lambdaClient.invoke({
109
+ FunctionName: functionName,
110
+ InvocationType: 'Event',
111
+ Payload: JSON.stringify(event),
112
+ });
113
+ }
114
+
115
+ async function routeEvent(
116
+ state: SubscriptionState,
117
+ event: EventPayload,
118
+ routes: ListenerRoutes,
119
+ apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> },
120
+ lambdaClient: { invoke(params: unknown): Promise<unknown> },
121
+ ): Promise<void> {
122
+ const tasks: Promise<void>[] = [];
123
+
124
+ // WebSocket fan-out
125
+ if (routes.websocket) {
126
+ tasks.push(fanOut(state, event, apigw));
127
+ }
128
+
129
+ // Lambda invocation
130
+ if (routes.lambda?.[event.table]) {
131
+ const route = routes.lambda[event.table];
132
+ const fnName = route.functionName || route.functionArn;
133
+ if (fnName) {
134
+ tasks.push(invokeLambda(lambdaClient, fnName, event));
135
+ }
136
+ }
137
+
138
+ // Callbacks
139
+ if (routes.callbacks) {
140
+ // Table-specific callback
141
+ if (routes.callbacks[event.table]) {
142
+ tasks.push(routes.callbacks[event.table](event));
143
+ }
144
+ // Wildcard callback
145
+ if (routes.callbacks['*']) {
146
+ tasks.push(routes.callbacks['*'](event));
147
+ }
148
+ }
149
+
150
+ await Promise.allSettled(tasks);
151
+ }
152
+
153
+ function handleSubAction(state: SubscriptionState, action: SubAction): void {
154
+ switch (action.action) {
155
+ case 'connect':
156
+ for (const channel of action.channels) {
157
+ addSubscription(state, channel, action.connectionId);
158
+ }
159
+ break;
160
+ case 'disconnect':
161
+ removeConnection(state, action.connectionId);
162
+ break;
163
+ case 'subscribe':
164
+ for (const channel of action.channels) {
165
+ addSubscription(state, channel, action.connectionId);
166
+ }
167
+ break;
168
+ case 'unsubscribe':
169
+ for (const channel of action.channels) {
170
+ removeSubscription(state, channel, action.connectionId);
171
+ }
172
+ break;
173
+ }
174
+ }
175
+
176
+ // --- Event actions helpers ---
177
+
178
+ function rowToEventAction(row: Record<string, unknown>): EventAction {
179
+ const config = row.config;
180
+ const filter = row.filter;
181
+ return {
182
+ id: row.id as string,
183
+ tenantId: (row.tenant_id as string) ?? null,
184
+ name: row.name as string,
185
+ schemaName: row.schema_name as string,
186
+ tableName: row.table_name as string,
187
+ operation: row.operation as EventAction['operation'],
188
+ actionType: row.action_type as EventAction['actionType'],
189
+ config: typeof config === 'string' ? JSON.parse(config) : (config as Record<string, unknown>) ?? {},
190
+ filter: filter ? (typeof filter === 'string' ? JSON.parse(filter) : filter as Record<string, unknown>) : null,
191
+ enabled: row.enabled as boolean,
192
+ priority: row.priority as number,
193
+ };
194
+ }
195
+
196
+ async function loadEventActions(
197
+ pgClient: { query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> },
198
+ cache: ReturnType<typeof createActionCache>,
199
+ ): Promise<void> {
200
+ try {
201
+ const { rows } = await pgClient.query(
202
+ `SELECT id, tenant_id, name, schema_name, table_name, operation,
203
+ action_type, config, filter, enabled, priority
204
+ FROM ops.event_actions
205
+ WHERE enabled = true
206
+ ORDER BY priority DESC`
207
+ );
208
+ cache.set(rows.map(rowToEventAction));
209
+ } catch {
210
+ // Table might not exist yet — keep existing cache (empty on first load)
211
+ }
212
+ }
213
+
214
+ // --- Cold path reconciliation ---
215
+
216
+ async function reconcile(
217
+ pgClient: { query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> },
218
+ state: SubscriptionState,
219
+ routes: ListenerRoutes,
220
+ lambdaClient: { invoke(params: unknown): Promise<unknown> },
221
+ actionCache: ReturnType<typeof createActionCache>,
222
+ ): Promise<void> {
223
+ // 1. Rebuild subscription map from auth.sessions (domain='ws')
224
+ try {
225
+ const { rows: connections } = await pgClient.query(
226
+ `SELECT data->>'connectionId' AS connection_id, data->'channels' AS channels
227
+ FROM auth.sessions
228
+ WHERE domain = 'ws' AND status = 'active' AND last_used_at > now() - interval '15 minutes'`
229
+ );
230
+ for (const conn of connections) {
231
+ const channels = conn.channels as string[];
232
+ const connectionId = conn.connection_id as string;
233
+ if (connectionId && channels) {
234
+ for (const channel of channels) {
235
+ addSubscription(state, channel, connectionId);
236
+ }
237
+ }
238
+ }
239
+ } catch {
240
+ // Table might not exist yet — skip subscription rebuild
241
+ }
242
+
243
+ // 2. Process pending jobs
244
+ if (routes.lambda?.jobs) {
245
+ try {
246
+ const { rows: pendingJobs } = await pgClient.query(
247
+ `SELECT id FROM jobs WHERE status = 'pending' AND run_at <= now() ORDER BY priority DESC, run_at ASC`
248
+ );
249
+ const fnName = routes.lambda.jobs.functionName || routes.lambda.jobs.functionArn;
250
+ if (fnName) {
251
+ for (const job of pendingJobs) {
252
+ await invokeLambda(lambdaClient, fnName, {
253
+ table: 'jobs',
254
+ op: 'insert',
255
+ id: job.id as string,
256
+ });
257
+ }
258
+ }
259
+ } catch {
260
+ // jobs table might not exist
261
+ }
262
+ }
263
+
264
+ // 3. Purge stale WS sessions
265
+ try {
266
+ await pgClient.query(
267
+ `UPDATE auth.sessions SET status = 'disconnected', ended_at = now()
268
+ WHERE domain = 'ws' AND status = 'active' AND last_used_at < now() - interval '15 minutes'`
269
+ );
270
+ } catch {
271
+ // Table might not exist yet
272
+ }
273
+
274
+ // 4. Load event_actions cache
275
+ await loadEventActions(pgClient, actionCache);
276
+ }
277
+
278
+ // --- Internal (testable) listener factory ---
279
+
280
+ interface InternalListenerOptions {
281
+ pgClient: {
282
+ connect(): Promise<void>;
283
+ query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
284
+ on(event: string, cb: (msg: { channel: string; payload: string }) => void): void;
285
+ end(): Promise<void>;
286
+ };
287
+ channels: string[];
288
+ lockId: number;
289
+ routes: ListenerRoutes;
290
+ apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> };
291
+ lambdaClient: { invoke(params: unknown): Promise<unknown> };
292
+ reconcile?: (db: unknown) => Promise<void>;
293
+ heartbeatInterval?: number;
294
+ eventActions?: EventActionsOptions;
295
+ kvsClient?: KvsClient | null;
296
+ }
297
+
298
+ interface HandlerEvent {
299
+ _testMaxMs?: number;
300
+ }
301
+
302
+ export function createListenerInternal(options: InternalListenerOptions) {
303
+ const {
304
+ pgClient,
305
+ channels,
306
+ lockId,
307
+ routes,
308
+ apigw,
309
+ lambdaClient,
310
+ heartbeatInterval = 30000,
311
+ eventActions,
312
+ kvsClient = null,
313
+ } = options;
314
+
315
+ return async function handler(event: HandlerEvent = {}): Promise<{ status: string; reason?: string }> {
316
+ // 1. Connect
317
+ await pgClient.connect();
318
+
319
+ // 2. Acquire advisory lock
320
+ const { rows } = await pgClient.query(
321
+ 'SELECT pg_try_advisory_lock($1)',
322
+ [lockId]
323
+ );
324
+ const lockAcquired = rows[0]?.pg_try_advisory_lock;
325
+ if (!lockAcquired) {
326
+ await pgClient.end();
327
+ return { status: 'skipped', reason: 'lock_held' };
328
+ }
329
+
330
+ // 3. LISTEN on channels
331
+ for (const channel of channels) {
332
+ await pgClient.query(`LISTEN "${channel}"`);
333
+ }
334
+
335
+ // 4. Set up subscription state + action cache
336
+ const state = createSubscriptionState();
337
+ const actionCache = createActionCache();
338
+
339
+ // 5. Cold path reconciliation (includes loading event_actions)
340
+ await reconcile(pgClient, state, routes, lambdaClient, actionCache);
341
+
342
+ // 6. Set up notification handler
343
+ pgClient.on('notification', (msg: { channel: string; payload: string }) => {
344
+ try {
345
+ if (msg.channel === '_subs') {
346
+ const action = JSON.parse(msg.payload) as SubAction;
347
+ handleSubAction(state, action);
348
+ } else if (msg.channel === '_event_actions') {
349
+ // Reload event_actions cache
350
+ loadEventActions(pgClient, actionCache);
351
+ } else if (msg.channel === 'events') {
352
+ const ev = JSON.parse(msg.payload) as EventPayload;
353
+ // Existing hardcoded routing (backwards compatible)
354
+ routeEvent(state, ev, routes, apigw, lambdaClient);
355
+ // Data-driven routing via event_actions
356
+ const matched = matchEventActions(actionCache.get(), ev);
357
+ if (matched.length > 0) {
358
+ dispatchActions(matched, ev, pgClient, lambdaClient, kvsClient, {
359
+ workerFunctionName: eventActions?.workerFunctionName,
360
+ defaultKvsArn: eventActions?.defaultKvsArn,
361
+ });
362
+ }
363
+ }
364
+ } catch {
365
+ // Malformed payload — skip
366
+ }
367
+ });
368
+
369
+ // 7. Run event loop
370
+ const maxMs = event._testMaxMs ?? 14 * 60 * 1000; // 14 minutes default
371
+ const heartbeat = setInterval(async () => {
372
+ try {
373
+ await pgClient.query('SELECT 1');
374
+ } catch {
375
+ // Connection lost — exit will happen naturally
376
+ }
377
+ }, heartbeatInterval);
378
+
379
+ await new Promise<void>((resolve) => setTimeout(resolve, maxMs));
380
+
381
+ clearInterval(heartbeat);
382
+
383
+ // 8. Cleanup
384
+ await pgClient.end();
385
+
386
+ return { status: 'completed' };
387
+ };
388
+ }
389
+
390
+ // --- Public API ---
391
+
392
+ export function createListener(options: ListenerOptions) {
393
+ return async function handler(_event?: unknown): Promise<{ status: string; reason?: string }> {
394
+ // Dynamic imports to avoid bundling AWS SDK when not needed
395
+ const { default: pg } = await import('pg' as string);
396
+ const pgClient = new pg.Client({ connectionString: options.connectionString });
397
+
398
+ let apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> } = {
399
+ postToConnection: async () => {},
400
+ };
401
+ let lambdaClient: { invoke(params: unknown): Promise<unknown> } = {
402
+ invoke: async () => {},
403
+ };
404
+
405
+ if (options.routes.websocket) {
406
+ const { ApiGatewayManagementApiClient, PostToConnectionCommand } = await import(
407
+ '@aws-sdk/client-apigatewaymanagementapi' as string
408
+ );
409
+ const client = new ApiGatewayManagementApiClient({
410
+ endpoint: options.routes.websocket.endpoint,
411
+ region: options.routes.websocket.region,
412
+ });
413
+ apigw = {
414
+ postToConnection: (params) =>
415
+ client.send(new PostToConnectionCommand(params)),
416
+ };
417
+ }
418
+
419
+ if (options.routes.lambda || options.eventActions?.workerFunctionName) {
420
+ const { LambdaClient, InvokeCommand } = await import(
421
+ '@aws-sdk/client-lambda' as string
422
+ );
423
+ const client = new LambdaClient({});
424
+ lambdaClient = {
425
+ invoke: (params) => client.send(new InvokeCommand(params as any)),
426
+ };
427
+ }
428
+
429
+ // KVS client for cache_purge event actions
430
+ let kvsClient: KvsClient | null = null;
431
+ if (options.eventActions?.defaultKvsArn) {
432
+ try {
433
+ await import('@aws-sdk/signature-v4a' as string);
434
+ const {
435
+ CloudFrontKeyValueStoreClient,
436
+ DescribeKeyValueStoreCommand,
437
+ PutKeyCommand,
438
+ } = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
439
+ const client = new CloudFrontKeyValueStoreClient({});
440
+ kvsClient = {
441
+ describe: async (kvsArn: string) => {
442
+ const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
443
+ return { ETag: desc.ETag! };
444
+ },
445
+ put: async (kvsArn: string, key: string, value: string, etag: string) => {
446
+ await client.send(new PutKeyCommand({
447
+ KvsARN: kvsArn,
448
+ Key: key,
449
+ Value: value,
450
+ IfMatch: etag,
451
+ }));
452
+ },
453
+ };
454
+ } catch {
455
+ // KVS SDK not available — skip cache_purge actions
456
+ }
457
+ }
458
+
459
+ const internal = createListenerInternal({
460
+ pgClient: pgClient as any,
461
+ channels: options.channels ?? ['events', '_subs', '_event_actions'],
462
+ lockId: options.lockId ?? 74736,
463
+ routes: options.routes,
464
+ apigw,
465
+ lambdaClient,
466
+ heartbeatInterval: options.heartbeatInterval,
467
+ eventActions: options.eventActions,
468
+ kvsClient,
469
+ });
470
+
471
+ return internal({});
472
+ };
473
+ }