空雲 Blog

Eye catch[すべて無料]Remix+Cloudflare Pagesでブロクシステムを作成する

publication: 2024/02/12
update:2024/03/02

ブログシステムの作成

現在この記事を表示しているのが、今回作成したブログシステムです

環境構成

インフラ

サービス内容
SupabaseDatabase
FirebaseStorage
認証
Cloudflare PagesFront(Remix) & Backend(GraphQL yoga)
Cloudflare Workers画像最適化
OGP 生成
PrismaQueryEngine

すべて無料で使用可能です。各サービスとも無料範囲が大きいのと、最終的な出力結果が 無制限で使える Cloudflare CDN キャッシュで配信されるので、一日に何万アクセスレベルでも無料でいけます。

主な使用パッケージ

パッケージ説明
PrismaDatabase 用 ORM
Graphql YogaGraphQL サーバ
Pothos GraphQLGraphQL フレームワーク
RemixCloudflare と相性の良い React 用フレームワーク
UrqlGraphQL クライアント

上記構成で作りました。
バックエンドとフロントのビルドを統合したかったので、GraphQL サーバは、Remix に載せる形になっています。この構成の凄まじいのは、ローカル環境からコマンドを実行して約 17 秒で、CloudflarePages にビルド込みでデプロイされることです。

Next.js から Remix への移植理由

もともと React のフレームワークは Next.js を使っていました。しかし商利用も可能で制限がゆるい Cloudflare Pages をインフラとして使いたかったので、相性の良い Remix に移植することにしました。

一応 CloudflarePages でも Next.js は動くのですが、私の Blog システムで Edge 設定を行うと、謎のビルドエラーが出ました。配置したファイルの数を減らすと内容に関係なく何故かビルドが通ります。容量的に問題があるわけでもなく、結局解決不能だったので Next.js の使用は諦めました。

事前に知っておくべきCloudflare Pages/Workersの制限事項

https://developers.cloudflare.com/workers/platform/limits/#worker-limits

項目制限
リクエスト数10万回/日
千回/分
メモリ128MB
CPU10ms
使用している感じだと、連続してオーバーしなければある程度は許してくれる感じ
外部へのfetch最大並列数 6
50回/1アクセスあたり
内部サービス1,000回/1アクセスあたり
Bodyサイズ100MB
Workerサイズ1MB(zip圧縮サイズで計算)

画像の最適化の場合、1600*1600ぐらいの画像を変換すると高確率でCPU制限に引っかかります。また、外部サービスを呼び出すようなコードを書いた場合、並列アクセス時にデッドロックしないように気をつける必要があります。

また、Workerサイズはバックエンドで動くJavaScriptやwasmを圧縮したときのサイズです。PagesでAssetsとして配るファイルは計算に入れません。このサイズがどうにもならない時は、Workerを分離するしかありません。

必要な機能

画像最適化機能

別になくても困らないのですが、あったほうがページが軽くなるので推奨事項です。Cloudflare には有料での画像最適化サービスがありますが、無料というのが大前提なので却下です。

画像変換は自分で作らなくともライブラリが世の中に一通り揃っています。その手のライブラリ類はほとんどが C 言語で組まれているので、wasm にすれば JavaScript から使えるというのは誰にでも思いつくでしょう。自分で作るのはそれらを組み合わせてツギハギをする部分だけです。

https://github.com/SoraKumo001/cloudflare-workers-image-optimization

OGP 画像生成機能

Next.js では Vercel がライブラリを提供しているので簡単に実装できます。Cloudflare で同じことをやろうとすると、wasm の読み込みなどの仕様の違いから、似たような内容を自分で再実装する必要があります。

https://github.com/SoraKumo001/cloudflare-ogp

Prisma の QueryEngine

Cloudflare のような EdgeRuntime を Prisma から使う場合、ネイティブバイナリで実装されている QueryEngine は使えません。wasm 版を使う必要があるのですが、そのサイズが圧縮しても 900KB 程度あります。無料版の Cloudflare での最大サイズは圧縮サイズで 1MB までなので、エンジンを積んだだけで他のことがほとんど何もできなくなります。解決策は QueryEngine とそれを呼び出す部分を分離することです。

Prisma は Prisma Accelerate というサービスを提供しており、QueryEngine を外部サービスに分離する機能を最初から持っています。一応、Prisma Accelerate も無料で使えるのですが、無料枠の範囲が小さいので、アクセスが増えてくるとこの部分がネックになります。

