Open42

GW は ORM を作るぞと思っていたがまずフルスタック環境を仕上げたい

mizchimizchi

sqlite 用に特化したORMを作りたい

何を作るか

  • 主に sqlite 用のクエリビルダ + マイグレーションキット
  • クエリビルダ部分は prisma 風の TypeScript の型推論をガンガン効かせたやつ。select するとそのフィールドだけ結果に出るやつ。

Why

  • 最近 sqlite-wasm が sqlite 公式から出たので、ブラウザから sqlite を使う頻度も増えそう sqlite3 WebAssembly & JavaScript Documentation Index
  • d1 や lite stream 等の各種の sqlite replication 系のDB が最近増えてるから sqlite の注目度が上がってる(俺の中で)
  • 現状、cloudflare d1 も sqlite-wasm も sql を生で書かないといけない
  • TypeScript になれた世代の自分には、型無しでSQL書くのはしんどい (諸説あります)
  • Active Record パターンより、 prisma の半分クエリビルダで sql が透けてみえる薄い実装が体験よかったので、これを再現したい
  • cloudflare の分散DB (d1 または自分が勝手に存在を仮定している未発表の cloudflare DB プロダクト) が次世代の覇権フレームワークだと思ってるので、こいつを気軽に使えるツールキットを作りたい。 (諸説あります)

Problem

  • indexeddb のORMを作ったことはあるが、自分は sql にそこまで詳しくない
  • 実装面の課題にぶつかるとは思うが、既存のものより使いやすくできるか?

Design / Goal

  • prisma の API を可能な限り模倣する
    • 宣言的スキーマいいよね / でもAPI定義変えた際に再ビルドしたくないから TypeScript DSL とその推論で完結させたい
    • 最悪 d1 で prisma がそのまま動いたらいいんだけど、実装読む限り rust でネイティブの sqlite-driver 使ってるので厳しそう。いずれ対応するかもしれないが、今は考えない
  • クエリビルダーとして完成形を高くすることを目指して、driver 部分は頑張らない
    • これは d1 や sqlite-wasm がクエリ文字列を食う API を持っててドライバ層を別に持ってるので
  • (今回は) sqlite 以外は頑張らない

最悪まともに完成しなくても、自分の sql に勉強になればよし

mizchimizchi

既存実装の調査

d1 対応が主目的なので、d1 の community projects を見てみる。

https://developers.cloudflare.com/d1/platform/community-projects/

D1-ORM

https://github.com/Interactions-as-a-Service/d1-orm

prisma 風のAPIだが、あんまり Active ではない。
TypeScript の型推論部分は参考になりそう

Drizzle ORM

https://github.com/drizzle-team/drizzle-orm/tree/main

D1 対応してる ORM
まだ未調査

prisma-kysery

valtyr/prisma-kysely: 🪄 Generate Kysely types directly from your Prisma schema!

クエリビルダ keysery の prisma adapter。
keysery ユーザー向けに見える。

workers-qb

https://github.com/G4brym/workers-qb

d1 console

https://github.com/isaac-mcfadyen/d1-console

REPL

mizchimizchi

prisma migrate の挙動の調査

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Todo {
  id        Int      @id
}

初期生成された sql

prisma/migrations/20230425154539_c1/migration.sql
-- CreateTable
CREATE TABLE "Todo" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
);

Migrate

テーブル定義を書き換えてみる。 content を追加

prisma/schema.prisma
// ry

model Todo {
  id        Int      @id
  content   String   @default("")
}

これで prisma migrade up ...

prisma/migrations/20230425154556_c2/migration.sql
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Todo" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "content" TEXT NOT NULL DEFAULT ''
);
INSERT INTO "new_Todo" ("id") SELECT "id" FROM "Todo";
DROP TABLE "Todo";
ALTER TABLE "new_Todo" RENAME TO "Todo";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

Q. このマイグレーションスクリプトを解説して

(以下GTP4)

このSQLスクリプトは、todoというテーブルにcontentという新しいカラムを追加するためのマイグレーションスクリプトです。各ステートメントの説明は以下の通りです。

PRAGMA foreign_keys=OFF;: 外部キー制約のチェックを無効化します。これにより、一時的に外部キー制約を無視してテーブルの操作ができます。
CREATE TABLE "new_Todo" ...: new_Todoという新しいテーブルを作成します。このテーブルには、既存のTodoテーブルのidカラムと、追加したいcontentカラムが含まれます。
INSERT INTO "new_Todo" ("id") SELECT "id" FROM "Todo";: 既存のTodoテーブルからidカラムの値を取得し、新しいnew_Todoテーブルのidカラムに挿入します。contentカラムにはデフォルト値(空文字列)が設定されます。
DROP TABLE "Todo";: 既存のTodoテーブルを削除します。
ALTER TABLE "new_Todo" RENAME TO "Todo";: 新しいnew_Todoテーブルの名前をTodoに変更し、既存のテーブルと同じ名前にします。
PRAGMA foreign_key_check;: 外部キー制約のチェックを実行し、制約違反がないか確認します。
PRAGMA foreign_keys=ON;: 外部キー制約のチェックを再び有効化します。
このマイグレーションスクリプトは、todoテーブルにcontentカラムを追加することに成功するはずです。ただし、外部キー制約がある場合、適切に処理されているかどうか注意深く確認する必要があります。また、このスクリプトは既存のデータを失わないように設計されていますが、実行する前にデータベースのバックアップを取ることをお勧めします。

Q. このマイグレーション手順は一般的ですか?

このマイグレーション手順は、SQLiteデータベースでのカラムの追加において一般的です。SQLiteでは、ALTER TABLEステートメントが限定的な機能しか提供していないため、カラムの追加や削除などの操作を行う際に、一時的なテーブルを作成してデータをコピーするような手順が一般的です。

他のデータベース管理システム(DBMS)では、もっとシンプルな方法でカラムを追加できることがあります。例えば、MySQLやPostgreSQLでは、以下のようなステートメントを実行するだけでカラムを追加できます。

ALTER TABLE "Todo" ADD COLUMN "content" TEXT NOT NULL DEFAULT '';

しかし、SQLiteでは上記のようなステートメントをサポートしていないため、一時的なテーブルを使ったマイグレーション手順が一般的になっています。

