UNPKG

@message-queue-toolkit/sns

Version:
371 lines 19.2 kB
import { isError } from '@lokalise/node-core'; import { isProduction, isStartupResourcePollingEnabled, waitForResource, } from '@message-queue-toolkit/core'; import { deleteQueue, getQueueAttributes, resolveQueueUrlFromLocatorConfig, } from '@message-queue-toolkit/sqs'; import { isCreateTopicCommand } from "../types/TopicTypes.js"; import { subscribeToTopic } from "./snsSubscriber.js"; import { assertTopic, deleteSubscription, deleteTopic, getTopicAttributes } from "./snsUtils.js"; import { buildTopicArn } from "./stsUtils.js"; // Helper function to poll for SNS topic availability async function pollForTopic(snsClient, topicArn, startupResourcePolling, extraParams, onResourceAvailable, onError) { const topicResult = await waitForResource({ config: startupResourcePolling, resourceName: `SNS topic ${topicArn}`, logger: extraParams?.logger, errorReporter: extraParams?.errorReporter, onResourceAvailable, onError, checkFn: async () => { const result = await getTopicAttributes(snsClient, topicArn); if (result.error === 'not_found') { return { isAvailable: false }; } return { isAvailable: true, result: result.result }; }, }); return { topicResult, topicArn }; } // Helper function to create subscription async function createSubscription(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, extraParams) { const { subscriptionArn, topicArn, queueUrl } = await subscribeToTopic(sqsClient, snsClient, stsClient, creationConfig.queue, topicResolutionOptions, subscriptionConfig, { updateAttributesIfExists: creationConfig.updateAttributesIfExists, queueUrlsWithSubscribePermissionsPrefix: creationConfig.queueUrlsWithSubscribePermissionsPrefix, allowedSourceOwner: creationConfig.allowedSourceOwner, topicArnsWithPublishPermissionsPrefix: creationConfig.topicArnsWithPublishPermissionsPrefix, logger: extraParams?.logger, forceTagUpdate: creationConfig.forceTagUpdate, }); if (!subscriptionArn) { throw new Error('Failed to subscribe'); } return { subscriptionArn, topicArn, queueUrl }; } // Helper to handle subscription creation with optional topic polling (blocking and non-blocking) async function createSubscriptionWithPolling(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, topicArn, startupResourcePolling, extraParams) { const nonBlocking = startupResourcePolling.nonBlocking === true; // biome-ignore lint/style/noNonNullAssertion: QueueName is validated in initSnsSqs before calling this function const queueName = creationConfig.queue.QueueName; const onTopicReady = async () => { try { const result = await createSubscription(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, extraParams); extraParams?.onResourcesReady?.({ topicArn: result.topicArn, queueUrl: result.queueUrl, subscriptionArn: result.subscriptionArn, queueName, }); } catch (err) { const error = isError(err) ? err : new Error(String(err)); extraParams?.logger?.error({ message: 'Background subscription creation failed', topicArn, error, }); // Subscription creation failure is final - we don't retry extraParams?.onResourcesError?.(error, { isFinal: true }); } }; const { topicResult } = await pollForTopic(snsClient, topicArn, startupResourcePolling, extraParams, nonBlocking ? onTopicReady : undefined, nonBlocking ? extraParams?.onResourcesError : undefined); // Non-blocking: return early if topic wasn't immediately available if (nonBlocking && topicResult === undefined) { return { subscriptionArn: '', topicArn, queueName, queueUrl: '', resourcesReady: false, }; } // Blocking: topic is now available, create subscription const result = await createSubscription(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, extraParams); return { subscriptionArn: result.subscriptionArn, topicArn: result.topicArn, queueName, queueUrl: result.queueUrl, resourcesReady: true, }; } // Helper function to poll for SQS queue availability async function pollForQueue(sqsClient, queueUrl, startupResourcePolling, extraParams, onResourceAvailable, onError) { return await waitForResource({ config: startupResourcePolling, resourceName: `SQS queue ${queueUrl}`, logger: extraParams?.logger, errorReporter: extraParams?.errorReporter, onResourceAvailable, onError, checkFn: async () => { const result = await getQueueAttributes(sqsClient, queueUrl); if (result.error === 'not_found') { return { isAvailable: false }; } return { isAvailable: true, result: result.result }; }, }); } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fixme export async function initSnsSqs(sqsClient, snsClient, stsClient, locatorConfig, creationConfig, subscriptionConfig, extraParams) { if (!locatorConfig?.subscriptionArn) { if (!creationConfig?.topic && !locatorConfig?.topicArn && !locatorConfig?.topicName) { throw new Error('If locatorConfig.subscriptionArn is not specified, creationConfig.topic is mandatory in order to attempt to create missing topic and subscribe to it OR locatorConfig.name or locatorConfig.topicArn parameter is mandatory, to create subscription for existing topic.'); } if (!creationConfig?.queue) { throw new Error('If locatorConfig.subscriptionArn is not specified, creationConfig.queue parameter is mandatory, as there will be an attempt to create the missing queue'); } if (!creationConfig.queue.QueueName) { throw new Error('If locatorConfig.subscriptionArn is not specified, creationConfig.queue.QueueName parameter is mandatory, as there will be an attempt to create the missing queue'); } if (!subscriptionConfig) { throw new Error('If locatorConfig.subscriptionArn is not specified, subscriptionConfig parameter is mandatory, as there will be an attempt to create the missing subscription'); } const topicResolutionOptions = { ...locatorConfig, ...creationConfig.topic, }; // If startup resource polling is enabled and we're not creating the topic (just locating it), // we should poll for the topic to exist before attempting to subscribe const startupResourcePolling = locatorConfig?.startupResourcePolling; if (isStartupResourcePollingEnabled(startupResourcePolling) && !isCreateTopicCommand(topicResolutionOptions)) { // Validate that we have either topicArn or topicName to build the ARN if (!topicResolutionOptions.topicArn && !topicResolutionOptions.topicName) { throw new Error('When startup resource polling is enabled and topic is not being created, either topicArn or topicName must be provided in locatorConfig to identify the topic to poll for'); } // topicName is guaranteed to be defined here because we validated above that at least one of topicArn or topicName is present const topicArnToWaitFor = topicResolutionOptions.topicArn ?? (await buildTopicArn(stsClient, topicResolutionOptions.topicName)); return await createSubscriptionWithPolling(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, topicArnToWaitFor, startupResourcePolling, extraParams); } // No polling needed - create subscription immediately const { subscriptionArn, topicArn, queueUrl } = await createSubscription(sqsClient, snsClient, stsClient, creationConfig, topicResolutionOptions, subscriptionConfig, extraParams); return { subscriptionArn, topicArn, queueName: creationConfig.queue.QueueName, queueUrl, resourcesReady: true, }; } const queueUrl = await resolveQueueUrlFromLocatorConfig(sqsClient, locatorConfig); // Check for existing resources, using the locators const subscriptionTopicArn = locatorConfig.topicArn ?? (await buildTopicArn(stsClient, locatorConfig.topicName ?? '')); const startupResourcePolling = locatorConfig.startupResourcePolling; // If startup resource polling is enabled, poll for resources to become available if (isStartupResourcePollingEnabled(startupResourcePolling)) { const nonBlocking = startupResourcePolling.nonBlocking === true; // Extract queueName early for use in callbacks const splitUrl = queueUrl.split('/'); // biome-ignore lint/style/noNonNullAssertion: It's ok const queueName = splitUrl[splitUrl.length - 1]; // Track availability for non-blocking mode coordination let topicAvailable = false; let queueAvailable = false; const notifyIfBothReady = () => { if (nonBlocking && topicAvailable && queueAvailable) { extraParams?.onResourcesReady?.({ topicArn: subscriptionTopicArn, queueUrl, // subscriptionArn is guaranteed to be defined here because we're in the branch where locatorConfig.subscriptionArn exists subscriptionArn: locatorConfig.subscriptionArn, queueName, }); } }; // Wait for topic to become available const { topicResult } = await pollForTopic(snsClient, subscriptionTopicArn, startupResourcePolling, extraParams, () => { topicAvailable = true; notifyIfBothReady(); }, (error, context) => { extraParams?.onResourcesError?.(error, context); }); // If topic was immediately available, mark it if (topicResult !== undefined) { topicAvailable = true; } // If non-blocking and topic wasn't immediately available, return early // Background polling will continue and call notifyIfBothReady when topic is available if (nonBlocking && topicResult === undefined) { // Also start polling for queue in background so we can notify when both are ready pollForQueue(sqsClient, queueUrl, startupResourcePolling, extraParams, () => { queueAvailable = true; notifyIfBothReady(); }, (error, context) => { extraParams?.onResourcesError?.(error, context); }) .then((result) => { // If queue was immediately available, pollForQueue returns the result // but doesn't call onResourceAvailable, so we handle it here if (result !== undefined) { queueAvailable = true; notifyIfBothReady(); } }) .catch((err) => { // Handle unexpected errors during background polling const error = isError(err) ? err : new Error(String(err)); extraParams?.logger?.error({ message: 'Background queue polling failed unexpectedly', queueUrl, error, }); extraParams?.onResourcesError?.(error, { isFinal: true }); }); return { subscriptionArn: locatorConfig.subscriptionArn, topicArn: subscriptionTopicArn, queueUrl, queueName, resourcesReady: false, }; } // Wait for queue to become available const queueResult = await pollForQueue(sqsClient, queueUrl, startupResourcePolling, extraParams, () => { queueAvailable = true; notifyIfBothReady(); }); // If queue was immediately available, mark it if (queueResult !== undefined) { queueAvailable = true; } // If non-blocking and queue wasn't immediately available, return early if (nonBlocking && queueResult === undefined) { return { subscriptionArn: locatorConfig.subscriptionArn, topicArn: subscriptionTopicArn, queueUrl, queueName, resourcesReady: false, }; } } else { // Original behavior: check resources once and fail immediately if not found const checkPromises = []; const topicPromise = getTopicAttributes(snsClient, subscriptionTopicArn); checkPromises.push(topicPromise); const queuePromise = getQueueAttributes(sqsClient, queueUrl); checkPromises.push(queuePromise); const [topicCheckResult, queueCheckResult] = await Promise.all(checkPromises); if (queueCheckResult?.error === 'not_found') { throw new Error(`Queue with queueUrl ${queueUrl} does not exist.`); } if (topicCheckResult?.error === 'not_found') { throw new Error(`Topic with topicArn ${subscriptionTopicArn} does not exist.`); } } let queueName; if (queueUrl) { const splitUrl = queueUrl.split('/'); // biome-ignore lint/style/noNonNullAssertion: It's ok queueName = splitUrl[splitUrl.length - 1]; } else { // biome-ignore lint/style/noNonNullAssertion: It's ok queueName = creationConfig.queue.QueueName; } return { subscriptionArn: locatorConfig.subscriptionArn, topicArn: subscriptionTopicArn, queueUrl, queueName, resourcesReady: true, }; } export async function deleteSnsSqs(sqsClient, snsClient, stsClient, deletionConfig, queueConfiguration, topicConfiguration, subscriptionConfiguration, extraParams, topicLocator) { if (!deletionConfig.deleteIfExists) { return; } if (isProduction() && !deletionConfig.forceDeleteInProduction) { throw new Error('You are running autodeletion in production. This can and probably will cause a loss of data. If you are absolutely sure you want to do this, please set deletionConfig.forceDeleteInProduction to true'); } const { subscriptionArn } = await subscribeToTopic(sqsClient, snsClient, stsClient, queueConfiguration, // biome-ignore lint/style/noNonNullAssertion: Checked by type topicConfiguration ?? topicLocator, subscriptionConfiguration, extraParams); if (!subscriptionArn) { throw new Error('subscriptionArn must be set for automatic deletion'); } await deleteQueue(sqsClient, // biome-ignore lint/style/noNonNullAssertion: It's ok queueConfiguration.QueueName, deletionConfig.waitForConfirmation !== false); if (topicConfiguration) { const topicName = isCreateTopicCommand(topicConfiguration) ? topicConfiguration.Name : 'undefined'; if (!topicName) { throw new Error('Failed to resolve topic name'); } await deleteTopic(snsClient, stsClient, topicName); } await deleteSubscription(snsClient, subscriptionArn); } export async function deleteSns(snsClient, stsClient, deletionConfig, creationConfig) { if (!deletionConfig.deleteIfExists) { return; } if (isProduction() && !deletionConfig.forceDeleteInProduction) { throw new Error('You are running autodeletion in production. This can and probably will cause a loss of data. If you are absolutely sure you want to do this, please set deletionConfig.forceDeleteInProduction to true'); } if (!creationConfig.topic?.Name) { throw new Error('topic.Name must be set for automatic deletion'); } await deleteTopic(snsClient, stsClient, creationConfig.topic.Name); } async function initSnsWithLocator(snsClient, stsClient, locatorConfig, extraParams) { if (!locatorConfig.topicArn && !locatorConfig.topicName) { throw new Error('When locatorConfig for the topic is specified, either topicArn or topicName must be specified'); } const topicArn = locatorConfig.topicArn ?? (await buildTopicArn(stsClient, locatorConfig.topicName ?? '')); const startupResourcePolling = locatorConfig.startupResourcePolling; // If startup resource polling is enabled, poll for topic to become available if (isStartupResourcePollingEnabled(startupResourcePolling)) { const nonBlocking = startupResourcePolling.nonBlocking === true; const topicResult = await waitForResource({ config: startupResourcePolling, resourceName: `SNS topic ${topicArn}`, logger: extraParams?.logger, errorReporter: extraParams?.errorReporter, onResourceAvailable: () => { if (nonBlocking) { extraParams?.onTopicReady?.({ topicArn }); } }, checkFn: async () => { const result = await getTopicAttributes(snsClient, topicArn); if (result.error === 'not_found') { return { isAvailable: false }; } return { isAvailable: true, result: result.result }; }, }); // In non-blocking mode, return early if topic wasn't immediately available if (nonBlocking && topicResult === undefined) { return { topicArn, resourcesReady: false }; } } else { // Original behavior: check once and fail immediately if not found const checkResult = await getTopicAttributes(snsClient, topicArn); if (checkResult.error === 'not_found') { throw new Error(`Topic with topicArn ${topicArn} does not exist.`); } } return { topicArn, resourcesReady: true }; } export async function initSns(snsClient, stsClient, locatorConfig, creationConfig, extraParams) { if (locatorConfig) { return initSnsWithLocator(snsClient, stsClient, locatorConfig, extraParams); } // create new topic if it does not exist if (!creationConfig) { throw new Error('When locatorConfig for the topic is not specified, creationConfig of the topic is mandatory'); } // biome-ignore lint/style/noNonNullAssertion: it's ok const topicArn = await assertTopic(snsClient, stsClient, creationConfig.topic, { queueUrlsWithSubscribePermissionsPrefix: creationConfig.queueUrlsWithSubscribePermissionsPrefix, allowedSourceOwner: creationConfig.allowedSourceOwner, forceTagUpdate: creationConfig.forceTagUpdate, }); return { topicArn, resourcesReady: true }; } //# sourceMappingURL=snsInitter.js.map