つまり、この部分の機能も自分で実装しろということです。ということで作りました。ローカルで動かせばローカル DB へのアクセスを提供しデバッグを容易にし、CloudflareWorkers に設置すれば Prisma Accelerate の代わりになります。

https://github.com/node-libraries/prisma-accelerate-local
https://github.com/SoraKumo001/prisma-accelerate-workers

Remix への移植

下準備に手間取りましたが、ようやく Remix へ移植できます。

fetch に細工をする

まず、Remix の起動時に呼ばれる server.ts で fetch に対して細工を行います。まず Prisma の data-proxy 機能がローカル Proxy(127.0.0.1)にアクセスしようとしたら https を http に変換します。Prisma の data-proxy は https がハードコーディングされており、外部からの設定変更ができなかったので、この対処が必要になります。また、本番稼働時に DATABASE_URL へのアクセスを検知したら、fetch を ServiceBindings 用のものへ切り替えます。これで Workers 間のやりとりが Cloudflare の内部ネットワークで接続されるので、多少は高速化されます。

  • server.ts

1import { logDevReady } from "@remix-run/cloudflare";
2import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
3import * as build from "@remix-run/dev/server-build";
4
5if (process.env.NODE_ENV === "development") {
6 logDevReady(build);
7}
8
9type Env = {
10 prisma: Fetcher;
11 DATABASE_URL: string;
12};
13
14const initFetch = (env: Env) => {
15 const that = globalThis as typeof globalThis & { originFetch?: typeof fetch };
16 if (that.originFetch) return;
17 const originFetch = globalThis.fetch;
18 that.originFetch = originFetch;
19 globalThis.fetch = (async (input: RequestInfo, init?: RequestInit) => {
20 const url = new URL(input.toString());
21 if (["127.0.0.1", "localhost"].includes(url.hostname)) {
22 url.protocol = "http:";
23 return originFetch(url.toString(), init);
24 }
25 const databaseURL = new URL(env.DATABASE_URL as string);
26 if (url.hostname === databaseURL.hostname && env.prisma) {
27 return (env.prisma as Fetcher).fetch(input, init);
28 }
29 return originFetch(input, init);
30 }) as typeof fetch;
31};
32
33export const onRequest = createPagesFunctionHandler({
34 build,
35 getLoadContext: (context) => {
36 initFetch(context.env);
37 return context;
38 },
39 mode: build.mode,
40});

バックエンド

GraphQL Server の作成

GraphQL Yoga + Pothos GraphQL + pothos-query-generator という組み合わせです。

pothos-query-generator は Prisma スキーマの情報を参照して GraphQL スキーマを自動生成します。こちらも以前に作りました。これを使うと、ログインなど一部のシステム依存処理以外は自分でリゾルバを書く必要がなくなります。

https://www.npmjs.com/package/pothos-query-generator

  • app/routes/api.graphql.ts

1import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/cloudflare";
2import { parse, serialize } from "cookie";
3import { createYoga } from "graphql-yoga";
4import { getUserFromToken } from "@/libs/client/getUserFromToken";
5import { Context, getPrisma } from "../libs/server/context";
6import { schema } from "../libs/server/schema";
7
8const yoga = createYoga<
9 {
10 request: Request;
11 env: { [key: string]: string };
12 responseCookies: string[];
13 },
14 Context
15>({
16 schema: schema(),
17 fetchAPI: { Response },
18 context: async ({ request: req, env, responseCookies }) => {
19 const cookies = parse(req.headers.get("Cookie") || "");
20 const token = cookies["auth-token"];
21 const user = await getUserFromToken({ token, secret: env.SECRET_KEY });
22 const setCookie: typeof serialize = (name, value, options) => {
23 const result = serialize(name, value, options);
24 responseCookies.push(result);
25 return result;
26 };
27 return {
28 req,
29 env,
30 prisma: getPrisma(env.DATABASE_URL),
31 user,
32 cookies,
33 setCookie,
34 };
35 },
36});
37
38export async function action({ request, context }: ActionFunctionArgs) {
39 const env = context.env as { [key: string]: string };
40 const responseCookies: string[] = [];
41 const response = await yoga.handleRequest(request, {
42 request,
43 env,
44 responseCookies,
45 });
46 responseCookies.forEach((v) => {
47 response.headers.append("set-cookie", v);
48 });
49 return new Response(response.body, response);
50}
51
52export async function loader({ request, context }: LoaderFunctionArgs) {
53 const env = context.env as { [key: string]: string };
54 const responseCookies: string[] = [];
55 const response = await yoga.handleRequest(request, {
56 request,
57 env,
58 responseCookies,
59 });
60 responseCookies.forEach((v) => {
61 response.headers.append("set-cookie", v);
62 });
63 return new Response(response.body, response);
64}

  • app/libs/server/builder.ts

