Claude Code、jaghaウェブポータルをReactに移植する(2)― 本番切り替えと、自分が残した落とし穴

前編では、jaghaウェブポータルをリファクタリングではなく「土台から作り直して移植する」方針に切り替えた経緯と、移植中に見つかった旧実装の不具合たちについて書きました。後編では、全コンテンツの移植が終わったあとの本番切り替えと、そこで起きたトラブルについて振り返ります。

本番切り替え

全コンテンツの移植とユーザーによる確認が一通り終わったところで、jaghaホストへの本番切り替えを実施しました。メモリ2GBというVPSの制約もあり、frontendイメージはローカルでビルドして`docker save`/`docker load`で転送し、backendは軽量なのでVPS上で直接ビルド、という手順です。旧`fastapi0`を止めて、新しい`fastapi1`にドメインを向け替えて完了…のはずでした。

本当の試練はその後に来た

ところが後日、「サインアップ確認メールが送信できない」という報告が入りました。調べてみるとbackendのログに、SMTP通信のデバッグログ(`smtplib`の`set_debuglevel(True)`)がそのまま残っていて認証情報が丸見えという、それ自体が結構まずい状態だったので、まずそこを削除。その上で本題を追うと、原因は二段構えでした。一つは、何度も認証に失敗させたせいでメールサーバー側(fail2ban)がbackendコンテナの送信元IPをBANしていたこと。もう一つ、そしてこちらが本質的な原因なのですが、`.env`に設定されていたSMTPアカウント名が実在しないものだったことです。

このアカウント名、実はfastapi1を最初に構築したときに、私(Claude Code)がサンプルとして書いたプレースホルダーがそのまま本番の`.env`に残っていたものでした。移植そのものより、自分が最初に残した「とりあえずの値」が一番の落とし穴だったというオチです。以来、こういう設定値は「もっともらしい実在しそうな値」を書かず、誰が見ても仮の値だとわかる書き方にするよう気をつけるようにしています。

おわりに

トップページから始まって、履歴、ナビ、認証、管理画面、ギャラリー、i18n、WASM/three.jsのお遊びページまで一通り移植し、本番切り替え後の細かいトラブル(メール送信、フッターの復元、ソースマップの公開など)にも対応して、ようやく一区切りになりました。旧`fastapi0`のコンテナ・イメージも役目を終えて片付けています。

「リファクタリングで安全に少しずつ」ではなく「土台を作って外から丸ごと移植する」というアプローチは、今回のような複雑に絡み合った旧アプリに対しては結果的に正解でした。ただし移植のたびに旧実装のバグや設計ミスを掘り当てることになったので、単純な「コピー作業」では全く済まなかったのも事実です。移植とはコードを右から左に運ぶことではなく、コードを読み直して裏側の前提ごと作り直すことなんだな、という当たり前のことを、今回はかなり実感を持って学びました。

ポータルのReact化は「暇が出来たらやろう!」と思って数年放置してたら、時代は既に生成AI!・・・例によって全部AIにやらせる実験と称して、「調べない」「頭使わない」ヤバいエンジニアになりつつあるw

Claude Code、jaghaウェブポータルをReactに移植する(1)― リファクタリングをやめて、土台から作り直す

jaghaホストで動いていたウェブポータル(旧`fastapi0`)を、React化して作り直しました。もともとは「既存コードをリファクタリングしてReact化しよう」という計画だったのですが、途中で方針転換して「土台だけ新しく作って、コンテンツを一つずつ移植する」というアプローチに切り替えています。前編では、その方針転換の経緯と、移植中に見つかった旧実装の不具合たちについて振り返ります。

リファクタリングを諦めた話

最初の計画は、既存のfastapi + TypeScript + Bootstrapのアプリを、内部からじわじわReact化していくというものでした。でも実際にコードを眺めてみると、テンプレートとロジックとルーティングが結構複雑に絡み合っていて、「リファクタリングしながら安全に置き換える」ための工数が、単純に「新しい土台を作って移植する」工数を上回りそうだと判断しました。

そこで方針転換。`fastapi1`というディレクトリに、backendはfastapi+SQLAlchemyをほぼそのまま、frontendはReact(TypeScript)+Bootstrap5+Viteでフルスクラッチという構成を新設し、旧サイトのコンテンツを機能単位で移植していく、という進め方にしました。backendの各種ルーター(admin/session/database/common/test)は、もともとHTMLテンプレートに依存しない素のJSON APIだったので、ほぼそのまま持ってこられたのは幸運でした。

