@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
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@dwk/websub` — signed content distribution.
3
+ *
4
+ * On publish, the hub fetches the topic's current content and `POST`s it to every
5
+ * active subscriber's callback (WebSub §7). When a subscriber registered a
6
+ * `hub.secret`, the body is authenticated with an HMAC signature in the
7
+ * `X-Hub-Signature: <method>=<hex>` header so the subscriber can verify the
8
+ * delivery came from this hub (§8). The digest method is a hub-level config
9
+ * option (`sha256` by default; `sha1`/`sha384`/`sha512` are also permitted by
10
+ * §8 and selected via `signatureAlgorithm`). Deliveries carry `Link` headers
11
+ * advertising
12
+ * the hub (`rel="hub"`) and the topic (`rel="self"`). Every POST goes through
13
+ * {@link safeFetch}. See `spec/packages/websub.md`.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ import { type Logger, type Metrics } from "@dwk/log";
18
+ import type { FetchLike } from "./fetch";
19
+ import type { Subscription } from "./store";
20
+ /** A topic's current content, as fetched from the topic URL. */
21
+ export interface TopicContent {
22
+ /** The raw body bytes to forward to subscribers. */
23
+ readonly body: Uint8Array;
24
+ /** The topic's `Content-Type`, forwarded verbatim to subscribers. */
25
+ readonly contentType: string;
26
+ }
27
+ /**
28
+ * The HMAC digest methods WebSub §8 permits for `X-Hub-Signature`. The method
29
+ * name is emitted verbatim as the header's `<method>=` prefix, so it must match
30
+ * the WebSub spelling (`sha1`, not `sha-1`).
31
+ */
32
+ export type SignatureAlgorithm = "sha1" | "sha256" | "sha384" | "sha512";
33
+ /** WebSub's secure default signature method; SHA-1 interop is opt-in only. */
34
+ export declare const DEFAULT_SIGNATURE_ALGORITHM: SignatureAlgorithm;
35
+ /** Outcome of delivering content to one subscriber. */
36
+ export interface DeliveryResult {
37
+ readonly callback: string;
38
+ /** Whether the callback accepted the delivery (2xx). */
39
+ readonly delivered: boolean;
40
+ /** The delivery's HTTP status (`0` when the POST threw or was blocked). */
41
+ readonly status: number;
42
+ }
43
+ /**
44
+ * Compute the `X-Hub-Signature` value for `body` under `secret`:
45
+ * `<method>=<lowercase hex HMAC>` (WebSub §8). `method` defaults to the secure
46
+ * `sha256`; WebSub also permits `sha1`/`sha384`/`sha512`. The header prefix is
47
+ * the WebSub method name verbatim (e.g. `sha1=`, not `sha-1=`).
48
+ */
49
+ export declare function contentSignature(secret: string, body: Uint8Array, method?: SignatureAlgorithm): Promise<string>;
50
+ /** Build the `Link` header advertising this hub and the topic (WebSub §5.1). */
51
+ export declare function buildLinkHeader(hubUrl: string, topic: string): string;
52
+ /** Inputs shared by distribution helpers. */
53
+ export interface DistributeOptions {
54
+ readonly fetch?: FetchLike;
55
+ readonly logger?: Logger;
56
+ readonly metrics?: Metrics;
57
+ /**
58
+ * HMAC method used for the `X-Hub-Signature` header. WebSub §8 has no
59
+ * per-request method parameter, so this is a hub-level choice; it defaults to
60
+ * the secure {@link DEFAULT_SIGNATURE_ALGORITHM} (`sha256`). Set it to `sha1`
61
+ * only for interop with subscribers that require the legacy method.
62
+ */
63
+ readonly signatureAlgorithm?: SignatureAlgorithm;
64
+ /**
65
+ * Media type to forward when the topic response declares no `Content-Type`.
66
+ * WebSub §7 requires the distribution `Content-Type` to correspond to the
67
+ * topic's, so the hub never fabricates a generic `application/octet-stream`:
68
+ * when the topic omits the header and no fallback is configured here, the
69
+ * content is refused rather than mislabeled.
70
+ */
71
+ readonly defaultContentType?: string;
72
+ }
73
+ /**
74
+ * Outcome of {@link fetchTopicContent}, telling the caller what to do with the
75
+ * queue message:
76
+ *
77
+ * - **`ok`** — the topic's current content, ready to fan out.
78
+ * - **`retry`** — a transient failure (topic unreachable, non-2xx, or an
79
+ * over-cap body that may be a truncated response): re-enqueue and try later.
80
+ * - **`drop`** — a deterministic refusal that re-fetching cannot fix, so the
81
+ * caller acks rather than burning retries and re-hammering the topic. Today
82
+ * this is a topic that declares no `Content-Type` and for which no
83
+ * {@link DistributeOptions.defaultContentType} fallback is configured, since
84
+ * forwarding it would mislabel the feed (WebSub §7).
85
+ */
86
+ export type TopicFetchResult = {
87
+ readonly kind: "ok";
88
+ readonly content: TopicContent;
89
+ } | {
90
+ readonly kind: "retry";
91
+ } | {
92
+ readonly kind: "drop";
93
+ };
94
+ /**
95
+ * Fetch the topic's current content through {@link safeFetch}, classifying the
96
+ * outcome as `ok` / `retry` / `drop` (see {@link TopicFetchResult}). A missing,
97
+ * unlabelable `Content-Type` is a `drop` — a permanent format/config error that
98
+ * retrying would only turn into a self-inflicted hammering of the topic — while
99
+ * unreachable/non-2xx/over-cap responses are transient `retry`s.
100
+ */
101
+ export declare function fetchTopicContent(topic: string, options?: DistributeOptions): Promise<TopicFetchResult>;
102
+ /**
103
+ * Deliver `content` to one subscriber. POSTs the body with the topic's
104
+ * `Content-Type`, the hub/self `Link` header, and — when the subscription
105
+ * carries a secret — the `X-Hub-Signature`. Never throws: a failed or blocked
106
+ * POST is reported as `delivered: false`.
107
+ */
108
+ export declare function deliverToSubscriber(subscription: Subscription, content: TopicContent, hubUrl: string, options?: DistributeOptions): Promise<DeliveryResult>;
109
+ //# sourceMappingURL=distribute.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distribute.d.ts","sourceRoot":"","sources":["../src/distribute.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,OAAO,EACb,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAIzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,gEAAgE;AAChE,MAAM,WAAW,YAAY;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAUzE,8EAA8E;AAC9E,eAAO,MAAM,2BAA2B,EAAE,kBAA6B,CAAC;AAExE,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,UAAU,EAChB,MAAM,GAAE,kBAAgD,GACvD,OAAO,CAAC,MAAM,CAAC,CAajB;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAErE;AAED,6CAA6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9B;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,gBAAgB,CAAC,CAoD3B;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,cAAc,CAAC,CA8CzB"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `@dwk/websub` — signed content distribution.
3
+ *
4
+ * On publish, the hub fetches the topic's current content and `POST`s it to every
5
+ * active subscriber's callback (WebSub §7). When a subscriber registered a
6
+ * `hub.secret`, the body is authenticated with an HMAC signature in the
7
+ * `X-Hub-Signature: <method>=<hex>` header so the subscriber can verify the
8
+ * delivery came from this hub (§8). The digest method is a hub-level config
9
+ * option (`sha256` by default; `sha1`/`sha384`/`sha512` are also permitted by
10
+ * §8 and selected via `signatureAlgorithm`). Deliveries carry `Link` headers
11
+ * advertising
12
+ * the hub (`rel="hub"`) and the topic (`rel="self"`). Every POST goes through
13
+ * {@link safeFetch}. See `spec/packages/websub.md`.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ import { hostFromUrl, noopLogger, noopMetrics, } from "@dwk/log";
18
+ import { readBytesCapped } from "./fetch";
19
+ import { WebSubLogEvent } from "./log";
20
+ import { safeFetch } from "./safe-fetch";
21
+ /** Map a WebSub signature method name to its WebCrypto SHA hash name. */
22
+ const HASH_FOR_METHOD = {
23
+ sha1: "SHA-1",
24
+ sha256: "SHA-256",
25
+ sha384: "SHA-384",
26
+ sha512: "SHA-512",
27
+ };
28
+ /** WebSub's secure default signature method; SHA-1 interop is opt-in only. */
29
+ export const DEFAULT_SIGNATURE_ALGORITHM = "sha256";
30
+ /**
31
+ * Compute the `X-Hub-Signature` value for `body` under `secret`:
32
+ * `<method>=<lowercase hex HMAC>` (WebSub §8). `method` defaults to the secure
33
+ * `sha256`; WebSub also permits `sha1`/`sha384`/`sha512`. The header prefix is
34
+ * the WebSub method name verbatim (e.g. `sha1=`, not `sha-1=`).
35
+ */
36
+ export async function contentSignature(secret, body, method = DEFAULT_SIGNATURE_ALGORITHM) {
37
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: HASH_FOR_METHOD[method] }, false, ["sign"]);
38
+ const mac = await crypto.subtle.sign("HMAC", key, body);
39
+ const hex = [...new Uint8Array(mac)]
40
+ .map((b) => b.toString(16).padStart(2, "0"))
41
+ .join("");
42
+ return `${method}=${hex}`;
43
+ }
44
+ /** Build the `Link` header advertising this hub and the topic (WebSub §5.1). */
45
+ export function buildLinkHeader(hubUrl, topic) {
46
+ return `<${hubUrl}>; rel="hub", <${topic}>; rel="self"`;
47
+ }
48
+ /**
49
+ * Fetch the topic's current content through {@link safeFetch}, classifying the
50
+ * outcome as `ok` / `retry` / `drop` (see {@link TopicFetchResult}). A missing,
51
+ * unlabelable `Content-Type` is a `drop` — a permanent format/config error that
52
+ * retrying would only turn into a self-inflicted hammering of the topic — while
53
+ * unreachable/non-2xx/over-cap responses are transient `retry`s.
54
+ */
55
+ export async function fetchTopicContent(topic, options) {
56
+ const doFetch = options?.fetch ?? ((input, init) => fetch(input, init));
57
+ const logger = options?.logger ?? noopLogger;
58
+ const metrics = options?.metrics ?? noopMetrics;
59
+ let response;
60
+ try {
61
+ const result = await safeFetch(doFetch, topic, { method: "GET" }, { logger, metrics });
62
+ response = result.response;
63
+ }
64
+ catch {
65
+ const fields = { topicHost: hostFromUrl(topic), status: 0 };
66
+ logger.warn(WebSubLogEvent.TopicFetchFailed, fields);
67
+ metrics.count(WebSubLogEvent.TopicFetchFailed, fields);
68
+ return { kind: "retry" };
69
+ }
70
+ if (!response.ok) {
71
+ await response.body?.cancel().catch(() => undefined);
72
+ const fields = { topicHost: hostFromUrl(topic), status: response.status };
73
+ logger.warn(WebSubLogEvent.TopicFetchFailed, fields);
74
+ metrics.count(WebSubLogEvent.TopicFetchFailed, fields);
75
+ return { kind: "retry" };
76
+ }
77
+ const contentType = response.headers.get("content-type") ?? options?.defaultContentType;
78
+ if (contentType === undefined || contentType === "") {
79
+ // WebSub §7: the distribution Content-Type MUST correspond to the topic's.
80
+ // With neither a topic header nor a configured fallback, forwarding would
81
+ // mislabel the feed. Re-fetching can't conjure a Content-Type, so drop the
82
+ // job (the caller acks) rather than retry — retrying would only clog the
83
+ // queue and re-hammer the topic for a permanent configuration error.
84
+ await response.body?.cancel().catch(() => undefined);
85
+ const fields = { topicHost: hostFromUrl(topic), status: response.status };
86
+ logger.warn(WebSubLogEvent.TopicContentTypeMissing, fields);
87
+ metrics.count(WebSubLogEvent.TopicContentTypeMissing, fields);
88
+ return { kind: "drop" };
89
+ }
90
+ const body = await readBytesCapped(response);
91
+ if (body === null) {
92
+ const fields = { topicHost: hostFromUrl(topic), status: response.status };
93
+ logger.warn(WebSubLogEvent.TopicFetchFailed, fields);
94
+ metrics.count(WebSubLogEvent.TopicFetchFailed, fields);
95
+ return { kind: "retry" };
96
+ }
97
+ return { kind: "ok", content: { body, contentType } };
98
+ }
99
+ /**
100
+ * Deliver `content` to one subscriber. POSTs the body with the topic's
101
+ * `Content-Type`, the hub/self `Link` header, and — when the subscription
102
+ * carries a secret — the `X-Hub-Signature`. Never throws: a failed or blocked
103
+ * POST is reported as `delivered: false`.
104
+ */
105
+ export async function deliverToSubscriber(subscription, content, hubUrl, options) {
106
+ const doFetch = options?.fetch ?? ((input, init) => fetch(input, init));
107
+ const logger = options?.logger ?? noopLogger;
108
+ const metrics = options?.metrics ?? noopMetrics;
109
+ const headers = {
110
+ "content-type": content.contentType,
111
+ link: buildLinkHeader(hubUrl, subscription.topic),
112
+ };
113
+ if (subscription.secret !== null) {
114
+ headers["x-hub-signature"] = await contentSignature(subscription.secret, content.body, options?.signatureAlgorithm ?? DEFAULT_SIGNATURE_ALGORITHM);
115
+ }
116
+ const finish = (delivered, status) => {
117
+ const fields = {
118
+ callbackHost: hostFromUrl(subscription.callback),
119
+ delivered,
120
+ status,
121
+ };
122
+ logger.info(WebSubLogEvent.DeliveryCompleted, fields);
123
+ metrics.count(WebSubLogEvent.DeliveryCompleted, fields);
124
+ return { callback: subscription.callback, delivered, status };
125
+ };
126
+ try {
127
+ const result = await safeFetch(doFetch, subscription.callback, {
128
+ method: "POST",
129
+ headers,
130
+ // `content.body` is a Uint8Array; pass its backing buffer as the body.
131
+ body: content.body,
132
+ }, { logger, metrics });
133
+ await result.response.body?.cancel().catch(() => undefined);
134
+ return finish(result.response.ok, result.response.status);
135
+ }
136
+ catch {
137
+ return finish(false, 0);
138
+ }
139
+ }
140
+ //# sourceMappingURL=distribute.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distribute.js","sourceRoot":"","sources":["../src/distribute.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@dwk/websub` — injectable `fetch` type and a body-size cap.
3
+ *
4
+ * Intent verification, topic fetching, and content distribution all perform HTTP
5
+ * I/O. They accept a {@link FetchLike} so callers can inject a stub in tests (no
6
+ * network) and so the package never reaches for a global it didn't receive.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ /** A minimal, injectable `fetch` signature. */
11
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
12
+ /**
13
+ * Default cap on a fetched body (4 MB). A challenge echo is tiny and a feed is
14
+ * modest; a larger body is almost certainly hostile or irrelevant, and buffering
15
+ * it would risk an OOM (the Worker memory limit is 128 MB). See
16
+ * `spec/non-functional-requirements.md`.
17
+ */
18
+ export declare const MAX_BODY_BYTES: number;
19
+ /**
20
+ * Read a response body as a `Uint8Array`, refusing bodies larger than `maxBytes`.
21
+ *
22
+ * A declared `Content-Length` over the cap is rejected up front; the stream is
23
+ * then read incrementally and aborted the moment the cap is exceeded, so a
24
+ * missing or lying `Content-Length` cannot force the whole body into memory.
25
+ * Returns `null` when the body is too large or cannot be read.
26
+ */
27
+ export declare function readBytesCapped(response: Response, maxBytes?: number): Promise<Uint8Array | null>;
28
+ //# sourceMappingURL=fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,SAAS,GAAG,CACtB,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,WAAW,KACf,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEvB;;;;;GAKG;AACH,eAAO,MAAM,cAAc,QAAkB,CAAC;AAE9C;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,QAAQ,EAClB,QAAQ,SAAiB,GACxB,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAgD5B"}
package/dist/fetch.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@dwk/websub` — injectable `fetch` type and a body-size cap.
3
+ *
4
+ * Intent verification, topic fetching, and content distribution all perform HTTP
5
+ * I/O. They accept a {@link FetchLike} so callers can inject a stub in tests (no
6
+ * network) and so the package never reaches for a global it didn't receive.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ /**
11
+ * Default cap on a fetched body (4 MB). A challenge echo is tiny and a feed is
12
+ * modest; a larger body is almost certainly hostile or irrelevant, and buffering
13
+ * it would risk an OOM (the Worker memory limit is 128 MB). See
14
+ * `spec/non-functional-requirements.md`.
15
+ */
16
+ export const MAX_BODY_BYTES = 4 * 1024 * 1024;
17
+ /**
18
+ * Read a response body as a `Uint8Array`, refusing bodies larger than `maxBytes`.
19
+ *
20
+ * A declared `Content-Length` over the cap is rejected up front; the stream is
21
+ * then read incrementally and aborted the moment the cap is exceeded, so a
22
+ * missing or lying `Content-Length` cannot force the whole body into memory.
23
+ * Returns `null` when the body is too large or cannot be read.
24
+ */
25
+ export async function readBytesCapped(response, maxBytes = MAX_BODY_BYTES) {
26
+ const declared = response.headers.get("content-length");
27
+ if (declared !== null) {
28
+ const length = Number.parseInt(declared, 10);
29
+ if (Number.isFinite(length) && length > maxBytes) {
30
+ return null;
31
+ }
32
+ }
33
+ const body = response.body;
34
+ if (body === null) {
35
+ try {
36
+ const buffer = await response.arrayBuffer();
37
+ return buffer.byteLength > maxBytes ? null : new Uint8Array(buffer);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ const reader = body.getReader();
44
+ const chunks = [];
45
+ let total = 0;
46
+ try {
47
+ for (;;) {
48
+ const { done, value } = await reader.read();
49
+ if (done) {
50
+ break;
51
+ }
52
+ if (value !== undefined) {
53
+ total += value.byteLength;
54
+ if (total > maxBytes) {
55
+ await reader.cancel();
56
+ return null;
57
+ }
58
+ chunks.push(value);
59
+ }
60
+ }
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ const merged = new Uint8Array(total);
66
+ let offset = 0;
67
+ for (const chunk of chunks) {
68
+ merged.set(chunk, offset);
69
+ offset += chunk.byteLength;
70
+ }
71
+ return merged;
72
+ }
73
+ //# sourceMappingURL=fetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch.js","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAE9C;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,QAAkB,EAClB,QAAQ,GAAG,cAAc;IAEzB,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IACxD,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC7C,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,QAAQ,EAAE,CAAC;YACjD,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC;IAC3B,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAClB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC;YAC5C,OAAO,MAAM,CAAC,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;QACtE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAChC,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,CAAC;QACH,SAAS,CAAC;YACR,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YAC5C,IAAI,IAAI,EAAE,CAAC;gBACT,MAAM;YACR,CAAC;YACD,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,KAAK,IAAI,KAAK,CAAC,UAAU,CAAC;gBAC1B,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;oBACrB,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC;oBACtB,OAAO,IAAI,CAAC;gBACd,CAAC;gBACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACrB,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC;IAC7B,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@dwk/websub` — the hub `fetch` handler and publish-notifier factory.
3
+ *
4
+ * `createWebSub` builds the WebSub hub endpoint: a single `POST`-only handler,
5
+ * mountable under any path prefix, that fields subscribe/unsubscribe requests and
6
+ * publish pings. Slow work (intent verification, content fan-out) is validated
7
+ * synchronously and pushed onto the queue, so the handler returns `202 Accepted`
8
+ * fast and the queue consumer (see {@link createWebSubQueueConsumer}) does the
9
+ * I/O with retries. `createPublishNotifier` is the in-process equivalent of a
10
+ * publish ping, for the Micropub write / Anglesite rebuild path. See
11
+ * `spec/packages/websub.md`.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ import type { ExecutionContext } from "@cloudflare/workers-types";
16
+ import { type WebSubConfig, type WebSubEnv } from "./config";
17
+ /** A `fetch`-compatible Worker handler. */
18
+ export type WebSubHandler = (request: Request, env: WebSubEnv, ctx: ExecutionContext) => Promise<Response>;
19
+ /**
20
+ * An in-process publish notifier: call it from the Micropub write path or an
21
+ * Anglesite rebuild hook to enqueue distribution of `topic` to its subscribers.
22
+ */
23
+ export type PublishNotifier = (env: WebSubEnv, topic: string) => Promise<void>;
24
+ /**
25
+ * Build the WebSub hub handler from configuration.
26
+ *
27
+ * Accepts a form-encoded `POST`. `hub.mode=subscribe|unsubscribe` is validated
28
+ * and the verification-of-intent job enqueued (`202`); `hub.mode=publish`
29
+ * enqueues a distribution job (`202`). Invalid requests get `400` with a stable
30
+ * error code; other methods get `405`. Fails loudly if `WEBSUB_QUEUE` is missing.
31
+ */
32
+ export declare function createWebSub(config: WebSubConfig): WebSubHandler;
33
+ /**
34
+ * Build an in-process publish notifier. The returned function enqueues a
35
+ * distribution job for `topic` (after checking it is a topic this hub serves),
36
+ * so a Micropub write or static rebuild can fan out a push without going through
37
+ * the HTTP publish endpoint.
38
+ *
39
+ * @throws when `topic` is not one this hub serves — a caller wiring this into its
40
+ * own write path should only ever publish its own feeds.
41
+ */
42
+ export declare function createPublishNotifier(config: WebSubConfig): PublishNotifier;
43
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,SAAS,EACf,MAAM,UAAU,CAAC;AAIlB,2CAA2C;AAC3C,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,EACd,GAAG,EAAE,gBAAgB,KAClB,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEvB;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAyB/E;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,CAmEhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,eAAe,CAc3E"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `@dwk/websub` — the hub `fetch` handler and publish-notifier factory.
3
+ *
4
+ * `createWebSub` builds the WebSub hub endpoint: a single `POST`-only handler,
5
+ * mountable under any path prefix, that fields subscribe/unsubscribe requests and
6
+ * publish pings. Slow work (intent verification, content fan-out) is validated
7
+ * synchronously and pushed onto the queue, so the handler returns `202 Accepted`
8
+ * fast and the queue consumer (see {@link createWebSubQueueConsumer}) does the
9
+ * I/O with retries. `createPublishNotifier` is the in-process equivalent of a
10
+ * publish ping, for the Micropub write / Anglesite rebuild path. See
11
+ * `spec/packages/websub.md`.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ import { hostFromUrl } from "@dwk/log";
16
+ import { resolveConfig, } from "./config";
17
+ import { WebSubLogEvent } from "./log";
18
+ import { readHubParams, validatePublish, validateSubscribe } from "./validate";
19
+ function textResponse(status, body) {
20
+ return new Response(body, {
21
+ status,
22
+ headers: { "content-type": "text/plain; charset=utf-8" },
23
+ });
24
+ }
25
+ function requireQueue(env) {
26
+ if (env.WEBSUB_QUEUE === undefined) {
27
+ throw new Error("@dwk/websub: missing required binding WEBSUB_QUEUE.");
28
+ }
29
+ }
30
+ function emit(config, level, event, fields) {
31
+ config.logger[level](event, fields);
32
+ config.metrics.count(event, fields);
33
+ }
34
+ /**
35
+ * Build the WebSub hub handler from configuration.
36
+ *
37
+ * Accepts a form-encoded `POST`. `hub.mode=subscribe|unsubscribe` is validated
38
+ * and the verification-of-intent job enqueued (`202`); `hub.mode=publish`
39
+ * enqueues a distribution job (`202`). Invalid requests get `400` with a stable
40
+ * error code; other methods get `405`. Fails loudly if `WEBSUB_QUEUE` is missing.
41
+ */
42
+ export function createWebSub(config) {
43
+ const resolved = resolveConfig(config);
44
+ return async (request, env, _ctx) => {
45
+ if (request.method !== "POST") {
46
+ return new Response("Method Not Allowed", {
47
+ status: 405,
48
+ headers: { allow: "POST" },
49
+ });
50
+ }
51
+ requireQueue(env);
52
+ let form;
53
+ try {
54
+ form = await request.formData();
55
+ }
56
+ catch {
57
+ return textResponse(400, "invalid_request");
58
+ }
59
+ // Lift the form into a string-only URLSearchParams; a `File`-valued field
60
+ // (multipart upload) is not a valid `hub.*` parameter and is dropped.
61
+ const search = new URLSearchParams();
62
+ for (const [key, value] of form.entries()) {
63
+ if (typeof value === "string") {
64
+ search.append(key, value);
65
+ }
66
+ }
67
+ const params = readHubParams(search);
68
+ if (params.mode === "publish") {
69
+ const result = validatePublish(params, resolved);
70
+ if (!result.ok) {
71
+ emit(resolved, "warn", WebSubLogEvent.PublishRejected, {
72
+ reason: result.error,
73
+ });
74
+ return textResponse(400, result.error);
75
+ }
76
+ await env.WEBSUB_QUEUE.send({ kind: "distribute", topic: result.topic });
77
+ emit(resolved, "info", WebSubLogEvent.PublishAccepted, {
78
+ topicHost: hostFromUrl(result.topic),
79
+ });
80
+ return new Response(null, { status: 202 });
81
+ }
82
+ const result = validateSubscribe(params, resolved);
83
+ if (!result.ok) {
84
+ emit(resolved, "warn", WebSubLogEvent.RequestRejected, {
85
+ reason: result.error,
86
+ });
87
+ return textResponse(400, result.error);
88
+ }
89
+ await env.WEBSUB_QUEUE.send({
90
+ kind: "verify",
91
+ mode: result.mode,
92
+ callback: result.callback,
93
+ topic: result.topic,
94
+ leaseSeconds: result.leaseSeconds,
95
+ ...(result.secret !== undefined ? { secret: result.secret } : {}),
96
+ });
97
+ emit(resolved, "info", WebSubLogEvent.RequestAccepted, {
98
+ mode: result.mode,
99
+ callbackHost: hostFromUrl(result.callback),
100
+ topicHost: hostFromUrl(result.topic),
101
+ });
102
+ return new Response(null, { status: 202 });
103
+ };
104
+ }
105
+ /**
106
+ * Build an in-process publish notifier. The returned function enqueues a
107
+ * distribution job for `topic` (after checking it is a topic this hub serves),
108
+ * so a Micropub write or static rebuild can fan out a push without going through
109
+ * the HTTP publish endpoint.
110
+ *
111
+ * @throws when `topic` is not one this hub serves — a caller wiring this into its
112
+ * own write path should only ever publish its own feeds.
113
+ */
114
+ export function createPublishNotifier(config) {
115
+ const resolved = resolveConfig(config);
116
+ return async (env, topic) => {
117
+ requireQueue(env);
118
+ if (!resolved.isAllowedTopic(topic)) {
119
+ throw new Error(`@dwk/websub: refusing to publish unsupported topic ${hostFromUrl(topic) ?? "<invalid>"}.`);
120
+ }
121
+ await env.WEBSUB_QUEUE.send({ kind: "distribute", topic });
122
+ emit(resolved, "info", WebSubLogEvent.PublishAccepted, {
123
+ topicHost: hostFromUrl(topic),
124
+ });
125
+ };
126
+ }
127
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@dwk/websub` — WebSub (W3C) hub.
3
+ *
4
+ * Endpoint package. The publish-side, real-time complement to `@dwk/webmention`:
5
+ * subscribers receive a push when the user's feed changes instead of polling. The
6
+ * hub fields `hub.mode=subscribe|unsubscribe` requests (validated synchronously,
7
+ * verified-of-intent and persisted asynchronously) and publish pings, keeps a
8
+ * strongly-consistent D1 subscription store with lease expiry, and on publish
9
+ * fans the topic's content out to every verified callback — signing the body with
10
+ * HMAC-SHA256 (`X-Hub-Signature`) when the subscriber registered a secret. The
11
+ * feeds themselves are static SSG artifacts (Anglesite's job, including the
12
+ * `Link rel="hub"`/`rel="self"` advertisement); this package is only the dynamic
13
+ * layer. Cloudflare specifics (D1, Queue) are confined here; validation, lease
14
+ * math, verification, and signing are pure and unit-test without a Workers
15
+ * runtime.
16
+ *
17
+ * @see spec/packages/websub.md
18
+ * @packageDocumentation
19
+ */
20
+ export { createWebSub, createPublishNotifier, type WebSubHandler, type PublishNotifier, } from "./handler";
21
+ export { createWebSubQueueConsumer, type WebSubQueueConsumer, type ConsumerOptions, } from "./consumer";
22
+ export { resolveConfig, normalizeTopic, clampLease, DEFAULT_MIN_LEASE_SECONDS, DEFAULT_MAX_LEASE_SECONDS, type WebSubConfig, type WebSubEnv, type ResolvedConfig, } from "./config";
23
+ export { createD1SubscriptionStore, type SubscriptionStore, type Subscription, type SubscriptionUpsert, type D1StoreOptions, } from "./store";
24
+ export { validateSubscribe, validatePublish, readHubParams, MAX_SECRET_BYTES, type SubscribeResult, type SubscribeRequest, type SubscribeError, type PublishResult, type PublishRequest, type PublishError, type RawHubParams, } from "./validate";
25
+ export { verifyIntent, buildVerificationUrl, generateChallenge, notifyDenial, buildDenialUrl, type VerifyIntentOptions, type VerifyIntentResult, type NotifyDenialOptions, } from "./verify";
26
+ export { contentSignature, buildLinkHeader, fetchTopicContent, deliverToSubscriber, DEFAULT_SIGNATURE_ALGORITHM, type SignatureAlgorithm, type TopicContent, type TopicFetchResult, type DeliveryResult, type DistributeOptions, } from "./distribute";
27
+ export type { WebSubJob, VerifyJob, DistributeJob } from "./queue";
28
+ export type { FetchLike } from "./fetch";
29
+ export { safeFetch, assertPublicUrl, isPrivateOrReservedHost, SsrfError, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_MS, type SafeFetchOptions, type SafeFetchResult, type SsrfReason, } from "./safe-fetch";
30
+ export { WebSubLogEvent } from "./log";
31
+ export type { Logger, Metrics } from "@dwk/log";
32
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EACL,YAAY,EACZ,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,eAAe,GACrB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,yBAAyB,EACzB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,aAAa,EACb,cAAc,EACd,UAAU,EACV,yBAAyB,EACzB,yBAAyB,EACzB,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,cAAc,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,yBAAyB,EACzB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,kBAAkB,EACvB,KAAK,cAAc,GACpB,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,2BAA2B,EAC3B,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,iBAAiB,GACvB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnE,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EACL,SAAS,EACT,eAAe,EACf,uBAAuB,EACvB,SAAS,EACT,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,UAAU,GAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AACvC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `@dwk/websub` — WebSub (W3C) hub.
3
+ *
4
+ * Endpoint package. The publish-side, real-time complement to `@dwk/webmention`:
5
+ * subscribers receive a push when the user's feed changes instead of polling. The
6
+ * hub fields `hub.mode=subscribe|unsubscribe` requests (validated synchronously,
7
+ * verified-of-intent and persisted asynchronously) and publish pings, keeps a
8
+ * strongly-consistent D1 subscription store with lease expiry, and on publish
9
+ * fans the topic's content out to every verified callback — signing the body with
10
+ * HMAC-SHA256 (`X-Hub-Signature`) when the subscriber registered a secret. The
11
+ * feeds themselves are static SSG artifacts (Anglesite's job, including the
12
+ * `Link rel="hub"`/`rel="self"` advertisement); this package is only the dynamic
13
+ * layer. Cloudflare specifics (D1, Queue) are confined here; validation, lease
14
+ * math, verification, and signing are pure and unit-test without a Workers
15
+ * runtime.
16
+ *
17
+ * @see spec/packages/websub.md
18
+ * @packageDocumentation
19
+ */
20
+ export { createWebSub, createPublishNotifier, } from "./handler";
21
+ export { createWebSubQueueConsumer, } from "./consumer";
22
+ export { resolveConfig, normalizeTopic, clampLease, DEFAULT_MIN_LEASE_SECONDS, DEFAULT_MAX_LEASE_SECONDS, } from "./config";
23
+ export { createD1SubscriptionStore, } from "./store";
24
+ export { validateSubscribe, validatePublish, readHubParams, MAX_SECRET_BYTES, } from "./validate";
25
+ export { verifyIntent, buildVerificationUrl, generateChallenge, notifyDenial, buildDenialUrl, } from "./verify";
26
+ export { contentSignature, buildLinkHeader, fetchTopicContent, deliverToSubscriber, DEFAULT_SIGNATURE_ALGORITHM, } from "./distribute";
27
+ export { safeFetch, assertPublicUrl, isPrivateOrReservedHost, SsrfError, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_MS, } from "./safe-fetch";
28
+ export { WebSubLogEvent } from "./log";
29
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EACL,YAAY,EACZ,qBAAqB,GAGtB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,yBAAyB,GAG1B,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,aAAa,EACb,cAAc,EACd,UAAU,EACV,yBAAyB,EACzB,yBAAyB,GAI1B,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,yBAAyB,GAK1B,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,gBAAgB,GAQjB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,iBAAiB,EACjB,YAAY,EACZ,cAAc,GAIf,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,2BAA2B,GAM5B,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,SAAS,EACT,eAAe,EACf,uBAAuB,EACvB,SAAS,EACT,qBAAqB,EACrB,kBAAkB,GAInB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC"}