🎉

NestJSとSvelteKitを使用したアプリケーションの構築

2024/05/22に公開

はじめに

まず、この構成になぜと疑問に思う方も多いでしょう。
実はこの構築は、最近転職した会社が採用している環境の内の一つなのです。
筆者もメインはNext.jsを利用することが多く、初めて聞いた時は結構驚きました。
今回この環境を作った経緯としては入社前に作ってしまって使用感を知ろうと思ったからです。
実はこの珍しい開発環境は入社を決めた決め手の一つだったりします。

とはいっても、実はSvelteはSapperが主要フレームワークの時から触っており、その書きやすさは知っています。
非常に見通しがよく書けるので、おすすめです。

この記事ではNestJSからHello Worldを送信するAPIを作成し、SvelteKit側で画面に表示するのをゴールに環境を構築していきます。

リポジトリは以下になります。
https://github.com/iidadaiti/hello-svnext

使用技術

  • SvelteKit
  • NestJS
  • Docker

Dockerの構築

コンテナ環境にする事でローカルの環境を(ほぼ)汚す事なく構築できます。
また、コンテナは移植や削除も簡単に行える為、今回のようなお試し環境を構築するには最適です。
さらに、vscodeを使用している場合は、Dev Containerを利用することでさらに幸せになれます。
この記事ではコンテナ環境の構築と、Dev Containerの設定を記載していますが、詳しく説明はしないのでご了承ください。

今回のDev Contaier構成です。
なお、compose.yamlはまだ空のファイルになります。

ファイル構成
.
├── .devcontainer/
│   ├── Dockerfile
│   ├── compose.yaml
│   └── devcontainer.json
├── .dockerignore
└── compose.yaml
.devcontainer/Dockerfile
FROM node:22.2.0-bookworm-slim AS base
ENV PNPM_HOME "/pnpm"
ENV PATH "$PNPM_HOME:$PATH"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT 0
RUN corepack enable \
    && corepack enable npm pnpm

FROM base AS dev
WORKDIR /app
RUN apt-get update -qq \
    && DEBIAN_FRONTEND=noninteractive apt-get install -qq --no-install-recommends \
    tini \
    && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node . /app
ENTRYPOINT ["/usr/bin/tini", "--"]
USER node
.devcontainer/compose.yaml
services:
  dev:
    build:
      context: .
      dockerfile: .devcontainer/Dockerfile
      target: dev
    volumes:
      - .:/app:cached
    tty: true
    command: /bin/sh -c "sleep infinity"
.devcontainer/devcontainer.json
{
	"name": "${localWorkspaceFolderBasename}",
	"dockerComposeFile": [
		"../compose.yaml",
		"./compose.yaml"
	],
	"service": "dev",
	"workspaceFolder": "/app",
	"customizations": {
		"vscode": {
			"extensions": [
				"ms-azuretools.vscode-docker",
				"eamodio.gitlens"
			]
		}
	}
}
.dockerignore
# vscode
.vscode/
.devcontainer/
*.code-workspace

# misc
.DS_Store
*.md

今回の設定ではdockerを立ち上げた際にcorepackが自動で有効になります。
また、PID1問題を回避する為にTiniを使用しています。

作成できたら、Dev Containerからdevコンテナに入ります。
以降の作業は全てこのコンテナで行います。

パッケージマネージャーの選択

今回pnpmを使用したいので、corepackを利用してパッケージマネージャーを指定します。
corepackはDocker側で有効化しているので、以下のコマンドを入力するだけです。

corepack use pnpm@latest

コマンドを打つと、package.jsonpnpm-lock.yamlファイルが作成され、package.jsonの中には使用するパッケージマネージャーが記載されていると思います。

package.json
{
  "packageManager": "***"
}

これで、パッケージマネージャーを設定できました。
npmやyarnを間違えて実行しても、エラーが出力されるようになります。

Sveltekitのセットアップ

次はフロントエンドのセットアップを行います。
以下のコマンドを入力します。

pnpm create svelte@latest app

質問がされますので、答えていきます。
今回の構成は以下のように答えてテストツールなど全部込み込みにしました。

create-svelte version 6.1.2

┌  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!

完了するとappディレクトリが作成されます。

編集しやすいように拡張機能を追加しておきましょう。

.devcontainer/devcontainer.json
{
	"name": "${localWorkspaceFolderBasename}",
	"dockerComposeFile": [
		"../compose.yaml",
		"./compose.yaml"
	],
	"service": "dev",
	"workspaceFolder": "/app",
	"customizations": {
		"vscode": {
			"extensions": [
+				"dbaeumer.vscode-eslint",
+				"esbenp.prettier-vscode",
+				"svelte.svelte-vscode",
				"ms-azuretools.vscode-docker",
				"eamodio.gitlens"
			]
		}
	}
}