Pothos の作成処理です。

1import SchemaBuilder from "@pothos/core";
2import PrismaPlugin from "@pothos/plugin-prisma";
3import PrismaUtils from "@pothos/plugin-prisma-utils";
4import { PrismaClient } from "@prisma/client/edge";
5import PothosPrismaGeneratorPlugin from "pothos-prisma-generator";
6import PrismaTypes from "@/generated/pothos-types";
7import { Context } from "./context";
8
9/**
10 * Create a new schema builder instance
11 */
12
13type BuilderType = {
14 PrismaTypes: PrismaTypes;
15 Scalars: {
16 Upload: {
17 Input: File;
18 Output: File;
19 };
20 };
21 Context: Context;
22};
23
24export const createBuilder = (datasourceUrl: string) => {
25 const builder = new SchemaBuilder<BuilderType>({
26 plugins: [PrismaPlugin, PrismaUtils, PothosPrismaGeneratorPlugin],
27 prisma: {
28 client: new PrismaClient({
29 datasourceUrl,
30 }),
31 },
32 pothosPrismaGenerator: {
33 authority: ({ context }) => (context.user ? ["USER"] : []),
34 replace: { "%%USER%%": ({ context }) => context.user?.id },
35 },
36 });
37
38 return builder;
39};

  • app/libs/server/schema.ts

こちらはログインと Firebase に対するファイルアップロード処理を追加リゾルバをスキーマに加えています。

