diff --git a/.changeset/add-event-bus-block.md b/.changeset/add-event-bus-block.md new file mode 100644 index 00000000..f782bff6 --- /dev/null +++ b/.changeset/add-event-bus-block.md @@ -0,0 +1,11 @@ +--- +"@aws-blocks/bb-event-bus": patch +"@aws-blocks/blocks": patch +"@aws-blocks/core": patch +--- + +feat(bb-event-bus): add EventBridge-backed pub/sub event bus block + +New `EventBus` block for server-to-server publish/subscribe with fan-out: `publish(type, detail)` emits to a dedicated Amazon EventBridge bus, and each `on(type, handler)` (or `'*'`) provisions a rule targeting the shared Lambda, routed to the right handler via a deterministic subscription id shared between the CDK and runtime layers. Typed via `EventBus`, with an optional per-subscription Standard Schema for validation. Fills the gap between `AsyncJob` (1→1, SQS) and `Realtime` (server→client). + +`@aws-blocks/blocks` re-exports the new block (`SubscribeOptions` re-exported as `EventSubscribeOptions` to avoid the `Realtime` clash) and adds it to the vendorize map. `@aws-blocks/core` regenerates `OFFICIAL_BB_NAMES` to include `EventBus`. diff --git a/README.md b/README.md index d3eff304..669dcd49 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ A Block is a module that gives you a complete feature: cloud resources, a runtim | Authentication | `AuthBasic`, `AuthCognito`, `AuthOIDC` | | Compute & background | `AsyncJob`, `CronJob` | | AI | `Agent`, `KnowledgeBase` | -| Communication | `Realtime`, `EmailClient` | +| Communication | `Realtime`, `EventBus`, `EmailClient` | | Configuration | `AppSetting` | | Observability | `Logger`, `Metrics`, `Tracer`, `Dashboard` | | Hosting | `Hosting` | diff --git a/package-lock.json b/package-lock.json index e12cfce6..02d05e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "packages/bb-auth-oidc", "packages/bb-realtime", "packages/bb-async-job", + "packages/bb-event-bus", "packages/bb-dashboard", "packages/bb-cron-job", "packages/bb-file-bucket", @@ -364,7 +365,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -381,7 +381,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -398,7 +397,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -415,7 +413,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -432,7 +429,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -449,7 +445,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -466,7 +461,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -483,7 +477,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -500,7 +493,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -20694,6 +20686,10 @@ "resolved": "packages/bb-email-client", "link": true }, + "node_modules/@aws-blocks/bb-event-bus": { + "resolved": "packages/bb-event-bus", + "link": true + }, "node_modules/@aws-blocks/bb-file-bucket": { "resolved": "packages/bb-file-bucket", "link": true @@ -22321,6 +22317,28 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-eventbridge": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-eventbridge/-/client-eventbridge-3.1073.0.tgz", + "integrity": "sha512-h37GDGdaWifY7MxGDdgN9byhMZ6qqwxZ8KuE6/l/NYeFYv7LsXXa3FJp/EruiH+U78EQdOFYbfyO/mMxCZJewQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-node": "^3.972.57", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-firehose": { "version": "3.1056.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-firehose/-/client-firehose-3.1056.0.tgz", @@ -22840,13 +22858,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", - "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.12", - "@aws-sdk/xml-builder": "^3.972.29", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", @@ -22889,13 +22907,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", - "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", + "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -22905,13 +22923,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.48", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", - "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", + "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -22923,20 +22941,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.53", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", - "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-login": "^3.972.52", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", + "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-login": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -22947,14 +22965,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", - "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", + "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -22964,18 +22982,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", - "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-ini": "^3.972.53", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/types": "^3.973.12", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", + "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-ini": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -22986,13 +23004,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", - "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", + "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -23002,15 +23020,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", - "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", + "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/token-providers": "3.1066.0", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -23020,14 +23038,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1066.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", - "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", + "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -23037,14 +23055,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", - "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", + "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -23476,16 +23494,16 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", - "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", + "version": "3.997.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", + "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.34", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -23527,12 +23545,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", - "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -23559,9 +23577,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", - "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -23650,9 +23668,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", - "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", + "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -24595,7 +24613,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24612,7 +24629,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24629,7 +24645,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24646,7 +24661,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24663,7 +24677,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24680,7 +24693,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24697,7 +24709,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24714,7 +24725,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -29026,7 +29036,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -29043,7 +29052,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -29060,7 +29068,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -29077,7 +29084,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -29094,7 +29100,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -29111,7 +29116,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -29128,7 +29132,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29145,7 +29148,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29162,7 +29164,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29179,7 +29180,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29196,7 +29196,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29213,7 +29212,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29230,7 +29228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29247,7 +29244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29264,7 +29260,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29281,7 +29276,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29298,7 +29292,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -29315,7 +29308,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29332,7 +29324,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29349,7 +29340,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29366,7 +29356,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -29383,7 +29372,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -29400,7 +29388,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -29417,7 +29404,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -29434,7 +29420,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -46813,7 +46798,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -50952,7 +50936,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -50969,7 +50952,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -50986,7 +50968,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51003,7 +50984,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51020,7 +51000,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51037,7 +51016,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51054,7 +51032,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51071,7 +51048,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51088,7 +51064,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51105,7 +51080,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51122,7 +51096,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51139,7 +51112,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51156,7 +51128,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51173,7 +51144,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51190,7 +51160,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51207,7 +51176,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51224,7 +51192,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51241,7 +51208,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51258,7 +51224,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51275,7 +51240,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51292,7 +51256,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51309,7 +51272,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51326,7 +51288,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51343,7 +51304,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51360,7 +51320,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51377,7 +51336,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -55621,6 +55579,25 @@ "constructs": "^10.6.0" } }, + "packages/bb-event-bus": { + "name": "@aws-blocks/bb-event-bus", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-blocks/bb-logger": "^0.1.1", + "@aws-blocks/core": "^0.1.1", + "@aws-sdk/client-eventbridge": "^3.0.0" + }, + "devDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "peerDependencies": { + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.0.0" + } + }, "packages/bb-file-bucket": { "name": "@aws-blocks/bb-file-bucket", "version": "0.1.1", @@ -55785,6 +55762,7 @@ "@aws-blocks/bb-distributed-data": "^0.1.1", "@aws-blocks/bb-distributed-table": "^0.1.1", "@aws-blocks/bb-email-client": "^0.1.1", + "@aws-blocks/bb-event-bus": "^0.1.0", "@aws-blocks/bb-file-bucket": "^0.1.1", "@aws-blocks/bb-knowledge-base": "^0.1.1", "@aws-blocks/bb-kv-store": "^0.1.1", diff --git a/package.json b/package.json index 4521e015..c202c64e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "packages/bb-auth-oidc", "packages/bb-realtime", "packages/bb-async-job", + "packages/bb-event-bus", "packages/bb-dashboard", "packages/bb-cron-job", "packages/bb-file-bucket", @@ -61,7 +62,7 @@ "prepare": "husky && bash scripts/setup-git-secrets.sh", "clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo test-apps/*/dist test-apps/*/.next test-apps/*/*.tsbuildinfo", "build": "npm run build --workspaces --if-present", - "build:packages": "npm run build --if-present -w packages/hosting -w packages/pipeline -w packages/core -w packages/bb-kv-store -w packages/bb-distributed-table -w packages/auth-common -w packages/data-common -w packages/bb-data -w packages/bb-distributed-data -w packages/bb-app-setting -w packages/bb-auth-basic -w packages/bb-auth-cognito -w packages/bb-auth-oidc -w packages/bb-realtime -w packages/bb-async-job -w packages/bb-cron-job -w packages/bb-file-bucket -w packages/bb-agent -w packages/bb-knowledge-base -w packages/bb-logger -w packages/bb-email-client -w packages/bb-tracer -w packages/bb-metrics -w packages/bb-dashboard -w packages/blocks -w packages/create-blocks-app", + "build:packages": "npm run build --if-present -w packages/hosting -w packages/pipeline -w packages/core -w packages/bb-kv-store -w packages/bb-distributed-table -w packages/auth-common -w packages/data-common -w packages/bb-data -w packages/bb-distributed-data -w packages/bb-app-setting -w packages/bb-auth-basic -w packages/bb-auth-cognito -w packages/bb-auth-oidc -w packages/bb-realtime -w packages/bb-async-job -w packages/bb-event-bus -w packages/bb-cron-job -w packages/bb-file-bucket -w packages/bb-agent -w packages/bb-knowledge-base -w packages/bb-logger -w packages/bb-email-client -w packages/bb-tracer -w packages/bb-metrics -w packages/bb-dashboard -w packages/blocks -w packages/create-blocks-app", "build:force": "npm run clean && npm run build", "dev": "tsc --build --watch", "nuke": "rm -rf node_modules packages/*/node_modules test-apps/*/node_modules packages/*/dist test-apps/*/dist test-apps/*/.next packages/*/*.tsbuildinfo test-apps/*/*.tsbuildinfo", diff --git a/packages/bb-event-bus/LICENSE b/packages/bb-event-bus/LICENSE new file mode 100644 index 00000000..dd5b3a58 --- /dev/null +++ b/packages/bb-event-bus/LICENSE @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/packages/bb-event-bus/README.md b/packages/bb-event-bus/README.md new file mode 100644 index 00000000..f183dcd5 --- /dev/null +++ b/packages/bb-event-bus/README.md @@ -0,0 +1,192 @@ +# @aws-blocks/bb-event-bus + +Server-to-server publish/subscribe event bus backed by Amazon EventBridge. + +Publish a typed event once and the bus fans it out to every subscriber — decoupling the code that produces an event from the (possibly many) reactions to it. + +## Quick Reference + +**Common Operations → API** + +| What you want | How | +|---------------|-----| +| Subscribe a handler | `bus.on('order.placed', async (detail, ctx) => { ... })` | +| Subscribe to every event | `bus.on('*', async (detail, ctx) => { ... })` | +| Publish an event | `await bus.publish('order.placed', { id })` | +| Validate delivered events | `bus.on('order.placed', handler, { schema })` | +| Make events type-safe | `new EventBus(scope, 'events')` | + +**Keywords:** events, pub/sub, publish, subscribe, fan-out, EventBridge, event-driven, decouple, message bus, domain events + +## Quick Start + +```typescript +import { Scope } from '@aws-blocks/core'; +import { EventBus } from '@aws-blocks/bb-event-bus'; + +const scope = new Scope('my-app'); + +const bus = new EventBus(scope, 'events'); + +// One event, many independent reactions: +bus.on('order.placed', async (detail: { id: string }) => { + await chargeCard(detail.id); +}); +bus.on('order.placed', async (detail: { id: string }) => { + await sendReceipt(detail.id); +}); + +// Producer doesn't know or care who listens: +await bus.publish('order.placed', { id: 'o_123' }); +``` + +## When to Use + +- **Decouple producers from consumers.** A producer emits `user.signed-up`; an + arbitrary set of features (welcome email, analytics, provisioning) react + without the producer importing any of them. +- **Fan-out.** One event needs to trigger several independent side effects. +- **Event-driven architecture.** Model your domain as a stream of past-tense + facts (`invoice.paid`, `file.uploaded`) that other parts of the system observe. + +## When NOT to Use + +- **Single-consumer work queue** with retries and a dead-letter queue → use + [`AsyncJob`](../bb-async-job). +- **Scheduled / recurring work** → use [`CronJob`](../bb-cron-job). +- **Pushing messages to connected browser clients** → use + [`Realtime`](../bb-realtime). (A common pattern: a subscriber on the bus calls + `realtime.publish(...)` to forward an event to the UI.) + +## API + +### `new EventBus(scope, id, options?)` + +| Option | Type | Description | +|--------|------|-------------| +| `logger` | `ChildLogger` | Optional logger for internal operations. | + +`TEvents` is an optional [event map](#type-safety) for end-to-end type safety. + +### `bus.on(type, handler, options?)` + +Subscribe `handler` to `type`. Pass `'*'` to receive every event on the bus. +Returns `this`, so calls chain. The handler receives `(detail, context)`: + +| Context field | Description | +|---------------|-------------| +| `eventId` | Unique id for the delivered event. | +| `type` | The published event type. | +| `source` | The publishing bus's fully-qualified id. | +| `publishedAt` | ISO 8601 publish timestamp. | + +`options.schema` — an optional [Standard Schema](https://standardschema.dev) +(Zod, Valibot, ArkType, …) validating each delivered detail before the handler +runs. On failure the event is dropped and the error logged. + +### `bus.publish(type, detail)` + +Publish an event. Resolves to `{ eventId }` once the bus accepts it — delivery +to subscribers is asynchronous. `type` must be a non-empty string and cannot be +the reserved `'*'`. + +## Type Safety + +Pass an event map to type both `publish` and `on`: + +```typescript +interface ShopEvents { + 'order.placed': { id: string; total: number }; + 'order.shipped': { id: string; carrier: string }; +} + +const bus = new EventBus(scope, 'shop'); + +await bus.publish('order.placed', { id: 'o_1', total: 42 }); // ✅ +await bus.publish('order.placed', { id: 'o_1' }); // ✗ missing `total` +bus.on('order.shipped', async (detail) => detail.carrier); // detail is typed +``` + +## Error Constants + +```typescript +import { EventBusErrors } from '@aws-blocks/bb-event-bus'; +``` + +| Constant | When | +|----------|------| +| `EventBusErrors.InvalidEventType` | Published type is empty or `'*'`. | +| `EventBusErrors.PayloadTooLarge` | Serialized detail exceeds 256 KB. | +| `EventBusErrors.ValidationFailed` | A subscription's schema rejects a detail. | +| `EventBusErrors.MissingBusConfig` | Bus name env var is absent (AWS, pre-deploy). | +| `EventBusErrors.PublishFailed` | EventBridge rejected the event (AWS). | + +## Examples + +### Forwarding a domain event to the browser via Realtime + +```typescript +const bus = new EventBus(scope, 'events'); +const realtime = new Realtime(scope, 'live'); + +bus.on('order.shipped', async (detail: { id: string }) => { + await realtime.publish(`orders/${detail.id}`, { status: 'shipped' }); +}); +``` + +### Triggering background work from an event + +```typescript +const bus = new EventBus(scope, 'events'); +const thumbnails = new AsyncJob(scope, 'thumbnails', { + handler: async (payload: { key: string }) => generateThumbnail(payload.key), +}); + +bus.on('file.uploaded', async (detail: { key: string }) => { + await thumbnails.submit({ key: detail.key }); +}); +``` + +### Validating delivered events + +```typescript +import { z } from 'zod'; + +const OrderPlaced = z.object({ id: z.string(), total: z.number().positive() }); + +bus.on('order.placed', async (detail) => { + // detail is validated before we get here +}, { schema: OrderPlaced }); +``` + +## Best Practices + +- Name events as **past-tense facts** (`user.signed-up`), not commands. +- Keep details **small** (< 256 KB) — pass an id and let subscribers fetch the rest. +- Make subscribers **idempotent**; EventBridge may redeliver an event. +- Treat subscribers as **independent** — one throwing does not affect the others. + +## Local Development + +`npm run dev` uses an in-process implementation: `publish()` dispatches to every +matching `on()` handler on a microtask, so behavior matches AWS without an AWS +account. Inspect counters via `bus._stats` (`published`, `delivered`, `failed`, +`subscriptions`). + +## AWS Deployment + +Each `EventBus` provisions a dedicated Amazon EventBridge custom bus. Every +`on()` subscription becomes an EventBridge rule that targets the shared Blocks +Lambda; a rule input transformer tags the event with a deterministic +subscription id so the runtime routes it to the right handler. `publish()` calls +EventBridge `PutEvents`. The application Lambda is granted `events:PutEvents` on +the bus automatically. + +## Key Distinction from AsyncJob + +| | `EventBus` | `AsyncJob` | +|---|---|---| +| Topology | 1 event → **N** subscribers (fan-out) | 1 message → **1** handler | +| Backing | EventBridge | SQS + Lambda | +| Retries / DLQ | EventBridge retry policy | Built-in retries + dead-letter queue | +| Use for | Reacting to domain events | Offloading a unit of work | diff --git a/packages/bb-event-bus/package.json b/packages/bb-event-bus/package.json new file mode 100644 index 00000000..3c0892f5 --- /dev/null +++ b/packages/bb-event-bus/package.json @@ -0,0 +1,44 @@ +{ + "name": "@aws-blocks/bb-event-bus", + "version": "0.1.0", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "type": "module", + "files": [ + "dist", + "README.md", + "src", + "LICENSE" + ], + "exports": { + ".": { + "browser": "./dist/index.browser.js", + "cdk": { + "types": "./dist/index.cdk.d.ts", + "default": "./dist/index.cdk.js" + }, + "aws-runtime": "./dist/index.aws.js", + "types": "./dist/index.mock.d.ts", + "default": "./dist/index.mock.js" + } + }, + "scripts": { + "prebuild": "node ../../scripts/generate-version.mjs EventBus", + "build": "tsc --build", + "test": "node --test --test-force-exit dist/**/*.test.js" + }, + "dependencies": { + "@aws-blocks/bb-logger": "^0.1.1", + "@aws-blocks/core": "^0.1.1", + "@aws-sdk/client-eventbridge": "^3.0.0" + }, + "devDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + }, + "peerDependencies": { + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.0.0" + } +} diff --git a/packages/bb-event-bus/src/errors.ts b/packages/bb-event-bus/src/errors.ts new file mode 100644 index 00000000..d131daed --- /dev/null +++ b/packages/bb-event-bus/src/errors.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Error name constants for EventBus operations. + */ +export const EventBusErrors = { + /** Thrown when a serialized event detail exceeds 256 KB. */ + PayloadTooLarge: 'PayloadTooLargeException', + /** Thrown when an event type is empty, not a string, or the reserved `*` wildcard. */ + InvalidEventType: 'InvalidEventTypeException', + /** Thrown when schema validation fails on publish or delivery. */ + ValidationFailed: 'ValidationFailedException', + /** Thrown when the event bus name environment variable is missing (AWS only). */ + MissingBusConfig: 'MissingBusConfigException', + /** Thrown when EventBridge rejects the published event (AWS only). */ + PublishFailed: 'PublishFailedException', +} as const; diff --git a/packages/bb-event-bus/src/index.aws.ts b/packages/bb-event-bus/src/index.aws.ts new file mode 100644 index 00000000..7e0f85ae --- /dev/null +++ b/packages/bb-event-bus/src/index.aws.ts @@ -0,0 +1,161 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; +import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core'; +import type { ScopeParent } from '@aws-blocks/core'; +import { Logger } from '@aws-blocks/bb-logger'; +import type { ChildLogger } from '@aws-blocks/bb-logger'; +import type { + EventBusOptions, + EventContext, + EventHandler, + EventMap, + PublishResult, + SubscribeOptions, +} from './types.js'; +import { EventBusErrors } from './errors.js'; +import { BB_NAME, BB_VERSION } from './version.js'; +import { + EVENT_SOURCE, + busEnvKey, + runSchema, + serializeDetail, + subscriptionId, + validateEventType, +} from './internal.js'; + +export { EventBusErrors } from './errors.js'; +export type { + EventBusOptions, + EventContext, + EventHandler, + EventMap, + PublishResult, + SubscribeOptions, +} from './types.js'; + +/** + * Server-to-server pub/sub event bus backed by Amazon EventBridge. + * + * Publish a typed event once and EventBridge fans it out to every subscriber. + * Subscribers are wired to a dedicated Lambda via EventBridge rules; the same + * `on()` calls drive both the infrastructure and the runtime dispatch. + * + * @example + * ```typescript + * const bus = new EventBus(scope, 'events'); + * bus.on('order.placed', async (detail: { id: string }) => { + * await fulfil(detail.id); + * }); + * await bus.publish('order.placed', { id: 'o_123' }); + * ``` + */ +export class EventBus> extends Scope { + private _id: string; + private _envKey: string; + private _ebClient: EventBridgeClient; + private _subCount = 0; + + /** @internal Logger for internal operations. Defaults to error-level when not provided. */ + protected log: ChildLogger; + + constructor(scope: ScopeParent, id: string, options: EventBusOptions = {}) { + super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); + this.log = options.logger ?? new Logger(this, 'logger', { level: 'error' }); + this._id = id; + this._ebClient = new EventBridgeClient({ + customUserAgent: this.buildUserAgentChain(), + }); + + this._envKey = busEnvKey(this.fullId); + const eventBusName = process.env[this._envKey] ?? ''; + registerSdkIdentifiers(this.fullId, { eventBusName }); + } + + /** + * Subscribe a handler to an event type. Call once per subscription; chainable. + * + * Pass `'*'` to receive every event published on this bus. Each subscription + * provisions its own EventBridge rule, so EventBridge invokes the handler + * independently — overlapping subscriptions each fire. + * + * @returns `this` for chaining. + */ + on( + type: K, + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on( + type: '*', + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on(type: string, handler: EventHandler, options?: SubscribeOptions): this { + const subId = subscriptionId(this.fullId, type, this._subCount++); + + this.registerLambdaEventHandler(EVENT_SOURCE, subId, async (event) => { + const detail = event.detail ?? {}; + await runSchema(detail, options?.schema); + const ctx: EventContext = { + eventId: event.eventId ?? '', + type: event.type ?? type, + source: this.fullId, + publishedAt: event.publishedAt ?? new Date().toISOString(), + }; + await handler(detail, ctx); + }); + + return this; + } + + /** + * Publish an event. Returns once EventBridge has accepted it — delivery to + * subscribers happens asynchronously. + * + * @throws {EventBusErrors.InvalidEventType} If `type` is empty or `'*'`. + * @throws {EventBusErrors.PayloadTooLarge} If the serialized detail exceeds 256 KB. + * @throws {EventBusErrors.MissingBusConfig} If the bus name env var is absent. + * @throws {EventBusErrors.PublishFailed} If EventBridge rejects the event. + */ + async publish(type: K, detail: TEvents[K]): Promise { + validateEventType(type); + const eventBusName = this.ensureBusName(); + const Detail = await serializeDetail(detail); + + const result = await this._ebClient.send(new PutEventsCommand({ + Entries: [{ + EventBusName: eventBusName, + Source: this.fullId, + DetailType: type, + Detail, + }], + })); + + if (result.FailedEntryCount && result.FailedEntryCount > 0) { + const entry = result.Entries?.[0]; + const err = new Error( + `${EventBusErrors.PublishFailed}: ${entry?.ErrorCode ?? 'Unknown'} — ${entry?.ErrorMessage ?? 'EventBridge rejected the event'}` + ); + err.name = EventBusErrors.PublishFailed; + throw err; + } + + return { eventId: result.Entries?.[0]?.EventId ?? '' }; + } + + /** Ensures the bus name is available, throwing a descriptive error if not. */ + private ensureBusName(): string { + const { eventBusName } = getSdkIdentifiers(this) as { eventBusName?: string }; + if (!eventBusName) { + const err = new Error( + `EventBus "${this._id}": missing required environment variable "${this._envKey}". ` + + `Ensure the CDK stack has been deployed and the Lambda environment is configured correctly.` + ); + err.name = EventBusErrors.MissingBusConfig; + throw err; + } + return eventBusName; + } +} diff --git a/packages/bb-event-bus/src/index.browser.ts b/packages/bb-event-bus/src/index.browser.ts new file mode 100644 index 00000000..15ab5632 --- /dev/null +++ b/packages/bb-event-bus/src/index.browser.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Browser stub — EventBus runs server-side only. Publishing and subscribing +// happen in your backend; clients receive events via Realtime, not directly. +export class EventBus { + constructor(...args: any[]) {} + on(...args: any[]): this { + return this; + } + async publish(...args: any[]): Promise<{ eventId: string }> { + return { eventId: '' }; + } +} + +export const EventBusErrors = { + PayloadTooLarge: 'PayloadTooLargeException', + InvalidEventType: 'InvalidEventTypeException', + ValidationFailed: 'ValidationFailedException', + MissingBusConfig: 'MissingBusConfigException', + PublishFailed: 'PublishFailedException', +} as const; diff --git a/packages/bb-event-bus/src/index.cdk.ts b/packages/bb-event-bus/src/index.cdk.ts new file mode 100644 index 00000000..81465a2e --- /dev/null +++ b/packages/bb-event-bus/src/index.cdk.ts @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBus as CfnEventBus, Rule, RuleTargetInput, EventField } from 'aws-cdk-lib/aws-events'; +import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; +import { Scope, registerConfig } from '@aws-blocks/core/cdk'; +import type { ScopeParent } from '@aws-blocks/core'; +import type { + EventBusOptions, + EventHandler, + EventMap, + SubscribeOptions, +} from './types.js'; +import { EVENT_SOURCE, busEnvKey, subscriptionId } from './internal.js'; + +export { EventBusErrors } from './errors.js'; +export type { + EventBusOptions, + EventContext, + EventHandler, + EventMap, + PublishResult, + SubscribeOptions, +} from './types.js'; + +/** + * EventBridge-backed pub/sub event bus. + * + * Provisions a dedicated custom event bus and, for each `on()` subscription, an + * EventBridge rule that targets the shared Blocks Lambda. The rule's input + * transformer reshapes the matched event into the envelope the runtime layer + * dispatches on, tagging it with a deterministic subscription id. + */ +export class EventBus> extends Scope { + public readonly bus: CfnEventBus; + private _subCount = 0; + + constructor(scope: ScopeParent, id: string, _options: EventBusOptions = {}) { + super(id, { parent: scope }); + + this.bus = new CfnEventBus(this, 'bus', { + eventBusName: `${this.fullId}`.substring(0, 256), + }); + + // The application Lambda needs to publish to the bus. + this.bus.grantPutEventsTo(this.handler); + + registerConfig(this, busEnvKey(this.fullId), this.bus.eventBusName); + } + + on( + type: K, + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on( + type: '*', + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on(type: string, _handler: EventHandler, _options?: SubscribeOptions): this { + const index = this._subCount++; + const subId = subscriptionId(this.fullId, type, index); + const isWildcard = type === '*'; + + const rule = new Rule(this, `sub-${index}`, { + eventBus: this.bus, + ruleName: `${this.fullId}-${index}`.substring(0, 64), + description: isWildcard + ? `EventBus ${this.fullId}: all events → subscription ${index}` + : `EventBus ${this.fullId}: ${type} → subscription ${index}`, + eventPattern: isWildcard + ? { source: [this.fullId] } + : { source: [this.fullId], detailType: [type] }, + }); + + rule.addTarget(new LambdaFunction(this.handler, { + event: RuleTargetInput.fromObject({ + source: EVENT_SOURCE, + id: subId, + type, + eventId: EventField.eventId, + publishedAt: EventField.time, + detail: EventField.fromPath('$.detail'), + }), + })); + + return this; + } + + /** Publishing has no infrastructure side effects — it runs at runtime only. */ + async publish(_type: string, _detail: unknown): Promise<{ eventId: string }> { + return { eventId: '' }; + } +} diff --git a/packages/bb-event-bus/src/index.mock.ts b/packages/bb-event-bus/src/index.mock.ts new file mode 100644 index 00000000..aee8419e --- /dev/null +++ b/packages/bb-event-bus/src/index.mock.ts @@ -0,0 +1,154 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Scope, registerSdkIdentifiers } from '@aws-blocks/core'; +import type { ScopeParent } from '@aws-blocks/core'; +import { randomUUID } from 'node:crypto'; +import { Logger } from '@aws-blocks/bb-logger'; +import type { ChildLogger } from '@aws-blocks/bb-logger'; +import type { + EventBusOptions, + EventContext, + EventHandler, + EventMap, + PublishResult, + SubscribeOptions, +} from './types.js'; +import { WILDCARD } from './types.js'; +import { EventBusErrors } from './errors.js'; +import { BB_NAME, BB_VERSION } from './version.js'; +import { runSchema, serializeDetail, validateEventType } from './internal.js'; + +export { EventBusErrors } from './errors.js'; +export type { + EventBusOptions, + EventContext, + EventHandler, + EventMap, + PublishResult, + SubscribeOptions, +} from './types.js'; + +interface Subscription { + type: string; + handler: EventHandler; + schema?: SubscribeOptions['schema']; +} + +/** + * Server-to-server pub/sub event bus. Publish a typed event once and every + * subscriber for that type receives it. + * + * Subscribe with `on(type, handler)` and emit with `publish(type, detail)`. + * Use `'*'` as the type to subscribe to every event on the bus. Delivery is + * asynchronous and fire-and-forget — `publish()` resolves once the event is + * accepted, not once subscribers finish. + * + * **When to use:** Decouple producers from consumers — fan one domain event + * (`order.placed`) out to many independent reactions (charge card, send email, + * update search index) without the producer knowing who listens. + * + * **When NOT to use:** For a single consumer of a work queue with retries and a + * dead-letter queue, use `AsyncJob`. For pushing messages to connected browser + * clients, use `Realtime`. + * + * **Best practices:** + * - Name events as past-tense facts (`user.signed-up`), not commands. + * - Keep details small (< 256 KB) — pass an id and let consumers fetch the rest. + * - Make subscribers idempotent; events may be redelivered in AWS. + * + * **Scaling (AWS):** Backed by a dedicated Amazon EventBridge bus. Each + * subscription is an EventBridge rule targeting a Lambda, scaling automatically. + * + * @example + * ```typescript + * const bus = new EventBus(scope, 'events'); + * + * bus.on('order.placed', async (detail: { id: string }) => { + * await chargeCard(detail.id); + * }); + * bus.on('order.placed', async (detail: { id: string }) => { + * await sendReceipt(detail.id); + * }); + * + * await bus.publish('order.placed', { id: 'o_123' }); // both subscribers run + * ``` + */ +export class EventBus> extends Scope { + private _id: string; + private _subs: Subscription[] = []; + + /** In-process counters for dev server inspection. */ + public readonly _stats: { + published: number; + delivered: number; + failed: number; + subscriptions: number; + }; + + /** @internal Logger for internal operations. Defaults to error-level when not provided. */ + protected log: ChildLogger; + + constructor(scope: ScopeParent, id: string, options: EventBusOptions = {}) { + super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); + this.log = options.logger ?? new Logger(this, 'logger', { level: 'error' }); + this._id = id; + this._stats = { published: 0, delivered: 0, failed: 0, subscriptions: 0 }; + registerSdkIdentifiers(this.fullId, { eventBusName: `mock-bus://${this.fullId}` }); + } + + /** + * Subscribe a handler to an event type. Pass `'*'` to receive every event. + * Chainable — returns `this`. + */ + on( + type: K, + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on( + type: '*', + handler: EventHandler, + options?: SubscribeOptions, + ): this; + on(type: string, handler: EventHandler, options?: SubscribeOptions): this { + this._subs.push({ type, handler, schema: options?.schema }); + this._stats.subscriptions++; + return this; + } + + /** + * Publish an event. Resolves once accepted; subscribers run asynchronously. + * + * @throws {EventBusErrors.InvalidEventType} If `type` is empty or `'*'`. + * @throws {EventBusErrors.PayloadTooLarge} If the serialized detail exceeds 256 KB. + */ + async publish(type: K, detail: TEvents[K]): Promise { + validateEventType(type); + await serializeDetail(detail); + + const eventId = randomUUID(); + const publishedAt = new Date().toISOString(); + this._stats.published++; + + const matched = this._subs.filter((s) => s.type === type || s.type === WILDCARD); + console.log(`[EventBus:${this._id}] published ${type} → ${matched.length} subscriber(s)`); + + for (const sub of matched) { + const ctx: EventContext = { eventId, type, source: this.fullId, publishedAt }; + // Fire-and-forget, mirroring EventBridge's async delivery. + setTimeout(async () => { + try { + await runSchema(detail, sub.schema); + await sub.handler(detail, ctx); + this._stats.delivered++; + } catch (error: any) { + this._stats.failed++; + console.error(`[EventBus:${this._id}] subscriber for ${type} threw: ${error?.message ?? error}`); + } + }, 0); + } + + return { eventId }; + } +} diff --git a/packages/bb-event-bus/src/index.test.ts b/packages/bb-event-bus/src/index.test.ts new file mode 100644 index 00000000..cf428559 --- /dev/null +++ b/packages/bb-event-bus/src/index.test.ts @@ -0,0 +1,165 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from 'node:test'; +import assert from 'node:assert'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { EventBus, EventBusErrors } from './index.mock.js'; +import { subscriptionId, sanitizeType, validateEventType } from './internal.js'; + +// Subscribers run on a 0ms timer, so let the microtask/timer queue drain. +async function flush(ms = 20): Promise { + await sleep(ms); +} + +test('EventBus - delivers an event to a matching subscriber with context', async () => { + const bus = new EventBus(null as any, 'events'); + let received: any; + let ctx: any; + + bus.on('order.placed', async (detail, c) => { + received = detail; + ctx = c; + }); + + const { eventId } = await bus.publish('order.placed', { id: 'o_1', total: 42 }); + await flush(); + + assert.deepStrictEqual(received, { id: 'o_1', total: 42 }); + assert.strictEqual(ctx.type, 'order.placed'); + assert.strictEqual(ctx.eventId, eventId); + assert.ok(ctx.publishedAt, 'publishedAt should be set'); + assert.ok(ctx.source.endsWith('events'), 'source should be the bus fullId'); +}); + +test('EventBus - fans one event out to every subscriber for that type', async () => { + const bus = new EventBus(null as any, 'events'); + const calls: string[] = []; + + bus.on('order.placed', async () => { calls.push('a'); }); + bus.on('order.placed', async () => { calls.push('b'); }); + + await bus.publish('order.placed', { id: 'o_2' }); + await flush(); + + assert.deepStrictEqual(calls.sort(), ['a', 'b']); +}); + +test('EventBus - only matching subscribers fire', async () => { + const bus = new EventBus(null as any, 'events'); + let placed = 0; + let shipped = 0; + + bus.on('order.placed', async () => { placed++; }); + bus.on('order.shipped', async () => { shipped++; }); + + await bus.publish('order.placed', { id: 'o_3' }); + await flush(); + + assert.strictEqual(placed, 1); + assert.strictEqual(shipped, 0); +}); + +test('EventBus - wildcard subscriber receives every event', async () => { + const bus = new EventBus(null as any, 'events'); + const seen: string[] = []; + + bus.on('*', async (_detail, ctx) => { seen.push(ctx.type); }); + + await bus.publish('order.placed', { id: 'o_4' }); + await bus.publish('user.signed-up', { id: 'u_1' }); + await flush(); + + assert.deepStrictEqual(seen.sort(), ['order.placed', 'user.signed-up']); +}); + +test('EventBus - on() is chainable', async () => { + const bus = new EventBus(null as any, 'events'); + const ret = bus.on('a', async () => {}).on('b', async () => {}); + assert.strictEqual(ret, bus); + assert.strictEqual(bus._stats.subscriptions, 2); +}); + +test('EventBus - publish returns a unique eventId', async () => { + const bus = new EventBus(null as any, 'events'); + const r1 = await bus.publish('e', { n: 1 }); + const r2 = await bus.publish('e', { n: 2 }); + assert.ok(typeof r1.eventId === 'string' && r1.eventId.length > 0); + assert.notStrictEqual(r1.eventId, r2.eventId); +}); + +test('EventBus - publishing the wildcard type throws InvalidEventType', async () => { + const bus = new EventBus(null as any, 'events'); + await assert.rejects( + () => bus.publish('*' as any, {}), + (err: Error) => err.name === EventBusErrors.InvalidEventType, + ); +}); + +test('EventBus - publishing an empty type throws InvalidEventType', async () => { + const bus = new EventBus(null as any, 'events'); + await assert.rejects( + () => bus.publish('' as any, {}), + (err: Error) => err.name === EventBusErrors.InvalidEventType, + ); +}); + +test('EventBus - oversized detail throws PayloadTooLarge', async () => { + const bus = new EventBus(null as any, 'events'); + const big = { blob: 'x'.repeat(257 * 1024) }; + await assert.rejects( + () => bus.publish('big', big), + (err: Error) => err.name === EventBusErrors.PayloadTooLarge, + ); +}); + +test('EventBus - subscriber schema rejection does not crash publish', async () => { + const bus = new EventBus(null as any, 'events'); + const failing = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: () => ({ issues: [{ message: 'nope' }] }), + }, + }; + + let delivered = false; + bus.on('x', async () => { delivered = true; }, { schema: failing as any }); + + await bus.publish('x', { whatever: true }); + await flush(); + + assert.strictEqual(delivered, false, 'handler should not run when schema rejects'); + assert.strictEqual(bus._stats.failed, 1); +}); + +test('EventBus - a throwing subscriber is isolated from others', async () => { + const bus = new EventBus(null as any, 'events'); + let good = 0; + + bus.on('e', async () => { throw new Error('boom'); }); + bus.on('e', async () => { good++; }); + + await bus.publish('e', {}); + await flush(); + + assert.strictEqual(good, 1); + assert.strictEqual(bus._stats.failed, 1); + assert.strictEqual(bus._stats.delivered, 1); +}); + +test('internal - subscriptionId is deterministic across layers', () => { + assert.strictEqual(subscriptionId('app-events', 'order.placed', 0), 'app-events-order_placed-0'); + assert.strictEqual(subscriptionId('app-events', '*', 2), 'app-events-all-2'); +}); + +test('internal - sanitizeType collapses non-alphanumerics', () => { + assert.strictEqual(sanitizeType('order.placed'), 'order_placed'); + assert.strictEqual(sanitizeType('a--b..c'), 'a_b_c'); + assert.strictEqual(sanitizeType('*'), 'all'); +}); + +test('internal - validateEventType narrows valid strings', () => { + assert.doesNotThrow(() => validateEventType('order.placed')); + assert.throws(() => validateEventType(123 as any), (e: Error) => e.name === EventBusErrors.InvalidEventType); +}); diff --git a/packages/bb-event-bus/src/internal.ts b/packages/bb-event-bus/src/internal.ts new file mode 100644 index 00000000..72bf2484 --- /dev/null +++ b/packages/bb-event-bus/src/internal.ts @@ -0,0 +1,99 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Pure, dependency-free helpers shared by the runtime, CDK, and mock layers. +// Keep this file free of AWS SDK / CDK imports so every layer can use it. + +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { EventBusErrors } from './errors.js'; +import { WILDCARD } from './types.js'; + +/** EventBridge caps a single PutEvents entry at 256 KB (detail + envelope). */ +export const MAX_DETAIL_BYTES = 256 * 1024; + +/** The custom event-source tag the shared Lambda dispatches EventBus deliveries on. */ +export const EVENT_SOURCE = 'blocks.eventbus'; + +/** Uppercase a fullId into the suffix used for config/env keys. */ +export function sanitizeId(fullId: string): string { + return fullId.toUpperCase().replace(/[^A-Z0-9]/g, '_'); +} + +/** The env var (and CDK config key) holding the deployed bus name for a given block. */ +export function busEnvKey(fullId: string): string { + return `BLOCKS_EVENT_BUS_NAME_${sanitizeId(fullId)}`; +} + +/** Turn an event type into a filesystem/identifier-safe token. */ +export function sanitizeType(type: string): string { + return type === WILDCARD ? 'all' : type.replace(/[^a-zA-Z0-9]+/g, '_'); +} + +/** + * Deterministic id for a subscription. + * + * Both the CDK layer (rule target input) and the runtime layer (handler + * registry key) derive it from the same inputs — the bus fullId, the event + * type, and the zero-based order of the `on()` call — so the two layers agree + * on the routing key without sharing state. + */ +export function subscriptionId(fullId: string, type: string, index: number): string { + return `${fullId}-${sanitizeType(type)}-${index}`; +} + +/** + * Validate an event type for publishing. + * + * @throws {EventBusErrors.InvalidEventType} If the type is empty, not a string, + * or the reserved `*` wildcard (which is subscribe-only). + */ +export function validateEventType(type: unknown): asserts type is string { + if (typeof type !== 'string' || type.length === 0) { + const err = new Error(`${EventBusErrors.InvalidEventType}: Event type must be a non-empty string`); + err.name = EventBusErrors.InvalidEventType; + throw err; + } + if (type === WILDCARD) { + const err = new Error(`${EventBusErrors.InvalidEventType}: "${WILDCARD}" is reserved for subscriptions and cannot be published`); + err.name = EventBusErrors.InvalidEventType; + throw err; + } +} + +/** + * Run optional schema validation, then size-check the serialized detail. + * + * @returns The serialized JSON string, ready to hand to EventBridge. + * @throws {EventBusErrors.ValidationFailed} If schema validation fails. + * @throws {EventBusErrors.PayloadTooLarge} If the serialized detail exceeds 256 KB. + */ +export async function serializeDetail(detail: D, schema?: StandardSchemaV1): Promise { + await runSchema(detail, schema); + + const serialized = JSON.stringify(detail ?? {}); + const bytes = Buffer.byteLength(serialized, 'utf8'); + if (bytes > MAX_DETAIL_BYTES) { + const kb = Math.ceil(bytes / 1024); + const err = new Error(`${EventBusErrors.PayloadTooLarge}: Serialized detail is ${kb} KB, exceeds 256 KB limit`); + err.name = EventBusErrors.PayloadTooLarge; + throw err; + } + return serialized; +} + +/** + * Validate a value against an optional StandardSchema, awaiting async validators. + * + * @throws {EventBusErrors.ValidationFailed} If validation reports issues. + */ +export async function runSchema(value: unknown, schema?: StandardSchemaV1): Promise { + if (!schema) return; + const raw = schema['~standard'].validate(value); + const result = raw instanceof Promise ? await raw : raw; + if (result && typeof result === 'object' && 'issues' in result && result.issues) { + const msg = result.issues[0]?.message ?? 'Validation failed'; + const err = new Error(`${EventBusErrors.ValidationFailed}: ${msg}`); + err.name = EventBusErrors.ValidationFailed; + throw err; + } +} diff --git a/packages/bb-event-bus/src/types.ts b/packages/bb-event-bus/src/types.ts new file mode 100644 index 00000000..fd8f49a5 --- /dev/null +++ b/packages/bb-event-bus/src/types.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { ChildLogger } from '@aws-blocks/bb-logger'; + +/** + * A map of event type names to their detail payload shapes. + * + * Use it to make an `EventBus` fully type-safe end to end: + * + * ```typescript + * interface OrderEvents { + * 'order.placed': { id: string; total: number }; + * 'order.shipped': { id: string; carrier: string }; + * } + * const bus = new EventBus(scope, 'orders'); + * bus.publish('order.placed', { id, total }); // ✅ detail is checked + * bus.on('order.shipped', async (detail) => {}); // ✅ detail is { id, carrier } + * ``` + */ +export type EventMap = Record; + +/** + * Metadata about the event being delivered to a subscriber. + */ +export interface EventContext { + /** Unique identifier for this event (EventBridge event ID in AWS, UUID in mock). */ + eventId: string; + /** The event type that was published (the EventBridge detail-type). */ + type: string; + /** The publishing bus's fully-qualified id (the EventBridge source). */ + source: string; + /** ISO 8601 timestamp of when the event was published. */ + publishedAt: string; +} + +/** + * Subscriber callback. Receives the typed event detail plus delivery metadata. + */ +export type EventHandler = (detail: D, context: EventContext) => void | Promise; + +/** + * Configuration options for creating an EventBus. + */ +export interface EventBusOptions { + /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */ + logger?: ChildLogger; +} + +/** + * Options for a single `on()` subscription. + */ +export interface SubscribeOptions { + /** + * Optional schema validating each delivered event's detail. Accepts any + * StandardSchemaV1 implementation (Zod, Valibot, ArkType, etc.). When + * validation fails the event is rejected before the handler runs. + */ + schema?: StandardSchemaV1; +} + +/** + * Result from a `publish()` call. + */ +export interface PublishResult { + /** Unique identifier assigned to the published event. */ + eventId: string; +} + +/** The reserved event type that subscribes to every event on a bus. */ +export const WILDCARD = '*'; diff --git a/packages/bb-event-bus/tsconfig.json b/packages/bb-event-bus/tsconfig.json new file mode 100644 index 00000000..a09304ee --- /dev/null +++ b/packages/bb-event-bus/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": [ + "src/**/*" + ], + "references": [ + { + "path": "../bb-logger" + } + ] +} diff --git a/packages/blocks/package.json b/packages/blocks/package.json index a9b87ef7..376a4eea 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -64,6 +64,7 @@ "@aws-blocks/bb-data": "^0.1.1", "@aws-blocks/bb-distributed-data": "^0.1.1", "@aws-blocks/bb-async-job": "^0.1.1", + "@aws-blocks/bb-event-bus": "^0.1.0", "@aws-blocks/bb-cron-job": "^0.1.1", "@aws-blocks/bb-file-bucket": "^0.1.1", "@aws-blocks/bb-app-setting": "^0.1.1", @@ -95,6 +96,9 @@ "@aws-blocks/bb-async-job": [ "AsyncJob" ], + "@aws-blocks/bb-event-bus": [ + "EventBus" + ], "@aws-blocks/bb-cron-job": [ "CronJob" ], diff --git a/packages/blocks/src/index.cdk.ts b/packages/blocks/src/index.cdk.ts index 771cef0c..b8c656b4 100644 --- a/packages/blocks/src/index.cdk.ts +++ b/packages/blocks/src/index.cdk.ts @@ -44,6 +44,8 @@ export { DistributedDatabase, DistributedDatabaseErrors } from '@aws-blocks/bb-d export type { DistributedDatabaseOptions, TransactionOptions } from '@aws-blocks/bb-distributed-data'; export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; export type { AsyncJobOptions, AsyncJobContext, SubmitOptions, BatchSubmitResult } from '@aws-blocks/bb-async-job'; +export { EventBus, EventBusErrors } from '@aws-blocks/bb-event-bus'; +export type { EventBusOptions, EventContext, EventHandler, EventMap, PublishResult, SubscribeOptions as EventSubscribeOptions } from '@aws-blocks/bb-event-bus'; export { Agent, AgentErrors, BedrockModels, OllamaModels } from '@aws-blocks/bb-agent'; export type { AgentConfig, AgentResult, AgentStreamChunk, ToolDefinition, ToolCallRecord, ModelConfig, StreamOptions, TokenUsage } from '@aws-blocks/bb-agent'; export { CronJob, CronJobErrors } from '@aws-blocks/bb-cron-job'; diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 28a5727e..ed92941a 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -202,6 +202,24 @@ export type { DistributedDatabaseOptions, TransactionOptions } from '@aws-blocks export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; export type { AsyncJobOptions, AsyncJobContext, SubmitOptions, BatchSubmitResult } from '@aws-blocks/bb-async-job'; +/** + * **Server-to-server pub/sub event bus backed by EventBridge.** + * + * Use for event-driven, decoupled architectures: publish a typed domain event + * once (`order.placed`) and the bus fans it out to every independent subscriber + * (charge card, send receipt, update search index) without the producer knowing + * who listens. Subscribe with `on(type, handler)` (or `'*'` for all events) and + * emit with `publish(type, detail)`. Optional per-subscription schema validation. + * + * For a single-consumer work queue with retries and a DLQ, use `AsyncJob`. To + * push events to browser clients, use `Realtime`. + * + * Package: `@aws-blocks/bb-event-bus` + * Full docs: `README.md` in the package directory above. + */ +export { EventBus, EventBusErrors } from '@aws-blocks/bb-event-bus'; +export type { EventBusOptions, EventContext, EventHandler, EventMap, PublishResult, SubscribeOptions as EventSubscribeOptions } from '@aws-blocks/bb-event-bus'; + /** * **AI agent with streaming, tool calling, and conversation persistence.** * diff --git a/packages/blocks/src/sdk-identifiers.ts b/packages/blocks/src/sdk-identifiers.ts index 20fd49c7..7e2abbc8 100644 --- a/packages/blocks/src/sdk-identifiers.ts +++ b/packages/blocks/src/sdk-identifiers.ts @@ -16,6 +16,7 @@ import type { KVStore } from '@aws-blocks/bb-kv-store'; import type { FileBucket } from '@aws-blocks/bb-file-bucket'; import type { DistributedTable } from '@aws-blocks/bb-distributed-table'; import type { AsyncJob } from '@aws-blocks/bb-async-job'; +import type { EventBus } from '@aws-blocks/bb-event-bus'; import type { AppSetting } from '@aws-blocks/bb-app-setting'; import type { CronJob } from '@aws-blocks/bb-cron-job'; import type { AuthCognito } from '@aws-blocks/bb-auth-cognito'; @@ -52,6 +53,7 @@ export function getSdkIdentifiers(bb: KVStore): { tableName: string }; export function getSdkIdentifiers(bb: DistributedTable): { tableName: string }; export function getSdkIdentifiers(bb: FileBucket): { bucketName: string }; export function getSdkIdentifiers(bb: AsyncJob): { queueUrl: string }; +export function getSdkIdentifiers(bb: EventBus): { eventBusName: string }; export function getSdkIdentifiers(bb: AppSetting): { parameterName: string }; export function getSdkIdentifiers(bb: CronJob): { scheduleName: string }; export function getSdkIdentifiers(bb: AuthCognito): { userPoolId: string; clientId: string }; diff --git a/packages/blocks/tsconfig.json b/packages/blocks/tsconfig.json index 1643adc0..8c35bd3d 100644 --- a/packages/blocks/tsconfig.json +++ b/packages/blocks/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../bb-data" }, { "path": "../bb-distributed-data" }, { "path": "../bb-async-job" }, + { "path": "../bb-event-bus" }, { "path": "../bb-agent" }, { "path": "../bb-app-setting" }, { "path": "../bb-knowledge-base" }, diff --git a/packages/core/src/common/official-bb-names.generated.ts b/packages/core/src/common/official-bb-names.generated.ts index d16fd1c3..a38f0bf6 100644 --- a/packages/core/src/common/official-bb-names.generated.ts +++ b/packages/core/src/common/official-bb-names.generated.ts @@ -19,6 +19,7 @@ export const OFFICIAL_BB_NAMES: ReadonlySet = new Set([ 'DistributedDatabase', 'DistributedTable', 'EmailClient', + 'EventBus', 'FileBucket', 'KVStore', 'KnowledgeBase',