Q. 先の sqlite のマイグレーション手順が一般的かどうかについてですが、sqlite は add column をサポートしているように思います。

おっしゃる通り、SQLiteはALTER TABLEステートメントを使ったカラムの追加をサポートしており、私の前の回答は誤りがありました。申し訳ありません。SQLiteでカラムを追加するための一般的な方法は次のようなステートメントを使用することです。

このステートメントで、Todoテーブルにcontentカラムが追加され、NOT NULL制約が適用され、デフォルト値として空文字列が設定されます。

ALTER TABLE "Todo" ADD COLUMN "content" TEXT NOT NULL DEFAULT '';

前回の回答で説明したマイグレーション手順は、SQLiteがALTER TABLEでカラムの追加や削除をサポートしていない場合や、より複雑なマイグレーションが必要な場合に適用されます。ただし、カラムを追加するだけであれば、上記の単純なALTER TABLEステートメントを使用することが一般的です。

mizchimizchi

Drizzle ORMをみてみる

$ pnpm add drizzle-orm drizzle-kit -D

スキーマ定義を置く

drizzle/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  name: text('full_name'),
});

初回マイグレーションを生成

$ pnpm drizzle-kit generate:sqlite --out drizzle/migrations --schema drizzle/schema.ts

drizzle/migrations/ の下に諸々の meta ファイルが生成される。
これ、どうやら schema.ts を構文解析して生成してるらしい。 https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/sqlite-core/README.md

マイグレーションを実行

drizzle/migrate.ts
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import Database from "better-sqlite3";

const sqlite = new Database("test.db");
const db = drizzle(sqlite);
migrate(db, {
  migrationsFolder: "drizzle/migrations",
});

実行して確認

import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import { users } from "./schema";

const sqlite = new Database('test.db');
const db = drizzle(sqlite);

db.insert(users).values({ name: "John Doe" }).run();

const allUsers = db.select().from(users).all();
console.log("allUsers", allUsers); // allUsers [ { id: 1, name: 'John Doe' } ]

ちゃんと allUsers に型が付いている。

俺が欲しかったの、もしかしてこれでは?

mizchimizchi

次にやること

  • cloudflare d1 をセットアップ
  • drizzle を cloudflare d1 に繋ぐ
  • todo アプリを作る
mizchimizchi

昔作った testdb で使おうとしたらなぜか動かなかったので testdb3 を作る (testdb2 は作って壊す実験に使った)

# 最初に --local をつけて実験していたが、今回は本番向けなのでつけない
$ pnpm wrangler d1 create testdb3

create したときに出た設定を wrangler.toml に書き込んで DB のバインディングを設定しておく。後で使う。

