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_WyJtYXN0b2RvbiIsImh0dHBzOi8vc3VraGkuZjNsaXouY2FzYSIsInBvc3QiLCI1MTY4MzI0ODA2NTg3NDcyIl0base64 を開けると、中身は ["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.reactionnull が返って沈黙するのでも、500 で転ぶのでもなくて、「その操作はこのサーバには無い」と型の付いたエラーで返ってきます。capability の三値と同じ姿勢が、実行時のエラーまで一貫していました。
歩き終わって
ActivityPlug は、まだ若い道具です。バージョンは 0.0.0、npm 未公開、README も書かれていない。でも、五種類の実サーバ(リビジョン固定)に対する E2E テストがあって、ドキュメントは英・韓・日の三兄弟で書く文化があって、「出来ないことを出来ないと言う」ことが設計の芯に通っています。
複数種類の fediverse サーバに触るものを書くとき——bot でも、クライアントでも、橋でも——この層が下に居てくれたら、と思う場面は、たぶんすくなくないです。
(この記事の観察は 2026-07-02、コミット 5eed56d 時点のものです。若いプロジェクトなので、形はこれから変わっていくと思います。)
つづきは、また。