Commit b2fb32a9 authored by ChanHaeng Lee's avatar ChanHaeng Lee
Browse files

Copy `packages/fedify/src/x/*.ts` files to `packages/*/src/mod.ts`

parent 2ff7aa0b
Loading
Loading
Loading
Loading
+144 −0
Original line number Diff line number Diff line
/**
 * `KvStore` & `MessageQueue` adapters for Cloudflare Workers
 * ==========================================================
 *
 * This module provides `KvStore` and `MessageQueue` implementations that use
 * Cloudflare Workers' KV and Queues bindings, respectively.
 *
 * @module
 * @since 1.6.0
 */
import type {
  KVNamespace,
  MessageSendRequest,
  Queue,
} from "@cloudflare/workers-types/experimental";
import type { KvKey, KvStore, KvStoreSetOptions } from "../federation/kv.ts";
import type {
  MessageQueue,
  MessageQueueEnqueueOptions,
  MessageQueueListenOptions,
} from "../federation/mq.ts";

interface KvMetadata {
  expires?: number;
}

/**
 * Implementation of the {@link KvStore} interface for Cloudflare Workers KV
 * binding.  This class provides a wrapper around Cloudflare's KV namespace to
 * store and retrieve JSON-serializable values using structured keys.
 *
 * Note that this implementation does not support the {@link KvStore.cas}
 * operation, as Cloudflare Workers KV does not support atomic compare-and-swap
 * operations.  If you need this functionality, consider using a different
 * key–value store that supports atomic operations.
 * @since 1.6.0
 */
export class WorkersKvStore implements KvStore {
  #namespace: KVNamespace<string>;

  constructor(namespace: KVNamespace<string>) {
    this.#namespace = namespace;
  }

  #encodeKey(key: KvKey): string {
    return JSON.stringify(key);
  }

  async get<T = unknown>(key: KvKey): Promise<T | undefined> {
    const encodedKey = this.#encodeKey(key);
    const { value, metadata } = await this.#namespace.getWithMetadata(
      encodedKey,
      "json",
    );
    return metadata == null || metadata.expires < Date.now()
      ? undefined
      : value as T;
  }

  async set(
    key: KvKey,
    value: unknown,
    options?: KvStoreSetOptions,
  ): Promise<void> {
    const encodedKey = this.#encodeKey(key);
    const metadata: KvMetadata = options?.ttl == null ? {} : {
      expires: Date.now() + options.ttl.total("milliseconds"),
    };
    await this.#namespace.put(
      encodedKey,
      JSON.stringify(value),
      options?.ttl == null ? { metadata } : {
        // According to Cloudflare Workers KV documentation,
        // the minimum TTL is 60 seconds:
        expirationTtl: Math.max(options.ttl.total("seconds"), 60),
        metadata,
      },
    );
  }

  delete(key: KvKey): Promise<void> {
    return this.#namespace.delete(this.#encodeKey(key));
  }
}

/**
 * Implementation of the {@link MessageQueue} interface for Cloudflare
 * Workers Queues binding.  This class provides a wrapper around Cloudflare's
 * Queues to send messages to a queue.
 *
 * Note that this implementation does not support the `listen()` method,
 * as Cloudflare Workers Queues do not support message consumption in the same
 * way as other message queue systems.  Instead, you should use
 * the {@link Federation.processQueuedTask} method to process messages
 * passed to the queue.
 * @since 1.6.0
 */
export class WorkersMessageQueue implements MessageQueue {
  #queue: Queue;

  /**
   * Cloudflare Queues provide automatic retry with exponential backoff
   * and Dead Letter Queues.
   * @since 1.7.0
   */
  readonly nativeRetrial = true;

  constructor(queue: Queue) {
    this.#queue = queue;
  }

  // deno-lint-ignore no-explicit-any
  enqueue(message: any, options?: MessageQueueEnqueueOptions): Promise<void> {
    return this.#queue.send(message, {
      contentType: "json",
      delaySeconds: options?.delay?.total("seconds") ?? 0,
    });
  }

  enqueueMany(
    // deno-lint-ignore no-explicit-any
    messages: any[],
    options?: MessageQueueEnqueueOptions,
  ): Promise<void> {
    const requests: MessageSendRequest[] = messages.map((msg) => ({
      body: msg,
      contentType: "json",
    }));
    return this.#queue.sendBatch(requests, {
      delaySeconds: options?.delay?.total("seconds") ?? 0,
    });
  }

  listen(
    // deno-lint-ignore no-explicit-any
    _handler: (message: any) => Promise<void> | void,
    _options?: MessageQueueListenOptions,
  ): Promise<void> {
    throw new TypeError(
      "WorkersMessageQueue does not support listen().  " +
        "Use Federation.processQueuedTask() method instead.",
    );
  }
}
+150 −0
Original line number Diff line number Diff line
/**
 * `KvStore` & `MessageQueue` adapters for Deno's KV store
 * =======================================================
 *
 * This module provides `KvStore` and `MessageQueue` implementations that use
 * Deno's KV store.  The `DenoKvStore` class implements the `KvStore` interface
 * using Deno's KV store, and the `DenoKvMessageQueue` class implements the
 * `MessageQueue` interface using Deno's KV store.
 *
 * @module
 * @since 0.5.0
 */
