🗨️

SvelteKitが正式リリースされたのでtRPCとPrismaを使ってWebアプリを開発してみた

2023/01/01に公開約5,700字

新年あけましておめでとうございます。
昨年はあっという間に過ぎ去ってしまったので、2023年はたくさん開発していきたいです。

はじめに

はじめまして、kosei28という者です。
普段は大学に通いながら個人開発している19歳です。

この度、Chatockという掲示板のようなWebアプリを開発したので、紹介させてください。

https://chatock.com/

つくったもの


スレッドを作って、その中に投稿することができます。
スレッドにはタグをつけることができて、タグによってスレッドを検索することができます。
いいねをすることもでき、いいねしたスレッドや投稿は自分のライブラリページから見返すことができます。

https://chatock.com/

背景

さて、皆さんはSvelteKitを知っていますか?

SvelteKitとは、SvelteのWebアプリを開発するためのフレームワークで、ReactにおけるNext.jsのようなものです。
ルーティングやSSRなどができるやつですね。

そんなSvelteKitですが、昨年の12/14に正式にリリースされました。
https://svelte.jp/blog/announcing-sveltekit-1.0

Svelteが好きで、ベータ版のときからSvelteKitを触っていたので、この機会にSvelteKitでWebアプリを作ってみようと思い、12/15に開発をスタートしました。

技術

今回、SvelteKitで開発するにあたって、以下のような技術を採用しました。

  • 言語
    • TypeScript
  • フロントエンド
    • SvelteKit
    • TailwindCSS
    • Headless UI
  • バックエンド
    • tRPC
    • Prisma
    • PlanetScale
    • Cloud Functions for Firebase
  • 認証
    • Firebase Authentication

フロントエンド

スタイリングは、主にTailwindCSSで行い、メニューやダイアログなどにはHeadless UIを使いました。

最初は、Headless UIではなく、CSSのみで書かれているTailwindCSSのプラグインのdaisyUIを使おうとしたのですが、CSSで無理やり実現するために、アクセシビリティ上良くないことも多く、ESLintに怒られまくったので、採用は諦めました。

Headless UIは初めて使ったのですが、Tailwindチームが開発していることもあって、TailwindCSSとの相性が抜群でとても使いやすかったです。
ただコンポーネントの数があまり多くないので、それは今後に期待ですね。

ちなみに、Svelteでは、svelte-headlessuiというライブラリでHeadless UIを使うことができます。
https://github.com/rgossiaux/svelte-headlessui

また、管理画面はTailwindCSSとMaterial UIを使ったのですが、CSSの競合が起きることがあり、直接CSSを書いたりと、少し工夫が必要でした。

バックエンド

バックエンドでは、tRPCとPrismaを使って開発しました。

tRPCは、バックエンドのエンドポイントを定義すると、フロントエンドからその型をそのまま使って開発できるというものです。
クライアントもtRPCが作ってくれるので、大変開発がはかどります。

最初は、trpc-sveltekitというライブラリを使っていたのですが、ただの薄いラッパーで型が合わなくなることがあったので、直接tRPCを使うことにしました。
SvelteKitには、Hooksという機能があり、そこでtRPCのfetchRequestHandlerを呼ぶことでエンドポイントにアクセスすることができます。

src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '$lib/trpc/router';
import { createContext } from '$lib/trpc/trpc';

export const handle: Handle = async ({ event, resolve }) => {
	if (event.url.pathname.startsWith('/trpc')) {
		return await fetchRequestHandler({
			endpoint: '/trpc',
			req: event.request,
			router: appRouter,
			createContext: () => createContext(event)
		});
	}

	return resolve(event);
};

Prismaは、TypeScriptのORMです。
schema.prismaというファイルでスキーマを定義すると、マイグレーションから、TypeScriptのクライアントの生成までやってくれます。

tRPCとPrismaによる型が保証された開発は、開発体験が良すぎて病みつきになりそうです。
エディタが自動補完してくれるのはもちろん、特に、既にあるコードを書き換えるときは、安心感がありますね。

データベースは、無料枠とブランチ機能があるPlanetScaleを使っています。
PlanetScaleには、Serverless Driverというhttpで通信できるクライアントがあるのですが、Prismaはまだそれに対応していません。
もし対応したら、Cloudflare Pagesなどで、Data Proxyを使わずにPrismaを使うことができるので期待してしまいます。
issueは既にあるので、対応してくれるのを待ちましょう(他力本願)。
https://github.com/prisma/prisma/issues/15265

Cloud Functionsは、Firebase Authenticationでユーザーが作られたときに、データベースにレコードを追加するためだけに使っています。

OGP画像

