@dwk/microsub 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.
- package/LICENSE +15 -0
- package/README.md +92 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +102 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +102 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/consumer.d.ts +40 -0
- package/dist/consumer.d.ts.map +1 -0
- package/dist/consumer.js +87 -0
- package/dist/consumer.js.map +1 -0
- package/dist/discovery.d.ts +59 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +190 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fetch.d.ts +28 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +72 -0
- package/dist/fetch.js.map +1 -0
- package/dist/handler.d.ts +24 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +434 -0
- package/dist/handler.js.map +1 -0
- package/dist/hfeed.d.ts +25 -0
- package/dist/hfeed.d.ts.map +1 -0
- package/dist/hfeed.js +252 -0
- package/dist/hfeed.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jf2.d.ts +69 -0
- package/dist/jf2.d.ts.map +1 -0
- package/dist/jf2.js +295 -0
- package/dist/jf2.js.map +1 -0
- package/dist/log.d.ts +44 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +42 -0
- package/dist/log.js.map +1 -0
- package/dist/poll.d.ts +22 -0
- package/dist/poll.d.ts.map +1 -0
- package/dist/poll.js +39 -0
- package/dist/poll.js.map +1 -0
- package/dist/queue.d.ts +25 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +13 -0
- package/dist/queue.js.map +1 -0
- package/dist/replay.d.ts +34 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +49 -0
- package/dist/replay.js.map +1 -0
- package/dist/safe-fetch.d.ts +86 -0
- package/dist/safe-fetch.d.ts.map +1 -0
- package/dist/safe-fetch.js +311 -0
- package/dist/safe-fetch.js.map +1 -0
- package/dist/store.d.ts +131 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +393 -0
- package/dist/store.js.map +1 -0
- package/dist/xml.d.ts +51 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +196 -0
- package/dist/xml.js.map +1 -0
- package/package.json +49 -0
- package/src/auth.ts +184 -0
- package/src/config.ts +156 -0
- package/src/consumer.ts +140 -0
- package/src/discovery.ts +270 -0
- package/src/fetch.ts +82 -0
- package/src/handler.ts +594 -0
- package/src/hfeed.ts +287 -0
- package/src/index.ts +86 -0
- package/src/jf2.ts +394 -0
- package/src/log.ts +46 -0
- package/src/poll.ts +72 -0
- package/src/queue.ts +26 -0
- package/src/replay.ts +68 -0
- package/src/safe-fetch.ts +346 -0
- package/src/store.ts +644 -0
- package/src/xml.ts +229 -0
package/src/hfeed.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/microsub` — `h-feed` / `h-entry` microformats parsing.
|
|
3
|
+
*
|
|
4
|
+
* When a followed source is an HTML page rather than a syndication feed, its
|
|
5
|
+
* entries are expressed as [microformats2](https://microformats.org/wiki/h-entry)
|
|
6
|
+
* `h-entry` items (usually inside an `h-feed`). This module extracts them into
|
|
7
|
+
* the same JF2 shape the other formats produce, using the Workers runtime's
|
|
8
|
+
* streaming `HTMLRewriter` rather than a regex or a bundled parser
|
|
9
|
+
* (`HTMLRewriter` is built into the runtime — zero script-size cost; see
|
|
10
|
+
* `spec/non-functional-requirements.md`). Because `HTMLRewriter` is a `workerd`
|
|
11
|
+
* global, this parser is async and exercised under the Workers test pool.
|
|
12
|
+
*
|
|
13
|
+
* It is a pragmatic extractor of the common `h-entry` properties — `u-url`,
|
|
14
|
+
* `p-name`, `e-content`, `dt-published`, `p-author` / nested `h-card`,
|
|
15
|
+
* `u-photo`, `p-category`, `u-in-reply-to`, `u-like-of` — not a full mf2 engine.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Jf2Author, Jf2Content, Jf2Entry } from "./jf2";
|
|
21
|
+
|
|
22
|
+
interface MutableCard {
|
|
23
|
+
name?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
photo?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MutableEntry {
|
|
29
|
+
url?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
content?: string;
|
|
32
|
+
published?: string;
|
|
33
|
+
photo: string[];
|
|
34
|
+
category: string[];
|
|
35
|
+
inReplyTo?: string;
|
|
36
|
+
likeOf?: string;
|
|
37
|
+
author?: MutableCard;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type PropFormat = "p" | "u" | "e" | "dt";
|
|
41
|
+
|
|
42
|
+
interface PropFrame {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly format: PropFormat;
|
|
45
|
+
readonly target: MutableEntry | MutableCard;
|
|
46
|
+
buf: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function classes(value: string | null): string[] {
|
|
50
|
+
return value === null ? [] : value.trim().split(/\s+/).filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* HTML void elements have no end tag, so `HTMLRewriter#onEndTag` throws on them.
|
|
55
|
+
* They also carry no text content — a `u-photo` / `dt-published` on one always
|
|
56
|
+
* takes its value from an attribute — so we commit immediately and never
|
|
57
|
+
* register an end-tag handler for them.
|
|
58
|
+
*/
|
|
59
|
+
const VOID_ELEMENTS = new Set([
|
|
60
|
+
"area",
|
|
61
|
+
"base",
|
|
62
|
+
"br",
|
|
63
|
+
"col",
|
|
64
|
+
"embed",
|
|
65
|
+
"hr",
|
|
66
|
+
"img",
|
|
67
|
+
"input",
|
|
68
|
+
"link",
|
|
69
|
+
"meta",
|
|
70
|
+
"param",
|
|
71
|
+
"source",
|
|
72
|
+
"track",
|
|
73
|
+
"wbr",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
function absolute(href: string, base: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(href, base).toString();
|
|
79
|
+
} catch {
|
|
80
|
+
return href;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** FNV-1a hash (base36), matching {@link ./jf2}'s fallback id derivation. */
|
|
85
|
+
function hash(input: string): string {
|
|
86
|
+
let h = 0x811c9dc5;
|
|
87
|
+
for (let i = 0; i < input.length; i++) {
|
|
88
|
+
h ^= input.charCodeAt(i);
|
|
89
|
+
h = Math.imul(h, 0x01000193);
|
|
90
|
+
}
|
|
91
|
+
return (h >>> 0).toString(36);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The first `(p|u|e|dt)-name` microformats class on an element, if any. */
|
|
95
|
+
function propertyClass(
|
|
96
|
+
classList: string[],
|
|
97
|
+
): { name: string; format: PropFormat } | null {
|
|
98
|
+
for (const cls of classList) {
|
|
99
|
+
const match = /^(p|u|e|dt)-(.+)$/.exec(cls);
|
|
100
|
+
if (match) {
|
|
101
|
+
return { name: match[2] as string, format: match[1] as PropFormat };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function commitEntry(entry: MutableEntry, baseUrl: string): Jf2Entry {
|
|
108
|
+
const url = entry.url ? absolute(entry.url, baseUrl) : undefined;
|
|
109
|
+
const content: Jf2Content | undefined = entry.content
|
|
110
|
+
? { text: entry.content }
|
|
111
|
+
: undefined;
|
|
112
|
+
const author: Jf2Author | undefined = entry.author
|
|
113
|
+
? (() => {
|
|
114
|
+
const card: Jf2Author = {
|
|
115
|
+
type: "card",
|
|
116
|
+
...(entry.author.name ? { name: entry.author.name } : {}),
|
|
117
|
+
...(entry.author.url
|
|
118
|
+
? { url: absolute(entry.author.url, baseUrl) }
|
|
119
|
+
: {}),
|
|
120
|
+
...(entry.author.photo
|
|
121
|
+
? { photo: absolute(entry.author.photo, baseUrl) }
|
|
122
|
+
: {}),
|
|
123
|
+
};
|
|
124
|
+
return card.name || card.url || card.photo ? card : undefined;
|
|
125
|
+
})()
|
|
126
|
+
: undefined;
|
|
127
|
+
const id = url ?? hash(`${entry.name ?? ""}${entry.content ?? ""}`);
|
|
128
|
+
|
|
129
|
+
const out: Record<string, unknown> = { type: "entry", _id: id };
|
|
130
|
+
if (url) out.url = url;
|
|
131
|
+
if (entry.published) out.published = entry.published;
|
|
132
|
+
if (entry.name) out.name = entry.name;
|
|
133
|
+
if (content) out.content = content;
|
|
134
|
+
if (author) out.author = author;
|
|
135
|
+
if (entry.category.length > 0) out.category = entry.category;
|
|
136
|
+
if (entry.photo.length > 0)
|
|
137
|
+
out.photo = entry.photo.map((p) => absolute(p, baseUrl));
|
|
138
|
+
if (entry.inReplyTo) out["in-reply-to"] = absolute(entry.inReplyTo, baseUrl);
|
|
139
|
+
if (entry.likeOf) out["like-of"] = absolute(entry.likeOf, baseUrl);
|
|
140
|
+
return out as unknown as Jf2Entry;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract JF2 entries from an HTML document's `h-entry` microformats. Entries
|
|
145
|
+
* are returned in document order. Returns `[]` when the document has none.
|
|
146
|
+
*/
|
|
147
|
+
export async function parseHFeed(
|
|
148
|
+
html: string,
|
|
149
|
+
baseUrl: string,
|
|
150
|
+
): Promise<Jf2Entry[]> {
|
|
151
|
+
if (html === "") return [];
|
|
152
|
+
|
|
153
|
+
const entries: Jf2Entry[] = [];
|
|
154
|
+
const entryStack: MutableEntry[] = [];
|
|
155
|
+
const cardStack: MutableCard[] = [];
|
|
156
|
+
const propStack: PropFrame[] = [];
|
|
157
|
+
|
|
158
|
+
const top = <T>(arr: T[]): T | undefined => arr[arr.length - 1];
|
|
159
|
+
|
|
160
|
+
const rewriter = new HTMLRewriter().on("*", {
|
|
161
|
+
element(el) {
|
|
162
|
+
const classList = classes(el.getAttribute("class"));
|
|
163
|
+
const isVoid = VOID_ELEMENTS.has(el.tagName);
|
|
164
|
+
|
|
165
|
+
if (classList.includes("h-entry") && !isVoid) {
|
|
166
|
+
const entry: MutableEntry = { photo: [], category: [] };
|
|
167
|
+
entryStack.push(entry);
|
|
168
|
+
el.onEndTag(() => {
|
|
169
|
+
const finished = entryStack.pop();
|
|
170
|
+
if (finished) entries.push(commitEntry(finished, baseUrl));
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (classList.includes("h-card") && !isVoid) {
|
|
176
|
+
const card: MutableCard = {};
|
|
177
|
+
const entry = top(entryStack);
|
|
178
|
+
// A bare p-author h-card attaches as the current entry's author.
|
|
179
|
+
if (entry && entry.author === undefined) entry.author = card;
|
|
180
|
+
cardStack.push(card);
|
|
181
|
+
el.onEndTag(() => {
|
|
182
|
+
cardStack.pop();
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const prop = propertyClass(classList);
|
|
188
|
+
if (prop === null) return;
|
|
189
|
+
const target = top(cardStack) ?? top(entryStack);
|
|
190
|
+
if (target === undefined) return;
|
|
191
|
+
|
|
192
|
+
const frame: PropFrame = {
|
|
193
|
+
name: prop.name,
|
|
194
|
+
format: prop.format,
|
|
195
|
+
target,
|
|
196
|
+
buf: "",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// u-* and dt-* take their value from an attribute when present; that value
|
|
200
|
+
// is committed immediately, so no text capture (or end tag) is needed.
|
|
201
|
+
if (prop.format === "u") {
|
|
202
|
+
const attr =
|
|
203
|
+
el.getAttribute("href") ??
|
|
204
|
+
el.getAttribute("src") ??
|
|
205
|
+
el.getAttribute("data") ??
|
|
206
|
+
el.getAttribute("poster");
|
|
207
|
+
if (attr !== null) {
|
|
208
|
+
assignProperty(frame, attr);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
} else if (prop.format === "dt") {
|
|
212
|
+
const attr = el.getAttribute("datetime") ?? el.getAttribute("value");
|
|
213
|
+
if (attr !== null) {
|
|
214
|
+
assignProperty(frame, attr);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Text-valued property: a void element has no text to capture.
|
|
220
|
+
if (isVoid) return;
|
|
221
|
+
|
|
222
|
+
propStack.push(frame);
|
|
223
|
+
el.onEndTag(() => {
|
|
224
|
+
const finished = propStack.pop();
|
|
225
|
+
if (finished) assignProperty(finished, finished.buf.trim());
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
text(chunk) {
|
|
229
|
+
const frame = top(propStack);
|
|
230
|
+
if (frame) frame.buf += chunk.text;
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await rewriter.transform(new Response(html)).text();
|
|
235
|
+
return entries;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Write a resolved property value onto its target entry or card. */
|
|
239
|
+
function assignProperty(frame: PropFrame, value: string): void {
|
|
240
|
+
if (value === "") return;
|
|
241
|
+
const target = frame.target;
|
|
242
|
+
// Card target (author).
|
|
243
|
+
if (isCard(target)) {
|
|
244
|
+
if (frame.name === "name" && target.name === undefined) target.name = value;
|
|
245
|
+
else if (frame.name === "url" && target.url === undefined)
|
|
246
|
+
target.url = value;
|
|
247
|
+
else if (frame.name === "photo" && target.photo === undefined)
|
|
248
|
+
target.photo = value;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Entry target.
|
|
252
|
+
switch (frame.name) {
|
|
253
|
+
case "url":
|
|
254
|
+
target.url ??= value;
|
|
255
|
+
break;
|
|
256
|
+
case "name":
|
|
257
|
+
target.name ??= value;
|
|
258
|
+
break;
|
|
259
|
+
case "content":
|
|
260
|
+
target.content ??= value;
|
|
261
|
+
break;
|
|
262
|
+
case "summary":
|
|
263
|
+
target.content ??= value;
|
|
264
|
+
break;
|
|
265
|
+
case "published":
|
|
266
|
+
target.published ??= value;
|
|
267
|
+
break;
|
|
268
|
+
case "photo":
|
|
269
|
+
target.photo.push(value);
|
|
270
|
+
break;
|
|
271
|
+
case "category":
|
|
272
|
+
target.category.push(value);
|
|
273
|
+
break;
|
|
274
|
+
case "in-reply-to":
|
|
275
|
+
target.inReplyTo ??= value;
|
|
276
|
+
break;
|
|
277
|
+
case "like-of":
|
|
278
|
+
target.likeOf ??= value;
|
|
279
|
+
break;
|
|
280
|
+
default:
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isCard(target: MutableEntry | MutableCard): target is MutableCard {
|
|
286
|
+
return !("photo" in target && Array.isArray((target as MutableEntry).photo));
|
|
287
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/microsub` — a [Microsub](https://indieweb.org/Microsub-spec) server: the
|
|
3
|
+
* IndieWeb **read side**.
|
|
4
|
+
*
|
|
5
|
+
* Endpoint package: exports a factory returning a `fetch`-compatible handler,
|
|
6
|
+
* mountable under a path prefix so it composes with other `@dwk` packages in one
|
|
7
|
+
* Worker. It manages feed **subscriptions** organised into **channels**, polls
|
|
8
|
+
* and parses sources server-side (Atom / RSS / JSON Feed / `h-feed`), and serves
|
|
9
|
+
* a normalised **JF2** timeline to reader clients (Monocle, Together,
|
|
10
|
+
* Indigenous) — keeping the user's reading state on infrastructure they own.
|
|
11
|
+
*
|
|
12
|
+
* It consumes the DPoP-bound IndieAuth access tokens issued by `@dwk/indieauth`
|
|
13
|
+
* (same authorization as `@dwk/micropub`), so a token minted for a different
|
|
14
|
+
* `me` cannot read here. Subscriptions, timeline, and read-state live in D1 — a
|
|
15
|
+
* strongly-consistent store, never KV. Polling runs off the read path on a
|
|
16
|
+
* Cron-triggered queue; every outbound fetch is SSRF-guarded.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/packages/microsub.md
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export { createMicrosub } from "./handler";
|
|
23
|
+
export type { MicrosubHandler } from "./handler";
|
|
24
|
+
|
|
25
|
+
export { createMicrosubPoller } from "./poll";
|
|
26
|
+
export type { MicrosubScheduledHandler } from "./poll";
|
|
27
|
+
|
|
28
|
+
export { createMicrosubQueueConsumer } from "./consumer";
|
|
29
|
+
export type { MicrosubQueueConsumer, ConsumerOptions } from "./consumer";
|
|
30
|
+
|
|
31
|
+
export { resolveConfig } from "./config";
|
|
32
|
+
export type { MicrosubConfig, MicrosubEnv, ResolvedConfig } from "./config";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
createMicrosubStore,
|
|
36
|
+
NOTIFICATIONS_CHANNEL,
|
|
37
|
+
type MicrosubStore,
|
|
38
|
+
type MicrosubStoreEnv,
|
|
39
|
+
type ChannelRecord,
|
|
40
|
+
type FollowRecord,
|
|
41
|
+
type StoredItem,
|
|
42
|
+
type ItemPage,
|
|
43
|
+
type ListOptions,
|
|
44
|
+
type FeedCache,
|
|
45
|
+
} from "./store";
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
authorize,
|
|
49
|
+
tokenFromHeader,
|
|
50
|
+
hasScope,
|
|
51
|
+
type AuthEnv,
|
|
52
|
+
type AuthResult,
|
|
53
|
+
type AuthSuccess,
|
|
54
|
+
type AuthFailure,
|
|
55
|
+
} from "./auth";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
parseFeed,
|
|
59
|
+
type Jf2Entry,
|
|
60
|
+
type Jf2Author,
|
|
61
|
+
type Jf2Content,
|
|
62
|
+
} from "./jf2";
|
|
63
|
+
|
|
64
|
+
export { parseHFeed } from "./hfeed";
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
discoverFeed,
|
|
68
|
+
fetchFeed,
|
|
69
|
+
type DiscoveredFeed,
|
|
70
|
+
type FetchedFeed,
|
|
71
|
+
type DiscoveryOptions,
|
|
72
|
+
} from "./discovery";
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
safeFetch,
|
|
76
|
+
assertPublicUrl,
|
|
77
|
+
isPrivateOrReservedHost,
|
|
78
|
+
SsrfError,
|
|
79
|
+
type SsrfReason,
|
|
80
|
+
} from "./safe-fetch";
|
|
81
|
+
|
|
82
|
+
export type { FetchLike } from "./fetch";
|
|
83
|
+
export type { MicrosubJob, PollJob } from "./queue";
|
|
84
|
+
|
|
85
|
+
export { MicrosubLogEvent } from "./log";
|
|
86
|
+
export type { Logger, Metrics } from "@dwk/log";
|