diff --git a/jest.setup.js b/jest.setup.js index 311c37e6..160f8b3e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -12,4 +12,5 @@ jest.mock("react", () => { // Override untable_cache method to avoid caching in tests jest.mock("next/cache", () => ({ unstable_cache: (fn) => fn, + cacheLife: jest.fn(), })); diff --git a/next.config.ts b/next.config.ts index 060d71c3..cee4651e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,7 @@ const nextConfig: NextConfig = { reactStrictMode: true, reactCompiler: true, serverExternalPackages: ["libnpmdiff", "npm-package-arg", "pacote"], + cacheComponents: true, }; export default nextConfig; diff --git a/src/app/[...parts]/_page/BundlephobiaDiff.tsx b/src/app/[...parts]/_page/BundlephobiaDiff.tsx index 04148926..45f574bc 100644 --- a/src/app/[...parts]/_page/BundlephobiaDiff.tsx +++ b/src/app/[...parts]/_page/BundlephobiaDiff.tsx @@ -1,3 +1,4 @@ +import { cacheLife } from "next/cache"; import bundlephobia from "^/lib/api/bundlephobia"; import { Bundlephobia } from "^/lib/Services"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; @@ -21,6 +22,11 @@ const BundlephobiaDiffInner = async ({ a, b, }: BundlephobiaDiffProps) => { + "use cache"; + + // The shortest cacheLife that `bundlephobia` uses is hours, so we can use that here too. + cacheLife("hours"); + const { result, time } = await measuredPromise(bundlephobia(specs)); if (result == null) { diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx index efb9f416..56aadf74 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -16,7 +16,7 @@ const SpecBox = forwardRef(
diff --git a/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx b/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx index 430132fa..19453728 100644 --- a/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx +++ b/src/app/[...parts]/_page/NpmDiff/NpmDiff.tsx @@ -1,4 +1,5 @@ import type { Options } from "libnpmdiff"; +import { cacheLife } from "next/cache"; import { Suspense } from "react"; import type { FileData } from "react-diff-view"; import Stack from "^/components/ui/Stack"; @@ -18,6 +19,10 @@ export interface NpmDiffProps { } const NpmDiff = async ({ a, b, specs, options }: NpmDiffProps) => { + "use cache"; + + cacheLife("max"); + const diff = await npmDiff(specs, options); const files: FileData[] = gitDiffParse(diff); diff --git a/src/app/[...parts]/_page/PackagephobiaDiff.tsx b/src/app/[...parts]/_page/PackagephobiaDiff.tsx index 2e45f687..214004ae 100644 --- a/src/app/[...parts]/_page/PackagephobiaDiff.tsx +++ b/src/app/[...parts]/_page/PackagephobiaDiff.tsx @@ -1,3 +1,4 @@ +import { cacheLife } from "next/cache"; import packagephobia from "^/lib/api/packagephobia"; import { Packagephobia } from "^/lib/Services"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; @@ -18,6 +19,11 @@ const PackagephobiaDiffInner = async ({ a, b, }: PackagephobiaDiffProps) => { + "use cache"; + + // Cache for the shortest window that packagephobia is cached + cacheLife("hours"); + const { result, time } = await measuredPromise(packagephobia(specs)); if (result == null) { diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index 1465f887..d3d223e4 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -1,6 +1,6 @@ import { type Metadata } from "next"; import { redirect } from "next/navigation"; -import { type JSX } from "react"; +import { type JSX, Suspense } from "react"; import { type ViewType } from "react-diff-view"; import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec"; import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; @@ -35,7 +35,7 @@ export async function generateMetadata({ }; } -const DiffPage = async ({ +const DiffPageInner = async ({ params, searchParams, }: DiffPageProps): Promise => { @@ -79,7 +79,7 @@ const DiffPage = async ({ a={a} b={b} specs={canonicalSpecs} - key={ + suspenseKey={ "bundlephobia-" + canonicalSpecs.join("...") } /> @@ -87,7 +87,7 @@ const DiffPage = async ({ a={a} b={b} specs={canonicalSpecs} - key={ + suspenseKey={ "packagephobia-" + canonicalSpecs.join("...") } @@ -101,11 +101,19 @@ const DiffPage = async ({ b={b} specs={canonicalSpecs} options={options} - key={JSON.stringify([canonicalSpecs, options])} + suspenseKey={JSON.stringify([canonicalSpecs, options])} /> ); } }; +const DiffPage = (props: DiffPageProps) => { + return ( + + + + ); +}; + export default DiffPage; diff --git a/src/app/_layout/Header/Header.tsx b/src/app/_layout/Header/Header.tsx index 1704c220..c48ebc20 100644 --- a/src/app/_layout/Header/Header.tsx +++ b/src/app/_layout/Header/Header.tsx @@ -1,10 +1,10 @@ import Link from "next/link"; -import { forwardRef, type HTMLAttributes } from "react"; +import { forwardRef, type HTMLAttributes, Suspense } from "react"; import Heading from "^/components/ui/Heading"; import { cx } from "^/lib/cva"; import ColorModeToggle from "./ColorModeToggle"; import GithubLink from "./GithubLink"; -import NavLink from "./NavLink"; +import NavLink, { NavLinkFallback } from "./NavLink"; export interface HeaderProps extends HTMLAttributes {} @@ -34,9 +34,23 @@ const Header = forwardRef(
- about - / - api + + + about + + / + + api + + + } + > + about + / + api +
), diff --git a/src/app/_layout/Header/NavLink.tsx b/src/app/_layout/Header/NavLink.tsx index 721443ee..87aab52e 100644 --- a/src/app/_layout/Header/NavLink.tsx +++ b/src/app/_layout/Header/NavLink.tsx @@ -52,4 +52,17 @@ const NavLink = forwardRef(function NavLink( ); }); +export const NavLinkFallback = forwardRef( + function NavLinkFallback({ href = "", className, ...props }, ref) { + return ( + + ); + }, +); + export default NavLink; diff --git a/src/app/about/api/page.tsx b/src/app/about/api/page.tsx index 5ea30d04..e90ee7a3 100644 --- a/src/app/about/api/page.tsx +++ b/src/app/about/api/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; import ExternalLink from "^/components/ExternalLink"; import Code from "^/components/ui/Code"; import Heading from "^/components/ui/Heading"; @@ -23,9 +24,11 @@ export const metadata: Metadata = { description: "API documentation for npm-diff.app", }; -// We need nodejs since we use Npm libs https://beta.nextjs.org/docs/api-reference/segment-config#runtime -export const runtime = "nodejs"; const AboutApiPage = async () => { + "use cache"; + + cacheLife("max"); + const specsOrVersions = splitParts(EXAMPLE_QUERY); const { canonicalSpecs: specs } = await destination(specsOrVersions); diff --git a/src/app/api/-/versions/route.ts b/src/app/api/-/versions/route.ts index a279679e..002a7088 100644 --- a/src/app/api/-/versions/route.ts +++ b/src/app/api/-/versions/route.ts @@ -2,8 +2,6 @@ import { NextResponse } from "next/server"; import getVersionData from "^/lib/api/npm/getVersionData"; import { type Version, VERSIONS_PARAMETER_PACKAGE } from "./types"; -export const runtime = "edge"; - export async function GET(request: Request) { const start = Date.now(); diff --git a/src/app/page.tsx b/src/app/page.tsx index d8b69751..93395797 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import { cacheLife } from "next/cache"; import fallback from "^/lib/autocomplete/fallback"; import Intro from "./_page/Intro"; import IndexPageClient from "./page.client"; @@ -5,6 +6,10 @@ import IndexPageClient from "./page.client"; export interface IndexProps {} const IndexPage = async ({}: IndexProps) => { + "use cache"; + + cacheLife("max"); + const fallbackSuggestions = await fallback(); return ( diff --git a/src/lib/api/bundlephobia/bundlephobia.ts b/src/lib/api/bundlephobia/bundlephobia.ts index 345537c4..067417c5 100644 --- a/src/lib/api/bundlephobia/bundlephobia.ts +++ b/src/lib/api/bundlephobia/bundlephobia.ts @@ -1,11 +1,16 @@ +import { cacheLife } from "next/cache"; import npa from "npm-package-arg"; +import { USER_AGENT } from "../user-agent"; import type BundlephobiaResponse from "./BundlephobiaResponse"; import type BundlephobiaResults from "./BundlephobiaResults"; async function getPackage(spec: string): Promise { + "use cache"; + const { scope } = npa(spec); if (scope === "@types") { + cacheLife("max"); return null; } @@ -14,18 +19,74 @@ async function getPackage(spec: string): Promise { `https://bundlephobia.com/api/size?package=${spec}`, { signal: AbortSignal.timeout(7_500), + headers: { + "User-Agent": USER_AGENT, + }, + // Opt out of fetch-level caching, we have caching in function + cache: "no-store", }, ); - if (response.status !== 200) { - throw new Error(`${response.status} ${response.statusText}`); - } + if (response.status === 200) { + // If we succeed, cache as long as we're allowed + cacheLife("max"); + + const json: BundlephobiaResponse = await response.json(); + + return json; + } else if (response.status === 403) { + // Bundlephobia returns 403 forbidden for packages that are not supposed to be bundled. + // This is a stable, permanent behaviour, so we cache forever. + // For a list of packages; https://github.com/pastelsky/bundlephobia/blob/bundlephobia/server/config.js + cacheLife("max"); + + console.warn(`[${spec}] Bundlephobia returned 403 Forbidden`); + + return null; + } else if (response.status === 404) { + // Package not found, cache for a while + cacheLife("hours"); + + console.warn(`[${spec}] Bundlephobia returned 404 Not Found`); + + return null; + } else if (response.status === 500) { + // Server error, this is most likely because the package is too large or complex for Bundlephobia to handle. + // We don't want to retry too often, but we also don't want to cache forever in case the issue is resolved. + cacheLife("days"); - const json: BundlephobiaResponse = await response.json(); + console.error( + `[${spec}] Bundlephobia returned 500 Internal Server Error`, + ); - return json; + return null; + } else { + // For other, unexpected statuses, we cache briefly and log the error. + cacheLife("hours"); + + console.error( + `[${spec}] Bundlephobia returned unexpected status: ${response.status} ${response.statusText}`, + ); + + return null; + } } catch (e) { - console.error(`[${spec}] Bundlephobia error:`, e); + if (e instanceof Error && e.name === "TimeoutError") { + // Timing out is typical for large or complex packages. + // We don't want to retry too often, but we also don't want to cache forever in case the issue is resolved. + cacheLife("days"); + + console.error(`[${spec}] Bundlephobia request timed out`); + + return null; + } else { + // For other, unexpected errors, we cache briefly and log the error. + cacheLife("hours"); + + console.error(`[${spec}] Bundlephobia error:`, e); + + return null; + } } return null; diff --git a/src/lib/api/npm/getVersionData.ts b/src/lib/api/npm/getVersionData.ts index 63881680..dcb58424 100644 --- a/src/lib/api/npm/getVersionData.ts +++ b/src/lib/api/npm/getVersionData.ts @@ -1,7 +1,6 @@ -import { unstable_cache } from "next/cache"; -import { cache } from "react"; +import { cacheLife } from "next/cache"; +import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; -import { simplePackageSpecToString } from "^/lib/SimplePackageSpec"; import packument from "./packument"; // Packuments include a lot of data, often enough to make them too large for the cache. @@ -17,17 +16,21 @@ export type VersionMap = { [version: string]: VersionData; }; -async function getVersionDataInner( - spec: string | SimplePackageSpec, -): Promise { - const specString = - typeof spec === "string" ? spec : simplePackageSpecToString(spec); +/** + * Separate function that takes only packagename for better caching. + * + * We want `a@1.2.3` and `a@2.0.0` to share the same cache entry for `a`. + */ +async function getVersionMap(packageName: string): Promise { + "use cache"; + + cacheLife("hours"); const { time, "dist-tags": tags, versions, - } = await packument(specString, { + } = await packument(packageName, { // Response is too large to cache in Next's Data Cache; always fetch cache: "no-store", }); @@ -52,13 +55,13 @@ async function getVersionDataInner( return versionData; } -const getVersionData = - // Cache for request de-dupe - cache( - // unstable cache to cache between requests (5 minute TTL) - unstable_cache(getVersionDataInner, ["versionData"], { - revalidate: 300, - }), - ); +async function getVersionData( + spec: string | SimplePackageSpec, +): Promise { + const { name } = + typeof spec === "string" ? createSimplePackageSpec(spec) : spec; + + return getVersionMap(name); +} export default getVersionData; diff --git a/src/lib/api/packagephobia/packagephobia.ts b/src/lib/api/packagephobia/packagephobia.ts index a7f6a7c9..780dfbd6 100644 --- a/src/lib/api/packagephobia/packagephobia.ts +++ b/src/lib/api/packagephobia/packagephobia.ts @@ -1,8 +1,11 @@ +import { cacheLife } from "next/cache"; import { USER_AGENT } from "../user-agent"; import type PackagephobiaResponse from "./PackagephobiaResponse"; import type PackagephobiaResults from "./PackagephobiaResult"; async function getPackage(spec: string): Promise { + "use cache"; + try { const response = await fetch( `https://packagephobia.com/v2/api.json?p=${spec}`, @@ -11,21 +14,62 @@ async function getPackage(spec: string): Promise { headers: { "User-Agent": USER_AGENT, }, + // Ensure no fetch-level caching, we have caching in function + cache: "no-store", }, ); - if (response.status !== 200) { - throw new Error(`${response.status} ${response.statusText}`); - } + if (response.status === 200) { + // If we succeed, cache as long as we're allowed + cacheLife("max"); + + const json: PackagephobiaResponse = await response.json(); + + return json; + } else if (response.status === 404) { + // Package not found, cache for a while + cacheLife("hours"); + + console.warn(`[${spec}] Packagephobia returned 404 Not Found`); + + return null; + } else if (response.status === 429) { + // Rate limited, cache briefly + cacheLife("hours"); + + console.warn( + `[${spec}] Packagephobia returned 429 Too Many Requests`, + ); + + return null; + } else { + // For other, unexpected status codes, we cache briefly and log the error. + cacheLife("hours"); - const json: PackagephobiaResponse = await response.json(); + console.warn( + `[${spec}] Packagephobia returned ${response.status} ${response.statusText}`, + ); - return json; + return null; + } } catch (e) { - console.error(`[${spec}] Packagephobia error:`, e); - } + if (e instanceof Error && e.name === "TimeoutError") { + // Timing out is typical for large or complex packages. + // We don't want to retry too often, but we also don't want to cache forever in case the issue is resolved. + cacheLife("days"); + + console.warn(`[${spec}] Packagephobia request timed out`); + + return null; + } else { + // For other errors, we cache briefly and log the error. + cacheLife("hours"); - return null; + console.error(`[${spec}] Packagephobia request error:`, e); + + return null; + } + } } async function getPackages( diff --git a/src/lib/destination/canonicalSpec.ts b/src/lib/destination/canonicalSpec.ts index e6881ae8..4c0a743b 100644 --- a/src/lib/destination/canonicalSpec.ts +++ b/src/lib/destination/canonicalSpec.ts @@ -1,4 +1,4 @@ -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; import npa, { type AliasResult } from "npm-package-arg"; import pacote from "pacote"; @@ -58,14 +58,14 @@ async function handleNpaResult(result: npa.Result): Promise { * - https://github.com/npm/npm-package-arg#result-object * - https://docs.npmjs.com/cli/v7/commands/npm-install */ -const canonicalSpec = unstable_cache( - async function _canonicalSpecs(spec: string): Promise { - const result = npa(spec); +async function canonicalSpec(spec: string): Promise { + "use cache"; - return handleNpaResult(result); - }, - [], - { revalidate: 60 * 30 }, -); + cacheLife("hours"); + + const result = npa(spec); + + return handleNpaResult(result); +} export default canonicalSpec; diff --git a/src/lib/npmDiff/npmDiff.ts b/src/lib/npmDiff/npmDiff.ts index 6783e80f..84af2352 100644 --- a/src/lib/npmDiff/npmDiff.ts +++ b/src/lib/npmDiff/npmDiff.ts @@ -1,5 +1,5 @@ import libnpmdiff, { type Options } from "libnpmdiff"; -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; interface ErrorETARGET { code: "ETARGET"; @@ -15,10 +15,14 @@ interface Error404 { pkgid: string; } -async function _npmDiff( +async function npmDiff( specs: [string, string], options: Options, ): Promise { + "use cache"; + + cacheLife("max"); + let startTime = 0; try { startTime = Date.now(); @@ -76,6 +80,4 @@ async function _npmDiff( } } -const npmDiff = unstable_cache(_npmDiff); - export default npmDiff; diff --git a/src/lib/suspense.tsx b/src/lib/suspense.tsx index b3ca4284..91ddb3d7 100644 --- a/src/lib/suspense.tsx +++ b/src/lib/suspense.tsx @@ -12,13 +12,13 @@ import { export default function suspense( WrappedComponent: ComponentType, fallback: FunctionComponent | ReactNode = <>, -): FunctionComponent { +): FunctionComponent { const C = async ({ - key, + suspenseKey, ...props - }: T & { key: string }): Promise => ( + }: T & { suspenseKey: string }): Promise => (