📸

vercel/og-imageをNetlify Functionsで動かせるように改造する

2022/02/10に公開

vercel/og-imageについて

https://github.com/vercel/og-image

Vercelが公開しているOG画像生成用のServerless Functionの例。

当然Vercelにデプロイする前提で作られているので、そのままではNetlifyにデプロイできません。

なんでNetlify Functionsにするかと言うと、Hobbyプランだと組織のリポジトリを使うことができず不便だからです。

https://github.com/vercel/og-image/issues/172

メインコントリビューターのSteven氏が「Netlify Functionsに書き換える必要あるけど、試してないわ」と言ってそのままCloseされているissue。

ということで試してみましょう。

リファクタリング

netlify.tomlを用意

[build]
  command = "yarn build"
  publish = "public"

[functions]
  directory = "api"
  external_node_modules = ["chrome-aws-lambda"]

chrome-aws-lambdaを外部モジュールとして指定する必要があります。

また、Netlify FunctionsはTypeScriptをサポートしているため、api/distを指定する必要はありません。

https://docs.netlify.com/configure-builds/file-based-configuration/#functions

esbuild: method that leverages esbuild to bundle functions, resulting in shorter bundling times and smaller artifacts. Currently available as an opt-in beta for JavaScript functions. TypeScript functions always use esbuild.

デプロイが割と速いなと思ったら、esbuildが使われているそうです。

ローカルでデバッグできるように

npm i -g netlify-cli
netlify login

Netlify CLIを使います。

package.json
  "scripts": {
-    "build": "tsc -p api/tsconfig.json && tsc -p web/tsconfig.json"
+    "build": "tsc -p api/tsconfig.json --noEmit && tsc -p web/tsconfig.json",
+    "dev": "yarn build && netlify dev --port 3003",
+    "dev:html": "OG_HTML_DEBUG=1 yarn dev"
  },

API部分のビルドは--noEmitで型チェックだけにします。 先述した通り向こうがesbuildを使ってくれるからです。

echo .netlify >> .gitignore

.netlify/functions-serve/indexができるため除外します。

追加パッケージを用意してindex.tsを書き換え

yarn add -D @netlify/functions
api/index.ts
-import { IncomingMessage, ServerResponse } from 'http';
import { parseRequest } from './_lib/parser';
import { getScreenshot } from './_lib/chromium';
import { getHtml } from './_lib/template';
+import { Handler } from '@netlify/functions';
+import { Buffer } from 'buffer';

-const isDev = !process.env.AWS_REGION;
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1';

