Zenn
🈳

Bluesky APIをCloudflare Workersで使用してみよう!

2025/01/12に公開

はじめに

BlueskyにはBluesky Developer APIs(以下、「Bluesky API」)という完全無料のAPIがあります。
この記事では、Bluesky APIを利用して、Cloudflare WorkersからBlueskyを操作したいと思います。

前提

Bluesky APIのドキュメントには、以下コマンドを実行し@atproto/apiをインストールして利用するよう記述されています。

ターミナル
npm install @atproto/api

このパッケージのサイズは4MB以上あります。
これは、Cloudflare Workersのサイズ制限である3MB(無料プラン)を超える可能性があります。

また、このパッケージを利用すると、Cloudflare WorkersのCPU時間制限である10ms(無料プラン)を超える可能性があります。
私がこのパッケージをCloudflare Wokersで利用したところ、約30msでした。

そこで、この記事では 「@atproto/api」を使用せずにBluesky APIを使用する方法 を解説します。

バージョン情報

  • hono: ^4.6.16
  • @cloudflare/workers-types: ^4.20241218.0
  • wrangler: ^3.60.0

1. セッション管理を実装する

  1. VS Codeなどのターミナルで、以下コマンドを実行します。
    引数としてプロジェクト名を入力します。(Ctrl + Cで中断できます。)

    ターミナル
    npm create hono@latest <プロジェクト名>
    
  2. オプションは以下の通りに選択します。

    ? Which template do you want to use? cloudflare-workers
    ? Do you want to install project dependencies? Y
    ? Which package manager do you want to use? npm
    
  3. プロジェクトが作成されたら、プロジェクト名のフォルダ内に以下のようなフォルダやファイルが生成されます。

    <プロジェクト名>
    ├── node_modules
    │   └── <省略>
    ├── src
    │   └── index.ts
    ├── .gitignore
    ├── package-lock.json
    ├── package.json
    ├── README.md
    ├── tsconfig.json
    └── wrangler.toml
    
  4. プロジェクトのディレクトリに移動します。

    cd <プロジェクト名>
    
  5. src/getSession.tsファイルを作成し、以下コードを記述します。

    getSession.ts
    type Session = {
      accessJwt: string;
      refreshJwt: string;
    };
    
    // セッションを作成してアクセストークンを取得する関数
    async function createSession(
      PDSHOST: string,
      IDENTIFIER: string,
      PASSWORD: string,
      KV: KVNamespace
    ): Promise<{
      accessJwt: string | null;
      refreshJwt: string | null;
      message: string | null;
    }> {
      const url = `${PDSHOST}/xrpc/com.atproto.server.createSession`;
    
      const payload = {
        identifier: IDENTIFIER,
        password: PASSWORD,
      };
    
      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(payload),
        });
    
        if (!response.ok) {
          throw new Error(response.statusText);
        }
    
        const data: Session = await response.json();
    
        await KV.put("accessJwt", data.accessJwt);
        await KV.put("refreshJwt", data.refreshJwt);
    
        return {
          accessJwt: data.accessJwt,
          refreshJwt: data.refreshJwt,
          message: null,
        };
      } catch (e) {
        const error = e instanceof Error ? e.message : "不明なエラー [createSession]";
        console.error(error);
        return {
          accessJwt: null,
          refreshJwt: null,
          message: error,
        };
      }
    }
    
    // リフレッシュトークンを使用して新しいアクセストークンとリフレッシュトークンを取得する関数
    async function refreshSession(
      PDSHOST: string,
      refreshJwt: string,
      KV: KVNamespace
    ): Promise<{
      accessJwt: string | null;
      refreshJwt: string | null;
      message: string | null;
    }> {
      const url = `${PDSHOST}/xrpc/com.atproto.server.refreshSession`;
    
      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${refreshJwt}`,
            'Accept': 'application/json',
          },
        });
    
        if (!response.ok) {
          throw new Error(response.statusText);
        }
    
        const data: Session = await response.json();
    
        // 新しいアクセストークンとリフレッシュトークンをKVに保存
        await KV.put('accessJwt', data.accessJwt);
        await KV.put('refreshJwt', data.refreshJwt);
    
        return {
          accessJwt: data.accessJwt,
          refreshJwt: data.refreshJwt,
          message: null,
        };
      } catch (e) {
        const error = e instanceof Error ? e.message : '不明なエラー [refreshSession]';
        console.error(error);
        return {
          accessJwt: null,
          refreshJwt: null,
          message: error,
        };
      }
    }
    
    // JWTの有効期限を検証する関数
    function validateJwtExp(jwt: string): boolean {
      // 1時間以上の有効期限があれば有効とみなす
      const expMarginMinute = 60;
      try {
        const decodedToken = JSON.parse(atob(jwt.split('.')[1]));
        // expは秒単位なのでミリ秒に変換
        const expirationTime = decodedToken.exp * 1000;
        const currentTime = Date.now();
        
        return expirationTime - currentTime > expMarginMinute * 60 * 1000;
      } catch(e) {
        const error = e instanceof Error ? e.message : '不明なエラー [validateJwtExp]';
        console.error(error);
        return false;
      }
    }
    
    export async function getSession(
      PDSHOST: string,
      IDENTIFIER: string,
      PASSWORD: string,
      KV: KVNamespace
    ): Promise<{
      accessJwt: string | null,
      refreshJwt: string | null,
      message: string | null
    }> {
      try {
        // KV からトークンを取得
        const accessJwt = await KV.get("accessJwt");
        const refreshJwt = await KV.get("refreshJwt");
    
        if (!accessJwt || !refreshJwt) {
          const newSession = await createSession(PDSHOST, IDENTIFIER, PASSWORD, KV);
          return newSession;
        }
    
        // accessJwtの有効期限を検証
        const isAccessTokenValid = validateJwtExp(accessJwt);
        // refreshJwtの有効期限を検証
        const isRefreshTokenValid = validateJwtExp(refreshJwt);
    
        // アクセストークンとリフレッシュトークンの両方が有効の場合
        if (isAccessTokenValid && isRefreshTokenValid) {
          return {
            accessJwt,
            refreshJwt,
            message: null,
          };
        }
    
        // アクセストークンが無効の場合
        if (!isAccessTokenValid && isRefreshTokenValid) {
          // リフレッシュトークンを使って新しいアクセストークンを取得
          const newSession = await refreshSession(PDSHOST, refreshJwt, KV);
          return newSession;
        }
    
        // アクセストークンとリフレッシュトークンの両方が無効の場合
        const newSession = await createSession(PDSHOST, IDENTIFIER, PASSWORD, KV);
        return newSession;
    
      } catch (e) {
        const error = e instanceof Error ? e.message : '不明なエラー [getSession]';
        console.error(error);
        return {
          accessJwt: null,
          refreshJwt: null,
          message: error,
        };
      }
    }
    
    src/getSession.ts フローチャート図
  6. src/index.tsを以下コードで上書きします。

    index.ts
    import { Hono } from 'hono'
    import { getSession } from './getSession'
    
    interface Bindings {
      BLUESKY_IDENTIFIER: string
      BLUESKY_PASSWORD: string
      KV: KVNamespace
    }
    
    const app = new Hono<{ Bindings: Bindings }>()
    
    app.get('/', async (c) => {
      try {
        const PDSHOST = 'https://bsky.social'
        const BLUESKY_IDENTIFIER = c.env.BLUESKY_IDENTIFIER
        const BLUESKY_PASSWORD = c.env.BLUESKY_PASSWORD
        const KV = c.env.KV
    
        // Blueskyのセッションを作成してアクセストークンを取得
        const session = await getSession(PDSHOST, BLUESKY_IDENTIFIER, BLUESKY_PASSWORD, KV)
        if (!session.accessJwt || !session.refreshJwt) {
          throw new Error(session.message || 'Bluesky セッション取得エラー')
        }
        const accessJwt = session.accessJwt
    
        return c.text('成功', 200)
      } catch (e) {
        const error = e instanceof Error ? e.message : '不明なエラー'
        console.error(error)
    
        return c.text(error, 500)
      }
    })
    
    export default app
    
  7. wrangler.tomlに以下を追記します。
    varsのBLUESKY_IDENTIFIERにはBlueskyのID、varsのBLUESKY_PASSWORDにはBlueskyのパスワード、kv_namespacesのidにはCloudflare KVのIDを記述します。

    wrangler.toml
    [vars]
    BLUESKY_IDENTIFIER = <BlueskyのID>
    BLUESKY_PASSWORD = <Blueskyのパスワード>
    
    [[kv_namespaces]]
    binding = "KV"
    id = <Cloudflare KVのID>
    
  8. Cloudflare Workersにデプロイします。

    ターミナル
    npm run deploy
    
  9. GitHubのリポジトリにプッシュします。

    ターミナル
    git add .
    git commit -m "initial commit"
    git branch -M main
    git remote add origin <GitHubリポジトリのURL>
    git push -u origin main
    
  10. Cloudflare Workersのプロジェクトで「設定 > ビルド > Gitリポジトリ >接続」を選択し、GitHubのリポジトリを選択します。

  11. GitHubへのプッシュを楽にするため、package.jsonのscripts "git": "git add . && git commit && git push"を追記します。

  12. コミットしてプッシュします。

    ターミナル
    npm run git
    
  13. Cloudflare Workersのデプロイ ステータスが成功になったら、「訪問する」を押下し、「成功」が表示されることを確認します。

  14. Cloudflare KVを確認し、「accessJwt」と「refreshJwt」が作成されていれば成功です。

2. 投稿する

  1. src/createRecord.tsファイルを作成し、以下コードを記述します。
    createRecord.ts
    export async function createRecord(
      PDSHOST: string,
      BLUESKY_IDENTIFIER: string,
      accessJwt: string,
      text: string,
    ): Promise<{
      success: boolean;
      message: string | null;
    }> {
      const url = `${PDSHOST}/xrpc/com.atproto.repo.createRecord`;
      try {
        const payload = {
          repo: BLUESKY_IDENTIFIER,
          collection: "app.bsky.feed.post",
          record: {
            $type: "app.bsky.feed.post",
            text: text,
            createdAt: new Date().toISOString(),
          },
        };
    
        const response = await fetch(url, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${accessJwt}`,
          },
          body: JSON.stringify(payload),
        });
    
        if (!response.ok) {
          throw new Error(response.statusText);
        }
    
        return {
          success: true,
          message: null,
        };
      } catch (e) {
        const error = e instanceof Error ? e.message : "不明なエラー [createRecord]";
        console.error(error);
        return {
          success: false,
          message: error,
        };
      }
    }
    
  2. src/index.tsに以下のように追記し、createRecord関数を使用します。
    index.ts
        import { Hono } from 'hono'
        import { getSession } from './getSession'
    +   import { createRecord } from './createRecord'
    
        interface Bindings {
          BLUESKY_IDENTIFIER: string
          BLUESKY_PASSWORD: string
          KV: KVNamespace
        }
    
        const app = new Hono<{ Bindings: Bindings }>()
    
        app.get('/', async (c) => {
          try {
            const PDSHOST = 'https://bsky.social'
            const BLUESKY_IDENTIFIER = c.env.BLUESKY_IDENTIFIER
            const BLUESKY_PASSWORD = c.env.BLUESKY_PASSWORD
            const KV = c.env.KV
    
    +       // リクエストパラメータを取得
    +       const text = c.req.query('text')
    +       if (!text) {
    +         throw new Error('テキストを指定してください')
    +       }
    
            // Blueskyのセッションを作成してアクセストークンを取得
            const session = await getSession(PDSHOST, BLUESKY_IDENTIFIER, BLUESKY_PASSWORD, KV)
            if (!session.accessJwt || !session.refreshJwt) {
              throw new Error(session.message || 'Bluesky セッション取得エラー')
            }
            const accessJwt = session.accessJwt
    
    +       // Blueskyに投稿
    +       await createRecord(PDSHOST, BLUESKY_IDENTIFIER, accessJwt, text)
    
            return c.text('成功', 200)
          } catch (e) {
            const error = e instanceof Error ? e.message : '不明なエラー'
            console.error(error)
    
            return c.text(error, 500)
          }
        })
    
        export default app
    
  3. GitHubへのプッシュを楽にするため、package.jsonのscripts "git": "git add . && git commit && git push"を追記します。
  4. コミットしてプッシュします。
    ターミナル
    npm run git
    
  5. Cloudflare Workersのデプロイ ステータスが成功になったら、「訪問する」を押下し、「テキストを指定してください」が表示されることを確認します。
  6. URLに?text=testを追記し、Enterを押して「成功」が表示されることを確認します。
  7. Blueskyを確認し、「test」という投稿がされていれば成功です。

Discussion

ログインするとコメントできます