30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Route 53 + Cloudflare + Heroku で Deno アプリを公開する

先日、砂場として使うために以下の構成のウェブアプリを作った。

  • Deno で作ったアプリケーションサーバを Heroku で公開し、それを Cloudflare 経由で配信する
  • ドメインは Route 53 で取得したものを使う
  • Heroku も Cloudflare も無料プランを利用する

忘れないうちにその手順を記録しておく。

ちなみにこの構成だと、ドメイン代以外の費用はかからない。
HTTPS で配信される独自ドメインのサイトを、無料で公開できる。また、クライアントとエッジサーバ間は HTTP/3 で配信できる。

Deno のバージョンはv1.10.3を使っている。

必要なファイルの用意

まずは Deno でアプリケーションサーバを作る。

以下の内容のserver.tsを作る。

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.97.0/http/mod.ts";
import { parse } from "https://deno.land/std@0.97.0/flags/mod.ts";

const argPort = parse(Deno.args).port;
const port = argPort ? Number(argPort) : 8080;

listenAndServe({ port }, (req: ServerRequest) => {
  const body = `<html>
<head>
  <title>Deno App</title>
</head>
<body>
  <p>foo</p>
</body>
</html>`;

  switch (req.url) {
    case "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body,
      });
      break;
    }
    default:
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
  }
});

$ deno run --allow-net server.tsを実行して、ローカルで動作確認する。
http://localhost:8080/にアクセスしたときにfooと表示され、他のパスにアクセスするとNot foundが表示されれば、成功。

次に、デプロイのために必要なファイルを 2 つ作成する。

まずはProcfile

web: deno run --allow-net=:${PORT} --cached-only server.ts --port=${PORT}

そしてruntime.txt。これは、使用する Deno のバージョンを指定するために使う。

v1.10.3

ここまでで、以下のディレクトリ構成になっている。

.
├── Procfile
├── runtime.txt
└── server.ts

これで準備は整ったので、次は Heroku へのデプロイを行う。

Heroku へのデプロイ

Heroku へのデプロイには Git を用いるため、上記 3 つのファイルをコミットする。
コミットメッセージは何でもよい。

$ git init
$ git add .
$ git commit -m "initial commit"

次は Heroku CLI を使うので、インストールしておく。
そしてログイン後、heroku createでアプリケーションを作る。

$ heroku login
$ heroku create --buildpack https://github.com/chibat/heroku-buildpack-deno.git

あとは$ git push heroku masterでプッシュすればデプロイされるので、$ heroku openで確認する。
ドメインは、foo-bar-1234.herokuapp.comのように、Heroku から自動的に付与されたものになっている。
fooと表示されていれば成功。
デプロイが失敗した場合は$ heroku logs --tailでログを確認できる。

Route 53 でドメインを取得

Route 53 でドメインを取得する。UI は変わる可能性が高いため、細かい手順は紹介しない。
この記事の文脈においては Route 53 である必要はなく、他の業者を使っても問題ない。

Cloudflare への登録

これ以降、Heroku アプリのドメインはfoo-bar-1234.herokuapp.com、Route 53 で取得したドメインはexample.comとして、説明していく。
そのため実現したいのは、クライアントがexample.comにアクセスしたときにfoo-bar-1234.herokuapp.comにデプロイした内容が表示されること、そしてそれが Cloudflare のエッジサーバ経由で配信されること、である。

Cloudflare に登録後、「サイトを追加」を選びexample.comを入力する。
次にこのドメインに対して、DNS レコードを設定する。
「タイプ」はCNAME、「名前」はexample.com、コンテンツはfoo-bar-1234.herokuapp.com
「続行」を押下すると次の画面に移るので、その画面に従ってドメインのネームサーバを変更する。
そうすることで、エンドユーザーがドメインにアクセスしたときに、Cloudflare のネームサーバに対して IP を問い合わせるようになる。 

今回は Route 53 でドメインを取得したので、Route 53 の設定を変更する必要がある。
下記画像の右端の「ドメインの登録」から「ネームサーバー」を設定しなければならないので、注意する。

f:id:numb_86:20210605231858p:plain

「ホストゾーン」から「NS レコード」を編集しても、いつまでも反映されない。

$ whois example.comName Serverが変わっていれば成功。

設定が上手くいくと、Cloudflare のダッシュボードにその旨が表示される。また、メールでも通知が来る。

ネームサーバに関する画面のあとに以下のページが表示されるが、基本的には全て有効でよいはず。

f:id:numb_86:20210605231840p:plain

Heroku でカスタムドメインを追加する

最後に、Heroku でexample.comを利用できるようにする。この設定を行わずにexample.comにアクセスしても、エラーになってしまう。

まず、カスタムドメインを追加するためには Heroku アカウントの認証が必要になる。そしてそのためにはクレジットカードかデビットカードを登録する必要がある。
前述のように無料プランを使っているので支払いは行われないが、認証のために登録が必要になる。
アカウントの確認 | Heroku Dev Center

登録後、以下のコマンドを実行する。

$ heroku domains:add example.com -a foo-bar-1234

これで、必要な作業は全て終えた。

動作確認

