🔖

hono × cloudflare workers に sessionを実装した

2024/04/07に公開

最近話題のhono × cloudflare workersを使ってIdPでも作ってみようと思ったのですがsessionを管理する機能が無いし、いい感じのOSSもなかったので自前で実装してみました

KVSはcloudflare workers kvを使っています

環境作成

wranglerを入れます

npm install -g wrangler

wranglerが入ったことが確認できたら

wrangler --version

cloudflareにログインします

wrangler login

プロジェクト作成します

wrangler init -y hono-sample
cd hono-sample

hono をインストールします。

npm install hono

src/index.ts をこのように書き換えます

import { Hono } from "hono";

const app = new Hono(); // ①
app.get("/", (c) => {
    return c.text("Hello world");
})

export default app;

npm start すると以下のような表示が出ると思います

hono-sample % npm start                                       
> auth-sample-app@0.0.0 start
> wrangler dev

 ⛅️ wrangler 3.48.0
-------------------
⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:56747

Ready on のところに出てる localhostにアクセスしてみてください
うまくいっていればHello worldが表示されているはずです

kvの作成と設定

workers kvを作成します

wrangler kv:namespace create "SAMPLE_KV"

🌀 Creating namespace with title "worker-SAMPLE_KV"
✨ Success!
Add the following to your configuration file in your kv_namespaces array:
{ binding = "SAMPLE_KV", id = "d5098bb808fb4043b4010e531b7e796b" }

wrangler.tomlを編集してください
この部分のコメントアウトをはずして

# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

kvを作成したときに表示されたbinding, idを入れます
[[kv_namespaces]]の部分のコメントアウトを外すのを忘れないようにしてください

[[kv_namespaces]]
binding = "SAMPLE_KV"
id = "d5098bb808fb4043b4010e531b7ebbbb"

こんな感じでkvにアクセスできるようになっているはずです

// src/index.ts

app.get('/',  async (c) => {
    const test = c.req.query('test') || 'no test'
  if (test) {
    // @ts-ignore
    await c.env.SAMPLE_KV.put('test', JSON.stringify(test));
  }
    // @ts-ignore
    const output_test = await c.env.SAMPLE_KV.get('test') || 'no test'
    return  c.text(output_test)
})

sessionとsessionMiddlewareの作成

lib/session.tsを作成します

mkdir lib
touch lib/session.ts

session.tsの中身はこんな感じです
sessionIdはmiddlewareで生成して引数で受け取るようにしています
sessionExpireもcookieと有効期限を同じにしたいのでmiddleWareで宣言するようにしています

本当はconstructorで this.data = ロード みたいにしたかったのですがconstructorでは非同期が使えないみたいなのでdataを使う前にloadDataメソッドを必ず呼び出すようにしています

import { Context }  from "hono";
import { KVNamespace } from "@cloudflare/workers-types";

class Session {
    private data: Record<string, any> | undefined;
    private sessionId: string;
    private kv: KVNamespace;
    private loaded: boolean = false;
    private sessionExpire: number;
  
    constructor(c: Context, sessionId: string, sessionExpire: number = 60 * 60 * 24) {
        this.sessionId = sessionId
        this.kv = c.env.SAMPLE_KV;
        this.sessionExpire = sessionExpire;
    }

    private async loadData(): Promise<void> {
        if (!this.loaded) {
            const data = await this.kv.get(this.sessionId)
            this.data = data ? JSON.parse(data) : {};
            this.loaded = true;
        }
    }
  
    public async set(key: string, value: any): Promise<void> {
        await this.loadData();
        this.data![key] = value;
        await this.kv.put(this.sessionId, JSON.stringify(this.data), { expirationTtl: this.sessionExpire });
    }
  
    public async get(key: string): Promise<any> {
        await this.loadData();
        return this.data![key];
    }
  
    public async delete(key: string): Promise<void> {
        await this.loadData();
        delete this.data![key];
        await this.kv.put(this.sessionId, JSON.stringify(this.data));
    }
  
    public async clear(): Promise<void> {
        this.data = {};
        await this.kv.put(this.sessionId, JSON.stringify(this.data));
    }

    public async destroy(): Promise<void> {
        await this.kv.delete(this.sessionId);
    }
}
  
export default Session;

次にSessionMiddlewareを作成します

mkdir lib
touch middleware/session.ts

中身はこんな感じです
cookieにsessionIdが存在すればそれを取り出し、存在しなければ作成してcookieにsetします
c.sessionにSessionObjをセットしてContextからいつでも扱えるようにします

import { Context } from "hono"
import Session from "../lib/session"
import { getCookie, setCookie } from "hono/cookie"

const SESSION_EXPIRE = 60 * 60 * 24;

function generateAndSetSessionId(c: Context) {
    const sessionId = crypto.randomUUID();
    setCookie(c, 'session_id', sessionId, { httpOnly: true, path: '/', maxAge: SESSION_EXPIRE});
    return sessionId;
}

export const sessionMiddleware = async (c: Context, next: () => Promise<void>) => {
    const sessionId = getCookie(c, 'session_id') || generateAndSetSessionId(c);
    c.session = new Session(c, sessionId, SESSION_EXPIRE);
    await next();
}

src/index.ts でこんな感じでmiddlewareを呼び出して

import { Hono } from 'hono'
import { sessionMiddleware } from '../middleware/session'

const app = new Hono()
app.use('*', sessionMiddleware)

こんな感じでContextからいつでもsessionにアクセスできるようになります!

app.get('/',  async (c) => {
  const input_test = c.req.query('test')
  if (input_test) {
    await c.session.set('test',input_test)
  }
  const output_test = await c.session.get('test') || 'no test'
  return  c.text(output_test)
})

最後に

ご意見、ご質問等ございましたらお気軽にコメントお願いします!

Discussion