Cloudflare Workers + Hono ワークショップ

資料はこちら => workshops.yusuke.run

#serverlessdays

アジェンダ

  1. ワークショップについて
  2. Workers イントロダクション
  3. Hono イントロダクション
  4. 基本編
  5. プロキシ編
  6. Web API編
  7. フルスタック編
  8. AI編
  9. Honoをより深く知る
  10. その他

1. ワークショップについて

1.1 対象

対象者

前提条件

1.2 自己紹介

自己紹介

Developer Relations チーム

時差すごい

SS

やってること

2. Workers イントロダクション

2.1 Cloudflare Workers/Pagesについて

Cloudflare Workers

Build serverless applications and deploy instantly across the globe for exceptional performance, reliability, and scale.

サーバーレスアプリケーションを構築し、卓越したパフォーマンス、信頼性、スケールのために世界中に即座にデプロイします。

https://developers.cloudflare.com/workers/

Cloudflare Pages

Deploy dynamic front-end applications in record time.

動的なフロントエンド・アプリケーションを記録的な速さで展開。

https://developers.cloudflare.com/pages/

2.2 Cloudflare Workers

Cloudflare Workersの思想

SS

Wrangler

SS

2.3 Bindings

CloudflareのリソースとWorkersを結ぶ

KV

Durable Objects

R2

D1

Service Bindings

Queue

Email

Constellation

2.4 Cloudflare Pages

2.5 Workersを知るために

リソース

2.6 その他のエッジで実行されるプラットフォーム

3. Hono イントロダクション

3.1 Honoとは?

副題

Ultrafast framework for the Edges

もしくは

The Web Framework built on Web Standards

Lightweight, Ultrafast, Web Standards

軽量で、速くて、Web標準を使っている

3.2 ユースケース

3.3 どこで使われているか?

主に4つのプラットフォーム

Cloudflare Workers

Fastly Compute@Edge

Deno

Bun

SS

Awesome Hono

Honoを使うべきか?

ベンチマーク

3.4 5つの特徴

3.5 速い

RegExpRouter

SS

SS

TriRouter

SS

SmartRouter

readonly defaultRouter: Router = new SmartRouter({
  routers: [new RegExpRouter(), new TrieRouter()],
})

LinearRouter

• GET /user/lookup/username/hey
----------------------------------------------------- -----------------------------
LinearRouter     1.82 µs/iter      (1.7 µs … 2.04 µs)   1.84 µs   2.04 µs   2.04 µs
MedleyRouter     4.44 µs/iter     (4.34 µs … 4.54 µs)   4.48 µs   4.54 µs   4.54 µs
FindMyWay       60.36 µs/iter      (45.5 µs … 1.9 ms)  59.88 µs  78.13 µs  82.92 µs
KoaTreeRouter    3.81 µs/iter     (3.73 µs … 3.87 µs)   3.84 µs   3.87 µs   3.87 µs
TrekRouter       5.84 µs/iter     (5.75 µs … 6.04 µs)   5.86 µs   6.04 µs   6.04 µs

summary for GET /user/lookup/username/hey
  LinearRouter
   2.1x faster than KoaTreeRouter
   2.45x faster than MedleyRouter
   3.21x faster than TrekRouter
   33.24x faster than FindMyWay

PatternRouter

SS

ベンチマーク

対象のルーター

結果

• all together
---------------------------------------------------------------------------- -----------------------------
Hono RegExpRouter                      460.6 ns/iter (429.69 ns … 525.88 ns)  479.5 ns 520.63 ns 525.88 ns
Hono TrieRouter                         1.52 µs/iter     (1.39 µs … 1.73 µs)   1.56 µs   1.73 µs   1.73 µs
@medley/router                        618.21 ns/iter (591.08 ns … 764.25 ns) 636.08 ns 764.25 ns 764.25 ns
find-my-way                           959.15 ns/iter   (892.79 ns … 1.02 µs) 979.02 ns   1.02 µs   1.02 µs
koa-tree-router                       926.79 ns/iter   (866.17 ns … 1.04 µs) 943.57 ns   1.04 µs   1.04 µs
trek-router                             1.76 µs/iter     (1.69 µs … 1.84 µs)   1.79 µs   1.84 µs   1.84 µs
express (WARNING: includes handling)    4.02 µs/iter      (3.9 µs … 4.33 µs)   4.06 µs   4.33 µs   4.33 µs
koa-router                              1.39 µs/iter     (1.34 µs … 1.57 µs)   1.41 µs   1.57 µs   1.57 µs
radix3                                763.26 ns/iter (734.77 ns … 902.52 ns)  781.2 ns 902.52 ns 902.52 ns
Memoirist                             540.11 ns/iter (507.38 ns … 699.65 ns) 554.52 ns 697.25 ns 699.65 ns

