og-generatorを作りなおす
モチベーション
今デプロイされている ogp.re-taro.dev が og 生成器としては冗長に感じたので Cloudflare workers と Satori を用いて再実装する
想定実装
- 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.
wrangler init して wrangler.toml が生成された
worker に少ししか触ってこなかったせいでSWスタイルしかしらない。お前 ESM で書けるのかよ
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"
}
]
フォントをr2に配置できた
生成するcardのコンポーネントはこれ
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に変換する処理
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に対して型をつけてあげたほうが良い
declare module '*.wasm' {
declare const value: WebAssembly.Module;
export default value;
}
FWにはHonoを採用する。
とりあえず、前のAPIにある程度互換性を持たせる
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から取ってくる
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を設定していなかった
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ってこっちじゃね?ってなりました
なるほど、これを使わずに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標準のものを意識してるんだけど、微妙に違うらしい?単一のグローバルオブジェクトで提供されてるみたい。
いやでもcaches.openで一応追加でインスタンスが作れるのか。
ETagってなにか分からなかったので調べる
なるほど、ブラウザはコンテンツごとにハッシュを持っていて、リクエストに添えるのか。
それをサーバー側で比較検証して、更新の有無をクライアントに返却するのか。
処理順序とししては、こう?
- クライアントからサーバへコンテンツを要求
- HTTPサーバがレスポンスヘッダーにETagにコンテンツのハッシュ値を付加して返す
- HTTPサーバにて、送信されたIf-None-Matchと最新のコンテンツのハッシュ値を比較し、
- 一致すれば、HTTP Status Code 304 を返却する。ボディは返却しない
- 一致しなければ、HTTP Status Code 200 および最新のコンテンツ、それに応じたETagヘッダー付加して、返却する
最終的にこうなった
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しないとだめらしい