import { isEqual } from "es-toolkit";
import type { KvKey, KvStore, KvStoreSetOptions } from "../federation/kv.ts";
import type {
  MessageQueue,
  MessageQueueEnqueueOptions,
  MessageQueueListenOptions,
} from "../federation/mq.ts";

/**
 * Represents a key–value store implementation using Deno's KV store.
 */
export class DenoKvStore implements KvStore {
  #kv: Deno.Kv;

  /**
   * Constructs a new {@link DenoKvStore} adapter with the given Deno KV store.
   * @param kv The Deno KV store to use.
   */
  constructor(kv: Deno.Kv) {
    this.#kv = kv;
  }

  /**
   * {@inheritDoc KvStore.set}
   */
  async get<T = unknown>(key: KvKey): Promise<T | undefined> {
    const entry = await this.#kv.get<T>(key);
    return entry == null || entry.value == null ? undefined : entry.value;
  }

  /**
   * {@inheritDoc KvStore.set}
   */
  async set(
    key: KvKey,
    value: unknown,
    options?: KvStoreSetOptions,
  ): Promise<void> {
    await this.#kv.set(
      key,
      value,
      options?.ttl == null ? undefined : {
        expireIn: options.ttl.total("millisecond"),
      },
    );
  }

  /**
   * {@inheritDoc KvStore.delete}
   */
  delete(key: KvKey): Promise<void> {
    return this.#kv.delete(key);
  }

  /**
   * {@inheritDoc KvStore.cas}
   */
  async cas(
    key: KvKey,
    expectedValue: unknown,
    newValue: unknown,
    options?: KvStoreSetOptions,
  ): Promise<boolean> {
    while (true) {
      const entry = await this.#kv.get(key);
      if (!isEqual(entry.value ?? undefined, expectedValue)) return false;
      const result = await this.#kv.atomic()
        .check(entry)
        .set(
          key,
          newValue,
          options?.ttl == null ? undefined : {
            expireIn: options.ttl.total("millisecond"),
          },
        )
        .commit();
      if (result.ok) return true;
    }
  }
}

/**
 * Represents a message queue adapter that uses Deno KV store.
 */
export class DenoKvMessageQueue implements MessageQueue, Disposable {
  #kv: Deno.Kv;

  /**
   * Deno KV queues provide automatic retry with exponential backoff.
   * @since 1.7.0
   */
  readonly nativeRetrial = true;

  /**
   * Constructs a new {@link DenoKvMessageQueue} adapter with the given Deno KV
   * store.
   * @param kv The Deno KV store to use.
   */
  constructor(kv: Deno.Kv) {
    this.#kv = kv;
  }

  async enqueue(
    // deno-lint-ignore no-explicit-any
    message: any,
    options?: MessageQueueEnqueueOptions | undefined,
  ): Promise<void> {
    await this.#kv.enqueue(
      message,
      options?.delay == null ? undefined : {
        delay: Math.max(options.delay.total("millisecond"), 0),
      },
    );
  }

  listen(
    // deno-lint-ignore no-explicit-any
    handler: (message: any) => void | Promise<void>,
    options: MessageQueueListenOptions = {},
  ): Promise<void> {
    options.signal?.addEventListener("abort", () => {
      try {
        this.#kv.close();
      } catch (e) {
        if (!(e instanceof Deno.errors.BadResource)) throw e;
      }
    }, { once: true });
    return this.#kv.listenQueue(handler);
  }

  [Symbol.dispose](): void {
    try {
      this.#kv.close();
    } catch (e) {
      if (!(e instanceof Deno.errors.BadResource)) throw e;
    }
  }
}
+116 −0
Original line number Diff line number Diff line
/**
 * Fedify with Fresh
 * =================
 *
 * This module contains some utilities for integrating Fedify with [Fresh],
 * a web framework for Deno.
 *
 * [Fresh]: https://fresh.deno.dev/
 *
 * @module
 * @since 0.4.0
 */
