@shivaedev/webhooks 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @shivaedev/webhooks
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Receive signed webhooks without building signature checks, delivery deduplication and receipt logging in every application. One route records a delivery and queues its work atomically; your workers own the feature.
4
+
5
+ ## Receive once, work in queues
6
+
7
+ A sender can retry or send concurrent copies. Your application declares which events become jobs, and the inbox queues them once per source and delivery ID.
8
+
9
+ ```ts
10
+ const sources = {
11
+ github: githubSource({
12
+ key: Config.Redacted("GITHUB_WEBHOOK_SECRET"),
13
+ events: {
14
+ issues: webhookEvent(IssueEvent, ({ issue }) => [
15
+ webhookJob(IndexIssue, { number: issue.number }),
16
+ ]),
17
+ },
18
+ }),
19
+ };
20
+ ```
21
+
22
+ ## Using the inbox
23
+
24
+ A source has a name, a verification strategy, a signing key loaded through Effect Config, and event registrations. Strategies read delivery identity and event names from headers and verify the raw bytes before JSON decoding. Event schemas describe only the fields your application needs; the mapping returns typed job requests. No worker handler runs in the route.
25
+
26
+ ### Register receipts and queues
27
+
28
+ Register `webhookReceipts` in the host's `migrations.components` alongside `pgBossSchema()`. The normal migration mode applies it; `check` reports a missing component, and `wait` waits for it. The receipt table is `webhook_receipts`, keyed by `(source, delivery_id)`, with the event, a UTC `timestamptz` received time, `queued` or `ignored` outcome, and a JSON array of queued job IDs.
29
+
30
+ ```ts
31
+ import { Config, Effect, Layer, Redacted, Schema } from "effect";
32
+ import { SqlClient } from "effect/sql";
33
+ import { defineQueue } from "@shivaedev/jobs/server/definition.ts";
34
+ import { pgBossSchema } from "@shivaedev/jobs/server/schema.ts";
35
+ import { makePgBoss } from "@shivaedev/jobs/server/service.ts";
36
+ import { appHost } from "@shivaedev/server/appHost.ts";
37
+ import { githubSource } from "@shivaedev/webhooks/github.ts";
38
+ import { webhookEvent, webhookJob } from "@shivaedev/webhooks/registration.ts";
39
+ import { webhookRoute } from "@shivaedev/webhooks/route.ts";
40
+ import { webhookReceipts } from "@shivaedev/webhooks/webhookReceipts.ts";
41
+
42
+ const Jobs = makePgBoss("app/Jobs");
43
+ const IndexIssue = defineQueue({
44
+ name: "index-issue",
45
+ schema: Schema.Struct({ number: Schema.Number }),
46
+ });
47
+ const IssueEvent = Schema.Struct({
48
+ issue: Schema.Struct({ number: Schema.Number }),
49
+ });
50
+ const sources = {
51
+ github: githubSource({
52
+ key: Config.Redacted("GITHUB_WEBHOOK_SECRET"),
53
+ events: {
54
+ issues: webhookEvent(IssueEvent, ({ issue }) => [
55
+ webhookJob(IndexIssue, { number: issue.number }),
56
+ ]),
57
+ },
58
+ }),
59
+ };
60
+
61
+ const Host = appHost({
62
+ config: { databaseUrl },
63
+ migrations: {
64
+ loader,
65
+ components: [pgBossSchema(), webhookReceipts],
66
+ },
67
+ application: Effect.gen(function* () {
68
+ const sql = yield* SqlClient.SqlClient;
69
+ const jobs = Jobs.layer({
70
+ connectionString: Redacted.value(databaseUrl),
71
+ jobs: [IndexIssue],
72
+ migrate: false,
73
+ });
74
+ return {
75
+ group: Api,
76
+ handlers,
77
+ sessions,
78
+ services: jobs,
79
+ routes: webhookRoute({ basePath: "/webhooks", jobs: Jobs, sources }).pipe(
80
+ Layer.provide(jobs),
81
+ Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
82
+ Layer.orDie,
83
+ ),
84
+ };
85
+ }),
86
+ });
87
+ ```
88
+
89
+ `databaseUrl`, `loader`, `Api`, `handlers` and `sessions` are the application's existing host inputs. The route above receives GitHub deliveries at `POST /webhooks/github`. It uses the existing `appHost.routes` option and the host's `maxBodySize`; the jobs client and SQL client must point at the same PostgreSQL database. Register workers separately with the job definitions' `handle` method, in the processes your application chooses.
90
+
91
+ ### Answers and retries
92
+
93
+ | Request | Status | Body |
94
+ | --- | --- | --- |
95
+ | Registered event maps to jobs | 202 | `queued` |
96
+ | Unlisted event, or mapping returns no jobs | 200 | `ignored` |
97
+ | Source and delivery ID already recorded | 200 | `already queued` |
98
+ | Unregistered source | 404 | Empty |
99
+ | Missing or wrong signature | 401 | Empty |
100
+ | Missing delivery ID or event header | 400 | `Invalid delivery` |
101
+ | Payload rejected by its Schema | 400 | Empty |
102
+ | Body exceeds the host limit | 413 | Empty |
103
+ | Database or enqueue failure | 503 | Empty |
104
+
105
+ Refused requests create no receipt and no jobs. Signatures are checked before decoding; unlisted events are verified and recorded without decoding. The signing key, signature and raw body stay out of responses, logs and spans.
106
+
107
+ The receipt insert and every enqueue use one SQL transaction. Concurrent copies wait on the receipt's unique key: a committed first copy makes the second a duplicate; a rolled-back first copy lets the second receive the delivery itself. If any enqueue fails, earlier enqueues and the receipt roll back, and the sender's retry can succeed. Receipts for ignored events are deduplicated too.
108
+
109
+ ### Other senders
110
+
111
+ `githubSource` supplies `githubStrategy`, which checks `X-Hub-Signature-256` through `@shivaedev/github` and reads `X-GitHub-Delivery` and `X-GitHub-Event`. Each key is a `Config.Config<Redacted.Redacted<string>>` and is resolved when the route Layer starts.
112
+
113
+ For another sender, supply a `WebhookSource` from `registration.ts` with its own `WebhookStrategy`. Its `verify(body, headers, key)` returns an Effect that fails with `WebhookUnauthorized` from `WebhookUnauthorized.ts` for invalid signatures. Its `delivery(headers)` returns `{ id, event }`; neither field comes from the payload. The route, receipts and queue mapping stay the same.
114
+
115
+ Applications install `@shivaedev/platform` and import this package by name. Libraries can install `@shivaedev/webhooks` directly with the Effect, Node HTTP and pg-boss peers used by their host. The stories run against `PLATFORM_JOBS_TEST_DATABASE_URL`, using real HTTP and isolated PostgreSQL databases.
@@ -0,0 +1,7 @@
1
+ declare const WebhookUnauthorized_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "WebhookUnauthorized";
3
+ } & Readonly<A>;
4
+ export declare class WebhookUnauthorized extends WebhookUnauthorized_base {
5
+ }
6
+ export {};
7
+ //# sourceMappingURL=WebhookUnauthorized.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WebhookUnauthorized.d.ts","sourceRoot":"","sources":["../src/WebhookUnauthorized.ts"],"names":[],"mappings":";;;AAEA,qBAAa,mBAAoB,SAAQ,wBAAuC;CAAG"}
@@ -0,0 +1,4 @@
1
+ import { Data } from "effect";
2
+ export class WebhookUnauthorized extends Data.TaggedError("WebhookUnauthorized") {
3
+ }
4
+ //# sourceMappingURL=WebhookUnauthorized.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WebhookUnauthorized.js","sourceRoot":"","sources":["../src/WebhookUnauthorized.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B,MAAM,OAAO,mBAAoB,SAAQ,IAAI,CAAC,WAAW,CAAC,qBAAqB,CAAC;CAAG"}
@@ -0,0 +1,4 @@
1
+ import type { WebhookSource, WebhookStrategy } from "#registration.ts";
2
+ export declare const githubStrategy: WebhookStrategy;
3
+ export declare function githubSource<R>(options: Omit<WebhookSource<R>, "strategy">): WebhookSource<R>;
4
+ //# sourceMappingURL=github.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../src/github.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGvE,eAAO,MAAM,cAAc,EAAE,eAG5B,CAAC;AAEF,wBAAgB,YAAY,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAE7F"}
package/dist/github.js ADDED
@@ -0,0 +1,11 @@
1
+ import { Effect } from "effect";
2
+ import { verifyWebhook } from "@shivaedev/github/verifyWebhook.ts";
3
+ import { WebhookUnauthorized } from "#WebhookUnauthorized.ts";
4
+ export const githubStrategy = {
5
+ delivery: (headers) => ({ event: headers["x-github-event"], id: headers["x-github-delivery"] }),
6
+ verify: (body, headers, key) => verifyWebhook(body, headers["x-hub-signature-256"], key).pipe(Effect.mapError(() => new WebhookUnauthorized())),
7
+ };
8
+ export function githubSource(options) {
9
+ return { ...options, strategy: githubStrategy };
10
+ }
11
+ //# sourceMappingURL=github.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github.js","sourceRoot":"","sources":["../src/github.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AAEnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,CAAC,MAAM,cAAc,GAAoB;IAC9C,QAAQ,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,gBAAgB,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,mBAAmB,CAAC,EAAE,CAAC;IAC/F,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,qBAAqB,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,mBAAmB,EAAE,CAAC,CAAC;CAC/I,CAAC;AAEF,MAAM,UAAU,YAAY,CAAI,OAA2C;IAC1E,OAAO,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC;AACjD,CAAC"}
@@ -0,0 +1,7 @@
1
+ import { Effect, type Redacted } from "effect";
2
+ import { HttpServerResponse } from "effect/http";
3
+ import { SqlClient } from "effect/sql";
4
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
5
+ import { type WebhookSource } from "#registration.ts";
6
+ export declare const receive: <R>(name: string, source: WebhookSource<R>, key: Redacted.Redacted<string>, body: Uint8Array<ArrayBufferLike>, headers: Readonly<Record<string, string>>, jobs: PgBossService) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("@shivaedev/jobs/server/error.ts").PgBossError | import("@shivaedev/jobs/server/error.ts").PgBossPayloadError | import("effect/Schema").SchemaError | import("effect/sql/SqlError").SqlError | import("../WebhookUnauthorized.ts").WebhookUnauthorized, R | SqlClient.SqlClient>;
7
+ //# sourceMappingURL=receive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receive.d.ts","sourceRoot":"","sources":["../../src/internal/receive.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAU,KAAK,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE1E,eAAO,MAAM,OAAO,GAAgC,CAAC,4fA6BnD,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { Effect, Option } from "effect";
2
+ import { HttpServerResponse } from "effect/http";
3
+ import { SqlClient } from "effect/sql";
4
+ import { enqueueWebhookJobs } from "#registration.ts";
5
+ export const receive = Effect.fnUntraced(function* (name, source, key, body, headers, jobs) {
6
+ yield* source.strategy.verify(body, headers, key);
7
+ const { id, event } = source.strategy.delivery(headers);
8
+ if (!(id && event)) {
9
+ return HttpServerResponse.text("Invalid delivery", { status: 400 });
10
+ }
11
+ const definition = Object.hasOwn(source.events, event) ? source.events[event] : undefined;
12
+ const sql = yield* SqlClient.SqlClient;
13
+ return yield* sql.withTransaction(Effect.gen(function* () {
14
+ const inserted = yield* sql `insert into webhook_receipts (source, delivery_id, event, outcome, job_ids)
15
+ values (${name}, ${id}, ${event}, 'ignored', '[]'::jsonb)
16
+ on conflict (source, delivery_id) do nothing returning delivery_id`;
17
+ if (inserted.length === 0) {
18
+ return HttpServerResponse.text("already queued", { status: 200 });
19
+ }
20
+ const requests = definition === undefined ? [] : yield* definition.decode(body);
21
+ const ids = (yield* enqueueWebhookJobs(requests, jobs)).flatMap(Option.toArray);
22
+ yield* sql `update webhook_receipts set job_ids = ${JSON.stringify(ids)}::jsonb, outcome = ${requests.length === 0 ? "ignored" : "queued"} where source = ${name} and delivery_id = ${id}`;
23
+ return HttpServerResponse.text(requests.length === 0 ? "ignored" : "queued", { status: requests.length === 0 ? 200 : 202 });
24
+ }));
25
+ });
26
+ //# sourceMappingURL=receive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receive.js","sourceRoot":"","sources":["../../src/internal/receive.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAiB,MAAM,QAAQ,CAAC;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,OAAO,EAAE,kBAAkB,EAAsB,MAAM,kBAAkB,CAAC;AAE1E,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EACjD,IAAY,EACZ,MAAwB,EACxB,GAA8B,EAC9B,IAAgB,EAChB,OAAyC,EACzC,IAAmB;IAEnB,KAAK,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC;IAClD,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,CAAC,CAAC,EAAE,IAAI,KAAK,CAAC,EAAE,CAAC;QACpB,OAAO,kBAAkB,CAAC,IAAI,CAAC,kBAAkB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1F,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,OAAO,KAAK,CAAC,CAAC,GAAG,CAAC,eAAe,CAChC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,GAAG,CAAA;cAChB,IAAI,KAAK,EAAE,KAAK,KAAK;uEACoC,CAAC;QACrE,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,OAAO,kBAAkB,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QACnE,CAAC;QACD,MAAM,QAAQ,GAAG,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAChF,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,kBAAkB,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAChF,KAAK,CAAC,CAAC,GAAG,CAAA,yCAAyC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,sBAAsB,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,mBAAmB,IAAI,sBAAsB,EAAE,EAAE,CAAC;QAC1L,OAAO,kBAAkB,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IAC7H,CAAC,CAAC,CACF,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,29 @@
1
+ import { type Config, Effect, type Option, type Redacted, Schema } from "effect";
2
+ import type { JobPayloadSchema, QueueDefinition } from "@shivaedev/jobs/server/definition.ts";
3
+ import type { PgBossError, PgBossPayloadError } from "@shivaedev/jobs/server/error.ts";
4
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
5
+ import type { WebhookUnauthorized } from "#WebhookUnauthorized.ts";
6
+ declare const enqueue: unique symbol;
7
+ export interface WebhookJob<R = never> {
8
+ readonly [enqueue]: (jobs: PgBossService) => Effect.Effect<Option.Option<string>, PgBossError | PgBossPayloadError, R>;
9
+ }
10
+ export declare function webhookJob<const TName extends string, TPayload extends JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, payload: NoInfer<Schema.Schema.Type<TPayload>>): WebhookJob<TPayload["EncodingServices"]>;
11
+ export interface WebhookEvent<R = never> {
12
+ readonly decode: (body: Uint8Array) => Effect.Effect<readonly WebhookJob<R>[], Schema.SchemaError, R>;
13
+ }
14
+ export declare function webhookEvent<TSchema extends Schema.Constraint, R = never>(schema: TSchema, jobs: (payload: TSchema["Type"]) => readonly WebhookJob<R>[]): WebhookEvent<TSchema["DecodingServices"] | R>;
15
+ export interface WebhookSource<R = never> {
16
+ readonly events: Readonly<Record<string, WebhookEvent<R>>>;
17
+ readonly key: Config.Config<Redacted.Redacted<string>>;
18
+ readonly strategy: WebhookStrategy;
19
+ }
20
+ export interface WebhookStrategy {
21
+ readonly delivery: (headers: Readonly<Record<string, string>>) => {
22
+ readonly id: string | undefined;
23
+ readonly event: string | undefined;
24
+ };
25
+ readonly verify: (body: Uint8Array, headers: Readonly<Record<string, string>>, key: Redacted.Redacted<string>) => Effect.Effect<void, WebhookUnauthorized>;
26
+ }
27
+ export declare function enqueueWebhookJobs<R>(requests: readonly WebhookJob<R>[], jobs: PgBossService): Effect.Effect<Option.Option<string>[], PgBossError | PgBossPayloadError, R>;
28
+ export {};
29
+ //# sourceMappingURL=registration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../src/registration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACjF,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AAC9F,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAEnE,QAAA,MAAM,OAAO,eAA+B,CAAC;AAE7C,MAAM,WAAW,UAAU,CAAC,CAAC,GAAG,KAAK;IACpC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,kBAAkB,EAAE,CAAC,CAAC,CAAC;CACvH;AAED,wBAAgB,UAAU,CAAC,KAAK,CAAC,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,gBAAgB,EACvF,KAAK,EAAE,eAAe,CAAC,KAAK,EAAE,QAAQ,CAAC,EACvC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,GAC5C,UAAU,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAE1C;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,KAAK;IACtC,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,MAAM,CAAC,MAAM,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtG;AAED,wBAAgB,YAAY,CAAC,OAAO,SAAS,MAAM,CAAC,UAAU,EAAE,CAAC,GAAG,KAAK,EACxE,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,KAAK,SAAS,UAAU,CAAC,CAAC,CAAC,EAAE,GAC1D,YAAY,CAAC,OAAO,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAI/C;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,KAAK;IACvC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IACvD,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;CACnC;AAED,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAAK;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAC;IAC1I,QAAQ,CAAC,MAAM,EAAE,CAChB,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EACzC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,mBAAmB,CAAC,CAAC;CAC9C;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,aAAa,+EAE5F"}
@@ -0,0 +1,14 @@
1
+ import { Effect, Schema } from "effect";
2
+ const enqueue = Symbol("WebhookJob.enqueue");
3
+ export function webhookJob(queue, payload) {
4
+ return { [enqueue]: (jobs) => jobs.enqueue(queue, payload) };
5
+ }
6
+ export function webhookEvent(schema, jobs) {
7
+ return {
8
+ decode: (body) => Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(body)).pipe(Effect.map(jobs)),
9
+ };
10
+ }
11
+ export function enqueueWebhookJobs(requests, jobs) {
12
+ return Effect.forEach(requests, (request) => request[enqueue](jobs));
13
+ }
14
+ //# sourceMappingURL=registration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registration.js","sourceRoot":"","sources":["../src/registration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,MAAM,EAA8B,MAAM,EAAE,MAAM,QAAQ,CAAC;AAMjF,MAAM,OAAO,GAAG,MAAM,CAAC,oBAAoB,CAAC,CAAC;AAM7C,MAAM,UAAU,UAAU,CACzB,KAAuC,EACvC,OAA8C;IAE9C,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,EAAE,CAAC;AAC9D,CAAC;AAMD,MAAM,UAAU,YAAY,CAC3B,MAAe,EACf,IAA4D;IAE5D,OAAO;QACN,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;KAClI,CAAC;AACH,CAAC;AAiBD,MAAM,UAAU,kBAAkB,CAAI,QAAkC,EAAE,IAAmB;IAC5F,OAAO,MAAM,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AACtE,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { type Context, Layer } from "effect";
2
+ import { HttpRouter } from "effect/http";
3
+ import type { SqlClient } from "effect/sql";
4
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
5
+ import type { WebhookSource } from "#registration.ts";
6
+ export interface WebhookRouteOptions<TJobs, R> {
7
+ readonly basePath: `/${string}`;
8
+ readonly jobs: Context.Key<TJobs, PgBossService>;
9
+ readonly sources: Readonly<Record<string, WebhookSource<R>>>;
10
+ }
11
+ export declare function webhookRoute<TJobs, R>(options: WebhookRouteOptions<TJobs, R>): Layer.Layer<never, never, HttpRouter.HttpRouter | SqlClient.SqlClient | Exclude<R, import("effect/Scope").Scope> | Exclude<TJobs, import("effect/Scope").Scope>>;
12
+ //# sourceMappingURL=route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAU,KAAK,EAAiB,MAAM,QAAQ,CAAC;AACpE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAC7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,MAAM,WAAW,mBAAmB,CAAC,KAAK,EAAE,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,IAAI,MAAM,EAAE,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC;IACjD,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAC7D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,mBAAmB,CAAC,KAAK,EAAE,CAAC,CAAC,oKA6B5E"}
package/dist/route.js ADDED
@@ -0,0 +1,26 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import { readBoundedBody } from "@shivaedev/http/server/boundedBody.ts";
4
+ import { receive } from "#internal/receive.ts";
5
+ export function webhookRoute(options) {
6
+ return Layer.unwrap(Effect.gen(function* () {
7
+ const context = yield* Effect.context();
8
+ const jobs = yield* options.jobs;
9
+ const keys = new Map();
10
+ for (const [name, source] of Object.entries(options.sources)) {
11
+ keys.set(name, yield* source.key);
12
+ }
13
+ return HttpRouter.add("POST", `/${options.basePath.replace(/^\/|\/$/gu, "")}/:source`, (request) => Effect.gen(function* () {
14
+ const { params } = yield* HttpRouter.RouteContext;
15
+ const name = params.source ?? "";
16
+ const source = Object.hasOwn(options.sources, name) ? options.sources[name] : undefined;
17
+ const key = keys.get(name);
18
+ if (source === undefined || key === undefined) {
19
+ return HttpServerResponse.empty({ status: 404 });
20
+ }
21
+ const body = new Uint8Array(yield* readBoundedBody(request));
22
+ return yield* receive(name, source, key, body, request.headers, jobs).pipe(Effect.provideContext(context));
23
+ }).pipe(Effect.catchTag("RequestBodyTooLarge", () => Effect.succeed(HttpServerResponse.empty({ status: 413 }))), Effect.catchTag("WebhookUnauthorized", () => Effect.succeed(HttpServerResponse.empty({ status: 401 }))), Effect.catchTag("SchemaError", () => Effect.succeed(HttpServerResponse.empty({ status: 400 }))), Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 503 })))));
24
+ }).pipe(Effect.orDie));
25
+ }
26
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { SchemaComponent } from "@shivaedev/sql/SchemaComponent.ts";
2
+ export declare const webhookReceipts: SchemaComponent;
3
+ //# sourceMappingURL=webhookReceipts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhookReceipts.d.ts","sourceRoot":"","sources":["../src/webhookReceipts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAEzE,eAAO,MAAM,eAAe,EAAE,eAmB7B,CAAC"}
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ export const webhookReceipts = {
4
+ apply: Effect.gen(function* () {
5
+ const sql = yield* SqlClient.SqlClient;
6
+ yield* sql `create table if not exists webhook_receipts (
7
+ source text not null,
8
+ delivery_id text not null,
9
+ event text not null,
10
+ received_at timestamptz not null default now(),
11
+ outcome text not null check (outcome in ('queued', 'ignored')),
12
+ job_ids jsonb not null,
13
+ primary key (source, delivery_id)
14
+ )`;
15
+ }),
16
+ name: "webhook-receipts",
17
+ pending: Effect.gen(function* () {
18
+ const sql = yield* SqlClient.SqlClient;
19
+ const [row] = yield* sql `select to_regclass('webhook_receipts')::text as name`;
20
+ return row === undefined || row.name === null ? "webhook_receipts is missing" : undefined;
21
+ }),
22
+ };
23
+ //# sourceMappingURL=webhookReceipts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhookReceipts.js","sourceRoot":"","sources":["../src/webhookReceipts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAGvC,MAAM,CAAC,MAAM,eAAe,GAAoB;IAC/C,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAC1B,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;QACvC,KAAK,CAAC,CAAC,GAAG,CAAA;;;;;;;;IAQR,CAAC;IACJ,CAAC,CAAC;IACF,IAAI,EAAE,kBAAkB;IACxB,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;QACvC,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAyB,sDAAsD,CAAC;QACxG,OAAO,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3F,CAAC,CAAC;CACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
2
  "name": "@shivaedev/webhooks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Verified webhook receipts and transactional job delivery over Effect HTTP",