summary for all together
  Hono RegExpRouter
   1.17x faster than Memoirist
   1.34x faster than @medley/router
   1.66x faster than radix3
   2.01x faster than koa-tree-router
   2.08x faster than find-my-way
   3.02x faster than koa-router
   3.3x faster than Hono TrieRouter
   3.82x faster than trek-router
   8.74x faster than express (WARNING: includes handling)

@usualomaさん

RegExpRouter, SmartRouter, LinearRouter, PatternRouterは @usualomaさん が作りました。 @usualomaさんが発表した資料が参考になります。

SS

3.6 軽量

プリセット

あらかじめ、おすすめのルーターのセッティングをプリセットとして提供している。

  • hono: ほとんどのユースケースでオススメです。ルーティング登録がhono/quickより遅いとはいえ、一度登録されれば高いパフォーマンスを発揮します。DenoBun、それにNode.jsなどを使った常駐型のサーバーには最適です。また、Cloudflare WorkersDeno DeployLagonでもこのプリセットを使えばいいでしょう。というのもこれらのようなv8 isolateを使った環境では、isolateは起動後しばらく行き続けるからです(時間が決まっていたり、メモリなどの状況に応じて変化したりします)。

  • hono/quick: このプリセットはリクエストのたびにアプリケーションが初期化されるような環境に適しています。Fastly Compute@Edgeはこれに従うので、このプリセットを使うといいでしょう。

  • hono/tiny: このプリセットは一番ファイルサイズの小さいプリセットです。リソースが限られている環境にはいいでしょう。

3.7 どこでも動く

Web Standard APIs

最低限のコード

export default {
  async fetch() {
    return new Response('Hello World')
  },
}

よく使うAPI

面白いAPI

スターターのテンプレートは12種類

  1. aws-lambda
  2. bun
  3. cloudflare-pages
  4. cloudflare-workers
  5. deno
  6. fastly
  7. lagon
  8. lambda-edge
  9. netlify
  10. nextjs
  11. nodejs
  12. vercel

CIでは9種類のランタイムのテストが走る

CI

エントリポイントが違うだけ

Cloudflare Workers & Bun

import { Hono } from 'hono'
const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default app

Fastly Compute@Edge

import { Hono } from 'hono'
const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

app.fire()

Deno

import { Hono } from 'https://deno.land/x/[email protected]/mod.ts'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

Deno.serve(app.fetch)

Lagon

import { Hono } from 'hono'
const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export const handler = app.fetch

Cloudflare Pages

import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export const onRequest = handle(app)

Vercel

import { Hono } from 'hono'
import { handle } from 'hono/vercel'

export const config = {
  runtime: 'edge',
}

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default handle(app)

Netlify

import { handle } from 'https://deno.land/x/[email protected]/adapter/netlify/mod.ts'
import { Hono } from 'https://deno.land/x/[email protected]/mod.ts'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default handle(app)

AWS Lambda

import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export const handler = handle(app)

Lambda@Edge

import { Hono } from 'hono'
import { handle } from 'hono/lambda-edge'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export const handler = handle(app)

Node.js

import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

serve(app)

3.8 揃っている

コアは小さいが、ミドルウェアとヘルパーがある。

ミドルウェア

onion

3つのミドルウェア

ミドルウェアとヘルパーの例

カスタムミドルウェア

app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const end = Date.now()
  c.res.headers.set('X-Response-Time', `${end - start}`)
})

JSX

import type { FC } from 'hono/jsx'

const app = new Hono()

const Layout: FC = (props) => {
  return (
    <html>
      <body>{props.children}</body>
    </html>
  )
}

