React + Rails + Vite.js なSPAアプリをモノレポで作ってみた

RailsでWebpacker以外の方法で React のSPAアプリが作れないかを調べたところ、 vite_rails がありました。
Rails Integration | Vite Ruby

webpackやRailsのWebpackerに詳しくないため、React と Rails と Vite.js を組み合わせてSPAが作れそうなのは魅力的でした。

そこで、 vite_rails を試すため、React + Rails + Vite.js なSPAアプリをモノレポで作ってみました。

 
目次

 

環境

  • バックエンド
  • フロントエンド
    • TypeScript 4.4.4
    • React.js 17.0.2
    • React Router 5.2.1
    • Vite.js 2.6.13

 

実装することとしないこと

実装すること

  • React + Rails + Vite.js なSPAアプリをモノレポで作る
  • vite_rails により、 bin/viteconfig/vite.json を作成する
  • Railsから切り離しやすくするため、ReactのディレクトリはRailsのアプリディレクトリ (app) の外側に置く

 

実装しないこと

  • Reactのディレクトリ構造をきちんと考えること
    • いろいろな構成が考えられますが、今回は適当に作ります

 

実装

セットアップ

Rails

rails new します。

今回のフロントエンドは Vite.js でセットアップするため、JavaScript まわりの生成はスキップします。

(JavaScriptとは関係ないものもスキップしていますが、今回試す範囲には影響ないはず)

% rails new react_with_vite_rails --skip-action-mailbox --skip-action-text --skip-action-cable --skip-sprockets --skip-javascript --skip-turbolinks --skip-jbuilder --skip-system-test

 
ちなみに、Rails 6.1 から最小限にするフラグ --minimal が追加されたようです。
Rails 6.1でrails newの生成を最小限にするフラグが追加(翻訳)|TechRacho by BPS株式会社

   

vite_rails

公式ドキュメントに従い、セットアップを行います。
Getting Started | Vite Ruby

 
Gemfileに追加します。

gem 'vite_rails'

 
bundle installします。

% bundle install

If you upgraded the gem manually, please run:
        bundle exec vite upgrade

 
vite_railsinstall を行います。

% bundle exec vite install
Creating binstub
Check that your vite.json configuration file is available in the load path:

        No such file or directory @ rb_sysopen - path/to/react_with_vite_rails/config/vite.json

Creating configuration files
Installing sample files
Installing js dependencies

> esbuild@0.13.11 postinstall path/to/react_with_vite_rails/node_modules/esbuild
> node install.js

+ vite@2.6.13
+ vite-plugin-ruby@3.0.3
added 35 packages from 83 contributors and audited 51 packages in 7.872s

6 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Adding files to .gitignore

Vite ⚡️ Ruby successfully installed! 🎉

 
bundle exec vite install 後に、Railsのアプリの中の app/frontend/entrypoints ディレクトリに application.js ファイルが作成されました。

ただ、今回はRailsのアプリの外に React 用のディレクトリを作るため、今は放置しておきます。

 

Vite.js のインストール

React + TypeScript な構成で作ります。

今回は、 ルートディレクトリの下に frontend ディレクトリを作成し、その中で React まわりを作成します。

% yarn create vite
yarn create v1.22.11
warning package.json: No license field
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Installed "create-vite@2.6.6" with binaries:
      - create-vite
      - cva
✔ Project name: … frontend
✔ Select a framework: › react
✔ Select a variant: › react-ts

Scaffolding project in path/to/react_with_vite_rails/frontend...

Done. Now run:

  cd frontend
  yarn
  yarn dev

✨  Done in 10.76s.

 

vite_rails と Vite.js の間で設定を調整

ここまでで vite_rails と Vite.js により、

が複数作成されています。

また、 tsconfig.json が Vite.js で作成された frontend ディレクトリの中にあります。

これらのファイルを調整するとともに、調整後の設定を vite_rails に伝えるため config/vite.json へ反映します。

 

package.json

2つの設定をマージします。

なお、 Vite.js のインストールバージョンが双方で異なっていたため、新しいバージョンの方を採用しました。

{
  "name": "frontend",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "serve": "vite preview"
  },
  "dependencies": {
    "react": "^17.0.0",
    "react-dom": "^17.0.0"
  },
  "devDependencies": {
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "@vitejs/plugin-react": "^1.0.0",
    "typescript": "^4.3.2",
    "vite": "^2.6.13",
    "vite-plugin-ruby": "^3.0.3"
  }

 

vite.config.ts

こちらもマージします。

vite_rails が生成した vite.config.ts に、 react プラグインを追加します。

import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    RubyPlugin(),
    react(),  // 追加
  ],
})

 

tsconfig.json

frontend/tsconfig.jsontsconfig.json へ移動します。

今回は中身を変更しません。

 

config/vite.json

Reactのコンポーネントなどは Vite.js で生成した場所においてゆくため、 sourceCodeDirrails_vite が生成した app/frontend から、Vite.jsで生成した frontend へと切り替えます。