1import { GraphQLScalarType, GraphQLSchema } from "graphql";
2
3import { SignJWT } from "jose";
4import { createBuilder } from "./builder";
5import { prisma } from "./context";
6import { getUser } from "./getUser";
7import { getUserInfo } from "./getUserInfo";
8import { importFile } from "./importFile";
9import { normalizationPostFiles } from "./normalizationPostFiles";
10import { isolatedFiles, uploadFile } from "./uploadFile";
11
12export const schema = () => {
13 let schema: GraphQLSchema;
14 return ({ env }: { env: { [key: string]: string | undefined } }) => {
15 if (!schema) {
16 const builder = createBuilder(env.DATABASE_URL ?? "");
17 builder.mutationType({
18 fields: (t) => ({
19 signIn: t.prismaField({
20 args: { token: t.arg({ type: "String" }) },
21 type: "User",
22 nullable: true,
23 resolve: async (_query, _root, { token }, { setCookie }) => {
24 const userInfo =
25 typeof token === "string"
26 ? await getUserInfo(env.NEXT_PUBLIC_projectId, token)
27 : undefined;
28 if (!userInfo) {
29 setCookie("auth-token", "", {
30 httpOnly: true,
31 secure: env.NODE_ENV !== "development",
32 sameSite: "strict",
33 path: "/",
34 maxAge: 0,
35 domain: undefined,
36 });
37 return null;
38 }
39 const user = await getUser(prisma, userInfo.name, userInfo.email);
40 if (user) {
41 const secret = env.SECRET_KEY;
42 if (!secret) throw new Error("SECRET_KEY is not defined");
43 const token = await new SignJWT({ payload: { user: user } })
44 .setProtectedHeader({ alg: "HS256" })
45 .sign(new TextEncoder().encode(secret));
46 setCookie("auth-token", token, {
47 httpOnly: true,
48 secure: env.NODE_ENV !== "development",
49 maxAge: 1000 * 60 * 60 * 24 * 7,
50 sameSite: "strict",
51 path: "/",
52 domain: undefined,
53 });
54 }
55 return user;
56 },
57 }),
58 uploadSystemIcon: t.prismaField({
59 type: "FireStore",
60 args: {
61 file: t.arg({ type: "Upload", required: true }),
62 },
63 resolve: async (_query, _root, { file }, { prisma, user }) => {
64 if (!user) throw new Error("Unauthorized");
65 const firestore = await uploadFile({
66 projectId: env.GOOGLE_PROJECT_ID ?? "",
67 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
68 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
69 binary: file,
70 });
71 const system = await prisma.system.update({
72 select: { icon: true },
73 data: {
74 iconId: firestore.id,
75 },
76 where: { id: "system" },
77 });
78 await isolatedFiles({
79 projectId: env.GOOGLE_PROJECT_ID ?? "",
80 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
81 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
82 });
83 if (!system.icon) throw new Error("icon is not found");
84 return system.icon;
85 },
86 }),
87 uploadPostIcon: t.prismaField({
88 type: "FireStore",
89 args: {
90 postId: t.arg({ type: "String", required: true }),
91 file: t.arg({ type: "Upload" }),
92 },
93 resolve: async (
94 _query,
95 _root,
96 { postId, file },
97 { prisma, user }
98 ) => {
99 if (!user) throw new Error("Unauthorized");
100 if (!file) {
101 const firestore = await prisma.post
102 .findUniqueOrThrow({
103 select: { card: true },
104 where: { id: postId },
105 })
106 .card();
107 if (!firestore) throw new Error("firestore is not found");
108 await prisma.fireStore.delete({
109 where: { id: firestore.id },
110 });
111 return firestore;
112 }
113 const firestore = await uploadFile({
114 projectId: env.GOOGLE_PROJECT_ID ?? "",
115 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
116 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
117 binary: file,
118 });
119 const post = await prisma.post.update({
120 select: { card: true },
121 data: {
122 cardId: firestore.id,
123 },
124 where: { id: postId },
125 });
126 await isolatedFiles({
127 projectId: env.GOOGLE_PROJECT_ID ?? "",
128 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
129 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
130 });
131 if (!post.card) throw new Error("card is not found");
132 return post.card;
133 },
134 }),
135 uploadPostImage: t.prismaField({
136 type: "FireStore",
137 args: {
138 postId: t.arg({ type: "String", required: true }),
139 file: t.arg({ type: "Upload", required: true }),
140 },
141 resolve: async (
142 _query,
143 _root,
144 { postId, file },
145 { prisma, user }
146 ) => {
147 if (!user) throw new Error("Unauthorized");
148 const firestore = await uploadFile({
149 projectId: env.GOOGLE_PROJECT_ID ?? "",
150 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
151 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
152 binary: file,
153 });
154 await prisma.post.update({
155 data: {
156 postFiles: { connect: { id: firestore.id } },
157 },
158 where: { id: postId },
159 });
160 return firestore;
161 },
162 }),
163 normalizationPostFiles: t.boolean({
164 args: {
165 postId: t.arg({ type: "String", required: true }),
166 removeAll: t.arg({ type: "Boolean" }),
167 },
168 resolve: async (_root, { postId, removeAll }, { prisma, user }) => {
169 if (!user) throw new Error("Unauthorized");
170 await normalizationPostFiles(prisma, postId, removeAll === true, {
171 projectId: env.GOOGLE_PROJECT_ID ?? "",
172 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
173 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
174 });
175 await isolatedFiles({
176 projectId: env.GOOGLE_PROJECT_ID ?? "",
177 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
178 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
179 });
180 return true;
181 },
182 }),
183 restore: t.boolean({
184 args: {
185 file: t.arg({ type: "Upload", required: true }),
186 },
187 resolve: async (_root, { file }, { user }) => {
188 if (!user) throw new Error("Unauthorized");
189 importFile({
190 file: await file.text(),
191 projectId: env.GOOGLE_PROJECT_ID ?? "",
192 clientEmail: env.GOOGLE_CLIENT_EMAIL ?? "",
193 privateKey: env.GOOGLE_PRIVATE_KEY ?? "",
194 });
195 return true;
196 },
197 }),
198 }),
199 });
200 const Upload = new GraphQLScalarType({
201 name: "Upload",
202 });
203 builder.addScalarType("Upload", Upload, {});
204 schema = builder.toSchema({ sortSchema: false });
205 }
206 return schema;
207 };
208};

フロントエンド

環境変数の受け取りとセッション処理を入れる

Cloudflare Pages では、環境変数はクライアントからの接続要求を受け取った際に引き渡されます。つまり接続要求があるまでは環境変数は利用できません。Remix のドキュメントには各ルーティングページの loader で受け取るような説明が書いてありますが、handleRequest で処理してしまえば一回ですみます。

ここでは Next.js の getInitialProps のような動作をさせて、クライアントへセッションデータとおなじみの環境変数 NEXTPUBLIC*を Context を通じてコンポーネントへ配っています。注意点はここで配った値は、Server 側でレンダリングするときしか有効ではないということです。クライアント側のコンポーネントでは消え去っているので、さらに別の場所で細工をする必要があります。

  • app/entry.server.tsx