const Top: FC<{ messages: string[] }> = (props: { messages: string[] }) => {
  return (
    <Layout>
      <h1>Hello Hono!</h1>
      <ul>
        {props.messages.map((message) => {
          return <li>{message}!!</li>
        })}
      </ul>
    </Layout>
  )
}

app.get('/', (c) => {
  const messages = ['Good Morning', 'Good Evening', 'Good Night']
  return c.html(<Top messages={messages} />)
})

3.8 楽しいDX

TypeScript

SS

RPC

APIを書く

import { Hono } from 'hono'

const app = new Hono()

app.get('/hello', (c) => {
  return c.json({
    message: `Hello!`,
  })
})

Zodでバリデーションをする

SC

import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

app.get(
  '/hello',
  zValidator(
    'query',
    z.object({
      name: z.string(),
    })
  ),
  (c) => {
    const { name } = c.req.valid('query')
    return c.json({
      message: `Hello! ${name}`,
    })
  }
)

型を共有する

SC

const route = app.get(
  '/hello',
  zValidator(
    'query',
    z.object({
      name: z.string(),
    })
  ),
  (c) => {
    const { name } = c.req.valid('query')
    return c.jsonT({
      message: `Hello! ${name}`,
    })
  }
)

export type AppType = typeof route

クライアントの実装

SC

import { AppType } from './server'
import { hc } from 'hono/client'

const client = hc<AppType>('/api')
const res = await client.hello.$get({
  query: {
    name: 'Hono',
  },
})

SC

const data = await res.json()
console.log(`${data.message}`)

SS

テスト

describe('Example', () => {
  test('GET /posts', async () => {
    const res = await app.request('/posts')
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Many posts')
  })
})

output

import { testClient } from 'hono/testing'

it('test', async() => {
  const app = new Hono().get('/search', (c) => c.jsonT({ hello: 'world' }))
  const res = await testClient(app).search.$get()

  expect(await res.json()).toEqual({ hello: 'world' })
})

3.10 Cloudflare + Hono を使ったアプリ例

r2-image-worker

SC

4. 基本編

4.1 初めてのCloudflare Workers

C3を使う

npm create cloudflare@latest

Or

yarn create cloudflare

Or

pnpm create cloudflare@latest

Or

bun create cloudflare

"Hello World" Worker

package.json

{
  "name": "curly-leaf-e7ce",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "deploy": "wrangler deploy",
    "start": "wrangler dev"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20230419.0",
    "typescript": "^5.0.4",
    "wrangler": "^3.0.0"
  }
}

src/index.ts

export interface Env {}

// ExportedHandler
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return new Response('Hello World!')
  },
}

fetchの引数

Workersアプリの流れ

  1. Requestを受け取る
  2. ロジック
  3. Responseを作る
  4. Responseを返す

waitUntil()

export default {
  async fetch(_req, _env, ctx) {
    const log = async () => console.log('Foo')
    ctx.waitUntil(log())
    return new Response('Hello')
  },
} as ExportedHandler

4.2 初めてのHono

create hono

npm create hono@latest

Or

yarn create hono

Or

pnpm create hono@latest

Or

bun create hono

レスポンスを返す

app.get('/', (c) => {
  return c.text('Hello!')
})

app.get('/json', (c) => {
  return c.json({
    message: 'Hello!',
  })
})

app.get('/html', (c) => {
  return c.json({
    message: '<h1>Hello!</h1>',
  })
})

app.get('/stream', (c) => {
  return c.streamText(async (stream) => {
    stream.sleep(1000)
    stream.writeln('Hello!')
  })
})

Contextを通してレスポンスを作る

app.get('/welcome', (c) => {
  // Set headers
  c.header('X-Message', 'Hello!')
  c.header('Content-Type', 'text/plain')

  // Set HTTP status code
  c.status(201)

  // Return the response body
  return c.body('Thank you for coming')
})

以下と同じ

new Response('Thank you for coming', {
  status: 201,
  headers: {
    'X-Message': 'Hello',
    'Content-Type': 'text/plain',
  },
})

その他

ルーティング

// HTTP Methods
app.get('/', (c) => c.text('GET /'))
app.post('/', (c) => c.text('POST /'))
app.put('/', (c) => c.text('PUT /'))
app.delete('/', (c) => c.text('DELETE /'))

