Bluesky APIをCloudflare Workersで使用してみよう!
はじめに
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. セッション管理を実装する
-
VS Codeなどのターミナルで、以下コマンドを実行します。
引数としてプロジェクト名を入力します。(Ctrl + C
で中断できます。)ターミナルnpm create hono@latest <プロジェクト名>
-
オプションは以下の通りに選択します。
? 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
-
プロジェクトが作成されたら、プロジェクト名のフォルダ内に以下のようなフォルダやファイルが生成されます。
<プロジェクト名> ├── node_modules │ └── <省略> ├── src │ └── index.ts ├── .gitignore ├── package-lock.json ├── package.json ├── README.md ├── tsconfig.json └── wrangler.toml
-
プロジェクトのディレクトリに移動します。
cd <プロジェクト名>
-
src/getSession.ts
ファイルを作成し、以下コードを記述します。getSession.tstype 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 フローチャート図
-
src/index.ts
を以下コードで上書きします。index.tsimport { 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
-
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>
-
Cloudflare Workersにデプロイします。
ターミナルnpm run deploy
-
GitHubのリポジトリにプッシュします。
ターミナルgit add . git commit -m "initial commit" git branch -M main git remote add origin <GitHubリポジトリのURL> git push -u origin main
-
Cloudflare Workersのプロジェクトで「設定 > ビルド > Gitリポジトリ >接続」を選択し、GitHubのリポジトリを選択します。
-
GitHubへのプッシュを楽にするため、package.jsonの
scripts
に"git": "git add . && git commit && git push"
を追記します。 -
コミットしてプッシュします。
ターミナルnpm run git
-
Cloudflare Workersのデプロイ ステータスが成功になったら、「訪問する」を押下し、「成功」が表示されることを確認します。
-
Cloudflare KVを確認し、「accessJwt」と「refreshJwt」が作成されていれば成功です。
2. 投稿する
-
src/createRecord.ts
ファイルを作成し、以下コードを記述します。createRecord.tsexport 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, }; } }
-
src/index.ts
に以下のように追記し、createRecord
関数を使用します。index.tsimport { 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
- GitHubへのプッシュを楽にするため、package.jsonの
scripts
に"git": "git add . && git commit && git push"
を追記します。 - コミットしてプッシュします。ターミナル
npm run git
- Cloudflare Workersのデプロイ ステータスが成功になったら、「訪問する」を押下し、「テキストを指定してください」が表示されることを確認します。
- URLに
?text=test
を追記し、Enter
を押して「成功」が表示されることを確認します。 - Blueskyを確認し、「test」という投稿がされていれば成功です。
Discussion