5
+ "keywords": [
6
+ "effect"
7
+ ],
8
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/webhooks#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/ShivaeDev/platform/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ShivaeDev/platform.git",
15
+ "directory": "packages/webhooks"
16
+ },
17
+ "license": "MIT",
18
+ "sideEffects": false,
19
+ "type": "module",
20
+ "imports": {
21
+ "#test/*": "./src/test-support/*",
22
+ "#*.ts": {
23
+ "source": "./src/*.ts",
24
+ "types": "./dist/*.d.ts",
25
+ "default": "./dist/*.js"
26
+ }
27
+ },
28
+ "exports": {
29
+ "./*.ts": {
30
+ "source": "./src/*.ts",
31
+ "types": "./dist/*.d.ts",
32
+ "default": "./dist/*.js"
33
+ },
34
+ "./package.json": "./package.json",
35
+ "./internal/*": null
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "!src/**/*.test.ts",
41
+ "!src/**/*.test.tsx",
42
+ "!src/**/*.spec.ts",
43
+ "!src/**/*.spec.tsx",
44
+ "!src/**/test-support",
45
+ "README.md"
46
+ ],
47
+ "dependencies": {
48
+ "@shivaedev/github": "0.7.0",
49
+ "@shivaedev/http": "0.1.0",
50
+ "@shivaedev/jobs": "0.21.0",
51
+ "@shivaedev/sql": "0.23.0"
52
+ },
53
+ "devDependencies": {
54
+ "@effect/platform-node": "4.0.5",
55
+ "@shivaedev/quality": "0.42.0",
56
+ "@shivaedev/server": "0.2.0",
57
+ "@shivaedev/test-sql": "0.10.1",
58
+ "@shivaedev/test-story": "0.21.1",
59
+ "@types/node": "24.19.2",
60
+ "effect": "4.0.5",
61
+ "pg-boss": "12.38.0",
62
+ "typescript": "7.0.2",
63
+ "vitest": "5.0.5"
64
+ },
65
+ "peerDependencies": {
66
+ "@effect/platform-node": "4.0.5",
67
+ "effect": "4.0.5"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@effect/platform-node": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "engines": {
75
+ "node": ">=24"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public",
79
+ "provenance": true
80
+ },
81
+ "scripts": {
82
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
83
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
84
+ "test": "vitest run",
85
+ "typecheck": "tsc --noEmit"
86
+ }
6
87
  }