// Wildcard
app.get('/wild/*/card', (c) => {
  return c.text('GET /wild/*/card')
})

// Any HTTP methods
app.all('/hello', (c) => c.text('Any Method /hello'))

// Custom HTTP method
app.on('PURGE', '/cache', (c) => c.text('PURGE Method /cache'))

// Multiple Method
app.on(['PUT', 'DELETE'], '/post', (c) => c.text('PUT or DELETE /post'))

// Path parameters
app.get('/user/:name', (c) => {
  const name = c.req.param('name')
  ...
})

// Optional parameters
// Will match `/api/animal` and `/api/animal/:type`
app.get('/api/animal/:type?', (c) => c.text('Animal!'))

// Regexp
app.get('/post/:date{[0-9]+}/:title{[a-z]+}', (c) => {
  const { date, title } = c.req.param()
  ...
})

// Including a slash
app.get('/posts/:filename{.+.png$}', (c) => {
  //...
})

// Chained routes
app
  .get('/endpoint', (c) => {
    return c.text('GET /endpoint')
  })
  .post((c) => {
    return c.text('POST /endpoint')
  })
  .delete((c) => {
    return c.text('DELETE /endpoint')
  })

ルートを分ける

const book = new Hono()

book.get('/', (c) => c.text('List Books')) // GET /book
book.get('/:id', (c) => {
  // GET /book/:id
  const id = c.req.param('id')
  return c.text('Get Book: ' + id)
})
book.post('/', (c) => c.text('Create Book')) // POST /book

const app = new Hono()
app.route('/book', book)

ホスト名でのルーティング

const app = new Hono({
  getPath: (req) => req.url.replace(/^https?:\/(.+?)$/, '$1'),
})

app.get('/www1.example.com/hello', (c) => c.text('hello www1'))
app.get('/www2.example.com/hello', (c) => c.text('hello www2'))

Context

Bindingsの取得

// Environment object for Cloudflare Workers
app.get('*', async c => {
  const counter = c.env.COUNTER
  ...
})

c.set() / c.get()

app.use('*', async (c, next) => {
  c.set('message', 'Hono is cool!!')
  await next()
})

app.get('/', (c) => {
  const message = c.get('message')
  return c.text(`The message is "${message}"`)
})

Variableappへ渡すことで型がつく

type Variables = {
  message: string
}

const app = new Hono<{ Variables: Variables }>()

c.var

c.set()でセットした変数へアクセスできる

const result = c.var.client.oneMethod()

その他

HonoRequest

5. プロキシ編

Cloudflare Workersプロキシパターンをやる

レスポンスヘッダの追加

import { Hono } from 'hono'

const app = new Hono()

app.all('*', async (c) => {
  const res = await fetch(c.req.raw)
  const newResponse = new Response(res.body, res)
  newResponse.headers.set('X-Custom', 'Foo')
  return newResponse
})

export default app

CORS

app.use('/api/*', cors())
app.use(
  '/api2/*',
  cors({
    origin: 'http://example.com',
    allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'],
    allowMethods: ['POST', 'GET', 'OPTIONS'],
    exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
    maxAge: 600,
    credentials: true,
  })
)

Basic認証

app.use(
  '/auth/*',
  basicAuth({
    username: 'yourname',
    password: 'yoursecret',
  })
)

CloudflareのBindingsの変数を使う場合

app.use('/auth/*', async (c, next) => {
  const auth = basicAuth({
    username: c.env.USERNAME,
    password: c.env.PASSWORD,
  })
  return auth(c, next)
})

認証は以下のミドルウェアがある

リダイレクト

app.get('/old/:id', async (c) => {
  const id = c.req.param('id')
  return c.redirect(`/new/${id}`)
})

オリジンの振り分け

const imageHost = 'http://imagehost'
const fontHost = 'http://fonthost'

app.get('/assets/:type{(?:images|fonts)}/:filename', async (c) => {
  const { type, filename } = c.req.param()
  const hostName = type === 'images' ? imageHost : fontHost
  const url = new URL(`/${filename}`, hostName)
  return fetch(url)
})

キャッシュ

app.get(
  '/assets/*',
  cache({
    cacheName: 'my-app',
  })
)

デバイス別の挙動変更

