From 2b91cda2962fd91d47fda232d6c22e55a26e24c2 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Thu, 14 Sep 2023 16:47:15 -0500 Subject: [PATCH] feat: socket (#445) --- apps/tests/aws-runtime/package.json | 2 + apps/tests/aws-runtime/test/test-service.ts | 222 ++++++++++++++++-- apps/tests/aws-runtime/test/tester.test.ts | 10 + packages/@eventual/aws-cdk/src/build.ts | 14 ++ .../@eventual/aws-cdk/src/entity-service.ts | 3 + .../@eventual/aws-cdk/src/service-common.ts | 3 + packages/@eventual/aws-cdk/src/service.ts | 106 +++++---- .../@eventual/aws-cdk/src/socket-service.ts | 179 ++++++++++++++ packages/@eventual/aws-cdk/src/utils.ts | 21 +- .../@eventual/aws-cdk/src/workflow-service.ts | 6 + packages/@eventual/aws-runtime/package.json | 1 + .../aws-runtime/src/clients/socket-client.ts | 62 +++++ packages/@eventual/aws-runtime/src/create.ts | 16 ++ packages/@eventual/aws-runtime/src/env.ts | 12 +- .../src/handlers/apig-command-worker.ts | 1 + .../src/handlers/bucket-handler-worker.ts | 4 +- .../src/handlers/command-worker.ts | 2 + .../src/handlers/entity-stream-worker.ts | 2 + .../aws-runtime/src/handlers/orchestrator.ts | 23 +- .../src/handlers/queue-handler-worker.ts | 2 + .../aws-runtime/src/handlers/socket-worker.ts | 77 ++++++ .../src/handlers/subscription-worker.ts | 4 +- .../src/handlers/system-command-handler.ts | 4 +- .../aws-runtime/src/handlers/task-worker.ts | 2 + .../src/handlers/transaction-worker.ts | 2 + .../aws-runtime/src/injected/service-spec.ts | 1 + packages/@eventual/aws-runtime/src/utils.ts | 32 +++ packages/@eventual/cli/package.json | 4 + packages/@eventual/cli/src/commands/local.ts | 176 +++++++++++--- packages/@eventual/cli/src/commands/replay.ts | 1 + .../cli/src/commands/service-info.ts | 3 + packages/@eventual/cli/src/display/event.ts | 19 +- .../cli/src/local/web-socket-container.ts | 69 ++++++ packages/@eventual/cli/src/service-action.ts | 2 + packages/@eventual/cli/src/service-data.ts | 1 + packages/@eventual/compiler/src/ast-util.ts | 41 +++- .../@eventual/compiler/src/eventual-infer.ts | 12 +- .../__snapshots__/infer-plugin.test.ts.snap | 96 +++++++- .../core-runtime/src/build-manifest.ts | 4 + .../core-runtime/src/call-executor.ts | 24 +- .../send-signal-call-executor.ts | 27 +++ .../call-executors/socket-call-executor.ts | 14 ++ .../core-runtime/src/clients/index.ts | 1 + .../core-runtime/src/clients/socket-client.ts | 11 + .../src/handlers/command-worker.ts | 215 +++++++---------- .../core-runtime/src/handlers/index.ts | 1 + .../core-runtime/src/handlers/orchestrator.ts | 95 ++++---- .../src/handlers/socket-worker.ts | 81 +++++++ .../src/handlers/transaction-worker.ts | 18 +- .../core-runtime/src/handlers/worker.ts | 16 +- .../src/local/clients/socket-client.ts | 26 ++ .../@eventual/core-runtime/src/local/index.ts | 1 + .../core-runtime/src/local/local-container.ts | 78 ++++-- .../src/local/local-environment.ts | 27 ++- .../src/local/web-socket-container.ts | 7 + .../socket-url-property-retriever.ts | 12 + .../core-runtime/src/transaction-executor.ts | 86 ++++--- packages/@eventual/core-runtime/src/utils.ts | 37 +++ .../src/workflow/call-eventual-factory.ts | 4 +- .../src/workflow/call-executor.ts | 9 +- .../socket-call.ts | 103 ++++++++ .../src/workflow/property-retriever.ts | 41 ++++ .../test/transaction-executor.test.ts | 13 +- packages/@eventual/core/src/http/api.ts | 2 +- packages/@eventual/core/src/http/command.ts | 25 +- .../@eventual/core/src/http/middleware.ts | 2 +- packages/@eventual/core/src/index.ts | 1 + packages/@eventual/core/src/internal/calls.ts | 44 +++- .../@eventual/core/src/internal/properties.ts | 12 + .../@eventual/core/src/internal/resources.ts | 6 +- .../core/src/internal/service-spec.ts | 7 + .../core/src/internal/service-type.ts | 1 + packages/@eventual/core/src/internal/util.ts | 15 ++ .../core/src/internal/workflow-events.ts | 68 +++++- packages/@eventual/core/src/socket/index.ts | 2 + .../@eventual/core/src/socket/middleware.ts | 117 +++++++++ packages/@eventual/core/src/socket/socket.ts | 221 +++++++++++++++++ packages/@eventual/testing/src/environment.ts | 2 + .../testing/src/web-socket-container.ts | 21 ++ pnpm-lock.yaml | 145 +++++++++++- 80 files changed, 2458 insertions(+), 421 deletions(-) create mode 100644 packages/@eventual/aws-cdk/src/socket-service.ts create mode 100644 packages/@eventual/aws-runtime/src/clients/socket-client.ts create mode 100644 packages/@eventual/aws-runtime/src/handlers/socket-worker.ts create mode 100644 packages/@eventual/cli/src/local/web-socket-container.ts create mode 100644 packages/@eventual/core-runtime/src/call-executors/send-signal-call-executor.ts create mode 100644 packages/@eventual/core-runtime/src/call-executors/socket-call-executor.ts create mode 100644 packages/@eventual/core-runtime/src/clients/socket-client.ts create mode 100644 packages/@eventual/core-runtime/src/handlers/socket-worker.ts create mode 100644 packages/@eventual/core-runtime/src/local/clients/socket-client.ts create mode 100644 packages/@eventual/core-runtime/src/local/web-socket-container.ts create mode 100644 packages/@eventual/core-runtime/src/property-retrievers/socket-url-property-retriever.ts create mode 100644 packages/@eventual/core-runtime/src/workflow/call-executors-and-factories/socket-call.ts create mode 100644 packages/@eventual/core-runtime/src/workflow/property-retriever.ts create mode 100644 packages/@eventual/core/src/socket/index.ts create mode 100644 packages/@eventual/core/src/socket/middleware.ts create mode 100644 packages/@eventual/core/src/socket/socket.ts create mode 100644 packages/@eventual/testing/src/web-socket-container.ts diff --git a/apps/tests/aws-runtime/package.json b/apps/tests/aws-runtime/package.json index b1147a3e2..898f59acf 100644 --- a/apps/tests/aws-runtime/package.json +++ b/apps/tests/aws-runtime/package.json @@ -21,6 +21,7 @@ "@eventual/cli": "workspace:^", "@eventual/client": "workspace:^", "@eventual/core": "workspace:^", + "ws": "^8.14.1", "zod": "^3.21.4" }, "devDependencies": { @@ -30,6 +31,7 @@ "@types/aws-lambda": "^8.10.115", "@types/jest": "^29.5.1", "@types/node": "^18", + "@types/ws": "^8.5.5", "aws-cdk": "^2.80.0", "esbuild": "^0.17.4", "jest": "^29", diff --git a/apps/tests/aws-runtime/test/test-service.ts b/apps/tests/aws-runtime/test/test-service.ts index c66698590..a01fd6571 100644 --- a/apps/tests/aws-runtime/test/test-service.ts +++ b/apps/tests/aws-runtime/test/test-service.ts @@ -28,6 +28,7 @@ import { sendSignal, sendTaskHeartbeat, signal, + socket, subscription, task, time, @@ -36,6 +37,7 @@ import { } from "@eventual/core"; import type openapi from "openapi3-ts"; import { Readable } from "stream"; +import { WebSocket } from "ws"; import z from "zod"; import { AsyncWriterTestEvent } from "./async-writer-handler.js"; @@ -1378,23 +1380,209 @@ export const searchBlog = command( } ); -// check types of entity -function streamShouldHaveEmptyIfNoInclude() { - counter.stream("", {}, (item) => { - if (item.operation === "modify") { - // @ts-expect-error - no oldValue without includeOld: true - item.oldValue!.namespace; +/** + * Socket Test! + * + * 1. a command start a workflow that will communicate with the sockets. + * 2. the command starts 2 web sockets against socket1 + * 3. on each connection, the socket will send a signal to the workflow + * 4. when the workflow get the connection, it will send a start signal to the socket + * 5. the socket will send a "n" (local id for the connection) and value to the socket, which will forward the message to the workflow + * 6. when the workflow has received 2 connections with n and v values, it will send a message to each socket with the n and v incremented by 1 + * 7. the socket will resolve the value in the connection, completing the test + */ + +interface SocketMessage { + id: string; + v: number; +} + +const jsonSocket = socket.use({ + message: ({ request: { body }, context, next }) => { + console.log(body); + const data = body + ? body instanceof Buffer + ? (JSON.parse(body.toString("utf-8")) as SocketMessage) + : (JSON.parse(body) as SocketMessage) + : undefined; + console.log("data", data); + if (!data) { + throw new Error("Expected data"); } - }); - counter.stream( - "", - { - includeOld: true, - }, - (item) => { - if (item.operation === "modify") { - item.oldValue.namespace; + return next({ ...context, data }); + }, +}); + +export const socket1 = jsonSocket.use(({ request, context, next }) => { + const { id, n } = (request.query ?? {}) as { n?: string; id?: string }; + if (!id || !n) { + throw new Error("Missing ID"); + } + return next({ ...context, id, n }); +})("socket1", { + $connect: async ({ connectionId }, { id, n }) => { + console.log("sending signal to", id); + await socketConnectSignal.sendSignal(id, { + connectionId, + n: Number(n), + }); + console.log("signal sent to", id); + }, + $disconnect: async () => undefined, + $default: async ({ connectionId }, { data }) => { + console.log("sending signal to", data.id); + await socketMessageSignal.sendSignal(data.id, { + ...data, + connectionId, + }); + console.log("signal sent to", data.id); + }, +}); + +export const socketConnectSignal = signal<{ connectionId: string; n: number }>( + "socketConnectSignal" +); +export const socketMessageSignal = signal<{ + connectionId: string; + v: number; +}>("socketMessageSignal"); + +interface StartSocketEvent { + type: "start"; + n: number; + v: number; +} + +interface DataSocketEvent { + type: "data"; + n: number; + v: number; +} + +export const socketWorkflow = workflow( + "socketWorkflow", + { timeout: duration(15, "minutes") }, + async () => { + const connections: Record = {}; + + socketConnectSignal.onSignal(async ({ connectionId, n }) => { + connections[connectionId] = { n }; + await socket1.send( + connectionId, + JSON.stringify({ + type: "start", + n, + v: n + 1, + } satisfies StartSocketEvent) + ); + }); + + socketMessageSignal.onSignal(({ connectionId, v }) => { + const entry = connections[connectionId]; + if (entry) { + entry.v = v; } - } - ); + }); + + await condition( + () => + Object.keys(connections).length > 1 && + Object.values(connections).every((c) => c.v !== undefined) + ); + + await Promise.all( + Object.entries(connections).map(async ([connectionId, { n, v }]) => { + await socket1.send( + connectionId, + JSON.stringify({ + type: "data", + n, + v: (v ?? 0) + 1, + } satisfies DataSocketEvent) + ); + }) + ); + + // close the connections + await Promise.all( + Object.entries(connections).map(async ([connectionId]) => { + await socket1.disconnect(connectionId); + }) + ); + } +); + +export const socketTest = command( + "socketTest", + { handlerTimeout: duration(5, "minute") }, + async () => { + // start workflow that waits for connections + const { executionId } = await socketWorkflow.startExecution({ + input: undefined, + }); + const encodedId = encodeURIComponent(executionId); + + console.log("pre-socket"); + + const ws1 = new WebSocket(`${socket1.wssEndpoint}?id=${encodedId}&n=0`); + const ws2 = new WebSocket(`${socket1.wssEndpoint}?id=${encodedId}&n=1`); + + console.log("setup-socket"); + + const running1 = setupWS(executionId, ws1); + const running2 = setupWS(executionId, ws2); + + console.log("waiting..."); + + return await Promise.all([running1, running2]); + } +); + +function setupWS(executionId: string, ws: WebSocket) { + let n: number | undefined; + let v: number | undefined; + return new Promise((resolve, reject) => { + ws.on("error", (err) => { + console.log("error", err); + reject(err); + }); + ws.on("message", (data) => { + try { + console.log(n, "message"); + const d = (data as Buffer).toString("utf8"); + console.log(d); + const event = JSON.parse(d) as StartSocketEvent | DataSocketEvent; + if (event.type === "start") { + n = event.n; + // after connecting, we will send our "n" and incremented "value" back. + ws.send( + JSON.stringify({ + id: executionId, + v: event.v + 1, + } satisfies SocketMessage) + ); + } else if (event.type === "data") { + v = event.v; + } else { + console.log("unexpected event", event); + reject(event); + } + } catch (err) { + console.error(err); + reject(err); + } + }); + ws.on("close", (code, reason) => { + try { + console.log(code, reason.toString("utf-8")); + console.log(n, "close", v); + if (n === undefined) { + throw new Error("n was not set"); + } + resolve(v ?? -1); + } catch (err) { + reject(err); + } + }); + }); } diff --git a/apps/tests/aws-runtime/test/tester.test.ts b/apps/tests/aws-runtime/test/tester.test.ts index 0038b6770..426f4e5e1 100644 --- a/apps/tests/aws-runtime/test/tester.test.ts +++ b/apps/tests/aws-runtime/test/tester.test.ts @@ -444,6 +444,16 @@ test("test service context", async () => { }); }); +test("socket test", async () => { + const rpcResponse = await ( + await fetch(`${url}/${commandRpcPath({ name: "socketTest" })}`, { + method: "POST", + }) + ).json(); + + expect(rpcResponse).toEqual([3, 4]); +}); + if (!process.env.TEST_LOCAL) { test("index.search", async () => { const serviceClient = new ServiceClient({ diff --git a/packages/@eventual/aws-cdk/src/build.ts b/packages/@eventual/aws-cdk/src/build.ts index 03391e774..9f86eb169 100644 --- a/packages/@eventual/aws-cdk/src/build.ts +++ b/packages/@eventual/aws-cdk/src/build.ts @@ -7,6 +7,7 @@ import { EVENTUAL_SYSTEM_COMMAND_NAMESPACE, QueueHandlerSpec, QueueSpec, + SocketSpec, SubscriptionSpec, TaskSpec, } from "@eventual/core/internal"; @@ -80,6 +81,7 @@ const WORKER_ENTRY_POINTS = [ "bucket-handler-worker", "queue-handler-worker", "transaction-worker", + "socket-worker", ] as const; export async function buildService(request: BuildAWSRuntimeProps) { @@ -127,6 +129,7 @@ export async function buildService(request: BuildAWSRuntimeProps) { name: "default", }, }, + sockets: await bundleSocketHandlers(serviceSpec.sockets), search: serviceSpec.search, entities: { entities: await Promise.all( @@ -299,6 +302,17 @@ export async function buildService(request: BuildAWSRuntimeProps) { }; } + async function bundleSocketHandlers(specs: SocketSpec[]) { + return await Promise.all( + specs.map(async (spec) => { + return { + entry: await bundleFile(spec, "socket", "socket-worker", spec.name), + spec, + }; + }) + ); + } + async function bundleFile< Spec extends CommandSpec | SubscriptionSpec | TaskSpec | QueueHandlerSpec >( diff --git a/packages/@eventual/aws-cdk/src/entity-service.ts b/packages/@eventual/aws-cdk/src/entity-service.ts index d48d148ea..4b6b368c9 100644 --- a/packages/@eventual/aws-cdk/src/entity-service.ts +++ b/packages/@eventual/aws-cdk/src/entity-service.ts @@ -139,6 +139,9 @@ export class EntityService { this.configureReadWriteEntityTable(this.transactionWorker); props.workflowService.configureSendSignal(this.transactionWorker); props.eventService.configureEmit(this.transactionWorker); + props.socketService.configureInvokeSocketEndpoints( + this.transactionWorker + ); } } diff --git a/packages/@eventual/aws-cdk/src/service-common.ts b/packages/@eventual/aws-cdk/src/service-common.ts index babde871c..07192c32b 100644 --- a/packages/@eventual/aws-cdk/src/service-common.ts +++ b/packages/@eventual/aws-cdk/src/service-common.ts @@ -8,6 +8,7 @@ import { LazyInterface } from "./proxy-construct"; import { QueueService } from "./queue-service"; import { SearchService } from "./search/search-service"; import { Service } from "./service"; +import { SocketService } from "./socket-service"; export interface ServiceConstructProps { /** @@ -38,6 +39,7 @@ export interface WorkerServiceConstructProps extends ServiceConstructProps { bucketService: LazyInterface>; entityService: LazyInterface>; searchService: LazyInterface> | undefined; + socketService: LazyInterface>; } export function configureWorkerCalls( @@ -50,4 +52,5 @@ export function configureWorkerCalls( serviceProps.bucketService.configureReadWriteBuckets(func); serviceProps.entityService.configureReadWriteEntityTable(func); serviceProps.entityService.configureInvokeTransactions(func); + serviceProps.socketService.configureInvokeSocketEndpoints(func); } diff --git a/packages/@eventual/aws-cdk/src/service.ts b/packages/@eventual/aws-cdk/src/service.ts index abf298229..520444e20 100644 --- a/packages/@eventual/aws-cdk/src/service.ts +++ b/packages/@eventual/aws-cdk/src/service.ts @@ -73,6 +73,12 @@ import { ServiceConstructProps, WorkerServiceConstructProps, } from "./service-common"; +import { + ISocket, + SocketOverrides, + SocketService, + Sockets, +} from "./socket-service"; import { Subscription, SubscriptionOverrides, @@ -151,6 +157,10 @@ export interface ServiceProps { * Override the properties of the queues within the service. */ queues?: QueueOverrides; + /** + * Override the properties of the socket apis within the service. + */ + sockets?: SocketOverrides; /** * Override the properties of an bucket streams within the service. */ @@ -235,6 +245,10 @@ export class Service extends Construct { * Queues defined by the service. */ public readonly queues: ServiceQueues; + /** + * Queues defined by the service. + */ + public readonly sockets: Sockets; /** * Handlers of bucket notification events defined by the service. */ @@ -263,6 +277,7 @@ export class Service extends Construct { private readonly bucketService: BucketService; private readonly eventService: EventService; private readonly commandService: CommandService; + private readonly socketService: SocketService; public grantPrincipal: IPrincipal; public commandsPrincipal: IPrincipal; @@ -271,6 +286,7 @@ export class Service extends Construct { public entityStreamsPrincipal: IPrincipal; public bucketNotificationHandlersPrincipal: IPrincipal; public queueHandlersPrincipal: IPrincipal; + public socketHandlersPrincipal: IPrincipal; public readonly system: ServiceSystem; @@ -340,6 +356,7 @@ export class Service extends Construct { const proxyEntityService = lazyInterface>(); const proxyQueueService = lazyInterface>(); const proxySearchService = lazyInterface>(); + const proxySocketService = lazyInterface>(); const serviceConstructProps: ServiceConstructProps = { build, @@ -358,6 +375,7 @@ export class Service extends Construct { entityService: proxyEntityService, queueService: proxyQueueService, searchService: proxySearchService, + socketService: proxySocketService, }; this.eventService = new EventService(serviceConstructProps); @@ -471,6 +489,14 @@ export class Service extends Construct { proxyQueueService._bind(queueService); this.queues = queueService.queues; + this.socketService = new SocketService({ + ...workerConstructProps, + overrides: props.sockets, + local: this.local, + }); + proxySocketService._bind(this.socketService); + this.sockets = this.socketService.sockets; + this.commandService.grantInvokeHttpServiceApi(accessRole); workflowService.grantFilterLogEvents(accessRole); @@ -485,58 +511,41 @@ export class Service extends Construct { eventBusArn: this.bus.eventBusArn, workflowExecutionLogGroupName: workflowService.logGroup.logGroupName, environmentVariables: props.environment, + socketEndpoints: Object.fromEntries( + Object.entries(this.socketService.sockets).map( + ([name, socket]) => [name, socket.gatewayStage.url] + ) + ), }), } ); - this.commandsPrincipal = - this.commandsList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.commandsList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); - this.tasksPrincipal = - this.tasksList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.tasksList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); - this.subscriptionsPrincipal = - this.subscriptionsList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.subscriptionsList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); - this.entityStreamsPrincipal = - this.entityStreamList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.entityStreamList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); + this.commandsPrincipal = this.createResourceGroupPrincipal( + this.commandsList + ); + this.tasksPrincipal = this.createResourceGroupPrincipal(this.tasksList); + this.subscriptionsPrincipal = this.createResourceGroupPrincipal( + this.subscriptionsList + ); + this.entityStreamsPrincipal = this.createResourceGroupPrincipal( + this.entityStreamList + ); this.bucketNotificationHandlersPrincipal = - this.bucketNotificationHandlersList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.bucketNotificationHandlersList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); - this.queueHandlersPrincipal = - this.queueHandlersList.length > 0 || this.local - ? new DeepCompositePrincipal( - ...(this.local ? [this.local.environmentRole] : []), - ...this.queueHandlersList.map((f) => f.grantPrincipal) - ) - : new UnknownPrincipal({ resource: this }); + this.createResourceGroupPrincipal(this.bucketNotificationHandlersList); + this.queueHandlersPrincipal = this.createResourceGroupPrincipal( + this.queueHandlersList + ); + this.socketHandlersPrincipal = this.createResourceGroupPrincipal( + this.socketHandlersList + ); this.grantPrincipal = new DeepCompositePrincipal( this.commandsPrincipal, this.tasksPrincipal, this.subscriptionsPrincipal, this.entityStreamsPrincipal, - this.bucketNotificationHandlersPrincipal + this.bucketNotificationHandlersPrincipal, + this.queueHandlersPrincipal, + this.socketHandlersPrincipal ); serviceDataSSM.grantRead(accessRole); @@ -577,6 +586,19 @@ export class Service extends Construct { return Object.values(this.queues).map((q) => q.handler); } + public get socketHandlersList(): ISocket[] { + return Object.values(this.sockets); + } + + private createResourceGroupPrincipal(grantables: IGrantable[]) { + return grantables.length > 0 || this.local + ? new DeepCompositePrincipal( + ...(this.local ? [this.local.environmentRole] : []), + ...grantables.map((f) => f.grantPrincipal) + ) + : new UnknownPrincipal({ resource: this }); + } + public subscribe( scope: Construct, id: string, diff --git a/packages/@eventual/aws-cdk/src/socket-service.ts b/packages/@eventual/aws-cdk/src/socket-service.ts new file mode 100644 index 000000000..a2774eaf8 --- /dev/null +++ b/packages/@eventual/aws-cdk/src/socket-service.ts @@ -0,0 +1,179 @@ +import { + IWebSocketApi, + WebSocketApi, + WebSocketNoneAuthorizer, + WebSocketStage, +} from "@aws-cdk/aws-apigatewayv2-alpha"; +import { WebSocketLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha"; +import { ENV_NAMES, socketServiceSocketName } from "@eventual/aws-runtime"; +import { SocketFunction } from "@eventual/core-runtime"; +import { SocketUrls } from "@eventual/core/internal/index.js"; +import { IGrantable, IPrincipal } from "aws-cdk-lib/aws-iam"; +import type { Function, FunctionProps } from "aws-cdk-lib/aws-lambda"; +import { Duration } from "aws-cdk-lib/core"; +import { Construct } from "constructs"; +import { SpecHttpApiProps } from "./constructs/spec-http-api.js"; +import { DeepCompositePrincipal } from "./deep-composite-principal.js"; +import { EventualResource } from "./resource.js"; +import { + WorkerServiceConstructProps, + configureWorkerCalls, +} from "./service-common.js"; +import { ServiceFunction } from "./service-function.js"; +import { ServiceLocal } from "./service.js"; +import { ServiceEntityProps } from "./utils.js"; + +export type ApiOverrides = Omit; + +export type Sockets = ServiceEntityProps; + +export type SocketOverrides = Partial< + ServiceEntityProps +>; + +export interface SocketsProps + extends WorkerServiceConstructProps { + local: ServiceLocal | undefined; + overrides?: SocketOverrides; +} + +/** + * Properties that can be overridden for an individual API handler Function. + */ +export type SocketHandlerProps = Partial< + Omit +>; + +export class SocketService { + /** + * API Gateway for providing service api + */ + public readonly sockets: Sockets; + + constructor(props: SocketsProps) { + const socketsScope = new Construct(props.serviceScope, "Sockets"); + + this.sockets = Object.fromEntries( + props.build.sockets.map((socket) => [ + socket.spec.name, + new Socket(socketsScope, { + serviceProps: props, + socketService: this, + socket, + local: props.local, + }), + ]) + ) as Sockets; + } + + private readonly ENV_MAPPINGS = { + [ENV_NAMES.SOCKET_URLS]: () => + JSON.stringify( + Object.fromEntries( + Object.entries(this.sockets as Record).map( + ([name, socket]) => + [ + name, + { + http: socket.gatewayStage.url.replace("wss://", "https://"), + wss: socket.gatewayStage.url, + } satisfies SocketUrls, + ] as const + ) + ) + ), + } as const; + + private addEnvs(func: Function, ...envs: (keyof typeof this.ENV_MAPPINGS)[]) { + envs.forEach((env) => func.addEnvironment(env, this.ENV_MAPPINGS[env]())); + } + + public configureInvokeSocketEndpoints(func: Function) { + this.grantInvokeSocketEndpoints(func); + this.addEnvs(func, ENV_NAMES.SOCKET_URLS); + } + + public grantInvokeSocketEndpoints(grantable: IGrantable) { + // generally we want to compute the grants, but apigateway urls use the generated appid and not the name + Object.values(this.sockets).map((s) => + s.gateway.grantManageConnections(grantable) + ); + } +} + +interface SocketProps { + serviceProps: SocketsProps; + socketService: SocketService; + socket: SocketFunction; + local: ServiceLocal | undefined; +} + +export interface ISocket { + grantPrincipal: IPrincipal; + gateway: IWebSocketApi; + gatewayStage: WebSocketStage; + handler: Function; +} + +class Socket extends Construct implements EventualResource, ISocket { + public grantPrincipal: IPrincipal; + public gateway: WebSocketApi; + public gatewayStage: WebSocketStage; + public handler: Function; + + constructor(scope: Construct, props: SocketProps) { + const socketName = props.socket.spec.name; + + super(scope, socketName); + + this.handler = new ServiceFunction(this, "DefaultHandler", { + build: props.serviceProps.build, + bundledFunction: props.socket, + functionNameSuffix: `socket-${socketName}-default`, + serviceName: props.serviceProps.serviceName, + defaults: { + timeout: Duration.minutes(1), + environment: { + [ENV_NAMES.SOCKET_NAME]: socketName, + ...props.serviceProps.environment, + }, + }, + runtimeProps: props.socket.spec, + overrides: props.serviceProps.overrides?.[socketName], + }); + + configureWorkerCalls(props.serviceProps, this.handler); + + this.gateway = new WebSocketApi(this, "Gateway", { + apiName: socketServiceSocketName( + props.serviceProps.serviceName, + socketName + ), + defaultRouteOptions: { + // https://stackoverflow.com/a/72716478 + // must create one integration per... + integration: new WebSocketLambdaIntegration("default", this.handler), + }, + connectRouteOptions: { + integration: new WebSocketLambdaIntegration("Connect", this.handler), + authorizer: new WebSocketNoneAuthorizer(), + }, + disconnectRouteOptions: { + integration: new WebSocketLambdaIntegration("Disconnect", this.handler), + }, + }); + + this.gatewayStage = new WebSocketStage(this, "Stage", { + stageName: "default", + webSocketApi: this.gateway, + autoDeploy: true, + }); + + this.grantPrincipal = props.local + ? new DeepCompositePrincipal( + this.handler.grantPrincipal, + props.local.environmentRole + ) + : this.handler.grantPrincipal; + } +} diff --git a/packages/@eventual/aws-cdk/src/utils.ts b/packages/@eventual/aws-cdk/src/utils.ts index 15e5cd72a..0169f1c75 100644 --- a/packages/@eventual/aws-cdk/src/utils.ts +++ b/packages/@eventual/aws-cdk/src/utils.ts @@ -1,4 +1,7 @@ -import { serviceFunctionName } from "@eventual/aws-runtime"; +import { + serviceFunctionName, + socketServiceSocketName, +} from "@eventual/aws-runtime"; import { ArnFormat, Stack } from "aws-cdk-lib/core"; import { Architecture, @@ -126,6 +129,22 @@ export function serviceQueueArn( ); } +export function serviceApiArn( + serviceName: string, + stack: Stack, + nameSuffix: string, + sanitized = true +) { + return stack.formatArn({ + service: "execute-api", + resource: sanitized + ? socketServiceSocketName(serviceName, nameSuffix) + : `${serviceName}-${nameSuffix}`, + resourceName: "*/*/*/*", + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); +} + export function formatQueueArn(queueName: string, region = "*", account = "*") { return `arn:aws:sqs:${region}:${account}:${queueName}`; } diff --git a/packages/@eventual/aws-cdk/src/workflow-service.ts b/packages/@eventual/aws-cdk/src/workflow-service.ts index aa5ee4f55..d560985b5 100644 --- a/packages/@eventual/aws-cdk/src/workflow-service.ts +++ b/packages/@eventual/aws-cdk/src/workflow-service.ts @@ -37,6 +37,7 @@ import type { TaskService } from "./task-service.js"; import type { SearchService } from "./search/search-service"; import { ServiceConstructProps } from "./service-common"; import { QueueService } from "./queue-service"; +import { SocketService } from "./socket-service"; export interface WorkflowsProps extends ServiceConstructProps { bucketService: LazyInterface>; @@ -45,6 +46,7 @@ export interface WorkflowsProps extends ServiceConstructProps { eventService: EventService; overrides?: WorkflowServiceOverrides; schedulerService: LazyInterface; + socketService: LazyInterface; taskService: LazyInterface; queueService: LazyInterface>; } @@ -488,6 +490,10 @@ export class WorkflowService { * Queue Calls */ this.props.queueService.configureSendMessage(this.orchestrator); + /** + * Socket Calls + */ + this.props.socketService.configureInvokeSocketEndpoints(this.orchestrator); } private readonly ENV_MAPPINGS = { diff --git a/packages/@eventual/aws-runtime/package.json b/packages/@eventual/aws-runtime/package.json index fe3bfc595..99349460d 100644 --- a/packages/@eventual/aws-runtime/package.json +++ b/packages/@eventual/aws-runtime/package.json @@ -13,6 +13,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.341.0", "@aws-sdk/client-cloudwatch-logs": "^3.341.0", "@aws-sdk/client-dynamodb": "^3.341.0", "@aws-sdk/client-eventbridge": "^3.341.0", diff --git a/packages/@eventual/aws-runtime/src/clients/socket-client.ts b/packages/@eventual/aws-runtime/src/clients/socket-client.ts new file mode 100644 index 000000000..404a2893a --- /dev/null +++ b/packages/@eventual/aws-runtime/src/clients/socket-client.ts @@ -0,0 +1,62 @@ +import { + DeleteConnectionCommand, + PostToConnectionCommand, + type ApiGatewayManagementApiClient, +} from "@aws-sdk/client-apigatewaymanagementapi"; +import { + getLazy, + type LazyValue, + type SocketClient, +} from "@eventual/core-runtime"; +import type { SocketUrls } from "@eventual/core/internal"; + +export type SocketEndpoints = Record; + +export interface AWSSocketClientProps { + apiGatewayManagementClientRetriever: ( + socketUrl: string + ) => ApiGatewayManagementApiClient; + socketUrls: LazyValue; +} + +export class AWSSocketClient implements SocketClient { + constructor(private props: AWSSocketClientProps) {} + + public async send( + socketName: string, + connectionId: string, + input: string | Buffer + ): Promise { + const client = this.props.apiGatewayManagementClientRetriever( + this.socketUrls(socketName).http + ); + + await client.send( + new PostToConnectionCommand({ + ConnectionId: connectionId, + Data: Buffer.from(input), + }) + ); + } + + public async disconnect( + socketName: string, + connectionId: string + ): Promise { + const client = this.props.apiGatewayManagementClientRetriever( + this.socketUrls(socketName).http + ); + + await client.send( + new DeleteConnectionCommand({ ConnectionId: connectionId }) + ); + } + + public socketUrls(socketName: string): SocketUrls { + const endpoints = getLazy(this.props.socketUrls)[socketName]; + if (!endpoints) { + throw new Error(`No service url provided for socket ${socketName}`); + } + return endpoints; + } +} diff --git a/packages/@eventual/aws-runtime/src/create.ts b/packages/@eventual/aws-runtime/src/create.ts index 4bc45b7a2..fdb02bc35 100644 --- a/packages/@eventual/aws-runtime/src/create.ts +++ b/packages/@eventual/aws-runtime/src/create.ts @@ -1,3 +1,4 @@ +import { ApiGatewayManagementApiClient } from "@aws-sdk/client-apigatewaymanagementapi"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { EventBridgeClient } from "@aws-sdk/client-eventbridge"; @@ -30,10 +31,12 @@ import { AWSExecutionQueueClient } from "./clients/execution-queue-client.js"; import { AWSLogsClient } from "./clients/log-client.js"; import { AWSOpenSearchClient } from "./clients/opensearch-client.js"; import { AWSQueueClient } from "./clients/queue-client.js"; +import { AWSSocketClient } from "./clients/socket-client.js"; import { AWSTaskClient } from "./clients/task-client.js"; import { AWSTimerClient, AWSTimerClientProps } from "./clients/timer-client.js"; import { AWSTransactionClient } from "./clients/transaction-client.js"; import * as env from "./env.js"; +import { socketUrls } from "./env.js"; import { AWSBucketStore } from "./stores/bucket-store.js"; import { AWSEntityStore } from "./stores/entity-store.js"; import { AWSExecutionHistoryStateStore } from "./stores/execution-history-state-store.js"; @@ -128,6 +131,19 @@ export const createWorkflowClient = /* @__PURE__ */ memoize( ) ); +export const createApiGatewayManagementClient = /* @__PURE__ */ memoize( + ({ socketUrl }: { socketUrl: string }) => + new ApiGatewayManagementApiClient({ endpoint: socketUrl }) +); + +export const createSocketClient = /* @__PURE__ */ memoize(() => { + return new AWSSocketClient({ + socketUrls, + apiGatewayManagementClientRetriever: (url) => + createApiGatewayManagementClient({ socketUrl: url }), + }); +}); + export const createExecutionQueueClient = /* @__PURE__ */ memoize( ({ workflowQueueUrl }: { workflowQueueUrl?: string } = {}) => new AWSExecutionQueueClient({ diff --git a/packages/@eventual/aws-runtime/src/env.ts b/packages/@eventual/aws-runtime/src/env.ts index 54305f83e..23e76fcc9 100644 --- a/packages/@eventual/aws-runtime/src/env.ts +++ b/packages/@eventual/aws-runtime/src/env.ts @@ -1,7 +1,8 @@ import { LogLevel } from "@eventual/core"; import { assertNonNull } from "@eventual/core/internal"; -import { BucketRuntimeOverrides } from "./stores/bucket-store.js"; -import { QueueRuntimeOverrides } from "./clients/queue-client.js"; +import type { QueueRuntimeOverrides } from "./clients/queue-client.js"; +import type { SocketEndpoints } from "./clients/socket-client.js"; +import type { BucketRuntimeOverrides } from "./stores/bucket-store.js"; export const ENV_NAMES = { AWS_ACCOUNT_ID: "EVENTUAL_AWS_ACCOUNT_ID", @@ -22,6 +23,8 @@ export const ENV_NAMES = { WORKFLOW_EXECUTION_LOG_GROUP_NAME: "EVENTUAL_WORKFLOW_EXECUTION_LOG_GROUP_NAME", DEFAULT_LOG_LEVEL: "EVENTUAL_LOG_LEVEL", + SOCKET_NAME: "EVENTUAL_SOCKET_NAME", + SOCKET_URLS: "EVENTUAL_SOCKET_URLS", ENTITY_NAME: "EVENTUAL_ENTITY_NAME", ENTITY_STREAM_NAME: "EVENTUAL_ENTITY_STREAM_NAME", QUEUE_NAME: "EVENTUAL_QUEUE_NAME", @@ -65,6 +68,7 @@ export const serviceLogGroupName = () => export const serviceUrl = () => tryGetEnv(ENV_NAMES.SERVICE_URL); export const defaultLogLevel = () => tryGetEnv(ENV_NAMES.DEFAULT_LOG_LEVEL) ?? LogLevel.INFO; +export const socketName = () => tryGetEnv(ENV_NAMES.SOCKET_NAME); export const entityName = () => tryGetEnv(ENV_NAMES.ENTITY_NAME); export const entityStreamName = () => tryGetEnv(ENV_NAMES.ENTITY_STREAM_NAME); export const bucketName = () => tryGetEnv(ENV_NAMES.BUCKET_NAME); @@ -86,3 +90,7 @@ export const queueOverrides = () => { QueueRuntimeOverrides >; }; +export const socketUrls = () => { + const socketUrlsString = process.env[ENV_NAMES.SOCKET_URLS] ?? "{}"; + return JSON.parse(socketUrlsString) as SocketEndpoints; +}; diff --git a/packages/@eventual/aws-runtime/src/handlers/apig-command-worker.ts b/packages/@eventual/aws-runtime/src/handlers/apig-command-worker.ts index d07bd9c82..d0ef8f38d 100644 --- a/packages/@eventual/aws-runtime/src/handlers/apig-command-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/apig-command-worker.ts @@ -52,6 +52,7 @@ export function createApiGCommandWorker({ serviceSpec, serviceName, serviceUrl, + socketClient: deps.socketClient, }); const requestBody = event.body diff --git a/packages/@eventual/aws-runtime/src/handlers/bucket-handler-worker.ts b/packages/@eventual/aws-runtime/src/handlers/bucket-handler-worker.ts index 48309574d..db30bfdfc 100644 --- a/packages/@eventual/aws-runtime/src/handlers/bucket-handler-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/bucket-handler-worker.ts @@ -14,6 +14,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, } from "../create.js"; import { bucketHandlerName, @@ -26,11 +27,12 @@ const worker = createBucketNotificationHandlerWorker({ bucketStore: createBucketStore(), entityStore: createEntityStore(), openSearchClient: await createOpenSearchClient(serviceSpec), - serviceClient: createServiceClient({}), queueClient: createQueueClient(), + serviceClient: createServiceClient({}), serviceName, serviceSpec, serviceUrl, + socketClient: createSocketClient(), }); export default (async (event) => { diff --git a/packages/@eventual/aws-runtime/src/handlers/command-worker.ts b/packages/@eventual/aws-runtime/src/handlers/command-worker.ts index a424eed48..c4d1bc956 100644 --- a/packages/@eventual/aws-runtime/src/handlers/command-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/command-worker.ts @@ -8,6 +8,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, createTransactionClient, } from "../create.js"; import { serviceName } from "../env.js"; @@ -34,4 +35,5 @@ export default createApiGCommandWorker({ }), serviceName, serviceSpec, + socketClient: createSocketClient(), }); diff --git a/packages/@eventual/aws-runtime/src/handlers/entity-stream-worker.ts b/packages/@eventual/aws-runtime/src/handlers/entity-stream-worker.ts index 58120f873..d04fb0ed3 100644 --- a/packages/@eventual/aws-runtime/src/handlers/entity-stream-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/entity-stream-worker.ts @@ -21,6 +21,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, } from "../create.js"; import { entityName, @@ -41,6 +42,7 @@ const worker = createEntityStreamWorker({ serviceSpec, serviceName, serviceUrl, + socketClient: createSocketClient(), }); export default (async (event) => { diff --git a/packages/@eventual/aws-runtime/src/handlers/orchestrator.ts b/packages/@eventual/aws-runtime/src/handlers/orchestrator.ts index d6f2f4661..3f06a0461 100644 --- a/packages/@eventual/aws-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/aws-runtime/src/handlers/orchestrator.ts @@ -1,8 +1,7 @@ -import serviceSpec from "@eventual/injected/spec"; import "@eventual/injected/entry"; +import serviceSpec from "@eventual/injected/spec"; import { - createDefaultWorkflowCallExecutor, createOrchestrator, ExecutionQueueEventEnvelope, RemoteExecutorProvider, @@ -19,6 +18,7 @@ import { createLogAgent, createOpenSearchClient, createQueueClient, + createSocketClient, createTaskClient, createTimerClient, createTransactionClient, @@ -33,27 +33,22 @@ import { serviceName } from "../env.js"; */ const orchestrate = createOrchestrator({ bucketStore: createBucketStore(), + entityStore: createEntityStore(), + eventClient: createEventClient(), + executionQueueClient: createExecutionQueueClient(), executionHistoryStore: createExecutionHistoryStore(), executorProvider: new RemoteExecutorProvider({ executionHistoryStateStore: createExecutionHistoryStateStore(), }), logAgent: createLogAgent(), metricsClient: AWSMetricsClient, - callExecutor: createDefaultWorkflowCallExecutor({ - bucketStore: createBucketStore(), - entityStore: createEntityStore(), - eventClient: createEventClient(), - queueClient: createQueueClient(), - executionQueueClient: createExecutionQueueClient(), - openSearchClient: await createOpenSearchClient(serviceSpec), - taskClient: createTaskClient(), - timerClient: createTimerClient(), - transactionClient: createTransactionClient(), - workflowClient: createWorkflowClient(), - }), + openSearchClient: await createOpenSearchClient(serviceSpec), queueClient: createQueueClient(), + socketClient: createSocketClient(), serviceName: serviceName(), + taskClient: createTaskClient(), timerClient: createTimerClient(), + transactionClient: createTransactionClient(), workflowClient: createWorkflowClient(), workflowProvider: createWorkflowProvider(), }); diff --git a/packages/@eventual/aws-runtime/src/handlers/queue-handler-worker.ts b/packages/@eventual/aws-runtime/src/handlers/queue-handler-worker.ts index 65fd0c163..b82e17e7b 100644 --- a/packages/@eventual/aws-runtime/src/handlers/queue-handler-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/queue-handler-worker.ts @@ -14,6 +14,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, } from "../create.js"; import { queueName, serviceName, serviceUrl } from "../env.js"; @@ -26,6 +27,7 @@ const worker = createQueueHandlerWorker({ serviceSpec, serviceName, serviceUrl, + socketClient: createSocketClient(), }); export default (async (event) => { diff --git a/packages/@eventual/aws-runtime/src/handlers/socket-worker.ts b/packages/@eventual/aws-runtime/src/handlers/socket-worker.ts new file mode 100644 index 000000000..f58bd2965 --- /dev/null +++ b/packages/@eventual/aws-runtime/src/handlers/socket-worker.ts @@ -0,0 +1,77 @@ +import serviceSpec from "@eventual/injected/spec"; +// the user's entry point will register streams as a side effect. +import "@eventual/injected/entry"; + +import { createSocketWorker, getLazy } from "@eventual/core-runtime"; +import type { + APIGatewayEventWebsocketRequestContextV2, + APIGatewayProxyEventV2WithRequestContext, + APIGatewayProxyResultV2, +} from "aws-lambda"; +import { + createBucketStore, + createEntityStore, + createOpenSearchClient, + createQueueClient, + createServiceClient, + createSocketClient, +} from "../create.js"; +import { serviceName, serviceUrl, socketName } from "../env.js"; + +const worker = createSocketWorker({ + bucketStore: createBucketStore(), + entityStore: createEntityStore(), + openSearchClient: await createOpenSearchClient(serviceSpec), + queueClient: createQueueClient(), + serviceClient: createServiceClient({}), + serviceName, + serviceSpec, + serviceUrl, + socketClient: createSocketClient(), +}); + +export default async ( + event: APIGatewayProxyEventV2WithRequestContext +): Promise | undefined> => { + const workerEvent = + event.requestContext.routeKey === "$connect" + ? { + type: "connect" as const, + connectionId: event.requestContext.connectionId, + query: event.queryStringParameters, + headers: event.headers, + } + : event.requestContext.routeKey === "$disconnect" + ? { + type: "disconnect" as const, + connectionId: event.requestContext.connectionId, + } + : { + type: "message" as const, + body: + event.isBase64Encoded && event.body + ? Buffer.from(event.body, "base64") + : event.body, + connectionId: event.requestContext.connectionId, + }; + + const result = await worker(getLazy(socketName), workerEvent); + + if (result) { + const [data, base64] = result.message + ? result.message instanceof Buffer + ? [result.message.toString("base64"), true] + : [JSON.stringify(result.message), false] + : [undefined, false]; + + return { + statusCode: result.status, + body: data, + isBase64Encoded: base64, + }; + } + + return { + statusCode: 200, + }; +}; diff --git a/packages/@eventual/aws-runtime/src/handlers/subscription-worker.ts b/packages/@eventual/aws-runtime/src/handlers/subscription-worker.ts index fcd4c113b..01d548174 100644 --- a/packages/@eventual/aws-runtime/src/handlers/subscription-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/subscription-worker.ts @@ -13,6 +13,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, createTransactionClient, } from "../create.js"; import { serviceName, serviceUrl } from "../env.js"; @@ -22,15 +23,16 @@ export const processEvent = createSubscriptionWorker({ entityStore: createEntityStore(), openSearchClient: await createOpenSearchClient(serviceSpec), // partially uses the runtime clients and partially uses the http client + queueClient: createQueueClient(), serviceClient: createServiceClient({ eventClient: createEventClient(), transactionClient: createTransactionClient(), }), - queueClient: createQueueClient(), serviceSpec, subscriptionProvider: new GlobalSubscriptionProvider(), serviceName, serviceUrl, + socketClient: createSocketClient(), }); export default async function (event: EventBridgeEvent) { diff --git a/packages/@eventual/aws-runtime/src/handlers/system-command-handler.ts b/packages/@eventual/aws-runtime/src/handlers/system-command-handler.ts index c5415c634..d13ea04e4 100644 --- a/packages/@eventual/aws-runtime/src/handlers/system-command-handler.ts +++ b/packages/@eventual/aws-runtime/src/handlers/system-command-handler.ts @@ -23,6 +23,7 @@ import { createExecutionQueueClient, createExecutionStore, createQueueClient, + createSocketClient, createTaskClient, createTransactionClient, createWorkflowClient, @@ -35,11 +36,12 @@ function systemCommandWorker( ): APIGatewayProxyHandlerV2 { return createApiGCommandWorker({ bucketStore: createBucketStore(), - queueClient: createQueueClient(), entityStore: undefined, openSearchClient: undefined, + queueClient: createQueueClient(), serviceSpec, serviceName, + socketClient: createSocketClient(), }); } diff --git a/packages/@eventual/aws-runtime/src/handlers/task-worker.ts b/packages/@eventual/aws-runtime/src/handlers/task-worker.ts index fca292148..30baf803f 100644 --- a/packages/@eventual/aws-runtime/src/handlers/task-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/task-worker.ts @@ -19,6 +19,7 @@ import { createOpenSearchClient, createQueueClient, createServiceClient, + createSocketClient, createTaskClient, createTaskStore, createTimerClient, @@ -47,6 +48,7 @@ const worker = createTaskWorker({ serviceName, serviceSpec, serviceUrl, + socketClient: createSocketClient(), taskProvider: new GlobalTaskProvider(), taskStore: createTaskStore(), timerClient: createTimerClient(), diff --git a/packages/@eventual/aws-runtime/src/handlers/transaction-worker.ts b/packages/@eventual/aws-runtime/src/handlers/transaction-worker.ts index 0507ed0da..11fc4f853 100644 --- a/packages/@eventual/aws-runtime/src/handlers/transaction-worker.ts +++ b/packages/@eventual/aws-runtime/src/handlers/transaction-worker.ts @@ -9,6 +9,7 @@ import { createEntityStore, createEventClient, createExecutionQueueClient, + createSocketClient, } from "../create.js"; import { serviceName } from "../env.js"; @@ -18,4 +19,5 @@ export default createTransactionWorker({ eventClient: createEventClient(), executionQueueClient: createExecutionQueueClient(), serviceName, + socketClient: createSocketClient(), }); diff --git a/packages/@eventual/aws-runtime/src/injected/service-spec.ts b/packages/@eventual/aws-runtime/src/injected/service-spec.ts index 2cea7b31b..8869240a1 100644 --- a/packages/@eventual/aws-runtime/src/injected/service-spec.ts +++ b/packages/@eventual/aws-runtime/src/injected/service-spec.ts @@ -10,6 +10,7 @@ export default { transactions: [], events: [], commands: [], + sockets: [], tasks: [], subscriptions: [], buckets: { buckets: [] }, diff --git a/packages/@eventual/aws-runtime/src/utils.ts b/packages/@eventual/aws-runtime/src/utils.ts index b678a07bd..ebb45b31c 100644 --- a/packages/@eventual/aws-runtime/src/utils.ts +++ b/packages/@eventual/aws-runtime/src/utils.ts @@ -243,6 +243,14 @@ export function serviceQueueName( )}${fifo ? FIFO_SUFFIX : ""}`; } +export function serviceWebSocketName(serviceName: string, suffix: string) { + const serviceNameAndSeparatorLength = serviceName.length + 1; + const remaining = 128 - serviceNameAndSeparatorLength; + return sanitizeFunctionName( + `${serviceName}-${suffix.substring(0, remaining)}` + ); +} + /** * Bucket names must: * * be between 3 and 63 characters long (inc) @@ -283,6 +291,10 @@ export function queueServiceQueueSuffix(queueName: string) { return `queue-${queueName}`; } +export function socketServiceSocketSuffix(socketName: string) { + return `socket-${socketName}`; +} + export function taskServiceFunctionName( serviceName: string, taskId: string @@ -307,6 +319,26 @@ export function bucketServiceBucketName( return serviceBucketName(serviceName, bucketServiceBucketSuffix(bucketName)); } +export function socketServiceSocketName( + serviceName: string, + socketName: string +) { + return serviceWebSocketName( + serviceName, + socketServiceSocketSuffix(socketName) + ); +} + +export function socketServiceSocketUrl( + serviceName: string, + socketName: string +) { + return `wss://${socketServiceSocketName( + serviceName, + socketName + )}.execute-api.${process.env.AWS_REGION}.amazonaws.com/${process.env.STAGE}`; +} + /** * Note: a queue's name can be overridden by the user. */ diff --git a/packages/@eventual/cli/package.json b/packages/@eventual/cli/package.json index c7e841cd1..69b784cad 100644 --- a/packages/@eventual/cli/package.json +++ b/packages/@eventual/cli/package.json @@ -37,7 +37,9 @@ "ora": "^6.1.2", "serve-static": "^1.15.0", "ts-node": "^10.9.1", + "uuid": "^9.0.1", "vite": "^3.2.3", + "ws": "^8.14.1", "yargs": "^17.6.2" }, "devDependencies": { @@ -48,6 +50,8 @@ "@types/jest": "^29.5.1", "@types/node": "^18", "@types/serve-static": "^1.15.1", + "@types/uuid": "^9.0.4", + "@types/ws": "^8.5.5", "@types/yargs": "^17.0.24", "jest": "^29", "ts-jest": "^29.1.0", diff --git a/packages/@eventual/cli/src/commands/local.ts b/packages/@eventual/cli/src/commands/local.ts index 301c306e2..254975fbd 100644 --- a/packages/@eventual/cli/src/commands/local.ts +++ b/packages/@eventual/cli/src/commands/local.ts @@ -1,14 +1,18 @@ import { inferFromMemory } from "@eventual/compiler"; -import { HttpMethod, HttpRequest } from "@eventual/core"; +import { HttpMethod, HttpRequest, SocketQuery } from "@eventual/core"; import { LocalEnvironment } from "@eventual/core-runtime"; import { ServiceSpec } from "@eventual/core/internal"; import { EventualConfig, discoverEventualConfig } from "@eventual/project"; import { exec as _exec } from "child_process"; -import express from "express"; +import express, { RequestHandler } from "express"; +import http from "http"; import ora, { Ora } from "ora"; import path from "path"; import { promisify } from "util"; +import { v4 as uuidv4 } from "uuid"; +import { WebSocketServer } from "ws"; import { Argv } from "yargs"; +import { LocalWebSocketContainer } from "../local/web-socket-container.js"; import { assumeCliRole } from "../role.js"; import { setServiceOptions } from "../service-action.js"; import { @@ -20,6 +24,7 @@ import { tryGetBuildManifest, tryResolveDefaultService, } from "../service-data.js"; + const execPromise = promisify(_exec); export const local = (yargs: Argv) => @@ -113,8 +118,6 @@ export const local = (yargs: Argv) => await import(path.resolve(buildManifest.entry)); const port = userPort; - const app = express(); - const url = `http://localhost:${port}`; // get the stored spec file to load values from synth @@ -127,35 +130,49 @@ export const local = (yargs: Argv) => storedServiceSpec.openApi ); + const webSocketContainer = new LocalWebSocketContainer( + `localhost:${port}` + ); + // TODO: should the loading be done by the local env? - const localEnv = new LocalEnvironment({ - serviceSpec, - serviceUrl: url, - serviceName: buildManifest.serviceName, - }); + const localEnv = new LocalEnvironment( + { + serviceSpec, + serviceUrl: url, + serviceName: buildManifest.serviceName, + }, + webSocketContainer + ); - app.use(express.json({ strict: false, limit: maxBodySize })); - // CORS for local - app.use((req, res, next) => { - next(); + const app = express(); + const server = http.createServer(app); - const headers = res.getHeaders(); - if (!headers["Access-Control-Allow-Origin"]) { - res.header("Access-Control-Allow-Origin", req.headers.origin ?? "*"); - } - if (!headers["Access-Control-Allow-Methods"]) { - res.header("Access-Control-Allow-Methods", "*"); - } - if (!headers["Access-Control-Allow-Headers"]) { - res.header("Access-Control-Allow-Headers", "*"); - } - if (!headers["Access-Control-Allow-Credentials"]) { - res.header("Access-Control-Allow-Credentials", "true"); - } - }); + const apiMiddleware: RequestHandler[] = [ + express.json({ strict: false, limit: maxBodySize }), + (req, res, next) => { + next(); + + const headers = res.getHeaders(); + if (!headers["Access-Control-Allow-Origin"]) { + res.header( + "Access-Control-Allow-Origin", + req.headers.origin ?? "*" + ); + } + if (!headers["Access-Control-Allow-Methods"]) { + res.header("Access-Control-Allow-Methods", "*"); + } + if (!headers["Access-Control-Allow-Headers"]) { + res.header("Access-Control-Allow-Headers", "*"); + } + if (!headers["Access-Control-Allow-Credentials"]) { + res.header("Access-Control-Allow-Credentials", "true"); + } + }, + ]; // open up all of the user and service commands to the service. - app.all("/*", async (req, res) => { + app.all("/*", ...apiMiddleware, async (req, res) => { const request = new HttpRequest(`${url}${req.originalUrl}`, { method: req.method as HttpMethod, body: req.body ? JSON.stringify(req.body) : undefined, @@ -170,11 +187,112 @@ export const local = (yargs: Argv) => res.send(resp.body); }); + const hasSockets = serviceSpec.sockets.length > 0; + + if (hasSockets) { + const wss = new WebSocketServer({ server }); + + server.on("upgrade", (request, socket, head) => { + if (request.url?.startsWith("/__ws/")) { + const [, , socketName] = request.url.split("/"); + if (!socketName) { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); + socket.destroy(); + return; + } + const query: SocketQuery = {}; + new URL(request.url).searchParams.forEach( + (value, name) => (query[name] = value) + ); + const headers = Object.fromEntries( + Object.entries(request.headers).map(([name, value]) => [ + name, + value && Array.isArray(value) ? value.join(",") : value, + ]) + ); + const connectionId = uuidv4(); + localEnv + .sendSocketRequest(socketName, { + type: "connect", + headers, + query, + connectionId, + }) + .then((result) => { + if (result) { + if (result.status < 200 || result.status >= 300) { + socket.write( + `HTTP/1.1 ${result.status} ${result.message}\r\n\r\n` + ); + socket.destroy(); + } + } + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request, { + connectionId, + socketName, + }); + }); + }) + .catch(() => { + socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); + socket.destroy(); + }); + } else { + socket.destroy(); + } + }); + + wss.on( + "connection", + (ws, _, ...args: [{ connectionId: string; socketName: string }]) => { + const [{ connectionId, socketName }] = args; + ws.on("message", (message) => { + localEnv + .sendSocketRequest(socketName, { + type: "message", + connectionId, + body: Array.isArray(message) + ? Buffer.concat(message) + : message instanceof ArrayBuffer + ? Buffer.from(message) + : message, + }) + .then((res) => { + if (res) { + ws.send(res.message); + } + }); + }); + ws.on("close", () => { + localEnv.sendSocketRequest(socketName, { + type: "disconnect", + connectionId, + }); + }); + webSocketContainer.connect(socketName, connectionId, ws); + } + ); + } + app.listen(port, () => { process.send?.("ready"); }); - spinner.succeed(`Eventual Dev Server running on ${url}`); + spinner.succeed( + `Eventual Dev Server running on ${url}. ${ + hasSockets + ? `\n Sockets are available at: \n\t${serviceSpec.sockets + .map( + (socket) => + `${socket.name} - ${url.replace("http", "ws")}/__ws/${ + socket.name + }` + ) + .join("\n")}` + : "" + }` + ); } ); diff --git a/packages/@eventual/cli/src/commands/replay.ts b/packages/@eventual/cli/src/commands/replay.ts index 67acaadf9..0bc42cbc2 100644 --- a/packages/@eventual/cli/src/commands/replay.ts +++ b/packages/@eventual/cli/src/commands/replay.ts @@ -99,6 +99,7 @@ export const replay = (yargs: Argv) => ServiceSpec: unsupportedPropertyRetriever, ServiceType: ServiceType.OrchestratorWorker, ServiceUrl: serviceData.apiEndpoint ?? unsupportedPropertyRetriever, + SocketUrls: unsupportedPropertyRetriever, TaskToken: unsupportedPropertyRetriever, }) ); diff --git a/packages/@eventual/cli/src/commands/service-info.ts b/packages/@eventual/cli/src/commands/service-info.ts index daed2a918..cab964361 100644 --- a/packages/@eventual/cli/src/commands/service-info.ts +++ b/packages/@eventual/cli/src/commands/service-info.ts @@ -19,6 +19,9 @@ export const serviceInfo = (yargs: Argv) => `API Gateway: ${serviceData.apiEndpoint}`, `Event Bus Arn: ${serviceData.eventBusArn}`, `Service Log Group: ${serviceData.workflowExecutionLogGroupName}`, + `Socket Endpoints: ${Object.entries( + serviceData.socketEndpoints + ).map(([name, ep]) => `\n\t${name} - ${ep}`)}`, ].join("\n") ); process.stdout.write("\n"); diff --git a/packages/@eventual/cli/src/display/event.ts b/packages/@eventual/cli/src/display/event.ts index d7f1f2111..ca6fe185c 100644 --- a/packages/@eventual/cli/src/display/event.ts +++ b/packages/@eventual/cli/src/display/event.ts @@ -9,6 +9,7 @@ import { isEntityRequest, isSignalReceived, isSignalSent, + isSocketRequest, isTaskScheduled, isTransactionRequest, type BucketRequest, @@ -35,6 +36,19 @@ export function displayEvent(event: WorkflowEvent) { }`, ...(isCallEvent(event) ? [ + ...("operation" in event.event + ? typeof event.event.operation === "object" && + "operation" in event.event.operation + ? [`Operation: ${event.event.operation.operation}`] + : [`Operation: ${event.event.operation}`] + : []), + ...(isSocketRequest(event.event) + ? [`Socket: ${event.event.operation.socketName}`] + : []), + ...(isSocketRequest(event.event) && + event.event.operation.operation === "send" + ? [`Input: ${event.event.operation.input}`] + : []), ...(isChildWorkflowScheduled(event.event) || isTaskScheduled(event.event) ? [`Task Name: ${JSON.stringify(event.event.name)}`] @@ -42,7 +56,9 @@ export function displayEvent(event: WorkflowEvent) { ...(isTransactionRequest(event.event) ? [`Transaction Name: ${event.event.transactionName}`] : []), - ...("signalId" in event ? [`Signal Id: ${event.signalId}`] : []), + ...("signalId" in event.event + ? [`Signal Id: ${event.event.signalId}`] + : []), ...((isChildWorkflowScheduled(event.event) || isTransactionRequest(event.event) || isTaskScheduled(event.event)) && @@ -63,6 +79,7 @@ export function displayEvent(event: WorkflowEvent) { ...(isSignalReceived(event) && event.payload ? [`Payload: ${JSON.stringify(event.payload)}`] : []), + ...("signalId" in event ? [`Signal Id: ${event.signalId}`] : []), ...("result" in event ? [`Result: ${JSON.stringify(event.result)}`] : []), ...("output" in event ? [`Output: ${JSON.stringify(event.output)}`] : []), ...("error" in event diff --git a/packages/@eventual/cli/src/local/web-socket-container.ts b/packages/@eventual/cli/src/local/web-socket-container.ts new file mode 100644 index 000000000..4a0f461a6 --- /dev/null +++ b/packages/@eventual/cli/src/local/web-socket-container.ts @@ -0,0 +1,69 @@ +import type { WebSocketContainer } from "@eventual/core-runtime"; +import type { SocketUrls } from "@eventual/core/internal"; +import type { WebSocket } from "ws"; + +export class LocalWebSocketContainer implements WebSocketContainer { + private sockets: Record = {}; + constructor(private domain: string) {} + + public send( + socketName: string, + connectionId: string, + input: string | Buffer + ): void { + this.getSocket(socketName).send(connectionId, input); + } + + public disconnect(socketName: string, connectionId: string): void { + this.getSocket(socketName).disconnect(connectionId); + } + + public connect( + socketName: string, + connectionId: string, + webSocket: WebSocket + ) { + this.getSocket(socketName).connect(connectionId, webSocket); + } + + private getSocket(socketName: string): LocalSocket { + if (!this.sockets[socketName]) { + this.sockets[socketName] = new LocalSocket(); + } + return this.sockets[socketName]!; + } + + public urls(socketName: string): SocketUrls { + return { + wss: `ws:/${this.domain}/__ws/${socketName}`, + http: "unsupported", + }; + } +} + +export class LocalSocket { + private idToSocketMap: Map = new Map(); + + public send(connectionId: string, input: string | Buffer): void { + const ws = this.idToSocketMap.get(connectionId); + if (!ws) { + throw new Error( + `Websocket for connection ${connectionId} does not exist.` + ); + } + ws.send(input); + } + + public disconnect(connectionId: string): void { + const socket = this.idToSocketMap.get(connectionId); + if (!socket) { + throw new Error(`No socket found for connectionId ${connectionId}`); + } + socket.close(); + this.idToSocketMap.delete(connectionId); + } + + public connect(connectionId: string, webSocket: WebSocket) { + this.idToSocketMap.set(connectionId, webSocket); + } +} diff --git a/packages/@eventual/cli/src/service-action.ts b/packages/@eventual/cli/src/service-action.ts index db75208e8..b9ee9a774 100644 --- a/packages/@eventual/cli/src/service-action.ts +++ b/packages/@eventual/cli/src/service-action.ts @@ -86,6 +86,8 @@ export function serviceAction( apiEndpoint: "http://localhost:3111", eventBusArn: "NOT SET ON LOCAL", workflowExecutionLogGroupName: "NOT SET ON LOCAL", + // TODO + socketEndpoints: {}, } satisfies ServiceData, undefined, new HttpEventualClient({ serviceUrl: "http://localhost:3111" }), diff --git a/packages/@eventual/cli/src/service-data.ts b/packages/@eventual/cli/src/service-data.ts index f7c7d2395..ca2ef7bb3 100644 --- a/packages/@eventual/cli/src/service-data.ts +++ b/packages/@eventual/cli/src/service-data.ts @@ -22,6 +22,7 @@ export interface ServiceData { eventBusArn: string; workflowExecutionLogGroupName: string; environmentVariables?: Record; + socketEndpoints: Record; } /** diff --git a/packages/@eventual/compiler/src/ast-util.ts b/packages/@eventual/compiler/src/ast-util.ts index 03b1b721d..200f41e69 100644 --- a/packages/@eventual/compiler/src/ast-util.ts +++ b/packages/@eventual/compiler/src/ast-util.ts @@ -82,21 +82,38 @@ export function isBucketHandlerMemberCall(call: CallExpression): boolean { } /** - * A heuristic for identifying a {@link CallExpression} that is a call to an `on` handler. + * A heuristic for identifying a {@link CallExpression} that is a call to an `queue` resource. * - * 1. must be a call to a MemberExpression matching to `.on(events, name, impl | props, impl)`. - * 2. must have 3 or 4 arguments. + * 1. must be a call to a MemberExpression matching to `queue(name, opts, impl)`. + * 2. must have 3 arguments. */ -export function isQueueHandlerForEachMemberCall(call: CallExpression): boolean { +export function isQueueResourceCall(call: CallExpression): boolean { const c = call.callee; - if (c.type === "MemberExpression") { - if (isId(c.property, "forEach") || isId(c.property, "forEachBatch")) { - // queue.forEach("handlerName", async () => { }) - // queue.forEach("handlerName", options, async () => { }) - // queue.forEachBatch("handlerName", async () => { }) - // queue.forEachBatch("handlerName", options, async () => { }) - return call.arguments.length === 2 || call.arguments.length === 3; - } + if ( + (c.type === "Identifier" && c.value === "queue") || + (c.type === "MemberExpression" && isId(c.property, "queue")) + ) { + // queue("handlerName", opts, async () => { }) + return call.arguments.length === 3; + } + return false; +} + +/** + * A heuristic for identifying a {@link CallExpression} that is a call to an `socket` resource. + * + * 1. must be a call to a MemberExpression matching to `socket(name, impl | opts, impl)`. + * 2. must have 2 or 3 arguments. + */ +export function isSocketResourceCall(call: CallExpression): boolean { + const c = call.callee; + if ( + (c.type === "Identifier" && c.value === "socket") || + (c.type === "MemberExpression" && isId(c.property, "socket")) + ) { + // socket("handlerName", async () => { }) + // socket("handlerName", options, async () => { }) + return call.arguments.length === 2 || call.arguments.length === 3; } return false; } diff --git a/packages/@eventual/compiler/src/eventual-infer.ts b/packages/@eventual/compiler/src/eventual-infer.ts index 6372b195f..ec30ce0e5 100644 --- a/packages/@eventual/compiler/src/eventual-infer.ts +++ b/packages/@eventual/compiler/src/eventual-infer.ts @@ -32,7 +32,8 @@ import { isCommandCall, isEntityStreamMemberCall, isOnEventCall, - isQueueHandlerForEachMemberCall, + isQueueResourceCall, + isSocketResourceCall, isSubscriptionCall, isTaskCall, } from "./ast-util.js"; @@ -185,6 +186,12 @@ export function inferFromMemory(openApi: ServiceSpec["openApi"]): ServiceSpec { visibilityTimeout: q.visibilityTimeout, } satisfies QueueSpec) ), + sockets: Array.from(getEventualResources("Socket").values()).map((s) => ({ + name: s.name, + handlerTimeout: s.handlerTimeout, + memorySize: s.memorySize, + sourceLocation: s.sourceLocation, + })), }; } @@ -270,7 +277,8 @@ export class InferVisitor extends Visitor { isTaskCall, isEntityStreamMemberCall, isBucketHandlerMemberCall, - isQueueHandlerForEachMemberCall, + isQueueResourceCall, + isSocketResourceCall, ].some((op) => op(call)) ) { this.didMutate = true; diff --git a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap index 613e18d58..05545377e 100644 --- a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap +++ b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap @@ -38,14 +38,20 @@ var CallKind; CallKind2[CallKind2["ExpectSignalCall"] = 3] = "ExpectSignalCall"; CallKind2[CallKind2["GetExecutionCall"] = 14] = "GetExecutionCall"; CallKind2[CallKind2["InvokeTransactionCall"] = 9] = "InvokeTransactionCall"; + CallKind2[CallKind2["QueueCall"] = 15] = "QueueCall"; + CallKind2[CallKind2["SearchCall"] = 11] = "SearchCall"; CallKind2[CallKind2["SendSignalCall"] = 6] = "SendSignalCall"; CallKind2[CallKind2["SignalHandlerCall"] = 5] = "SignalHandlerCall"; + CallKind2[CallKind2["SocketCall"] = 16] = "SocketCall"; + CallKind2[CallKind2["StartWorkflowCall"] = 13] = "StartWorkflowCall"; CallKind2[CallKind2["TaskCall"] = 0] = "TaskCall"; CallKind2[CallKind2["TaskRequestCall"] = 12] = "TaskRequestCall"; - CallKind2[CallKind2["SearchCall"] = 11] = "SearchCall"; - CallKind2[CallKind2["StartWorkflowCall"] = 13] = "StartWorkflowCall"; - CallKind2[CallKind2["QueueCall"] = 15] = "QueueCall"; })(CallKind || (CallKind = {})); +var CallSymbol = /* @__PURE__ */ Symbol.for("eventual:EventualCall"); +function createCall(kind, e2) { + e2[CallSymbol] = kind; + return e2; +} var Schedule = { duration(dur, unit = "seconds") { @@ -73,8 +79,14 @@ var PropertyKind; PropertyKind2[PropertyKind2["ServiceSpec"] = 5] = "ServiceSpec"; PropertyKind2[PropertyKind2["ServiceType"] = 6] = "ServiceType"; PropertyKind2[PropertyKind2["ServiceUrl"] = 7] = "ServiceUrl"; - PropertyKind2[PropertyKind2["TaskToken"] = 8] = "TaskToken"; + PropertyKind2[PropertyKind2["SocketUrls"] = 8] = "SocketUrls"; + PropertyKind2[PropertyKind2["TaskToken"] = 9] = "TaskToken"; })(PropertyKind || (PropertyKind = {})); +var PropertySymbol = /* @__PURE__ */ Symbol.for("eventual:EventualProperty"); +function createEventualProperty(kind, e2) { + e2[PropertySymbol] = kind; + return e2; +} globalThis._eventual ??= { resources: {} }; function registerEventualResource(resourceKind, resource) { @@ -96,6 +108,7 @@ var ServiceType; ServiceType2["EntityStreamWorker"] = "EntityStreamWorker"; ServiceType2["OrchestratorWorker"] = "OrchestratorWorker"; ServiceType2["QueueHandlerWorker"] = "QueueHandlerWorker"; + ServiceType2["SocketWorker"] = "SocketWorker"; ServiceType2["Subscription"] = "Subscription"; ServiceType2["TaskWorker"] = "TaskWorker"; ServiceType2["TransactionWorker"] = "TransactionWorker"; @@ -130,8 +143,12 @@ function e({ base: t = "", routes: n = [] } = {}) { } var itty_router_min_default = { Router: e }; +function parseArgs(args, predicates) { + return Object.fromEntries(Object.entries(predicates).map(([name, predicate]) => [name, args.find(predicate)])); +} + function command(...args) { - const [sourceLocation, name, options, handler] = parseCommandArgs(args); + const { sourceLocation, name, options, handler } = parseCommandArgs(args); const command2 = { kind: "Command", name, @@ -143,12 +160,12 @@ function command(...args) { return registerEventualResource("Command", command2); } function parseCommandArgs(args) { - return [ - args.find(isSourceLocation), - args.find((a) => typeof a === "string"), - args.find((a) => typeof a === "object" && !isSourceLocation(a)), - args.find((a) => typeof a === "function") - ]; + return parseArgs(args, { + sourceLocation: isSourceLocation, + name: (a) => typeof a === "string", + options: (a) => typeof a === "object" && !isSourceLocation(a), + handler: (a) => typeof a === "function" + }); } var router = itty_router_min_default.Router(); @@ -162,7 +179,7 @@ function createRouter(middlewares) { return (middleware) => createRouter([...middlewares ?? [], middleware]); } else if (method === "command") { return (...args) => { - const [sourceLocation, name, options, handler] = parseCommandArgs(args); + const { sourceLocation, name, options, handler } = parseCommandArgs(args); return command(sourceLocation, name, { ...options ?? {}, middlewares @@ -295,6 +312,61 @@ globalThis.tryGetEventualHook ??= () => { return void 0; }; +function createSocketBuilder(middlewares) { + const socketFunction = (...args) => { + const { sourceLocation, name, options, handlers } = parseSocketArgs(args); + const socket2 = { + middlewares, + name, + handlers, + sourceLocation, + kind: "Socket", + handlerTimeout: options?.handlerTimeout, + memorySize: options?.memorySize, + get wssEndpoint() { + return getEventualHook().getEventualProperty(createEventualProperty(PropertyKind.SocketUrls, { socketName: name })).wss; + }, + get httpEndpoint() { + return getEventualHook().getEventualProperty(createEventualProperty(PropertyKind.SocketUrls, { socketName: name })).http; + }, + send(...params) { + return getEventualHook().executeEventualCall(createCall(CallKind.SocketCall, { + operation: { + operation: "send", + socketName: name, + params + } + })); + }, + disconnect(...params) { + return getEventualHook().executeEventualCall(createCall(CallKind.SocketCall, { + operation: { + operation: "disconnect", + socketName: name, + params + } + })); + } + }; + return registerEventualResource("Socket", socket2); + }; + const useFunction = (socketMiddleware) => { + const middleware = typeof socketMiddleware === "function" ? { connect: socketMiddleware } : socketMiddleware; + return createSocketBuilder([...middlewares, middleware]); + }; + socketFunction.use = useFunction; + return socketFunction; +} +var socket = createSocketBuilder([]); +function parseSocketArgs(args) { + return parseArgs(args, { + sourceLocation: isSourceLocation, + name: (a) => typeof a === "string", + options: (a) => typeof a === "object" && !isSourceLocation(a) && !("$connect" in a), + handlers: (a) => typeof a === "object" && !isSourceLocation(a) && "$connect" in a + }); +} + var myHandler = api.get("/", async () => { return new HttpResponse(); }); diff --git a/packages/@eventual/core-runtime/src/build-manifest.ts b/packages/@eventual/core-runtime/src/build-manifest.ts index 307b85507..7cc2fe5c8 100644 --- a/packages/@eventual/core-runtime/src/build-manifest.ts +++ b/packages/@eventual/core-runtime/src/build-manifest.ts @@ -9,6 +9,7 @@ import type { IndexSpec, QueueHandlerSpec, QueueSpec, + SocketSpec, SubscriptionSpec, TaskSpec, TransactionSpec, @@ -25,6 +26,7 @@ export interface BuildManifest { * The events and their schema. */ events: EventSpec[]; + sockets: SocketFunction[]; /** * All subscriptions to events declared within the service. */ @@ -127,3 +129,5 @@ export type BucketNotificationHandlerFunction = BundledFunction; export type QueueHandlerFunction = BundledFunction; + +export type SocketFunction = BundledFunction; diff --git a/packages/@eventual/core-runtime/src/call-executor.ts b/packages/@eventual/core-runtime/src/call-executor.ts index dc1b1bb3d..76d519ecc 100644 --- a/packages/@eventual/core-runtime/src/call-executor.ts +++ b/packages/@eventual/core-runtime/src/call-executor.ts @@ -18,7 +18,7 @@ export type AllCallExecutors = { >; }; -export class UnsupportedExecutor +export class UnsupportedCallExecutor implements CallExecutor { constructor(private name: string) {} @@ -37,15 +37,27 @@ export class UnsupportedExecutor export class AllCallExecutor implements CallExecutor { constructor(private executors: AllCallExecutors) {} public execute(call: E) { - const kind = call[CallSymbol]; - const executor = this.executors[CallKind[kind] as keyof typeof CallKind] as - | CallExecutor - | undefined; - + const kind = this.getCallKindName(call); + const executor = this.executors[kind] as CallExecutor | undefined; if (executor) { return executor.execute(call) as unknown as EventualPromise; } throw new Error(`Missing Executor for ${CallKind[kind]}`); } + + public isUnsupported(call: E): boolean { + const kind = this.getCallKindName(call); + const executor = this.executors[kind] as CallExecutor | undefined; + return ( + !executor || + this.executors[this.getCallKindName(call)] instanceof + UnsupportedCallExecutor + ); + } + + private getCallKindName(call: E): keyof typeof CallKind { + const kind = call[CallSymbol]; + return CallKind[kind] as keyof typeof CallKind; + } } diff --git a/packages/@eventual/core-runtime/src/call-executors/send-signal-call-executor.ts b/packages/@eventual/core-runtime/src/call-executors/send-signal-call-executor.ts new file mode 100644 index 000000000..bd1660e6e --- /dev/null +++ b/packages/@eventual/core-runtime/src/call-executors/send-signal-call-executor.ts @@ -0,0 +1,27 @@ +import { + CallOutput, + SendSignalCall, + isChildExecutionTarget, +} from "@eventual/core/internal"; +import type { CallExecutor } from "../call-executor.js"; +import { ExecutionQueueClient } from "../clients/execution-queue-client.js"; + +export class SendSignalCallExecutor implements CallExecutor { + constructor(private executionQueueClient: ExecutionQueueClient) {} + + public execute(call: SendSignalCall): Promise> { + if (isChildExecutionTarget(call.target)) { + throw new Error( + "Cannot signal to child execution targets outside of a workflow" + ); + } + const childExecutionId = call.target.executionId; + + return this.executionQueueClient.sendSignal({ + signal: call.signalId, + execution: childExecutionId, + id: call.id, + payload: call.payload, + }); + } +} diff --git a/packages/@eventual/core-runtime/src/call-executors/socket-call-executor.ts b/packages/@eventual/core-runtime/src/call-executors/socket-call-executor.ts new file mode 100644 index 000000000..51421f625 --- /dev/null +++ b/packages/@eventual/core-runtime/src/call-executors/socket-call-executor.ts @@ -0,0 +1,14 @@ +import type { CallOutput, SocketCall } from "@eventual/core/internal"; +import type { CallExecutor } from "../call-executor.js"; +import type { SocketClient } from "../clients/socket-client.js"; + +export class SocketCallExecutor implements CallExecutor { + constructor(private socketClient: SocketClient) {} + public execute(call: SocketCall): Promise> { + return this.socketClient[call.operation.operation]( + call.operation.socketName, + // @ts-ignore - typescript won't let me case the params... + ...call.operation.params + ); + } +} diff --git a/packages/@eventual/core-runtime/src/clients/index.ts b/packages/@eventual/core-runtime/src/clients/index.ts index 81f08fd99..4a7d0e06b 100644 --- a/packages/@eventual/core-runtime/src/clients/index.ts +++ b/packages/@eventual/core-runtime/src/clients/index.ts @@ -5,6 +5,7 @@ export * from "./metrics-client.js"; export * from "./open-search-client.js"; export * from "./queue-client.js"; export * from "./runtime-service-clients.js"; +export * from "./socket-client.js"; export * from "./task-client.js"; export * from "./timer-client.js"; export * from "./transaction-client.js"; diff --git a/packages/@eventual/core-runtime/src/clients/socket-client.ts b/packages/@eventual/core-runtime/src/clients/socket-client.ts new file mode 100644 index 000000000..178734bef --- /dev/null +++ b/packages/@eventual/core-runtime/src/clients/socket-client.ts @@ -0,0 +1,11 @@ +import { Socket } from "@eventual/core"; +import { SocketMethod, SocketUrls } from "@eventual/core/internal"; + +export type SocketClient = { + [K in keyof Pick]: ( + socketName: string, + ...args: Parameters + ) => ReturnType; +} & { + socketUrls(socketName: string): SocketUrls; +}; diff --git a/packages/@eventual/core-runtime/src/handlers/command-worker.ts b/packages/@eventual/core-runtime/src/handlers/command-worker.ts index fcd2b09d5..90e041080 100644 --- a/packages/@eventual/core-runtime/src/handlers/command-worker.ts +++ b/packages/@eventual/core-runtime/src/handlers/command-worker.ts @@ -9,6 +9,7 @@ import { import { ServiceType, getEventualResources } from "@eventual/core/internal"; import itty from "itty-router"; import { WorkerIntrinsicDeps, createEventualWorker } from "./worker.js"; +import { withMiddlewares } from "../utils.js"; export type ApiHandlerDependencies = WorkerIntrinsicDeps; @@ -96,41 +97,44 @@ function initRouter() { // RPC route takes a POST request and passes the parsed JSON body as input to the input router.post( commandRpcPath(command), - withMiddleware(async (request, context) => { - if (command.passThrough) { - // if passthrough is enabled, just proxy the request-response to the handler - return command.handler(request, context); - } + withMiddlewares( + command.middlewares ?? [], + async (request, context) => { + if (command.passThrough) { + // if passthrough is enabled, just proxy the request-response to the handler + return command.handler(request, context); + } - let input: any = await request.tryJson(); - if (command.input && shouldValidate) { - try { - input = command.input.parse(input); - } catch (err) { - console.error("Invalid input", err, input); - return new HttpResponse(JSON.stringify(err), { - status: 400, - statusText: "Invalid input", - }); + let input: any = await request.tryJson(); + if (command.input && shouldValidate) { + try { + input = command.input.parse(input); + } catch (err) { + console.error("Invalid input", err, input); + return new HttpResponse(JSON.stringify(err), { + status: 400, + statusText: "Invalid input", + }); + } } - } - let output: any = await command.handler(input, context); - if (command.output?.schema && shouldValidate) { - try { - output = command.output.schema.parse(output); - } catch (err) { - console.error("RPC output did not match schema", output, err); - return new HttpResponse(JSON.stringify(err), { - status: 500, - statusText: "RPC output did not match schema", - }); + let output: any = await command.handler(input, context); + if (command.output?.schema && shouldValidate) { + try { + output = command.output.schema.parse(output); + } catch (err) { + console.error("RPC output did not match schema", output, err); + return new HttpResponse(JSON.stringify(err), { + status: 500, + statusText: "RPC output did not match schema", + }); + } } + return new HttpResponse(JSON.stringify(output, jsonReplacer), { + status: 200, + }); } - return new HttpResponse(JSON.stringify(output, jsonReplacer), { - status: 200, - }); - }) + ) ); } @@ -143,116 +147,67 @@ function initRouter() { // REST routes parse the request according to the command's path/method/params configuration router[method]( path, - withMiddleware(async (request: HttpRequest, context) => { - if (command.passThrough) { - // if passthrough is enabled, just proxy the request-response to the handler - return command.handler(request, context); - } - - // first, get the body as pure JSON - assume it's an object - const body = await request.tryJson(); - let input: any = { - ...request.params, - ...(body && typeof body === "object" ? body : {}), - }; - - // parse headers/params/queries/body into the RPC interface - if (command.params) { - Object.entries(command.params).forEach(([name, spec]) => { - input[name] = resolveInput(name, spec); - }); - } - - if (command.input && shouldValidate) { - // validate the zod input schema if one is specified - input = command.input.parse(input); - } - - // call the command RPC handler - let output: any = await command.handler(input, context); + withMiddlewares( + command.middlewares ?? [], + async (request: HttpRequest, context) => { + if (command.passThrough) { + // if passthrough is enabled, just proxy the request-response to the handler + return command.handler(request, context); + } - if (command.output?.schema && shouldValidate) { - // validate the output of the command handler against the schema if it's defined - output = command.output.schema.parse(output); - } + // first, get the body as pure JSON - assume it's an object + const body = await request.tryJson(); + let input: any = { + ...request.params, + ...(body && typeof body === "object" ? body : {}), + }; + + // parse headers/params/queries/body into the RPC interface + if (command.params) { + Object.entries(command.params).forEach(([name, spec]) => { + input[name] = resolveInput(name, spec); + }); + } - // TODO: support mapping RPC output back to HTTP properties such as Headers - // TODO: support alternative status code https://github.com/functionless/eventual/issues/276 + if (command.input && shouldValidate) { + // validate the zod input schema if one is specified + input = command.input.parse(input); + } - return new HttpResponse(JSON.stringify(output, jsonReplacer), { - status: command.output?.restStatusCode ?? 200, - headers: { - "Content-Type": "application/json", - }, - }); + // call the command RPC handler + let output: any = await command.handler(input, context); - function resolveInput(name: string, spec: RestParam): any { - if (spec === "body") { - return body?.[name]; - } else if (spec === "query") { - return request.query?.[name]; - } else if (spec === "header") { - return request.headers.get(name); - } else if (spec === "path") { - return request.params?.[name]; - } else { - return resolveInput(spec.name ?? name, spec.in); + if (command.output?.schema && shouldValidate) { + // validate the output of the command handler against the schema if it's defined + output = command.output.schema.parse(output); } - } - }) - ); - } - - /** - * Applies the chain of middleware callbacks to the request to build up - * context and pass it through the chain and finally to the handler. - * - * Each context can add to or completely replace the context. They can - * also break the chain at any time by returning a HttpResponse instead - * of calling `next`. - * - * @param handler - * @returns - */ - function withMiddleware( - handler: ( - request: HttpRequest, - context: CommandContext - ) => Promise - ) { - return async ( - request: HttpRequest, - context: CommandContext - ): Promise => { - const chain = (command.middlewares ?? []).values(); - return next(request, context); + // TODO: support mapping RPC output back to HTTP properties such as Headers + // TODO: support alternative status code https://github.com/functionless/eventual/issues/276 - async function next( - request: HttpRequest, - context: CommandContext - ): Promise { - let consumed = false; - const middleware = chain.next(); - if (middleware.done) { - return handler(request, context); - } else { - return middleware.value({ - request, - context, - next: async (context) => { - if (consumed) { - consumed = true; - throw new Error( - `Middleware cannot call 'next' more than once` - ); - } - return next(request, context); + return new HttpResponse(JSON.stringify(output, jsonReplacer), { + status: command.output?.restStatusCode ?? 200, + headers: { + "Content-Type": "application/json", }, }); + + function resolveInput(name: string, spec: RestParam): any { + if (spec === "body") { + return body?.[name]; + } else if (spec === "query") { + return request.query?.[name]; + } else if (spec === "header") { + return request.headers.get(name); + } else if (spec === "path") { + return request.params?.[name]; + } else { + return resolveInput(spec.name ?? name, spec.in); + } + } } - } - }; + ) + ); } } diff --git a/packages/@eventual/core-runtime/src/handlers/index.ts b/packages/@eventual/core-runtime/src/handlers/index.ts index 16126ffba..017eb208c 100644 --- a/packages/@eventual/core-runtime/src/handlers/index.ts +++ b/packages/@eventual/core-runtime/src/handlers/index.ts @@ -4,6 +4,7 @@ export * from "./entity-stream-worker.js"; export * from "./orchestrator.js"; export * from "./queue-handler-worker.js"; export * from "./schedule-forwarder.js"; +export * from "./socket-worker.js"; export * from "./subscription-worker.js"; export * from "./task-fallback-handler.js"; export * from "./task-worker.js"; diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index 127aab45c..aecbc187d 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -9,7 +9,6 @@ import { type Workflow, } from "@eventual/core"; import { - ServiceType, WorkflowEventType, isCallEvent, isTimerCompleted, @@ -42,11 +41,7 @@ import { import type { MetricsLogger } from "../metrics/metrics-logger.js"; import { Unit } from "../metrics/unit.js"; import { timed } from "../metrics/utils.js"; -import { - AllPropertyRetriever, - UnsupportedPropertyRetriever, -} from "../property-retriever.js"; -import { BucketPhysicalNamePropertyRetriever } from "../property-retrievers/bucket-name-property-retriever.js"; +import type { AllPropertyRetriever } from "../property-retriever.js"; import type { ExecutorProvider } from "../providers/executor-provider.js"; import type { WorkflowProvider } from "../providers/workflow-provider.js"; import { @@ -57,16 +52,21 @@ import { resultToString, } from "../result.js"; import { computeScheduleDate } from "../schedule.js"; -import type { BucketStore } from "../stores/bucket-store.js"; import type { ExecutionHistoryStore } from "../stores/execution-history-store.js"; import type { WorkflowTask } from "../tasks.js"; import { groupBy } from "../utils.js"; -import { WorkflowCallExecutor } from "../workflow/call-executor.js"; +import { + createDefaultWorkflowCallExecutor, + type WorkflowCallExecutor, + type WorkflowCallExecutorDependencies, +} from "../workflow/call-executor.js"; import { createEvent } from "../workflow/events.js"; import { isExecutionId, parseWorkflowName } from "../workflow/execution.js"; +import { + createDefaultWorkflowPropertyRetriever, + type WorkflowPropertyRetrieverDeps, +} from "../workflow/property-retriever.js"; import { WorkflowExecutor } from "../workflow/workflow-executor.js"; -import { QueuePhysicalNamePropertyRetriever } from "../property-retrievers/queue-name-property-retriever.js"; -import { QueueClient } from "../clients/queue-client.js"; export interface OrchestratorResult { /** @@ -86,39 +86,31 @@ export interface ExecutorRunContext { runTimestamp: number | undefined; } +interface OrchestratorDependencies + extends WorkflowCallExecutorDependencies, + WorkflowPropertyRetrieverDeps, + OrchestrateExecutionDeps {} + export function createOrchestrator( deps: OrchestratorDependencies ): Orchestrator { - const unsupportedProperty = new UnsupportedPropertyRetriever( - "Workflow Orchestrator" - ); // TODO: load these from history when running past executions https://github.com/functionless/eventual/issues/416 - const propertyRetriever = new AllPropertyRetriever({ - BucketPhysicalName: new BucketPhysicalNamePropertyRetriever( - deps.bucketStore - ), - OpenSearchClient: unsupportedProperty, - QueuePhysicalName: new QueuePhysicalNamePropertyRetriever(deps.queueClient), - ServiceClient: unsupportedProperty, - ServiceName: deps.serviceName, - ServiceSpec: unsupportedProperty, - ServiceType: ServiceType.OrchestratorWorker, - ServiceUrl: unsupportedProperty, - TaskToken: unsupportedProperty, - }); + const propertyRetriever = createDefaultWorkflowPropertyRetriever(deps); + const callExecutor = createDefaultWorkflowCallExecutor(deps); return async (workflowTasks, baseTime = () => new Date()) => { const result = await runExecutions( workflowTasks, (workflowName, executionId, events) => { - return orchestrateExecution( + return orchestrateExecution({ + callExecutor, workflowName, executionId, events, - baseTime(), + executionTime: baseTime(), deps, - propertyRetriever - ); + propertyRetriever, + }); } ); @@ -131,35 +123,40 @@ export function createOrchestrator( }; } -interface OrchestratorDependencies { - /** - * Supports retrieval of the bucket physical name from within the workflow. - */ - bucketStore: BucketStore; - callExecutor: WorkflowCallExecutor; +export interface ExecutorContext { + date: number; +} + +export interface OrchestrateExecutionDeps { executionHistoryStore: ExecutionHistoryStore; executorProvider: ExecutorProvider; - logAgent?: LogAgent; metricsClient?: MetricsClient; - queueClient: QueueClient; + logAgent?: LogAgent; serviceName: string; timerClient: TimerClient; workflowClient: WorkflowClient; workflowProvider: WorkflowProvider; } -export interface ExecutorContext { - date: number; +export interface OrchestrateExecutionRequest { + callExecutor: WorkflowCallExecutor; + deps: OrchestrateExecutionDeps; + executionId: ExecutionID; + events: WorkflowInputEvent[]; + executionTime: Date; + propertyRetriever: AllPropertyRetriever; + workflowName: string; } -export async function orchestrateExecution( - workflowName: string, - executionId: ExecutionID, - events: WorkflowInputEvent[], - executionTime: Date, - deps: OrchestratorDependencies, - propertyRetriever: AllPropertyRetriever -) { +export async function orchestrateExecution({ + callExecutor, + deps, + executionId, + events, + executionTime, + propertyRetriever, + workflowName, +}: OrchestrateExecutionRequest) { const metrics = initializeMetrics( deps.serviceName, workflowName, @@ -268,7 +265,7 @@ export async function orchestrateExecution( await timed(metrics, OrchestratorMetrics.InvokeCallsDuration, () => Promise.all( calls.map((call) => - deps.callExecutor.executeForWorkflow(call.call, { + callExecutor.executeForWorkflow(call.call, { executionId, executionTime, seq: call.seq, diff --git a/packages/@eventual/core-runtime/src/handlers/socket-worker.ts b/packages/@eventual/core-runtime/src/handlers/socket-worker.ts new file mode 100644 index 000000000..844aa228e --- /dev/null +++ b/packages/@eventual/core-runtime/src/handlers/socket-worker.ts @@ -0,0 +1,81 @@ +import { + SocketConnectRequest, + SocketDisconnectRequest, + SocketHandlerContext, + SocketMessageRequest, + SocketMiddleware, + SocketRequest, + SocketResponse, +} from "@eventual/core"; +import { ServiceType, getEventualResource } from "@eventual/core/internal"; +import { getLazy, withMiddlewares } from "../utils.js"; +import { createEventualWorker, type WorkerIntrinsicDeps } from "./worker.js"; + +export type SocketWorkerDependencies = WorkerIntrinsicDeps; + +function isSocketRequestType( + type: Type, + request: SocketRequest +): request is SocketRequest & { type: Type } { + return request.type === type; +} + +export interface SocketWorker { + (socketName: string, request: SocketRequest): Promise; +} + +export function createSocketWorker( + dependencies: SocketWorkerDependencies +): SocketWorker { + return createEventualWorker( + { serviceType: ServiceType.SocketWorker, ...dependencies }, + async (socketName, request) => { + const socket = getEventualResource("Socket", socketName); + if (!socket) throw new Error(`Socket ${socketName} does not exist`); + const handlers = socket.handlers; + + const context: SocketHandlerContext = { + socket: { socketName }, + service: { + serviceName: getLazy(dependencies.serviceName), + serviceUrl: getLazy(dependencies.serviceUrl), + }, + }; + + if (isSocketRequestType("connect", request)) { + return withMiddlewares( + getSocketMiddlewaresWithFunction("connect", socket.middlewares), + async (request, context) => + (await handlers.$connect(request, context)) ?? { status: 200 } + )(request, context); + } else if (isSocketRequestType("disconnect", request)) { + return withMiddlewares< + SocketHandlerContext, + any, + SocketDisconnectRequest + >( + getSocketMiddlewaresWithFunction("disconnect", socket.middlewares), + async (request, context) => + (await handlers.$disconnect(request, context)) ?? { status: 200 } + )(request, context); + } else if (isSocketRequestType("message", request)) { + return withMiddlewares( + getSocketMiddlewaresWithFunction("message", socket.middlewares), + async (request, context) => + (await handlers.$default(request, context)) ?? { status: 200 } + )(request, context); + } + } + ); +} + +function getSocketMiddlewaresWithFunction< + Fn extends Exclude +>( + fn: Fn, + middlewares: SocketMiddleware[] +): Exclude[] { + return middlewares + .map((m) => m[fn]) + .filter((m): m is Exclude => !!m); +} diff --git a/packages/@eventual/core-runtime/src/handlers/transaction-worker.ts b/packages/@eventual/core-runtime/src/handlers/transaction-worker.ts index ba82f9473..5d9b4bc29 100644 --- a/packages/@eventual/core-runtime/src/handlers/transaction-worker.ts +++ b/packages/@eventual/core-runtime/src/handlers/transaction-worker.ts @@ -3,23 +3,25 @@ import type { ExecuteTransactionResponse, } from "@eventual/core"; import { ServiceType, getEventualResource } from "@eventual/core/internal"; -import type { EventClient } from "../clients/event-client.js"; -import type { ExecutionQueueClient } from "../clients/execution-queue-client.js"; import { AllPropertyRetriever, UnsupportedPropertyRetriever, } from "../property-retriever.js"; +import { SocketUrlPropertyRetriever } from "../property-retrievers/socket-url-property-retriever.js"; import type { EntityProvider } from "../providers/entity-provider.js"; import { isResolved, normalizeFailedResult } from "../result.js"; import type { EntityStore } from "../stores/entity-store.js"; -import { createTransactionExecutor } from "../transaction-executor.js"; +import { + TransactionCallExecutorDependencies, + createTransactionCallExecutor, + createTransactionExecutor, +} from "../transaction-executor.js"; import { getLazy, type LazyValue } from "../utils.js"; -export interface TransactionWorkerProps { +export interface TransactionWorkerProps + extends TransactionCallExecutorDependencies { entityStore: EntityStore; entityProvider: EntityProvider; - executionQueueClient: ExecutionQueueClient; - eventClient: EventClient; serviceName: LazyValue; } @@ -44,13 +46,13 @@ export function createTransactionWorker( ServiceSpec: unsupportedPropertyRetriever, ServiceType: ServiceType.TransactionWorker, ServiceUrl: unsupportedPropertyRetriever, + SocketUrls: new SocketUrlPropertyRetriever(props.socketClient), TaskToken: unsupportedPropertyRetriever, }); const transactionExecutor = createTransactionExecutor( props.entityStore, props.entityProvider, - props.executionQueueClient, - props.eventClient, + createTransactionCallExecutor(props), propertyRetriever ); diff --git a/packages/@eventual/core-runtime/src/handlers/worker.ts b/packages/@eventual/core-runtime/src/handlers/worker.ts index e728de50e..fce7b30b2 100644 --- a/packages/@eventual/core-runtime/src/handlers/worker.ts +++ b/packages/@eventual/core-runtime/src/handlers/worker.ts @@ -3,16 +3,18 @@ import { ServiceType, type ServiceSpec } from "@eventual/core/internal"; import { AllCallExecutor, AllCallExecutors, - UnsupportedExecutor, + UnsupportedCallExecutor, } from "../call-executor.js"; import { AwaitTimerCallPassthroughExecutor } from "../call-executors/await-timer-executor.js"; import { BucketCallExecutor } from "../call-executors/bucket-call-executor.js"; import { EntityCallExecutor } from "../call-executors/entity-call-executor.js"; import { QueueCallExecutor } from "../call-executors/queue-call-executor.js"; import { SearchCallExecutor } from "../call-executors/search-call-executor.js"; +import { SocketCallExecutor } from "../call-executors/socket-call-executor.js"; import { ServiceClientExecutor } from "../call-executors/service-client-executor.js"; import type { OpenSearchClient } from "../clients/open-search-client.js"; import type { QueueClient } from "../clients/queue-client.js"; +import type { SocketClient } from "../clients/socket-client.js"; import { enterEventualCallHookScope } from "../eventual-hook.js"; import { AllPropertyRetriever, @@ -22,6 +24,7 @@ import { import { BucketPhysicalNamePropertyRetriever } from "../property-retrievers/bucket-name-property-retriever.js"; import { OpenSearchClientPropertyRetriever } from "../property-retrievers/open-search-client-property-retriever.js"; import { QueuePhysicalNamePropertyRetriever } from "../property-retrievers/queue-name-property-retriever.js"; +import { SocketUrlPropertyRetriever } from "../property-retrievers/socket-url-property-retriever.js"; import type { BucketStore } from "../stores/bucket-store.js"; import type { EntityStore } from "../stores/entity-store.js"; import type { LazyValue } from "../utils.js"; @@ -35,6 +38,7 @@ export interface WorkerIntrinsicDeps { serviceName: string | LazyValue; serviceSpec: ServiceSpec | undefined; serviceUrl: string | LazyValue; + socketClient: SocketClient | undefined; } type AllExecutorOverrides = { @@ -58,7 +62,7 @@ export function createEventualWorker( }, worker: (...input: Input) => Promise ): (...input: Input) => Promise> { - const unsupportedExecutor = new UnsupportedExecutor("Eventual Worker"); + const unsupportedExecutor = new UnsupportedCallExecutor("Eventual Worker"); const unsupportedProperty = new UnsupportedPropertyRetriever( "Eventual Worker" ); @@ -86,6 +90,12 @@ export function createEventualWorker( const queuePhysicalNamePropertyRetriever = props.queueClient ? new QueuePhysicalNamePropertyRetriever(props.queueClient) : unsupportedProperty; + const [socketCallExecutor, socketUrlPropertyRetriever] = props.socketClient + ? [ + new SocketCallExecutor(props.socketClient), + new SocketUrlPropertyRetriever(props.socketClient), + ] + : [unsupportedExecutor, unsupportedProperty]; return (...input: Input) => { const resolvedExecutorOverrides = props.executorOverrides @@ -133,6 +143,7 @@ export function createEventualWorker( SignalHandlerCall: unsupportedExecutor, SearchCall: openSearchExecutor, SendSignalCall: serviceClientExecutor, + SocketCall: socketCallExecutor, StartWorkflowCall: serviceClientExecutor, // directly calling a task does not work outside of a workflow TaskCall: unsupportedExecutor, @@ -148,6 +159,7 @@ export function createEventualWorker( ServiceSpec: props.serviceSpec ?? unsupportedProperty, ServiceType: props.serviceType, ServiceUrl: props.serviceUrl, + SocketUrls: socketUrlPropertyRetriever, TaskToken: unsupportedProperty, // the task worker should override this ...resolvedPropertyOverrides, }), diff --git a/packages/@eventual/core-runtime/src/local/clients/socket-client.ts b/packages/@eventual/core-runtime/src/local/clients/socket-client.ts new file mode 100644 index 000000000..c114f70c9 --- /dev/null +++ b/packages/@eventual/core-runtime/src/local/clients/socket-client.ts @@ -0,0 +1,26 @@ +import { type SocketUrls } from "@eventual/core/internal"; +import type { SocketClient } from "../../clients/socket-client.js"; +import { WebSocketContainer } from "../web-socket-container.js"; + +export class LocalSocketClient implements SocketClient { + constructor(private wsContainer: WebSocketContainer) {} + + public async send( + socketName: string, + connectionId: string, + input: string | Buffer + ): Promise { + this.wsContainer.send(socketName, connectionId, input); + } + + public async disconnect( + socketName: string, + connectionId: string + ): Promise { + this.wsContainer.disconnect(socketName, connectionId); + } + + public socketUrls(socketName: string): SocketUrls { + return this.wsContainer.urls(socketName); + } +} diff --git a/packages/@eventual/core-runtime/src/local/index.ts b/packages/@eventual/core-runtime/src/local/index.ts index fe42df9d0..0303928f9 100644 --- a/packages/@eventual/core-runtime/src/local/index.ts +++ b/packages/@eventual/core-runtime/src/local/index.ts @@ -3,3 +3,4 @@ export * from "./local-container.js"; export * from "./local-environment.js"; export * from "./stores/index.js"; export * from "./time-controller.js"; +export * from "./web-socket-container.js"; diff --git a/packages/@eventual/core-runtime/src/local/local-container.ts b/packages/@eventual/core-runtime/src/local/local-container.ts index 545e134d5..5d72db98d 100644 --- a/packages/@eventual/core-runtime/src/local/local-container.ts +++ b/packages/@eventual/core-runtime/src/local/local-container.ts @@ -32,6 +32,10 @@ import { QueueHandlerWorker, createQueueHandlerWorker, } from "../handlers/queue-handler-worker.js"; +import { + createSocketWorker, + type SocketWorker, +} from "../handlers/socket-worker.js"; import { SubscriptionWorker, createSubscriptionWorker, @@ -81,13 +85,13 @@ import { createUpdateTaskCommand, } from "../system-commands.js"; import type { WorkflowTask } from "../tasks.js"; -import { createDefaultWorkflowCallExecutor } from "../workflow/call-executor.js"; import { LocalEventClient } from "./clients/event-client.js"; import { LocalExecutionQueueClient } from "./clients/execution-queue-client.js"; import { LocalLogsClient } from "./clients/logs-client.js"; import { LocalMetricsClient } from "./clients/metrics-client.js"; import { LocalOpenSearchClient } from "./clients/open-search-client.js"; import { LocalQueueClient } from "./clients/queue-client.js"; +import { LocalSocketClient } from "./clients/socket-client.js"; import { LocalTaskClient } from "./clients/task-client.js"; import { LocalTimerClient } from "./clients/timer-client.js"; import { LocalTransactionClient } from "./clients/transaction-client.js"; @@ -97,6 +101,7 @@ import { LocalExecutionHistoryStateStore } from "./stores/execution-history-stat import { LocalExecutionHistoryStore } from "./stores/execution-history-store.js"; import { LocalExecutionStore } from "./stores/execution-store.js"; import { LocalTaskStore } from "./stores/task-store.js"; +import type { WebSocketContainer } from "./web-socket-container.js"; export type LocalEvent = | WorkflowTask @@ -150,6 +155,7 @@ export interface LocalContainerProps { serviceName: string; serviceUrl: string; subscriptionProvider?: SubscriptionProvider; + webSocketContainer: WebSocketContainer; } export class LocalContainer { @@ -157,6 +163,7 @@ export class LocalContainer { public commandWorker: CommandWorker; public timerHandler: TimerHandler; public taskWorker: TaskWorker; + public socketWorker: SocketWorker; public bucketHandlerWorker: BucketNotificationHandlerWorker; public entityStreamWorker: EntityStreamWorker; public subscriptionWorker: SubscriptionWorker; @@ -169,6 +176,7 @@ export class LocalContainer { public metricsClient: MetricsClient; public queueClient: LocalQueueClient; public serviceClient: EventualServiceClient; + public socketClient: LocalSocketClient; public taskClient: TaskClient; public timerClient: TimerClient; public transactionClient: TransactionClient; @@ -239,12 +247,15 @@ export class LocalContainer { this.localConnector ); + this.socketClient = new LocalSocketClient(props.webSocketContainer); + this.transactionWorker = createTransactionWorker({ entityStore, entityProvider: this.entityProvider, eventClient: this.eventClient, executionQueueClient: this.executionQueueClient, serviceName: props.serviceName, + socketClient: this.socketClient, }); this.transactionClient = new LocalTransactionClient(this.transactionWorker); @@ -270,6 +281,7 @@ export class LocalContainer { serviceSpec: undefined, serviceName: props.serviceName, serviceUrl: props.serviceUrl, + socketClient: this.socketClient, }); this.subscriptionWorker = createSubscriptionWorker({ subscriptionProvider: this.subscriptionProvider, @@ -281,6 +293,7 @@ export class LocalContainer { serviceName: props.serviceName, serviceSpec: undefined, serviceUrl: props.serviceUrl, + socketClient: this.socketClient, }); this.taskWorker = createTaskWorker({ @@ -296,6 +309,7 @@ export class LocalContainer { serviceClient: this.serviceClient, serviceSpec: undefined, serviceUrl: props.serviceUrl, + socketClient: this.socketClient, taskProvider: this.taskProvider, taskStore: this.taskStore, timerClient: this.timerClient, @@ -310,31 +324,27 @@ export class LocalContainer { serviceName: props.serviceName, serviceSpec: undefined, serviceUrl: props.serviceUrl, + socketClient: this.socketClient, }); this.orchestrator = createOrchestrator({ bucketStore, - callExecutor: createDefaultWorkflowCallExecutor({ - bucketStore, - entityStore, - eventClient: this.eventClient, - executionQueueClient: this.executionQueueClient, - openSearchClient, - queueClient: this.queueClient, - taskClient: this.taskClient, - timerClient: this.timerClient, - transactionClient: this.transactionClient, - workflowClient: this.workflowClient, - }), - queueClient: this.queueClient, - workflowClient: this.workflowClient, - timerClient: this.timerClient, - serviceName: props.serviceName, + entityStore, + eventClient: this.eventClient, + executionQueueClient: this.executionQueueClient, executionHistoryStore: this.executionHistoryStore, executorProvider: new InMemoryExecutorProvider(), - workflowProvider: this.workflowProvider, - logAgent, metricsClient: this.metricsClient, + logAgent, + openSearchClient, + queueClient: this.queueClient, + serviceName: props.serviceName, + socketClient: this.socketClient, + taskClient: this.taskClient, + timerClient: this.timerClient, + transactionClient: this.transactionClient, + workflowClient: this.workflowClient, + workflowProvider: this.workflowProvider, }); this.queueHandlerWorker = createQueueHandlerWorker({ @@ -346,6 +356,31 @@ export class LocalContainer { serviceName: props.serviceName, serviceSpec: undefined, serviceUrl: props.serviceUrl, + socketClient: this.socketClient, + }); + + this.queueHandlerWorker = createQueueHandlerWorker({ + openSearchClient, + bucketStore, + entityStore, + queueClient: this.queueClient, + serviceClient: this.serviceClient, + serviceName: props.serviceName, + serviceSpec: undefined, + serviceUrl: props.serviceUrl, + socketClient: this.socketClient, + }); + + this.socketWorker = createSocketWorker({ + openSearchClient, + bucketStore, + entityStore, + queueClient: this.queueClient, + serviceClient: this.serviceClient, + serviceName: props.serviceName, + serviceSpec: undefined, + serviceUrl: props.serviceUrl, + socketClient: this.socketClient, }); /** @@ -379,14 +414,15 @@ export class LocalContainer { // must register commands before the command worker is loaded! this.commandWorker = createCommandWorker({ - entityStore, bucketStore, + entityStore, + openSearchClient, queueClient: this.queueClient, serviceClient: this.serviceClient, serviceName: props.serviceName, serviceUrl: props.serviceUrl, serviceSpec: undefined, - openSearchClient, + socketClient: this.socketClient, }); this.timerHandler = createTimerHandler({ diff --git a/packages/@eventual/core-runtime/src/local/local-environment.ts b/packages/@eventual/core-runtime/src/local/local-environment.ts index aca5f8122..a03693168 100644 --- a/packages/@eventual/core-runtime/src/local/local-environment.ts +++ b/packages/@eventual/core-runtime/src/local/local-environment.ts @@ -1,4 +1,12 @@ -import { EntityStreamItem, HttpRequest, HttpResponse } from "@eventual/core"; +import { + EntityStreamItem, + HttpRequest, + HttpResponse, + SocketConnectRequest, + SocketDisconnectRequest, + SocketMessageRequest, + SocketResponse, +} from "@eventual/core"; import { getEventualResources, type ServiceSpec, @@ -22,6 +30,7 @@ import { isLocalQueuePollEvent, } from "./local-container.js"; import { TimeController } from "./time-controller.js"; +import { WebSocketContainer } from "./web-socket-container.js"; export interface EnvironmentManifest { serviceName: string; @@ -35,7 +44,10 @@ export class LocalEnvironment { private running = false; private localContainer: LocalContainer; - constructor(private environmentManifest: EnvironmentManifest) { + constructor( + private environmentManifest: EnvironmentManifest, + webSocketContainer: WebSocketContainer + ) { this.timeController = new TimeController([], { increment: 1, start: new Date().getTime(), @@ -59,6 +71,7 @@ export class LocalEnvironment { this.localContainer = new LocalContainer(this.localConnector, { serviceName: environmentManifest.serviceName, serviceUrl: environmentManifest.serviceUrl, + webSocketContainer, }); this.start(); @@ -224,4 +237,14 @@ export class LocalEnvironment { }, }); } + + public async sendSocketRequest( + socketName: string, + request: + | SocketDisconnectRequest + | SocketMessageRequest + | SocketConnectRequest + ): Promise { + return this.localContainer.socketWorker(socketName, request); + } } diff --git a/packages/@eventual/core-runtime/src/local/web-socket-container.ts b/packages/@eventual/core-runtime/src/local/web-socket-container.ts new file mode 100644 index 000000000..465d4a47c --- /dev/null +++ b/packages/@eventual/core-runtime/src/local/web-socket-container.ts @@ -0,0 +1,7 @@ +import { SocketUrls } from "@eventual/core/internal"; + +export interface WebSocketContainer { + send(socketName: string, connectionId: string, input: string | Buffer): void; + disconnect(socketName: string, connectionId: string): void; + urls(socketName: string): SocketUrls; +} diff --git a/packages/@eventual/core-runtime/src/property-retrievers/socket-url-property-retriever.ts b/packages/@eventual/core-runtime/src/property-retrievers/socket-url-property-retriever.ts new file mode 100644 index 000000000..f0d6d914e --- /dev/null +++ b/packages/@eventual/core-runtime/src/property-retrievers/socket-url-property-retriever.ts @@ -0,0 +1,12 @@ +import type { SocketUrls, SocketUrlsProperty } from "@eventual/core/internal"; +import { SocketClient } from "../clients/socket-client.js"; +import type { PropertyResolver } from "../property-retriever.js"; + +export class SocketUrlPropertyRetriever + implements PropertyResolver +{ + constructor(private socketClient: SocketClient) {} + public getProperty(property: SocketUrlsProperty): SocketUrls { + return this.socketClient.socketUrls(property.socketName); + } +} diff --git a/packages/@eventual/core-runtime/src/transaction-executor.ts b/packages/@eventual/core-runtime/src/transaction-executor.ts index d400acf73..6a0252d3b 100644 --- a/packages/@eventual/core-runtime/src/transaction-executor.ts +++ b/packages/@eventual/core-runtime/src/transaction-executor.ts @@ -14,22 +14,26 @@ import { type TransactionFunction, } from "@eventual/core"; import { + Call, EventualPromise, EventualPromiseSymbol, - SignalTargetType, assertNever, - isEmitEventsCall, isEntityCall, isEntityOperationOfType, - isSendSignalCall, - type EmitEventsCall, type EntityOperation, - type SendSignalCall, } from "@eventual/core/internal"; -import { CallExecutor } from "./call-executor.js"; +import { + AllCallExecutor, + CallExecutor, + UnsupportedCallExecutor, +} from "./call-executor.js"; +import { EmitEventsCallExecutor } from "./call-executors/emit-events-call-executor.js"; +import { SendSignalCallExecutor } from "./call-executors/send-signal-call-executor.js"; +import { SocketCallExecutor } from "./call-executors/socket-call-executor.js"; import type { EventClient } from "./clients/event-client.js"; import type { ExecutionQueueClient } from "./clients/execution-queue-client.js"; import { enterEventualCallHookScope } from "./eventual-hook.js"; +import { SocketClient } from "./index.js"; import { type PropertyRetriever } from "./property-retriever.js"; import type { EntityProvider } from "./providers/entity-provider.js"; import { Result, isResolved } from "./result.js"; @@ -104,8 +108,7 @@ interface TransactionEntityState { export function createTransactionExecutor( entityStore: EntityStore, entityProvider: EntityProvider, - executionQueueClient: ExecutionQueueClient, - eventClient: EventClient, + callExecutor: AllCallExecutor, propertyRetriever: PropertyRetriever ): TransactionExecutor { return async function ( @@ -143,8 +146,8 @@ export function createTransactionExecutor( > { // a map of the keys of all mutable entity calls that have been made to the request const entityCalls = new Map>(); - // store all of the event and signal calls to execute after the transaction completes - const eventCalls: (EmitEventsCall | SendSignalCall)[] = []; + // store all of the calls to execute after the transaction completes + const eventCalls: Call[] = []; // a map of the keys of all get operations or mutation operations to check during the transaction. // also serves as a get cache when get is called multiple times on the same keys const retrievedEntities = new Map(); @@ -242,15 +245,12 @@ export function createTransactionExecutor( return assertNever(operation); }); } - } else if (isEmitEventsCall(eventual)) { - eventCalls.push(eventual); - return createResolvedEventualPromise(Result.resolved(undefined)); - } else if (isSendSignalCall(eventual)) { + } else if (!callExecutor.isUnsupported(eventual)) { eventCalls.push(eventual); return createResolvedEventualPromise(Result.resolved(undefined)); } throw new Error( - `Unsupported eventual call type. Only Entity requests, emit events, and send signals are supported.` + `Unsupported eventual call type. Only Entity requests, emit events, socket send message, and send signals are supported.` ); }, }; @@ -359,20 +359,7 @@ export function createTransactionExecutor( */ await Promise.allSettled( eventCalls.map(async (call) => { - if (isEmitEventsCall(call)) { - await eventClient.emitEvents(...call.events); - } else if (call) { - // shouldn't happen - if (call.target.type === SignalTargetType.ChildExecution) { - return; - } - await executionQueueClient.sendSignal({ - execution: call.target.executionId, - signal: call.signalId, - payload: call.payload, - id: call.id, - }); - } + await callExecutor.execute(call); }) ); @@ -418,3 +405,44 @@ export function createTransactionExecutor( } }; } + +export interface TransactionCallExecutorDependencies { + eventClient: EventClient; + executionQueueClient: ExecutionQueueClient; + socketClient: SocketClient; +} + +const unsupportedExecutor = new UnsupportedCallExecutor("Transaction Worker"); + +/** + * Calls that the transaction worker supports. + * + * The general rules is that, other than Entity calls, the transaction worker support calls that return Promise | void. + * This is because the calls will not be executed unless the transaction succeeds, thus cannot return values that impact the transaction. + * + * Entity calls are currently handled directly with the client. + */ +export function createTransactionCallExecutor( + deps: TransactionCallExecutorDependencies +) { + return new AllCallExecutor({ + AwaitTimerCall: unsupportedExecutor, + BucketCall: unsupportedExecutor, + ConditionCall: unsupportedExecutor, + EmitEventsCall: new EmitEventsCallExecutor(deps.eventClient), + SocketCall: new SocketCallExecutor(deps.socketClient), + // the transaction execution handles this itself + EntityCall: unsupportedExecutor, + ExpectSignalCall: unsupportedExecutor, + InvokeTransactionCall: unsupportedExecutor, + QueueCall: unsupportedExecutor, + SearchCall: unsupportedExecutor, + SendSignalCall: new SendSignalCallExecutor(deps.executionQueueClient), + SignalHandlerCall: unsupportedExecutor, + TaskCall: unsupportedExecutor, + TaskRequestCall: unsupportedExecutor, + ChildWorkflowCall: unsupportedExecutor, + GetExecutionCall: unsupportedExecutor, + StartWorkflowCall: unsupportedExecutor, + }); +} diff --git a/packages/@eventual/core-runtime/src/utils.ts b/packages/@eventual/core-runtime/src/utils.ts index 597b69656..7400e8d73 100644 --- a/packages/@eventual/core-runtime/src/utils.ts +++ b/packages/@eventual/core-runtime/src/utils.ts @@ -302,3 +302,40 @@ export function deepEqual(a: any, b: any): boolean { return true; } + +export function withMiddlewares( + middlewares: ((input: { + next: (context: Context) => Promise; + context: Context; + request: Request; + }) => + | Promise + | (Output & { context?: Context }))[], + handler: (request: Request, context: Context) => Promise +): (request: Request, context: Context) => Promise { + return async (request: Request, context: Context): Promise => { + const chain = middlewares.values(); + + return next(request, context); + + async function next(request: Request, context: Context): Promise { + let consumed = false; + const middleware = chain.next(); + if (middleware.done) { + return handler(request, context); + } else { + return middleware.value({ + request, + context, + next: async (context) => { + if (consumed) { + consumed = true; + throw new Error(`Middleware cannot call 'next' more than once`); + } + return next(request, context); + }, + }); + } + } + }; +} diff --git a/packages/@eventual/core-runtime/src/workflow/call-eventual-factory.ts b/packages/@eventual/core-runtime/src/workflow/call-eventual-factory.ts index c461d842a..4299a2651 100644 --- a/packages/@eventual/core-runtime/src/workflow/call-eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/workflow/call-eventual-factory.ts @@ -13,13 +13,14 @@ import { EmitEventsCallEventualFactory } from "./call-executors-and-factories/em import { EntityCallEventualFactory } from "./call-executors-and-factories/entity-call.js"; import { ExpectSignalFactory } from "./call-executors-and-factories/expect-signal-call.js"; import { SearchCallEventualFactory } from "./call-executors-and-factories/open-search-client-call.js"; +import { QueueCallEventualFactory } from "./call-executors-and-factories/queue-call.js"; import { SendSignalEventualFactory } from "./call-executors-and-factories/send-signal-call.js"; +import { SendSocketCallEventualFactory } from "./call-executors-and-factories/socket-call.js"; import { RegisterSignalHandlerCallFactory } from "./call-executors-and-factories/signal-handler-call.js"; import { TaskCallEventualFactory } from "./call-executors-and-factories/task-call.js"; import { TransactionCallEventualFactory } from "./call-executors-and-factories/transaction-call.js"; import { UnsupportedEventualFactory } from "./call-executors-and-factories/unsupported.js"; import type { EventualDefinition } from "./eventual-definition.js"; -import { QueueCallEventualFactory } from "./call-executors-and-factories/queue-call.js"; export interface ResolveEventualFunction { (seq: number, result: Result): void; @@ -80,6 +81,7 @@ export function createDefaultEventualFactory(): AllWorkflowEventualFactory { SearchCall: new SearchCallEventualFactory(), SendSignalCall: new SendSignalEventualFactory(), SignalHandlerCall: new RegisterSignalHandlerCallFactory(), + SocketCall: new SendSocketCallEventualFactory(), StartWorkflowCall: unsupportedFactory, TaskCall: new TaskCallEventualFactory(), TaskRequestCall: unsupportedFactory, // TODO: support task requests (succeed, fail, heartbeat) diff --git a/packages/@eventual/core-runtime/src/workflow/call-executor.ts b/packages/@eventual/core-runtime/src/workflow/call-executor.ts index e08d914c7..72ccb1db6 100644 --- a/packages/@eventual/core-runtime/src/workflow/call-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow/call-executor.ts @@ -10,6 +10,7 @@ import type { EventClient } from "../clients/event-client.js"; import type { ExecutionQueueClient } from "../clients/execution-queue-client.js"; import type { OpenSearchClient } from "../clients/open-search-client.js"; import type { QueueClient } from "../clients/queue-client.js"; +import type { SocketClient } from "../clients/socket-client.js"; import type { TaskClient } from "../clients/task-client.js"; import type { TimerClient } from "../clients/timer-client.js"; import type { TransactionClient } from "../clients/transaction-client.js"; @@ -24,18 +25,20 @@ import { NoOpWorkflowExecutor } from "./call-executors-and-factories/no-op-call- import { createSearchWorkflowQueueExecutor } from "./call-executors-and-factories/open-search-client-call.js"; import { createQueueCallWorkflowCallExecutor } from "./call-executors-and-factories/queue-call.js"; import { SendSignalWorkflowCallExecutor } from "./call-executors-and-factories/send-signal-call.js"; +import { createSocketWorkflowQueueExecutor } from "./call-executors-and-factories/socket-call.js"; import { SimpleWorkflowExecutorAdaptor } from "./call-executors-and-factories/simple-workflow-executor-adaptor.js"; import { TaskCallWorkflowExecutor } from "./call-executors-and-factories/task-call.js"; import { createTransactionWorkflowQueueExecutor } from "./call-executors-and-factories/transaction-call.js"; import { UnsupportedWorkflowCallExecutor } from "./call-executors-and-factories/unsupported.js"; -interface WorkflowCallExecutorDependencies { +export interface WorkflowCallExecutorDependencies { bucketStore: BucketStore; entityStore: EntityStore; eventClient: EventClient; openSearchClient?: OpenSearchClient; executionQueueClient: ExecutionQueueClient; queueClient: QueueClient; + socketClient: SocketClient; taskClient: TaskClient; timerClient: TimerClient; transactionClient: TransactionClient; @@ -62,6 +65,10 @@ export function createDefaultWorkflowCallExecutor( EmitEventsCall: new SimpleWorkflowExecutorAdaptor( new EmitEventsCallExecutor(deps.eventClient) ), + SocketCall: createSocketWorkflowQueueExecutor( + deps.socketClient, + deps.executionQueueClient + ), EntityCall: createEntityWorkflowQueueExecutor( deps.entityStore, deps.executionQueueClient diff --git a/packages/@eventual/core-runtime/src/workflow/call-executors-and-factories/socket-call.ts b/packages/@eventual/core-runtime/src/workflow/call-executors-and-factories/socket-call.ts new file mode 100644 index 000000000..4fe3664a5 --- /dev/null +++ b/packages/@eventual/core-runtime/src/workflow/call-executors-and-factories/socket-call.ts @@ -0,0 +1,103 @@ +import { EventualError } from "@eventual/core"; +import { + SocketRequestFailed, + SocketRequestSucceeded, + WorkflowCallHistoryType, + WorkflowEventType, + isSocketCallOperation, + type SocketCall, + type SocketMethod, + type SocketOperation, +} from "@eventual/core/internal"; +import { SocketCallExecutor } from "../../call-executors/socket-call-executor.js"; +import { ExecutionQueueClient } from "../../clients/execution-queue-client.js"; +import { SocketClient } from "../../clients/socket-client.js"; +import { Result, normalizeError } from "../../result.js"; +import type { EventualFactory } from "../call-eventual-factory.js"; +import { createEvent } from "../events.js"; +import { EventualDefinition, Trigger } from "../eventual-definition.js"; +import { WorkflowTaskQueueExecutorAdaptor } from "./task-queue-executor-adaptor.js"; + +export function createSocketWorkflowQueueExecutor( + socketClient: SocketClient, + queueClient: ExecutionQueueClient +) { + return new WorkflowTaskQueueExecutorAdaptor( + new SocketCallExecutor(socketClient), + queueClient, + async (call: SocketCall, result, { executionTime, seq }) => { + return createEvent( + { + type: WorkflowEventType.SocketRequestSucceeded, + operation: call.operation.operation, + result, + seq, + }, + executionTime + ); + }, + (call, err, { executionTime, seq }) => { + return createEvent( + { + type: WorkflowEventType.SocketRequestFailed, + operation: call.operation.operation, + seq, + ...normalizeError(err), + }, + executionTime + ); + } + ); +} + +export class SendSocketCallEventualFactory + implements EventualFactory +{ + public initializeEventual(call: SocketCall): EventualDefinition { + return { + triggers: [ + Trigger.onWorkflowEvent( + WorkflowEventType.SocketRequestSucceeded, + (event) => { + return Result.resolved(event.result); + } + ), + Trigger.onWorkflowEvent( + WorkflowEventType.SocketRequestFailed, + (event) => { + return Result.failed(new EventualError(event.error, event.message)); + } + ), + ], + createCallEvent: (seq) => { + if (isSocketCallOperation("send", call)) { + const [connectionId, input] = call.operation.params; + const [data, base64] = + input instanceof Buffer + ? [input.toString("base64"), true] + : [input, false]; + + return { + type: WorkflowCallHistoryType.SocketRequest, + seq, + operation: { + operation: "send", + connectionId, + input: data, + isBase64Encoded: base64, + socketName: call.operation.socketName, + }, + }; + } else { + return { + type: WorkflowCallHistoryType.SocketRequest, + seq, + operation: call.operation as SocketOperation< + Exclude + >, + }; + } + }, + }; + } +} diff --git a/packages/@eventual/core-runtime/src/workflow/property-retriever.ts b/packages/@eventual/core-runtime/src/workflow/property-retriever.ts new file mode 100644 index 000000000..f521c3ac3 --- /dev/null +++ b/packages/@eventual/core-runtime/src/workflow/property-retriever.ts @@ -0,0 +1,41 @@ +import { ServiceType } from "@eventual/core/internal"; +import { QueueClient } from "../clients/queue-client.js"; +import { SocketClient } from "../clients/socket-client.js"; +import { + AllPropertyRetriever, + UnsupportedPropertyRetriever, +} from "../property-retriever.js"; +import { BucketPhysicalNamePropertyRetriever } from "../property-retrievers/bucket-name-property-retriever.js"; +import { QueuePhysicalNamePropertyRetriever } from "../property-retrievers/queue-name-property-retriever.js"; +import { SocketUrlPropertyRetriever } from "../property-retrievers/socket-url-property-retriever.js"; +import { BucketStore } from "../stores/bucket-store.js"; + +const unsupportedProperty = new UnsupportedPropertyRetriever( + "Workflow Orchestrator" +); + +export interface WorkflowPropertyRetrieverDeps { + bucketStore: BucketStore; + queueClient: QueueClient; + socketClient: SocketClient; + serviceName: string; +} + +export function createDefaultWorkflowPropertyRetriever( + deps: WorkflowPropertyRetrieverDeps +): AllPropertyRetriever { + return new AllPropertyRetriever({ + BucketPhysicalName: new BucketPhysicalNamePropertyRetriever( + deps.bucketStore + ), + OpenSearchClient: unsupportedProperty, + QueuePhysicalName: new QueuePhysicalNamePropertyRetriever(deps.queueClient), + ServiceClient: unsupportedProperty, + ServiceName: deps.serviceName, + ServiceSpec: unsupportedProperty, + ServiceType: ServiceType.OrchestratorWorker, + ServiceUrl: unsupportedProperty, + SocketUrls: new SocketUrlPropertyRetriever(deps.socketClient), + TaskToken: unsupportedProperty, + }); +} diff --git a/packages/@eventual/core-runtime/test/transaction-executor.test.ts b/packages/@eventual/core-runtime/test/transaction-executor.test.ts index e8e9e2b78..729934a87 100644 --- a/packages/@eventual/core-runtime/test/transaction-executor.test.ts +++ b/packages/@eventual/core-runtime/test/transaction-executor.test.ts @@ -20,8 +20,10 @@ import { EntityStore } from "../src/stores/entity-store.js"; import { TransactionExecutor, TransactionResult, + createTransactionCallExecutor, createTransactionExecutor, } from "../src/transaction-executor.js"; +import { SocketClient } from "../src/clients/socket-client.js"; const entity = (() => { let n = 0; @@ -44,6 +46,10 @@ const mockExecutionQueueClient = { const mockEventClient = { emitEvents: jest.fn() as EventClient["emitEvents"], } satisfies Partial as unknown as EventClient; +const mockSocketClient = { + send: jest.fn() as SocketClient["send"], + socketUrls: jest.fn() as SocketClient["socketUrls"], +} satisfies Partial as unknown as SocketClient; let store: EntityStore; let executor: TransactionExecutor; @@ -64,8 +70,11 @@ beforeEach(() => { executor = createTransactionExecutor( store, entityProvider, - mockExecutionQueueClient, - mockEventClient, + createTransactionCallExecutor({ + eventClient: mockEventClient, + executionQueueClient: mockExecutionQueueClient, + socketClient: mockSocketClient, + }), propertyRetriever ); }); diff --git a/packages/@eventual/core/src/http/api.ts b/packages/@eventual/core/src/http/api.ts index 9dcd1d056..980f26a6b 100644 --- a/packages/@eventual/core/src/http/api.ts +++ b/packages/@eventual/core/src/http/api.ts @@ -57,7 +57,7 @@ function createRouter( createRouter([...(middlewares ?? []), middleware]); } else if (method === "command") { return (...args: any[]) => { - const [sourceLocation, name, options, handler] = + const { sourceLocation, name, options, handler } = parseCommandArgs(args); return (command as any)( sourceLocation, diff --git a/packages/@eventual/core/src/http/command.ts b/packages/@eventual/core/src/http/command.ts index 041cc4787..4779ad0b4 100644 --- a/packages/@eventual/core/src/http/command.ts +++ b/packages/@eventual/core/src/http/command.ts @@ -6,6 +6,7 @@ import { CommandSpec, isSourceLocation } from "../internal/service-spec.js"; import type { ServiceContext } from "../service.js"; import type { Middleware } from "./middleware.js"; import type { ParsePath } from "./path.js"; +import { parseArgs } from "../internal/util.js"; export interface CommandContext { service: ServiceContext; @@ -209,7 +210,7 @@ export function command< Output = void, Context extends CommandContext = CommandContext >(...args: any[]): Command { - const [sourceLocation, name, options, handler] = parseCommandArgs< + const { sourceLocation, name, options, handler } = parseCommandArgs< Name, Input, Output @@ -236,18 +237,12 @@ export function parseCommandArgs< Output = void, Context extends CommandContext = CommandContext >(args: any[]) { - return [ - // TODO: is this 4x scan too inefficient, or is the trade-off between simplicity and performance worth it here? - // i think it would be marginal looping over a small array multiple times but i could be wrong - args.find(isSourceLocation), - args.find((a): a is Name => typeof a === "string")!, - args.find((a) => typeof a === "object" && !isSourceLocation(a)) as - | CommandOptions - | undefined, - args.find((a) => typeof a === "function") as CommandHandler< - Input, - Output, - Context - >, - ] as const; + return parseArgs(args, { + sourceLocation: isSourceLocation, + name: (a: any): a is Name => typeof a === "string", + options: (a: any): a is CommandOptions => + typeof a === "object" && !isSourceLocation(a), + handler: (a: any): a is CommandHandler => + typeof a === "function", + }); } diff --git a/packages/@eventual/core/src/http/middleware.ts b/packages/@eventual/core/src/http/middleware.ts index 1aecebc15..16a48ee36 100644 --- a/packages/@eventual/core/src/http/middleware.ts +++ b/packages/@eventual/core/src/http/middleware.ts @@ -42,7 +42,7 @@ export type Middleware = ( export function middleware< PrevContext extends CommandContext = CommandContext, OutContext extends CommandContext = CommandContext ->(fn: (input: MiddlewareInput) => MiddlewareOutput) { +>(fn: Middleware) { return async (input: MiddlewareInput) => fn({ ...input, diff --git a/packages/@eventual/core/src/index.ts b/packages/@eventual/core/src/index.ts index becb899b5..e061dfa59 100644 --- a/packages/@eventual/core/src/index.ts +++ b/packages/@eventual/core/src/index.ts @@ -18,6 +18,7 @@ export * from "./secret.js"; export * from "./service-client.js"; export * from "./service.js"; export * from "./signals.js"; +export * from "./socket/index.js"; export * from "./subscription.js"; export * from "./task.js"; export * from "./transaction.js"; diff --git a/packages/@eventual/core/src/internal/calls.ts b/packages/@eventual/core/src/internal/calls.ts index 43d5697b3..65d06ddda 100644 --- a/packages/@eventual/core/src/internal/calls.ts +++ b/packages/@eventual/core/src/internal/calls.ts @@ -7,12 +7,17 @@ import type { } from "../entity/entity.js"; import type { EventEnvelope } from "../event.js"; import type { Execution, ExecutionHandle } from "../execution.js"; +import type { + FifoContentBasedDeduplication, + FifoQueue, + Queue, +} from "../queue.js"; import type { DurationSchedule, Schedule } from "../schedule.js"; import type { SearchIndex } from "../search/search-index.js"; +import { Socket } from "../socket/socket.js"; import type { Task } from "../task.js"; import type { Workflow, WorkflowExecutionOptions } from "../workflow.js"; import type { SignalTarget } from "./signal.js"; -import { FifoContentBasedDeduplication, FifoQueue, Queue } from "../queue.js"; export type Call = | AwaitTimerCall @@ -27,6 +32,7 @@ export type Call = | SignalHandlerCall | SearchCall | SendSignalCall + | SocketCall | StartWorkflowCall | TaskCall | TaskRequestCall @@ -42,13 +48,14 @@ export enum CallKind { ExpectSignalCall = 3, GetExecutionCall = 14, InvokeTransactionCall = 9, + QueueCall = 15, + SearchCall = 11, SendSignalCall = 6, SignalHandlerCall = 5, + SocketCall = 16, + StartWorkflowCall = 13, TaskCall = 0, TaskRequestCall = 12, - SearchCall = 11, - StartWorkflowCall = 13, - QueueCall = 15, } export const CallSymbol = /* @__PURE__ */ Symbol.for("eventual:EventualCall"); @@ -452,3 +459,32 @@ export interface InvokeTransactionCall input: Input; transactionName: string; } + +export type SocketMethod = Exclude< + { + [op in keyof Socket]: [Socket[op]] extends [Function] ? op : never; + }[keyof Socket], + undefined +>; + +export type SocketOperation = { + operation: Op; + socketName: string; + params: Parameters; +}; + +export function isSocketCall(a: any): a is SocketCall { + return isCallOfKind(CallKind.SocketCall, a); +} + +export interface SocketCall + extends CallBase { + operation: SocketOperation; +} + +export function isSocketCallOperation( + op: Op, + operation: SocketCall +): operation is SocketCall { + return operation.operation.operation === op; +} diff --git a/packages/@eventual/core/src/internal/properties.ts b/packages/@eventual/core/src/internal/properties.ts index be6a3c015..0bc06780a 100644 --- a/packages/@eventual/core/src/internal/properties.ts +++ b/packages/@eventual/core/src/internal/properties.ts @@ -12,6 +12,7 @@ export enum PropertyKind { ServiceSpec, ServiceType, ServiceUrl, + SocketUrls, TaskToken, } @@ -28,6 +29,7 @@ export type Property = | ServiceSpecProperty | ServiceTypeProperty | ServiceUrlProperty + | SocketUrlsProperty | TaskTokenProperty; export type PropertyType = E extends PropertyBase< @@ -67,6 +69,16 @@ export interface QueuePhysicalName queueName: string; } +export interface SocketUrls { + http: string; + wss: string; +} + +export interface SocketUrlsProperty + extends PropertyBase { + socketName: string; +} + export type OpenSearchClientProperty = PropertyBase< PropertyKind.OpenSearchClient, OpenSearchClient diff --git a/packages/@eventual/core/src/internal/resources.ts b/packages/@eventual/core/src/internal/resources.ts index 64c546283..d21a0a753 100644 --- a/packages/@eventual/core/src/internal/resources.ts +++ b/packages/@eventual/core/src/internal/resources.ts @@ -2,8 +2,9 @@ import type { Bucket } from "../bucket.js"; import type { Entity } from "../entity/entity.js"; import type { Event } from "../event.js"; import type { AnyCommand } from "../http/command.js"; -import { Queue } from "../queue.js"; +import type { Queue } from "../queue.js"; import type { SearchIndex } from "../search/search-index.js"; +import type { Socket } from "../socket/socket.js"; import type { Subscription } from "../subscription.js"; import type { Task } from "../task.js"; import type { Transaction } from "../transaction.js"; @@ -14,11 +15,12 @@ type Resource = | Bucket | Entity | Event + | Queue | SearchIndex + | Socket | Subscription | Task | Transaction - | Queue | Workflow; type ResourceKind = Resource["kind"]; diff --git a/packages/@eventual/core/src/internal/service-spec.ts b/packages/@eventual/core/src/internal/service-spec.ts index 51c30fd48..15e69cb76 100644 --- a/packages/@eventual/core/src/internal/service-spec.ts +++ b/packages/@eventual/core/src/internal/service-spec.ts @@ -29,6 +29,7 @@ export interface ServiceSpec { transactions: TransactionSpec[]; tasks: TaskSpec[]; commands: CommandSpec[]; + sockets: SocketSpec[]; /** * Open API 3 schema definitions for all known Events in this Service. */ @@ -326,3 +327,9 @@ export interface QueueSpec { name: Name; visibilityTimeout?: DurationSchedule; } + +export interface SocketSpec + extends FunctionRuntimeProps { + name: Name; + sourceLocation?: SourceLocation; +} diff --git a/packages/@eventual/core/src/internal/service-type.ts b/packages/@eventual/core/src/internal/service-type.ts index 1cb53d3c0..48cfb4866 100644 --- a/packages/@eventual/core/src/internal/service-type.ts +++ b/packages/@eventual/core/src/internal/service-type.ts @@ -6,6 +6,7 @@ export enum ServiceType { EntityStreamWorker = "EntityStreamWorker", OrchestratorWorker = "OrchestratorWorker", QueueHandlerWorker = "QueueHandlerWorker", + SocketWorker = "SocketWorker", Subscription = "Subscription", TaskWorker = "TaskWorker", TransactionWorker = "TransactionWorker", diff --git a/packages/@eventual/core/src/internal/util.ts b/packages/@eventual/core/src/internal/util.ts index a3571e916..56a441641 100644 --- a/packages/@eventual/core/src/internal/util.ts +++ b/packages/@eventual/core/src/internal/util.ts @@ -21,3 +21,18 @@ export function or a is any)[]>( export function encodeExecutionId(executionId: string) { return Buffer.from(executionId, "utf-8").toString("base64"); } + +export function parseArgs>( + args: any[], + predicates: Predicates +): Args { + return Object.fromEntries( + Object.entries(predicates).map( + ([name, predicate]) => [name, args.find(predicate)] as const + ) + ) as Args; +} + +export type Predicates> = { + [arg in keyof Args]: (value: any) => value is Args[arg]; +}; diff --git a/packages/@eventual/core/src/internal/workflow-events.ts b/packages/@eventual/core/src/internal/workflow-events.ts index 7e24c1a16..213c04e4a 100644 --- a/packages/@eventual/core/src/internal/workflow-events.ts +++ b/packages/@eventual/core/src/internal/workflow-events.ts @@ -8,6 +8,8 @@ import type { EntityOperation, QueueOperation, SearchOperation, + SocketMethod, + SocketOperation, } from "./calls.js"; import type { SignalTarget } from "./signal.js"; import { or } from "./util.js"; @@ -64,7 +66,11 @@ export enum WorkflowEventType { EntityRequestSucceeded = 53, QueueRequestSucceeded = 56, QueueRequestFailed = 64, + SearchRequestSucceeded = 62, + SearchRequestFailed = 63, SignalReceived = 24, + SocketRequestFailed = 65, + SocketRequestSucceeded = 66, TaskSucceeded = 46, TaskFailed = 57, TaskHeartbeatTimedOut = 58, @@ -77,8 +83,6 @@ export enum WorkflowEventType { WorkflowRunCompleted = 80, WorkflowRunStarted = 15, WorkflowTimedOut = 90, - SearchRequestSucceeded = 62, - SearchRequestFailed = 63, } export enum WorkflowCallHistoryType { @@ -89,6 +93,7 @@ export enum WorkflowCallHistoryType { EventsEmitted = 3, SearchRequest = 4, SignalSent = 5, + SocketRequest = 11, TaskScheduled = 7, TimerScheduled = 8, TransactionRequest = 9, @@ -115,6 +120,7 @@ export type WorkflowCallHistoryEvent = | EventsEmitted | QueueRequest | SignalSent + | SocketRequest | TaskScheduled | TimerScheduled | TransactionRequest; @@ -131,9 +137,11 @@ export type CompletionEvent = | EntityRequestSucceeded | QueueRequestSucceeded | QueueRequestFailed - | SignalReceived | SearchRequestSucceeded | SearchRequestFailed + | SignalReceived + | SocketRequestFailed + | SocketRequestSucceeded | TaskFailed | TaskHeartbeatTimedOut | TaskSucceeded @@ -157,7 +165,11 @@ export const isCompletionEvent = /* @__PURE__ */ or( isEntityRequestSucceeded, isQueueRequestFailed, isQueueRequestSucceeded, + isSearchRequestFailed, + isSearchRequestSucceeded, isSignalReceived, + isSocketRequestFailed, + isSocketRequestSucceeded, isTaskSucceeded, isTaskFailed, isTaskHeartbeatTimedOut, @@ -165,9 +177,7 @@ export const isCompletionEvent = /* @__PURE__ */ or( isTransactionRequestFailed, isTransactionRequestSucceeded, isWorkflowTimedOut, - isWorkflowRunStarted, - isSearchRequestFailed, - isSearchRequestSucceeded + isWorkflowRunStarted ); /** @@ -582,6 +592,52 @@ export function isEventsEmitted( return event.type === WorkflowCallHistoryType.EventsEmitted; } +export interface SocketRequest + extends CallEventBase { + operation: + | SocketOperation> + | { + operation: "send"; + socketName: string; + connectionId: string; + input: string; + isBase64Encoded: boolean; + }; +} + +export interface SocketRequestSucceeded + extends CallEventResultBase { + name?: string; + operation: SocketMethod; + result: any; +} + +export interface SocketRequestFailed + extends CallEventResultBase { + operation: SocketMethod; + name?: string; + error: string; + message: string; +} + +export function isSocketRequest( + event: WorkflowCallHistoryEvent +): event is SocketRequest { + return event.type === WorkflowCallHistoryType.SocketRequest; +} + +export function isSocketRequestSucceeded( + event: WorkflowEvent +): event is SocketRequestSucceeded { + return event.type === WorkflowEventType.SocketRequestSucceeded; +} + +export function isSocketRequestFailed( + event: WorkflowEvent +): event is SocketRequestFailed { + return event.type === WorkflowEventType.SocketRequestFailed; +} + export interface SearchRequest extends CallEventBase { operation: SearchOperation; diff --git a/packages/@eventual/core/src/socket/index.ts b/packages/@eventual/core/src/socket/index.ts new file mode 100644 index 000000000..675aafa64 --- /dev/null +++ b/packages/@eventual/core/src/socket/index.ts @@ -0,0 +1,2 @@ +export * from "./socket.js"; +export * from "./middleware.js"; diff --git a/packages/@eventual/core/src/socket/middleware.ts b/packages/@eventual/core/src/socket/middleware.ts new file mode 100644 index 000000000..e9f7fed7c --- /dev/null +++ b/packages/@eventual/core/src/socket/middleware.ts @@ -0,0 +1,117 @@ +import type { + SocketConnectRequest, + SocketContext, + SocketDisconnectRequest, + SocketHandlerContext, + SocketMessageRequest, + SocketRequest, + SocketResponse, +} from "./socket.js"; + +export interface SocketMiddlewareInput< + Request extends SocketRequest, + In extends SocketHandlerContext +> { + request: Request; + context: In; + // Middleware should maintain the base context form in the next context. + // The base context values can be modified/used. + next: ( + context: O + ) => Promise>; +} + +export type SocketMiddlewareOutput = + SocketResponse & { + // TODO: leaving this as undefined breaks type safety + context?: Context; + }; + +export type SocketMiddlewareFunction< + Request extends SocketRequest, + In extends SocketHandlerContext, + Out extends SocketHandlerContext +> = ( + input: SocketMiddlewareInput +) => Promise> | SocketMiddlewareOutput; + +export interface SocketMiddleware< + In extends SocketContext = any, + Out extends SocketContext = any +> { + connect?: SocketMiddlewareFunction< + SocketConnectRequest, + In["connect"], + Out["connect"] + >; + disconnect?: SocketMiddlewareFunction< + SocketDisconnectRequest, + In["disconnect"], + Out["disconnect"] + >; + message?: SocketMiddlewareFunction< + SocketMessageRequest, + In["message"], + Out["message"] + >; +} + +/** + * Utility for creating a Socket Middleware function that combines its output context + * with the input context. + * + * ```ts + * const auth = socketMiddleware(({request, context, next}) => { + * return next({ + * ...context, + * isAuthenticated: request.headers.Authorization !== undefined + * }) + * }); + * + * socket.use(auth)("myAuthorizedCommand", async (request, { isAuthenticated }) => { + * if (isAuthenticated) { + * // do work + * } + * }) + * ``` + */ +export function socketMiddleware< + PrevContext extends SocketContext = SocketContext, + OutContext extends SocketContext = SocketContext +>( + fn: + | SocketMiddlewareFunction< + SocketConnectRequest, + PrevContext["connect"], + OutContext["connect"] + > + | SocketMiddleware +) { + const { connect, disconnect, message } = + typeof fn === "function" + ? { connect: fn, disconnect: undefined, message: undefined } + : fn; + return { + connect: connect ? createNext(connect) : undefined, + disconnect: disconnect ? createNext(disconnect) : undefined, + message: message ? createNext(message) : undefined, + }; + + function createNext< + Request extends SocketRequest, + InContext extends SocketHandlerContext, + OutContext extends SocketHandlerContext + >(fn: SocketMiddlewareFunction) { + return async ( + input: SocketMiddlewareInput + ) => + fn({ + ...input, + next: (context) => + input.next({ + ...input.context, + ...context, + }), + }); + } +} diff --git a/packages/@eventual/core/src/socket/socket.ts b/packages/@eventual/core/src/socket/socket.ts new file mode 100644 index 000000000..8ea5dff09 --- /dev/null +++ b/packages/@eventual/core/src/socket/socket.ts @@ -0,0 +1,221 @@ +import type { FunctionRuntimeProps } from "../function-props.js"; +import { CallKind, createCall, type SocketCall } from "../internal/calls.js"; +import { + createEventualProperty, + PropertyKind, + type SocketUrlsProperty, +} from "../internal/properties.js"; +import { registerEventualResource } from "../internal/resources.js"; +import { isSourceLocation, type SocketSpec } from "../internal/service-spec.js"; +import { parseArgs } from "../internal/util.js"; +import type { ServiceContext } from "../service.js"; +import type { + SocketMiddleware, + SocketMiddlewareFunction, +} from "./middleware.js"; + +export interface SocketContext< + ConnectContext extends SocketHandlerContext = SocketHandlerContext, + DisconnectContext extends SocketHandlerContext = SocketHandlerContext, + MessageContext extends SocketHandlerContext = SocketHandlerContext +> { + connect: ConnectContext; + disconnect: DisconnectContext; + message: MessageContext; +} + +export interface SocketHandlerContext { + socket: { socketName: string }; + service: ServiceContext; +} + +export type SocketHeaders = Record; +export type SocketQuery = Record; +export type SocketRequest = + | SocketConnectRequest + | SocketDisconnectRequest + | SocketMessageRequest; + +export interface SocketConnectRequest { + type: "connect"; + connectionId: string; + query?: SocketQuery; + headers: SocketHeaders; +} + +export interface SocketDisconnectRequest { + type: "disconnect"; + connectionId: string; +} + +export interface SocketMessageRequest { + type: "message"; + connectionId: string; + body?: string | Buffer; +} + +export interface SocketResponse { + status: number; + message?: string | Buffer | any; +} + +export type SocketHandlers = { + $connect: ( + request: SocketConnectRequest, + context: Context["connect"] + ) => Promise | SocketResponse | void; + $disconnect: ( + request: SocketDisconnectRequest, + context: Context["disconnect"] + ) => Promise | SocketResponse | void; + $default: ( + request: SocketMessageRequest, + context: Context["message"] + ) => + | Promise + | SocketResponse + | string + | Buffer + | any + | void; +}; + +export type Socket< + Name extends string = string, + Context extends SocketContext = SocketContext +> = SocketSpec & { + kind: "Socket"; + handlers: SocketHandlers; + wssEndpoint: string; + httpEndpoint: string; + middlewares: SocketMiddleware[]; +} & { + send: (connectionId: string, input: Buffer | string) => Promise; + disconnect: (connectionId: string) => Promise; +}; + +export type SocketOptions = FunctionRuntimeProps; + +export interface socket { + middlewares: SocketMiddleware[]; + use< + NextConnectContext extends SocketHandlerContext = Context["connect"], + NextDisconnectContext extends SocketHandlerContext = Context["disconnect"], + NextMessageContext extends SocketHandlerContext = Context["message"] + >( + socketMiddleware: + | SocketMiddleware< + Context, + SocketContext< + NextConnectContext, + NextDisconnectContext, + NextMessageContext + > + > + | SocketMiddlewareFunction< + SocketConnectRequest, + Context["connect"], + NextConnectContext + > + ): socket< + SocketContext + >; + ( + name: Name, + options: SocketOptions, + handlers: SocketHandlers + ): Socket; + (name: Name, handlers: SocketHandlers): Socket< + Name, + Context + >; +} + +function createSocketBuilder( + middlewares: SocketMiddleware[] +): socket { + const socketFunction = ( + ...args: + | [name: Name, options: SocketOptions, handlers: SocketHandlers] + | [name: Name, handlers: SocketHandlers] + ): Socket => { + const { sourceLocation, name, options, handlers } = + parseSocketArgs(args); + const socket = { + middlewares, + name, + handlers, + sourceLocation, + kind: "Socket", + handlerTimeout: options?.handlerTimeout, + memorySize: options?.memorySize, + get wssEndpoint() { + return getEventualHook().getEventualProperty( + createEventualProperty(PropertyKind.SocketUrls, { socketName: name }) + ).wss; + }, + get httpEndpoint() { + return getEventualHook().getEventualProperty( + createEventualProperty(PropertyKind.SocketUrls, { socketName: name }) + ).http; + }, + send(...params) { + return getEventualHook().executeEventualCall( + createCall(CallKind.SocketCall, { + operation: { + operation: "send", + socketName: name, + params, + }, + }) + ); + }, + disconnect(...params) { + return getEventualHook().executeEventualCall( + createCall(CallKind.SocketCall, { + operation: { + operation: "disconnect", + socketName: name, + params, + }, + }) + ); + }, + } as Socket; + + return registerEventualResource("Socket", socket as any) as Socket; + }; + const useFunction: socket["use"] = < + NextContext extends SocketContext = Context + >( + socketMiddleware: + | SocketMiddleware + | SocketMiddlewareFunction< + SocketConnectRequest, + Context["connect"], + NextContext["connect"] + > + ) => { + const middleware: SocketMiddleware = + typeof socketMiddleware === "function" + ? { connect: socketMiddleware } + : socketMiddleware; + return createSocketBuilder([...middlewares, middleware]); + }; + (socketFunction as unknown as socket).use = useFunction; + + return socketFunction as unknown as socket; +} + +export const socket = createSocketBuilder([]); + +export function parseSocketArgs(args: any[]) { + return parseArgs(args, { + sourceLocation: isSourceLocation, + name: (a: any): a is Name => typeof a === "string", + options: (a: any): a is SocketOptions => + typeof a === "object" && !isSourceLocation(a) && !("$connect" in a), + handlers: (a: any): a is SocketHandlers => + typeof a === "object" && !isSourceLocation(a) && "$connect" in a, + }); +} diff --git a/packages/@eventual/testing/src/environment.ts b/packages/@eventual/testing/src/environment.ts index 75fdd1845..bfd28a639 100644 --- a/packages/@eventual/testing/src/environment.ts +++ b/packages/@eventual/testing/src/environment.ts @@ -42,6 +42,7 @@ import { import { ulid } from "ulidx"; import { TestSubscriptionProvider } from "./providers/subscription-provider.js"; import { MockTask, MockableTaskProvider } from "./providers/task-provider.js"; +import { TestWebSocketContainer } from "./web-socket-container.js"; export interface TestEnvironmentProps { /** @@ -116,6 +117,7 @@ export class TestEnvironment extends RuntimeServiceClient { serviceUrl, taskProvider, subscriptionProvider, + webSocketContainer: new TestWebSocketContainer(), }); super({ diff --git a/packages/@eventual/testing/src/web-socket-container.ts b/packages/@eventual/testing/src/web-socket-container.ts new file mode 100644 index 000000000..5b16193a5 --- /dev/null +++ b/packages/@eventual/testing/src/web-socket-container.ts @@ -0,0 +1,21 @@ +import { WebSocketContainer } from "@eventual/core-runtime"; +import { SocketUrls } from "@eventual/core/internal"; + +// TOOD: support web sockets in the test env +export class TestWebSocketContainer implements WebSocketContainer { + public urls(_socketName: string): SocketUrls { + throw new Error("Sockets are not supported in the Test Env."); + } + + public send( + _socketName: string, + _connectionId: string, + _input: string | Buffer + ): void { + throw new Error("Sockets are not supported in the Test Env."); + } + + public disconnect(_socketName: string, _connectionId: string): void { + throw new Error("Sockets are not supported in the Test Env."); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d4d03979..89976b29a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: '@eventual/core': specifier: workspace:^ version: link:../../../packages/@eventual/core + ws: + specifier: ^8.14.1 + version: 8.14.1 zod: specifier: ^3.21.4 version: 3.21.4 @@ -270,6 +273,9 @@ importers: '@types/node': specifier: ^18 version: 18.0.0 + '@types/ws': + specifier: ^8.5.5 + version: 8.5.5 aws-cdk: specifier: ^2.80.0 version: 2.80.0 @@ -465,6 +471,9 @@ importers: packages/@eventual/aws-runtime: dependencies: + '@aws-sdk/client-apigatewaymanagementapi': + specifier: ^3.341.0 + version: 3.341.0 '@aws-sdk/client-cloudwatch-logs': specifier: ^3.341.0 version: 3.341.0 @@ -625,9 +634,15 @@ importers: ts-node: specifier: ^10.9.1 version: 10.9.1(@swc/core@1.3.19)(@types/node@18.0.0)(typescript@5.0.4) + uuid: + specifier: ^9.0.1 + version: 9.0.1 vite: specifier: ^3.2.3 version: 3.2.3(@types/node@18.0.0) + ws: + specifier: ^8.14.1 + version: 8.14.1 yargs: specifier: ^17.6.2 version: 17.6.2 @@ -653,6 +668,12 @@ importers: '@types/serve-static': specifier: ^1.15.1 version: 1.15.1 + '@types/uuid': + specifier: ^9.0.4 + version: 9.0.4 + '@types/ws': + specifier: ^8.5.5 + version: 8.5.5 '@types/yargs': specifier: ^17.0.24 version: 17.0.24 @@ -1245,6 +1266,50 @@ packages: tslib: 2.6.2 dev: false + /@aws-sdk/client-apigatewaymanagementapi@3.341.0: + resolution: {integrity: sha512-vvpotbfq1HzRaB6lnkLfPqED6hE/hKfGOJ5xL3pYbBWQ6ksHVFv7ANcb1jpXgscxO5e1TbuJWxT2WX7UBw+24Q==} + engines: {node: '>=14.0.0'} + dependencies: + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/client-sts': 3.341.0 + '@aws-sdk/config-resolver': 3.341.0 + '@aws-sdk/credential-provider-node': 3.341.0 + '@aws-sdk/fetch-http-handler': 3.341.0 + '@aws-sdk/hash-node': 3.341.0 + '@aws-sdk/invalid-dependency': 3.341.0 + '@aws-sdk/middleware-content-length': 3.341.0 + '@aws-sdk/middleware-endpoint': 3.341.0 + '@aws-sdk/middleware-host-header': 3.341.0 + '@aws-sdk/middleware-logger': 3.341.0 + '@aws-sdk/middleware-recursion-detection': 3.341.0 + '@aws-sdk/middleware-retry': 3.341.0 + '@aws-sdk/middleware-serde': 3.341.0 + '@aws-sdk/middleware-signing': 3.341.0 + '@aws-sdk/middleware-stack': 3.341.0 + '@aws-sdk/middleware-user-agent': 3.341.0 + '@aws-sdk/node-config-provider': 3.341.0 + '@aws-sdk/node-http-handler': 3.341.0 + '@aws-sdk/smithy-client': 3.341.0 + '@aws-sdk/types': 3.341.0 + '@aws-sdk/url-parser': 3.341.0 + '@aws-sdk/util-base64': 3.310.0 + '@aws-sdk/util-body-length-browser': 3.310.0 + '@aws-sdk/util-body-length-node': 3.310.0 + '@aws-sdk/util-defaults-mode-browser': 3.341.0 + '@aws-sdk/util-defaults-mode-node': 3.341.0 + '@aws-sdk/util-endpoints': 3.341.0 + '@aws-sdk/util-retry': 3.341.0 + '@aws-sdk/util-user-agent-browser': 3.341.0 + '@aws-sdk/util-user-agent-node': 3.341.0 + '@aws-sdk/util-utf8': 3.310.0 + '@smithy/protocol-http': 1.0.1 + '@smithy/types': 1.2.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + dev: false + /@aws-sdk/client-cloudwatch-logs@3.341.0: resolution: {integrity: sha512-Bw8ltfwFWOLZGDrkXM9kV1RpDjVeAsDEHapwTUos67Qm06DmhnRkS1/MLglIhsM9KNJoyCtEUIGsr38S5E65IQ==} engines: {node: '>=14.0.0'} @@ -2095,7 +2160,7 @@ packages: '@aws-sdk/property-provider': 3.341.0 '@aws-sdk/shared-ini-file-loader': 3.341.0 '@aws-sdk/types': 3.341.0 - tslib: 2.5.2 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt @@ -4233,14 +4298,14 @@ packages: '@jest/test-result': 29.5.0 '@jest/transform': 29.5.0 '@jest/types': 29.5.0 - '@types/node': 18.0.0 + '@types/node': 18.11.8 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.8.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.5.0 - jest-config: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) + jest-config: 29.5.0(@types/node@18.11.8)(ts-node@10.9.1) jest-haste-map: 29.5.0 jest-message-util: 29.5.0 jest-regex-util: 29.4.3 @@ -4433,7 +4498,7 @@ packages: '@jest/schemas': 29.4.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 18.0.0 + '@types/node': 18.11.8 '@types/yargs': 17.0.24 chalk: 4.1.2 dev: true @@ -5372,7 +5437,7 @@ packages: resolution: {integrity: sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==} engines: {node: '>=14.0.0'} dependencies: - '@smithy/types': 1.0.0 + '@smithy/types': 1.2.0 tslib: 2.6.2 /@smithy/types@1.0.0: @@ -5812,12 +5877,22 @@ packages: /@types/tsscmp@1.0.0: resolution: {integrity: sha512-rj18XR6c4Ohds86Lq8MI1NMRrXes4eLo4H06e5bJyKucE1rXGsfBBbFGD2oDC+DSufQCpnU3TTW7QAiwLx+7Yw==} + /@types/uuid@9.0.4: + resolution: {integrity: sha512-zAuJWQflfx6dYJM62vna+Sn5aeSWhh3OB+wfUEACNcqUSc0AGc5JKl+ycL1vrH7frGTXhJchYjE1Hak8L819dA==} + dev: true + /@types/ws@7.4.7: resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} dependencies: '@types/node': 18.11.8 dev: false + /@types/ws@8.5.5: + resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} + dependencies: + '@types/node': 18.11.8 + dev: true + /@types/yargs-parser@21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -10218,6 +10293,46 @@ packages: - supports-color dev: true + /jest-config@29.5.0(@types/node@18.11.8)(ts-node@10.9.1): + resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.22.1 + '@jest/test-sequencer': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.11.8 + babel-jest: 29.5.0(@babel/core@7.22.1) + chalk: 4.1.2 + ci-info: 3.8.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.5.0 + jest-environment-node: 29.5.0 + jest-get-type: 29.4.3 + jest-regex-util: 29.4.3 + jest-resolve: 29.5.0 + jest-runner: 29.5.0 + jest-util: 29.5.0 + jest-validate: 29.5.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.5.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + ts-node: 10.9.1(@swc/core@1.3.19)(@types/node@18.0.0)(typescript@5.0.4) + transitivePeerDependencies: + - supports-color + dev: true + /jest-diff@29.5.0: resolution: {integrity: sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -10461,7 +10576,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.5.0 - '@types/node': 18.0.0 + '@types/node': 18.11.8 chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.11 @@ -14278,6 +14393,11 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -14663,6 +14783,19 @@ packages: optional: true dev: true + /ws@8.14.1: + resolution: {integrity: sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xml2js@0.4.19: resolution: {integrity: sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==} dependencies: