Open31

og-generatorを作りなおす

りんたろーりんたろー

想定実装

  • Cloudflare workers に投げる
  • R2使用する assets(fonts, etc...)を置く
  • Cache API を用いてキャッシュする
    • きちんと勉強した記憶がないのでキャッチアップする
  • FW に Hono を使う
りんたろーりんたろー

wrangler init するために login する

> pnpm wrangler login
 ⛅️ wrangler 3.0.0 (update available 3.0.1)
-----------------------------------------------------
Attempting to login via OAuth...
Successfully logged in.
りんたろーりんたろー

worker に少ししか触ってこなかったせいでSWスタイルしかしらない。お前 ESM で書けるのかよ

src/index.ts
export default {
  async fetch(request: Request): Promise<Response> {
    return new Response("Hello World!");
  },
};
りんたろーりんたろー

既存の Next.js のプロジェクトに Hono やSatori, Yoga などを追加した

───────┬───────────────────────────────────────────────────────────────────────────────
       │ File: package.json
───────┼───────────────────────────────────────────────────────────────────────────────
   1{
   2"name": "ogp.re-taro.dev",
   3"description": "My personal OGP API",
   4"version": "2.2.10",
   5"packageManager": "pnpm@8.6.0",
   6"license": "MIT",
   7"author": {
   8"name": "Rintaro Itokawa",
   9"email": "me@re-taro.dev",
  10"url": "https://re-taro.dev"
  11},
  12"repository": {
  13"type": "git",
  14"url": "https://github.com/re-taro/ogp.re-taro.dev"
  15},
  16"scripts": {
  17"deploy": "wrangler deploy",
  18"build": "wrangler deploy --dry-run --minify --outdir dist",
  19"start": "wrangler dev",
  20"lint": "eslint . --ext .cjs,.ts,.tsx ",
  21"lint:fix": "eslint . --ext .cjs,.ts,.tsx --fix",
  22"fmt": "prettier --write .",
  23"test": "vitest"
  24},
  25"dependencies": {
  26"@resvg/resvg-wasm": "2.4.1",
  27"hono": "3.2.3",
  28"react": "18.2.0",
  29"satori": "0.9.1",
  30"yoga-wasm-web": "0.3.3"
  31},
  32"devDependencies": {
  33"@cloudflare/workers-types": "4.20230419.0",
  34"@re-taro/eslint-config": "1.9.6",
  35"@types/react": "18.2.7",
  36"eslint": "8.40.0",
  37"prettier": "2.8.8",
  38"typescript": "4.9.5",
  39"wrangler": "3.0.0"
  40}
  41}
───────┴───────────────────────────────────────────────────────────────────────────────
りんたろーりんたろー

font や cache を置く場所として R2 を採用する

> pnpm wrangler r2 bucket create ogp
⛅️ wrangler 3.0.0 (update available 3.0.1)
-------------------
Creating bucket ogp.
Created bucket ogp.
りんたろーりんたろー

テストとかで期間が空いてしまった

r2が作成されているか確認する

> pnpm wrangler r2 bucket list
[
  {
    "name": "ogp",
    "creation_date": "2023-05-30T10:39:46.031Z"
  }
]
りんたろーりんたろー

生成するcardのコンポーネントはこれ

src/card.tsx
import type { FC } from 'react';

export type CardProps = {
  title: string;
  date: string;
  domain: string;
  icon: string;
};

export const Card: FC<CardProps> = ({ title, date, domain, icon }) => (
  <div
    style={{
      height: '100%',
      width: '100%',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      padding: '30px',
      fontFamily: 'NotoSansJP',
      backgroundColor: '#2E3440',
      color: '#E5E9F0',
    }}
  >
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        padding: '12px',
        width: '100%',
        height: '100%',
        borderStyle: 'solid',
        borderColor: '#FFFFFF',
        borderWidth: '4px',
        borderRadius: '20px',
      }}
    >
      <div
        style={{
          display: 'flex',
          flex: 1,
          maxWidth: '100%',
          alignItems: 'center',
          maxHeight: '100%',
        }}
      >
        <h1
          style={{
            fontSize: '60px',
            lineHeight: 'tight',
            maxWidth: '100%',
            width: '100%',
            textAlign: 'center',
          }}
        >
          {title}
        </h1>
      </div>
      <div
        style={{
          display: 'flex',
          flexDirection: 'row',
          justifyContent: 'space-between',
          alignItems: 'center',
          width: '100%',
        }}
      >
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
          }}
        >
          <img
            src={icon}
            alt="icon"
            width={100}
            height={100}
            style={{ borderRadius: '50%', marginRight: '5px' }}
          />
          <h2
            style={{
              fontSize: '36px',
              marginRight: '5px',
              fontFamily: 'JetBrainsMono',
            }}
          >
            {domain}
          </h2>
        </div>
        <div
          style={{
            display: 'flex',
          }}
        >
          <h2
            style={{
              fontSize: '36px',
            }}
          >
            {date}
          </h2>
        </div>
      </div>
    </div>
  </div>
);
りんたろーりんたろー

satoriとresvgを使って、ReactNodeをpngに変換する処理

src/image.ts
import satori, { init as initSatori } from 'satori/wasm';
import initYoga from 'yoga-wasm-web';
import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
import wasmYoga from '../node_modules/yoga-wasm-web/dist/yoga.wasm';
import wasmResvg from '../node_modules/@resvg/resvg-wasm/index_bg.wasm';
import type { ReactNode } from 'react';

const yoga = await initYoga(wasmYoga);
initSatori(yoga);
await initResvg(wasmResvg);

type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type Style$1 = 'normal' | 'italic';
type FontOptions = {
  data: ArrayBuffer;
  name: string;
  weight?: Weight;
  style?: Style$1;
  lang?: string;
};

export async function generateImage(
  element: ReactNode,
  width: number,
  height: number,
  fonts: Array<FontOptions>,
  graphemeImages: Record<string, string>,
): Promise<Uint8Array> {
  const svg = await satori(element, {
    width,
    height,
    fonts,
    graphemeImages,
  });
  const png = new Resvg(svg).render().asPng();

  return png;
}
りんたろーりんたろー

このとき、.wasmに対して型をつけてあげたほうが良い

src/types.d.ts
declare module '*.wasm' {
  declare const value: WebAssembly.Module;
  export default value;
}
りんたろーりんたろー

FWにはHonoを採用する。
とりあえず、前のAPIにある程度互換性を持たせる

src/index.tsx
import { Hono } from 'hono';
import { Card } from './card';
import { generateImage } from './image';

const app = new Hono();

app.get('/', async (c) => {
  const title = c.req.query('title') ?? '';
  const date = c.req.query('date') ? `📅 ― ${c.req.query('date')}` : '';
  const domain = c.req.query('domain')
    ? (c.req.query('domain') as string)
    : 're-taro.dev';

  const image = await generateImage(
    <Card title={title} date={date} domain={domain}  />,
    1200,
    630,
    {
      '📅': 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f4c5.svg',
    },
  );

  return new Response(image);
});

export default app;
りんたろーりんたろー

このままだとフォントやiconが適用されないのでr2から取ってくる

src/index.tsx
import { Hono } from 'hono';
import { Card } from './card';
import { generateImage } from './image';

+ type Bindings = {
+   BUCKET: R2Bucket;
+ };

- const app = new Hono();
+ const app = new Hono<{ Bindings: Bindings }>();

+ let notoSansBuf: null | ArrayBuffer = null;
+ let jbMonoBuf: null | ArrayBuffer = null;
+ let iconKey: null | string = null;

