@dwk/websub 0.1.0-beta.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.
Files changed (63) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +155 -0
  3. package/dist/config.d.ts +122 -0
  4. package/dist/config.d.ts.map +1 -0
  5. package/dist/config.js +96 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/consumer.d.ts +39 -0
  8. package/dist/consumer.d.ts.map +1 -0
  9. package/dist/consumer.js +146 -0
  10. package/dist/consumer.js.map +1 -0
  11. package/dist/distribute.d.ts +109 -0
  12. package/dist/distribute.d.ts.map +1 -0
  13. package/dist/distribute.js +140 -0
  14. package/dist/distribute.js.map +1 -0
  15. package/dist/fetch.d.ts +28 -0
  16. package/dist/fetch.d.ts.map +1 -0
  17. package/dist/fetch.js +73 -0
  18. package/dist/fetch.js.map +1 -0
  19. package/dist/handler.d.ts +43 -0
  20. package/dist/handler.d.ts.map +1 -0
  21. package/dist/handler.js +127 -0
  22. package/dist/handler.js.map +1 -0
  23. package/dist/index.d.ts +32 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +29 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/log.d.ts +54 -0
  28. package/dist/log.d.ts.map +1 -0
  29. package/dist/log.js +52 -0
  30. package/dist/log.js.map +1 -0
  31. package/dist/queue.d.ts +38 -0
  32. package/dist/queue.d.ts.map +1 -0
  33. package/dist/queue.js +12 -0
  34. package/dist/queue.js.map +1 -0
  35. package/dist/safe-fetch.d.ts +101 -0
  36. package/dist/safe-fetch.d.ts.map +1 -0
  37. package/dist/safe-fetch.js +354 -0
  38. package/dist/safe-fetch.js.map +1 -0
  39. package/dist/store.d.ts +61 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +110 -0
  42. package/dist/store.js.map +1 -0
  43. package/dist/validate.d.ts +67 -0
  44. package/dist/validate.d.ts.map +1 -0
  45. package/dist/validate.js +106 -0
  46. package/dist/validate.js.map +1 -0
  47. package/dist/verify.d.ts +85 -0
  48. package/dist/verify.d.ts.map +1 -0
  49. package/dist/verify.js +149 -0
  50. package/dist/verify.js.map +1 -0
  51. package/package.json +46 -0
  52. package/src/config.ts +199 -0
  53. package/src/consumer.ts +187 -0
  54. package/src/distribute.ts +257 -0
  55. package/src/fetch.ts +84 -0
  56. package/src/handler.ts +163 -0
  57. package/src/index.ts +98 -0
  58. package/src/log.ts +56 -0
  59. package/src/queue.ts +40 -0
  60. package/src/safe-fetch.ts +412 -0
  61. package/src/store.ts +190 -0
  62. package/src/validate.ts +179 -0
  63. package/src/verify.ts +229 -0
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 David W. Keith
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # `@dwk/websub`
2
+
3
+ > WebSub (W3C) hub. Endpoint package.
4
+
5
+ Part of the [`@dwk` IndieWeb + Solid cohort](../../README.md). See the
6
+ [package specification](../../spec/packages/websub.md) for the full
7
+ requirements.
8
+
9
+ A WebSub hub is the **publish-side, real-time complement** to
10
+ [`@dwk/webmention`](../webmention)'s interaction side: subscribers receive a
11
+ push when the user's feed changes instead of polling. Subscribe/unsubscribe
12
+ requests are validated **synchronously** (fast `202 Accepted`); the
13
+ verification-of-intent callback and content fan-out run **asynchronously** on a
14
+ queue with retries and backoff. The subscription store is **D1 (strongly
15
+ consistent), never KV** — a stale or lost subscription is a correctness bug. On
16
+ publish the hub fetches the topic and POSTs it to every verified callback,
17
+ signing the body with HMAC-SHA256 (`X-Hub-Signature`) when the subscriber
18
+ registered a secret.
19
+
20
+ ## What this package does *not* do
21
+
22
+ The **feeds themselves are static** — RSS / Atom / JSON Feed and `h-feed` /
23
+ `h-entry` microformats are SSG build artifacts that **Anglesite generates**, so
24
+ there is no `@dwk/feeds` package and feed generation is out of scope here. The
25
+ feed's `Link rel="hub"` / `rel="self"` advertisement is likewise Anglesite's to
26
+ emit. This package is only the dynamic hub a static host can't provide.
27
+
28
+ ## Hub endpoint
29
+
30
+ ```ts
31
+ import { createWebSub } from "@dwk/websub";
32
+
33
+ const websub = createWebSub({
34
+ baseUrl: "https://hub.example.com",
35
+ // The feeds this hub serves; a subscribe/publish for any other topic is 400.
36
+ allowedTopics: ["https://example.com/feed.xml"],
37
+ });
38
+
39
+ // In your Worker's fetch handler, mount under any path prefix:
40
+ // POST /websub (application/x-www-form-urlencoded)
41
+ return websub(request, env, ctx);
42
+ ```
43
+
44
+ `createWebSub` parses the form body and routes on `hub.mode`:
45
+
46
+ - **`subscribe` / `unsubscribe`** — validates `hub.callback`, `hub.topic`,
47
+ optional `hub.lease_seconds` (clamped into the hub's bounds), and optional
48
+ `hub.secret` (< 200 bytes); enqueues a verification job and returns `202`.
49
+ - **`publish`** — validates `hub.url` (or legacy `hub.topic`) against the
50
+ allowed topics; enqueues a distribution job and returns `202`.
51
+
52
+ Invalid requests get `400` with a stable error code in the body; other methods
53
+ get `405`. The handler **fails loudly** if the required `WEBSUB_QUEUE` binding
54
+ is missing.
55
+
56
+ ### Async work (queue consumer)
57
+
58
+ ```ts
59
+ import { createWebSub, createWebSubQueueConsumer } from "@dwk/websub";
60
+
61
+ const config = {
62
+ baseUrl: "https://hub.example.com",
63
+ allowedTopics: ["https://example.com/feed.xml"],
64
+ };
65
+
66
+ export default {
67
+ fetch: createWebSub(config),
68
+ queue: createWebSubQueueConsumer(config), // bound to WEBSUB_QUEUE
69
+ };
70
+ ```
71
+
72
+ The consumer handles both job kinds:
73
+
74
+ - **verify** — issues the `hub.challenge` GET to the callback; on a confirming
75
+ `2xx` echo it activates the subscription (subscribe) or removes it
76
+ (unsubscribe). A subscription row is written **only after** verification
77
+ succeeds, so an unverified callback never lands in the store.
78
+ - **distribute** — prunes expired leases, fetches the topic's current content,
79
+ and POSTs it (signed per-subscriber when a secret is set) to every active
80
+ callback.
81
+
82
+ A store/queue failure — or a distribution that can't fetch the topic — is
83
+ retried; everything else is acked.
84
+
85
+ ### Publishing from your own write path
86
+
87
+ To ping the hub in-process when [`@dwk/micropub`](../micropub) writes or a static
88
+ rebuild finishes — instead of POSTing the HTTP publish endpoint — use the
89
+ notifier:
90
+
91
+ ```ts
92
+ import { createPublishNotifier } from "@dwk/websub";
93
+
94
+ const notifyPublish = createPublishNotifier(config);
95
+ // After a write that changed the feed:
96
+ await notifyPublish(env, "https://example.com/feed.xml");
97
+ ```
98
+
99
+ ## Bindings (`Env` fragment)
100
+
101
+ | Binding | Type | Required | Purpose |
102
+ | -------------- | ------------ | -------- | ------------------------------------------------ |
103
+ | `WEBSUB_DB` | `D1Database` | yes | Strongly-consistent subscription store. |
104
+ | `WEBSUB_QUEUE` | `Queue` | yes | Verification + distribution fan-out and retries. |
105
+
106
+ The subscription store **MUST** be D1 (or another strongly-consistent store),
107
+ **never KV**: staleness here is a correctness/security bug, not a safe-to-be-stale
108
+ cache (see [`spec/non-functional-requirements.md`](../../spec/non-functional-requirements.md)).
109
+
110
+ ## Config
111
+
112
+ | Field | Type | Default | Purpose |
113
+ | --------------------- | ----------------------------- | ---------------- | ------------------------------------------------------------- |
114
+ | `baseUrl` | `string` | — | Hub base URL. |
115
+ | `hubUrl` | `string` | `baseUrl` | URL advertised in the `rel="hub"` `Link` header. |
116
+ | `allowedTopics` | `string[]` | — | Topics this hub serves (normalized match). |
117
+ | `isAllowedTopic` | `(topic) => boolean` | from `allowed…` | Predicate alternative for a dynamic feed set. |
118
+ | `minLeaseSeconds` | `number` | `300` | Lower bound on a granted lease. |
119
+ | `maxLeaseSeconds` | `number` | `864000` | Upper bound on a granted lease (10 days). |
120
+ | `defaultLeaseSeconds` | `number` | `864000` | Lease when the subscriber omits `hub.lease_seconds`. |
121
+ | `fetch` | `FetchLike` | global `fetch` | Override `fetch` (verification / distribution). |
122
+ | `logger` | `Logger` | `noopLogger` | Structured logs (see `@dwk/log`). |
123
+ | `metrics` | `Metrics` | `noopMetrics` | Queryable counters (see `@dwk/log`). |
124
+
125
+ Either `allowedTopics` or `isAllowedTopic` is required — a hub must know which
126
+ topics it serves.
127
+
128
+ ## Security
129
+
130
+ Every outbound request — the verification GET and each distribution POST — goes
131
+ through an SSRF-safe `fetch`: the callback host (and every redirect hop) is
132
+ validated against private, loopback, link-local (incl. the cloud metadata IP),
133
+ and reserved ranges, redirects are followed manually with re-validation and a
134
+ hop cap, and the whole operation is bounded by a timeout. Credential-bearing
135
+ headers (including `X-Hub-Signature`) are stripped on a cross-origin redirect.
136
+
137
+ ## Observability
138
+
139
+ Logging and metrics are **opt-in and injected** (see [`@dwk/log`](../log)),
140
+ defaulting to no-ops. Both seams share one event vocabulary (`WebSubLogEvent`),
141
+ so a log line and its counter line up — SSRF blocks (by reason), request
142
+ accepted/rejected, verification outcomes (by confirmed/status), subscription
143
+ activated/removed, deliveries (by delivered/status), topic-fetch failures, and
144
+ queue retries. Only sanitized hosts, status, reason codes, booleans, and counts
145
+ are recorded — never secrets, bodies, or full URLs.
146
+
147
+ ## Conformance
148
+
149
+ The hub targets the [websub.rocks](https://websub.rocks/) hub test suite. The
150
+ lease math, request validation, intent verification, and HMAC signing are
151
+ unit-tested without a Workers runtime; the D1 store is tested under Miniflare.
152
+
153
+ ## License
154
+
155
+ [ISC](../../LICENSE)
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `@dwk/websub` — injected configuration and the Cloudflare `Env` fragment.
3
+ *
4
+ * Per the composition contract, a package never reads the global environment
5
+ * directly: all config (base URL, the topics this hub serves, lease bounds) is
6
+ * passed into {@link createWebSub}, so the hub can be instantiated multiple times
7
+ * and unit-tested in isolation. The Cloudflare bindings the hub needs are
8
+ * declared as a TypeScript `Env` fragment that the composed Worker's `Env` is a
9
+ * superset of. See `spec/composition-contract.md` and `spec/packages/websub.md`.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ import { type Logger, type Metrics } from "@dwk/log";
14
+ import type { D1Database, Queue } from "@cloudflare/workers-types";
15
+ import { type SignatureAlgorithm } from "./distribute";
16
+ import type { FetchLike } from "./fetch";
17
+ import type { WebSubJob } from "./queue";
18
+ /**
19
+ * Cloudflare bindings required by the hub handler and its queue consumer.
20
+ *
21
+ * The subscription store **MUST** be strongly consistent — D1 (session
22
+ * consistency), never KV: a stale or lost subscription is a correctness bug, not
23
+ * a safe-to-be-stale cache (`spec/non-functional-requirements.md`).
24
+ */
25
+ export interface WebSubEnv {
26
+ /** D1 database backing the subscription table. */
27
+ readonly WEBSUB_DB: D1Database;
28
+ /** Queue for intent verification and content-distribution fan-out + retries. */
29
+ readonly WEBSUB_QUEUE: Queue<WebSubJob>;
30
+ }
31
+ /** Configuration passed to {@link createWebSub}. */
32
+ export interface WebSubConfig {
33
+ /** Base URL of this hub; informational and used for self-advertisement. */
34
+ readonly baseUrl: string;
35
+ /**
36
+ * Absolute URL of the hub endpoint itself, advertised to subscribers in the
37
+ * `rel="hub"` `Link` header on each delivery. Defaults to {@link baseUrl};
38
+ * set it when the hub is mounted under a path prefix.
39
+ */
40
+ readonly hubUrl?: string;
41
+ /**
42
+ * The topic URLs this hub will serve (the user's own feeds). A subscribe or
43
+ * publish request for any other topic is rejected. Compared by normalized URL
44
+ * (default ports and a trailing-slash-only path are folded). Provide either
45
+ * this or {@link isAllowedTopic}.
46
+ */
47
+ readonly allowedTopics?: readonly string[];
48
+ /**
49
+ * Predicate deciding whether a topic URL is one this hub serves, for callers
50
+ * whose feed set is dynamic. Takes precedence over {@link allowedTopics}.
51
+ */
52
+ readonly isAllowedTopic?: (topic: string) => boolean;
53
+ /** Minimum lease (seconds) the hub will grant. Default 300 (5 minutes). */
54
+ readonly minLeaseSeconds?: number;
55
+ /** Maximum lease (seconds) the hub will grant. Default 864000 (10 days). */
56
+ readonly maxLeaseSeconds?: number;
57
+ /**
58
+ * Lease granted when a subscriber omits `hub.lease_seconds`. Default 864000
59
+ * (10 days). Clamped into `[minLeaseSeconds, maxLeaseSeconds]`.
60
+ */
61
+ readonly defaultLeaseSeconds?: number;
62
+ /**
63
+ * HMAC digest method for the `X-Hub-Signature` on signed deliveries (WebSub
64
+ * §8 permits `sha1`/`sha256`/`sha384`/`sha512`). WebSub has no per-request
65
+ * method parameter, so this is a hub-level choice; it defaults to the secure
66
+ * `sha256`. Set it to `sha1` **only** when a subscriber requires the legacy
67
+ * method for interop — SHA-1 is weaker and not the default for that reason.
68
+ */
69
+ readonly signatureAlgorithm?: SignatureAlgorithm;
70
+ /**
71
+ * Media type to forward on distribution when the topic response declares no
72
+ * `Content-Type`. WebSub §7 requires the distribution `Content-Type` to
73
+ * correspond to the topic's, so the hub never fabricates a generic
74
+ * `application/octet-stream`. Set this to the type the hub's feeds are served
75
+ * as (e.g. `application/atom+xml`) so a topic that omits the header is still
76
+ * distributable; left unset, such a topic is refused rather than mislabeled.
77
+ */
78
+ readonly defaultContentType?: string;
79
+ /** `fetch` implementation for verification/distribution; defaults to global `fetch`. */
80
+ readonly fetch?: FetchLike;
81
+ /** Logger; defaults to a no-op (see `@dwk/log`). */
82
+ readonly logger?: Logger;
83
+ /** Metrics sink; defaults to a no-op (see `@dwk/log`). */
84
+ readonly metrics?: Metrics;
85
+ }
86
+ /** Default lower bound on a granted lease (5 minutes). */
87
+ export declare const DEFAULT_MIN_LEASE_SECONDS = 300;
88
+ /** Default upper bound on a granted lease (10 days). */
89
+ export declare const DEFAULT_MAX_LEASE_SECONDS = 864000;
90
+ /** Config with defaults resolved and the topic check normalized to a predicate. */
91
+ export interface ResolvedConfig {
92
+ readonly baseUrl: string;
93
+ readonly hubUrl: string;
94
+ readonly isAllowedTopic: (topic: string) => boolean;
95
+ readonly minLeaseSeconds: number;
96
+ readonly maxLeaseSeconds: number;
97
+ readonly defaultLeaseSeconds: number;
98
+ readonly signatureAlgorithm: SignatureAlgorithm;
99
+ /** Fallback distribution `Content-Type`; `undefined` refuses to mislabel. */
100
+ readonly defaultContentType?: string;
101
+ readonly fetch: FetchLike;
102
+ readonly logger: Logger;
103
+ readonly metrics: Metrics;
104
+ }
105
+ /**
106
+ * Normalize a topic URL for comparison: lowercase host, default port dropped,
107
+ * a bare `/` path elided, and fragment removed. Query strings are preserved
108
+ * (a feed may legitimately carry one). Returns `null` when `raw` is unparseable
109
+ * or not `http(s)`.
110
+ */
111
+ export declare function normalizeTopic(raw: string): string | null;
112
+ /**
113
+ * Resolve {@link WebSubConfig} into {@link ResolvedConfig}, filling defaults and
114
+ * turning the topic allowlist into a single predicate.
115
+ *
116
+ * @throws when neither `allowedTopics` nor `isAllowedTopic` is supplied — a hub
117
+ * with no topics would accept subscriptions it can never serve.
118
+ */
119
+ export declare function resolveConfig(config: WebSubConfig): ResolvedConfig;
120
+ /** Clamp a requested lease into the hub's `[min, max]` bounds. */
121
+ export declare function clampLease(requested: number, config: ResolvedConfig): number;
122
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAA2B,KAAK,MAAM,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AAC9E,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,2BAA2B,CAAC;AACnE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,kDAAkD;IAClD,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAC;IAC/B,gFAAgF;IAChF,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CACzC;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,2EAA2E;IAC3E,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;OAGG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,wFAAwF;IACxF,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,0DAA0D;AAC1D,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,wDAAwD;AACxD,eAAO,MAAM,yBAAyB,SAAU,CAAC;AAEjD,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,cAAc,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IACpD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,kBAAkB,EAAE,kBAAkB,CAAC;IAChD,6EAA6E;IAC7E,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAazD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,cAAc,CAiDlE;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,MAAM,CAK5E"}
package/dist/config.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `@dwk/websub` — injected configuration and the Cloudflare `Env` fragment.
3
+ *
4
+ * Per the composition contract, a package never reads the global environment
5
+ * directly: all config (base URL, the topics this hub serves, lease bounds) is
6
+ * passed into {@link createWebSub}, so the hub can be instantiated multiple times
7
+ * and unit-tested in isolation. The Cloudflare bindings the hub needs are
8
+ * declared as a TypeScript `Env` fragment that the composed Worker's `Env` is a
9
+ * superset of. See `spec/composition-contract.md` and `spec/packages/websub.md`.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ import { noopLogger, noopMetrics } from "@dwk/log";
14
+ import { DEFAULT_SIGNATURE_ALGORITHM, } from "./distribute";
15
+ /** Default lower bound on a granted lease (5 minutes). */
16
+ export const DEFAULT_MIN_LEASE_SECONDS = 300;
17
+ /** Default upper bound on a granted lease (10 days). */
18
+ export const DEFAULT_MAX_LEASE_SECONDS = 864_000;
19
+ /**
20
+ * Normalize a topic URL for comparison: lowercase host, default port dropped,
21
+ * a bare `/` path elided, and fragment removed. Query strings are preserved
22
+ * (a feed may legitimately carry one). Returns `null` when `raw` is unparseable
23
+ * or not `http(s)`.
24
+ */
25
+ export function normalizeTopic(raw) {
26
+ let url;
27
+ try {
28
+ url = new URL(raw);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
34
+ return null;
35
+ }
36
+ url.hash = "";
37
+ const path = url.pathname === "/" ? "" : url.pathname;
38
+ return `${url.protocol}//${url.host}${path}${url.search}`;
39
+ }
40
+ /**
41
+ * Resolve {@link WebSubConfig} into {@link ResolvedConfig}, filling defaults and
42
+ * turning the topic allowlist into a single predicate.
43
+ *
44
+ * @throws when neither `allowedTopics` nor `isAllowedTopic` is supplied — a hub
45
+ * with no topics would accept subscriptions it can never serve.
46
+ */
47
+ export function resolveConfig(config) {
48
+ const min = config.minLeaseSeconds ?? DEFAULT_MIN_LEASE_SECONDS;
49
+ const max = config.maxLeaseSeconds ?? DEFAULT_MAX_LEASE_SECONDS;
50
+ if (min > max) {
51
+ throw new Error(`@dwk/websub: minLeaseSeconds (${min}) exceeds maxLeaseSeconds (${max}).`);
52
+ }
53
+ const fallback = config.defaultLeaseSeconds ?? DEFAULT_MAX_LEASE_SECONDS;
54
+ const defaultLeaseSeconds = Math.min(Math.max(fallback, min), max);
55
+ let isAllowedTopic;
56
+ if (config.isAllowedTopic !== undefined) {
57
+ isAllowedTopic = config.isAllowedTopic;
58
+ }
59
+ else if (config.allowedTopics !== undefined) {
60
+ const allowed = new Set();
61
+ for (const topic of config.allowedTopics) {
62
+ const normalized = normalizeTopic(topic);
63
+ if (normalized !== null) {
64
+ allowed.add(normalized);
65
+ }
66
+ }
67
+ isAllowedTopic = (topic) => {
68
+ const normalized = normalizeTopic(topic);
69
+ return normalized !== null && allowed.has(normalized);
70
+ };
71
+ }
72
+ else {
73
+ throw new Error("@dwk/websub: configure allowedTopics or isAllowedTopic — a hub must " +
74
+ "know which topics it serves.");
75
+ }
76
+ return {
77
+ baseUrl: config.baseUrl,
78
+ hubUrl: config.hubUrl ?? config.baseUrl,
79
+ isAllowedTopic,
80
+ minLeaseSeconds: min,
81
+ maxLeaseSeconds: max,
82
+ defaultLeaseSeconds,
83
+ signatureAlgorithm: config.signatureAlgorithm ?? DEFAULT_SIGNATURE_ALGORITHM,
84
+ ...(config.defaultContentType !== undefined
85
+ ? { defaultContentType: config.defaultContentType }
86
+ : {}),
87
+ fetch: config.fetch ?? ((input, init) => fetch(input, init)),
88
+ logger: config.logger ?? noopLogger,
89
+ metrics: config.metrics ?? noopMetrics,
90
+ };
91
+ }
92
+ /** Clamp a requested lease into the hub's `[min, max]` bounds. */
93
+ export function clampLease(requested, config) {
94
+ return Math.min(Math.max(requested, config.minLeaseSeconds), config.maxLeaseSeconds);
95
+ }
96
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `@dwk/websub` — the queue consumer.
3
+ *
4
+ * The hub's slow work runs here, off the request path, with the queue providing
5
+ * retries and backoff. Two job kinds flow through:
6
+ *
7
+ * - **verify** — issue the verification-of-intent GET; on a confirmed subscribe,
8
+ * write the subscription to the D1 store with its lease expiry, and on a
9
+ * confirmed unsubscribe, remove it. A subscription row is created only after
10
+ * verification succeeds, so an unverified callback never lands in the store.
11
+ * - **distribute** — prune expired leases, fetch the topic's current content, and
12
+ * fan it out (signed per-subscriber when a secret is set) to every active
13
+ * subscriber.
14
+ *
15
+ * A job whose store/fetch work throws — or a distribution that cannot fetch the
16
+ * topic — is retried; everything else is acked. See `spec/packages/websub.md`.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+ import type { ExecutionContext, MessageBatch } from "@cloudflare/workers-types";
21
+ import { type WebSubConfig, type WebSubEnv } from "./config";
22
+ import type { WebSubJob } from "./queue";
23
+ import { type SubscriptionStore } from "./store";
24
+ /** A Queue consumer for WebSub verification and distribution jobs. */
25
+ export type WebSubQueueConsumer = (batch: MessageBatch<WebSubJob>, env: WebSubEnv, ctx: ExecutionContext) => Promise<void>;
26
+ /** Extra wiring for the consumer, primarily to inject a store in tests. */
27
+ export interface ConsumerOptions {
28
+ /** Override the subscription store; defaults to a D1 store over `WEBSUB_DB`. */
29
+ readonly store?: SubscriptionStore;
30
+ /** Clock injection for deterministic tests; defaults to `Date.now`. */
31
+ readonly now?: () => number;
32
+ }
33
+ /**
34
+ * Build the Queue consumer that performs intent verification and content
35
+ * distribution. Fails loudly if no store is configured (neither
36
+ * `options.store` nor the `WEBSUB_DB` binding).
37
+ */
38
+ export declare function createWebSubQueueConsumer(config: WebSubConfig, options?: ConsumerOptions): WebSubQueueConsumer;
39
+ //# sourceMappingURL=consumer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consumer.d.ts","sourceRoot":"","sources":["../src/consumer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAChF,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,SAAS,EACf,MAAM,UAAU,CAAC;AAGlB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EAA6B,KAAK,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAG5E,sEAAsE;AACtE,MAAM,MAAM,mBAAmB,GAAG,CAChC,KAAK,EAAE,YAAY,CAAC,SAAS,CAAC,EAC9B,GAAG,EAAE,SAAS,EACd,GAAG,EAAE,gBAAgB,KAClB,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;IACnC,uEAAuE;IACvE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAyBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,eAAe,GACxB,mBAAmB,CA0GrB"}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `@dwk/websub` — the queue consumer.
3
+ *
4
+ * The hub's slow work runs here, off the request path, with the queue providing
5
+ * retries and backoff. Two job kinds flow through:
6
+ *
7
+ * - **verify** — issue the verification-of-intent GET; on a confirmed subscribe,
8
+ * write the subscription to the D1 store with its lease expiry, and on a
9
+ * confirmed unsubscribe, remove it. A subscription row is created only after
10
+ * verification succeeds, so an unverified callback never lands in the store.
11
+ * - **distribute** — prune expired leases, fetch the topic's current content, and
12
+ * fan it out (signed per-subscriber when a secret is set) to every active
13
+ * subscriber.
14
+ *
15
+ * A job whose store/fetch work throws — or a distribution that cannot fetch the
16
+ * topic — is retried; everything else is acked. See `spec/packages/websub.md`.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+ import { hostFromUrl } from "@dwk/log";
21
+ import { resolveConfig, } from "./config";
22
+ import { deliverToSubscriber, fetchTopicContent } from "./distribute";
23
+ import { WebSubLogEvent } from "./log";
24
+ import { createD1SubscriptionStore } from "./store";
25
+ import { notifyDenial, verifyIntent } from "./verify";
26
+ function emit(config, level, event, fields) {
27
+ config.logger[level](event, fields);
28
+ config.metrics.count(event, fields);
29
+ }
30
+ function resolveStore(options, env) {
31
+ if (options?.store !== undefined) {
32
+ return options.store;
33
+ }
34
+ if (env.WEBSUB_DB === undefined) {
35
+ throw new Error("@dwk/websub: missing required binding WEBSUB_DB.");
36
+ }
37
+ return createD1SubscriptionStore(env.WEBSUB_DB);
38
+ }
39
+ /**
40
+ * Build the Queue consumer that performs intent verification and content
41
+ * distribution. Fails loudly if no store is configured (neither
42
+ * `options.store` nor the `WEBSUB_DB` binding).
43
+ */
44
+ export function createWebSubQueueConsumer(config, options) {
45
+ const resolved = resolveConfig(config);
46
+ const clock = options?.now ?? (() => Date.now());
47
+ return async (batch, env, _ctx) => {
48
+ const store = resolveStore(options, env);
49
+ for (const message of batch.messages) {
50
+ const job = message.body;
51
+ try {
52
+ if (job.kind === "verify") {
53
+ const result = await verifyIntent(job.callback, job.topic, {
54
+ mode: job.mode,
55
+ leaseSeconds: job.mode === "subscribe" ? job.leaseSeconds : undefined,
56
+ fetch: resolved.fetch,
57
+ logger: resolved.logger,
58
+ metrics: resolved.metrics,
59
+ });
60
+ if (result.confirmed) {
61
+ if (job.mode === "subscribe") {
62
+ await store.upsert({
63
+ callback: job.callback,
64
+ topic: job.topic,
65
+ secret: job.secret,
66
+ leaseSeconds: job.leaseSeconds,
67
+ now: clock(),
68
+ });
69
+ emit(resolved, "info", WebSubLogEvent.SubscriptionActivated, {
70
+ callbackHost: hostFromUrl(job.callback),
71
+ topicHost: hostFromUrl(job.topic),
72
+ leaseSeconds: job.leaseSeconds,
73
+ });
74
+ }
75
+ else {
76
+ await store.remove(job.callback, job.topic);
77
+ emit(resolved, "info", WebSubLogEvent.SubscriptionRemoved, {
78
+ callbackHost: hostFromUrl(job.callback),
79
+ topicHost: hostFromUrl(job.topic),
80
+ reason: "unsubscribed",
81
+ });
82
+ }
83
+ }
84
+ else if (job.mode === "subscribe") {
85
+ // Intent unconfirmed: signal denial to the subscriber (WebSub §5.2)
86
+ // instead of silently dropping the request. (An unconfirmed
87
+ // unsubscribe simply leaves the existing subscription in place.)
88
+ await notifyDenial(job.callback, job.topic, {
89
+ reason: "verification_failed",
90
+ fetch: resolved.fetch,
91
+ logger: resolved.logger,
92
+ metrics: resolved.metrics,
93
+ });
94
+ }
95
+ message.ack();
96
+ continue;
97
+ }
98
+ // kind === "distribute"
99
+ const now = clock();
100
+ await store.pruneExpired(now);
101
+ const fetched = await fetchTopicContent(job.topic, {
102
+ fetch: resolved.fetch,
103
+ logger: resolved.logger,
104
+ metrics: resolved.metrics,
105
+ defaultContentType: resolved.defaultContentType,
106
+ });
107
+ if (fetched.kind === "retry") {
108
+ // The topic was unreachable / non-2xx — retry the whole job later
109
+ // rather than dropping the push.
110
+ message.retry();
111
+ continue;
112
+ }
113
+ if (fetched.kind === "drop") {
114
+ // A permanent, deterministic refusal (e.g. an unlabelable topic):
115
+ // retrying can't fix it, so ack and move on rather than clog the
116
+ // queue and re-hammer the topic. fetchTopicContent already logged why.
117
+ message.ack();
118
+ continue;
119
+ }
120
+ const content = fetched.content;
121
+ const subscribers = await store.listActive(job.topic, now);
122
+ // Fan out in parallel: deliverToSubscriber never throws (it reports a
123
+ // failed/blocked POST as delivered:false), so one slow or dead callback
124
+ // must not head-of-line block the rest and risk timing out the whole
125
+ // consumer invocation.
126
+ await Promise.all(subscribers.map((subscriber) => deliverToSubscriber(subscriber, content, resolved.hubUrl, {
127
+ fetch: resolved.fetch,
128
+ logger: resolved.logger,
129
+ metrics: resolved.metrics,
130
+ signatureAlgorithm: resolved.signatureAlgorithm,
131
+ })));
132
+ message.ack();
133
+ }
134
+ catch (err) {
135
+ // A store/queue failure must not retry silently — name the kind and
136
+ // error so an operator can tell a transient blip from a wedged job.
137
+ emit(resolved, "warn", WebSubLogEvent.QueueRetry, {
138
+ kind: job.kind,
139
+ error: err instanceof Error ? err.name : "unknown",
140
+ });
141
+ message.retry();
142
+ }
143
+ }
144
+ };
145
+ }
146
+ //# sourceMappingURL=consumer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consumer.js","sourceRoot":"","sources":["../src/consumer.ts"],"names":[],"mappings":"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"}