🧡 SvelteKit 実践ガイド
SvelteKit + Tailwind CSS + Bun環境での実践的な知識を凝縮。ルーティングからデプロイまで、このページ一枚で全体像が掴める。
SvelteKitとは
SvelteKitはSvelte公式のフルスタックWebフレームワーク。ビルドツールにViteを採用しており、開発時のHMR(ホットモジュールリプレイスメント)が極めて高速。SSR(サーバーサイドレンダリング)・SSG(静的サイト生成)・SPA(シングルページアプリ)を同一コードベースから選択できるのが最大の特徴だ。ReactベースのNext.jsと対比されることが多いが、SvelteはコンパイラベースのUIフレームワークであり、バンドルに仮想DOMのランタイムを含まないため最終的な出力が軽量になる。
なぜSvelteKitを選ぶか。第一に書きやすさ。コンポーネントはHTML・CSS・JSをひとつの .svelte ファイルにまとめる方式で、ReactのJSXや複雑なstate管理に疲れた人に刺さる。第二にパフォーマンス。Viteによる超高速ビルド、仮想DOM不使用による軽量バンドル。第三に柔軟なレンダリング。ページ単位でSSR/SSGを切り替えられ、Cloudflare PagesやVercelへのデプロイも簡単。
Next.js vs SvelteKit 比較
| 観点 | Next.js | SvelteKit |
|---|---|---|
| UIフレームワーク | React(仮想DOM) | Svelte(コンパイラ、仮想DOMなし) |
| ビルドツール | Webpack / Turbopack | Vite(超高速) |
| バンドルサイズ | 大(Reactランタイム含む) | 小(ランタイム不要) |
| SSR/SSG/SPA切替 | 可(設定ベース) | 可(export const prerender等) |
| 学習コスト | 高(React知識が前提) | 低〜中(HTMLに近い構文) |
| エコシステム | 巨大(npmパッケージ豊富) | 成長中(React比で小さい) |
| TypeScript対応 | ネイティブ対応 | ネイティブ対応(lang="ts") |
SSR(サーバーサイドレンダリング)
リクエストのたびにサーバーでHTMLを生成。SEOや動的コンテンツに適する。デフォルト動作。
SSG(静的サイト生成)
ビルド時にHTMLを生成。CDNで高速配信可能。ブログ・ドキュメントサイトに最適。
SPA(シングルページアプリ)
全てクライアントで処理。管理画面・ダッシュボード等SEO不要なアプリに向く。
ルーティング
SvelteKitはファイルシステムベースのルーティングを採用している。src/routes/ディレクトリ以下のディレクトリ構造がそのままURLになる。例えば src/routes/wiki/sveltekit/+page.svelte は /wiki/sveltekit というURLで公開される。設定ファイルを書かなくていいのが大きなメリットで、ディレクトリを作ればページが生えるシンプルさがある。
動的ルートは角括弧でディレクトリ名を囲む。src/routes/blog/[slug]/+page.svelte とすれば、/blog/hello-world のようにslugパラメータを受け取れる。パラメータはload関数の params から取得する。
主要ファイルの役割
| ファイル名 | 役割 | 備考 |
|---|---|---|
| +page.svelte | ページのUIコンポーネント | そのURLのメイン表示内容 |
| +layout.svelte | 共通レイアウト(ヘッダー・サイドバー等) | 配下の全ページに適用される |
| +page.ts | クライアント・サーバー共用のload関数 | データ取得・パラメータ処理 |
| +page.server.ts | サーバー専用のload関数・フォームアクション | DBアクセス・秘密鍵使用可 |
| +layout.ts | レイアウト用のload関数 | 配下の全ページでdataが利用可能 |
| +error.svelte | エラーページのUI | 404・500等のエラー表示 |
ルーティングのパターン
| ディレクトリ構造 | 対応URL | 用途 |
|---|---|---|
| routes/+page.svelte | / | トップページ |
| routes/about/+page.svelte | /about | 固定ページ |
| routes/blog/[slug]/+page.svelte | /blog/any-slug | 動的ルート |
| routes/api/+server.ts | /api(GET/POST等) | APIエンドポイント |
| routes/(group)/+page.svelte | URL非影響グループ | レイアウト共有のグルーピング |
注意: パスの前方一致マッチに注意。例えば garden-survival-oneroom は garden-survival より先に条件分岐を書かないと誤マッチする。
データロード(load関数)
SvelteKitのデータ取得の仕組みの中心が load関数だ。+page.ts または +page.server.ts に export const load として書く。この関数が返したオブジェクトが、+page.svelte の let { data } = $props() で受け取れる。ページ表示前にデータを準備できるため、コンポーネント内での非同期処理が不要になる。
+page.ts はサーバー・クライアント両方で実行される(ユニバーサルload)。+page.server.ts はサーバーのみで実行される(サーバーload)。DBへのアクセスや環境変数の参照など、クライアントに漏らしてはいけない処理は必ず +page.server.ts に書く。
+page.ts vs +page.server.ts
| 項目 | +page.ts(ユニバーサル) | +page.server.ts(サーバー専用) |
|---|---|---|
| 実行タイミング | 初回はサーバー、以後クライアント | 常にサーバーのみ |
| DBアクセス | 不可(セキュリティリスク) | 可 |
| 環境変数(秘密) | 不可 | 可($env/static/private) |
| fetch | 可(SvelteKit拡張fetchを使う) | 可 |
| フォームアクション | 不可 | 可(export const actions) |
load関数が受け取る引数
- params — URLのパラメータ。動的ルート
[slug]ならparams.slugで取得 - url — URLオブジェクト。クエリパラメータは
url.searchParams.get('q')で取得 - fetch — SvelteKitが拡張したfetch。SSR時に認証クッキーを引き継ぎ、相対URLが使える
- parent — 親layoutのload関数のデータを取得(await必要)
- depends — キャッシュの依存関係を宣言。invalidate()でリフレッシュ制御
フォームアクション
SvelteKitのフォームアクションは +page.server.ts に export const actions として書く。HTMLの <form> タグの action 属性でどのアクションを呼ぶか指定する。
最大の特徴は Progressive Enhancement(プログレッシブエンハンスメント)だ。JavaScriptが無効な環境でも、HTMLの標準フォーム送信でアクションが動作する。JSが有効な場合はSvelteKitが自動でAjax送信に切り替え、ページ全体リロードなしに処理が完了する。use:enhance ディレクティブを付けるだけで有効になる。
アクションの種類
| 種類 | 書き方(概念) | 用途 |
|---|---|---|
| デフォルトアクション | actions = { default: ... } | フォームが1つだけのページ |
| 名前付きアクション | actions = { login: ..., register: ... } | 複数フォームが共存するページ |
バリデーションの概念フロー
- フォーム送信 → アクション関数が
request.formData()でデータ取得 - サーバー側でバリデーション(空欄チェック・型変換・ビジネスロジック)
- エラーがあれば
fail(400, { errors } )を返す - +page.svelte でエラーメッセージを
formプロパティから表示 - 成功なら
redirect(303, '/success')でリダイレクト
Base64と画像保存
Base64はバイナリデータ(画像・PDF・バイナリファイル)をASCIIテキストに変換するエンコード方式だ。メールやHTMLのdata URIなど、テキストしか扱えない場所にバイナリを埋め込む必要があるときに使う。「テキスト形式ならどこにでも入れられる」という汎用性が強みだが、そのかわりサイズが増える。
なぜ33%サイズが増えるのか。Base64は3バイト(24ビット)を4文字(各6ビット)に変換する。元データが3バイトなら出力は4文字、つまり4/3倍 ≈ 133%になる。さらにパディング文字(=)や改行も加わるため実際には少し増える。100KBの画像をBase64にすると約133KB相当の文字列になる計算だ。
SvelteKitでの保存先別上限・特性
| 保存先 | 上限目安 | 特徴 |
|---|---|---|
| ソースファイル(.svelte) | 〜4KB推奨(Viteデフォルト) | バンドルに直接埋め込まれる。大きいとビルドが遅くなる |
| localStorage | 5〜10MB(ブラウザ依存) | 同期API、ページ遷移後も保持、SSRでは使えない |
| sessionStorage | 5〜10MB(ブラウザ依存) | タブを閉じると消える。一時的なデータに適する |
| Cookie | 4KB | 毎リクエストに付く。画像Base64には実用的でない |
| IndexedDB | 数百MB〜GB(クォータ依存) | 非同期API、大量データに対応。直接APIが複雑でDexie.js推奨 |
Viteのassetsインライン化(assetsInlineLimit)
Viteはデフォルトで 4KB以下の画像を自動的にBase64インライン化する。つまり import icon from './icon.png' と書いた小さいPNGは、ビルド後にdata URI文字列に変換されJSバンドルに埋め込まれる。HTTPリクエストを1本減らせるため、アイコンやファビコンには有効。大きい画像に適用されるとバンドルが肥大化するため、vite.config.ts の build.assetsInlineLimit で閾値を調整できる。
実用的な使いどころ
| 用途 | 判定 | 理由 |
|---|---|---|
| SVGアイコン・ファビコン(〜4KB) | OK | サイズが小さくHTTPリクエスト削減効果あり |
| 通常の写真・ビジュアル(50KB〜) | NG | バンドルが肥大化し初回ロードが遅くなる |
| ユーザーアップロード画像 | R2/S3へ | Cloudflare R2 または AWS S3 に保存し、CDN URLで参照する |
| APIレスポンスで画像を返したい | 要検討 | 小さいサムネイルならBase64可。大きい場合はURLで返す |
アセット管理・Vite設定
SvelteKitのアセット置き場は大きく2箇所ある。それぞれ扱いが異なるため、ファイルの用途に応じて使い分けることが重要だ。
/static/ と /src/lib/assets/ の違い
| 項目 | /static/ | /src/lib/assets/ |
|---|---|---|
| Viteによる処理 | なし(そのままコピー) | あり(ハッシュ付きファイル名、最適化) |
| URL | /ファイル名(固定) | import後の変数から取得(ハッシュ付き) |
| キャッシュバスティング | 手動対応が必要 | 自動(ファイル内容変更でハッシュ変化) |
| Base64インライン化 | なし | あり(4KB以下は自動) |
| 適したファイル | robots.txt・sitemap.xml・favicon.ico | コンポーネントで使う画像・フォント |
画像最適化の考え方
- フォーマット選択: 写真はWebP/AVIF、アイコンはSVGが最適。JPEGより30〜50%小さくなる
- サイズ指定: <img> に width/height を必ず指定。Cumulative Layout Shift(CLS)を防ぐ
- 遅延読み込み: スクロール外の画像には
loading="lazy"を付ける - CDN活用: Cloudflare ImagesやCloudinaryを使うとリサイズ・フォーマット変換をオンデマンドで実行できる
Svelteストア
Svelteストアはコンポーネントをまたいでリアクティブな状態を共有する仕組みだ。Reactの Context APIやReduxに相当するが、記述がはるかにシンプル。ストアを $ 接頭辞で参照するだけで自動的にサブスクライブ・アンサブスクライブが行われる(オートサブスクリプション)。
Svelte 5では runes($state・$derived・$effect) が導入された。コンポーネント内の状態管理はrunesが推奨されるが、コンポーネント間の共有状態にはまだストアが有用。両者の使い分けを理解しておく必要がある。
ストアの種類
| 種類 | 概要 | 主な用途 |
|---|---|---|
| writable | 読み書き可能なストア。set()・update()で値を変更 | グローバルな状態(ユーザー情報・テーマ等) |
| readable | 外部からは読み取り専用。内部ロジックのみ変更可 | 時刻・センサー値など外部データのラップ |
| derived | 他のストアから派生した値。依存ストアの変化で自動再計算 | フィルタ済みリスト・合計値・変換済み値 |
$page ストアの活用
$app/stores からインポートできる page ストアは、現在のURL・ルートパラメータ・ページのデータを提供する。$page.url.pathname で現在のパスを取得でき、サイドバーのアクティブ状態の判定やページごとの条件分岐に活用できる。
Svelte 5 runes との使い分け
| シナリオ | 推奨 |
|---|---|
| コンポーネント内のローカル状態 | $state / $derived(Svelte 5 runes) |
| 複数コンポーネントで共有する状態 | writable store(または context API) |
| 外部データの購読(WebSocket等) | readable store |
| 副作用(DOM操作・APIコール) | $effect(Svelte 5)または onMount |
Tailwind CSS連携
SvelteKitとTailwind CSSは公式ドキュメントでも推奨される組み合わせだ。PostCSSプラグインとして動作するため、svelte-add コマンドで簡単に追加できる。Svelteコンポーネントの <style> ブロックに書いたCSSと共存させることも可能で、ユーティリティクラスとコンポーネントスコープCSSを使い分けられる。
dark: クラスの使い方
Tailwindのダークモード対応には dark: バリアントを使う。tailwind.config.js で darkMode: 'class' を設定すると、htmlタグに class="dark" が付いているときに dark: が有効になる。OSの設定を使う場合は darkMode: 'media'。
prose / not-prose パターン
@tailwindcss/typography プラグインの prose クラスは、h1〜h6・p・ul・table等の要素に読みやすいスタイルを自動適用する。MarkdownレンダリングやCMSコンテンツに便利だが、テーブルや独自グリッドに干渉することがある。そこで prose の子要素に not-prose を付けることでproseスタイルを局所的に無効化できる。
よく使うパターン
| パターン | クラス例 | 用途 |
|---|---|---|
| モバイルファーストグリッド | grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 | カードレイアウト等 |
| 横スクロール対応テーブル | overflow-x-auto | モバイルでテーブルがはみ出ないように |
| ダークモード対応テキスト | text-gray-900 dark:text-white | 見出し・本文の標準パターン |
| ダークモード対応カード | bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 | 情報カード・ボックス |
| フォーカスリング(アクセシビリティ) | focus:outline-none focus:ring-2 focus:ring-blue-500 | ボタン・入力フォームのフォーカス |
デプロイ(Cloudflare等)
SvelteKitはアダプター(adapter)によってデプロイ先を切り替える設計になっている。svelte.config.js の kit.adapter にアダプターを指定するだけで、同一コードをCloudflare Pages・Vercel・Node.jsサーバー・静的ファイルと出力先を変えられる。
アダプター比較
| アダプター | 出力 | 適した用途 | 注意点 |
|---|---|---|---|
| adapter-cloudflare | Cloudflare Workers形式 | Cloudflare Pages/Workers。無料枠が手厚い | Node.js APIの一部が使えない(Edge Runtime制約) |
| adapter-static | 静的HTML/CSS/JS | GitHub Pages・Netlify。完全静的サイト | SSR不可。サーバー側処理が一切できない |
| adapter-node | Node.jsサーバー | VPS・自宅サーバー。Node.js API全て使える | サーバーの管理コストが発生する |
| adapter-vercel | Vercel Serverless Functions | Vercelデプロイ。自動的にSSR対応 | 無料枠の関数実行時間制限あり |
| adapter-auto | 環境を自動検出 | Vercel/Netlify/Cloudflare自動判定 | 本番環境が決まったら専用アダプターに切替推奨 |
Cloudflare Pages でのデプロイ概念
adapter-cloudflareをインストールし svelte.config.js に設定- GitHubにリポジトリをpush
- Cloudflare PagesダッシュボードでGitHubリポジトリを接続
- ビルドコマンド(
bun run build)と出力ディレクトリ(.svelte-kit/cloudflare)を設定 - mainブランチへのpushで自動デプロイが走る
Bun環境でのビルド
Bunは Node.js 互換のJavaScriptランタイム兼パッケージマネージャ。npmやpnpmより高速でインストール・ビルドが完結する。SvelteKitプロジェクトをBunで動かす場合は、bun install・bun run dev・bun run build で統一できる。Windows環境ではBunがクラッシュする場合があり、その際は bun upgrade で最新版に更新することで解決するケースが多い。
よくあるミスとTips
実際に開発する中で踏みやすい落とし穴と、その対処法をまとめた。同じミスで時間を溶かさないために事前に把握しておきたい。
よくあるミス一覧
| ミス | 症状 | 対処法 |
|---|---|---|
| PowerShell Set-Content でファイル保存 | 日本語が文字化けする | Bash(Unix shell)か専用Writeツールを使う |
| <pre> 内で { や } を使う | Svelteテンプレートエラー・ビルド失敗 | {"{"}でエスケープ。{ → {"{"} 、} → {"}"} |
| パスの前方一致ルーティング誤り | 短いパスが長いパスをマッチしてしまう | 長いパスを先に書く(garden-survival-oneroom → garden-survival の順) |
| layout.svelte の更新漏れ | 新ページのサイドバーが表示されない | import・routing条件・else if ブロックの3点をgrepで確認 |
| テーブルにゼブラストライプ適用 | dark:bg-gray-750 等存在しないクラスを使用 | tbody内の全行を dark:bg-gray-800 で統一する |
| SSRで window/document を直接参照 | ReferenceError: window is not defined | onMount()内で実行する。またはbrowser フラグで分岐 |
| $env/static/private をクライアントから参照 | ビルドエラー・秘密情報漏洩リスク | 必ず +page.server.ts または +server.ts 内でのみ使う |
Svelte 5 runes の注意点
- $state はコンポーネントのトップレベルのみ: 関数の中で $state を使うと期待通りに動かないケースがある。.svelte.ts ファイルに切り出すか、クラスフィールドとして使う
- $derived は純粋関数に: 副作用(APIコール・DOM操作等)を $derived 内で実行しない。副作用には $effect を使う
- $effect の循環依存: $effect 内で参照している $state を同じ $effect 内で変更すると無限ループになる
- 旧構文との混在: Svelte 4 の reactive ($: ) と Svelte 5 の runes を同一ファイルで混在させるとエラーになる
layout.svelte の肥大化対策
ページが増えるにつれ layout.svelte はサイドバーのimportと条件分岐で肥大化しやすい。現状では各ページ専用のサイドバーコンポーネントを layout.svelte に集約しているが、以下を意識することで管理しやすくなる。
- ルートグループ(括弧ディレクトリ)を使ってカテゴリ別に +layout.svelte を分割する
- サイドバーの動的インポート(
import())を使って必要なときだけロードする - 新しいページを作るたびに layout.svelte の3点(import・routing・else if)を必ず追加し、grep で確認してからコミットする
ラズパイへBotトークンでデータ送信
SvelteKitのAPIエンドポイントにBotトークン認証を実装し、ラズベリーパイからHTTPSでデータを投稿・取得する仕組み。「なぜこれが動くのか」から始め、Cloudflare Workersの仕組み・トークン認証の原理・通信フローを順番に理解する。
「なぜこれが動くのか」を先に理解する
ラズパイからSvelteKitのAPIにデータを送ることを考えたとき、多くの人が「ラズパイにSSHで繋げればいい」あるいは「TursoにDB接続ライブラリで繋げばいい」と考えがちだ。どちらも不要で、実際には HTTPSリクエスト1本 で完結する。なぜそれが可能なのかを理解すると、IoTのアーキテクチャ設計が根本から変わる。
この仕組みが成立する3つの理由
- Cloudflare WorkersはHTTPSでしかアクセスできない(SSHできない理由そのもの)
- トークン認証はCookieもセッションも不要なため、IoTデバイスでも使える
- SvelteKit APIがMiddlewareとして機能し、DBへの直接接続を隠蔽してくれる
Cloudflare Workersという「インターネット上のサーバーレス関数」
SvelteKitをCloudflare Pagesにデプロイすると、APIルートは Cloudflare Workers として動作する。これは従来のサーバーとは根本的に異なる実行環境だ。
| 比較 | 従来のVPS/サーバー | Cloudflare Workers |
|---|---|---|
| 実行場所 | 特定のIPアドレスを持つマシン | 世界270拠点以上のエッジサーバー(固定マシンなし) |
| 接続方法 | SSH / HTTP / その他ポート | HTTPS(ポート443)のみ |
| ランタイム | Node.js / Python などOS上のプロセス | V8 isolate(Chromeと同じJSエンジン) |
| コールドスタート | プロセス起動で数秒かかる | 数ミリ秒(V8 isolateの超軽量起動) |
| ルーティング | DNSで特定マシンに直接向ける | Anycast:物理的に最も近いエッジノードに自動ルーティング |
「なぜSSHでWorkersに繋げないのか」の答えはここにある。SSHは「どのマシンに繋ぐか」を指定するプロトコルだが、WorkersにはそもそもSSHで繋ぐべき「固定マシン」が存在しない。ラズパイがHTTPSリクエストを送ると、Anycastルーティングによって物理的に最も近いCloudflareのエッジノードに自動的に振り分けられ、そこでV8 isolateが起動してSvelteKitのコードが実行される。
トークン認証の原理:「合言葉を知っているなら本人だ」
Webの認証といえばログイン後のCookieセッションを思い浮かべるかもしれない。しかしラズパイのようなIoTデバイスはブラウザを持たず、Cookieを扱えない。そこで使うのがトークン認証だ。
トークン認証の原理は極めてシンプルだ。サーバー側はDBにトークンを保存しておく。リクエストが来たらヘッダーからトークンを取り出し、DBを照合するだけで認証完了する。セッション管理も状態保持も不要な ステートレス認証 だ。
| 認証方式 | 何を証明するか | IoTで使えるか |
|---|---|---|
| Cookieセッション | ブラウザがログイン状態を保持していること | 不可(ブラウザ前提) |
| SSH鍵認証 | 特定マシンへのログイン権限を持つこと | 不可(固定マシン前提) |
| HTTPSトークン | APIへのアクセス権限を持つこと(合言葉) | 可(HTTPSのみで完結) |
これはDiscord Bot TokenやGitHub Personal Access Tokenとまったく同じ概念だ。「Authorization: Bot sat_xxxxx というヘッダーを付けてHTTPSリクエストを送れる者だけがAPIを利用できる」という合言葉方式の認証だ。
通信の全体フロー
ラズパイがデータを送信してからDBに保存されるまでの流れを視覚化すると次のようになる。
ラズパイ(Python requests)
|
| HTTPS POST /api/bot/post
| Authorization: Bot sat_xxxxx
| Body: {"content": "温度: 24.5°C"}
|
v
インターネット(TLS暗号化)
|
v
Cloudflare Anycast
├── 東京エッジ(日本から接続時)
├── ロサンゼルスエッジ(米国から)
└── ...(物理的に最近のノードが自動選択)
|
v
V8 isolate 起動(数ミリ秒)
└── SvelteKitの +server.ts が実行される
|
v
トークン照合(DBクエリ)
└── channel_members WHERE bot_token = 'sat_xxxxx'
|
v
データ保存 → Turso DB
|
v
レスポンス返却 → ラズパイへ なぜTurso直接接続より優れているのか
「ラズパイからTursoに直接接続すればAPIを経由しなくていい」と考えるのは自然だが、これにはいくつかの問題がある。SvelteKit APIを経由する API Gatewayパターン がなぜ優れているかを比較する。
| 比較 | Turso直接接続 | SvelteKit API経由(推奨) |
|---|---|---|
| DB認証情報 | ラズパイ本体に埋め込む必要がある(漏洩リスク) | サーバーサイドのみ。ラズパイには絶対に渡らない |
| 必要なライブラリ | TursoのSDK(Python版は非公式・不安定) | requestsのみ(Python標準に近い) |
| 接続数制限 | ラズパイ台数×TursoのDB接続上限に直接影響 | API側でプーリングするためラズパイ台数は無関係 |
| ビジネスロジック | ラズパイ側に書く(更新が困難) | API側に集中(ラズパイを触らず変更できる) |
| セキュリティ | DB認証情報が盗まれると全データにアクセスされる | Botトークンが漏洩しても削除・再発行で即対処できる |
このパターンはAWSのAPI GatewayやGCPのCloud Endpointsと同じ設計思想だ。IoTデバイスに直接DB認証情報を渡すのはセキュリティ上の悪手であり、HTTPSトークンによるAPI Gatewayを挟むのが現代的な正解だ。
全体の構成要素
| 要素 | 役割 |
|---|---|
| チャンネル | メッセージを受け取る箱。ラズパイの用途ごとに作る(センサーデータ用・ログ用など) |
| Bot(ラズパイ側) | Botとして登録されたラズパイ。sat_xxxxx 形式のトークンが発行される |
| Bot Token | ラズパイがAPIを叩く際の認証キー。Authorization: Bot sat_xxxxx ヘッダーで送る |
| SvelteKit API | +server.ts でPOST/GETを受け取り、DBに保存・返却する |
Step 1: BotトークンのDB構造
なぜこう設計するのか: bot_source でデバイス種別を区別することで、ラズパイにのみトークンを発行し、LLMボットはトークンなし(サーバー内部から直接呼ぶ)という使い分けを実現する。
channel_members テーブルにBotを保存する。bot_source: 'raspberry_pi' のときだけトークンを発行する。
-- channel_members テーブル(抜粋)
CREATE TABLE channel_members (
id TEXT PRIMARY KEY, -- "pi-xxxxxxxx"
display_name TEXT NOT NULL,
type TEXT DEFAULT 'bot', -- 'bot' | 'llm'
bot_source TEXT, -- 'raspberry_pi' | 'openrouter'
bot_token TEXT, -- sat_xxxxx(ラズパイのみ発行)
default_channel TEXT, -- デフォルト送信先チャンネルID
created_at TEXT NOT NULL
); Step 2: トークン生成関数
なぜこう設計するのか: sat_ プレフィックスを付けることで「これはSatomata API用のトークンだ」と一目でわかる。DiscordのBot Tokenが Bot プレフィックスを使うのと同じ設計思想だ。32文字のランダム文字列で総当たり攻撃を現実的に不可能にする。
sat_ プレフィックス+ランダム文字列でトークンを生成する。DBライブラリ側に実装する。
// $lib/server/db.ts
export function generateBotToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = 'sat_';
for (let i = 0; i < 32; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
} Step 3: Bot作成APIエンドポイント
なぜこう設計するのか: Botの登録はブラウザからのログインユーザーのみ実行できるよう制限する。発行されたトークンはこのエンドポイントのレスポンスでのみ一度だけ返される。DBにはトークンが保存され、以降はAPIの照合に使われる。
POST /api/channels/create-bot — ログイン済みユーザーのみ呼べる。bot_source: 'raspberry_pi' を指定するとトークンが発行される。
// src/routes/api/channels/create-bot/+server.ts
import type { RequestHandler } from './$types';
import { getTursoClient, generateBotToken } from '$lib/server/db';
export const POST: RequestHandler = async ({ request, platform, locals }) => {
if (!locals.user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
const db = getTursoClient(/* url, token */);
const { display_name, bot_source, default_channel } = await request.json();
const isRpi = bot_source === 'raspberry_pi';
const id = (isRpi ? 'pi-' : 'llm-') + crypto.randomUUID().slice(0, 8);
const token = isRpi ? generateBotToken() : null; // ラズパイのみトークン発行
await db.execute({
sql: 'INSERT INTO channel_members (id, display_name, type, bot_source, bot_token, default_channel, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
args: [id, display_name, 'bot', bot_source, token, default_channel || null, new Date().toISOString()]
});
return new Response(JSON.stringify({ id, token }), { status: 201 });
}; Step 4: データ投稿API(ラズパイから呼ぶ)
なぜこう設計するのか: このエンドポイントはラズパイ(別オリジン)から呼ばれるため、CORSヘッダーが必要だ。トークン検証はDBへの1クエリで完結するステートレスな処理なので、Workersのような短命な実行環境でも問題なく動く。
POST /api/bot/post — CORSを許可し、Authorization: Bot sat_xxxxx ヘッダーでトークン認証する。
// src/routes/api/bot/post/+server.ts
export const POST: RequestHandler = async ({ request, platform }) => {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Content-Type': 'application/json'
};
// トークン認証:Authorization: Bot sat_xxxxx
const authHeader = request.headers.get('Authorization') || '';
const match = authHeader.match(/^Bot\s+(sat_\w+)$/);
if (!match) {
return new Response(JSON.stringify({ error: 'Invalid authorization. Use: Authorization: Bot sat_xxxxx' }), { status: 401, headers: corsHeaders });
}
const db = getTursoClient(/* ... */);
const bot = await getChannelMemberByToken(db, match[1]);
if (!bot) return new Response(JSON.stringify({ error: 'Invalid bot token' }), { status: 401, headers: corsHeaders });
const { channel, content, metadata } = await request.json();
if (!content?.trim()) {
return new Response(JSON.stringify({ error: 'content is required' }), { status: 400, headers: corsHeaders });
}
// チャンネルはBotのデフォルトか、リクエスト指定を使う
const channelId = channel || bot.default_channel;
const message = await createChannelMessage(db, channelId, bot.id, content.trim(), metadata);
return new Response(JSON.stringify({ message }), { status: 201, headers: corsHeaders });
};
// CORS preflight
export const OPTIONS: RequestHandler = async () => {
return new Response(null, {
status: 204,
headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type' }
});
}; Step 5: ラズパイ側の実装(Python)
なぜこう設計するのか: ラズパイ側のコードはHTTPSリクエストを1本送るだけだ。TursoのSDKも特別なライブラリも不要で、Pythonの標準的な requests ライブラリだけで実現できる。ここにDB接続情報は一切含まれない点が重要だ。
ラズパイでは requests ライブラリを使って1行でデータを送信できる。センサーデータや定期ログの自動投稿に使う。
import requests
import json
BOT_TOKEN = "sat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 発行したトークン
API_URL = "https://your-app.pages.dev/api/bot/post"
def post_to_channel(content: str, metadata: dict = None):
headers = {
"Authorization": f"Bot {BOT_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"content": content,
"metadata": json.dumps(metadata) if metadata else None
}
res = requests.post(API_URL, headers=headers, json=payload)
return res.json()
# 使用例:センサーデータを送信
post_to_channel(
content="温度: 24.5°C, 湿度: 61%",
metadata={"temp": 24.5, "humidity": 61, "location": "living"}
) Step 6: データ取得API
なぜこう設計するのか: 同じBotトークンで書き込みと読み込みの両方を行えるようにする。ラズパイが過去のデータを参照して処理を変える(例:直近の温度平均を見て警告を出す)ようなユースケースに対応できる。
GET /api/bot/read?channel=xxx&limit=20 — 同じトークンで過去メッセージも取得できる。
// ラズパイから取得する場合
def read_channel(channel_id: str, limit: int = 20):
headers = {"Authorization": f"Bot {BOT_TOKEN}"}
res = requests.get(
f"{API_URL.replace('/post', '/read')}",
headers=headers,
params={"channel": channel_id, "limit": limit}
)
return res.json()["messages"] APIエンドポイント一覧
| エンドポイント | メソッド | 認証 | 用途 |
|---|---|---|---|
| /api/channels/create-bot | POST | ログインセッション | ラズパイBotを登録してトークン発行 |
| /api/bot/post | POST | Bot sat_xxxxx | チャンネルにメッセージを投稿 |
| /api/bot/read | GET | Bot sat_xxxxx | チャンネルのメッセージ取得 |
| /api/bot/join | POST | Bot sat_xxxxx | 追加チャンネルにBotを参加させる |
ポイントとハマりどころ
- CORS設定は必須: ラズパイ(別オリジン)からAPIを叩くため、
Access-Control-Allow-Origin: *を全レスポンスに付けること。OPTIONSのpreflight処理も忘れずに - トークンはラズパイ専用: LLMボットにはトークンを発行しない。
bot_source === 'raspberry_pi'のときのみgenerateBotToken()を呼ぶ - Cloudflare Pages の環境変数: DBのURLやトークンは
platform?.env?.TURSO_DATABASE_URLで取得する。$env/static/privateはCloudflare Pagesでは使えない - チャンネルへの割り当て確認: Botがチャンネルに割り当てられているか必ず確認する。未割り当てチャンネルへの投稿は403を返す
- metadataはJSON文字列: メタデータはオブジェクトではなくJSON文字列としてDBに保存し、取得時にパースする
- トークン漏洩時の対処: BotトークンはDBから削除して再発行するだけでいい。DB認証情報を変更する必要がないため、漏洩時の被害範囲が限定される