app.get('/pages/*', async (c) => {
  let isMobile = false
  const userAgent = c.req.header('User-Agent') || ''

  if (userAgent.match(/(iPhone|iPod|Android|Mobile)/)) {
    isMobile = true
  }

  const cache = caches.default

  const device = isMobile ? 'Mobile' : 'Desktop'
  const cacheKey = c.req.url + '-' + device

  let response = await cache.match(cacheKey)

  if (!response) {
    response = await fetch(c.req.raw)
    response = new Response(response.body, response)
    response.headers.append('Cache-Control', 's-maxage=3600')
    c.executionCtx.waitUntil(cache.put(cacheKey, response.clone()))
  }

  return response
})

HTMLタグの置換

HTMLRewriterを使う

app.get('/pages/*', async (c) => {
  const OLD_URL = 'oldhost'
  const NEW_URL = 'newhost'

  class AttributeRewriter {
    constructor(attributeName) {
      this.attributeName = attributeName
    }
    element(element) {
      const attribute = element.getAttribute(this.attributeName)
      if (attribute) {
        element.setAttribute(this.attributeName,
	  attribute.replace(OLD_URL, NEW_URL))
      }
    }
  }

  const rewriter = new HTMLRewriter().on('a', new AttributeRewriter('href'))

  const res = await fetch(c.req.raw)
  const contentType = res.headers.get('Content-Type')

  if (contentType.startsWith('text/html')) {
    return rewriter.transform(res)
  } else {
    return res
  }
})

その他

6. Web API編

以下基本をやる

6.1 Blog APIをつくってみよう

雛形をダウンロード

npx degit yusukebe/cloudflare-workshop-examples/projects/starter-web-api starter-web-api

ディレクトリ構成

$ tree ./
./
├── blog.sql
├── package.json
├── src
│   └── index.ts
├── test
│   └── index.test.ts
├── tsconfig.json
├── vitest.config.ts
└── wrangler.sample.toml

D1の設定

wrangler d1 create blog
wrangler d1 execute blog --local --file ./blog.sql
wrangler d1 execute blog --local --command "INSERT INTO posts(id,title,content) VALUES('1','Hello','Nice day!')"
wrangler d1 execute blog --local --command "SELECT * FROM posts"

wrangler.toml

name = "blog"
compatibility_date = "2023-01-01"

[[d1_databases]]
binding = "DB"
database_name = "blog"
database_id = ""

# [vars]
# MY_VARIABLE = "production_value"

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

# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
CREATE TABLE posts (
  id TEXT PRIMARY KEY,
  created_at TEXT DEFAULT (datetime('now')),
  title TEXT,
  content TEXT
);
npm i zod @hono/zod-validator

共通で使う型

import { z } from 'zod'

const schema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
})

export type Bindings = {
  DB: D1Database
}
export type Post = z.infer<typeof schema> & {
  created_at: string
  id: string
}

GET /postsの実装

const { results } = await c.env.DB.prepare(
  'SELECT * FROM posts ORDER BY created_at DESC;'
).all<Post>()

POST /postsの実装

Zod Validatorを使う

import { zValidator } from '@hono/zod-validator'
// ...
app.post('/posts', zValidator('form', schema), async (c) => {
  const { title, content } = c.req.valid('form')
  // ...
})

UUIDの生成

const id = crypto.randomUUID()

Insertの実行

const { success } = await c.env.DB.prepare(
  'INSERT INTO posts(id, title, content) values (?, ?, ?)'
)
  .bind(id, title, content)
  .run()

デプロイ

npm run deploy

実際はWranglerを実行している

wrangler deploy --minify src/index.ts

7. フルスタック編

7.1 BlogのUIもつくってみよう

完成形

SS

雛形のダウンロード

npx degit yusukebe/cloudflare-workshop-examples/projects/starter-pages pages

ディレクトリ構造

$ tree .
.
├── front
│   ├── App.tsx // Reactアプリ
│   └── main.tsx // クライントのエントリポイント
├── index.html // エントリポイント
├── package.json
├── tsconfig.json
├── vite.config.ts
└── wrangler.sample.toml

プロキシさせてViteを使う

wrangler pages dev --compatibility-date=2023-01-01 -- vite

Functionsを使う

