@fedify/fedify
Version:
An ActivityPub server framework
1,292 lines • 249 kB
JavaScript
const { Temporal } = require("@js-temporal/polyfill");
const { URLPattern } = require("urlpattern-polyfill");
const require_chunk = require("./chunk-DDcVe30Y.cjs");
const require_transformers = require("./transformers-NeAONrAq.cjs");
const require_http = require("./http-BajqV0pH.cjs");
const require_proof = require("./proof-HzavUYxN.cjs");
const require_types = require("./types-KC4QAoxe.cjs");
const require_kv_cache = require("./kv-cache-CVG3m2_-.cjs");
let _logtape_logtape = require("@logtape/logtape");
let _fedify_uri_template = require("@fedify/uri-template");
let _fedify_vocab = require("@fedify/vocab");
let _opentelemetry_api = require("@opentelemetry/api");
let byte_encodings_hex = require("byte-encodings/hex");
let es_toolkit = require("es-toolkit");
let _fedify_vocab_runtime = require("@fedify/vocab-runtime");
let _opentelemetry_semantic_conventions = require("@opentelemetry/semantic-conventions");
let _fedify_vocab_runtime_jsonld = require("@fedify/vocab-runtime/jsonld");
_fedify_vocab_runtime_jsonld = require_chunk.__toESM(_fedify_vocab_runtime_jsonld, 1);
let _fedify_webfinger = require("@fedify/webfinger");
let _opentelemetry_sdk_metrics = require("@opentelemetry/sdk-metrics");
let node_url = require("node:url");
//#region src/federation/activity-listener.ts
var ActivityListenerSet = class {
#listeners;
constructor() {
this.#listeners = /* @__PURE__ */ new Map();
}
clone() {
const Clone = this.constructor;
const clone = new Clone();
clone.#listeners = new Map(this.#listeners);
return clone;
}
add(type, listener) {
if (this.#listeners.has(type)) throw new TypeError("Listener already set for this type.");
this.#listeners.set(type, listener);
}
dispatchWithClass(activity) {
let cls = activity.constructor;
while (cls != null) {
if (this.#listeners.has(cls)) break;
if (cls === _fedify_vocab.Activity) return null;
cls = globalThis.Object.getPrototypeOf(cls);
}
if (cls == null) return null;
const listener = this.#listeners.get(cls);
return {
class: cls,
listener
};
}
dispatch(activity) {
return this.dispatchWithClass(activity)?.listener ?? null;
}
};
//#endregion
//#region src/federation/builder.ts
const ACTOR_ALIAS_PREFIX = "actorAlias:";
/**
* Route options shared by every dispatcher whose path must expose exactly
* one `{identifier}` variable bound to a single, non-empty value
* for `setOutboxDispatcher/Listener()`.
*/
const identifierSingular = {
exact: true,
variables: { identifier: { operatables: [""] } }
};
/**
* Route options shared by every dispatcher whose path must expose
* `{identifier}` and `{+identifier}` variables bound to the same single,
* non-empty value for following setters:
* - `setActorDispatcher()` (actor path)
* - `setInboxDispatcher/Listener()` (inbox path)
* - `setFollowingDispatcher()` (following path)
* - `setFollowersDispatcher()` (followers path)
* - `setLikedDispatcher()` (liked path)
* - `setFeaturedDispatcher()` (featured path)
* - `setFeaturedTagsDispatcher()` (featured tags path)
*/
const identifierSingularAllowPlus = {
exact: true,
variables: { identifier: { operatables: ["", "+"] } }
};
var FederationBuilderImpl = class {
router;
actorCallbacks;
nodeInfoDispatcher;
webFingerLinksDispatcher;
objectCallbacks;
objectTypeIds;
inboxPath;
outboxPath;
inboxCallbacks;
outboxCallbacks;
followingCallbacks;
followersCallbacks;
likedCallbacks;
featuredCallbacks;
featuredTagsCallbacks;
inboxListeners;
outboxListeners;
inboxErrorHandler;
outboxListenerErrorHandler;
outboxAuthorizePredicate;
sharedInboxKeyDispatcher;
unverifiedActivityHandler;
outboxPermanentFailureHandler;
idempotencyStrategy;
collectionTypeIds;
collectionCallbacks;
/**
* Symbol registry for unique identification of unnamed symbols.
*/
#symbolRegistry = /* @__PURE__ */ new Map();
constructor() {
this.router = new _fedify_uri_template.Router();
this.objectCallbacks = {};
this.objectTypeIds = {};
this.collectionCallbacks = {};
this.collectionTypeIds = {};
}
/**
* Builds the federation object.
* @param options Parameters for initializing the federation object.
* @returns The federation object.
* @throws {TypeError} If benchmark mode and `meterProvider` are both
* specified.
*/
async build(options) {
const { FederationImpl } = await Promise.resolve().then(() => middleware_exports);
const f = new FederationImpl(options);
const trailingSlashInsensitiveValue = f.router.trailingSlashInsensitive;
f.router = this.router.clone();
f.router.trailingSlashInsensitive = trailingSlashInsensitiveValue;
f._initializeRouter();
f.actorCallbacks = this.actorCallbacks == null ? void 0 : { ...this.actorCallbacks };
f.nodeInfoDispatcher = this.nodeInfoDispatcher;
f.webFingerLinksDispatcher = this.webFingerLinksDispatcher;
f.objectCallbacks = { ...this.objectCallbacks };
f.objectTypeIds = { ...this.objectTypeIds };
f.inboxPath = this.inboxPath;
f.outboxPath = this.outboxPath;
f.inboxCallbacks = this.inboxCallbacks == null ? void 0 : { ...this.inboxCallbacks };
f.outboxCallbacks = this.outboxCallbacks == null ? void 0 : { ...this.outboxCallbacks };
f.followingCallbacks = this.followingCallbacks == null ? void 0 : { ...this.followingCallbacks };
f.followersCallbacks = this.followersCallbacks == null ? void 0 : { ...this.followersCallbacks };
f.likedCallbacks = this.likedCallbacks == null ? void 0 : { ...this.likedCallbacks };
f.featuredCallbacks = this.featuredCallbacks == null ? void 0 : { ...this.featuredCallbacks };
f.featuredTagsCallbacks = this.featuredTagsCallbacks == null ? void 0 : { ...this.featuredTagsCallbacks };
f.inboxListeners = this.inboxListeners?.clone();
f.outboxListeners = this.outboxListeners?.clone();
f.inboxErrorHandler = this.inboxErrorHandler;
f.outboxListenerErrorHandler = this.outboxListenerErrorHandler;
f.outboxAuthorizePredicate = this.outboxAuthorizePredicate;
f.sharedInboxKeyDispatcher = this.sharedInboxKeyDispatcher;
f.unverifiedActivityHandler = this.unverifiedActivityHandler;
f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler;
f.idempotencyStrategy = this.idempotencyStrategy;
return f;
}
_getTracer() {
return _opentelemetry_api.trace.getTracer(require_http.name, require_http.version);
}
setActorDispatcher(path, dispatcher) {
if (this.router.has("actor")) throw new _fedify_uri_template.RouterError("Actor dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "actor", identifierSingularAllowPlus);
const callbacks = { dispatcher: async (context, identifier) => {
const actor = await this._getTracer().startActiveSpan("activitypub.dispatch_actor", {
kind: _opentelemetry_api.SpanKind.SERVER,
attributes: { "fedify.actor.identifier": identifier }
}, async (span) => {
try {
const actor = await dispatcher(context, identifier);
span.setAttribute("activitypub.actor.id", (actor?.id ?? context.getActorUri(identifier)).href);
if (actor == null) span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
else span.setAttribute("activitypub.actor.type", (0, _fedify_vocab.getTypeId)(actor).href);
return actor;
} catch (error) {
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(error)
});
throw error;
} finally {
span.end();
}
});
if (actor == null) return null;
const logger = (0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"actor"
]);
if (actor.id == null) logger.warn("Actor dispatcher returned an actor without an id property. Set the property with Context.getActorUri(identifier).");
else if (actor.id.href != context.getActorUri(identifier).href) logger.warn("Actor dispatcher returned an actor with an id property that does not match the actor URI. Set the property with Context.getActorUri(identifier).");
if (actor instanceof _fedify_vocab.Tombstone) return actor;
if (this.followingCallbacks != null && this.followingCallbacks.dispatcher != null) {
if (actor.followingId == null) logger.warn("You configured a following collection dispatcher, but the actor does not have a following property. Set the property with Context.getFollowingUri(identifier).");
else if (actor.followingId.href != context.getFollowingUri(identifier).href) logger.warn("You configured a following collection dispatcher, but the actor's following property does not match the following collection URI. Set the property with Context.getFollowingUri(identifier).");
}
if (this.followersCallbacks != null && this.followersCallbacks.dispatcher != null) {
if (actor.followersId == null) logger.warn("You configured a followers collection dispatcher, but the actor does not have a followers property. Set the property with Context.getFollowersUri(identifier).");
else if (actor.followersId.href != context.getFollowersUri(identifier).href) logger.warn("You configured a followers collection dispatcher, but the actor's followers property does not match the followers collection URI. Set the property with Context.getFollowersUri(identifier).");
}
if (this.outboxCallbacks != null && this.outboxCallbacks.dispatcher != null) {
if (actor?.outboxId == null) logger.warn("You configured an outbox collection dispatcher, but the actor does not have an outbox property. Set the property with Context.getOutboxUri(identifier).");
else if (actor.outboxId.href != context.getOutboxUri(identifier).href) logger.warn("You configured an outbox collection dispatcher, but the actor's outbox property does not match the outbox collection URI. Set the property with Context.getOutboxUri(identifier).");
}
if (this.likedCallbacks != null && this.likedCallbacks.dispatcher != null) {
if (actor?.likedId == null) logger.warn("You configured a liked collection dispatcher, but the actor does not have a liked property. Set the property with Context.getLikedUri(identifier).");
else if (actor.likedId.href != context.getLikedUri(identifier).href) logger.warn("You configured a liked collection dispatcher, but the actor's liked property does not match the liked collection URI. Set the property with Context.getLikedUri(identifier).");
}
if (this.featuredCallbacks != null && this.featuredCallbacks.dispatcher != null) {
if (actor?.featuredId == null) logger.warn("You configured a featured collection dispatcher, but the actor does not have a featured property. Set the property with Context.getFeaturedUri(identifier).");
else if (actor.featuredId.href != context.getFeaturedUri(identifier).href) logger.warn("You configured a featured collection dispatcher, but the actor's featured property does not match the featured collection URI. Set the property with Context.getFeaturedUri(identifier).");
}
if (this.featuredTagsCallbacks != null && this.featuredTagsCallbacks.dispatcher != null) {
if (actor?.featuredTagsId == null) logger.warn("You configured a featured tags collection dispatcher, but the actor does not have a featuredTags property. Set the property with Context.getFeaturedTagsUri(identifier).");
else if (actor.featuredTagsId.href != context.getFeaturedTagsUri(identifier).href) logger.warn("You configured a featured tags collection dispatcher, but the actor's featuredTags property does not match the featured tags collection URI. Set the property with Context.getFeaturedTagsUri(identifier).");
}
if (this.router.has("inbox")) {
if (actor.inboxId == null) logger.warn("You configured inbox listeners, but the actor does not have an inbox property. Set the property with Context.getInboxUri(identifier).");
else if (actor.inboxId.href != context.getInboxUri(identifier).href) logger.warn("You configured inbox listeners, but the actor's inbox property does not match the inbox URI. Set the property with Context.getInboxUri(identifier).");
if (actor.endpoints == null || actor.endpoints.sharedInbox == null) logger.warn("You configured inbox listeners, but the actor does not have a endpoints.sharedInbox property. Set the property with Context.getInboxUri().");
else if (actor.endpoints.sharedInbox.href != context.getInboxUri().href) logger.warn("You configured inbox listeners, but the actor's endpoints.sharedInbox property does not match the shared inbox URI. Set the property with Context.getInboxUri().");
}
if (callbacks.keyPairsDispatcher != null) {
if (actor.publicKeyId == null) logger.warn("You configured a key pairs dispatcher, but the actor does not have a publicKey property. Set the property with Context.getActorKeyPairs(identifier).");
if (actor.assertionMethodId == null) logger.warn("You configured a key pairs dispatcher, but the actor does not have an assertionMethod property. Set the property with Context.getActorKeyPairs(identifier).");
}
return actor;
} };
this.actorCallbacks = callbacks;
const setters = {
setKeyPairsDispatcher: (dispatcher) => {
callbacks.keyPairsDispatcher = (ctx, identifier) => this._getTracer().startActiveSpan("activitypub.dispatch_actor_key_pairs", {
kind: _opentelemetry_api.SpanKind.SERVER,
attributes: {
"activitypub.actor.id": ctx.getActorUri(identifier).href,
"fedify.actor.identifier": identifier
}
}, async (span) => {
try {
return await dispatcher(ctx, identifier);
} catch (e) {
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(e)
});
throw e;
} finally {
span.end();
}
});
return setters;
},
mapHandle(mapper) {
callbacks.handleMapper = mapper;
return setters;
},
mapAlias(mapper) {
callbacks.aliasMapper = mapper;
return setters;
},
mapActorAlias: (path, identifier) => {
if (identifier === "") throw new _fedify_uri_template.RouterError("Identifier cannot be empty.");
if (this.router.has(`actorAlias:${identifier}`)) throw new _fedify_uri_template.RouterError(`Actor alias for "${identifier}" already set.`);
if (_fedify_uri_template.Router.variables(path).size > 0) throw new _fedify_uri_template.RouterError("Path for actor alias must have no variables.");
const existingRoute = this.router.route(path);
if (existingRoute != null) throw new _fedify_uri_template.RouterError(`Actor alias path "${path}" conflicts with existing route "${existingRoute.name}".`);
this.router.add(path, `${ACTOR_ALIAS_PREFIX}${identifier}`);
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setNodeInfoDispatcher(path, dispatcher) {
if (this.router.has("nodeInfo")) throw new _fedify_uri_template.RouterError("NodeInfo dispatcher already set.");
if (_fedify_uri_template.Router.variables(path).size !== 0) throw new _fedify_uri_template.RouterError("Path for NodeInfo dispatcher must have no variables.");
this.router.add(path, "nodeInfo");
this.nodeInfoDispatcher = dispatcher;
}
setWebFingerLinksDispatcher(dispatcher) {
this.webFingerLinksDispatcher = dispatcher;
}
/**
* The RFC 6570 template-literal `path` overloads were removed for
* type-checking efficiency, so the URI variable types can no longer be
* inferred from `path` (now typed as a plain `string`); to use them, specify
* the variable name through the `TParam` generic argument.
*/
setObjectDispatcher(cls, path, dispatcher) {
const routeName = `object:${cls.typeId.href}`;
if (this.router.has(routeName)) throw new _fedify_uri_template.RouterError(`Object dispatcher for ${cls.name} already set.`);
(0, _fedify_uri_template.assertPath)(path);
const variables = _fedify_uri_template.Router.variables(path);
this.router.add(path, routeName);
const callbacks = {
dispatcher: (ctx, values) => {
return this._getTracer().startActiveSpan("activitypub.dispatch_object", {
kind: _opentelemetry_api.SpanKind.SERVER,
attributes: {
"fedify.object.type": cls.typeId.href,
...globalThis.Object.fromEntries(globalThis.Object.entries(values).map(([k, v]) => [`fedify.object.values.${k}`, v]))
}
}, async (span) => {
try {
const object = await dispatcher(ctx, values);
span.setAttribute("activitypub.object.id", (object?.id ?? ctx.getObjectUri(cls, values)).href);
if (object == null) span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
else span.setAttribute("activitypub.object.type", (0, _fedify_vocab.getTypeId)(object).href);
return object;
} catch (e) {
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(e)
});
throw e;
} finally {
span.end();
}
});
},
parameters: variables
};
this.objectCallbacks[cls.typeId.href] = callbacks;
this.objectTypeIds[cls.typeId.href] = cls;
const setters = { authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
} };
return setters;
}
setInboxDispatcher(path, dispatcher) {
if (this.inboxCallbacks != null) throw new _fedify_uri_template.RouterError("Inbox dispatcher already set.");
if (this.router.has("inbox")) {
if (this.inboxPath !== path) throw new _fedify_uri_template.RouterError("Inbox dispatcher path must match inbox listener path.");
} else {
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "inbox", identifierSingularAllowPlus);
this.inboxPath = path;
}
const callbacks = { dispatcher };
this.inboxCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setOutboxDispatcher(path, dispatcher) {
if (this.outboxCallbacks != null) throw new _fedify_uri_template.RouterError("Outbox dispatcher already set.");
if (this.router.has("outbox")) {
if (this.outboxPath !== path) throw new _fedify_uri_template.RouterError("Outbox dispatcher path must match outbox listener path.");
} else {
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "outbox", identifierSingular);
this.outboxPath = path;
}
const callbacks = { dispatcher };
this.outboxCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setOutboxListeners(outboxPath) {
if (this.outboxListeners != null) throw new _fedify_uri_template.RouterError("Outbox listeners already set.");
if (this.router.has("outbox")) {
if (this.outboxPath !== outboxPath) throw new _fedify_uri_template.RouterError("Outbox listener path must match outbox dispatcher path.");
} else {
(0, _fedify_uri_template.assertPath)(outboxPath);
this.router.add(outboxPath, "outbox", identifierSingular);
this.outboxPath = outboxPath;
}
const listeners = this.outboxListeners = new ActivityListenerSet();
const setters = {
on(type, listener) {
listeners.add(type, listener);
return setters;
},
onError: (handler) => {
this.outboxListenerErrorHandler = handler;
return setters;
},
authorize: (predicate) => {
this.outboxAuthorizePredicate = predicate;
return setters;
}
};
return setters;
}
setFollowingDispatcher(path, dispatcher) {
if (this.router.has("following")) throw new _fedify_uri_template.RouterError("Following collection dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "following", identifierSingularAllowPlus);
const callbacks = { dispatcher };
this.followingCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setFollowersDispatcher(path, dispatcher) {
if (this.router.has("followers")) throw new _fedify_uri_template.RouterError("Followers collection dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "followers", identifierSingularAllowPlus);
const callbacks = { dispatcher };
this.followersCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setLikedDispatcher(path, dispatcher) {
if (this.router.has("liked")) throw new _fedify_uri_template.RouterError("Liked collection dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "liked", identifierSingularAllowPlus);
const callbacks = { dispatcher };
this.likedCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setFeaturedDispatcher(path, dispatcher) {
if (this.router.has("featured")) throw new _fedify_uri_template.RouterError("Featured collection dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "featured", identifierSingularAllowPlus);
const callbacks = { dispatcher };
this.featuredCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setFeaturedTagsDispatcher(path, dispatcher) {
if (this.router.has("featuredTags")) throw new _fedify_uri_template.RouterError("Featured tags collection dispatcher already set.");
(0, _fedify_uri_template.assertPath)(path);
this.router.add(path, "featuredTags", identifierSingularAllowPlus);
const callbacks = { dispatcher };
this.featuredTagsCallbacks = callbacks;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
setInboxListeners(inboxPath, sharedInboxPath) {
if (this.inboxListeners != null) throw new _fedify_uri_template.RouterError("Inbox listeners already set.");
if (this.router.has("inbox")) {
if (this.inboxPath !== inboxPath) throw new _fedify_uri_template.RouterError("Inbox listener path must match inbox dispatcher path.");
} else {
(0, _fedify_uri_template.assertPath)(inboxPath);
this.router.add(inboxPath, "inbox", identifierSingularAllowPlus);
this.inboxPath = inboxPath;
}
if (sharedInboxPath != null) {
if (_fedify_uri_template.Router.variables(sharedInboxPath).size !== 0) throw new _fedify_uri_template.RouterError("Path for shared inbox must have no variables.");
this.router.add(sharedInboxPath, "sharedInbox");
}
const listeners = this.inboxListeners = new ActivityListenerSet();
const setters = {
on(type, listener) {
listeners.add(type, listener);
return setters;
},
onError: (handler) => {
this.inboxErrorHandler = handler;
return setters;
},
onUnverifiedActivity: (handler) => {
this.unverifiedActivityHandler = handler;
return setters;
},
setSharedKeyDispatcher: (dispatcher) => {
this.sharedInboxKeyDispatcher = dispatcher;
return setters;
},
withIdempotency: (strategy) => {
this.idempotencyStrategy = strategy;
return setters;
}
};
return setters;
}
/**
* The RFC 6570 template-literal `path` overloads were removed for
* type-checking efficiency, so the URI variable types can no longer be
* inferred from `path` (now typed as a plain `string`); to use them, specify
* the variable name through the `TParam` generic argument.
*/
setCollectionDispatcher(name, itemType, path, dispatcher) {
return this.#setCustomCollectionDispatcher(name, "collection", itemType, path, dispatcher);
}
/**
* The RFC 6570 template-literal `path` overloads were removed for
* type-checking efficiency, so the URI variable types can no longer be
* inferred from `path` (now typed as a plain `string`); to use them, specify
* the variable name through the `TParam` generic argument.
*/
setOrderedCollectionDispatcher(name, itemType, path, dispatcher) {
return this.#setCustomCollectionDispatcher(name, "orderedCollection", itemType, path, dispatcher);
}
#setCustomCollectionDispatcher(name, collectionType, itemType, path, dispatcher) {
const strName = String(name);
const routeName = `${collectionType}:${this.#uniqueCollectionId(name)}`;
if (this.router.has(routeName)) throw new _fedify_uri_template.RouterError(`Collection dispatcher for ${strName} already set.`);
if (this.collectionCallbacks[name] != null) throw new _fedify_uri_template.RouterError(`Collection dispatcher for ${strName} already set.`);
(0, _fedify_uri_template.assertPath)(path);
if (_fedify_uri_template.Router.variables(path).size < 1) throw new _fedify_uri_template.RouterError("Path for collection dispatcher must have at least one variable.");
this.router.add(path, routeName);
const callbacks = { dispatcher };
this.collectionCallbacks[name] = callbacks;
this.collectionTypeIds[name] = itemType;
const setters = {
setCounter(counter) {
callbacks.counter = counter;
return setters;
},
setFirstCursor(cursor) {
callbacks.firstCursor = cursor;
return setters;
},
setLastCursor(cursor) {
callbacks.lastCursor = cursor;
return setters;
},
authorize(predicate) {
callbacks.authorizePredicate = predicate;
return setters;
}
};
return setters;
}
/**
* Get the URL path for a custom collection.
* If the collection is not registered, returns null.
* @template TParam The parameter names of the requested URL.
* @param {string | symbol} name The name of the custom collection.
* @param {TParam} values The values to fill in the URL parameters.
* @returns {string | null} The URL path for the custom collection, or null if not registered.
*/
getCollectionPath(name, values) {
if (!(name in this.collectionCallbacks)) return null;
const routeName = this.#uniqueCollectionId(name);
return this.router.build(`collection:${routeName}`, values) ?? this.router.build(`orderedCollection:${routeName}`, values);
}
setOutboxPermanentFailureHandler(handler) {
this.outboxPermanentFailureHandler = handler;
}
/**
* Converts a name (string or symbol) to a unique string identifier.
* For symbols, generates and caches a UUID if not already present.
* For strings, returns the string as-is.
* @param name The name to convert to a unique identifier
* @returns A unique string identifier
*/
#uniqueCollectionId(name) {
if (typeof name === "string") return name;
if (!this.#symbolRegistry.has(name)) this.#symbolRegistry.set(name, crypto.randomUUID());
return this.#symbolRegistry.get(name);
}
};
/**
* Creates a new {@link FederationBuilder} instance.
* @returns A new {@link FederationBuilder} instance.
* @since 1.6.0
*/
function createFederationBuilder() {
return new FederationBuilderImpl();
}
//#endregion
//#region src/federation/circuit-breaker.ts
const MAX_CUSTOM_FAILURE_HISTORY = 100;
/**
* Tracks reachability state for remote outbox delivery hosts.
* @since 2.3.0
*/
var CircuitBreaker = class {
#kv;
#prefix;
#options;
#now;
#stateChangeObserver;
constructor(options) {
this.#kv = options.kv;
this.#prefix = options.prefix;
this.#options = normalizeCircuitBreakerOptions(options.options ?? {});
this.#now = options.now ?? (() => Temporal.Now.instant());
this.#stateChangeObserver = options.stateChangeObserver;
}
get options() {
return this.#options;
}
capHeldDelay(heldSince, delay) {
const now = this.#now();
return now.until(this.#capHeldRetryAt(now, heldSince, now.add(delay)));
}
async beforeSend(remoteHost, message) {
const heldSince = parseHeldSince(message.circuitHeldSince);
const now = this.#now();
if (heldSince != null && Temporal.Instant.compare(heldSince.add(this.#options.heldActivityTtl), now) <= 0) return {
type: "drop",
heldSince
};
let lastConflictingState;
for (let attempt = 0; attempt < 10; attempt++) {
const oldState = await this.#get(remoteHost);
if (oldState == null || oldState.state === "closed") return {
type: "send",
probe: false
};
if (oldState.state === "half-open") {
const halfOpened = oldState.halfOpened == null ? void 0 : Temporal.Instant.from(oldState.halfOpened);
if (halfOpened != null) {
const staleAt = halfOpened.add(this.#options.recoveryDelay);
if (Temporal.Instant.compare(now, staleAt) < 0) {
const releaseAt = now.add(this.#options.releaseInterval);
const retryAt = Temporal.Instant.compare(releaseAt, staleAt) < 0 ? releaseAt : staleAt;
const cappedRetryAt = this.#capHeldRetryAt(now, heldSince, retryAt);
return {
type: "hold",
state: "half-open",
delay: now.until(cappedRetryAt),
heldSince: heldSince ?? now
};
}
}
const newState = {
...oldState,
state: "half-open",
halfOpened: now.toString()
};
if (await this.#replace(remoteHost, oldState, newState)) return {
type: "send",
probe: true
};
lastConflictingState = "half-open";
continue;
}
const probeAt = (oldState.opened == null ? now : Temporal.Instant.from(oldState.opened)).add(this.#options.recoveryDelay);
if (Temporal.Instant.compare(now, probeAt) < 0) {
const retryAt = this.#capHeldRetryAt(now, heldSince, probeAt);
return {
type: "hold",
state: "open",
delay: now.until(retryAt),
heldSince: heldSince ?? now
};
}
const newState = {
...oldState,
state: "half-open",
halfOpened: now.toString()
};
if (await this.#replace(remoteHost, oldState, newState)) {
await this.#notifyStateChange(remoteHost, "open", "half-open");
return {
type: "send",
probe: true,
stateChange: {
previousState: "open",
newState: "half-open"
}
};
}
lastConflictingState = "open";
}
if (lastConflictingState != null) {
const retryAt = this.#capHeldRetryAt(now, heldSince, now.add(this.#options.releaseInterval));
return {
type: "hold",
state: lastConflictingState,
delay: now.until(retryAt),
heldSince: heldSince ?? now
};
}
throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
}
async recordSuccess(remoteHost) {
for (let attempt = 0; attempt < 10; attempt++) {
const oldState = await this.#get(remoteHost);
if (oldState == null) return void 0;
if (await this.#replace(remoteHost, oldState, void 0)) {
if (oldState.state !== "closed") {
await this.#notifyStateChange(remoteHost, oldState.state, "closed");
return {
previousState: oldState.state,
newState: "closed"
};
}
return;
}
}
throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
}
async recordReachableFailure(remoteHost) {
return await this.recordSuccess(remoteHost);
}
async recordFailure(remoteHost) {
const now = this.#now();
for (let attempt = 0; attempt < 10; attempt++) {
const oldState = await this.#get(remoteHost);
if (oldState?.state === "open") return void 0;
const oldFailures = oldState?.failures.map(Temporal.Instant.from) ?? [];
const failures = this.#options.pruneFailures([...oldFailures, now], now);
let newState;
let transition;
if (oldState?.state === "half-open" || this.#options.failure(failures)) {
newState = {
state: "open",
failures: failures.map((t) => t.toString()),
opened: now.toString()
};
transition = [oldState?.state ?? "closed", "open"];
} else newState = {
state: "closed",
failures: failures.map((t) => t.toString())
};
if (await this.#replace(remoteHost, oldState, newState)) {
if (transition != null) {
await this.#notifyStateChange(remoteHost, transition[0], transition[1]);
return {
previousState: transition[0],
newState: transition[1]
};
}
return;
}
}
throw new Error(`Failed to update circuit breaker state for ${remoteHost}`);
}
async dropActivity(remoteHost, details) {
try {
await this.#options.onActivityDrop?.(remoteHost, details);
} catch (error) {
(0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"circuit"
]).error("An unexpected error occurred in circuit breaker activity drop handler:\n{error}", {
remoteHost,
error
});
}
}
async getState(remoteHost) {
return await this.#get(remoteHost);
}
#key(remoteHost) {
return [...this.#prefix, remoteHost];
}
#capHeldRetryAt(now, heldSince, retryAt) {
const expiresAt = (heldSince ?? now).add(this.#options.heldActivityTtl);
return Temporal.Instant.compare(expiresAt, retryAt) < 0 ? expiresAt : retryAt;
}
async #get(remoteHost) {
return parseCircuitBreakerKvState(await this.#kv.get(this.#key(remoteHost)));
}
async #replace(remoteHost, oldState, newState) {
const key = this.#key(remoteHost);
if (this.#kv.cas == null) {
if (newState == null) await this.#kv.delete(key);
else await this.#kv.set(key, newState);
return true;
}
return await this.#kv.cas(key, oldState, newState);
}
async #notifyStateChange(remoteHost, previousState, newState) {
try {
await this.#options.onStateChange?.(remoteHost, previousState, newState);
} catch (error) {
(0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"circuit"
]).error("An unexpected error occurred in circuit breaker state change handler:\n{error}", {
remoteHost,
previousState,
newState,
error
});
}
try {
await this.#stateChangeObserver?.(remoteHost, previousState, newState);
} catch (error) {
(0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"circuit"
]).error("An unexpected error occurred in circuit breaker state change observer:\n{error}", {
remoteHost,
previousState,
newState,
error
});
}
}
};
/**
* Normalizes user-provided circuit breaker options into the internal policy
* shape used while processing queued outbox deliveries.
*
* @param options The public circuit breaker options supplied to Fedify.
* @returns The normalized failure predicate, failure pruning function,
* duration values, and optional callbacks with defaults applied.
* @throws {RangeError} If any configured duration is not positive.
* @throws {TypeError} If `failureThreshold` is not a positive integer.
*/
function normalizeCircuitBreakerOptions(options) {
const recoveryDelay = toInstantDuration(options.recoveryDelay ?? { minutes: 30 });
const heldActivityTtl = toInstantDuration(options.heldActivityTtl ?? { hours: 168 });
const releaseInterval = toInstantDuration(options.releaseInterval ?? { seconds: 1 });
assertPositiveDuration(recoveryDelay, "recoveryDelay");
assertPositiveDuration(heldActivityTtl, "heldActivityTtl");
assertPositiveDuration(releaseInterval, "releaseInterval");
let failure;
let pruneFailures;
if (options.failure == null) {
const failureThreshold = options.failureThreshold ?? 5;
if (!Number.isInteger(failureThreshold) || failureThreshold <= 0) throw new TypeError("failureThreshold must be a positive integer.");
const failureWindow = toInstantDuration(options.failureWindow ?? { minutes: 10 });
assertPositiveDuration(failureWindow, "failureWindow");
pruneFailures = (timestamps, now) => {
const earliest = now.subtract(failureWindow);
return timestamps.filter((timestamp) => Temporal.Instant.compare(timestamp, earliest) >= 0).slice(-failureThreshold);
};
failure = (timestamps) => {
if (timestamps.length < failureThreshold) return false;
const first = timestamps[timestamps.length - failureThreshold];
const last = timestamps[timestamps.length - 1];
return Temporal.Duration.compare(first.until(last), failureWindow) <= 0;
};
} else {
failure = options.failure;
pruneFailures = (timestamps) => timestamps.slice(-MAX_CUSTOM_FAILURE_HISTORY);
}
return {
failure,
pruneFailures,
recoveryDelay,
heldActivityTtl,
releaseInterval,
onStateChange: options.onStateChange,
onActivityDrop: options.onActivityDrop
};
}
function toInstantDuration(duration) {
const parsed = Temporal.Duration.from(duration);
return Temporal.Duration.from({ milliseconds: Math.trunc(parsed.total({
unit: "millisecond",
relativeTo: Temporal.PlainDateTime.from("2026-01-01T00:00:00")
})) });
}
function assertPositiveDuration(duration, name) {
if (Temporal.Duration.compare(duration, { seconds: 0 }) <= 0) throw new RangeError(`${name} must be a positive duration.`);
}
function parseHeldSince(value) {
if (value == null) return void 0;
try {
return Temporal.Instant.from(value);
} catch (error) {
(0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"circuit"
]).warn("Invalid circuitHeldSince value in queued outbox message: {value}", {
value,
error
});
return;
}
}
/**
* Parses a value loaded from the circuit breaker KV store.
*
* @param value The raw KV value to validate.
* @returns A circuit breaker state when `value` has a recognized state and
* valid instant strings, or `undefined` when the stored value is malformed.
*/
function parseCircuitBreakerKvState(value) {
const isInstantString = (v) => {
if (typeof v !== "string") return false;
try {
Temporal.Instant.from(v);
return true;
} catch {
return false;
}
};
if (typeof value !== "object" || value == null) return void 0;
const record = value;
if (record.state !== "closed" && record.state !== "open" && record.state !== "half-open") return;
if (!Array.isArray(record.failures) || !record.failures.every((failure) => isInstantString(failure))) return;
if (record.opened != null && !isInstantString(record.opened)) return;
if (record.halfOpened != null && !isInstantString(record.halfOpened)) return;
return {
state: record.state,
failures: record.failures,
...record.opened == null ? {} : { opened: record.opened },
...record.halfOpened == null ? {} : { halfOpened: record.halfOpened }
};
}
//#endregion
//#region src/federation/collection.ts
/**
* Calculates the [partial follower collection digest][1].
*
* [1]: https://w3id.org/fep/8fcf#partial-follower-collection-digest
* @param uris The URIs to calculate the digest. Duplicate URIs are ignored.
* @returns The digest.
*/
async function digest(uris) {
const processed = /* @__PURE__ */ new Set();
const encoder = new TextEncoder();
const result = new Uint8Array(32);
for (const uri of uris) {
const u = uri instanceof URL ? uri.href : uri;
if (processed.has(u)) continue;
processed.add(u);
const encoded = encoder.encode(u);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded));
for (let i = 0; i < 32; i++) result[i] ^= digest[i];
}
return result;
}
/**
* Builds [`Collection-Synchronization`][1] header content.
*
* [1]: https://w3id.org/fep/8fcf#the-collection-synchronization-http-header
*
* @param collectionId The sender's followers collection URI.
* @param actorIds The actor URIs to digest.
* @returns The header content.
*/
async function buildCollectionSynchronizationHeader(collectionId, actorIds) {
const [anyActorId] = actorIds;
const baseUrl = new URL(anyActorId);
const url = new URL(collectionId);
url.searchParams.set("base-url", `${baseUrl.origin}/`);
return `collectionId="${collectionId}", url="${url}", digest="${(0, byte_encodings_hex.encodeHex)(await digest(actorIds))}"`;
}
//#endregion
//#region src/federation/inbox.ts
async function routeActivity({ context: ctx, json, originalJson, normalizedActivity, ldSignatureVerified, activity, recipient, inboxListeners, inboxContextFactory, listenerInboxContextFactory, inboxErrorHandler, kv, kvPrefixes, queue, span, meterProvider, tracerProvider, idempotencyStrategy }) {
const logger = (0, _logtape_logtape.getLogger)([
"fedify",
"federation",
"inbox"
]);
let cacheKey = null;
if (activity.id != null) {
const inboxContext = inboxContextFactory(recipient, json, activity.id?.href, (0, _fedify_vocab.getTypeId)(activity).href);
const strategy = idempotencyStrategy ?? "per-inbox";
let keyString;
if (typeof strategy === "function") keyString = await strategy(inboxContext, activity);
else switch (strategy) {
case "global":
keyString = activity.id.href;
break;
case "per-origin":
keyString = `${ctx.origin}\n${activity.id.href}`;
break;
case "per-inbox":
keyString = `${ctx.origin}\n${activity.id.href}\n${recipient == null ? "sharedInbox" : `inbox\n${recipient}`}`;
break;
default: keyString = `${ctx.origin}\n${activity.id.href}`;
}
if (keyString != null) cacheKey = [...kvPrefixes.activityIdempotence, keyString];
}
if (cacheKey != null) {
if (await kv.get(cacheKey) === true) {
logger.debug("Activity {activityId} has already been processed.", {
activityId: activity.id?.href,
activity: json,
recipient
});
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.UNSET,
message: `Activity ${activity.id?.href} has already been processed.`
});
require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href);
return "alreadyProcessed";
}
}
if (activity.actorId == null) {
logger.error("Missing actor.", { activity: json });
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: "Missing actor."
});
require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href);
return "missingActor";
}
span.setAttribute("activitypub.actor.id", activity.actorId.href);
if (queue != null) {
const carrier = {};
_opentelemetry_api.propagation.inject(_opentelemetry_api.context.active(), carrier);
try {
await queue.enqueue({
type: "inbox",
id: crypto.randomUUID(),
baseUrl: ctx.origin,
activity: originalJson ?? json,
...normalizedActivity == null ? {} : { normalizedActivity },
...ldSignatureVerified == null ? {} : { ldSignatureVerified },
identifier: recipient,
attempt: 0,
started: (/* @__PURE__ */ new Date()).toISOString(),
traceContext: carrier
});
} catch (error) {
logger.error("Failed to enqueue the incoming activity {activityId}:\n{error}", {
error,
activityId: activity.id?.href,
activity: json,
recipient
});
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: `Failed to enqueue the incoming activity ${activity.id?.href}.`
});
throw error;
}
require_http.getFederationMetrics(meterProvider).recordQueueTaskEnqueued({
role: "inbox",
queue,
activityType: (0, _fedify_vocab.getTypeId)(activity).href
}, 0);
require_http.recordInboxActivity(meterProvider, "queued", (0, _fedify_vocab.getTypeId)(activity).href);
logger.info("Activity {activityId} is enqueued.", {
activityId: activity.id?.href,
activity: json,
recipient
});
return "enqueued";
}
tracerProvider = tracerProvider ?? _opentelemetry_api.trace.getTracerProvider();
return await tracerProvider.getTracer(require_http.name, require_http.version).startActiveSpan("activitypub.dispatch_inbox_listener", { kind: _opentelemetry_api.SpanKind.INTERNAL }, async (span) => {
const dispatched = inboxListeners?.dispatchWithClass(activity);
if (dispatched == null) {
logger.error("Unsupported activity type:\n{activity}", {
activity: json,
recipient
});
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.UNSET,
message: `Unsupported activity type: ${(0, _fedify_vocab.getTypeId)(activity).href}`
});
require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href);
span.end();
return "unsupportedActivity";
}
const { class: cls, listener } = dispatched;
span.updateName(`activitypub.dispatch_inbox_listener ${cls.name}`);
try {
const activityType = (0, _fedify_vocab.getTypeId)(activity).href;
const started = performance.now();
try {
const contextFactory = listenerInboxContextFactory ?? inboxContextFactory;
await listener(contextFactory(recipient, contextFactory === inboxContextFactory ? json : originalJson ?? json, activity.id?.href, activityType), activity);
} finally {
require_http.getFederationMetrics(meterProvider).recordInboxProcessingDuration(activityType, require_http.getDurationMs(started));
}
} catch (error) {
try {
await inboxErrorHandler?.(ctx, error);
} catch (error) {
logger.error("An unexpected error occurred in inbox error handler:\n{error}", {
error,
activityId: activity.id?.href,
activity: json,
recipient
});
}
logger.error("Failed to process the incoming activity {activityId}:\n{error}", {
error,
activityId: activity.id?.href,
activity: json,
recipient
});
span.setStatus({
code: _opentelemetry_api.SpanStatusCode.ERROR,
message: String(error)
});
require_http.recordInboxActivity(meterProvider, "rejected", (0, _fedify_vocab.getTypeId)(activity).href);
span.end();
return "error";
}
require_http.recordInboxActivity(meterProvider, "processed", (0, _fedify_vocab.getTypeId)(activity).href);
if (cacheKey != null) await kv.set(cacheKey, true, { ttl: Temporal.Duration.from({ days: 1 }) });
logger.info("Activity {activityId} has been processed.", {
activityId: activity.id?.href,
activity: json,
recipient
});
span.end();
return "success";
});
}
//#endregion
//#region src/federation/keycache.ts
var KvKeyCache = class {
kv;
prefix;
options;
unavailableKeyTtl;
nullKeys;
constructor(kv, prefix, options = {}) {
this.kv = kv;
this.prefix = prefix;
this.options = options;
this.unavailableKeyTtl = options.unavailableKeyTtl ?? Temporal.Duration.from({ minutes: 10 });
this.nullKeys = /* @__PURE__ */ new Map();
}
#getFetchErrorKey(keyId) {
return [
...this.prefix,
"__fetchError",
keyId.href
];
}
async get(keyId) {
const negativeExpiration = this.nullKeys.get(keyId.href);
if (negativeExpiration != null) {
if (Temporal.Now.instant().until(negativeExpiration).sign >= 0) return null;
this.nullKeys.delete(keyId.href);
}
const serialized = await this.kv.get([...this.prefix, keyId.href]);
if (serialized === void 0) return void 0;
if (serialized === null) {
this.nullKeys.set(keyId.href, Temporal.Now.instant().add(this.unavailableKeyTtl));
return null;
}
try {
return await _fedify_vocab.CryptographicKey.fromJsonLd(serialized, this.options);
} catch {
try {
return await _fedify_vocab.Multikey.fromJsonLd(serialized, this.options);
} catch {
await this.kv.delete([...this.prefix, keyId.href]);
return;
}
}
}
async set(keyId, key) {
if (key == null) {
this.nullKeys.set(keyId.href, Temporal.Now.instant().add(this.unavailableKeyTtl));
await this.kv.set([...this.prefix, keyId.href], null, { ttl: this.unavailableKeyTtl });
return;
}
this.nullKeys.delete(keyId.href);
const serialized = await key.toJsonLd(this.options);
await this.kv.set([...this.prefix, keyId.href], serialized);
}
async getFetchError(keyId) {
const cached = await this.kv.get(this.#getFetchErrorKey(keyId));
if (cached == null || typeof cached !== "object") return void 0;
if ("status" in cached && typeof cached.status === "number" && "statusText" in cached && typeof cached.statusText === "string" && "headers" in cached && Array.isArray(cached.headers) && "body" in cached && typeof cached.body === "string") return {
status: cached.status,
response: new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers: cached.headers
})
};
else if ("errorName" in cached && typeof cached.errorName === "string" && "errorMessage" in cached && typeof cached.errorMessage === "string") {
const error = new Error(cached.errorMessage);
error.name = cached.errorName;
return { error };
}
}
async setFetchError(keyId, error) {
if (error == null) {
await this.kv.delete(this.#getFetchErrorKey(keyId));
return;
}
if ("status" in error) {
await this.kv.set(this.#getFetchErrorKey(keyId), {
status: error.status,
statusText: error.response.statusText,
headers: Array.from(error.response.headers.entries()),
body: awai