UNPKG

@mbc-cqrs-serverless/core

Version:
147 lines 6.92 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var AppSyncEventsService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.AppSyncEventsService = void 0; const node_crypto_1 = require("node:crypto"); const sha256_js_1 = require("@aws-crypto/sha256-js"); const credential_provider_node_1 = require("@aws-sdk/credential-provider-node"); const common_1 = require("@nestjs/common"); const config_1 = require("@nestjs/config"); const protocol_http_1 = require("@smithy/protocol-http"); const signature_v4_1 = require("@smithy/signature-v4"); const node_fetch_1 = __importDefault(require("node-fetch")); const decorators_1 = require("../decorators"); const enums_1 = require("./enums"); /** Default headers required for AppSync Events API requests */ const DEFAULT_HEADERS = { accept: 'application/json, text/javascript', 'content-encoding': 'amz-1.0', 'content-type': 'application/json; charset=UTF-8', }; let AppSyncEventsService = AppSyncEventsService_1 = class AppSyncEventsService { constructor(config) { this.config = config; this.logger = new common_1.Logger(AppSyncEventsService_1.name); const endpoint = config.get('APPSYNC_EVENTS_ENDPOINT'); this.namespace = config.get('APPSYNC_EVENTS_NAMESPACE') ?? 'default'; if (endpoint) { this.url = new URL(endpoint); // Region is parsed from the hostname automatically: // <id>.appsync-api.<region>.amazonaws.com → region // Falls back to AWS_REGION env var (always set in Lambda runtime). const match = this.url.hostname.match(/\w+\.appsync-api\.([\w-]+)\.amazonaws\.com/); const region = match?.[1] ?? process.env.AWS_REGION ?? 'ap-northeast-1'; this.signer = new signature_v4_1.SignatureV4({ credentials: (0, credential_provider_node_1.defaultProvider)(), service: 'appsync', region, sha256: sha256_js_1.Sha256, }); } } /** * Publish INotification to an AppSync Events channel via IAM SigV4. * * Channel structure (max 5 segments, seg 1 = namespace): * /{namespace}/{tenantCode}/{action}/{sanitizedId} * * Client subscription options (wildcard /* catches all sub-channels): * /{namespace}/{tenantCode}/* — all events for tenant * /{namespace}/{tenantCode}/{action}/* — filtered by action * /{namespace}/{tenantCode}/{action}/{id} — specific command * * Requires: Lambda execution role must have appsync:EventPublish permission. */ async sendMessage(notification) { if (!this.url || !this.signer) { this.logger.warn('APPSYNC_EVENTS_ENDPOINT not set, skipping'); return; } const channel = this.resolveChannel(notification); this.logger.debug(`sendMessage:: channel=${channel}`); await this.postToChannel(channel, notification); } /** * Resolves the most specific channel path for a notification. * Non-alphanumeric characters (e.g. #, @) are sanitized to dashes * since AppSync Events channel segments only allow [a-zA-Z0-9-]. */ resolveChannel(notification) { const namespace = this.sanitizeSegment(this.namespace); const tenantCode = this.sanitizeSegment(notification.tenantCode); const action = this.sanitizeSegment(notification.action); const id = this.sanitizeSegment(notification.id); return `/${namespace}/${tenantCode}/${action}/${id}`; } /** * Formats a string to comply with AppSync Events API channel segment rules: * 1. Only alphanumeric characters and dashes. * 2. Cannot start or end with a dash. * 3. Maximum length of 50 characters. */ sanitizeSegment(segment) { if (!segment) return 'none'; // Replace any non-alphanumeric character (like _, %, =) with a dash let s = segment.replace(/[^a-zA-Z0-9]/g, '-'); // Truncate to AWS's strict 50 character limit s = s.substring(0, 50); // Strip out any dashes from the very beginning or end s = s.replace(/^-+|-+$/g, ''); // Fallback if the string became empty after trimming return s || 'none'; } async postToChannel(channel, notification) { const signedReq = await this.signRequest(channel, notification); const res = await (0, node_fetch_1.default)(this.url.toString(), { method: signedReq.method, headers: signedReq.headers, body: signedReq.body, }); if (!res.ok) { const text = await res.text(); throw new Error(`AppSync Events publish failed [${res.status}] on channel ${channel}: ${text}`); } this.logger.debug(`sendMessage:: published successfully to ${channel}`); } /** * Builds and signs an HttpRequest for the given channel and notification. * Separating signing from the fetch call makes each step independently testable. */ async signRequest(channel, notification) { const body = JSON.stringify({ id: (0, node_crypto_1.randomUUID)(), channel, events: [JSON.stringify(notification)], }); const httpRequest = new protocol_http_1.HttpRequest({ method: 'POST', headers: { ...DEFAULT_HEADERS, host: this.url.hostname, }, body, hostname: this.url.hostname, path: this.url.pathname, }); return this.signer.sign(httpRequest); } }; exports.AppSyncEventsService = AppSyncEventsService; exports.AppSyncEventsService = AppSyncEventsService = AppSyncEventsService_1 = __decorate([ (0, decorators_1.NotificationTransport)(enums_1.NotificationTransports.APPSYNC_EVENT), __metadata("design:paramtypes", [config_1.ConfigService]) ], AppSyncEventsService); //# sourceMappingURL=appsync-events.service.js.map