さとまたwiki

🧡 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.jsSvelteKit
UIフレームワークReact(仮想DOM)Svelte(コンパイラ、仮想DOMなし)
ビルドツールWebpack / TurbopackVite(超高速)
バンドルサイズ大(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エラーページのUI404・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.svelteURL非影響グループレイアウト共有のグルーピング

注意: パスの前方一致マッチに注意。例えば garden-survival-oneroomgarden-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: ... }複数フォームが共存するページ

バリデーションの概念フロー

  1. フォーム送信 → アクション関数が request.formData() でデータ取得
  2. サーバー側でバリデーション(空欄チェック・型変換・ビジネスロジック)
  3. エラーがあれば fail(400, { errors } ) を返す
  4. +page.svelte でエラーメッセージを form プロパティから表示
  5. 成功なら 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デフォルト)バンドルに直接埋め込まれる。大きいとビルドが遅くなる
localStorage5〜10MB(ブラウザ依存)同期API、ページ遷移後も保持、SSRでは使えない
sessionStorage5〜10MB(ブラウザ依存)タブを閉じると消える。一時的なデータに適する
Cookie4KB毎リクエストに付く。画像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.tsbuild.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.jsdarkMode: '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.jskit.adapter にアダプターを指定するだけで、同一コードをCloudflare Pages・Vercel・Node.jsサーバー・静的ファイルと出力先を変えられる。

アダプター比較

アダプター出力適した用途注意点
adapter-cloudflareCloudflare Workers形式Cloudflare Pages/Workers。無料枠が手厚いNode.js APIの一部が使えない(Edge Runtime制約)
adapter-static静的HTML/CSS/JSGitHub Pages・Netlify。完全静的サイトSSR不可。サーバー側処理が一切できない
adapter-nodeNode.jsサーバーVPS・自宅サーバー。Node.js API全て使えるサーバーの管理コストが発生する
adapter-vercelVercel Serverless FunctionsVercelデプロイ。自動的にSSR対応無料枠の関数実行時間制限あり
adapter-auto環境を自動検出Vercel/Netlify/Cloudflare自動判定本番環境が決まったら専用アダプターに切替推奨

Cloudflare Pages でのデプロイ概念

  1. adapter-cloudflare をインストールし svelte.config.js に設定
  2. GitHubにリポジトリをpush
  3. Cloudflare PagesダッシュボードでGitHubリポジトリを接続
  4. ビルドコマンド(bun run build)と出力ディレクトリ(.svelte-kit/cloudflare)を設定
  5. mainブランチへのpushで自動デプロイが走る

Bun環境でのビルド

Bunは Node.js 互換のJavaScriptランタイム兼パッケージマネージャ。npmやpnpmより高速でインストール・ビルドが完結する。SvelteKitプロジェクトをBunで動かす場合は、bun installbun run devbun 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 definedonMount()内で実行する。または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つの理由

  1. Cloudflare WorkersはHTTPSでしかアクセスできない(SSHできない理由そのもの)
  2. トークン認証はCookieもセッションも不要なため、IoTデバイスでも使える
  3. 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-botPOSTログインセッションラズパイBotを登録してトークン発行
/api/bot/postPOSTBot sat_xxxxxチャンネルにメッセージを投稿
/api/bot/readGETBot sat_xxxxxチャンネルのメッセージ取得
/api/bot/joinPOSTBot 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認証情報を変更する必要がないため、漏洩時の被害範囲が限定される