wrangler.toml
[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "testdb3"
database_id = "...."

マイグレーション

$ wrangler d1 migrations create testdb3 init

マイグレーションが作られるので自分で編集

migrations/0000_init.sql
CREATE TABLE t(id INTEGER, name TEXT);

適用する

$ pnpm wrangler d1 migrations apply testdb3

# 確認
$ pnpm wrangler d1 execute testdb3 --command="select count(*) from t;"

(ちょっと困った。自前でマイグレーションの仕組みを持ってると、他のORMの仕組みとぶつかりそう)

worker から動かす

src/index.ts
export type Env = {
	DB: D1Database
}

export default {
	async fetch(
		request: Request,
		env: Env,
		ctx: ExecutionContext
	): Promise<Response> {
		const ret = await env.DB.prepare(`SELECT * FROM t`).all();
		return Response.json(ret.results);
	},
};

実行

$ wrangler dev src/index.ts --persist

http://localhost:8787 で開く。動いたらおk

mizchimizchi

マイグレーションの挙動を確かめたい

https://developers.cloudflare.com/d1/platform/migrations/

(deepl 翻訳)

現在、マイグレーションシステムは、シンプルかつ効果的なものを目指しています。現在の実装で、あなたは

空のマイグレーションファイルを作成します。
未適用のマイグレーションをリストアップします。
残りのマイグレーションを適用する。
migrationsフォルダー内のすべての移行ファイルは、ファイル名に指定されたバージョン番号を持っています。ファイルは順番にリストアップされます。すべての移行ファイルはSQLファイルであり、実行するクエリを指定できます。

どこまで適用したかの情報って、どこにあるんだろう。

mizchimizchi

デフォルトでは、マイグレーションは Worker プロジェクトディレクトリのmigrations/フォルダに作成されます。マイグレーションを作成すると、データベースにあるd1_migrationsテーブルに適用されたマイグレーションの記録が残ります。

d1 自体に書き込まれるっぽい。query 飛ばしてみる

$ pnpm wrangler d1 execute testdb3 --command="select * from d1_migrations;"

🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on testdb3 (cbdf14e0-bf7f-4211-86d5-8225a65b4564):
🚣 Executed 1 command in 4.813999995589256ms
┌────┬───────────────┬─────────────────────┐
│ id │ name          │ applied_at          │
├────┼───────────────┼─────────────────────┤
│ 1  │ 0000_init.sql │ 2023-04-28 16:24:54 │
└────┴───────────────┴─────────────────────┘

確かにあった。db 自体に migration 情報書き込むのはいいのか???

mizchimizchi

ここから drizzle on cloudflare workers する。環境を作り直す。

$ npx wrangler generate d1-drizzle
 ⛅️ wrangler 2.12.1 (update available 2.17.0)
-------------------------------------------------------
Using npm as package manager.
✨ Created d1-drizzle/wrangler.toml
✔ Would you like to use git to manage this Worker? … yes
✨ Initialized git repository at d1-drizzle
✔ No package.json found. Would you like to create one? … yes
✨ Created d1-drizzle/package.json
✔ Would you like to use TypeScript? … yes
✨ Created d1-drizzle/tsconfig.json
✔ Would you like to create a Worker at d1-drizzle/src/index.ts? › Fetch handler
✨ Created d1-drizzle/src/index.ts
✔ Would you like us to write your first test with Vitest? … yes
✨ Created d1-drizzle/src/index.test.ts
npm WARN deprecated rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 165 packages, and audited 166 packages in 25s

20 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✨ Installed @cloudflare/workers-types, typescript, and vitest into devDependencies

To start developing your Worker, run `cd d1-drizzle && npm start`
To start testing your Worker, run `npm test`
To publish your Worker to the Internet, run `npm run deploy`

確認

$ tree . -I node_modules
.
├── package-lock.json
├── package.json
├── src
│   ├── index.test.ts
│   └── index.ts
├── tsconfig.json
└── wrangler.toml

pnpm で初期化

$ rm -r node_modules package-lock.json
$ pnpm install

# 起動確認

$ pnpm wrangler dev --local

http://localhost:8787

ここまでで worker の setup 完了


Drizzle のインストール

$ pnpm add drizzle-kit drizzle-orm -D

schema 定義を置く。

src/schema.ts
/*
  DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey().notNull(),
  name: text('name').notNull(),
});

リネームしないように釘を刺しておいた

$ pnpm drizzle-kit generate:sqlite --out migrations --schema src/schema.ts
drizzle-kit: v0.17.6
drizzle-orm: v0.25.3

1 tables
users 2 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ migrations/0000_acoustic_doorman.sql

名前はなんか適当に生成されてるらしい。

$ tree migrations/
migrations/
├── 0000_acoustic_doorman.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

中身を見てみる

migrations/0000_acoustic_doorman.sql
CREATE TABLE `users` (
	`id` integer PRIMARY KEY NOT NULL,
	`name` text NOT NULL
);

今ならわかる。 prisma と違ってマイグレーションスクリプトがフラットに展開されてるのは、 cloudflare d1 がそれを受け付けるから、ということを。

ここから新しい d1 database を作る。

$ pnpm wrangler d1 create dzltest

✅ Successfully created DB 'dzltest'!

Add the following to your wrangler.toml to connect to it from a Worker:

[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "dzltest"
database_id = "<your-id>"

これを wrangler.toml に追記

--local

次にローカル環境の sqlite に向けて migration を実行してみる。まず sqlite driver をインストールしておく(これをしないとmigration の適用ができない)

$ pnpm add better-sqlite3 --dev

--local をつけて実行

$ wrangler d1 migrations apply dzltest --local
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
Migrations to be applied:
┌───────────────────────────┐
│ Name                      │
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
┌───────────────────────────┬────────┐
│ Name                      │ Status │
├───────────────────────────┼────────┤
│ 0000_acoustic_doorman.sql │ ✅       │
└───────────────────────────┴────────┘

worker から drizzle-orm を使ってクエリを実行する

import { drizzle } from 'drizzle-orm/d1';
import { users } from './schema';

export interface Env {
  DB: D1Database;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    const db = drizzle(env.DB);
    const result = await db.select().from(users).all();
    return Response.json(result);
  },
};

これで pnpm wrangler dev --local --persist で実行

http://localhost:8787[] が帰ってきたら成功(まだデータを入れてないので)

本番デプロイ

TODO: あとで CI 化したい

今のを --local 外して実行

$ pnpm wrangler d1 migrations apply dzltest

🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 1.704942001029849ms
🚣 Executed 1 command in 10.565411001443865ms
🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 1.6205519996583462ms
Migrations to be applied:
┌───────────────────────────┐
│ Name                      │
🌀 Mapping SQL input into an array of statements
🌀 Parsing 2 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 0.4101349990814924ms
🚣 Executed 1 command in 7.989365998655558ms
┌───────────────────────────┬────────┐
│ Name                      │ Status │
├───────────────────────────┼────────┤
│ 0000_acoustic_doorman.sql │ ✅       │
└───────────────────────────┴────────┘

--local 外して開発サーバーを建てる。本番に飛んでるはず。

$ pnpm wrangler dev

先ほどと同じ http://localhost:8787 で確認。これはローカルの worker から本番DBに繋いでるイメージ。

ここでちゃんと migration が適用されてるか確認

$ pnpm wrangler d1 execute dzltest --command="select * from d1_migrations;"

🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 86.91357399895787ms
┌────┬───────────────────────────┬─────────────────────┐
│ id │ name                      │ applied_at          │
├────┼───────────────────────────┼─────────────────────┤
│ 1  │ 0000_acoustic_doorman.sql │ 2023-04-29 08:43:11 │
└────┴───────────────────────────┴─────────────────────┘

deploy

特に何かやる必要もないので、そのまま publish する

$ pnpm wrangler publish
Your worker has access to the following bindings:
- D1 Databases:
  - DB: dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506)
Total Upload: 6.47 KiB / gzip: 2.08 KiB
Uploaded d1-drizzle (1.77 sec)
Published d1-drizzle (3.74 sec)
  https://d1-drizzle.mizchi.workers.dev
Current Deployment ID: b7994ea4-6f54-4d33-b87e-60de1c6a02dd

これで https://d1-drizzle.mizchi.workers.dev/ を開いて、動いてることを確認。
あとで DB とこの worker を消すので、再現性はない

お疲れさまでした。

mizchimizchi

動いたのでこのエンドポイントを使うアプリをなにか作りたい。
俺が使うというより、みんなに使ってもらうなら長いものに巻かれた方がいい気がするので、 next.js on cloudflare workers を検討してみる。

next.js

しばらく使ってないので思い出すところから。

next 12

https://nextjs.org/blog/next-12

  • swc support
  • middleware support
  • ServerSide streaming
  • server component
  • ESM Support

next-13

https://nextjs.org/blog/next-13

next on pages

https://zenn.dev/laiso/articles/c086dd115f5d4e

https://scrapbox.io/razokulover-tech-memo/Cloudflare_D1+_Next.jsを試してるがうまく動かない

mizchimizchi

https://github.com/cloudflare/next-on-pages

結果から言うと、pnpm を使ったら next のファイル監視で無限ループが走ったので、npm でやる。

next project の作成

$ npx create-next-app@latest my-next-app
$ cd my-next-app
$ npm install
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  runtime: 'edge',
  swcMinify: true,
  experimental: {
    appDir: true,
  },
}
module.exports = nextConfig

next-on-pages でビルド

$ npx install @cloudflare/next-on-pages vercel -D
$ npx @cloudflare/next-on-pages
$ npx wrangler pages dev .vercel/output/static --compatibility-flag=nodejs_compat

一旦動いたのでヨシとするが、なんか重いのが不穏。 watch mode で起動してみる。

# プロセスを2つ使う。next のビルドコマンド。
$ npx @cloudflare/next-on-pages --watch
# 別のターミナルで wrangler を起動
$ npx wrangler pages dev .vercel/output/static --compatibility-flag=nodejs_compat

この状態で、 src/app/page.tsx を書き換えてみる。

import Image from 'next/image'
import styles from './page.module.css'

export default function Home() {
  return (
    <div>
      hello world 4
    </div>
  )
  // ry
}

1つ目のプロセスで next のフルビルドが走り、それが終わると wrangler 側で閲覧できる。
この最小のサンプルで、書き換えてから反映されるまで10秒以上かかる。 それもロード中は 404 になり、ビルドが終わっても自動リフレッシュされたりとかはない。拷問か?

あまりに開発体験が悪いので他のフレームワークを検討する...

mizchimizchi

これをやってみる

https://zenn.dev/yusukebe/articles/c86b8bd8d47a53

自分で真似したらプロセス渡すところでなにかミスってバグってハングしたので、これを丸コピした
https://github.com/yusukebe/hono-examples/tree/main/projects/pages-vite-minimal

$ tree . -I node_modules -I pages
.
├── functions
│   └── api
│       └── [[route]].ts
├── index.html
├── package.json
├── pnpm-lock.yaml
├── src
│   ├── App.tsx
│   └── index.tsx
├── tsconfig.json
├── vite.config.ts
└── wrangler.toml

package.json の scripts.dev がこう

wrangler pages dev --local -- vite

実行するとこうなる。

...
[mf:wrn] Service bindings are experimental. There may be breaking changes in the future.
[mf:inf] Worker reloaded! (55.71KiB)
[mf:inf] Listening on 0.0.0.0:8788
[mf:inf] - http://127.0.0.1:8788
[mf:inf] - http://192.168.11.2:8788
[mf:inf] Updated `Request.cf` object cache!

vite が src/... を監視して pages/... に吐き出して、wrangler pages dev が vite をプロキシしながら localhost:8788 で動く。このとき、 functions/api もマウントされるので、クライアントからは同一ホストの /api が [[route]].ts で実行される。

だからこのコードが動く

import { useState, useEffect } from 'react'

const App = () => {
  const [message, setMessage] = useState('')

  useEffect(() => {
    const fetchData = async () => {
      const res = await fetch('/api')
      const data = await res.json() as any;
      console.log("hello", data);
      setMessage(data.message)
    }
    fetchData()
  }, [])

  return <h1>{message}</h1>
}

export default App

いつ使えるか

SSR なしの SPA ならこれで十分。404.html を置かなければ index.html で応答するらしいので、動的URL の SPA も可能。

https://developers.cloudflare.com/pages/platform/serving-pages/#single-page-application-spa-rendering

ついでに vite-plugin-ssr を使えば、静的ページ に限ってSSR 可能かもしれない。(未実験)

とはいえ今回は誰にでも採用可能な汎用的な解を求めてるので、次は remix とかを探ってみる

mizchimizchi

https://remix.run/ で案内されるまま blog-tutorial を作ったら余計な層が多すぎて混乱したので、 簡単なボイラープレートがほしくて作り直した

 npx create-remix@latest plane-remix
? What type of app do you want to create? Just the 
basics
? Where do you want to deploy? Choose Remix App 
Server if you're unsure; it's easy to change 
deployment targets. Cloudflare Pages
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? No
💿 That's it! `cd` into "/Users/kotaro.chikuba/proj/plane-remix" and check the README for development and deploy instructions!

$ tree . -I node_modules
.
├── README.md
├── app
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── root.tsx
│   └── routes
│       └── _index.tsx
├── package.json
├── pnpm-lock.yaml
├── public
│   ├── _headers
│   ├── _routes.json
│   └── favicon.ico
├── remix.config.js
├── remix.env.d.ts
├── server.ts
├── tsconfig.json
└── wrangler.toml

$ pnpm install

これを pnpm dev で起動。 http://localhost:8788 で動作確認。

dev で何が立ち上がってるかというと、プロセスが2つ

package.json
    "dev:remix": "remix watch",
    "dev:wrangler": "cross-env NODE_ENV=development npm run wrangler",
    "dev": "npm-run-all build --parallel \"dev:*\"",

あ、これさっきみた プロセス渡すやつで ... と思ったけどビルドしてるだけで por/t で bind してるわけではないから、このままでいいか。

なんか趣味があわないので、ちょっとリファクタした

    "build": "remix build",
    "dev": "NODE_ENV=development npm-run-all build --parallel dev:*",
    "dev:remix": "remix watch",
    "dev:wrangler": "wrangler pages dev ./public --local --persist",
    "start": "NODE_ENV=production wrangler pages dev ./public",
    "typecheck": "tsc -p . --noEmit"

なるほど、wrangler pages devpublic/ の静的アセットと functions/ を読むので、 remix watch はそこに incremental にファイルを送り込んでて、 wrangler pages は規約を満たす範囲で動いてる。

試しにこの時点で pnpm dev しながら routes/_index.tsx を書き換えて反映される時間を計測したが、だいたい 1~1.5秒程度でホットリロードされる。これなら next-on-pages で耐えられなかった体も満足できそう。

で、今回の cloudflare pages へのビルド設定がたぶん remix.config.js

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  devServerBroadcastDelay: 1000,
  ignoredRouteFiles: ["**/.*"],
  server: "./server.ts",
  serverBuildPath: "functions/[[path]].js",
  serverConditions: ["worker"],
  serverDependenciesToBundle: "all",
  serverMainFields: ["browser", "module", "main"],
  serverMinify: true,
  serverModuleFormat: "esm",
  serverPlatform: "neutral",
  future: {
    v2_errorBoundary: true,
    v2_meta: true,
    v2_normalizeFormMethod: true,
    v2_routeConvention: true,
  },
};