// functions/api/[[route]].ts
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
import app from '../../server'

const main = new Hono()
main.route('/api', app)

export const onRequest = handle(main)

RPCモードを使う

// front/App.tsx
const client = hc<AppType>('/api')
const res = await client.posts.$get()
const { posts } = await res.json()
setPosts(posts)

7.2 SSRでつくってみよう

完成品のダウンロード

npx degit yusukebe/cloudflare-workshop-examples/projects/ssr ssr

7.3 R2画像アップローダーをつくってみよう

完成品のダウンロード

npx degit yusukebe/image-tag image-tag

ディレクトリ構成

tree .
.
├── .dev.vars
├── .gitignore
├── README.md
├── package.json
├── schema.sql
├── src
│   ├── index.tsx
│   └── renderer.ts
├── tsconfig.json
└── wrangler.toml

R2のバケットをつくる

npx wrangler r2 bucket create image-tag

D1のデータベースをつくる

スキーマ

CREATE TABLE images (
  id TEXT PRIMARY KEY,
  created_at TEXT DEFAULT (datetime('now')),
  tag TEXT
);

実行

npx wrangler d1 create image-tag
npx wrangler d1 execute image-tag --local --file=./schema.sql

シークレットのつくり方

ローカルでは.dev.varsに値を入れる

BASIC_AUTH_USERNAME=foo
BASIC_AUTH_PASSWORD=bar

リモートではダッシュボードからやる

TSX

mv src/index.ts src/.index.tsx

src/renderer.ts

import { Context } from 'hono'
import { html } from 'hono/html'

export const renderer = (c: Context) => (content: string) => {
  return c.html(html`<!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css" />
      </head>
      <body>
        <main class="container">${content}</main>
      </body>
    </html>`)
}

src/index.ts

Bindings

type Bindings = {
  DB: D1Database
  BUCKET: R2Bucket
  BASIC_AUTH_USERNAME: string
  BASIC_AUTH_PASSWORD: string
}

Image

type Image = {
  id: string
  tag: string
  created_at: string
}

Rendererの登録

app.get('*', async (c, next) => {
  c.setRenderer(renderer(c))
  await next()
})

POST /

const schema = z.object({
  file: z.instanceof(File),
  tag: z.string().min(1)
})

app.post('/', zValidator('form', schema), async (c) => {
  const { file, tag } = c.req.valid('form')
  const id = crypto.randomUUID()
  if (file instanceof File) {
    const data = await file.arrayBuffer()
    const object = await c.env.BUCKET.put(id, data, {
      httpMetadata: {
        contentType: file.type
      }
    })
    if (object) {
      console.log(`${object.key} is uploaded!`)
      const { success } = await c.env.DB.prepare('INSERT INTO images(id,tag) VALUES(?,?)').bind(id, tag).run()
      if (!success) {
        return c.text('Something went wrong', 500)
      }
    }
  }
  return c.redirect('/')
})

GET /file/:fileName

app.get('/file/:fileName', async (c) => {
  const object = await c.env.BUCKET.get(c.req.param('fileName'))
  if (object) {
    c.header('content-type', object.httpMetadata?.contentType)
    return c.body(await object.arrayBuffer())
  }
  return c.notFound()
})

GET /

app.get('/', async (c) => {
  const { results } = await c.env.DB.prepare('SELECT * FROM images ORDER BY created_at DESC').all<Image>()
  const images = results

  return c.render(
    <>
      <h1>Top!</h1>
      <form method="POST" action="/" enctype="multipart/form-data">
        <input type="file" name="file" accept="image/*" />
        <input type="text" name="tag" />
        <button type="submit">Submit</button>
      </form>
      <div>
        {images.map((image) => {
          return (
            <div>
              <h3>{image.tag}</h3>
              <img src={`/file/${image.id}`} />
            </div>
          )
        })}
      </div>
    </>
  )
})

Basic認証のためのミドルウェア

const myBasicAuth = middleware<{
  Bindings: Bindings
}>(async (c, next) => {
  const auth = basicAuth({
    username: c.env.BASIC_AUTH_USERNAME,
    password: c.env.BASIC_AUTH_PASSWORD
  })
  return await auth(c, next)
})

GET /api/random

const querySchema = z.object({
  tag: z.string().min(1)
})