スレッドのページのOGP画像は動的に生成しています。

↓こんな感じです。
https://chatock.com/thread/clcczos9f0001s60dpha9mf8z

OGP画像の動的生成にあたって、最初は、satoriというライブラリを採用しようとしました。
satoriは、vercelが開発している新しいOGP画像生成用のライブラリである@vercel/ogのために開発されたライブラリです。
vercel/og-imageなどとは違い、HTMLをヘッドレスブラウザを用いずにSVGに変換でき、SVGを画像に変換することによってOGP画像を生成することができます。
しかし、HTMLから変換すると言いましたが、実際はJSXを渡す必要があり、svelteとはあまり相性がよくありませんでした。
satori-htmlなどを使ったり、オブジェクトを直接渡したりして、使うこともできるのですが、絵文字が表示されなかったり、改行が思い通りにいかなかったりしたので、違う方法を試すことにしました。

次に試したのは、Cloudinaryを使う方法です。
Cloudinaryは画像の変換や配信が簡単できるSaaSで、ZennのOGP画像の配信にも使われています。
最初は良さそうに思えたのですが、幅に収まりきらない単語のあとに他の単語が続くと、最初の単語が改行されずに、はみ出てしまうという問題があることがわかりました。
そこまで長い単語を使うことはほとんど無いし、日本語では全く問題にならないので別に良いかとも思ったのですが、どうせなら完璧にしたい!と思い、この方法も見送ることにしました。

そして、最終的に採用した方法は、opentype.jsでテキストをSVG化して、sharpでSVGを画像にするというものです。
ヘッドレスブラウザを使わないため軽く、SVGなので画像を直感的に配置したりできます。

opentype.jsはJavaScriptでフォントをパースできるライブラリで、テキストからパスを取得することができるのですが、複数のフォントに対応するため、1文字ずつグリフが存在するか検証して、グリフが存在するフォントからパスを取得するようにしました。

また、幅が超えたら自動で改行するようにしたのですが、単語単位でも改行するようにするため、Intl.Segmenterを使っています。
Intl.SegmenterはJavaScriptの標準のテキストセグメンテーションができるオブジェクトです。
CJK(中国語、日本語、韓国語)のテキストは、単語の途中でも改行するようにしたかったので、正規表現で判定しています。

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

このように、UnicodeのScript_Extensionsを使っています。
正規表現でUnicodeのプロパティが使えるのは便利ですね。

絵文字はTwemojiで表示しています。
Twemojiをそのまま使うのはヤバそうだという記事を見たので(2023年になった今でもまだアクセスできるようですが)、jsDelivrを使っています。
https://zenn.dev/yhatt/articles/60ce0c3ca79994

背景画像を読み込むために、base64でimportできるViteのプラグインも書きました。

vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { readFile } from 'fs/promises';

/** @type {import('vite').Plugin} */
const base64 = {
	name: 'base64',
	async transform(_code, id) {
		if (id.endsWith('?base64')) {
			const path = id.slice(0, -7);
			const buffer = await readFile(path);
			const base64Data = await buffer.toString('base64');
			return `export default '${base64Data}';`;
		}
	}
};

/** @type {import('vite').UserConfig} */
const config = {
	plugins: [base64, sveltekit()]
};

export default config;
import.d.ts
declare module '*?base64' {
	const value: string;
	export default value;
}

こんな感じにbase64形式でインポートすることができます。

import imageBase64 from '$lib/assets/image.png?base64';

今回、OGP画像の生成はほとんど自前で実装したのですが、意外と簡単に(面倒ではありましたが)良いものができたのではないかと思います。
気が向いたらライブラリを作るかもしれません。

1/3 追記:ライブラリ作りました。OGP画像を動的に生成する機会があったら、ぜひ試してみてください。
https://zenn.dev/kosei28/articles/39ed9663ae4136
https://github.com/kosei28/ezog

開発してみて

最初は爆速で開発して3日くらいでリリースしてやる!と意気込んでおり、実際にメインの機能はそれくらいで開発できたのですが、管理画面の開発などリリースするためにやらないといけないことが面倒くさくて結構時間がかかってしまいました。

今までも、いくつかWebアプリを開発したことはあったのですが、リリースまでしたものはほとんどありませんでした。
今回、リリースまでやってみて、改めてその大変さを実感するとともに、途中でやめないでリリースまで持っていくことが大切なのではないかと感じました。
面倒くさくても、大変なことをやっているということは、絶対に自分のためになることをしているということですからね。

というわけで、手前味噌ではありますが、結構良いものができたのではないかと自負しているので、ぜひ使ってみてください。

https://chatock.com/

Discussion

ログインするとコメントできます