@teever/ez-hook-effect
v0.6.1
Published
A Discord webhook library built with Effect - type-safe, composable, and resilient
Downloads
755
Readme
@teever/ez-hook-effect
A Discord webhook library built with Effect - type-safe, composable, and resilient.
This library is for Effect apps. Every operation returns an
Effectthat you run inside your Effect runtime (yield*inEffect.gen, orEffect.runPromiseat the edge). If you don't use Effect and just want to fire a webhook withasync/await, use the plain version instead: github.com/teeverc/ez-hook.
Features
- Type-safe - Runtime validation with Effect schemas
- Composable - Functional programming patterns with Effect
- Error Handling - Comprehensive error tracking with structured validation issues
- Retry Logic - Built-in exponential backoff with jitter
- Dependency Injection - Layer-based architecture
- Validated - All Discord webhook constraints enforced
- One Dependency - Only requires Effect
- Environment Support - Configure via environment variables
- CRUD Operations - Send, get, modify, delete, and validate webhooks
Installation
From npm:
bun add @teever/ez-hook-effect effect
# or
npm install @teever/ez-hook-effect effect
# or
pnpm add @teever/ez-hook-effect effect
# or
yarn add @teever/ez-hook-effect effectFrom JSR:
bunx jsr add @teever/ez-hook-effect
# or
npx jsr add @teever/ez-hook-effectQuick Start
Builders are pure, immutable plain values — assembling one needs no Effect. You compose a draft with pipe, then hand it straight to sendWebhook, which builds and validates it internally.
import { Effect, pipe } from 'effect'
import { makeWebhookLayer, sendWebhook, Webhook } from '@teever/ez-hook-effect'
// Configure your webhook
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
// Create service layer
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
// A draft is just a value — no Effect.gen required to assemble it
const webhook = pipe(
Webhook.make,
Webhook.setContent('Hello from Effect!'),
Webhook.setUsername('My Bot')
)
// sendWebhook accepts the draft directly and validates it internally
const program = sendWebhook(webhook)
Effect.runPromise(Effect.provide(program, AppLayer))Run it for real
Copy this into send.ts, set a real webhook URL, and run it with bun send.ts (or npx tsx send.ts). It exits non-zero and logs the error if delivery fails.
import { Effect, pipe } from 'effect'
import { makeWebhookLayer, sendWebhook, Webhook, formatWebhookError } from '@teever/ez-hook-effect'
const url = process.env.DISCORD_WEBHOOK_URL
if (!url) throw new Error('Set DISCORD_WEBHOOK_URL')
const program = sendWebhook(
pipe(Webhook.make, Webhook.setContent('Hello from ez-hook-effect 👋'))
).pipe(
Effect.tap(() => Effect.log('sent ✅')),
Effect.tapError((error) => Effect.logError(formatWebhookError(error)))
)
Effect.runPromise(Effect.provide(program, makeWebhookLayer(url)))DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN' bun send.tsThe Builder API
There is one builder API. Setters are pure: value in, value out. Nothing is validated until you reach a build boundary, so you can assemble, log, reuse, and branch a draft freely with no Effect involved.
Embed.make is a plain value, and an embed draft can be handed straight to Webhook.addEmbed — Webhook.build (run for you inside sendWebhook) applies type: "rich", validates every embed, and enforces all limits (max 10 embeds, max 25 fields, lengths, color range, URLs) in a single pass.
import { Effect, pipe } from 'effect'
import { Webhook, Embed, sendWebhook, makeWebhookLayer } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
// Pure value — no Effect.gen needed to build the embed
const embed = pipe(
Embed.make,
Embed.setTitle('System Status'),
Embed.setDescription('All systems operational'),
Embed.setColor(0x00ff00),
Embed.addField('CPU', '45%', true),
Embed.setTimestamp()
)
// addEmbed takes the embed draft directly (no manual Embed.build)
const webhook = pipe(
Webhook.make,
Webhook.setContent('📊 Status Update'),
Webhook.setUsername('Ez-Hook Bot'),
Webhook.addEmbed(embed)
)
// sendWebhook builds + validates the draft for you
const program = sendWebhook(webhook)
Effect.runPromise(Effect.provide(program, AppLayer))Data-last and data-first setters
Every setter is dual: use the data-last (pipeable) form inside pipe, or the data-first form when you already have the draft in hand. Both produce a new, immutable value.
import { pipe } from 'effect'
import { Embed } from '@teever/ez-hook-effect'
// data-last (pipeable)
const a = pipe(Embed.make, Embed.setTitle('Status'), Embed.setColor('#00ff00'))
// data-first (same result)
const b = Embed.setColor(Embed.setTitle(Embed.make, 'Status'), '#00ff00')
// setColor accepts a number, "#RRGGBB", or "RRGGBB" — all normalized purely
const c = pipe(Embed.make, Embed.setColor(0x00ff00))
const d = pipe(Embed.make, Embed.setColor('00ff00'))Getting the validated payload as a value
sendWebhook builds internally on the happy path, but Webhook.build is still available when you want the validated, decoded payload as a value (e.g. to inspect, store, or send later).
import { Effect, pipe } from 'effect'
import { Webhook } from '@teever/ez-hook-effect'
const draft = pipe(Webhook.make, Webhook.setContent('Hello!'))
// Effect<WebhookType, ValidationError> — no service required to build
const program = Webhook.build(draft)
Effect.runPromise(program).then((payload) => console.log(payload))Setters & limits
All limits are Discord's; build (and sendWebhook) enforce them in one pass and fail with a ValidationError if exceeded.
Webhook.*
| Setter | Accepts | Limit |
| --- | --- | --- |
| setContent(text) | string | ≤ 2000 chars |
| setUsername(name) | string | ≤ 80 chars |
| setAvatarUrl(url) | URL string | valid URL |
| setTTS(flag) | boolean | — |
| setThreadName(name) | string | ≤ 100 chars (creates a forum/media thread) |
| setFile(file) | string | Attachment | { name, data } | upload data: string or Uint8Array; ≤ 10 MiB default |
| addEmbed(embed) / addEmbeds([...]) | embed draft(s) | ≤ 10 embeds total |
| setEmbeds([...]) | embed drafts | replaces all; ≤ 10 |
Embed.*
| Setter | Accepts | Limit |
| --- | --- | --- |
| setTitle(text) | string | ≤ 256 chars |
| setDescription(text) | string | ≤ 4096 chars |
| setURL(url) | URL string | valid URL |
| setColor(c) | number, "#RRGGBB", or "RRGGBB" | 0 – 16777215 (0xFFFFFF) |
| setTimestamp(date?) | Date (defaults to now) | ISO 8601 |
| setFooter(text \| { text, icon_url }) | string or object | text ≤ 2048 chars |
| setAuthor(name \| { name, url, icon_url }) | string or object | name ≤ 256 chars |
| setImage(url \| Attachment) | URL or Attachment | — |
| setThumbnail(url \| Attachment) | URL or Attachment | — |
| addField(name, value, inline?) / setFields([...]) | strings + bool | ≤ 25 fields; name ≤ 256, value ≤ 1024 |
Every setter is dual (data-last in pipe, or data-first) and pure — nothing is validated until a build boundary (Webhook.build, Embed.build, or sendWebhook).
Attaching a File
Use setFile({ name, data }) to upload a file; Discord receives it as a multipart upload with the non-file webhook fields in payload_json. The library validates Discord's default per-file upload limit of 10 MiB. Discord may allow larger files for Nitro users or boosted servers, but webhooks do not expose that context for local validation.
import { Effect, pipe } from 'effect'
import { makeWebhookLayer, sendWebhook, Webhook } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const webhook = pipe(
Webhook.make,
Webhook.setContent('Logs attached'),
Webhook.setFile({ name: 'report.txt', data: 'hello from ez-hook-effect' })
)
Effect.runPromise(Effect.provide(sendWebhook(webhook), AppLayer))Thread-Aware Delivery
Use threadId when the webhook should post into an existing thread, and Webhook.setThreadName(...) when Discord should create a forum/media thread from the message payload.
import { Effect, pipe } from 'effect'
import { makeWebhookLayer, sendWebhook, Webhook } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL, {
threadId: '123456789012345678',
wait: true,
})
const webhook = pipe(
Webhook.make,
Webhook.setThreadName('Day-Use Alerts'),
Webhook.setContent('A new alert arrived')
)
const program = sendWebhook(webhook)
Effect.runPromise(Effect.provide(program, AppLayer))Building Rich Embeds
Embeds are pure values you can build up field by field, then pass directly to Webhook.addEmbed. Sub-objects like the footer or author accept either a string or an object form.
import { Effect, pipe } from 'effect'
import { Embed, Webhook, sendWebhook, makeWebhookLayer } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const embed = pipe(
Embed.make,
Embed.setTitle('Server Status'),
Embed.setDescription('All systems operational'),
Embed.setColor('#00ff00'),
Embed.addField('CPU', '45%', true),
Embed.addField('Memory', '2.1GB', true),
Embed.addField('Uptime', '15 days', true),
Embed.setFooter('Reported by Ez-Hook Bot'),
Embed.setTimestamp()
)
const webhook = pipe(
Webhook.make,
Webhook.setContent('Daily Report'),
Webhook.addEmbed(embed)
)
const embedProgram = sendWebhook(webhook)
Effect.runPromise(Effect.provide(embedProgram, AppLayer))Error Handling
Retries for rate limits (429) and transient errors (5xx) are handled automatically using configurable exponential backoff. sendWebhook fails the Effect on any non-204 response, so errors always surface to your error handling paths.
import { Effect, pipe } from 'effect'
import { Webhook, sendWebhook, makeWebhookLayer } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const webhook = pipe(Webhook.make, Webhook.setContent('Hello!'))
// sendWebhook validates the draft internally, so ValidationError can surface here too
const program = sendWebhook(webhook).pipe(
Effect.catchTags({
ValidationError: (error) =>
Effect.logError(`Validation failed: ${error.message}`),
RateLimitError: (error) =>
Effect.logError(`Rate limited: retry after ${error.retryAfter}ms`),
HttpError: (error) =>
Effect.logError(`Request failed: ${error.message}`),
NetworkError: (error) =>
Effect.logError(`Network error: ${error.message}`),
WebhookError: (error) =>
Effect.logError(`Webhook error: ${error.message}`),
})
)
Effect.runPromise(Effect.provide(program, AppLayer))Structured Validation Errors
Validation errors include detailed issue information:
Effect.catchTag('ValidationError', (error) =>
Effect.logError(`Validation failed:\n${error.format()}`)
)Error Helpers
import { Effect, pipe } from 'effect'
import { Webhook, sendWebhook, makeWebhookLayer, formatWebhookError, webhookErrorToLogObject } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const webhook = pipe(Webhook.make, Webhook.setContent('Hello!'))
const program = sendWebhook(webhook).pipe(
Effect.catchAll((error) =>
Effect.logError(formatWebhookError(error)).pipe(
Effect.tap(() => Effect.log(JSON.stringify(webhookErrorToLogObject(error))))
)
)
)
Effect.runPromise(Effect.provide(program, AppLayer))Raw Response
When you need status/body, use sendWebhookRaw:
import { Effect, pipe } from 'effect'
import { sendWebhookRaw, Webhook, makeWebhookLayer } from '@teever/ez-hook-effect'
const WEBHOOK_URL = 'https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const webhook = pipe(Webhook.make, Webhook.setContent('Hello!'))
// sendWebhookRaw also accepts a draft and builds it internally
const program = Effect.gen(function* () {
const res = yield* sendWebhookRaw(webhook)
yield* Effect.log(`Status: ${res.status}`)
})
Effect.runPromise(Effect.provide(program, AppLayer))Validate Only
import { Effect, pipe } from 'effect'
import { Webhook } from '@teever/ez-hook-effect'
const program = Effect.gen(function* () {
yield* Webhook.validate({ content: 'Hello!' })
yield* Effect.log('Valid payload')
})
// pure — validation needs no service layer
Effect.runPromise(program)Configuration Options
Pass options as the second argument to makeWebhookLayer — this is the standard way to configure the layer.
import { makeWebhookLayer } from '@teever/ez-hook-effect'
const AppLayer = makeWebhookLayer(WEBHOOK_URL, {
maxRetries: 5, // Maximum retry attempts
baseDelayMs: 1000, // Base delay for exponential backoff
maxDelayMs: 60000, // Maximum delay between retries
enableJitter: true, // Add randomness to retry delays
threadId: '123...', // Optional existing Discord thread target
wait: true, // Accept Discord's JSON response instead of 204
})Environment Variables
import { WebhookConfigFromEnv, WebhookHttpClientLive, WebhookServiceLive } from '@teever/ez-hook-effect'
// Reads from DISCORD_WEBHOOK_URL, WEBHOOK_MAX_RETRIES, etc.
const AppLayer = WebhookServiceLive.pipe(
Layer.provide(WebhookConfigFromEnv),
Layer.provide(WebhookHttpClientLive)
)
// Available environment variables:
// DISCORD_WEBHOOK_URL - Webhook URL (required)
// WEBHOOK_MAX_RETRIES - Maximum retry attempts (default: 3)
// WEBHOOK_BASE_DELAY_MS - Base delay in ms (default: 1000)
// WEBHOOK_MAX_DELAY_MS - Maximum delay in ms (default: 60000)
// WEBHOOK_ENABLE_JITTER - Enable jitter (default: true)
// WEBHOOK_THREAD_ID - Existing Discord thread to post into (optional)
// WEBHOOK_WAIT - Request a response body from Discord (default: false)Manual layer composition
makeWebhookLayer(url, options) is sugar for the composition below. Reach for the manual form only when you need to swap an individual layer — e.g. plug in WebhookConfigFromEnv (above) or a custom/test HTTP client (see Testing Utilities).
import { Layer } from 'effect'
import { WebhookServiceLive, makeWebhookConfigLayer, WebhookHttpClientLive } from '@teever/ez-hook-effect'
// identical to makeWebhookLayer(WEBHOOK_URL)
const AppLayer = WebhookServiceLive.pipe(
Layer.provide(makeWebhookConfigLayer(WEBHOOK_URL)),
Layer.provide(WebhookHttpClientLive)
)Parse Webhook URL
Extract webhook ID and token from a URL:
import { Effect } from 'effect'
import { parseWebhookUrl } from '@teever/ez-hook-effect'
const program = Effect.gen(function* () {
const result = yield* parseWebhookUrl('https://discord.com/api/webhooks/123456789/token')
yield* Effect.log(`Webhook id: ${result.id}`)
})
// pure — parsing needs no service layer
Effect.runPromise(program)Webhook Operations
Beyond sending messages, the library supports full webhook CRUD:
import { Effect } from 'effect'
import { getWebhook, modifyWebhook, deleteWebhook, checkWebhook, makeWebhookLayer } from '@teever/ez-hook-effect'
const AppLayer = makeWebhookLayer(WEBHOOK_URL)
const program = Effect.gen(function* () {
// Check if webhook is valid and accessible
const isValid = yield* checkWebhook()
// Get webhook information
const info = yield* getWebhook()
// Modify webhook settings
const modified = yield* modifyWebhook({
name: 'New Name',
})
// Delete the webhook
const deleted = yield* deleteWebhook()
})
Effect.runPromise(Effect.provide(program, AppLayer))Testing Utilities
import { Effect, Layer } from 'effect'
import { makeTestWebhookHttpClient, WebhookServiceLive, makeWebhookConfigLayer } from '@teever/ez-hook-effect'
const TestHttpClient = makeTestWebhookHttpClient((_req) =>
Effect.succeed({
status: 204,
statusText: 'No Content',
headers: {},
body: null,
text: '',
})
)
const AppLayer = WebhookServiceLive.pipe(
Layer.provide(makeWebhookConfigLayer('https://discord.com/api/webhooks/123/abc')),
Layer.provide(TestHttpClient)
)Development
bun run lint # Biome lint + format
bun run typecheck # TypeScript type checkingTesting
The library includes comprehensive tests for all features:
bun run test # Run all tests (vitest)
bun run test:watch # Watch mode
bun run test:coverage # Coverage reportBuilding
bun run build # Build the library
bun run build:standalone # Create standalone executableExamples
Check out the examples/ directory:
01-basic-webhook.ts- Send a simple message02-rich-embeds.ts- Build embeds with fields, colors, metadata03-error-handling.ts- Validation errors and recovery patterns04-multiple-embeds.ts- Add several embeds to one webhook05-service-layer.ts- Dependency injection with layers and CRUD operations
License
MIT
