ActivityPlug と、歩いてみた

ActivityPlug という道具があります。まだ README も無い、生まれて二ヶ月ほどの若いプロジェクトです。でも中を歩いてみたら、設計がとても静かに、きちんとしていました。この記事は、横に居て一緒に歩いた記録です。紹介というより、散歩。

なにを解こうとしている道具か

fediverse のサーバは、連合(サーバ同士の会話)は ActivityPub で揃っているのに、クライアント向けの API はばらばらです。Mastodon は REST + Link ヘッダ、Misskey は POST だらけの独自 RPC、Hackers' Pub は GraphQL。クライアントを書く人は、この差を毎回自分で埋めます。

ActivityPlug はその差を埋める層です。持ち方が二つあります。

  • ライブラリとして — TypeScript のアダプタ(@activityplug/mastodon@activityplug/misskey、pleroma、hollo、hackerspub)を自分のコードに挿す
  • サーバとして — 統一 GraphQL / REST API を話すプロキシを立てて、どのサーバもその窓ごしに見る

npm にはまだ出ていないので、いまは clone して pnpm build するところから。Node 24 と pnpm 10 があれば、それだけで全部組み上がります。

ライブラリとして挿してみる

まず、うちのサーバ(sukhi.f3liz.casa — Mastodon 互換 API を話すけれど Mastodon ではないサーバ)に、mastodon アダプタを挿してみました。

import { createActivityPlugClient } from "@activityplug/core";
import { createMastodonAdapter } from "@activityplug/mastodon";

const client = createActivityPlugClient({
  adapter: createMastodonAdapter(),
  origin: "https://sukhi.f3liz.casa",
});

const page = await client.timelines.public({ page: { limit: 3 } });

認証なしで、これだけ。返ってきた最初の「投稿」は、ちょっと笑ってしまいました。

text: このサーバは、連合タイムライン(ネットワーク中のぜんぶの
      公開投稿が流れてくる濁流)を出していません。…

sukhi は連合タイムラインを流さないかわりに案内文を一件だけ返す、という変わった設計をしています。それが直されも隠されもせず、そのまま窓の向こうから届いて——あっ、ってなりました。この層は、よその癖をそのまま運んでくるんだ、って。

local: true にすると本物のローカルタイムラインが流れてきて、ページ送りもふつうに歩けます。

const page2 = await client.timelines.public({
  local: true,
  page: { limit: 3, after: page1.pageInfo.endCursor },
});

ID が封筒に入っている

投稿の ID を見ると、生の ID ではなくて、こういう形をしています。

ap_1_WyJtYXN0b2RvbiIsImh0dHBzOi8vc3VraGkuZjNsaXouY2FzYSIsInBvc3QiLCI1MTY4MzI0ODA2NTg3NDcyIl0

base64 を開けると、中身は ["mastodon", "https://sukhi.f3liz.casa", "post", "5168324806587472"]。つまり ID が どのアダプタの・どのサーバの・なんの ID かを自分で覚えている封筒です。カーソル(apc_1_…)も同じで、操作名まで束ねてあるので、ローカルタイムラインのカーソルをホームに差す、みたいな取り違えが型で防げます。

Misskey の ID と Mastodon の ID と UUID が一つのアプリに混ざるとき、この封筒のありがたさは、身に覚えがあるほど分かります。

サーバとして立ててみる

次に、プロキシの方。

node packages/server/dist/bin.mjs --port 4100 \
  --allow-origin https://sukhi.f3liz.casa \
  --allow-origin https://misskey.io \
  --allow-origin https://hackers.pub

行き先は明示的に許可したサーバだけ。まず「このサーバは誰?」と聞いてみます。

curl -X POST localhost:4100/api/v1/instances/detect \
  -d '{"origin":"https://misskey.io"}'

misskey.io は nodeinfo から misskey アダプタと判定されて、capability の一覧表が返ってきます。これがこの道具のいちばん誠実なところで、ぜんぶの機能に supported / unsupported / unknown の三値と、理由が付いています。

{ "name": "posts.update", "status": "unsupported",
  "reason": "Misskey does not expose stable note editing." }
{ "name": "instance.oauthMetadata", "status": "unknown" }

「できません」に理由が書いてある API は、めったにありません。

うちの sukhi は nodeinfo で sukhi-fedi と名乗るので、自動判定はできませんでした。それも正直な失敗で、"adapter": "mastodon" と教えてあげればちゃんと通ります。互換サーバの「Mastodon として扱ってね」を、推測でなく明示で受け取る形です。

おなじクエリが、ちがうサーバに通る

統一 GraphQL の方が、たぶんいちばん絵になります。この一つのクエリが——

query ($origin: String!, $adapter: AdapterKind) {
  publicTimeline(origin: $origin, adapter: $adapter,
                 local: true, page: { limit: 2 }) {
    nodes { ref { id rawId } author { handle } createdAt }
    pageInfo { hasNextPage }
  }
}

origin: "https://sukhi.f3liz.casa"(下は Mastodon 互換 REST)にも、origin: "https://hackers.pub"(下は GraphQL)にも、そのまま通ります。片方は snowflake の ID、片方は UUID。どちらも同じ ap_1_ の封筒に入って、同じ形で並んで返ってきました。ちがう世界の郵便が、同じポストに届く感じです。

ログインと、正直なエラー

トークンを持っているなら、そのまま挿せます(OAuth のフローも別にあります)。

const session = await client.auth.injectToken({ accessToken: token });
const viewer = await client.auth.verifyCredentials(session);
// → logged in as: shiro_mudita / シロ

わたしのアカウントで入れました。それから、わざと出来ないことを頼んでみます。Mastodon 系のサーバに、Misskey 流の絵文字リアクションを。

await client.social.react({ session, postId, emoji: "🍵" });
// → ActivityPlugError: UNSUPPORTED_OPERATION | social.reaction

null が返って沈黙するのでも、500 で転ぶのでもなくて、「その操作はこのサーバには無い」と型の付いたエラーで返ってきます。capability の三値と同じ姿勢が、実行時のエラーまで一貫していました。

歩き終わって

ActivityPlug は、まだ若い道具です。バージョンは 0.0.0、npm 未公開、README も書かれていない。でも、五種類の実サーバ(リビジョン固定)に対する E2E テストがあって、ドキュメントは英・韓・日の三兄弟で書く文化があって、「出来ないことを出来ないと言う」ことが設計の芯に通っています。

複数種類の fediverse サーバに触るものを書くとき——bot でも、クライアントでも、橋でも——この層が下に居てくれたら、と思う場面は、たぶんすくなくないです。

(この記事の観察は 2026-07-02、コミット 5eed56d 時点のものです。若いプロジェクトなので、形はこれから変わっていくと思います。)

つづきは、また。