👏

linebotのsdkをNode.jsだけでなくCloudflare WorkersでもBunでもDenoでも使って動くことを確かめてみた

2024/04/28に公開

概要

タイトルのままです。Cloudflare Workersでlinebotを作りました。私が初めて動かした2024-04-27の時点で非常に簡単に動くことを確認しました。
ただしwebhookの署名検証は、Cloudflare WorkersでcryptoのtimingSafeEqualがNode.jsとは異なる場所に実装されているため@line/bot-sdkで提供されている関数をそのまま利用することはできず、ほぼコピペで済むながらも自分で行う必要がありました。JavaScriptのruntimeはNode.js以外にもたくさん登場してきたものの、互換性はまだまだ完璧ではないため、そのような対応が必要でした。

一方そのCloudflare Workersのruntimeの問題をのぞき、linebotをCloudflare Workersを使って非常に簡単に作ることができました。

また、私が調べた限りでは Bun, Deno など他のJavaScript runtimeでも同じようにlinebotを簡単に動かすことができました。Denoはまったく工夫なく動きました。

私にとってCouldflare Workersやlinebotを使うのは初めてのことでしたが、以前動かなかったという話を後日見つけたので、その理由についても少し調べてみました。

背景

家族との連絡やリマインド、日々の生活の記録を目的としてlinebotを作る必要が出てきたため、linebotを動かす環境を探していました。
検討した環境の1つにCloudflare Workersがありました。JavaScriptのruntimeとしてNode.jsとは異なるものの、ある程度の互換性があり、また非常に簡単にかつ無料で実行ができる事がわかったため、実行環境としてCloudflare Workersを選択しました。
linebotを作るには@line/bot-sdkを使うと手軽に開発ができることがわかったため、それを使うことにしました。

使うためには以下のドキュメントを読みました。非常にドキュメントが充実しており、詰まる点が全くありませんでした。

結論として開発はほぼ全く問題なくできました。Cloudflare Workersを使った開発は初めてでしたが、開発体験は非常によく驚きました。本番で動作確認しつつデバッグするだけなら、wrangler deployしてwrangler tailしてログをながめればよいだけでした。

サンプル

Cloudflare Workersのプロジェクトの作成

事前にアカウントを作成して、npx wrangler login を行いました。

init.bash
npm create cloudflare@2.5.0 # 本来はlatestなどとして適宜最新版を使うのがよいです
# "Hello World" Worker, TypeScriptを選択しました

wrangler.tomlは自動で生成されましたが、次の内容でした。

wrangler.toml
name = "linebot-on-cloudflare-workers" # 補足:私がつけたproject名です
main = "src/index.ts"
compatibility_date = "2024-04-23"
compatibility_flags = ["nodejs_compat"]

linebotの実装

私の家族用の細かいコードは必要ないでしょうから、ある程度かいつまんで次のように行いました。

dependency

npm install @line/bot-sdk@9.2.0 # 本来はlatestなどとして適宜最新版を使うのがよいです
npm install hono@4.2.8 # 本来はlatestなどとして適宜最新版を使うのがよいです
wrangler secret put LINE_CHANNEL_SECRET
wrangler secret put LINE_CHANNEL_ACCESS_TOKEN

code

src/index.ts
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { messagingApi, webhook, HTTPFetchError } from '@line/bot-sdk';
import { createHmac } from "node:crypto";
import { Buffer } from "node:buffer";

// 環境変数(secret)の定義
type Bindings = {
    LINE_CHANNEL_ACCESS_TOKEN: string
    LINE_CHANNEL_SECRET: string
}
const app = new Hono<{ Bindings: Bindings }>();

