Cloudflare Workers での SSR する。この時 Client Side JS のコードをどう書くか悩んでいる
- Cloudflare Workers と Hono を使っている
- Hono には html を SSR としてレンダリングするためのヘルパーが用意されてる
- 例として Client Side JS に依存するライブラリとして Alpine.js を使っている
以下のような HTML をレンダリングする時に利用する、共通のレイアウトヘルパーテンプレートを用意する。ぱっと見 JSX っぽいけど、これは JavaScript テンプレートリテラルを利用している。
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 テンプレートであることを忘れないでほしい。
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
これまでに書いたお悩みポイントについて:
- script タグに記述するコードを TypeScript の世界から持ってきたいが、ここに記述する Client Side JS のコードは Alpine.js に依存している。
-
{add: function(x, y) { return x + y }}
のadd
に対する値は JavaScript の関数である。これも TypeScript の世界から持ってきたい。
これまで書いたコードの中でお悩みポイントは 2 つあり、共通した前提は Client Side JS のコードを Cloudflare Workers のコード(TypeScript)と一緒に管理したいがどうすると良いんだろうという点
scriptタグに埋め込むTypeScriptの世界からJavaScriptで持ってくる
これなんだけど、ちょっと特殊な方法で実装できないかなと思ってる。
- scriptタグには普通にsrc属性でTypeScriptの世界からJavaScriptを持ってくる用のパスを記載する。もちろんTypeScriptも同じ用に
/typescript/hoge.ts
に配置する。
<script src='{assetPath("/typescript/hoge.ts")}' />
-
viteでビルドして起動するようにして、
manifest.json
を出力しておく -
assetPath
関数で渡されたパスをみて、manifest.json
から本物のパス(/typescript/hoge.869aea0d.js
な感じのもの) へ変換する。
てな手順でビルドを通してから読み込ませるパスをビルド済みのファイルパスへ変換させる感じにできないかなと。Railsのasset pipelineという仕組みを真似してみた。
Cloudflare Workers の強みは Browser で動く JavaScript をサーバーサイドのコードとして記述点なんだけど、このことを上手く活かして、コードを埋め込めないかなと考えている。
これは試してみて上手く動いた例:
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 が依存しているライブラリに依存がないので上手くいってる
「お悩みポイント 1」はこれで解決できてるんじゃないかなー。Vite素晴らしい。
wrangler.toml の中身を読み取れないと厳しいことがわかったので、後々追求していく
なんかあらぬ方向から、TS問題解決できるかもしれなくて、ものすごい素敵なIssueがきてた。
遅くなったんですけど、めっちゃいいですねこれ...
ただ static 配下の ts ファイルはエディタ上で Cannot find module 'https://esm.sh/react@18.2.0' or its corresponding type declarations.
など出るので、この辺どどう直すか考えます。
hono-test/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 />);
}
});
app.get('/', async (c) => {
return c.html(
<html>
<head>
<script type="module" src="/static/script.tsx" />
</head>
<body>
<div id="root" />
</body>
</html>,
);
});
[site]
bucket = "./sites"
wasm が 11MB あり、初めて warning をみた。
kv か R2 経由で取得するようにする必要がありそう
esm.sh を使ってもうまく補完が効くようにするためには deno を使うと良さそうだった。
workers site に設置する対象の TypeScript ファイルを deno のワークスペースとして VSCode にセットする。
{
"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
とかでキャッシュに持っておく必要がある。
/// <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();
複雑なフロントエンド構成に関してはここに追記していく。
ここで Deno を使って気づいて、試したんだけど、小さなフロントエンドのコードであれば deno_emit を使ってバンドルし、Workers Site へアップロードすることにすると開発体験もかなり良かった。
{
"deno.codeLens.references": true,
"deno.enablePaths": [
"./sites/static"
],
"deno.enable": true
}
ディレクトリ
.
├── client
│ ├── frontend
│ │ └── main.ts
│ └── bundler.ts
├── sites
│ └── static
└── wrangler.toml
[site]
bucket = "./sites"
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: './' }),
);