Closed45

Sveltekit・tRPC・Prismaで掲示板を作ってみる

ピン留めされたアイテム
kosei28kosei28

ひとまず、開発完了。
リリースするために思ったより時間がかかってしまった。

技術構成

  • SvelteKit
  • TailwindCSS
  • Headless UI
  • Firebase Authentication
  • Cloud Functions for Firebase
  • tRPC
  • Prisma
  • PlanetScale
  • Cloud Run

管理画面

  • SvelteKit
  • Tailwind CSS
  • Material UI
  • tRPC
  • Prisma

OGP画像生成

  • opentype.js
  • sharp
  • Twemoji
kosei28kosei28

Sveltekitが正式にリリースされたので、モダンな掲示板を作ってみる。

技術選定

  • Sveltekit
  • tRPC
  • Prisma
  • PlanetScale
  • Firebase Authentication
  • TailwindCSS
  • daisyUI
kosei28kosei28

ログイン・ログアウトはできるようになった。

kosei28kosei28

firebase functionsをsveltekitのプロジェクト内で初期化したら、eslintでfunctionsフォルダ内のtsconfigを読んでくれない。

kosei28kosei28

parserOptionsにtsconfigRootDir: 'functions'を追加したら大丈夫っぽい。
今度はeslintとprettierが競合した?

kosei28kosei28

functionsフォルダ内にもprettier入れようかと思ったけど、面倒くさいので、とりあえず.prettierignoreに/functionsを追加して対応

kosei28kosei28

やっぱりprettier欲しいので、eslint-config-prettierをfunctionsフォルダ内にもインストール。
.eslintrc.jsのextendsにprettierを追加して、rulesからquotesを削除して解決。

kosei28kosei28

firebase deployするとCannot read file 'functions/tsconfig.json'と言われた

kosei28kosei28

tsconfigRootDirを消して、.vscode/settings.jsonでeslint.workingDirectoriesを設定したら、直った。

{
    "eslint.workingDirectories": ["./", "./functions"]
}
kosei28kosei28

prismaとcopyfilesをfunctionsディレクトリ内にインストールして、package.jsonのscriptsに"prisma:generate": "copyfiles ../prisma/* ./prisma && prisma generate"を追加。npm run prisma:generateをfunctionsディレクトリ内で実行したら、firebase deploy成功した。

最初は、package.jsonに

"prisma": {
  "schema": "../prisma/schema.prisma"
}

って書いてやろうとしたけど、prisma generateするとプロジェクトディレクトリ直下のnode_modulesに生成されるため、断念。

kosei28kosei28

functionsのbuildスクリプトを"build": "npm run prisma:generate && tsc"にして、firebase deployの実行時にprisma generateを実行

kosei28kosei28

無事アカウント作成したら、functionが実行されたけど、DATABASE_URLが無いと怒られた。functionsフォルダ内にも.envファイルを作成したら解決。

kosei28kosei28

次は、スレッドを作成する機能を作る。
データベースの操作はサーバーサイドでPrismaでやるので、tRPCを使えるようにする。

kosei28kosei28

trpcでユーザーの取得ができた。
x-firebase-tokenヘッダーにfirebaseのid tokenを入れて、サーバーサイドでtokenを検証。

kosei28kosei28
  • スレッドの作成ページ
  • スレッドの詳細ページ
  • トップページの新着スレッド表示

ができた。

kosei28kosei28

とりあえず、今日はここまで。

残ってるタスクは

  • スレッドへのいいね
  • スレッドの削除
  • スレッドへの投稿
  • 投稿へのいいね
  • 投稿へのリプライ
  • 投稿の削除
  • ユーザーページ
  • スレッドのタグ検索
  • スタイル調整

調べながらやってたら、思ったより時間がかかってしまった。アプリのベースはできてきたので、ガンガン進めていきたい。あと、今はtRPCを直接叩いている状態なので、TanStack Queryを使いたい。

今日できたところまでのスクショ↓


kosei28kosei28

TanStack QueryがまだSvelteに対応していなかったので、svelte/storeで簡易的なSWRモドキを作ってみた。

import { get, writable } from 'svelte/store';

const swr = writable<{ key: unknown[]; data: unknown; fetchedAt: Date }[]>([]);

const refetch = async <T>(key: unknown[], queryFn: () => Promise<T>, index: number) => {
	const data = await queryFn();

	if (index == -1) {
		swr.update(($swr) => [
			...$swr,
			{
				key,
				data,
				fetchedAt: new Date()
			}
		]);
	} else {
		swr.update(($swr) => [
			...$swr.slice(0, index),
			{
				key,
				data,
				fetchedAt: new Date()
			},
			...$swr.slice(index + 1)
		]);
	}

	return data;
};

