皆さんこんにちは、ブラックフライデーにちょっといいまくらを買いました。おっくんです。
- 本記事は 虎の穴ラボ Advent Calendar 2023 とDenoのカレンダー | Advent Calendar 2023 - Qiitaの 6日目の記事です。
- 次回は H.Hさんの「『Whisperを使用した音声ファイルの文字起こしの検証してみた」です。 ご期待ください!
Deno向けWebフレームワークFreshには、プラグイン機能があります。 プラグインを使うことで、アプリケーション本体への煩雑な改修を避けつつ、ミドルウェアやルートを機能拡張する事ができます。
今回は、Freshに認証機能を一旦は直接組み込み、後にプラグインに切り出す一連の流れを紹介します。
参考
- Fresh https://fresh.deno.dev/
- Lucia https://lucia-auth.com/
目次
Lucia の動作環境構築
Luciaは、様々なデータベースをバックエンドとして、使用することができます。
公式に提供されているデータベースアダプターは、以下の通りです。
- better-sqlite3
- Cloudflare D1
- ioredis
- libSQL
- Mongoose
- mysql2
- pg
- PlanetScale serverless
- postgres
- Prisma
- Redis
- Unstorage
- Upstash Redis
今回はこの中から、mysql2アダプターを使用します。
試すときには、使いやすいモノを選定いただければ良いです。
mysqlアダプターを使うには、3つのテーブルを用意する必要があります。
https://lucia-auth.com/database-adapters/mysql2/
今回は、ユーザー名とパスワードで認証するようにするので、次のようにしました。
# user CREATE TABLE auth_user ( id VARCHAR(15) NOT NULL PRIMARY KEY username VARCHAR(30) NOT NULL ); # user_key CREATE TABLE user_key ( id VARCHAR(255) NOT NULL PRIMARY KEY, user_id VARCHAR(15) NOT NULL, hashed_password VARCHAR(255), FOREIGN KEY (user_id) REFERENCES auth_user(id) ); # user_session CREATE TABLE user_session ( id VARCHAR(127) NOT NULL PRIMARY KEY, user_id VARCHAR(15) NOT NULL, active_expires BIGINT UNSIGNED NOT NULL, idle_expires BIGINT UNSIGNED NOT NULL, FOREIGN KEY (user_id) REFERENCES auth_user(id) );
一旦プラグインを使わないで改修
Fresh の初期設定
ここでは、Freshのプラグイン機能を使わずに、Luciaを組み込みます。
まずは、Freshの初期設定を行います。
以下の操作で初期設定します。
$ deno -V deno 1.38.2 $ deno run -A -r https://fresh.deno.dev Project Name [fresh-project] => . Fresh has built in support for styling using Tailwind CSS. Do you want to use this? [y/N] => y Do you use VS Code? [y/N] => y The manifest has been generated for 5 routes and 1 islands. Project initialized! Run deno task start to start the project. CTRL-C to stop. Stuck? Join our Discord https://discord.gg/deno Happy hacking! 🦕 $ deno task Available tasks: - check deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx - start deno run -A --watch=static/,routes/ dev.ts - build deno run -A dev.ts build - preview deno run -A main.ts - update deno run -A -r https://fresh.deno.dev/update . $ deno task start Task start deno run -A --watch=static/,routes/ dev.ts Watcher Process started. The manifest has been generated for 5 routes and 1 islands. 🍋 Fresh ready Local: http://localhost:8001/ $ tree ├─.vscode ├─components ├─islands ├─routes │ ├─api │ └─greet └─static
Lucia の組み込み
認証機能を組み込むにあたって本記事では、Luciaを使用します。
Luciaは、ユーザー認証とセッション処理の複雑さを抽象化した認証ライブラリと紹介されています。
Luciaを使って作る認証ページ群は、アカウントの作成や検証、ログイン状況のチェックを行います。
共通に呼び出される部分は、routesやmiddlewareに直接実装するのではなく、それらから呼び出せるようにutils/lucia_auth.ts
に切り出すことにします。
// utils/lucia_auth.ts import { lucia } from "npm:lucia"; import { web } from "npm:lucia/middleware"; import { mysql2 } from "npm:@lucia-auth/adapter-mysql"; import mysql from "npm:mysql2/promise"; import "https://deno.land/std@0.207.0/dotenv/load.ts"; const connectionPool = mysql.createPool({ database: Deno.env.get("MYSQL_DATABASE") || "", host: Deno.env.get("MYSQL_HOST") || "", user: Deno.env.get("MYSQL_USER") || "", port: parseInt(Deno.env.get("MYSQL_PORT") || ""), }); export const luciaAuth = lucia({ adapter: mysql2(connectionPool, { user: "user", key: "user_key", session: "user_session", }), env: "DEV", middleware: web(), sessionCookie: { expires: false, }, getUserAttributes: (databaseUser) => { return { username: databaseUser.username, }; }, });
各ページの作成
初期設定されたFreshは、/
に対応したroutes/index.tsx
が用意されています。
Luciaを組み込んだアカウント作成/認証の仕組みのために、以下のようにパスとコンポーネント(及びハンドラ)を対応させます。
パス | メソッド | ソース |
---|---|---|
/create_account |
GET/POST | routes/create_account.tsx |
/login |
GET/POST | routes/login.tsx |
/logout |
GET | routes/logout.tsx |
以下各コンポーネント(及びハンドラ)の実装です。
- 各処理の実装は、 LuciaのStarter guidesの内容に従います。
単体ではCSRF対策など不足も見られますので、公開するサービスには本記事の実装内容を直接利用せず、よくご確認ください。 - ログインに必要な情報になるusernameとpasswordは、本来ある程度の制約があってしかるべきですが、簡単のため5文字以上という制限だけで用意することにします。
- この時点では、サードパーティモジュール群は各ページコンポーネントのファイルごとに読み込むようにします。後のプラグイン化する際に、deps.tsにまとめることとします。
// routes/create_account.tsx import type { HandlerContext, Handlers, } from "https://deno.land/x/fresh@1.5.4/server.ts"; import { LuciaError } from "npm:lucia"; import { luciaAuth } from "../utils/lucia_auth.ts"; interface CreateAccountComponentProps { data: { errors: string[]; username?: string; }; } interface CreateAccountValidateSuccess { success: true; username: string; password: string; } interface CreateAccountValidateFailure { success: false; errors: string[]; } type CreateAccountValidateResult = | CreateAccountValidateSuccess | CreateAccountValidateFailure; function createAccountValidate( rawUsername: FormDataEntryValue | null, rawPassword: FormDataEntryValue | null, ): CreateAccountValidateResult { const errors = []; let username = ""; let password = ""; if (!rawUsername || rawUsername.toString().length < 5) { errors.push("Username must be at least 5 characters long"); } else { username = rawUsername.toString(); } if (!rawPassword || rawPassword.toString().length < 5) { errors.push("Password must be at least 5 characters long"); } else { password = rawPassword.toString(); } if (errors.length === 0) { return { success: true, username: username, password: password }; } return { success: false, errors }; } export const handler: Handlers = { async POST(req: Request, ctx: HandlerContext) { const formData = await req.formData(); const rawUsername = formData.get("username"); const rawPassword = formData.get("password"); const createAccountValidateResult = createAccountValidate( rawUsername, rawPassword, ); if (!createAccountValidateResult.success) { return ctx.render({ errors: createAccountValidateResult.errors, rawUsername, }); } try { const user = await luciaAuth.createUser({ key: { providerId: "username", providerUserId: createAccountValidateResult.username, password: createAccountValidateResult.password, }, attributes: { username: createAccountValidateResult.username, }, }); const session = await luciaAuth.createSession({ userId: user.userId, attributes: {}, }); const sessionCookie = luciaAuth.createSessionCookie(session); return new Response(null, { headers: { Location: "/", "Set-Cookie": sessionCookie.serialize(), }, status: 302, }); } catch (e) { console.error("e", e); if (e instanceof LuciaError) { return ctx.render({ errors: ["Auth system error"], rawUsername, }); } return new Response("An unknown error occurred", { status: 500, }); } }, }; export default function CreateAccount({ data }: CreateAccountComponentProps) { return ( <div class="px-4 py-8 mx-auto bg-[#86efac]"> <div class="max-w-screen-md mx-auto flex flex-col items-center justify-center"> <h2 class="text-lg">LOGIN</h2> <div> <form action="/create_account" method="post"> <div> {data?.errors?.length > 0 && ( <ul class="text-red-500 font-bold"> {data.errors.map((error) => <li>{error}</li>)} </ul> )} </div> <div class="mb-1"> <label for="username" class="float-left w-[100px]"> Username </label> <input type="text" id="username" name="username" value={data?.username} class="border-b-2 border-gray-300 w-[220px]" /> </div> <div class="mb-1"> <label for="password" class="float-left w-[100px]"> Password </label> <input type="password" id="password" name="password" class="border-b-2 border-gray-300 w-[220px]" /> </div> <div class="mb-1 flex justify-center"> <button type="submit" class="px-2 py-1 bg-gray-200 border-1 border-gray-300 rounded-md" > CREATE </button> </div> </form> </div> <a href="/login" class="text-blue-500 text-underline"> LOGIN </a> </div> </div> ); }
// routes/login.tsx import type { HandlerContext, Handlers, } from "https://deno.land/x/fresh@1.5.4/server.ts"; import { LuciaError } from "npm:lucia"; import { luciaAuth } from "../utils/lucia_auth.ts"; interface LoginComponentProps { data: { errors: string[]; username?: string; }; } interface CreateAccountValidateSuccess { success: true; username: string; password: string; } interface CreateAccountValidateFailure { success: false; errors: string[]; } type CreateAccountValidateResult = | CreateAccountValidateSuccess | CreateAccountValidateFailure; function loginValidate( rawUsername: FormDataEntryValue | null, rawPassword: FormDataEntryValue | null, ): CreateAccountValidateResult { const errors = []; let username = ""; let password = ""; if (!rawUsername) { errors.push("Username must be at least 5 characters long"); } else { username = rawUsername.toString(); } if (!rawPassword) { errors.push("Password must be at least 5 characters long"); } else { password = rawPassword.toString(); } if (errors.length === 0) { return { success: true, username: username, password: password }; } return { success: false, errors }; } export const handler: Handlers = { async POST(req: Request, ctx: HandlerContext) { const formData = await req.formData(); const rawUsername = formData.get("username"); const rawPassword = formData.get("password"); const loginValidateResult = loginValidate( rawUsername, rawPassword, ); if (!loginValidateResult.success) { return ctx.render({ errors: loginValidateResult.errors, rawUsername, }); } try { const key = await luciaAuth.useKey( "username", loginValidateResult.username, loginValidateResult.password, ); const session = await luciaAuth.createSession({ userId: key.userId, attributes: {}, }); const sessionCookie = luciaAuth.createSessionCookie(session); return new Response(null, { headers: { Location: "/", "Set-Cookie": sessionCookie.serialize(), }, status: 302, }); } catch (e) { console.error("e", e); if ( e instanceof LuciaError && ["AUTH_INVALID_KEY_ID", "AUTH_INVALID_PASSWORD"].includes(e.message) ) { return ctx.render({ errors: ["Incorrect username or password"], rawUsername, }); } throw new LuciaError("AUTH_INVALID_KEY_ID"); } }, }; export default function Login({ data }: LoginComponentProps) { return ( <div class="px-4 py-8 mx-auto bg-[#86efac]"> <div class="max-w-screen-md mx-auto flex flex-col items-center justify-center"> <h2 class="text-lg">LOGIN</h2> <div> <form action="/login" method="post"> <div> {data?.errors?.length > 0 && ( <ul class="text-red-500 font-bold"> {data.errors.map((error) => <li>{error}</li>)} </ul> )} </div> <div class="mb-1"> <label for="username" class="float-left w-[100px]"> Username </label> <input type="text" id="username" name="username" value={data?.username} class="border-b-2 border-gray-300 w-[220px]" /> </div> <div class="mb-1"> <label for="password" class="float-left w-[100px]"> Password </label> <input type="password" id="password" name="password" class="border-b-2 border-gray-300 w-[220px]" /> </div> <div class="mb-1 flex justify-center"> <button type="submit" class="px-2 py-1 bg-gray-200 border-1 border-gray-300 rounded-md" > Login </button> </div> </form> </div> <a href="/create_account" class="text-blue-500 text-underline"> Create Account </a> </div> </div> ); }
// routes/logout.tsx import type { HandlerContext, Handlers, } from "https://deno.land/x/fresh@1.5.4/server.ts"; import { luciaAuth } from "../utils/lucia_auth.ts"; export const handler: Handlers = { async GET(req: Request, _ctx: HandlerContext) { const authRequest = luciaAuth.handleRequest(req); const session = await authRequest.validate(); if (session) { luciaAuth.invalidateSession(session.sessionId); return new Response("", { status: 302, headers: { "Location": "/login" }, }); } else { return new Response("", { status: 302, headers: { "Location": req.referrer || "/" }, }); } }, };
さらに、ミドルウェアでリクエストチェックし、ログインセッションが無い場合にはログインページへリダイレクトするようにします。 この時、セッションをcontextに保持するようにします。
/// routes/_middleware.ts import { MiddlewareHandlerContext } from "$fresh/server.ts"; import { luciaAuth } from "../utils/lucia_auth.ts"; async function luciaSessionMiddleware( req: Request, ctx: MiddlewareHandlerContext, ) { const pathname = new URL(req.url).pathname; const authRequest = luciaAuth.handleRequest(req); const session = await authRequest.validate(); if ( ctx.destination == "route" && !["/login", "/create_account"].includes(pathname) && !session ) { return new Response("Unauthorized", { status: 302, headers: { "Location": "/login" }, }); } if (ctx.destination == "route" && session) { ctx.state.session = session; } return await ctx.next(); } export const handler = [luciaSessionMiddleware];
初期設定の段階で作成されているroutes/index.tsxでは、ログインしたユーザーのアカウント名を表示するようにしておきます。
先に設定したミドルウェアで、state にセッション情報を持つようになっています。
// routes/index.tsx import type { HandlerContext } from "https://deno.land/x/fresh@1.5.4/server.ts"; import { Session } from "npm:lucia"; export type WithSession = { state: { session?: Session } }; export type WithSessionHandlerContext = HandlerContext & WithSession; export default function Home({ state }: WithSessionHandlerContext) { return ( <div className="px-4 py-8 mx-auto bg-[#86efac]"> <div className="max-w-screen-md mx-auto flex flex-col items-center justify-center"> <p> Login user: {state.session ? state.session?.user?.username : "NO LOGIN"} </p> <a href="/logout" className="my-4"> LOGOUT </a> </div> </div> ); }
動作確認
ここまでで、アカウント作成->トップページ遷移(ユーザー名を表示)->ログアウト(->/
にアクセスするとリダイレクト)という動きが確認できます。
プラグイン化の改修
直接routes以下のファイルの追加と改修することで、Luciaを使った一連の動作を実現できました。
このパートでは、改めてこれらの機能をプラグインに切り出しを行います。
プラグインのディレクトリ構成
ディレクトリ構成の仕方については、Freshから受ける制約はありません。
が、追々モジュールをdeno.land/xで公開することも見越して、次のように用意することにします。
plugins └─lucia_plugin ├─mod.ts ├─plugin.ts ├─deps.ts ├─routes │ ├─create_account.tsx │ ├─login.tsx │ └─logout.tsx └─middlewares └─session_middleware.ts
deps.tsの準備
これまでのroutes以下を直接改修する際に外部依存モジュール群を各ファイルに直接https://~
やnpm:~
を記述していました。
plugins/lucia_plugin/deps.ts
にすべて取りまとめて、各ファイルは子のファイルを参照するようにします。
// plugins/lucia_plugin/deps.ts export type { AppProps, HandlerContext, Handlers, MiddlewareHandlerContext, PageProps, Plugin, } from "https://deno.land/x/fresh@1.5.4/server.ts"; export type { ComponentType } from "https://esm.sh/preact@10.18.1"; export type { Auth, Session } from "npm:lucia"; export { LuciaError } from "npm:lucia";
routesの移設
routes以下を直接拡張した際には、Luciaのインスタンスは、utils/lucia_auth.ts
を直接参照することで取得していました。
プラグイン化するにあたり、各routesにプラグインから渡せるようにするためにhandlerは1枚ラップするようにしています
3ファイルすべてを記述するのは、先のソースと重複する箇所が多いので、 plugins/lucia_plugin/routes/logout.tsx
のみ掲載します。
// plugins/lucia_plugin/routes/logout.tsx import type { Auth, HandlerContext, Handlers } from "../deps.ts"; export function getLogoutHandler(luciaAuth: Auth):Handlers { // LuciaAuth は外から渡す形に変更 return { async GET(req: Request, _ctx: HandlerContext) { const authRequest = luciaAuth.handleRequest(req); const session = await authRequest.validate(); if (session) { luciaAuth.invalidateSession(session.sessionId); return new Response("", { status: 302, headers: { "Location": "/login" }, }); } else { return new Response("", { status: 302, headers: { "Location": req.referrer || "/" }, }); } }, }; }
middlewareの切り出し/移設
middlewareも同様に直截参照していたluciaのインスタンスに依存しています。こちらも1枚ラップする関数を用意します。
// plugins/lucia_plugin/middlewares/lucia_middleware.ts import { MiddlewareHandlerContext, Auth } from "../deps.ts"; export function getLuciaSessionMiddleware(luciaAuth: Auth) { return async ( req: Request, ctx: MiddlewareHandlerContext, ) => { const pathname = new URL(req.url).pathname; const authRequest = luciaAuth.handleRequest(req); const session = await authRequest.validate(); if ( ctx.destination == "route" && !["/login", "/create_account"].includes(pathname) && !session ) { return new Response("Unauthorized", { status: 302, headers: { "Location": "/login" }, }); } if (ctx.destination == "route" && session) { ctx.state.session = session; } return await ctx.next(); }; }
plugin.ts/mod.tsの準備
各routesとmiddlewareを用意できたので、plugin.tsにとりまとめを行い deps.tsから参照できるようにします。
// plugins/lucia_plugin/plugin.ts import type { Auth, ComponentType, PageProps, Plugin, } from "./deps.ts"; import { getLuciaSessionMiddleware } from "./middlewares/lucia_middleware.ts"; import Login, { getLoginHandler } from "./routes/login.tsx"; import CreateAccount, { getCreateAccountHandler } from "./routes/create_account.tsx"; import { getLogoutHandler } from "./routes/logout.tsx"; export function getLuciaPlugin( luciaAuth: Auth, ): Plugin { return { name: "LuciaPlugin", middlewares: [ { middleware: { handler: getLuciaSessionMiddleware(luciaAuth), }, path: "/", }, ], routes: [ { path: "/create_account", handler: getCreateAccountHandler(luciaAuth), component: CreateAccount as ComponentType<PageProps>, }, { path: "/login", handler: getLoginHandler(luciaAuth), component: Login as ComponentType<PageProps>, }, { path: "/logout", handler: getLogoutHandler(luciaAuth), }, ], }; }
// plugins/lucia_plugin/mod.ts export type {Auth, Session, HandlerContext} from "./deps.ts" export * from "./plugin.ts"
これで、プラグイン化ができました。
改めてプラグインだけ使う
では、改めてプラグインだけ使用してLuciaを使用します。
deno.json にプラグインのインポート先を記述します。
// deno.json(抜粋) { "imports": { "$fresh/": "https://deno.land/x/fresh@1.5.4/", "preact": "https://esm.sh/preact@10.18.1", "preact/": "https://esm.sh/preact@10.18.1/", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", "twind": "https://esm.sh/twind@0.16.19", "twind/": "https://esm.sh/twind@0.16.19/", "$std/": "https://deno.land/std@0.193.0/", "lucia_plugin/": "./plugins/lucia_plugin/" // <= 追記 }, }
deno.jsonのインポートマップ部分に追記したので、Fresh本体のソースコードから具体的なパスの情報が消えます。
// fresh.config.ts import { defineConfig } from "$fresh/server.ts"; import twindPlugin from "$fresh/plugins/twind.ts"; import twindConfig from "./twind.config.ts"; import {getLuciaPlugin} from "lucia_plugin/mod.ts"; // deno.jsonのインポートマップ部分に追記したことで `./plugins/`は 不要に import {luciaAuth} from "./utils/lucia_auth.ts"; export default defineConfig({ plugins: [ twindPlugin(twindConfig), getLuciaPlugin(luciaAuth) // Luciaのインスタンスを引数にpluginを取得してpluginの配列を構築する。 ], });
routes/index.tsで直接外部モジュールを参照していた箇所も、プラグインのモジュール経由に変えておきます。
// routes/index.tsx import {HandlerContext, Session } from "lucia_plugin/mod.ts"; export type WithSession = { state: { session?: Session } }; export type WithSessionHandlerContext = HandlerContext & WithSession; export default function Home({ state }: WithSessionHandlerContext) { return ( <div className="px-4 py-8 mx-auto bg-[#86efac]"> <div className="max-w-screen-md mx-auto flex flex-col items-center justify-center"> <p> Login user: {state.session ? state.session?.user?.username : "NO LOGIN"} </p> <a href="/logout" className="my-4"> LOGOUT </a> </div> </div> ); }
これで、すべての機能をプラグイン経由での利用に切り替えることができました。
最後にディレクトリ構成を再度確認します。
# ディレクトリ構成(一部抜粋) ├─deno.json ├─dev.ts ├─fresh.config.ts ├─fresh.gen.ts ├─main.ts ├─twind.config.ts ├─components │ └─Button.tsx ├─islands │ └─Counter.tsx ├─plugins │ └─lucia_plugin # 今回実装したlucia_plugin本体 │ ├─deps.ts │ ├─mod.ts │ ├─plugin.ts │ ├─middlewares │ │ └─lucia_middleware.ts │ └─routes │ ├─create_account.tsx │ ├─login.tsx │ └─logout.tsx ├─routes # routes以下はデフォルトの構成に戻っています │ ├─index.tsx │ ├─_404.tsx │ ├─_app.tsx │ ├─api │ │ └─joke.ts │ └─greet │ └─[name].tsx ├─static │ ├─favicon.ico │ └─logo.svg └─utils └─lucia_auth.ts
見えずらい依存関係
プラグイン化することで、getLuciaPlugin()
を呼び出すだけで機能を使えるようになったことには間違い無いのですが、
このプラグインはプラグイン自体で成立しない箇所が1点あります。
Pluginで記述したコンポーネントのスタイリングには、すべてtailwindを使っていました。
twindもプラグインで適用されており、スタイリングについてはプラグイン単体で成立していない部分です。
次のようにすることで、twindを機能停止できます。
// fresh.config.ts import { defineConfig } from "$fresh/server.ts"; import twindPlugin from "$fresh/plugins/twind.ts"; import twindConfig from "./twind.config.ts"; import { getLuciaPlugin } from "lucia_plugin/mod.ts"; import { luciaAuth } from "./utils/lucia_auth.ts"; export default defineConfig({ plugins: [ //twindPlugin(twindConfig), // <= twindPluginを適用しない getLuciaPlugin(luciaAuth), ], });
するとログインページは次のような表示になります。
routes(特にcomponent)を提供するプラグインを公開するならは、特定のCSSフレームワークに依存させないか、好きなスタイルが使用できるようにcomponentもオプションとして設定できるのが良さそうです。
まとめ
Freshをプラグインで拡張する改修を、一旦プラグインを使わない改修を挟みながら見てきました。
プラグインを使用すると、アプリケーション全体(指定次第で特定のパスも可)に影響させられるミドルウェアと、各パスに対応した画面やハンドラの固まりを提供できるようになります。
使ってもらうときには、defineConfig
に渡すだけ(依存があるものはそれも渡しつつ)でよくなります。
固定されたパスのログインページ、固定されたリダイレクト先など機能をプラグインに切り離すのは
Routes以下に構築するのとは明らかに異なる点が1点あります。
それは、機能が提供されるパスを明示的に開発者が記述できる点です。
例えば、
/
はセッションも持っている必要は無いので/mypage
以下だけミドルウェアを動作させる/create_account
は/register
にする
ようなこともLuciaのインスタンスを渡したようにプラグインの引数として定義することで実現できます。 オプションとして機能提供できると広く使ってもらいやすいのではないでしょうか。
Fresh 1.6 がリリースされました
Fresh 1.6が、12月1日に公開されました。
現在、議論されていたプラグインでIslandsを設定する機能が利用可能になりました。
また、作成する中ではスタイリングがtailwindに依存しているということを紹介しましたが、このリリースでCSSもプラグインから設定できる(を設定できる)ようになったので、こちらを使えば解決する部分も多いように思います。
最後に、今回開発したプラグインにいくつかオプションを追加して公開しました。
よかったら、参考にしてみてください。
P.S.
採用
虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。