Open10

Cloudflare Workers での SSR する。この時 Client Side JS のコードをどう書くか悩んでいる

codehexcodehex
  • Cloudflare Workers と Hono を使っている
  • Hono には html を SSR としてレンダリングするためのヘルパーが用意されてる
  • 例として Client Side JS に依存するライブラリとして Alpine.js を使っている

以下のような HTML をレンダリングする時に利用する、共通のレイアウトヘルパーテンプレートを用意する。ぱっと見 JSX っぽいけど、これは JavaScript テンプレートリテラルを利用している。

layout.ts
import { html } from 'hono/html';
import type { FC } from 'hono/jsx';

export const Layout: FC<{
  title: string;
  children: any;
}> = ({ title, children }) => {
  return html`<html>
    <head>
      <meta name="robots" content="noindex" />
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
      />
      <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
      <title>${title}</title>
      <script>
        // お悩みポイント 1
        document.addEventListener('alpine:init', () => {
          Alpine.directive(
            'destroy',
            (el, { expression }, { evaluateLater, cleanup }) => {
              const clean = evaluateLater(expression);
              cleanup(() => clean());
            },
          );
          Alpine.store('auth', {
            currentUser: null,
          });
        });
      </script>
    </head>
    <body>
    ${children}
    </body>
  </html>`;
};

定義した Layout はこんな感じで利用できる。

ファイル拡張子を tsx にすることで記述するシンタックスを JSX にできる。ただし React ではなく純粋な HTML テンプレートであることを忘れないでほしい。

worker.tsx
import { Hono } from 'hono'

