Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# @suaveplan/slack-client - Typed deterministic Slack client — Slack Web API + incoming-webhook surface, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike, stable defineCodes('SLACK', [...]) error registry. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock` / `Rng` / `LoggerLike` / `FetchLike`, and a stable `defineCodes("SLACK", […])` error registry.
|
||||
|
||||
Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [classify](./src/client/classify.md) — `classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES…
|
||||
- [retry-after](./src/client/retry-after.md) — `retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-Afte…
|
||||
- [slack-api-client](./src/client/slack-api-client.md) — `SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on —…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [otel](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [metrics](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
- [slack-webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/slack-client
|
||||
```
|
||||
|
||||
## Two clients
|
||||
|
||||
- `SlackApiClient` — Slack Web API. Token-authenticated. Supports `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers`, `getStatus`.
|
||||
- `SlackWebhook` — Slack incoming webhooks. URL-authenticated. Supports `send` and `sendText`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { SlackApiClient, SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const api = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
await api.sendMessage({ channel: "C0123", text: "Deploy succeeded." });
|
||||
|
||||
const hook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
});
|
||||
await hook.sendText("Build broken on main.");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `SLACK_CODES` registry + 8 typed error subclasses.
|
||||
- [`types/slack-client`](./src/types/slack-client.types.ts) — public shapes.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod boundary schemas.
|
||||
- [`client/slack-api-client`](./src/client/slack-api-client.md) — Slack Web API client.
|
||||
- [`client/retry-after`](./src/client/retry-after.md) — `Retry-After` / `x-ratelimit-reset` parser.
|
||||
- [`client/classify`](./src/client/classify.md) — HTTP-status + Slack-error string classifier.
|
||||
- [`webhook/slack-webhook`](./src/webhook/slack-webhook.md) — incoming-webhook client.
|
||||
- [`otel/metrics`](./src/otel/metrics.md) — four OpenTelemetry instruments.
|
||||
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
|
||||
|
||||
## Error codes
|
||||
|
||||
All eight reasons land under the `SLACK.*` namespace:
|
||||
|
||||
- `SLACK.API_ERROR` — generic non-ok response from the Slack API.
|
||||
- `SLACK.AUTH_ERROR` — token rejected (HTTP 401/403, `invalid_auth`, `token_revoked`, `account_inactive`).
|
||||
- `SLACK.RATE_LIMITED` — HTTP 429; `context.retryAfterMs` carries parsed delay.
|
||||
- `SLACK.CHANNEL_NOT_FOUND` — HTTP 404 or `channel_not_found` / `is_archived` / `not_in_channel`.
|
||||
- `SLACK.WORKSPACE_NOT_FOUND` — `team_not_found` / `no_workspace`.
|
||||
- `SLACK.CONFIG_MISSING` — empty/invalid token or webhook URL at construct time.
|
||||
- `SLACK.WEBHOOK_FAILED` — webhook POST returned non-2xx (non-auth).
|
||||
- `SLACK.TIMEOUT` — internal `timeoutMs` fired OR caller `AbortSignal` aborted OR network failure.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
Four instruments under meter `@suaveplan/slack-client@0.1.0`:
|
||||
|
||||
- `slack.requests` (counter) — `{ method, outcome }`
|
||||
- `slack.errors` (counter) — `{ method, code }`
|
||||
- `slack.latency_ms` (histogram, unit `ms`) — `{ method, outcome }`
|
||||
- `slack.webhook_sends` (counter) — `{ outcome }`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# `client/classify` — HTTP status + Slack-error classifier
|
||||
|
||||
## Purpose
|
||||
|
||||
`classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES` registry. `classifyHttpStatus` partitions every observed HTTP status code into one of `AUTH_ERROR`, `CHANNEL_NOT_FOUND`, `TIMEOUT`, `RATE_LIMITED`, or the catch-all `API_ERROR`. `classifySlackError` does the same for Slack's JSON-level error strings (`invalid_auth`, `channel_not_found`, `team_not_found`, etc.). Centralising the classification keeps the API client and the webhook client in lock-step and gives downstream retry wrappers a single source of truth for what the package considers a retryable failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Total HTTP-status partition — every numeric status maps to exactly one code.
|
||||
- Total Slack-error partition — every JSON `error` string maps to exactly one code, with regex matchers grouping family members (`is_archived`/`not_in_channel` count as `CHANNEL_NOT_FOUND`).
|
||||
- Pure functions — no side effects, no module-scope state, safe to tree-shake.
|
||||
- Literal-typed return — `SlackErrorCode` discriminated union is preserved.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
|
||||
const fromStatus = classifyHttpStatus(429);
|
||||
// "SLACK.RATE_LIMITED"
|
||||
|
||||
const fromBody = classifySlackError("invalid_auth");
|
||||
// "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
import { SLACK_CODES } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
function isRetryable(code: ReturnType<typeof classifyHttpStatus>): boolean {
|
||||
return (
|
||||
code === SLACK_CODES.RATE_LIMITED ||
|
||||
code === SLACK_CODES.TIMEOUT ||
|
||||
code === SLACK_CODES.API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const retry = isRetryable(classifyHttpStatus(503));
|
||||
// true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `classifyHttpStatus(status: number): SlackErrorCode`
|
||||
- `classifySlackError(slackError: string): SlackErrorCode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Slack-error matchers use case-insensitive regular expressions because Slack's documentation is inconsistent on casing and the API has historically returned both `invalid_auth` and `Invalid_Auth` in different surfaces. The order of the checks is important — `invalid_auth` must match before any `not_*` matcher because `not_authed` is an auth error, not a channel error. The HTTP-status partition deliberately uses `===` comparisons rather than range checks so that adding a new status (e.g. 451) requires an explicit decision rather than silently joining the catch-all bucket.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `client/retry-after` — Retry-After header parser
|
||||
|
||||
## Purpose
|
||||
|
||||
`retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-After` header into a millisecond delay suitable for backoff loops. Slack's rate limiter emits both the standard `Retry-After` header (RFC 7231) and a Slack-specific `x-ratelimit-reset` header in epoch-seconds form, sometimes ISO-8601, sometimes both — the parser handles every observed shape and degrades gracefully to `undefined` when nothing parseable is present. The function injects a `Clock` so tests can pin the wall-clock at a deterministic instant and assert against an exact millisecond delta.
|
||||
|
||||
## Features
|
||||
|
||||
- Delay-seconds form (`"30"`) — returns `seconds * 1000`.
|
||||
- HTTP-date form (`"Wed, 21 Oct 2026 07:28:00 GMT"`) — returns `parsed - clock()`.
|
||||
- `x-ratelimit-reset` fallback in epoch-seconds, epoch-milliseconds (auto-detected by magnitude), or ISO-8601.
|
||||
- `undefined` when no header is present OR all parse attempts fail.
|
||||
- Negative deltas are clamped to `0` (a past reset means retry immediately).
|
||||
- Pure function — no module-scope clock, no `Date.now()` baked in.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
const delay = parseRetryAfterMs(response);
|
||||
if (delay !== undefined) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
// Pin the clock for a deterministic test
|
||||
const t0 = 1_000_000_000_000;
|
||||
const fakeResponse = new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": new Date(t0 + 60_000).toUTCString() },
|
||||
});
|
||||
const delay = parseRetryAfterMs(fakeResponse, () => t0);
|
||||
// delay ≈ 60000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`parseRetryAfterMs(response: Response, clock?: Clock): number | undefined`
|
||||
|
||||
- `response` — any `Response` whose `headers` are inspected.
|
||||
- `clock` — wall-clock source defaulted to `() => Date.now()`.
|
||||
- Returns the milliseconds-to-wait, or `undefined` when no header is parseable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The epoch detection uses the magnitude heuristic `resetNum < 1e12` to distinguish seconds from milliseconds — `1e12` corresponds to year 33658 in epoch-seconds and is therefore safely "in the future" for both interpretations. The function intentionally tests `Number.isFinite` first, before `Date.parse`, because a numeric `Retry-After` like `"0"` produces `Date.parse("0") = NaN` in some engines and `0` in others, and the seconds branch handles the universal case unambiguously.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `client/slack-api-client` — Slack Web API client
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on — `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers` — plus `getStatus` for retrieving the delivery report stored after a successful `sendMessage`. The client accepts an injectable transport bag (`fetch`, `clock`, `logger`) so production code can pass `globalThis.fetch` while tests inject a `FakeFetch`. Every public method is bounded by an internal timeout enforced via `AbortController`, supports a caller-supplied `AbortSignal`, classifies non-2xx responses through the shared HTTP / Slack-error matchers, and emits the four OTel instruments defined in `otel/metrics`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — empty `token` rejects with `SlackConfigMissingError` whose `cause` is the originating `ZodError`.
|
||||
- Eight typed error subclasses surfaced through `classify` + `#errorForCode`.
|
||||
- `Retry-After` and `x-ratelimit-reset` parsing on HTTP 429 — millisecond delta exposed via `error.context.retryAfterMs`.
|
||||
- snake_case → camelCase mapping for channel and user payloads with `undefined`-elision so the public shape stays compatible with `exactOptionalPropertyTypes`.
|
||||
- Internal `timeoutMs` (default 10 000 ms) AND caller `AbortSignal` both routed through one `AbortController`.
|
||||
- `slack.requests` / `slack.errors` / `slack.latency_ms` emitted from every code path.
|
||||
- Delivery-report cache (`Map<ts, SlackDeliveryReport>`) for `getStatus(ts)` lookups.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
|
||||
const client = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
const res = await client.sendMessage({
|
||||
channel: "C0123456789",
|
||||
text: "Build is green :white_check_mark:",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: { ok: true, channel: "C1", ts: "1.1" },
|
||||
});
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const client = new SlackApiClient({
|
||||
token: "xoxb-test",
|
||||
fetch: fake.fetch,
|
||||
clock: () => now++,
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const res = await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
const report = client.getStatus(res.ts!);
|
||||
// report.status === "delivered"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackApiClient(options: SlackApiClientOptions)`
|
||||
- `sendMessage(message: SlackMessage, signal?: AbortSignal): Promise<SlackResponse>`
|
||||
- `updateMessage(channel, ts, text, blocks?): Promise<SlackResponse>`
|
||||
- `deleteMessage(channel, ts): Promise<SlackResponse>`
|
||||
- `listChannels(limit?): Promise<SlackChannel[]>`
|
||||
- `getChannelInfo(channelId): Promise<SlackChannel>`
|
||||
- `listUsers(limit?): Promise<SlackUser[]>`
|
||||
- `getStatus(ts: string): SlackDeliveryReport | undefined`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackChannelNotFoundError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackTimeoutError`, `SlackWorkspaceNotFoundError`, `SlackApiError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The client uses private `#`-prefixed class fields (not TS `private`) so consumer tooling cannot accidentally reach into the instance through bracket access — the contract is enforced at the runtime level by the engine. The request method assembles one `AbortController` that listens to both the caller signal AND the internal timer, then aborts on whichever fires first; the listener is removed in `finish()` to avoid leaking attached handlers across requests. The `#errorForCode` switch is exhaustive over `SlackErrorCode` and defaults to `SlackApiError` for forward-compatibility when the registry expands.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `error-codes` — Slack error-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It registers a stable namespace `SLACK` with eight reason codes via `defineCodes("SLACK", [...])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. Side-effect import from `src/index.ts` ensures the codes are part of the package's public ABI even for consumers that pull a subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable reason codes: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `CONFIG_MISSING`, `WEBHOOK_FAILED`, `TIMEOUT`.
|
||||
- One typed subclass per code, extending an abstract `SlackError` base.
|
||||
- Compile-time-frozen `SLACK_CODES` record with literal `"SLACK.REASON"` values.
|
||||
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
|
||||
- `SlackRateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackAuthError,
|
||||
type SlackErrorCode,
|
||||
} from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
try {
|
||||
// ... call Slack ...
|
||||
} catch (err) {
|
||||
if (err instanceof SlackAuthError) {
|
||||
// err.code is literal-typed as "SLACK.AUTH_ERROR"
|
||||
const code: SlackErrorCode = err.code;
|
||||
void code;
|
||||
}
|
||||
}
|
||||
|
||||
void SLACK_CODES.AUTH_ERROR; // "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackRateLimitedError } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
throw new SlackRateLimitedError({
|
||||
message: "slack.errors.rateLimited",
|
||||
retryAfterMs: 30_000,
|
||||
context: { url: "https://slack.com/api/chat.postMessage" },
|
||||
traceId: "trace-abc",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SLACK_CODES` — frozen record: `{ readonly [K in Reason]: "SLACK.${K}" }`.
|
||||
- `SlackErrorCode` — discriminated union of all eight literal codes.
|
||||
- `SlackError` — abstract base extending `SuaveplanError<SlackErrorCode>`.
|
||||
- `SlackApiError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackChannelNotFoundError`, `SlackWorkspaceNotFoundError`, `SlackConfigMissingError`, `SlackWebhookFailedError`, `SlackTimeoutError` — concrete subclasses.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` rather than the legacy `ErrorCodes.registerMany` runtime registry from the upstream library. `defineCodes` is purely type-level + a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in the `tsconfig.base.json` — passing `{ traceId: undefined }` directly would violate that flag.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `fakes/fake-fetch` — Deterministic FetchLike test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every transport-level scenario without monkey-patching `globalThis.fetch` and without `vi.mock`. Each `FakeFetch` instance carries a FIFO queue of scripted `FakeFetchResponse` objects and a `calls` array that records every invocation in order. The `fetch` property satisfies the `FetchLike` shape from `@suaveplan/types`, so any client that accepts a `FetchLike` (notably `SlackApiClient` and `SlackWebhook`) can be driven directly from a test without touching the global. Downstream packages can re-export the class for their own tests too.
|
||||
|
||||
## Features
|
||||
|
||||
- FIFO response queue — the first `push` is consumed by the first `fetch` call.
|
||||
- Captured-call recording — `calls[]` preserves every URL and `RequestInit` verbatim.
|
||||
- Three response modes — scripted (status + body), `pending: true` (never resolves), `reject: Error` (immediate rejection).
|
||||
- Abort propagation — a `pending` response respects the caller's `AbortSignal` and rejects with an `AbortError` whose `name === "AbortError"`.
|
||||
- Headers via the standard WHATWG `Headers` API — case-insensitive lookup, multi-value support.
|
||||
- Zero external deps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
|
||||
const res = await fake.fetch("https://slack.com/api/chat.postMessage", {
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
// body.ok === true
|
||||
// fake.calls[0].url === "https://slack.com/api/chat.postMessage"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
|
||||
const controller = new AbortController();
|
||||
const promise = fake.fetch("https://example", { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `push(response: FakeFetchResponse): void` — queue a scripted response.
|
||||
- `readonly fetch: FetchLike` — the callable to inject into the client under test.
|
||||
- `readonly calls: FakeFetchCall[]` — captured calls in FIFO order.
|
||||
- `interface FakeFetchResponse { status?: number; headers?: Record<string, string>; json?: unknown; text?: string; pending?: boolean; reject?: Error }`
|
||||
- `interface FakeFetchCall { url: string; init: RequestInit | undefined }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `fetch` member is declared as an arrow function so the `this` binding is preserved when callers destructure (`const { fetch } = new FakeFetch()`). The `pending` mode allocates a `Promise` whose only resolution path is via the caller's `AbortSignal`; this matches the abort semantics of real `globalThis.fetch` and lets us pin the in-flight-then-cancelled scenario without a real timer. The shim builds the response body through the standard `Response` constructor so callers see exactly the WHATWG shape (`.json()`, `.text()`, `.headers`) — no bespoke method set means the double is byte-compatible with `globalThis.fetch`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `otel/metrics` — OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the helper functions every production path uses to record observations: `recordRequest`, `recordError`, `recordWebhook`. The module intentionally retrieves the meter once at module load AND exposes `refreshSlackMeters()` so tests can rewire the meter against a real in-memory `MeterProvider` after configuring the global provider. This keeps the production hot path free of `MeterProvider` lookups while still permitting `getMetrics()` assertions in the test suite.
|
||||
|
||||
## Features
|
||||
|
||||
- Four instruments: `slack.requests` (Counter), `slack.errors` (Counter), `slack.latency_ms` (Histogram, unit `ms`), `slack.webhook_sends` (Counter).
|
||||
- Attributes — `{ method, outcome }` on requests, `{ method, code }` on errors, `{ outcome }` on webhook sends.
|
||||
- Module-load resolution against `metrics.getMeter("@suaveplan/slack-client", "0.1.0")`.
|
||||
- `refreshSlackMeters()` for tests to rebind after `metrics.setGlobalMeterProvider(...)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordRequest } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
recordRequest("chat.postMessage", "ok", 142);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { refreshSlackMeters } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const provider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSlackMeters();
|
||||
|
||||
// ... run code that records ...
|
||||
|
||||
await provider.forceFlush();
|
||||
const observed = exporter.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRequest(method: string, outcome: "ok" | "error", latencyMs: number): void` — increments `slack.requests` and records `slack.latency_ms`.
|
||||
- `recordError(method: string, code: string): void` — increments `slack.errors`.
|
||||
- `recordWebhook(outcome: "ok" | "error"): void` — increments `slack.webhook_sends`.
|
||||
- `refreshSlackMeters(): void` — re-resolves every instrument against the current global meter provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are held in module-scope `let` bindings (not `const`) because `refreshSlackMeters()` rebinds them after a test wires a fresh `MeterProvider`. This is the standard pattern for packages that exercise OTel in tests via a real in-memory exporter — the alternative (proxying every call through a getter) adds a per-call indirection on the production hot path. Instrument names follow the `<domain>.<metric>` convention seen in the wider ecosystem; the histogram unit is set to `"ms"` so dashboards render correctly without a unit-conversion step.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `schemas` — Zod boundary validation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that crosses the package boundary. The schemas are the single source of truth for the runtime shape contract — they pair with the structural types in `types/slack-client.types.ts` so callers can rely on identical compile-time AND runtime guarantees. Schema-driven validation lets the constructors reject malformed input with a typed `SlackConfigMissingError` whose `cause` is the original `ZodError`, surfacing the offending field path through the standard Zod issue list.
|
||||
|
||||
## Features
|
||||
|
||||
- `SlackApiClientOptionsSchema` — validates `{ token, baseUrl?, defaultChannel?, timeoutMs? }`.
|
||||
- `SlackWebhookOptionsSchema` — validates `{ webhookUrl, timeoutMs? }`.
|
||||
- `SlackMessageSchema` — validates the `chat.postMessage` payload.
|
||||
- `SlackBlockSchema`, `SlackBlockElementSchema`, `SlackTextObjectSchema` — Block Kit boundary shapes.
|
||||
- All schemas are exported as values (not just types) so callers can re-use them at their own boundaries.
|
||||
- Strict-mode (`.strict()`) on Block Kit shapes; `.passthrough()` on top-level option bags so callers may forward arbitrary transport overrides (`fetch`, `clock`, `rng`, `logger`) which Zod cannot describe.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackMessageSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const parsed = SlackMessageSchema.parse({
|
||||
channel: "C123",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClientOptionsSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const result = SlackApiClientOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.warn(issue.path.join("."), issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SlackApiClientOptionsSchema: ZodObject` — primary client options.
|
||||
- `SlackWebhookOptionsSchema: ZodObject` — webhook options.
|
||||
- `SlackMessageSchema: ZodObject` — full message payload.
|
||||
- `SlackBlockSchema: ZodObject` — single block.
|
||||
- `SlackBlockElementSchema: ZodType` — nested block element.
|
||||
- `SlackTextObjectSchema: ZodObject` — Block Kit text composition object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SlackBlockElementSchema` is annotated with an explicit `ZodType<...>` shape because TypeScript cannot infer the recursive cross-reference between blocks and elements through Zod's chained builders. The top-level option schemas use `.passthrough()` rather than `.strict()` because the transport bag (`fetch`, `clock`, `rng`, `logger`) is structural and falls outside Zod's serialisable surface — strict mode would reject these legitimate option keys.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `webhook/slack-webhook` — Slack incoming-webhook client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackApiClient` requires an OAuth token and supports the full Web API, the webhook client takes only the URL and POSTs `{ text, blocks? }` payloads. It exists because many ops surfaces (CI bots, alerting, build notifications) only need fire-and-forget delivery and the webhook URL is the lower-friction credential — no token rotation, no scoped permissions. The client returns a `SlackDeliveryReport` on success and surfaces typed errors on every failure path so retry middleware can pattern-match on `.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — invalid `webhookUrl` rejects with `SlackConfigMissingError`.
|
||||
- Three failure paths — network/timeout (`SlackTimeoutError`), HTTP 401/403 (`SlackAuthError`), other 4xx/5xx (`SlackWebhookFailedError`).
|
||||
- Injectable `Clock` for deterministic message-id timestamps.
|
||||
- Optional `Rng` injection — when supplied, the message-id suffix uses `rng.nextUint32().toString(36)` for uniqueness across rapidly-issued sends; otherwise falls back to the monotonic counter.
|
||||
- `slack.webhook_sends` and `slack.errors` OTel instruments emitted on every path.
|
||||
- Caller-supplied `AbortSignal` AND internal `timeoutMs` (default 10 000 ms) both honoured.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T0/B0/X",
|
||||
});
|
||||
|
||||
const report = await webhook.sendText("Deploy complete.");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
|
||||
let counter = 0;
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000 + counter++,
|
||||
rng: { nextUint32: () => 42, nextFloat: () => 0.42 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const report = await webhook.send({
|
||||
text: "Block kit",
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Hi*" } }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackWebhook(options: SlackWebhookOptions)`
|
||||
- `send(message: { text: string; blocks?: SlackBlock[] }, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
- `sendText(text: string, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackTimeoutError`, `SlackAuthError`, `SlackWebhookFailedError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The message-id format is `webhook-<ms>-<counter>-<rngOrCounter>` so even rapid bursts on the same clock tick produce distinct ids. The counter is monotone within a single instance; injecting an `Rng` adds cross-instance uniqueness without coupling to `crypto.randomUUID` (which is universally available but adds an unwanted dependency on the runtime's CSPRNG availability in test environments). The constructor explicitly binds `globalThis.fetch` to `globalThis` when used as default — without the bind, `fetch.call(undefined, ...)` triggers an "Illegal invocation" `TypeError` in some browser builds.
|
||||
Reference in New Issue
Block a user