@@ -0,0 +1,3 @@
1
+ import { Data } from "effect";
2
+
3
+ export class WebhookUnauthorized extends Data.TaggedError("WebhookUnauthorized") {}
package/src/github.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Effect } from "effect";
2
+ import { verifyWebhook } from "@shivaedev/github/verifyWebhook.ts";
3
+ import type { WebhookSource, WebhookStrategy } from "#registration.ts";
4
+ import { WebhookUnauthorized } from "#WebhookUnauthorized.ts";
5
+
6
+ export const githubStrategy: WebhookStrategy = {
7
+ delivery: (headers) => ({ event: headers["x-github-event"], id: headers["x-github-delivery"] }),
8
+ verify: (body, headers, key) => verifyWebhook(body, headers["x-hub-signature-256"], key).pipe(Effect.mapError(() => new WebhookUnauthorized())),
9
+ };
10
+
11
+ export function githubSource<R>(options: Omit<WebhookSource<R>, "strategy">): WebhookSource<R> {
12
+ return { ...options, strategy: githubStrategy };
13
+ }
@@ -0,0 +1,36 @@
1
+ import { Effect, Option, type Redacted } from "effect";
2
+ import { HttpServerResponse } from "effect/http";
3
+ import { SqlClient } from "effect/sql";
4
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
5
+ import { enqueueWebhookJobs, type WebhookSource } from "#registration.ts";
6
+
7
+ export const receive = Effect.fnUntraced(function* <R>(
8
+ name: string,
9
+ source: WebhookSource<R>,
10
+ key: Redacted.Redacted<string>,
11
+ body: Uint8Array,
12
+ headers: Readonly<Record<string, string>>,
13
+ jobs: PgBossService,
14
+ ) {
15
+ yield* source.strategy.verify(body, headers, key);
16
+ const { id, event } = source.strategy.delivery(headers);
17
+ if (!(id && event)) {
18
+ return HttpServerResponse.text("Invalid delivery", { status: 400 });
19
+ }
20
+ const definition = Object.hasOwn(source.events, event) ? source.events[event] : undefined;
21
+ const sql = yield* SqlClient.SqlClient;
22
+ return yield* sql.withTransaction(
23
+ Effect.gen(function* () {
24
+ const inserted = yield* sql`insert into webhook_receipts (source, delivery_id, event, outcome, job_ids)
25
+ values (${name}, ${id}, ${event}, 'ignored', '[]'::jsonb)
26
+ on conflict (source, delivery_id) do nothing returning delivery_id`;
27
+ if (inserted.length === 0) {
28
+ return HttpServerResponse.text("already queued", { status: 200 });
29
+ }
30
+ const requests = definition === undefined ? [] : yield* definition.decode(body);
31
+ const ids = (yield* enqueueWebhookJobs(requests, jobs)).flatMap(Option.toArray);
32
+ yield* sql`update webhook_receipts set job_ids = ${JSON.stringify(ids)}::jsonb, outcome = ${requests.length === 0 ? "ignored" : "queued"} where source = ${name} and delivery_id = ${id}`;
33
+ return HttpServerResponse.text(requests.length === 0 ? "ignored" : "queued", { status: requests.length === 0 ? 200 : 202 });
34
+ }),
35
+ );
36
+ });
@@ -0,0 +1,50 @@
1
+ import { type Config, Effect, type Option, type Redacted, Schema } from "effect";
2
+ import type { JobPayloadSchema, QueueDefinition } from "@shivaedev/jobs/server/definition.ts";
3
+ import type { PgBossError, PgBossPayloadError } from "@shivaedev/jobs/server/error.ts";
4
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
5
+ import type { WebhookUnauthorized } from "#WebhookUnauthorized.ts";
6
+
7
+ const enqueue = Symbol("WebhookJob.enqueue");
8
+
9
+ export interface WebhookJob<R = never> {
10
+ readonly [enqueue]: (jobs: PgBossService) => Effect.Effect<Option.Option<string>, PgBossError | PgBossPayloadError, R>;
11
+ }
12
+
13
+ export function webhookJob<const TName extends string, TPayload extends JobPayloadSchema>(
14
+ queue: QueueDefinition<TName, TPayload>,
15
+ payload: NoInfer<Schema.Schema.Type<TPayload>>,
16
+ ): WebhookJob<TPayload["EncodingServices"]> {
17
+ return { [enqueue]: (jobs) => jobs.enqueue(queue, payload) };
18
+ }
19
+
20
+ export interface WebhookEvent<R = never> {
21
+ readonly decode: (body: Uint8Array) => Effect.Effect<readonly WebhookJob<R>[], Schema.SchemaError, R>;
22
+ }
23
+
24
+ export function webhookEvent<TSchema extends Schema.Constraint, R = never>(
25
+ schema: TSchema,
26
+ jobs: (payload: TSchema["Type"]) => readonly WebhookJob<R>[],
27
+ ): WebhookEvent<TSchema["DecodingServices"] | R> {
28
+ return {
29
+ decode: (body) => Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(body)).pipe(Effect.map(jobs)),
30
+ };
31
+ }
32
+
33
+ export interface WebhookSource<R = never> {
34
+ readonly events: Readonly<Record<string, WebhookEvent<R>>>;
35
+ readonly key: Config.Config<Redacted.Redacted<string>>;
36
+ readonly strategy: WebhookStrategy;
37
+ }
38
+
39
+ export interface WebhookStrategy {
40
+ readonly delivery: (headers: Readonly<Record<string, string>>) => { readonly id: string | undefined; readonly event: string | undefined };
41
+ readonly verify: (
42
+ body: Uint8Array,
43
+ headers: Readonly<Record<string, string>>,
44
+ key: Redacted.Redacted<string>,
45
+ ) => Effect.Effect<void, WebhookUnauthorized>;
46
+ }
47
+
48
+ export function enqueueWebhookJobs<R>(requests: readonly WebhookJob<R>[], jobs: PgBossService) {
49
+ return Effect.forEach(requests, (request) => request[enqueue](jobs));
50
+ }
package/src/route.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { type Context, Effect, Layer, type Redacted } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import type { SqlClient } from "effect/sql";
4
+ import { readBoundedBody } from "@shivaedev/http/server/boundedBody.ts";
5
+ import type { PgBossService } from "@shivaedev/jobs/server/service.ts";
6
+ import { receive } from "#internal/receive.ts";
7
+ import type { WebhookSource } from "#registration.ts";
8
+
9
+ export interface WebhookRouteOptions<TJobs, R> {
10
+ readonly basePath: `/${string}`;
11
+ readonly jobs: Context.Key<TJobs, PgBossService>;
12
+ readonly sources: Readonly<Record<string, WebhookSource<R>>>;
13
+ }
14
+
15
+ export function webhookRoute<TJobs, R>(options: WebhookRouteOptions<TJobs, R>) {
16
+ return Layer.unwrap(
17
+ Effect.gen(function* () {
18
+ const context = yield* Effect.context<TJobs | R | SqlClient.SqlClient>();
19
+ const jobs = yield* options.jobs;
20
+ const keys = new Map<string, Redacted.Redacted<string>>();
21
+ for (const [name, source] of Object.entries(options.sources)) {
22
+ keys.set(name, yield* source.key);
23
+ }
24
+ return HttpRouter.add("POST", `/${options.basePath.replace(/^\/|\/$/gu, "")}/:source`, (request) =>
25
+ Effect.gen(function* () {
26
+ const { params } = yield* HttpRouter.RouteContext;
27
+ const name = params.source ?? "";
28
+ const source = Object.hasOwn(options.sources, name) ? options.sources[name] : undefined;
29
+ const key = keys.get(name);
30
+ if (source === undefined || key === undefined) {
31
+ return HttpServerResponse.empty({ status: 404 });
32
+ }
33
+ const body = new Uint8Array(yield* readBoundedBody(request));
34
+ return yield* receive(name, source, key, body, request.headers, jobs).pipe(Effect.provideContext(context));
35
+ }).pipe(
36
+ Effect.catchTag("RequestBodyTooLarge", () => Effect.succeed(HttpServerResponse.empty({ status: 413 }))),
37
+ Effect.catchTag("WebhookUnauthorized", () => Effect.succeed(HttpServerResponse.empty({ status: 401 }))),
38
+ Effect.catchTag("SchemaError", () => Effect.succeed(HttpServerResponse.empty({ status: 400 }))),
39
+ Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 503 }))),
40
+ ),
41
+ );
42
+ }).pipe(Effect.orDie),
43
+ );
44
+ }
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { SchemaComponent } from "@shivaedev/sql/SchemaComponent.ts";
4
+
5
+ export const webhookReceipts: SchemaComponent = {
6
+ apply: Effect.gen(function* () {
7
+ const sql = yield* SqlClient.SqlClient;
8
+ yield* sql`create table if not exists webhook_receipts (
9
+ source text not null,
10
+ delivery_id text not null,
11
+ event text not null,
12
+ received_at timestamptz not null default now(),
13
+ outcome text not null check (outcome in ('queued', 'ignored')),
14
+ job_ids jsonb not null,
15
+ primary key (source, delivery_id)
16
+ )`;
17
+ }),
18
+ name: "webhook-receipts",
19
+ pending: Effect.gen(function* () {
20
+ const sql = yield* SqlClient.SqlClient;
21
+ const [row] = yield* sql<{ name: string | null }>`select to_regclass('webhook_receipts')::text as name`;
22
+ return row === undefined || row.name === null ? "webhook_receipts is missing" : undefined;
23
+ }),
24
+ };