旧構成と新構成、何が変わったのか

言葉だけだと分かりにくいので図にしてみました。

旧構成(fastapi0)と新構成(fastapi1)の比較図

旧構成(fastapi0)は、1つのFastAPIアプリが「Jinja2テンプレートでHTMLを組み立てる」役目と「JSON APIを返す」役目を両方兼任していました。画面を作っているTypeScriptも、Viteでビルドしたものをそのテンプレートに埋め込んで使う、という密結合な形です。

新構成(fastapi1)では、この2つの役目をコンテナごと分離しました。frontendコンテナ(nginx)はReactでビルドした静的ファイルを配るだけの存在になり、APIっぽいパスだけをbackendコンテナへ転送します。backendは逆にJSON API専門になり、HTMLは一切返しません。ブラウザから見ると同じ1つのサイトですが、中身は「見た目を作る係」と「データを扱う係」がきっちり分かれた形です。外側の`nginx-proxy`(jaghaホスト共通の外部リバースプロキシ)は旧・新どちらも変わらず同じものを使っています。

移植は「発掘」の連続だった

トップページの静的コンテンツから始まって、履歴表示、共通ナビ、ログイン/サインアップ、履歴編集(EditorJS)、ギャラリー、ユーザー/グループ管理、i18n、そしてキャンバス/WASMのお遊びページまで、10フェーズ近くに分けて一つずつ移植していきました。各フェーズごとにユーザーにブラウザで確認してもらいながら進める、という地道なやり方です。

ただの移植作業のはずが、実際にやってみると「発掘」に近い体験でした。旧コードを一行ずつReactに書き直す過程で、これまで誰も気づいていなかった不具合がぼろぼろ出てくるのです。いくつか印象に残っているものを挙げると、

  • historycontentsのcreate/update/delete APIに、そもそも認証チェックが一切無かった(誰でも記事を作成・削除できる状態)
  • グループ削除処理で「adminグループは消せない」というチェックが、削除処理の後に書かれていて実質機能していなかった
  • Pydantic v1→v2のデフォルト値の意味論の違いで、`SchemaUpdate`系のモデルにデフォルト値(`= None`)が付いていない箇所が複数あり、部分更新リクエストが常に422エラーになっていた(ギャラリーとグループ管理の2箇所)
  • 日本語ロケールに`menu`名前空間が丸ごと欠落していて、ナビのi18nキーがずっと未解決のまま表示されていた

どれも「今まで動いていたように見えて、実は静かに壊れていた」タイプの不具合で、移植という名目でコードを全部読み直したからこそ見つかったものばかりです。中でも一番手強かったのは、履歴編集パネル(EditorJS)で「新規作成ボタンを押しても反応しない」→ 直したつもりが「今度は本文欄に文字が入力できない」というバグでした。深追いした結果、EditorJS本体の実装まで読みにいく羽目になり、最終的な原因は「Reactの開発モード(StrictMode)がeffectを2回実行することと、EditorJSの初期化が非同期(Promiseが解決してから`destroy`が生える)であることが噛み合って、新旧2つのインスタンスがDOMを取り合う競合状態になっていた」という、なかなか渋いものでした。初期化と破棄を1本のPromiseチェーンに直列化して解決しています。

後編では、全コンテンツの移植が終わったあとの本番切り替えと、そこで起きたちょっとしたトラブルについて書きます。

文章書くの相変わらず上手いよな~。と思って読み進むとやたらに前の構成をDISってくるのがムカつくなぁw
「AI視点で自虐的なネタ的な」とかいう指示なんだけど、自虐の意味を取り違えてるよね!「完全にオレ視点の自虐になってる」という愚痴w

週間アラーム(早速のTauriの習作アプリケーション)

今更の話だけど、コロナ禍でリモートワークなど自分も含めて自宅で活動することが多くなり、自宅などでも適切なタイムスケジュールでメリハリを付ける必要が増えてきたと思います。

そのためには会社や学校と同じように指定した時間割でアラーム(チャイム)を鳴らす設備があると便利かと思い、既存のアプリケーションを探して使用しておりました(スマホ/タブレット用アプリ)。