import type {
  Federation,
  FederationFetchOptions,
} from "../federation/federation.ts";

interface FreshContext {
  next(): Promise<Response>;
}

/**
 * Create options for the {@link Federation.fetch} method to integrate with
 * Fresh.
 *
 * @example _middleware.ts
 * ``` typescript
 * import { integrateFetchOptions } from "@fedify/fedify/x/fresh";
 * import { FreshContext } from "$fresh/server.ts";
 * import { federation } from "./federation.ts"; // Import the `Federation` object
 *
 * export async function handler(request: Request, context: FreshContext) {
 *   return await federation.fetch(request, {
 *     contextData: undefined,
 *     ...integrateHandlerOptions(context),
 *   })
 * }
 * ```
 *
 * @param context A Fresh context.
 * @returns Options for the {@link Federation.fetch} method.
 * @since 0.6.0
 */
export function integrateFetchOptions(
  context: FreshContext,
): Omit<FederationFetchOptions<void>, "contextData"> {
  return {
    // If the `federation` object finds a request not responsible for it
    // (i.e., not a federation-related request), it will call the `next`
    // provided by the Fresh framework to continue the request handling
    // by the Fresh:
    onNotFound: context.next.bind(context),

    // Similar to `onNotFound`, but slightly more tricky one.
    // When the `federation` object finds a request not acceptable type-wise
    // (i.e., a user-agent doesn't want JSON-LD), it will call the `next`
    // provided by the Fresh framework so that it renders HTML if there's some
    // page.  Otherwise, it will simply return a 406 Not Acceptable response.
    // This kind of trick enables the Fedify and Fresh to share the same routes
    // and they do content negotiation depending on `Accept` header:
    async onNotAcceptable(_request: Request) {
      const response = await context.next();
      if (response.status !== 404) return response;
      return new Response("Not acceptable", {
        status: 406,
        headers: {
          "Content-Type": "text/plain",
          Vary: "Accept",
        },
      });
    },
  };
}

/**
 * Create a Fresh middleware handler to integrate with the {@link Federation}
 * object.
 *
 * @example _middleware.ts
 * ``` typescript
 * import { integrateHandler } from "@fedify/fedify/x/fresh";
 * import { federation } from "./federation.ts"; // Import the `Federation` object
 *
 * export const handler = integrateHandler(federation, () => undefined);
 * ```
 *
 * @template TContextData A type of the context data for the {@link Federation}
 *                         object.
 * @template TFreshContext A type of the Fresh context.
 * @param federation A {@link Federation} object to integrate with Fresh.
 * @param createContextData A function to create a context data for the
 *                          {@link Federation} object.
 * @returns A Fresh middleware handler.
 * @since 0.4.0
 */
export function integrateHandler<
  TContextData,
  TFreshContext extends FreshContext,
>(
  federation: Federation<TContextData>,
  createContextData: (
    req: Request,
    ctx: TFreshContext,
  ) => TContextData | Promise<TContextData>,
): (req: Request, ctx: TFreshContext) => Promise<Response> {
  return async (
    request: Request,
    context: TFreshContext,
  ): Promise<Response> => {
    const contextData = await createContextData(request, context);
    return await federation.fetch(request, {
      contextData,
      ...integrateFetchOptions(context),
    });
  };
}
+103 −0
Original line number Diff line number Diff line
/**
 * Fedify with Hono
 * ================
 *
 * This module provides a [Hono] middleware to integrate with the Fedify.
 *
 * [Hono]: https://hono.dev/
 *
 * @module
 * @since 0.6.0
 */
import type {
  Federation,
  FederationFetchOptions,
} from "../federation/federation.ts";

interface HonoRequest {
  raw: Request;
}

interface HonoContext {
  req: HonoRequest;
  res: Response;
}

type HonoMiddleware<THonoContext extends HonoContext> = (
  ctx: THonoContext,
  next: () => Promise<void>,
) => Promise<Response | void>;

/**
 * A factory function to create a context data for the {@link Federation}
 * object.
 *
 * @template TContextData A type of the context data for the {@link Federation}
 *                         object.
 * @template THonoContext A type of the Hono context.
 * @param context A Hono context object.
 * @returns A context data for the {@link Federation} object.
 */
export type ContextDataFactory<TContextData, THonoContext> = (
  context: THonoContext,
) => TContextData | Promise<TContextData>;

