Remixで毎日着てるバンTの記録管理機能を作って運用始めた話
Remixでとある個人目的のちょっとしたアプリを作って運用を開始した。まだ開発途中の部分はあるが部分的に運用開始できたので、技術スタックなどをふまえてここで一旦振り返る。
はじめに
私は毎日筋トレしていて、その記録の管理やSNSへの投稿データの生成などをNext.jsで作ったWebサイトで実施しているが、今回作ったアプリは、性質としてはこれとほぼ同じものである。このアプリは、CloudflareでウチのグローバルIP以外からのアクセスを原則禁止しているし、Auth0で認証かけてるしなどで、第三者が利用することはできなくなっており、完全に「オレ専用」のアプリで、「社内の人しか使えない業務アプリ」みたいなものに近い。そういう意味で「オレ専用の社内(?)管理サイト」といったものに近い。すでに存在する「筋トレの記録管理アプリ」とほぼ同じ性質のものなら、わざわざ新規に作るまでもなくそれを改造するとかすればよいのだが、今回作ろうと思っていたものは、もっとそれを汎用化することを目的としており、管理するデータの性質上「筋トレの記録」に特化した既存のアプリとは、運用の観点で相性が悪いと思ったのと、Next.jsに飽きていて別のフレームワークを使いたい-もっと具体的に言うとRemixを使いたい-という目的があり、新規に1個Webアプリを開発した、という経緯である。
技術スタック
大まかには以下
- Framework: Remix
- ORM: Prisma
- Hosting: Heroku
- DB: Supabase
- DNS、WAF: Cloudflare
- Object Storage: Cloudflare R2
- Authentication: Auth0
以下ポイントなど
- 元々「Remixでなんか作ってみたいなあ」という思いはあり、それに向けて水面下で色々準備はしていた。そのときの話はこの記事にまとめている。ここに書いた通りで、Remix on Herokuという構成で開発した。
- DBはHeroku Postgresqlを使っても良かったんだが、今回作った「毎日バンTの記録」は、「1日最低1回は接続・更新する」という運用の性質から、SupabaseのInactive回避と相性が良く(*)、これは既に別アプリで実施している筋トレの記録管理も同様なのだが、その意味でわざわざSupabaseから離れることもあるまいと思い、Supabaseを選んだ。ただそういう意味では惰性による部分は正直少し大きい。Heroku Postgresqlのほうが高機能だし、DBだけ別サービス使っているという状況もあまり個人的に好ましくなく、そのうちHeroku Postgresqlに移るかもしれない。
- (*) SupabaseのPricingのページに、“Free projects are paused after 1 week of inactivity. Limit of 2 active projects.“という記述がある。が、“inactivity"の条件が正確にわかっていない。(Github issueで質問している、が、明確な回答はない)今までの経験上、「DBにConnection張る」だけでは不足しているようだということと、「24時間以内に最低1回CRUDのどれかを実行していれば生きながらえる」ということまではわかっている。で、「毎日バンTの記録」や「毎日筋トレの記録」は「毎日」という運用の性質が後者の条件を満たすので都合がいい、という意味である。
- Object StorageはS3使っても良かったんだが、DNSだのWAFだのでどうせCloudflare使うならわざわざ別サービスに手出すこともないかと思い、R2に落ち着いた。WorkersなどのようにBINDINGが出来るわけじゃないので、やり取りには普通に@aws-sdk/client-s3使う。余談だが「どうせCloudflare使うならS3じゃなくてR2でいいか」という判断は最近増えてきており、そのうちS3から撤退するかもしれない。Egress完全無料とCloudflareとの統合力はやはり強い。
- いつものようにORMにはPrismaを用いたが、最近Prismaには少し不満も出てきていて、正直惰性で選択した感じではある。次なにか作るときは違うORM使うかもしれない。(Drizzleというのが少し気になっている)この辺の不満は後述。
- CloudflareのWAFで我が家のグローバルIP以外からのアクセスを拒否しているので、原則として俺以外そもそもシステムにたどり着けない。旅行中などでは一時的に開放してスマホのキャリア回線やホテルのWiFiなどからのアクセスも可とする。
- Auth0はカスタムドメイン利用しているものの、CloudflareのDNS Proxyができない(らしい)ので、DNSのみの利用で、WAFは入らない。したがってやろうと思えばAuth0の認証画面自体には誰でもたどり着ける。ただしコールバック先がWAF配下のURLなので結局そこで止まるし、ユーザーの新規登録も止めているので、万が一認証画面にたどり着けてもできることはほぼない。
運用している機能
2025年7月現在では、毎日バンT投稿の画像アップロードと投稿データの登録・管理などをこのシステムで運用している。この企画(?)の趣旨は以下など参照をば
この運用は、
- 朝Tシャツの写真を撮る
- Twitterに投稿
- あとで投稿した写真をダウンロード
- 自作のツールで「前に着たことあるか」をチェック
- CSVに記録
という手動運用をしていた。1.2.あたりは自然な流れなので別にいいんだが、その後の3.~5.が運用的に負荷が高く(平たく言えば面倒くさい)、もっとスムーズに運用できるようにしたいとはずっと思っていた。運用上の最大の課題は、1.2.はスマホで完結するが3.~5.はPC上で行う必要があったということだ。要するに1.2.と3.~5.はそもそも「別の運用」で、各々独立していたのだ。「後からTwitterの投稿を見にいって画像DLしてローカルに保存しPythonの自作ツールで過去画像と突合して結果確認してCSVに記録」なんてアナログなことをやっていたんだが、毎日こんなことしてたわけじゃなく、10日か下手すると1か月くらい空いてから、たまった分をまとめてDLして毎回突合してCSVに記録、、、みたいなことやってて、面倒くさくてそのうち放置してしまいそうになり、危機感を募らせていた。こんな運用を1年ちょっとも続けていたわけだが、よく1年も続けていたなと逆に感心する。暇か!とにかく、ここをシームレスにする運用化が必要だった。
機能上では、特に4.のツールの、これはPythonのtochvisionによる類似画像検索の「前に着たことがあるか」のチェックが課題だった。これ、写真を撮る時間や撮った場所の明るさなどによっては、例えばカーキのTシャツが黒に見えたり、その逆もしかりで、「類似した画像である(=前に着たことのあるTシャツの画像との類似性が高い)」という判定がこちらの想定通りに動かないことが多くあった。また、「黒Tの前面にシンプルにバンドロゴだけがプリントされている」みたいなのはどのバンドでも共通的に割とあり、単純な画像の特徴量だと別バンドのこうしたバンTも類似しているとして抽出されてしまう。要するに「前に着たことがあるか」の判定を、画像の特徴量でやること自体にそもそも無理があるということであり、別の方法でこの判定を行う必要があった。この要件の趣旨は、「そのバンドの」「その色の」「半袖のor長袖の」Tシャツを前に着たことがあるか?というものなので、画像の特徴などではなくその情報をDBにブチ込んで検索しちまえばいいということに気づいた。幸いにして5.で今までの記録はすべてCSVに保存してあったので、DB化するのはそれほど苦ではなかった(まあ将来的にそれを見越してCSVにしていたということでもあるのだが)。
現状は
- 前に着たことがあるかを検索
- 検索結果をもとに今日のTシャツ画像をアップロード&付随情報(バンド名、Tシャツの色、半袖or長袖 etc)をDBに登録
- 登録した情報からTwitterに投稿する投稿文を生成してクリップボードにコピー
- Twitterに投稿
という運用になっている。1.~3.は今回開発したRemixのWebアプリで行っており、4.だけTwitterのスマホアプリで行うが、全体を通してすべてスマホ上で操作が完結するので、運用の流れが非常にスムーズになった。今までは投稿した「後」にその情報を拾っていたのだが、投稿する「前」にそれらの情報を準備しておいて、投稿はその延長という形のフローになったので、投稿のための準備と投稿までがスムーズに統合されて、「後」から色々面倒な手動運用をする必要がなくなった。また、「前」に色々できるようになったので、例えば「最後に着たのはいつか」とか「これが何回目か」といった情報の採取も前より容易になり、投稿文の情報量がリッチになった。運用負荷が減り、満足である。
開発中の課題
いろいろなエラー
このQiitaの記事に書いたので参考にしていただきたい。
Remixのクライアントサイド実装
例えば以下のようなコード
export async function loader(){
// ...
}
export async function action() {
const hogehogeResult = await hogehoge();
// ...
}
async function hogehoge() {
// ...
}
export default function Page() {
// ...
}
において、hogehoge()
はサーバー側で動くかクライアント側で動くか?という疑問があった。action()
から呼び出されているのでサーバー側の処理として組み込まれる想定だったが、実際はそうはならず、クライアント側の実装としてビルドされる。よってhogehoge()
の中にサーバー処理(DBアクセスとか)を入れると、ページでsubmitした直後にエラーになる。ちなみにクライアント側の実行なので、エラーもクライアント側で出る(devtoolで見る必要がある)。
どうもRemixはloader()
とaction()
以外のサーバー処理というのを規定していないようだ。それ以外のfunction等を定義すると問答無用でクライアント側の処理として扱われるようになっている(絶対にサーバー処理として扱わない)節がある。これは外部に切り出したコンポーネントでも同様で、すべてクライアント側の実装として動作する。逆に言えば、サーバー側の処理にしたいならloader()
かaction()
に組み入れる必要がある。つまり上の例でいうと、hogehoge()
がサーバー処理ならば、それはloader()
やaction()
と同列に並べてはいけなく、例えば
export async function action() {
async function hogehoge() {
// ...
}
const hogehogeResult = await hogehoge();
// ...
}
みたいにしてaction()
の中に組み入れる必要がある。あるいはhogehoge()
の中身に相当する処理を/app/route/api.hogehoge.ts
のようにAPIとして別に切り出してその中のloader()
かaction()
に入れて、クライアント側からfetch
で呼び出す等の工夫がいる。
これに関して、最初は「融通が利かない」と思ってたが、Next.jsに比べるとむしろシンプルでわかりやすく、実装しやすいと今は思う。要するに「loader()
かaction()
以外は全部クライアント側」なので、「ここは全部クライアント」「ここは全部サーバー」という切り分け(境界線)がはっきりしている。Next.jsは、use client;
とかuse server;
などのディレクティブでクライアントとサーバーの実装がゴチャゴチャ変わり得るので、クライアントとサーバーのどっちで動くかイマイチ掴みどころがない部分が多い印象だが(App Routerになってから余計にその色が濃くなっている気がする…まあこれは余談だが)、それと比べると、確かに細かい制御はできないだろうが、できることが限られている分かえって実装がシンプルになって分かりやすいというのが、振り返ってみた印象としてある。このシンプルな思想は今後も続けてほしい。
Prisma Clientについて
Prismaは、v6.6.0から、schema.prisma
のoutput
の定義(Prisma Clientの出力場所)を「ほぼ」強制している(v7からは必須とすると書いてある)。このため、実際の開発では、Prisma関連のパッケージをimportする場合、その出力した先のディレクトリを指定する必要がある。たとえばoutput = "../generated/prisma"
ならimport {PrismaClient} from '../generated/prisma/index.js';
という感じである。
ただ、色々やってみた感じ、Remixのビルドがこのディレクトリを見てくれず、ビルド時に@prisma/client did not initialize yet.
のエラーが出る問題を解消できなかった。output
の指定をapp
配下にしたりとかvite.config.js
いじくったりとかビルドの手前でprisma ganarate
したりとか、色々試したんだがだめ。Remixのビルドの指定がだめなのか、Prismaの出力がだめなのか、いまだに結局原因がわかってない。暫定的なソリューションとしては
- コードは
import {PrismaClient} from '@prisma/client'
schema.prisma
ではoutput
で適当な場所を指定prisma generate
の後、生成されたモンを毎回./node_modules/@prisma/client
下に全コピー
で乗り切った。(ここで呟いている)要するにPrisma Clientの配置場所が従来通りnode_modules
直下になるように、とりあえず一時的に適当なところにgenerateしておいて、generate直後にそれらをnode_modules
にコピーして、それと同じ状況を作ってしまえばいいというものである。このためにpackage.json
に専用のprisma generate
のコマンドも用意した。このGithub Issueのコメントが参考になった。
とりあえず動いてるからこれで良しとしているが、この件も含め、Prismaには最近振り回されている印象がぬぐえない。使いやすいので使ってはいるが、万能でもないし、使おうとするたび毎回こう面倒な調整が必要になるような変更を加え続けるようだと、正直そこまで使い勝手がいいものでもない。上で「惰性で選んだ」というのもその理由による。他にもDrizzleというのがあるのは目にしており、次に何か作る場合はPrismaではなくこのORMにするかもしれない。
クリップボードへのコピー時にfetch
できない
これはRemixは関係なく、純粋なWebアプリ開発の課題というか、制限事項である。このWebアプリでは、テキストをクリップボードにコピーするために、クライアント側でawait navigator.clipboard.writeText(text)
を使ってるのだが、そのtext
を取得するにあたり、fetch
でリクエストしてデータ取得するということをやっていた。そしたらUnhandled Promise Rejection: NotAllowedError
というのが出て、クリップボードのコピーまで処理がいかなかった。ただし、これはスマホのChromeでのみ発生し、PCでは発生しなかった。
調べてみたらSafariにおけるWebkitのバグだという情報が出てきたが、この時使っていたのはGoogle Chromeなので、この事例には合致しない。ただ「クリップボードにコピーする値を外部から取得してくる」という行為自体がどうも怪しいと踏んで、クライアント側でfetch
でテキスト取得する実装をやめて、この処理自体を1画面別に切り出して独立させ、fetch
でやってた処理をその画面のloader()
でやるように変えて、クライアント側では単純にテキストをクリップボードにコピーするだけにした。そしたら動いた。なので”「クリップボードにコピーする値を外部から取得してくる」という行為自体がNG"という勘は恐らく当たっている。なんか昨今のブラウザは色々やってんだなぁ~というのを逆に感心した事例であった。
将来的な展望
X APIとの統合
運用開始してから1週間くらい経つが、特に3.4.が分離しているのが若干まだ手間である。画像も投稿データもすべてシステム上にあるのだから、そのままTwitterにAPIで投稿するのも不可能ではないはずだし、そうなれば運用上の手間もさらに一つ減る。このためには、X API v2ではできない(と思っていた)「画像付きの投稿」を実行できる必要があるが、最近、X API v2でも画像の投稿ができる(らしい)という情報を目にしたので、現在は目下その開発中である。これが実現すれば3.4.は消えて、2.の投稿後に裏でTwitterの投稿まで済ませられるようになる。素敵だ。一応、curl
での実験には成功しており、やろうと思えばアプリのフローに組み入れることも最早可能である。(HerokuならOne-Off Dyno使ってcurl
のスクリプト流すのはたやすい。こういうのが手軽にできるのはHerokuのいいところだ)ただどうせだからTypescriptで実装してWebアプリに統合したい。が、これが難航している。
基本的にはv1.1で画像の投稿してた流れと同じようなのだが(INITしてmedia idを取得し、そのmedia idに対して画像や動画のバイトデータをAPPENDし、完了したらFINALIZE)、Typescriptでfetch使って実装する場合だとこれがどうもうまくいかない。/2/media/upload/{id}/append
が400エラーになって先に進まない。色々試しているうちにRate Limitを超えたようで429が返ってくるようになり、以後そこから復旧しない。で、現状止まっている。こういうのは何か基本的になことを見落としているんだろうと思うが、429エラーが解消しない現状だと調査や他のパターンの試行もできず、停滞している。
Pythonのコードサンプルはあるんだが、それをTypescriptのfetchで焼きなおす場合に、どのような実装にするべきなのかいまいちわからない。v1.1のときとパラメータのフィールド名は同じなので、それに倣えばいいと思ってBuffer<ArrayBuffer>
をslice
して設定して送ってるのだが、うまくいかない。curl
でいうと--form 'media=@/hogehoge/hugahuga.jpg'
にあたる部分のパラメータ(のはず)なので、
const formData = new FormData();
formData.append('media', bytedata, filename);
でうまくいきそうなもんだが、これだとエラーになるのだ。イマイチどこがだめなのかがわかっていない。自分でHTTP Header全部書いたらうまくいったっていう(v1.1時代だが)の記録も見つけており、これを試してみたいところだが、429エラーが解消しない現状だとこれも試せない。
そもそもの話として、ドキュメントだと、/2/media/upload/{id}/append
はapplication/json
で送れと書いてあるが、バイナリデータを送信する以上このContent-Typeはmultipart/form-data
か'application/octet-stream'
にしなければいけないはずである。v1.1時代のエンドポイントのドキュメントを見てもそうなっている。それに、ドキュメントでは
- INIT:
/2/media/upload?COMMAND=INIT
- APPEND:
/2/media/upload?COMMAND=APPEND
- FINALIZE:
/2/media/upload?COMMAND=FINALIZE
と、GETパラメータでアップロードの工程を指示する記述をしているが、こちらのQiitaを見るとこのエンドポイントは2025年5月で廃止されているという記述もある。こういうのを踏まえても、X APIのドキュメントの正確性には少し懐疑的なものを感じる(今に始まったことでもないが)。それにFree Planだと明確にこのエンドポイントが許可されているという情報はない(このCommunity記事だと「スコープつければいける」とは書いてるし、実際curl
で試した限りではその通りだったが)。色々踏まえると、現状はまだ時期尚早なのかもしれないな、と思ってしばらく様子見することにした。Typescriptの実装例もそのうち出てくるかもしれないしな。もう少し長い目で見てのんびり開発していこうと思う。
筋トレ管理+αの統合
上述したように、筋トレ管理アプリは現状独立していて、Vercel上で稼働している。が、これは別に意図があってそうしているわけじゃなくて、たまたま先行して筋トレアプリだけを作っていたという理由に過ぎない。「毎日やってることの記録」という意味だとTシャツの記録と利用用途としては同じであり、わざわざ別アプリに独立させておく意味がない。「筋トレの記録はあっち、Tシャツの記録はこっち」みたいにいちいち移動するのもたるい。なので最終的にはこの筋トレの記録管理機能もこっちのアプリ(Remix側)に統合させたい。筋トレの記録管理はNext.jsで作っているので、フロント側も含めてそのまま横流しで移行ってわけにはいかないが、コアな機能(DB操作など)は別の.tsファイルに独立させているし、Prisma使ってるという点では同じなので、ある程度はそのまま移植可能な想定なのだ。これは現状動いている機能があるので今すぐに移行したいものでもないが、ある程度のロングタームで移行させたい。(2026年中くらいかなー)
あと他に「毎日」やってることでいうと、「英語の勉強」がある。この記録自体は現状スタディサプリのアプリに任せているので、その機能性を保有する必要はないのだが、ただ正直「連続日数の記録を絶やさないために」惰性で使っている色が大きく、日々の「勉強」といってもNHK English NewsやCNNの視聴・精読が9割以上を占める。つまりスタディサプリ自体に備わっている教材はほとんど使ってないので、連続日数の記録機能をこっちのアプリの方に持ってきちゃってもよい気はする。それとは別に、英単語の復習機能を実装したい。NHK English Newsを聴き始める前までは、日々のスクワットに合わせて毎日英単語の復習をしてたのが、NHK English Newsを始めてからこの習慣がなくなっており、正直英単語のボキャブラリは衰えている。手元にフラッシュカードが22個ほどあるが、物理的にこれらをめくって復習していくスタイルだと、可搬性・利便性の観点で課題がある。これをWebアプリ経由で使えるようにすればもっと手軽に毎日できるようにはなるのでは、という目論見である。既に実装を開始しており、近く運用を開始する予定だ。問題なのは、手元にある22個のフラッシュカードに記録された大量の単語の初期登録(移行)。1つのフラッシュカードに大体80個の単語があるので、ざっくり見積もって1760個の単語がある。これはTシャツの記録と違ってCSVにもなってないので、地道に手動登録が必要で、これが地味にすごい大変。まあ、がんばろうと思う。
これらを含めて、「毎日やる」ことの記録を登録・管理するためのアプリとして、順次機能追加していきたいと思っている。現状は(予定も含めて)「Tシャツの記録」「英単語の復習」「筋トレの記録」など、機能特性ごとに特化したものを作っているが、最終的にはこれをもっと汎用化させたいという野望がある。これはもっとかなり長期的な展望だが、そのうち実現できたらいいなと思っている。
おわりに
上の「将来的な展望」に書いたとおりで、まだまだ開発途上で今後拡張していく予定ではあるが、Remixを使ったシンプルなWebアプリケーションの開発としては良い体験ができていると思う。今後も継続的に進めていきたい。