UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

501 lines (500 loc) • 17.6 kB
import { Temporal } from "@js-temporal/polyfill"; import { URLPattern } from "urlpattern-polyfill"; import { a as SendActivityError, c as buildCollectionSynchronizationHeader, d as normalizeCircuitBreakerOptions, f as parseCircuitBreakerKvState, i as createExponentialBackoffPolicy, l as digest, o as respondWithObject, p as createFederationBuilder, r as handleWebFinger, s as respondWithObjectIfAcceptable, t as createFederation, u as CircuitBreaker } from "../middleware-By8lqc18.js"; import { getLogger } from "@logtape/logtape"; import { Router as Router$1, RouterError as RouterError$1, assertPath, isPath } from "@fedify/uri-template"; import { isEqual } from "es-toolkit"; //#region src/federation/kv.ts /** * A key–value store that stores values in memory. * Do not use this in production as it does not persist values. * * @since 0.5.0 */ var MemoryKvStore = class { #values = {}; #encodeKey(key) { return JSON.stringify(key); } /** * {@inheritDoc KvStore.get} */ get(key) { const encodedKey = this.#encodeKey(key); const entry = this.#values[encodedKey]; if (entry == null) return Promise.resolve(void 0); const [value, expiration] = entry; if (expiration != null && Temporal.Now.instant().until(expiration).sign < 0) { delete this.#values[encodedKey]; return Promise.resolve(void 0); } return Promise.resolve(value); } /** * {@inheritDoc KvStore.set} */ set(key, value, options) { const encodedKey = this.#encodeKey(key); const expiration = options?.ttl == null ? null : Temporal.Now.instant().add(options.ttl.round({ largestUnit: "hour" })); this.#values[encodedKey] = [value, expiration]; return Promise.resolve(); } /** * {@inheritDoc KvStore.delete} */ delete(key) { const encodedKey = this.#encodeKey(key); delete this.#values[encodedKey]; return Promise.resolve(); } /** * {@inheritDoc KvStore.cas} */ cas(key, expectedValue, newValue, options) { const encodedKey = this.#encodeKey(key); const entry = this.#values[encodedKey]; let currentValue; if (entry == null) currentValue = void 0; else { const [value, expiration] = entry; if (expiration != null && Temporal.Now.instant().until(expiration).sign < 0) { delete this.#values[encodedKey]; currentValue = void 0; } else currentValue = value; } if (!isEqual(currentValue, expectedValue)) return Promise.resolve(false); const expiration = options?.ttl == null ? null : Temporal.Now.instant().add(options.ttl.round({ largestUnit: "hour" })); this.#values[encodedKey] = [newValue, expiration]; return Promise.resolve(true); } /** * {@inheritDoc KvStore.list} */ async *list(prefix) { const now = Temporal.Now.instant(); for (const [encodedKey, entry] of Object.entries(this.#values)) { const key = JSON.parse(encodedKey); if (prefix != null) { if (key.length < prefix.length) continue; if (!prefix.every((p, i) => key[i] === p)) continue; } const [value, expiration] = entry; if (expiration != null && now.until(expiration).sign < 0) { delete this.#values[encodedKey]; continue; } yield { key, value }; } } }; //#endregion //#region src/federation/mq.ts /** * A message queue that processes messages in the same process. * Do not use this in production as it does neither persist messages nor * distribute them across multiple processes. * * @since 0.5.0 */ var InProcessMessageQueue = class { #messages; #monitors; #pollIntervalMs; #delayedMessages; /** * Tracks which ordering keys are currently being processed to ensure * sequential processing for messages with the same key. */ #processingKeys; /** * In-process message queue does not provide native retry mechanisms. * @since 1.7.0 */ nativeRetrial = false; /** * Constructs a new {@link InProcessMessageQueue} with the given options. * @param options Additional options for the in-process message queue. */ constructor(options = {}) { this.#messages = []; this.#monitors = {}; this.#pollIntervalMs = Temporal.Duration.from(options.pollInterval ?? { seconds: 5 }).total("millisecond"); this.#delayedMessages = 0; this.#processingKeys = /* @__PURE__ */ new Set(); } enqueue(message, options) { const delay = options?.delay == null ? 0 : Math.max(options.delay.total("millisecond"), 0); if (delay > 0) { this.#delayedMessages++; setTimeout(() => { this.#delayedMessages--; this.#enqueueReady(message, options); }, delay); return Promise.resolve(); } this.#enqueueReady(message, options); return Promise.resolve(); } #enqueueReady(message, options) { const orderingKey = options?.orderingKey ?? null; this.#messages.push({ message, orderingKey }); this.#notifyMonitors(); } #notifyMonitors() { for (const monitorId in this.#monitors) this.#monitors[monitorId](); } enqueueMany(messages, options) { if (messages.length === 0) return Promise.resolve(); const delay = options?.delay == null ? 0 : Math.max(options.delay.total("millisecond"), 0); if (delay > 0) { const delayedCount = messages.length; const deferredMessages = [...messages]; this.#delayedMessages += delayedCount; setTimeout(() => { this.#delayedMessages -= delayedCount; this.#enqueueManyReady(deferredMessages, options); }, delay); return Promise.resolve(); } this.#enqueueManyReady(messages, options); return Promise.resolve(); } #enqueueManyReady(messages, options) { const orderingKey = options?.orderingKey ?? null; for (const message of messages) this.#messages.push({ message, orderingKey }); this.#notifyMonitors(); } async listen(handler, options = {}) { const signal = options.signal; while (signal == null || !signal.aborted) { const idx = this.#messages.findIndex((m) => m.orderingKey == null || !this.#processingKeys.has(m.orderingKey)); if (idx >= 0) { const { message, orderingKey } = this.#messages.splice(idx, 1)[0]; if (orderingKey != null) this.#processingKeys.add(orderingKey); try { await handler(message); } finally { if (orderingKey != null) this.#processingKeys.delete(orderingKey); } } else if (this.#messages.length === 0) await this.#wait(this.#pollIntervalMs, signal); else await this.#wait(10, signal); } } getDepth() { const ready = this.#messages.length; const delayed = this.#delayedMessages; return Promise.resolve({ queued: ready + delayed, ready, delayed }); } #wait(ms, signal) { let timer = null; return Promise.any([new Promise((resolve) => { signal?.addEventListener("abort", () => { if (timer != null) clearTimeout(timer); resolve(); }, { once: true }); const monitorId = crypto.randomUUID(); this.#monitors[monitorId] = () => { delete this.#monitors[monitorId]; if (timer != null) clearTimeout(timer); resolve(); }; }), new Promise((resolve) => timer = setTimeout(resolve, ms))]); } }; /** * A message queue that processes messages in parallel. It takes another * {@link MessageQueue}, and processes messages in parallel up to a certain * number of workers. * * Actually, it's rather a decorator than a queue itself. * * Note that the workers do not run in truly parallel, in the sense that they * are not running in separate threads or processes. They are running in the * same process, but are scheduled to run in parallel. Hence, this is useful * for I/O-bound tasks, but not for CPU-bound tasks, which is okay for Fedify's * workloads. * * When using `ParallelMessageQueue`, the ordering guarantee is preserved * *only if* the underlying queue implementation delivers messages in a wrapper * format that includes the `__fedify_ordering_key__` property. Currently, * only `DenoKvMessageQueue` and `WorkersMessageQueue` use this format. * For other queue implementations (e.g., `InProcessMessageQueue`, * `RedisMessageQueue`, `PostgresMessageQueue`, `SqliteMessageQueue`, * `AmqpMessageQueue`), the ordering key cannot be detected by * `ParallelMessageQueue`, so ordering guarantees are handled by those * implementations directly rather than at the `ParallelMessageQueue` level. * * Messages with the same ordering key will never be processed concurrently * by different workers, ensuring sequential processing within each key. * Messages with different ordering keys (or no ordering key) can still be * processed in parallel. * * @since 1.0.0 */ var ParallelMessageQueue = class ParallelMessageQueue { queue; workers; /** * Inherits the native retry capability from the wrapped queue. * @since 1.7.0 */ nativeRetrial; getDepth; /** * Tracks which ordering keys are currently being processed to ensure * sequential processing for messages with the same key. */ #processingKeys = /* @__PURE__ */ new Set(); /** * Pending messages waiting for their ordering key to become available. */ #pendingMessages = []; /** * Constructs a new {@link ParallelMessageQueue} with the given queue and * number of workers. * @param queue The message queue to use under the hood. Note that * {@link ParallelMessageQueue} cannot be nested. * @param workers The number of workers to process messages in parallel. * @throws {TypeError} If the given queue is an instance of * {@link ParallelMessageQueue}. */ constructor(queue, workers) { if (queue instanceof ParallelMessageQueue) throw new TypeError("Cannot nest ParallelMessageQueue."); this.queue = queue; this.workers = workers; this.nativeRetrial = queue.nativeRetrial; if (queue.getDepth != null) this.getDepth = () => queue.getDepth(); } enqueue(message, options) { return this.queue.enqueue(message, options); } async enqueueMany(messages, options) { if (this.queue.enqueueMany == null) { const errors = (await Promise.allSettled(messages.map((message) => this.queue.enqueue(message, options)))).filter((r) => r.status === "rejected").map((r) => r.reason); if (errors.length > 1) throw new AggregateError(errors, "Failed to enqueue messages."); else if (errors.length === 1) throw errors[0]; return; } await this.queue.enqueueMany(messages, options); } /** * Extracts ordering key from a message if present. * * This method only works for queue implementations that deliver messages * in the wrapper format with `__fedify_ordering_key__` property. Currently, * only `DenoKvMessageQueue` and `WorkersMessageQueue` use this format. * * For other queue implementations (`InProcessMessageQueue`, * `RedisMessageQueue`, `PostgresMessageQueue`, `SqliteMessageQueue`, * `AmqpMessageQueue`), messages are delivered as raw payloads without the * wrapper, so the ordering key cannot be detected here. Those * implementations handle ordering guarantees internally. */ #extractOrderingKey(message) { if (message != null && typeof message === "object") { if ("__fedify_ordering_key__" in message) return message.__fedify_ordering_key__; } } listen(handler, options = {}) { const workers = /* @__PURE__ */ new Map(); return this.queue.listen(async (message) => { while (workers.size >= this.workers) { const consumedId = await Promise.any(workers.values()); workers.delete(consumedId); } const workerId = crypto.randomUUID(); const orderingKey = this.#extractOrderingKey(message); if (orderingKey != null && this.#processingKeys.has(orderingKey)) await new Promise((resolve) => { this.#pendingMessages.push({ message, orderingKey, resolve }); }); if (orderingKey != null) this.#processingKeys.add(orderingKey); const promise = this.#work(workerId, handler, message, orderingKey); workers.set(workerId, promise); }, options); } async #work(workerId, handler, message, orderingKey) { await this.#sleep(0); try { await handler(message); } finally { if (orderingKey != null) { this.#processingKeys.delete(orderingKey); const pendingIdx = this.#pendingMessages.findIndex((p) => p.orderingKey === orderingKey); if (pendingIdx >= 0) this.#pendingMessages.splice(pendingIdx, 1)[0].resolve(); } } return workerId; } #sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }; //#endregion //#region src/federation/router.ts const logger = getLogger([ "fedify", "federation", "router", "deprecated" ]); let deprecationWarned = false; function warnDeprecated() { if (deprecationWarned) return; deprecationWarned = true; logger.warn("The `Router` and `RouterError` classes from `@fedify/fedify` are deprecated. Please use `Router` from `@fedify/uri-template` instead."); } /** * URL router and constructor based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). * * @deprecated Import `Router` from `@fedify/uri-template` instead. This class * remains only for compatibility with older Fedify code. The * `@fedify/uri-template` router is the replacement implementation * and should be used directly in new code. */ var Router = class Router { #router; /** * Create a new {@link Router}. * @param options Options for the router. * @deprecated Use `new Router(options)` from `@fedify/uri-template` * instead. */ constructor(options) { this.#router = convertRouterError(() => new Router$1(options)); } /** * Whether to ignore trailing slashes when matching paths. * @deprecated Use `Router` from `@fedify/uri-template` instead. This * accessor forwards to the underlying `@fedify/uri-template` * router so that post-construction mutation keeps working as * in older Fedify code. */ get trailingSlashInsensitive() { return this.#router.trailingSlashInsensitive; } set trailingSlashInsensitive(value) { this.#router.trailingSlashInsensitive = value; } /** * Clones this router. * @deprecated Use `Router` from `@fedify/uri-template` instead. */ clone() { return convertRouterError(() => { const clone = new Router(); clone.#router = this.#router.clone(); return clone; }); } /** * Checks if a path name exists in the router. * @param name The name of the path. * @returns `true` if the path name exists, otherwise `false`. * @deprecated Use `Router` from `@fedify/uri-template` instead. */ has(name) { return convertRouterError(() => this.#router.has(name)); } /** * Adds a new path rule to the router. * @param template The path pattern. * @param name The name of the path. * @returns The names of the variables in the path pattern. * @deprecated Use `Router` from `@fedify/uri-template` instead. In this * compatibility class, `add()` both registers the route and * returns the variables in the path pattern. In * `@fedify/uri-template`, these two responsibilities are split: * `router.add(template, name)` registers the route and returns * `void`, while the pure static method * `Router.variables(template)` returns the variable names. To * migrate, call `Router.variables(template)` when variables are * needed, then call `router.add(template, name)` to register the * route. */ add(template, name) { return convertRouterError(() => { assertPath(template); this.#router.add(template, name); return Router$1.variables(template); }); } /** * Resolves a path name and values from a URL, if any match. * @param url The URL to resolve. * @returns The name of the path and its values, if any match. Otherwise, * `null`. * @deprecated Use `Router` from `@fedify/uri-template` instead. Unlike the * stricter `@fedify/uri-template` router, this compatibility * method keeps the old Fedify 2.x contract of returning `null` * (rather than throwing) for inputs that are not router paths. */ route(url) { return convertRouterError(() => { if (!isPath(url)) return null; return this.#router.route(url); }); } /** * Constructs a URL/path from a path name and values. * @param name The name of the path. * @param values The values to expand the path with. * @returns The URL/path, if the name exists. Otherwise, `null`. * @deprecated Use `Router` from `@fedify/uri-template` instead. */ build(name, values) { return convertRouterError(() => this.#router.build(name, values)); } }; /** * An error thrown by the {@link Router}. * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ var RouterError = class extends RouterError$1 { /** * Treats every `RouterError` from `@fedify/uri-template` as an instance of * this deprecated class. * * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ static [Symbol.hasInstance](instance) { return instance instanceof RouterError$1; } /** * Create a new {@link RouterError}. * @param message The error message. * @deprecated Import `RouterError` from `@fedify/uri-template` instead. */ constructor(message) { super(message); warnDeprecated(); } }; function convertRouterError(func) { try { warnDeprecated(); return func(); } catch (error) { if (error instanceof RouterError$1) throw new RouterError(error.message); throw error; } } //#endregion export { CircuitBreaker, InProcessMessageQueue, MemoryKvStore, ParallelMessageQueue, Router, RouterError, SendActivityError, buildCollectionSynchronizationHeader, createExponentialBackoffPolicy, createFederation, createFederationBuilder, digest, handleWebFinger, normalizeCircuitBreakerOptions, parseCircuitBreakerKvState, respondWithObject, respondWithObjectIfAcceptable };