const Add: FC = () => {
  // お悩みポイント 2
  return html`<div x-data="{add: function(x, y) { return x + y }}">
    <button
      type="button"
      x-on:click="alert(add(1, 2))"
    >
      Show
    </button>
  </div>`

const app = new Hono()

app.get('/', async (c) => {
    return c.html(
      <Layout title="テスト">
        <Add />
      </Layout>,
    );
});

export default app

これまでに書いたお悩みポイントについて:

  1. script タグに記述するコードを TypeScript の世界から持ってきたいが、ここに記述する Client Side JS のコードは Alpine.js に依存している。
  2. {add: function(x, y) { return x + y }}add に対する値は JavaScript の関数である。これも TypeScript の世界から持ってきたい。

これまで書いたコードの中でお悩みポイントは 2 つあり、共通した前提は Client Side JS のコードを Cloudflare Workers のコード(TypeScript)と一緒に管理したいがどうすると良いんだろうという点

chimamechimame

scriptタグに埋め込むTypeScriptの世界からJavaScriptで持ってくる

これなんだけど、ちょっと特殊な方法で実装できないかなと思ってる。

  1. scriptタグには普通にsrc属性でTypeScriptの世界からJavaScriptを持ってくる用のパスを記載する。もちろんTypeScriptも同じ用に /typescript/hoge.ts に配置する。
<script src='{assetPath("/typescript/hoge.ts")}' />
  1. viteでビルドして起動するようにして、 manifest.json を出力しておく

  2. assetPath 関数で渡されたパスをみて、manifest.json から本物のパス( /typescript/hoge.869aea0d.js な感じのもの) へ変換する。

てな手順でビルドを通してから読み込ませるパスをビルド済みのファイルパスへ変換させる感じにできないかなと。Railsのasset pipelineという仕組みを真似してみた。

codehexcodehex

Cloudflare Workers の強みは Browser で動く JavaScript をサーバーサイドのコードとして記述点なんだけど、このことを上手く活かして、コードを埋め込めないかなと考えている。

これは試してみて上手く動いた例:

worker.tsx
const add = function(x: number, y: number): number { return x + y }

const Add: FC = () => {
  // .toString() を使って関数を文字列へ変換。それを埋め込む
  const embed = `{add: ${add.toString()}}`
  return html`<div x-data="${embed}">
    <button
      type="button"
      x-on:click="alert(add(1, 2))"
    >
      Show
    </button>
  </div>`

上記の例だと Client Side JS が依存しているライブラリに依存がないので上手くいってる

yusukebeyusukebe

なんかあらぬ方向から、TS問題解決できるかもしれなくて、ものすごい素敵なIssueがきてた。

https://github.com/honojs/hono/issues/1627

codehexcodehex

遅くなったんですけど、めっちゃいいですねこれ...
ただ static 配下の ts ファイルはエディタ上で Cannot find module 'https://esm.sh/react@18.2.0' or its corresponding type declarations. など出るので、この辺どどう直すか考えます。

hono-test/sites
└── static
    └── script.tsx
./sites/static/script.tsx
/// <reference lib="DOM" />

import { renderToString } from 'https://esm.sh/react-dom@18.2.0/server';
import React from 'https://esm.sh/react@18.2.0';

const add = (num1: number, num2: number): number => {
  return num1 + num2;
};

const Component = () => (
  <div>
    <h1>
      Hello from <code>/static/script.tsx</code>
    </h1>
    <p>{add(1, 2).toString()}</p>
  </div>
);

addEventListener('DOMContentLoaded', () => {
  const root = document.getElementById('root');
  if (root) {
    root.innerHTML = renderToString(<Component />);
  }
});
./src/index.tsx
app.get('/', async (c) => {
    return c.html(
      <html>
        <head>
          <script type="module" src="/static/script.tsx" />
        </head>
        <body>
          <div id="root" />
        </body>
      </html>,
    );
  });
wrangler.toml
[site]
bucket = "./sites"
codehexcodehex

wasm が 11MB あり、初めて warning をみた。
kv か R2 経由で取得するようにする必要がありそう

esm.sh を使ってもうまく補完が効くようにするためには deno を使うと良さそうだった。
workers site に設置する対象の TypeScript ファイルを deno のワークスペースとして VSCode にセットする。

.vscode/settings.json
{
	"deno.codeLens.references": true,
	"deno.enablePaths": [
		"./sites/static"
	],
	"deno.enable": true
}

書いたコード。https://esm.sh/alpinejs@3.13.2 はあらかじめ deno run https://esm.sh/alpinejs@3.13.2 とかでキャッシュに持っておく必要がある。

sites/static/script.ts
/// <reference lib="DOM" />

import Alpine from 'https://esm.sh/alpinejs@3.13.2';

// @ts-ignore: window.Alpine がまだないため
window.Alpine = Alpine;

document.addEventListener('alpine:init', () => {
  Alpine.directive(
    'destroy',
    (_el, { expression }, { evaluateLater, cleanup }) => {
      const clean = evaluateLater(expression);
      cleanup(() => clean());
    },
  );
  Alpine.store('auth', {
    currentUser: null,
  });
});

Alpine.start();

codehexcodehex

ここで Deno を使って気づいて、試したんだけど、小さなフロントエンドのコードであれば deno_emit を使ってバンドルし、Workers Site へアップロードすることにすると開発体験もかなり良かった。

.vscode/settings.json
{
  "deno.codeLens.references": true,
  "deno.enablePaths": [
    "./sites/static"
  ],
  "deno.enable": true
}

ディレクトリ

.
├── client
│   ├── frontend
│   │   └── main.ts
│   └── bundler.ts
├── sites
│   └── static
└── wrangler.toml
wrangler.toml
[site]
bucket = "./sites"
./client/bundler.ts
import { bundle } from 'https://deno.land/x/emit@0.31.2/mod.ts';

const url = new URL(import.meta.resolve('./frontend/main.ts'));
const { code } = await bundle(url, {
  minify: true,
});

const outputUrl = new URL(
  import.meta.resolve('../sites/static/frontend.main.js'),
);
Deno.writeTextFile(outputUrl, code);

これで deno run --no-check=remote --allow-all --no-npm ./client/bundler.ts を実行すると ./frontend/main.ts がバンドルされて Workers Site のバケットである ./sites 以下に設置される。

もちろん補完もちゃんとできる。もちろん tsconfig.json.eslintrc./client 以下を対象にしないようにする必要がある。

Hono を使って etag と Cache-Control の設定も行う。

app.get(
  '/static/*',
  etag({ weak: true }),
  cache({
    cacheName: 'static',
    cacheControl: 'max-age=3600',
  }),
  serveStatic({ root: './' }),
);