使用したアプリはスケジューリングした指定時刻に好きな音源を鳴らすことが出来て便利だったが、カレンダーとの関連が無く、日祝日など休みの日にもチャイムが鳴ってしまうのでその度にOFFにするのが面倒でした。

ならば作ってみようネタにしようということで、構想から(・・・というのが10月くらいのお話)。

  1. 平日<休日<曜日個別<祝祭日<個別指定日 の優先度でスケジューリングしたアラームタイムテーブルで運用
  2. アプリケーションの見た目は時計表示機能をもつGUIでスケジューリング等の設定を行う
  3. デフォルト音源(フリーの音源素材)のほか任意のMP3音源をアラームとして使用可能
  4. ターゲット機器はPC。ただしアラーム用に占有出来るよう安価なもの

4のターゲット機器は、安価なものとしてRasberryPiにUSBスピーカーを接続した構成とした(今となってはRaspberryPi自体が入手困難な貴重なデバイスですが・・・w)

ターゲットはLinuxだけど、GUIなどデバッグはMacOSでやりたいのでマルチプラットフォームGUIのTauriが最適ということで、アプリケーションフレームワークとしてTauriを採用という、まさに取って付けたような結論ありきの流れ。。

TauriであればフロントエンドはWebと同等なので3の音源再生も造作も無しと。

で、一通りの機能を実装して完成したアプリケーションが左のレポジトリ。MacOSでの実行画面が下記になります。折角Bootstrapを使用したフロントエンドなのにこのデザインセンスよwww。とりあえずは実用的には動けばいいや的なものになっています。

スケジュール管理画面
タイムテーブル編集状態

アラームスケジュールの設定は左記のように、デフォルトで平日<休日といった優先度の低い日程が定められており、ユーザーはスケジュールを特に変更したい日を個別に追加する。

それぞれのスジュール日程に対してアラームセットと呼ばれるタイムテーブルを設定する。アラームセットは個別データとなっているので、別のスケジュール日程に同じアラームセットを適用することが可能(左下画面)。

画面は省略するが、祝祭について、2023年まではデフォルト設定してあるが、それ以降はユーザーで追加する(祝祭日でーたをCSVファイルで一括登録も可能)。

しかし、さすがに工夫無しで使用するとUIがWEBアプリのそのまんまになってしまいますね。もう少しデスクトップアプリっぽくしたかったのですが。。。

あと、マルチプラットフォームGUIで苦労した所として、Linux環境だけ音源再生出来ない問題がありました。

もともとWEBアプリでは、ページロード後にユーザー操作無しには音声再生出来ないような仕組みになっている(広告アプリなどが勝手に大音量で鳴らし始めたら大変なので)ので、同じエンジンを使用しているTauriでも初期には同様の問題があったらしいが、デスクトップアプリでそれでは不便だろうとのことで対策されているはず?なので、本家GithubのDiscussionに質問を投げているが、イマイチ反応は無いですねーΣ(゚д゚lll)

一応自前で解決しているが、何か原因が良く分からない解決方法でした(笑)

これをキッカケに本家のGithubに爪痕でも残せたら、、、と思ってたけど。うーん高い壁なのかなw
(機械翻訳のいい加減な英文投稿でうまく伝わらなかったのかもwww)

Tauriアプリケーション開発メモ(Webpack+Typescript+React)

いままで覚えるのが面倒とかいう理由でフロントエンドのフレームワークを使用せずに素のTypescriptでゴリゴリ書いていたが、そろそろ限界というかネタ不足と言うことで、TauriアプリケーションにおいてReactをフロント側のフレームワークとして使用することにしました。

ということで、Typescript+Reactをフロントエンドに使用したTauriアプリケーション開発準備メモ。

以下のような手順でアプリケーションを作成します。

  1. プロジェクトディレクトリの作成
  2. フロントエンドの準備
  3. Tauri (Rust)プロジェクトの作成
  4. コンパイル

プロジェクトディレクトリの作成

アプリケーション名に準ずるディレクトリを作成して、移動します。

フロントエンドの準備

npmの初期化を行い、Webpack、Typescript、 Sass、Reactをインストールします

$ npm init -y
$ npm install -D webpack webpack-cli webpack-dev-server
$ npm install -D typescript ts-loader sass style-loader css-loader postcss-loader sass-loader
$ nom install -D babel-loader @babel/core @babel/preset-env
$ npm install -S react react-dom @types/react @types/react-dom
$ npm install -D @babel/preset-react