app.get('/', async (c) => {
  const title = c.req.query('title') ?? '';
  const date = c.req.query('date') ? `📅 ― ${c.req.query('date')}` : '';
  const domain = c.req.query('domain')
    ? (c.req.query('domain') as string)
    : 're-taro.dev';

+ if (notoSansBuf === null) {
+   const fontObj = await c.env.BUCKET.get('fonts/NotoSansJP-Bold.woff2');
+   if (fontObj === null || typeof fontObj === 'undefined') {
+     return c.text('Failed to get font', 500, {
+       'Content-Type': 'text/plain',
+     });
+   }
+   notoSansBuf = await fontObj.arrayBuffer();
+ }
+ if (jbMonoBuf === null) {
+   const fontObj = await c.env.BUCKET.get('fonts/JetBrainsMono-Medium.woff');
+   if (fontObj === null || typeof fontObj === 'undefined') {
+     return c.text('Failed to get font', 500, {
+       'Content-Type': 'text/plain',
+     });
+   }
+   jbMonoBuf = await fontObj.arrayBuffer();
+ }
+ if (iconKey === null) {
+   const iconObj = await c.env.BUCKET.get('assets/rintaro.jpg');
+   if (iconObj === null || typeof iconObj === 'undefined') {
+     return c.text('Failed to get icon', 500, {
+       'Content-Type': 'text/plain',
+     });
+   }
+   iconKey = iconObj.key;
+ }
  const image = await generateImage(
-     <Card title={title} date={date} domain={domain} />,
+     <Card title={title} date={date} domain={domain} icon={iconKey} />,
    1200,
    630,
+ [
+   {
+     name: 'NotoSansJP',
+     data: notoSansBuf,
+     weight: 700,
+     style: 'normal',
+   },
+   {
+     name: 'JetBrainsMono',
+     data: jbMonoBuf,
+     weight: 500,
+     style: 'normal',
+   },
+ ],
    {
      '📅': 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f4c5.svg',
    },
  );

  return new Response(image);
});

export default app;
りんたろーりんたろー

愚かなのでbindingを設定していなかった

wrangler.toml
name = "ogp"
main = "src/index.tsx"
compatibility_date = "2023-05-18"

+ [[r2_buckets]]
+ binding = "BUCKET"
+ bucket_name = "ogp"
+ preview_bucket_name = "ogp"
りんたろーりんたろー

Cache APIについて

  • 基本的にどのブラウザでも使える
  • cachesプロパティを通じて公開されている
  • reqとresのペアを格納できる
    • HTTPで転送できるデータならなんでも格納できる
  • デバイスの使用可能なストレージ量によって保管可能データ数はかわる
    • 前にTBSかなにかが話題になってたのってこの辺の話だったっけ?
りんたろーりんたろー

ここまで調べて、あれCloudflare workerで動作するCache APIってこっちじゃね?ってなりました

https://developers.cloudflare.com/workers/runtime-apis/cache/

りんたろーりんたろー

なるほど、これを使わずにKVを使う人が多い理由に、別のデータセンターには複製されないからなのか

The Cache API is available globally but the contents of the cache do not replicate outside of the originating data center. A GET /users response can be cached in the originating data center, but will not exist in another data center unless it has been explicitly created.

りんたろーりんたろー

Cloudflare WorkersにあるCache APIは結構Web標準のものを意識してるんだけど、微妙に違うらしい?単一のグローバルオブジェクトで提供されてるみたい。

りんたろーりんたろー

なるほど、ブラウザはコンテンツごとにハッシュを持っていて、リクエストに添えるのか。
それをサーバー側で比較検証して、更新の有無をクライアントに返却するのか。

りんたろーりんたろー

処理順序とししては、こう?

  • クライアントからサーバへコンテンツを要求
  • HTTPサーバがレスポンスヘッダーにETagにコンテンツのハッシュ値を付加して返す
  • HTTPサーバにて、送信されたIf-None-Matchと最新のコンテンツのハッシュ値を比較し、
    • 一致すれば、HTTP Status Code 304 を返却する。ボディは返却しない
    • 一致しなければ、HTTP Status Code 200 および最新のコンテンツ、それに応じたETagヘッダー付加して、返却する
