SonikをAWS Lambda(+ Functions URL)で動かしてみる

Sonikは、Honoのメンテナであるyusukeさんが開発しているメタフレームワークであり、Next.jsのようなファイルベースのルーティングやHonoを使用したAPIの実装が可能です。AWS CDKを使用してSonikアプリをデプロイする方法を紹介しています。SonikのセットアップやAWS Lambda向けの調整、S3の利用方法なども解説されています。ただし、LambdaからS3の画像を配信する部分には問題があり、今回はS3にリダイレクトさせた形でデプロイされています。

広告ここから
広告ここまで

目次

    Sonikは、Honoのメンテナのyusukeさんが現在開発中のメタフレームワークです。

    https://www.npmjs.com/package/sonik

     Next.jsのようなファイルベースルーティングや、HonoをつかったAPIの実装なども可能とのことで、Next.jsがToo muchなケースに使えるのではないかと思い、試してみました。

    AWS CDKのセットアップ

    Cloudflareへのデプロイはシンプルに終わりそうな予感がしたので、あえてAWSにデプロイします。

    今回もCDKでリソース定義を行いましょう。

    % mkdir my-sonik-app
    % cd my-sonik-app
    % cdk init app -l typescript

    lib/cdk-stack.tsにLambdaとS3を定義します。

    import * as cdk from 'aws-cdk-lib';
    import { Construct } from 'constructs';
    // import * as sqs from 'aws-cdk-lib/aws-sqs';
    import { Function, Runtime, Code, FunctionUrlAuthType } from 'aws-cdk-lib/aws-lambda';
    import { BlockPublicAccess, Bucket } from 'aws-cdk-lib/aws-s3';
    import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
    import path = require('path');
    
    export class CdkStack extends cdk.Stack {
      constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
    
        const websiteBucket = new Bucket(this, 'SonikStaticAssets', {
          websiteIndexDocument: 'index.html',
          publicReadAccess: true,
          blockPublicAccess: BlockPublicAccess.BLOCK_ACLS,
        });
        
        new BucketDeployment(this, 'DeployWebsite', {
          sources: [Source.asset('./sonik-app/public')],
          destinationBucket: websiteBucket,
        });
    
        const fn = new Function(this, 'MyFunction', {
          runtime: Runtime.NODEJS_18_X,
          handler: 'lambda.handler',
          code: Code.fromAsset(path.join(__dirname, '../sonik-app/dist')),
          environment: {
            S3_BUCKET_URL: websiteBucket.bucketWebsiteUrl,
          },
        });
        fn.addFunctionUrl({
          authType: FunctionUrlAuthType.NONE
        })
      }
    }
    

    S3が出てくるのは、faviconや画像・CSSファイルなどの保存と配信に利用するためです。

    そのため、BucketDeploymentsも定義し、デプロイ時にSonik内の静的ファイルをアップロードします。

    Sonikアプリのセットアップ

    インフラの準備ができたので、Sonikをセットアップします。

    % npx degit yusukebe/sonik/examples/basic sonik-app
    % npm --prefix sonik-app install
    % npm install -D @types/aws-lambda

    この方法は、v1リリース時に変わる可能性がありますので、2024年以降に読まれる方はご注意ください。

    [Checkpoint] ディレクトリ構造

    ここまでで、プロジェクトのディレクトリ構造はだいたいこんな感じになります。

     % tree -L 2 -I node_modules -I cdk.out 
    .
    ├── README.md
    ├── bin
    │   └── cdk.ts
    ├── cdk.json
    ├── jest.config.js
    ├── lib
    │   └── cdk-stack.ts
    ├── package-lock.json
    ├── package.json
    ├── sonik-app
    │   ├── _worker.ts
    │   ├── app
    │   ├── dist
    │   ├── lambda.ts
    │   ├── package.json
    │   ├── public
    │   ├── tsconfig.json
    │   ├── vite.config.ts
    │   └── yarn.lock
    ├── test
    │   └── cdk.test.ts
    └── tsconfig.json
    
    7 directories, 16 files

    SonikをAWS Lambda向けに調整する

    続いてSonikのコードをAWS Lambda向けに編集しましょう。

    エントリーポイントlambda.tsの作成

    エントリーポイントを_worker.tsのままにしても良いのですが、今回は別途作成します。

    import { createApp } from 'sonik'
    import { handle } from "hono/aws-lambda"
    import { Handler } from 'aws-lambda'
    
    const app = createApp()
    export const handler: Handler = handle(app)
    export default app

    exportの方法が少し変わっているくらいですね。

    画像などをS3から配信するためのmiddlewareを用意

    続いてmiddlewareを追加します。これは静的ファイルへのアクセスを処理するためのものです。

    
    const getMimeType = (filename: string): string | undefined => {
        const regexp = /\.([a-zA-Z0-9]+?)$/
        const match = filename.match(regexp)
        if (!match) return
        let mimeType = mimes[match[1]]
        if ((mimeType && mimeType.startsWith('text')) || mimeType === 'application/json') {
          mimeType += '; charset=utf-8'
        }
        return mimeType
      }
      
      const mimes: Record<string, string> = {
        aac: 'audio/aac',
        abw: 'application/x-abiword',
        arc: 'application/x-freearc',
        avi: 'video/x-msvideo',
        azw: 'application/vnd.amazon.ebook',
        bin: 'application/octet-stream',
        bmp: 'image/bmp',
        bz: 'application/x-bzip',
        bz2: 'application/x-bzip2',
        csh: 'application/x-csh',
        css: 'text/css',
        csv: 'text/csv',
        doc: 'application/msword',
        docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        eot: 'application/vnd.ms-fontobject',
        epub: 'application/epub+zip',
        gz: 'application/gzip',
        gif: 'image/gif',
        htm: 'text/html',
        html: 'text/html',
        ico: 'image/x-icon',
        ics: 'text/calendar',
        jar: 'application/java-archive',
        jpeg: 'image/jpeg',
        jpg: 'image/jpeg',
        js: 'text/javascript',
        json: 'application/json',
        jsonld: 'application/ld+json',
        map: 'application/json',
        mid: 'audio/x-midi',
        midi: 'audio/x-midi',
        mjs: 'text/javascript',
        mp3: 'audio/mpeg',
        mpeg: 'video/mpeg',
        mpkg: 'application/vnd.apple.installer+xml',
        odp: 'application/vnd.oasis.opendocument.presentation',
        ods: 'application/vnd.oasis.opendocument.spreadsheet',
        odt: 'application/vnd.oasis.opendocument.text',
        oga: 'audio/ogg',
        ogv: 'video/ogg',
        ogx: 'application/ogg',
        opus: 'audio/opus',
        otf: 'font/otf',
        png: 'image/png',
        pdf: 'application/pdf',
        php: 'application/php',
        ppt: 'application/vnd.ms-powerpoint',
        pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        rar: 'application/vnd.rar',
        rtf: 'application/rtf',
        sh: 'application/x-sh',
        svg: 'image/svg+xml',
        swf: 'application/x-shockwave-flash',
        tar: 'application/x-tar',
        tif: 'image/tiff',
        tiff: 'image/tiff',
        ts: 'video/mp2t',
        ttf: 'font/ttf',
        txt: 'text/plain',
        vsd: 'application/vnd.visio',
        wav: 'audio/wav',
        weba: 'audio/webm',
        webm: 'video/webm',
        webp: 'image/webp',
        woff: 'font/woff',
        woff2: 'font/woff2',
        xhtml: 'application/xhtml+xml',
        xls: 'application/vnd.ms-excel',
        xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        xml: 'application/xml',
        xul: 'application/vnd.mozilla.xul+xml',
        zip: 'application/zip',
        '3gp': 'video/3gpp',
        '3g2': 'video/3gpp2',
        '7z': 'application/x-7z-compressed',
      }
    
    app.use('/static/*', async (_c, next) => {
        if (_c.finalized) {
            await next()
        }
        const url = new URL(_c.req.url)
        const bucketURL = process?.env?.S3_BUCKET_URL
        //const bucketName = process?.env?.S3_BUCKET_NAME
        if (bucketURL) {
            const mimeType = getMimeType(url.pathname)
            if (mimeType && mimeType.startsWith('image')) {
                return _c.redirect(`${bucketURL}${url.pathname}`, 302)
            }
            const response = await fetch(`${bucketURL}${url.pathname}`)
            if (response.ok) {
                const body = await response.text()
                const mimeType = getMimeType(url.pathname)
                if (mimeType) {
                  _c.header('Content-Type', mimeType)
                }
                return _c.body(body)
            }
        }
        await next()
    })

    画像などもS3からGetObjectして返す方がよい気はしています。

    が、今回はとりあえず動かすことを目標として、簡単に済むリダイレクトを選びました。

    また、getMimeTypeはHonoのソースコードにあったものを持ってきました。

    Viteの設定を変更

    最後にViteの設定を変更します。

    import { defineConfig } from 'vite'
    import sonik from 'sonik/vite'
    
    export default defineConfig({
      plugins: [
        sonik({
          entry: './lambda.ts',
          minify: true,
        }),
      ],
      // 追加ここから
      build: {
        rollupOptions: {
          output: {
            format: 'commonjs'
          }
        }
      }
      // 追加ここまで
    })
    

    Lambda側でcommonjsでないとエラーになるケースがあったので追加しています。

    今後のLambda側のアップデートなどで、対応が不要になることもあるかもしれません。

    デプロイ方法

    実際にデプロイしてみましょう。

    Sonikアプリをビルドする

    今回の方法では、CDK側でアプリのビルドを行いません。

    そのため。前もってSonik側でビルドが必要です。

    % npm --prefix ./sonik-app run build

    ViteのビルドをCDKから実行する方法があるらしいので、分かり次第紹介します。

    AWS CDKでデプロイする

    デプロイするアプリの準備ができれば、AWSにデプロイします。

    % cdk deploy

    CloudFormationまたはLambdaのコンソールやAPIから、デプロイしたLambdaのfunctions URLを取得しましょう。

    試してみて

    実際にデプロイしてみたものがこちらです。

    LambdaからS3の画像を配信する部分がどうも上手くいかず、今回はS3にリダイレクトさせる形にしています。

    実際のアプリで使う場合は、CloudFrontのbehaviorを上手く使って同一ドメインで配信できるようにする必要がありそうです。

    その場合、middlewareの処理をCloudFrontまたはLambda@edgeやCloudFront function側でやってもよいかもしれません。

    参考

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark