npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 Effect that you run inside your Effect runtime (yield* in Effect.gen, or Effect.runPromise at the edge). If you don't use Effect and just want to fire a webhook with async/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 effect

From JSR:

bunx jsr add @teever/ez-hook-effect
# or
npx jsr add @teever/ez-hook-effect

Quick 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.ts

The 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.addEmbedWebhook.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 checking

Testing

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 report

Building

bun run build         # Build the library
bun run build:standalone  # Create standalone executable

Examples

Check out the examples/ directory:

  • 01-basic-webhook.ts - Send a simple message
  • 02-rich-embeds.ts - Build embeds with fields, colors, metadata
  • 03-error-handling.ts - Validation errors and recovery patterns
  • 04-multiple-embeds.ts - Add several embeds to one webhook
  • 05-service-layer.ts - Dependency injection with layers and CRUD operations

License

MIT