設定したドメインのページ(この記事の例で言えばhttps://example.com)にアクセスして、正しく表示されているか確認する。
画面にはfooが表示されており、レスポンスヘッダのserverフィールドの値がcloudflareになっているはずである。

参考資料

Service Worker のインポートを理解する

Service Worker では、importScriptsを使うことでスクリプトをインポートできる。
ES Modules (以下、ESM)を使うこともできるが、ブラウザによってはまだ対応していない。Chrome ではバージョン 91 から利用できるようになった。
この記事では、これらの機能を使って Service Worker でスクリプトをインポートする方法を見ていく。

動作確認には Next.js と Google Chrome を使っている。それぞれのバージョンは以下の通り。

  • Next.js 10.2.0
  • Google Chrome 91.0.4472.77

ユーザーが初めてページにアクセスした状況を再現するために、シークレットウィンドウでページを開くようにしている。

Module Service Worker と Classic Service Worker

Service Worker は、Module Service Worker と Classic Service Worker に分類できる。
全ての Service Worker は必ずどちらかになる。

どちらであるかは、Service Worker の登録時に決まる。
registerで登録する際にtypeの値をmoduleにすると、Module Service Worker になる。

// Module Service Worker になる
navigator.serviceWorker.register('/sw.js', {type: 'module'})

typeclassicにした場合、あるいは設定を省略した場合は、Classic Service Worker になる。

// Classic Service Worker になる
navigator.serviceWorker.register('/sw.js', {type: 'classic'})

// Classic Service Worker になる
navigator.serviceWorker.register('/sw.js')

そしてどちらの Service Worker であるかで、利用できるインポート方法が変わる。
Module Service Worker では ESM を、Classic Service Worker ではimportScriptsを、利用できる。
利用できない方のインポートを使おうとすると、エラーになる。

ES Modules の使い方

まずは、Module Service Worker による ESM の使い方を見ていく。

以下の内容のpublic/sw.jsを用意する。

import {x} from './module.js';

console.log(x);

public/module.jsも用意する。

export const x = 1;

そして最後に、pages/index.jsを以下の内容にする。

import {useEffect} from 'react';

export default function Home() {
  useEffect(() => {
    navigator.serviceWorker.register('/sw.js', {type: 'module'});
  }, []);

  return (
    <>
      <p>Hello Service Worker!</p>
    </>
  );
}

この状態で$ yarn devを実行しhttp://localhost:3000/にアクセスする。
そうするとログに1が表示されるので、Service Worker の登録、そしてその Service Worker によるmodule.jsの読み込みが上手くいっていることが分かる。

Dynamic Import は使えず、使おうとするとエラーになる。

// TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML specification.
import('./module.js').then((res) => {
  console.log(res.x);
});

importScripts の使い方

次に、importScriptsの使い方を見ていく。

ESM と異なり、「エクスポートした値をインポートする」というような挙動にはならない。
読み込んだスクリプトを実行し、名前空間が共有される。
HTML ファイルのなかでscriptタグを使ったときと同じような使用感になる。

そのため、public/module.jsの内容を以下のようにしてそれをimportScriptsで読み込んだ場合、Service Worker 側でfoobarを使えるようになる。

const foo = 'foo';

function bar() {
  return 'bar';
}

public/sw.jsを書き換えて、確認してみる。

importScripts('./module.js');

console.log(foo);
console.log(bar);
console.log(bar());

最後にpages/index.jsを書き換えて、Classic Service Worker として登録されるようにする。

navigator.serviceWorker.register('/sw.js');

この状態でhttp://localhost:3000/にアクセスすると以下のログが流れ、module.jsと名前空間が共有されていること分かる。

foo
ƒ bar() {
  return 'bar';
}
bar

名前空間が共有されているので、同じ名前の変数を定義してしまうと、エラーになる。
そのため以下のようにするとエラーになる。

// public/module.js
const x = 1;
// public/sw.js
importScripts('./module.js');
const x = 9;

ここまでの例ではトップレベルでimportScriptsを実行していたが、installイベントのなかで実行することもできる。
そのため以下の内容にすると、ページを表示してから約3秒後にThis is module.jsがログに流れ、そこからさらに約3秒後に、fooがログに流れる。

// public/module.js
const foo = 'foo';

console.log('This is module.js');
// public/sw.js
function sleep(ms) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
}

self.addEventListener('install', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      sleep(3000);
      importScripts('./module.js');
      sleep(3000);
      console.log(foo);
      resolve();
    })
  );
});

installイベントよりも後にimportScriptsを実行するとエラーになる。
そのため、以下のコードはエラーになる。

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      importScripts('./module.js');
      resolve();
    })
  );
});

installイベントよりも後にimportScriptsでスクリプトを実行したい場合は、installイベント、あるいはトップレベルで、そのスクリプトを実行しておく必要がある。
以下のようにするとエラーにはならず、ページ表示時にThis is module.jsというログが 2 回流れる。

// public/module.js
console.log('This is module.js');
// public/sw.js
importScripts('./module.js');

self.addEventListener('activate', (e) => {
  e.waitUntil(
    new Promise((resolve) => {
      importScripts('./module.js');
      resolve();
    })
  );
});

ただ、public/module.jsのなかで変数を定義していた場合、その変数の定義を 2 回実行することになりpublic/module.jsでエラーが発生するので、注意する。

Classic Service Worker のなかで Dynamic Import を使うことはできない。

// TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML specification.
import('./module.js').then((res) => {
  console.log(res.x);
});

一方で、typemoduleを設定していないscript要素や Web Worker では、Dynamic Import を使える。
Service Worker だけ挙動が異なっているため注意する。

script要素での Dynamic Import については以下を参照。

numb86-tech.hatenablog.com

Web Worker での Dynamic Import については以下を参照。

numb86-tech.hatenablog.com

更新確認とインポート

更新確認の際の挙動は、ESM でもimportScriptsでも同じ。挙動が変わることはない。
updateViaCacheの設定によって挙動が変化し、更新があった場合は Service Worker スクリプト自体が改めて読み込まれる。

更新確認やupdateViaCacheの詳細は以下の記事に書いた。

numb86-tech.hatenablog.com

参考資料