package.jsonに デバッグ/ビルド script を追加

scripts: {
    "build": "webpack",
    "dev": "webpack serve"
},

typescriptの設定初期化

$ npx tsc --init --target es6 \
                --module es2022 \
                --lib es2022,DOM \
                --sourceMap true \
                --moduleResolution node \
                --allowSyntheticDefaultImports true \
                --resolveJsonModule true \
                --jsx react

webpack.config.js作成

webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, "dist"),
    filename: "main.js",
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env', '@babel/react']
            },
          },
          {
            loader: 'ts-loader',
            options: {
              configFile: path.resolve(__dirname, 'tsconfig.json'),
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [{
          loader: 'style-loader'
        }, {
          loader: 'css-loader'
        }, {
          loader: 'postcss-loader',
        }, {
          loader: 'sass-loader'
        }]
      },
      {
        test:/\.SVG$/,
        type: 'asset/source',
      },
      {
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist'),
    },
    port: 3000,
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  target: 'web',
};

ソースディレクトリ(src)と生成物ディレクトリ(dist)の作成とテンプレ作成

dist/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tauri App Title</title>
  <script async type="module" src="main.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';

new Promise(resolv => window.onload = resolv).then(() => {
    const root: ReactDOM.Root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(
        <div>sample contents</div>
    );
});

Tauriプロジェクトの作成

$ cargo tauri init

ターゲットディレクトリを../distとするほか、フロントエンドが先に作成されているのでデフォルト通りでOK。

コンパイル&デバッグ

$ cargo tauri dev

その他UI構築にbootstrapを使いたい場合は
$ npm install -D bootstrap @types/bootstrap react-bootstrap @types/react-bootstrap
と、当然Tauri APIを使用するなら
$ npm install -D @tauri-apps/api
など・・・・とな。

TauriによるRustデスクトップアプリケーション開発

Webpack(Typescript、Sass)フロントエンド開発環境

Rustと関連クレートをインストールしたら、アプリケーションプロジェクトを作成します。

通常のRustプロジェクトであれば、cargo init [project name]とするのですが、Tauri開発環境ではフロントエンドとの同時進行開発となるので、まずはプロジェクトのトップディレクトリの作成から

$ mkdir [project name]
$ cd [project name]

として作業を始めます。ここではプロジェクト名を単純に「proj」としておきます。

次に下記コマンド cargo tauri init で対話的にプロジェクトを作成します。

  • プロジェクト名
  • タイトル
  • フロントエンドのアセットディレクトリ。生成されるRustプロジェクトからの相対パスとなるので、../dist の様にする。
    この中にあるindex.htmlがフロントエンドデザインの起点となる
  • 開発用サーバーの設定。上記アセットと同じパスにしても良いが、本番構成と同じTauiの内部サーバーを使用するので毎回コンパイルが必要となるため、開発用サーバーを立ち上げてそのURLを記載する方が便利かも。(後述のwebpack-dev-server)を使用するのが良いと思います。
proj$ cargo tauri init
What is your app name? · [project name]
What should the window title be? · [project name]
? Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that w
Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created? · ../dist
What is the url of your dev server? · ../dist または http://localhost:8080

プロジェクト名その他は後からでも変更可能のようです。(src-tauri/tairu.conf.json)

設定したアセットディレクトリと、その中にindex.htmlを作成します。内容はとりあえずテンプレート的な感じで。(VScodeなら1文字目に’!’を入力して[tab]キーでドーンとテンプレートが入力されます)

proj$ mkdir dist
proj$ touch dist/index.html
proj
├── dist
│   └── index.html
└── src-tauri
    ├── Cargo.lock
    ├── Cargo.toml
    ├── build.rs
    ├── icons
    │   ├── 128x128.png
    │   ├── ........
    │   ├── 32x32.png
    │   ├── icon.icns
    │   ├── icon.ico
    │   └── icon.png
    ├── src
    │   └── main.rs
    └── tauri.conf.json		

ここまでのディレクトリ構成は左のようになります。

src-tauri以下がRustのプロジェクトです。dist以下がフロントエンドを構成するアセットディレクトリで、ここからWEBアプリと同様なノリで進めることが出来ます。

ちなみにこの段階で、tauriアプリとしてコンパイル&実行が出来ます。

