空雲 Blog

Eye catchNext.jsで対象ユーザのnpmパッケージリストを作成し、SSRでOGPも作る

publication: 2024/07/28
update:2024/07/30

npm のパッケージリスト

npm にパッケージを公開し続けていたら、気がつくと 50 を超えるパッケージを公開していました。これらのパッケージのダウンロード状態などを個別に把握するのは辛いので、一覧表示するページを作成しました。

https://next-npm.vercel.app/?name=sora_kumo&sort=3

{"width":"1004px","height":"747px"}

データの取得方法

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";
5
6const App = ({ Component, pageProps }: AppProps<{ host?: string }>) => {
7 return (
8 <SSRProvider>
9 <Component {...pageProps} />
10 </SSRProvider>
11 );
12};
13
14App.getInitialProps = async (context: AppContext) => {
15 const host = getHost(context?.ctx?.req);
16 return {
17 pageProps: {
18 host,
19 },
20 };
21};
22
23export 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};
31
32export 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";
15
16const 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};
29
30const 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};
55
56export 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);
61
62 const downloads = usePackageDownloads(value?.[0].objects);
63 const sortIndex = Number(router.query["sort"] || "0");
64
65 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].objects
79 .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]);
98
99 const title = name ? `${name} npm packages list` : "List of npm packages";
100 const systemDescription = name
101 ? `Number of npm packages is ${value?.[0].objects.length ?? 0}`
102 : "System for listing npm packages";
103 const imageUrl = value?.[1];
104
105 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>
116
117 <form onSubmit={handleSubmit} className="flex items-center gap-2 p-1">
118 <input
119 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 <Link
127 href="https://github.com/SoraKumo001/next-npm"
128 className="underline"
129 target="_blank"
130 >
131 Source Code
132 </Link>
133 </form>
134
135 <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 <Link
154 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";
3
4export 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";
12
13export 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にフレームワーク側の機能は不要なのです。