プログラミング/svelte の履歴(No.9)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- プログラミング/svelte へ行く。
svelte や svelte kit を使いこなしたい†
VS Code ありきで作られた最新のエコシステムの恩恵を受けるために勉強してみよう。
いまのところ情報収集中†
- SvelteKit+Superforms+Prisma+Luciaでログイン機能を爆速で実装する
https://zenn.dev/gawarago/articles/f75f5113a3803d - SvelteKitが正式リリースされたのでtRPCとPrismaを使ってWebアプリを開発してみた
https://zenn.dev/kosei28/articles/d965f221a656fd - Vitest / Playwrightを使ってSvelteのコンポーネントをテストする
https://qiita.com/oekazuma/items/925ddbf48870fb999c19#vitest%E3%81%A8%E3%81%AF - TypeScript 4.9のas const satisfiesが便利。型チェックとwidening防止を同時に行う
https://zenn.dev/moneyforward/articles/typescript-as-const-satisfies - コーディング不要でGraphQLサーバが作れるPrismaを触ってみて可能性を感じた - SMARTCAMP Engineer Blog
https://tech.smartcamp.co.jp/entry/started-prisma
VS Code + Git Bash + nvm のインストール†
https://blog.css-net.co.jp/entry/dev-environment-windows#2-2-Git-for-Windows
に従った。
VS Code 上の GitBash で nvm が動かない†
VS Code を起動中に nvm をインストールすると、
LANG: console $ nvm bash: nvm: command not found
とか、
LANG: console $ nvm install lts ERROR open \settings.txt: The system cannot find the file specified.
となる。
これは環境変数の更新が VS Code に認識されていないためなので、 VS Code を一旦落として立ち上げなおすと動くようになる。
LANG: console $ node bash: node: command not found $ nvm list 18.16.0
のような場合は、
LANG: console $ nvm use 18.16.0 Now using node v18.16.0 (64-bit) $ nvm list * 18.16.0 (Currently using 64-bit executable) $ node Welcome to Node.js v18.16.0.
のように nvm use する。
pnpm を入れる†
良いうわさを聞くので。
LANG: console $ 'npm' install -g pnpm added 1 package in 6s
そして ~/.bashrc に、
alias npm=pnpm
としてしまう。
VS Code 設定†
- フォント Myrica
https://www.sejuku.net/blog/60345 を参考にした - テーマファイルについて
https://coliss.com/articles/build-websites/operation/work/best-of-visual-studio-code-themes.html - アドオン
- Japanese Language Pack for Visual Studio Code
https://marketplace.visualstudio.com/items?itemName=MS-CEINTL.vscode-language-pack-ja - Keyboard Macro Beta
https://marketplace.visualstudio.com/items?itemName=tshino.kb-macro - macro-commander (Command Runner)
https://marketplace.visualstudio.com/items?itemName=jeff-hykin.macro-commander - GLSL Lint (WebGL をやる場合)
https://marketplace.visualstudio.com/items?itemName=dtoplak.vscode-glsllint - Shader languages support for VS Code (WebGL をやる場合)
https://marketplace.visualstudio.com/items?itemName=slevesque.shader - ESLint
https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint - GitLens — Git supercharged
https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens - npm Intellisense
https://marketplace.visualstudio.com/items?itemName=christian-kohler.npm-intellisense - Playwright Test for VSCode
https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright - Prettier - Code formatter
https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode - Pretty TypeScript Errors
https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors - Prisma
https://marketplace.visualstudio.com/items?itemName=Prisma.prisma - Svelte for VS Code
https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode
- Japanese Language Pack for Visual Studio Code
テスト環境の確認†
https://qiita.com/oekazuma/items/925ddbf48870fb999c19#vitest%E3%81%A8%E3%81%AF
LANG: console $ pnpm create svelte@latest testenv .../Local/pnpm/store/v3/tmp/dlx-41072 | +6 + Packages are hard linked from the content-addressable store to the virtual store. Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3 Virtual store is at: ../../Users/osamu/AppData/Local/pnpm/store/v3/tmp/dlx-41072/node_modules/.pnpm .../Local/pnpm/store/v3/tmp/dlx-41072 | Progress: resolved 6, reused 0, downloaded 6, added 6, done create-svelte version 4.2.0 ┌ Welcome to SvelteKit! │ ◇ Which Svelte app template? │ Skeleton project │ ◇ Add type checking with TypeScript? │ Yes, using TypeScript syntax │ ◇ Select additional options (use arrow keys/space bar) │ Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing │ └ Your project is ready! ✔ Typescript Inside Svelte components, use <script lang="ts"> ✔ ESLint https://github.com/sveltejs/eslint-plugin-svelte ✔ Prettier https://prettier.io/docs/en/options.html https://github.com/sveltejs/prettier-plugin-svelte#options ✔ Playwright https://playwright.dev ✔ Vitest https://vitest.dev Install community-maintained integrations: https://github.com/svelte-add/svelte-add Next steps: 1: cd testenv 2: npm install (or pnpm install, etc) 3: git init && git add -A && git commit -m "Initial commit" (optional) 4: npm run dev -- --open To close the dev server, hit Ctrl-C Stuck? Visit us at https://svelte.dev/chat $ cd testenv $ pnpm install $ git init && git add -A && git commit -m "Initial commit" $ pnpm run dev > testenv@0.0.1 dev (home)\svelte\testenv > vite dev Forced re-optimization of dependencies VITE v4.3.9 ready in 1901 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h to show help Shortcuts press r to restart the server press u to show server url press o to open in browser press c to clear console press q to quit
ここで o を押すとブラウザが立ち上がって Welcome to SvelteKit が表示された。
^C で抜けて、
LANG: console $ cat package.json { "name": "testenv", "version": "0.0.1", "private": true, "scripts": { "dev": "vite dev", "build": "vite build", "preview": "vite preview", "test": "playwright test", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:unit": "vitest", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { "@playwright/test": "^1.28.1", "@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/kit": "^1.5.0", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-svelte": "^2.26.0", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "svelte": "^3.54.0", "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^4.3.0", "vitest": "^0.25.3" }, "type": "module" }
vitest†
LANG: console $ pnpm run test:unit > testenv@0.0.1 test:unit (home)\svelte\testenv > vitest DEV v0.25.8 ~/svelte/testenv ✓ src/index.test.ts (1) Test Files 1 passed (1) Tests 1 passed (1) Start at 12:11:24 Duration 2.21s (transform 720ms, setup 0ms, collect 77ms, tests 6ms) PASS Waiting for file changes... press h to show help, press q to quit Watch Usage press a to rerun all tests press f to rerun only failed tests press u to update snapshot press p to filter by a filename press t to filter by a test name regex pattern press q to quit
別の terminal から、
LANG: console $ touch src/index.test.ts
すると、再度テストが実行される。
LANG: console $ cat vite.config.ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [sveltekit()], test: { include: ['src/**/*.{test,spec}.{js,ts}'] } });
となっているので、src/ の下のすべての .test.ts および .spec.ts がテストの対象になる。
PlayWright†
LANG: console $ pnpm run test > testenv@0.0.1 test (home)\svelte\testenv > playwright test Running 1 test using 1 worker [WebServer] [WebServer] [WebServer] ✘ 1 test.ts:3:1 › index page has expected h1 (58ms) 1) test.ts:3:1 › index page has expected h1 ────────────────────────────────────────────────────── Error: browserType.launch: Executable doesn't exist at C:\Users\osamu\AppData\Local\ms-playwright\chromium-1064\chrome-win\chrome.exe ╔═════════════════════════════════════════════════════════════════════════╗ ║ Looks like Playwright Test or Playwright was just installed or updated. ║ ║ Please run the following command to download new browsers: ║ ║ ║ ║ npx playwright install ║ ║ ║ ║ <3 Playwright Team ║ ╚═════════════════════════════════════════════════════════════════════════╝ 1 failed test.ts:3:1 › index page has expected h1 ─────────────────────────────────────────────────────── ELIFECYCLE Test failed. See above for more details.
ふむ。言われた通りにしよう。
LANG: console $ npx playwright install Downloading Chromium 114.0.5735.35 (playwright build v1064) from https://playwright.azureedge.net/builds/chromium/1064/chromium-win64.zip 113.5 Mb [====================] 100% 0.0s Chromium 114.0.5735.35 (playwright build v1064) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\chromium-1064 Downloading FFMPEG playwright build v1009 from https://playwright.azureedge.net/builds/ffmpeg/1009/ffmpeg-win64.zip 1.4 Mb [====================] 100% 0.0s FFMPEG playwright build v1009 downloaded to C:\Users\osamu\AppData\Local\ms-playwright\ffmpeg-1009 Downloading Firefox 113.0 (playwright build v1408) from https://playwright.azureedge.net/builds/firefox/1408/firefox-win64.zip79.7 Mb [====================] 100% 0.0s Firefox 113.0 (playwright build v1408) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\firefox-1408 Downloading Webkit 16.4 (playwright build v1848) from https://playwright.azureedge.net/builds/webkit/1848/webkit-win64.zip 45.2 Mb [====================] 100% 0.0s Webkit 16.4 (playwright build v1848) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\webkit-1848 $ pnpm run test > testenv@0.0.1 test (home)\svelte\testenv > playwright test Running 1 test using 1 worker [WebServer] [WebServer] [WebServer] ✓ 1 test.ts:3:1 › index page has expected h1 (967ms) 1 passed (4.0s) $ ls -a test-results/ ./ ../
かなり時間がかかるけど、ちゃんと動いたと言っている。 test-results/ には何も入っていない。
上では 4.0s かかったと言っているけど実際にはテストが走り始めるまでに 20秒近くかかっていて、そっちの方がずっと長い。
LANG: console $ time node_modules/.bin/playwright test Running 1 test using 1 worker [WebServer] [WebServer] [WebServer] ✓ 1 test.ts:3:1 › index page has expected h1 (473ms) 1 passed (3.0s) real 0m22.550s user 0m0.076s sys 0m0.247s
gui を使うことにして立ち上げっぱなしにすると、起動にかかる時間を待たずに済む。
LANG: console $ node_modules/.bin/playwright test --ui
うまく行きそうだ。
LANG: console $ cat playwright.config.ts import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { webServer: { command: 'npm run build && npm run preview', port: 4173 }, testDir: 'tests', testMatch: /(.+\.)?(test|spec)\.[jt]s/ };
tests/ フォルダの *.test.ts のようなファイルがテスト対象になる。
あーと、これを見ると ファイルを更新した場合には npm run build をし直さなければならないっぽいのでその点には注意が必要だ。
LANG: console $ git status On branch master Untracked files: (use "git add <file>..." to include in what will be committed) test-results/ nothing added to commit but untracked files present (use "git add" to track)
となってしまうので、.gitignore に /test-results を追加することも必要。
LANG: console $ echo /test-results >> .gitignore $ git status On branch master Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: .gitignore no changes added to commit (use "git add" and/or "git commit -a") $ git commit -a -m "add /test-results to .gitignore"
何にしても、ユニットテストばかりでなく E2E テストまで Out of the box で簡単に行えるのはすごい。
apache2 や nginx どころか WSL すらセットアップの必要がないとは。。。
Prisma と Lucia を使った認証システムを作ってみる†
SvelteKit+Superforms+Prisma+Luciaでログイン機能を爆速で実装する
https://zenn.dev/gawarago/articles/f75f5113a3803d
を参考に以下を使ったサイトを作る基本を身に着けたいなと。
- Svelte
- SvelteKit
- SuperForms : フォームバリデーション
- Prisma : データベースアクセス
- Lucia : 認証ライブラリ
- sveltekit-flash-message : flash メッセージの管理
- svelte-french-toast : flash メッセージ(など)を一定時間だけ表示する
スケルトンプロジェクトを作成†
LANG:console $ pnpm create svelte@latest authtest .../Local/pnpm/store/v3/tmp/dlx-26152 | +6 + Packages are hard linked from the content-addressable store to the virtual store. Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3 Virtual store is at: ../../AppData/Local/pnpm/store/v3/tmp/dlx-26152/node_modules/.pnpm .../Local/pnpm/store/v3/tmp/dlx-26152 | Progress: resolved 6, reused 6, downloaded 0, added 6, done create-svelte version 5.3.1 ┌ Welcome to SvelteKit! │ ◇ Which Svelte app template? │ Skeleton project │ ◇ Add type checking with TypeScript? │ Yes, using TypeScript syntax │ ◇ Select additional options (use arrow keys/space bar) │ Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing │ └ Your project is ready! ✔ Typescript Inside Svelte components, use <script lang="ts"> ✔ ESLint https://github.com/sveltejs/eslint-plugin-svelte ✔ Prettier https://prettier.io/docs/en/options.html https://github.com/sveltejs/prettier-plugin-svelte#options ✔ Playwright https://playwright.dev ✔ Vitest https://vitest.dev Install community-maintained integrations: https://github.com/svelte-add/svelte-add Next steps: 1: cd authtest 2: pnpm install 3: git init && git add -A && git commit -m "Initial commit" (optional) 4: pnpm run dev -- --open To close the dev server, hit Ctrl-C Stuck? Visit us at https://svelte.dev/chat $ cd authtest $ pnpm install Packages: +271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Packages are hard linked from the content-addressable store to the virtual store. Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3 Virtual store is at: node_modules/.pnpm Progress: resolved 293, reused 267, downloaded 4, added 271, done node_modules/.pnpm/svelte-preprocess@5.1.0_postcss@8.4.31_svelte@4.0.5_typescript@5.3.2/node_modules/svelte-preprocess: Running postinstall script, done in 57ms node_modules/.pnpm/@sveltejs+kit@1.27.4_svelte@4.0.5_vite@4.4.2/node_modules/@sveltejs/kit: Running postinstall script, done in 6.4s devDependencies: + @playwright/test 1.28.1 (1.34.3 is available) + @sveltejs/adapter-auto 2.0.0 (2.1.0 is available) + @sveltejs/kit 1.27.4 (1.27.6 is available) + @typescript-eslint/eslint-plugin 6.0.0 (6.11.0 is available) + @typescript-eslint/parser 6.0.0 (6.11.0 is available) + eslint 8.28.0 (8.41.0 is available) + eslint-config-prettier 9.0.0 + eslint-plugin-svelte 2.30.0 (2.35.0 is available) + prettier 3.0.0 (3.1.0 is available) + prettier-plugin-svelte 3.0.0 (3.1.0 is available) + svelte 4.0.5 (4.2.5 is available) + svelte-check 3.6.0 + tslib 2.4.1 (2.5.2 is available) + typescript 5.0.2 (5.3.2 is available) + vite 4.4.2 (5.0.0 is available) + vitest 0.32.2 (0.34.6 is available) Done in 21.5s $ git init && git add -A && git commit -m "Initial commit" Initialized empty Git repository in C:/Users/osamu/Desktop/svelte/authtest/.git/ [master (root-commit) ab94078] Initial commit 20 files changed, 2586 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/index.test.ts create mode 100644 src/lib/index.ts create mode 100644 src/routes/+page.svelte create mode 100644 static/favicon.png create mode 100644 svelte.config.js create mode 100644 tests/test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts $ pnpm run dev -- --open > authtest@0.0.1 dev C:\Users\osamu\Desktop\svelte\authtest > vite dev "--" "--open" VITE v4.4.2 ready in 3242 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h to show help
これで http://localhost:5173/ に Welcome to SvelteKit が表示された。
サインアップフォームを作成†
src/routes/(loggedOut)/signup/+page.svelte
LANG: html <div> <h1>サインアップ</h1> <form method="POST"> <label for="name">ユーザー名</label> <input type="text" name="name" /> <label for="email">メールアドレス</label> <input type="text" name="email" /> <label for="password">パスワード</label> <input type="password" name="password" /> <label for="confirmPassword">パスワード(確認)</label> <input type="password" name="confirm"/> <div><button>サインアップ</button></div> <a href="/login">ログイン</a> </form> </div>
http://localhost:5173/signup でフォームを表示可能なことを確認
POST に対応する Action を書いていないため「ログイン」を押すと "405 POST method not allowed. No actions exist for this page" になることを確認。
src/routes/(loggedOut)/signup/+page.server.ts
LANG: ts import type { Actions } from './$types'; import { fail } from '@sveltejs/kit'; export const actions: Actions = { default: async () => { return fail(400, {message: 'サインアップ処理が未実装'}) } };
これで「ログイン」を押してもエラーは出なくなった一方、エラーメッセージも表示されない。
src/routes/(loggedOut)/signup/+page.svelte の冒頭と末尾を下記のように変更
LANG: html <script lang="ts"> import type { ActionData } from './$types'; export let form: ActionData; </script> <div> <h1>サインアップ</h1> {#if form?.message}<span class="invalid">{form.message}</span>{/if} ... </div> <style> .invalid { color: red; } </style>
これでエラーメッセージが表示されるようになった。
LANG:console $ git add . $ git commit -m "signup ページを作成" [master e758e44] signup ページを作成 2 files changed, 36 insertions(+) create mode 100644 src/routes/(loggedOut)/signup/+page.server.ts create mode 100644 src/routes/(loggedOut)/signup/+page.svelte
入力内容の validation を行うために SuperForms を入れる†
LANG:console $ pnpm i -D sveltekit-superforms zod Packages: +2 ++ Progress: resolved 295, reused 273, downloaded 0, added 2, done devDependencies: + sveltekit-superforms 1.10.2 + zod 3.22.4 Done in 3.6s
signup フォーム用のスキーマを追加†
src/lib/formSchemas/signupSchema.ts
LANG:ts import { z } from 'zod'; // メールアドレスを表す現実的な正規表現 by @sakuro // https://qiita.com/sakuro/items/1eaa307609ceaaf51123 const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; export const signupSchema = z .object({ name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }), email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }), password: z .string() .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, { message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください' }), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { message: '確認用パスワードが一致しません', path: ['confirm'] }); export type signupSchemaType = typeof signupSchema;
バリデーションを行う†
src/routes/(loggedOut)/signup/+page.server.ts
LANG:ts import type { Actions, PageServerLoad } from './$types'; import { signupSchema } from '$lib/formSchemas/signupSchema'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; const schema = signupSchema; export const load = (async () => { const form = await superValidate(schema); return { form }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); if (!form.valid) { return fail(400, { form }); } // TODO: サインアップ処理をここに return { form }; } };
エラーメッセージを表示するためのフィールドを用意する†
src/routes/(loggedOut)/signup/+page.svelte
LANG:html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, { taintedMessage: false }); export const snapshot = { capture, restore }; </script> <div> <h1>サインアップ</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <label for="name">ユーザー名</label> <input type="text" name="name" bind:value={$form.name} disabled={$submitting} /> {#if $errors.name}<span class="invalid">{$errors.name}</span>{/if} <label for="password">パスワード</label> <input type="password" name="password" bind:value={$form.password} disabled={$submitting} /> {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if} <label for="confirmPassword">パスワード(確認)</label> <input type="password" name="confirm" bind:value={$form.confirm} disabled={$submitting} /> {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if} <div><button disabled={$submitting}>サインアップ</button></div> <a href="/login">ログイン</a> </form> </div> <style> .invalid { color: red; } </style>
これで正しくエラーが検出されるようになった。
Prisma の導入†
https://www.prisma.io/docs/getting-started/quickstart に従って さしあたり `sqlite` を使うよう初期化
LANG:console $ pnpm i -D prisma && pnpm i @prisma/client Packages: +2 ++ Progress: resolved 297, reused 275, downloaded 0, added 2, done devDependencies: + prisma 5.6.0 Done in 5.4s Packages: +2 ++ Progress: resolved 299, reused 277, downloaded 0, added 2, done dependencies: + @prisma/client 5.6.0 Done in 8.5s $ npx prisma init --datasource-provider sqlite ✔ Your Prisma schema was created at prisma/schema.prisma You can now open it in your favorite editor. warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information. Next steps: 1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started 2. Run prisma db pull to turn your database schema into a Prisma schema. 3. Run prisma generate to generate the Prisma Client. You can then start querying your database. More information in our documentation: https://pris.ly/d/getting-started $ cat .env ... DATABASE_URL="file:./dev.db"
データベースは `(app)/prisma/dev.db` に作成される。
コミットされないよう .gitignore に追加しておく
LANG: console $ echo "/prisma/*.db" >> .gitignore
Lucia に対応したテーブルを定義†
prisma/schema.prisma
LANG: prisma model AuthUser { id String @id @unique auth_session AuthSession[] auth_key AuthKey[] name String @unique email String @unique @@map("auth_user") } model AuthSession { id String @id @unique user_id String active_expires BigInt idle_expires BigInt auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) @@index([user_id]) @@map("auth_session") } model AuthKey { id String @id @unique hashed_password String? user_id String primary_key Boolean expires BigInt? auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) @@index([user_id]) @@map("auth_key") }
migrate する†
LANG: console $ npx prisma migrate dev --name "add tables for lucia" Environment variables loaded from .env Prisma schema loaded from prisma\schema.prisma Datasource "db": SQLite database "dev.db" at "file:./dev.db" Applying migration `20231121075732_add_tables_for_lucia` The following migration(s) have been created and applied from new schema changes: migrations/ └─ 20231121075732_add_tables_for_lucia/ └─ migration.sql Your database is now in sync with your schema. ✔ Generated Prisma Client (v5.6.0) to .\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client in 344ms
ライブラリに追加†
サーバー上でしか使わないので lib/server の下に置く
src/lib/server/db.ts
LANG: ts import { PrismaClient } from '@prisma/client'; export const db = new PrismaClient();
commit†
LANG: console $ git add . $ git status On branch master Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: .gitignore modified: package.json modified: pnpm-lock.yaml new file: prisma/dev.db-journal new file: prisma/migrations/20231121075732_add_tables_for_lucia/migration.sql new file: prisma/migrations/migration_lock.toml new file: prisma/schema.prisma $ git commit -m "Prisma を使い Lucia 用のテーブルを準備"
Lucia を導入†
Lucia は認証やセッション管理を行うライブラリ。
LANG: console $ pnpm i lucia-auth @lucia-auth/adapter-prisma WARN deprecated lucia-auth@1.8.0: Deprecated - see the docs for migrating to v2 Packages: +3 +++ Progress: resolved 302, reused 280, downloaded 0, added 0, done dependencies: + @lucia-auth/adapter-prisma 3.0.2 + lucia-auth 1.8.0 deprecated Done in 3s
んー、1.8.0 は deprecated だから v2 を使えと言われる?
じゃあ https://lucia-auth.com/getting-started/sveltekit/ を見ながら v2 を使おう
LANG: console $ pnpm rm lucia-auth @lucia-auth/adapter-prisma Packages: -3 --- Progress: resolved 299, reused 277, downloaded 0, added 0, done dependencies: - @lucia-auth/adapter-prisma 3.0.2 - lucia-auth 1.8.0 Done in 1.9s $ pnpm i lucia @lucia-auth/adapter-prisma Packages: +2 ++ Progress: resolved 301, reused 279, downloaded 0, added 0, done dependencies: + @lucia-auth/adapter-prisma 3.0.2 + lucia 2.7.4 Done in 3.2s
Lucia v2 に対応してテーブル定義を変更†
AuthKey から primary_key と expires を削除
prisma/schema.prisma
model AuthKey { id String @id @unique hashed_password String? user_id String - primary_key Boolean - expires BigInt? auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) @@index([user_id]) @@map("auth_key") }
データベースに反映
LANG: console $ npx prisma migrate dev --name "alter tables for lucia v2"
Lucia の初期化†
テーブルの対応や User テーブルに追加した追加の属性をライブラリに教える。
src/lib/server/lucia.ts
LANG: ts import { lucia } from "lucia"; import { sveltekit } from "lucia/middleware"; import { dev } from "$app/environment"; import { prisma } from "@lucia-auth/adapter-prisma"; import { db } from "$lib/server/db"; export const auth = lucia({ env: dev ? "DEV" : "PROD", middleware: sveltekit(), adapter: prisma(db, { user: "authUser", key: "authKey", session: "authSession" }), getUserAttributes: (data) => { return { // IMPORTANT!!!! // `userId` included by default!! name: data.name, email: data.email }; }, }); export type Auth = typeof auth;
locals.auth を追加†
User および Session に追加したフィールドの型定義を与えるとともに、
locals.auth に認証情報を持てるようにする
src/app.d.ts
LANG: ts // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { declare namespace Lucia { type Auth = import('$lib/server/lucia').Auth; type DatabaseUserAttributes = {name: string, email: string}; type DatabaseSessionAttributes = object; } namespace App { // interface Error {} interface Locals { auth: import('lucia').AuthRequest; } // interface PageData {} // interface Platform {} } } export {};
hook で認証情報を読み込む†
https://lucia-auth.com/basics/handle-requests/#sveltekit の通り、 hooks で event から認証情報を得て locals.auth へ入れる。
src/hooks.server.ts
LANG: ts import { auth } from '$lib/server/lucia'; import type { Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; const authHandler: Handle = async ({ event, resolve }) => { event.locals.auth = auth.handleRequest(event); return await resolve(event); }; export const handle = sequence(authHandler);
サインアップ処理†
v2 では createUser にて primaryKey の代わりに key とする。
src/routes/(loggedOut)/signup/+page.server.ts
LANG: ts // 冒頭に追加 import { auth } from '$lib/server/lucia'; import { LuciaError } from 'lucia'; ... // サインアップ処理 try { const user = await auth.createUser({ key: { providerId: 'email', providerUserId: form.data.email, password: form.data.password }, attributes: { name: form.data.name, email: form.data.email, } }); // そのままログイン状態にする const session = await auth.createSession({userId: user.userId, attributes: {}}); event.locals.auth.setSession(session); } catch (e) { if (e instanceof LuciaError && e.message === `AUTH_DUPLICATE_KEY_ID`) { return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' } }); } // provided user attributes violates database rules (e.g. unique constraint) // or unexpected database errors return fail(400, { form: { ...form, message: 'サインアップエラー' } }); } throw redirect(302, '/');
これでサインアップできるようになった。
ここではそのままログイン状態にしてしまっている。
メールアドレスの確認などを行うならログイン処理はせず /login へリダイレクトするのが良い。
データベースを確認†
テスト用にサインアップをしてみた後、
LANG: console $ pnpm prisma studio
とすると Prisma Studio が立ち上がるので、AuthUser テーブルや AuthKey テーブルに正しく情報が登録していることを確認する。
ログイン状態を確認†
上記のコードではサインアップと同時にログインしているので、 hooks により locals.auth にログイン情報が入っているはず。
そこで、トップページでログイン情報を参照してみることにする。
src/routes/+page.server.ts
LANG: ts import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async ({locals}) => { const session = await locals.auth.validate(); return {user: session?.user} }
session.user に格納されるログインユーザーの情報をページに渡す。
ページ側でそれを受け取って、
src/routes/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; export let data: PageData; </script> <h1>Welcome {data.user ? data.user.name : 'to Svelte'}</h1>
とすれば、ログインしている場合に "Welcome (ユーザー名)" と表示されるはずで・・・
試したところうまく行ってるようだった。
ログアウト処理†
locals.auth の持つ validate() 結果 (session 情報) の内部キャッシュを無効化し、 それを参照するクッキーも削除する。
src/routes/(loggedIn)/logout/+server.ts
LANG: ts import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ locals }) => { // 読み出し済みセッション情報の無効化 locals.auth.invalidate(); // クッキーからセッションID削除 locals.auth.setSession(null); throw redirect(302, '/'); };
確かにログアウトできた。
locals.auth は読み出し済みのセッション情報のキャッシュを消すだけで データベース上のセッションレコードは消さないみたいなのだけれどそれでいいのかしら???
ログイン処理†
フォームのバリデーション
メールアドレスやパスワードが正しいかどうかはログイン処理で確認するので ここでは1文字でも入力されていれば良いことにする。
src/lib/formSchemas/loginSchema.ts
LANG: ts import { z } from 'zod'; export const loginSchema = z .object({ email: z.string().min(1, 'メールアドレスを入力して下さい'), password: z.string().min(1, 'パスワードを入力して下さい'), }); export type loginSchemaType = typeof loginSchema;
ログインフォーム
src/routes/(loggedOut)/login/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; import { superForm } from 'sveltekit-superforms/client'; export let data: PageData; const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {taintedMessage: false}); export const snapshot = { capture, restore }; </script> <div> <h1>ログイン</h1> {#if $message}<span class="invalid">{$message}</span>{/if} <form method="POST" use:enhance> <label for="email">ユーザー名</label> <input type="text" name="email" bind:value={$form.email} disabled={$submitting} /> {#if $errors.email}<span class="invalid">{$errors.email[0]}</span>{/if} <label for="password">パスワード</label> <input type="password" name="password" bind:value={$form.password} disabled={$submitting} /> {#if $errors.password}<span class="invalid">{$errors.password[0]}</span>{/if} <div><button disabled={$submitting}>ログイン</button></div> <a href="/signup">サインアップ</a> </form> </div> <style> .invalid { color: red; } </style>
ログイン処理
src/routes/(loggedOut)/login/+page.server.ts
LANG: ts import type { Actions, PageServerLoad } from './$types'; import { loginSchema as schema } from '$lib/formSchemas/loginSchema'; import { superValidate } from 'sveltekit-superforms/server'; import { fail } from '@sveltejs/kit'; import { auth } from '$lib/server/lucia'; export const load = (async () => { const form = await superValidate(schema); return { form }; }) satisfies PageServerLoad; export const actions: Actions = { default: async (event) => { // フォームデータのバリデーション const form = await superValidate(event, schema); if (!form.valid) { return fail(400, { form }); } // ログイン処理 try { const key = await auth.useKey('email', form.data.email, form.data.password); const session = await auth.createSession({userId: key.userId, attributes: {}}); event.locals.auth.setSession(session); } catch { return fail(400, { form: { ...form, message: 'ログインエラー' } }); } throw redirect(302, '/'); } };
ログインもできるようになった
ログインあり・なしで閲覧制限する†
src/hooks.server.ts
LANG: ts const authHandler: Handle = async ({ event, resolve }) => { event.locals.auth = auth.handleRequest(event); const session = await event.locals.auth.validate(); if (event.route.id?.startsWith('/(loggedIn)') && !session?.user) { return Response.redirect(`${event.url.origin}/login`, 302); } if (event.route.id?.startsWith('/(loggedOut)') && session?.user) { return Response.redirect(`${event.url.origin}/`, 302); } return await resolve(event); };
権限の管理を与える†
AuthUser と 多対多 関係を持つ Role を導入する。
prisma/schema.prisma
model AuthUser { ... + roles Role[] } + model Role { + id String @id @default(uuid()) + name String @unique + + users AuthUser[] + }
マイグレーションする。
LANG: console $ npx prisma migrate dev --name "add Role"
Prisma で多対多関係を扱うためのコードは結構煩雑になるようなので、 Role を使うためのユーティリティ関数を db に持たせる。
このやり方が良いのかは疑問が残る?
src/lib/server/db.ts
LANG: ts import { PrismaClient } from '@prisma/client'; export class ExtendedPrismaClient extends PrismaClient { constructor() { super() } // userId が undefined なら権限は空とみなされる async getRoles(userId: string | undefined) { if(!userId) { return [] } return (await this.authUser.findUnique({ where: {id: userId}, select: {roles: true} }))?.roles || [] } async getRolesString(userId: string | undefined) { return (await this.getRoles(userId)).map((role)=> role.name); } async hasRole(userId: string | undefined, role: string) { return (await this.getRolesString(userId)).includes(role); } async addRoles(userId: string, ...roles: string[]) { await this.authUser.update({ where: { id: userId }, data: { roles: { connectOrCreate: roles.map(role=> ({where: {name: role}, create: {name: role}}) ) }, }, }) } async removeRoles(userId: string, ...roles: string[]) { await this.authUser.update({ where: { id: userId }, data: { roles: { deleteMany: roles.map(role=>({name: role})) }, }, }) } } export const db = new ExtendedPrismaClient();
Prisma の言語仕様を見ていると TypeScript と VSCode の存在がいかに偉大か実感する。 こんな DSL はエディタ上でエラー検出してくれなきゃ、とてもじゃないけど書けない。
ところで、
LANG: ts roles.map(role=>({name: role}))
などという記述に注意が必要。これを
LANG: ts roles.map(role=>{name: role})
と書いてしまうと role=>{ の { がブロックの開始として解釈されてしまい、うまく行かない。 (その場合 name: はラベルとして解釈される)
JavaScriptのアロー関数でオブジェクトを返す方法
https://dev.classmethod.jp/articles/arrow-func-return-object/
そこで一見すると無駄に見える括弧で括っている。
サインアップ時、最初のユーザーには admin 権限を持たせることにする。
src/routes/(loggedOut)/signup/+page.server.ts
+ import { db } from '$lib/server/db' ... + // 最初のユーザーには admin 権限を持たせる + if(await db.authUser.count() == 1) { + await db.addRoles(user.userId, 'admin') + }
/(admin) あるいは /admin から始まるパスには admin 権限を持っているユーザーしかアクセスできない。
src/hooks.server.ts
LANG:ts + if (event.route.id?.startsWith('/(admin)') || event.route.id?.startsWith('/admin')) { + if (!await db.hasRole(session?.user?.userId, 'admin')) { + return Response.redirect(`${event.url.origin}/`, 302); + } + }
flash メッセージ†
redirect による遷移先で表示するメッセージをページ間で引き継ぐためのライブラリ。
http では遷移元と遷移先のページの表示は独立した処理になるため、 遷移元ページの処理結果に応じて遷移先ページにメッセージを出すには工夫がいる。 それを担うのがこのライブラリ。
もっともよくある使い方としては、フォームの POST が成功した場合に redirect で 別ページに移るのが良い作りとされているので、 その遷移先で送信成功を示すメッセージを表示するのにこのライブラリを使える。
https://www.npmjs.com/package/sveltekit-flash-message の通りの手順で、
LANG: console $ pnpm i -D sveltekit-flash-message
src/app.d.ts
LANG: ts declare namespace App { ... interface PageData { flash?: { type: 'success' | 'error'; message: string }; } ... }
src/routes/+layout.server.ts
LANG: ts export { load } from 'sveltekit-flash-message/server';
src/routes/+layout.svelte
LANG: html <script lang="ts"> import { getFlash } from 'sveltekit-flash-message'; import { page } from '$app/stores'; const flash = getFlash(page); </script> {#if $flash} {@const bg = $flash.type == 'success' ? '#3D9970' : '#FF4136'} <div style:background-color={bg} class="flash">{$flash.message}</div> {/if} <slot />
これで準備完了。
LANG: console $ git commit -m "sveltekit-flash-message を導入"
redirect しているところに片っ端からメッセージを付ける (server 側)†
@sveltejs/kit の redirect の代わりに
sveltekit-flash-message/server の redirect を使いメッセージを追加する。
src/routes/(loggedOut)/signup/+page.svelte
LANG: ts - import { fail, redirect } from '@sveltejs/kit'; + import { fail } from '@sveltejs/kit'; + import { redirect } from 'sveltekit-flash-message/server'; ... - throw redirect(302, '/'); + throw redirect(302, '/', { type: 'success', message: 'サインアップに成功しました'}, event);
みたいな感じ。
src/routes/(loggedOut)/login/+page.svelte
LANG: ts throw redirect(302, '/', { type: 'success', message: 'ログインしました'}, event);
も問題ない。
ところが +server.ts の RequestHandler からだと event が手に入らないため困ってしまう。
この場合どうしたらいいんだろう???
"sveltekit-flash-message" "RequestHandler" で検索しても何も出ないのだけれど...
無理やりダミーの RequestEvent を作ればなんとかはなる。でもこうするしかないのかどうか???
LANG: ts export const GET: RequestHandler = async ({ locals, cookies }) => { // 読み出し済みセッション情報の無効化 locals.auth.invalidate(); // クッキーからセッションID削除 locals.auth.setSession(null); const dummyEvent = {cookies} as RequestEvent; throw redirect(302, '/', { type: 'success', message: 'ログアウトしました'}, dummyEvent); };
src/hooks.server.ts については event は手に入るけど redirect の仕方が違うので、 redirect と独立して flash メッセージを設定するため setFlash を使う?
と思って
src/hooks.server.ts
+ import { setFlash } from 'sveltekit-flash-message/server'; ... if (event.route.id?.startsWith('/(loggedOut)') && session?.user) { + setFlash({ type: 'error', message: 'You have already logged in.' }, event); return Response.redirect(`${event.url.origin}/`, 302); }
のようにしてみたけどうまく行かなかった。
event.cookies.set を呼んでももそれは event の中を書き換えるだけで、 Response.redirect が event を参照しないから何の意味もない。
throw redirect でリダイレクトしないとダメみたいだった。
src/hooks.server.ts
LANG: ts + import { redirect } from 'sveltekit-flash-message/server'; ... const authHandler: Handle = async ({ event, resolve }) => { event.locals.auth = auth.handleRequest(event); const session = await event.locals.auth.validate(); if (event.route.id?.startsWith('/(admin)') || event.route.id?.startsWith('/admin')) { if (!await db.hasRole(session?.user?.userId, 'admin')) { - return Response.redirect(`${event.url.origin}/`, 302); + throw redirect(302, '/', { type: 'error', message: 'Accessible only for admin users.' }, event); } } if (event.route.id?.startsWith('/(loggedIn)') && !session?.user) { - return Response.redirect(`${event.url.origin}/login`, 302); + throw redirect(302, '/', { type: 'error', message: 'Accessible only for login users.' }, event); } if (event.route.id?.startsWith('/(loggedOut)') && session?.user) { - return Response.redirect(`${event.url.origin}/`, 302); + throw redirect(302, '/', { type: 'error', message: 'You have already logged in.' }, event); } return await resolve(event); };
としたらうまく行った。
見せ方†
特にクライアントサイドから表示した場合には
画面上部に出るだけだと気づけない場合もあるので、
「表示領域の上部に出て、クリックされたら、あるいはしばらくしたら、引っ込む」
みたいな動作が望ましい気がする。
https://github.com/ciscoheat/sveltekit-flash-message#toast-messages-event-style
によれば svelte-french-toast を使うと良いらしい。
LANG: console $ pnpm i svelte-french-toast Packages: +2 ++ Progress: resolved 304, reused 280, downloaded 2, added 2, done dependencies: + svelte-french-toast 1.2.0 Done in 4.9s
src/routes/+layout.svelte
LANG: html <script lang="ts"> import { getFlash } from 'sveltekit-flash-message'; import { page } from '$app/stores'; import toast, { Toaster } from 'svelte-french-toast'; const flash = getFlash(page); flash.subscribe(($flash) => { if (!$flash) return; toast($flash.message, { icon: $flash.type == 'success' ? '✅' : '❌' }); // Clearing the flash message could sometimes // be required here to avoid double-toasting. flash.set(undefined); }); </script> <Toaster /> <slot />
これでうまく行った。
クライアントサイドで使う例も後で勉強すべき†
https://www.npmjs.com/package/sveltekit-flash-message#client-side
どういう風に実装されているかまで理解しないと どうしてこういう処理が必要になるのか納得しづらいかも。