{
  "all": {
    // 変更前
    //    "sourceCodeDir": "app/frontend",
    // 変更後
    "sourceCodeDir": "frontend",
    "watchAdditionalPaths": []
  },

 
以上で調整が終わりました。調整の詳細は、以下のコミットとなります。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/ecb76c98455d32c79db02d1d6d8de50697cd7575

 

RailsのViewの上で、React のコンポーネントを動かす

RailsのControllerとViewを生成

Controller と View を生成します。

% bin/rails g controller hello index
Running via Spring preloader in process 29384
      create  app/controllers/hello_controller.rb
       route  get 'hello/index'
      invoke  erb
      create    app/views/hello
      create    app/views/hello/index.html.erb
      invoke  test_unit
      create    test/controllers/hello_controller_test.rb
      invoke  helper
      create    app/helpers/hello_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    css
      create      app/assets/stylesheets/hello.css

 

Layoutの修正

vite_rails のタグを Layout に追加します。

  • vite_client_tag
    • Hot Module Reload 用
  • vite_react_refresh_tag
    • React 向けに、 追加が必要な Hot Module Reload 用
  • vite_javascript_tag 'main.tsx'
    • 今回は React のエントリポイントディレクトリにある main.tsx を指定
      • このファイルに、React Routerのルーティングを書く予定

 
また、CSSの読み込みは行わないので、 stylesheet_link_tag 'application', media: 'all'コメントアウト(削除)しておきます。

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>ReactWithViteRails</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <!-- 追加 開始 --->
    <%= vite_client_tag %>
    <%= vite_react_refresh_tag %>
    <%= vite_javascript_tag 'main.tsx' %>
    <!-- 追加 終了 --->
  </head>

  <body>
    <%= yield %>
  </body>
</html>

 

Viewの修正

Controllerはそのままにします。

Viewに React 用の id を設定します。

app/views/hello/index.html.erb

<div id="root"></div> 

 

routesを修正

hello#indexroot として設定します。

config/routes.rb

Rails.application.routes.draw do
  root 'hello#index'
end

 

pumaの起動ポートを修正

他のアプリと重複するのを避けるため、適当なポートへ変更しておきます。

port ENV.fetch("PORT") { 7100 }

 

Helloコンポーネントを作成

hello と表示するだけのコンポーネントです。

frontend/src/components/Hello.tsx

const Home = (): JSX.Element => {
    return (
        <>
            <h1>hello</h1>
        </>
    )
}

export default Home 

 

main.tsx を修正

先ほど作成した Hello コンポーネントを組み込んで表示します。

(なお、importする時に名前を誤って Home にしてしまいましたが、あとで直すので気にしないこととします)

frontend/src/main.tsx

import ReactDOM from "react-dom";
import React from "react";
import Home from "./components/Hello";

ReactDOM.render(
    <React.StrictMode>
        <Home />
    </React.StrictMode>,
    document.getElementById('root')
) 

 

config/vite.json を修正

エントリポイントディレクトリを Vite.js で生成した frontend の下の src に変更します。

これで、Railsの Layouts で <%= vite_javascript_tag 'main.tsx' %> とした時に、 frontend/src/main.tsx が読み込まれるようになります。

{
  "all": {
    "sourceCodeDir": "frontend",
    "entrypointsDir": "src",  // 追加
    "watchAdditionalPaths": []
  },
...
}

 

動作確認

ここまでで準備ができたため、RailsとVite.jsの両サーバを起動して動作を確認します。

Vite.jsのサーバ

$ bin/vite dev

 
Railsのサーバ

% bin/rails s

 
http://localhost:7100 にアクセスしたところ、 hello が表示されました。

 
ここまでのコミットはこちら。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/7da5085e782b0333c4c54325e8ef9b2b00bef5d6

 

axiosをセットアップし、RailsAPIからデータを取得して表示する

続いて、RailsAPIからデータを取得し、画面に表示することを試してみます。

 

RailsAPIを作成

今回は現在時刻をJSONで返すAPIを作成します。

このAPIJSONを返すだけなので、 --no-assets で生成します。

% rails g controller api --no-assets
Running via Spring preloader in process 47954
      create  app/controllers/api_controller.rb
      invoke  erb
      create    app/views/api
      invoke  test_unit
      create    test/controllers/api_controller_test.rb
      invoke  helper
      create    app/helpers/api_helper.rb
      invoke    test_unit

 
生成されたコントローラーを修正します。

React側でローディング中の表示を行うため、重い処理をしているつもりで sleep を入れてあります。

また、日時を画面に表示できれば良いので、日時の表示形式は特に考えず、そのままレスポンスすることにします。

app/controllers/api_controller.rb

class ApiController < ApplicationController
  def now
    # 重い処理をしているつもり
    sleep 1

    # レスポンスを返す
    render json: {
      current: Time.now
    }
  end
end

 
routesも追加します。

config/routes.rb

Rails.application.routes.draw do
  root 'hello#index'

  get 'api/now'  # 追加
end

 
Rails側の実装が終わったため、続いてReact側を実装します。

 

axiosのインストール

% yarn add axios
% yarn add -D @types/axios

 

APIからデータを取得するカスタムフックを作成

今回はAPIからデータを取得するところをカスタムフックとして作成します。

frontend/src/hooks/useFetchApi.tsx

import axios, {AxiosError} from 'axios'
import {useEffect, useState} from "react";

type ResponseData = {
    current: string
}

export interface Response {
    data: ResponseData
    error: AxiosError | null,
    loading: boolean
}

export const useFetchApi = () => {
    const [response, setResponse] = useState<Response>({ data: { current: '' }, error: null, loading: false })

    useEffect(() => {
        fetchRequest()
    }, [])

    const fetchRequest = () => {
        setResponse(prevState => ({ ...prevState, loading: true }))
        axios.get<ResponseData>('http://localhost:7100/api/now').then((response) => {
            setResponse({ data: response.data, error: null, loading: false })
        }).catch(error => {
            setResponse({ data: { current: '' }, error: error, loading: false })
        })
    }

    return response
}

 

「カスタムフックの結果を表示する」コンポーネントの作成

APIから取得した現在時刻をそのまま表示します。

frontend/src/components/CurrentTime.tsx

import {useFetchApi} from "../hooks/useFetchApi";

const CurrentTime = (): JSX.Element => {
    const { data, error, loading } = useFetchApi()

    if (loading) return <div>...loading</div>
    if (error) return <div>{error.message}</div>

    return (
        <div>current: {data.current}</div>
    )
}

export default CurrentTime

 

main.tsxコンポーネントを追加

現在時刻を表示するコンポーネントを追加します。

import ReactDOM from "react-dom";
import React from "react";
import Hello from "./components/Hello";
import CurrentTime from "./components/CurrentTime";

ReactDOM.render(
    <React.StrictMode>
        <Hello />
        <CurrentTime />
    </React.StrictMode>,
    document.getElementById('root')
)

 

動作確認

最初は loading 表示になります。

 
1秒くらい経過すると、現在時刻が表示されました。

 

React Routerでのルーティングを追加

Rails

フロントエンドでルーティングするため、マッチしないルートが来てもフロントへ渡します。

Rails.application.routes.draw do
  root 'hello#index'
  get 'api/now'

  # マッチしないルートはフロントに流す
  get '*all', to: 'hello#index'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

 

React Routerのインストール

% yarn add react-router-dom
% yarn add -D @types/react-router-dom

 

各ページのコンポーネントを作成

今回は

  • HelloPage
    • 今まで作ってきた、現在時刻を表示するコンポーネントをここへ移動
    • AboutPageへのリンクあり
  • AboutPage
    • HelloPageへのリンクあり
  • NotFoundPage
    • どのルートにもマッチしない場合に表示
    • HelloPageへのリンクあり

の3つのページのコンポーネントを作成します。

 

HelloPage

今まで作ってきたコンポーネントを移動したものです。

また、フロントでルーティングするため、React Routerの Link コンポーネントを使っています。
https://reactrouter.com/web/api/Link

import Hello from "../components/Hello";
import CurrentTime from "../components/CurrentTime";
import {Link} from "react-router-dom";

const HelloPage = (): JSX.Element => {
    return (
        <>
            <Hello />
            <CurrentTime />

            <Link to="/about">Go to About page</Link>
        </>
    )
}

export default HelloPage

 

AboutPage

import { Link } from 'react-router-dom'

const AboutPage = (): JSX.Element => {
    return (
        <>
            <h1>About Page</h1>
            <Link to="/">Go to Hello page</Link>
        </>
    )
}

export default AboutPage 

 

NotFoundPage

中身はAboutPageと変わりません。

import { Link } from 'react-router-dom'

const NotFoundPage = (): JSX.Element => {
    return (
        <>
            <h1>Not Found Page</h1>
            <Link to="/">Go to Hello page</Link>
        </>
    )
}

export default NotFoundPage

 

main.tsxにReact Routerを組み込む

マッチさせたい順に設定するため、

  • /about
  • /
  • その他

の順に Route を書きます。

import ReactDOM from "react-dom";
import React from "react";
import {Switch} from "react-router"
import {BrowserRouter, Route} from "react-router-dom";
import HelloPage from "./pages/HelloPage";
import AboutPage from "./pages/AboutPage";
import NotFound from "./pages/NotFound";

ReactDOM.render(
    <React.StrictMode>
        <BrowserRouter>
            <Switch>
                <Route path="/about">
                    <AboutPage />
                </Route>

                <Route exact path="/">
                    <HelloPage />
                </Route>

                <Route>
                    <NotFound />
                </Route>
            </Switch>
        </BrowserRouter>
    </React.StrictMode>,
    document.getElementById('root')
)

 
以上で実装は終わりです。コミットはこちら。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/commit/bc350f88e0bc3ca4e6ec2dfc1392939e8fbff5a0

 

動作確認

/ にアクセスした時

 

/about にアクセスした時

 

/foo にアクセスした時

ルートとしては存在しないため、NotFoundPage が表示されます。

 
以上より、Webpackerを使わずに、 React + Rails + Vite.js なSPAアプリをモノレポで作っても動作することが分かりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/pull/1

Play Framework の View に React + TypeScript を組み込み、 sbt + Vite.js ですべてコンパイルできるようにしてみた

Play Framework アプリに React を組み込んでみる機会がありました。

ただ、Reactを使って SPA にするのではなく、まずは

  • ルーティングは Play Framework (バックエンド)が担当
  • Play Framework の View上で React が動く

という、MPA (Multi Page Application) な Play Framework アプリの作成を考えました。

 
MPAで作るにはどうすれば良いかを調べたところ、以下の記事がありました。とても分かりやすく参考になりました。ありがとうございました。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 
上記の記事のまとめには

本ブログではVue.jsを既存のPlay Frameworkに統合する方法を紹介しましたが、AngularJS、Reactを統合する方法もほぼ同じです。ぜひこれを参考に、様々な Modern WebSiteを作っていきましょう。

と書かれていたことから、React であっても作れるかもしれないと考え、この記事をベースに作ってみることにしました。

ただ、いろいろ悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Play Framework の View に React を組み込むこと
    • 1つのViewで1つのReact アプリ
    • Viewでは script タグで読み込む
  • React を始めとする Assets には、フィンガープリントが付いていること
    • 73ee83e91609731acc7ecffc9854ecfb-vendor.js みたいなの
  • React は TypeScript で書くこと
  • sbt run で、Play と React の両方をコンパイルした上で動作すること

 

実装しないこと

  • SPAなアプリにすること
  • Reactから Play Framework の API にアクセスすること
  • Viteでビルドするが、バックエンドとは統合しないこと

 

構成

  • Play Frameworkまわり
    • Scala 2.13.6
    • Play Framework 2.8.8
    • sbt 1.5.5
    • sbt-digest 1.1.4
    • Java や sbt は SDKMAN! でインストール
  • Reactまわり
    • React 17.0.2
      • create-react-app ではなく、 Vite でセットアップ
    • TypeScript 4.4.3
  • ビルドツール
    • vite 2.6.2

 

作るもの

方針

ページは以下の3つを作ります。Reactの有無などは以下の通りです。

No URL 説明 Reactファイル
1 / いわゆるHome なし
2 /apple りんご一覧ページ apple.js
3 /sweet_potato さつまいも一覧ページ sweet_potato.js

 
なお、各ページのルーティングはサーバサイドで行います。

 

ディレクトリ構造

主なディレクトリとファイルは以下の通りです。

.
├── app
│   ├── controllers/    # Play の Controller
│   └── views/          # PlayのView
├── build.sbt
├── conf/
│   ├── application.conf
│   ├── logback.xml
│   ├── messages
│   └── routes
├── frontend/
│   ├── node_modules/
│   ├── package.json
│   ├── project
│   │   ├── build.properties
│   │   └── target/
│   ├── src/
│   │   ├── components/          # React の各コンポーネント
│   │   ├── pages/
│   │   │   ├── apple/           # `/apple` ページ
│   │   │   └── sweet_potato/    # `/sweet_potate` ページ
│   │   └── vite-env.d.ts
│   ├── target/
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── yarn.lock
├── logs/
├── project/
├── public/
│   └── vite_assets/             # Viteでビルドした時の出力先 兼 PlayのAssets
├── target/
└── test/

 

完成イメージ

Home

React を使わない Twirl だけのページです。各ページへのリンクを置いてあります。

 

りんご一覧ページ

Homeにある りんご一覧ページへ をクリックすると、このページへ遷移します。

 
なお、 へー ボタンを押すと、数字がカウントアップします。

カウントアップは React Hooks の useState を使い、 Twirl 上で React が動作することを確認します。

 

さつまいも一覧ページ

こちらはりんご一覧ページと同じです。

 

課題:Assets のフィンガープリントを誰が付与するか

Play Frameworkでは routes

GET  /assets/*file  controllers.Assets.versioned(path="/public", file: Asset)

と書いて、Twirlに

<link rel="stylesheet" href="@routes.Assets.versioned("assets/css/app.css")">

と書くことで、フィンガープリントが付いた Assets へのリンクが自動で作成されます。
Using reverse routing and static state | Assets Overview - 2.8.x

 
では、各ファイルにフィンガープリントを付与するのはどこで行えばよいのかを考えてみます。

Reactで付与するのも良いのですが、 React 以外の Asset を使うことも想定されます。

そこで、

なことができるかを調べてみます。
Reverse routing and fingerprinting for public assets | Assets Overview - 2.8.x

 
今回の React アプリは create-react-app を使って作成します。ただ、 create-react-app で作ったReactアプリではフィンガープリントを無効にするのは難しそうでした。
Generate build without hashed file names. · Issue #821 · facebook/create-react-app

対応するには

  • ファイルのリネーム
  • create-react-appのeject

など、少々つらい方面でした。

 
それ以外の方法を調べたところ、Vue.jsを作ったEvan You氏が開発した Vite というビルドツールがありました。
Home | Vite

Viteのドキュメントを見たところ、

と分かりました。

そこで今回は Vite を使ってみることにしました。

 

実装

Play Framework と React の初期セットアップ

まずは sbt new で Play Framework アプリを生成します。

 % sbt new playframework/play-scala-seed.g8
...
name [play-scala-seed]: mpa_app_with_play_react_vite
organization [com.example]: com.example.thinkami

 
続いて Vite を使い React をセットアップします。

# ディレクトリを移動
% cd mpa_app_with_play_react_vite


# Vite でセットアップ
% npm init vite@latest
npx: installed 6 in 2.148s
...

# React アプリを入れるディレクトリを `frontend` として設定
✔ Project name: … frontend

# React を選択
✔ Select a framework: › react

# TypeScript を使うため、 `react-ts` を選択
✔ Select a variant: › react-ts

Done. Now run:

  cd frontend
  npm install
  npm run dev


# `frontend` ディレクトリへ移動してインストール
% cd frontend
% yarn install


# Node.jsの型定義を使うため、追加でインストール
% yarn add -D @types/node

 
これで Play Framework + React の初期セットアップができました。

 

React の 各Component を作成

List コンポーネント

一覧表示するためのコンポーネントを用意します。

// src/components/List.tsx

export type Props = {
    items: string[]
}

const Component = ({items}: Props): JSX.Element => {
    return (
        <ul>
            {items.map((item) => (
                <li key={item}>
                    <p>{item}</p>
                </li>
            ))}
        </ul>
    )
}
export default Component

 

Counter コンポーネント

へー ボタンを用意し、クリックするたびに +1 するための処理を入れます。

// src/components/Counter.tsx

import {useState} from "react";

const Component = (): JSX.Element => {
    const [count, setCount] = useState(0)

    return (
        <>
            <button type="button" onClick={() => setCount((count) => count + 1)}>
                へー {count}
            </button>
        </>
    )
}
export default Component

 

ListApp コンポーネント

ListとCounterを組み合わせたコンポーネントです。

りんご一覧とさつまいも一覧のページのひな形になっています。

// src/components/ListApp.tsx

import List from "./List";
import Counter from "./Counter";

export type Props = {
    name: string
    items: string[]
}

const ListApp = ({name, items}: Props): JSX.Element => {
    return (
        <>
            <h1>{name}</h1>
            <List items={items} />
            <Counter />
            <p><a href="/">戻る</a></p>
        </>
    )
}
export default ListApp

 

Reactの各ページを作成

先ほど作成したコンポーネントReactDOM.render します。

りんご一覧ページは以下の通りです。 さつまいも一覧ページは ListApp の引数 name だけが異なるため、ここでは省略します。

// src/pages/apple/index.tsx

import React from "react";
import ReactDOM from "react-dom";
import ListApp from "../../components/ListApp";

ReactDOM.render(
    <React.StrictMode>
        <ListApp name={'りんご'} items={['シナノゴールド', '秋映', 'ふじ']} />
    </React.StrictMode>,
    document.getElementById('root')
)

 
ここまでで React の必要なページとコンポーネントができました。

 

Viteの設定ファイル frontend/vite.config.ts を編集

Viteでのビルド設定を vite.config.ts に記載します。

 

出力先ディレクトリの設定

Play Framework の Assets ディレクトリである public の中に出力すると、その先は Play Framework に任せることができそうです。

そこで、

の各設定を追加します。

なお、出力先は frontend ディレクトリの外側になるため、 resolve()__dirname.. を使って指定します。
javascript - fs: how do I locate a parent folder? - Stack Overflow

export default defineConfig({
  plugins: [react()],
  build: {
    // 再度ビルドした時に、以前のファイルを消す
    emptyOutDir: true,
    // ビルドしたファイルを変更する
    outDir: resolve(__dirname, '..', 'public', 'vite_assets'),

 

Multi-Page Appの設定を追加

今回はViewごとにReactのエントリポイントを用意する必要があります。

そこで、Multi-Page Appとなるよう、以下の記事に従い rollupOptionsInput に設定を行います。
Multi-Page App | Building for Production | Vite

今回は

  • エントリポイント src/pages/apple/index.tsx を元に apple.js を出力
  • エントリポイント src/pages/sweet_potato/index.tsx を元に sweet_potato.js を出力

となるよう、Inputのキーにはビルドしてできるファイル名を、値にはエントリポイントのファイル名を指定します。

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        apple: resolve(__dirname, 'src', 'pages', 'apple', 'index.tsx'),
        sweet_potato: resolve(__dirname, 'src', 'pages', 'sweet_potato', 'index.tsx')
      },

 

出力ファイル名の設定を追加

今回はフィンガープリントを付けない形での出力ファイル名とするため、rollupOptionsOutput に設定を行います。
build.rollupOptions | Configuring Vite | Vite

rollupOptionsについては rollup.js のドキュメントに記載があります。

今回は

を設定します。

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: '[name].js',
        chunkFileNames: '[name].js',
        assetFileNames: '[name][extname]',
      }

 
以上で Vite の設定も完了です。

 

Play Framework の実装

Controller

今回は View をそのまま表示するだけです。

ここでは AppleController だけ記載しますが、他もほぼ同じ Controller です。

package controllers
import play.api.mvc._
import javax.inject._

@Singleton
class AppleController @Inject()(val cc: ControllerComponents) extends AbstractController(cc) {
  def index(): Action[AnyContent] = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.apple())
  }
}

 

View

index.scala.html

Twirl そのままです。Reactを使っているページへのリンクを付けています。

@()

@main("Welcome to Play") {
  <h1>Home</h1>
  <ul>
    <li><a href="/apple">りんご一覧へ</a></li>
    <li><a href="/sweet_potato">さつまいも一覧へ</a></li>
  </ul>
}

 

apple.scala.html

ビルドしたJavaScriptscript タグで読み込みます。

@()

@main("Welcome to Play") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/apple.js")"></script>
}

 

sweet_potato.scala.html

apple.scala.html とは、2つ目の script タグの src のみ異なります。

@()

@main("Welcome to Sweet Potato") {
  <div id="root"></div>

  <script type="module" src="@routes.Assets.versioned("vite_assets/vendor.js")"></script>
  <script type="module" src="@routes.Assets.versioned("vite_assets/sweet_potato.js")"></script>
}

 

routes

各コントローラのルーティングを追加します。

なお、Assetsのルーティングはデフォルトのままです。

GET     /                           controllers.HomeController.index()
GET     /apple                      controllers.AppleController.index()
GET     /sweet_potato               controllers.SweetPotatoController.index()

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)

 

Play Frameworkのビルド設定

sbt run でフロントエンドとバックエンドが両方コンパイルできるようにする

Play Frameworkのアプリを sbt run で起動すると、 dev mode となります。

公式ドキュメントによると、dev modeで起動する場合、以下のメソッドを使うことにより処理を差し込めそうです。

  • beforeStarted(): Unit - called before the play application is started, but after all “before run” tasks have been completed.
  • afterStarted(): Unit - called after the play application has been started.
  • afterStopped(): Unit - called after the play process has been stopped.

Hooking into Play’s dev mode | Sbt Cookbook - 2.8.x

 
そこで、冒頭でも紹介した記事を参考にしながら必要なものを実装していきます。
Integrate Vue.js into existing Play Framework project | CyberAgent Developers Blog

 

PlayKeys.playRunHooks 用のクラスを作成

PlayKeys.playRunHooks 用のクラスとして、 project/PlayDevRunHook.scala を実装します。

import java.io.PrintWriter
import play.sbt.PlayRunHook
import sbt._

import scala.io.Source
import scala.sys.process.Process
import scala.util.Using

object PlayDevRunHook {
  def apply(base: File): PlayRunHook = {
    val frontendBase = base / "frontend"
    val packageJsonPath = frontendBase / "package.json"
    // フロントエンドのビルド時に vite_assets にあるファイルが消されてしまうので、package.json と同じディレクトリに置いておく
    val packageJsonHashPath = frontendBase / "package.json.hash"

    object FrontendBuildProcess extends PlayRunHook {
      var process: Option[Process] = None

      override def beforeStarted(): Unit = {
        println("Hook to Play Framework dev run -- beforeStarted")

        val currPackageJsonHash = Using(Source.fromFile(packageJsonPath)) { source =>
          source.getLines().mkString.hashCode().toString
        }.getOrElse("")

        val oldPackageJsonHash = getStoredPackageJsonHash

        // frontend/package.json が変更されたら、もう一度 'yarn install` コマンドを実行する
        if (!oldPackageJsonHash.contains(currPackageJsonHash)) {
          println(s"Found new/changed package.json. Run yarn install ...")

          // 同期的にインストールを実行
          Process("yarn install", frontendBase).!

          updateStoredPackageJsonHash(currPackageJsonHash)
        }
      }

      override def afterStarted(): Unit = {
        println(s"> Watching frontend changes in $frontendBase")
        // フロントエンドのビルド用のプロセスを立ち上げ、非同期で実行する
        process = Option(Process("yarn build --watch", frontendBase).run)
      }

      override def afterStopped(): Unit = {
        // フロントエンドのビルド用のプロセスを停止する
        process.foreach(_.destroy)
        process = None
      }

      private def getStoredPackageJsonHash: Option[String] = {
        if (packageJsonHashPath.exists()) {
          val hash = Using(Source.fromFile(packageJsonHashPath)) { source =>
            source.getLines().mkString
          }
          Some(hash.getOrElse(""))
        } else {
          None
        }
      }

      private def updateStoredPackageJsonHash(hash: String): Unit = {
        Using(new PrintWriter(packageJsonHashPath)) { writer =>
          writer.write(hash)
        }
      }
    }

    FrontendBuildProcess
  }
}

 
修正箇所としては

  • 今回のアプリでは yarn installyarn build --watch は一箇所でしか使わないため、Shell object をインライン化
  • 元の実装ではビルドしたアプリの出力先に package.json.hash を出力していたが、Viteでビルドする時に出力先のファイルをすべて削除してしまうため、 package.json と同じディレクトリに置く
  • scala.util.Using の使用や、戻り値の型を追加するなど、IDEでの警告に対応

です。

 
なお、 Shell object の記法について、一部調べたことがあったため、メモとして残します。

object Shell {
  def execute(cmd: String, cwd: File, envs: (String, String)*): Int = {
    Process(cmd, cwd, envs: _*).!
  }

  def invoke(cmd: String, cwd: File, envs: (String, String)*): Process = {
    Process(cmd, cwd, envs: _*).run
  }
}

 
まず、可変長引数まわりです。

  • envs: (String, String)*(String, String)* は可変長引数としてのタプルです。
  • Process(cmd, cwd, envs: _*)_* は、可変長引数を Process に渡しています。

このあたりは、Pythonと比較した記法が紹介されている以下がわかりやすかったです。
syntax - What does param: _* mean in Scala? - Stack Overflow

 
次は、Process のメソッド

  • !
  • run

の違いです。

これについては、Scala Standard Library に記載があり、ざっくり run は非同期・ ! は同期での実行と理解しました。

Starting Processes

To execute all external commands associated with a ProcessBuilder, one may use one of four groups of methods. Each of these methods have various overloads and variations to enable further control over the I/O. These methods are:

  • run: the most general method, it returns a scala.sys.process.Process immediately, and the external command executes concurrently.
  • !: blocks until all external commands exit, and returns the exit code of the last one in the chain of execution.
  • !!: blocks until all external commands exit, and returns a String with the output generated.
  • lazyLines: returns immediately like run, and the output being generated is provided through a LazyList[String]. Getting the next element of that LazyList may block until it becomes available. This method will throw an exception if the return code is different than zero -- if this is not desired, use the lazyLines_! method.

 

build.sbt に追記

公式ドキュメントの記述に従い、 build.sbt に追加します。

// sbt run 時に、フロントエンドもコンパイルする
PlayKeys.playRunHooks += PlayDevRunHook(baseDirectory.value)

 

ビルド時に Assets にフィンガープリントを付与する

JavaScript などの Assets の Cache Busting として、ビルド時にフィンガープリントを付与するよう設定します。

Play Framework の場合、sbt-websbt-digest を使います。

 
sbt-digest のREADMEと以下の記事を参考に、設定を行います。
Play Frameworkでブラウザキャッシュ対策(Cache Busting) - Qiita

まず project/plugins.sbt に以下を追加します。

addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4")

 
続いて、 build.sbt に追記します。

なお、今回は開発環境でもフィンガープリントを付与する設定を行います。

lazy val root = (project in file("."))
  .enablePlugins(PlayScala)
  .enablePlugins(SbtWeb)  // 追加

// 開発環境でもフィンガープリントを付与
Assets / pipelineStages := Seq(digest)

ちなみに、sbt 1.5 より、 pipelineStages in Assets ではなく Assets / pipelineStages となったようです。
Migrating to slash syntax | sbt Reference Manual — Migrating from sbt 0.13.x

 

dist taskにて、Play Framework に React を含めるようにする

Play Framework では dist taskを使うことでバイナリバージョンができます。

その dist task にも React を含めるよう、 build.sbt に設定を追加します。

lazy val frontEndBuild = taskKey[Unit]("Execute dashboard frontend build command")

val frontendPath = "frontend"
val frontEndFile = file(frontendPath)

frontEndBuild := {
  println(Process("yarn install", frontEndFile).!!)
  println(Process("yarn build", frontEndFile).!!)
}

dist := (dist dependsOn frontEndBuild).value
stage := (stage dependsOn dist).value

 

動作確認

sbt run 時にフロントエンドもコンパイルされること

ログを見たところ、コンパイルされていました。

% sbt run
...
[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
> Watching frontend changes in path/to/mpa_app_with_play_react_vite/frontend

(Server started, use Enter to stop and go back to the console...)

yarn run v1.22.11
warning package.json: No license field
$ tsc && vite build --watch
vite v2.6.2 building for production...

watching for file changes...

build started...
transforming...
✓ 31 modules transformed.
rendering chunks...
../public/vite_assets/apple.js          0.26 KiB / gzip: 0.21 KiB
../public/vite_assets/sweet_potato.js   0.33 KiB / gzip: 0.23 KiB
../public/vite_assets/ListApp.js        0.48 KiB / gzip: 0.30 KiB
../public/vite_assets/vendor.js         129.47 KiB / gzip: 41.77 KiB
built in 1646ms.

 

scriptタグにフィンガープリントが付いていること

Elementを開いてみると、scriptタグにフィンガープリントが付与されていることを確認できました。

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/mpa_app_with_play_react_vite/pull/1

Scala + Play Framework 2.8 系で、設定ファイル(application.conf)の内容を読み込む

Scala + Play Framework 2.8系で、設定ファイル (application.conf) の内容を読み込む機会があったため、メモを残します。

 
目次

 

環境

  • Scala 2.13.6
  • Play Framework 2.8.8

 

方法

Play Framework 2.8系では

  • Controllerで play.api.Configurationplay.api. Environment をDIする
  • ConfigFactory.load().getString を使う

で行うようです。

 

動作確認

以下の application.config・Controller・Viewを用意し、試してみます。

application.config

foo=bar

 
ConfigLoaderController.scala

今回は

  • Controllerで play.api.Configuration をDIする
  • ConfigFactory.load().getString を使う

を試してみます。

package controllers

import com.typesafe.config.ConfigFactory
import play.api.mvc._

import javax.inject.Inject

class ConfigLoaderController @Inject()(val cc: ControllerComponents, val config: play.api.Configuration) extends AbstractController(cc) {
  def diConfig() = Action { implicit request: Request[AnyContent] =>
    // injectされた config を使うパターン
    Ok(views.html.di_config(config.underlying.getString("foo")))
  }

  def load() = Action { implicit request: Request[AnyContent] =>
    // Factoryでloadする
    val conf = ConfigFactory.load()
    val value = conf.getString("foo")
    Ok(views.html.load_config(value))
  }
}

 
それぞれの方法用にViewを用意して、動作を確認してみます。

DI用のView (di_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Configuration value: @name</h1>
}

と用意したところ、値を読み込めてViewで表示できました。

 
一方、load用のView (load_config.scala.html) を

@(name: String)

@main("Welcome to Play") {
    <h1>Load value: @name</h1>
}

と用意したところ、こちらも読み込めてViewで表示できました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/play_scala_hello_app

PRはこちらです。
https://github.com/thinkAmi-sandbox/play_scala_hello_app/pull/1

Scala + Play Framework なアプリを作る前段として、 sbt new した後のコードを読んで分かったこと/分からなかったことをまとめてみた

Scala + Play Framework を試してみようと、公式ドキュメントの Getting Started の Already know a bit about Play? に従ってみました。
Getting Started with Play Framework

ただ、試してみていくうちに

  • 分かったこと
  • 分からないこと

をまとめたくなったため、メモを残します。

もし「分からない」としたことについて、ご存じの方がいれば教えていただけるとありがたいです。

 
目次

 

環境

 
また、すでにPlay Frameworkのプロジェクト生成は、以下のようにして済んだものとします。

% sbt new playframework/play-scala-seed.g8
[info] welcome to sbt 1.5.5 (Eclipse Foundation Java 11.0.12)
[info] loading global plugins from path/to/.sbt/1.0/plugins
[info] set current project to new (in build file:/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/sbt_d5c88b9e/new/)

This template generates a Play Scala project 

name [play-scala-seed]: hello
organization [com.example]: com.example.thinkami

Template applied in path/to/hello

 

Play Frameworkのディレクトリ構造について

.gitignore にあるディレクトリやファイルを取り除いて残ったものは、以下の通りでした。

% tree -a
.
├── .g8
│   └── form
│       ├── app
│       │   ├── controllers
│       │   │   └── $model__Camel$Controller.scala
│       │   └── views
│       │       └── $model__camel$
│       │           └── form.scala.html
│       ├── default.properties
│       └── test
│           └── controllers
│               └── $model__Camel$ControllerSpec.scala
├── .gitignore
├── app
│   ├── controllers
│   │   └── HomeController.scala
│   └── views
│       ├── index.scala.html
│       └── main.scala.html
├── blog.md
├── build.sbt
├── conf
│   ├── application.conf
│   ├── logback.xml
│   ├── messages
│   └── routes
├── logs
│   └── application.log
├── project
│   ├── build.properties
│   ├── plugins.sbt
│   └── project
├── public
│   ├── images
│   │   └── favicon.png
│   ├── javascripts
│   │   └── main.js
│   └── stylesheets
│       └── main.css
└── test
    └── controllers
        └── HomeControllerSpec.scala

 
それぞれのディレクトリの役割については、公式ドキュメントに記載がありました。

公式の2.8系のドキュメントと日本語である2.3系のドキュメントでは、2.8系では dist ディレクトリが増えているくらいの違いでした。

 
ただ、上記ドキュメントについては .g8 ディレクトリの説明がなかったため、調べてみました。

 

.g8 ディレクトリについて

stackoverflowによると、 scaffolds 用のものが置いてあるディレクトリのようです。

The project supports using giter8 to create scaffolds So technically it is safe to delete, but you will lose the g8Scaffold form feature.

scala - Is .g8 directory necessary? - Stack Overflow

 
play-scala-seed.g8リポジトリはこちら。
playframework/play-scala-seed.g8: Play Scala Seed Template: run "sbt new playframework/play-scala-seed.g8"

 
ディレクトリ構造がわかったので、次は実行可能なコードが含まれる app ディレクトリの中を見てみます。

 

Controllerについて

新規プロジェクトの生成によりできた HelloController を見てみます。

import とコメントを消してみると、こんな感じでした。

@Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {
  def index() = Action { implicit request: Request[AnyContent] =>
    Ok(views.html.index())
  }
}

 
これを読んで、分かったこと/分からなかったことがあったため、まとめてみます。

 

分かったこと

@Injectについて

@Inject()JSR330アノテーションによるDIと理解しました。詳しくは以下が参考になりました。

 
この @Inject を使い、 trait ControllerComponents のデータがinjectされます。

trait ControllerComponents

The base controller components dependencies that most controllers rely on.

とあり、コントローラーで必要な機能を持っているようです。 https://www.playframework.com/documentation/2.8.x/api/scala/play/api/mvc/ControllerComponents.html

 
そして、 inject した内容は、以下のような順で渡されていくようです。
ControllerComponents の中身を少し見てみる – tchiba.dev

 

プライマリーコンストラクタについて

(val controllerComponents: ControllerComponents) の部分は、プライマリーコンストラクタでした。

プライマリコンストラクタの引数にval/varをつけるとそのフィールドは公開され、外部からアクセスできるようになります。

クラス · Scala研修テキスト

 
また、プライマリーコンストラクタで使用している val は値 (Value) で再代入不可、 var は変数 (Variable) で再代入可でした。
基本 | Scala Documentation

 

分からないこと

@Singleton を付与する基準

上記のように、生成された HelloController には @Singleton が付いています。

ただ、Web上のサンプルコードを見ると、Controllerに @Singleton が付いていない実装もありました。

 
@Singleton について調べたところ、stackoverflowには

In general, it is probably best to not use @Singleton unless you have a fair understanding of immutability and thread-safety. If you think you have a use case for Singleton though just make sure you are protecting any shared state.

In a nutshell, don't use @Singleton.

playframework - Why use singleton controllers in play 2.5? - Stack Overflow

や、上記を引用した別の質問へのコメントがありました。

In effect HomeController is behaving like a singleton and avoiding singletons could be safer. For example, by default Play Framework uses Guice dependency injection which creates a new controller instance per request as long as it is not a @Singleton. One motivation is there is less state to worry about regarding concurrency protection as suggested by Nio's answer:

multithreading - Play Scala and thread safety - Stack Overflow

 
とはいえ、これだけでは @Singleton を使うかどうかの判断基準が分かりませんでした。

 

Controllerで BaseController と AbstractController のどちらを継承させるかの基準

HomeController では BaseControllerextends していました。

class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {}

 
公式ドキュメントでControllerで継承すべきものを見てみると、以下に記載がありました。
Scala Controller changes | Migration26 - 2.6.x

  • BaseController
  • AbstractController
  • InjectedController

 
このうち InjectedController については、説明に

a trait, extending BaseController, that obtains the ControllerComponents through method injection (calling a setControllerComponents method). If you are using a runtime DI framework like Guice, this is done automatically.

とあり、Play FrameworkのデフォルのDI フレームワークである Guice でのDIではなく、メソッドでのDIをする時に使うものと理解しました。

また、 AbstractController については、説明に

an abstract class extending BaseController with a ControllerComponents constructor parameter that can be injected using constructor injection.

とありました。

そのため、基本は AbstractController を使うのかなと思いつつも、「BaseController を使うケースはどんなものがあるのか」が分からなかったことから、 AbstractControllerBaseController を使い分ける基準が分かりませんでした。

 

Indexメソッドのブロック式にある構文

Indexメソッドは以下の実装でした。

def index() = Action { implicit request: Request[AnyContent] =>
  Ok(views.html.index())
}

 
まず、 = の左辺の def index() は Index メソッドを定義していると理解しました。

 
次に、 = の右辺の Action {} は、 Action の引数としてブロック式を渡していると理解しました。

Scalaでは {} で複数の式の並びを囲むと、それ全体が式になりますが、便宜上それをブロック式と呼ぶことにします。

ブロック式 | Scalaの制御構文 · Scala研修テキスト

Actionの引数にブロック式を渡せることについては

Scalaでは、引数を1個だけ渡すメソッド呼び出しなら、引数を囲む括弧を中括弧に変えても良いことになっている。 ... 引数を1個渡すときに括弧ではなく中括弧を使える機能は、クライアントプログラマーが中括弧の間に関数リテラルを書き込めるようにすることを目的としている。そうすれば、メソッド呼び出しなのに、制御構造を使っているような感じが強まる

Scalaスケーラブルプログラミング 第4版 - インプレスブックス p180

と理解しました。

以下の記事を参考にしながらActionの実装を見ても、Actionがブロック式を受け取るように見えたためです。

 
問題はブロック式の中身です。ワンライナーに直すとこんな感じです。

implicit request: Request[AnyContent] => Ok(views.html.index())

「暗黙的なパラメータである request を受け取り、 Ok を返す」という処理は分かりましたが、記法に対する自分の理解が合っているのかが分かりませんでした。

 
まず、 => を使っていることから、この部分は関数リテラルと考えました。

次に => の左辺 implicit request: Request[AnyContent] については、暗黙のパラメータと考えました。

ここの暗黙のパラメータの意味については、実践Scala入門:書籍案内|技術評論社 の p97 に

「sumを呼び出す時に、implicit とマークされた Adder[T] の値が存在すれば、それを暗黙のうちに補完してください」とコンパイラに指示するものです

とあることから、この部分は「関数リテラルを呼び出す時に、 implicit とマークされた request の値があれば、暗黙のうちに補完する」と理解しました。

 
ただ、ここで悩んだのは関数リテラルの書式です。

言語仕様のページを見ると

Expr            ::=  (Bindings | [‘implicit’] id | ‘_’) ‘=>’ Expr
ResultExpr      ::=  (Bindings | ([‘implicit’] id | ‘_’) ‘:’ CompoundType) ‘=>’ Block
Bindings        ::=  ‘(’ Binding {‘,’ Binding} ‘)’
Binding         ::=  (id | ‘_’) [‘:’ Type]

とありました。
https://scala-lang.org/files/archive/spec/2.13/06-expressions.html#anonymous-functions

Action の引数は ResultExpr を指しているのかなと考えたものの、果たしてこれが正しい理解なのかは分かりませんでした。

そこでためしに、

def index() = Action { (implicit request: Request[AnyContent]) => Ok(views.html.index()) }

と実装を変えてみたところ、 '=>' expected but ')' found. というエラーでコンパイルができなくなりました。

一方、implicit を外してみた

def index() = Action { (request: Request[AnyContent]) => Ok(views.html.index()) }

コンパイルできました。

これらを見る限り理解は合ってそうでしたが、自身は持てませんでした。

その他参考にしたところは以下の通りです。

 

Viewについて

app/views には

の2つのViewテンプレートがありました。

公式ドキュメントによると

Play comes with Twirl, a powerful Scala-based template engine, whose design was inspired by ASP.NET Razor.

The template engine | Scala Templates - 2.8.x

とのことです。

 

conf/routes について

routes はルーティングファイルでした。
Scala Routing - 2.8.x

複数のルーティングファイルを用意することもできそうです。
scala - PlayFramework: multiple routes file in project - Stack Overflow

 

project/build.properties で sbt のバージョンを指定

生成したプロジェクトを起動してみると

[info] welcome to sbt 1.5.2 (Eclipse Foundation Java 11.0.12)

と、ローカルでは sbt 1.5.5 で動作するはずが、1.5.2 で動作していました。

原因は build.properties

sbt.version=1.5.2

と書かれていたためです。
Ensure sbt and Scala versions compatibility | sbt | IntelliJ IDEA

 
そこで、build.properties

sbt.version=1.5.5

としたところ、

[info] welcome to sbt 1.5.5 (Eclipse Foundation Java 11.0.12)

と表示されるようになりました。

 

.sdkmanrc について

ここは Play Framework とは関係ありませんが。。。

pyenvやrbenvのようにディレクトリに移動したら自動的に SDKMAN! でインストールした Java や sbt のバージョンを切り替えたくなりました。

以下の記事によると、 SDKMAN! でインストールした場合も、 .sdkmanrc ファイルがあれば切り替えができそうです。 sdkman で複数のバージョンの Java をディレクトリーごとに切り替える - mike-neckのブログ

そこで、生成したプロジェクトには .sdkmanrc ファイルは含まれていないことから、追加してみました。

java=11.0.12-tem
sbt=1.5.5

 

その他分からないこと

関数リテラルと無名関数とラムダ式の違い

=> まわりを調べていたところ

などの用語を見かけました。

Scalaという文脈では、上記の言葉の定義に違いがあるのかどうかが分かりませんでした。

Next.js に CASL React を組み込んで権限管理をしてみた

アプリケーションの権限設計について調べていたところ、以下の記事に出会いました。
アプリケーションにおける権限設計の課題 - kenfdev’s blog

権限設計で考えなければいけないことがまとまっていて、とても参考になりました。ありがとうございました。

 
上記の記事を読む中で

  • RBAC(Role-Based Access Control)
  • ABAC(Attribute-Based Access Control)

あたりを試してみたくなりました。

ただ、スクラッチで作るには時間がかかりそうだったため、記事で紹介されているライブラリを使ってみることにしました。

 
調べてみたところ、JavaScript製の CASL

JAXenter: When should you use CASL?

Sergii Stotskyi: Whenever you have a requirement to implement Access Control in the application. CASL, in its core, implements ABAC (i.e., Attribute Based Access Control), but it can be successfully used to implement RBAC (Role Based Access Control) and even Claim based access control.

"CASL is an isomorphic JavaScript permission management library"

という点で良さそうでした。

 
また、実装した例なども色々と見つかりました。

 
そこで、Next.js に CASL を組み込んで権限管理をしてみることにしました。

ただ、色々と悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Next.js に CASL React で組み込む
  • 認証(ログイン)は NextAuth.js を使う
  • 用意するページは2つ
    • /
      • ログインページ
      • ToDoが書かれているページへのリンクあり
    • /todo/<id>
      • ToDoが書かれているページ
      • ログインユーザーのロールに従い、画面の表示を変える

 

実装しないこと

  • TypeScriptで書く
  • NextAuth.js での認証はがんばらない
    • 今回の検証ではログインさえできれば良いので、OAuthとか使わず、ユーザーとパスワードにする
    • ユーザー名とパスワードが一致していれば、ログインOKにする
  • ロールはがんばらない
    • ログインしたユーザーの name をロールにする
  • エラーハンドリング
    • 記事の最後にありますが、今回は CASL を使うのが目的なため、エラーハンドリングはあきらめています。。。

 

構成

  • Next.js 11.1.2
  • React 17.0.2
  • @casl/react 2.3.0
  • @casl/ability 5.4.3
  • next-auth 3.29.0

 

NextAuth.js によるログイン機能を実装

今回はログインをがんばらないため、NextAuth.js を使ってログイン機能を実装することにしました。

また、手軽にログインを実現したかったため、NextAuth.js では OAuth2.0 などを使わず、ユーザーとパスワードだけでログインすることとしました。

そこで、以下の記事を参考に、ユーザーとパスワードだけでログインできるようにしてみます。
Next.js + NextAuthでメールアドレス/パスワード認証をする

なお、今回はCASLを使うのが目的であったため、「ユーザー名とパスワードが一致すれば、ログインOK」というセキュリティ的には明らかによろしくない実装となっています。

 
NextAuth.js の実装は上記の記事通りですので、細かな説明は省略します。

まずはインストールします。

# 現在のディレクトリ名で Next.js のアプリを作り、現在のディレクトリの中に各種ソースを入れる
% npx create-next-app .

# NextAuth を追加
$ yarn add next-auth

 
続いて、 NextAuth.js 用APIを作成します。

# NextAuth 用APIのファイルを用意
% mkdir pages/api/auth 
% touch "pages/api/auth/[...nextauth].js"

 
pages/api/auth/[...nextauth].js を実装します。

import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

const authenticate = credentials =>
  // CASLの検証が目的なので、認証は適当...
  credentials.name === credentials.password ?
    { id: 1, name: credentials.name } :
    null;

const options = {
  providers: [
    Providers.Credentials({
      name: 'Name',
      credentials: {
        name: { label: 'Name', type: 'text', placeholder: 'admin' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async credentials => {
        const user = authenticate(credentials)
        return user ?
          Promise.resolve(user) :
          Promise.resolve(null)
      },
    }),
  ],
}

export default (req, res) => NextAuth(req, res, options)

 
pages/_app.js を変更します。

import {Provider} from 'next-auth/client'

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  )
}

 
pages/index.js にログイン機能とToDoページへのリンクを追加します。

import {signIn, signOut, useSession} from 'next-auth/client'
import Link from 'next/link'

export default function Home() {
  const [session, loading] = useSession();

  if (loading) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {session && (
        <>
          Signed in as {session.user.name} <br/>
          <button onClick={signOut}>Sign out</button>
        </>
      )}
      {!session && (
        <>
          Not signed in <br/>
          <button onClick={signIn}>Sign in</button>
        </>
      )}

      <Link href="/todo/1">
        <a>Go Todo</a>
      </Link>
    </div>
  )
}

 
package.json で、Next.js の起動ポートを変更しておきます。

{
  "name": "nextjs_casl-sample",
  "scripts": {
    "dev": "next dev -p 4800",
//...

 
なお、NEXTAUTH_URL のポートはNext.jsと合わせます。

NEXTAUTH_URL=http://localhost:4800

 
実装が終わったため、 yarn run dev して、ログインできるようになればOKです。

 

CASLによる権限管理を実装

以下の公式ドキュメントやサンプルを参考にしながら、CASL を Next.js に組み込んでいきます。

 

権限管理 (Ability) の作成

今回はログインしたユーザーごとに権限が異なるため、サンプルのように user を引数に取る

  • buildAbilityFor
  • defineRulesFor

を作成していきます。

今回、公式サンプルと異なり、 subject type detection を行いません。

また、 ユーザー名 = ロール名 として、以下の権限で設定します。

ユーザー名 Staff部分の表示 Manager部分の表示 Admin部分の表示
admin o o o
manager o o x
staff o x x
その他 x x x

 
まずは、公式サンプル通り、 AbilityBuilder を使って権限 (ability ) を config/abilities.js ファイルに定義します。
AbilityBuilder class| Define Rules | CASL

import {Ability, AbilityBuilder} from '@casl/ability';

export default function defineRulesFor(user) {
  const {can, rules} = new AbilityBuilder(Ability);

  switch(user?.name) {
    case 'admin':
      can('manage', 'all');
      break;
    case 'manager':
      can('show', 'Manager');
      can('show', 'Staff');
      break;
    case 'staff':
      can('show', 'Staff');
      break;
    default:
  }

  return rules;
}

export function buildAbilityFor(user) {
  // 公式サンプルと異なり、今回は subject type detection を行わないので、オプションなしでインスタンス生成
  return new Ability(defineRulesFor(user));
}

 

AbilityProvider コンポーネントの作成

公式サンプルでは、 App.tsxbuildAbilityFor を使って ability をインスタンス化しています。

// https://github.com/stalniy/casl-examples/blob/19c2dbd1c3dc9fb855ed24108b4481a4eafca5ba/packages/react-todo/src/App.tsx#L7

const ability = buildAbilityFor('member');

 
しかし、今回は / でログインしたユーザーを元に ability をインスタンス化する必要があるため、 _app.js では ability を決め打ちできません。

そこで、

  • ability を useState フックで保存
  • AuthNext.js の useSession フックを使って取得できる session の状態を監視する useEffect フックを用意
  • ability をグローバルに扱えるよう、 useContext フックを使用

というような処理を行うコンポーネント (components/AbilityProvider.jsx) を用意します。

import {createContext, useEffect, useState} from 'react';
import {useSession} from 'next-auth/client';
import {buildAbilityFor} from '../config/abilities';

// グローバルな state を管理する Context を用意
export const AbilityContext = createContext({});

const Component = props => {
  const [session] = useSession();
  const [ability, setAbility] = useState({});

  // session の値が変わったら、session の内容により、abilityの値を変える
  useEffect(() => {
    session ? setAbility(buildAbilityFor(session.user)) : setAbility({});
  }, [session]);

  const {children} = props;

  // Providerコンポーネントを用意し、 ability をグローバルで扱えるようにする
  return (
    <AbilityContext.Provider value={{ability}}>
      {children}
    </AbilityContext.Provider>
  )
}
export default Component

 

_app.js に AbilityProvider を追加

アプリのグローバルで扱えるよう、_app.jsAbilityProvider を追加します。

sessionのProviderの下で、 Component を囲むように定義します。

import {Provider} from 'next-auth/client'
import AbilityProvider from '../components/AbilityProvider';

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <AbilityProvider>
        <Component {...pageProps} />
      </AbilityProvider>
    </Provider>
  )
}

 

権限ごとに表示が異なるページを用意

pages/todo/[todoId].js に実装します。

このページでは

  • 権限ごとに表示を変えるため、Context から ability を取り出す
    • const {ability} = useContext(AbilityContext);
  • ability.can() により、表示可否を決める
    • 例: {ability.can('show', 'Admin') && <li>Admin</li>}
  • ログインユーザー名を表示するため、 session を取り出す
    • const [session] = useSession();

を行います。

import {useRouter} from 'next/router'
import {useContext} from 'react'
import {AbilityContext} from '../../components/AbilityProvider';
import {useSession} from 'next-auth/client';

const Page = () => {
  const router = useRouter();
  const {todoId} = router.query;
  const {ability} = useContext(AbilityContext);
  const [session] = useSession();

  return (
    <div>
      <h1>ToDo</h1>
      <p>User: {session.user.name}</p>
      <p>Todo ID: {todoId}</p>
      <ul>
        {ability.can('show', 'Admin') && <li>Admin</li>}
        {ability.can('show', 'Manager') && <li>Manager and above</li>}
        {ability.can('show', 'Staff') && <li>Staff and above</li>}
        <li>Everyone</li>
      </ul>
    </div>
  );
}

export default Page

 

ToDoページに直接アクセスした時には404を表示する

ここまでで、クライアントサイドでのルーティングでは想定通りの動きとなりそうです。

ただ、直接 http://localhost:4800/todo/1 にアクセスしたり、ログイン前に ToDo ページに移動するとエラーになってしまいます。

 
そこで、Next.js で404を表示できるようにします。
Advanced Features: Custom Error Page | Next.js

まずは、独自の 404 ページ (pages/404.js) を用意します。

export default function Custom404() {
  return <h1>404 - Page Not Found</h1>
}

 
次に、ToDoページの getServerSidePropssession がなければ、404ページを表示させます。

なお、NextAuth.jsで session をサーバーサイドで取得するには、 getSession を使います。 getSession() | Client API | NextAuth.js

import {getSession, useSession} from 'next-auth/client';

export async function getServerSideProps(context) {
  const session = await getSession(context);
  if (!session) {
    return {
      notFound: true
    };
  }

  return {
    props: {}
  }
}

 
以上で実装が終わりました。

 

動作確認

一通りできたため、ログインユーザーごとの表示を確認します。

 

admin

 

manager

 

staff

 

その他

 

できていないこと

ログイン後、ToDoページに直接アクセスした場合はエラーになります。

とはいえ、今回は CASL を使うのがメインなので、あきらめます。。。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/nextjs_casl-sample

Next.js と express-openid-connect を使った Relying Party を TypeScript 化してみた

前回、Next.js と express-openid-connect を使った Relying Party を書きました。
Next.js と express-openid-connect を使って、認証が必要/不要な各ページを持つ Relying Party を作ってみた - メモ的な思考的な

 
上記の記事では JavaScript で実装していましたが、せっかくなので TypeScript 化することにしました。

ただ、TypeScript 化する中で色々悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Next.js と express-openid-connect を使った Relying Party を TypeScript 化
  • TypeScript化に合わせ、ESLint設定を変更

 

実装しないこと

  • Prettier を入れる

 

構成

Relying PartyをTypeScript化したくらいで、前回と変わりません。

  • macOS
  • Open ID Provider
    • 前回のものを流用
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
  • Relying Party
    • localhost:3784 で起動
    • Next.js 11.1.2
    • express 4.17.1
      • Next.js の Custom Server 機能として使用
    • express-openid-connect 2.5.0
    • redis 3.1.2
    • connect-redis 6.0.0
    • セッションストア: Redis
      • Dockerで作成し、ポート 16380 で起動

 

実装

Next.js を TypeScript 化

公式ドキュメントに従い、Next.js を TypeScript 化します。
Existing projects | Basic Features: TypeScript | Next.js

 
今回はすでに JavaScript で作った Next.js アプリがあるため、 Existing projects に従って作業することにします。

そこで、

  1. tsconfig.json を作成
  2. Next.js を起動

したところ、エラーになりました。

# tsconfig.json を作成する
% touch tsconfig.json


# 起動するとエラーになる
% yarn run dev
...
It looks like you're trying to use TypeScript but do not have the required package(s) installed.

Please install typescript and @types/react by running:

        yarn add --dev typescript @types/react

If you are not trying to use TypeScript, please remove the tsconfig.json file from your package root (and any TypeScript files in your pages directory).

 
エラーメッセージによるとパッケージが足りていないようなので、インストールします。

% yarn add --dev typescript @types/react

 
再度 yarn run dev すると、起動しました。

% yarn run dev                          
...
We detected TypeScript in your project and created a tsconfig.json file for you.

event - compiled successfully
> Ready on http://localhost:3784

 
起動後ディレクトリを見たところ、ファイル next-env.d.ts ができていました。公式ドキュメントによると

A file named next-env.d.ts will be created in the root of your project. This file ensures Next.js types are picked up by the TypeScript compiler. You cannot remove it or edit it as it can change at any time.

https://nextjs.org/docs/basic-features/typescript#existing-projects

のようです。
next-env.d.tsの意味 - Qiita

 

不足しているパッケージの追加と .eslintrc.json の更新

公式ドキュメントの

You're now ready to start converting files from .js to .tsx and leveraging the benefits of TypeScript!.

に従い、 pages ディレクトリにある各コンポーネントtsx 化していきます。

 
ためしに、 404.js404.tsx にしたところ

Error: Failed to load parser '@typescript-eslint/parser' declared in '.eslintrc.json » eslint-config-next/core-web-vitals » path/to/nextjs_relying_party_with_public_page/node_modules/eslint-config-next/index.tsx#overrides[0]': Cannot find module 'typescript'

というエラーが出ました。

そこで、まずは不足しているパッケージを追加します。

# ESLintまわり
% yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

# 使用中のパッケージの型定義を追加
% yarn add -D @types/express @types/redis @types/connect-redis

 
.eslintrc.json を書き換えます。

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "next/core-web-vitals",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ]
}

 

pagesディレクトリの各コンポーネントtsx

準備ができたので、順番に tsx 化していきます。

 

404.tsx

こちらは戻り値の型 JSX.Element を追加します。

export default function Custom404(): JSX.Element {
  return <h1>404 - Page Not Found</h1>
}

 
なお、 JSX.Element は型定義を見ると、

# https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c20bff8d46570278a10ec76102af314d1cdca6e3/types/react/index.d.ts#L3083

declare global {
    namespace JSX {
        interface Element extends React.ReactElement<any, any> { }
// ...

でした。

JSX.ElementReact.ReactElement などの用語は、以下が参考になりました。
Reactのコンポーネント周りの用語を整理する | blog.ojisan.io

 

_app.tsx

こちらは公式ドキュメント通りに変更します。
Custom App | Basic Features: TypeScript | Next.js

import '../styles/globals.css'
import {AppProps} from "next/app";

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
  return <Component {...pageProps} />
}

export default MyApp

 

index.tsx

このファイルでは getServerSideProps を使っています。

そのため、まずは getServerSideProps 部分をTypeScript化します。

getServerSideProps 部分は特に変更していないため、公式ドキュメント通りの型 GetServerSideProps を指定します。
Static Generation and Server-side Rendering | Basic Features: TypeScript | Next.js

import {GetServerSideProps} from "next";

export const getServerSideProps: GetServerSideProps = async function () {
  return {
    props: {
      host: process.env.NEXT_HOST,
      port: process.env.PORT,
    }
  }
}

 
続いて、コンポーネント部分をTypeScript化します。

getServerSideProps で取得した process.env の各値はどんな型かを調べたところ、

# https://github.com/DefinitelyTyped/DefinitelyTyped/blob/6bf1eeabf96a96d292a37797f21961a9003bb719/types/node/process.d.ts#L107

interface ProcessEnv extends Dict<string> {
    /**
      * Can be used to change the default timezone at runtime
      */
    TZ?: string;
}