proj$ cargo tauri dev

しかし、まだ真っ白なウインドウだけのアプリですが・・・・w

また、プロジェクト初期化の段階で開発サーバーにhttp://localhostとか設定していた場合は、まだサーバーを立ち上げていないので何も起こりません。

ここからフロントエンドの開発環境を整えます。まずはプロジェクトのトップディレクトリでnodejsの初期化と必要なものを色々とインストールします

proj$ npm init -y
proj$ npm install --save-dev webpack webpack-cli webpack-dev-server
proj$ npm install --save-dev typescript sass ts-loader sass-loader css-loader
proj$ npm install --save-dev postcss-loader style-loader
proj$ npm install --save-dev @tauri-apps/api @tauri-apps/cli

片っ端からインストールしている感じがしないでもないですが、これだけあれば足りるでしょうか。

次にTypescriptの初期化。この辺は好みがあるかもなので、テキトウに。。。

proj$ npx tsc --init --target es6 --module ES2020 --lib ES2020,DOM --sourceMap true --moduleResolution node --allowSyntheticDefaultImports true --resolveJsonModule true

webpack.config.jsも通常のWEBアプリと同様の設定としました。

webpack.config.js

const path = require("path");
module.exports = {
  mode: "development",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader"
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader'
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: function () {
                  return [require('autoprefixer')];
                }
              }
            }
          },
          {
            loader: 'sass-loader'
          }
        ]
      },
      {
        test:/\.xml$/,
        loader: 'xml-loader',
      },
      {
        test:/\.html$/,
        loader: 'html-loader',
      },
      {
        test:/\.SVG$/,
        //loader: 'svg-inline-loader'
        type: 'asset/source',
      },
      {
        // https://ics.media/entry/16329/
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        // type: 'asset/inline',
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist')
    },
    port: 8080,
  }
}

webpackはデフォルトではソースはsrc/index.js、生成物はdist/main.mjs(モジュール版)となっているので、上記のファイル構成に対してトップへsrcディレクトリを追加します

proj$ mkdir src
proj$ touch src/index.js

そしてdist/index.htmlに埋め込むために次の一行をHEADの最後に追加

  <script type="module" src="./main.mjs"></script>

package.jsonに実行環境のスクリプトを追加

  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
  },

src-tauri/tauri.config.json を変更

  • beforeBuildCommand : Build前に実行されるコマンド、この場合Webpackコンパイルのみ
  • beforeDevCommand:デバッグ時に事前に実行されるコマンド。webpack-dev-server起動、webpackコンパイルもリアルタイムに行われる。
  • devPath:webpack-dev-serverに置き換える。これによりデバッグ状態でもフロントエンドの編集がリアルタイムに反映されるようになる
  "build": {
    "beforeBuildCommand": "npm run build",
    "beforeDevCommand": "npm run dev",
    "devPath": "http://localhost:8080/",
    "distDir": "../dist"
  },

しかし、実際のデバッグはVScode上で行われるので、.vscodeディレクトリと各種設定を行う(VScodeのデバッグでは上記tauri.config.jsonは使われないようです)