りんたろーりんたろー

最終的にこうなった

src/index.tsx
import { Hono } from 'hono';
import { etag } from 'hono/etag';
import { cache } from 'hono/cache';
import { Card } from './card';
import { generateImage } from './image';

type Bindings = {
  BUCKET: R2Bucket;
};

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

let notoSansBuf: null | ArrayBuffer = null;
let jbMonoBuf: null | ArrayBuffer = null;
let iconKey: null | string = null;

app.use(
  '/',
  etag(),
  cache({
    cacheName: 'ogp',
    cacheControl: 'public, max-age=604800',
  }),
);

app.get('/', async (c) => {
  const title = c.req.query('title') ?? '';
  const date = c.req.query('date') ? `📅 ― ${c.req.query('date')}` : '';
  const domain = c.req.query('domain')
    ? (c.req.query('domain') as string)
    : 're-taro.dev';

  const cache = caches.default;
  const cachedRes = await cache.match(c.req.url);
  if (cachedRes) {
    const etag = c.req.headers.get('If-None-Match');
    if (etag !== null && etag === cachedRes.headers.get('ETag')) {
      return new Response(null, {
        status: 304,
        headers: cachedRes.headers,
      });
    }

    return cachedRes;
  }

  const key = `${title}-${date}-${domain}`;
  const cachedImage = await c.env.BUCKET.get(`cache/${key}.png`);
  if (cachedImage !== null && typeof cachedImage !== 'undefined') {
    const res = new Response(cachedImage.body, {
      headers: {
        'Cache-Control': 'public, max-age=604800',
        ETag: `W/${cachedImage.httpEtag}`,
        'Content-Type':
          cachedImage.httpMetadata?.contentType ?? 'application/octet-stream',
      },
    });

    return res;
  }

  if (notoSansBuf === null) {
    const fontObj = await c.env.BUCKET.get('fonts/NotoSansJP-Bold.ttf');
    if (fontObj === null || typeof fontObj === 'undefined') {
      return c.text('Failed to get font', 500, {
        'Content-Type': 'text/plain',
      });
    }
    notoSansBuf = await fontObj.arrayBuffer();
  }
  if (jbMonoBuf === null) {
    const fontObj = await c.env.BUCKET.get('fonts/JetBrainsMono-Medium.ttf');
    if (fontObj === null || typeof fontObj === 'undefined') {
      return c.text('Failed to get font', 500, {
        'Content-Type': 'text/plain',
      });
    }
    jbMonoBuf = await fontObj.arrayBuffer();
  }
  if (iconKey === null) {
    const iconObj = await c.env.BUCKET.get('assets/rintaro.jpg');
    if (iconObj === null || typeof iconObj === 'undefined') {
      return c.text('Failed to get icon', 500, {
        'Content-Type': 'text/plain',
      });
    }
    iconKey = iconObj.key;
  }
  const image = await generateImage(
    <Card title={title} date={date} domain={domain} icon={iconKey} />,
    1200,
    630,
    [
      {
        name: 'NotoSansJP',
        data: notoSansBuf,
        weight: 700,
        style: 'normal',
      },
      {
        name: 'JetBrainsMono',
        data: jbMonoBuf,
        weight: 500,
        style: 'normal',
      },
    ],
    {
      '📅': 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f4c5.svg',
    },
  );
  await c.env.BUCKET.put(`cache/${key}.png`, image);

  return new Response(image, {
    headers: {
      'Cache-Control': 'public, max-age=604800',
      'Conetnt-Type': 'image/png',
    },
  });
});

export default app;
りんたろーりんたろー

デバッグしてたらこれ出たんだけど、普通にworkerにdeployしないとだめらしい