export const useSWR = async <T>(key: unknown[], queryFn: () => Promise<T>, maxAge = 600) => {
	let data: T;

	const $swr = get(swr);

	const chachedQueryIndex = $swr.findIndex(
		(query) => JSON.stringify(query.key) == JSON.stringify(key)
	);
	const chachedQuery = $swr[chachedQueryIndex];

	if (!chachedQuery || (Number(new Date()) - Number(chachedQuery.fetchedAt)) / 1000 > maxAge) {
		refetch(key, queryFn, chachedQueryIndex);
	}

	if (chachedQuery) {
		if ((Number(new Date()) - Number(chachedQuery.fetchedAt)) / 1000 > maxAge) {
			refetch(key, queryFn, chachedQueryIndex);
		}
		data = chachedQuery.data as T;
	} else {
		data = await refetch(key, queryFn, chachedQueryIndex);
	}

	return data;
};
kosei28kosei28

スレッドのいいねと削除機能ができた。
次はいよいよ書き込み機能をつくる。

kosei28kosei28

投稿機能完成。いいねと削除もできるようになった。リプライ機能はできたら後でやる。
新着投稿の表示はwebsocketでやろうかと思ったけど、別インスタンスだとできなくなるから、とりあえず10秒ごとにAPI叩いてる。あとでpub/subとか使ってやりたい。

kosei28kosei28

ユーザーページを作る。

  • 公開されるプロフィール
    • 作成したスレッド
    • スレッドへの投稿
  • ライブラリ
    • スレッドと投稿へのいいね
  • 設定
    • プロフィール
    • アカウント削除
kosei28kosei28

リプライ機能は作ってないけど、ほとんど完成した。
かかった時間はだいたい3日。
Svelte・Tailwind・Headless UIでフロントの開発が楽だったのもあるけど、なによりtRPC・Prismaの開発体験が良すぎて、めっちゃ簡単に機能追加できた。あと、認証をFirebaseに丸投げできたのも大きい。

最終的な技術構成

  • SvelteKit
  • TailwindCSS
  • Headless UI
  • Firebase Authentication
  • Cloud Functions for Firebase
  • tRPC
  • Prisma
  • PlanetScale
kosei28kosei28

報告機能を作る。
スレッドと投稿で別々の報告用テーブルを作成。

kosei28kosei28

報告機能が完成。
ラジオボタンにtailwindのformsプラグイン使おうとしたけど、意外と使い勝手が悪かったので、自前で実装。

kosei28kosei28

スレッドの共有機能が完成。
あとは、規約書いて、Metaタグ書けば終わりかな。

kosei28kosei28

管理アプリ作るために、モノレポ化。

firebase functionsはデプロイ時にnpm installするためprisma generateを事前にする必要はなかった。でも、firebase.jsonのsourceで指定したディレクトリ以下しかアップロードされないためschema.prismaをコピーする必要はある。schema.jsonをルートディレクトリにおいたら、copyfilesではパスをうまく解決できなかったので、ncpに変えた。

kosei28kosei28

管理アプリはmaterial uiとtailwindを使って開発。
monorepoにしたおかげで、prismaのスキーマを共有できて、楽に開発できた。
trpcを使うために、trpc-sveltekitを使っていたが、ただの薄いラッパーだった上に、型が合わなくなるので、直接trpcを使うことにした。

kosei28kosei28

Cloud Runに開発環境をデプロイ。
Cloud Load Balancing使って、IAPでアクセス制限。
問題なく動作することを確認。
あとはアクセスログの記録と、SEO対策(OGPの動的生成とかしたい)したら開発はひとまず終了。
規約も書かないといけないけど。

kosei28kosei28

OGP画像の動的生成はopentype.jsとsharpで実装。
1文字ずつ処理することで、複数のフォントに対応。
長い文章の省略にも対応。
Intl.Segmenterで単語に分けて改行。
1行に入り切らない単語は、単語の途中で改行。

kosei28kosei28

絵文字はemoji-regexで取得してから、Twemojiの画像を読み込んで表示。

kosei28kosei28

CJKの文章は単語の途中でも改行するようにした。
CJKの文字の判定は

text.matchAll(/\p{scx=Hani}|\p{scx=Hira}|\p{scx=Kana}|\p{scx=Hang}/gu);

という正規表現でできる。

kosei28kosei28

なぜかCloud Runで動かすと絵文字が分割された。

ローカルで実行すると、正しく表示される。

コンテナで実行してるので環境差異はないはずなんだけど・・・
原因はよくわからないけど、emoji-regexからtwemoji-parserに変えたら直った。

kosei28kosei28

console.logでcloud loggingにログを保存。

kosei28kosei28

ログルーターで保持期間90日のログバケットに保存。

このスクラップは2022/12/31にクローズされました