とありました。

なお、型を再定義したほうが扱いやすいようですが、今回はサンプルなので特に変更しません。

 
型が分かったので、型を定義します。

type Props = {
  host: string
  port: string
}

export default function Home({ host, port }: Props): JSX.Element {
  return (
    <>
      <h1>Index page</h1>
      <a href={`${host}:${port}/profile`}>Go</a>
    </>
  )
}

 

profile.tsx

getServerSideProps の戻り値の型定義

こちらも getServerSideProps の戻り値の型定義から見ていきます。

index.tsx と異なり、

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {
  if (!req.oidc.isAuthenticated()) {
// ...
    if (!res.oidc.errorOnRequiredAuth && req.accepts('html')) {
      await res.oidc.login()
      return {
        props: {
          email: ''
        }
      }
    }
    return {
      notFound: true
    }
  }

// ...

のように、異なる型で値が返ってくるため、 GetServerSideProps が使えなさそうでした。

 
そこで、TypeScriptの合併型 (Union型)にて、戻り値の型を定義します。

type ReturnValues = {
  props: {
    email: string
  }
} | {
  notFound: boolean
}

 
あとは async な関数なので、Promise<T> を使うことで、

export const getServerSideProps = async function ({ req, res }): Promise<ReturnValues> {

となりました。

 

getServerSideProps の引数の型定義

型定義をするため、必要な情報を調べます。

引数 reqres は関数の内部で

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {
  if (!req.oidc.isAuthenticated()) {
// ...

と、 req.oidc.isAuthenticated() のような使い方をしています。

 
reqres の属性 oidcexpress-openid-connect で追加されています。

そこで、express-openid-connect の型定義を見ると、 req

interface OpenidRequest extends Request {
  /**
   * Library namespace for authentication methods and data.
   */
  oidc: RequestContext;
}

でした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L40

 
続けて、 oidcisAuthenticatedOpenidRequest 型の中で定義されていました。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L85

interface RequestContext {
  /**
   * Method to check the user's authenticated state, returns `true` if logged in.
   */
  isAuthenticated: () => boolean;
// ...

 
一方、 res

interface OpenidResponse extends Response {
  /**
   * Library namespace for authentication methods and data.
   */
  oidc: ResponseContext;
}

でした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L61

 
そこで、

import {OpenidRequest, OpenidResponse} from "express-openid-connect";

type Args = {
  req: OpenidRequest
  res: OpenidResponse
}

という型を定義することで

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {

となりました。

 

ちなみに:引数の分割代入に対する型指定をワンライナーで書く場合

ちなみに、

type Args = {
  req: OpenidRequest
  res: OpenidResponse
}

export const getServerSideProps = async function ({ req, res }: Args): Promise<ReturnValues> {}

ワンライナーで書く場合は

expeort const getServerSideProps = async function ({ req, res }: {req: OpenidRequest, res: OpenidResponse}): Promise<ReturnValues> {}

となります。
TypeScript - TypeScriptで関数の引数に分割代入したときの型指定方法|teratail

 

express-openid-connectの型定義 ResponseContext をアンビエント宣言で拡張

ふとコードを見てみると、

と、

TS2339: Property 'errorOnRequiredAuth' does not exist on type 'ResponseContext'.

というエラーが出ていました。

 
ResponseContextの型定義を見てみると、

interface ResponseContext {
  login: (opts?: LoginOptions) => Promise<void>;
  logout: (opts?: LogoutOptions) => Promise<void>;
}

と、 errorOnRequiredAuth がありませんでした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/index.d.ts#L147

ただ、 errorOnRequiredAuthミドルウェア requiresAuth で使っているものを移植しただけなので、型定義に errorOnRequiredAuth を追加しても問題無さそうでした。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/requiresAuth.js#L20

 
そこで、以下を参考にアンビエント宣言で errorOnRequiredAuth を型定義に追加します。

 
なお、以下により、アンビエント宣言だけを行い、 typeRoots での設定は行いません。

 
上記で見た通り ResponseContextinterface だったため、types/express-openid-connect/index.d.ts ファイルを用意し、アンビエント宣言します。

import 'express-openid-connect'

declare module 'express-openid-connect' {
  interface ResponseContext {
    errorOnRequiredAuth? :boolean
  }
}

 
すると、エラーが無くなりました。

 

コンポーネントの型定義

こちらは今まで通りです。

 type Props = {
  email: string
}

export default function Profile({ email }: Props): JSX.Element {
  return (
    <>
      <h1>Your Profile</h1>
      <p>Email: {email}</p>

      <Link href="/" passHref>
        <button>Go Home</button>
      </Link>
    </>
  )
}

 

カスタムサーバの server.js を TypeScript 化

続いて、カスタムサーバ部分を TypeScript 化します。

TypeScript化で影響のあったところは、

  • RedisClientまわり
  • server.listen()時のエラーハンドリングまわり

でした。

後者については、以下を参考に server.on('error') の構文に差し替えました。

import express from 'express'
import { auth } from 'express-openid-connect'
import  next from 'next'
// Redisの設定
import redis from 'redis'
import connectRedis from 'connect-redis'
const RedisStore = connectRedis(auth)

const dev = process.env.NODE_ENV !== 'production'

const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const server = express();
  const port = parseInt(process.env.PORT, 10)

  // Redisクライアントを定義
  // TypeScriptでのエラーが出るため、undefined にならないようにしておく
  const redisPort = process.env.REDIS_PORT || '16380'
  const redisClient = redis.createClient({
    host: process.env.REDIS_HOST,
    port: parseInt(redisPort, 10),
  });

  // express-openid-connectの設定を追加
  server.use(auth({
    issuerBaseURL: process.env.OP_BASE_URL,
    baseURL: `${process.env.NEXT_HOST}:${process.env.PORT}`,
    clientID: process.env.CLIENT_ID_OF_MY_OP,
    clientSecret: process.env.CLIENT_SECRET_OF_MY_OP,
    secret: process.env.SECRET_OF_OIDC,
    authorizationParams: {
      response_type: 'code',
      scope: 'openid',
    },
    session: {
      name: 'sessionOfExpressPublic', // sessionの名前を変える
      store: new RedisStore({client: redisClient}) // セッションストアをRedisにする
    },
    authRequired: false,
  }));

  server.all('*', (req, res) => {
    return handle(req, res)
  })

  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`)
  }).on('error', function(e) {
    console.log('Error happened: ', e.message)
  })
})

 

tsconfigの strict を true にした時の対応

ここまでは tsconfig の compilerOptions において、 strictfalse でした。

厳密にやっても問題ないだろうと考え、 stricttrue にしたところ、 server.ts にて以下のエラーが出ました。

 
エラーになった箇所ですが、 express-openid-connect の example からそのまま持ってきている実装でした。
https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md#9-use-a-custom-session-store

また、リポジトリを見るとissueが上がっていました。
TypeScript errors when defining custom session store · Issue #234 · auth0/express-openid-connect

今後改善される可能性があるため、今回は TypeScript 化をあきらめました。

そのため、

session: {
   // ...
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  store: new RedisStore({client: redisClient}) // セッションストアをRedisにする
},

のようにエラーを無視するようにしました。

 
以上で TypeScript 化は終わりました。

 

TypeScriptのままで動作させる

今の状態でコンパイルして Next.js を動作するようにもできますが、ローカルで開発するのには手間なので、TypeScript のままで動作させたくなりました。

方法を探したところ、

  • ts-node
  • ts-node-dev
  • nodemon

あたりが見つかりました。

 
ただ、 ts-node-dev を使ってみたところ、

TypeError: Unexpected response from worker: undefined
    at ChildProcessWorker._onMessage (path/to/nextjs_relying_party_with_public_page/node_modules/jest-worker/build/workers/ChildProcessWorker.js:264:15)
    at ChildProcess.emit (events.js:327:22)
    at emit (internal/child_process.js:903:12)
    at processTicksAndRejections (internal/process/task_queues.js:81:21)
[ERROR] 15:42:30 TypeError: Unexpected response from worker: undefined

というエラーが発生しました。

 
ts-node-devリポジトリを見ると、同じ現象のissueがありました。
tsnd fails to run jest-worker with enableWorkerThreads: false · Issue #255 · wclr/ts-node-dev

issueのコメントに

The only solution that I found is to use node-dev directly. Fortunately, they introduced Typescript support out of the box so I don't need ts-node-dev anymore.

とあったため、TypeScript化した Next.js では使うのが難しいのかなと思いました。

 
ここで、改めてTypeScriptを使う公式サンプルを見たところ、 ts-node + nodemon を使っていました。
https://github.com/vercel/next.js/blob/canary/examples/custom-server-typescript/package.json

 
そこで今回は Next.js のサンプルや以下の記事を参考に、ts-node + nodemon で書いてみます。
Next.jsをExpressカスタムサーバ + TypeScriptで動かす設定 - Qiita

 

tsconfig.server.json の追加

参考 Qiita のままで問題ないです。

{
  "extends": "./tsconfig.json", // tsconfig.jsonの設定を継承する
  "compilerOptions": {
    "module": "commonjs", // Next.jsとExpressの両方を連携させるために、commmonjsを利用する
    "outDir": "dist", // ビルドファイルの出力先
    "noEmit": false // Next.jsはBabelを使用してTypeScriptをコンパイルするので、TSコンパイラはjsを出力しない。設定を上書きする。
  },
  "include": ["server"] // TSコンパイラにserverディレクトリのみをコンパイル対象として認識させる。
}

 

nodemon.json の追加

こちらは公式サンプルの通りです。

{
  "watch": ["server"],
  "exec": "ts-node --project tsconfig.server.json server/index.ts",
  "ext": "js ts"
}

 

package.json の修正

nodemon で動くよう、 scriptsdev を修正します。

{
  "name": "nextjs_relying_party_with_public_page",
// ...
  "scripts": {
    "dev": "nodemon",
// ...

 

動作確認

yarn run dev し、今まで通り http://localhost:3784/ へアクセスできればOKです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

 
今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/7

Next.js と express-openid-connect を使って、認証が必要/不要な各ページを持つ Relying Party を作ってみた

前回、Next.js + express-openid-connect を使って、全てのページでOpenID Connectによる認証が必要な Relying Party を作りました。
Next.js + express-openid-connect を使って、バックエンドで OpenID Provider と通信する Relying Party を作ってみた - メモ的な思考的な

そんな中、上記の Relying Party に認証が不要なページも追加してみたところ、いくつか悩んだところがあったため、メモを残します。

 
なお、「この実装で良い」と言い切れない部分があるので、もしより良い実装があれば教えていただけるとありがたいです。

 
目次

 

環境

実装すること

  • ベースは、前回作成した RP
  • 認証が必要なページと不要なページを用意
    • /
      • 認証不要
    • /profile
      • 認証必要

 

構成

  • macOS
  • Open ID Provider
    • 前回のものを流用
    • localhost:3780 で起動
    • Rails 6.1.4
    • doorkeeper 5.5.2
    • doorkeeper-openid_connect 1.8.0
    • devise 4.8.0
  • Relying Party
    • localhost:3784 で起動
    • Next.js 11.1.2
    • express 4.17.1
      • Next.js の Custom Server 機能として使用
    • express-openid-connect 2.5.0
    • redis 3.1.2
    • connect-redis 6.0.0
    • セッションストア: Redis
      • Dockerで作成し、ポート 16380 で起動

 

失敗

express-openid-connect の 例を見ると、 requiresAuth() を使えば 認証が必要/不要なページを制御できそうでした。
2. Require authentication for specific routes | express-openid-connect/EXAMPLES.md at master · auth0/express-openid-connect

 
そこで、 server.js に対し、

app.prepare().then(() => {
  const server = express();
  // ...
  server.use(auth({
    // ...
    // 以下を追加し、指定したルートで認証の要不要を指定
    authRequired: false,
  }));

  // `/` は認証不要
  server.get('/', (req, res) => {
    return handle(req, res)
  })

  // 上記以外は認証必要
  server.all('*', requiresAuth(), (req, res) => {
    return handle(req, res)
  })

として、Next.js を起動しました (このコミット)。

 
すると、 / にアクセスした時に

のようなエラーがChromeのConsoleに表示されました。

 
エラーは

# まとめあげ
Refused to execute script from '<URL>' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.

# 詳細
Refused to execute script from 'http://localhost:3780/users/sign_in' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.

であり、ワーニングは

# まとめあげ
Cross-Origin Read Blocking (CORB) blocked cross-origin response <URL> with MIME type text/html. See <URL> for more details.

# 詳細
Cross-Origin Read Blocking (CORB) blocked cross-origin response http://localhost:3780/users/sign_in with MIME type text/html. See https://www.chromestatus.com/feature/5629709824032768 for more details.

でした。

 
また、OPのログを見てみると、 / を開いた瞬間に、OPの認可エンドポイントへリクエストが飛んでいました。

Started GET "/oauth/authorize?
client_id=***
&scope=openid
&response_type=code
&redirect_uri=http%3A%2F%2Flocalhost%3A3784%2Fcallback
&nonce=***
&code_challenge_method=S256&code_challenge=***"
 for 127.0.0.1 at 2021-09-09 21:04:39 +0900

 
一方、 /profile へ直接アクセスすると、OPのログイン画面が表示されました。ログイン後は //profile にアクセスしても問題ありませんでした。

 

対応

今回、 express-openid-connectミドルウェアとして express に組み込んでいるため、ミドルウェアまわりの処理がうまくいっていないように見えました。

そこで、 requiresAuth() を使うのではなく、 requiresAuth() 相当の処理を Next.js の getServerSideProps へ実装することにしました。

なお、Next.js のドキュメントには

Note: You can import modules in top-level scope for use in getServerSideProps. Imports used in getServerSideProps will not be bundled for the client-side. This means you can write server-side code directly in getServerSideProps. This includes reading from the filesystem or a database.

https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering

とあったため、今回は /profile のページの getServerSideProps に直接書くこととしました。

 

req.oidc.isAuthenticated の追加

express-openid-connectソースコードを読むと、 req.oidc.isAuthenticated にて認証済か否かを判断していました。

 
そこで、

import Link from 'next/link';

export const getServerSideProps = async function ({ req, res }) {
  // requireAuth() の中身を移植
  if (!req.oidc.isAuthenticated()) {
    return {
      redirect: {
        // 途中のエラーが表示される
        destination: '/login',
        permanent: false
      },
    }
  }

  // 以降は同じ
  return await req.oidc.fetchUserInfo()
    .then(response => {
      return {
        props: {
          email: response.email
        }
      }
    })
}

export default function Profile({ email }) {
  return (
    <>
      <h1>Your Profile</h1>
      <p>Email: {email}</p>

      <Link href="/" passHref>
        <button>Go Home</button>
      </Link>
    </>
  )
}

と、認証されていない !req.oidc.isAuthenticated 時は Next.js でログインページへリダイレクトするようにしてみました (このコミット)。

 
すると、 / にアクセスしてもエラーが表示されなくなりました。

続いて /profile へ遷移するボタンをクリックしたところ、ログインページは表示されるようになったものの、一瞬エラーページが表示されました。

Chrome の Network を見てみると、HTTP 404 となっていました。

 
詳細を確認すると、クライアントサイドでルーティングしたことにより、 HTTP 404 が表示されているようでした。

 
そこで、サーバサイドでルーティングするよう、ホストとポートを指定してみました (このコミット)。

export const getServerSideProps = async function ({ req, res }) {
  if (!req.oidc.isAuthenticated()) {
    return {
      redirect: {
        // destination: '/login',
        // Next.js の外としてルーティング
        destination: `${process.env.NEXT_HOST}:${process.env.PORT}/login`,
        permanent: false
      },
    }
  }
// ...

 
すると、クライアントサイドではルーティングされなくなり、HTTP 404 ページも表示されなくなりました。

 
ただ、 /profile へ遷移した後にログインしたのに、ログイン後は / に戻されてしまいました。

 

res.oidc.login の追加

express-openid-connectソースコードをさらに読むと、 res.oidc.login を使ってログイン処理を行っていました。
https://github.com/auth0/express-openid-connect/blob/v2.5.0/middleware/requiresAuth.js#L20

 
そこで、

export const getServerSideProps = async function ({ req, res }) {
  if (!req.oidc.isAuthenticated()) {
    // 追加
    if (!res.oidc.errorOnRequiredAuth && req.accepts('html')) {
      await res.oidc.login()
      return {
        props: {
          email: ''
        }
      }
    }
    return {
      notFound: true
    }
  }

と、 res.oidc.login を使うようにしました。

また、 !res.oidc.errorOnRequiredAuth && req.accepts('html') を満たさない場合は HTTP 404 ページを表示させるため、Next.js 10 で追加された notFound を使うようにしました。
notFound Support | Blog - Next.js 10 | Next.js

それに合わせて、カスタムの 404 ページを pages/404.js として用意しました(ここまででこのコミット)。
Customizing The 404 Page | Advanced Features: Custom Error Page | Next.js

export default function Custom404() {
  return <h1>404 - Page Not Found</h1>
}

 
準備ができたため、

  • / へアクセス
  • Go Profile ボタンをクリック
  • OPのログイン画面でログイン

の順で操作したところ、ログイン後は /profile へと遷移するようになりました。

 
ただ、Chrome の Network タブを見たところ

と、 CORS error: Cross-Origin Resource Sharing error: MissingAllowOriginHeader エラーが表示されていました。

 

クライアントサイドでの /profile への遷移をあきらめる

デバッグしてみたところ、 res.oidc.login() の処理後に CORS エラーが出ていました。

とはいえ、res.oidc.login() の削除もできません。

そこで、pages/index.js の Link コンポーネントによるクライアントサイドでの遷移を、 <a> タグでの遷移に切り替えました (このコミット)。
Linking between pages | Routing: Introduction | Next.js

export const getServerSideProps = async function ({ req, res }) {
  return {
    props: {
      host: process.env.NEXT_HOST,
      port: process.env.PORT,
    }
  }
}


export default function Home({ host, port }) {
  return (
    <>
      <h1>Index page</h1>

      {/* クライアントでのルーティングができないので、aタグに差し替え */}
      <a href={`${host}:${port}/profile`}>Go</a>
    </>
  )
}

 
再度実行したところ、CORSエラーが出なくなりました。

 
これで、認証が必要/不要な各ページを持つ Relying Party が実装できました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample

今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/6