remix は今まで追ってなかったので雰囲気で読んだ。

ためしに devServerBloadcastDelay を 300 にしたらリフレッシュ早くなるのかなーと試したが、一瞬 404 になってからちょっと遅れてリロードされた。特にいじらなくてよさそう。

ビルドサイズの確認

cloudflare workers は無課金だと 1MB, bundle プランだと 5MB がビルド上限なので、一応サイズを確認

$ du -hs public/ functions/
268K    public/
1.7M    functions/

public の方はたぶん WorkersKV に書き込まれるので、この上限にひっかからずフロントエンドチューニングする時にみる分だと思われる。

functions 1.7M がかなりしんどく感じたが、中身を見るとほとんど sourcemap だった。

$ la functions/
total 3432
-rw-r--r--  1 kotaro.chikuba  staff   206K May  1 17:27 [[path]].js
-rw-r--r--  1 kotaro.chikuba  staff   1.5M May  1 17:09 [[path]].js.map

これをどう捉えるか。余裕があるうちはそのままアップロードしてしまって良いとして、サイズが大きくなったときは sourcemap なしで頑張るか、 sentry の sourcemap 外付け機能等を使ってエラートレースをする、ということが考えられそう。

https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/

終わり

実のところ react-router の破壊的変更で mjackson 氏のソフトウェアにトラウマがあって、あまり気が乗らなかったのだが、cloudflare workers + remix の example で remix が使わる理由がわかった。ここまで体験よければ自分も採用する。