1/**
2 * By default, Remix will handle generating the HTTP Response for you.
3 * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 * For more information, see https://remix.run/file-conventions/entry.server
5 */
6
7import { RemixServer } from "@remix-run/react";
8import { renderToReadableStream } from "react-dom/server";
9import { getUserFromToken } from "./libs/client/getUserFromToken";
10import { getHost } from "./libs/server/getHost";
11import { RootProvider } from "./libs/server/RootContext";
12import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare";
13
14export default async function handleRequest(
15 request: Request,
16 responseStatusCode: number,
17 responseHeaders: Headers,
18 remixContext: EntryContext,
19 // This is ignored so we can keep it in the template for visibility. Feel
20 // free to delete this parameter in your app if you're not using it!
21 // eslint-disable-next-line @typescript-eslint/no-unused-vars
22 loadContext: AppLoadContext
23) {
24 const rootValue = await getInitialProps(request, loadContext);
25 const body = await renderToReadableStream(
26 <RootProvider value={rootValue}>
27 <RemixServer context={remixContext} url={request.url} />
28 </RootProvider>,
29 {
30 signal: request.signal,
31 onError(error: unknown) {
32 // Log streaming rendering errors from inside the shell
33 console.error(error);
34 responseStatusCode = 500;
35 },
36 }
37 );
38
39 await body.allReady;
40
41 responseHeaders.set("Content-Type", "text/html");
42 return new Response(body, {
43 headers: responseHeaders,
44 status: responseStatusCode,
45 });
46}
47
48const getInitialProps = async (
49 request: Request,
50 loadContext: AppLoadContext
51) => {
52 const env = loadContext.env as Record<string, string>;
53 const cookie = request.headers.get("cookie");
54 const cookies = Object.fromEntries(
55 cookie?.split(";").map((v) => v.trim().split("=")) ?? []
56 );
57 const token = cookies["auth-token"];
58 const session = await getUserFromToken({ token, secret: env.SECRET_KEY });
59 const host = getHost(request);
60 return {
61 cookie: String(cookie),
62 host,
63 session: session && { name: session.name, email: session.email },
64 env: Object.fromEntries(
65 Object.entries(env).filter(([v]) => v.startsWith("NEXT_PUBLIC_"))
66 ),
67 };
68};

Server/Client の共通処理

entry.server.tsx で渡したデータを受け取って、初回 HTML レンダリング時にそのデータを埋め込むようにしています。クライアント側は、埋め込まれたデータを受け取って処理を行います。これでサーバ側で生成したセッション情報や環境変数がクライアントでも処理できるようになります。

GraphQL のクライアントとして Urql を使っています。この中で SSR 用の
https://www.npmjs.com/package/@react-libraries/next-exchange-ssr
を使っています。これ組み込むと、コンポーネントで Urql のクエリを普通に使うだけで勝手に SSR 化されます。ページごとに loader と useLoadData を書く必要はありません。

1import { NextSSRWait } from "@react-libraries/next-exchange-ssr";
2import { cssBundleHref } from "@remix-run/css-bundle";
3import {
4 Links,
5 LiveReload,
6 Meta,
7 Outlet,
8 Scripts,
9 ScrollRestoration,
10} from "@remix-run/react";
11import stylesheet from "@/tailwind.css";
12import { GoogleAnalytics } from "./components/Commons/GoogleAnalytics";
13import { HeadProvider, HeadRoot } from "./components/Commons/Head";
14import { EnvProvider } from "./components/Provider/EnvProvider";
15import { UrqlProvider } from "./components/Provider/UrqlProvider";
16import { Header } from "./components/System/Header";
17import { LoadingContainer } from "./components/System/LoadingContainer";
18import { NotificationContainer } from "./components/System/Notification/NotificationContainer";
19import { StoreProvider } from "./libs/client/context";
20import { RootValue, useRootContext } from "./libs/server/RootContext";
21import type { LinksFunction } from "@remix-run/cloudflare";
22
23export const links: LinksFunction = () => [
24 { rel: "stylesheet", href: stylesheet },
25 ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
26];
27
28export default function App() {
29 const value = useRootContext();
30 const { host, session, cookie, env } = value;
31 return (
32 <html lang="ja">
33 <EnvProvider value={env}>
34 <StoreProvider initState={() => ({ host, user: session })}>
35 <UrqlProvider host={host} cookie={cookie}>
36 <HeadProvider>
37 <head>
38 <meta charSet="utf-8" />
39 <meta
40 name="viewport"
41 content="width=device-width, initial-scale=1"
42 />
43 <link rel="preconnect" href="https://fonts.googleapis.com" />
44 <link
45 rel="preconnect"
46 href="https://fonts.gstatic.com"
47 crossOrigin="anonymous"
48 />
49 <Meta />
50 <Links />
51 <GoogleAnalytics />
52 <NextSSRWait>
53 <HeadRoot />
54 </NextSSRWait>
55 <RootValue value={{ session, env }} />
56 </head>
57 <body>
58 <div className={"flex h-screen flex-col"}>
59 <Header />
60 <main className="relative flex-1 overflow-hidden">
61 <Outlet />
62 </main>
63 <LoadingContainer />
64 <NotificationContainer />
65 </div>
66 <ScrollRestoration />
67 <Scripts />
68 <LiveReload />
69 </body>
70 </HeadProvider>
71 </UrqlProvider>
72 </StoreProvider>
73 </EnvProvider>
74 </html>
75 );
76}