app.get('/api/random', zValidator('query', querySchema), async (c) => {
  const { tag } = c.req.valid('query')
  const { results } = await c.env.DB.prepare('SELECT * FROM images WHERE tag = ? ORDER BY RANDOM() LIMIT 1')
    .bind(tag)
    .all<Image>()
  const images = results
  return c.json(images)
})

デプロイ

image-tagという名前でデプロイしておく

8. AI編

8.1 ChatGPTのPluginをつくってみよう

完成形

output

完成品のダウンロード

npx degit yusukebe/cloudflare-workshop-examples/projects/ssr ssr

ディレクトリ構造

$ tree .
.
├── package.json
├── src
│   ├── ai-plugin.ts
│   ├── index.ts
│   └── routes.ts
└── tsconfig.json

ドキュメント

APIの定義を書く

自然言語で書けばよい

// src/ai-plugin.ts
// GET /.well-known/ai-plugin.json にマッピングされる
export const aiPluginJson = {
  schema_version: 'v1',
  name_for_human: 'TODO List (No Auth)',
  name_for_model: 'todo',
  description_for_human: 'Manage your TODO list. You can add, remove and view your TODOs.',
  description_for_model:
    'Plugin for managing a TODO list, you can add, remove and view your TODOs.',
  auth: {
    type: 'none',
  },
  api: {
    type: 'openapi',
    url: 'http://localhost:8787/openapi.json',
  },
  logo_url:
    'https://ss.yusukebe.com/a4ebf3360db8e05185b83866352539ff4a587f2df97273258551dbb34c88b792_800x744.png',
  contact_email: '[email protected]',
  legal_info_url: 'https://example.com/legal',
}

Zod OpenAPI

インストール

@hono/zod-openapi

Routeの定義。Zodを使う

import { z, createRoute } from '@hono/zod-openapi'

const UsernameParamsSchema = z.object({
  username: z.string().openapi({
    description: 'The name of user.',
  }),
})

const routeGetTodos = createRoute({
  method: 'get',
  path: '/todos/{username}',
  operationId: 'getTodos',
  summary: 'Get the list of todos',
  request: {
    params: UsernameParamsSchema,
  },
  responses: {
    '200': {
      description: 'OK',
      content: {
        'application/json': {
          schema: z.object({
            todos: z.array(z.string()).openapi({
              description: 'The list of todos.',
            }),
          }),
        },
      },
    },
  },
})

エンドポイントの実装

app.openapi(routeGetTodos, (c) => {
  const { username } = c.req.valid('param')
  return c.jsonT({
    todos: _TODOS[username],
  })
})

8.2 ChatGPTのGatewayをつくってみよう

完成形

output

完成品のダウンロード

npx degit yusukebe/cloudflare-workshop-examples/projects/chatgpt-streaming chatgpt-streaming

Sonik

実は開発中のSonikというメタフレームワークを使っている。

ディレクトリ構造

$ tree .
.
├── app
│   ├── client.ts
│   ├── islands
│   │   └── component.tsx
│   ├── routes
│   │   ├── _404.tsx
│   │   ├── _layout.tsx
│   │   └── index.tsx
│   ├── server.ts
│   └── style.css
├── package.json
├── tsconfig.json
└── vite.config.ts

キモはここ

// app/routes/index.tsx
app.post('/api', async (c) => {
  const body = await c.req.json<{ message: string }>()

  const openai = new OpenAI({ apiKey: c.env.OPENAI_API_KEY })
  const chatStream = await openai.chat.completions.create({
    messages: PROMPT(body.message),
    model: 'gpt-3.5-turbo',
    stream: true,
  })

  return c.streamText(async (stream) => {
    for await (const message of chatStream) {
      await stream.write(message.choices[0]?.delta.content ?? '')
    }
  })
})

8.3 先程のR2と組み合わせたChatGPTのPluginをつくってみよう

完成品のダウンロード

npx degit yusukebe/image-tag image-tag-plugin

ディレクトリ構成

tree .
.
├── .gitignore
├── README.md
├── package.json
├── src
│   ├── ai-plugin.ts
│   ├── index.ts
│   └── routes.ts
├── tsconfig.json
└── wrangler.toml

src/ai-plugin.ts

