sdxc

[ Feeds & publishing ]

@sdxc/webmention

Receive, verify, discover and send Webmentions

npm add @sdxc/webmention
pnpm add @sdxc/webmention
yarn add @sdxc/webmention
bun add @sdxc/webmention
Used by
blog

Receive, verify, discover and send Webmentions.

Webmention tells a page it was linked to: the linking site discovers the page's endpoint and POSTs a source and a target; the receiver fetches source, confirms it links to target, and shows it as a reply, like, repost, bookmark or mention. This package holds the protocol; storage, moderation and queueing stay with you.

Installation

npm add @sdxc/webmention

Fallible functions answer a Result from @sdxc/result. Sources are parsed with @sdxc/html and read with @sdxc/microformats; both install with it.

Every outbound fetch is bounded: HTTP(S) to public host names only (no address literals, reserved suffixes such as localhost, internal or test, or URLs carrying credentials), each redirect hop re-checked, and by default five hops, one megabyte and five seconds for the whole fetch.

Usage

Receive

The endpoint only reads and checks the request, then queues verification, so an anonymous POST never makes your server fetch a URL while the caller 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

A mention is keyed by its pair: linked upserts it, gone and unlinked delete it.

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)) return outcome.error.retryable ? message.retry() : message.ack();

if (outcome.data.status === "linked") await mentions.upsert(pair, outcome.data.mention);
else await mentions.delete(pair);
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"
// in the page head: <link rel={link.rel} href={link.href} />

Send

import { outboundLinks, plan, send } from "@sdxc/webmention/sender";

let current = outboundLinks(post.html, post.url);
for (let target of plan(current, await sentTargets(post.url)).targets) {
	await send({ source: new URL(post.url), target }, { userAgent: "Example/1.0" });
}

API

@sdxc/webmention

Webmention

Types only. Pair is { source: URL; target: URL }; sending a pair again updates the same mention. Kind is "reply" | "like" | "repost" | "bookmark" | "mention". Author has name, url and photo, each nullable. Mention has kind, url (the entry's u-url, else the source), author, content: { html, text } | null (html already sanitized against the source), name and published: Date | null.

WebmentionRequestError

A request answered 400. reason (a WebmentionRequestReason) is "media-type", "missing", "invalid-url", "same-url" or "target-not-accepted"; message is the body rejected sends.

WebmentionFetchError

A fetch that did not finish. retryable is true for a timeout, a network failure or a 5xx or 429 answer, and false for a refused host, too many redirects or a body over the cap.

WebmentionSendError

An endpoint that answered a send outside 2xx, with that status.

@sdxc/webmention/receiver

parseRequest(request: Request, options: Receiver.ParseOptions): Promise<Result<Webmention.Pair, WebmentionRequestError>>

Checks, in the specification's order, a form-encoded Content-Type, both URLs present and HTTP(S), source on a public host, the two different, and options.accepts(target). Pass the already-read body as options.formData. 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 the bounds and answers { status: "linked", mention, document, finalUrl } (document is the source's microformats, finalUrl where redirects ended), { status: "gone" } for a 410, or { status: "unlinked" } for any other 4xx, a body without the link, or a format that carries no links. HTML links by attribute, JSON when a string in it is the target (and is summarized when it is a microformats document), text 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, resolved against the page's <base href> or baseUrl, ignoring fragments on both sides.

summarize(document: MF2.Document, source: URL, target: URL, title?: string | null): Webmention.Mention

The mention from the entry responding to target: its kind, u-url, author by the authorship algorithm, sanitized content, published instant, and its name when it is an article. A page with no such entry is a mention named title and authored by its representative h-card.

@sdxc/webmention/discover

discover(target: string | URL, options: Discover.Options): Promise<Result<URL | null, WebmentionFetchError>>

Fetches target under the bounds and answers its endpoint, or null when it advertises none or answers 4xx. An endpoint on a private host is a non-retryable failure.

endpointOf(response: Response, finalUrl: string): Promise<URL | null>

The endpoint a response you already fetched advertises: the first Link value with rel="webmention", else the first such <link> or <a> in an HTML body, resolved against finalUrl. An empty href names the page itself.

{ header, link }: the Link header value and the <link> attributes for your endpoint. A URL is written absolute, a string as given.

@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" }.

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, so receivers learn a link was removed.