ただビルドサイズはちょっと気になる。

mizchimizchi

先に作ったプレーンな remix 環境から d1 に繋ぐ

まず、app/routes/ の下で d1 を触る loader を書いてみる。migaration も何もしてないので、 table 触らないクエリを叩く。

先に better-sqlite3 を入れておく必要があった。pnpm add better-sqlite3

app/routes/with-server.tsx
import type { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({ context }: LoaderArgs) => {
  const db = context.DB as D1Database;
  const { results } = await db
    .prepare("SELECT 1 as x")
    .all();

  return json({
    results: results ?? [],
  });
};

import { Layout } from "../components/Layout";
export default function WithServer() {
  const { results } = useLoaderData<typeof loader>();
  return (
    <Layout>
      <h2>With Server</h2>
      <pre><code>{JSON.stringify(results, null, 2)}</code></pre>
    </Layout>
  );
}

これで http://localhost:8788/with-server で d1 が叩けたことを確認。念の為 --local 外してもう一回叩いてみようとしたら問題が発生した。

wrangler pages dev はデフォルトで --local になってて、必ず local の miniflare:d1 を経由してしまう。これを外す方法がない。

ためしに --local false で起動しようとした回

$ pnpm wrangler pages dev --local false
🚧 'wrangler pages <command>' is a beta command. Please report any issues to github.com/cloudflare/wor…

✘ [ERROR] Only local mode is supported at the moment.

単に --local じゃない実装ができてないから一時的にデフォルトが --local で、実装終わったら外れるのかもしれない。とはいえ現状 cloudflare pages functions で d1 繋ぐ方法がないってことになる。リリースしないとわからん。

とりあえず d1-drizzle 環境の migration 一式を移植する。

mizchimizchi

横着して先に作った d1-drizzle テスト用の dzltest を再利用する。

app/schema.ts
/*
  DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey().notNull(),
  name: text('name').notNull(),
});

これテーブル定義だからサーバー依存ない気がするんだけど、なにか問題起きたら app/schema.server.ts にする。最終的には型だけ export type して import type する形式になる気がしてるが、drizzle 組み込みの zod validator とか使う場合クライアントからも呼ぶ可能性がある。

# 必要だったらmigration 生成。今回は `migrations` もそのままコピーした
# pnpm drizzle-kit generate:sqlite --out migrations --schema app/schema.ts

# 開発環境向けに apply
$ pnpm wrangler d1 migrations apply dzltest --local

ついでに、とりあえず開発環境で sqlite に繋ぐには --persist が必要だったので追加。

package.json
    "dev:wrangler": "wrangler pages dev ./public --persist",

ここでマイグレーションされたかの確認で、 loader で users テーブルを叩いてみる。

app/routes/with-server.tsx
import type { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({ context }: LoaderArgs) => {
  const db = context.DB as D1Database;
  console.log("db", db);
  const { results } = await db
    .prepare("SELECT * from users")
    .all();

  return json({
    results: results ?? [],
  });
};

export default function WithServer() {
  const { results } = useLoaderData<typeof loader>();
  return (
    <>
      <h2>With Server</h2>
      <pre><code>{JSON.stringify(results, null, 2)}</code></pre>
    </>
  );
}

動いた。次にこれを drizzle 経由にするのだが、その前に schema ファイルから User 型の推論を export しておく (が、まだ使わなかった)

app/schema.ts
/*
  DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { InferModel } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey().notNull(),
  name: text('name').notNull(),
});

export type User = InferModel<typeof users>;

次に db.server.ts で createClient を実装。

app/db.server.ts
import { drizzle } from 'drizzle-orm/d1';

export function createClient(db: D1Database) {
  return drizzle(db);
}

使う時に毎回呼ぶのが面倒だが、下手なグローバル変数のハックするよりは健全な気がするので一旦このまま。

で、やっと本題の app/routes/with-server.tsx に取り掛かる。

import type { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import { createClient } from "../db.server";
import { users } from "../schema";

export const loader = async ({ context }: LoaderArgs) => {
  const db = createClient(context.DB as D1Database);
  const results = await db.select().from(users).all();
  return json({results});
};

export default function WithServer() {
  const { results } = useLoaderData<typeof loader>();
  console.log("schema", users);
  return (
    <>
      <h2>With Server</h2>
      <pre><code>{JSON.stringify(results, null, 2)}</code></pre>
    </>
  );
}

実行できた。

ちなみに user schema は client 側でも console.log(users) できたので、.server にしない方針。ただ呼んだ場合のビルドサイズはそこそこ増えてしまいそう。あとで確認する。

mizchimizchi

リリースする...前にプロジェクト名がなかったので remix-d1-drizzle という名前を与えておいた。

name = "remix-d1-drizzle"
compatibility_date = "2023-04-30"
compatibility_flags = ["streams_enable_constructors"]

[[ d1_databases ]]
binding = "DB"
database_name = "dzltest"
database_id = "ab8b338d-2bdb-4c24-9f8e-1095dc814506"
# production build
$ NODE_ENV=production pnpm remix build


# first release
$ pnpm wrangler pages publish ./public
No project selected. Would you like to create one or use an
existing project?
❯ Create a new project
  Use an existing project
✔ Enter the name of your new project: … remix-d1-drizzle
✔ Enter the production branch name: … main
✨ Successfully created the 'remix-d1-drizzle' project.
▲ [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


✨ Compiled Worker successfully
🌍  Uploading... (9/9)

✨ Success! Uploaded 9 files (102.19 sec)

✨ Uploading _headers
✨ Uploading Functions bundle
✨ Uploading _routes.json
✨ Deployment complete! Take a peek over at https://9bb9e46e.remix-d1-drizzle.pages.dev

なぜか console.log(users) が残っている環境だとエラーでて駄目だったが、取り除くとうまくいった。やっぱ users のクライアント露出は駄目っぽいが、たぶん node_compat とかそのへんかな。

これで https://remix-d1-drizzle.pages.dev/with-server にアクセスすると空配列が表示されて成功。(いい加減データいれて実験したい)

mizchimizchi

この段階で functions みると 232k。まだ余裕ある。

mizchimizchi

workers kv で session 作ってみたけど、なんかセキュリティ的な課題もあるし、現代で自前ログイン作るイメージないので、実用性を考えて firebase authenticate 組み込む事にする

mizchimizchi

firebase authentication を使いたい

https://zenn.dev/mktu/articles/36db22aec54e7f
https://invertase.io/blog/remix-firebase-auth

firebase-authentication を使うとクライアントの jwt token での認証になると思うが、そうなると remix の loader でセッション認証できなくなる。 そうなるとページの公開/非公開情報を初期ロード時にできなくなる。

jwt を cookie に書き込んでセッション認証するのはアリか調べたが、あまりやらないほうがよさそう
https://zenn.dev/nameless_sn/articles/the_best_practice_of_jwt

調べてわかったが、 google authenticate と firebase authentication は別物で、前者は remix-auth-google で session 認証に対応してそう?

後者もググればあるだろうか。firebase authentication は複数プロバイダ対応が売りなので、可能なら寄せたいが。。。

mizchimizchi

remix-auth と session 永続化で workers-kv backend を使って認証ページを作る

wrangler が kv を2つ設定しないと文句を言ってくるので、本番用とpreview 用で2種類作る。

$ pnpm wrangler kv:namespace create remix_d1_drizzle_session_kv
$ pnpm wrangler kv:namespace create remix_d1_drizzle_session_kv_preview --preview

wrangler.toml に binding を書く

kv_namespaces = [
  # remix_d1_drizzle_session_kv / remix_d1_drizzle_session_kv_preview
  { binding = "SESSION_KV", id = "<your-kv-hash>", preview_id = "<your-kv-preview-hash>" }
]

セッションの実装

remix-auth を使う。

remix は同期初期化を期待していて、 @remix-run/cloudflare は kv インスタンスを要求しているため、非同期でしか初期化出来ない。なので、初回時にキャッシュするシングルトンとして実装した。

app/sessions.server.ts
import {
  createCookie,
  createWorkersKVSessionStorage,
} from "@remix-run/cloudflare";

export function createSessionCookie(kv: KVNamespace) {
  const cookie = createCookie("__session", {
    secrets: ["mysecretkey"],
    sameSite: true,
    httpOnly: true,
    secure: true,
  });
  return createWorkersKVSessionStorage({
    kv,
    cookie
  });
}
services/auth.server.ts
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
import { createSessionCookie } from "../sessions.server";

export type AuthUser = {
  email: string;
  password: string;
}

let _authenticator: Authenticator<AuthUser> | undefined;
export function getAuthenticator(kv: KVNamespace): Authenticator<AuthUser> {
  // init once
  if (_authenticator == null) {
    _authenticator = new Authenticator<AuthUser>(createSessionCookie(kv));
    _authenticator.use(
      new FormStrategy(
        async (req) => {
          const form = req.form;
          const email = form.get("email") as string;
          const password = form.get("password") as string;
          if (email === 'miz404@gmail.com' && password === 'test') {
            return {
              email,
              password  
            };
          }
          throw new Error("Invalid email or password");
        },
      ),
      "user-pass"
    );
  }
  return _authenticator;
}

login/logout

(このへんでやる気が尽きてきて適当になってる)

app/routes/login.tsx
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { getAuthenticator } from "../services/auth.server";

export async function action({ request, context }: ActionArgs) {
  console.log('on action');
  const authenticator = getAuthenticator(context.SESSION_KV as KVNamespace);
  return await authenticator.authenticate('user-pass', request, {
    successRedirect: "/",
    // failureRedirect: "/login",
  });
};

export async function loader({ request, context }: LoaderArgs) {
  const authenticator = getAuthenticator(context.SESSION_KV as KVNamespace);
  const user = await authenticator.isAuthenticated(request, {
    successRedirect: "/",
  });
  return user;
};

import { Layout } from "../components/Layout";
export default function Login() {
  return (
    <Layout>
      <Form method="post">
        <input type="email" name="email" required />
        &nbsp;
        <input
          type="password"
          name="password"
          autoComplete="current-password"
          required
        />
        <button>Sign In</button>
      </Form>
    </Layout>
  );
}
app/routes/login.tsx
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { getAuthenticator } from "../services/auth.server";

export async function action({ request, context }: ActionArgs) {
  const authenticator = getAuthenticator(context.SESSION_KV as KVNamespace);
  return authenticator.logout(request, {
    redirectTo: '/login'
  });
};

// First we create our UI with the form doing a POST and the inputs with the
// names we are going to use in the strategy
import { Layout } from "../components/Layout";
export default function Logout() {
  return (
    <Layout>
      <Form method="post">
        <button>Logout</button>
      </Form>
    </Layout>
  );
}

感想

ストレージにつなぎこむ部分はやってないが、ここまでやってみた感想として、現代で emal/password の自前認証なんか、セキュアじゃないしパスワードマネージャの管理も面倒なので誰も使わないよなぁ、と思ってやる気がなくなってきた。自分が使いたいものでいうと、 google authenticate か firebase authenticate だと思うので、そちらに切り替えていくことにする。

remix-auth の最低限の使い方を覚えたので満足。backend としての kv はいずれにしても使いそう。

そういえば、なんとなく Rails と張り合えるものにしたく、作る順番も Rails Guide が脳内にあったんだけど、セッションを自力で作るなんで脆弱性の元だし Rails でも学習目的以外はやめたほうがいい気がしている。

mizchimizchi

auth 環境を作ったが、そのサンプルコードをアップロードする前に、 wrangler における secrets の扱いを確認したい。

ぶつけた際の優先度を調べると際限なさそうなので、今回はやめておく。

$ npx wrangler generate cf-worker-vars

これでテストする

src/index.ts
export default {
	async fetch(
		request: Request,
		env: Env,
		ctx: ExecutionContext
	): Promise<Response> {
		return Response.json(env);
	},
};

最初は {}

調べるターゲット

  • wrangler.toml の [vars]
  • .dev.vars
  • wrangler secret put

wrangler.toml の vars

wrangler.toml に 変数を記入

wrangler.toml
[vars]
x = 1

これで一旦プロダクションに出す。同じ結果になった。特に production だから追加されるとかは何とかない。

$ wrangler dev
{"x": 1}
$ wrangler dev --local
{"x": 1}
$ wnagler publish
{"x": 1}

.dev.vars

[vars] は一旦コメントアウトした。

.dev.vars はローカルのみで適用されるという認識だが...

$ wrangler dev
{"from_dev_vars":"3","x":1}
$ wrangler dev --local
{"from_dev_vars":"3"}
$ wrangler publish
{}

消えたはずの x: 1 が残ってしまっている。これはおそらく、 cloudflare 側でトンネリングしてるので、先にリリースした wrangler の設定が有効になっていそう。--local を付けるとリリース済みの wrangler の変数がないので消えることからもわかる。

wrangler secret put foo

$ pnpm wrangler secret put foo
 ⛅️ wrangler 2.12.1 (update available 2.18.0)
-------------------------------------------------------
✔ Enter a secret value: … ****
🌀 Creating the secret for the Worker "cf-worker-vars" 
✨ Success! Uploaded secret foo
$ wrangler dev --local
{}
$ wrangler dev
{"foo":"unko"}
$ wrangler publish
{"foo":"unko"}

cf環境をトンネリングすると foo の中身が生える(内容はあってる)

消しておいた。

$ pnpm wrangler secret delete foo
 ⛅️ wrangler 2.12.1 (update available 2.18.0)
-------------------------------------------------------
✔ Are you sure you want to permanently delete the secret foo on the Worker cf-worker-vars? … yes
🌀 Deleting the secret foo on the Worker cf-worker-vars
✨ Success! Deleted secret foo

--env

env 指定による上書きルールをみる

wrangler.toml
[vars]
x = 2

[env.production.vars]
z = 4
x = 1
$ wrangler dev --local
{"x": 1}
$ wrangler dev --local --env production
{"z": 4, "x": 2}

.dev.vars が優先されるっぽい。

publish するとどうなるか

$ pnpm wrangler publish --env production
Your worker has access to the following bindings:
- Vars:
  - z: 4
  - x: 1
Current Deployment ID: e5dbc52a-b7b2-4e27-b8e7-6b47d5eac34e

本番の結果は {"z": 4, "x": 1} だった

mizchimizchi

プロダクションにリリースして見つけたバグ

wrangler pages publish でリリースされたものは wrangler.toml[vars] を読んでくれない。discord の forum で聞いたらバグとのこと。

管理画面から Pages > ProjectName > Settings > Production の Define variables for your Production environment. から環境変数をセットできる。

これは現状、管理画面から手動でセットするしかない。

KV も同じだったので binding 設定した。なんなら d1 以外動いてなくないか?

wrangler pages publish 環境は全部管理画面で手動で設定するのがいいという教訓を得た。半年後に治ってたらいいな。

mizchimizchi

memo: エラーを追うためにどうしたらいいか

  • wrangler [pages] publish した際のリリースハッシュ値をキーとして、 remix build した結果の functions/[[path]].js.map を R2 にアップロードする(TODO: 現在のハッシュを取る方法)
  • remix 内部のエラーを catch して、stackframe でパースする。自身の sourcemap をダウンロードして、内部エラー行を整形する (もしくは外部に専用の worker を binding して送ってしまう?)

sidecar 的に sourcemap tool をインクルードした エラー整形くんを binding しちゃうといいかも

mizchimizchi

現代の認証機構なんて Google OAuth だけでいいやろという判断の元、 Google 認証だけ作る。他にほしければ remix-auth みながら auth0 なり何なりつなぎ込んでください。

Google Authenticate

Google Cloud の Console に入って 「APIとサービス」 から OAuth Serivce を作る

ローカルテストと本番の発射元と callback を登録する。

cloudflare に環境変数を設定

管理画面 > Pages > ProjectName > Settings

ローカル環境

wrangler pages dev は --local でしか動かないため、本番環境でないと context にバインドできない。なので、ローカルのみに参照可能な .dev.vars に必要な情報を書く。これは .gitignore する。デプロイ時にバインドされたりもしないため、完全にローカル環境専用。

.dev.vars
GOOGLE_AUTH_CALLBACK_URL="http://localhost:8788/auth/google/callback"
GOOGLE_AUTH_CLIENT_ID=""
GOOGLE_AUTH_CLIENT_SECRET=""
SESSION_SECRET=""

手元の git には .dev.vars.example でコミットしておいた。

コードの変更

app/sessions.server.ts の規約に従ってないのに気づいたので、ファイルごと消してこういう一つのauth.server.ts にまとめた。getAuthenticator の引数は context そのものにした。

app/services/auth.server.ts
import type { AppLoadContext } from "@remix-run/cloudflare";
import {
  createCookie,
  createWorkersKVSessionStorage,
} from "@remix-run/cloudflare";

import { Authenticator } from "remix-auth";
import { GoogleStrategy } from "remix-auth-google";

export type AuthUser = {
  id: string;
  name: string;
  email: string;
}

let _authenticator: Authenticator<AuthUser> | undefined;
export function getAuthenticator(context: AppLoadContext): Authenticator<AuthUser> {
  // console.log('[auth] getAuthenticator', 'context', context);
  if (_authenticator == null) {
    // const cookie = createSessionCookie(context);
    const cookie = createCookie("__session", {
      secrets: [context.SESSION_SECRET as string],
      path: "/",
      sameSite: "lax",
      httpOnly: true,
      secure: process.env.NODE_ENV ==  "production",
    });
    console.log('[auth.server] cookie', cookie);
    const sessionStorage = createWorkersKVSessionStorage({
      kv: context.SESSION_KV as KVNamespace,
      cookie
    });    
    _authenticator = new Authenticator<AuthUser>(sessionStorage);
    /// google
    const googleAuth = new GoogleStrategy({
      clientID: context.GOOGLE_AUTH_CLIENT_ID as string,
      clientSecret: context.GOOGLE_AUTH_CLIENT_SECRET as string,
      callbackURL: context.GOOGLE_AUTH_CALLBACK_URL as string,
    }, async ({ profile }) => {
      console.log('[auth] google callback', 'profile', profile);
      return {
        id: profile.id,
        name: profile.displayName,
        email: profile.emails?.[0].value,
      };
    });
    _authenticator.use(googleAuth);
  }
  return _authenticator;
}

auth.google.tsx と auth.google.callback.tsx に認証エンドポイントとコールバックを作る

app/routes/auth.google.tsx
import { redirect, type ActionArgs } from "@remix-run/cloudflare";
import { getAuthenticator } from '~/services/auth.server'

export const loader = () => redirect('/login')

export const action = ({ request, context }: ActionArgs) => {
  const authenticator = getAuthenticator(context);
  return authenticator.authenticate('google', request, {
    successRedirect: '/',
    failureRedirect: '/login',
  })
}
app/routes/auth.google.callback.tsx
import { LoaderArgs } from '@remix-run/node';
import { getAuthenticator } from '../services/auth.server'

export let loader = ({ request, context }: LoaderArgs) => {
  const authenticator = getAuthenticator(context)
  return authenticator.authenticate('google', request, {
    successRedirect: '/',
    failureRedirect: '/login',
  })
}

これで email/password は不要になったので /login 画面では /auth/google に飛ばすボタンだけになる。認証済みのときだけ logout に飛ばすようにした。

app/routes/login.tsx
import type { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import { Form } from "@remix-run/react";
import { getAuthenticator } from "../services/auth.server";
import { Layout } from "../components/Layout";

export async function loader({ request, context }: LoaderArgs) {
  const authenticator = getAuthenticator(context);
  const user = await authenticator.isAuthenticated(request);
  return json({
    user,
  });
};

export default function Login() {
  const { user } = useLoaderData<typeof loader>();

  if (user) {
    return (
      <Layout>
        <pre><code>{JSON.stringify(user)}</code></pre>
        <Form method="post" action="/auth/logout">
          <button>Logout</button>
        </Form>
      </Layout>
    );
  }
  return (
    <Layout>
      <Form action="/auth/google" method="post">
        <button>Login with Google</button>
      </Form>
    </Layout>
  );
}

手元で確認、本番リリース後、 /login で認証ボタンを押してみて動作確認。
動かなかったら cloudflare の binding を見直していく。

ここで学んだクソテクニック。minify したままだとエラー情報が全然足りないので、一時的に開発用ビルドのまま minify せずにリリースした。確認後、元に戻した。未リリースなら許される。

mizchimizchi

次にやること。

  • production ready なボイラープレートを目指すなら sentry
  • service binding で sentry をラップする?
  • 次こそ blog 作る
mizchimizchi

Error の仕組みを見てみる

remix-cloudflare-pages がどういう風に pages をラップしてるか
https://github.com/remix-run/remix/blob/main/packages/remix-cloudflare-pages/worker.ts#L74-L89

cloudflare 側はただ proxy してるだけ。
https://github.com/remix-run/remix/blob/main/packages/remix-cloudflare/index.ts

pages にラップする部分を自力で書き換えれば良さそう...と思ったが error 時にここまで伝わってなかった。

sentry はどういう実装してるか。

https://github.com/getsentry/sentry-javascript/blob/master/packages/remix/src/index.server.ts#L20-L36

ここまで調べて、@remix-run/cloudflare(-pages) は統一的なエラーハンドラーがないということに気づいたので、 Discussion 作って終了

https://github.com/remix-run/remix/discussions/6313

mizchimizchi

真面目に drizzle で作り始めたら remix の Form コンポーネントで ajax(死語)するのが難しかったので、簡単なコンポーネント作って整理する。

app/routes/form.tsx
import { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { Form, useActionData } from "@remix-run/react";

export const action = ({ request }: LoaderArgs) => {
  return json({ value: 1, at: Date.now()});
}

export default function FormPage() {
  const actionData = useActionData<typeof action>();
  return (
    <>
      <Form method="post">
        <fieldset>
          <legend>FormAction</legend>
          <label htmlFor="value">value</label>
          &nbsp;
          <input name="value" defaultValue={actionData?.value} />
          <input type="submit" value="submit" />
        </fieldset>
      </Form>
      {actionData?.at && (<div>at: {actionData.at}</div>)}
    </>
  );
}

useActionData を使うと、 submit するたびに action を再実行し、再バインドする。

mizchimizchi

ちょっと複雑な版。初期値をロードしつつ、サーバー保存時に空文字だとエラーを返す。

import { LoaderArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";

export const action = async ({ request }: LoaderArgs) => {
  const form = await request.formData();
  const val = form.get('value');
  if (val == "" || val == null) {
    return json({
      ok: false as const,
      errors: [
        { field: 'value', message: 'value is empty'}
      ]
    });
  }
  await new Promise((resolve) => setTimeout(resolve, 500));
  return json({
    ok: true as const,
    at: Date.now(),
  });
}

export const loader = async ({ request }: LoaderArgs) => {
  return json({ ok: false as const, value: 0, at: Date.now()});
}

export default function UserSettingPage() {
  const { value: initialValue } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  return (
    <>
      <Form method="post">
        <fieldset disabled={navigation.state === 'submitting'}>
          <legend>FormAction</legend>
          <label htmlFor="value">value</label>
          &nbsp;
          <input name="value" defaultValue={initialValue} />
          <input type="submit" value="submit" />
        </fieldset>
      </Form>
      {
        actionData?.ok === true
          ? <>
            Saved at {actionData.at}
          </>
          : <>
            {
              actionData?.errors.map((error, i) => {
                return <div key={i} style={{color: 'red'}}>{error.field}: {error.message}</div>
              })
            }
          </>
      }
    </>
  );
}

こんな感じ