proj
├── .vscode
│   ├── launch.json
│   └── tasks.json
└── src-tauri
・・・・・
.vscode/*

launch.json

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Development Debug",
        "cargo": {
          "args": [
            "build",
            "--manifest-path=./src-tauri/Cargo.toml",
            "--no-default-features"
          ]
        },
        // task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:dev"
      },
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Production Debug",
        "cargo": {
          "args": ["build", "--release", "--manifest-path=./src-tauri/Cargo.toml"]
        },
        // task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:build"
      }
    ]
  }

tasks.json

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
      {
        "label": "dist:dev",
        "type": "shell",
        // `dev` keeps running in the background
        // ideally you should also configure a `problemMatcher`
        // see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
        "isBackground": true,
        // change this to your `beforeDevCommand`:
        "command": "yarn",
        "args": ["dev"],
      },
      {
        "label": "dist:build",
        "type": "shell",
        // change this to your `beforeBuildCommand`:
        "command": "yarn",
        "args": ["build"],
      }
    ]
  }

コレで、VScodeで開発・デバッグが出来るようになりました。

フロントエンドの開発が殆どWEBアプリのそれと同じなのが良いですね。ていうか、特にバックエンドの要らないアプリだとjavascriptだけでデスクトップアプリが出来てしまうという。逆に殆どをRsutによるバックエンドだけにしてもアプリケーションを構成出来たり、アプリの用途や開発者の好みでどちら寄りにも出来てしまいそうなのが面白い。

TauriによるRustデスクトップアプリ開発環境構築メモ

Rustのインストール

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 全てデフォルトで作成するので下記のように、フルオートでインストール出来る
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# 下記コマンドでパスを設定
$ source $HOME/.cargo/env

Cargoで必要なモジュールをインストール

$ cargo install cargo-generate
$ cargo install cargo-update
$ cargo install cargo-edit

VScodeなどによるデバッグ用に下記ツールをインストール

$ rustup component add rls rust-analysis rust-src

ツールのアップデート

$ rustup update
$ cargo install-update --all

Tauri関連のインストール

$ cargo install kauri-cli
$ npm install --save-dev @tauri-apps/cli

とりあえずこんな感じで、MacOS、Windows、Linux(Ubuntu)でやってみました。次は簡単なアプリケーションを作成してみる

Rustアプリケーション開発(Tauri)

以前の記事でチョットだけ触れたRsut言語ですが、チェックしていたTauri(デスクトップアプリケーション開発フレームワーク)が6月16日にバージョン1.0で正式公開されたのを機に、当方でもアプリ開発のツールとして導入することにしました。

Rustのデスクトップアプリと言うか、その構成はWEBアプリに近く、Rust作成するバックエンドコアを軸にUIなどのフロントエンドは各OSが持つブラウザエンジンが担当する。従ってフロントエンドはブラウザアプリと同様に主にHTML5で書くことになり、さらに同じソースファイルでマルチプラットフォームを実現するというコンセプトだそうです。

現在対応中のプラットフォームは

  • Windows(7以降)
  • MacOS
  • Linux

で、その他AndroidやiOSへの対応もすすんでいるという(こちらが導入の目的だったりする)。

自分の触手が動いたのは上記のマルチプラットフォームが第1に上げられるが、その他

  • フロントエンドの開発がWEBアプリのそのままで、Webpack、Sass、Typescriptなどの環境がそのまま使え、あまり新しいことを覚える必要が無い
  • 最新の言語Rustで開発出来る事。これが目的の人には待望の環境かも知れないですね。

ということで久々なのに短めですが、次回よりまた開発環境の構築などからメモしていこうと思います。

ES6とDOMの件:Typescript編

小さな規模のアプリしか作らないこともあり、フロントエンドに関しては頑なにフレームワークを使わない勢の私は、FastAPI、webpack、wasm(Rust)までに手を出しつつも、未だに素のTypescriptで何とかしようとしている(Typescriptの段階で「素」では無いかも知れないが)。

とくにDOMに関しては以前の記事にあるようなヘルパ関数を使用して、比較的構造的に組み立てられる仕組みを自作し、利用している。下記のように作成・追加したエレメントを編集(属性や子要素を追加する)する関数で、引数の関数の中でさらに要素を追加して属性設定やさらに子要素追加するなど、入れ子構造でコーディング出来る。

/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
 HTMLElement.prototype.editElement = function(func) {
  func(this);
  return this;
} 
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function(child) {
  if(child instanceof HTMLElement){
    this.appendChild(child);
  } else if (typeof(child) === "string") {
    this.append(child);
  }
  return this;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function() {
  this.innerHTML = "";
  return this;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  this.appendChild(elm);
  return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  return elm;
}

  let body = document.querySelector('article').clearElement();
  body.editElement(bdy => {
    bdy.className = 'bg-secondary';
    bdy.appendElement('p', para => {
      para.style.color = 'red';
      para.textContent = 'test string';
    });
  });

⇒

<article class="bg-secondary">
  <p style="color: red;">
   test string
  </p>
</article>

これをTypescriptに移植するのは結構面倒だったので、今回のメインネタにしました。究極には全部anyの型にして押し切ってしまえば動くと言えば動くんですが、せっかくならしっかり作ってエディタサポートの恩恵も得られて何ならjavascriptよりも使いやすくしてしまおうということで下記のように移植しました。

/**
 * インターフェース定義:組込みのHTMLElementやDocumentに追加するために declare global する
 */
