@sdxc/webmention
Receive, verify, discover and send Webmentions
- Depends on
remix- Used by
- blog
- Source
- packages/webmention
Receive, verify, discover and send Webmentions.
Installation
npm add @sdxc/webmention
Fallible functions return @sdxc/result values.
It installs alongside this package with @sdxc/microformats,
which reads a source's entry, author and response type, and
@sdxc/html, which parses sources once and
sanitizes their content.
Webmention tells a page it was linked to. The linking site
discovers the linked page's endpoint and POSTs two URLs, source and target; the receiver
fetches source, confirms it links to target, and shows it as a reply, a like, a repost, a
bookmark or a plain mention. This package holds the protocol; storage, moderation and queueing
stay with you.
@sdxc/webmention/receiverreads the endpoint's request without fetching anything, answers it202or400, and verifies a queued pair in a background task: a bounded fetch ofsource, an exact link check, and a display-ready summary read from the source's microformats.@sdxc/webmention/discoverfinds a page's endpoint (Linkheader first, then the first<link>or<a>in document order) and writes the values advertising your own.endpointOfpasses all 23 webmention.rocks discovery tests.@sdxc/webmention/senderlists an entry's outbound links, plans which targets a create, update or delete notifies, and discovers and POSTs one mention.@sdxc/webmentionholds the sharedWebmentiontypes and the three errors.
Every outbound fetch is bounded: HTTP(S) to public hosts only, every redirect hop re-checked, five hops, one megabyte and five seconds by default.
Usage
Receive
Queue verification rather than running it inside the endpoint's request, so an anonymous POST cannot make your server fetch a URL of the caller's choosing while it waits.
import { isFailure } from "@sdxc/result";
import { accepted, parseRequest, rejected } from "@sdxc/webmention/receiver";
export async function webmentionEndpoint(request: Request) {
let parsed = await parseRequest(request, {
formData: await request.formData(),
accepts: (target) => posts.has(target.pathname),
});
if (isFailure(parsed)) return rejected(parsed.error);
await queue.send({ source: parsed.data.source.href, target: parsed.data.target.href });
return accepted();
}
Verify In The Background
Key stored mentions on the pair: a linked outcome upserts, gone and unlinked delete.
import { isFailure } from "@sdxc/result";
import { verify } from "@sdxc/webmention/receiver";
let pair = { source: new URL(message.source), target: new URL(message.target) };
let outcome = await verify(pair, { userAgent: "Example/1.0 (+https://example.com)" });
if (isFailure(outcome)) {
if (outcome.error.retryable) return message.retry();
return message.ack();
}
if (outcome.data.status === "linked") await mentions.upsert(pair, outcome.data.mention);
else await mentions.delete(pair);
Send
import { outboundLinks, plan, send } from "@sdxc/webmention/sender";
let current = outboundLinks(post.html, post.url);
let previous = await sentTargets(post.url);
for (let target of plan(current, previous).targets) {
let delivery = await send(
{ source: new URL(post.url), target },
{ userAgent: "Example/1.0 (+https://example.com)" },
);
}
Advertise
import { advertise } from "@sdxc/webmention/discover";
let { header, link } = advertise("https://example.com/webmention");
response.headers.append("Link", header); // <https://example.com/webmention>; rel="webmention"
<link rel={link.rel} href={link.href} />;
API
@sdxc/webmention
Webmention namespace
Pair (source, target as URLs; a repeated pair is an update), Kind (reply, like,
repost, bookmark, mention), Author (name, url, photo, each nullable) and
Mention (kind, url, author, content: { html, text } | null, name, published).
Mention.content.html is already sanitized, with the source as its base URL.
WebmentionRequestError
A request the specification answers with 400. reason (a WebmentionRequestReason) is
media-type, missing, invalid-url, same-url or target-not-accepted; message is the
text rejected sends.
WebmentionFetchError
A fetch that did not finish. retryable is true for a timeout, a network failure, a
redirect chain over the limit, or a 5xx/429 answer, and false for a refused host or a
body over the cap.
WebmentionSendError
An endpoint that answered a send outside 2xx; status is what it answered.
@sdxc/webmention/receiver
parseRequest(request: Request, options: Receiver.ParseOptions): Promise<Result<Webmention.Pair, WebmentionRequestError>>
Reads source and target from options.formData, the body your framework already read
(the request stream is consumed by then), and checks them in the specification's order: a form-encoded body, both URLs
present, both HTTP(S), source on a public host, the two different, and options.accepts(target)
true. It fetches nothing.
accepted(location?: string | URL): Response
202 Accepted, with Location when you have a status page for the mention.
rejected(error: WebmentionRequestError): Response
400 Bad Request with the error's message as text/plain.
verify(pair: Webmention.Pair, options: Receiver.VerifyOptions): Promise<Result<Receiver.Outcome, WebmentionFetchError>>
Fetches source under bounds (userAgent required; maxBytes 1 MiB, timeoutMs 5000 and
maxRedirects 5 by default) and answers:
{ status: "linked", mention, document, finalUrl }: the source links to the target;documentis its microformats,finalUrlwhere the redirects ended.{ status: "gone" }: the source answered410.{ status: "unlinked" }: any other 4xx, a body without the link, or a format that carries no links (an image, say).
HTML is parsed once for linksTo, the microformats and the <title>. A JSON source links
when a string in it is the target, and is summarized like HTML when it is a microformats
document; a text source links when it contains the target.
linksTo(document: DOMDocument, baseUrl: string, target: URL): boolean
Whether a parsed page links to target from an href, src, poster or data
attribute, each resolved against the page's <base href> or baseUrl. Fragments are
ignored on both sides; the URL as plain text, in a comment or in escaped markup is no link.
summarize(document: MF2.Document, source: URL, target: URL, title?: string | null): Webmention.Mention
The mention from the entry that responds to target, found with responseTo from
@sdxc/microformats/vocabulary: its
kind, u-url, author by the authorship algorithm, sanitized content (or its summary as
text), published instant, and its name when it is an article. A page with no such entry
is a mention named by title and authored by the page's representative h-card.
Receiver
Types only: ParseOptions (formData, accepts), VerifyOptions (userAgent, maxBytes,
timeoutMs, maxRedirects) and Outcome.
@sdxc/webmention/discover
endpointOf(response: Response, finalUrl: string): Promise<URL | null>
The endpoint a response advertises: the first Link value whose rel holds webmention,
else, for an HTML body, the first <link href> or <a href> in document order whose rel
holds it. Resolved against finalUrl, query string kept; an empty href is the page itself.
discover(target: string | URL, options: Discover.Options): Promise<Result<URL | null, WebmentionFetchError>>
Fetches target under the same bounds as verify and runs discovery. A 4xx target has no
endpoint (null); a 5xx or 429 is a retryable failure; an endpoint on a private host is
a non-retryable failure, so a page cannot point a sender at an internal address.
advertise(endpoint: string | URL): Discover.Advertisement
{ header, link }: the Link header value and the <link> attributes for your endpoint. A
URL is written absolute, a string as given.
Discover
Types only: Options (the same bounds as Receiver.VerifyOptions) and Advertisement.
@sdxc/webmention/sender
send(pair: Webmention.Pair, options: Sender.Options): Promise<Result<Sender.Delivery, WebmentionFetchError | WebmentionSendError>>
Discovers the target's endpoint and POSTs the pair form-encoded, following no redirect.
Answers { status: "sent", endpoint, code, location } for any 2xx (location absolute, or
null), or { status: "no-endpoint" }.
outboundLinks(html: string, baseUrl: string | URL): URL[]
The <a> and <area> links in an entry's content, resolved against its URL, HTTP(S) only,
each once in document order, leaving out the entry's own origin.
plan(current: URL[], previous: URL[]): Sender.Plan
{ targets }: the current links, then every previously notified target the post no longer
links to, since the specification has removed links notified too.
Sender
Types only: Options (the same bounds), Delivery and Plan.
Pattern: Delete A Post
Serve 410 Gone from the post's URL first, then notify every past target, so each receiver's
verification sees the deletion and removes its mention:
import { plan, send } from "@sdxc/webmention/sender";
let { targets } = plan([], await sentTargets(post.url));
for (let target of targets) {
await send({ source: new URL(post.url), target }, { userAgent });
}
Pattern: Retry Or Acknowledge
WebmentionFetchError.retryable and WebmentionSendError.status are what a background task
reads:
import { isFailure } from "@sdxc/result";
import { WebmentionFetchError } from "@sdxc/webmention";
import { send } from "@sdxc/webmention/sender";
let delivery = await send(pair, { userAgent });
if (isFailure(delivery)) {
let error = delivery.error;
let retry =
error instanceof WebmentionFetchError
? error.retryable
: error.status >= 500 || error.status === 429;
if (retry) return message.retry();
return message.ack();
}
Rate-limit the endpoint per client IP and verification per source host. Store mentions as
pending and show them once approved; content.html is safe to render, and author photos render
best with referrerpolicy="no-referrer" and fixed dimensions.