[すべて無料]Remix+Cloudflare Pagesでブロクシステムを作成する
ブログシステムの作成
現在この記事を表示しているのが、今回作成したブログシステムです
環境構成
インフラ
サービス | 内容 |
---|---|
Supabase | Database |
Firebase | Storage 認証 |
Cloudflare Pages | Front(Remix) & Backend(GraphQL yoga) |
Cloudflare Workers | 画像最適化 OGP 生成 PrismaQueryEngine |
すべて無料で使用可能です。各サービスとも無料範囲が大きいのと、最終的な出力結果が 無制限で使える Cloudflare CDN キャッシュで配信されるので、一日に何万アクセスレベルでも無料でいけます。
主な使用パッケージ
パッケージ | 説明 |
---|---|
Prisma | Database 用 ORM |
Graphql Yoga | GraphQL サーバ |
Pothos GraphQL | GraphQL フレームワーク |
Remix | Cloudflare と相性の良い React 用フレームワーク |
Urql | GraphQL クライアント |
上記構成で作りました。
バックエンドとフロントのビルドを統合したかったので、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 |
CPU | 10ms 使用している感じだと、連続してオーバーしなければある程度は許してくれる感じ |
外部への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";45if (process.env.NODE_ENV === "development") {6 logDevReady(build);7}89type Env = {10 prisma: Fetcher;11 DATABASE_URL: string;12};1314const 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};3233export 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";78const yoga = createYoga<9 {10 request: Request;11 env: { [key: string]: string };12 responseCookies: string[];13 },14 Context15>({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});3738export 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}5152export 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";89/**10 * Create a new schema builder instance11 */1213type BuilderType = {14 PrismaTypes: PrismaTypes;15 Scalars: {16 Upload: {17 Input: File;18 Output: File;19 };20 };21 Context: Context;22};2324export 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 });3738 return builder;39};
app/libs/server/schema.ts
こちらはログインと Firebase に対するファイルアップロード処理を追加リゾルバをスキーマに加えています。
1import { GraphQLScalarType, GraphQLSchema } from "graphql";23import { 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";1112export 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.post102 .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.server5 */67import { 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";1314export 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. Feel20 // free to delete this parameter in your app if you're not using it!21 // eslint-disable-next-line @typescript-eslint/no-unused-vars22 loadContext: AppLoadContext23) {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 shell33 console.error(error);34 responseStatusCode = 500;35 },36 }37 );3839 await body.allReady;4041 responseHeaders.set("Content-Type", "text/html");42 return new Response(body, {43 headers: responseHeaders,44 status: responseStatusCode,45 });46}4748const getInitialProps = async (49 request: Request,50 loadContext: AppLoadContext51) => {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";2223export const links: LinksFunction = () => [24 { rel: "stylesheet", href: stylesheet },25 ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),26];2728export 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 <meta40 name="viewport"41 content="width=device-width, initial-scale=1"42 />43 <link rel="preconnect" href="https://fonts.googleapis.com" />44 <link45 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";1112const DATA_NAME = "__HEAD_VALUE__";1314export type ContextType<T = ReactNode[]> = {15 state: T;16 storeChanges: Set<() => void>;17 dispatch: (callback: (state: T) => T) => void;18 subscribe: (onStoreChange: () => void) => () => void;19};2021export 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};3839const HeadContext = createContext<40 ContextType<{ type: string; props: Record<string, unknown> }[][]>41>(undefined as never);4243export 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};6061export const HeadRoot: FC = () => {62 const context = useContext(HeadContext);63 const state = useSyncExternalStore(64 context.subscribe,65 () => context.state,66 () => context.state67 );68 useEffect(() => {69 context.dispatch(() => {70 return [];71 });72 }, [context]);73 const heads = state.flat();74 return (75 <>76 <script77 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]);9899 if (typeof window === "undefined") {100 context.dispatch((heads) => [...heads, extractInfoFromChildren(children)]);101 }102 return null;103};104105const extractInfoFromChildren = (106 children: ReactNode107): { 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";67interface Props {}89/**10 * TopPage11 *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 : 139 );40 }, [data?.findManyPost]);41 const system = dataSystem?.findUniqueSystem;42 useLoading(fetching);4344 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で高速に画像が配信されるため、効果の確認がし辛いです。
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";67type Props = {8 src: string;9 width?: number;10 height?: number;11 alt?: string;12 className?: string;13};1415const 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};4243export 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 }5253 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 <img64 className={classNames(isBlur ? className : "hidden")}65 src={hashUrl}66 alt={alt}67 width={width}68 height={height}69 />70 <img71 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";56const type = "avif";78export const convertImage = async (9 blob: Blob,10 width?: number,11 height?: number12): Promise<File | Blob | null> => {13 if (!blob.type.match(/^image\/(png|jpeg|webp|avif)/)) return blob;1415 const src = await blob16 .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));2122 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 }3132 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};4849export const getImageSize = async (blob: Blob) => {50 const src = await blob51 .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};
まとめ
無いものは作る。ただこの一点です。