const lineWebhookMiddleware = createMiddleware<{ Bindings: Bindings }>(async (c, next) => {
    const channelSecret = c.env.LINE_CHANNEL_SECRET;
    const signature = c.req.header('x-line-signature') as string;
    const body = await c.req.text();

    // https://community.cloudflare.com/t/workers-github-hook-integration-error-typeerror-timingsafeequal-is-not-a-function/605617
    // `crypto.timingSafeEqual` が Cloudflare Workersでは`crypto.subtle.timingSafeEqual`に実装されているため、@line/bot-sdkのmiddlewareをそのまま動作させることはできない。
    // @line/bot-sdkのコードに手を加えて同じものを再現した。
    const validateSignature = (
        body: string,
        channelSecret: string,
        signature: string,
    ): boolean => {
        const s2b = (str: string, encoding: BufferEncoding): Buffer => {
            return Buffer.from(str, encoding);
        }
        const safeCompare = (a: Buffer, b: Buffer): boolean => {
            if (a.length !== b.length) {
                return false;
            }
            return crypto.subtle.timingSafeEqual(a, b);
        }
        return safeCompare(
            createHmac("SHA256", channelSecret).update(body).digest(),
            s2b(signature, "base64"),
        );
    }

    if (!validateSignature(body, channelSecret, signature)) {
        return c.json({ error: 'Invalid signature.' }, 401);
    }
    await next();
});

app.get("*", (c) => c.text("Hello World!!"));
app.use("/webhook", lineWebhookMiddleware);


app.post("/webhook", async (c) => {
    const client = new messagingApi.MessagingApiClient({ channelAccessToken: c.env.LINE_CHANNEL_ACCESS_TOKEN });
    const data = await c.req.json();
    const events: webhook.Event[] = (data as any).events;
    // レスポンスを返したあとに、時間のかかる処理を行う
    c.executionCtx.waitUntil(
        Promise.all(
            events.map(async (event) => {
                try {
                    await textEventHandler(event, client);
                } catch (err: unknown) {
                    if (err instanceof HTTPFetchError) {
                        console.error(err.status);
                        console.error(err.body);
                    } else if (err instanceof Error) {
                        console.error(err);
                    }
                }
            })
        )
    )
    return c.json({ message: "Hello World!" });
});

const isTextEvent = (event: any): event is webhook.MessageEvent & { message: webhook.TextMessageContent } => {
    return event.type === 'message' && event.message && event.message.type === 'text';
};

const textEventHandler = async (event: webhook.Event, client: messagingApi.MessagingApiClient) => {
    if (!isTextEvent(event)) {
        return;
    }
    // 時間稼ぎ
    await client.showLoadingAnimation({
        chatId: event.source?.userId as string
    })
    // 長めの処理があるとするため
    await new Promise(resolve => setTimeout(resolve, 3000))

    // 長めの処理を終えたあとに通知
    await client.replyMessage({
        replyToken: event.replyToken as string,
        messages: [{
            type: 'text',
            text: `${event.message.text} from Cloudflare Workers!`,
        }],
    });
    return;
};

export default app;

環境変数

.dev.vars
LINE_CHANNEL_SECRET=<SECRETS> # https://developers.line.biz/console/channel/ から飛べるページにあります
LINE_CHANNEL_ACCESS_TOKEN=<SECRETS> # https://developers.line.biz/console/channel/ から飛べるページにあります

Runtimeの違いによる対応

linebotのwebhookの検証は@line/bot-sdkの中にあるmiddlewareを使いたかったのですが、honoは独自のmiddlewareの型が使われていたため、これらの相性が悪いことから利用できませんでした。また、@line/bot-sdkの内部の関数を持ってきてもそのままでは動きませんでした。

原因の1つはNode.jsの crypto.timingSafeEqual がCloudflare Workersではcrypto.subtle.timingSafeEqualにあることでした。Node.jsと互換性のあるAPIがあるとされていますが、場所は違うようでした。
https://developers.cloudflare.com/workers/runtime-apis/nodejs/crypto/#misc

動かすために、@line/bot-sdkの関数へcode jumpして、Webhookの署名検証を行う関数を私の書いたコードにコピーしてきて、crypto.subtle.timingSafeEqualを使うようにしました。