-export default async function handler(req: IncomingMessage, res: ServerResponse) {
+export const handler: Handler = async (event, ctx) => {
+  const isDev = !ctx.clientContext?.custom?.netlify;
  try {
-    const parsedReq = parseRequest(req);
+    const parsedReq = parseRequest(event);
    const html = getHtml(parsedReq, isHtmlDebug);
    if (isHtmlDebug) {
-      res.setHeader('Content-Type', 'text/html');
-      res.end(html);
-      return;
+      return {
+        statusCode: 200,
+        headers: {
+          'Content-Type': 'text/html',
+          'Cache-Control': '',
+        },
+        body: html,
+      };
    }
    const { fileType } = parsedReq;
    const file = await getScreenshot(html, fileType, isDev);
-    res.statusCode = 200;
-    res.setHeader('Content-Type', `image/${fileType}`);
-    res.setHeader('Cache-Control', `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`);
-    res.end(file);
+    return {
+      statusCode: 200,
+      headers: {
+        'Content-Type': `image/${fileType}`,
+        'Cache-Control': `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`,
+      },
+      body: Buffer.from(file).toString('base64'),
+      isBase64Encoded: true,
+    };
  } catch (e) {
-    res.statusCode = 500;
-    res.setHeader('Content-Type', 'text/html');
-    res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>');
+    return {
+      statusCode: 500,
+      headers: {
+        'Content-Type': 'text/html',
+        'Cache-Control': '',
+      },
+      body: '<h1>Internal Error</h1><p>Sorry, there was a problem</p>',
+    };
    console.error(e);
  }
};

AWS_REGIONでプロダクション判定していましたが、Netlify CLIがLambdaをエミュレートする際にAWS CLIのリージョン設定を拾うらしく、私はap-northeast-1が勝手に入ってしまいました。

https://docs.netlify.com/configure-builds/environment-variables/#build-metadata

Netlify側が設定する環境変数はこちらで解説されているんですが、LambdaではNODE_ENVすら定義されていないらしく、ctx.clientContext?.custom?.netlifyを使うことでやっとこさ本番の判定ができました。

パーサー部分の書き換え

api/_lib/parser.ts
-import { IncomingMessage } from 'http';
+import { Event } from '@netlify/functions/dist/function/event';
import { parse } from 'url';
import { ParsedRequest, Theme } from './types';

-export function parseRequest(req: IncomingMessage) {
+export function parseRequest(req: Event) {
    console.log('HTTP ' + req.url);
-   const { pathname, query } = parse(req.url || '/', true);
+   const { pathname, query } = parse(req.path.replace('/.netlify/functions/index', '') || '/', true);

/.netlify/functions/index/が付いてしまったので応急処置。あと、NetlifyのHandlerに合わせた型定義に変えています。

web部分の書き換え

web/index.ts
-url.pathname = `${encodeURIComponent(text)}.${fileType}`;
+url.pathname = `/.netlify/functions/index/${encodeURIComponent(text)}.${fileType}`;

これも応急処置。Web部分は実使用だと使わないかも。私はまるごと消しています。

動作確認

3:33:12 AM: ────────────────────────────────────────────────────────────────
3:33:12 AM:   1. build.command from netlify.toml                            
3:33:12 AM: ────────────────────────────────────────────────────────────────
3:33:12 AM: ​
3:33:12 AM: $ yarn build
3:33:12 AM: yarn run v1.22.10
3:33:12 AM: $ tsc -p api/tsconfig.json --noEmit
3:33:18 AM: Done in 6.16s.
3:33:18 AM: ​
3:33:18 AM: (build.command completed in 6.4s)
3:33:18 AM: ​
3:33:18 AM: ────────────────────────────────────────────────────────────────
3:33:18 AM:   2. Functions bundling                                         
3:33:18 AM: ────────────────────────────────────────────────────────────────
3:33:18 AM: ​
3:33:18 AM: Packaging Functions from api directory:
3:33:18 AM:  - index.ts
3:33:18 AM: ​
3:33:24 AM: ​
3:33:24 AM: (Functions bundling completed in 5.8s)
3:33:24 AM: ​
3:33:24 AM: ────────────────────────────────────────────────────────────────
3:33:24 AM:   3. Deploy site                                                
3:33:24 AM: ────────────────────────────────────────────────────────────────
3:33:24 AM: ​
3:33:24 AM: Starting to deploy site from 'og-image/public'
3:33:24 AM: Creating deploy tree 
3:33:24 AM: Creating deploy upload records
3:33:24 AM: 0 new files to upload
3:33:24 AM: 1 new functions to upload
3:33:40 AM: Site deploy was successfully initiated
3:33:40 AM: ​
3:33:40 AM: (Deploy site completed in 16.3s)
3:33:40 AM: 

Webを消した場合のログはこんな感じになります。

環境 レスポンス
Netlify Netlify
Vercel Vercel

Netlifyの方はcontent-lengthが追加されないようですね。

strict-transport-securityの設定にはnetlify.tomlの編集が必要らしいですが、長くなるので割愛。

注意

リクエスト回数の制限が違う

https://www.netlify.com/pricing#features

2022年2月10日現在、Netlify FunctionsのStarter(無料)におけるリクエスト回数上限は125k per site /month ($25+ when exceeded)です。

https://vercel.com/pricing

で、Vercelはプランに関係なく「Unlimited Serverless Function Invocations」らしいです。太っ腹ですね。

URLが長くなってしまう

https://アプリ名.netlify.app/.netlify/functions/index/記事タイトル.png

Vercelにはなかった/.netlify/functions/indexが付いてこうなるのですが、何かしら経由して短縮させるといった方法を取らないと使いづらいですね。

参考

https://docs.netlify.com/functions/build-with-typescript/

Discussion

ログインするとコメントできます