次に、dockerに追加されたくないファイルを追加します。

.dockerignore
+# dependencies
+node_modules/
+.pnpm-store/
+
+# build
+build/
+.svelte-kit/
+package/
+
# vscode
.vscode/
.devcontainer/
*.code-workspace

# misc
.DS_Store
*.md

今回のプロジェクトに合わせて数箇所修正を行います。

まずはパッケージをインストールします

pnpm --prefix app install

次に、このままではローカルから接続できないので、app/vite.config.tsに以下の一行を追加します。

app/vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
+	server: { host: true },
	plugins: [sveltekit()],
	test: {
		include: ['src/**/*.{test,spec}.{js,ts}']
	}
});

次に、デフォルトではnpmを利用する設定になっているので、pnpmに変更します。

client/package.json
{
    ...
	"scripts": {
		"dev": "vite dev",
		"build": "vite build",
		"preview": "vite preview",
-		"test": "npm run test:integration && npm run test:unit",
+		"test": "pnpm run test:integration && pnpm run test:unit",
        ...
    },
    ...
}
client/playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test';

const config: PlaywrightTestConfig = {
	webServer: {
-		command: 'npm run build && npm run preview',
+		command: 'pnpm run build && pnpm run preview',
		port: 4173
	},
	testDir: 'tests',
	testMatch: /(.+\.)?(test|spec)\.[jt]s/
};

export default config;

これで設定が終わったので、Sveltekitを動作させるコンテナを作成します。

app/Dockerfile
FROM node:22.2.0-bookworm-slim AS base
ENV PNPM_HOME "/pnpm"
ENV PATH "$PNPM_HOME:$PATH"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT 0
RUN corepack enable \
    && corepack enable npm pnpm

FROM base as fetcher
WORKDIR /app
COPY --chown=node:node ./app/pnpm-lock.yaml /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch --prod ./

FROM base AS deps
WORKDIR /app
COPY --chown=node:node --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node ./app/package.json /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM base AS dev
WORKDIR /app
RUN apt-get update -qq \
    && DEBIAN_FRONTEND=noninteractive apt-get install -qq --no-install-recommends \
    tini \
    && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node ./app /app
COPY --chown=node:node --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node --from=deps /app/node_modules /app/node_modules
EXPOSE 5173
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["pnpm", "run", "dev"]
USER node
compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: ./app/Dockerfile
      target: dev
    volumes:
      - ./app:/app:cached
    ports:
      - 5173:5173
    tty: true

コンテナをリビルドすると、appコンテナが立ち上がるようになります。
フロントエンドのURLはデフォルトでhttp://localhost:5173/になっています。

NestJSのセットアップ

以下を実行します。
実行してしばらくするとapiディレクトリが作成されます。

pnpm dlx @nestjs/cli@latest new api --strict -p pnpm

dockerに追加されたくないファイルを追加します。

.dockerignore
# dependencies
node_modules/
.pnpm-store/

# build
build/
.svelte-kit/
package/
+dist/

# vscode
.vscode/
.devcontainer/
*.code-workspace

# misc
.DS_Store
*.md

作成したNestJSのプロジェクト用のコンテナを追加します。

api/Dockerfile
FROM node:22.2.0-bookworm-slim AS base
ENV PNPM_HOME "/pnpm"
ENV PATH "$PNPM_HOME:$PATH"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT 0
RUN corepack enable \
    && corepack enable npm pnpm

FROM base as fetcher
WORKDIR /app
COPY --chown=node:node ./api/pnpm-lock.yaml /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch --prod ./

FROM base AS deps
WORKDIR /app
COPY --chown=node:node --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node ./api/package.json /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM base AS dev
WORKDIR /app
RUN apt-get update -qq \
    && DEBIAN_FRONTEND=noninteractive apt-get install -qq --no-install-recommends \
    tini \
    && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node ./api /app
COPY --chown=node:node --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node --from=deps /app/node_modules /app/node_modules
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["pnpm", "run", "start:debug"]
USER node
compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: ./app/Dockerfile
      target: dev
    volumes:
      - ./app:/app:cached
    ports:
      - 5173:5173
    tty: true
+  api:
+    build:
+      context: .
+      dockerfile: ./api/Dockerfile
+      target: dev
+    volumes:
+      - ./api:/app:cached
+    ports:
+      - 3000:3000
+    tty: true

これで、コンテナをリビルドすると、apiコンテナが立ち上がるようになります。
また、http://localhost:3000/を立ち上げると、Hello World!が返ってきていると思います。

タスクの修正