export const aiPluginJson = (baseURL: string) => {
  return {
    schema_version: 'v1',
    name_for_human: 'meshitero',
    name_for_model: 'meshitero',
    description_for_human: 'Meshitero Plugin',
    description_for_model:
      'This plugin is a food-terrorism plugin that shows hungry users the pictures specified in the tag.',
    auth: {
      type: 'none'
    },
    api: {
      type: 'openapi',
      url: `${baseURL}/openapi.json`
    },
    logo_url: 'https://ss.yusukebe.com/4203deb97493d70346bfff59b02d0f769467c29df64c072bc947d4a55a495dec_800x636.png',
    contact_email: '[email protected]',
    legal_info_url: 'https://example.com/legal'
  }
}

routes.ts

import { z, createRoute } from '@hono/zod-openapi'

export const schema = z.object({
  id: z.string(),
  tag: z.string(),
  created_at: z.string(),
  url: z.string()
})

const paramSchema = z.object({
  tag: z.enum(['肉', 'ラーメン'])
})

const routeRandom = createRoute({
  method: 'get',
  path: '/random',
  request: {
    query: paramSchema
  },
  operationId: 'random',
  summary: 'Get a random niku info',
  responses: {
    '200': {
      description: 'OK',
      content: {
        'application/json': {
          schema: schema
        }
      }
    }
  }
})

export { routeRandom }

index.ts

import { OpenAPIHono, z } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import { aiPluginJson } from './ai-plugin'
import { routeRandom, schema } from './routes'

const app = new OpenAPIHono()
app.use('*', cors())

const apiBaseURL = 'https://image-tag.yusukebe.workers.dev'
const baseURL = 'http://localhost:8787'

app.openapi(routeRandom, async (c) => {
  const url = new URL(`${apiBaseURL}/api/random`)
  const { tag } = c.req.valid('query')
  url.searchParams.set('tag', tag)
  console.log(`Request to ${url.toString()}`)
  const resRandom = await fetch(url.toString())
  const images = await resRandom.json<z.infer<typeof schema>[]>()
  const data = images[0]
  data.url = `https://image-tag.yusukebe.workers.dev/file/${data.id}`
  return c.jsonT(data)
})

app.get('/.well-known/ai-plugin.json', (c) => {
  return c.json(aiPluginJson(baseURL))
})

app.doc('/openapi.json', {
  openapi: '3.0.1',
  info: {
    title: 'Meshitero',
    description: 'This plugin is a food-terrorism plugin that shows hungry users the pictures specified in the tag.',
    version: 'v1'
  },
  servers: [
    {
      url: baseURL
    }
  ]
})

export default app

消す

つくったものを消しておく

9. Honoをより深く使う

9.1 他のランタイムで動かしてみよう

  1. aws-lambda
  2. bun
  3. cloudflare-pages
  4. cloudflare-workers
  5. deno
  6. fastly
  7. lagon
  8. lambda-edge
  9. netlify
  10. nextjs
  11. nodejs
  12. vercel

9.2 大きなアプリケーションの構成

9.3 アダプトとマウント

app.mount()

// Create itty-router application
const ittyRouter = IttyRouter()

// Handle `GET /itty-router/hello`
ittyRouter.get('/hello', () => new Response('Hello from itty-router!'))

// Hono application
const app = new Hono()

// Hono application
app.mount('/itty-router', ittyRouter.handle)

Honoはランタイムにアダプトし、フレームワークをマウントする

SS

フレームワークAは各ランタイムへのアダプターを作りがち

SS

フレームワークAを各種ランタイムに対応させたければ、Hono上で動けばよい

SS

マウントすることでHonoのミドルウェアが使える

app.use('/another-app/admin/*', basicAuth({ username, password }))

10. その他

おまけ. この資料もWorkersでできてる

これだけで、MarkdownをHTMLにするやつを作れて、デプロイ、配信までできてしまう

import { Hono } from 'hono'
import { marked } from 'marked'
import { rendererMiddleware } from './renderer'
import index from '../pages/index.md'

const app = new Hono()
app.get('*', rendererMiddleware)

app.get('/', (c) =>
  c.render(marked(index), {
    title: 'Cloudflare Workers + Hono Workshop'
  })
)

種明かし

リンク集