/**
 * Create a Hono middleware to integrate with the {@link Federation} object.
 *
 * @template TContextData A type of the context data for the {@link Federation}
 *                         object.
 * @template THonoContext A type of the Hono context.
 * @param federation A {@link Federation} object to integrate with Hono.
 * @param contextDataFactory A function to create a context data for the
 *                           {@link Federation} object.
 * @returns A Hono middleware.
 */
export function federation<TContextData, THonoContext extends HonoContext>(
  federation: Federation<TContextData>,
  contextDataFactory: ContextDataFactory<TContextData, THonoContext>,
): HonoMiddleware<THonoContext> {
  return async (ctx, next) => {
    let contextData = contextDataFactory(ctx);
    if (contextData instanceof Promise) contextData = await contextData;
    return await federation.fetch(ctx.req.raw, {
      contextData,
      ...integrateFetchOptions(ctx, next),
    });
  };
}

function integrateFetchOptions<THonoContext extends HonoContext>(
  ctx: THonoContext,
  next: () => Promise<void>,
): Omit<FederationFetchOptions<void>, "contextData"> {
  return {
    // If the `federation` object finds a request not responsible for it
    // (i.e., not a federation-related request), it will call the `next`
    // provided by the Hono framework to continue the request handling
    // by the Hono:
    async onNotFound(_req: Request): Promise<Response> {
      await next();
      return ctx.res;
    },

    // Similar to `onNotFound`, but slightly more tricky one.
    // When the `federation` object finds a request not acceptable type-wise
    // (i.e., a user-agent doesn't want JSON-LD), it will call the `next`
    // provided by the Hono framework so that it renders HTML if there's some
    // page.  Otherwise, it will simply return a 406 Not Acceptable response.
    // This kind of trick enables the Fedify and Hono to share the same routes
    // and they do content negotiation depending on `Accept` header:
    async onNotAcceptable(_req: Request): Promise<Response> {
      await next();
      if (ctx.res.status !== 404) return ctx.res;
      return new Response("Not acceptable", {
        status: 406,
        headers: {
          "Content-Type": "text/plain",
          Vary: "Accept",
        },
      });
    },
  };
}
+87 −0
Original line number Diff line number Diff line
/**
 * Fedify with SvelteKit
 * =====================
 *
 * This module provides a [SvelteKit] hook to integrate with the Fedify.
 *
 * [SvelteKit]: https://kit.svelte.dev/
 *
 * @module
 * @since 1.3.0
 */

import type {
  Federation,
  FederationFetchOptions,
} from "../federation/federation.ts";

type RequestEvent = {
  request: Request;
};

type HookParams = {
  event: RequestEvent;
  resolve: (event: RequestEvent) => Promise<Response>;
};

/**
 * Create a SvelteKit hook handler to integrate with the {@link Federation}
 * object.
 *
 * @example hooks.server.ts
 * ``` typescript
 * import { federation } from "./federation"; // Import the `Federation` object
 *
 * export const handle = fedifyHook(federation, () => undefined);
 * ```
 *
 * @template TContextData A type of the context data for the {@link Federation}
 *                         object.
 * @param federation A {@link Federation} object to integrate with SvelteKit.
 * @param createContextData A function to create a context data for the
 *                          {@link Federation} object.
 * @returns A SvelteKit hook handler.
 * @since 1.3.0
 */
export function fedifyHook<TContextData>(
  federation: Federation<TContextData>,
  createContextData: (
    event: RequestEvent,
  ) => TContextData | Promise<TContextData>,
): (params: HookParams) => Promise<Response> {
  return async ({ event, resolve }: HookParams) => {
    return await federation.fetch(event.request, {
      contextData: await createContextData(event),
      ...integrateFetchOptions({ event, resolve }),
    });
  };
}

function integrateFetchOptions(
  { event, resolve }: HookParams,
): Omit<FederationFetchOptions<void>, "contextData"> {
  return {
    async onNotFound(): Promise<Response> {
      return await resolve(event);
    },

    // Similar to `onNotFound`, but slightly more tricky one.
    // When the `federation` object finds a request not acceptable type-wise
    // (i.e., a user-agent doesn't want JSON-LD), it will call the `resolve`
    // provided by the SvelteKit framework so that it renders HTML if there's some
    // page.  Otherwise, it will simply return a 406 Not Acceptable response.
    // This kind of trick enables the Fedify and SvelteKit to share the same routes
    // and they do content negotiation depending on `Accept` header:
    async onNotAcceptable(): Promise<Response> {
      const res = await resolve(event);
      if (res.status !== 404) return res;
      return new Response("Not acceptable", {
        status: 406,
        headers: {
          "Content-Type": "text/plain",
          Vary: "Accept",
        },
      });
    },
  };
}