Next.jsで対象ユーザのnpmパッケージリストを作成し、SSRでOGPも作る
npm のパッケージリスト
npm にパッケージを公開し続けていたら、気がつくと 50 を超えるパッケージを公開していました。これらのパッケージのダウンロード状態などを個別に把握するのは辛いので、一覧表示するページを作成しました。
https://next-npm.vercel.app/?name=sora_kumo&sort=3
データの取得方法
npm パッケージの検索
以下のアドレスで GET リクエストを送ると、指定したユーザがメンテナしているパッケージの一覧を取得できます。
https://registry.npmjs.org/-/v1/search?text=maintainer:${name}&size=1000
ユーザー情報の取得
以下のアドレスで GET リクエストを送ると、指定したユーザの情報を取得できます。
https://www.npmjs.com/~${name}
ただし普通にアクセスすると HTML で返ってくるので、JSON 形式で欲しい場合は以下のヘッダを追加します。
headers: { "X-Spiferack": "1" }
また、こちらは CORS が許可されていないので、バックエンド側で取得してからフロントに渡す必要があります。
フロントの作成
_app.tsx
getHost でホスト名をヘッダから取得する機能を追加しています。フロントからバックエンドにリクエストを送る際に、ホスト名を指定するためです。また SSRProvider を加えています。これは SSR を行うための Provider です。これを入れておくだけで、Next.js の標準機能をスルーして SSR を行うことができます。
1import { IncomingMessage } from "http";2import { AppContext, AppProps } from "next/app";3import "./global.css";4import { SSRProvider } from "next-ssr";56const App = ({ Component, pageProps }: AppProps<{ host?: string }>) => {7 return (8 <SSRProvider>9 <Component {...pageProps} />10 </SSRProvider>11 );12};1314App.getInitialProps = async (context: AppContext) => {15 const host = getHost(context?.ctx?.req);16 return {17 pageProps: {18 host,19 },20 };21};2223export const getHost = (req?: Partial<IncomingMessage>) => {24 const headers = req?.headers;25 const host = headers?.["x-forwarded-host"] ?? headers?.["host"];26 if (!host) return undefined;27 const proto =28 headers?.["x-forwarded-proto"]?.toString().split(",")[0] ?? "http";29 return headers ? `${proto}://${host}` : undefined;30};3132export default App;
pages/index.tsx
useSSRを使うと、引数で指定した非同期処理で取得したデータが SSR されます。Next.js の getServerSideProps などは不要です。ServerComponents のような仕組みもいりません。コンポーネント上に非同期処理を書けば、それが SSR されます。
SSR 用に取得したデータは OGP 生成で利用しています。これでパッケージリストを SNS などに貼り付けたときに、カードが表示されます。
1import Head from "next/head";2import Link from "next/link";3import { useRouter } from "next/router";4import { useSSR } from "next-ssr";5import {6 FormEventHandler,7 MouseEventHandler,8 useDeferredValue,9 useEffect,10 useMemo,11 useState,12} from "react";13import { DateString } from "../libs/DateString";14import { NpmObject, NpmPackagesType } from "../types/npm";1516const usePackages = (name: string, host?: string) => {17 const { data } = useSSR<[NpmPackagesType, string] | undefined>(18 () =>19 Promise.all([20 fetch(21 `https://registry.npmjs.org/-/v1/search?text=maintainer:${name}&size=1000`22 ).then((r) => r.json()),23 fetch(`${host ?? ""}/user/?name=${name}`).then((r) => r.text()),24 ]),25 { key: name }26 );27 return data;28};2930const usePackageDownloads = (objects?: NpmObject[]) => {31 const [downloads, setDownloads] = useState<Record<string, number[]>>({});32 const downloadsDelay = useDeferredValue(downloads);33 useEffect(() => {34 if (objects) {35 objects.forEach((npm) => {36 const name = npm.package.name;37 setDownloads((v) => ({ ...v, [name]: Array(3).fill(undefined) }));38 const periods = ["last-year", "last-week", "last-day"] as const;39 periods.forEach((period, index) => {40 fetch(`https://api.npmjs.org/downloads/point/${period}/${name}`)41 .then((r) => r.json())42 .then((r) => {43 setDownloads((v) => {44 const d: number[] = v[name] ?? [];45 d[index] = r.downloads;46 return { ...v, [name]: d };47 });48 });49 });50 });51 }52 }, [objects]);53 return downloadsDelay;54};5556export const NpmList = ({ host }: { host?: string }) => {57 const router = useRouter();58 const name =59 typeof router.query["name"] === "string" ? router.query["name"] : "";60 const value = usePackages(name, host);6162 const downloads = usePackageDownloads(value?.[0].objects);63 const sortIndex = Number(router.query["sort"] || "0");6465 const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {66 e.preventDefault();67 const query: Record<string, string | number> = {68 name: e.currentTarget.maintainer.value,69 };70 if (sortIndex) query["sort"] = sortIndex;71 router.push({ query });72 };73 const handleClick: MouseEventHandler<HTMLElement> = (e) => {74 const index = e.currentTarget.dataset["index"];75 router.replace({ query: { name, sort: index } });76 };77 const items = useMemo(() => {78 return value?.[0].objects79 .map((o) => o.package)80 .sort((a, b) => {81 switch (sortIndex) {82 default:83 return 0;84 case 1:85 return new Date(b.date).getTime() - new Date(a.date).getTime();86 case 2:87 return a.name < b.name ? -1 : 1;88 case 3:89 case 4:90 case 5:91 return (92 (downloads[b.name]?.[sortIndex - 3] ?? 0) -93 (downloads[a.name]?.[sortIndex - 3] ?? 0)94 );95 }96 });97 }, [value, sortIndex, downloads]);9899 const title = name ? `${name} npm packages list` : "List of npm packages";100 const systemDescription = name101 ? `Number of npm packages is ${value?.[0].objects.length ?? 0}`102 : "System for listing npm packages";103 const imageUrl = value?.[1];104105 return (106 <>107 <Head>108 <title>{`${name} npm list`}</title>109 <meta property="description" content={systemDescription} />110 <meta property="og:title" content={title} />111 <meta property="og:description" content={systemDescription} />112 <meta property="og:type" content="website" />113 <meta property="og:image" content={imageUrl} />114 <meta name="twitter:card" content={"summary"} />115 </Head>116117 <form onSubmit={handleSubmit} className="flex items-center gap-2 p-1">118 <input119 name="maintainer"120 className="input input-bordered w-full max-w-xs"121 defaultValue={name}122 />123 <button className="btn" type="submit">124 設定125 </button>126 <Link127 href="https://github.com/SoraKumo001/next-npm"128 className="underline"129 target="_blank"130 >131 Source Code132 </Link>133 </form>134135 <table className="table [&_*]:border-gray-300 [&_td]:border-x [&_td]:py-1 [&_th:hover]:bg-slate-100 [&_th]:border-x">136 <thead>137 <tr className="sticky top-0 cursor-pointer bg-white text-lg font-semibold">138 {["index", "date", "name", "year", "week", "day"].map(139 (v, index) => (140 <th key={v} onClick={handleClick} data-index={index}>141 {v}142 </th>143 )144 )}145 </tr>146 </thead>147 <tbody className="[&_tr:nth-child(odd)]:bg-slate-200">148 {items?.map(({ name, date }, index) => (149 <tr key={name}>150 <td>{index + 1}</td>151 <td>{DateString(date)}</td>152 <td>153 <Link154 href={`https://www.npmjs.com/package/${name}`}155 target="_blank"156 >157 {name}158 </Link>159 </td>160 {downloads[name]?.map((v, index) => (161 <td key={index}>{v}</td>162 ))}163 </tr>164 ))}165 </tbody>166 </table>167 </>168 );169};
app/user/route.ts
AppRoute 上に API を作成しています。こちらはユーザー情報を取得する API を使って、OPG 用の画像を取得しています。アドレスが JWT 形式になっているので、デコードしてから avatarURL を取得しています。ちなみに JWT はただの Base64 文字列なので、データを取り出すだけなら専用のライブラリは不要です。
1import { NextRequest, NextResponse } from "next/server";2import { NpmUserType } from "../../types/npm";34export const GET = async (req: NextRequest): Promise<NextResponse> => {5 const url = new URL(req.url);6 const name = url.searchParams.get("name");7 if (!name) {8 return new NextResponse("name is required", { status: 400 });9 }10 const npmUser = await fetch(`https://www.npmjs.com/~${name}`, {11 headers: { "X-Spiferack": "1" },12 })13 .then((r) => r.json() as Promise<NpmUserType>)14 .catch(() => null);15 const img = npmUser?.scope?.parent.avatars.large;16 const token = img?.split("/")[2];17 const avatarURL = token && JSON.parse(atob(token.split(".")[1]))["avatarURL"];18 if (!avatarURL) return new NextResponse("Not Found", { status: 404 });19 return new NextResponse(avatarURL, { status: 404 });20};
eslint の設定
eslint.config.mjs
eslint を入れるとデフォルトで flat-config を使う形になったので設定例を紹介します。flat-config の趣旨としては、プラグインとルールの設定を種類ごとに分離してかけるようにしたのだろうと想像できるので、そのように設定しています。flat-config の情報はイマイチ情報が少ないです。
1/**2 * @type {import('eslint').Linter.FlatConfig[]}3 */4import { fixupPluginRules } from "@eslint/compat";5import eslint from "@eslint/js";6import eslintConfigPrettier from "eslint-config-prettier";7import importPlugin from "eslint-plugin-import";8import react from "eslint-plugin-react";9import reactHooks from "eslint-plugin-react-hooks";10import tailwind from "eslint-plugin-tailwindcss";11import tslint from "typescript-eslint";1213export default [14 eslint.configs.recommended,15 ...tslint.configs.recommended,16 ...tailwind.configs["flat/recommended"],17 eslintConfigPrettier,18 {19 plugins: {20 react: fixupPluginRules(react),21 },22 rules: react.configs["jsx-runtime"].rules,23 },24 {25 plugins: {26 "react-hooks": fixupPluginRules(reactHooks),27 },28 rules: reactHooks.configs.recommended.rules,29 },30 {31 plugins: {32 import: importPlugin,33 },34 rules: {35 "@typescript-eslint/no-unused-vars": 0,36 "no-empty-pattern": 0,37 "no-empty": 0,38 "import/order": [39 "warn",40 {41 groups: [42 "builtin",43 "external",44 "internal",45 ["parent", "sibling"],46 "object",47 "type",48 "index",49 ],50 pathGroupsExcludedImportTypes: ["builtin"],51 alphabetize: {52 order: "asc",53 caseInsensitive: true,54 },55 },56 ],57 },58 },59];
まとめ
最近Next.js の AppRoute が話題になることが多いですが、その中でコンポーネント内に非同期処理を直書いて SSR 出来るという利点が語られることがあります。しかし今回やった通り、 PagesRouter でもそのまま書けます。もちろん ServerComponents は使う必要がありません。実はReact本体にコンポーネント内での非同期処理機能が搭載されているので、SSRにフレームワーク側の機能は不要なのです。