declare global {
    interface HTMLElement {
        appendElement<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement
        editElement<T extends HTMLElement>(func: (elem: T) =>  void): T
        appendChain<T extends HTMLElement>(child: HTMLElement | string): T
        clearElement<T extends HTMLElement>(): T
    }
    interface Document {
        createElementEdit<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K]
    }
}
/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
HTMLElement.prototype.editElement = function<T extends HTMLElement>(func: (elem : T) => void) : T {
    console.log(this.constructor.name)
    func(this as T);
    return this as T;
}
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function<T extends HTMLElement>(child: HTMLElement | string) : T {
    if(child instanceof HTMLElement){
        this.appendChild(child);
    } else if (typeof(child) === "string"){
        this.appendChild(document.createTextNode(child));
    } else {
        throw new TypeError("parameter node: must be string or HTMLElement.");
    }
    return this as T;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function<T extends HTMLElement>() : T {
    assertIsInstanceOf(this, HTMLElement);
    this.innerHTML = "";
    return this as T;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement {
    assertIsInstanceOf(this, HTMLElement);
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    if(func) func(elm);
    this.appendChild(elm);
    return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    func(elm);
    return elm;
}

いろいろ試行錯誤した結果、上記のように落ち着きました。Typescirptでメソッド追加するためにはInterfaceを定義する必要があるけど、HTMLElementなど組込みのクラスに対して普通にインターフェースを追加で定義しようとしてもエラーになるようなので、上記のようにdeclare globalで定義する必要があるようです。あとは普通にprototypeで追加していくのだが、これも型定義が割と面倒で、createElement系などは引数のタグ名で戻り値の型が決まる変則ジェネリクスなので、typescriptのパッケージの中身を参考にほぼコピペしました(④、⑤)。

また、①や②は、関数でエレメントを編集したり、子要素を追加して自身を返す関数で、メソッドチェーン用に使用していたのですが、HTMLElementへのインターフェースとして実装している関係でthisが常にHTMLElementになってしまう問題があります。例えば<a></a>のリンクタグを追加しようとしても、createElementしたときはHTMLAncorElementであっても関数内で利用する時はHTMLElementとして扱われるのでthis.hrefメンバーが使えないなど。この場合、一旦unknownにしてHTMLAnchorElementにキャストし直す(element as unknown as HTMLAnchorElement)か、下記のような関数でアサートすることで解決出来ます。

/**
 * val が 指定のクラス cls のインスタンスであることをアサートする関数
 * 例えば HTMLELementにキャストされた下位クラス(HTMLInputELementなど)を、もとのクラスとしてアサート
 */
export function assertIsInstanceOf<T>(val: any, cls: new() => T) : asserts val is T {
    if(!(val instanceof cls)){
        throw new Error(`${val} is not instance of cls`);
    }
}

例)

document.createElement('a').editElement(link => {
   link.href = '#'; // ----  × ここではまだlinkはHTMLElementでhrefメンバがないのでエラー
   assertIsInstanceOf(link, HTMLAnchorElement);
   link.href = '#'; // ----  ○ HTMLAnchorElementとしてアサートされているのでOK
});

とは言ってもイチイチアサートするのも面倒な仕様なので、イマイチ移植できていない感があります。
まぁ、実際使用していると、こういう引数が長くなりがちな関数に対してメソッドチェーンを利用しても読みにくくなってしまうだけなので、これらは使用しないということで封印の方向で進んでいますw
エレメントをサクッと作成するdocument.createElementEdit(tag, func)と、構造的にサクッと子要素を追加するelement.appendElement(tag, fuc)だけあれば全然問題がないという結論に達しつつある感じ。

Webpackその他

メモ記事とはいえなかなか書いてる時間がとれず一月以上の間が空いてしまった。FastAPIによるバックエンド実装からTypescriptとSASSによるフロントエンド実装の勉強に入った所、WebAssemblyとその有力言語のRustに興味が写ってしまい、そのあたりを勉強しながらフロントエンドに組み込む技術を調査していたら、Webpackという技術に突き当たった。

WebAssemblyとRustに関しては別の機会に書くとして、今回はWebpack。いままで何度か単語程度に見かけはしたが、なんとなくお腹一杯感でスルーしていたが、必要に応じて調べてみると目からウロコ的な技術だった。

従来の静的コンテンツ(ブラウザ側で処理をする所謂フロントエンド)は、HTMLベースでスタールシート(CSS)やJavascriptを必要に応じて読み込む形式だったが、Webpack化することで、大まかには一つのJavascriptにまとめられる。

例えば下記のようなindex.htmlでは

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
  <style type="text/css">
    @import "/static/css/bootstrap-custom.css";
  </style>
  <script type="module">
    import { Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip } from "/static/js/bootstrap.esm.js"
  </script>
</head>
<body class="base">
  <script type="module">
    import {init} from "/static/js/app.js"
    window.addEventListener("load", init());
  </script>
</body>
</html>

6行目〜でbootstrapのカスタムスタイルシート(これは別途SASSでコンパイルしている)を読み込んで、その下9行目〜でbootstrap用のスクリプトをモジュールとして読み込んでいる。Bodyタグを読み込んだあとで14行目〜でシングルページアプリケーション用スクリプトapp.jsを読み込んでページロード完了後にinitを実行(window.addEventListener)。app.jsもTypescriptのソースから別途コンパイルしておりアプリケーションの規模によっては多くのモジュールに分かれてロードされる。

こういった構成のものが、Webpack化することで下記のように出来る

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
    <script src="/static/dist/main.js"></script>
</head>
<body class="base">
</body>
</html>

6行目のmain.js を読み込むだけとなる。main.jsはWebpackによってコンパイルされたjavascriptで読み込むべきリソースが全てパック化された生成物となる。ソースファイルは一般にindex.jsとされ、下記のような内容となる

import '../scss/bootstrap-custom.scss';
import 'bootstrap/dist/js/bootstrap.esm.js';

import {init, build} from '../ts/app';

init();

window.addEventListener("load", () => {
  build();
});

一行目でbootstrapのカスタムスタイルシートを読み込むが、ここではscssのソースファイルを直接インポートして、WebpackのプラグインでSassコンパイラを起動する。生成したスタイルシートをインポートすると呼び出し側のHTMLのHEADタグ内にSTYLEタグを生成して直接コーディングするため、CSSファイルは生成されない。

2行目はbootstrap用のスクリプトをインポート。4行目でシングルページアプリケーションスクリプトを読み込むが、これもTypescriptのソースを直接していして内部でコンパイラを起動している。また、従来構成と違って読み込んだ時点ではBODYタグが生成されていないため、DOM関連の初期化があると失敗する。DOM関連の初期化はloadイベントをトリガとして始めるよう工夫が必要。

とりあえず、Webpackを導入するとこうなるというだけのメモで、Webpackの具体的な設定などはまた別の機会に。

FastAPI認証機構の追加

小規模djangoページの置き替えの為にFastAPIアプリケーションの勉強と実装実験を行っているが、次に用意したものは管理ページなどへアクセスするためのユーザー認証機構。前述の通りFastAPIはAPIの提供に特化したシンプルなフレームワークなので実用的な機能などは自前で準備する必要がある。かといって何も情報が無いかと言えばそうでも無く、公式のドキュメントやチュートリアルに必要そうなものが一通りまとめてあるので(例えばセキュリティの項目)、それをチマチマ実装していくという実に勉強になる仕組みが用意されている。

ドキュメント・チュートリアルで今回参考(コピペ)にしたのもは主に次の二点

  • セキュリティ
    • パスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearer
    • ユーザー認証の基本的な構造サンプル
  • SQL database(データベースアクセス)
    • SQLAlchemyを使用してFastAPIのパス構文に接続する方法

以上を組み合わせてユーザー情報をデータベースに保存し、ユーザー認証を行うような仕組みが実現出来る。

ただし、この段階ではAPIとしての実装なので実際に使用できるようにするには、フロントエンドの実装が必要となる。Djangoではバックエンド寄りの実装でHTMLテンプレートを用いてのUIだったが、今回のUIはFastAPI対応ではせっかくなのでシングルページの実装としてjavascript(Typescript)での実装としている。

こんな感じのログイン画面を準備してみました。ユーザー追加の画面や実際の管理画面は未実装だが、同じようなノリで追々追加していきます。

あと、このままではトークンの期限が切れる度に再認証になるので、セッションIDによるリフレッシュトークンの実装で同じ端末からはなるべくログインし続けられるような機構の導入を検討している(実装の手段はともかく見た目くらいはDjangoと同じくらいにしたいw)。

bootstrapをほぼ素で利用しているだけで何となく見た目だけでも良い感じなのが嬉しいですね。