🍣

LLRTでHonoを動かす

2024/02/17に公開

LLRT (Low Latency Runtime)はAWS Labsの人たちによって公開されたOSSで、

「v8やJSCよりミニマムなJavaScriptエンジン付けてLambdaにデプロイしたらめっちゃ速くなるんじゃない?」というようなコンセプトを持つ新しいJavaScriptコードのランタイムです

QuickJS[1]というES2023準拠のJavaScriptエンジンとそのRustバインディングのrquickjsを使って全体的に構築されています(LLRTはES2020と明記されていますが)

ECMAScript(ES)にはないNode.jsの標準API群が一部Rustを使って書かれています

JavaScriptから呼び出せるモジュールを「LLRTからロードできるネイティブなESMモジュールを追加する」で示したとうり自作できるので、Web Standard APIs互換なウェブフレームワーク Honoぐらいならもう動くのではと思ったので試しました

インストール

お手軽なインストール方法はGitHub Releasesからお使いのシステム用のバイナリをダウンロードしてくることです

https://github.com/awslabs/llrt/releases

cargo buildで自分でビルドしてもかまいません

app.fire()不発

とりあえずCloudflare Workersみたいにして動いてくれという想いでapp.fire()してみます

hono-llrt.js
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono on LLRT!'))
app.fire()

import { Hono } from 'hono'はLLRTではネイティブに動かないので単一ファイルにバンドル化する必要があります

Using node_modules (dependencies) with LLRTにあるというりesbuildを使ってみます

esbuild hono-llrt.js --outfile=bundle.mjs --platform=browser --target=es2020 --format=esm --bundle
llrt bundle.mjs

ReferenceError: 'addEventListener' is not defined
    at <anonymous> (bundle.mjs:786:7)
    at <anonymous> (bundle.mjs:1558:5)

addEventListenerはブラウザが持つグローバル関数なのでLLRTにはありません

LLRTはNode.js互換APIを用意しているということで、https://hono.dev/getting-started/nodejs を参考にNode.js Adapterを使ってみます

@hono/node-serverが動作しない理由

以下でビルドしました

esbuild hono-llrt.js --outfile=bundle.mjs --platform=node --target=es2020 --format=esm --bundle

--platform=nodeになっています

llrt bundle.mjs                                                                        

ReferenceError: Error resolving module 'http' from 'bundle.mjs'

Compatibility matrixを確認するとhttpモジュールはまだ完成していません

ただよく見るとnet:serverモジュールがあります。なのでAdapterになるHTTPサーバーのレイヤーを書けばいいのかと結論付けました

new Response()がない

HTTPサーバーを順調にこんな感じに書けました

hono-llrt.js
import { createServer } from 'net'
import { Hono } from 'hono'

const PORT = 3000

const app = new Hono()
app.get('/', (c) => c.text('Hello Hono on LLRT!'))

const server = createServer((socket) => {
  socket.on('error', (error) => {
    console.error('Socket error:', error)
    socket.end()
  })
  socket.on('data', async (data) => {
    try {
      const requestString = data.toString()
      const requestLines = requestString.split('\r\n')
      const requestLine = requestLines[0].split(' ')
      const headers = {}

      for (let i = 1; i < requestLines.length; i++) {
        const line = requestLines[i]
        if (line) {
          const [key, ...valueParts] = line.split(': ')
          headers[key] = valueParts.join(': ')
        }
      }

      const method = requestLine[0]
      const path = requestLine[1]
      const protocol = requestLine[2]
      const url = `http://localhost:${PORT}${path}`
      const request = new Request(url, {
        method,
        path,
        protocol,
        headers,
      })

      console.log('Received request:', request)

      const response = await app.fetch(request)

      console.log('Converted response:', response)

      const body = await response.text()
      let responseHeaders = ''

      for (const [key, value] of Object.entries(response.headers)) {
        responseHeaders += `${key}: ${value}\r\n`
      }

      const httpResponse = `HTTP/1.1 ${response.status} ${response.statusText}\r\n${responseHeaders}\r\n${body}`

      socket.write(httpResponse)
      socket.end()
    } catch (error) {
      console.error('Error handling request:', error)
      socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n')
      socket.end()
    }
  })
})

server.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}/`)
})

ただ起動してブラウザからアクセスするとReferenceError: 'Response' is not definedとエラーが発生してしまいます

llrt bundle.mjs

Server listening on http://localhost:3000/
Received request: {
  keepalive: true,
  url: 'http://localhost:3000/',
  headers: { Symbol.iterator: undefined },
  method: 'GET',
  body: undefined
}
ReferenceError: 'Response' is not defined
  at <anonymous> (bundle.mjs:180:22)      at <anonymous> (bundle.mjs:1546:23)      at dispatch (bundle.mjs:926:15)      at <anonymous> (bundle.mjs:771:19)      at <anonymous> (bundle.mjs:1576:34)
Error handling request: ReferenceError: 'Response' is not defined

ソースコードを確認する限りグローバル空間にResponseは定義されています

https://github.com/awslabs/llrt/blob/f0a4983d4fe45890f037741a66ee53707d5adcb5/src/http/mod.rs#L16-L28

純粋な呼び出しコードをevalしてみると再現するのでLLRTのレイヤーの問題かと思います

llrt -e 'new Request("http://localhost:3000/") && console.log("OK")'

OK
llrt -e 'new Response() && console.log("OK")'

ReferenceError: 'Response' is not defined
    at <eval> (eval_script:1:5)

これはもうちょっと調べてIssueで報告したいところですが[2]、LLRT本体ソースコードをいくら改変してみても解決しなかったのでとりあえず方針を変更して実行時に代替オブジェクトを差し込むことにしました

hono-llrt.js
/**
 * TODO: globalThis.Responseを消す
 * Responseオブジェクトは本来ランタイムでサポートされている
 * LLRT 0.1.6-beta時点で`new Response`がグローバルスコープで参照できない問題へのワークアラウンド
 */
globalThis.Response = class {
  constructor(body, status = 200, statusText = 'OK', headers = {}) {
    this.body = body
    this.status = status
    this.statusText = statusText
    this.headers = headers
  }

  async text() {
    return this.body
  }
}

これで完成です。ブラウザにレスポンスが表示されました

しかし問題も……

1回目のリクエストがすぐ応答が来るのですが、2回目を連続でアクセスすると数秒から数十秒かかります

ソケットの処理が適切にflushされてないような挙動……

LLRTの用途はLambdaで起動してすぐ終了とのことなのでここも一旦気にしないことにしました

Tips: JavaScript部分のデバッグ

LLRTバイナリはJavaScriptのデバッグ実行に対応していなくエラーメッセージもフレンドリーなものを吐いてくれないので実行中に死んでたりします

なのでHonoのソースコード内にconsole.log()を入れてプリントデバッグをしてしのぎました(つらい)

git clone https://github.com/honojs/hono.git
cd hono
yarn
- import { Hono } from 'hono'
+ import { Hono } from './dist'

直接実行されているのはHonoのTypeScriptのソースコードではなくてJavaScriptに変換した後のコードであることに注意が必要です

全体のソースコード

脚注
  1. QuickJS: QEMU、FFmpegの同作者というのもまたすごい ↩︎

  2. Responseのコンストラクタが定義されていなかったようです https://github.com/awslabs/llrt/issues/168 ↩︎

Discussion