head の情報挿入

タイトルや OGP の情報を埋め込むため、head の中に情報を挿入する必要があります。Remix では meta ファンクションを使うことになっていますが、私がやりたいのは Next.js の Pages で使っていたコンポーネント内で情報を設定可能な next/head と同等の機能です。

ということでサクッと作ります。

  • app/components/Commons/Head/index.tsx

1import React from "react";
2import {
3 FC,
4 ReactNode,
5 createContext,
6 useContext,
7 useEffect,
8 useRef,
9 useSyncExternalStore,
10} from "react";
11
12const DATA_NAME = "__HEAD_VALUE__";
13
14export type ContextType<T = ReactNode[]> = {
15 state: T;
16 storeChanges: Set<() => void>;
17 dispatch: (callback: (state: T) => T) => void;
18 subscribe: (onStoreChange: () => void) => () => void;
19};
20
21export const useCreateHeadContext = <T,>(initState: () => T) => {
22 const context = useRef<ContextType<T>>({
23 state: initState(),
24 storeChanges: new Set(),
25 dispatch: (callback) => {
26 context.state = callback(context.state);
27 context.storeChanges.forEach((storeChange) => storeChange());
28 },
29 subscribe: (onStoreChange) => {
30 context.storeChanges.add(onStoreChange);
31 return () => {
32 context.storeChanges.delete(onStoreChange);
33 };
34 },
35 }).current;
36 return context;
37};
38
39const HeadContext = createContext<
40 ContextType<{ type: string; props: Record<string, unknown> }[][]>
41>(undefined as never);
42
43export const HeadProvider = ({ children }: { children: ReactNode }) => {
44 const context = useCreateHeadContext<
45 { type: string; props: Record<string, unknown> }[][]
46 >(() => {
47 if (typeof window !== "undefined") {
48 return [
49 JSON.parse(
50 document.querySelector(`script#${DATA_NAME}`)?.textContent ?? "{}"
51 ),
52 ];
53 }
54 return [[]];
55 });
56 return (
57 <HeadContext.Provider value={context}>{children}</HeadContext.Provider>
58 );
59};
60
61export const HeadRoot: FC = () => {
62 const context = useContext(HeadContext);
63 const state = useSyncExternalStore(
64 context.subscribe,
65 () => context.state,
66 () => context.state
67 );
68 useEffect(() => {
69 context.dispatch(() => {
70 return [];
71 });
72 }, [context]);
73 const heads = state.flat();
74 return (
75 <>
76 <script
77 id={DATA_NAME}
78 type="application/json"
79 dangerouslySetInnerHTML={{
80 __html: JSON.stringify(heads).replace(/</g, "\\u003c"),
81 }}
82 />
83 {heads.map(({ type: Tag, props }, index) => (
84 <Tag key={`HEAD${Tag}${index}`} {...props} />
85 ))}
86 </>
87 );
88};
89export const Head: FC<{ children: ReactNode }> = ({ children }) => {
90 const context = useContext(HeadContext);
91 useEffect(() => {
92 const value = extractInfoFromChildren(children);
93 context.dispatch((heads) => [...heads, value]);
94 return () => {
95 context.dispatch((heads) => heads.filter((head) => head !== value));
96 };
97 }, [children, context]);
98
99 if (typeof window === "undefined") {
100 context.dispatch((heads) => [...heads, extractInfoFromChildren(children)]);
101 }
102 return null;
103};
104
105const extractInfoFromChildren = (
106 children: ReactNode
107): { type: string; props: Record<string, unknown> }[] =>
108 React.Children.toArray(children).flatMap((child) => {
109 if (React.isValidElement(child)) {
110 if (child.type === React.Fragment) {
111 return extractInfoFromChildren(child.props.children);
112 }
113 if (typeof child.type === "string") {
114 return [{ type: child.type, props: child.props }];
115 }
116 }
117 return [];
118 });

