qwik-city + cloudflare-pages で cloudflare binding を触りながら高速に再ビルドしたい

2023/09/03に公開1

結果から言うと、お行儀がいい方法は遅く、高速な方法は公式にサポートが切れたライブラリを使っていて、なんとも言えない状態です。

もうちょっと工夫すると動く気はするんですが、試行錯誤のログを書いたので誰か助けて...という記事。

公式 cloudflare-adapter の問題

# ... qwik-city プロジェクトの初期化は略
$ pnpm qwik add cloudflare-pages
$ pnpm build
$ pnpm serve

これは動きます。が... next.js を cloudflare で動かす next-on-pages と同じ問題を抱えていて、qwik-city をフルビルドした結果を wrangler で serve するので、ちょっとした変更ごとにコマンドを打って再ビルドする必要があります。
これは非常に開発体験が悪いです。

cloudflare のAPIをちょっとだけ使う設計なら都度ビルドでもいいかもしれませんが、自分は設計の根幹で cloudflare d1 を使おうとしていたので、d1 の binding がある状態でライブリロードするのが必須要件でした。

remix の cloudflare-adapter も結局は似たような感じなんですが、あちらは remix watch でデバッグビルドを高速に生成できるので、比較的マシです。とりあえずあの体験を目指そうとしたんですが...色々あって失敗しています。

失敗その1: vite build --watch

qwik-city の npm scripts の構成を見る限り、こうなっています。

  • build.client: vite build でクライアント用の ./dist を生成
  • build.server: vite build -c adapters/cloudflare-pages/vite.config.ts --mode ssr./server を生成
  • serve: wrangler pages dev ./distdist/_worker.js があるので Cloudflare Pages Advance Mode になり、これが worker として起動
    • dist/worker.jsserver/* のコードを呼ぶ。これは wrangler 内臓の esbuild でビルドされて serve される

じゃあこれらを全部並列に起動すればいいじゃん、と思ったんですが、結果から言うと動いたり動かなかったりします。

$ pnpm vite build --watch
$ pnpm vite build --watch -c adapters/cloudflare-pages/vite.config.ts --mode ssr
$ pnpm wrangler pages dev ./dist

コードを追った結果、 qwik-city の build.serverbuild.client の生成するファイル dist/... に依存していて、 vite build --watch を複数立てるとレースコンディションが発生します。

vite 自体の問題として、 SSR モードのビルドとクライアントのビルドを同時に行うことができません。なので必ずビルドプロセスを分ける必要があるのですが、それを並列化するとレースコンディションで動かなくなります。

ロックファイル作るなどの何らかの方法で build.client の実行が終わったことを保証して build.server を呼ぶ必要があります。自前の watcher を書いて逐一ビルドプロセス呼ぶ方法もあるんですが、 vite のインメモリキャッシュが破棄されてしまい低速化します。結局体験が悪いです。

失敗その2: wrangler dev の proxy mode

$ pnpm wrangler pages dev -- pnpm vite --mode ssr

wrangler pages dev-- でプロセスを渡すと、その port を proxy して workerd のサーバーとして動きます。

だったらこのプロセスから d1 呼べばいいじゃん!と思ったんですが、proxy したところでそのプロセスから cloudflare の env binding を取得する方法がありません。つまりは export default { fetch(req, env) {...} } の env を取るインターフェースがないです。

import.metaglobalThis の中身を探してみたけど、どこにもなさそうでした。

動くけど将来性はない: miniflare v2 で d1 インスタンスをモック

もう d1 だけモックすればいいやと思って、やってみました。

https://github.com/cloudflare/miniflare/tree/master/packages/d1

import type { D1Database } from "@cloudflare/workers-types";

let _db: D1Database | undefined = undefined;
export const createDevDb = async (database_id: string) => {
  if (!import.meta.env.DEV) throw new Error("Dev Only");
  if (_db) return _db;

  const { D1DatabaseAPI, D1Database } = await import("@miniflare/d1");
  const { createSQLiteDB } = await import("@miniflare/shared");
  const db = await createSQLiteDB(`.wrangler/state/v3/d1/${database_id}/db.sqlite`);
  const d1db = new D1Database(new D1DatabaseAPI(db));
  return (_db = d1db);
};

miniflare で wrangler の生成するパスの db ファイルをそのまま掴んで起動します。 qwik の dev サーバーに d1 インスタンスがいるだけなので、非常に高速に再ビルドでき、開発体験も 優れています。

が... miniflare v2 はセキュリティ上の理由でメンテナンスされないという告知がされています。

https://miniflare.dev/

miniflare v3 は完全に設計が変わってしまい、 workerd 越しに各種 Cloudflare APIを呼び出すプロキシになってしまったので、wrangler dev の中じゃないと動きません。

個人的にはユニットテストの書きやすさを考えても miniflare v2 の設計の方が嬉しいと思うんですが、これによって Rust でも動くようになったっぽくもあり...

あとなんか僕の環境だと wrangler proxy mode はずっと workerd が LLVM のエラーを吐き続けていて、ちゃんと動いてるのか怪しいです。

どうしよう...

一旦 miniflare v2 で誤魔化しつつ、qwik 自体のコードをもうちょっと読んだり、wrangler 側に便利なAPIが生えるのを待つしかない気がしてます。

workerd の完成度が低いのに、まともな代替手段を提供しないまま miniflare v2 のサポートを切ろうとしている cloudflare の意思決定は正直どうかと思いますね...

Discussion

mizchimizchi

追記: miniflare v3 で動くには動いた

import { Miniflare } from "miniflare";
const mf = new Miniflare({
  modules: true,
  script: `export default {
    async fetch(req, env, ctx) {
      return new Response("ok");
    }
  }
  `,
  d1Persist: true,
  d1Databases: {
    "DB": "db"
  }
});

// const response = await mf.dispatchFetch("http://localhost:8787/");
// console.log(await response.text()); // Hello Miniflare!
const bindings = await mf.getBindings();
const db = bindings.DB;
await db.exec("CREATE TABLE IF NOT EXISTS requests (url TEXT)");
await db.exec("INSERT INTO requests (url) VALUES ('aaa')");
await db.exec("INSERT INTO requests (url) VALUES ('bbb')");
await db.exec("INSERT INTO requests (url) VALUES ('ccc')");
const prepared = await db.prepare(`select * from "requests"`);
const res = await prepared.all();
// const res = await db.exec(`select * from "requests"`).;
console.log(res);


await mf.dispose();