プログラミング/svelte のバックアップ(No.7)

更新


公開メモ

svelte や svelte kit を使いこなしたい

いまのところ情報収集中

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 設定

テスト環境の確認

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 が表示された。

vite-dev.png

^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

playwright-gui.png

うまく行きそうだ。

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 メッセージの管理

スケルトンプロジェクトを作成

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:html
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` に作成される。

Lucia に対応したテーブルを定義

prisma/schema.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

ライブラリに追加

src/lib/server/db.ts

LANG: ts
import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient();

.gitignore を変更して commit

LANG: console
$ echo "/prisma/*.db" >> .gitignore

$ 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 へ入れる。

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 とする。

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 (ユーザー名)" と表示されるはずで・・・

試したところ、ちゃんとうまくいった。

ログアウト処理

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, '/');
};

確かにログアウトできた。

ログイン処理

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 } from '$lib/formSchemas/loginSchema';
import { superValidate } from 'sveltekit-superforms/server';
import { fail } from '@sveltejs/kit';

import { auth } from '$lib/server/lucia';

const schema = loginSchema;

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[]
+ }

ユーティリティ関数を db に持たせる。

このやり方が良いのかは疑問が残る?

src/lib/server/db.ts

LANG: ts
import { PrismaClient } from '@prisma/client';

export class ExtendedPrismaClient extends PrismaClient {
  constructor() { super() }

  async getRoles(userId?: string) {
    if(!userId) {
      return []
    }
    return (await this.authUser.findUnique({
      where: {id: userId},
      select: {roles: true}
    }))?.roles || []
  }

  async getRolesString(userId?: string) {
    return (await this.getRoles(userId)).map((role)=> role.name);
  }

  async hasRole(userId: string | undefined, role: string) {
    return (await this.getRolesString(userId)).includes(role);
  }

  async addRole(userId: string, roles: string | string[]) {
    if(typeof roles === 'string') {
      roles = [roles];
    }
    await this.authUser.update({
      where: {
        id: userId
      },
      data: {
        roles: {
          connectOrCreate: roles.map((role)=>{
            return {create: {name: role}, where: {name: role}} 
          })
        },
      },
    })
  }

  async removeRole(userId: string, roles: string | string[]) {
    if(typeof roles === 'string') {
      roles = [roles]
    }
    await this.authUser.update({
      where: {
        id: userId
      },
      data: {
        roles: {
          deleteMany: roles.map((role)=>{
            return {name: role}
          })
        },
      },
    })
  }
}

export const db = new ExtendedPrismaClient();

最初のユーザーに admin 権限を持たせる

src/routes/(loggedOut)/signup/+page.server.ts

+ import { db } from '$lib/server/db'
...

+       if(await db.authUser.count() == 1) {
+         // 最初のユーザーには admin 権限を持たせる
+         await db.addRole(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 メッセージ

https://www.npmjs.com/package/sveltekit-flash-message

LANG: console
$ pnpm i -D sveltekit-flash-message

Counter: 1184 (from 2010/06/03), today: 1, yesterday: 4