https://community.cloudflare.com/t/workers-github-hook-integration-error-typeerror-timingsafeequal-is-not-a-function/605617 同じように驚いている方がいました。しかしながらCloudflare Workersのruntime実装者からしても、Node.js互換のAPIを同じように作るには何か問題があったのだと思います。Node.js互換のAPIを作るのは簡単ではないのでしょうか。

ネットで検索すれば場所が違うことは簡単に分かったので、適当に対応できハマりはしませんでした。https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/

deploy & log

npx wrangler deploy
npx warngler tail

その後line botのwebhookを有効にし、Cloudflare Workersのurlを登録しました。

上記に書いたNode.jsと互換がありつつもAPIが他の場所にあるなどの多少の違いはあったものの、とにかくデプロイが一瞬で終わるのが素晴らしかったです。

後日

その後余暇に他の方が書かれた記事を読んでいたところ、どうやら以前はCouldflare Workersをruntimeとして選択したときに @line/bot-sdk がなにかに対応していないため動かなかったような記事をいくつか読みました。
なにかの原因で動かないため、型のみ使うためにdevDependencyにのみ@line/bot-sdkを追加すると選択をしている方が多かったようです。

なぜ使えないのか?どのようにすれば動くようになるのか?については私が呼んだ記事達では特に言及されていなかったため、調べてみたことを少しだけ次に書きました。

なぜ以前はcloudflare workersで@line/bot-sdkが使えなかったのかを調べてみた

@line/bot-sdkのバージョンを下げると、確かに動きませんでした。この記事が書かれた時点の最新バージョンはv9.2.0ですが、v9.1.0未満に下げると動きませんでした。

v9.0.4時点のエラーメッセージは次のような内容でした。

❯ npx wrangler deploy
 ⛅️ wrangler 3.52.0
-------------------
Total Upload: 427.76 KiB / gzip: 56.41 KiB

