@fluojs/slack
v2.0.2
Published
Webhook-first, transport-agnostic Slack delivery core for Fluo with notifications integration.
Maintainers
Readme
@fluojs/slack
Webhook-first, transport-agnostic Slack delivery core for fluo. It provides a Nest-like module API, an injectable SlackService for standalone usage, and a first-party SlackChannel for @fluojs/notifications integration without assuming a Node-only SDK.
Table of Contents
Installation
npm install @fluojs/slack @fluojs/notificationsThis package requires Node.js >=24.0.0 <27 as its package-owned support contract. Upgrade Node 20 and Node 22 hosts to Node.js >=24.0.0 <27; Node versions below 24 and Node 27+ are unsupported, so move the process to a supported line. Its delivery contract remains transport-agnostic at runtime through explicit fetch-compatible boundaries.
When to Use
- When you want one package that can send Slack messages directly and also plug into
@fluojs/notifications. - When transport choice must stay explicit and portable across Node, Bun, Deno, and Cloudflare-compatible application boundaries.
- When Slack delivery should prefer incoming webhooks while still allowing richer API integrations through a custom transport contract.
- When configuration must enter through DI or explicit options instead of
process.envreads inside the package.
Quick Start
Register the module
import { Module } from '@fluojs/core';
import { SlackModule, createSlackWebhookTransport } from '@fluojs/slack';
@Module({
imports: [
SlackModule.forRoot({
defaultChannel: '#ops',
transport: createSlackWebhookTransport({
fetch: globalThis.fetch.bind(globalThis),
webhookUrl: 'https://hooks.slack.com/services/T000/B000/XXXX',
}),
}),
],
})
export class AppModule {}Send Slack messages directly
import { Inject } from '@fluojs/core';
import { SlackService } from '@fluojs/slack';
@Inject(SlackService)
export class DeployNotifier {
constructor(private readonly slack: SlackService) {}
async announce(version: string) {
await this.slack.send({
text: `Deploy ${version} finished successfully.`,
});
}
}Common Patterns
Module visibility and migration boundaries
SlackModule.forRoot(...) and SlackModule.forRootAsync(...) return a global module by default. The module exports SlackService, SlackChannel, and SLACK_CHANNEL; pass global: false only when migrated code needs those providers to remain visible only to modules that explicitly import the returned module. The option is global?: boolean, not NestJS isGlobal.
Async registration supports the injected factory shape only: SlackModule.forRootAsync({ inject, useFactory, global? }). It consumes inject and useFactory, not NestJS imports, useClass, or useExisting. Register dependencies in the application module graph first, list their tokens in inject, then return the final Slack options from useFactory.
The package-level registration surface is intentionally singleton-oriented. SLACK_CHANNEL identifies the one configured notifications channel; application code injects SlackService for direct provider delivery. Applications that need multiple Slack clients should compose their own modules/providers around distinct SlackTransport instances instead of expecting a package-level multi-client registry.
Standalone delivery with SlackService
Use SlackService when your application wants direct Slack delivery without routing through the notifications foundation.
SlackModule.forRootAsync({
inject: [ConfigService],
useFactory: (config) => ({
defaultChannel: config.slack.defaultChannel,
transport: createSlackWebhookTransport({
fetch: config.runtime.fetch,
webhookUrl: config.slack.webhookUrl,
}),
}),
});Behavioral contract notes:
SlackService.send(...)resolvesdefaultChannelbefore delivery.SlackService.sendMany(...)sends messages sequentially and supportscontinueOnErrorwhen callers need a batch result instead of fail-fast behavior; it collects ordinary provider failures only, while caller cancellation always rejects the batch.SlackService.send(...),SlackService.sendMany(...), andSlackService.sendNotification(...)honor already-aborted signals before provider handoff. An aborted signal or a transportAbortErrortakes precedence overcontinueOnError, and the same signal is propagated through notification channels to transport calls.- The service initializes the configured transport during module bootstrap and closes factory-owned resources during application shutdown.
- Direct and notification-backed delivery require the lifecycle to be
ready; calls beforeonModuleInit()finishes, after initialization failure, or during shutdown fail withSlackLifecycleErrorinstead of lazily creating or reusing transports. - Shutdown rejects new deliveries, waits for the shared bootstrap initialization promise—including any in-flight factory-owned transport creation and
verify()call—to settle, drains active deliveries, and then closes the owned transport before completion. - Factory-owned transport cleanup is serialized across bootstrap-failure cleanup and application shutdown, so the same owned transport is closed at most once even when those paths race.
SlackService.createPlatformStatusSnapshot()reports lifecycle, readiness, and transport ownership without requiring callers to reach into internal options.- The package never reads
process.envdirectly. All configuration must enter through explicit options or DI.
Bootstrap verification with verifyOnModuleInit
Set SlackModuleOptions.verifyOnModuleInit?: boolean to true when the selected transport can verify its own readiness during application bootstrap. SlackService.onModuleInit() always resolves the configured transport first; if verifyOnModuleInit is enabled and the resolved transport exposes an optional verify() method, the service awaits transport.verify() before marking the Slack provider ready. Transports that do not implement verify are still valid and simply skip the verification step.
import { Module } from '@fluojs/core';
import {
SlackModule,
type SlackTemplateRenderer,
type SlackTransport,
} from '@fluojs/slack';
const renderer: SlackTemplateRenderer = {
render({ template, payload, subject }) {
if (template === 'deploy.finished') {
return {
blocks: [
{
text: { text: `*${String(payload.version)}* is live`, type: 'mrkdwn' },
type: 'section',
},
],
text: subject,
};
}
return { text: subject };
},
};
const slackApiTransport: SlackTransport = {
async verify() {
await slackApi.authTest();
},
async send(message, { signal }) {
const receipt = await slackApi.postMessage(message, { signal });
return {
channel: receipt.channel,
messageTs: receipt.ts,
ok: receipt.ok,
};
},
};
@Module({
imports: [
SlackModule.forRoot({
defaultChannel: '#ops',
renderer,
transport: slackApiTransport,
verifyOnModuleInit: true,
}),
],
})
export class AppModule {}Behavioral contract notes:
verifyOnModuleInitis optional and defaults tofalse.- Verification is capability-based: only transports that expose
verify()are called, so webhook-only or app-owned transports do not have to add a no-op verifier. - Bootstrap initialization and verification share one promise with shutdown, so a factory-owned transport remains open until an in-flight
verify()call settles and is then closed at most once. - If
transport.verify()rejects, bootstrap fails withSlackLifecycleErrorwrapping the initialization failure, the service lifecycle moves tofailed, readiness/status snapshots report the provider as not ready, and factory-owned transports that were already resolved are closed before the error is rethrown. If shutdown starts while that cleanup is still in progress, both callers await the same close work. SlackService.createPlatformStatusSnapshot()includesverifiedOnModuleInitso health/readiness tooling can tell whether bootstrap verification was requested.
Integration with @fluojs/notifications
Inject SLACK_CHANNEL into NotificationsModule.forRootAsync(...) so the Slack package remains the only place that understands Slack-specific payload fields and recipient-to-channel translation.
import { Module } from '@fluojs/core';
import { NotificationsModule } from '@fluojs/notifications';
import {
SLACK_CHANNEL,
SlackModule,
createSlackWebhookTransport,
} from '@fluojs/slack';
@Module({
imports: [
SlackModule.forRoot({
transport: createSlackWebhookTransport({
fetch: globalThis.fetch.bind(globalThis),
webhookUrl: 'https://hooks.slack.com/services/T000/B000/XXXX',
}),
}),
NotificationsModule.forRootAsync({
inject: [SLACK_CHANNEL],
useFactory: (channel) => ({
channels: [channel],
}),
}),
],
})
export class AppModule {}Supported notification payload fields:
text,blocks,attachmentsthreadTs,replyBroadcastusername,iconEmoji,iconUrlmrkdwn,unfurlLinks,unfurlMedia,metadata
Behavioral contract notes:
- One notification dispatch maps to exactly one Slack destination from a single entry in
recipients. - If
recipientsis omitted,SlackService.sendNotification(...)falls back todefaultChannel. - Notification metadata is merged from payload metadata, dispatch metadata, and subject/template markers before delivery.
- If a notification needs fan-out across multiple Slack destinations, call
sendMany(...)instead of one multi-recipient dispatch.
Template rendering and merge precedence
Provide a SlackTemplateRenderer when notification templates should turn shared @fluojs/notifications envelopes into Slack-specific text, blocks, or attachments. SlackService.sendNotification(...) calls renderer.render(input) only when both notification.template and SlackModuleOptions.renderer are present. The render input includes template, payload, optional subject, optional locale, and optional dispatch metadata.
import type { SlackTemplateRenderer } from '@fluojs/slack';
const renderer: SlackTemplateRenderer = {
async render(input) {
return {
blocks: [
{
text: {
text: `*${String(input.payload.releaseId)}* deployed for ${input.locale ?? 'default'}`,
type: 'mrkdwn',
},
type: 'section',
},
],
text: input.subject,
};
},
};
await notifications.dispatch({
channel: 'slack',
locale: 'en',
metadata: { source: 'ci' },
payload: {
metadata: { releaseId: 'rel-42' },
text: 'Deploy rel-42 finished.',
},
recipients: ['#ops'],
subject: 'Deploy finished',
template: 'deploy.finished',
});Merge precedence is deterministic:
payload.attachmentsandpayload.blockswin over renderedattachmentsandblockswhen those payload fields are defined. Non-blankpayload.textwins over renderedtext.- Text falls back from non-blank
payload.textto non-blank renderedtext, then to non-blanknotification.subject. Empty or whitespace-only text values are treated as unspecified. - Metadata is merged as payload metadata, dispatch metadata, subject marker, then template marker. Later entries win on duplicate keys, so the final message records the dispatch
subjectandtemplatemarkers when present. - If no
templateis set or no renderer is registered, no template rendering occurs; the notification is adapted from its payload and subject only.
Webhook-first delivery with explicit fetch injection
Use createSlackWebhookTransport(...) when you want a portable first-party transport that only depends on a fetch-compatible HTTP boundary.
const transport = createSlackWebhookTransport({
fetch: runtime.fetch,
webhookUrl: slackWebhookUrl,
});
await slack.send({
blocks: [{ type: 'section', text: { type: 'mrkdwn', text: '*Deploy finished*' } }],
text: 'Deploy finished',
});For richer API integrations such as chat.postMessage, implement the exported SlackTransport contract and inject it through SlackModule.forRoot(...) or forRootAsync(...).
Behavioral contract notes:
- Passing
fetchexplicitly is the portable path and is recommended for all supported runtimes. For backward compatibility, omittingfetchfalls back toglobalThis.fetchwhen that ambient runtime API exists; runtimes withoutglobalThis.fetchfail fast withSlackConfigurationError. - The built-in webhook transport consumes transient
408,429, and5xxresponse bodies before retrying with bounded exponential backoff, then surfaces an error only after retries are exhausted. - Abort signals are passed to the injected
fetchboundary and cancel retry backoff without wrappingAbortErrorvalues asSlackTransportError. - Caller-visible
SlackTransportErrormessages omit raw upstream response bodies by default.
Intentional limitations
The Slack package intentionally does not:
- read credentials or webhook URLs from
process.env - ship a Node-only Slack SDK inside the shared root package boundary
- force one provider strategy beyond the webhook-first helper and exported transport contract
- provide a package-level multi-client registry beyond the singleton module surface
- translate one notification into multi-channel fan-out inside a single dispatch call
These limitations are part of the package contract so runtime choice, provider capability, and rollout strategy stay explicit at the application boundary.
Public API Overview
Core
SlackModule.forRoot(options)/SlackModule.forRootAsync(options)SlackModuleOptionsSlackAsyncModuleOptionsSlackServiceSlackService.send(message, options)SlackService.sendMany(messages, options)SlackService.sendNotification(notification, options)SlackService.createPlatformStatusSnapshot()SlackChannelSLACK_CHANNEL
Service and result contracts
SlackSendOptionsSlackSendManyOptionsSlackSendResultSlackSendBatchResultSlackSendFailure
Contracts and helpers
SlackMessageNormalizedSlackMessageSlackNotificationPayloadSlackNotificationDispatchRequestSlackBlockSlackAttachmentSlackTransportSlackTransportFactorySlackTransportContextSlackTransportReceiptSlackFetchLikeSlackFetchResponseSlackWebhookTransportOptionscreateSlackWebhookTransport(options)SlackTemplateRendererSlackTemplateRenderInputSlackTemplateRenderResult
Status and errors
createSlackPlatformStatusSnapshot(...)SlackPlatformStatusSnapshotSlackLifecycleStateSlackStatusAdapterInputSlackConfigurationErrorSlackLifecycleError: thrown by lifecycle-gated delivery before readiness, after initialization failure, or during shutdown, plus transport factory, verification, and owned-resource cleanup failures. Catch this error when sends can race with bootstrap or application teardown.SlackMessageValidationError: thrown when a direct message has no Slack-visibletext,blocks, orattachments, or when one notification dispatch resolves multiple Slack recipients and should be split withsendMany(...).SlackTransportError
Related Packages
@fluojs/notifications: Shared orchestration layer that consumesSLACK_CHANNEL.@fluojs/config: Recommended for resolving webhook URLs or tokens without direct environment access.@fluojs/event-bus: Useful when Slack notifications are one side effect among several event-driven workflows.
Example Sources
packages/slack/src/module.test.ts: Module registration, async wiring, webhook transport, and notifications integration examples.packages/slack/src/lifecycle-regression.test.ts: Bootstrap verification and shutdown ordering regression coverage.packages/slack/src/public-surface.test.ts: Public export and TypeScript contract verification.packages/slack/src/status.test.ts: Health/readiness contract examples.
Compatible service copies
SlackService and SlackChannel support compatible-copy class injection; Slack channel tokens remain isolated.
