From d4244c77b0add3d6a7327f9989799cce5041dc38 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 13:40:03 +0000 Subject: [PATCH 01/29] Add custom background task API Generalize Fedify's enqueue-and-process-later pattern, previously limited to outgoing activity delivery, to arbitrary application-defined background jobs. `Federation` and `FederationBuilder` gain `defineTask()` (via the new `TaskRegistry` interface), and `Context` gains `enqueueTask()`/`enqueueTaskMany()`. Each task carries a Standard Schema that infers the payload type and validates it both at enqueue time and at dequeue time, guarding against schema drift across deployments. Payloads are serialized with devalue so that `Date`, `Map`, `Set`, `URL`, `bigint`, circular references, and Activity Vocabulary objects round-trip faithfully across every message queue backend. Failed handlers retry with exponential backoff by default, configurable per task or federation-wide, and tasks can be isolated onto a dedicated queue or fall back to the outbox queue. The payload codec is implemented twice on purpose: `codec.ts` as a class (`TaskCodec`) and `codec-fn.ts` as standalone utility functions, each with its own tests. Only the class is wired into the runtime; the functional variant is kept temporarily so the team can compare the two styles and decide which reads better before one is removed. https://github.com/fedify-dev/fedify/issues/206 https://github.com/fedify-dev/fedify/issues/797 Assisted-by: Claude Code:claude-opus-4-8 --- CHANGES.md | 32 + deno.lock | 9 +- docs/.vitepress/config.mts | 1 + docs/manual/tasks.md | 247 +++++++ packages/fedify/deno.json | 2 + packages/fedify/package.json | 2 + packages/fedify/src/federation/builder.ts | 29 + packages/fedify/src/federation/context.ts | 47 ++ packages/fedify/src/federation/federation.ts | 31 +- packages/fedify/src/federation/middleware.ts | 232 ++++++- packages/fedify/src/federation/mod.ts | 7 + packages/fedify/src/federation/queue.ts | 25 +- .../src/federation/tasks/codec-fn.test.ts | 230 +++++++ .../fedify/src/federation/tasks/codec-fn.ts | 188 +++++ .../fedify/src/federation/tasks/codec.test.ts | 284 ++++++++ packages/fedify/src/federation/tasks/codec.ts | 186 +++++ packages/fedify/src/federation/tasks/mod.ts | 11 + packages/fedify/src/federation/tasks/task.ts | 169 +++++ .../fedify/src/federation/tasks/tasks.test.ts | 647 ++++++++++++++++++ packages/fedify/src/testing/context.ts | 8 + packages/testing/src/context.ts | 8 + packages/testing/src/mock.ts | 37 +- pnpm-lock.yaml | 22 +- 23 files changed, 2427 insertions(+), 27 deletions(-) create mode 100644 docs/manual/tasks.md create mode 100644 packages/fedify/src/federation/tasks/codec-fn.test.ts create mode 100644 packages/fedify/src/federation/tasks/codec-fn.ts create mode 100644 packages/fedify/src/federation/tasks/codec.test.ts create mode 100644 packages/fedify/src/federation/tasks/codec.ts create mode 100644 packages/fedify/src/federation/tasks/mod.ts create mode 100644 packages/fedify/src/federation/tasks/task.ts create mode 100644 packages/fedify/src/federation/tasks/tasks.test.ts diff --git a/CHANGES.md b/CHANGES.md index 3a2f6f842..f3901d3a6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -259,6 +259,37 @@ To be released. the dispatcher overload hot path; other contributors to `check-all` cost may remain. [[#613], [#800] by ChanHaeng Lee] + - Added a custom background task API that generalizes Fedify's + enqueue-and-process-later pattern to arbitrary application-defined jobs: + + - `Federation` and `FederationBuilder` gained a `defineTask()` method + through the new `TaskRegistry` interface, which `Federatable` now + extends. + - `Context` gained `enqueueTask()` and `enqueueTaskMany()` methods, + with `delay` and `orderingKey` options + (new `TaskEnqueueOptions` interface). + - Every task requires a [Standard Schema] + (`schema` option) from which the payload type is inferred; payloads + are validated at enqueue time (fail fast) and again at dequeue time + (protection against schema drift across deployments). + - Payloads are serialized by Fedify with devalue, so `Date`, `Map`, + `Set`, `URL`, `bigint`, circular references, and Activity Vocabulary + objects round-trip faithfully across every message queue backend. + - Failed handlers are retried with exponential backoff by default; + tasks support per-task `retryPolicy` and `onError` options, the new + `FederationOptions.taskRetryPolicy` sets the federation-wide default, + and queues with `nativeRetrial` delegate retries to the backend. + - Tasks can be isolated from activity delivery through the new + `FederationQueueOptions.task` slot or a per-task `queue` option; + without them, tasks fall back to the outbox queue unless the new + `FederationOptions.taskQueueResolution` option is set to `"strict"`. + `Federation.startQueue()` now accepts `queue: "task"` to run + a task-only worker. + + [[#206], [#797] by ChanHaeng Lee] + +[Standard Schema]: https://standardschema.dev/ +[#206]: https://github.com/fedify-dev/fedify/issues/206 [#316]: https://github.com/fedify-dev/fedify/issues/316 [#418]: https://github.com/fedify-dev/fedify/issues/418 [#613]: https://github.com/fedify-dev/fedify/issues/613 @@ -288,6 +319,7 @@ To be released. [#778]: https://github.com/fedify-dev/fedify/pull/778 [#782]: https://github.com/fedify-dev/fedify/issues/782 [#787]: https://github.com/fedify-dev/fedify/pull/787 +[#797]: https://github.com/fedify-dev/fedify/issues/797 [#800]: https://github.com/fedify-dev/fedify/pull/800 ### @fedify/cli diff --git a/deno.lock b/deno.lock index 884f61e52..2f1e29cc8 100644 --- a/deno.lock +++ b/deno.lock @@ -123,6 +123,7 @@ "npm:cli-highlight@^2.1.11": "2.1.11", "npm:cli-table3@~0.6.5": "0.6.5", "npm:dax@~0.46.1": "0.46.1", + "npm:devalue@^5.8.1": "5.8.1", "npm:enquirer@^2.4.1": "2.4.1", "npm:es-toolkit@^1.46.1": "1.46.1", "npm:esbuild-wasm@~0.25.11": "0.25.12", @@ -4637,8 +4638,8 @@ "base-64" ] }, - "devalue@5.7.1": { - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==" + "devalue@5.8.1": { + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==" }, "devlop@1.1.0": { "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", @@ -8267,7 +8268,6 @@ "aria-query@5.3.1", "axobject-query", "clsx", - "devalue", "esm-env", "esrap", "is-reference@3.0.3", @@ -9463,9 +9463,11 @@ }, "packages/fedify": { "dependencies": [ + "jsr:@standard-schema/spec@^1.1.0", "jsr:@std/assert@0.226", "jsr:@std/url@~0.225.1", "npm:@multiformats/base-x@^4.0.1", + "npm:devalue@^5.8.1", "npm:fast-check@^3.22.0", "npm:fetch-mock@^12.5.2", "npm:json-canon@^1.0.1", @@ -9478,6 +9480,7 @@ "npm:@js-temporal/polyfill@~0.5.1", "npm:@jsr/std__assert@0.226", "npm:@types/node@^24.2.1", + "npm:devalue@^5.8.1", "npm:json-canon@^1.0.1", "npm:jsonld@9", "npm:miniflare@^4.20250523.0", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a71503133..caf1c79d4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -145,6 +145,7 @@ const MANUAL = { { text: "Pragmatics", link: "/manual/pragmatics.md" }, { text: "Key–value store", link: "/manual/kv.md" }, { text: "Message queue", link: "/manual/mq.md" }, + { text: "Background tasks", link: "/manual/tasks.md" }, { text: "Circuit breaker", link: "/manual/circuit-breaker.md" }, { text: "Integration", link: "/manual/integration.md" }, { text: "Migration", link: "/manual/migrate.md" }, diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md new file mode 100644 index 000000000..dd52e1ef4 --- /dev/null +++ b/docs/manual/tasks.md @@ -0,0 +1,247 @@ +Background tasks +================ + +*This API is available since Fedify 2.3.0.* + +Fedify already processes outgoing and incoming activities on background +workers through its [message queue](./mq.md). The custom background task API +generalizes the same pattern—enqueue work, return immediately, process the +payload on a separate worker—to arbitrary application-defined jobs: sending +digest e-mails, rebuilding timelines, fetching link previews, and so on. + +A task is *defined* once on the `Federation` (or `FederationBuilder`) object +with `~TaskRegistry.defineTask()`, and *dispatched* from any `Context` with +`~Context.enqueueTask()`. The payload is validated, serialized by Fedify, +delivered through a message queue, and handed—decoded and re-validated—to the +task's handler on a worker. + + +Defining a task +--------------- + +`~TaskRegistry.defineTask()` registers a named task and returns a handle that +`~Context.enqueueTask()` consumes. Every task requires a `schema`, a +[Standard Schema] (implemented by [Zod], [Valibot], [ArkType], and friends) +that validates the payload; the payload type is inferred from it, so handlers +and call sites are fully typed without manual type annotations: + +~~~~ typescript +import { z } from "zod"; + +const sendDigest = federation.defineTask("sendDigest", { + schema: z.object({ + userId: z.string(), + since: z.date(), + }), + handler: async (ctx, data) => { + // data is typed as { userId: string; since: Date } + const digest = await buildDigest(data.userId, data.since); + await sendEmail(data.userId, digest); + }, +}); +~~~~ + +Task names must be unique within a federation; defining the same name twice +throws a `TypeError`. + +[Standard Schema]: https://standardschema.dev/ +[Zod]: https://zod.dev/ +[Valibot]: https://valibot.dev/ +[ArkType]: https://arktype.io/ + + +Payload handling +---------------- + +Task payloads cross a message queue, so they are serialized on enqueue and +deserialized on dispatch. Fedify owns this codec—applications never encode +payloads themselves. The codec is built on [devalue], which means payloads +are not limited to JSON: `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, +typed arrays, circular references, and repeated references all round-trip +faithfully. + +Activity Vocabulary objects (`Note`, `Create`, `Person`, `Link`, and so on) +are also supported as payload values. Each vocabulary object is bridged +through expanded JSON-LD on the wire and comes back as a real instance, so +the handler can call its methods and getters as usual: + +~~~~ typescript +import { Note } from "@fedify/fedify/vocab"; +import { z } from "zod"; + +const indexNote = federation.defineTask("indexNote", { + schema: z.object({ + note: z.instanceof(Note), // an opaque instanceof leaf + indexedAt: z.date(), + }), + handler: async (ctx, data) => { + await searchIndex.add(data.note.id?.href, data.note.content?.toString()); + }, +}); +~~~~ + +The schema validates the *envelope* of the payload; vocabulary objects are +opaque `instanceof` leaves (e.g., `z.instanceof(Note)`), so no enormous +schema is needed. + +Validation runs twice: once at enqueue time, so a caller passing a wrong +shape fails fast at the call site, and once at dequeue time, which protects +against *schema drift*—a durable queue can hand a new deployment a payload +that an older deployment enqueued. A payload that fails dequeue-time +validation (or cannot be decoded at all) is dropped with an error log rather +than retried, because retrying cannot make it valid. + +[devalue]: https://github.com/sveltejs/devalue + + +Dispatching tasks +----------------- + +`~Context.enqueueTask()` validates the payload, serializes it, and enqueues +it. It returns as soon as the message is accepted by the queue: + +~~~~ typescript +await ctx.enqueueTask(sendDigest, { + userId: "alice", + since: new Date("2026-06-01T00:00:00Z"), +}); +~~~~ + +Passing a payload that does not match the task's schema is a compile-time +type error, and—for shapes the type system cannot catch—a runtime +`TypeError` at the call site. + +`~Context.enqueueTaskMany()` enqueues multiple payloads at once, using the +queue's bulk `~MessageQueue.enqueueMany()` operation when the backend +supports it and falling back to parallel single enqueues otherwise: + +~~~~ typescript +await ctx.enqueueTaskMany(sendDigest, users.map((u) => ({ + userId: u.id, + since: u.lastDigestAt, +}))); +~~~~ + +Both methods accept options: + +`delay` +: A `Temporal.DurationLike` to postpone processing, e.g., + `{ minutes: 30 }`. + +`orderingKey` +: Tasks with the same ordering key are processed sequentially (one at + a time), like the same option on the message queue layer. + +~~~~ typescript +await ctx.enqueueTask(sendDigest, payload, { + delay: { minutes: 30 }, + orderingKey: `digest:${payload.userId}`, +}); +~~~~ + + +Retry and error handling +------------------------ + +When a handler throws, Fedify consults the retry policy and re-enqueues the +message with an incremented attempt counter. The policy is resolved in this +order: + +1. The task's own `retryPolicy` passed to `~TaskRegistry.defineTask()`. +2. The federation-wide `~FederationOptions.taskRetryPolicy`. +3. The default: exponential backoff with a maximum of 10 attempts. + +When the queue backend reports `~MessageQueue.nativeRetrial`, Fedify rethrows +the error instead and lets the backend drive retries. + +A task can also register an `onError` callback, which is invoked with the +error and the decoded payload before a retry is scheduled—useful for +reporting or compensating actions: + +~~~~ typescript +const sendDigest = federation.defineTask("sendDigest", { + schema: digestSchema, + handler: async (ctx, data) => { + await sendEmail(data.userId, await buildDigest(data.userId, data.since)); + }, + retryPolicy: createExponentialBackoffPolicy({ maxAttempts: 3 }), + onError: async (ctx, error, data) => { + await reportFailure("sendDigest", data.userId, error); + }, +}); +~~~~ + +Two failure cases are *dropped without retry*, because retrying cannot help: +a message whose `taskName` has no registered handler (logged as a warning), +and a payload that cannot be decoded or fails dequeue-time validation +(logged as an error). + + +Queue routing and isolation +--------------------------- + +By default tasks share the outbox queue, so no extra configuration is needed +beyond a `queue` on `createFederation()`. For heavier workloads, tasks can +be isolated at two levels. + +A dedicated task queue, separate from activity delivery, is configured with +the `~FederationQueueOptions.task` slot: + +~~~~ typescript +const federation = createFederation({ + // ... + queue: { + inbox: new PostgresMessageQueue(sql, { channel: "inbox" }), + outbox: new PostgresMessageQueue(sql, { channel: "outbox" }), + task: new PostgresMessageQueue(sql, { channel: "task" }), // [!code highlight] + }, +}); +~~~~ + +A single task can also carry its own queue, which takes precedence over +everything else: + +~~~~ typescript +const transcodeVideo = federation.defineTask("transcodeVideo", { + schema: transcodeSchema, + handler: transcodeHandler, + queue: new PostgresMessageQueue(sql, { channel: "transcode" }), +}); +~~~~ + +The queue for a task is resolved in order: the per-task `queue`, then the +federation's `task` queue, then the outbox queue. Deployments that must +*not* silently share the outbox queue can opt out of the last step with +`~FederationOptions.taskQueueResolution`: + +~~~~ typescript +const federation = createFederation({ + // ... + taskQueueResolution: "strict", // no outbox fallback // [!code highlight] +}); +~~~~ + +Under `"strict"` resolution, enqueuing a task that has no queue throws +a `TypeError` instead of falling back. + +On the worker side, `~Federation.startQueue()` accepts `"task"` in its +`queue` option, so a dedicated worker process can consume only tasks: + +~~~~ typescript +await federation.startQueue(contextData, { queue: "task" }); +~~~~ + +A task that falls back to the outbox queue needs no dedicated worker; the +outbox worker dispatches every message by its type regardless of which queue +delivered it. + + +Limitations +----------- + +The current API intentionally ships without deduplication, task-specific +OpenTelemetry spans and metrics, cron-style periodic scheduling, result +backends, and per-task priority. Some of these are planned as follow-ups; +see the [tracking issue]. + +[tracking issue]: https://github.com/fedify-dev/fedify/issues/206 diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json index f4c071af8..5cbf69826 100644 --- a/packages/fedify/deno.json +++ b/packages/fedify/deno.json @@ -15,8 +15,10 @@ }, "imports": { "@multiformats/base-x": "npm:@multiformats/base-x@^4.0.1", + "@standard-schema/spec": "jsr:@standard-schema/spec@^1.1.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/url": "jsr:@std/url@^0.225.1", + "devalue": "npm:devalue@^5.8.1", "fast-check": "npm:fast-check@^3.22.0", "fetch-mock": "npm:fetch-mock@^12.5.2", "json-canon": "npm:json-canon@^1.0.1", diff --git a/packages/fedify/package.json b/packages/fedify/package.json index 216e72cd2..946159bd1 100644 --- a/packages/fedify/package.json +++ b/packages/fedify/package.json @@ -150,7 +150,9 @@ "@opentelemetry/sdk-metrics": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", "@opentelemetry/semantic-conventions": "catalog:", + "@standard-schema/spec": "catalog:", "byte-encodings": "catalog:", + "devalue": "^5.8.1", "es-toolkit": "catalog:", "json-canon": "^1.0.1", "jsonld": "^9.0.0", diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 1503ccf7b..e237d9c2e 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -67,6 +67,13 @@ import type { CollectionCallbacks, CustomCollectionCallbacks, } from "./handler.ts"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { + TaskDefinition, + TaskDefinitionInternal, + TaskDefinitionOptions, + TaskHandler, +} from "./tasks/mod.ts"; export const ACTOR_ALIAS_PREFIX = "actorAlias:"; @@ -181,6 +188,7 @@ export class FederationBuilderImpl TContextData > >; + taskDefinitions: Record>; /** * Symbol registry for unique identification of unnamed symbols. @@ -193,6 +201,7 @@ export class FederationBuilderImpl this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; + this.taskDefinitions = {}; } /** @@ -258,6 +267,7 @@ export class FederationBuilderImpl f.unverifiedActivityHandler = this.unverifiedActivityHandler; f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; f.idempotencyStrategy = this.idempotencyStrategy; + f.taskDefinitions = { ...this.taskDefinitions }; return f; } @@ -593,6 +603,25 @@ export class FederationBuilderImpl this.webFingerLinksDispatcher = dispatcher; } + defineTask( + name: string, + options: TaskDefinitionOptions, + ): TaskDefinition> { + if (name in this.taskDefinitions) { + throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); + } + this.taskDefinitions[name] = { + name, + schema: options.schema, + handler: options.handler as TaskHandler, + onError: options + .onError as TaskDefinitionInternal["onError"], + retryPolicy: options.retryPolicy, + queue: options.queue, + }; + return { name, schema: options.schema }; + } + /** * The RFC 6570 template-literal `path` overloads were removed for * type-checking efficiency, so the URI variable types can no longer be diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 3a95bfdde..8375e6a76 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -22,6 +22,7 @@ import type { JsonValue, NodeInfo } from "../nodeinfo/types.ts"; import type { GetKeyOwnerOptions } from "../sig/owner.ts"; import type { ConstructorWithTypeId, Federation } from "./federation.ts"; import type { SenderKeyPair } from "./send.ts"; +import type { TaskDefinition, TaskEnqueueOptions } from "./tasks/mod.ts"; /** * A context. @@ -429,6 +430,52 @@ export interface Context { options?: RouteActivityOptions, ): Promise; + /** + * Enqueues a custom background task. The payload is validated against + * the task's schema, serialized, and processed by the task's handler on + * a background worker. + * + * @example + * ``` typescript + * await ctx.enqueueTask(sendDigest, { userId: "alice" }); + * ``` + * + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param task The handle returned by {@link TaskRegistry.defineTask}. + * @param data The task payload. It is validated against the task's + * schema before being enqueued. + * @param options Options for enqueuing the task. + * @throws {TypeError} If no message queue is configured for tasks, or if + * the payload fails schema validation. + * @since 2.3.0 + */ + enqueueTask( + task: TaskDefinition, + data: TData, + options?: TaskEnqueueOptions, + ): Promise; + + /** + * Enqueues multiple payloads for a custom background task at once. + * Uses the queue's bulk enqueue operation when available, falling back + * to parallel single enqueues. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param task The handle returned by {@link TaskRegistry.defineTask}. + * @param payloads The task payloads. Each is validated against the + * task's schema before being enqueued. + * @param options Options for enqueuing the tasks. + * @throws {TypeError} If no message queue is configured for tasks, or if + * a payload fails schema validation. + * @since 2.3.0 + */ + enqueueTaskMany( + task: TaskDefinition, + payloads: readonly TData[], + options?: TaskEnqueueOptions, + ): Promise; + /** * Builds the URI of a collection of objects with the given name and values. * @param name The name of the collection, which can be a string or a symbol. diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index e1d5e9d40..dbb5231f1 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -49,6 +49,7 @@ import type { import type { MessageQueue } from "./mq.ts"; import type { Message } from "./queue.ts"; import type { RetryPolicy } from "./retry.ts"; +import type { TaskRegistry } from "./tasks/mod.ts"; /** * Options for {@link Federation.startQueue} method. @@ -62,11 +63,11 @@ export interface FederationStartQueueOptions { /** * Starts the task worker only for the specified queue. If unspecified, - * which is the default, the task worker starts for all three queues: - * inbox, outbox, and fanout. + * which is the default, the task worker starts for all four queues: + * inbox, outbox, fanout, and task. * @since 1.3.0 */ - queue?: "inbox" | "outbox" | "fanout"; + queue?: "inbox" | "outbox" | "fanout" | "task"; } /** @@ -74,7 +75,7 @@ export interface FederationStartQueueOptions { * @template TContextData The context data to pass to the {@link Context}. * @since 1.6.0 */ -export interface Federatable { +export interface Federatable extends TaskRegistry { /** * Registers a NodeInfo dispatcher. * @param path The URI path pattern for the NodeInfo dispatcher. The syntax @@ -1080,6 +1081,28 @@ export interface FederationOptions { */ inboxRetryPolicy?: RetryPolicy; + /** + * The retry policy for processing custom background tasks. By default, + * this uses an exponential backoff strategy with a maximum of 10 attempts + * and a maximum delay of 12 hours. A per-task retry policy + * ({@link TaskDefinitionOptions.retryPolicy}) overrides this. + * @since 2.3.0 + */ + taskRetryPolicy?: RetryPolicy; + + /** + * How a queue is resolved for a custom background task when neither + * a per-task queue ({@link TaskDefinitionOptions.queue}) nor a dedicated + * task queue ({@link FederationQueueOptions.task}) is configured. + * + * - `"fallback"` (the default): the task is routed to the outbox queue. + * - `"strict"`: no fallback; enqueuing the task throws instead of + * silently sharing the outbox queue. + * @default `"fallback"` + * @since 2.3.0 + */ + taskQueueResolution?: "fallback" | "strict"; + /** * Activity transformers that are applied to outgoing activities. It is * useful for adjusting outgoing activities to satisfy some ActivityPub diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index d38a17a45..43fa562a4 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -57,13 +57,6 @@ import type { ActivityTransformer } from "../compat/types.ts"; import { getNodeInfo, type GetNodeInfoOptions } from "../nodeinfo/client.ts"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import type { JsonValue, NodeInfo } from "../nodeinfo/types.ts"; -import { - type BenchmarkMetricReader, - type BenchmarkTriggerOptions, - createBenchmarkMeterProvider, - handleBenchmarkStats, - handleBenchmarkTrigger, -} from "./bench.ts"; import { type HttpMessageSignaturesSpec, type HttpMessageSignaturesSpecDeterminer, @@ -87,6 +80,13 @@ import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; +import { + type BenchmarkMetricReader, + type BenchmarkTriggerOptions, + createBenchmarkMeterProvider, + handleBenchmarkStats, + handleBenchmarkTrigger, +} from "./bench.ts"; import { ACTOR_ALIAS_PREFIX, FederationBuilderImpl } from "./builder.ts"; import type { OutboxErrorHandler } from "./callback.ts"; import { @@ -158,6 +158,7 @@ import type { Message, OutboxMessage, SenderKeyJwkPair, + TaskMessage, } from "./queue.ts"; import { createExponentialBackoffPolicy, type RetryPolicy } from "./retry.ts"; import { @@ -166,8 +167,13 @@ import { SendActivityError, type SenderKeyPair, } from "./send.ts"; -import { handleWebFinger } from "./webfinger.ts"; +import { + TaskCodec, + type TaskDefinition, + type TaskEnqueueOptions, +} from "./tasks/mod.ts"; import { hasMalformedKnownTemporalLiteral } from "./temporal.ts"; +import { handleWebFinger } from "./webfinger.ts"; const circuitBreakerCasWarningKvStores = new WeakSet(); let nextQueueDepthGaugeSourceId = 0; @@ -449,6 +455,14 @@ export interface FederationQueueOptions { * {@link Context.sendActivity} calls. */ readonly fanout?: MessageQueue; + + /** + * The message queue for custom background tasks. If not provided, + * tasks are routed to the outbox queue (unless + * {@link FederationOptions.taskQueueResolution} is `"strict"`). + * @since 2.3.0 + */ + readonly task?: MessageQueue; } /** @@ -542,9 +556,11 @@ export class FederationImpl inboxQueue?: MessageQueue; outboxQueue?: MessageQueue; fanoutQueue?: MessageQueue; + taskQueue?: MessageQueue; inboxQueueStarted: boolean; outboxQueueStarted: boolean; fanoutQueueStarted: boolean; + taskQueueStarted: boolean; manuallyStartQueue: boolean; origin?: FederationOrigin; documentLoaderFactory: DocumentLoaderFactory; @@ -558,6 +574,8 @@ export class FederationImpl skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; inboxRetryPolicy: RetryPolicy; + taskRetryPolicy: RetryPolicy; + taskQueueResolution: "fallback" | "strict"; circuitBreaker?: CircuitBreaker; activityTransformers: readonly ActivityTransformer[]; _tracerProvider: TracerProvider | undefined; @@ -626,14 +644,19 @@ export class FederationImpl this.inboxQueue = undefined; this.outboxQueue = undefined; this.fanoutQueue = undefined; + this.taskQueue = undefined; } else if ("enqueue" in options.queue && "listen" in options.queue) { this.inboxQueue = options.queue; this.outboxQueue = options.queue; this.fanoutQueue = options.queue; + // A bare queue leaves taskQueue undefined; tasks are served through + // the outboxQueue fallback. + this.taskQueue = undefined; } else { this.inboxQueue = options.queue.inbox; this.outboxQueue = options.queue.outbox; this.fanoutQueue = options.queue.fanout; + this.taskQueue = options.queue.task; } if (options.circuitBreaker !== false && this.outboxQueue != null) { this.circuitBreaker = new CircuitBreaker({ @@ -664,6 +687,7 @@ export class FederationImpl this.inboxQueueStarted = false; this.outboxQueueStarted = false; this.fanoutQueueStarted = false; + this.taskQueueStarted = false; this.manuallyStartQueue = options.manuallyStartQueue ?? false; if (options.origin != null) { if (typeof options.origin === "string") { @@ -842,6 +866,9 @@ export class FederationImpl createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy(); + this.taskRetryPolicy = options.taskRetryPolicy ?? + createExponentialBackoffPolicy(); + this.taskQueueResolution = options.taskQueueResolution ?? "fallback"; this.activityTransformers = options.activityTransformers ?? getDefaultActivityTransformers(); this._tracerProvider = options.tracerProvider; @@ -894,12 +921,24 @@ export class FederationImpl return this.tracerProvider.getTracer(metadata.name, metadata.version); } + resolveTaskQueue(taskName: string): MessageQueue | undefined { + const def = this.taskDefinitions[taskName]; + const resolved = def?.queue ?? this.taskQueue; + if (resolved != null) return resolved; + return this.taskQueueResolution === "strict" ? undefined : this.outboxQueue; + } + async _startQueueInternal( ctxData: TContextData, signal?: AbortSignal, queue?: keyof FederationQueueOptions, ): Promise { - if (this.inboxQueue == null && this.outboxQueue == null) return; + if ( + this.inboxQueue == null && this.outboxQueue == null && + this.fanoutQueue == null && this.taskQueue == null + ) { + return; + } const logger = getLogger(["fedify", "federation", "queue"]); const promises: Promise[] = []; if ( @@ -946,6 +985,23 @@ export class FederationImpl ), ); } + if ( + this.taskQueue != null && + this.taskQueue !== this.inboxQueue && + this.taskQueue !== this.outboxQueue && + this.taskQueue !== this.fanoutQueue && + (queue == null || queue === "task") && + !this.taskQueueStarted + ) { + logger.debug("Starting a task worker."); + this.taskQueueStarted = true; + promises.push( + this.taskQueue.listen( + (msg) => this.processQueuedTask(ctxData, msg), + { signal }, + ), + ); + } await Promise.all(promises); } @@ -1126,6 +1182,8 @@ export class FederationImpl ); }, ); + } else if (message.type === "task") { + await this.#listenTaskMessage(contextData, message); } }); } @@ -2021,6 +2079,82 @@ export class FederationImpl ); } + async #listenTaskMessage( + contextData: TContextData, + message: TaskMessage, + ): Promise { + const logger = getLogger(["fedify", "federation", "task"]); + const def = this.taskDefinitions[message.taskName]; + if (def == null) { + // Unknown task: a handler won't appear by retrying. Drop and log. + logger.warn( + "Received a custom task {taskName} with no registered handler; " + + "dropping.", + { taskName: message.taskName }, + ); + return; + } + const context = this.#createContext(new URL(message.baseUrl), contextData); + let data: unknown; + try { + // decode() deserializes then re-validates at the dequeue boundary + // (drift protection): a durable queue can hand a new deploy a payload + // an old deploy enqueued. + data = await context.codec.decode(def.schema, message.data); + } catch (error) { + // A malformed or incompatible payload won't succeed by retrying. + logger.error( + "Custom task {taskName} payload could not be decoded or validated; " + + "dropping:\n{error}", + { taskName: message.taskName, error }, + ); + return; + } + try { + await def.handler(context, data); + } catch (error) { + if (def.onError != null) { + try { + await def.onError(context, error, data); + } catch (onErrorError) { + logger.error( + "onError for custom task {taskName} threw:\n{error}", + { taskName: message.taskName, error: onErrorError }, + ); + } + } + const queue = this.resolveTaskQueue(def.name); + if (queue?.nativeRetrial) throw error; // the backend owns retries + const retryPolicy = def.retryPolicy ?? this.taskRetryPolicy; + const delay = retryPolicy({ + elapsedTime: Temporal.Instant.from(message.started) + .until(Temporal.Now.instant()), + attempts: message.attempt, + }); + if (delay != null && queue != null) { + logger.error( + "Custom task {taskName} failed (attempt #{attempt}); retry...:" + + "\n{error}", + { taskName: message.taskName, attempt: message.attempt, error }, + ); + const retryMessage = { + ...message, + attempt: message.attempt + 1, + } satisfies TaskMessage; + await queue.enqueue(retryMessage, { + delay: clampNegativeDelay(delay), + orderingKey: message.orderingKey, + }); + } else { + logger.error( + "Custom task {taskName} failed after {attempt} attempts; giving " + + "up:\n{error}", + { taskName: message.taskName, attempt: message.attempt, error }, + ); + } + } + } + startQueue( contextData: TContextData, options: FederationStartQueueOptions = {}, @@ -2890,6 +3024,7 @@ export class ContextImpl implements Context { readonly documentLoader: DocumentLoader; readonly contextLoader: DocumentLoader; readonly invokedFromActorKeyPairsDispatcher?: { identifier: string }; + #codec?: TaskCodec; constructor( { @@ -2910,6 +3045,16 @@ export class ContextImpl implements Context { invokedFromActorKeyPairsDispatcher; } + /** + * A {@link TaskCodec} bound to this context's loaders, used to encode + * and decode custom task payloads. Lazily created and cached so a context + * that never enqueues or dispatches a task pays nothing. + * @internal + */ + get codec(): TaskCodec { + return this.#codec ??= new TaskCodec(this); + } + clone(data: TContextData): Context { return new ContextImpl({ url: this.url, @@ -3446,6 +3591,75 @@ export class ContextImpl implements Context { }); } + async enqueueTask( + task: TaskDefinition, + data: TData, + options: TaskEnqueueOptions = {}, + ): Promise { + await this.#enqueueTasks(task, [data], options); + } + + async enqueueTaskMany( + task: TaskDefinition, + payloads: readonly TData[], + options: TaskEnqueueOptions = {}, + ): Promise { + await this.#enqueueTasks(task, payloads, options); + } + + async #enqueueTasks( + task: TaskDefinition, + items: readonly TData[], + options: TaskEnqueueOptions, + ): Promise { + const queue = this.federation.resolveTaskQueue(task.name); + if (queue == null) { + throw new TypeError( + "No message queue is configured for tasks; pass `queue` to " + + "createFederation() or to defineTask().", + ); + } + const delay = options.delay == null + ? undefined + : Temporal.Duration.from(options.delay); + // Encode in parallel: `enqueueTaskMany` is the bulk path, and the enqueue + // below already parallelizes, so serial encoding would be the bottleneck. + // `map` preserves order, and a rejected encode (validation failure) rejects + // the whole batch before anything is enqueued, keeping fail-fast intact. + const messages: TaskMessage[] = await Promise.all( + items.map(this.#enqueueSingular(task, options)), + ); + const enqueueOptions = { delay, orderingKey: options.orderingKey }; + if (messages.length === 1) { + await queue.enqueue(messages[0], enqueueOptions); + } else if (queue.enqueueMany != null) { + await queue.enqueueMany(messages, enqueueOptions); + } else { + await Promise.all(messages.map((m) => queue.enqueue(m, enqueueOptions))); + } + } + + #enqueueSingular = ( + task: TaskDefinition, + options: TaskEnqueueOptions, + ) => + async (data: TData): Promise => { + const encoded = await this.codec.encode(task.schema, data); + const carrier: Record = {}; + propagation.inject(context.active(), carrier); + return { + type: "task", + id: crypto.randomUUID(), + baseUrl: this.origin, + taskName: task.name, + data: encoded, + started: Temporal.Now.instant().toString(), + attempt: 0, + orderingKey: options.orderingKey, + traceContext: carrier, + }; + }; + sendActivity( sender: | SenderKeyPair diff --git a/packages/fedify/src/federation/mod.ts b/packages/fedify/src/federation/mod.ts index 5a87fdf0f..6a3bb1f6e 100644 --- a/packages/fedify/src/federation/mod.ts +++ b/packages/fedify/src/federation/mod.ts @@ -25,6 +25,13 @@ export * from "./mq.ts"; export type { Message } from "./queue.ts"; export * from "./retry.ts"; export * from "./router.ts"; +export type { + TaskDefinition, + TaskDefinitionOptions, + TaskEnqueueOptions, + TaskHandler, + TaskRegistry, +} from "./tasks/mod.ts"; export { SendActivityError, type SenderKeyPair } from "./send.ts"; export { handleWebFinger, diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 36f35ad02..5bef2abfc 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -12,7 +12,11 @@ export interface SenderKeyJwkPair { * type. * @since 1.6.0 */ -export type Message = FanoutMessage | OutboxMessage | InboxMessage; +export type Message = + | FanoutMessage + | OutboxMessage + | InboxMessage + | TaskMessage; export interface FanoutMessage { readonly type: "fanout"; @@ -71,6 +75,25 @@ export interface OutboxMessage { readonly traceContext: Readonly>; } +/** + * A message that carries a custom background task. Every field is + * a string, number, or plain record so that the message survives both + * JSON serialization and structured clone on every queue backend. + * @since 2.3.0 + */ +export interface TaskMessage { + readonly type: "task"; + readonly id: ReturnType; + readonly baseUrl: string; + readonly taskName: string; + /** devalue-encoded task data; vocab objects bridged to expanded JSON-LD. */ + readonly data: string; + readonly started: string; + readonly attempt: number; + readonly orderingKey?: string; + readonly traceContext: Readonly>; +} + export interface InboxMessage { readonly type: "inbox"; readonly id: ReturnType; diff --git a/packages/fedify/src/federation/tasks/codec-fn.test.ts b/packages/fedify/src/federation/tasks/codec-fn.test.ts new file mode 100644 index 000000000..9cc4b9279 --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec-fn.test.ts @@ -0,0 +1,230 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, Link, Note, Person } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; +import { + deserializeTaskData, + serializeTaskData, + validateTaskData, +} from "./codec-fn.ts"; + +const loaders = { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, +}; + +function makeSchema( + check: (data: unknown) => data is T, +): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "fedify-test", + validate(value: unknown) { + return check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }; + }, + }, + }; +} + +test("serializeTaskData() / deserializeTaskData()", async (t) => { + const note = new Note({ + id: new URL("https://example.com/notes/1"), + content: "Hello, world!", + }); + const person = new Person({ + id: new URL("https://example.com/users/alice"), + name: "Alice", + }); + const link = new Link({ + href: new URL("https://example.com/"), + mediaType: "text/html", + }); + const create = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + object: note, + }); + + await t.step("round-trips a mixed payload", async () => { + const payload = { + note, + when: new Date("2026-01-02T03:04:05Z"), + big: 1234567890123456789n, + url: new URL("https://example.com/some/path"), + list: [person, link], + map: new Map([["create", create], ["n", 42]]), + set: new Set([1, 2, 3]), + nested: { create }, + }; + const encoded = await serializeTaskData(payload, mockDocumentLoader); + strictEqual(typeof encoded, "string"); + const decoded = await deserializeTaskData(encoded, loaders) as Record< + string, + unknown + >; + ok(decoded.note instanceof Note); + strictEqual(decoded.note.content?.toString(), "Hello, world!"); + strictEqual(decoded.note.id?.href, "https://example.com/notes/1"); + ok(decoded.when instanceof Date); + strictEqual(decoded.when.toISOString(), "2026-01-02T03:04:05.000Z"); + strictEqual(decoded.big, 1234567890123456789n); + ok(decoded.url instanceof URL); + strictEqual(decoded.url.href, "https://example.com/some/path"); + const list = decoded.list as unknown[]; + ok(list[0] instanceof Person); + strictEqual(list[0].name?.toString(), "Alice"); + ok(list[1] instanceof Link); + strictEqual(list[1].href?.href, "https://example.com/"); + const map = decoded.map as Map; + ok(map.get("create") instanceof Create); + strictEqual(map.get("n"), 42); + deepStrictEqual(decoded.set, new Set([1, 2, 3])); + const nested = decoded.nested as Record; + ok(nested.create instanceof Create); + const nestedObject = await nested.create.getObject({ + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + ok(nestedObject instanceof Note); + strictEqual(nestedObject.content?.toString(), "Hello, world!"); + }); + + await t.step( + "encodes vocab objects in expand form (no @context)", + async () => { + const encoded = await serializeTaskData({ note }, mockDocumentLoader); + ok(!encoded.includes("@context")); + }, + ); + + await t.step("leaves a non-vocab payload untouched", async () => { + const payload = { + text: "plain", + n: 1, + flag: true, + nothing: null, + when: new Date("2026-06-10T00:00:00Z"), + list: [1, "two", 3n], + }; + const encoded = await serializeTaskData(payload, mockDocumentLoader); + const decoded = await deserializeTaskData(encoded, loaders); + deepStrictEqual(decoded, payload); + }); + + await t.step("throws on a malformed wire string", async () => { + // deserializeTaskData() throws synchronously on a malformed wire string + // (devalue's parse() runs before the first await); the async wrapper + // funnels both sync throws and rejections into one assertion. + await rejects(async () => await deserializeTaskData("garbage", loaders)); + }); + + await t.step("preserves circular and repeated references", async () => { + const shared = new Note({ content: "shared" }); + interface Cyclic { + name: string; + self?: Cyclic; + notes: Note[]; + } + const payload: Cyclic = { name: "root", notes: [shared, shared] }; + payload.self = payload; + const encoded = await serializeTaskData(payload, mockDocumentLoader); + const decoded = await deserializeTaskData(encoded, loaders) as Cyclic; + strictEqual(decoded.self, decoded); + ok(decoded.notes[0] instanceof Note); + strictEqual(decoded.notes[0], decoded.notes[1]); + strictEqual(decoded.notes[0].content?.toString(), "shared"); + }); + + await t.step("preserves a cycle through an array", async () => { + const list: unknown[] = ["head"]; + list.push(list); + const encoded = await serializeTaskData({ list }, mockDocumentLoader); + const decoded = await deserializeTaskData(encoded, loaders) as { + list: unknown[]; + }; + strictEqual(decoded.list[0], "head"); + strictEqual(decoded.list[1], decoded.list); + }); + + await t.step("preserves cycles re-entering at a Map and a Set", async () => { + // The cycle must re-enter at the Map/Set *itself* (not at a plain + // object) to exercise their pre-registration in the reviver. + const set = new Set(); + set.add({ set }); + const map = new Map(); + map.set("entry", { map }); + const encoded = await serializeTaskData({ set, map }, mockDocumentLoader); + const decoded = await deserializeTaskData(encoded, loaders) as { + set: Set<{ set: Set }>; + map: Map }>; + }; + const [member] = decoded.set; + strictEqual(member.set, decoded.set); + strictEqual(decoded.map.get("entry")?.map, decoded.map); + }); +}); + +test("validateTaskData()", async (t) => { + interface Envelope { + note: Note; + title: string; + } + const schema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", + ); + + await t.step("accepts a payload with a vocab instanceof leaf", async () => { + const payload = { + note: new Note({ content: "Hi" }), + title: "greeting", + }; + const validated = await validateTaskData(schema, payload); + deepStrictEqual(validated, payload); + }); + + await t.step("rejects a wrong-shaped payload", async () => { + await rejects( + () => validateTaskData(schema, { note: "not a Note", title: 42 }), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }); + + await t.step("supports async validation", async () => { + const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + Promise.resolve( + typeof value === "number" + ? { value } + : { issues: [{ message: "not a number" }] }, + ), + }, + }; + strictEqual(await validateTaskData(asyncSchema, 42), 42); + await rejects(() => validateTaskData(asyncSchema, "nope")); + }); + + await t.step( + "round-trip then validate (same schema on both sides)", + async () => { + const payload = { + note: new Note({ content: "Hi" }), + title: "greeting", + }; + const encoded = await serializeTaskData(payload, mockDocumentLoader); + const decoded = await deserializeTaskData(encoded, loaders); + const validated = await validateTaskData(schema, decoded); + ok(validated.note instanceof Note); + strictEqual(validated.title, "greeting"); + strictEqual(validated.note.content?.toString(), "Hi"); + }, + ); +}); diff --git a/packages/fedify/src/federation/tasks/codec-fn.ts b/packages/fedify/src/federation/tasks/codec-fn.ts new file mode 100644 index 000000000..0f181d7fc --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec-fn.ts @@ -0,0 +1,188 @@ +/** + * Serializes custom-task payloads with [devalue], bridging Activity + * Vocabulary objects (`Note`, `Create`, `Person`, `Link`, and so on) through + * JSON-LD. + * + * Vocabulary objects keep their state in private fields, so devalue cannot + * serialize them directly. devalue's custom-type hook (a reducer on encode, + * a reviver on decode) carries each object as JSON-LD without writing a + * marker into the payload. Encoding uses the *expand* JSON-LD form, which + * has no `@context`, so decoding dereferences nothing and never touches the + * network. + * + * [devalue]: https://github.com/sveltejs/devalue + * + * @module + */ +import { Link, Object as APObject } from "@fedify/vocab"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { TracerProvider } from "@opentelemetry/api"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { parse, stringifyAsync } from "devalue"; + +/** Which `fromJsonLd` entry point rebuilds a given vocabulary object. */ +type VocabKind = "object" | "link"; + +/** A vocabulary object reduced to its wire form: a kind tag plus JSON-LD. */ +interface VocabWire { + readonly kind: VocabKind; + readonly jsonLd: unknown; +} + +/** + * The loaders a worker {@link Context} already exposes; both decode passes + * use them. + * @internal + */ +export interface TaskCodecLoaders { + readonly contextLoader?: DocumentLoader; + readonly documentLoader?: DocumentLoader; + readonly tracerProvider?: TracerProvider; + readonly baseUrl?: URL; +} + +const isVocab = (value: unknown): value is APObject | Link => + value instanceof APObject || value instanceof Link; + +const isPlainObject = (value: unknown): value is Record => + value === null || typeof value !== "object" + ? false + : globalThis.Object.getPrototypeOf(value) === + globalThis.Object.prototype; + +/** Reduce a vocabulary object to expanded JSON-LD (no `@context`). */ +const vocabToJsonLd = async ( + value: APObject | Link, + contextLoader: DocumentLoader, +): Promise => ({ + kind: value instanceof Link ? "link" : "object", + jsonLd: await value.toJsonLd({ format: "expand", contextLoader }), +}); + +/** Rebuild a vocabulary object from its wire form. */ +const vocabFromJsonLd = ( + { kind, jsonLd }: VocabWire, + loaders: TaskCodecLoaders, +): Promise => + kind === "link" + ? Link.fromJsonLd(jsonLd, loaders) + : APObject.fromJsonLd(jsonLd, loaders); + +/** + * Encodes a task payload to a devalue string. + * + * The reducer is deliberately a plain function, not an `async` one: + * `stringifyAsync` treats a truthy return as a match and awaits it. An + * `async` reducer would return a promise for *every* node, which is always + * truthy, so it would "match" non-vocab values too. The plain + * `isVocab(v) && …` form returns a synchronous `false` for non-vocab nodes + * and the `toJsonLd()` promise only for vocab ones. + * + * @internal + */ +export const serializeTaskData = ( + data: unknown, + contextLoader: DocumentLoader, +): Promise => + stringifyAsync(data, { + Vocab: (value: unknown) => + isVocab(value) && vocabToJsonLd(value, contextLoader), + }); + +/** + * A vocabulary object parked by the synchronous decode reviver, held until + * the async {@link reviveVocab} pass can `fromJsonLd()` it back into an + * instance. + */ +class VocabHolder implements VocabWire { + constructor(readonly kind: VocabKind, readonly jsonLd: unknown) {} + static from = ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd); +} + +/** + * Second decode pass: replace every parked holder with a real instance. + * + * devalue preserves circular and repeated references, so the walker keeps + * a `seen` map from each visited container to its revived counterpart. + * Containers are registered *before* their contents are walked; a cycle + * therefore resolves to the (still-filling) revived container instead of + * recursing forever, and a repeated reference revives to the same instance. + */ +function reviveVocab( + loaders: TaskCodecLoaders, +): (node: unknown) => Promise { + const seen = new Map(); + return async function inner(node: unknown): Promise { + if (node === null || typeof node !== "object") return node; + if (seen.has(node)) return seen.get(node); + if (node instanceof VocabHolder) { + const revived = await vocabFromJsonLd(node, loaders); + seen.set(node, revived); + return revived; + } + if (Array.isArray(node)) { + const out: unknown[] = []; + seen.set(node, out); + out.push(...await Array.fromAsync(node, inner)); + return out; + } + if (node instanceof Map) { + const out = new Map(); + seen.set(node, out); + for (const [k, v] of node) out.set(await inner(k), await inner(v)); + return out; + } + if (node instanceof Set) { + const out = new Set(); + seen.set(node, out); + for (const v of await Array.fromAsync(node, inner)) out.add(v); + return out; + } + if (isPlainObject(node)) { + const out: Record = {}; + seen.set(node, out); + for (const [k, v] of globalThis.Object.entries(node)) { + out[k] = await inner(v); + } + return out; + } + return node; // Date / URL / RegExp and the like — devalue handled them + }; +} + +/** + * Decodes a devalue string back to a task payload. + * + * Two passes are unavoidable: `parse` revivers are synchronous while + * `fromJsonLd()` is async. The reviver only parks each object; + * {@link reviveVocab} then walks the result and awaits `fromJsonLd()`. + * + * @internal + */ +export const deserializeTaskData = ( + raw: string, + loaders: TaskCodecLoaders, +): Promise => + reviveVocab(loaders)( + parse(raw, { + Vocab: ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd), + }), + ); + +/** + * Validates `data` through the vendor-agnostic + * [Standard Schema](https://standardschema.dev/) interface. + * @internal + */ +export const validateTaskData = async ( + schema: S, + data: unknown, +): Promise> => { + const result = await schema["~standard"].validate(data); + if (result.issues) { + throw new TypeError( + `Task data failed schema validation: ${JSON.stringify(result.issues)}`, + ); + } + return result.value; +}; diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts new file mode 100644 index 000000000..d7dd4b636 --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -0,0 +1,284 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, Link, Note, Person } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; +import TaskCodec from "./codec.ts"; + +const loaders = { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, +}; + +const codec = new TaskCodec(loaders); + +function makeSchema( + check: (data: unknown) => data is T, +): StandardSchemaV1 { + return { + "~standard": { + version: 1, + vendor: "fedify-test", + validate(value: unknown) { + return check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }; + }, + }, + }; +} + +test("TaskCodec (fresh instance per operation)", async (t) => { + const note = new Note({ + id: new URL("https://example.com/notes/1"), + content: "Hello, world!", + }); + const person = new Person({ + id: new URL("https://example.com/users/alice"), + name: "Alice", + }); + const link = new Link({ + href: new URL("https://example.com/"), + mediaType: "text/html", + }); + const create = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/users/alice"), + object: note, + }); + + await t.step("round-trips a mixed payload", async () => { + const payload = { + note, + when: new Date("2026-01-02T03:04:05Z"), + big: 1234567890123456789n, + url: new URL("https://example.com/some/path"), + list: [person, link], + map: new Map([["create", create], ["n", 42]]), + set: new Set([1, 2, 3]), + nested: { create }, + }; + const encoded = await codec.serialize(payload); + strictEqual(typeof encoded, "string"); + const decoded = await codec.deserialize(encoded) as Record< + string, + unknown + >; + ok(decoded.note instanceof Note); + strictEqual(decoded.note.content?.toString(), "Hello, world!"); + strictEqual(decoded.note.id?.href, "https://example.com/notes/1"); + ok(decoded.when instanceof Date); + strictEqual(decoded.when.toISOString(), "2026-01-02T03:04:05.000Z"); + strictEqual(decoded.big, 1234567890123456789n); + ok(decoded.url instanceof URL); + strictEqual(decoded.url.href, "https://example.com/some/path"); + const list = decoded.list as unknown[]; + ok(list[0] instanceof Person); + strictEqual(list[0].name?.toString(), "Alice"); + ok(list[1] instanceof Link); + strictEqual(list[1].href?.href, "https://example.com/"); + const map = decoded.map as Map; + ok(map.get("create") instanceof Create); + strictEqual(map.get("n"), 42); + deepStrictEqual(decoded.set, new Set([1, 2, 3])); + const nested = decoded.nested as Record; + ok(nested.create instanceof Create); + const nestedObject = await nested.create.getObject({ + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + ok(nestedObject instanceof Note); + strictEqual(nestedObject.content?.toString(), "Hello, world!"); + }); + + await t.step( + "encodes vocab objects in expand form (no @context)", + async () => { + const encoded = await codec.serialize({ note }); + ok(!encoded.includes("@context")); + }, + ); + + await t.step("leaves a non-vocab payload untouched", async () => { + const payload = { + text: "plain", + n: 1, + flag: true, + nothing: null, + when: new Date("2026-06-10T00:00:00Z"), + list: [1, "two", 3n], + }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded); + deepStrictEqual(decoded, payload); + }); + + await t.step("throws on a malformed wire string", async () => { + await rejects(async () => await codec.deserialize("garbage")); + }); + + await t.step("preserves circular and repeated references", async () => { + const shared = new Note({ content: "shared" }); + interface Cyclic { + name: string; + self?: Cyclic; + notes: Note[]; + } + const payload: Cyclic = { name: "root", notes: [shared, shared] }; + payload.self = payload; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as Cyclic; + strictEqual(decoded.self, decoded); + ok(decoded.notes[0] instanceof Note); + strictEqual(decoded.notes[0], decoded.notes[1]); + strictEqual(decoded.notes[0].content?.toString(), "shared"); + }); + + await t.step("preserves a cycle through an array", async () => { + const list: unknown[] = ["head"]; + list.push(list); + const encoded = await codec.serialize({ list }); + const decoded = await codec.deserialize(encoded) as { + list: unknown[]; + }; + strictEqual(decoded.list[0], "head"); + strictEqual(decoded.list[1], decoded.list); + }); + + await t.step("preserves cycles re-entering at a Map and a Set", async () => { + const set = new Set(); + set.add({ set }); + const map = new Map(); + map.set("entry", { map }); + const encoded = await codec.serialize({ set, map }); + const decoded = await codec.deserialize(encoded) as { + set: Set<{ set: Set }>; + map: Map }>; + }; + const [member] = decoded.set; + strictEqual(member.set, decoded.set); + strictEqual(decoded.map.get("entry")?.map, decoded.map); + }); +}); + +test("TaskCodec (one instance reused across decodes)", async (t) => { + // The instance carries the cycle-tracking `#seen` map across decodes, but + // each decode parses a fresh object graph with distinct identities, so a + // reused instance still decodes every payload independently. + await t.step("two sequential decodes stay independent", async () => { + const codec = new TaskCodec(loaders); + const first = await codec.serialize({ + note: new Note({ content: "A" }), + }); + const second = await codec.serialize({ + note: new Note({ content: "B" }), + }); + const a = await codec.deserialize(first) as { note: Note }; + const b = await codec.deserialize(second) as { note: Note }; + ok(a.note instanceof Note); + ok(b.note instanceof Note); + strictEqual(a.note.content?.toString(), "A"); + strictEqual(b.note.content?.toString(), "B"); + }); +}); + +test("TaskCodec.validate()", async (t) => { + interface Envelope { + note: Note; + title: string; + } + const schema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", + ); + + await t.step("accepts a payload with a vocab instanceof leaf", async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const validated = await TaskCodec.validate(schema, payload); + deepStrictEqual(validated, payload); + }); + + await t.step("rejects a wrong-shaped payload", async () => { + await rejects( + () => TaskCodec.validate(schema, { note: "not a Note", title: 42 }), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }); + + await t.step("supports async validation", async () => { + const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + Promise.resolve( + typeof value === "number" + ? { value } + : { issues: [{ message: "not a number" }] }, + ), + }, + }; + strictEqual(await TaskCodec.validate(asyncSchema, 42), 42); + await rejects(() => TaskCodec.validate(asyncSchema, "nope")); + }); + + await t.step( + "round-trip then validate (same schema on both sides)", + async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded); + const validated = await TaskCodec.validate(schema, decoded); + ok(validated.note instanceof Note); + strictEqual(validated.title, "greeting"); + strictEqual(validated.note.content?.toString(), "Hi"); + }, + ); +}); + +test("TaskCodec.encode() / decode()", async (t) => { + interface Envelope { + note: Note; + title: string; + } + const schema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", + ); + + await t.step( + "encode() validates then serializes; decode() round-trips", + async () => { + const payload = { note: new Note({ content: "Hi" }), title: "greeting" }; + const wire = await codec.encode(schema, payload); + strictEqual(typeof wire, "string"); + const back = await codec.decode(schema, wire); + ok(back.note instanceof Note); + strictEqual(back.note.content?.toString(), "Hi"); + strictEqual(back.title, "greeting"); + }, + ); + + await t.step("encode() rejects a wrong-shaped payload", async () => { + await rejects( + () => codec.encode(schema, { note: "nope", title: 42 }), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }); + + await t.step( + "decode() re-validates and rejects a drifted payload", + async () => { + // Encode under a permissive schema, decode under the strict one. + const loose = makeSchema((_data): _data is unknown => true); + const wire = await codec.encode(loose, { note: "not a note" }); + await rejects( + () => codec.decode(schema, wire), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }, + ); +}); diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts new file mode 100644 index 000000000..c073c413a --- /dev/null +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -0,0 +1,186 @@ +import { Link, Object as APObject } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { parse, stringifyAsync } from "devalue"; +import type { TaskCodecLoaders } from "./codec-fn.ts"; + +export default class TaskCodec { + constructor(readonly options: TaskCodecLoaders) {} + + serialize = (data: unknown): Promise => + stringifyAsync(data, { Vocab: this.#stringifyVocab }); + + deserialize = (raw: string): Promise => + this.#revive(new Map())(parse(raw, { Vocab: VocabHolder.from })); + + /** Validates `data` against `schema`, then serializes it. */ + encode = async ( + schema: S, + data: StandardSchemaV1.InferInput, + ): Promise => this.serialize(await TaskCodec.validate(schema, data)); + + /** Deserializes `raw`, then validates the result against `schema`. */ + decode = async ( + schema: S, + raw: string, + ): Promise> => + TaskCodec.validate(schema, await this.deserialize(raw)); + + static validate = async ( + schema: S, + data: unknown, + ): Promise> => + getValueIfSchema(await schema["~standard"].validate(data)); + + #stringifyVocab = (value: unknown) => isVocab(value) && this.#toWire(value); + + #toWire = async (value: APObject | Link): Promise => ({ + kind: value instanceof Link ? "link" : "object", + jsonLd: await value.toJsonLd({ format: "expand", ...this.options }), + }); + + // The explicit return type breaks the inference cycle between #revive and + // #classRevivers (whose `set` callbacks call back into #revive). + #revive = (seen: Seen): Revive => async (node: unknown): Promise => + node === null || typeof node !== "object" + ? node + : seen.has(node) + ? seen.get(node) + : (await Array.fromAsync( + this.#classRevivers.map(this.#reviveByClass(seen)), + (f) => f(node), + )).find(Boolean) ?? + node; // Date / URL / RegExp and the like — devalue handled them + + #reviveByClass = + (seen: Seen) => + ([filter, init, set]: ClassReviver) => + async (node: object): Promise => { + if (!filter(node)) return; + // @ts-ignore tsc faults + const out: Revived = await init(node); + seen.set(node, out); + // @ts-ignore tsc faults + await set(this.#revive(seen), node, out); + return out; + }; + + #classRevivers = [ + [ + isInstanceOf(VocabHolder), + ({ kind, jsonLd }: VocabWire): Promise => + kind === "link" + ? Link.fromJsonLd(jsonLd, this.options) + : APObject.fromJsonLd(jsonLd, this.options), + () => Promise.resolve(), + ], + [ + isInstanceOf(Array), + (): unknown[] => [], + async (revive: Revive, node: unknown[], arr: typeof node) => { + arr.push(...await Array.fromAsync(node, revive)); + }, + ], + [ + isInstanceOf(Map), + () => new Map(), + async (revive: Revive, node: Map, map: typeof node) => { + for (const [k, v] of node) map.set(await revive(k), await revive(v)); + }, + ], + [ + isInstanceOf(Set), + () => new Set(), + async (revive: Revive, node: Set, set: typeof node) => { + for (const v of await Array.fromAsync(node, revive)) set.add(v); + }, + ], + [ + isPlainObject, + () => ({}), + async ( + revive: Revive, + node: Record, + obj: typeof node, + ) => { + for (const [k, v] of globalThis.Object.entries(node)) { + obj[k] = await revive(v); + } + }, + ], + ] as const; +} + +const isVocab = (value: unknown): value is APObject | Link => + value instanceof APObject || value instanceof Link; + +const isPlainObject = (value: unknown): value is Record => + value === null || typeof value !== "object" + ? false + : globalThis.Object.getPrototypeOf(value) === globalThis.Object.prototype; + +const isInstanceOf = (cls: Constructor) => (v: unknown): v is T => + v instanceof cls; + +function getValueIfSchema(result: StandardSchemaV1.Result) { + assertSchema(result); + return result.value; +} + +function assertSchema( + result: StandardSchemaV1.Result, +): asserts result is StandardSchemaV1.SuccessResult { + if (result.issues && result.issues.length > 0) { + throw new TypeError( + `Task data failed schema validation: ${JSON.stringify(result.issues)}`, + ); + } +} + +/** Which `fromJsonLd` entry point rebuilds a given vocabulary object. */ +type VocabKind = "object" | "link"; + +/** A vocabulary object reduced to its wire form: a kind tag plus JSON-LD. */ +interface VocabWire { + readonly kind: VocabKind; + readonly jsonLd: unknown; +} + +/** + * A vocabulary object parked by the synchronous decode reviver, held until + * the async revive pass can `fromJsonLd()` it back into an instance. + */ +class VocabHolder implements VocabWire { + constructor(readonly kind: VocabKind, readonly jsonLd: unknown) {} + static from = ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd); +} + +/** Per-decode map from each visited container to its revived counterpart. */ +type Seen = Map; + +/** Revives one node, sharing the per-decode {@link Seen} map via closure. */ +type Revive = (node: unknown) => Promise; + +/** + * One row of {@link TaskCodec.#classRevivers}: a type guard, a factory + * that makes the empty revived container, and a filler that walks the source + * into it using the supplied per-node {@link Revive}. `#reviveByClass` + * cannot annotate its parameter as `typeof this.#classRevivers[number]` + * because a `typeof` query on a private field does not parse, so this loose + * structural shape stands in; the `init` and `set` calls are reconciled with + * `@ts-ignore` at the call site. + */ +type ClassReviver = readonly [ + (value: unknown) => boolean, + (node: never) => unknown, + (revive: Revive, node: never, out: never) => Promise, +]; + +type Container = + | VocabHolder + | Map + | Set + | Array + | Record; +type Revived = Exclude | APObject | Link; +// deno-lint-ignore no-explicit-any +type Constructor = new (...arg: any[]) => T; diff --git a/packages/fedify/src/federation/tasks/mod.ts b/packages/fedify/src/federation/tasks/mod.ts new file mode 100644 index 000000000..a40b57bd3 --- /dev/null +++ b/packages/fedify/src/federation/tasks/mod.ts @@ -0,0 +1,11 @@ +/** + * The internal barrel for the custom background task API. Cross-directory + * consumers (*federation.ts*, *builder.ts*, *context.ts*, *middleware.ts*) + * import from this module, not the individual files. Only the public subset + * is re-exported from *federation/mod.ts*. + * + * @module + */ +export * from "./codec-fn.ts"; +export { default as TaskCodec } from "./codec.ts"; +export * from "./task.ts"; diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts new file mode 100644 index 000000000..1318d647b --- /dev/null +++ b/packages/fedify/src/federation/tasks/task.ts @@ -0,0 +1,169 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { Context } from "../context.ts"; +import type { MessageQueue } from "../mq.ts"; +import type { RetryPolicy } from "../retry.ts"; + +/** + * A callback that processes a custom background task. + * @template TContextData The context data to pass to the {@link Context}. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @param ctx The context for the worker processing the task. + * @param data The decoded and validated task payload. + * @since 2.3.0 + */ +export type TaskHandler = ( + ctx: Context, + data: TData, +) => Promise | void; + +/** + * Options for {@link TaskRegistry.defineTask}. + * @template TContextData The context data to pass to the {@link Context}. + * @template TSchema The [Standard Schema](https://standardschema.dev/) that + * validates the task payload. + * @since 2.3.0 + */ +export interface TaskDefinitionOptions< + TContextData, + TSchema extends StandardSchemaV1, +> { + /** + * The [Standard Schema](https://standardschema.dev/) that validates the + * task payload. The payload type is inferred from this schema. + * + * The payload is validated twice: once at enqueue time (fail fast) and + * once at dequeue time (drift protection against payloads enqueued by an + * older deployment). + */ + readonly schema: TSchema; + + /** + * The callback that processes the task on a background worker. + */ + readonly handler: TaskHandler< + TContextData, + StandardSchemaV1.InferOutput + >; + + /** + * The retry policy for this task. If omitted, the federation-wide + * task retry policy is used, which defaults to an exponential backoff + * policy. + */ + readonly retryPolicy?: RetryPolicy; + + /** + * A callback invoked when the {@link handler} throws an error, before + * a retry is scheduled. + * @param ctx The context for the worker processing the task. + * @param error The error thrown by the handler. + * @param data The decoded and validated task payload. + */ + readonly onError?: ( + ctx: Context, + error: unknown, + data: StandardSchemaV1.InferOutput, + ) => Promise | void; + + /** + * The message queue dedicated to this task. If omitted, the task is + * routed to the federation-wide task queue, falling back to the outbox + * queue (unless `taskQueueResolution: "strict"` is configured). + */ + readonly queue?: MessageQueue; +} + +/** + * The handle returned by {@link TaskRegistry.defineTask}. It carries the + * task name and schema so that {@link Context.enqueueTask} can validate the + * payload and infer its type at every call site. + * @template TContextData The context data to pass to the {@link Context}. + * @template TData The type of the task payload, inferred from the task's + * schema. + * @since 2.3.0 + */ +export interface TaskDefinition { + /** + * The unique name of the task. + */ + readonly name: string; + + /** + * The [Standard Schema](https://standardschema.dev/) that validates the + * task payload. + */ + readonly schema: StandardSchemaV1; + + /** + * @internal Phantom marker binding the handle to its federation. + */ + readonly __contextData?: TContextData; +} + +/** + * Registration of custom background tasks. Both {@link Federation} and + * {@link FederationBuilder} implement this interface. + * @template TContextData The context data to pass to the {@link Context}. + * @since 2.3.0 + */ +export interface TaskRegistry { + /** + * Defines a custom background task. The returned handle is passed to + * {@link Context.enqueueTask} to enqueue the task. + * + * @example + * ``` typescript + * const sendDigest = federation.defineTask("sendDigest", { + * schema: digestSchema, + * handler: async (ctx, data) => { + * // …process the payload on a background worker… + * }, + * }); + * ``` + * + * @param name The unique name of the task. + * @param options The task definition options. The payload type is + * inferred from `options.schema`. + * @returns The handle to pass to {@link Context.enqueueTask}. + * @throws {TypeError} If a task with the same name is already defined. + */ + defineTask( + name: string, + options: TaskDefinitionOptions, + ): TaskDefinition>; +} + +/** + * Options for {@link Context.enqueueTask} and {@link Context.enqueueTaskMany}. + * @since 2.3.0 + */ +export interface TaskEnqueueOptions { + /** + * The delay before the task is processed. No delay by default. + */ + readonly delay?: Temporal.DurationLike; + + /** + * An optional key that ensures tasks with the same ordering key are + * processed sequentially (one at a time). + */ + readonly orderingKey?: string; +} + +/** + * The stored shape of a task definition, read at dispatch time. + * @internal + */ +export interface TaskDefinitionInternal { + readonly name: string; + readonly schema: StandardSchemaV1; + readonly handler: TaskHandler; + readonly retryPolicy?: RetryPolicy; + readonly onError?: ( + ctx: Context, + error: unknown, + data: unknown, + ) => Promise | void; + readonly queue?: MessageQueue; +} diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts new file mode 100644 index 000000000..bf4e862f8 --- /dev/null +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -0,0 +1,647 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Note } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { delay } from "es-toolkit"; +import { + deepStrictEqual, + ok, + rejects, + strictEqual, + throws, +} from "node:assert/strict"; +import { createFederationBuilder } from "../builder.ts"; +import type { Context } from "../context.ts"; +import type { Federatable, FederationOptions } from "../federation.ts"; +import { MemoryKvStore } from "../kv.ts"; +import { createFederation, type FederationImpl } from "../middleware.ts"; +import { + InProcessMessageQueue, + type MessageQueue, + type MessageQueueEnqueueOptions, + type MessageQueueListenOptions, +} from "../mq.ts"; +import type { TaskMessage } from "../queue.ts"; +import TaskCodec from "./codec.ts"; +import type { TaskDefinition, TaskRegistry } from "./task.ts"; + +type Assert = T; + +const makeSchema = ( + check: (data: unknown) => data is T, +): StandardSchemaV1 => ({ + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }, + }, +}); + +interface Envelope { + note: Note; + title: string; +} + +const envelopeSchema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", +); + +const stringSchema = makeSchema((d): d is string => typeof d === "string"); +const numberSchema = makeSchema((d): d is number => typeof d === "number"); + +class MockQueue implements MessageQueue { + readonly nativeRetrial: boolean; + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + readonly enqueuedMany: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + listenCount = 0; + enqueueMany?: ( + messages: readonly TaskMessage[], + options?: MessageQueueEnqueueOptions, + ) => Promise; + + constructor( + options: { nativeRetrial?: boolean; supportsEnqueueMany?: boolean } = {}, + ) { + this.nativeRetrial = options.nativeRetrial ?? false; + if (options.supportsEnqueueMany) { + this.enqueueMany = (messages, opts) => { + this.enqueuedMany.push({ messages, options: opts }); + return Promise.resolve(); + }; + } + } + + enqueue( + message: TaskMessage, + options?: MessageQueueEnqueueOptions, + ): Promise { + this.enqueued.push({ message, options }); + return Promise.resolve(); + } + + listen( + _handler: (message: TaskMessage) => Promise | void, + options?: MessageQueueListenOptions, + ): Promise { + this.listenCount++; + return new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => resolve()); + }); + } +} + +const baseOptions: Omit, "queue"> = { + kv: new MemoryKvStore(), + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + manuallyStartQueue: true, +}; + +const makeTaskMessage = async ( + taskName: string, + data: unknown, + overrides: Partial = {}, +): Promise => ({ + type: "task", + id: crypto.randomUUID(), + baseUrl: "https://example.com/", + taskName, + data: await new TaskCodec({ contextLoader: mockDocumentLoader }) + .serialize(data), + started: Temporal.Now.instant().toString(), + attempt: 0, + traceContext: {}, + ...overrides, +}); + +async function waitFor( + predicate: () => boolean, + timeoutMs: number, +): Promise { + const started = Date.now(); + while (!predicate()) { + await delay(50); + if (Date.now() - started > timeoutMs) throw new Error("Timeout"); + } +} + +test("defineTask()", async (t) => { + await t.step("returns a handle carrying name and schema", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + const task = federation.defineTask("greet", { + schema: stringSchema, + handler: () => {}, + }); + strictEqual(task.name, "greet"); + strictEqual(task.schema, stringSchema); + }); + + await t.step("throws on a duplicate name", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + federation.defineTask("dup", { + schema: stringSchema, + handler: () => {}, + }); + throws( + () => + federation.defineTask("dup", { + schema: stringSchema, + handler: () => {}, + }), + { name: "TypeError", message: /already defined/ }, + ); + }); + + await t.step("build() clones the task registry", async () => { + const builder = createFederationBuilder(); + builder.defineTask("first", { + schema: stringSchema, + handler: () => {}, + }); + const f1 = await builder.build({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + builder.defineTask("second", { + schema: stringSchema, + handler: () => {}, + }); + const f2 = await builder.build({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + deepStrictEqual(Object.keys(f1.taskDefinitions), ["first"]); + deepStrictEqual(Object.keys(f2.taskDefinitions), ["first", "second"]); + // Defining on a built federation does not leak back into the builder: + f1.defineTask("third", { schema: stringSchema, handler: () => {} }); + deepStrictEqual(Object.keys(f2.taskDefinitions), ["first", "second"]); + }); +}); + +test("task type-level guards", () => { + // Forward-compat seam: Federatable must remain assignable to TaskRegistry, + // so a future Worker can implement TaskRegistry directly. + type _ForwardCompat = Assert< + Federatable extends TaskRegistry ? true : false + >; + const _wrongPayloadIsACompileError = ( + ctx: Context, + task: TaskDefinition, + ) => { + // @ts-expect-error: a wrong-shaped payload must not type-check. + return ctx.enqueueTask(task, { n: "not a number" }); + }; +}); + +test("Context.enqueueTask() end-to-end", async (t) => { + await t.step("round-trips a typed payload to the handler", async () => { + const queue = new InProcessMessageQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const received: { ctx: Context; data: Envelope }[] = []; + const task = federation.defineTask("notify", { + schema: envelopeSchema, + handler: (ctx, data) => { + received.push({ ctx, data }); + }, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, { + note: new Note({ content: "Hello, world!" }), + title: "greeting", + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + queue: "task", + }); + try { + await waitFor(() => received.length > 0, 15_000); + } finally { + controller.abort(); + await listening; + } + strictEqual(received.length, 1); + const { ctx: handlerCtx, data } = received[0]; + ok(data.note instanceof Note); + strictEqual(data.note.content?.toString(), "Hello, world!"); + strictEqual(data.title, "greeting"); + strictEqual(handlerCtx.origin, "https://example.com"); + }); + + await t.step("rejects an invalid payload at enqueue", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("strictly-typed", { + schema: numberSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + // deno-lint-ignore no-explicit-any + () => ctx.enqueueTask(task, "not a number" as any), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("passes delay and orderingKey through", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("delayed", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload", { + delay: { seconds: 30 }, + orderingKey: "user:alice", + }); + strictEqual(queue.enqueued.length, 1); + const { message, options } = queue.enqueued[0]; + strictEqual(message.taskName, "delayed"); + strictEqual(message.orderingKey, "user:alice"); + strictEqual(message.attempt, 0); + ok(options?.delay instanceof Temporal.Duration); + strictEqual(options.delay.total("second"), 30); + strictEqual(options.orderingKey, "user:alice"); + }); + + await t.step( + "enqueueTaskMany() uses enqueueMany when available", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, ["a", "b", "c"]); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 1); + strictEqual(queue.enqueuedMany[0].messages.length, 3); + }, + ); + + await t.step( + "enqueueTaskMany() falls back to parallel enqueues", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, ["a", "b"]); + strictEqual(queue.enqueued.length, 2); + }, + ); +}); + +test("task queue routing", async (t) => { + await t.step("prefers the per-task queue", async () => { + const taskQueue = new MockQueue(); + const perTaskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + const task = federation.defineTask("isolated", { + schema: stringSchema, + handler: () => {}, + queue: perTaskQueue, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "data"); + strictEqual(perTaskQueue.enqueued.length, 1); + strictEqual(taskQueue.enqueued.length, 0); + }); + + await t.step("falls back to the outbox queue by default", async () => { + const outboxQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: outboxQueue }, + }); + const task = federation.defineTask("fallback", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "data"); + strictEqual(outboxQueue.enqueued.length, 1); + }); + + await t.step( + 'taskQueueResolution: "strict" throws at enqueue instead', + async () => { + const outboxQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: outboxQueue }, + taskQueueResolution: "strict", + }); + const task = federation.defineTask("strict", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "data"), + { name: "TypeError", message: /No message queue is configured/ }, + ); + strictEqual(outboxQueue.enqueued.length, 0); + }, + ); + + await t.step("throws when no queue is configured at all", async () => { + const federation = createFederation({ ...baseOptions }); + const task = federation.defineTask("queueless", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(task, "data"), + { name: "TypeError", message: /No message queue is configured/ }, + ); + }); +}); + +test("startQueue() task worker", async (t) => { + await t.step('starts only the task worker for queue: "task"', async () => { + const inbox = new MockQueue(); + const outbox = new MockQueue(); + const fanout = new MockQueue(); + const taskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { inbox, outbox, fanout, task: taskQueue }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + queue: "task", + }); + strictEqual(taskQueue.listenCount, 1); + strictEqual(inbox.listenCount, 0); + strictEqual(outbox.listenCount, 0); + strictEqual(fanout.listenCount, 0); + controller.abort(); + await listening; + }); + + await t.step("starts the worker for a task-only deployment", async () => { + const taskQueue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(taskQueue.listenCount, 1); + controller.abort(); + await listening; + }); + + await t.step("does not double-listen on a shared queue", async () => { + const shared = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { outbox: shared, task: shared }, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(shared.listenCount, 1); + controller.abort(); + await listening; + }); +}); + +test("processQueuedTask() task dispatch", async (t) => { + await t.step("drops an unknown task with a warning", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + const message = await makeTaskMessage("never-defined", "data"); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("drops an undecodable payload without retry", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + let called = 0; + federation.defineTask("broken-wire", { + schema: stringSchema, + handler: () => { + called++; + }, + }); + const message = await makeTaskMessage("broken-wire", "data"); + await federation.processQueuedTask(undefined, { + ...message, + data: "garbage that is not devalue", + }); + strictEqual(called, 0); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step("drops a drifted payload without retry", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + let called = 0; + federation.defineTask("drifted", { + schema: numberSchema, // the "new deploy" expects a number… + handler: () => { + called++; + }, + }); + // …but the payload was enqueued by an "old deploy" as a string: + const message = await makeTaskMessage("drifted", "stringly-typed"); + await federation.processQueuedTask(undefined, message); + strictEqual(called, 0); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step( + "re-enqueues with attempt + 1 when the handler throws", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + const errors: unknown[] = []; + federation.defineTask("flaky", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + onError: (_ctx, error, data) => { + errors.push([error, data]); + }, + }); + const message = await makeTaskMessage("flaky", "data", { + orderingKey: "k", + }); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 1); + const retry = queue.enqueued[0]; + strictEqual(retry.message.attempt, 1); + strictEqual(retry.message.taskName, "flaky"); + strictEqual(retry.message.orderingKey, "k"); + strictEqual(retry.options?.orderingKey, "k"); + ok(retry.options?.delay instanceof Temporal.Duration); + strictEqual(errors.length, 1); + deepStrictEqual(errors[0], [new Error("boom"), "data"]); + }, + ); + + await t.step("gives up when the retry policy returns null", async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("hopeless", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => null, + }); + const message = await makeTaskMessage("hopeless", "data"); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 0); + }); + + await t.step( + "per-task retryPolicy overrides the federation default", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + taskRetryPolicy: () => null, // the federation default gives up… + }) as FederationImpl; + federation.defineTask("override", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + // …but the per-task policy retries: + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + }); + federation.defineTask("default", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + }); + await federation.processQueuedTask( + undefined, + await makeTaskMessage("override", "data"), + ); + strictEqual(queue.enqueued.length, 1); + await federation.processQueuedTask( + undefined, + await makeTaskMessage("default", "data"), + ); + strictEqual(queue.enqueued.length, 1); // unchanged: gave up + }, + ); + + await t.step("rethrows on a nativeRetrial queue", async () => { + const queue = new MockQueue({ nativeRetrial: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("native", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + }); + const message = await makeTaskMessage("native", "data"); + await rejects( + () => federation.processQueuedTask(undefined, message), + { message: /boom/ }, + ); + strictEqual(queue.enqueued.length, 0); + }); +}); diff --git a/packages/fedify/src/testing/context.ts b/packages/fedify/src/testing/context.ts index 0f9781d14..c613c93c4 100644 --- a/packages/fedify/src/testing/context.ts +++ b/packages/fedify/src/testing/context.ts @@ -51,6 +51,8 @@ export function createContext( lookupWebFinger, sendActivity, routeActivity, + enqueueTask, + enqueueTaskMany, } = values; function throwRouterError(): URL { throw new RouterError("Not implemented"); @@ -113,6 +115,12 @@ export function createContext( routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }), + enqueueTask: enqueueTask ?? ((_task, _data, _options) => { + throw new Error("Not implemented"); + }), + enqueueTaskMany: enqueueTaskMany ?? ((_task, _payloads, _options) => { + throw new Error("Not implemented"); + }), }; } diff --git a/packages/testing/src/context.ts b/packages/testing/src/context.ts index a4771cf14..c8e91a2af 100644 --- a/packages/testing/src/context.ts +++ b/packages/testing/src/context.ts @@ -100,6 +100,8 @@ function createContext( lookupWebFinger, sendActivity, routeActivity, + enqueueTask, + enqueueTaskMany, } = values; function throwRouterError(): URL { throw new RouterError("Not implemented"); @@ -165,6 +167,12 @@ function createContext( routeActivity: routeActivity ?? ((_params) => { throw new Error("Not implemented"); }), + enqueueTask: enqueueTask ?? ((_task, _data, _options) => { + throw new Error("Not implemented"); + }), + enqueueTaskMany: enqueueTaskMany ?? ((_task, _payloads, _options) => { + throw new Error("Not implemented"); + }), }; } diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 271387c25..74cfe1a0d 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -263,7 +263,7 @@ type ActivityConstructor = new (...args: any[]) => Activity; class MockFederation implements Federation { public sentActivities: SentActivity[] = []; public queueStarted = false; - private activeQueues: Set<"inbox" | "outbox" | "fanout"> = new Set(); + private activeQueues: Set<"inbox" | "outbox" | "fanout" | "task"> = new Set(); public sentCounter = 0; private nodeInfoDispatcher?: any; // Note: Using `any` instead of WebFingerLinksDispatcher to avoid JSR hang. @@ -285,6 +285,7 @@ class MockFederation implements Federation { public sharedInboxPath?: string; public objectPaths: Map = new Map(); public objectDispatchers: Map = new Map(); + public taskDefinitions: Map = new Map(); private inboxDispatcher?: any; private outboxDispatcher?: any; private outboxAuthorizePredicate?: any; @@ -317,6 +318,16 @@ class MockFederation implements Federation { this.nodeInfoPath = path; } + // Note: Parameter and return types are `any` like the other mock methods; + // the structural shape follows TaskRegistry.defineTask(). + defineTask(name: string, options: any): any { + if (this.taskDefinitions.has(name)) { + throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); + } + this.taskDefinitions.set(name, { name, ...options }); + return { name, schema: options.schema }; + } + // Note: Parameter type is `any` instead of WebFingerLinksDispatcher to avoid // JSR type analyzer hang (issue #468). See comment on webFingerDispatcher field. setWebFingerLinksDispatcher( @@ -504,10 +515,11 @@ class MockFederation implements Federation { if (options?.queue) { this.activeQueues.add(options.queue); } else { - // If no specific queue, activate all three + // If no specific queue, activate all four this.activeQueues.add("inbox"); this.activeQueues.add("outbox"); this.activeQueues.add("fanout"); + this.activeQueues.add("task"); } } @@ -955,6 +967,27 @@ class MockContext implements Context { return Promise.resolve(null); } + // No queue in mock type: the task handler is invoked immediately, + // mirroring how processQueuedTask() processes immediately. + async enqueueTask(task: any, data: any, _options?: any): Promise { + if (!(this.federation instanceof MockFederation)) { + throw new TypeError("No task definitions are available."); + } + const def = this.federation.taskDefinitions.get(task.name); + if (def == null) { + throw new TypeError(`Task ${JSON.stringify(task.name)} is not defined.`); + } + await def.handler(this, data); + } + + async enqueueTaskMany( + task: any, + payloads: readonly any[], + options?: any, + ): Promise { + for (const data of payloads) await this.enqueueTask(task, data, options); + } + clone(data: TContextData): TestContext { return new MockContext({ url: this.url, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84a59ab96..b7c3b95cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1188,9 +1188,15 @@ importers: '@opentelemetry/semantic-conventions': specifier: 'catalog:' version: 1.40.0 + '@standard-schema/spec': + specifier: 'catalog:' + version: 1.1.0 byte-encodings: specifier: 'catalog:' version: 1.0.11 + devalue: + specifier: ^5.8.1 + version: 5.8.1 es-toolkit: specifier: 'catalog:' version: 1.46.1 @@ -8116,15 +8122,15 @@ packages: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} - devalue@5.1.1: - resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} - devalue@5.6.1: resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} devalue@5.7.1: resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -15506,7 +15512,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.7.1 + devalue: 5.8.1 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -17108,7 +17114,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.1.1 + devalue: 5.8.1 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.17 @@ -17129,7 +17135,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.1.1 + devalue: 5.8.1 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.17 @@ -19968,12 +19974,12 @@ snapshots: dependencies: base-64: 1.0.0 - devalue@5.1.1: {} - devalue@5.6.1: {} devalue@5.7.1: {} + devalue@5.8.1: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 From 5f9af3365604a2c4998ec28a088b9807b7af5b30 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 13:57:26 +0000 Subject: [PATCH 02/29] Restructure mise install tasks and update docs Split the monolithic `install` task into `install:deno` and `install:pnpm`, with `codegen` as an explicit dependency, so each runtime's setup can be run on its own. `test:deno` now depends on `install:deno` instead of `prepare`, since Deno runs the TypeScript sources directly and does not need the build step. Update AGENTS.md to match: document `mise run prepare`/`prepare-each` for building, `check-each` and `test-each` for scoping work to specific packages, and add a section directing agents to consult `mise tasks`. Assisted-by: Claude Code:claude-opus-4-8 --- AGENTS.md | 24 +++++++++++++++++++----- mise.toml | 13 +++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 57986f30b..b209fab3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,11 +127,18 @@ Development workflow - **Code Generation**: Run `mise run codegen` whenever vocabulary YAML files or code generation scripts change. - **Building Packages**: After installation, all packages are automatically - built. To rebuild a specific package and its dependencies, run `pnpm build` - in that package's directory. - - **Checking Code**: Run `mise run check` before committing. - - **Running Tests**: Use `mise run test:deno` for Deno tests or - `mise run test` for all environments. + built. To rebuild a specific package and its dependencies, run + `mise run prepare`, or run `mise run prepare-each ` to build + specific packages. + - **Checking Code**: Run `mise run check` before committing, or run + `mise run check-each ` to check specific packages. + - **Running Tests**: + - `mise run test`: Executes all the tests in every runtime. + - `mise run test:`: Executes all the tests by the + runtime. If some specific tests are needed, execute + `mise run test: `. + - `mise run test-each `: Executes tests in packages that include + `pkgs` in every runtime. For detailed contribution guidelines, see *CONTRIBUTING.md*. @@ -151,6 +158,13 @@ When working with federation code: Common tasks ------------ +### **BE WELL-ACQUAINTED WITH `mise tasks`** + +*mise.toml* has useful tasks. **Acquaint all of them** and use them in the right +place at the right time. If it has too many information, use `mise tasks`. This +command show the summary of the tasks and descriptions. If `mise tasks` didn't +not make it sure, use `mise tasks ` to check the details for the task. + ### Adding ActivityPub vocabulary types 1. Create a new YAML file in *packages/vocab/vocab/* following existing diff --git a/mise.toml b/mise.toml index cebfcc4dc..23a50f24c 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,16 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" # Installation and setup [tasks.install] description = "Install all dependencies and set up the development environment" -run = "deno task install" +depends = ["codegen", "install:deno", "install:pnpm"] + +[tasks."install:deno"] +description = "Install all dependencies and set up for Deno" +run = "deno run --allow-read --allow-env --allow-run scripts/install.ts" + +[tasks."install:pnpm"] +description = "Install all dependencies and set up for Deno" +run = "pnpm install" + [tasks.codegen] description = "Generate ActivityPub vocabulary types from YAML definitions" @@ -147,7 +156,7 @@ done # Testing [tasks."test:deno"] description = "Run the test suite using Deno" -depends = ["prepare"] +depends = ["install:deno"] # `prepare` is for building and Deno doesn't need it. run = "deno test --check --doc --allow-all --unstable-kv --trace-leaks --parallel" [tasks."test:node"] From b3070ea1bd81eca7de9ccde657af67279924999b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 15:40:17 +0000 Subject: [PATCH 03/29] Make codegen a prerequisite of the install subtasks The `install:deno` task runs `scripts/install.ts`, which `deno cache`s each workspace member's export entry points; those include the generated `packages/vocab/src/vocab.ts`. `install:pnpm` likewise expects the generated sources to be present. Both therefore require `codegen` to have run first. Previously `codegen` sat alongside `install:deno` and `install:pnpm` in the `install` task's `depends` list, which `mise` runs in parallel, so the cache step could start before `vocab.ts` was generated. Move `codegen` into each subtask's own `depends` so it is ordered before them; `mise` dedupes the shared dependency to a single run. As a result `install:deno` and `install:pnpm` are now correct when invoked on their own, not only as part of `install`. Also correct the `install:pnpm` description, which said "for Deno". Assisted-by: Claude Code:claude-opus-4-8 --- mise.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index 23a50f24c..cf2b06840 100644 --- a/mise.toml +++ b/mise.toml @@ -14,14 +14,16 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" # Installation and setup [tasks.install] description = "Install all dependencies and set up the development environment" -depends = ["codegen", "install:deno", "install:pnpm"] +depends = ["install:deno", "install:pnpm"] [tasks."install:deno"] description = "Install all dependencies and set up for Deno" +depends = ["codegen"] run = "deno run --allow-read --allow-env --allow-run scripts/install.ts" [tasks."install:pnpm"] -description = "Install all dependencies and set up for Deno" +description = "Install all dependencies and set up for pnpm" +depends = ["codegen"] run = "pnpm install" From d07ea051d0722776e91627961231fc2518bf43a3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 15:46:48 +0000 Subject: [PATCH 04/29] Add PR #803 link in changelog --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index f3901d3a6..ecc65bd58 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -286,7 +286,7 @@ To be released. `Federation.startQueue()` now accepts `queue: "task"` to run a task-only worker. - [[#206], [#797] by ChanHaeng Lee] + [[#206], [#797], [#803] by ChanHaeng Lee] [Standard Schema]: https://standardschema.dev/ [#206]: https://github.com/fedify-dev/fedify/issues/206 @@ -321,6 +321,7 @@ To be released. [#787]: https://github.com/fedify-dev/fedify/pull/787 [#797]: https://github.com/fedify-dev/fedify/issues/797 [#800]: https://github.com/fedify-dev/fedify/pull/800 +[#803]: https://github.com/fedify-dev/fedify/pull/803 ### @fedify/cli From 3ada130055841f8375102ab2d3e91127493f6b6c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 16:41:08 +0000 Subject: [PATCH 05/29] Revive vocab nested in null-prototype objects `isPlainObject` in the task codec only accepted objects whose prototype is exactly `Object.prototype`, so an object made with `Object.create(null)` was treated as a non-plain leaf. Any vocab object nested inside such an object was therefore left as its parked holder instead of being revived, even though devalue round-trips null-prototype objects without throwing. Accept a `null` prototype as well, and add a regression test that round-trips a vocab object nested in an `Object.create(null)` object. https://github.com/fedify-dev/fedify/pull/803#discussion_r3396953879 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/tasks/codec.test.ts | 14 ++++++++++++++ packages/fedify/src/federation/tasks/codec.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index d7dd4b636..d45d39ea1 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -158,6 +158,20 @@ test("TaskCodec (fresh instance per operation)", async (t) => { strictEqual(member.set, decoded.set); strictEqual(decoded.map.get("entry")?.map, decoded.map); }); + + await t.step( + "revives a vocab object nested in a null-prototype object", + async () => { + const nullProto = Object.create(null) as Record; + nullProto.note = note; + const encoded = await codec.serialize({ wrap: nullProto }); + const decoded = await codec.deserialize(encoded) as { + wrap: Record; + }; + ok(decoded.wrap.note instanceof Note); + strictEqual(decoded.wrap.note.content?.toString(), "Hello, world!"); + }, + ); }); test("TaskCodec (one instance reused across decodes)", async (t) => { diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index c073c413a..e83da0045 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -116,7 +116,10 @@ const isVocab = (value: unknown): value is APObject | Link => const isPlainObject = (value: unknown): value is Record => value === null || typeof value !== "object" ? false - : globalThis.Object.getPrototypeOf(value) === globalThis.Object.prototype; + : isObjectPrototype(globalThis.Object.getPrototypeOf(value)); + +const isObjectPrototype = (proto: unknown): boolean => + proto === null || proto === globalThis.Object.prototype; const isInstanceOf = (cls: Constructor) => (v: unknown): v is T => v instanceof cls; From c2a2d4c5c6aee945ffa354af00218dee2c4fe2b0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 16:58:44 +0000 Subject: [PATCH 06/29] Guard against an unparsable task started time When a custom task handler throws and the queue does not own retries, the error path computes the elapsed time from `message.started` to feed the retry policy. `message.started` is normally a valid ISO instant set at enqueue time, but a corrupted or drifted queue could hand back an invalid string, in which case `Temporal.Instant.from()` threw out of the error-handling block. That masked the original handler error and aborted the retry, silently dropping the task. Wrap the parse in a try-catch, fall back to a zero elapsed time, and log the offending value. A regression test drives a message with a malformed `started` through a throwing handler and asserts the retry is still enqueued. https://github.com/fedify-dev/fedify/pull/803#discussion_r3396953943 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/middleware.ts | 19 +++++++++++-- .../fedify/src/federation/tasks/tasks.test.ts | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 43fa562a4..2db8ec50c 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2126,9 +2126,24 @@ export class FederationImpl const queue = this.resolveTaskQueue(def.name); if (queue?.nativeRetrial) throw error; // the backend owns retries const retryPolicy = def.retryPolicy ?? this.taskRetryPolicy; + // A corrupted `started` must not throw here and abort the retry. + let elapsedTime = Temporal.Duration.from({ seconds: 0 }); + try { + elapsedTime = Temporal.Instant.from(message.started) + .until(Temporal.Now.instant()); + } catch (parseError) { + logger.error( + "Custom task {taskName} has an unparsable started time " + + "{started}; treating elapsedTime as zero:\n{error}", + { + taskName: message.taskName, + started: message.started, + error: parseError, + }, + ); + } const delay = retryPolicy({ - elapsedTime: Temporal.Instant.from(message.started) - .until(Temporal.Now.instant()), + elapsedTime, attempts: message.attempt, }); if (delay != null && queue != null) { diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index bf4e862f8..3ef0489d6 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -625,6 +625,33 @@ test("processQueuedTask() task dispatch", async (t) => { }, ); + await t.step( + "still retries when message.started is malformed", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }) as FederationImpl; + federation.defineTask("bad-started", { + schema: stringSchema, + handler: () => { + throw new Error("boom"); + }, + retryPolicy: () => Temporal.Duration.from({ milliseconds: 1 }), + }); + // A corrupted or drifted queue can hand back an invalid `started`; + // computing elapsedTime must not throw out of the error path and abort + // the retry. + const message = await makeTaskMessage("bad-started", "data", { + started: "not-an-instant", + }); + await federation.processQueuedTask(undefined, message); + strictEqual(queue.enqueued.length, 1); + strictEqual(queue.enqueued[0].message.attempt, 1); + }, + ); + await t.step("rethrows on a nativeRetrial queue", async () => { const queue = new MockQueue({ nativeRetrial: true }); const federation = createFederation({ From 6c0766ff30dfd66f3d22634fbb7337331c25e26c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 18:30:47 +0000 Subject: [PATCH 07/29] Store task definitions in a prototype-safe Map `FederationBuilderImpl.taskDefinitions` was a plain object, so the duplicate check `name in this.taskDefinitions` and the lookups `this.taskDefinitions[taskName]` consulted the prototype chain. Task names are arbitrary user-supplied strings, so a name such as "constructor", "toString", or "__proto__" was wrongly reported as already defined and resolved to an inherited method on lookup. Switch the registry to a `Map`, which is immune to prototype keys by construction and avoids the clone footgun where a later spread or `Object.assign` would silently reintroduce the prototype. Sibling registries stay plain objects since they are keyed by controlled values (type-id URLs). Add a regression test covering names that collide with `Object.prototype`. https://github.com/fedify-dev/fedify/pull/803#discussion_r3397749459 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/builder.ts | 12 +++---- packages/fedify/src/federation/middleware.ts | 4 +-- .../fedify/src/federation/tasks/tasks.test.ts | 33 +++++++++++++++++-- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index e237d9c2e..59460f3f9 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -188,7 +188,7 @@ export class FederationBuilderImpl TContextData > >; - taskDefinitions: Record>; + taskDefinitions: Map>; /** * Symbol registry for unique identification of unnamed symbols. @@ -201,7 +201,7 @@ export class FederationBuilderImpl this.objectTypeIds = {}; this.collectionCallbacks = {}; this.collectionTypeIds = {}; - this.taskDefinitions = {}; + this.taskDefinitions = new Map(); } /** @@ -267,7 +267,7 @@ export class FederationBuilderImpl f.unverifiedActivityHandler = this.unverifiedActivityHandler; f.outboxPermanentFailureHandler = this.outboxPermanentFailureHandler; f.idempotencyStrategy = this.idempotencyStrategy; - f.taskDefinitions = { ...this.taskDefinitions }; + f.taskDefinitions = new Map(this.taskDefinitions); return f; } @@ -607,10 +607,10 @@ export class FederationBuilderImpl name: string, options: TaskDefinitionOptions, ): TaskDefinition> { - if (name in this.taskDefinitions) { + if (this.taskDefinitions.has(name)) { throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); } - this.taskDefinitions[name] = { + this.taskDefinitions.set(name, { name, schema: options.schema, handler: options.handler as TaskHandler, @@ -618,7 +618,7 @@ export class FederationBuilderImpl .onError as TaskDefinitionInternal["onError"], retryPolicy: options.retryPolicy, queue: options.queue, - }; + }); return { name, schema: options.schema }; } diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 2db8ec50c..54ecaa520 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -922,7 +922,7 @@ export class FederationImpl } resolveTaskQueue(taskName: string): MessageQueue | undefined { - const def = this.taskDefinitions[taskName]; + const def = this.taskDefinitions.get(taskName); const resolved = def?.queue ?? this.taskQueue; if (resolved != null) return resolved; return this.taskQueueResolution === "strict" ? undefined : this.outboxQueue; @@ -2084,7 +2084,7 @@ export class FederationImpl message: TaskMessage, ): Promise { const logger = getLogger(["fedify", "federation", "task"]); - const def = this.taskDefinitions[message.taskName]; + const def = this.taskDefinitions.get(message.taskName); if (def == null) { // Unknown task: a handler won't appear by retrying. Drop and log. logger.warn( diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 3ef0489d6..6b313f0a9 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -169,6 +169,33 @@ test("defineTask()", async (t) => { ); }); + await t.step("accepts names that collide with Object.prototype", () => { + const federation = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }) as FederationImpl; + // These names exist on Object.prototype; a plain-object registry would + // mistake them for already-defined tasks (`name in {}`) and would return + // an inherited method on lookup. + for (const name of ["constructor", "toString", "hasOwnProperty"]) { + const task = federation.defineTask(name, { + schema: stringSchema, + handler: () => {}, + }); + strictEqual(task.name, name); + strictEqual(federation.taskDefinitions.get(name)?.name, name); + } + // A genuine duplicate still throws. + throws( + () => + federation.defineTask("toString", { + schema: stringSchema, + handler: () => {}, + }), + { name: "TypeError", message: /already defined/ }, + ); + }); + await t.step("build() clones the task registry", async () => { const builder = createFederationBuilder(); builder.defineTask("first", { @@ -187,11 +214,11 @@ test("defineTask()", async (t) => { ...baseOptions, queue: { task: new MockQueue() }, }) as FederationImpl; - deepStrictEqual(Object.keys(f1.taskDefinitions), ["first"]); - deepStrictEqual(Object.keys(f2.taskDefinitions), ["first", "second"]); + deepStrictEqual([...f1.taskDefinitions.keys()], ["first"]); + deepStrictEqual([...f2.taskDefinitions.keys()], ["first", "second"]); // Defining on a built federation does not leak back into the builder: f1.defineTask("third", { schema: stringSchema, handler: () => {} }); - deepStrictEqual(Object.keys(f2.taskDefinitions), ["first", "second"]); + deepStrictEqual([...f2.taskDefinitions.keys()], ["first", "second"]); }); }); From 9b3c8a554134b33193728e7d250caf74bdb562c0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 18:36:51 +0000 Subject: [PATCH 08/29] Simplify task payload revival to a single matching reviver `#revive` mapped every node through all five class revivers, allocating five promises per node and resolving them with `Array.fromAsync` before picking the first truthy result. The class filters are mutually exclusive, so it now finds the single matching reviver and runs only that one, cutting the per-node work to a single promise. This keeps the existing behaviour (cycles, repeated references, and Map/Set/Array/plain-object/null-prototype containers all still round-trip, as the codec tests assert) and folds the rationale for two declined suggestions into a comment: the walked tree is devalue's throwaway parse output, so there is no external identity to preserve and nothing to clone lazily; and a recursion-depth cap is moot because this pass recurses with `await` (unwinding the stack each level) while devalue's own recursive `stringify`/`parse` is the binding limit on nesting and would overflow first. https://github.com/fedify-dev/fedify/pull/803#discussion_r3397748334 https://github.com/fedify-dev/fedify/pull/803#discussion_r3397748344 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/tasks/codec.ts | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index e83da0045..3eab7f52f 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -40,29 +40,31 @@ export default class TaskCodec { // The explicit return type breaks the inference cycle between #revive and // #classRevivers (whose `set` callbacks call back into #revive). - #revive = (seen: Seen): Revive => async (node: unknown): Promise => - node === null || typeof node !== "object" - ? node - : seen.has(node) - ? seen.get(node) - : (await Array.fromAsync( - this.#classRevivers.map(this.#reviveByClass(seen)), - (f) => f(node), - )).find(Boolean) ?? - node; // Date / URL / RegExp and the like — devalue handled them - - #reviveByClass = - (seen: Seen) => - ([filter, init, set]: ClassReviver) => - async (node: object): Promise => { - if (!filter(node)) return; - // @ts-ignore tsc faults - const out: Revived = await init(node); - seen.set(node, out); - // @ts-ignore tsc faults - await set(this.#revive(seen), node, out); - return out; - }; + // + // Every node walked here belongs to the throwaway tree that devalue's + // `parse` just built from the wire string, not to any caller-shared graph, + // so the revived containers are always fresh: there is nothing to clone + // lazily and no external identity to preserve. A recursion-depth cap is + // likewise unnecessary: this pass recurses with `await`, which unwinds the + // synchronous stack at each level, and the binding limit on nesting is + // devalue's own synchronous, recursive `stringify`/`parse`, which would + // overflow long before this pass — capping depth here would add nothing. + #revive = (seen: Seen): Revive => async (node: unknown): Promise => { + if (node === null || typeof node !== "object") return node; + if (seen.has(node)) return seen.get(node); + // The class filters are mutually exclusive, so find the single matching + // reviver instead of running all of them against every node. + const reviver = this.#classRevivers.find(([filter]) => filter(node)); + // Date / URL / RegExp and the like — devalue already handled them. + if (reviver == null) return node; + const [, init, set] = reviver; + // @ts-ignore tsc faults + const out: Revived = await init(node); + seen.set(node, out); + // @ts-ignore tsc faults + await set(this.#revive(seen), node, out); + return out; + }; #classRevivers = [ [ From fc0af86a5220ffa6482a1a99532e7dfb61a0b1c6 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 18:42:10 +0000 Subject: [PATCH 09/29] Start workers for dedicated per-task queues A task may route to its own queue via `defineTask(name, { queue })`, and `resolveTaskQueue()` enqueues its messages there, but `_startQueueInternal()` only listened on the four federation-wide queues (inbox, outbox, fanout, task). A task queue that was none of those got no worker, so its messages were never processed even while `startQueue()` was running. Collect the distinct dedicated queue instances from the task registry and start a worker for each, treating them as part of the "task" selector. Dedupe against the standard queues and against task queues already started on an earlier call so no instance is listened on twice, and let a deployment whose only queues are per-task ones still start: the early return no longer bails out when a dedicated task queue exists. https://github.com/fedify-dev/fedify/pull/803#discussion_r3397749449 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/middleware.ts | 41 ++++++++- .../fedify/src/federation/tasks/tasks.test.ts | 92 +++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 54ecaa520..bd1ec0662 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -561,6 +561,10 @@ export class FederationImpl outboxQueueStarted: boolean; fanoutQueueStarted: boolean; taskQueueStarted: boolean; + // Dedicated per-task queues (defineTask({ queue })) that already have a + // worker listening, so a later _startQueueInternal() call does not listen + // on the same instance twice. + startedTaskQueues: Set; manuallyStartQueue: boolean; origin?: FederationOrigin; documentLoaderFactory: DocumentLoaderFactory; @@ -688,6 +692,7 @@ export class FederationImpl this.outboxQueueStarted = false; this.fanoutQueueStarted = false; this.taskQueueStarted = false; + this.startedTaskQueues = new Set(); this.manuallyStartQueue = options.manuallyStartQueue ?? false; if (options.origin != null) { if (typeof options.origin === "string") { @@ -933,9 +938,16 @@ export class FederationImpl signal?: AbortSignal, queue?: keyof FederationQueueOptions, ): Promise { + // Tasks may route to a dedicated queue of their own (defineTask({ queue })) + // even when no federation-wide queue is configured, so a deployment with + // only per-task queues still has work to start. + const hasDedicatedTaskQueue = [...this.taskDefinitions.values()].some( + (def) => def.queue != null, + ); if ( this.inboxQueue == null && this.outboxQueue == null && - this.fanoutQueue == null && this.taskQueue == null + this.fanoutQueue == null && this.taskQueue == null && + !hasDedicatedTaskQueue ) { return; } @@ -1002,6 +1014,33 @@ export class FederationImpl ), ); } + // Dedicated per-task queues belong to the "task" selector. Each distinct + // instance needs its own worker; dedupe against the standard queues and + // against task queues already started on an earlier call so no instance is + // listened on twice. + if (queue == null || queue === "task") { + const standardQueues = new Set( + [this.inboxQueue, this.outboxQueue, this.fanoutQueue, this.taskQueue] + .filter((q): q is MessageQueue => q != null), + ); + for (const def of this.taskDefinitions.values()) { + const taskQueue = def.queue; + if ( + taskQueue == null || standardQueues.has(taskQueue) || + this.startedTaskQueues.has(taskQueue) + ) { + continue; + } + logger.debug("Starting a worker for a dedicated per-task queue."); + this.startedTaskQueues.add(taskQueue); + promises.push( + taskQueue.listen( + (msg) => this.processQueuedTask(ctxData, msg), + { signal }, + ), + ); + } + } await Promise.all(promises); } diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 6b313f0a9..cbaca519b 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -507,6 +507,98 @@ test("startQueue() task worker", async (t) => { controller.abort(); await listening; }); + + await t.step("starts a worker for a dedicated per-task queue", async () => { + const taskQueue = new MockQueue(); + const dedicated = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: taskQueue }, + }); + federation.defineTask("dedicated", { + schema: stringSchema, + handler: () => {}, + queue: dedicated, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(taskQueue.listenCount, 1); + strictEqual(dedicated.listenCount, 1); + controller.abort(); + await listening; + }); + + await t.step( + "starts a per-task queue even without a federation queue", + async () => { + const dedicated = new MockQueue(); + const federation = createFederation({ ...baseOptions }); + federation.defineTask("dedicated", { + schema: stringSchema, + handler: () => {}, + queue: dedicated, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(dedicated.listenCount, 1); + controller.abort(); + await listening; + }, + ); + + await t.step( + "does not listen twice on a per-task queue shared with a standard queue", + async () => { + const shared = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: shared }, + }); + federation.defineTask("reuses-task-queue", { + schema: stringSchema, + handler: () => {}, + queue: shared, + }); + const controller = new AbortController(); + const listening = federation.startQueue(undefined, { + signal: controller.signal, + }); + strictEqual(shared.listenCount, 1); + controller.abort(); + await listening; + }, + ); + + await t.step( + "routes an enqueued task on a dedicated queue to its handler", + async () => { + const dedicated = new MockQueue(); + const federation = createFederation({ ...baseOptions }); + let received: string | undefined; + const task = federation.defineTask("dedicated-end-to-end", { + schema: stringSchema, + handler: (_ctx, data) => { + received = data; + }, + queue: dedicated, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTask(task, "payload"); + strictEqual(dedicated.enqueued.length, 1); + await (federation as FederationImpl).processQueuedTask( + undefined, + dedicated.enqueued[0].message, + ); + strictEqual(received, "payload"); + }, + ); }); test("processQueuedTask() task dispatch", async (t) => { From 058fa84b57d9a46d43b379584d21972fddc04a8d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 19:11:36 +0000 Subject: [PATCH 10/29] Removed overly detailed comments and unused code --- packages/fedify/src/federation/middleware.ts | 3 -- packages/fedify/src/federation/tasks/codec.ts | 30 +------------------ 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index bd1ec0662..c10e97129 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -561,9 +561,6 @@ export class FederationImpl outboxQueueStarted: boolean; fanoutQueueStarted: boolean; taskQueueStarted: boolean; - // Dedicated per-task queues (defineTask({ queue })) that already have a - // worker listening, so a later _startQueueInternal() call does not listen - // on the same instance twice. startedTaskQueues: Set; manuallyStartQueue: boolean; origin?: FederationOrigin; diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index 3eab7f52f..0681ce444 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -38,24 +38,11 @@ export default class TaskCodec { jsonLd: await value.toJsonLd({ format: "expand", ...this.options }), }); - // The explicit return type breaks the inference cycle between #revive and - // #classRevivers (whose `set` callbacks call back into #revive). - // - // Every node walked here belongs to the throwaway tree that devalue's - // `parse` just built from the wire string, not to any caller-shared graph, - // so the revived containers are always fresh: there is nothing to clone - // lazily and no external identity to preserve. A recursion-depth cap is - // likewise unnecessary: this pass recurses with `await`, which unwinds the - // synchronous stack at each level, and the binding limit on nesting is - // devalue's own synchronous, recursive `stringify`/`parse`, which would - // overflow long before this pass — capping depth here would add nothing. #revive = (seen: Seen): Revive => async (node: unknown): Promise => { if (node === null || typeof node !== "object") return node; if (seen.has(node)) return seen.get(node); - // The class filters are mutually exclusive, so find the single matching - // reviver instead of running all of them against every node. const reviver = this.#classRevivers.find(([filter]) => filter(node)); - // Date / URL / RegExp and the like — devalue already handled them. + // devalue can handled non-container objects. if (reviver == null) return node; const [, init, set] = reviver; // @ts-ignore tsc faults @@ -165,21 +152,6 @@ type Seen = Map; /** Revives one node, sharing the per-decode {@link Seen} map via closure. */ type Revive = (node: unknown) => Promise; -/** - * One row of {@link TaskCodec.#classRevivers}: a type guard, a factory - * that makes the empty revived container, and a filler that walks the source - * into it using the supplied per-node {@link Revive}. `#reviveByClass` - * cannot annotate its parameter as `typeof this.#classRevivers[number]` - * because a `typeof` query on a private field does not parse, so this loose - * structural shape stands in; the `init` and `set` calls are reconciled with - * `@ts-ignore` at the call site. - */ -type ClassReviver = readonly [ - (value: unknown) => boolean, - (node: never) => unknown, - (revive: Revive, node: never, out: never) => Promise, -]; - type Container = | VocabHolder | Map From e8bd70dfda97ccb66eb05747ffde129b5d6b223b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 19:24:08 +0000 Subject: [PATCH 11/29] Remove *codec-fn.ts* @dahlia, the maintainer picks `TaskCodec` because it carries the loader state on the instance at [a comment](https://github.com/fedify-dev/fedify/pull/803#issuecomment-4684047851). Therefore remove the *codec-fn.ts*. `TaskCodecLoaders` moved to *codec.ts* because `TaskCodec` use it. --- .../src/federation/tasks/codec-fn.test.ts | 230 ------------------ .../fedify/src/federation/tasks/codec-fn.ts | 188 -------------- packages/fedify/src/federation/tasks/codec.ts | 15 +- packages/fedify/src/federation/tasks/mod.ts | 1 - 4 files changed, 14 insertions(+), 420 deletions(-) delete mode 100644 packages/fedify/src/federation/tasks/codec-fn.test.ts delete mode 100644 packages/fedify/src/federation/tasks/codec-fn.ts diff --git a/packages/fedify/src/federation/tasks/codec-fn.test.ts b/packages/fedify/src/federation/tasks/codec-fn.test.ts deleted file mode 100644 index 9cc4b9279..000000000 --- a/packages/fedify/src/federation/tasks/codec-fn.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { mockDocumentLoader, test } from "@fedify/fixture"; -import { Create, Link, Note, Person } from "@fedify/vocab"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; -import { - deserializeTaskData, - serializeTaskData, - validateTaskData, -} from "./codec-fn.ts"; - -const loaders = { - contextLoader: mockDocumentLoader, - documentLoader: mockDocumentLoader, -}; - -function makeSchema( - check: (data: unknown) => data is T, -): StandardSchemaV1 { - return { - "~standard": { - version: 1, - vendor: "fedify-test", - validate(value: unknown) { - return check(value) - ? { value } - : { issues: [{ message: "Invalid task data." }] }; - }, - }, - }; -} - -test("serializeTaskData() / deserializeTaskData()", async (t) => { - const note = new Note({ - id: new URL("https://example.com/notes/1"), - content: "Hello, world!", - }); - const person = new Person({ - id: new URL("https://example.com/users/alice"), - name: "Alice", - }); - const link = new Link({ - href: new URL("https://example.com/"), - mediaType: "text/html", - }); - const create = new Create({ - id: new URL("https://example.com/activities/1"), - actor: new URL("https://example.com/users/alice"), - object: note, - }); - - await t.step("round-trips a mixed payload", async () => { - const payload = { - note, - when: new Date("2026-01-02T03:04:05Z"), - big: 1234567890123456789n, - url: new URL("https://example.com/some/path"), - list: [person, link], - map: new Map([["create", create], ["n", 42]]), - set: new Set([1, 2, 3]), - nested: { create }, - }; - const encoded = await serializeTaskData(payload, mockDocumentLoader); - strictEqual(typeof encoded, "string"); - const decoded = await deserializeTaskData(encoded, loaders) as Record< - string, - unknown - >; - ok(decoded.note instanceof Note); - strictEqual(decoded.note.content?.toString(), "Hello, world!"); - strictEqual(decoded.note.id?.href, "https://example.com/notes/1"); - ok(decoded.when instanceof Date); - strictEqual(decoded.when.toISOString(), "2026-01-02T03:04:05.000Z"); - strictEqual(decoded.big, 1234567890123456789n); - ok(decoded.url instanceof URL); - strictEqual(decoded.url.href, "https://example.com/some/path"); - const list = decoded.list as unknown[]; - ok(list[0] instanceof Person); - strictEqual(list[0].name?.toString(), "Alice"); - ok(list[1] instanceof Link); - strictEqual(list[1].href?.href, "https://example.com/"); - const map = decoded.map as Map; - ok(map.get("create") instanceof Create); - strictEqual(map.get("n"), 42); - deepStrictEqual(decoded.set, new Set([1, 2, 3])); - const nested = decoded.nested as Record; - ok(nested.create instanceof Create); - const nestedObject = await nested.create.getObject({ - documentLoader: mockDocumentLoader, - contextLoader: mockDocumentLoader, - }); - ok(nestedObject instanceof Note); - strictEqual(nestedObject.content?.toString(), "Hello, world!"); - }); - - await t.step( - "encodes vocab objects in expand form (no @context)", - async () => { - const encoded = await serializeTaskData({ note }, mockDocumentLoader); - ok(!encoded.includes("@context")); - }, - ); - - await t.step("leaves a non-vocab payload untouched", async () => { - const payload = { - text: "plain", - n: 1, - flag: true, - nothing: null, - when: new Date("2026-06-10T00:00:00Z"), - list: [1, "two", 3n], - }; - const encoded = await serializeTaskData(payload, mockDocumentLoader); - const decoded = await deserializeTaskData(encoded, loaders); - deepStrictEqual(decoded, payload); - }); - - await t.step("throws on a malformed wire string", async () => { - // deserializeTaskData() throws synchronously on a malformed wire string - // (devalue's parse() runs before the first await); the async wrapper - // funnels both sync throws and rejections into one assertion. - await rejects(async () => await deserializeTaskData("garbage", loaders)); - }); - - await t.step("preserves circular and repeated references", async () => { - const shared = new Note({ content: "shared" }); - interface Cyclic { - name: string; - self?: Cyclic; - notes: Note[]; - } - const payload: Cyclic = { name: "root", notes: [shared, shared] }; - payload.self = payload; - const encoded = await serializeTaskData(payload, mockDocumentLoader); - const decoded = await deserializeTaskData(encoded, loaders) as Cyclic; - strictEqual(decoded.self, decoded); - ok(decoded.notes[0] instanceof Note); - strictEqual(decoded.notes[0], decoded.notes[1]); - strictEqual(decoded.notes[0].content?.toString(), "shared"); - }); - - await t.step("preserves a cycle through an array", async () => { - const list: unknown[] = ["head"]; - list.push(list); - const encoded = await serializeTaskData({ list }, mockDocumentLoader); - const decoded = await deserializeTaskData(encoded, loaders) as { - list: unknown[]; - }; - strictEqual(decoded.list[0], "head"); - strictEqual(decoded.list[1], decoded.list); - }); - - await t.step("preserves cycles re-entering at a Map and a Set", async () => { - // The cycle must re-enter at the Map/Set *itself* (not at a plain - // object) to exercise their pre-registration in the reviver. - const set = new Set(); - set.add({ set }); - const map = new Map(); - map.set("entry", { map }); - const encoded = await serializeTaskData({ set, map }, mockDocumentLoader); - const decoded = await deserializeTaskData(encoded, loaders) as { - set: Set<{ set: Set }>; - map: Map }>; - }; - const [member] = decoded.set; - strictEqual(member.set, decoded.set); - strictEqual(decoded.map.get("entry")?.map, decoded.map); - }); -}); - -test("validateTaskData()", async (t) => { - interface Envelope { - note: Note; - title: string; - } - const schema = makeSchema( - (data): data is Envelope => - typeof data === "object" && data != null && - (data as Envelope).note instanceof Note && - typeof (data as Envelope).title === "string", - ); - - await t.step("accepts a payload with a vocab instanceof leaf", async () => { - const payload = { - note: new Note({ content: "Hi" }), - title: "greeting", - }; - const validated = await validateTaskData(schema, payload); - deepStrictEqual(validated, payload); - }); - - await t.step("rejects a wrong-shaped payload", async () => { - await rejects( - () => validateTaskData(schema, { note: "not a Note", title: 42 }), - { name: "TypeError", message: /Task data failed schema validation/ }, - ); - }); - - await t.step("supports async validation", async () => { - const asyncSchema: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "fedify-test", - validate: (value: unknown) => - Promise.resolve( - typeof value === "number" - ? { value } - : { issues: [{ message: "not a number" }] }, - ), - }, - }; - strictEqual(await validateTaskData(asyncSchema, 42), 42); - await rejects(() => validateTaskData(asyncSchema, "nope")); - }); - - await t.step( - "round-trip then validate (same schema on both sides)", - async () => { - const payload = { - note: new Note({ content: "Hi" }), - title: "greeting", - }; - const encoded = await serializeTaskData(payload, mockDocumentLoader); - const decoded = await deserializeTaskData(encoded, loaders); - const validated = await validateTaskData(schema, decoded); - ok(validated.note instanceof Note); - strictEqual(validated.title, "greeting"); - strictEqual(validated.note.content?.toString(), "Hi"); - }, - ); -}); diff --git a/packages/fedify/src/federation/tasks/codec-fn.ts b/packages/fedify/src/federation/tasks/codec-fn.ts deleted file mode 100644 index 0f181d7fc..000000000 --- a/packages/fedify/src/federation/tasks/codec-fn.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Serializes custom-task payloads with [devalue], bridging Activity - * Vocabulary objects (`Note`, `Create`, `Person`, `Link`, and so on) through - * JSON-LD. - * - * Vocabulary objects keep their state in private fields, so devalue cannot - * serialize them directly. devalue's custom-type hook (a reducer on encode, - * a reviver on decode) carries each object as JSON-LD without writing a - * marker into the payload. Encoding uses the *expand* JSON-LD form, which - * has no `@context`, so decoding dereferences nothing and never touches the - * network. - * - * [devalue]: https://github.com/sveltejs/devalue - * - * @module - */ -import { Link, Object as APObject } from "@fedify/vocab"; -import type { DocumentLoader } from "@fedify/vocab-runtime"; -import type { TracerProvider } from "@opentelemetry/api"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { parse, stringifyAsync } from "devalue"; - -/** Which `fromJsonLd` entry point rebuilds a given vocabulary object. */ -type VocabKind = "object" | "link"; - -/** A vocabulary object reduced to its wire form: a kind tag plus JSON-LD. */ -interface VocabWire { - readonly kind: VocabKind; - readonly jsonLd: unknown; -} - -/** - * The loaders a worker {@link Context} already exposes; both decode passes - * use them. - * @internal - */ -export interface TaskCodecLoaders { - readonly contextLoader?: DocumentLoader; - readonly documentLoader?: DocumentLoader; - readonly tracerProvider?: TracerProvider; - readonly baseUrl?: URL; -} - -const isVocab = (value: unknown): value is APObject | Link => - value instanceof APObject || value instanceof Link; - -const isPlainObject = (value: unknown): value is Record => - value === null || typeof value !== "object" - ? false - : globalThis.Object.getPrototypeOf(value) === - globalThis.Object.prototype; - -/** Reduce a vocabulary object to expanded JSON-LD (no `@context`). */ -const vocabToJsonLd = async ( - value: APObject | Link, - contextLoader: DocumentLoader, -): Promise => ({ - kind: value instanceof Link ? "link" : "object", - jsonLd: await value.toJsonLd({ format: "expand", contextLoader }), -}); - -/** Rebuild a vocabulary object from its wire form. */ -const vocabFromJsonLd = ( - { kind, jsonLd }: VocabWire, - loaders: TaskCodecLoaders, -): Promise => - kind === "link" - ? Link.fromJsonLd(jsonLd, loaders) - : APObject.fromJsonLd(jsonLd, loaders); - -/** - * Encodes a task payload to a devalue string. - * - * The reducer is deliberately a plain function, not an `async` one: - * `stringifyAsync` treats a truthy return as a match and awaits it. An - * `async` reducer would return a promise for *every* node, which is always - * truthy, so it would "match" non-vocab values too. The plain - * `isVocab(v) && …` form returns a synchronous `false` for non-vocab nodes - * and the `toJsonLd()` promise only for vocab ones. - * - * @internal - */ -export const serializeTaskData = ( - data: unknown, - contextLoader: DocumentLoader, -): Promise => - stringifyAsync(data, { - Vocab: (value: unknown) => - isVocab(value) && vocabToJsonLd(value, contextLoader), - }); - -/** - * A vocabulary object parked by the synchronous decode reviver, held until - * the async {@link reviveVocab} pass can `fromJsonLd()` it back into an - * instance. - */ -class VocabHolder implements VocabWire { - constructor(readonly kind: VocabKind, readonly jsonLd: unknown) {} - static from = ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd); -} - -/** - * Second decode pass: replace every parked holder with a real instance. - * - * devalue preserves circular and repeated references, so the walker keeps - * a `seen` map from each visited container to its revived counterpart. - * Containers are registered *before* their contents are walked; a cycle - * therefore resolves to the (still-filling) revived container instead of - * recursing forever, and a repeated reference revives to the same instance. - */ -function reviveVocab( - loaders: TaskCodecLoaders, -): (node: unknown) => Promise { - const seen = new Map(); - return async function inner(node: unknown): Promise { - if (node === null || typeof node !== "object") return node; - if (seen.has(node)) return seen.get(node); - if (node instanceof VocabHolder) { - const revived = await vocabFromJsonLd(node, loaders); - seen.set(node, revived); - return revived; - } - if (Array.isArray(node)) { - const out: unknown[] = []; - seen.set(node, out); - out.push(...await Array.fromAsync(node, inner)); - return out; - } - if (node instanceof Map) { - const out = new Map(); - seen.set(node, out); - for (const [k, v] of node) out.set(await inner(k), await inner(v)); - return out; - } - if (node instanceof Set) { - const out = new Set(); - seen.set(node, out); - for (const v of await Array.fromAsync(node, inner)) out.add(v); - return out; - } - if (isPlainObject(node)) { - const out: Record = {}; - seen.set(node, out); - for (const [k, v] of globalThis.Object.entries(node)) { - out[k] = await inner(v); - } - return out; - } - return node; // Date / URL / RegExp and the like — devalue handled them - }; -} - -/** - * Decodes a devalue string back to a task payload. - * - * Two passes are unavoidable: `parse` revivers are synchronous while - * `fromJsonLd()` is async. The reviver only parks each object; - * {@link reviveVocab} then walks the result and awaits `fromJsonLd()`. - * - * @internal - */ -export const deserializeTaskData = ( - raw: string, - loaders: TaskCodecLoaders, -): Promise => - reviveVocab(loaders)( - parse(raw, { - Vocab: ({ kind, jsonLd }: VocabWire) => new VocabHolder(kind, jsonLd), - }), - ); - -/** - * Validates `data` through the vendor-agnostic - * [Standard Schema](https://standardschema.dev/) interface. - * @internal - */ -export const validateTaskData = async ( - schema: S, - data: unknown, -): Promise> => { - const result = await schema["~standard"].validate(data); - if (result.issues) { - throw new TypeError( - `Task data failed schema validation: ${JSON.stringify(result.issues)}`, - ); - } - return result.value; -}; diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index 0681ce444..6f5b0f005 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -1,7 +1,8 @@ import { Link, Object as APObject } from "@fedify/vocab"; +import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { TracerProvider } from "@opentelemetry/api"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import { parse, stringifyAsync } from "devalue"; -import type { TaskCodecLoaders } from "./codec-fn.ts"; export default class TaskCodec { constructor(readonly options: TaskCodecLoaders) {} @@ -128,6 +129,18 @@ function assertSchema( } } +/** + * The loaders a worker {@link Context} already exposes; both decode passes + * use them. + * @internal + */ +interface TaskCodecLoaders { + readonly contextLoader?: DocumentLoader; + readonly documentLoader?: DocumentLoader; + readonly tracerProvider?: TracerProvider; + readonly baseUrl?: URL; +} + /** Which `fromJsonLd` entry point rebuilds a given vocabulary object. */ type VocabKind = "object" | "link"; diff --git a/packages/fedify/src/federation/tasks/mod.ts b/packages/fedify/src/federation/tasks/mod.ts index a40b57bd3..107e0e445 100644 --- a/packages/fedify/src/federation/tasks/mod.ts +++ b/packages/fedify/src/federation/tasks/mod.ts @@ -6,6 +6,5 @@ * * @module */ -export * from "./codec-fn.ts"; export { default as TaskCodec } from "./codec.ts"; export * from "./task.ts"; From d88a5a1fdae5368a4185d2bc93c7164b6487a57d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 19:57:24 +0000 Subject: [PATCH 12/29] Document the idempotent-validation requirement for task schemas The task payload schema validates on both sides of the queue: at enqueue time and again at dequeue time. The wire therefore carries the validated *output*, which the same schema must re-accept as input, so transforming schemas (e.g., Zod's .transform()) whose output differs in shape from their input cannot round-trip. This constraint was neither documented nor tested; state it in the manual and the schema option's JSDoc, and pin it with a regression test. https://github.com/fedify-dev/fedify/pull/803 Assisted-by: Claude Code:claude-fable-5 --- docs/manual/tasks.md | 6 +++++ .../fedify/src/federation/tasks/codec.test.ts | 23 +++++++++++++++++++ packages/fedify/src/federation/tasks/task.ts | 5 +++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md index dd52e1ef4..2c266ee1b 100644 --- a/docs/manual/tasks.md +++ b/docs/manual/tasks.md @@ -91,6 +91,12 @@ that an older deployment enqueued. A payload that fails dequeue-time validation (or cannot be decoded at all) is dropped with an error log rather than retried, because retrying cannot make it valid. +Because the same schema validates on both sides of the queue, its validation +must be *idempotent*: the validated output must itself be a valid input. +Transforming schemas (e.g., Zod's `.transform()`) whose output differs in +shape from their input are not supported—the payload type is inferred as the +schema's *output*, so the call site already fails validation at enqueue time. + [devalue]: https://github.com/sveltejs/devalue diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index d45d39ea1..ca0c65f19 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -295,4 +295,27 @@ test("TaskCodec.encode() / decode()", async (t) => { ); }, ); + + await t.step( + "a non-idempotent (transforming) schema fails to round-trip", + async () => { + // Validation must be idempotent: the wire carries the validated + // output, which the same schema re-validates as input at dequeue. + const transforming: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "string" + ? { value: value.length } + : { issues: [{ message: "Expected a string." }] }, + }, + }; + const wire = await codec.encode(transforming, "hello"); + await rejects( + () => codec.decode(transforming, wire), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + }, + ); }); diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts index 1318d647b..f7012f5f0 100644 --- a/packages/fedify/src/federation/tasks/task.ts +++ b/packages/fedify/src/federation/tasks/task.ts @@ -34,7 +34,10 @@ export interface TaskDefinitionOptions< * * The payload is validated twice: once at enqueue time (fail fast) and * once at dequeue time (drift protection against payloads enqueued by an - * older deployment). + * older deployment). Because the same schema runs on both sides, its + * validation must be idempotent: the validated output must itself be + * a valid input. Transforming schemas (e.g., Zod's `.transform()`) whose + * output differs in shape from their input are not supported. */ readonly schema: TSchema; From 3a924f970c2994d373586cb5fea749ec029e9bb0 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 20:02:10 +0000 Subject: [PATCH 13/29] Reject enqueuing a task unknown to the federation resolveTaskQueue() returns the fallback queue even for a task name with no registered definition, so enqueuing a handle created by a different federation instance silently succeeded and the worker later dropped the message with only a warning. The task API's contract is to fail fast at the enqueue call site (it already validates the payload there), so check the registry before resolving a queue and throw a TypeError instead. https://github.com/fedify-dev/fedify/pull/803 Assisted-by: Claude Code:claude-fable-5 --- packages/fedify/src/federation/context.ts | 6 ++-- packages/fedify/src/federation/middleware.ts | 10 +++++++ .../fedify/src/federation/tasks/tasks.test.ts | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index 8375e6a76..a7a629c7c 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -446,7 +446,8 @@ export interface Context { * @param data The task payload. It is validated against the task's * schema before being enqueued. * @param options Options for enqueuing the task. - * @throws {TypeError} If no message queue is configured for tasks, or if + * @throws {TypeError} If the task is not defined on this federation, + * if no message queue is configured for tasks, or if * the payload fails schema validation. * @since 2.3.0 */ @@ -466,7 +467,8 @@ export interface Context { * @param payloads The task payloads. Each is validated against the * task's schema before being enqueued. * @param options Options for enqueuing the tasks. - * @throws {TypeError} If no message queue is configured for tasks, or if + * @throws {TypeError} If the task is not defined on this federation, + * if no message queue is configured for tasks, or if * a payload fails schema validation. * @since 2.3.0 */ diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index c10e97129..027523ef1 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3663,6 +3663,16 @@ export class ContextImpl implements Context { items: readonly TData[], options: TaskEnqueueOptions, ): Promise { + // Fail fast on a handle from another federation instance; without this + // check the message would enqueue fine and be dropped by the worker. + if (!this.federation.taskDefinitions.has(task.name)) { + throw new TypeError( + `Task ${ + JSON.stringify(task.name) + } is not defined on this federation; ` + + "pass a handle returned by its defineTask().", + ); + } const queue = this.federation.resolveTaskQueue(task.name); if (queue == null) { throw new TypeError( diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index cbaca519b..7a4bba48f 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -300,6 +300,34 @@ test("Context.enqueueTask() end-to-end", async (t) => { strictEqual(queue.enqueued.length, 0); }); + await t.step( + "rejects a handle from another federation at enqueue", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const other = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + const foreignTask = other.defineTask("foreign", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(foreignTask, "data"), + { name: "TypeError", message: /is not defined on this federation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }, + ); + await t.step("passes delay and orderingKey through", async () => { const queue = new MockQueue(); const federation = createFederation({ From a6f9137ced341715e1fc2c5a06e8b351cde8baca Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 20:05:01 +0000 Subject: [PATCH 14/29] Polish task enqueue path and queue-isolation docs Small follow-ups from review: - Document that tasks must be defined before startQueue() (or the first request); workers for dedicated per-task queues are only registered when the queue machinery starts, so a queue defined later never gets a worker. - Return early from the enqueue path when no payloads are given, instead of reaching enqueueMany()/Promise.all with an empty batch, whose backend behavior is undefined. - Rename #enqueueSingular to #encodeTaskMessage; it encodes and builds a TaskMessage but does not enqueue anything. - Fix a comment typo in the codec. https://github.com/fedify-dev/fedify/pull/803 Assisted-by: Claude Code:claude-fable-5 --- docs/manual/tasks.md | 6 +++++ packages/fedify/src/federation/middleware.ts | 5 +++-- packages/fedify/src/federation/tasks/codec.ts | 2 +- .../fedify/src/federation/tasks/tasks.test.ts | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md index 2c266ee1b..cde246b5c 100644 --- a/docs/manual/tasks.md +++ b/docs/manual/tasks.md @@ -215,6 +215,12 @@ const transcodeVideo = federation.defineTask("transcodeVideo", { }); ~~~~ +Workers for dedicated per-task queues are registered when the queue +machinery starts, so define every task before `~Federation.startQueue()` +is called (or, without `~FederationOptions.manuallyStartQueue`, before the +first request is handled); a per-task queue defined later never gets +a worker. + The queue for a task is resolved in order: the per-task `queue`, then the federation's `task` queue, then the outbox queue. Deployments that must *not* silently share the outbox queue can opt out of the last step with diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 027523ef1..39259a0d4 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3680,6 +3680,7 @@ export class ContextImpl implements Context { "createFederation() or to defineTask().", ); } + if (items.length < 1) return; const delay = options.delay == null ? undefined : Temporal.Duration.from(options.delay); @@ -3688,7 +3689,7 @@ export class ContextImpl implements Context { // `map` preserves order, and a rejected encode (validation failure) rejects // the whole batch before anything is enqueued, keeping fail-fast intact. const messages: TaskMessage[] = await Promise.all( - items.map(this.#enqueueSingular(task, options)), + items.map(this.#encodeTaskMessage(task, options)), ); const enqueueOptions = { delay, orderingKey: options.orderingKey }; if (messages.length === 1) { @@ -3700,7 +3701,7 @@ export class ContextImpl implements Context { } } - #enqueueSingular = ( + #encodeTaskMessage = ( task: TaskDefinition, options: TaskEnqueueOptions, ) => diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index 6f5b0f005..d0620abb2 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -43,7 +43,7 @@ export default class TaskCodec { if (node === null || typeof node !== "object") return node; if (seen.has(node)) return seen.get(node); const reviver = this.#classRevivers.find(([filter]) => filter(node)); - // devalue can handled non-container objects. + // devalue can handle non-container objects. if (reviver == null) return node; const [, init, set] = reviver; // @ts-ignore tsc faults diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 7a4bba48f..e7582766e 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -399,6 +399,28 @@ test("Context.enqueueTask() end-to-end", async (t) => { strictEqual(queue.enqueued.length, 2); }, ); + + await t.step( + "enqueueTaskMany() with no payloads touches no queue", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-empty", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, []); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 0); + }, + ); }); test("task queue routing", async (t) => { From 39e6ec796f72fd51a11b940a2a0be299f3765d12 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 21:37:06 +0000 Subject: [PATCH 15/29] Type the task codec's reviver dispatch The revival dispatch pulled init/set out of a heterogeneous tuple list, losing the correlation between each tuple's filter and its init/set node type, which forced two @ts-ignore suppressions at the call site. Such suppressions hide any future error on those lines, so they are unfit for a permanent implementation. Each entry is now built by a generic classReviver() factory whose single type parameter ties the filter to its init/set, letting the compiler check the calls it previously could not. Also bind the recursive reviver to one inner closure per decode pass instead of allocating a fresh closure on every dispatch. https://github.com/fedify-dev/fedify/pull/803#issuecomment-4683028319 Assisted-by: Claude Code:claude-fable-5 --- packages/fedify/src/federation/tasks/codec.ts | 105 ++++++++++-------- 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index d0620abb2..b93366c0b 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -39,65 +39,60 @@ export default class TaskCodec { jsonLd: await value.toJsonLd({ format: "expand", ...this.options }), }); - #revive = (seen: Seen): Revive => async (node: unknown): Promise => { - if (node === null || typeof node !== "object") return node; - if (seen.has(node)) return seen.get(node); - const reviver = this.#classRevivers.find(([filter]) => filter(node)); - // devalue can handle non-container objects. - if (reviver == null) return node; - const [, init, set] = reviver; - // @ts-ignore tsc faults - const out: Revived = await init(node); - seen.set(node, out); - // @ts-ignore tsc faults - await set(this.#revive(seen), node, out); - return out; + #revive = (seen: Seen): Revive => { + const inner: Revive = async (node) => { + if (node === null || typeof node !== "object") return node; + if (seen.has(node)) return seen.get(node); + for (const reviver of this.#classRevivers) { + const out = reviver(seen, inner, node); + if (out !== undefined) return await out; + } + // devalue can handle non-container objects. + return node; + }; + return inner; }; - #classRevivers = [ - [ + #classRevivers: readonly ClassReviver[] = [ + classReviver( isInstanceOf(VocabHolder), - ({ kind, jsonLd }: VocabWire): Promise => + ({ kind, jsonLd }): Promise => kind === "link" ? Link.fromJsonLd(jsonLd, this.options) : APObject.fromJsonLd(jsonLd, this.options), - () => Promise.resolve(), - ], - [ + () => {}, + ), + classReviver( isInstanceOf(Array), (): unknown[] => [], - async (revive: Revive, node: unknown[], arr: typeof node) => { + async (revive, node, arr) => { arr.push(...await Array.fromAsync(node, revive)); }, - ], - [ + ), + classReviver( isInstanceOf(Map), - () => new Map(), - async (revive: Revive, node: Map, map: typeof node) => { + () => new Map(), + async (revive, node, map) => { for (const [k, v] of node) map.set(await revive(k), await revive(v)); }, - ], - [ + ), + classReviver( isInstanceOf(Set), - () => new Set(), - async (revive: Revive, node: Set, set: typeof node) => { + () => new Set(), + async (revive, node, set) => { for (const v of await Array.fromAsync(node, revive)) set.add(v); }, - ], - [ + ), + classReviver( isPlainObject, - () => ({}), - async ( - revive: Revive, - node: Record, - obj: typeof node, - ) => { + (): Record => ({}), + async (revive, node, obj) => { for (const [k, v] of globalThis.Object.entries(node)) { obj[k] = await revive(v); } }, - ], - ] as const; + ), + ]; } const isVocab = (value: unknown): value is APObject | Link => @@ -165,12 +160,32 @@ type Seen = Map; /** Revives one node, sharing the per-decode {@link Seen} map via closure. */ type Revive = (node: unknown) => Promise; -type Container = - | VocabHolder - | Map - | Set - | Array - | Record; -type Revived = Exclude | APObject | Link; +/** Revives one matched container, or `undefined` when the node isn't its kind. */ +type ClassReviver = ( + seen: Seen, + revive: Revive, + node: object, +) => Promise | undefined; + +/** + * Ties a container filter to its empty-shell `init` and child-filling `set` + * through one type parameter—a correlation the heterogeneous reviver list + * cannot carry, which previously forced `@ts-ignore` at the dispatch site. + */ +const classReviver = ( + filter: (v: unknown) => v is TNode, + init: (node: TNode) => TOut | Promise, + set: (revive: Revive, node: TNode, out: TOut) => void | Promise, +): ClassReviver => +(seen, revive, node) => { + if (!filter(node)) return undefined; + return (async () => { + const out = await init(node); + seen.set(node, out); + await set(revive, node, out); + return out; + })(); +}; + // deno-lint-ignore no-explicit-any type Constructor = new (...arg: any[]) => T; From 5237e133c1b0ab4d06f07c7240b526a8941d5151 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 21:38:44 +0000 Subject: [PATCH 16/29] Hide the task handle's phantom context-data marker The __contextData phantom field binds a TaskDefinition handle to its federation's context data type, but as a string-keyed property it leaked into user-facing docs and IDE completions despite its @internal tag. Replace it with a module-private unique symbol key: no value exists at runtime, the marker disappears from completions, and cross-federation handle rejection still type-checks, now guarded by a regression test. Also replace the tasks barrel's wildcard re-export of task.ts with explicit named exports of the six types its consumers actually use, so nothing new falls through the barrel unnoticed. https://github.com/fedify-dev/fedify/pull/803#issuecomment-4683028319 Assisted-by: Claude Code:claude-fable-5 --- packages/fedify/src/federation/tasks/mod.ts | 9 ++++++++- packages/fedify/src/federation/tasks/task.ts | 9 ++++++++- packages/fedify/src/federation/tasks/tasks.test.ts | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/tasks/mod.ts b/packages/fedify/src/federation/tasks/mod.ts index 107e0e445..e151fa576 100644 --- a/packages/fedify/src/federation/tasks/mod.ts +++ b/packages/fedify/src/federation/tasks/mod.ts @@ -7,4 +7,11 @@ * @module */ export { default as TaskCodec } from "./codec.ts"; -export * from "./task.ts"; +export type { + TaskDefinition, + TaskDefinitionInternal, + TaskDefinitionOptions, + TaskEnqueueOptions, + TaskHandler, + TaskRegistry, +} from "./task.ts"; diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts index f7012f5f0..836e4802f 100644 --- a/packages/fedify/src/federation/tasks/task.ts +++ b/packages/fedify/src/federation/tasks/task.ts @@ -77,6 +77,13 @@ export interface TaskDefinitionOptions< readonly queue?: MessageQueue; } +/** + * Phantom key binding a {@link TaskDefinition} to its federation's context + * data type. Declared only—no value exists at runtime, and the symbol is + * not exported, so the marker stays out of user-facing completions. + */ +declare const contextDataBrand: unique symbol; + /** * The handle returned by {@link TaskRegistry.defineTask}. It carries the * task name and schema so that {@link Context.enqueueTask} can validate the @@ -101,7 +108,7 @@ export interface TaskDefinition { /** * @internal Phantom marker binding the handle to its federation. */ - readonly __contextData?: TContextData; + readonly [contextDataBrand]?: TContextData; } /** diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index e7582766e..5f366b4b8 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -235,6 +235,14 @@ test("task type-level guards", () => { // @ts-expect-error: a wrong-shaped payload must not type-check. return ctx.enqueueTask(task, { n: "not a number" }); }; + const _crossContextHandleIsACompileError = ( + ctx: Context, + task: TaskDefinition<{ tenant: string }, { n: number }>, + ) => { + // @ts-expect-error: a handle bound to different context data must not + // type-check. + return ctx.enqueueTask(task, { n: 1 }); + }; }); test("Context.enqueueTask() end-to-end", async (t) => { From 40a994896f8397961a043816a3311cd4bbd47e05 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 11 Jun 2026 22:11:58 +0000 Subject: [PATCH 17/29] Pin deep-nesting support in the task codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated reviewers keep proposing a fixed recursion depth cap (~100) in TaskCodec's #revive to guard against stack overflow from deeply nested payloads. The concern does not apply: the revive traversal suspends at an await on every level, so nesting depth consumes heap (promise chains) rather than native stack, and a structure deep enough to threaten the stack would fail inside devalue.parse() before #revive ever ran. A cap would only reject legitimate payloads. Add a regression test that round-trips a payload nested 1,000 levels deep—an order of magnitude above any proposed cap—through alternating objects and arrays down to a vocab leaf, so introducing such a cap now fails the suite. https://github.com/fedify-dev/fedify/pull/803#discussion_r3399383165 Assisted-by: Claude Code:claude-fable-5 --- .../fedify/src/federation/tasks/codec.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index ca0c65f19..b194cda6c 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -172,6 +172,31 @@ test("TaskCodec (fresh instance per operation)", async (t) => { strictEqual(decoded.wrap.note.content?.toString(), "Hello, world!"); }, ); + + await t.step( + "revives a payload nested far deeper than any fixed depth cap", + async () => { + // `#revive` suspends at an `await` on every level, so nesting depth + // consumes heap (promise chains) rather than native stack—deep + // payloads cannot overflow it, and a fixed depth cap would only + // reject legitimate data. Pins a depth an order of magnitude above + // any such cap. + const depth = 1000; + let payload: unknown = new Note({ content: "deep" }); + for (let i = 0; i < depth; i++) { + payload = i % 2 === 0 ? { inner: payload } : [payload]; + } + const encoded = await codec.serialize(payload); + let decoded = await codec.deserialize(encoded); + for (let i = depth - 1; i >= 0; i--) { + decoded = i % 2 === 0 + ? (decoded as { inner: unknown }).inner + : (decoded as unknown[])[0]; + } + ok(decoded instanceof Note); + strictEqual(decoded.content?.toString(), "deep"); + }, + ); }); test("TaskCodec (one instance reused across decodes)", async (t) => { From 14313a1059f44e45743788c6853f69039804abfa Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 05:11:23 +0000 Subject: [PATCH 18/29] Reject foreign task handles by identity, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enqueue guard only checked that the handle's task name existed in the local registry, so once two federation instances defined the same task name, a handle from the other instance slipped through: the local context encoded the payload under the schema carried by the foreign handle while the worker decoded it under the local definition's schema. A payload the local schema would have rejected at enqueue thus landed in the queue anyway, only to be dropped at decode time—defeating the fail-fast purpose the guard exists for. defineTask() now stores the exact handle object it returns alongside the internal definition, and enqueueTask()/enqueueTaskMany() compare that handle by identity. Handles still work on every federation built from the same builder, since build() shares the stored definitions. The cross-federation regression test now covers the same-name case in addition to the undefined-name case. https://github.com/fedify-dev/fedify/pull/803#discussion_r3399385300 Assisted-by: Claude Code:claude-fable-5 --- packages/fedify/src/federation/builder.ts | 7 ++- packages/fedify/src/federation/middleware.ts | 7 ++- packages/fedify/src/federation/tasks/task.ts | 7 +++ .../fedify/src/federation/tasks/tasks.test.ts | 45 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/builder.ts b/packages/fedify/src/federation/builder.ts index 59460f3f9..e1c35b6fc 100644 --- a/packages/fedify/src/federation/builder.ts +++ b/packages/fedify/src/federation/builder.ts @@ -610,16 +610,21 @@ export class FederationBuilderImpl if (this.taskDefinitions.has(name)) { throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); } + const handle: TaskDefinition< + TContextData, + StandardSchemaV1.InferOutput + > = { name, schema: options.schema }; this.taskDefinitions.set(name, { name, schema: options.schema, + handle, handler: options.handler as TaskHandler, onError: options .onError as TaskDefinitionInternal["onError"], retryPolicy: options.retryPolicy, queue: options.queue, }); - return { name, schema: options.schema }; + return handle; } /** diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 39259a0d4..60a305479 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3665,7 +3665,12 @@ export class ContextImpl implements Context { ): Promise { // Fail fast on a handle from another federation instance; without this // check the message would enqueue fine and be dropped by the worker. - if (!this.federation.taskDefinitions.has(task.name)) { + // Compare the registered handle by identity, not just the name: another + // instance may define the same task name with a different schema, and + // its handle would otherwise encode under that foreign schema here + // while the worker decodes under the local one. + const def = this.federation.taskDefinitions.get(task.name); + if (def == null || def.handle !== task) { throw new TypeError( `Task ${ JSON.stringify(task.name) diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts index 836e4802f..a11ba5f9a 100644 --- a/packages/fedify/src/federation/tasks/task.ts +++ b/packages/fedify/src/federation/tasks/task.ts @@ -168,6 +168,13 @@ export interface TaskEnqueueOptions { export interface TaskDefinitionInternal { readonly name: string; readonly schema: StandardSchemaV1; + /** + * The exact handle object {@link TaskRegistry.defineTask} returned for + * this definition. {@link Context.enqueueTask} compares it by identity: + * another federation instance may define the same task name with a + * different schema, so name lookup alone cannot tell its handle apart. + */ + readonly handle: TaskDefinition; readonly handler: TaskHandler; readonly retryPolicy?: RetryPolicy; readonly onError?: ( diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 5f366b4b8..8ce9bf15f 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -336,6 +336,51 @@ test("Context.enqueueTask() end-to-end", async (t) => { }, ); + await t.step( + "rejects a same-named handle from another federation", + async () => { + // Name lookup alone cannot tell a foreign handle apart once both + // instances define the same task name: the local context would + // encode under the *schema carried by the foreign handle*, so a + // payload the local schema rejects would enqueue anyway, only to be + // dropped by the worker decoding under the local schema. Both + // instances share TContextData = void, so the phantom-brand check + // cannot reject this at compile time; the handle-identity guard is + // the only defense. + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + queue: { task: queue }, + }); + let called = 0; + federation.defineTask("rename", { + schema: numberSchema, // the local "rename" takes a number… + handler: () => { + called++; + }, + }); + const other = createFederation({ + ...baseOptions, + queue: { task: new MockQueue() }, + }); + // …while the other instance's "rename" takes a string: + const foreignTask = other.defineTask("rename", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + () => ctx.enqueueTask(foreignTask, "not a number"), + { name: "TypeError", message: /is not defined on this federation/ }, + ); + strictEqual(queue.enqueued.length, 0); + strictEqual(called, 0); + }, + ); + await t.step("passes delay and orderingKey through", async () => { const queue = new MockQueue(); const federation = createFederation({ From e4f1880be832e52f4c6380dc7fda01a2b221c28d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 05:47:07 +0000 Subject: [PATCH 19/29] Validate task payloads in the testing mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockContext.enqueueTask() invoked the handler with the raw input, while production enqueueTask() validates the payload against the task schema and hands the validated output to the handler. Tests written against @fedify/testing therefore accepted payloads that production rejects at enqueue, and observed the raw input rather than the coerced or normalized value a transforming schema produces—masking integration bugs the mock exists to surface. The mock now runs the registered schema's Standard Schema validator before invoking the handler, throwing the same TypeError production throws on failure and passing the validated output through. enqueueTaskMany() inherits this since it delegates to enqueueTask(). Added tests covering a rejected payload, a coercing schema whose validated output reaches the handler, and per-item validation in the batch path. https://github.com/fedify-dev/fedify/pull/803#discussion_r3399385307 Assisted-by: Claude Code:claude-fable-5 --- packages/testing/package.json | 1 + packages/testing/src/mock.test.ts | 87 +++++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 12 ++++- pnpm-lock.yaml | 3 ++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/testing/package.json b/packages/testing/package.json index 6ab038136..b83458a88 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -58,6 +58,7 @@ "devDependencies": { "@fedify/fixture": "workspace:^", "@js-temporal/polyfill": "catalog:", + "@standard-schema/spec": "catalog:", "@std/assert": "catalog:", "@std/async": "catalog:", "tsdown": "catalog:", diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index 9f2fcd85e..cd14c343c 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1,5 +1,6 @@ import type { InboxContext, OutboxContext } from "@fedify/fedify/federation"; import { signJsonLd } from "@fedify/fedify/sig"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; import { mockDocumentLoader, test } from "@fedify/fixture"; import { Activity, @@ -1711,3 +1712,89 @@ test("MockContext.getActorKeyPairs() returns empty array when no dispatcher regi const keyPairs = await context.getActorKeyPairs("alice"); assertEquals(keyPairs, []); }); + +// A Standard Schema that only accepts numbers; mirrors a strict task schema. +const numberSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "number" + ? { value } + : { issues: [{ message: "Expected a number." }] }, + }, +}; + +test("MockContext.enqueueTask rejects a payload the schema refuses", async () => { + const federation = createFederation(); + let called = 0; + const task = federation.defineTask("count", { + schema: numberSchema, + handler: () => { + called++; + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await assertRejects( + () => context.enqueueTask(task, "not a number" as unknown as number), + TypeError, + "Task data failed schema validation", + ); + assertEquals(called, 0); // production fails fast at enqueue; so must the mock +}); + +test("MockContext.enqueueTask passes the schema's validated output to the handler", async () => { + const federation = createFederation(); + // A coercing schema: uppercases the string. Input and output share the + // same type, but the validated value differs from the raw input. + const upper: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + typeof value === "string" + ? { value: value.toUpperCase() } + : { issues: [{ message: "Expected a string." }] }, + }, + }; + let received = "UNSET"; + const task = federation.defineTask("shout", { + schema: upper, + handler: (_ctx, data) => { + received = data; + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await context.enqueueTask(task, "hi"); + // The handler observes the validated output, not the raw "hi". + assertEquals(received, "HI"); +}); + +test("MockContext.enqueueTaskMany validates every payload", async () => { + const federation = createFederation(); + const seen: number[] = []; + const task = federation.defineTask("count-many", { + schema: numberSchema, + handler: (_ctx, data) => { + seen.push(data); + }, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + // The second item is invalid: the batch rejects and the first item, which + // ran before it, is the only one the handler saw. + await assertRejects( + () => context.enqueueTaskMany(task, [1, "two" as unknown as number]), + TypeError, + "Task data failed schema validation", + ); + assertEquals(seen, [1]); +}); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 74cfe1a0d..6c1101fa2 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -977,7 +977,17 @@ class MockContext implements Context { if (def == null) { throw new TypeError(`Task ${JSON.stringify(task.name)} is not defined.`); } - await def.handler(this, data); + // Mirror production: validate against the schema and hand the *validated* + // output to the handler. Without this, the mock would accept payloads + // that production rejects at enqueue, and a normalizing schema's output + // (defaults, coercions) would differ between tests and production. + const result = await def.schema["~standard"].validate(data); + if (result.issues != null && result.issues.length > 0) { + throw new TypeError( + `Task data failed schema validation: ${JSON.stringify(result.issues)}`, + ); + } + await def.handler(this, result.value); } async enqueueTaskMany( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c3b95cf..f0dea9e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1674,6 +1674,9 @@ importers: '@js-temporal/polyfill': specifier: 'catalog:' version: 0.5.1 + '@standard-schema/spec': + specifier: 'catalog:' + version: 1.1.0 '@std/assert': specifier: 'catalog:' version: '@jsr/std__assert@1.0.13' From 23fc26384c9565075906968c911aa701a8ff09c9 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 06:06:18 +0000 Subject: [PATCH 20/29] Hoist @standard-schema/spec to the workspace root The @standard-schema/spec import is shared by the fedify and testing packages, so it belongs at the workspace level rather than being declared per package. The root deno.json already lists it and workspace members inherit the root import map, making the copy in the fedify package's deno.json redundant; drop it. The pnpm side already sources the version from the catalog in pnpm-workspace.yaml, with each package.json referencing it as "catalog:". Assisted-by: Claude Code:claude-fable-5 --- deno.lock | 1 - packages/fedify/deno.json | 1 - 2 files changed, 2 deletions(-) diff --git a/deno.lock b/deno.lock index 2f1e29cc8..99bbf6f5b 100644 --- a/deno.lock +++ b/deno.lock @@ -9463,7 +9463,6 @@ }, "packages/fedify": { "dependencies": [ - "jsr:@standard-schema/spec@^1.1.0", "jsr:@std/assert@0.226", "jsr:@std/url@~0.225.1", "npm:@multiformats/base-x@^4.0.1", diff --git a/packages/fedify/deno.json b/packages/fedify/deno.json index 5cbf69826..c15bff077 100644 --- a/packages/fedify/deno.json +++ b/packages/fedify/deno.json @@ -15,7 +15,6 @@ }, "imports": { "@multiformats/base-x": "npm:@multiformats/base-x@^4.0.1", - "@standard-schema/spec": "jsr:@standard-schema/spec@^1.1.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/url": "jsr:@std/url@^0.225.1", "devalue": "npm:devalue@^5.8.1", From bccbd9fad7e5ca55a4034e606440edda7ebad77d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 08:04:19 +0000 Subject: [PATCH 21/29] Start the queue worker on custom task enqueue Every other enqueue path (inbox, outbox, fanout, forwarding) calls _startQueueInternal() right before enqueuing unless manuallyStartQueue is set, but #enqueueTasks did not. An application that only uses the custom task API never sends an activity, so with the default configuration its first enqueueTask() accepted the message while no worker ever listened: tasks piled up in the queue unprocessed until startQueue() was called explicitly or an activity happened to be sent. Add the same guard to #enqueueTasks, plus a regression test asserting that the first enqueue starts the task worker exactly once and that a second enqueue does not start another listener. https://github.com/fedify-dev/fedify/pull/803#discussion_r3401351190 Assisted-by: Claude Code:claude-fable-5 --- packages/fedify/src/federation/middleware.ts | 3 ++ .../fedify/src/federation/tasks/tasks.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 60a305479..2c4f5ccd8 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -3696,6 +3696,9 @@ export class ContextImpl implements Context { const messages: TaskMessage[] = await Promise.all( items.map(this.#encodeTaskMessage(task, options)), ); + if (!this.federation.manuallyStartQueue) { + this.federation._startQueueInternal(this.data); + } const enqueueOptions = { delay, orderingKey: options.orderingKey }; if (messages.length === 1) { await queue.enqueue(messages[0], enqueueOptions); diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 8ce9bf15f..87477c336 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -308,6 +308,35 @@ test("Context.enqueueTask() end-to-end", async (t) => { strictEqual(queue.enqueued.length, 0); }); + await t.step( + "starts the task worker on first enqueue without startQueue()", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...baseOptions, + manuallyStartQueue: false, + queue: { task: queue }, + }); + const task = federation.defineTask("auto-start", { + schema: stringSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + // An app that only uses the custom task API never sends an activity, + // so enqueueTask() itself must start the worker like the other + // enqueue paths do; otherwise tasks pile up unprocessed forever. + await ctx.enqueueTask(task, "first"); + strictEqual(queue.listenCount, 1); + // The started flag keeps a second enqueue from re-listening. + await ctx.enqueueTask(task, "second"); + strictEqual(queue.listenCount, 1); + strictEqual(queue.enqueued.length, 2); + }, + ); + await t.step( "rejects a handle from another federation at enqueue", async () => { From 0e31bdddca4aec0fc67b688091d83787830287b7 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 08:15:19 +0000 Subject: [PATCH 22/29] Vet the whole mock task batch before any handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production's enqueueTaskMany() validates and encodes every payload with Promise.all() before enqueuing anything, so a batch with one invalid item rejects with no effect. The mock looped enqueueTask() per item instead, invoking handlers for earlier payloads before a later one failed validation—tests could observe a partial processing state that cannot occur in production. Split the definition lookup and the schema validation out of enqueueTask() into helpers, and make enqueueTaskMany() validate the whole batch up front, running handlers only once every payload has passed. The existing batch-validation test now pins that no handler runs at all when the batch rejects. https://github.com/fedify-dev/fedify/pull/803#discussion_r3401351204 https://github.com/fedify-dev/fedify/pull/803#discussion_r3401416242 Assisted-by: Claude Code:claude-fable-5 --- packages/testing/src/mock.test.ts | 9 ++++---- packages/testing/src/mock.ts | 35 ++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index cd14c343c..eaf07c4e8 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1776,7 +1776,7 @@ test("MockContext.enqueueTask passes the schema's validated output to the handle assertEquals(received, "HI"); }); -test("MockContext.enqueueTaskMany validates every payload", async () => { +test("MockContext.enqueueTaskMany validates the whole batch before any handler runs", async () => { const federation = createFederation(); const seen: number[] = []; const task = federation.defineTask("count-many", { @@ -1789,12 +1789,13 @@ test("MockContext.enqueueTaskMany validates every payload", async () => { new URL("https://example.com"), undefined, ); - // The second item is invalid: the batch rejects and the first item, which - // ran before it, is the only one the handler saw. + // The second item is invalid: production validates every payload before + // enqueuing anything, so the whole batch rejects with no effect. The + // mock must not let the first handler run before the batch is vetted. await assertRejects( () => context.enqueueTaskMany(task, [1, "two" as unknown as number]), TypeError, "Task data failed schema validation", ); - assertEquals(seen, [1]); + assertEquals(seen, []); }); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 6c1101fa2..24dfb9636 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -967,9 +967,7 @@ class MockContext implements Context { return Promise.resolve(null); } - // No queue in mock type: the task handler is invoked immediately, - // mirroring how processQueuedTask() processes immediately. - async enqueueTask(task: any, data: any, _options?: any): Promise { + #resolveTaskDefinition(task: any): any { if (!(this.federation instanceof MockFederation)) { throw new TypeError("No task definitions are available."); } @@ -977,25 +975,42 @@ class MockContext implements Context { if (def == null) { throw new TypeError(`Task ${JSON.stringify(task.name)} is not defined.`); } - // Mirror production: validate against the schema and hand the *validated* - // output to the handler. Without this, the mock would accept payloads - // that production rejects at enqueue, and a normalizing schema's output - // (defaults, coercions) would differ between tests and production. + return def; + } + + // Mirror production: validate against the schema and hand the *validated* + // output to the handler. Without this, the mock would accept payloads + // that production rejects at enqueue, and a normalizing schema's output + // (defaults, coercions) would differ between tests and production. + async #validateTaskPayload(def: any, data: any): Promise { const result = await def.schema["~standard"].validate(data); if (result.issues != null && result.issues.length > 0) { throw new TypeError( `Task data failed schema validation: ${JSON.stringify(result.issues)}`, ); } - await def.handler(this, result.value); + return result.value; + } + + // No queue in mock type: the task handler is invoked immediately, + // mirroring how processQueuedTask() processes immediately. + async enqueueTask(task: any, data: any, _options?: any): Promise { + const def = this.#resolveTaskDefinition(task); + await def.handler(this, await this.#validateTaskPayload(def, data)); } async enqueueTaskMany( task: any, payloads: readonly any[], - options?: any, + _options?: any, ): Promise { - for (const data of payloads) await this.enqueueTask(task, data, options); + const def = this.#resolveTaskDefinition(task); + // Mirror production: the whole batch validates before anything runs, so + // a failing payload rejects the batch with no partial processing. + const values = await Promise.all( + payloads.map((data) => this.#validateTaskPayload(def, data)), + ); + for (const value of values) await def.handler(this, value); } clone(data: TContextData): TestContext { From fc3965daae2eeb61266156e2b77c87223bb68514 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 08:19:16 +0000 Subject: [PATCH 23/29] Reject foreign task handles in the testing mock Production compares the registered handle by identity (14313a105), so passing a handle from another federation instance throws even when both instances define the same task name. The mock looked definitions up by name only, and defineTask() did not keep the handle it returned, so an identity check was impossible: tests could pass with a handle the real federation rejects. Store the returned handle with the definition and require the enqueued handle to be that very object, with the same error message production uses. A regression test defines the same task name on two mock federations and asserts the foreign handle is rejected without running any handler. https://github.com/fedify-dev/fedify/pull/803#discussion_r3401351212 Assisted-by: Claude Code:claude-fable-5 --- packages/testing/src/mock.test.ts | 29 +++++++++++++++++++++++++++++ packages/testing/src/mock.ts | 17 +++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/mock.test.ts b/packages/testing/src/mock.test.ts index eaf07c4e8..c6048b5a6 100644 --- a/packages/testing/src/mock.test.ts +++ b/packages/testing/src/mock.test.ts @@ -1799,3 +1799,32 @@ test("MockContext.enqueueTaskMany validates the whole batch before any handler r ); assertEquals(seen, []); }); + +test("MockContext.enqueueTask rejects a handle from another federation", async () => { + const federation = createFederation(); + const other = createFederation(); + let called = 0; + federation.defineTask("shared-name", { + schema: numberSchema, + handler: () => { + called++; + }, + }); + // Same task name on another federation: production compares the registered + // handle by identity and rejects the foreign one, so a name-only lookup in + // the mock would let tests pass with a handle the real federation refuses. + const foreign = other.defineTask("shared-name", { + schema: numberSchema, + handler: () => {}, + }); + const context = federation.createContext( + new URL("https://example.com"), + undefined, + ); + await assertRejects( + () => context.enqueueTask(foreign, 1), + TypeError, + "is not defined on this federation", + ); + assertEquals(called, 0); +}); diff --git a/packages/testing/src/mock.ts b/packages/testing/src/mock.ts index 24dfb9636..60fb98db8 100644 --- a/packages/testing/src/mock.ts +++ b/packages/testing/src/mock.ts @@ -324,8 +324,12 @@ class MockFederation implements Federation { if (this.taskDefinitions.has(name)) { throw new TypeError(`Task ${JSON.stringify(name)} is already defined.`); } - this.taskDefinitions.set(name, { name, ...options }); - return { name, schema: options.schema }; + // Keep the returned handle with the definition: enqueue compares the + // handle by identity, as production does, so a same-named handle from + // another federation instance is rejected rather than looked up by name. + const handle = { name, schema: options.schema }; + this.taskDefinitions.set(name, { name, ...options, handle }); + return handle; } // Note: Parameter type is `any` instead of WebFingerLinksDispatcher to avoid @@ -972,8 +976,13 @@ class MockContext implements Context { throw new TypeError("No task definitions are available."); } const def = this.federation.taskDefinitions.get(task.name); - if (def == null) { - throw new TypeError(`Task ${JSON.stringify(task.name)} is not defined.`); + if (def == null || def.handle !== task) { + throw new TypeError( + `Task ${ + JSON.stringify(task.name) + } is not defined on this federation; ` + + "pass a handle returned by its defineTask().", + ); } return def; } From ec87fc070735d7db2190adbdd50076d90f8cf6d3 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 18:11:48 +0000 Subject: [PATCH 24/29] Use 2.x.x @since for new task APIs The custom background task APIs added on this branch were annotated with @since 2.3.0, but the release that will include them is not yet decided. Replace those tags with the placeholder 2.x.x so the documentation does not promise a specific version prematurely. Affected APIs: Context.enqueueTask and enqueueTaskMany, the taskRetryPolicy and taskQueueResolution federation options, the task queue option, TaskMessage, and the task definition types (TaskHandler, TaskDefinitionOptions, TaskDefinition, TaskRegistry, and TaskEnqueueOptions). Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/context.ts | 4 ++-- packages/fedify/src/federation/federation.ts | 4 ++-- packages/fedify/src/federation/middleware.ts | 2 +- packages/fedify/src/federation/queue.ts | 2 +- packages/fedify/src/federation/tasks/task.ts | 10 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/fedify/src/federation/context.ts b/packages/fedify/src/federation/context.ts index a7a629c7c..fda194530 100644 --- a/packages/fedify/src/federation/context.ts +++ b/packages/fedify/src/federation/context.ts @@ -449,7 +449,7 @@ export interface Context { * @throws {TypeError} If the task is not defined on this federation, * if no message queue is configured for tasks, or if * the payload fails schema validation. - * @since 2.3.0 + * @since 2.x.x */ enqueueTask( task: TaskDefinition, @@ -470,7 +470,7 @@ export interface Context { * @throws {TypeError} If the task is not defined on this federation, * if no message queue is configured for tasks, or if * a payload fails schema validation. - * @since 2.3.0 + * @since 2.x.x */ enqueueTaskMany( task: TaskDefinition, diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index dbb5231f1..baa00edf2 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -1086,7 +1086,7 @@ export interface FederationOptions { * this uses an exponential backoff strategy with a maximum of 10 attempts * and a maximum delay of 12 hours. A per-task retry policy * ({@link TaskDefinitionOptions.retryPolicy}) overrides this. - * @since 2.3.0 + * @since 2.x.x */ taskRetryPolicy?: RetryPolicy; @@ -1099,7 +1099,7 @@ export interface FederationOptions { * - `"strict"`: no fallback; enqueuing the task throws instead of * silently sharing the outbox queue. * @default `"fallback"` - * @since 2.3.0 + * @since 2.x.x */ taskQueueResolution?: "fallback" | "strict"; diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 2c4f5ccd8..0148ef006 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -460,7 +460,7 @@ export interface FederationQueueOptions { * The message queue for custom background tasks. If not provided, * tasks are routed to the outbox queue (unless * {@link FederationOptions.taskQueueResolution} is `"strict"`). - * @since 2.3.0 + * @since 2.x.x */ readonly task?: MessageQueue; } diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 5bef2abfc..a8e862a1e 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -79,7 +79,7 @@ export interface OutboxMessage { * A message that carries a custom background task. Every field is * a string, number, or plain record so that the message survives both * JSON serialization and structured clone on every queue backend. - * @since 2.3.0 + * @since 2.x.x */ export interface TaskMessage { readonly type: "task"; diff --git a/packages/fedify/src/federation/tasks/task.ts b/packages/fedify/src/federation/tasks/task.ts index a11ba5f9a..0105e8e21 100644 --- a/packages/fedify/src/federation/tasks/task.ts +++ b/packages/fedify/src/federation/tasks/task.ts @@ -10,7 +10,7 @@ import type { RetryPolicy } from "../retry.ts"; * schema. * @param ctx The context for the worker processing the task. * @param data The decoded and validated task payload. - * @since 2.3.0 + * @since 2.x.x */ export type TaskHandler = ( ctx: Context, @@ -22,7 +22,7 @@ export type TaskHandler = ( * @template TContextData The context data to pass to the {@link Context}. * @template TSchema The [Standard Schema](https://standardschema.dev/) that * validates the task payload. - * @since 2.3.0 + * @since 2.x.x */ export interface TaskDefinitionOptions< TContextData, @@ -91,7 +91,7 @@ declare const contextDataBrand: unique symbol; * @template TContextData The context data to pass to the {@link Context}. * @template TData The type of the task payload, inferred from the task's * schema. - * @since 2.3.0 + * @since 2.x.x */ export interface TaskDefinition { /** @@ -115,7 +115,7 @@ export interface TaskDefinition { * Registration of custom background tasks. Both {@link Federation} and * {@link FederationBuilder} implement this interface. * @template TContextData The context data to pass to the {@link Context}. - * @since 2.3.0 + * @since 2.x.x */ export interface TaskRegistry { /** @@ -146,7 +146,7 @@ export interface TaskRegistry { /** * Options for {@link Context.enqueueTask} and {@link Context.enqueueTaskMany}. - * @since 2.3.0 + * @since 2.x.x */ export interface TaskEnqueueOptions { /** From b444a6e57a89d8eb4a99a7d7f9eacc1c33abd8bc Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 12 Jun 2026 18:34:25 +0000 Subject: [PATCH 25/29] Fix doc and comment inaccuracies found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local review of the custom background task PR flagged several documentation-level problems; no runtime behavior is affected: - The tasks manual claimed the API ships in Fedify 2.3.0, while the new APIs' JSDoc had already moved to `@since 2.x.x` because the containing release is undecided. Align the manual with the JSDoc. - The manual also claimed a per-task queue defined after the queue machinery starts "never gets a worker." Without `manuallyStartQueue`, the next request or enqueue starts the worker, so soften the claim to match the implementation. - A comment in codec.test.ts described an instance-level `#seen` map that does not exist—each `deserialize()` call builds its own per-decode map—and the first test's title claimed a fresh instance per operation while the tests share one module-level codec. Correct both. - Fix grammar errors in the new *AGENTS.md* paragraph about `mise tasks`. https://github.com/fedify-dev/fedify/pull/803 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5-5 --- AGENTS.md | 6 +++--- docs/manual/tasks.md | 6 +++--- packages/fedify/src/federation/tasks/codec.test.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b209fab3b..6f4432f09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,9 +161,9 @@ Common tasks ### **BE WELL-ACQUAINTED WITH `mise tasks`** *mise.toml* has useful tasks. **Acquaint all of them** and use them in the right -place at the right time. If it has too many information, use `mise tasks`. This -command show the summary of the tasks and descriptions. If `mise tasks` didn't -not make it sure, use `mise tasks ` to check the details for the task. +place at the right time. If it has too much information, use `mise tasks`. This +command shows the summary of the tasks and descriptions. If `mise tasks` does +not make it clear, use `mise tasks ` to check the details for the task. ### Adding ActivityPub vocabulary types diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md index cde246b5c..ec5ca09bf 100644 --- a/docs/manual/tasks.md +++ b/docs/manual/tasks.md @@ -1,7 +1,7 @@ Background tasks ================ -*This API is available since Fedify 2.3.0.* +*This API is available since Fedify 2.x.x.* Fedify already processes outgoing and incoming activities on background workers through its [message queue](./mq.md). The custom background task API @@ -218,8 +218,8 @@ const transcodeVideo = federation.defineTask("transcodeVideo", { Workers for dedicated per-task queues are registered when the queue machinery starts, so define every task before `~Federation.startQueue()` is called (or, without `~FederationOptions.manuallyStartQueue`, before the -first request is handled); a per-task queue defined later never gets -a worker. +first request is handled); a per-task queue defined later may not get +a worker until the queue machinery is next started. The queue for a task is resolved in order: the per-task `queue`, then the federation's `task` queue, then the outbox queue. Deployments that must diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index b194cda6c..31bc43c33 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -27,7 +27,7 @@ function makeSchema( }; } -test("TaskCodec (fresh instance per operation)", async (t) => { +test("TaskCodec.serialize() / deserialize()", async (t) => { const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Hello, world!", @@ -200,9 +200,9 @@ test("TaskCodec (fresh instance per operation)", async (t) => { }); test("TaskCodec (one instance reused across decodes)", async (t) => { - // The instance carries the cycle-tracking `#seen` map across decodes, but - // each decode parses a fresh object graph with distinct identities, so a - // reused instance still decodes every payload independently. + // Each deserialize() call builds its own per-decode `seen` map, so no + // cycle-tracking state crosses calls and a reused instance decodes every + // payload independently. await t.step("two sequential decodes stay independent", async () => { const codec = new TaskCodec(loaders); const first = await codec.serialize({ From ed60ecddf0d36ad76bfd250817f788847655895e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 14 Jun 2026 12:07:29 +0000 Subject: [PATCH 26/29] Cover enqueueTask(Many) in middleware tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom task API's producer side—Context.enqueueTask() and enqueueTaskMany()—had no direct coverage at the middleware layer. Add tests that drive a real ContextImpl against a recording queue and assert on what it enqueues: - enqueueTask() builds a well-formed task message (type, taskName, baseUrl, attempt, UUID id, parseable started instant, trace context) and round-trips a vocab payload through the codec as JSON-LD. - enqueueTaskMany() routes a multi-item batch through enqueueMany(), preserving order and forwarding delay/orderingKey, while a single-item batch uses enqueue() instead. - When the queue lacks enqueueMany(), the batch falls back to concurrent single enqueues—verified with a rendezvous queue that blocks until both are in flight—still preserving order and options. - An invalid payload anywhere in the batch rejects with a schema TypeError and enqueues nothing. To avoid duplicating fixtures, the MockQueue and Standard Schema test helpers that tasks.test.ts defined inline move to testing/tasks.ts (re-exported by testing/mod.ts); both suites now import the single implementation, and the fixture-usage allowlist covers the new file. https://github.com/fedify-dev/fedify/pull/803 Assisted-by: Claude Code:claude-opus-4-8 --- .../fedify/src/federation/middleware.test.ts | 286 +++++++++++++++++- .../fedify/src/federation/tasks/tasks.test.ts | 90 +----- packages/fedify/src/testing/mod.ts | 9 + packages/fedify/src/testing/tasks.ts | 101 +++++++ scripts/check_fixture_usage.ts | 2 + 5 files changed, 391 insertions(+), 97 deletions(-) create mode 100644 packages/fedify/src/testing/tasks.ts diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index ad073b5df..40b67f189 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -5,9 +5,23 @@ import { test, } from "@fedify/fixture"; import { RouterError } from "@fedify/uri-template"; -import { configure, type LogRecord, reset } from "@logtape/logtape"; import * as vocab from "@fedify/vocab"; -import { Create, getTypeId, lookupObject, Offer, Person } from "@fedify/vocab"; +import { + Create, + getTypeId, + lookupObject, + Note, + Offer, + Person, +} from "@fedify/vocab"; +import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; +import { configure, type LogRecord, reset } from "@logtape/logtape"; +import { metrics, SpanStatusCode } from "@opentelemetry/api"; +import { + DataPointType, + MeterProvider, + MetricReader, +} from "@opentelemetry/sdk-metrics"; import { assert, assertEquals, @@ -21,6 +35,7 @@ import { } from "@std/assert"; import fetchMock from "fetch-mock"; import serialize from "json-canon"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import createFixture from "../../../fixture/src/fixtures/example.com/create.json" with { type: "json", }; @@ -49,20 +64,12 @@ import { rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.ts"; -import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime"; -import { metrics, SpanStatusCode } from "@opentelemetry/api"; -import { - DataPointType, - MeterProvider, - MetricReader, -} from "@opentelemetry/sdk-metrics"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; -import { CircuitBreaker } from "./circuit-breaker.ts"; import { handleBenchmarkTrigger } from "./bench.ts"; - -const documentLoader = getDocumentLoader(); +import { CircuitBreaker } from "./circuit-breaker.ts"; import type { Context, GetActorOptions } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; +import { recordInboxActivity } from "./metrics.ts"; import { ContextImpl, createFederation, @@ -70,9 +77,26 @@ import { InboxContextImpl, KvSpecDeterminer, } from "./middleware.ts"; -import { recordInboxActivity } from "./metrics.ts"; -import type { MessageQueue } from "./mq.ts"; -import type { InboxMessage, Message, OutboxMessage } from "./queue.ts"; +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, +} from "./mq.ts"; +import type { + InboxMessage, + Message, + OutboxMessage, + TaskMessage, +} from "./queue.ts"; +import TaskCodec from "./tasks/codec.ts"; +import { + type Envelope, + envelopeSchema, + MockQueue, + numberSchema, +} from "../testing/mod.ts"; + +const documentLoader = getDocumentLoader(); type IsEqual = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; @@ -10480,3 +10504,235 @@ test("createFederation() omits instrumentation when no meterProvider is set", () assertStrictEquals(ctx.documentLoader, mockDocumentLoader); assertStrictEquals(ctx.contextLoader, mockDocumentLoader); }); + +const taskCodec = new TaskCodec({ contextLoader: mockDocumentLoader }); +const decodeEnvelope = (message: TaskMessage): Promise => + taskCodec.decode(envelopeSchema, message.data); +const envelope = (title: string): Envelope => ({ + note: new Note({ content: title }), + title, +}); + +class RendezvousQueue implements MessageQueue { + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + #count = 0; + #markDispatched!: () => void; + #openGate!: () => void; + readonly dispatched: Promise; + readonly #gate: Promise; + + constructor(readonly expected: number) { + this.dispatched = new Promise((resolve) => { + this.#markDispatched = resolve; + }); + this.#gate = new Promise((resolve) => { + this.#openGate = resolve; + }); + } + + release(): void { + this.#openGate(); + } + + // deno-lint-ignore no-explicit-any + enqueue(message: any, options?: MessageQueueEnqueueOptions): Promise { + this.enqueued.push({ message, options }); + if (++this.#count >= this.expected) this.#markDispatched(); + return this.#gate; + } + + listen( + // deno-lint-ignore no-explicit-any + _handler: (message: any) => Promise | void, + _options?: MessageQueueListenOptions, + ): Promise { + return new Promise(() => {}); + } +} + +const withTimeout = ( + promise: Promise, + ms: number, + message: string, +): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +const taskFederationOptions = { + kv: new MemoryKvStore(), + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + manuallyStartQueue: true, +}; + +test("ContextImpl.enqueueTask()", async (t) => { + await t.step( + "builds the task message envelope and round-trips a vocab payload", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("greet", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + ok(ctx instanceof ContextImpl); + await ctx.enqueueTask(task, envelope("greeting")); + strictEqual(queue.enqueuedMany.length, 0); + strictEqual(queue.enqueued.length, 1); + const { message } = queue.enqueued[0]; + strictEqual(message.type, "task"); + strictEqual(message.taskName, "greet"); + strictEqual(message.baseUrl, "https://example.com"); + strictEqual(message.attempt, 0); + ok(/^[0-9a-f-]{36}$/i.test(message.id)); + // `started` must be a parseable instant; Temporal.Instant.from throws + // otherwise. + ok(Temporal.Instant.from(message.started) instanceof Temporal.Instant); + strictEqual(typeof message.traceContext, "object"); + ok(message.traceContext != null); + // A vocab object must survive the producer-side encode as JSON-LD. + const decoded = await decodeEnvelope(message); + ok(decoded.note instanceof Note); + strictEqual(decoded.note.content?.toString(), "greeting"); + strictEqual(decoded.title, "greeting"); + }, + ); +}); + +test("ContextImpl.enqueueTaskMany()", async (t) => { + await t.step( + "round-trips every payload through enqueueMany in order, forwarding options", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const payloads = [envelope("a"), envelope("b"), envelope("c")]; + await ctx.enqueueTaskMany(task, payloads, { + delay: { seconds: 30 }, + orderingKey: "batch", + }); + strictEqual(queue.enqueued.length, 0); + strictEqual(queue.enqueuedMany.length, 1); + const { messages, options } = queue.enqueuedMany[0]; + ok(options?.delay instanceof Temporal.Duration); + strictEqual(options.delay.total("second"), 30); + strictEqual(options.orderingKey, "batch"); + const decoded = await Promise.all(messages.map(decodeEnvelope)); + deepStrictEqual(decoded.map((d) => d.title), ["a", "b", "c"]); + for (const message of messages) strictEqual(message.orderingKey, "batch"); + }, + ); + + await t.step( + "with a single payload uses enqueue() instead of enqueueMany", + async () => { + const queue = new MockQueue({ supportsEnqueueMany: true }); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-single", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await ctx.enqueueTaskMany(task, [envelope("solo")]); + strictEqual(queue.enqueuedMany.length, 0); + strictEqual(queue.enqueued.length, 1); + }, + ); + + await t.step( + "falls back to concurrent single enqueues, preserving order and options", + async () => { + const queue = new RendezvousQueue(2); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-fallback", { + schema: envelopeSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const payloads = [envelope("x"), envelope("y")]; + const pending = ctx.enqueueTaskMany(task, payloads, { + orderingKey: "batch", + }); + try { + await withTimeout( + queue.dispatched, + 2000, + "fallback did not dispatch enqueues concurrently", + ); + strictEqual(queue.enqueued.length, 2); + } finally { + queue.release(); + await pending; + } + const decoded = await Promise.all( + queue.enqueued.map(({ message }) => decodeEnvelope(message)), + ); + deepStrictEqual(decoded.map((d) => d.title), ["x", "y"]); + for (const { message, options } of queue.enqueued) { + strictEqual(message.orderingKey, "batch"); + strictEqual(options?.orderingKey, "batch"); + } + }, + ); + + await t.step( + "fallback path aborts the whole batch when one payload is invalid", + async () => { + const queue = new MockQueue(); + const federation = createFederation({ + ...taskFederationOptions, + queue: { task: queue }, + }); + const task = federation.defineTask("bulk-typed", { + schema: numberSchema, + handler: () => {}, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await rejects( + // deno-lint-ignore no-explicit-any + () => ctx.enqueueTaskMany(task, [1, "two", 3] as any), + { name: "TypeError", message: /Task data failed schema validation/ }, + ); + strictEqual(queue.enqueued.length, 0); + }, + ); +}); diff --git a/packages/fedify/src/federation/tasks/tasks.test.ts b/packages/fedify/src/federation/tasks/tasks.test.ts index 87477c336..6ac05255a 100644 --- a/packages/fedify/src/federation/tasks/tasks.test.ts +++ b/packages/fedify/src/federation/tasks/tasks.test.ts @@ -1,6 +1,5 @@ import { mockDocumentLoader, test } from "@fedify/fixture"; import { Note } from "@fedify/vocab"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; import { delay } from "es-toolkit"; import { deepStrictEqual, @@ -14,93 +13,20 @@ import type { Context } from "../context.ts"; import type { Federatable, FederationOptions } from "../federation.ts"; import { MemoryKvStore } from "../kv.ts"; import { createFederation, type FederationImpl } from "../middleware.ts"; -import { - InProcessMessageQueue, - type MessageQueue, - type MessageQueueEnqueueOptions, - type MessageQueueListenOptions, -} from "../mq.ts"; +import { InProcessMessageQueue } from "../mq.ts"; import type { TaskMessage } from "../queue.ts"; import TaskCodec from "./codec.ts"; import type { TaskDefinition, TaskRegistry } from "./task.ts"; +import { + type Envelope, + envelopeSchema, + MockQueue, + numberSchema, + stringSchema, +} from "../../testing/mod.ts"; type Assert = T; -const makeSchema = ( - check: (data: unknown) => data is T, -): StandardSchemaV1 => ({ - "~standard": { - version: 1, - vendor: "fedify-test", - validate: (value: unknown) => - check(value) - ? { value } - : { issues: [{ message: "Invalid task data." }] }, - }, -}); - -interface Envelope { - note: Note; - title: string; -} - -const envelopeSchema = makeSchema( - (data): data is Envelope => - typeof data === "object" && data != null && - (data as Envelope).note instanceof Note && - typeof (data as Envelope).title === "string", -); - -const stringSchema = makeSchema((d): d is string => typeof d === "string"); -const numberSchema = makeSchema((d): d is number => typeof d === "number"); - -class MockQueue implements MessageQueue { - readonly nativeRetrial: boolean; - readonly enqueued: { - message: TaskMessage; - options?: MessageQueueEnqueueOptions; - }[] = []; - readonly enqueuedMany: { - messages: readonly TaskMessage[]; - options?: MessageQueueEnqueueOptions; - }[] = []; - listenCount = 0; - enqueueMany?: ( - messages: readonly TaskMessage[], - options?: MessageQueueEnqueueOptions, - ) => Promise; - - constructor( - options: { nativeRetrial?: boolean; supportsEnqueueMany?: boolean } = {}, - ) { - this.nativeRetrial = options.nativeRetrial ?? false; - if (options.supportsEnqueueMany) { - this.enqueueMany = (messages, opts) => { - this.enqueuedMany.push({ messages, options: opts }); - return Promise.resolve(); - }; - } - } - - enqueue( - message: TaskMessage, - options?: MessageQueueEnqueueOptions, - ): Promise { - this.enqueued.push({ message, options }); - return Promise.resolve(); - } - - listen( - _handler: (message: TaskMessage) => Promise | void, - options?: MessageQueueListenOptions, - ): Promise { - this.listenCount++; - return new Promise((resolve) => { - options?.signal?.addEventListener("abort", () => resolve()); - }); - } -} - const baseOptions: Omit, "queue"> = { kv: new MemoryKvStore(), documentLoaderFactory: () => mockDocumentLoader, diff --git a/packages/fedify/src/testing/mod.ts b/packages/fedify/src/testing/mod.ts index fe72cbdda..27cf5bdc9 100644 --- a/packages/fedify/src/testing/mod.ts +++ b/packages/fedify/src/testing/mod.ts @@ -3,5 +3,14 @@ export { createOutboxContext, createRequestContext, } from "./context.ts"; +export { + type Envelope, + envelopeSchema, + makeSchema, + MockQueue, + type MockQueueOptions, + numberSchema, + stringSchema, +} from "./tasks.ts"; // Without the export below, `test:cfworkers` makes an error. export { testDefinitions } from "@fedify/fixture"; diff --git a/packages/fedify/src/testing/tasks.ts b/packages/fedify/src/testing/tasks.ts new file mode 100644 index 000000000..2dc50c83d --- /dev/null +++ b/packages/fedify/src/testing/tasks.ts @@ -0,0 +1,101 @@ +import { Note } from "@fedify/vocab"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, +} from "../federation/mq.ts"; +import type { TaskMessage } from "../federation/queue.ts"; + +/** + * Builds a minimal [Standard Schema](https://standardschema.dev/) from a type + * guard, for use as a task payload schema in tests. + */ +export const makeSchema = ( + check: (data: unknown) => data is T, +): StandardSchemaV1 => ({ + "~standard": { + version: 1, + vendor: "fedify-test", + validate: (value: unknown) => + check(value) + ? { value } + : { issues: [{ message: "Invalid task data." }] }, + }, +}); + +export const stringSchema = makeSchema( + (d): d is string => typeof d === "string", +); +export const numberSchema = makeSchema( + (d): d is number => typeof d === "number", +); + +/** A task payload that carries a vocabulary object, to exercise the codec's + * vocab-to-JSON-LD bridging. */ +export interface Envelope { + note: Note; + title: string; +} + +export const envelopeSchema = makeSchema( + (data): data is Envelope => + typeof data === "object" && data != null && + (data as Envelope).note instanceof Note && + typeof (data as Envelope).title === "string", +); + +/** Options for {@link MockQueue}. */ +export interface MockQueueOptions { + readonly nativeRetrial?: boolean; + readonly supportsEnqueueMany?: boolean; +} + +/** + * A {@link MessageQueue} that records what it was asked to enqueue and resolves + * its `listen()` when the abort signal fires, so tests can inspect dispatch + * without a real backend. + */ +export class MockQueue implements MessageQueue { + readonly nativeRetrial: boolean; + readonly enqueued: { + message: TaskMessage; + options?: MessageQueueEnqueueOptions; + }[] = []; + readonly enqueuedMany: { + messages: readonly TaskMessage[]; + options?: MessageQueueEnqueueOptions; + }[] = []; + listenCount = 0; + enqueueMany?: ( + messages: readonly TaskMessage[], + options?: MessageQueueEnqueueOptions, + ) => Promise; + + constructor(options: MockQueueOptions = {}) { + this.nativeRetrial = options.nativeRetrial ?? false; + if (options.supportsEnqueueMany) { + this.enqueueMany = (messages, opts) => { + this.enqueuedMany.push({ messages, options: opts }); + return Promise.resolve(); + }; + } + } + + // deno-lint-ignore no-explicit-any + enqueue(message: any, options?: MessageQueueEnqueueOptions): Promise { + this.enqueued.push({ message, options }); + return Promise.resolve(); + } + + listen( + // deno-lint-ignore no-explicit-any + _handler: (message: any) => Promise | void, + options?: MessageQueueListenOptions, + ): Promise { + this.listenCount++; + return new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => resolve()); + }); + } +} diff --git a/scripts/check_fixture_usage.ts b/scripts/check_fixture_usage.ts index 7ca5e28ff..73ef518d9 100644 --- a/scripts/check_fixture_usage.ts +++ b/scripts/check_fixture_usage.ts @@ -35,6 +35,8 @@ const ALLOWLIST: readonly string[] = [ // tsdown `noExternal` so consumers never resolve `@fedify/fixture` at // runtime. "packages/fedify/src/testing/mod.ts", + // Test utils for custom tasks + "packages/fedify/src/testing/tasks.ts", // JSDoc `@example` block mentions `import { test } from "@fedify/fixture"` // as documentation; not a real runtime import. "packages/testing/src/mq-tester.ts", From 94633f14ed406cda6b178bfca0c6a03b41202b4d Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 18 Jun 2026 18:14:40 +0000 Subject: [PATCH 27/29] Append revived array elements without spread The array reviver in TaskCodec restored elements with `arr.push(...await Array.fromAsync(node, revive))`. Spreading the revived elements into a single call hits the engine's argument-count limit, so a large enough array throws `RangeError: Maximum call stack size exceeded` during decode. Since the worker drops decode failures without retry, an otherwise-valid payload that enqueued fine is silently lost on the dequeue side. Replace the spread with a per-item loop append, matching the existing Map and Set revivers, so revival no longer depends on the array length. Add a regression test that round-trips a 200,000-element array. https://github.com/fedify-dev/fedify/pull/803#discussion_r3418709602 Assisted-by: Claude Code:claude-opus-4-8 --- packages/fedify/src/federation/tasks/codec.test.ts | 13 +++++++++++++ packages/fedify/src/federation/tasks/codec.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index 31bc43c33..c90374f6b 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -197,6 +197,19 @@ test("TaskCodec.serialize() / deserialize()", async (t) => { strictEqual(decoded.content?.toString(), "deep"); }, ); + + await t.step( + "round-trips an array too large to spread into push()", + async () => { + const length = 200_000; + const payload = { big: Array.from({ length }, (_, i) => i) }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as { big: number[] }; + strictEqual(decoded.big.length, length); + strictEqual(decoded.big[0], 0); + strictEqual(decoded.big[length - 1], length - 1); + }, + ); }); test("TaskCodec (one instance reused across decodes)", async (t) => { diff --git a/packages/fedify/src/federation/tasks/codec.ts b/packages/fedify/src/federation/tasks/codec.ts index b93366c0b..f13d85581 100644 --- a/packages/fedify/src/federation/tasks/codec.ts +++ b/packages/fedify/src/federation/tasks/codec.ts @@ -66,7 +66,7 @@ export default class TaskCodec { isInstanceOf(Array), (): unknown[] => [], async (revive, node, arr) => { - arr.push(...await Array.fromAsync(node, revive)); + for (const item of await Array.fromAsync(node, revive)) arr.push(item); }, ), classReviver( From e095d6ce69a7b11959e370682397714be6254d95 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 18 Jun 2026 18:24:28 +0000 Subject: [PATCH 28/29] Polish task payload docs and cover Temporal Three documentation points raised on the task API review, plus a regression test backing the Temporal claim: - The payload codec round-trips devalue's built-in Temporal types with no extra code, but the supported-payload list omitted them. List `Temporal` (with `Temporal.Instant` / `Temporal.Duration` examples) and add a serialize/deserialize round-trip test so the documented support stays covered. - The vocab import example used the compatibility path `@fedify/fedify/vocab`. Switch it to `@fedify/vocab`, matching the surrounding docs and the current package boundary so copied code does not bind to a path slated for removal. - Task payloads now cross durable queue storage and can hold arbitrary application data. Add a trust-boundary security note to the queue isolation section: treat the backend and payloads as internal trusted storage, pass identifiers the worker resolves rather than long-lived secrets, and use a dedicated task queue with `taskQueueResolution: "strict"` when isolation is required. https://github.com/fedify-dev/fedify/pull/803#discussion_r3418856889 https://github.com/fedify-dev/fedify/pull/803#discussion_r3418709609 https://github.com/fedify-dev/fedify/pull/803#discussion_r3418793171 Assisted-by: Claude Code:claude-opus-4-8 --- docs/manual/tasks.md | 15 ++++++++++++--- .../fedify/src/federation/tasks/codec.test.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/manual/tasks.md b/docs/manual/tasks.md index ec5ca09bf..55adef8dc 100644 --- a/docs/manual/tasks.md +++ b/docs/manual/tasks.md @@ -57,8 +57,9 @@ Task payloads cross a message queue, so they are serialized on enqueue and deserialized on dispatch. Fedify owns this codec—applications never encode payloads themselves. The codec is built on [devalue], which means payloads are not limited to JSON: `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, -typed arrays, circular references, and repeated references all round-trip -faithfully. +typed arrays, `Temporal` values (e.g., `Temporal.Instant`, +`Temporal.Duration`), circular references, and repeated references all +round-trip faithfully. Activity Vocabulary objects (`Note`, `Create`, `Person`, `Link`, and so on) are also supported as payload values. Each vocabulary object is bridged @@ -66,7 +67,7 @@ through expanded JSON-LD on the wire and comes back as a real instance, so the handler can call its methods and getters as usual: ~~~~ typescript -import { Note } from "@fedify/fedify/vocab"; +import { Note } from "@fedify/vocab"; import { z } from "zod"; const indexNote = federation.defineTask("indexNote", { @@ -247,6 +248,14 @@ A task that falls back to the outbox queue needs no dedicated worker; the outbox worker dispatches every message by its type regardless of which queue delivered it. +> [!CAUTION] +> Task payloads cross durable queue storage, so treat the queue backend and +> its payloads as internal, trusted storage. Do not place long-lived secrets +> or credentials directly in a task payload; pass an identifier that the +> worker resolves from your application storage instead. When task workloads +> must stay isolated from ActivityPub delivery, give them a dedicated task +> queue and set `taskQueueResolution: "strict"`. + Limitations ----------- diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index c90374f6b..a3f2259d4 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -210,6 +210,22 @@ test("TaskCodec.serialize() / deserialize()", async (t) => { strictEqual(decoded.big[length - 1], length - 1); }, ); + + await t.step("round-trips Temporal values", async () => { + const payload = { + instant: Temporal.Instant.from("2026-01-02T03:04:05Z"), + duration: Temporal.Duration.from({ hours: 1, minutes: 30 }), + }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as { + instant: Temporal.Instant; + duration: Temporal.Duration; + }; + ok(decoded.instant instanceof Temporal.Instant); + ok(decoded.instant.equals(payload.instant)); + ok(decoded.duration instanceof Temporal.Duration); + strictEqual(decoded.duration.toString(), payload.duration.toString()); + }); }); test("TaskCodec (one instance reused across decodes)", async (t) => { From 3b5b5c8a3ed6681a642108380565a99c85e92903 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Thu, 18 Jun 2026 19:23:07 +0000 Subject: [PATCH 29/29] Ignore `Temporal` test in Bun --- .../fedify/src/federation/tasks/codec.test.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/federation/tasks/codec.test.ts b/packages/fedify/src/federation/tasks/codec.test.ts index a3f2259d4..fcc84de0e 100644 --- a/packages/fedify/src/federation/tasks/codec.test.ts +++ b/packages/fedify/src/federation/tasks/codec.test.ts @@ -211,20 +211,24 @@ test("TaskCodec.serialize() / deserialize()", async (t) => { }, ); - await t.step("round-trips Temporal values", async () => { - const payload = { - instant: Temporal.Instant.from("2026-01-02T03:04:05Z"), - duration: Temporal.Duration.from({ hours: 1, minutes: 30 }), - }; - const encoded = await codec.serialize(payload); - const decoded = await codec.deserialize(encoded) as { - instant: Temporal.Instant; - duration: Temporal.Duration; - }; - ok(decoded.instant instanceof Temporal.Instant); - ok(decoded.instant.equals(payload.instant)); - ok(decoded.duration instanceof Temporal.Duration); - strictEqual(decoded.duration.toString(), payload.duration.toString()); + await t.step({ + name: "round-trips Temporal values", + ignore: "Bun" in globalThis, + async fn() { + const payload = { + instant: Temporal.Instant.from("2026-01-02T03:04:05Z"), + duration: Temporal.Duration.from({ hours: 1, minutes: 30 }), + }; + const encoded = await codec.serialize(payload); + const decoded = await codec.deserialize(encoded) as { + instant: Temporal.Instant; + duration: Temporal.Duration; + }; + ok(decoded.instant instanceof Temporal.Instant); + ok(decoded.instant.equals(payload.instant)); + ok(decoded.duration instanceof Temporal.Duration); + strictEqual(decoded.duration.toString(), payload.duration.toString()); + }, }); });