HeadRoot は root.tsx に設置しています。この機能によって、各コンポーネントで Next.js 同様に Head タグを設定すれば、その情報が収集され<head>タグの中に挿入されます。

コンポーネントの例

graphql-codegen で作った Urql の hook からデータを読み取ってレンダリングしています。Remix の一般的な作り方と違うところは、loader と useLoadData を使用しないことです。Urql に next-exchange-ssr を組み込むだけで、クエリで吐き出した内容は自動的に SSR されるようになります。

こちらも以前に作りました。

https://www.npmjs.com/package/@react-libraries/next-exchange-ssr

  • app/components/Pages/TopPage/index.tsx

1import { FC, useMemo } from "react";
2import { PostsQuery, usePostsQuery, useSystemQuery } from "@/generated/graphql";
3import { useLoading } from "@/hooks/useLoading";
4import { PostList } from "../../PostList";
5import { Title } from "../../System/Title";
6
7interface Props {}
8
9/**
10 * TopPage
11 *
12 * @param {Props} { }
13 */
14export const TopPage: FC<Props> = ({}) => {
15 const [{ data: dataSystem }] = useSystemQuery();
16 const [{ fetching, data }] = usePostsQuery();
17 const posts = useMemo(() => {
18 if (!data?.findManyPost) return undefined;
19 return [...data.findManyPost].sort(
20 (a, b) =>
21 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
22 );
23 }, [data?.findManyPost]);
24 const categories = useMemo(() => {
25 if (!data?.findManyPost) return undefined;
26 const categoryPosts: {
27 [key: string]: { name: string; posts: PostsQuery["findManyPost"] };
28 } = {};
29 data.findManyPost.forEach((post) => [
30 post.categories.forEach((c) => {
31 const value =
32 categoryPosts[c.id] ??
33 (categoryPosts[c.id] = { name: c.name, posts: [] });
34 value.posts.push(post);
35 }),
36 ]);
37 return Object.entries(categoryPosts).sort(([, a], [, b]) =>
38 a.name < b.name ? -1 : 1
39 );
40 }, [data?.findManyPost]);
41 const system = dataSystem?.findUniqueSystem;
42 useLoading(fetching);
43
44 if (!posts || !categories || !system) return null;
45 return (
46 <>
47 <Title>{system.description || "Article List"}</Title>
48 <div className="flex h-full w-full flex-col gap-16 overflow-auto p-8">
49 <PostList id="news" title="新着順" posts={posts} limit={10} />
50 {categories.map(([id, { name, posts }]) => (
51 <PostList key={id} id={id} title={name} posts={posts} limit={10} />
52 ))}
53 </div>
54 </>
55 );
56};

その他

Blurhash

画像最適化機能用のコンポーネントです。与えられた URL を画像最適化用のアドレスに変換する機能と、Blurhash の機能を組み込んでいます。Blurhash は画像が読み込まれるまでの間、元画像から生成したブラーのかかったような代替画像を表示する機能です。この Blog システムでは画像アップロード時に、Blurhash で生成した短い文字列をファイル名として組み込んでいます。そのファイル名を参照して代替画像を表示しています。一応実装はしてみたものの、画像最適化機能+CloudflareのCDNで高速に画像が配信されるため、効果の確認がし辛いです。

{"width":"580px","height":"330px"}

Blurhash で生成される文字列 Base83 は、そのままファイル名として使用すると URL 文字列で問題が起こるので、いったんバイナリデータに戻してから、ファイル名として問題ない文字列へ再変換をかけています。

  • app/components/Commons/Image/index.tsx

