AWSAWS CDKHono / SonikJavaScript

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側でやってもよいかもしれません。

参考

ブックマークや限定記事(予定)など

WP Kyotoサポーター募集中

WordPressやフロントエンドアプリのホスティング、Algolia・AWSなどのサービス利用料を支援する「WP Kyotoサポーター」を募集しています。
月額または年額の有料プランを契約すると、ブックマーク機能などのサポーター限定機能がご利用いただけます。

14日間のトライアルも用意しておりますので、「このサイトよく見るな」という方はぜひご検討ください。

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

Related Category posts