Open8

cloudflare の better micro frontend を読む

mizchimizchi

これはなにか

cloudflare スタックを使ったマイクロフロントエンドの提案。

特に service-binding を活用することで異なるサービス(ここでは cloudflare worker)から配信されるフロントエンドを統一的にSSRしつつ、開発単位を分離している。

RTT最適化のために qwik で書かれているが、SSR を意識しなければ他のライブラリを採用しても良い。

mizchimizchi

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 になっていて、それぞれがモノレポパッケージになっている。

package.json
	"workspaces": [
		"helpers",
		"footer",
		"header",
		"filter",
		"gallery",
		"body",
		"main"
	],
mizchimizchi

ギャラリーだけ起動してみる

$ 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 で接続する。

mizchimizchi

main から service-binding でそれぞれのサービスが 結合されてる

wrangler.toml
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サーバー

main/src/entry.ssr.tsx
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 がミソかな

helpers/src/fragmentHelpers.tsx
/**
 * 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 の特性を生かした使い方。

mizchimizchi

じゃあ main は何をレンダリングしてるか

main/src/root.tsx
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 をバインドしてそう

helpers/src/fragmentHelpers.tsx

// ...

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 で流し込んでそう。

src/fragmentHelpers.tsx
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 タグがエントリポイントになったりはしないわけか。

mizchimizchi

cloudflare と qwik である理由

素朴な HTML + script と SPA 的な動的コンポーネント比べると、 cloudflare の service-binding で 1 RTT, qwik SSR で 1 RTT, 合計 2 RTT 削減されている。

edge で返してることを思うと素朴なHTMLサーバーより十分に速いとして 3 RTT 分は速いかもしれない。

mizchimizchi

このボイラープレートから何を持ち帰ることができるか

  • Placeholder コンポーネントによる抽象。親がどのように子サービスを扱おうとしているか、意味論的にわかりやすい。
  • 初期状態の文字列テンプレートを展開するSSR と単純に assets を配信する2エンドポイントを分離する。SSR の有無に関わらず、script 以外の初期HTMLを受け取れる余地を用意することで、選択的にSSRやその他のセットアップの選択肢がとれる。
  • /_fragment/xxx を別ホストの /xxx に転送する。ローカル開発サーバーでは、本番に向けるか開発環境に向けるかプロキシする側でコントロールできる。マイクロフロントエンド環境とするならば、自分の担当以外はそのまま本番用アセットを使ってもいいだろう。 問題点として、Edge Worker 以外で別CDNをプロキシすると単純に低速化する。