1import { decode } from "blurhash";
2import { useEffect, useRef, useState } from "react";
3import { useEnv } from "@/components/Provider/EnvProvider";
4import { fileNameToBase83 } from "@/libs/client/blurhash";
5import { classNames } from "@/libs/client/classNames";
6
7type Props = {
8 src: string;
9 width?: number;
10 height?: number;
11 alt?: string;
12 className?: string;
13};
14
15const useBluerHash = ({
16 src,
17 width,
18 height,
19}: {
20 src: string;
21 width: number;
22 height: number;
23}) => {
24 const [value, setValue] = useState<string>();
25 useEffect(() => {
26 const hash = src.match(/-\[(.*?)\]$/)?.[1];
27 if (!hash || !width || !height) return;
28 try {
29 const canvas = document.createElement("canvas");
30 canvas.width = width;
31 canvas.height = height;
32 const ctx = canvas.getContext("2d")!;
33 const imageData = ctx.createImageData(width, height);
34 const pixels = decode(fileNameToBase83(hash), width, height);
35 imageData.data.set(pixels);
36 ctx.putImageData(imageData, 0, 0);
37 setValue(canvas.toDataURL("image/png"));
38 } catch (e) {}
39 }, [height, src, width]);
40 return value;
41};
42
43export const Image = ({ src, width, height, alt, className }: Props) => {
44 const env = useEnv();
45 const optimizer = env.NEXT_PUBLIC_IMAGE_URL;
46 const url = new URL(optimizer ?? src);
47 if (optimizer) {
48 url.searchParams.set("url", encodeURI(src));
49 width && url.searchParams.set("w", String(width));
50 url.searchParams.set("q", "90");
51 }
52
53 const [, setLoad] = useState(false);
54 const hashUrl = useBluerHash({
55 src,
56 width: width ?? 0,
57 height: height ?? 0,
58 });
59 const ref = useRef<HTMLImageElement>(null);
60 const isBlur = hashUrl && !ref.current?.complete;
61 return (
62 <>
63 <img
64 className={classNames(isBlur ? className : "hidden")}
65 src={hashUrl}
66 alt={alt}
67 width={width}
68 height={height}
69 />
70 <img
71 className={isBlur ? "invisible fixed" : className}
72 ref={ref}
73 src={url.toString()}
74 width={width}
75 height={height}
76 alt={alt}
77 loading="lazy"
78 onLoad={() => setLoad(true)}
79 />
80 </>
81 );
82};

画像の avif 変換

画像アップロード時、サイズを削減するため形式を avif に変換するようにしています。変換はブラウザ上で avif エンコード用の wasm を動きます。この wasm は 3MB あり、圧縮しても 1MB を超えますが、ブラウザ実行用の Assets として配置する場合は、1MB 制限には引っかかりません。

こちらもサクッと作りました

https://www.npmjs.com/package/@node-libraries/wasm-avif-encoder

以下はwasm-avif-encoderを利用してavifに変換する部分です。avifの状態で利用するのはあくまでストレージに格納する段階です。このデータがクライアント配信時に画像最適化を通って適切なサイズのwebpに変換されます。

1import { encode } from "@node-libraries/wasm-avif-encoder";
2import { encode as encodeHash } from "blurhash";
3import { arrayBufferToBase64 } from "@/libs/server/buffer";
4import { base83toFileName } from "./blurhash";
5
6const type = "avif";
7
8export const convertImage = async (
9 blob: Blob,
10 width?: number,
11 height?: number
12): Promise<File | Blob | null> => {
13 if (!blob.type.match(/^image\/(png|jpeg|webp|avif)/)) return blob;
14
15 const src = await blob
16 .arrayBuffer()
17 .then((v) => `data:${blob.type};base64,` + arrayBufferToBase64(v));
18 const img = document.createElement("img");
19 img.src = src;
20 await new Promise((resolve) => (img.onload = resolve));
21
22 let outWidth = width ? width : img.width;
23 let outHeight = height ? height : img.height;
24 const aspectSrc = img.width / img.height;
25 const aspectDest = outWidth / outHeight;
26 if (aspectSrc > aspectDest) {
27 outHeight = outWidth / aspectSrc;
28 } else {
29 outWidth = outHeight * aspectSrc;
30 }
31
32 const canvas = document.createElement("canvas");
33 [canvas.width, canvas.height] = [outWidth, outHeight];
34 const ctx = canvas.getContext("2d");
35 if (!ctx) return null;
36 ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, outWidth, outHeight);
37 const data = ctx.getImageData(0, 0, outWidth, outHeight);
38 const value = await encode({
39 data,
40 worker: `/${type}/worker.js`,
41 quality: 90,
42 });
43 if (!value) return null;
44 const hash = encodeHash(data.data, outWidth, outHeight, 4, 4);
45 const filename = base83toFileName(hash);
46 return new File([value], filename, { type: `image/${type}` });
47};
48
49export const getImageSize = async (blob: Blob) => {
50 const src = await blob
51 .arrayBuffer()
52 .then((v) => `data:${blob.type};base64,` + arrayBufferToBase64(v));
53 const img = document.createElement("img");
54 img.src = src;
55 await new Promise((resolve) => (img.onload = resolve));
56 return { width: img.naturalWidth, height: img.naturalHeight };
57};

まとめ

無いものは作る。ただこの一点です。