Types

  • Receiver.ParseOptions: formData and accepts(target), which may be async.

  • Receiver.VerifyOptions, Discover.Options, Sender.Options: userAgent (required), maxBytes (default 1 MiB), timeoutMs (default 5000) and maxRedirects (default 5).

  • Receiver.Outcome, Sender.Delivery, Sender.Plan, Discover.Advertisement: the results described above.

Pattern: An Endpoint And Its Verification Job

The request handler answers at once; the job verifies, stores the mention as pending moderation, and retries only what can succeed later.

import { isFailure } from "@sdxc/result";
import { accepted, parseRequest, rejected, verify } from "@sdxc/webmention/receiver";

let USER_AGENT = "Example/1.0 (+https://example.com)";

export async function endpoint(request: Request) {
	let parsed = await parseRequest(request, {
		formData: await request.formData(),
		accepts: async (target) =>
			target.origin === "https://example.com" && (await posts.exists(target)),
	});
	if (isFailure(parsed)) return rejected(parsed.error);
	await queue.send({ source: parsed.data.source.href, target: parsed.data.target.href });
	return accepted();
}

export async function verifyJob(message: { source: string; target: string }) {
	let pair = { source: new URL(message.source), target: new URL(message.target) };
	let outcome = await verify(pair, { userAgent: USER_AGENT, timeoutMs: 10_000 });
	if (isFailure(outcome)) {
		if (outcome.error.retryable) throw outcome.error;
		return;
	}
	if (outcome.data.status === "linked") {
		await mentions.upsert(pair, { ...outcome.data.mention, approved: false });
	} else {
		await mentions.delete(pair);
	}
}

mention.content.html is safe to render as it stands. Rate-limit the endpoint per client IP and verification per source host, and render author photos with referrerpolicy="no-referrer" and fixed dimensions.

Pattern: Notify On Create And Update

Record which targets each post notified, so the next update also notifies links you removed.

import { isFailure } from "@sdxc/result";
import { outboundLinks, plan, send } from "@sdxc/webmention/sender";

export async function notify(post: { url: string; html: string }) {
	let source = new URL(post.url);
	let current = outboundLinks(post.html, source);
	let { targets } = plan(current, await sent.targetsOf(post.url));

	for (let target of targets) {
		let delivery = await send({ source, target }, { userAgent: "Example/1.0" });
		if (isFailure(delivery)) continue;
		if (delivery.data.status === "sent") await sent.record(post.url, target);
	}
}

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 sent.targetsOf(post.url));
for (let target of targets) {
	await send({ source: new URL(post.url), target }, { userAgent: "Example/1.0" });
}

Pattern: Retry Or Acknowledge A Send

A fetch error carries retryable; an endpoint's refusal carries its status.

import { isFailure } from "@sdxc/result";
import { WebmentionFetchError } from "@sdxc/webmention";
import { send } from "@sdxc/webmention/sender";

let delivery = await send(pair, { userAgent: "Example/1.0" });
if (isFailure(delivery)) {
	let error = delivery.error;
	let retry =
		error instanceof WebmentionFetchError
			? error.retryable
			: error.status >= 500 || error.status === 429;
	return retry ? message.retry() : message.ack();
}

Pattern: Check A Source You Already Parsed

When you fetch and parse the source yourself, linksTo and summarize give the same answer verify would.

import { parseDocument } from "@sdxc/html/document";
import { fromDocument } from "@sdxc/microformats";
import { isFailure } from "@sdxc/result";
import { linksTo, summarize } from "@sdxc/webmention/receiver";

let source = new URL("https://example.org/reply");
let target = new URL("https://example.com/post");

let document = parseDocument(html);
if (isFailure(document)) return null;
if (!linksTo(document.data, source.href, target)) return null;

let title = document.data.querySelector("title")?.textContent ?? null;
let mention = summarize(fromDocument(document.data, source), source, target, title);

Pattern: Discover Without Sending

discover answers whether a page takes Webmentions; endpointOf reads a response you fetched through your own client.

import { isFailure } from "@sdxc/result";
import { discover, endpointOf } from "@sdxc/webmention/discover";

let found = await discover("https://example.org/post", { userAgent: "Example/1.0" });
let acceptsMentions = !isFailure(found) && found.data !== null;

let response = await fetch("https://example.org/post");
let endpoint = await endpointOf(response, response.url);

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.