Netlify Edge Functions を試す(Deno)

先日の Supabase Edge Functionsに続いて、Netlify Edge Function が公開されました。

Supabase Edge Functions は、認証情報を要求することもありブラウザアクセスしてページを返すのは少し難しいようでした。
比較として Netlify Edge Function は、ブラウザアクセスもできるより Deno Deploy っぽいサービスとして使えるものになっていました。

というわけで、動作の確認もできたのでメモしておきます。

参考

準備

Deno - Netlify Edge Functions on Deno Deploy にある netlify-cli を使いたかったのですが、挫折。
(インストール時、どうしてもエラーになったのであきらめた。)

ということで、cli を使わずに進めます。
前提として以下の準備をします。

  1. github にリポジトリを作成(gitlab でもイイみたいだけど使ってないので…)
  2. Netlify にアカウントを作成
  3. Netlify でサイトを作成
  4. サイトの参照先リポジトリを 1. で作ったリポジトリにする

これで、github に push すると、Netlify でビルドが開始されてデプロイできる。

Netlify Edge Functions をデプロイ

git 管理したディレクトリの最小構成は次のようになります。

1
2
3
4
5
6
$ tree
.
|-- netlify
| `-- edge-functions
| `-- first-function.ts
`-- netlify.toml

netlify.toml の中身は次の通り。

netlify.toml
1
2
3
[[edge_functions]]
function = "first-function"
path = "/"

netlify/edge-functions/first-function.ts の中身は次の通り。

netlify/edge-functions/first-function.ts
1
export default () => new Response("First Function");

Deno Deploy との比較としてポイントになるのが、この関数のエクスポートだけで動作できる点。

ここまで用意できたら github に push します。
Netlify のページにアクセスするとデプロイ結果が出ています。
作成したサイトにアクセスすると、実装の通り次のように表示されています。

複数の関数のデプロイができその場合は、次のようになります。

netlify.toml(複数の関数をデプロイ)
1
2
3
4
5
6
7
[[edge_functions]]
function = "first-function"
path = "/"

[[edge_functions]]
function = "second-function"
path = "/second"
netlify/edge-functions/second-function.ts
1
export default () => new Response("Second Function");

記述した通り、/second にアクセスすると、次のようになります。

Netlify Edge Functions の特徴

Netlify Edge Functions の特徴として、独自拡張された Context があります。

Netlify-specific Context object

独自拡張されている内容として、次のようなものがあります。

  • Cookies
  • geo
  • json
  • log
  • next()
  • rewrite(url)

これらそれぞれの使い方については、Netlify - Edge Functions Examples - Edge Functions on Netlify に説明がありました。

ライブラリを使わなくても、Cookies が扱えるのは便利そうです。
(このあたりは、関数を直接エクスポートするという形式のため、既存の Context との互換性が取れなかったのでは?と感じる)

試しに、Cookies と geo を使ってみると、次のようになります。

netlify/edge-functions/third-function.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Context } from "netlify:edge";

export default async (request: Request, context: Context) => {
let value = context.cookies.get("key");

if (!value) {
value = "0";
}

context.cookies.set({
name: "key",
value: `${Number(value) + 1}`,
});

return context.json({
count: value,
geo: context.geo,
});
};

アクセスすると、次のレスポンスがあります。

netlify/edge-functions/third-function.ts のレスポンス
1
2
3
4
5
6
7
8
{
"count": "14", // <= この部分はアクセス都度カウントアップ
"geo": {
"city": "Tokyo",
"country": { "code": "JP", "name": "Japan" },
"subdivision": { "code": "13", "name": "Tokyo" }
}
}

ということで、アクセス元の情報の取得と、Cookies を使えました。

next() がよくわからない

next() の説明を読んでみると、チェーン内に次の関数が有る場合… などと書いてあります。
どういうこと?と感じますが、netlify.toml の書き方のドキュメントにヒントがあります。

見てみると、同じパスが複数の関数に割り当てされています。
実行は上から順にとあるので、動作を確認するには次のように用意します。

netlify.toml(next()の確認部分抜粋)
1
2
3
4
5
6
7
[[edge_functions]]
function = "next1-function"
path = "/next"

[[edge_functions]]
function = "next2-function"
path = "/next"
netlify/edge-functions/next1-function.ts
1
2
3
4
5
6
import { Context } from "netlify:edge";

export default async (req: Request, context: Context) => {
const res = await context.next({ sendConditionalRequest: true });
return context.json({ exec: "next1-function", nextExec: await res.json() });
};
netlify/edge-functions/next1-function.ts
1
2
3
4
5
import { Context } from "netlify:edge";

export default async (req: Request, context: Context) => {
return context.json({ exec: "next2-function" });
};

デプロイして、/next にアクセスすると、次のようになります。

1
{ "exec": "next1-function", "nextExec": { "exec": "next2-function" } }

ここまで動かしてみると、意味が分かります。

通常の deno deploy 相当のこともできるのか?

関数を直接エクスポートするのは、Netlify Edge Functions の独自拡張です。
既存 deno deploy 相当の普通のサーバーアプリのデプロイもできましたが。
次のように複数のサーバーを立てることはできませんでした。

netlify.toml(サーバーを2つデプロイ)
1
2
3
4
5
6
7
[[edge_functions]]
function = "server1-function"
path = "/server1"

[[edge_functions]]
function = "server2-function"
path = "/server2"
netlify/edge-functions/server1-function.ts
1
2
3
4
5
6
7
import { serve } from "https://deno.land/std@0.136.0/http/server.ts";

serve((_req) => {
return new Response("Server 1", {
headers: { "content-type": "text/plain" },
});
});
netlify/edge-functions/server2-function.ts
1
2
3
4
5
6
7
import { serve } from "https://deno.land/std@0.136.0/http/server.ts";

serve((_req) => {
return new Response("Server 2", {
headers: { "content-type": "text/plain" },
});
});

それぞれのサーバーでポートを変えてもダメ。
さらに netlify.toml で定義をしていなくても、デプロイされるファイル群に serve が 2 箇所有るだけで動かなくなりました。
上の例では、netlify/edge-functions/server2-function.ts が存在しているだけでエラーになります。

Netlify のビルドログを見ると、次のように記載されています。

1
2
3
4
5
6
7
8
9
10
11
12
4:39:09 PM: ────────────────────────────────────────────────────────────────
4:39:09 PM: 1. Edge Functions bundling
4:39:09 PM: ────────────────────────────────────────────────────────────────
4:39:09 PM: ​
4:39:09 PM: Packaging Edge Functions from netlify/edge-functions directory:
4:39:09 PM: - first-function
4:39:09 PM: - next1-function
4:39:09 PM: - next2-function
4:39:09 PM: - second-function
4:39:09 PM: - server1-function
4:39:09 PM: - server2-function
4:39:09 PM: - third-function

おそらく、すべてのファイルを読み込むときに、すべて実行されているのでは?と考えられます。

サーバーアプリケーションを立てるときは1 つだけ書くようにする必要があります。

複数サーバー立てられたらいいなと思いましたが、そうは上手くいかないものです。

さらに、関数エクスポートする Netlify Edge Functions 形式と server アプリケーションとの共存もできないものでした。
パスの設定にもよるのでしょうが、処理がすべてサーバーアプリケーションに吸われてしまう様でした。
レスポンスが、サーバーアプリケーションからのレスポンスしか確認できませんでした。

tsx も使えるの?

ビルドログを確認すると、.tsx ファイルは読み込み対象にならないようだったので、関数自体は.ts ファイルを使用。
tsx はモジュールとして呼び出して使用しました。
レンダリングは ReactDOMServer を使いました。

以下のファイルの用意をします。

netlify/edge-functions/views/page.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React from "https://esm.sh/react";
import ReactDOMServer from "https://esm.sh/react-dom/server";

function Template(props: { children: React.ReactNode }) {
return (
<html>
<head>
<title>Page from Netlify Edge Functions</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
crossOrigin="anonymous"
></link>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossOrigin="anonymous"
></script>
</head>
<body>
<div className="container-md">{props.children}</div>
</body>
</html>
);
}

export default function Page() {
return ReactDOMServer.renderToString(
<Template>
<h1>Tsx-Function-Page</h1>
</Template>
);
}
netlify/edge-functions/tsx-function.tsx
1
2
3
4
import Page from "./views/page.tsx";

export default () =>
new Response(Page(), { headers: { "content-type": "text/html" } });
1
2
3
[[edge_functions]]
function = "tsx-function"
path = "/tsx/"

デプロイ後、/tsx/ にアクセスすると次のようになります。

パスルーティングできる?

できる。

netlify.toml(全リクエストをroute-functionへ)
1
2
3
[[edge_functions]]
function = "route-function"
path = "/*"
netlify/edge-functions/route-function.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Context } from "netlify:edge";

export default async (req: Request, context: Context) => {
if (new URLPattern({ pathname: "/a/" }).test(req.url)) {
return context.json({ url: req.url, route: "A" });
}
if (new URLPattern({ pathname: "/b/" }).test(req.url)) {
return context.json({ url: req.url, route: "B" });
}
if (new URLPattern({ pathname: "/c/:id([1-9]+)" }).test(req.url)) {
const match = new URLPattern({ pathname: "/c/:id([1-9]*)" }).exec(req.url);
if (!match) return new Response("ERROR");

const { groups } = match.pathname;
const id = groups["id"];
const idNumber = parseInt(id, 10);

return context.json({ url: req.url, route: "C", id: idNumber });
}
};

これをデプロイしてアクセスすると次のようになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ curl https://xxxxxxxxxxxxxx.netlify.app/a/ -s |jq . -c
{"url":"https://xxxxxxxxxxxxxx.netlify.app/a/","route":"A"}

$ curl https://xxxxxxxxxxxxxx.netlify.app/b/ -s |jq . -c
{"url":"https://xxxxxxxxxxxxxx.netlify.app/b/","route":"B"}

$ curl https://xxxxxxxxxxxxxx.netlify.app/c/ -s |jq . -c
parse error: Invalid numeric literal at line 1, column 10

$ curl https://xxxxxxxxxxxxxx.netlify.app/c/123 -s |jq . -c
{"url":"https://xxxxxxxxxxxxxx.netlify.app/c/123","route":"C","id":123}

$ curl https://xxxxxxxxxxxxxx.netlify.app/c/123a -s |jq . -c
parse error: Invalid numeric literal at line 1, column 10

ちゃんとパスルーティングもできる。
ただし、パスのリライト?する機能は無いようなので、netlify.toml で path = “/a/*“ と書くと /a/しか応答できなくなる。

引っかかったポイント

Netlify Edge Functions は、netlify.toml に定義が無くても netlify/edge-functions に置いてある .ts(.js) を読み込んでいる様です。

それ故か、default export がされていないファイルが有ると全体でエラーを起こすようです。
(ここにハマって 1 時間かかった)

また、1 回 .tsx でデプロイすると、.ts に直しても差分検知しないようで、ファイル名を直さないとデプロイできなくなりました。


Netlify Edge Functions を触ってみました。
触った所感としては、deno deploy と同じようにサーバーアプリはデプロイできるけど、止めた方がいい。
Netlify Edge Functions の拡張した利点が全部死ぬから。という感じ。

この拡張用にserverless_oakみたいな oak の関連モジュールもそのうちだれか作りそう。

ドキュメントを見ると、Next や Nuxt、SvelteKit などが動く?らしく、引き続き調べたいところです。

ではでは。