@mbc-cqrs-serverless/core
Version:
CQRS and event base core
147 lines • 6.92 kB
JavaScript
;
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