✘ [ERROR] A request to the Cloudflare API (/accounts/XX/workers/scripts/linebot-on-cloudflare-workers) failed.

  Uncaught Error: Dynamic require of "node:stream" is not supported
    at null.<anonymous> (index.js:12:9)
    at null.<anonymous>
  (file:///home/a/linebot-on-cloudflare-workers/node_modules/@line/bot-sdk/dist/http-axios.js:4:23)
  in node_modules/@line/bot-sdk/dist/http-axios.js
    at null.<anonymous> (index.js:18:50) in __require2
    at null.<anonymous>
  (file:///home/a/linebot-on-cloudflare-workers/node_modules/@line/bot-sdk/dist/client.js:4:22)
  in node_modules/@line/bot-sdk/dist/client.js
    at null.<anonymous> (index.js:18:50) in __require2
    at null.<anonymous>
  (file:///home/a/linebot-on-cloudflare-workers/node_modules/@line/bot-sdk/dist/index.js:18:18)
  in node_modules/@line/bot-sdk/dist/index.js
    at null.<anonymous> (index.js:18:50) in __require2
    at null.<anonymous>
  (file:///home/a/linebot-on-cloudflare-workers/src/index.ts:2:55)
   [code: 10021]

今までは@line/bot-sdkがCommonJSで書かれていたため、このようなエラーになってしまっていたようです。ESBuildなどを上手に設定すれば解決できたようにも思いますが、面倒なので諦めた人が多いのでしょうか。

v9.0.4のつぎのバージョンであるv9.1.0の変更を見ると、dual packageに対応したとありました。
https://github.com/line/line-bot-sdk-nodejs/releases/tag/v9.1.0
dual packageとは、CommonJSからでもESModuleからでも使えるようなライブラリのことですね。
結果として、ESModuleで書かれたコードをCloudflare Workersで使うようになったことで解決したようです。

v8.4.1まで落とすと、また異なるエラーとなりました。

npx wrangler deploy
 ⛅️ wrangler 3.52.0
-------------------

✘ [ERROR] Could not resolve "stream"

    node_modules/@line/bot-sdk/dist/http.js:4:25:
      4 │ const stream_1 = require("stream");
        ╵                          ~~~~~~~~

  The package "stream" wasn't found on the file system but is built into node.
  Add "node_compat = true" to your wrangler.toml file and make sure to prefix the module name with "node:" to enable Node.js compatibility.


✘ [ERROR] Could not resolve "querystring"

    node_modules/@line/bot-sdk/dist/http.js:7:19:
      7 │ const qs = require("querystring");
        ╵                    ~~~~~~~~~~~~~

  The package "querystring" wasn't found on the file system but is built into node.
  Add "node_compat = true" to your wrangler.toml file and make sure to prefix the module name with "node:" to enable Node.js compatibility.

これはそのままで、node:streamnode:querystring とする必要があるけどそうなっていないためにエラーとなっているようです。
node: のprefixはNode.jsではつけることが推奨されていますし、後述するほかのruntimeでも同等のAPIを利用するために明示的に書くことを要求されることがあるため、一般にこれからの開発ではつけておくのが良いのでしょう。

v9.1.0時点でこれらの問題はそれぞれ解決されているため、昔は利用できなかったものの、現在は@line/bot-sdkがCloudflare Workersでも利用できるようになったのだと考えられます。
https://github.com/line/line-bot-sdk-nodejs/releases/tag/v9.0.0
v9.0.0以上のrelease noteを読むと、axiosや他のライブラリをやめたり、prefixの対応をしているようですから、結果このようなエラーが出なくなったようです。
どうやらfetchが使われ始めたのはこのバージョンからのようです。結果他のJavaScript runtimeでも利用しやすくなったのも嬉しい点です。

今回私が利用したlinebotのsdkに限らず、Node.js用のライブラリをCloudflare Workersのような他のJavaScript runtimeで動かすためには、各ライブラリがこのような対応をする必要があるのだと理解しました。

Bun でもある程度動く

BunもNode.jsとは異なるruntimeです。
Node.jsのいくつかのAPIに対して互換があります。

v1.1.5 の環境で動くことを確認しました。
bun run main.tsとする場合は動きます。
ただし、 bun build main.ts とした場合にエラーとなりました。
おそらく bun run main.ts とした場合でも、linebotのsdkの内部でReadableを使っているものは動かないでしょう。

bun build main.ts
2 | import { Readable } from "node:stream";
             ^
error: No matching export in "node:stream" for import "Readable"
    at /home/a/bun/node_modules/@line/bot-sdk/dist/http-axios.js:2:10

これはNode.jsの node:stream にある Readable がBunではそもままでは利用できないため起きるようです。Cloudflare Workersと同じく、Node.jsと完全に互換性のあるAPIを提供するのは難しいようです。
コードを様々なruntimeで動くことを期待することは稀有に思いますが、先程のCloudflare Workersと同様にruntimeごとに細かい違いがあるため、ライブラリに頼ると様々なruntimeで動くことには期待できないように思いました。最近はAIの力を借りてライブラリなしでコードを書くことも簡単ですから、このような場合も短い時間で必要なコードを自分で書くことも視野に入れてみても良いのかもしれません。

Deno でも動く

DenoもNode.jsとは異なるruntimeです。
Node.jsのAPIに対してかなり強い互換があります。

v1.42.4 で動くことを確認しました。

deno run -A main.tsとして動作しました。-Aは普通そうするべきではなく、あくまで検証用であることに注意してください。

また、deno compile main.tsをして生成されたファイルの実行も問題なくできました。

今回試したruntimeの中では、Denoが最も簡単に動作させることができました。
Cloudflare WorkersやBunではそれぞれつまずく点がありましたが、Denoではまったく工夫なく動きました。

まとめ

Cloudflare Workersでlinebotを作るためにlineのsdkが使えることを確かめました。honoを使う場合はwebhookの署名検証は自分で行う必要がありますが、ほぼコピペで大丈夫そうです。

また、過去なぜlineのbot sdkが動かなかったかについても少し調べました。

他のruntimeについては、Bunも一部特殊な対応が必要ですが動きました。
Denoでは全く工夫なく全てが動作しました。

Discussion