cloudflare の better micro frontend を読む
元ブログ
ソースコード
デプロイされてるもの
これはなにか
cloudflare スタックを使ったマイクロフロントエンドの提案。
特に service-binding を活用することで異なるサービス(ここでは cloudflare worker)から配信されるフロントエンドを統一的にSSRしつつ、開発単位を分離している。
RTT最適化のために qwik で書かれているが、SSR を意識しなければ他のライブラリを採用しても良い。
Overview
git clone して cloud-gallery を見る
$ tree . -I node_modules
.
├── README.md
├── body
│ ├── package.json
│ ├── public
│ │ └── favicon.ico
│ ├── src
│ │ ├── Body.css
│ │ ├── entry.ssr.tsx
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── constants.ts
├── filter
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ └── workers-logo.svg
│ ├── src
│ │ ├── Filter.css
│ │ ├── entry.ssr.tsx
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── footer
│ ├── package.json
│ ├── public
│ │ └── favicon.ico
│ ├── src
│ │ ├── Footer.css
│ │ ├── entry.ssr.tsx
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── gallery
│ ├── images
│ │ ├── 0.jpg
(ry)
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ └── images
│ │ ├── 0-e72f5f6be5a5eeaf0be7a319c8dd675c.webp
(ry)
│ ├── scripts
│ │ └── resizeImages.js
│ ├── src
│ │ ├── Gallery.css
│ │ ├── components
│ │ │ └── GalleryItem
│ │ │ ├── GalleryItem.css
│ │ │ └── GalleryItem.tsx
│ │ ├── entry.ssr.tsx
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── header
│ ├── package.json
│ ├── public
│ │ ├── cf-logo.png
│ │ ├── favicon.ico
│ │ ├── github-icon.svg
│ │ └── workers-logo.svg
│ ├── src
│ │ ├── Header.css
│ │ ├── Slider
│ │ │ ├── Slider.scss
│ │ │ └── Slider.tsx
│ │ ├── entry.ssr.tsx
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── helpers
│ ├── package.json
│ ├── src
│ │ ├── base.ts
│ │ ├── cookies.ts
│ │ ├── fragmentHelpers.tsx
│ │ ├── image
│ │ │ └── image.tsx
│ │ ├── index.tsx
│ │ ├── isBrowser.ts
│ │ ├── renderResponse.ts
│ │ └── useLocation.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── main
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ └── robots.txt
│ ├── src
│ │ ├── entry.ssr.tsx
│ │ ├── global.scss
│ │ ├── layout.css
│ │ ├── normalize.css
│ │ └── root.tsx
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── wrangler.toml
├── package-lock.json
├── package.json
├── tsconfig.json
└── vite-config.ts
とりあえず npm workspaces になっていて、それぞれがモノレポパッケージになっている。
"workspaces": [
"helpers",
"footer",
"header",
"filter",
"gallery",
"body",
"main"
],
ギャラリーだけ起動してみる
$ npm start -w gallery
個別に起動できることがミソっぽい。
共通の vite.config.ts でSSRする設定になっていた。
import { qwikVite } from "@builder.io/qwik/optimizer";
import tsconfigPaths from "vite-tsconfig-paths";
export default () => {
return {
ssr: { target: "webworker", noExternal: true },
// build: { minify: false, sourcemap: true },
// esbuild: {
// minifySyntax: false,
// minifyIdentifiers: false,
// minifyWhitespace: false,
// },
// plugins: [qwikVite({ debug: true }), tsconfigPaths()],
plugins: [qwikVite(), tsconfigPaths()],
};
};
# それぞれ別タブで起動
$ npm run start -w header --local
$ npm run start -w footer --local
$ npm run start -w main --local
ここで main を起動(B を押す)
cloudflare(のローカルエミュレーターのminiflare) の service-binding で接続する。
main から service-binding でそれぞれのサービスが 結合されてる
name = "cloud-gallery"
# `web-experiments` account (replace this with your own account id)
account_id = "8ed4d03ac99f77561d0e8c9cbcc76cb6"
compatibility_date = "2022-07-25"
compatibility_flags = [
"streams_enable_constructors",
"transformstream_enable_standard_constructor",
]
# The SSR code is generated in the `server` directory.
main = "server/entry.ssr.js"
# assets = { bucket = "./dist", include = ["**", "../../header/dist/**"] }
assets = "./dist"
# Build the SSR code that will run in the Worker.
[build]
# The client code is generated in the `dist` directory.
# We use the `assets` config to supply these static files as needed.
command = "npm run build"
# Rebuild when the helper library changes
watch_dir = ["src", "../helpers/src"]
[[services]]
binding = "header"
service = "cloud-gallery-header"
[[services]]
binding = "gallery"
service = "cloud-gallery-gallery"
[[services]]
binding = "filter"
service = "cloud-gallery-filter"
[[services]]
binding = "body"
service = "cloud-gallery-body"
[[services]]
binding = "footer"
service = "cloud-gallery-footer"
vite で起動されるSSRサーバー
import { manifest } from "@qwik-client-manifest";
import { tryGetFragmentAsset, renderResponse } from "helpers";
import Root from "./root";
export default {
async fetch(
request: Request,
env: Record<string, unknown>
): Promise<Response> {
// Requests for assets hosted by a fragment service must be proxied through to the client.
const asset = await tryGetFragmentAsset(env, request);
if (asset !== null) {
return asset;
}
// Otherwise SSR the application injecting any fragments into the response stream.
return renderResponse(request, env, <Root />, manifest);
},
};
SSR の中で使われてる tryGetFragmentAsset がミソかな
/**
* Attempt to get an asset hosted by a fragment service.
*
* Such asset requests start with `/_fragment/{service-name}/`, which enables us
* to choose the appropriate service binding and delegate the request there.
*/
export async function tryGetFragmentAsset(
env: Record<string, unknown>,
request: Request
) {
const url = new URL(request.url);
const match = /^\/_fragment\/([^/]+)(\/.*)$/.exec(url.pathname);
if (match === null) {
return null;
}
const serviceName = match[1];
const service = env[serviceName];
if (!isFetcher(service)) {
throw new Error("Unknown fragment service: " + serviceName);
}
return await service.fetch(
new Request(new URL(match[2], request.url), request)
);
}
/_fragment/{service-name}/*
にリクエストが来ると、env.[service].fetch(...)
にリクエストを投げ直している。
それに一致しない場合は自分自身でハンドルする。
理解してきた。cloudflare の中では Edge 上で複数の worker が低遅延で通信できるので、SSR できる Worker を複数用意しておいて、それを個別にSSRして一つのSSRに見せかけている。
qwik でやってるのは、実際に触るまでロジック注入が行われないので、SSR だけして待機している状態にできるから。これは qwik の特性を生かした使い方。
じゃあ main は何をレンダリングしてるか
import { FragmentPlaceholder } from "helpers";
import "./global.scss";
import "./normalize.css";
import "./layout.css";
export default () => {
return (
<>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta
name="description"
content="A demo showcasing a fragments architecture on Cloudflare Workers"
/>
<title>Cloud Gallery</title>
</head>
<body>
<div class="page-container">
<div class="header-fragment">
<a
href="https://cloud-gallery-header.web-experiments.workers.dev/"
class="seam-link"
>
header
</a>
<FragmentPlaceholder name="header" />
</div>
<div class="body-fragment">
<a
href="https://cloud-gallery-body.web-experiments.workers.dev/"
class="seam-link"
>
body
</a>
<FragmentPlaceholder name="body" />
</div>
<div class="footer-fragment">
<a
href="https://cloud-gallery-footer.web-experiments.workers.dev/"
class="seam-link"
>
footer
</a>
<FragmentPlaceholder name="footer" />
</div>
</div>
</body>
</>
);
};
(Tab が展開されて読みづらい...)
FragmentPlaceholder というコンポーネントで、それぞれの Component をバインドしてそう
// ...
export const FragmentPlaceholder = component$(({ name }: { name: string }) => {
const env = useEnvData<Record<string, unknown>>("env")!;
const request = useEnvData<Request>("request")!;
const decoder = new TextDecoder();
return (
<SSRStream>
{async (streamWriter) => {
const fragment = await fetchFragment(env, name, request);
const reader = fragment.getReader();
let fragmentChunk = await reader.read();
while (!fragmentChunk.done) {
streamWriter.write(decoder.decode(fragmentChunk.value));
fragmentChunk = await reader.read();
}
}}
</SSRStream>
);
});
SSR を stream で流し込んでそう。
export async function fetchFragment(
env: Record<string, unknown>,
fragmentName: string,
request: Request
) {
const service = env[fragmentName];
if (!isFetcher(service)) {
throw new Error(
`Fragment ${fragmentName} does not have an equivalent service binding.`
);
}
const url = new URL(request.url);
url.searchParams.set("base", `/_fragment/${fragmentName}/`);
const response = await service.fetch(new Request(url, request));
if (response.body === null) {
throw new Error(`Response from "${fragmentName}" request is null.`);
}
return response.body;
}
何が展開されるか見てもわからなかったので、実際の View をみる
qwik の View が展開されている。触り始めるまで動き出さないから、明示的な script タグがエントリポイントになったりはしないわけか。
cloudflare と qwik である理由
素朴な HTML + script と SPA 的な動的コンポーネント比べると、 cloudflare の service-binding で 1 RTT, qwik SSR で 1 RTT, 合計 2 RTT 削減されている。
edge で返してることを思うと素朴なHTMLサーバーより十分に速いとして 3 RTT 分は速いかもしれない。
このボイラープレートから何を持ち帰ることができるか
- Placeholder コンポーネントによる抽象。親がどのように子サービスを扱おうとしているか、意味論的にわかりやすい。
- 初期状態の文字列テンプレートを展開するSSR と単純に assets を配信する2エンドポイントを分離する。SSR の有無に関わらず、script 以外の初期HTMLを受け取れる余地を用意することで、選択的にSSRやその他のセットアップの選択肢がとれる。
- /_fragment/xxx を別ホストの /xxx に転送する。ローカル開発サーバーでは、本番に向けるか開発環境に向けるかプロキシする側でコントロールできる。マイクロフロントエンド環境とするならば、自分の担当以外はそのまま本番用アセットを使ってもいいだろう。 問題点として、Edge Worker 以外で別CDNをプロキシすると単純に低速化する。