これでクライアントとサーバーサイドのセットアップが完了したのですが、
タスクの実行にわざわざ目的のディレクトリに行ってからコマンドを打つ必要があり、面倒です。
そもそも、テスト環境をコンテナに乗せていないので、クライアント側のテスト(playgroundを利用したテスト)に失敗します。
なのでここでは、プロジェクトディレクトリ上でタスクを実行できるようにします。

(パッケージマネージャーの切り替え)[#パッケージマネージャーの切り替え]を行なった際に。package.jsonが作成されているはずなので、そこにタスクを記述していきます。

今回は以下のように設定しました。

package.json
{
+  "scripts": {
+    "install": "pnpm --prefix app install && pnpm --prefix api install",
+    "dev": "pnpm run --prefix app dev && pnpm run --prefix api start:debug",
+    "build": "pnpm run --prefix app build && pnpm run --prefix api build",
+    "start": "pnpm run --prefix app preview && pnpm run --prefix api start:prod",
+    "lint": "pnpm run --prefix app lint && pnpm run --prefix api lint",
+    "test": "pnpm run --prefix app test && pnpm run --prefix api test",
+    "format": "pnpm run --prefix app format"
+  },
    ...
}

app側のユニットテストで使用されているvitestは、デフォルトでwatchモードのため、このままではタスクが順番に処理されません。
テストした後に止まるように修正します。

app/package.json
{
	...
	"scripts": {
		...
		"format": "prettier --write .",
		"test:integration": "playwright test",
-		"test:unit": "vitest"
+		"test:unit": "vitest run"
	},
	....
}

続いて、テスト環境をdevコンテナに追加します。

.devcontainer/Dockerfile
FROM node:22.2.0-bookworm-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable npm yarn pnpm
WORKDIR /workspaces

FROM base AS dev
RUN apt-get update -qq \
    && DEBIAN_FRONTEND=noninteractive apt-get install -qq --no-install-recommends \
    tini \
-    && rm -rf /var/lib/apt/lists/*
+    && rm -rf /var/lib/apt/lists/* \
+    && pnpm dlx playwright install-deps
ENTRYPOINT ["/usr/bin/tini", "--"]
USER node

また、初回テストの際はplaywrightのインストールが必要です。

pnpm --prefix app exec playwright install

このように書き換えると、テスト実行時に自動でインストールしてくれます。

app/package.json
{
	...
	"scripts": {
		...
		"format": "prettier --write .",
-		"test:integration": "playwright test",
+		"test:integration": "playwright install && playwright test",
		"test:unit": "vitest run"
	},
	....
}

ちなみに、筆者の環境ではテストのセットアップがタイムアウトし、さらにFirefox以外のテストもタイムアウトするので、以下のようにしています。

app/package.json
{
	...
	"scripts": {
		...
		"format": "prettier --write .",
-		"test:integration": "playwright test",
+		"test:integration": "playwright install && pnpm run build && playwright test",
		"test:unit": "vitest run"
	},
	....
}
app/playwright.config.ts
import { devices } from '@playwright/test';
import type { PlaywrightTestConfig } from '@playwright/test';

const config: PlaywrightTestConfig = {
	webServer: {
-		command: 'pnpm run build && pnpm run preview',
+		command: 'pnpm run preview',
		port: 4173
	},
	testDir: 'tests',
+	projects: [{ name: 'Desktop Firefox', use: { ...devices['Desktop Firefox'] } }],
	testMatch: /(.+\.)?(test|spec)\.[jt]s/
};

export default config;

APIの呼び出し

いよいよ、appコンテナからapiコンテナを呼び出してみましょう。
まず、作成したNestJSがCORS接続できるように設定します。

api/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
+  app.enableCors();
  await app.listen(3000);
}
bootstrap();

SvelteKit側ではFetchするだけです。

app/src/routes/+page.ts
import type { PageLoad } from './$types'

export const ssr = false

export const load: PageLoad = async ({ fetch }) => {
    try {
        const res = await fetch('http://localhost:3000')
        const text = await res.text()

        return { text }
    } catch (error) {
        console.error(error);

        if (error instanceof Error) {
            return { text: error.message }
        }

        return { text: 'failed to fetch' }
    }
}
app/src/routes/+page.svelte
+<script lang="ts">
+	import type { PageData } from './$types';
+
+	export let data: PageData;
+</script>

<h1>Welcome to SvelteKit</h1>
+<h2>{data.text}</h2>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

これで、ページにHello Worldができるようになりました。
お疲れ様です!

終わりに

今回は直接APIを叩きましたが、実際の環境では直接呼ばずにGraphqlやtRPCなどを利用して型情報を持ったAPIのやり取りをすると思います。

今回の環境ではほぼ触りませんでしたがNestJSも非常に扱いやすそうなフレームワークだと感じました。
こんな環境に触れる機会があって本当に恵まれました!

Discussion