Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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;
};
Expand Down
41 changes: 41 additions & 0 deletions packages/vitnode/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
],
},
},
];
15 changes: 15 additions & 0 deletions packages/vitnode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose adapter setters without importing Next

When a TanStack or other non-Next host imports this public entry point to call setRequestAdapter, index.js eagerly imports the Next adapter, which loads server-only and next/* before the custom adapter can be installed; the adjacent cache entry point has the same problem. The framework-independent runtime.js files cannot be imported instead because they are not listed in exports, so Node rejects those subpaths. Export a framework-neutral registration entry point, or avoid eagerly installing the Next defaults from the only public modules.

Useful? React with 👍 / 👎.

},
"./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",
Expand Down
7 changes: 5 additions & 2 deletions packages/vitnode/src/components/table/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 5 additions & 2 deletions packages/vitnode/src/components/table/order-table-head.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
7 changes: 5 additions & 2 deletions packages/vitnode/src/components/table/pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions packages/vitnode/src/components/table/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 2 additions & 8 deletions packages/vitnode/src/content/admin/fetch.server.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -39,14 +39,8 @@ export const contentApiFetch = async <TSchema extends z.ZodType>({
query?: Record<string, string | string[] | undefined>;
schema?: TSchema;
}): Promise<ContentFetchResult<z.infer<TSchema>>> => {
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}`,
Expand Down
27 changes: 22 additions & 5 deletions packages/vitnode/src/content/boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
16 changes: 11 additions & 5 deletions packages/vitnode/src/content/cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CacheExpiryMode } from "../framework/cache/types";
import type { ContentLocalizationFallback } from "./types";

import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const";
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions packages/vitnode/src/content/next/cache-privacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
35 changes: 18 additions & 17 deletions packages/vitnode/src/content/next/redirect.server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
*
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading