From 7394ff3d1c5f446ec42c0b6b69c90a90973813db Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 20 Aug 2026 18:51:30 +0200 Subject: [PATCH] feat!: Migrate to tanstack start - Stage 1 --- .../(vitnode-core)/core/users/[id]/page.tsx | 4 +- packages/vitnode/eslint.config.mjs | 41 +++ packages/vitnode/package.json | 15 ++ .../vitnode/src/components/table/filters.tsx | 7 +- .../src/components/table/order-table-head.tsx | 7 +- .../src/components/table/pagination.tsx | 7 +- .../vitnode/src/components/table/search.tsx | 7 +- .../vitnode/src/content/admin/fetch.server.ts | 10 +- .../vitnode/src/content/boundaries.test.ts | 27 +- packages/vitnode/src/content/cache.ts | 16 +- .../src/content/next/cache-privacy.test.ts | 13 +- .../src/content/next/redirect.server.ts | 35 +-- .../src/content/next/revalidate.server.ts | 58 ++--- .../src/framework/cache/boundaries.test.ts | 114 ++++++++ packages/vitnode/src/framework/cache/index.ts | 43 +++ .../vitnode/src/framework/cache/next.test.ts | 148 +++++++++++ packages/vitnode/src/framework/cache/next.ts | 93 +++++++ .../src/framework/cache/runtime.test.ts | 244 ++++++++++++++++++ .../vitnode/src/framework/cache/runtime.ts | 138 ++++++++++ packages/vitnode/src/framework/cache/types.ts | 128 +++++++++ .../framework/navigation/boundaries.test.ts | 169 ++++++++++++ .../vitnode/src/framework/navigation/index.ts | 42 +++ .../src/framework/navigation/next.test.ts | 158 ++++++++++++ .../vitnode/src/framework/navigation/next.ts | 68 +++++ .../src/framework/navigation/types.test-d.ts | 27 ++ .../vitnode/src/framework/navigation/types.ts | 125 +++++++++ .../src/framework/request/boundaries.test.ts | 117 +++++++++ .../vitnode/src/framework/request/index.ts | 40 +++ .../src/framework/request/next.test.ts | 173 +++++++++++++ .../vitnode/src/framework/request/next.ts | 67 +++++ .../src/framework/request/runtime.test.ts | 220 ++++++++++++++++ .../vitnode/src/framework/request/runtime.ts | 121 +++++++++ .../vitnode/src/framework/request/types.ts | 87 +++++++ .../vitnode/src/lib/api/get-middleware-api.ts | 13 +- packages/vitnode/src/lib/fetcher.ts | 15 +- .../vitnode/src/lib/fetcher/helpers-server.ts | 5 +- packages/vitnode/src/lib/navigation.ts | 43 +-- .../src/routes/admin/core/users/[id]/page.tsx | 4 +- .../content/actions/mutation-api.server.ts | 20 +- .../content/actions/options-action.test.ts | 4 + .../content/actions/translation-api.server.ts | 10 +- .../views/content/content-admin-view.tsx | 2 +- .../views/content/content-labels.test.tsx | 16 +- .../admin/views/content/page/page-views.tsx | 2 +- .../cron/run-action/mutation-api.server.ts | 5 +- .../advanced/search/mutation-api.server.ts | 13 +- .../core/dashboard/grid/save-layout.server.ts | 5 +- .../clear-cache/mutation-api.server.ts | 5 +- .../create/create-staff-permissions-view.tsx | 2 +- .../core/staff/create/mutation-api.server.ts | 5 +- .../edit/edit-staff-permissions-view.tsx | 2 +- .../core/staff/edit/mutation-api.server.ts | 5 +- .../table/actions/delete-action.server.ts | 5 +- .../views/core/staff/table/staff-table.tsx | 2 +- .../staff/views/admins/admins-staff-view.tsx | 2 +- .../moderators/moderators-staff-view.tsx | 2 +- .../files/actions/delete-action.server.ts | 5 +- .../core/system/files/files-table-view.tsx | 2 +- .../actions/create/mutation-api.server.ts | 5 +- .../verify-email/mutation-api.server.ts | 5 +- .../create-edit/mutation-api.server.ts | 7 +- .../core/users/roles/roles-admin-view.tsx | 2 +- .../actions/delete-role.action.server.ts | 5 +- .../core/users/show/mutation-api.server.ts | 5 +- .../show/roles/update-roles.action.server.ts | 5 +- .../core/users/show/show-user-admin-view.tsx | 2 +- .../password-reset/password-reset-view.tsx | 2 +- .../settings/devices/revoke-action.server.ts | 5 +- .../src/views/auth/settings/layout.tsx | 3 +- .../auth/sign-in/form/mutation-api.server.ts | 7 +- .../auth/sign-up/form/mutation-api.server.ts | 5 +- .../callback/client/mutation-api.server.ts | 5 +- .../src/views/error/global-error-view.tsx | 7 +- .../files/actions/delete-action.server.ts | 5 +- .../src/views/files/my-files-table-view.tsx | 2 +- .../user/auth/log-out-mutation-api.server.ts | 7 +- .../vitnode/src/views/search/fetch-feed.ts | 10 +- 77 files changed, 2614 insertions(+), 243 deletions(-) create mode 100644 packages/vitnode/src/framework/cache/boundaries.test.ts create mode 100644 packages/vitnode/src/framework/cache/index.ts create mode 100644 packages/vitnode/src/framework/cache/next.test.ts create mode 100644 packages/vitnode/src/framework/cache/next.ts create mode 100644 packages/vitnode/src/framework/cache/runtime.test.ts create mode 100644 packages/vitnode/src/framework/cache/runtime.ts create mode 100644 packages/vitnode/src/framework/cache/types.ts create mode 100644 packages/vitnode/src/framework/navigation/boundaries.test.ts create mode 100644 packages/vitnode/src/framework/navigation/index.ts create mode 100644 packages/vitnode/src/framework/navigation/next.test.ts create mode 100644 packages/vitnode/src/framework/navigation/next.ts create mode 100644 packages/vitnode/src/framework/navigation/types.test-d.ts create mode 100644 packages/vitnode/src/framework/navigation/types.ts create mode 100644 packages/vitnode/src/framework/request/boundaries.test.ts create mode 100644 packages/vitnode/src/framework/request/index.ts create mode 100644 packages/vitnode/src/framework/request/next.test.ts create mode 100644 packages/vitnode/src/framework/request/next.ts create mode 100644 packages/vitnode/src/framework/request/runtime.test.ts create mode 100644 packages/vitnode/src/framework/request/runtime.ts create mode 100644 packages/vitnode/src/framework/request/types.ts diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx index df6ff9e14..15cb39937 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx @@ -2,12 +2,12 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; -import { connection } from "next/server"; import React from "react"; import { adminModule } from "@vitnode/core/api/modules/admin/admin.module"; import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { Loader } from "@vitnode/core/components/ui/loader"; +import { awaitRequest } from "@vitnode/core/framework/request"; import { fetcher } from "@vitnode/core/lib/fetcher"; const ShowUserAdminView = dynamic(async () => @@ -54,7 +54,7 @@ export const generateMetadata = async ({ * dynamic so the metadata is allowed to be, while the body still prerenders. */ const DynamicMarker = async () => { - await connection(); + await awaitRequest(); return null; }; diff --git a/packages/vitnode/eslint.config.mjs b/packages/vitnode/eslint.config.mjs index b1ce26beb..b80e0d0ee 100644 --- a/packages/vitnode/eslint.config.mjs +++ b/packages/vitnode/eslint.config.mjs @@ -16,4 +16,45 @@ export default [ }, }, }, + { + // Navigation goes through `@/framework/navigation`, whose whole purpose is to + // be the one module that knows which framework is underneath. Two areas are + // exempt because they *are* the framework layer: the adapter itself, and + // `src/routes/**`, which is App Router `page.tsx`/`layout.tsx` files copied + // verbatim into the apps - a port rewrites those files rather than reusing + // them, so a raw `next/*` import there costs nothing. + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/framework/**", "src/routes/**"], + rules: { + "no-restricted-imports": [ + "error", + { + name: "next/link", + message: "Please import from `@/framework/navigation` instead.", + }, + { + name: "next/navigation", + importNames: [ + "notFound", + "permanentRedirect", + "redirect", + "usePathname", + "useRouter", + "useSearchParams", + ], + message: "Please import from `@/framework/navigation` instead.", + }, + { + name: "next/router", + importNames: ["useRouter"], + message: + "This import is from Page router. Please import from `@/framework/navigation` instead.", + }, + { + name: "drizzle-orm/mysql-core", + message: "Please import from `drizzle-orm/pg-core` instead.", + }, + ], + }, + }, ]; diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 790fe4e72..4f72e6f12 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -99,11 +99,26 @@ "types": "./dist/src/content/next/revalidate-route.server.d.ts", "default": "./dist/src/content/next/revalidate-route.server.js" }, + "./framework/request": { + "import": "./dist/src/framework/request/index.js", + "types": "./dist/src/framework/request/index.d.ts", + "default": "./dist/src/framework/request/index.js" + }, + "./framework/cache": { + "import": "./dist/src/framework/cache/index.js", + "types": "./dist/src/framework/cache/index.d.ts", + "default": "./dist/src/framework/cache/index.js" + }, "./content/admin-form": { "import": "./dist/src/views/admin/views/content/form/index.js", "types": "./dist/src/views/admin/views/content/form/index.d.ts", "default": "./dist/src/views/admin/views/content/form/index.js" }, + "./framework/navigation": { + "import": "./dist/src/framework/navigation/index.js", + "types": "./dist/src/framework/navigation/index.d.ts", + "default": "./dist/src/framework/navigation/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx index fc1443a00..eb629927d 100644 --- a/packages/vitnode/src/components/table/filters.tsx +++ b/packages/vitnode/src/components/table/filters.tsx @@ -2,11 +2,14 @@ import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useSearchParams } from "next/navigation"; import React from "react"; import { useDebouncedCallback } from "use-debounce"; -import { usePathname, useRouter } from "@/lib/navigation"; +import { + usePathname, + useRouter, + useSearchParams, +} from "@/framework/navigation"; import { cn } from "@/lib/utils"; import { Badge } from "../ui/badge"; diff --git a/packages/vitnode/src/components/table/order-table-head.tsx b/packages/vitnode/src/components/table/order-table-head.tsx index 2805ee6ec..2c009d3a5 100644 --- a/packages/vitnode/src/components/table/order-table-head.tsx +++ b/packages/vitnode/src/components/table/order-table-head.tsx @@ -1,10 +1,13 @@ "use client"; import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; -import { useSearchParams } from "next/navigation"; import React from "react"; -import { usePathname, useRouter } from "@/lib/navigation"; +import { + usePathname, + useRouter, + useSearchParams, +} from "@/framework/navigation"; import type { DataTable, DataTableTMin } from "./data-table"; diff --git a/packages/vitnode/src/components/table/pagination.tsx b/packages/vitnode/src/components/table/pagination.tsx index 461ca4e21..2b8bedfc1 100644 --- a/packages/vitnode/src/components/table/pagination.tsx +++ b/packages/vitnode/src/components/table/pagination.tsx @@ -2,10 +2,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useSearchParams } from "next/navigation"; import React from "react"; -import { usePathname, useRouter } from "@/lib/navigation"; +import { + usePathname, + useRouter, + useSearchParams, +} from "@/framework/navigation"; import { Button } from "../ui/button"; import { diff --git a/packages/vitnode/src/components/table/search.tsx b/packages/vitnode/src/components/table/search.tsx index d537dc214..9a32ca7e5 100644 --- a/packages/vitnode/src/components/table/search.tsx +++ b/packages/vitnode/src/components/table/search.tsx @@ -2,11 +2,14 @@ import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useSearchParams } from "next/navigation"; import React from "react"; import { useDebouncedCallback } from "use-debounce"; -import { usePathname, useRouter } from "@/lib/navigation"; +import { + usePathname, + useRouter, + useSearchParams, +} from "@/framework/navigation"; import { InputGroup, diff --git a/packages/vitnode/src/content/admin/fetch.server.ts b/packages/vitnode/src/content/admin/fetch.server.ts index f7900154d..2e1728fa4 100644 --- a/packages/vitnode/src/content/admin/fetch.server.ts +++ b/packages/vitnode/src/content/admin/fetch.server.ts @@ -1,7 +1,7 @@ import "server-only"; import type { z } from "zod"; -import { cookies, headers } from "next/headers"; +import { forwardApiRequestHeaders } from "@/framework/request"; import type { AnyContentTypeDefinition } from "../types"; @@ -39,14 +39,8 @@ export const contentApiFetch = async ({ query?: Record; schema?: TSchema; }): Promise>> => { - const [nextHeaders, cookieStore] = await Promise.all([headers(), cookies()]); - const response = await rawApiFetch({ - additionalHeaders: { - Cookie: cookieStore.toString(), - ["user-agent"]: nextHeaders.get("user-agent") ?? "node", - ["x-forwarded-for"]: nextHeaders.get("x-forwarded-for") ?? "0.0.0.0", - }, + additionalHeaders: await forwardApiRequestHeaders(), body, method, module: `content/${definition.permissionModule}`, diff --git a/packages/vitnode/src/content/boundaries.test.ts b/packages/vitnode/src/content/boundaries.test.ts index 8f0204290..cbb9b78a9 100644 --- a/packages/vitnode/src/content/boundaries.test.ts +++ b/packages/vitnode/src/content/boundaries.test.ts @@ -79,13 +79,30 @@ describe("layer boundaries", () => { expect(offenders.map(path => relative(here, path))).toEqual([]); }); - it("is where the Next imports actually live", () => { + it("is where the framework-bound imports actually live", () => { // The other half of the rule: `content/next/` exists precisely so those - // imports have somewhere legal to be. - const nextFiles = filesUnder(resolve(here, "next")); - const specifiers = nextFiles.flatMap(importsFrom); + // imports have somewhere legal to be. What lives there has moved on, + // though - no `next/*` specifier is left in the layer at all. Cache + // invalidation goes through `framework/cache` and routing through + // `framework/navigation`, each of which owns the single file in the package + // that imports the Next API behind it. + // + // So the layer is still the boundary, and the pair below is what makes it + // one: it is the only part of the engine that carries `server-only` and + // reaches a framework adapter, and it no longer names a framework to do it. + const specifiers = filesUnder(resolve(here, "next")).flatMap(importsFrom); - expect(specifiers).toContain("next/cache"); expect(specifiers).toContain("server-only"); + expect( + specifiers.filter(specifier => specifier.startsWith("next/")), + ).toEqual([]); + + for (const layer of ["cache", "navigation"]) { + expect( + specifiers.some(specifier => + new RegExp(`framework/${layer}$`).test(specifier), + ), + ).toBe(true); + } }); }); diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index cc09755e7..72a24d1fe 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -1,3 +1,4 @@ +import type { CacheExpiryMode } from "../framework/cache/types"; import type { ContentLocalizationFallback } from "./types"; import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; @@ -127,12 +128,17 @@ export const contentDeliverySitemapTag = ( /** * How hard a mutation expires the tags it touched. * - * Lives here, in the client-safe layer, because the background - * [bridge](./server/revalidate-bridge.ts) has to name a mode from a process - * where `next/cache` cannot even be imported. `content/next` re-exports it, so - * the public name has not moved. + * An alias of the framework-independent {@link CacheExpiryMode} rather than a + * union of its own, so the Content Engine and the cache API cannot drift into + * disagreeing about what a mode means - the bridge serialises this value into an + * HTTP body and the adapter on the other side switches on it. + * + * The name stays because it is public: `content/next` re-exports it, and the + * background [bridge](./server/revalidate-bridge.ts) names a mode from a process + * where `next/cache` cannot even be imported. Both still work, because the type + * it now points at is equally free of `next/*`. */ -export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; +export type ContentInvalidationMode = CacheExpiryMode; /** * One locale's share of a mutation. diff --git a/packages/vitnode/src/content/next/cache-privacy.test.ts b/packages/vitnode/src/content/next/cache-privacy.test.ts index 7b395f3cc..89225bc45 100644 --- a/packages/vitnode/src/content/next/cache-privacy.test.ts +++ b/packages/vitnode/src/content/next/cache-privacy.test.ts @@ -34,9 +34,16 @@ interface FetchArgs { const calls = vi.hoisted(() => [] as FetchArgs[]); vi.mock("server-only", () => ({})); -vi.mock("next/headers", () => ({ - cookies: async () => await Promise.resolve({ toString: () => "session=x" }), - headers: async () => await Promise.resolve(new Headers()), +// The AdminCP fetcher reads the request through `framework/request`, whose +// barrel installs the Next adapter on import. Stubbing the one helper it calls +// keeps `next/headers` out of this suite entirely. +vi.mock("@/framework/request", () => ({ + forwardApiRequestHeaders: async () => + await Promise.resolve({ + Cookie: "session=x", + "user-agent": "node", + "x-forwarded-for": "0.0.0.0", + }), })); vi.mock("../../lib/fetcher/raw", () => ({ diff --git a/packages/vitnode/src/content/next/redirect.server.ts b/packages/vitnode/src/content/next/redirect.server.ts index 3e97f68c0..3c67dd720 100644 --- a/packages/vitnode/src/content/next/redirect.server.ts +++ b/packages/vitnode/src/content/next/redirect.server.ts @@ -1,12 +1,13 @@ import "server-only"; -// `vitnode-frontend/navigation` is the locale-aware wrapper every app-level redirect -// should use, and this is the one place it would be wrong: a delivery location is a -// **complete** path that already carries its locale segment - the engine built it - -// so routing it through `next-intl` would prefix the locale a second time and send -// `/pl/articles/x` to `/pl/pl/articles/x`. That wrapper is also a 307; a canonical -// slug change needs the permanent, method-preserving 308. -// eslint-disable-next-line no-restricted-imports -import { notFound, permanentRedirect, RedirectType } from "next/navigation"; + +// `redirect` from the navigation layer is the locale-aware wrapper every app-level +// redirect should use, and this is the one place it would be wrong: a delivery +// location is a **complete** path that already carries its locale segment - the +// engine built it - so routing it through the locale-aware wrapper would prefix the +// locale a second time and send `/pl/articles/x` to `/pl/pl/articles/x`. That +// wrapper is also a 307; a canonical slug change needs the permanent, +// method-preserving 308. Hence the unlocalized primitive. +import { notFound, unlocalizedPermanentRedirect } from "@/framework/navigation"; import type { DeliverableContentTypeDefinition } from "../types"; import type { ContentDeliveryResponse } from "./delivery.server"; @@ -17,7 +18,7 @@ import { contentDeliveryResolve } from "./delivery.server"; * Resolves a public URL and *acts* on the answer: renders, redirects or 404s. * * The one helper in the delivery adapter that has a side effect, and it is kept in - * its own module because of what it imports: `next/navigation`'s control-flow + * its own module because of what it imports: the navigation layer's control-flow * functions throw to unwind the render, so a page that only wanted metadata should * not be able to reach them by accident. * @@ -37,14 +38,14 @@ import { contentDeliveryResolve } from "./delivery.server"; * }; * ``` * - * `permanentRedirect` issues a **308**, which is what the engine's resolver reports - * and the status a canonical slug change deserves: it preserves the request method, - * where a `301` lets a client rewrite it to `GET`. Both behave identically for the - * `GET` a content page is read with - and only one of them still behaves correctly - * the day a form under a moved path is submitted. + * `unlocalizedPermanentRedirect` issues a **308**, which is what the engine's + * resolver reports and the status a canonical slug change deserves: it preserves the + * request method, where a `301` lets a client rewrite it to `GET`. Both behave + * identically for the `GET` a content page is read with - and only one of them still + * behaves correctly the day a form under a moved path is submitted. * - * `RedirectType.replace`, so a reader who follows an old link does not have to press - * back twice to leave the page they were never meant to land on. + * `"replace"`, so a reader who follows an old link does not have to press back twice + * to leave the page they were never meant to land on. */ export const contentDeliveryPage = async ({ definition, @@ -65,7 +66,7 @@ export const contentDeliveryPage = async ({ }); if (resolution.type === "redirect") { - permanentRedirect(resolution.location, RedirectType.replace); + unlocalizedPermanentRedirect(resolution.location, "replace"); } // A draft, an unpublished record, a deleted one, a slug that never existed and a diff --git a/packages/vitnode/src/content/next/revalidate.server.ts b/packages/vitnode/src/content/next/revalidate.server.ts index c766c9b95..63295acd9 100644 --- a/packages/vitnode/src/content/next/revalidate.server.ts +++ b/packages/vitnode/src/content/next/revalidate.server.ts @@ -1,11 +1,12 @@ import "server-only"; -import { revalidateTag, updateTag } from "next/cache"; +import type { CacheExpiryContext } from "../../framework/cache/types"; import type { ContentInvalidationInput, ContentInvalidationMode, } from "../cache"; +import { expireCacheTags } from "../../framework/cache"; import { contentInvalidationTags } from "../cache"; export type { ContentInvalidationMode }; @@ -13,27 +14,36 @@ export type { ContentInvalidationMode }; /** * Where the call is coming from, which decides *how* `immediate` is done. * - * `updateTag` buys read-your-own-writes and is Server-Action-only. A Route - * Handler cannot call it - but `revalidateTag(tag, { expire: 0 })` expires a - * tag immediately there, which is the documented path for a webhook. Same - * guarantee for the next reader either way, so the caller names its context and - * gets the strongest option available to it. + * An alias of the framework-independent {@link CacheExpiryContext}: which + * primitive a mutation handler may reach for that a webhook may not is a + * framework question, so the adapter answers it and this layer only reports the + * truth about itself. The name stays because it is public API. */ -export type ContentInvalidationContext = "route-handler" | "server-action"; +export type ContentInvalidationContext = CacheExpiryContext; /** * Expires the public cache entries one mutation actually affected. * - * The **only** module in the Content Engine that imports `next/cache`, and the - * reason the tag builders are pure strings a directory up: `content/` and - * `content/server/` are loaded by `apps/api` (a plain `@hono/node-server` - * process) and by drizzle-kit, where `next/cache` throws on import. + * Two pure steps and nothing else: `contentInvalidationTags` says *which* + * entries the mutation reached, and {@link expireCacheTags} expires them through + * whichever cache adapter is installed. Neither step names a Next function - the + * mapping from (`mode`, `context`) onto `updateTag` or `revalidateTag` lives in + * [the adapter](../../framework/cache/next.ts), which is the only module in the + * package that imports `next/cache`. + * + * It still lives under `content/next/` and still carries `server-only`, because + * that is what its callers are: it must never be reached from `content/` or + * `content/server/`, which `apps/api` (a plain `@hono/node-server` process) and + * drizzle-kit both load in plain Node. * * Call it from a server action, after the write has returned. Not from the * service: a service call may be inside a transaction that has not committed, - * may be running outside Next entirely, and has no request scope for the Next - * cache APIs to attach to. A direct caller invalidates for itself, after it - * commits. + * may be running outside Next entirely, and has no request scope for the cache + * APIs to attach to. A direct caller invalidates for itself, after it commits. + * + * The two options are handed to {@link expireCacheTags} untouched rather than + * defaulted here, so there is one place a default can be read and one place it + * can be wrong. * * `mode` defaults to `immediate`, because the mutations that matter most are * the ones that *remove* something. Stale-while-revalidate would keep serving @@ -55,23 +65,5 @@ export const revalidateContent = ( mode?: ContentInvalidationMode; }, ): void => { - const mode = options?.mode ?? "immediate"; - const context = options?.context ?? "server-action"; - - for (const tag of contentInvalidationTags(input)) { - if (mode !== "immediate") { - revalidateTag(tag, "max"); - continue; - } - - if (context === "server-action") { - updateTag(tag); - continue; - } - - // `updateTag` throws outside a Server Action. `expire: 0` is the documented - // equivalent for a webhook: the entry is expired now rather than served - // stale once more. - revalidateTag(tag, { expire: 0 }); - } + expireCacheTags(contentInvalidationTags(input), options); }; diff --git a/packages/vitnode/src/framework/cache/boundaries.test.ts b/packages/vitnode/src/framework/cache/boundaries.test.ts new file mode 100644 index 000000000..4095e031a --- /dev/null +++ b/packages/vitnode/src/framework/cache/boundaries.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const sourceRoot = resolve(here, "../.."); + +const filesUnder = (directory: string): string[] => { + const entries: string[] = []; + + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) { + entries.push(...filesUnder(path)); + continue; + } + if (/\.tsx?$/.test(name)) entries.push(path); + } + + return entries; +}; + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, "utf8").matchAll( + /from\s+"([^"]+)"|import\s+"([^"]+)"/g, + ), + ] + .map(match => match[1] ?? match[2]) + .filter(Boolean); + +/** + * The rule the `framework/cache` layer is for, asserted rather than remembered. + * + * Cache invalidation is spread across every write path there is - forty-odd + * server actions, the Content Engine, the search feed - so left alone it couples + * the whole codebase to one framework's cache functions a line at a time. This + * suite pins both halves: `next/cache` is imported in exactly one file, and the + * framework-free half stays loadable from plain Node so `apps/api` and + * drizzle-kit can keep reading the parts of the layer that are only types and + * strings. + */ +describe("cache layer boundaries", () => { + const sourceFiles = filesUnder(sourceRoot); + const adapter = relative(sourceRoot, join(here, "next.ts")); + + it("has files to check", () => { + // A move that relocated the package should fail loudly here rather than + // making the suite vacuously pass. + expect(sourceFiles.length).toBeGreaterThan(100); + }); + + it("imports next/cache from the Next adapter and nowhere else", () => { + const importers = sourceFiles + .filter(path => importsFrom(path).includes("next/cache")) + .map(path => relative(sourceRoot, path)); + + expect(importers).toEqual([adapter]); + }); + + it("keeps the framework-agnostic half free of any framework import", () => { + // An adapter for another framework imports these, and so does anything that + // has to load in plain Node. One `next/*` import here would put Next back in + // that graph. + const agnostic = ["runtime.ts", "types.ts"]; + + const offenders = agnostic.filter(name => + importsFrom(join(here, name)).some(specifier => + specifier.startsWith("next/"), + ), + ); + + expect(offenders).toEqual([]); + }); + + it("routes the whole layer through the barrel, not the adapter", () => { + // Importing `./next` directly would bind a call site to Next again even + // though it went through this folder to do it. + const offenders = sourceFiles + .filter(path => dirname(path) !== here) + .filter(path => + importsFrom(path).some(specifier => + specifier.endsWith("framework/cache/next"), + ), + ) + .map(path => relative(sourceRoot, path)); + + expect(offenders).toEqual([]); + }); + + it("never puts the barrel in a plain-Node layer's import graph", () => { + // The barrel installs the Next adapter, so importing it pulls in both + // `next/cache` and `server-only` - and `content/` and `content/server/` are + // loaded by `apps/api` (a plain `@hono/node-server` process) and by + // drizzle-kit, where both of those throw. Those layers read `./runtime` and + // `./types` instead, and `content/boundaries.test.ts` cannot catch a slip + // here: the specifier it would see is a relative path, not `next/*`. + const plainNode = filesUnder(join(sourceRoot, "content")).filter( + path => !path.includes(`${sep}content${sep}next${sep}`), + ); + + const offenders = plainNode + .filter(path => + importsFrom(path).some(specifier => + /^(?:@\/|(?:\.\.\/)+)framework\/cache$/.test(specifier), + ), + ) + .map(path => relative(sourceRoot, path)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/framework/cache/index.ts b/packages/vitnode/src/framework/cache/index.ts new file mode 100644 index 000000000..af6f8967e --- /dev/null +++ b/packages/vitnode/src/framework/cache/index.ts @@ -0,0 +1,43 @@ +/** + * Caching and revalidation, done through VitNode rather than through Next. + * + * Importing this module installs the Next.js adapter as the default, so server + * code gets working `expireCacheTags()` / `expireCachePath()` / + * `tagCacheEntry()` / `setCacheEntryLife()` with no setup. An application on + * another host framework calls `setCacheAdapter()` with its own adapter, which + * takes precedence. + * + * `./next` is the only file behind this barrel that touches `next/*`, and it + * carries `server-only` - so this module does too. Code that has to load in + * plain Node (`content/`, `content/server/`, drizzle-kit) must import + * `./runtime` and `./types` directly instead, both of which are framework-free. + * + * The cache *tags* are not here: they are plain strings, built per content type + * in `@vitnode/core/content` and per feature in `@vitnode/core/lib/cache-tags`, + * so an application can name the same tag its own cached functions carry. + */ +import { nextCacheAdapter } from "./next"; +import { setDefaultCacheAdapter } from "./runtime"; + +setDefaultCacheAdapter(nextCacheAdapter); + +export { nextCacheAdapter } from "./next"; +export { + expireCachePath, + expireCacheTags, + getCacheAdapter, + hasCacheAdapter, + resetCacheAdapter, + setCacheAdapter, + setCacheEntryLife, + setDefaultCacheAdapter, + tagCacheEntry, +} from "./runtime"; +export type { + CacheAdapter, + CacheExpiryContext, + CacheExpiryMode, + CacheExpiryOptions, + CacheLifeProfile, + CachePathScope, +} from "./types"; diff --git a/packages/vitnode/src/framework/cache/next.test.ts b/packages/vitnode/src/framework/cache/next.test.ts new file mode 100644 index 000000000..cf0b575d5 --- /dev/null +++ b/packages/vitnode/src/framework/cache/next.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface CacheCall { + args: unknown[]; + fn: string; +} + +const calls = vi.hoisted(() => [] as CacheCall[]); + +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => { + const record = + (fn: string) => + (...args: unknown[]) => { + calls.push({ args, fn }); + }; + + return { + cacheLife: record("cacheLife"), + cacheTag: record("cacheTag"), + revalidatePath: record("revalidatePath"), + revalidateTag: record("revalidateTag"), + updateTag: record("updateTag"), + }; +}); + +const { nextCacheAdapter } = await import("./next"); + +/** + * The Next adapter is the only place the mapping onto `next/cache` lives, so + * this is where the mapping is pinned - one assertion per branch, against the + * real module rather than against a stub of it. + */ +beforeEach(() => { + calls.length = 0; +}); + +describe("expireTags", () => { + it("uses updateTag for an immediate expiry from a Server Action", () => { + // The only primitive that gives the user who submitted the mutation + // read-your-own-writes. + nextCacheAdapter.expireTags(["a", "b"], { + context: "server-action", + mode: "immediate", + }); + + expect(calls).toEqual([ + { args: ["a"], fn: "updateTag" }, + { args: ["b"], fn: "updateTag" }, + ]); + }); + + it("uses revalidateTag with expire 0 from a Route Handler", () => { + // `updateTag` throws outside a Server Action, so a background revalidation + // that used it would be a 500 rather than a stale page. + nextCacheAdapter.expireTags(["a"], { + context: "route-handler", + mode: "immediate", + }); + + expect(calls).toEqual([ + { args: ["a", { expire: 0 }], fn: "revalidateTag" }, + ]); + }); + + it.each(["route-handler", "server-action"] as const)( + "uses the max profile for stale-while-revalidate, from %s", + context => { + // SWR works in either context, so the context changes nothing - and the + // profile is always named, because the bare one-argument form of + // `revalidateTag` is deprecated and means immediate. + nextCacheAdapter.expireTags(["a"], { + context, + mode: "stale-while-revalidate", + }); + + expect(calls).toEqual([{ args: ["a", "max"], fn: "revalidateTag" }]); + }, + ); + + it("makes one call per tag, in order", () => { + nextCacheAdapter.expireTags(["one", "two", "three"], { + context: "server-action", + mode: "immediate", + }); + + expect(calls.map(call => call.args[0])).toEqual(["one", "two", "three"]); + }); +}); + +describe("expirePath", () => { + it("omits the type argument entirely when no scope was named", () => { + // Next appends the type to the implicit tag, so `revalidatePath("/x")` and + // `revalidatePath("/x", "page")` target different keys. Passing an explicit + // `undefined` would be the second call, not the first. + nextCacheAdapter.expirePath("/x", undefined); + + expect(calls).toEqual([{ args: ["/x"], fn: "revalidatePath" }]); + }); + + it.each(["layout", "page"] as const)("passes the %s scope through", scope => { + nextCacheAdapter.expirePath("/x", scope); + + expect(calls).toEqual([{ args: ["/x", scope], fn: "revalidatePath" }]); + }); +}); + +describe("the entry-time verbs", () => { + it("spreads tags into cacheTag, which is variadic", () => { + nextCacheAdapter.tagEntry(["one", "two"]); + + expect(calls).toEqual([{ args: ["one", "two"], fn: "cacheTag" }]); + }); + + it("passes a lifetime profile straight to cacheLife", () => { + nextCacheAdapter.setEntryLife("max"); + + expect(calls).toEqual([{ args: ["max"], fn: "cacheLife" }]); + }); +}); + +describe("the barrel", () => { + it("installs this adapter as the default", async () => { + // Importing `@vitnode/core/framework/cache` has to be enough: no call site + // should have to remember a wiring step to get a working cache. + const { getCacheAdapter } = await import("./index"); + + expect(getCacheAdapter()).toBe(nextCacheAdapter); + expect(getCacheAdapter().name).toBe("next"); + }); + + it("lets an application override it", async () => { + const { getCacheAdapter, resetCacheAdapter, setCacheAdapter } = + await import("./index"); + const other = { ...nextCacheAdapter, name: "other" }; + + setCacheAdapter(other); + expect(getCacheAdapter().name).toBe("other"); + + // Put it back, so the default is what the rest of this file sees. + resetCacheAdapter(); + const { setDefaultCacheAdapter } = await import("./runtime"); + setDefaultCacheAdapter(nextCacheAdapter); + expect(getCacheAdapter().name).toBe("next"); + }); +}); diff --git a/packages/vitnode/src/framework/cache/next.ts b/packages/vitnode/src/framework/cache/next.ts new file mode 100644 index 000000000..4af9bb8bd --- /dev/null +++ b/packages/vitnode/src/framework/cache/next.ts @@ -0,0 +1,93 @@ +import "server-only"; +import * as nextCache from "next/cache"; + +import type { CacheAdapter } from "./types"; + +/** + * The Next.js cache adapter - the **only** module in `@vitnode/core` that + * imports `next/cache`. + * + * That is the whole point of the `framework/cache` layer: core code expires + * entries through {@link CacheAdapter}, and swapping host frameworks means + * writing a sibling of this file rather than editing forty call sites. + * `framework/cache/boundaries.test.ts` asserts the rule instead of trusting it, + * and `server-only` here keeps the whole layer out of client bundles. + * + * ## Why a namespace import + * + * `import * as nextCache` rather than named imports, because several suites + * exercise one cache verb and mock `next/cache` with a factory holding only the + * function they care about. Named imports are resolved when this module is + * evaluated, so a partial mock would fail the *import* over a function the test + * never calls. A namespace access fails only if that function is actually + * reached, which is the behaviour a partial mock is asking for. + */ +export const nextCacheAdapter: CacheAdapter = { + /** + * `scope` is forwarded as given, `undefined` included. + * + * Not a defaulting oversight: Next builds the implicit tag for a path expiry + * out of the path *and* the type, so `revalidatePath("/x", "page")` targets a + * different key than `revalidatePath("/x")`. Substituting a default here would + * quietly point every unscoped caller at entries it never meant. + */ + expirePath: (path, scope) => { + if (scope === undefined) { + nextCache.revalidatePath(path); + + return; + } + + nextCache.revalidatePath(path, scope); + }, + + /** + * One call per tag, because that is the shape Next's API takes - both + * `updateTag` and `revalidateTag` are single-tag functions. + * + * The three-way choice is Next's, not the contract's: + * + * - **stale-while-revalidate** is `revalidateTag(tag, "max")`. The bare + * one-argument form is deprecated - it warns, and means immediate - so the + * profile is always named. + * - **immediate from a Server Action** is `updateTag`, the only one that gives + * the user who submitted the mutation read-your-own-writes. + * - **immediate from a Route Handler** is `revalidateTag(tag, { expire: 0 })`. + * `updateTag` throws outside a Server Action, so the honest context matters: + * getting it wrong turns every background revalidation into a 500 rather + * than into a stale page. + */ + expireTags: (tags, { context, mode }) => { + for (const tag of tags) { + if (mode !== "immediate") { + nextCache.revalidateTag(tag, "max"); + continue; + } + + if (context === "server-action") { + nextCache.updateTag(tag); + continue; + } + + nextCache.revalidateTag(tag, { expire: 0 }); + } + }, + + name: "next", + + setEntryLife: profile => { + nextCache.cacheLife(profile); + }, + + /** + * Synchronous and variadic, straight through to `cacheTag`. + * + * Both this and `setEntryLife` read the work-unit store Next keeps in + * async-local storage, which a synchronous call from inside the `"use cache"` + * function still sees - so the indirection is free. What would break it is an + * `await` on the way in, which is why neither returns a promise. + */ + tagEntry: tags => { + nextCache.cacheTag(...tags); + }, +}; diff --git a/packages/vitnode/src/framework/cache/runtime.test.ts b/packages/vitnode/src/framework/cache/runtime.test.ts new file mode 100644 index 000000000..20feb6f79 --- /dev/null +++ b/packages/vitnode/src/framework/cache/runtime.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from "vitest"; + +import type { CacheAdapter, CacheExpiryOptions, CachePathScope } from "./types"; + +import { + expireCachePath, + expireCacheTags, + getCacheAdapter, + hasCacheAdapter, + resetCacheAdapter, + setCacheAdapter, + setCacheEntryLife, + setDefaultCacheAdapter, + tagCacheEntry, +} from "./runtime"; + +/** + * The framework-free half, tested with no framework at all. + * + * This file imports `./runtime` rather than the barrel on purpose: the barrel + * installs the Next adapter, and what is under test here is precisely the + * behaviour that has to hold *without* one - which defaults are resolved, which + * calls are refused, and which slot wins. + */ +interface Recorded { + args: unknown[]; + fn: string; +} + +const recorder = (name: string) => { + const calls: Recorded[] = []; + const push = + (fn: string) => + (...args: unknown[]) => { + calls.push({ args, fn }); + }; + + const adapter: CacheAdapter = { + expirePath: push("expirePath"), + expireTags: push("expireTags"), + name, + setEntryLife: push("setEntryLife"), + tagEntry: push("tagEntry"), + }; + + return { adapter, calls }; +}; + +beforeEach(() => { + resetCacheAdapter(); +}); + +describe("the registry", () => { + it("has nothing installed until something installs it", () => { + expect(hasCacheAdapter()).toBe(false); + }); + + it("throws a message naming the fix rather than doing nothing", () => { + // The whole reason this throws: a no-op adapter leaves a withdrawn page + // readable with no error anywhere to trace it back from. + expect(() => getCacheAdapter()).toThrow(/@vitnode\/core\/framework\/cache/); + expect(() => getCacheAdapter()).toThrow(/setCacheAdapter/); + }); + + it("uses the default when nothing was installed explicitly", () => { + const { adapter } = recorder("default"); + setDefaultCacheAdapter(adapter); + + expect(hasCacheAdapter()).toBe(true); + expect(getCacheAdapter().name).toBe("default"); + }); + + it.each([ + ["default first", true], + ["explicit first", false], + ])("lets the explicit adapter win, %s", (_label, defaultFirst) => { + // Order-independence is the whole reason for two slots: the barrel installs + // its default on import, and an application cannot control whether its own + // `setCacheAdapter` call runs before or after that import is evaluated. + const { adapter: fallback } = recorder("default"); + const { adapter: installed } = recorder("explicit"); + + if (defaultFirst) { + setDefaultCacheAdapter(fallback); + setCacheAdapter(installed); + } else { + setCacheAdapter(installed); + setDefaultCacheAdapter(fallback); + } + + expect(getCacheAdapter().name).toBe("explicit"); + }); + + it("empties both slots on reset", () => { + const { adapter } = recorder("a"); + setCacheAdapter(adapter); + setDefaultCacheAdapter(adapter); + resetCacheAdapter(); + + expect(hasCacheAdapter()).toBe(false); + }); +}); + +describe("expireCacheTags", () => { + const optionsOf = (calls: Recorded[]): CacheExpiryOptions => + calls[0].args[1] as CacheExpiryOptions; + + it("defaults to immediate, from a server action", () => { + // Both defaults protect the mutation that *removed* something, which is the + // one where being wrong is a correctness bug rather than a slow page. + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags(["one"]); + + expect(calls).toHaveLength(1); + expect(optionsOf(calls)).toEqual({ + context: "server-action", + mode: "immediate", + }); + }); + + it("passes an explicit mode and context through untouched", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags(["one"], { + context: "route-handler", + mode: "stale-while-revalidate", + }); + + expect(optionsOf(calls)).toEqual({ + context: "route-handler", + mode: "stale-while-revalidate", + }); + }); + + it("fills in only the option that was left out", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags(["one"], { context: "route-handler" }); + + expect(optionsOf(calls)).toEqual({ + context: "route-handler", + mode: "immediate", + }); + }); + + it("accepts a single tag as a bare string", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags("only-one"); + + expect(calls[0].args[0]).toEqual(["only-one"]); + }); + + it("hands the adapter the list as given, in order", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags(["b", "a", "b"]); + + // No sorting and no de-duplication: the tag list is the caller's, and an + // adapter that wants to collapse it can. + expect(calls[0].args[0]).toEqual(["b", "a", "b"]); + }); + + it("does nothing at all for an empty list", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCacheTags([]); + + expect(calls).toEqual([]); + }); + + it("does not even need an adapter for an empty list", () => { + // `contentInvalidationTags` legitimately returns nothing for a mutation on a + // record that was private before and after. That call must not be the thing + // that discovers the wiring is missing. + expect(() => expireCacheTags([])).not.toThrow(); + }); +}); + +describe("expireCachePath", () => { + it.each(["layout", "page", undefined])( + "forwards the scope %s exactly as given", + scope => { + // `undefined` is a third answer rather than a synonym for `page`: Next + // keys a scoped expiry differently from a bare one, so defaulting here + // would retarget every unscoped caller. + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCachePath("/[locale]/admin", scope); + + expect(calls[0]).toEqual({ + args: ["/[locale]/admin", scope], + fn: "expirePath", + }); + }, + ); + + it("passes undefined when the scope is omitted entirely", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + expireCachePath("/"); + + expect(calls[0].args).toEqual(["/", undefined]); + }); +}); + +describe("the entry-time helpers", () => { + it("collects variadic tags into one call", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + tagCacheEntry("one", "two"); + + expect(calls[0]).toEqual({ args: [["one", "two"]], fn: "tagEntry" }); + }); + + it("does nothing when tagging with no tags", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + tagCacheEntry(); + + expect(calls).toEqual([]); + }); + + it("forwards a lifetime profile by name", () => { + const { adapter, calls } = recorder("a"); + setCacheAdapter(adapter); + + setCacheEntryLife("minutes"); + + expect(calls[0]).toEqual({ args: ["minutes"], fn: "setEntryLife" }); + }); +}); diff --git a/packages/vitnode/src/framework/cache/runtime.ts b/packages/vitnode/src/framework/cache/runtime.ts new file mode 100644 index 000000000..1766c7acb --- /dev/null +++ b/packages/vitnode/src/framework/cache/runtime.ts @@ -0,0 +1,138 @@ +import type { + CacheAdapter, + CacheExpiryContext, + CacheExpiryMode, + CacheLifeProfile, + CachePathScope, +} from "./types"; + +/** + * Which adapter answers cache calls, and the helpers core code calls. + * + * Two slots rather than one, so installation is order-independent. A host + * framework's adapter fills the *default* slot as a side effect of the barrel + * being imported ({@link setDefaultCacheAdapter}); an application that wants a + * different one calls {@link setCacheAdapter}, which always wins no matter which + * module happened to evaluate first. + * + * This module imports nothing but its own types, so a cache adapter can be + * written - and this registry loaded - without pulling Next into the graph. + * `content/` and `content/server/`, which `apps/api` and drizzle-kit load in + * plain Node, import this and `./types` rather than the barrel for that reason. + */ +let installed: CacheAdapter | undefined; +let fallback: CacheAdapter | undefined; + +/** Install the cache adapter for this application. Overrides any default. */ +export const setCacheAdapter = (adapter: CacheAdapter): void => { + installed = adapter; +}; + +/** + * Offer an adapter as the default, used only when nothing was installed + * explicitly. Called by the barrel on import. + */ +export const setDefaultCacheAdapter = (adapter: CacheAdapter): void => { + fallback = adapter; +}; + +/** Whether any adapter - installed or default - can answer a cache call. */ +export const hasCacheAdapter = (): boolean => + installed !== undefined || fallback !== undefined; + +/** + * The adapter to call, or a thrown error naming the fix. + * + * It throws rather than falling back to doing nothing, and that is the whole + * decision in this file. A silently absent cache adapter turns every mutation + * into a page that keeps serving what it served before - a withdrawn post still + * readable, a deleted file still listed - with no error anywhere to trace it + * back from. A missing adapter is a wiring mistake, so the first call says so. + */ +export const getCacheAdapter = (): CacheAdapter => { + const adapter = installed ?? fallback; + if (!adapter) { + throw new Error( + "No VitNode cache adapter is installed. Import `@vitnode/core/framework/cache` (which installs the Next.js adapter) before expiring anything, or call `setCacheAdapter()` with your own adapter.", + ); + } + + return adapter; +}; + +/** For tests: drop both slots so the next call starts from nothing. */ +export const resetCacheAdapter = (): void => { + installed = undefined; + fallback = undefined; +}; + +/** + * Expires every cache entry carrying any of these tags. + * + * Defaults are chosen for the mutation that *removes* something, because that is + * where being wrong is a correctness bug rather than a slow page: + * + * - `mode` defaults to `immediate`. Stale-while-revalidate would keep serving an + * unpublished record, a deleted one, or a URL that has moved - for one more + * request each, which is one too many. A caller whose edit left the page + * reachable at the same address opts out explicitly. + * - `context` defaults to `server-action`, which is where nearly every write + * path in VitNode already lives. Background work - a webhook, a cron callback, + * the content revalidation bridge - says `route-handler` and gets a primitive + * that is legal there. + * + * An empty list returns without touching the registry. Not a micro-optimisation: + * `contentInvalidationTags` legitimately returns nothing for a mutation on a + * record that was private before and after, and such a call must not be the + * thing that discovers no adapter is installed. + */ +export const expireCacheTags = ( + tags: readonly string[] | string, + options?: { + context?: CacheExpiryContext; + mode?: CacheExpiryMode; + }, +): void => { + const list = typeof tags === "string" ? [tags] : tags; + if (list.length === 0) return; + + getCacheAdapter().expireTags(list, { + context: options?.context ?? "server-action", + mode: options?.mode ?? "immediate", + }); +}; + +/** + * Expires everything cached for a route path. + * + * `scope` has **no default**, and that is deliberate rather than an omission: a + * scoped expiry and a bare one are keyed differently by the framework + * underneath, so quietly filling in `page` here would silently retarget every + * caller that meant the unscoped form. See {@link CachePathScope}. + */ +export const expireCachePath = (path: string, scope?: CachePathScope): void => { + getCacheAdapter().expirePath(path, scope); +}; + +/** + * Tags the cache entry currently being produced. + * + * Call it **synchronously, inside the cached function**, before the first + * `await`. An adapter reaches the entry through async-local storage, and there + * is no entry to tag from anywhere else. + */ +export const tagCacheEntry = (...tags: string[]): void => { + if (tags.length === 0) return; + + getCacheAdapter().tagEntry(tags); +}; + +/** + * Declares how long the cache entry currently being produced stays useful. + * + * Same placement rule as {@link tagCacheEntry}: synchronously, inside the cached + * function. + */ +export const setCacheEntryLife = (profile: CacheLifeProfile): void => { + getCacheAdapter().setEntryLife(profile); +}; diff --git a/packages/vitnode/src/framework/cache/types.ts b/packages/vitnode/src/framework/cache/types.ts new file mode 100644 index 000000000..a64692c64 --- /dev/null +++ b/packages/vitnode/src/framework/cache/types.ts @@ -0,0 +1,128 @@ +/** + * The cache contract VitNode is written against. + * + * Types only, no implementation and no `next/*`, so this module is safe + * everywhere the framework-independent layers are: `apps/api` (a plain + * `@hono/node-server` process), drizzle-kit, and the browser. It is the whole + * vocabulary a VitNode caller needs - nothing above this file names Next. + * + * Two things live here and they are deliberately separate: + * + * - **The verbs** ({@link CacheAdapter}), which one adapter implements per + * framework. + * - **The nouns** - the modes, contexts and scopes a caller names. They are + * spelled in route-tree and freshness terms rather than in one framework's + * function names, because they have to keep meaning something when the + * adapter underneath changes. + */ + +/** + * How hard an expiry hits. + * + * `immediate` is the one to reach for when a mutation *removed* something: the + * next reader must not be served the old response even once. It costs a cold + * render. + * + * `stale-while-revalidate` keeps serving what is already stored while a fresh + * copy is built behind it. Correct only for an edit that changed what a page + * *says* while leaving it reachable at the same address - one more stale read is + * survivable there, and the warm cache is worth more than the seconds of + * freshness. + */ +export type CacheExpiryMode = "immediate" | "stale-while-revalidate"; + +/** + * Where the expiry is being called from. + * + * Not decoration: frameworks give a mutation handler stronger cache primitives + * than a webhook, and the strongest one available differs between the two. The + * caller states its situation truthfully and the adapter picks - which is the + * only arrangement that cannot produce a call that throws in production because + * it was made from the wrong kind of handler. + * + * `server-action` is a request the user's own submit started, so the adapter may + * use a primitive that gives that user read-your-own-writes. `route-handler` is + * anything else that arrived over HTTP - a webhook, a cron callback, the content + * revalidation bridge - where no such primitive exists. + */ +export type CacheExpiryContext = "route-handler" | "server-action"; + +/** + * How much of the route tree a path expiry reaches. + * + * `page` is the leaf alone; `layout` is that route and everything nested under + * it. Both are route-tree concepts rather than Next vocabulary - any framework + * with nested layouts has the same two answers - so the names survive an adapter + * swap even though the call underneath does not. + * + * Omitting it is a third, distinct answer rather than a synonym for `page`, and + * the difference is load-bearing: a framework identifies a scoped expiry by a + * *different* key than an unscoped one, so the two do not reach each other's + * entries. Name a scope unless you specifically want the framework's own default + * reach for a bare path. + */ +export type CachePathScope = "layout" | "page"; + +/** + * How long a cache entry stays useful, named rather than measured. + * + * The set is deliberately closed, and deliberately the intersection of what + * every adapter can honour: a profile invented in one framework's config file is + * a number the next adapter has no way to read. A caller that needs an exact + * duration is describing framework-specific behaviour and belongs on the other + * side of an adapter, not in front of one. + */ +export type CacheLifeProfile = + "days" | "default" | "hours" | "max" | "minutes" | "seconds" | "weeks"; + +/** What {@link CacheAdapter.expireTags} was told about the caller. */ +export interface CacheExpiryOptions { + context: CacheExpiryContext; + mode: CacheExpiryMode; +} + +/** + * One framework's implementation of the contract. + * + * Four verbs, split down the middle by *when* they run: + * + * - `tagEntry` and `setEntryLife` describe an entry **while it is being + * produced**, so they are called from inside whatever the framework's cached + * function is and must stay synchronous - a framework that keeps this state in + * async-local storage loses it across an `await`. + * - `expirePath` and `expireTags` act on entries **already stored**, from a + * mutation. + * + * Every method is `void`. Whether an expiry is applied synchronously, batched, + * or posted to another process is the adapter's business, and a caller that + * awaited it would be waiting on an implementation detail. + */ +export interface CacheAdapter { + /** + * Expires everything cached for a route path. + * + * `scope` is passed through exactly as the caller gave it, `undefined` + * included - see {@link CachePathScope} for why an adapter must not substitute + * a default of its own. + */ + readonly expirePath: ( + path: string, + scope: CachePathScope | undefined, + ) => void; + /** + * Expires every stored entry carrying any of these tags. + * + * The list is never empty - {@link CacheExpiryOptions} arrives resolved, so an + * adapter never has to guess a default or handle a no-op call. + */ + readonly expireTags: ( + tags: readonly string[], + options: CacheExpiryOptions, + ) => void; + /** Identifies the adapter in errors and tests. */ + readonly name: string; + /** Declares how long the entry being produced stays useful. */ + readonly setEntryLife: (profile: CacheLifeProfile) => void; + /** Tags the entry being produced, so an expiry can find it later. */ + readonly tagEntry: (tags: readonly string[]) => void; +} diff --git a/packages/vitnode/src/framework/navigation/boundaries.test.ts b/packages/vitnode/src/framework/navigation/boundaries.test.ts new file mode 100644 index 000000000..578a76933 --- /dev/null +++ b/packages/vitnode/src/framework/navigation/boundaries.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const sourceRoot = resolve(here, "../.."); + +const filesUnder = (directory: string): string[] => { + const entries: string[] = []; + + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) { + entries.push(...filesUnder(path)); + continue; + } + // Tests are excluded on purpose: a test names a framework module in order to + // stub it, and that is the opposite of depending on one. + if (/\.test(-d)?\.tsx?$/.test(name)) continue; + if (/\.tsx?$/.test(name)) entries.push(path); + } + + return entries; +}; + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, "utf8").matchAll( + /from\s+"([^"]+)"|import\s+"([^"]+)"/g, + ), + ] + .map(match => match[1] ?? match[2]) + .filter(Boolean); + +/** The names a file pulls out of one specifier, across both import forms. */ +const namedImportsFrom = (path: string, specifier: string): string[] => { + const source = readFileSync(path, "utf8"); + const pattern = new RegExp( + `import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s+from\\s+"${specifier}"`, + "g", + ); + + return [...source.matchAll(pattern)] + .flatMap(match => match[1].split(",")) + .map( + name => + name + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0], + ) + .filter(Boolean) + .toSorted(); +}; + +/** + * The rule the `framework/navigation` layer is for, asserted rather than + * remembered. + * + * A link, a router, a redirect and a 404 are needed by roughly sixty modules in + * here, which is exactly why they are the easiest way to weld the UI to one + * framework a line at a time. This suite pins the two places where naming the + * framework is still correct, so that the list can only grow deliberately. + */ +describe("navigation layer boundaries", () => { + const sourceFiles = filesUnder(sourceRoot); + const adapter = relative(sourceRoot, join(here, "next.ts")); + const themeProvider = join("components", "theme-provider.tsx"); + + const importersOf = (specifier: string): string[] => + sourceFiles + .filter(path => importsFrom(path).includes(specifier)) + .map(path => relative(sourceRoot, path)) + .toSorted(); + + it("has files to check", () => { + // A move that relocated the package should fail loudly here rather than + // making the suite vacuously pass. + expect(sourceFiles.length).toBeGreaterThan(100); + }); + + it("imports next/link from the Next adapter and nowhere else", () => { + expect(importersOf("next/link")).toEqual([adapter]); + }); + + it("imports next-intl's navigation from the Next adapter and nowhere else", () => { + // The locale-aware half is next-intl's, and next-intl is a Next library: + // reaching for it directly binds a call site just as tightly as `next/*`. + expect(importersOf("next-intl/navigation")).toEqual([adapter]); + }); + + it("leaves next/navigation to the adapter, the route files and one SSR hook", () => { + const offenders = importersOf("next/navigation").filter( + path => + path !== adapter && + path !== themeProvider && + !path.startsWith(`routes${sep}`), + ); + + expect(offenders).toEqual([]); + }); + + it("lets the theme provider take the SSR hook and nothing that navigates", () => { + // `useServerInsertedHTML` ships from `next/navigation` but has nothing to do + // with navigating - it injects the no-flash theme script into the streamed + // HTML. It is the one symbol this exemption covers. + const names = namedImportsFrom( + join(sourceRoot, themeProvider), + "next/navigation", + ); + + expect(names).toEqual(["useServerInsertedHTML"]); + }); + + it("lets a route file take notFound and nothing else", () => { + // `src/routes/**` is App Router `page.tsx`/`layout.tsx` copied verbatim into + // the apps - framework files by definition, so `notFound()` there is honest. + // Anything else a page wants to do (redirect, read the router) has a + // framework-independent form, and should use it. + const routeFiles = sourceFiles.filter(path => + relative(sourceRoot, path).startsWith(`routes${sep}`), + ); + + const offenders = routeFiles + .flatMap(path => + namedImportsFrom(path, "next/navigation").map(name => ({ name, path })), + ) + .filter(({ name }) => name !== "notFound") + .map(({ name, path }) => `${relative(sourceRoot, path)}: ${name}`); + + expect(offenders).toEqual([]); + }); + + it("keeps the framework-agnostic half free of any framework import", () => { + // An adapter for another framework imports this, and nothing else in the + // layer describes the contract. One `next/*` import here would make the + // contract itself Next-shaped. + const offenders = importsFrom(join(here, "types.ts")).filter(specifier => + /^next(-intl)?(\/|$)/.test(specifier), + ); + + expect(offenders).toEqual([]); + }); + + it("routes the whole layer through the barrel, not the adapter", () => { + // Importing `./next` directly would bind a call site to Next again even + // though it went through this folder to do it. + const offenders = sourceFiles + .filter(path => dirname(path) !== here) + .filter(path => + importsFrom(path).some(specifier => + specifier.endsWith("framework/navigation/next"), + ), + ) + .map(path => relative(sourceRoot, path)); + + expect(offenders).toEqual([]); + }); + + it("keeps the `@/lib/navigation` shim a re-export of the barrel", () => { + // Fifty modules and every app import navigation by that name. The shim may + // forward to this layer; it may not grow a second implementation. + const shim = join(sourceRoot, "lib", "navigation.ts"); + + expect(importsFrom(shim)).toEqual(["@/framework/navigation"]); + }); +}); diff --git a/packages/vitnode/src/framework/navigation/index.ts b/packages/vitnode/src/framework/navigation/index.ts new file mode 100644 index 000000000..0b64c01cb --- /dev/null +++ b/packages/vitnode/src/framework/navigation/index.ts @@ -0,0 +1,42 @@ +export { + getPathname, + Link, + notFound, + redirect, + UnlocalizedLink, + unlocalizedPermanentRedirect, + usePathname, + useRouter, + useSearchParams, +} from "./next"; + +/** + * VitNode's navigation surface. + * + * Import navigation from here (or from the `@/lib/navigation` shim that has + * always pointed at the locale-aware half of it) rather than from `next/*`. + * Everything below is re-exported from the active adapter with the adapter's + * own inferred types intact, so nothing is lost at the call site; `./types` + * describes the narrower contract a second adapter would have to satisfy. + * + * Swapping frameworks is one edit: repoint the `from "./next"` line. + * + * No runtime registry, unlike its `framework/cache` and `framework/request` + * siblings, and the difference is not an oversight. Most of what navigation + * exports is a React component or a hook, read during render and across the + * server/client boundary - putting `Link` behind a `getAdapter()` lookup means a + * wrapper component on every link in the product, and an installation order that + * has to be right before the first render rather than before the first call. + * A static re-export costs nothing, keeps the adapter's own types at the call + * site, and leaves exactly one line to change. + */ +export type { + NavigationAdapter, + NavigationHref, + NavigationLink, + NavigationLinkProps, + NavigationQueryParams, + NavigationRedirectType, + NavigationRouter, + NavigationSearchParams, +} from "./types"; diff --git a/packages/vitnode/src/framework/navigation/next.test.ts b/packages/vitnode/src/framework/navigation/next.test.ts new file mode 100644 index 000000000..f32d92966 --- /dev/null +++ b/packages/vitnode/src/framework/navigation/next.test.ts @@ -0,0 +1,158 @@ +/** + * What the navigation layer promises, tested against a stubbed framework. + * + * Two things are worth a test here and the rest is not. The first is *which* + * primitive each export reaches for - the locale-aware redirect and the + * unlocalized one differ by nothing at the call site and by a doubled locale + * segment in production, so the wiring is the bug. The second is that + * `@/lib/navigation` still hands back the same functions, because roughly fifty + * modules and every app import it by that name. + * + * The framework is stubbed rather than exercised: whether Next.js redirects + * correctly is Next.js's test, not ours. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const nextIntl = vi.hoisted(() => ({ + getLocale: vi.fn(async () => await Promise.resolve("pl")), + getPathname: vi.fn(), + Link: () => null, + redirect: vi.fn(), + usePathname: vi.fn(), + useRouter: vi.fn(), +})); + +const next = vi.hoisted(() => ({ + Link: () => null, + notFound: vi.fn(), + permanentRedirect: vi.fn(), + useSearchParams: vi.fn(), +})); + +vi.mock("next-intl/navigation", () => ({ + createNavigation: () => ({ + getPathname: nextIntl.getPathname, + Link: nextIntl.Link, + redirect: nextIntl.redirect, + usePathname: nextIntl.usePathname, + useRouter: nextIntl.useRouter, + }), +})); + +vi.mock("next-intl/server", () => ({ getLocale: nextIntl.getLocale })); + +vi.mock("next/link", () => ({ default: next.Link })); + +vi.mock("next/navigation", () => ({ + notFound: next.notFound, + permanentRedirect: next.permanentRedirect, + useSearchParams: next.useSearchParams, +})); + +const navigation = await import("./index"); +const shim = await import("@/lib/navigation"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("redirect", () => { + it("prefixes the reader's locale, which is why it has to be async", async () => { + await navigation.redirect("/settings"); + + expect(nextIntl.getLocale).toHaveBeenCalledTimes(1); + expect(nextIntl.redirect).toHaveBeenCalledWith( + { href: "/settings", locale: "pl" }, + undefined, + ); + }); + + it("passes the history mode through untouched", async () => { + await navigation.redirect("/settings", "replace"); + + expect(nextIntl.redirect).toHaveBeenCalledWith( + { href: "/settings", locale: "pl" }, + "replace", + ); + }); + + it("carries a query object as a query object, not a serialised string", async () => { + await navigation.redirect({ pathname: "/search", query: { q: "hono" } }); + + expect(nextIntl.redirect).toHaveBeenCalledWith( + { href: { pathname: "/search", query: { q: "hono" } }, locale: "pl" }, + undefined, + ); + }); +}); + +describe("unlocalizedPermanentRedirect", () => { + it("leaves the location alone - it already carries its locale segment", () => { + navigation.unlocalizedPermanentRedirect("/pl/articles/moved", "replace"); + + expect(next.permanentRedirect).toHaveBeenCalledWith( + "/pl/articles/moved", + "replace", + ); + // The whole reason this export exists: prefixing again would send + // `/pl/articles/moved` to `/pl/pl/articles/moved`. + expect(nextIntl.getLocale).not.toHaveBeenCalled(); + expect(nextIntl.redirect).not.toHaveBeenCalled(); + }); +}); + +describe("the two links", () => { + it("keeps the locale-aware and locale-free anchors distinct", () => { + // `global-error` renders above the i18n provider and must get the plain one; + // a mix-up here throws at runtime on the page that exists to not throw. + expect(navigation.Link).toBe(nextIntl.Link); + expect(navigation.UnlocalizedLink).toBe(next.Link); + expect(navigation.Link).not.toBe(navigation.UnlocalizedLink); + }); +}); + +describe("the surface", () => { + it("exports exactly the contract, so a port knows what it owes", () => { + expect(Object.keys(navigation).toSorted()).toEqual([ + "Link", + "UnlocalizedLink", + "getPathname", + "notFound", + "redirect", + "unlocalizedPermanentRedirect", + "usePathname", + "useRouter", + "useSearchParams", + ]); + }); + + it("delegates the read-only hooks and `notFound` without wrapping them", () => { + // Re-exported by identity on purpose: a wrapper around a function whose job + // is to throw only adds a frame to every stack trace. + expect(navigation.notFound).toBe(next.notFound); + expect(navigation.useSearchParams).toBe(next.useSearchParams); + expect(navigation.usePathname).toBe(nextIntl.usePathname); + expect(navigation.useRouter).toBe(nextIntl.useRouter); + expect(navigation.getPathname).toBe(nextIntl.getPathname); + }); +}); + +describe("the `@/lib/navigation` shim", () => { + it("still exports the same five names it always did", () => { + expect(Object.keys(shim).toSorted()).toEqual([ + "Link", + "getPathname", + "redirect", + "usePathname", + "useRouter", + ]); + }); + + it("hands back the very same functions, not lookalikes", () => { + expect(shim.Link).toBe(navigation.Link); + expect(shim.getPathname).toBe(navigation.getPathname); + expect(shim.redirect).toBe(navigation.redirect); + expect(shim.usePathname).toBe(navigation.usePathname); + expect(shim.useRouter).toBe(navigation.useRouter); + }); +}); diff --git a/packages/vitnode/src/framework/navigation/next.ts b/packages/vitnode/src/framework/navigation/next.ts new file mode 100644 index 000000000..ff2bac2fa --- /dev/null +++ b/packages/vitnode/src/framework/navigation/next.ts @@ -0,0 +1,68 @@ +/** + * The Next.js navigation adapter - the active implementation. + * + * This is the one module in the package that is allowed to import `next/link` + * and `next/navigation`. Everything else goes through `./index`, so porting + * VitNode to another framework is a matter of writing a sibling of this file + * and repointing that barrel, rather than editing the ~60 call sites that + * navigate. + * + * The locale-aware half is `next-intl`'s, not ours: `createNavigation()` + * returns a `Link`, a router and a `redirect` that prefix the active locale. + * The locale-free half comes straight from `next/navigation`, unwrapped - + * `notFound` and `permanentRedirect` already take the shape the contract asks + * for, and a wrapper around a function whose whole job is to throw would only + * add a frame to every stack trace. + */ + +// The one module where these imports are the point rather than a leak. +/* eslint-disable no-restricted-imports */ +import type { QueryParams } from "next-intl/navigation"; + +import { createNavigation } from "next-intl/navigation"; +import { getLocale } from "next-intl/server"; + +import type { NavigationRedirectType } from "./types"; + +/** An anchor that renders the href it was given, locale and all. */ +export { default as UnlocalizedLink } from "next/link"; + +/** + * Locale-free primitives, re-exported as-is. + * + * `permanentRedirect` is renamed rather than aliased away: the name is the + * warning. It issues a 308 to a location it does not touch, so the caller is + * responsible for the locale segment - see {@link NavigationAdapter} for the + * two places where that is the correct trade. + */ +export { + notFound, + permanentRedirect as unlocalizedPermanentRedirect, + useSearchParams, +} from "next/navigation"; + +const { + Link, + getPathname, + redirect: redirectWithLocale, + usePathname, + useRouter, +} = createNavigation(); + +/** + * A temporary (307) redirect to a path written *without* a locale prefix. + * + * Async, and that is not incidental: the locale lives in the request, so the + * only honest way to prefix it is to await it. Callers are server actions and + * route handlers, which are async already. + */ +const redirect = async ( + href: string | { pathname: string; query?: QueryParams }, + type?: NavigationRedirectType, +): Promise => { + const locale = await getLocale(); + + redirectWithLocale({ href, locale }, type); +}; + +export { getPathname, Link, redirect, usePathname, useRouter }; diff --git a/packages/vitnode/src/framework/navigation/types.test-d.ts b/packages/vitnode/src/framework/navigation/types.test-d.ts new file mode 100644 index 000000000..7229ac383 --- /dev/null +++ b/packages/vitnode/src/framework/navigation/types.test-d.ts @@ -0,0 +1,27 @@ +/** + * The assertion that makes `./types` more than documentation. + * + * `./next` is checked against {@link NavigationAdapter} as a whole, so a + * Next-shaped signature cannot drift into the adapter unnoticed: widen a + * parameter, drop an export, or return a framework-specific type, and this + * fails rather than the port failing months later. + */ +import { assertType, describe, it } from "vitest"; + +import type { NavigationAdapter, NavigationRouter } from "./types"; + +import * as adapter from "./next"; + +describe("the Next.js adapter", () => { + it("satisfies the framework-agnostic contract", () => { + assertType(adapter); + }); + + it("returns a router with only the methods the contract names", () => { + assertType(adapter.useRouter()); + }); + + it("takes a plain string for the history mode, not a framework enum", () => { + assertType>(adapter.redirect("/settings", "replace")); + }); +}); diff --git a/packages/vitnode/src/framework/navigation/types.ts b/packages/vitnode/src/framework/navigation/types.ts new file mode 100644 index 000000000..8e2f4462b --- /dev/null +++ b/packages/vitnode/src/framework/navigation/types.ts @@ -0,0 +1,125 @@ +/** + * Framework-agnostic navigation contracts. + * + * A link, a router, a redirect and a 404 are the four things roughly a hundred + * VitNode components cannot be written without, and they are also the four + * things the host framework owns. This module writes them down *without* the + * framework in the type, so that `./next` stays the single module in the + * package that has to be rewritten the day VitNode runs somewhere else. + * + * Nothing here imports a framework - the only import is React, which the whole + * UI is built on either way. The active adapter is asserted against + * {@link NavigationAdapter} in `types.test-d.ts`, and that assertion is what + * keeps this file honest: a Next-shaped signature that leaked into the adapter + * and got used from a view would fail the type suite rather than quietly + * becoming part of the contract. + * + * The contracts are deliberately *narrower* than what Next.js offers. The + * public exports in `./index` keep the adapter's own inferred types, so callers + * lose nothing today; the contract describes the subset a second framework + * would have to reimplement. + */ +import type React from "react"; + +/** + * Whether a redirect adds a history entry or overwrites the current one. + * + * A plain union rather than a framework enum: `replace` is what a moved URL + * wants, so that following a stale link does not force the reader to press back + * twice to leave a page they were never meant to land on. + */ +export type NavigationRedirectType = "push" | "replace"; + +/** Query values a navigation target may carry. */ +export type NavigationQueryParams = Record< + string, + boolean | number | readonly string[] | string +>; + +/** A navigation target: a path, or a path plus a query to serialise onto it. */ +export type NavigationHref = + string | { pathname: string; query?: NavigationQueryParams }; + +/** + * A read-only view of the current URL's query string. + * + * Read-only because the query is derived from the URL: the way to change it is + * to navigate, not to mutate the object the framework handed you. + */ +export type NavigationSearchParams = Omit< + URLSearchParams, + "append" | "delete" | "set" | "sort" +>; + +/** Imperative, client-side navigation. */ +export interface NavigationRouter { + back: () => void; + forward: () => void; + prefetch: (href: NavigationHref) => void; + push: (href: NavigationHref, options?: { scroll?: boolean }) => void; + /** Re-fetch the current route's server data without losing client state. */ + refresh: () => void; + replace: (href: NavigationHref, options?: { scroll?: boolean }) => void; +} + +/** + * The props a VitNode link is allowed to rely on. + * + * Intentionally a small subset of ``: everything listed here has to exist in + * every framework's link, so the list is the price of a port rather than a + * catalogue of what the current one happens to support. + */ +export interface NavigationLinkProps { + children?: React.ReactNode; + className?: string; + href: NavigationHref; + onClick?: React.MouseEventHandler; + prefetch?: boolean | null; + ref?: React.Ref; + target?: React.HTMLAttributeAnchorTarget; +} + +/** A component that renders {@link NavigationLinkProps} as an anchor. */ +export type NavigationLink = React.ComponentType; + +/** + * Everything a host framework has to provide for VitNode's UI to navigate. + * + * Two link components and two redirects, because locale is the axis that splits + * them. `Link` and `redirect` prefix the reader's locale, which is what an + * in-app href wants. `UnlocalizedLink` and `unlocalizedPermanentRedirect` do + * not, which is what the two callers outside the i18n provider need: the global + * error page, which renders above it, and the content engine's delivery + * resolver, whose locations already carry their locale segment - prefixing them + * again would send `/pl/articles/x` to `/pl/pl/articles/x`. + */ +export interface NavigationAdapter { + /** Builds the href {@link Link} would render, without rendering one. */ + getPathname: (args: { href: NavigationHref; locale: string }) => string; + /** Locale-aware anchor: `/settings` renders as `/pl/settings`. */ + Link: NavigationLink; + /** Ends the render and shows the nearest not-found page. */ + notFound: () => never; + /** Locale-aware, temporary (307). Async because it resolves the locale. */ + redirect: ( + href: NavigationHref, + type?: NavigationRedirectType, + ) => Promise; + /** Anchor for the paths that must not be locale-prefixed. */ + UnlocalizedLink: NavigationLink; + /** + * Permanent (308), for a location that is already a complete path. + * + * 308 rather than 301 because it preserves the request method: both behave + * identically for the `GET` a page is read with, and only one of them still + * behaves correctly the day a form under a moved path is submitted. + */ + unlocalizedPermanentRedirect: ( + location: string, + type?: NavigationRedirectType, + ) => never; + /** The current path with the locale segment stripped back off. */ + usePathname: () => string; + useRouter: () => NavigationRouter; + useSearchParams: () => NavigationSearchParams; +} diff --git a/packages/vitnode/src/framework/request/boundaries.test.ts b/packages/vitnode/src/framework/request/boundaries.test.ts new file mode 100644 index 000000000..2f2e393ee --- /dev/null +++ b/packages/vitnode/src/framework/request/boundaries.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const sourceRoot = resolve(here, "../.."); + +const filesUnder = (directory: string): string[] => { + const entries: string[] = []; + + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) { + entries.push(...filesUnder(path)); + continue; + } + if (/\.tsx?$/.test(name)) entries.push(path); + } + + return entries; +}; + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, "utf8").matchAll( + /from\s+"([^"]+)"|import\s+"([^"]+)"/g, + ), + ] + .map(match => match[1] ?? match[2]) + .filter(Boolean); + +/** The request APIs this layer exists to own. */ +const REQUEST_MODULES = ["next/headers", "next/server"]; + +/** Everything in this folder that must stay loadable without a framework. */ +const AGNOSTIC = ["runtime.ts", "types.ts"]; + +/** + * The rule the `framework/request` layer is for, asserted rather than + * remembered. + * + * Per-request state is the part of a host framework that reaches furthest into + * a codebase: cookies and headers are read wherever the API is called, and + * "wait for a real request" is read wherever something is cached. Left alone + * those imports spread, and by the time a second runtime is on the table the + * coupling is everywhere instead of in one file. + * + * Both halves are pinned here, because either one alone is satisfiable by + * accident: nothing outside `next.ts` imports the request APIs, and the + * framework-free files stay framework-free so an adapter for another runtime - + * and the plain-Node processes that load `content/` - can import them. + */ +describe("request layer boundaries", () => { + const sourceFiles = filesUnder(sourceRoot); + const adapter = relative(sourceRoot, join(here, "next.ts")); + + it("has files to check", () => { + // A move that relocated the package should fail loudly here rather than + // making the suite vacuously pass. + expect(sourceFiles.length).toBeGreaterThan(100); + expect(AGNOSTIC.every(name => sourceFiles.includes(join(here, name)))).toBe( + true, + ); + }); + + it.each(REQUEST_MODULES)( + "imports %s in the Next adapter and nowhere else", + specifier => { + const importers = sourceFiles + .filter(path => importsFrom(path).includes(specifier)) + .map(path => relative(sourceRoot, path)); + + expect(importers).toEqual([adapter]); + }, + ); + + it("is where those imports actually live", () => { + // The other half of the rule. Without this the suite above passes just as + // happily when the adapter has been gutted. + expect(importsFrom(join(here, "next.ts"))).toEqual( + expect.arrayContaining(REQUEST_MODULES), + ); + }); + + it.each(AGNOSTIC)("keeps %s free of any framework import", name => { + // These two are what a second framework's adapter is written against, and + // what has to keep loading in `apps/api` and drizzle-kit. One `next/*` + // here - including a convenience re-export from `./next` - puts Next back + // in that graph, and `server-only` would do the same to the browser. + const offenders = importsFrom(join(here, name)).filter( + specifier => + specifier.startsWith("next/") || + specifier === "./next" || + specifier === "server-only", + ); + + expect(offenders).toEqual([]); + }); + + it("installs the adapter from the barrel, not from the adapter itself", () => { + // `./next` stays a plain description of one framework: the barrel is what + // decides it is the default. An adapter that installed itself on import + // would make `hasRequestAdapter()` depend on which module a bundler + // happened to reach first. + expect(importsFrom(join(here, "index.ts"))).toEqual( + expect.arrayContaining(["./next", "./runtime"]), + ); + expect(readFileSync(join(here, "index.ts"), "utf8")).toContain( + "setDefaultRequestAdapter(nextRequestAdapter)", + ); + expect(readFileSync(join(here, "next.ts"), "utf8")).not.toContain( + "setDefaultRequestAdapter(", + ); + }); +}); diff --git a/packages/vitnode/src/framework/request/index.ts b/packages/vitnode/src/framework/request/index.ts new file mode 100644 index 000000000..d4a842034 --- /dev/null +++ b/packages/vitnode/src/framework/request/index.ts @@ -0,0 +1,40 @@ +/** + * Per-request state - cookies, headers, and "wait for a real request" - read + * through VitNode rather than through Next. + * + * Importing this module installs the Next.js adapter as the default, so server + * code gets working `requestHeaders()` / `requestCookies()` / `awaitRequest()` + * with no setup. An application on another host framework calls + * `setRequestAdapter()` with its own adapter, which takes precedence. + * + * `./next` is the only file behind this barrel that touches `next/*`, and it + * carries `server-only` - so this module does too. Code that has to load in + * plain Node (`content/`, `content/server/`, drizzle-kit) must import + * `./runtime` and `./types` directly instead, both of which are framework-free. + * + * The same split as `framework/cache`, for the same reason. + */ +import { nextRequestAdapter } from "./next"; +import { setDefaultRequestAdapter } from "./runtime"; + +setDefaultRequestAdapter(nextRequestAdapter); + +export { nextRequestAdapter } from "./next"; +export { + awaitRequest, + forwardApiRequestHeaders, + getRequestAdapter, + hasRequestAdapter, + requestCookies, + requestHeaders, + resetRequestAdapter, + setDefaultRequestAdapter, + setRequestAdapter, +} from "./runtime"; +export type { + RequestAdapter, + RequestCookie, + RequestCookieAttributes, + RequestCookieStore, + RequestHeaders, +} from "./types"; diff --git a/packages/vitnode/src/framework/request/next.test.ts b/packages/vitnode/src/framework/request/next.test.ts new file mode 100644 index 000000000..34a20ca36 --- /dev/null +++ b/packages/vitnode/src/framework/request/next.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface CookieCall { + args: unknown[]; + fn: string; +} + +const state = vi.hoisted(() => ({ + calls: [] as CookieCall[], + connections: 0, + headers: new Headers(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("next/headers", () => { + const record = + (fn: string) => + (...args: unknown[]) => { + state.calls.push({ args, fn }); + + return undefined; + }; + + return { + cookies: async () => + await Promise.resolve({ + delete: record("delete"), + get: (name: string) => { + state.calls.push({ args: [name], fn: "get" }); + + return { name, value: `${name}-value` }; + }, + getAll: () => { + state.calls.push({ args: [], fn: "getAll" }); + + return [{ name: "a", value: "1" }]; + }, + has: (name: string) => { + state.calls.push({ args: [name], fn: "has" }); + + return true; + }, + set: record("set"), + toString: () => "a=1; b=2", + }), + headers: async () => await Promise.resolve(state.headers), + }; +}); + +vi.mock("next/server", () => ({ + connection: async () => { + state.connections += 1; + + return await Promise.resolve(); + }, +})); + +const { nextRequestAdapter } = await import("./next"); + +/** + * The adapter is the only place the mapping onto `next/headers` and + * `next/server` lives, so this is where the mapping is pinned - against the + * real module rather than a stub of it. + * + * The cookie assertions matter most. `handleSetCookiesFetcher` replays the + * API's `Set-Cookie` headers through this store, so a dropped attribute is not + * a cosmetic bug: a session cookie that loses `httpOnly` becomes readable from + * JavaScript, and one that loses `expires` becomes a session cookie that dies + * with the tab. + */ +beforeEach(() => { + state.calls.length = 0; + state.connections = 0; + state.headers = new Headers(); +}); + +describe("nextRequestAdapter", () => { + it("identifies itself", () => { + expect(nextRequestAdapter.name).toBe("next"); + }); + + it("hands out Next's own headers rather than a copy", async () => { + state.headers = new Headers({ "user-agent": "probe" }); + + const headers = await nextRequestAdapter.getHeaders(); + + // Same object: a snapshot could drift from the request, and Next's + // `ReadonlyHeaders` already satisfies the contract unchanged. + expect(headers).toBe(state.headers); + expect(headers.get("user-agent")).toBe("probe"); + }); + + it("serialises the cookie jar as a Cookie header value", async () => { + const store = await nextRequestAdapter.getCookies(); + + expect(store.toString()).toBe("a=1; b=2"); + }); + + it("writes every attribute through to Next", async () => { + const store = await nextRequestAdapter.getCookies(); + const expires = new Date("2030-01-01T00:00:00.000Z"); + + store.set("vitnode-session", "abc", { + domain: "example.com", + expires, + httpOnly: true, + path: "/", + sameSite: "lax", + secure: true, + }); + + expect(state.calls).toEqual([ + { + fn: "set", + args: [ + "vitnode-session", + "abc", + { + domain: "example.com", + expires, + httpOnly: true, + path: "/", + sameSite: "lax", + secure: true, + }, + ], + }, + ]); + }); + + it("passes the reads straight through", async () => { + const store = await nextRequestAdapter.getCookies(); + + expect(store.get("session")).toEqual({ + name: "session", + value: "session-value", + }); + expect(store.getAll()).toEqual([{ name: "a", value: "1" }]); + expect(store.has("session")).toBe(true); + store.delete("session"); + + expect(state.calls.map(call => call.fn)).toEqual([ + "get", + "getAll", + "has", + "delete", + ]); + }); + + it("does not leak Next-only members of the store", async () => { + const store = await nextRequestAdapter.getCookies(); + + // The wrapper exists so nothing downstream can reach past the contract and + // quietly re-couple itself to Next. + expect(Object.keys(store).toSorted()).toEqual([ + "delete", + "get", + "getAll", + "has", + "set", + "toString", + ]); + expect("size" in store).toBe(false); + }); + + it("waits for a request through connection()", async () => { + await nextRequestAdapter.awaitRequest(); + + expect(state.connections).toBe(1); + }); +}); diff --git a/packages/vitnode/src/framework/request/next.ts b/packages/vitnode/src/framework/request/next.ts new file mode 100644 index 000000000..bafceff02 --- /dev/null +++ b/packages/vitnode/src/framework/request/next.ts @@ -0,0 +1,67 @@ +import "server-only"; +import * as nextHeaders from "next/headers"; +import * as nextServer from "next/server"; + +import type { RequestAdapter, RequestCookieStore } from "./types"; + +/** + * The Next.js request adapter - the **only** module in `@vitnode/core` that + * imports `next/headers` or `next/server`. + * + * That is the whole point of the `framework/request` layer: core code reads + * request state through {@link RequestAdapter}, and swapping host frameworks + * means writing a sibling of this file rather than editing every call site. + * `framework/request/boundaries.test.ts` asserts the rule instead of trusting + * it, and `server-only` here keeps the whole layer out of client bundles. + * + * ## Why namespace imports + * + * `import * as` rather than named imports, because a suite that exercises one + * verb mocks `next/headers` with a factory holding only the function it cares + * about. Named imports are resolved when this module is evaluated, so a partial + * mock would fail the *import* over a function the test never calls. A namespace + * access fails only if that function is actually reached, which is the behaviour + * a partial mock is asking for. + */ + +type NextCookieStore = Awaited>; + +/** + * Delegates rather than handing Next's own store out, so nothing downstream can + * reach for a Next-only member and quietly re-couple itself. `set` and `delete` + * still write through to the same response Next would have written to. + */ +const toCookieStore = (store: NextCookieStore): RequestCookieStore => ({ + delete: name => { + store.delete(name); + }, + get: name => store.get(name), + getAll: () => store.getAll(), + has: name => store.has(name), + set: (name, value, attributes) => { + store.set(name, value, attributes); + }, + toString: () => store.toString(), +}); + +export const nextRequestAdapter: RequestAdapter = { + /** + * `connection()`, whose contract is exactly the one + * {@link RequestAdapter.awaitRequest} describes: it never resolves during a + * prerender and resolves immediately while serving a request. + */ + awaitRequest: async () => { + await nextServer.connection(); + }, + + getCookies: async () => toCookieStore(await nextHeaders.cookies()), + + /** + * Next's `ReadonlyHeaders` is the web `Headers` interface with its mutators + * stubbed out, so it already satisfies {@link RequestHeaders} - no copy, and + * no chance of handing out a snapshot that drifts from the request. + */ + getHeaders: async () => await nextHeaders.headers(), + + name: "next", +}; diff --git a/packages/vitnode/src/framework/request/runtime.test.ts b/packages/vitnode/src/framework/request/runtime.test.ts new file mode 100644 index 000000000..58f455435 --- /dev/null +++ b/packages/vitnode/src/framework/request/runtime.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RequestAdapter, RequestCookieStore } from "./types"; + +import { + awaitRequest, + forwardApiRequestHeaders, + getRequestAdapter, + hasRequestAdapter, + requestCookies, + requestHeaders, + resetRequestAdapter, + setDefaultRequestAdapter, + setRequestAdapter, +} from "./runtime"; + +/** + * The registry, the verbs, and what a server-side API call inherits from the + * visitor's own request. + * + * The forwarding assertions are privilege and attribution questions rather than + * formatting ones: drop the cookie and the API answers anonymously, drop the + * address and every visitor shares one rate-limit bucket. The fallbacks are + * pinned too, because they are what the API records when a header is missing, + * and changing them silently changes what gets logged. + */ + +const cookieStore = (serialised = ""): RequestCookieStore => ({ + delete: () => undefined, + get: () => undefined, + getAll: () => [], + has: () => false, + set: () => undefined, + toString: () => serialised, +}); + +const fakeAdapter = ({ + cookies = "", + headers = {}, + name = "test", +}: { + cookies?: string; + headers?: Record; + name?: string; +} = {}): RequestAdapter => ({ + name, + awaitRequest: vi.fn(async () => await Promise.resolve()), + getCookies: vi.fn(async () => await Promise.resolve(cookieStore(cookies))), + getHeaders: vi.fn(async () => await Promise.resolve(new Headers(headers))), +}); + +beforeEach(() => { + resetRequestAdapter(); +}); + +describe("request adapter registry", () => { + it("holds nothing until one is offered", () => { + expect(hasRequestAdapter()).toBe(false); + }); + + it("names both ways out in the error when nothing is installed", async () => { + // A missing adapter surfaces as a failed render three layers away, so the + // message has to carry the fix rather than the symptom. + expect(() => getRequestAdapter()).toThrow(/no vitnode request adapter/i); + expect(() => getRequestAdapter()).toThrow( + /@vitnode\/core\/framework\/request/, + ); + expect(() => getRequestAdapter()).toThrow(/setRequestAdapter/); + + await expect(requestHeaders()).rejects.toThrow(/request adapter/i); + await expect(requestCookies()).rejects.toThrow(/request adapter/i); + await expect(awaitRequest()).rejects.toThrow(/request adapter/i); + await expect(forwardApiRequestHeaders()).rejects.toThrow( + /request adapter/i, + ); + }); + + it("uses the default when nothing was installed explicitly", () => { + setDefaultRequestAdapter(fakeAdapter({ name: "default" })); + + expect(hasRequestAdapter()).toBe(true); + expect(getRequestAdapter().name).toBe("default"); + }); + + it("prefers an installed adapter over the default, whichever came first", () => { + // The barrel installs the default as an import side effect, and an + // application cannot control whether its own `setRequestAdapter()` runs + // before or after that import. + setDefaultRequestAdapter(fakeAdapter({ name: "default" })); + setRequestAdapter(fakeAdapter({ name: "host" })); + expect(getRequestAdapter().name).toBe("host"); + + resetRequestAdapter(); + + setRequestAdapter(fakeAdapter({ name: "host" })); + setDefaultRequestAdapter(fakeAdapter({ name: "default" })); + expect(getRequestAdapter().name).toBe("host"); + }); +}); + +describe("request verbs", () => { + it("delegates each read to the adapter", async () => { + const adapter = fakeAdapter({ cookies: "a=1", headers: { "x-p": "yes" } }); + setRequestAdapter(adapter); + + await expect( + requestHeaders().then(headers => headers.get("x-p")), + ).resolves.toBe("yes"); + await expect( + requestCookies().then(store => store.toString()), + ).resolves.toBe("a=1"); + await awaitRequest(); + + expect(adapter.getHeaders).toHaveBeenCalledTimes(1); + expect(adapter.getCookies).toHaveBeenCalledTimes(1); + expect(adapter.awaitRequest).toHaveBeenCalledTimes(1); + }); + + it("resolves the adapter per call, not once at import", async () => { + // Core code imports these helpers at module load. If one captured the + // adapter then, installing a different one later would have no effect. + setRequestAdapter(fakeAdapter({ headers: { "x-p": "first" } })); + await expect( + requestHeaders().then(headers => headers.get("x-p")), + ).resolves.toBe("first"); + + setRequestAdapter(fakeAdapter({ headers: { "x-p": "second" } })); + await expect( + requestHeaders().then(headers => headers.get("x-p")), + ).resolves.toBe("second"); + }); +}); + +describe("forwardApiRequestHeaders", () => { + it("forwards the session, the agent and the client address", async () => { + setRequestAdapter( + fakeAdapter({ + cookies: "vitnode-session=abc; theme=dark", + headers: { + "user-agent": "Mozilla/5.0", + "x-forwarded-for": "203.0.113.7, 70.41.3.18", + }, + }), + ); + + await expect(forwardApiRequestHeaders()).resolves.toEqual({ + Cookie: "vitnode-session=abc; theme=dark", + "user-agent": "Mozilla/5.0", + "x-forwarded-for": "203.0.113.7, 70.41.3.18", + }); + }); + + it("falls back to the historical values when a header is absent", async () => { + setRequestAdapter(fakeAdapter()); + + await expect(forwardApiRequestHeaders()).resolves.toEqual({ + Cookie: "", + "user-agent": "node", + "x-forwarded-for": "0.0.0.0", + }); + }); + + it("forwards nothing else", async () => { + setRequestAdapter( + fakeAdapter({ + cookies: "a=1", + headers: { + authorization: "Bearer other-service", + host: "example.com", + }, + }), + ); + + // The API trusts what it is handed, so anything forwarded beyond these + // three has to be a deliberate decision. The key set is asserted exactly. + await expect( + forwardApiRequestHeaders().then(headers => + Object.keys(headers).toSorted(), + ), + ).resolves.toEqual(["Cookie", "user-agent", "x-forwarded-for"]); + }); + + it("returns a fresh mutable object per call", async () => { + setRequestAdapter(fakeAdapter({ cookies: "a=1" })); + + const first = await forwardApiRequestHeaders(); + first["x-vitnode-captcha-token"] = "token"; + const second = await forwardApiRequestHeaders(); + + // `fetcher` adds the captcha token to what this returns. A shared or frozen + // object would either leak one request's token into the next or throw on + // assignment. + expect(second).not.toBe(first); + expect(second["x-vitnode-captcha-token"]).toBeUndefined(); + }); + + it("reads the cookies and the headers concurrently", async () => { + // Both are awaited on every server-rendered API call, so they are + // deliberately started together rather than in sequence. + const started: string[] = []; + setRequestAdapter({ + name: "test", + awaitRequest: async () => await Promise.resolve(), + getCookies: async () => { + started.push("cookies"); + + return await Promise.resolve(cookieStore("a=1")); + }, + getHeaders: async () => { + started.push("headers"); + + return await Promise.resolve(new Headers()); + }, + }); + + const pending = forwardApiRequestHeaders(); + expect(started).toEqual(["headers", "cookies"]); + await pending; + }); +}); diff --git a/packages/vitnode/src/framework/request/runtime.ts b/packages/vitnode/src/framework/request/runtime.ts new file mode 100644 index 000000000..ce715f32f --- /dev/null +++ b/packages/vitnode/src/framework/request/runtime.ts @@ -0,0 +1,121 @@ +import type { + RequestAdapter, + RequestCookieStore, + RequestHeaders, +} from "./types"; + +/** + * Which adapter answers per-request reads, and the helpers core code calls. + * + * Two slots rather than one, so installation is order-independent. A host + * framework's adapter fills the *default* slot as a side effect of the barrel + * being imported ({@link setDefaultRequestAdapter}); an application that wants a + * different one calls {@link setRequestAdapter}, which always wins no matter + * which module happened to evaluate first. + * + * This module imports nothing but its own types, so a request adapter can be + * written - and this registry loaded - without pulling Next into the graph. + */ +let installed: RequestAdapter | undefined; +let fallback: RequestAdapter | undefined; + +/** Install the request adapter for this application. Overrides any default. */ +export const setRequestAdapter = (adapter: RequestAdapter): void => { + installed = adapter; +}; + +/** + * Offer an adapter as the default, used only when nothing was installed + * explicitly. Called by the barrel on import. + */ +export const setDefaultRequestAdapter = (adapter: RequestAdapter): void => { + fallback = adapter; +}; + +/** Whether any adapter - installed or default - can answer a request read. */ +export const hasRequestAdapter = (): boolean => + installed !== undefined || fallback !== undefined; + +/** + * The adapter to call, or a thrown error naming the fix. + * + * It throws rather than answering with an empty request, and that is the whole + * decision in this file. Silently returning no cookies would make every + * server-rendered API call anonymous: the pages still render, the session is + * simply gone, and what looks like a logout bug is a wiring mistake three layers + * away. Refusing to guess is what keeps that out of production. + */ +export const getRequestAdapter = (): RequestAdapter => { + const adapter = installed ?? fallback; + if (!adapter) { + throw new Error( + "No VitNode request adapter is installed. Import `@vitnode/core/framework/request` (which installs the Next.js adapter) before reading request state, or call `setRequestAdapter()` with your own adapter.", + ); + } + + return adapter; +}; + +/** For tests: drop both slots so the next read starts from nothing. */ +export const resetRequestAdapter = (): void => { + installed = undefined; + fallback = undefined; +}; + +/** + * The incoming request's headers. + * + * Resolved through the registry on every call rather than captured at import. + * Core code imports this helper at module load, so a captured adapter would + * freeze whichever one happened to be installed first - the one order an + * application cannot control. + */ +export const requestHeaders = async (): Promise => + await getRequestAdapter().getHeaders(); + +/** The request's cookie jar, which also writes cookies onto the response. */ +export const requestCookies = async (): Promise => + await getRequestAdapter().getCookies(); + +/** + * Waits for a real request before continuing - see + * {@link RequestAdapter.awaitRequest}. + */ +export const awaitRequest = async (): Promise => { + await getRequestAdapter().awaitRequest(); +}; + +/** + * The headers a server-side call to the VitNode API carries over from the + * visitor's own request. + * + * Three of them, and each one is load-bearing: + * + * - `Cookie` is the session. Drop it and the API answers as an anonymous + * visitor, which is an authorisation change rather than a missing + * convenience. + * - `user-agent` and `x-forwarded-for` are what the API records against a + * session and rate-limits on. Without them every request looks like it came + * from the app server, so one visitor's traffic is attributed to - and + * throttled alongside - everybody else's. + * + * The fallbacks exist because a header is absent far more often than it is + * wrong: internal calls, some proxies, local dev. They are the historical values + * and are kept verbatim. + * + * A fresh mutable object each call - `fetcher` adds the captcha token to it. + */ +export const forwardApiRequestHeaders = async (): Promise< + Record +> => { + const [headers, cookies] = await Promise.all([ + requestHeaders(), + requestCookies(), + ]); + + return { + Cookie: cookies.toString(), + ["user-agent"]: headers.get("user-agent") ?? "node", + ["x-forwarded-for"]: headers.get("x-forwarded-for") ?? "0.0.0.0", + }; +}; diff --git a/packages/vitnode/src/framework/request/types.ts b/packages/vitnode/src/framework/request/types.ts new file mode 100644 index 000000000..59f740c50 --- /dev/null +++ b/packages/vitnode/src/framework/request/types.ts @@ -0,0 +1,87 @@ +/** + * The per-request contract VitNode is written against. + * + * Types only, no implementation and no `next/*`, so this module is safe + * everywhere the framework-independent layers are: `apps/api` (a plain + * `@hono/node-server` process), drizzle-kit, and the browser. It is the whole + * vocabulary a VitNode caller needs for request state - nothing above this file + * names Next. + * + * The surface is deliberately smaller than what any framework offers. Every + * member is something core code already reads or writes today: the incoming + * headers, the cookie jar, and "wait for a real request before continuing". + * Anything wider would be a Next.js shape with a different name on it. + */ + +/** + * The incoming request's headers. + * + * The web `Headers` interface minus its mutators, because what a runtime hands + * out is a view of what the client sent - writing to it either throws or + * silently does nothing. Both Next's `ReadonlyHeaders` and a plain `Headers` + * satisfy this, so an adapter never has to copy. + */ +export type RequestHeaders = Omit; + +/** A cookie as it arrives on the request: no attributes, only a value. */ +export interface RequestCookie { + name: string; + value: string; +} + +/** + * The `Set-Cookie` attributes VitNode writes. + * + * The subset of the serialisation options {@link RequestCookieStore.set} is + * actually called with in this codebase, rather than whatever the framework + * underneath happens to accept. Adding one is cheap; the point is that the list + * is stated here instead of inherited. + */ +export interface RequestCookieAttributes { + domain?: string; + expires?: Date | number; + httpOnly?: boolean; + maxAge?: number; + path?: string; + sameSite?: "lax" | "none" | "strict" | boolean; + secure?: boolean; +} + +/** Reads the request's cookies; writes cookies onto the response. */ +export interface RequestCookieStore { + readonly delete: (name: string) => void; + readonly get: (name: string) => RequestCookie | undefined; + readonly getAll: () => RequestCookie[]; + readonly has: (name: string) => boolean; + readonly set: ( + name: string, + value: string, + attributes?: RequestCookieAttributes, + ) => void; + /** Serialised as a `Cookie:` **request** header value, ready to forward. */ + readonly toString: () => string; +} + +/** + * One framework's implementation of the contract. + * + * Three verbs, and every one of them is asynchronous because reading request + * state is: a framework that keeps the current request in async-local storage + * resolves it per call, and an adapter that had to await a store internally + * would have nowhere to do it behind a synchronous signature. + */ +export interface RequestAdapter { + /** + * Resolves once an actual request is in flight. + * + * Under a framework that prerenders (Next's `connection()`) this never + * resolves during the prerender pass and resolves immediately while serving, + * which is what keeps a build from filling a cache entry it has no request to + * fill it with. An adapter with no prerender pass resolves immediately. + */ + readonly awaitRequest: () => Promise; + readonly getCookies: () => Promise; + readonly getHeaders: () => Promise; + /** Identifies the adapter in errors and tests. */ + readonly name: string; +} diff --git a/packages/vitnode/src/lib/api/get-middleware-api.ts b/packages/vitnode/src/lib/api/get-middleware-api.ts index 8460394bd..f7a8faf5b 100644 --- a/packages/vitnode/src/lib/api/get-middleware-api.ts +++ b/packages/vitnode/src/lib/api/get-middleware-api.ts @@ -1,7 +1,6 @@ -import { cacheLife } from "next/cache"; -import { connection } from "next/server"; - import { middlewareModule } from "@/api/modules/middleware/middleware.module"; +import { setCacheEntryLife } from "@/framework/cache"; +import { awaitRequest } from "@/framework/request"; import { coreFetcher } from "@/lib/fetcher/core"; /** @@ -9,7 +8,7 @@ import { coreFetcher } from "@/lib/fetcher/core"; * registered, whether an email adapter exists, and the public captcha key. * * Every field is derived from `vitnode.api.config.ts`, so the response is the - * same for every visitor and only changes on deploy - hence `cacheLife("max")`. + * same for every visitor and only changes on deploy - hence `setCacheEntryLife("max")`. * * Goes through `coreFetcher` rather than `fetcher` deliberately: `fetcher` * forwards the request's cookies and headers, and `use cache` cannot enclose a @@ -18,7 +17,7 @@ import { coreFetcher } from "@/lib/fetcher/core"; */ const fetchMiddlewareApi = async () => { "use cache"; - cacheLife("max"); + setCacheEntryLife("max"); const res = await coreFetcher(middlewareModule, { path: "/", @@ -30,7 +29,7 @@ const fetchMiddlewareApi = async () => { }; /** - * `connection()` first, so the entry above is filled by the first real request + * `awaitRequest()` first, so the entry above is filled by the first real request * instead of during `next build`. * * Cache Components fills a `use cache` entry while prerendering, and filling @@ -47,7 +46,7 @@ const fetchMiddlewareApi = async () => { * `instant = false`. */ export const getMiddlewareApi = async () => { - await connection(); + await awaitRequest(); return await fetchMiddlewareApi(); }; diff --git a/packages/vitnode/src/lib/fetcher.ts b/packages/vitnode/src/lib/fetcher.ts index c27924251..aa8027823 100644 --- a/packages/vitnode/src/lib/fetcher.ts +++ b/packages/vitnode/src/lib/fetcher.ts @@ -1,5 +1,4 @@ import "server-only"; -import { cookies, headers } from "next/headers"; import type { BaseBuildModuleReturn, @@ -7,6 +6,8 @@ import type { } from "@/api/lib/module"; import type { Route } from "@/api/lib/route"; +import { forwardApiRequestHeaders } from "@/framework/request"; + import type { FetcherParams, GetModulePaths, @@ -53,17 +54,7 @@ export async function fetcher< ): Promise< InferResponseType > { - const [nextInternalHeaders, cookie] = await Promise.all([ - headers(), - cookies(), - ]); - - const additionalHeaders: Record = { - Cookie: cookie.toString(), - ["user-agent"]: nextInternalHeaders.get("user-agent") ?? "node", - ["x-forwarded-for"]: - nextInternalHeaders.get("x-forwarded-for") ?? "0.0.0.0", - }; + const additionalHeaders = await forwardApiRequestHeaders(); if (captchaToken) { additionalHeaders["x-vitnode-captcha-token"] = captchaToken; diff --git a/packages/vitnode/src/lib/fetcher/helpers-server.ts b/packages/vitnode/src/lib/fetcher/helpers-server.ts index 21ff015fc..27dd81920 100644 --- a/packages/vitnode/src/lib/fetcher/helpers-server.ts +++ b/packages/vitnode/src/lib/fetcher/helpers-server.ts @@ -1,5 +1,6 @@ import "server-only"; -import { cookies } from "next/headers"; + +import { requestCookies } from "@/framework/request"; import { cookieFromStringToObject } from "./cookie-from-string-to-object"; @@ -11,7 +12,7 @@ export const handleSetCookiesFetcher = async (res: Response) => { if (typeof value !== "string" || typeof key !== "string") return; - (await cookies()).set(key, value, { + (await requestCookies()).set(key, value, { domain: cookie.Domain, path: cookie.Path, expires: new Date(cookie.Expires), diff --git a/packages/vitnode/src/lib/navigation.ts b/packages/vitnode/src/lib/navigation.ts index 0f64e6900..6cc4b1dbe 100644 --- a/packages/vitnode/src/lib/navigation.ts +++ b/packages/vitnode/src/lib/navigation.ts @@ -1,32 +1,17 @@ -import type { QueryParams } from "next-intl/navigation"; - -// Some Next versions export RedirectType as a value; declare a local type to avoid -// the "refers to a value, but is being used as a type" TS error. -type RedirectType = "push" | "replace"; - -import { createNavigation } from "next-intl/navigation"; -import { getLocale } from "next-intl/server"; - -const { +/** + * The locale-aware half of {@link "@/framework/navigation"}, under the import + * path it has always had. + * + * Kept as a shim rather than deleted: apps, the `create-vitnode-app` template + * and roughly fifty modules in here import `@vitnode/core/lib/navigation`, and + * the framework abstraction is not a reason to break any of them. New code + * should import from `@/framework/navigation`, which also carries `notFound`, + * `useSearchParams` and the two locale-free primitives. + */ +export { + getPathname, Link, - redirect: redirectFromImport, + redirect, usePathname, useRouter, - getPathname, -} = createNavigation(); - -const redirect = async ( - href: - | string - | { - pathname: string; - query?: QueryParams; - }, - type?: RedirectType, -) => { - const locale = await getLocale(); - - redirectFromImport({ href, locale }, type); -}; - -export { getPathname, Link, redirect, usePathname, useRouter }; +} from "@/framework/navigation"; diff --git a/packages/vitnode/src/routes/admin/core/users/[id]/page.tsx b/packages/vitnode/src/routes/admin/core/users/[id]/page.tsx index b6530736d..be6d61594 100644 --- a/packages/vitnode/src/routes/admin/core/users/[id]/page.tsx +++ b/packages/vitnode/src/routes/admin/core/users/[id]/page.tsx @@ -2,12 +2,12 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; -import { connection } from "next/server"; import React from "react"; import { adminModule } from "@/api/modules/admin/admin.module"; import { I18nProvider } from "@/components/i18n-provider"; import { Loader } from "@/components/ui/loader"; +import { awaitRequest } from "@/framework/request"; import { fetcher } from "@/lib/fetcher"; const ShowUserAdminView = dynamic(async () => @@ -54,7 +54,7 @@ export const generateMetadata = async ({ * dynamic so the metadata is allowed to be, while the body still prerenders. */ const DynamicMarker = async () => { - await connection(); + await awaitRequest(); return null; }; diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 433c0aab9..101ba36b6 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -1,6 +1,5 @@ "use server"; -import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { ContentPublicLocaleState } from "@/content/cache"; @@ -35,6 +34,7 @@ import { } from "@/content/conflicts"; import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; import { revalidateContent } from "@/content/next/revalidate.server"; +import { expireCachePath } from "@/framework/cache"; import type { TranslationRow } from "./translation-api.server"; @@ -334,7 +334,7 @@ export const createContentAction = async ( if (result.status !== 201) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); const created = result.data?.id ?? 0; // A new row starts as a draft, so this normally invalidates nothing at all - // it is computed rather than assumed, so the rule holds if that changes. The @@ -378,7 +378,7 @@ export const editContentAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); invalidate(definition, id, before, result.data, { after: await readContentPublicLocales(definition, pluginId, id), before: localesBefore, @@ -448,7 +448,7 @@ export const createLocalizedContentAction = async ( if (result.status !== 201) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); const created = result.data?.id ?? 0; invalidate(definition, created, undefined, result.data, { after: await readContentPublicLocales(definition, pluginId, created), @@ -509,7 +509,7 @@ export const editLocalizedContentAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); invalidate(definition, id, before, result.data, { after: await readContentPublicLocales(definition, pluginId, id), before: localesBefore, @@ -647,7 +647,7 @@ export const restoreContentRevisionAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); // A restore never moves `status`, so visibility is unchanged - but the slug // may have, and `invalidate` compares both rows to work out which. invalidate(definition, id, before, result.data?.row, { @@ -774,7 +774,7 @@ export const scheduleContentAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }; @@ -796,7 +796,7 @@ export const cancelContentScheduleAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }; @@ -835,7 +835,7 @@ export const deleteContentAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); // Every language loses its page at once, so the "after" side is empty rather // than re-read - there is nothing left to read. @@ -910,7 +910,7 @@ const publicationAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); // A no-op transitioned nothing, so nothing public went stale. Expiring a tag // on every button press would throw away a warm cache for free. diff --git a/packages/vitnode/src/views/admin/views/content/actions/options-action.test.ts b/packages/vitnode/src/views/admin/views/content/actions/options-action.test.ts index b13fa9630..118408779 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/options-action.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/options-action.test.ts @@ -12,6 +12,10 @@ import { describe, expect, it, vi } from "vitest"; */ const fetchMock = vi.fn(); +// The action expires a cache path, and cache invalidation now runs through +// `framework/cache` - whose Next adapter carries `server-only`. Mocking +// `next/cache` alone would still fail on that import. +vi.mock("server-only", () => ({})); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("@/content/admin/config", () => ({ findFrontendContentType: () => ({ diff --git a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts index e7408171e..fca1ef3cf 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts @@ -1,6 +1,5 @@ "use server"; -import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { @@ -21,6 +20,7 @@ import { parseContentTranslationConflict, parseContentUnprocessable, } from "@/content/conflicts"; +import { expireCachePath } from "@/framework/cache"; import { invalidateContentLocales, @@ -250,7 +250,7 @@ export const createContentTranslationAction = async ( if (result.status !== 201) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }); @@ -276,7 +276,7 @@ export const editContentTranslationAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }); @@ -301,7 +301,7 @@ export const deleteContentTranslationAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }); @@ -392,7 +392,7 @@ export const restoreContentTranslationRevisionAction = async ( if (result.status !== 200) return failure(result); - revalidatePath(CONTENT_PAGE_PATH, "page"); + expireCachePath(CONTENT_PAGE_PATH, "page"); return {}; }); diff --git a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx index 0040154de..95c8e848b 100644 --- a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx @@ -1,5 +1,4 @@ import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import React from "react"; import type { RegisteredFrontendContentType } from "@/content/admin/config"; @@ -26,6 +25,7 @@ import { } from "@/content/admin/spec"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { contentCreateHref } from "@/content/registry"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { CreateContentAction } from "./actions/create-action"; diff --git a/packages/vitnode/src/views/admin/views/content/content-labels.test.tsx b/packages/vitnode/src/views/admin/views/content/content-labels.test.tsx index e300e185e..8c774c096 100644 --- a/packages/vitnode/src/views/admin/views/content/content-labels.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/content-labels.test.tsx @@ -54,12 +54,20 @@ vi.mock("next-intl/server", () => ({ ), })); -vi.mock("@/lib/navigation", () => ({ +// One mock covers `@/lib/navigation` too - the shim re-exports this module, so +// stubbing the framework layer stubs both import paths at once. +vi.mock("@/framework/navigation", () => ({ Link: () => null, + UnlocalizedLink: () => null, getPathname: () => "", + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, redirect: () => undefined, + unlocalizedPermanentRedirect: () => undefined, usePathname: () => "", useRouter: () => ({ push: () => undefined, refresh: () => undefined }), + useSearchParams: () => new URLSearchParams(), })); vi.mock("@/lib/api/get-session-admin-api", () => ({ @@ -71,12 +79,6 @@ vi.mock("@/content/admin/fetch.server", () => ({ await Promise.resolve({ data: undefined, status: 200 }), })); -vi.mock("next/navigation", () => ({ - notFound: () => { - throw new Error("NEXT_NOT_FOUND"); - }, -})); - const { getContentLabels } = await import("./content-admin-view"); const article = defineContentType({ diff --git a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx index 65cc2d0dc..8dd9561e1 100644 --- a/packages/vitnode/src/views/admin/views/content/page/page-views.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/page-views.tsx @@ -1,6 +1,5 @@ import { ArrowLeftIcon } from "lucide-react"; import { getLocale, getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { z } from "zod"; import type { RegisteredFrontendContentType } from "@/content/admin/config"; @@ -11,6 +10,7 @@ import { contentApiFetch } from "@/content/admin/fetch.server"; import { buildContentFormSpec } from "@/content/admin/spec"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { contentAdminHref, contentEditHrefTemplate } from "@/content/registry"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { Link } from "@/lib/navigation"; import { resolveContentFormLayout } from "@/lib/plugin"; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.server.ts index 271ac3ad8..dcf99d500 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { cronAdminModule } from "@/api/modules/admin/advanced/cron/cron.admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const mutationApi = async (id: number) => { @@ -20,7 +19,7 @@ export const mutationApi = async (id: number) => { return { error: "Failed to run cron job" }; } - revalidatePath( + expireCachePath( "[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron", ); }; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts index 76d9fc73a..c86a5ea61 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { updateTag } from "next/cache"; - import { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; +import { expireCacheTags } from "@/framework/cache"; import { SEARCH_FEED_TAG } from "@/lib/cache-tags"; import { fetcher } from "@/lib/fetcher"; @@ -25,10 +24,10 @@ export const rebuildSearchIndexMutation = async (itemType?: string) => { // The public browse feed is a cached read of this index. Expiring it here is // what makes a rebuild visible on `/search` and `/discover` without waiting - // out its `cacheLife`. `updateTag` rather than `revalidateTag` because this is - // a Server Action: the admin who pressed the button sees the new feed on the - // refresh it triggers, not one navigation later. - updateTag(SEARCH_FEED_TAG); + // out its lifetime. The defaults are the ones that matter here - immediate, + // from a Server Action - so the admin who pressed the button sees the new feed + // on the refresh it triggers rather than one navigation later. + expireCacheTags(SEARCH_FEED_TAG); return { data }; }; @@ -56,7 +55,7 @@ export const clearSearchCollectionMutation = async (itemType: string) => { const data = await res.json(); // Documents just disappeared from the index; the feed must stop listing them. - updateTag(SEARCH_FEED_TAG); + expireCacheTags(SEARCH_FEED_TAG); return { data }; }; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts index 9f969681f..7d9bd0e90 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; import type { DashboardLayoutItem } from "../widgets/types"; @@ -31,5 +30,5 @@ export const saveDashboardLayoutMutation = async ({ return { error: await res.text() }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); }; diff --git a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/mutation-api.server.ts index affc9531a..4a1786a4a 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/mutation-api.server.ts @@ -1,7 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; - +import { expireCachePath } from "@/framework/cache"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; export const clearCacheMutation = async () => { @@ -14,5 +13,5 @@ export const clearCacheMutation = async () => { throw new Error("Forbidden"); } - await Promise.resolve(revalidatePath("/", "layout")); + await Promise.resolve(expireCachePath("/", "layout")); }; diff --git a/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-permissions-view.tsx b/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-permissions-view.tsx index 8c6d5f766..c88cb032e 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-permissions-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-permissions-view.tsx @@ -1,12 +1,12 @@ import { ArrowLeftIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; import { staffPermissionModuleByType } from "@/api/modules/admin/staff/lib/schema"; import { Button } from "@/components/ui/button"; import { HeaderContent } from "@/components/ui/header-content"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/admin/views/core/staff/create/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/staff/create/mutation-api.server.ts index 66dbd8dbf..be238e4fb 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/create/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/create/mutation-api.server.ts @@ -1,10 +1,9 @@ "use server"; -import { revalidatePath } from "next/cache"; - import type { PermissionStaffType } from "@/api/lib/permission-staff"; import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const createStaffEntry = async ({ @@ -30,7 +29,7 @@ export const createStaffEntry = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data: await res.json() }; }; diff --git a/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-permissions-view.tsx b/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-permissions-view.tsx index fd42432c0..7efdf6f2b 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-permissions-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-permissions-view.tsx @@ -1,6 +1,5 @@ import { ArrowLeftIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; @@ -10,6 +9,7 @@ import { staffPermissionModuleByType } from "@/api/modules/admin/staff/lib/schem import { RoleFormat } from "@/components/role-format"; import { Button } from "@/components/ui/button"; import { HeaderContent } from "@/components/ui/header-content"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { fetcher } from "@/lib/fetcher"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/admin/views/core/staff/edit/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/staff/edit/mutation-api.server.ts index 85cc336ec..bc3cf145e 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/edit/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/edit/mutation-api.server.ts @@ -1,13 +1,12 @@ "use server"; -import { revalidatePath } from "next/cache"; - import type { PermissionsStaffArgs, PermissionStaffType, } from "@/api/lib/permission-staff"; import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const updateStaffPermissions = async ({ @@ -35,7 +34,7 @@ export const updateStaffPermissions = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/admin/views/core/staff/table/actions/delete-action.server.ts b/packages/vitnode/src/views/admin/views/core/staff/table/actions/delete-action.server.ts index 732c96e36..a70a9f307 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/table/actions/delete-action.server.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/table/actions/delete-action.server.ts @@ -1,10 +1,9 @@ "use server"; -import { revalidatePath } from "next/cache"; - import type { PermissionStaffType } from "@/api/lib/permission-staff"; import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const deleteStaffEntry = async ({ @@ -27,7 +26,7 @@ export const deleteStaffEntry = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/admin/views/core/staff/table/staff-table.tsx b/packages/vitnode/src/views/admin/views/core/staff/table/staff-table.tsx index f7f7c95fb..6693ede3f 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/table/staff-table.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/table/staff-table.tsx @@ -1,6 +1,5 @@ import { ShieldUserIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { adminModule } from "@/api/modules/admin/admin.module"; import { staffPermissionModuleByType } from "@/api/modules/admin/staff/lib/schema"; @@ -8,6 +7,7 @@ import { DateFormat } from "@/components/date-format"; import { RoleFormat } from "@/components/role-format"; import { DataTable } from "@/components/table/data-table"; import { Badge } from "@/components/ui/badge"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { fetcher } from "@/lib/fetcher"; diff --git a/packages/vitnode/src/views/admin/views/core/staff/views/admins/admins-staff-view.tsx b/packages/vitnode/src/views/admin/views/core/staff/views/admins/admins-staff-view.tsx index f32366b35..35bfd26f7 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/views/admins/admins-staff-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/views/admins/admins-staff-view.tsx @@ -1,11 +1,11 @@ import { PlusIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import React from "react"; import { DataTableSkeleton } from "@/components/table/data-table"; import { Button } from "@/components/ui/button"; import { HeaderContent } from "@/components/ui/header-content"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/admin/views/core/staff/views/moderators/moderators-staff-view.tsx b/packages/vitnode/src/views/admin/views/core/staff/views/moderators/moderators-staff-view.tsx index ec96dcb7b..421f004f3 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/views/moderators/moderators-staff-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/views/moderators/moderators-staff-view.tsx @@ -1,11 +1,11 @@ import { PlusIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import React from "react"; import { DataTableSkeleton } from "@/components/table/data-table"; import { Button } from "@/components/ui/button"; import { HeaderContent } from "@/components/ui/header-content"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/delete-action.server.ts b/packages/vitnode/src/views/admin/views/core/system/files/actions/delete-action.server.ts index 47dec7fae..7772b4896 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/actions/delete-action.server.ts +++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/delete-action.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { filesAdminModule } from "@/api/modules/admin/files/files.admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const deleteFileAction = async ({ @@ -24,7 +23,7 @@ export const deleteFileAction = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx b/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx index b67a85673..0ec286b04 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-table-view.tsx @@ -1,6 +1,5 @@ import { FileIcon, FolderIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { filesAdminModule } from "@/api/modules/admin/files/files.admin.module"; import { DateFormat } from "@/components/date-format"; @@ -11,6 +10,7 @@ import { type SearchParamsDataTable, } from "@/components/table/data-table"; import { UserFormat } from "@/components/user-format"; +import { notFound } from "@/framework/navigation"; import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { fetcher } from "@/lib/fetcher"; import { formatBytes } from "@/lib/format-bytes"; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.server.ts index ae8b7aace..eb38d5080 100644 --- a/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.server.ts @@ -2,11 +2,10 @@ import type { z } from "zod"; -import { revalidatePath } from "next/cache"; - import type { zodCreateUserAdminSchema } from "@/api/modules/admin/users/routes/create.route"; import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const mutationApi = async ( @@ -26,7 +25,7 @@ export const mutationApi = async ( } const data = await res.json(); - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data }; }; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.server.ts index dfca0983c..1dd2bbe82 100644 --- a/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const mutationApi = async (id: number) => { @@ -20,7 +19,7 @@ export const mutationApi = async (id: number) => { } const data = await res.json(); - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data }; }; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.server.ts index 807193c87..c9b8f524a 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.server.ts @@ -2,11 +2,10 @@ import type { z } from "zod"; -import { revalidatePath } from "next/cache"; - import type { zodCreateRoleAdminSchema } from "@/api/modules/admin/roles/routes/create.route"; import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const createRole = async ( @@ -25,7 +24,7 @@ export const createRole = async ( return { error: await res.text() }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); }; export const editRole = async ({ @@ -46,5 +45,5 @@ export const editRole = async ({ return { error: await res.text() }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx index 296b7712e..0d767a0a7 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx @@ -1,12 +1,12 @@ import { ExternalLink, ShieldIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { adminModule } from "@/api/modules/admin/admin.module"; import { DateFormat } from "@/components/date-format"; import { RoleFormat } from "@/components/role-format"; import { DataTable } from "@/components/table/data-table"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { notFound } from "@/framework/navigation"; import { fetcher } from "@/lib/fetcher"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts index 0a2dfc336..ea6eebb2f 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const deleteRole = async ({ @@ -29,7 +28,7 @@ export const deleteRole = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return {}; }; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.server.ts index e67cceb03..e2fa4b91b 100644 --- a/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/show/mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; type MutationResult = @@ -28,7 +27,7 @@ export const mutationApi = async ( } const data = await res.json(); - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data }; }; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/roles/update-roles.action.server.ts b/packages/vitnode/src/views/admin/views/core/users/show/roles/update-roles.action.server.ts index 1df9a530b..c13aa52bb 100644 --- a/packages/vitnode/src/views/admin/views/core/users/show/roles/update-roles.action.server.ts +++ b/packages/vitnode/src/views/admin/views/core/users/show/roles/update-roles.action.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { adminModule } from "@/api/modules/admin/admin.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; type UpdateRolesResult = { data: true } | { error: { status: number } }; @@ -25,7 +24,7 @@ export const updateUserRoles = async ( return { error: { status: res.status } }; } - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx index 7446a926a..b4de59bf2 100644 --- a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx @@ -1,6 +1,5 @@ import { ExternalLinkIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { hasStaffPermission } from "@/api/lib/staff-permission"; import { adminModule } from "@/api/modules/admin/admin.module"; @@ -9,6 +8,7 @@ import { DateFormat } from "@/components/date-format"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { CONFIG_PLUGIN } from "@/config"; +import { notFound } from "@/framework/navigation"; import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; import { fetcher } from "@/lib/fetcher"; import { Link } from "@/lib/navigation"; diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx index 425bd0479..14c2de53f 100644 --- a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx +++ b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx @@ -1,6 +1,5 @@ import type z from "zod"; -import { notFound } from "next/navigation"; import React from "react"; import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; @@ -8,6 +7,7 @@ import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; import { I18nProvider } from "@/components/i18n-provider"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { notFound } from "@/framework/navigation"; import { getMiddlewareApi } from "@/lib/api/get-middleware-api"; import { ChangePasswordForm } from "./change-password-form/form"; diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts index cfcdc5341..15aa6ab03 100644 --- a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts +++ b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { usersModule } from "@/api/modules/users/users.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const revokeDeviceAction = async ({ @@ -23,7 +22,7 @@ export const revokeDeviceAction = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/auth/settings/layout.tsx b/packages/vitnode/src/views/auth/settings/layout.tsx index 8aaf153a4..1a8ecc59d 100644 --- a/packages/vitnode/src/views/auth/settings/layout.tsx +++ b/packages/vitnode/src/views/auth/settings/layout.tsx @@ -1,6 +1,5 @@ -import { notFound } from "next/navigation"; - import { I18nProvider } from "@/components/i18n-provider"; +import { notFound } from "@/framework/navigation"; import { getSessionApi } from "@/lib/api/get-session-api"; import { SettingsShell } from "./shell"; diff --git a/packages/vitnode/src/views/auth/sign-in/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/sign-in/form/mutation-api.server.ts index ffcf66c8f..aefd73aa6 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/mutation-api.server.ts +++ b/packages/vitnode/src/views/auth/sign-in/form/mutation-api.server.ts @@ -2,11 +2,10 @@ import type { z } from "zod"; -import { revalidatePath } from "next/cache"; - import type { zodSignInSchema } from "@/api/modules/users/routes/sign-in.route"; import { usersModule } from "@/api/modules/users/users.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; import { redirect } from "@/lib/navigation"; @@ -34,12 +33,12 @@ export const mutationApi = async ( } if (input.isAdmin) { - revalidatePath("/[locale]/admin", "layout"); + expireCachePath("/[locale]/admin", "layout"); await redirect("/admin/core"); return; } - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); await redirect("/"); }; diff --git a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts index 4c391645e..e07da7936 100644 --- a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts +++ b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts @@ -2,11 +2,10 @@ import type { z } from "zod"; -import { revalidatePath } from "next/cache"; - import type { zodSignUpSchema } from "@/api/modules/users/routes/sign-up.route"; import { usersModule } from "@/api/modules/users/users.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; import { redirect } from "@/lib/navigation"; @@ -31,7 +30,7 @@ export const mutationApi = async ({ const data = await res.json(); if (data.emailVerified) { - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); await redirect("/"); } diff --git a/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts b/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts index 1da17d8c2..517f9c0c6 100644 --- a/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts +++ b/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { usersModule } from "@/api/modules/users/users.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const mutationApi = async ({ @@ -38,5 +37,5 @@ export const mutationApi = async ({ return { error: "Something went wrong" }; } - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); }; diff --git a/packages/vitnode/src/views/error/global-error-view.tsx b/packages/vitnode/src/views/error/global-error-view.tsx index dea63c3a0..2c310bba1 100644 --- a/packages/vitnode/src/views/error/global-error-view.tsx +++ b/packages/vitnode/src/views/error/global-error-view.tsx @@ -3,14 +3,17 @@ import type { Metadata } from "next/dist/types"; import { HomeIcon, RefreshCwIcon } from "lucide-react"; -// eslint-disable-next-line no-restricted-imports -import Link from "next/link"; import { useTransition } from "react"; import { LogoVitNode } from "@/components/logo-vitnode"; import { ThemeProvider } from "@/components/theme-provider"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +// `global-error` renders above the root layout, so it renders above the i18n +// provider too - the locale-aware `Link` would throw looking for a context that +// does not exist this high up. The unlocalized one renders the href verbatim, +// and "/" is the only href on the page. +import { UnlocalizedLink as Link } from "@/framework/navigation"; import { cn } from "@/lib/utils"; export const metadata: Metadata = { diff --git a/packages/vitnode/src/views/files/actions/delete-action.server.ts b/packages/vitnode/src/views/files/actions/delete-action.server.ts index 2d81ea664..8e9d409dc 100644 --- a/packages/vitnode/src/views/files/actions/delete-action.server.ts +++ b/packages/vitnode/src/views/files/actions/delete-action.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { userFilesModule } from "@/api/modules/users/files/files.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; export const deleteMyFileAction = async ({ @@ -24,7 +23,7 @@ export const deleteMyFileAction = async ({ return { error: { status: res.status } }; } - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); return { data: true }; }; diff --git a/packages/vitnode/src/views/files/my-files-table-view.tsx b/packages/vitnode/src/views/files/my-files-table-view.tsx index 9e66478d7..2b222961e 100644 --- a/packages/vitnode/src/views/files/my-files-table-view.tsx +++ b/packages/vitnode/src/views/files/my-files-table-view.tsx @@ -1,6 +1,5 @@ import { FileIcon, FolderIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; import { userFilesModule } from "@/api/modules/users/files/files.module"; import { DateFormat } from "@/components/date-format"; @@ -10,6 +9,7 @@ import { DataTable, type SearchParamsDataTable, } from "@/components/table/data-table"; +import { notFound } from "@/framework/navigation"; import { fetcher } from "@/lib/fetcher"; import { formatBytes } from "@/lib/format-bytes"; diff --git a/packages/vitnode/src/views/layouts/theme/header/user/auth/log-out-mutation-api.server.ts b/packages/vitnode/src/views/layouts/theme/header/user/auth/log-out-mutation-api.server.ts index 99e6aaffa..961ed313f 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/auth/log-out-mutation-api.server.ts +++ b/packages/vitnode/src/views/layouts/theme/header/user/auth/log-out-mutation-api.server.ts @@ -1,8 +1,7 @@ "use server"; -import { revalidatePath } from "next/cache"; - import { usersModule } from "@/api/modules/users/users.module"; +import { expireCachePath } from "@/framework/cache"; import { fetcher } from "@/lib/fetcher"; import { redirect } from "@/lib/navigation"; @@ -23,12 +22,12 @@ export const logOutMutationApi = async ({ if (res.status === 200) { if (isAdmin) { - revalidatePath("/admin/(main)", "layout"); + expireCachePath("/admin/(main)", "layout"); await redirect("/admin"); return; } - revalidatePath("/[locale]/(main)", "layout"); + expireCachePath("/[locale]/(main)", "layout"); await redirect("/"); } }; diff --git a/packages/vitnode/src/views/search/fetch-feed.ts b/packages/vitnode/src/views/search/fetch-feed.ts index 330dabbc0..9af20f40a 100644 --- a/packages/vitnode/src/views/search/fetch-feed.ts +++ b/packages/vitnode/src/views/search/fetch-feed.ts @@ -1,8 +1,8 @@ import "server-only"; -import { cacheLife, cacheTag } from "next/cache"; -import { connection } from "next/server"; import { searchModule } from "@/api/modules/search/search.module"; +import { setCacheEntryLife, tagCacheEntry } from "@/framework/cache"; +import { awaitRequest } from "@/framework/request"; import { SEARCH_FEED_TAG } from "@/lib/cache-tags"; import { fetcher } from "@/lib/fetcher"; import { coreFetcher } from "@/lib/fetcher/core"; @@ -31,8 +31,8 @@ const FEED_PAGE_SIZE = "20"; */ const fetchCachedFeed = async (locale: string): Promise => { "use cache"; - cacheLife("minutes"); - cacheTag(SEARCH_FEED_TAG); + setCacheEntryLife("minutes"); + tagCacheEntry(SEARCH_FEED_TAG); const res = await coreFetcher(searchModule, { module: "search", @@ -81,7 +81,7 @@ export const fetchSearchFeed = async ({ return await fetchLiveFeed({ locale, search }); } - await connection(); + await awaitRequest(); return await fetchCachedFeed(locale); };