From fa420b7e65b352591f9cf905a5a4c8aec715be78 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Thu, 2 Apr 2026 14:07:14 +0000 Subject: [PATCH 1/2] feat(fonts): self-hosted Google Fonts with fallback metrics (Next.js parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix import regex: [^;\n] prevents cross-line matching - Add destructure detection for default import pattern (const { Font } = googleFonts) - Remove isBuild gate — self-hosting runs in dev and build - Build manual Google Fonts URL to avoid URLSearchParams encoding issues - Copy woff2 to public/fonts/ for web serving - Generate hashed font-family names (__FontName_hash) - Generate fallback @font-face with size-adjust metrics via @capsizecss/metrics - Inject _hashedFamily, _fallbackCSS, _fallbackFamily into font loader calls - Runtime: handle hashed families with fallback in font-google-base.ts - Add font-metrics.ts from PR #158 for capsize metric calculation Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/preview-release.yml | 2 +- packages/vinext/README.md | 737 +++++++++++++++++- packages/vinext/package.json | 1 + packages/vinext/src/font-metrics.ts | 391 ++++++++++ packages/vinext/src/plugins/fonts.ts | 89 ++- packages/vinext/src/shims/font-google-base.ts | 43 +- packages/vinext/src/shims/navigation.ts | 8 +- pnpm-lock.yaml | 11 + pnpm-workspace.yaml | 1 + 9 files changed, 1247 insertions(+), 36 deletions(-) create mode 100644 packages/vinext/src/font-metrics.ts diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index e389de4cc..e08b1d3b3 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -11,7 +11,7 @@ permissions: {} jobs: publish: # Skip for forks — they don't have write access needed by pkg-pr-new. - if: github.repository_owner == 'cloudflare' + if: github.repository_owner == 'cloudflare' || github.repository_owner == 'streamloop' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/packages/vinext/README.md b/packages/vinext/README.md index 9be99ca48..a9d30a7bf 100644 --- a/packages/vinext/README.md +++ b/packages/vinext/README.md @@ -1,3 +1,738 @@ # vinext -This file is overwritten from the repo root README.md during `prepack`. Edit the root README.md instead. +The Next.js API surface, reimplemented on Vite. + +> **Read the announcement:** [How we rebuilt Next.js with AI in one week](https://blog.cloudflare.com/vinext/) + +> 🚧 **Experimental — under heavy development.** This project is an experiment in AI-driven software development. The vast majority of the code, tests, and documentation were written by AI (Claude Code). Humans direct architecture, priorities, and design decisions, but have not reviewed most of the code line-by-line. Treat this accordingly — there will be bugs, rough edges, and things that don't work. Use at your own risk. + +## Quick start + +vinext includes an [Agent Skill](https://agentskills.io/home) that handles migration for you. It works with Claude Code, OpenCode, Cursor, Codex, and dozens of other AI coding tools. Install it, open your Next.js project, and tell the AI to migrate: + +```sh +npx skills add cloudflare/vinext +``` + +Then open your Next.js project in any supported tool and say: + +``` +migrate this project to vinext +``` + +The skill handles compatibility checking, dependency installation, config generation, and dev server startup. It knows what vinext supports and will flag anything that needs manual attention. + +### Or do it manually + +```bash +npm install -D vinext vite @vitejs/plugin-react +``` + +If you're using the App Router, also install: + +```bash +npm install -D @vitejs/plugin-rsc react-server-dom-webpack +``` + +Replace `next` with `vinext` in your scripts: + +```json +{ + "scripts": { + "dev": "vinext dev", + "build": "vinext build", + "start": "vinext start" + } +} +``` + +```bash +vinext dev # Development server with HMR +vinext build # Production build +vinext deploy # Build and deploy to Cloudflare Workers +``` + +vinext auto-detects your `app/` or `pages/` directory, loads `next.config.js`, and configures Vite automatically. No `vite.config.ts` required for basic usage. + +Your existing `pages/`, `app/`, `next.config.js`, and `public/` directories work as-is. Run `vinext check` first to scan for known compatibility issues, or use `vinext init` to [automate the full migration](#migrating-an-existing-nextjs-project). + +### CLI reference + +| Command | Description | +| --------------- | ----------------------------------------------------------------------- | +| `vinext dev` | Start dev server with HMR | +| `vinext build` | Production build (multi-environment for App Router: RSC + SSR + client) | +| `vinext start` | Start local production server for testing | +| `vinext deploy` | Build and deploy to Cloudflare Workers | +| `vinext init` | Migrate a Next.js project to run under vinext | +| `vinext check` | Scan your Next.js app for compatibility issues before migrating | +| `vinext lint` | Delegate to eslint or oxlint | + +Options: `-p / --port `, `-H / --hostname `, `--turbopack` (accepted, no-op). + +`vinext deploy` options: `--preview`, `--env `, `--name `, `--skip-build`, `--dry-run`, `--experimental-tpr`. + +`vinext init` options: `--port ` (default: 3001), `--skip-check`, `--force`. + +If your `next.config.*` sets `output: "standalone"`, `vinext build` emits a self-hosting bundle at `dist/standalone/`. Start it with: + +```bash +node dist/standalone/server.js +``` + +Environment variables: `PORT` (default `3000`), `HOST` (default `0.0.0.0`). + +> **Note:** Next.js standalone uses `HOSTNAME` for the bind address, but vinext uses `HOST` to avoid collision with the system-set `HOSTNAME` variable on Linux. Update your deployment config accordingly. + +### Starting a new vinext project + +Run `npm create next-app@latest` to create a new Next.js project, and then follow these instructions to migrate it to vinext. + +In the future, we will have a proper `npm create vinext` new project workflow. + +### Migrating an existing Next.js project + +`vinext init` automates the migration in one command: + +```bash +npx vinext init +``` + +This will: + +1. Run `vinext check` to scan for compatibility issues +2. Install `vite`, `@vitejs/plugin-react`, and App Router-only deps (`@vitejs/plugin-rsc`, `react-server-dom-webpack`) as devDependencies +3. Rename CJS config files (e.g. `postcss.config.js` -> `.cjs`) to avoid ESM conflicts +4. Add `"type": "module"` to `package.json` +5. Add `dev:vinext`, `build:vinext`, and `start:vinext` scripts to `package.json` +6. Generate a minimal `vite.config.ts` + +The migration is non-destructive -- your existing Next.js setup continues to work alongside vinext. It does not modify `next.config`, `tsconfig.json`, or any source files, and it does not remove Next.js dependencies. + +vinext targets Vite 8, which defaults to Rolldown, Oxc, Lightning CSS, and a newer browser baseline. If you bring custom Vite config or plugins from an older setup, prefer `oxc`, `optimizeDeps.rolldownOptions`, and `build.rolldownOptions` over older `esbuild` and `build.rollupOptions` knobs, and override `build.target` if you still need older browsers. If a dependency breaks because of stricter CommonJS default import handling, fix the import or use `legacy.inconsistentCjsInterop: true` as a temporary escape hatch. See the [Vite 8 migration guide](https://vite.dev/guide/migration). + +```bash +npm run dev:vinext # Start the vinext dev server (port 3001) +npm run build:vinext # Build production output with vinext +npm run start:vinext # Start vinext production server +npm run dev # Still runs Next.js as before +``` + +Use `--force` to overwrite an existing `vite.config.ts`, or `--skip-check` to skip the compatibility report. + +## Why + +Vite has become the default build tool for modern web frameworks — fast HMR, a clean plugin API, native ESM, and a growing ecosystem. With [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) adding React Server Components support, it's now possible to build a full RSC framework on Vite. + +vinext is an experiment: can we reimplement the Next.js API surface on Vite, so that existing Next.js applications can run on a completely different toolchain? The answer, so far, is mostly yes — about 94% of the API surface works. + +vinext works everywhere. It natively supports Cloudflare Workers (with `vinext deploy`, bindings, KV caching), and can be deployed to Vercel, Netlify, AWS, Deno Deploy, and more via the [Nitro](https://v3.nitro.build/) Vite plugin. Native support for additional platforms is [planned](https://github.com/cloudflare/vinext/issues/80). + +**Alternatives worth knowing about:** + +- **[OpenNext](https://opennext.js.org/)** — adapts `next build` output for AWS, Cloudflare, and other platforms. OpenNext has been around much longer than vinext, is more mature, and covers more of the Next.js API surface because it builds on top of Next.js's own output rather than reimplementing it. If you want the safer, more proven option, start there. +- **[Next.js self-hosting](https://nextjs.org/docs/app/building-your-application/deploying#self-hosting)** — Next.js can be deployed to any Node.js server, Docker container, or as a static export. + +### Design principles + +- **Deploy anywhere.** Natively supports Cloudflare Workers, with other platforms available via Nitro. Native adapters for more platforms are [planned](https://github.com/cloudflare/vinext/issues/80). +- **Pragmatic compatibility, not bug-for-bug parity.** Targets 95%+ of real-world Next.js apps. Edge cases that depend on undocumented Vercel behavior are intentionally not supported. +- **Latest Next.js only.** Targets Next.js 16.x. No support for deprecated APIs from older versions. +- **Incremental adoption.** Drop in the plugin, fix what breaks, deploy. + +## FAQ + +**What is this?** +vinext is a Vite plugin that reimplements the public Next.js API — routing, server rendering, `next/*` module imports, the CLI — so you can run Next.js applications on Vite instead of the Next.js compiler toolchain. It can be deployed anywhere: Cloudflare Workers is the first natively supported target, with other platforms available via Nitro. Native adapters for more platforms are [planned](https://github.com/cloudflare/vinext/issues/80). + +**Is this a fork of Next.js?** +No. vinext is an alternative implementation of the Next.js API surface built on Vite. It does import some Next.js types and utilities, but the core is written from scratch. The goal is not to create a competing framework or add features beyond what Next.js offers — it's an experiment in how far AI-driven development and Vite's toolchain can go in replicating an existing, well-defined API surface. + +**How is this different from OpenNext?** +[OpenNext](https://opennext.js.org/) adapts the _output_ of a standard `next build` to run on various platforms. Because it builds on Next.js's own output, it inherits broad API coverage and has been well-tested for much longer. vinext takes a different approach: it reimplements the Next.js APIs on Vite from scratch, which means faster builds and smaller bundles, but less coverage of the long tail of Next.js features. If you need a mature, well-tested way to run Next.js outside Vercel, OpenNext is the safer choice. If you're interested in experimenting with a lighter toolchain and don't need every Next.js API, vinext might be worth a look. + +**Can I use this in production?** +You can, with caution. This is experimental software with known bugs. It works well enough for demos and exploration, but it hasn't been battle-tested with real production traffic. + +**Can I just self-host Next.js?** +Yes. Next.js supports [self-hosting](https://nextjs.org/docs/app/building-your-application/deploying#self-hosting) on Node.js servers, Docker containers, and static exports. If you're happy with the Next.js toolchain and just want to run it somewhere other than Vercel, self-hosting is the simplest path. + +**How are you verifying this works?** +The test suite has over 1,700 Vitest tests and 380 Playwright E2E tests. This includes tests ported directly from the [Next.js test suite](https://github.com/vercel/next.js/tree/canary/test) and [OpenNext's Cloudflare conformance suite](https://github.com/opennextjs/opennextjs-cloudflare), covering routing, SSR, RSC, server actions, caching, metadata, middleware, streaming, and more. Vercel's [App Router Playground](https://github.com/vercel/next-app-router-playground) also runs on vinext as an integration test. See the [Tests](#tests) section and `tests/nextjs-compat/TRACKING.md` for details. + +**Who is reviewing this code?** +Mostly nobody. This is an experiment in seeing how far AI-driven development can go. The test suite is the primary quality gate — not human code review. Contributions and code review are welcome. + +**Why Vite?** +Vite is an excellent build tool with a rich plugin ecosystem, first-class ESM support, and fast HMR. The [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) plugin adds React Server Components support with multi-environment builds. This project is an experiment to see how much of the Next.js developer experience can be replicated on top of Vite's infrastructure. + +**Does this support the Pages Router, App Router, or both?** +Both. File-system routing, SSR, client hydration, and deployment to Cloudflare Workers work for both routers. + +**What version of Next.js does this target?** +Next.js 16.x. No support for deprecated APIs from older versions. + +**Can I deploy to AWS/Netlify/other platforms?** +Yes. Add the [Nitro](https://v3.nitro.build/) Vite plugin alongside vinext, and you can deploy to Vercel, Netlify, AWS Amplify, Deno Deploy, Azure, and [many more](https://v3.nitro.build/deploy). See [Other platforms (via Nitro)](#other-platforms-via-nitro) for setup. For Cloudflare Workers, the native integration (`vinext deploy`) gives you the smoothest experience. Native adapters for more platforms are [planned](https://github.com/cloudflare/vinext/issues/80). + +**What happens when Next.js releases a new feature?** +We track the public Next.js API surface and add support for new stable features. Experimental or unstable Next.js features are lower priority. The plan is to add commit-level tracking of the Next.js repo so we can stay current as new versions are released. + +## Deployment + +### Cloudflare Workers + +vinext has native integration with Cloudflare Workers through `@cloudflare/vite-plugin`, including bindings access via `cloudflare:workers`, KV caching, image optimization, and the `vinext deploy` one-command workflow. + +#### Prerequisites + +Before running `vinext deploy` for the first time you need to authenticate with Cloudflare and tell wrangler which account to deploy to. + +**Authentication — pick one:** + +- **`wrangler login`** (recommended for local development) — opens a browser window to authenticate. Run it once and wrangler caches the token. +- **`CLOUDFLARE_API_TOKEN` env var** (CI / non-interactive) — create a token at [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) using the **Edit Cloudflare Workers** template. That template grants all the permissions `vinext deploy` needs. + +**Account ID:** + +wrangler needs to know which Cloudflare account to deploy to. Add your account ID to `wrangler.jsonc`: + +```jsonc +{ + "account_id": "", + ... +} +``` + +Find your account ID in the Cloudflare dashboard URL (`dash.cloudflare.com/`) or by running `wrangler whoami` after logging in. + +Alternatively, set the `CLOUDFLARE_ACCOUNT_ID` environment variable instead of hardcoding it in the config file. + +`vinext deploy` auto-generates the necessary configuration files (`vite.config.ts`, `wrangler.jsonc`, `worker/index.ts`) if they don't exist, builds the application, and deploys to Workers. + +```bash +vinext deploy +vinext deploy --env staging +``` + +Use `--env ` to target `wrangler.jsonc` `env.`. `--preview` is shorthand for `--env preview`. + +The deploy command also auto-detects and fixes common migration issues: + +- Adds `"type": "module"` to package.json if missing +- Resolves tsconfig.json path aliases automatically (via `vite-tsconfig-paths`) +- Detects MDX usage and configures `@mdx-js/rollup` +- Renames CJS config files (postcss.config.js, etc.) to `.cjs` when needed +- Detects native Node.js modules (sharp, resvg, satori, lightningcss, @napi-rs/canvas) and auto-stubs them for Workers. If you encounter others that need stubbing, PRs are welcome. + +Both App Router and Pages Router work on Workers with full client-side hydration. + +#### Cloudflare Bindings (D1, R2, KV, AI, etc.) + +Use `import { env } from "cloudflare:workers"` to access bindings in any server component, route handler, or server action. No custom worker entry or special configuration required. + +```tsx +import { env } from "cloudflare:workers"; + +export default async function Page() { + const result = await env.DB.prepare("SELECT * FROM posts").all(); + return
{JSON.stringify(result)}
; +} +``` + +This works because `@cloudflare/vite-plugin` runs the RSC environment in workerd, where `cloudflare:workers` is a native module. In production builds, the import is externalized so workerd resolves it at runtime. All binding types are supported: D1, R2, KV, Durable Objects, AI, Queues, Vectorize, Browser Rendering, etc. + +Define your bindings in `wrangler.jsonc` as usual: + +```jsonc +{ + "name": "my-app", + "compatibility_date": "2026-02-12", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [{ "binding": "DB", "database_name": "my-db", "database_id": "..." }], + "kv_namespaces": [{ "binding": "CACHE", "id": "..." }], +} +``` + +For TypeScript types, generate them with `wrangler types` and the `env` import will be fully typed. + +> **Note:** You do not need `getPlatformProxy()`, a custom worker entry with `fetch(request, env)`, or any other workaround. `cloudflare:workers` is the recommended way to access bindings in vinext. + +#### Traffic-aware Pre-Rendering (experimental) + +TPR queries Cloudflare zone analytics at deploy time to find which pages actually get traffic, pre-renders only those, and uploads them to KV cache. The result is SSG-level latency for popular pages without pre-rendering your entire site. + +```bash +vinext deploy --experimental-tpr # Pre-render pages covering 90% of traffic +vinext deploy --experimental-tpr --tpr-coverage 95 # More aggressive coverage +vinext deploy --experimental-tpr --tpr-limit 500 # Cap at 500 pages +vinext deploy --experimental-tpr --tpr-window 48 # Use 48h of analytics +``` + +Requires a custom domain (zone analytics are unavailable on `*.workers.dev`) and `CLOUDFLARE_API_TOKEN` with Zone.Analytics read permission. + +For production caching (ISR), use the built-in Cloudflare KV cache handler: + +```ts +import { KVCacheHandler } from "vinext/cloudflare"; +import { setCacheHandler } from "next/cache"; + +setCacheHandler(new KVCacheHandler(env.MY_KV_NAMESPACE)); +``` + +#### Custom Vite configuration + +If you need to customize the Vite config, create a `vite.config.ts`. vinext will merge its config with yours. This is required for Cloudflare Workers deployment with the App Router (RSC needs explicit plugin configuration): + +```ts +import { defineConfig } from "vite"; +import vinext from "vinext"; +import rsc from "@vitejs/plugin-rsc"; +import { cloudflare } from "@cloudflare/vite-plugin"; + +export default defineConfig({ + plugins: [ + vinext(), + rsc({ + entries: { + rsc: "virtual:vinext-rsc-entry", + ssr: "virtual:vinext-app-ssr-entry", + client: "virtual:vinext-app-browser-entry", + }, + }), + cloudflare({ + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + }), + ], +}); +``` + +See the [examples](#live-examples) for complete working configurations. + +### Other platforms (via Nitro) + +For deploying to platforms other than Cloudflare, vinext works with [Nitro](https://v3.nitro.build/) as a Vite plugin. Add `nitro` alongside `vinext` in your Vite config and deploy to any [Nitro-supported platform](https://v3.nitro.build/deploy). + +```ts +import { defineConfig } from "vite"; +import vinext from "vinext"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ + plugins: [vinext(), nitro()], +}); +``` + +```bash +npm install nitro +``` + +Nitro auto-detects the deployment platform in most CI/CD environments (Vercel, Netlify, AWS Amplify, Azure, and others), so you typically don't need to set a preset. For local builds, set the `NITRO_PRESET` environment variable: + +```bash +NITRO_PRESET=vercel npx vite build +NITRO_PRESET=netlify npx vite build +NITRO_PRESET=deno_deploy npx vite build +``` + +> **Deploying to Cloudflare?** You can use Nitro, but the native integration (`vinext deploy` / `@cloudflare/vite-plugin`) is recommended. It provides the best developer experience with `cloudflare:workers` bindings, KV caching, image optimization, and one-command deploys. + +
+Vercel + +Nitro auto-detects Vercel in CI. For local builds: + +```bash +NITRO_PRESET=vercel npx vite build +``` + +Deploy with the [Vercel CLI](https://vercel.com/docs/cli) or connect your Git repo in the Vercel dashboard. Set the build command to `vite build` and the output directory to `.output`. + +
+ +
+Netlify + +Nitro auto-detects Netlify in CI. For local builds: + +```bash +NITRO_PRESET=netlify npx vite build +``` + +Deploy with the [Netlify CLI](https://docs.netlify.com/cli/get-started/) or connect your Git repo. Set the build command to `vite build`. + +
+ +
+AWS (Amplify) + +Nitro auto-detects AWS Amplify in CI. For local builds: + +```bash +NITRO_PRESET=aws_amplify npx vite build +``` + +Connect your Git repo in the AWS Amplify console. Set the build command to `vite build`. + +
+ +
+Deno Deploy + +```bash +NITRO_PRESET=deno_deploy npx vite build +cd .output +deployctl deploy --project=my-project server/index.ts +``` + +
+ +
+Node.js server + +```bash +NITRO_PRESET=node npx vite build +node .output/server/index.mjs +``` + +This produces a standalone Node.js server. Suitable for Docker, VMs, or any environment that can run Node. + +
+ +See the [Nitro deployment docs](https://v3.nitro.build/deploy) for the full list of supported platforms and provider-specific configuration. + +## Live examples + +These are deployed to Cloudflare Workers and updated on every push to `main`: + +| Example | Description | URL | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| App Router Playground | [Vercel's Next.js App Router Playground](https://github.com/vercel/next-app-router-playground) running on vinext | [app-router-playground.vinext.workers.dev](https://app-router-playground.vinext.workers.dev) | +| Hacker News | HN clone (App Router, RSC) | [hackernews.vinext.workers.dev](https://hackernews.vinext.workers.dev) | +| Nextra Docs | Nextra docs site (MDX, App Router) | [nextra-docs-template.vinext.workers.dev](https://nextra-docs-template.vinext.workers.dev) | +| App Router (minimal) | Minimal App Router on Workers | [app-router-cloudflare.vinext.workers.dev](https://app-router-cloudflare.vinext.workers.dev) | +| Pages Router (minimal) | Minimal Pages Router on Workers | [pages-router-cloudflare.vinext.workers.dev](https://pages-router-cloudflare.vinext.workers.dev) | +| RealWorld API | REST API routes example | [realworld-api-rest.vinext.workers.dev](https://realworld-api-rest.vinext.workers.dev) | +| Benchmarks Dashboard | Build performance tracking over time (D1-backed) | [benchmarks.vinext.workers.dev](https://benchmarks.vinext.workers.dev) | +| App Router + Nitro | App Router deployed via Nitro (multi-platform) | [examples/app-router-nitro](examples/app-router-nitro) | + +## API coverage + +~94% of the Next.js 16 API surface has full or partial support. The remaining gaps are intentional stubs for deprecated features and Partial Prerendering (which Next.js 16 reworked into `"use cache"` — that directive is fully supported). + +> ✅ = full implementation | 🟡 = partial (runtime behavior correct, some build-time optimizations missing) | ⬜ = intentional stub/no-op + +### Module shims + +Every `next/*` import is shimmed to a Vite-compatible implementation. + +| Module | | Notes | +| ------------------- | --- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `next/link` | ✅ | All props including `prefetch` (IntersectionObserver), `onNavigate`, scroll restoration, `basePath`, `locale` | +| `next/image` | 🟡 | Remote images via [@unpic/react](https://unpic.pics) (28 CDNs). Local images via `` + srcSet. No build-time optimization/resizing | +| `next/head` | ✅ | SSR collection + client-side DOM manipulation | +| `next/router` | ✅ | `useRouter`, `Router` singleton, events, client-side navigation, SSR context, i18n | +| `next/navigation` | ✅ | `usePathname`, `useSearchParams`, `useParams`, `useRouter`, `redirect`, `notFound`, `forbidden`, `unauthorized` | +| `next/server` | ✅ | `NextRequest`, `NextResponse`, `NextURL`, cookies, `userAgent`, `after`, `connection`, `URLPattern` | +| `next/headers` | ✅ | Async `headers()`, `cookies()`, `draftMode()` | +| `next/dynamic` | ✅ | `ssr: true`, `ssr: false`, `loading` component | +| `next/script` | ✅ | All 4 strategies (`beforeInteractive`, `afterInteractive`, `lazyOnload`, `worker`) | +| `next/font/google` | 🟡 | Runtime CDN loading. No self-hosting, font subsetting, or fallback metrics | +| `next/font/local` | 🟡 | Runtime `@font-face` injection. Not extracted at build time | +| `next/og` | ✅ | OG image generation via `@vercel/og` (Satori + resvg) | +| `next/cache` | ✅ | `revalidateTag`, `revalidatePath`, `unstable_cache`, pluggable `CacheHandler`, `"use cache"` with `cacheLife()` and `cacheTag()` | +| `next/form` | ✅ | GET form interception + POST server action delegation | +| `next/legacy/image` | ✅ | Translates legacy props to modern Image | +| `next/error` | ✅ | Default error page component | +| `next/config` | ✅ | `getConfig` / `setConfig` | +| `next/document` | ✅ | `Html`, `Head`, `Main`, `NextScript` | +| `next/constants` | ✅ | All phase constants | +| `next/amp` | ⬜ | No-op (AMP is deprecated) | +| `next/web-vitals` | ⬜ | No-op (use the `web-vitals` library directly) | + +### Routing + +| Feature | | Notes | +| -------------------------------- | --- | ------------------------------------------------------------------------------------------------------------------ | +| File-system routing (`pages/`) | ✅ | Automatic scanning with hot-reload on file changes | +| File-system routing (`app/`) | ✅ | Pages, routes, layouts, templates, loading, error, not-found, forbidden, unauthorized | +| Dynamic routes `[param]` | ✅ | Both routers | +| Catch-all `[...slug]` | ✅ | Both routers | +| Optional catch-all `[[...slug]]` | ✅ | Both routers | +| Route groups `(group)` | ✅ | URL-transparent, layouts still apply | +| Parallel routes `@slot` | ✅ | Discovery, layout props, `default.tsx`, inherited slots | +| Intercepting routes | ✅ | `(.)`, `(..)`, `(..)(..)`, `(...)` conventions | +| Route handlers (`route.ts`) | ✅ | Named HTTP methods, auto OPTIONS/HEAD, cookie attachment | +| Middleware | ✅ | `middleware.ts` and `proxy.ts` (Next.js 16). Matcher patterns (string, array, regex, `:param`, `:path*`, `:path+`) | +| i18n routing | 🟡 | Pages Router locale prefix, Accept-Language detection, NEXT_LOCALE cookie. No domain-based routing | +| `basePath` | ✅ | Applied everywhere — URLs, Link, Router, navigation hooks | +| `trailingSlash` | ✅ | 308 redirects to canonical form | + +### Server features + +| Feature | | Notes | +| ------------------------------------------ | --- | ------------------------------------------------------------------------------------------- | +| SSR (Pages Router) | ✅ | Streaming, `_app`/`_document`, `__NEXT_DATA__`, hydration | +| SSR (App Router) | ✅ | RSC pipeline, nested layouts, streaming, nav context for client components | +| `getStaticProps` | ✅ | Props, redirect, notFound, revalidate | +| `getStaticPaths` | ✅ | `fallback: false`, `true`, `"blocking"` | +| `getServerSideProps` | ✅ | Full context including locale | +| ISR | ✅ | Stale-while-revalidate, pluggable `CacheHandler`, background regeneration | +| Server Actions (`"use server"`) | ✅ | Action execution, FormData, re-render after mutation, `redirect()` in actions | +| React Server Components | ✅ | Via `@vitejs/plugin-rsc`. `"use client"` boundaries work correctly | +| Streaming SSR | ✅ | Both routers | +| Metadata API | ✅ | `metadata`, `generateMetadata`, `viewport`, `generateViewport`, title templates | +| `generateStaticParams` | ✅ | With `dynamicParams` enforcement | +| Metadata file routes | ✅ | sitemap.xml, robots.txt, manifest, favicon, OG images (static + dynamic) | +| Static export (`output: 'export'`) | ✅ | Generates static HTML/JSON for all routes | +| Standalone output (`output: 'standalone'`) | ✅ | Generates `dist/standalone` with `server.js`, build artifacts, and runtime deps | +| `connection()` | ✅ | Forces dynamic rendering | +| `"use cache"` directive | ✅ | File-level and function-level. `cacheLife()` profiles, `cacheTag()`, stale-while-revalidate | +| `instrumentation.ts` | ✅ | `register()` and `onRequestError()` callbacks | +| Route segment config | 🟡 | `revalidate`, `dynamic`, `dynamicParams`. `runtime` and `preferredRegion` are ignored | + +### Configuration + +| Feature | | Notes | +| ------------------------------------------------ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `next.config.js` / `.ts` / `.mjs` | ✅ | Function configs, phase argument | +| `rewrites` / `redirects` / `headers` | ✅ | All phases, param interpolation | +| Environment variables (`.env*`, `NEXT_PUBLIC_*`) | ✅ | Auto-loads Next.js-style dotenv files; only public vars are inlined | +| `images` config | 🟡 | Parsed but not used for optimization | +| `experimental.optimizePackageImports` | ✅ | Rewrites barrel imports to direct sub-module imports in RSC/SSR environments. A default set (lucide-react, date-fns, radix-ui, antd, MUI, and others) are always optimized. Add package names here to extend the list. | +| `vinext({ nextConfig })` | ✅ | Inline Next-style config from `vite.config.*`. Supports object-form and function-form config. When provided, this overrides root `next.config.*`. | + +### Environment variable loading (`.env*`) + +vinext automatically loads dotenv files for `dev`, `build`, `start`, and `deploy`. + +Load order matches Next.js (highest priority first): + +1. Existing `process.env` values (shell/CI) +2. `.env..local` +3. `.env.local` (skipped when mode is `test`) +4. `.env.` +5. `.env` + +Modes: + +- `vinext dev` uses `development` +- `vinext build`, `vinext start`, and `vinext deploy` use `production` + +Variable expansion (`$VAR` / `${VAR}`) is supported. + +Client exposure remains explicit: + +- `NEXT_PUBLIC_*` variables are inlined for browser usage +- `next.config.js` `env` entries are also inlined +- Other env vars stay server-only unless you explicitly expose them through Vite (for example `VITE_*` + `import.meta.env`) + +Override behavior: + +- To override any `.env*` value, set it in your shell/CI environment before running vinext. Existing `process.env` always wins. + +### Caching + +The cache is pluggable. The default `MemoryCacheHandler` works out of the box. Swap in your own backend for production: + +```ts +import { setCacheHandler } from "next/cache"; +setCacheHandler(new MyCacheHandler()); // Redis, DynamoDB, etc. +``` + +The `CacheHandler` interface matches Next.js 16's shape, so community adapters should be compatible. + +## What's NOT supported (and won't be) + +These are intentional exclusions: + +- **Vercel-specific features** — `@vercel/og` edge runtime, Vercel Analytics integration, Vercel KV/Blob/Postgres bindings. Use platform equivalents. +- **AMP** — Deprecated since Next.js 13. `useAmp()` returns `false`. +- **`next export` (legacy)** — Use `output: 'export'` in config instead. +- **Turbopack/webpack configuration** — This runs on Vite. Use Vite plugins instead of webpack loaders/plugins. +- **`next/jest`** — Use Vitest. +- **`create-next-app` scaffolding** — Not a goal. +- **Bug-for-bug parity with undocumented behavior** — If it's not in the Next.js docs, we probably don't replicate it. + +## Known limitations + +- **Image optimization doesn't happen at build time.** Remote images work via `@unpic/react` (auto-detects 28 CDN providers). Local images are routed through a `/_vinext/image` endpoint that can resize and transcode on Cloudflare Workers (via the Images binding) in production, but no build-time optimization or static resizing occurs. +- **Google Fonts are loaded from the CDN, not self-hosted.** No `size-adjust` fallback font metrics. Local fonts work but `@font-face` CSS is injected at runtime, not extracted at build time. +- **`useSelectedLayoutSegment(s)`** derives segments from the pathname rather than being truly layout-aware. May differ from Next.js in edge cases with parallel routes. +- **Route segment config** — `runtime` and `preferredRegion` are ignored (everything runs in the same environment). +- **Node.js production server (`vinext start`)** works for testing but is less complete than Workers deployment. Cloudflare Workers is the primary target. +- **Native Node modules (sharp, resvg, satori, lightningcss, @napi-rs/canvas)** crash Vite's RSC dev environment. Dynamic OG image/icon routes using these work in production builds but not in dev mode. These are auto-stubbed during `vinext deploy`. + +## Benchmarks + +> **Caveat:** Benchmarks are hard to get right and these are early results. Take them as directional, not definitive. + +These benchmarks measure **compilation and bundling speed**, not production serving performance. Next.js and vinext have fundamentally different default approaches: Next.js statically pre-renders pages at build time (making builds slower but production serving faster for static content), while vinext server-renders all pages on each request. To make the comparison apples-to-apples, the benchmark app uses `export const dynamic = "force-dynamic"` to disable Next.js static pre-rendering — both frameworks are doing the same work: compiling, bundling, and preparing server-rendered routes. + +The benchmark app is a shared 33-route App Router application (server components, client components, dynamic routes, nested layouts, API routes) built identically by both tools. We compare Next.js (Turbopack) against vinext (Vite 8). Both Turbopack and Rolldown parallelize across cores, so results on machines with more cores may differ significantly. + +We measure three things: + +- **Production build time** — 5 runs, timed with `hyperfine`. +- **Client bundle size** — gzipped output of each build. +- **Dev server cold start** — 10 runs, randomized execution order. Vite's dependency optimizer cache is cleared before each run. + +Benchmarks run on GitHub CI runners (2-core Ubuntu) on every merge to `main`. See the launch numbers in the [announcement blog post](https://blog.cloudflare.com/vinext/) and the latest results at **[benchmarks.vinext.workers.dev](https://benchmarks.vinext.workers.dev)**. + +
+Why the bundle size difference? + +Analysis of the build output shows two main factors: + +1. **Tree-shaking**: Vite/Rollup produces a smaller React+ReactDOM bundle than Next.js/Turbopack. Rollup's more aggressive dead-code elimination accounts for roughly half the overall difference. +2. **Framework overhead**: Next.js ships more client-side infrastructure (router, Turbopack runtime loader, prefetching, error handling) than vinext's lighter client runtime. + +Both frameworks ship the same app code and the same RSC client runtime (`react-server-dom-webpack`). The difference is in how much of React's internals survive tree-shaking and how much framework plumbing each tool adds. + +
+ +Reproduce with `node benchmarks/run.mjs --runs=5 --dev-runs=10`. Exact framework versions are recorded in each result. + +## Architecture + +vinext is a Vite plugin that: + +1. **Resolves all `next/*` imports** to local shim modules that reimplement the Next.js API using standard Web APIs and React primitives. +2. **Scans your `pages/` and `app/` directories** to build a file-system router matching Next.js conventions. +3. **Generates virtual entry modules** for the RSC, SSR, and browser environments that handle request routing, component rendering, and client hydration. +4. **Integrates with `@vitejs/plugin-rsc`** for React Server Components — handling `"use client"` / `"use server"` directives, RSC stream serialization, and multi-environment builds. + +The result is a standard Vite application that happens to be API-compatible with Next.js. + +### Pages Router flow + +``` +Request → Vite dev server middleware → Route match → getServerSideProps/getStaticProps + → renderToReadableStream(App + Page) → HTML with __NEXT_DATA__ → Client hydration +``` + +### App Router flow + +``` +Request → RSC entry (Vite rsc environment) → Route match → Build layout/page tree + → renderToReadableStream (RSC payload) → SSR entry (Vite ssr environment) + → renderToReadableStream (HTML) → Client hydration from RSC stream +``` + +## Project structure + +``` +packages/vinext/ + src/ + index.ts # Main plugin — resolve aliases, config, virtual modules + cli.ts # vinext CLI (dev/build/start/deploy/init/check/lint) + check.ts # Compatibility scanner + deploy.ts # Cloudflare Workers deployment + init.ts # vinext init — one-command migration for Next.js apps + client/ + entry.ts # Client-side hydration entry + routing/ + pages-router.ts # Pages Router file-system scanner + app-router.ts # App Router file-system scanner + entries/ + app-rsc-entry.ts # App Router RSC entry generator + app-ssr-entry.ts # App Router SSR entry generator + app-browser-entry.ts # App Router browser entry generator + pages-server-entry.ts # Pages Router SSR entry generator + pages-client-entry.ts # Pages Router client entry generator + server/ + dev-server.ts # Pages Router SSR request handler + prod-server.ts # Production server with compression + api-handler.ts # Pages Router API routes + isr-cache.ts # ISR cache layer + middleware.ts # middleware.ts / proxy.ts runner + metadata-routes.ts # File-based metadata route scanner + instrumentation.ts # instrumentation.ts support + cloudflare/ + kv-cache-handler.ts # Cloudflare KV-backed CacheHandler for ISR + shims/ # One file per next/* module (33 shims + 6 internal) + build/ + static-export.ts # output: 'export' support + utils/ + project.ts # Shared project utilities (ESM, CJS, package manager detection) + config/ + next-config.ts # next.config.js loader + config-matchers.ts # Config matching utilities + +tests/ + *.test.ts # Vitest unit + integration tests + nextjs-compat/ # Tests ported from Next.js test suite + fixtures/ # Test apps (pages-basic, app-basic, ecosystem libs) + e2e/ # Playwright E2E tests (5 projects) + +examples/ # Deployed demo apps (see Live Examples above) +``` + +## Tests + +```bash +pnpm test # Vitest unit + integration tests +pnpm run test:e2e # Playwright E2E tests (5 projects) +pnpm run check # Format, lint, and type checks +pnpm run lint # Lint only (type-aware oxlint) +pnpm run fmt # Formatting (oxfmt) +pnpm run fmt:check # Check formatting without writing +``` + +E2E tests cover Pages Router (dev + production), App Router (dev), and both routers on Cloudflare Workers via `wrangler dev`. + +The [Vercel App Router Playground](https://github.com/vercel/next-app-router-playground) runs on vinext as an integration test — see it live at [app-router-playground.vinext.workers.dev](https://app-router-playground.vinext.workers.dev). + +## Local setup (from source) + +If you're working from the repo instead of installing from npm: + +```bash +git clone https://github.com/cloudflare/vinext.git +cd vinext +pnpm install +pnpm run build +``` + +This builds the vinext package to `packages/vinext/dist/` via `vp pack`. For active development, use `pnpm --filter vinext run dev` to run `vp pack --watch`. + +To use it against an external Next.js app, link the built package: + +```bash +# From your Next.js project directory: +pnpm link /path/to/vinext/packages/vinext +``` + +Or add it to your `package.json` as a file dependency: + +```json +{ + "dependencies": { + "vinext": "file:/path/to/vinext/packages/vinext" + } +} +``` + +vinext has peer dependencies on `react ^19.2.4`, `react-dom ^19.2.4`, and `vite ^7.0.0 || ^8.0.0`. Then replace `next` with `vinext` in your scripts and run as normal. + +## Contributing + +This project is experimental and under active development. Issues and PRs are welcome. + +### CI + +When you open a PR, CI (check, Vitest, Playwright E2E) runs automatically. First-time contributors need one manual approval from a maintainer, then subsequent PRs run without intervention. + +Deploy previews (building and deploying examples to Cloudflare Workers) only run for branches pushed to the main repo. If you're a Cloudflare employee, push your branch to the main repo instead of forking, and previews deploy automatically. For fork PRs, a maintainer can comment `/deploy-preview` to trigger the deploy and post preview URLs. + +### Reporting bugs + +If something doesn't work with your Next.js app, please file an issue — we want to hear about it. + +Before you do, try pointing an AI agent at the problem. Open your project with Claude Code, Cursor, OpenCode, or whatever you use, and ask it to figure out why your app isn't working with vinext. In our experience, agents are very good at tracing through the vinext source, identifying the gap or bug, and often producing a fix or at least a clear diagnosis. An issue that includes "here's what the agent found" is significantly more actionable than "it doesn't work." + +Even a partial diagnosis helps — stack traces, which `next/*` import is involved, whether it's a dev or production build issue, App Router vs Pages Router. The more context, the faster we can fix it. + +## License + +MIT diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 192ab8a01..0fc46b654 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -60,6 +60,7 @@ "dev": "vp pack --watch" }, "dependencies": { + "@capsizecss/metrics": "catalog:", "@unpic/react": "catalog:", "@vercel/og": "catalog:", "magic-string": "catalog:", diff --git a/packages/vinext/src/font-metrics.ts b/packages/vinext/src/font-metrics.ts new file mode 100644 index 000000000..d40c62c89 --- /dev/null +++ b/packages/vinext/src/font-metrics.ts @@ -0,0 +1,391 @@ +/** + * Font fallback metrics and hashing utilities. + * + * Generates adjusted fallback @font-face declarations to reduce CLS (Cumulative + * Layout Shift) during font loading. Uses the same algorithm as Next.js: + * - Google fonts: precalculated metrics from @capsizecss/metrics + * - Local fonts: metrics extracted from font files via fontkitten + * + * Also provides font-family name hashing for class name scoping, matching + * Next.js's `__FontFamily_` pattern. + * + * Build-time only — never imported at runtime or bundled into production output. + */ + +import { createHash } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FontMetrics { + ascent: number; + descent: number; + lineGap: number; + unitsPerEm: number; + xWidthAvg: number; + category?: "serif" | "sans-serif" | "monospace" | "display" | "handwriting"; +} + +export interface FallbackFontResult { + /** The generated @font-face CSS for the fallback font */ + css: string; + /** The fallback font-family name (e.g. "__Inter_a3b2c1 Fallback") */ + fallbackFamily: string; + /** The system font used as the base (e.g. "Arial") */ + fallbackFont: string; +} + +// --------------------------------------------------------------------------- +// System font metrics — loaded from @capsizecss/metrics at first use +// --------------------------------------------------------------------------- + +let _arialMetrics: FontMetrics | undefined; +let _timesNewRomanMetrics: FontMetrics | undefined; + +async function getSystemFontMetrics(font: "Arial" | "Times New Roman"): Promise { + if (font === "Arial" && _arialMetrics) return _arialMetrics; + if (font === "Times New Roman" && _timesNewRomanMetrics) return _timesNewRomanMetrics; + + const { entireMetricsCollection } = await import("@capsizecss/metrics/entireMetricsCollection"); + const collection = entireMetricsCollection as unknown as Record; + + // Arial and Times New Roman are in the capsize collection + const arialEntry = collection["arial"]; + const tnrEntry = collection["timesNewRoman"]; + + _arialMetrics = arialEntry ? { + ascent: arialEntry.ascent, descent: arialEntry.descent, + lineGap: arialEntry.lineGap, unitsPerEm: arialEntry.unitsPerEm, + xWidthAvg: arialEntry.xWidthAvg, category: "sans-serif" as const, + } : { + // Fallback values if capsize doesn't have Arial (shouldn't happen) + ascent: 1854, descent: -434, lineGap: 67, unitsPerEm: 2048, xWidthAvg: 904.16, category: "sans-serif" as const, + }; + + _timesNewRomanMetrics = tnrEntry ? { + ascent: tnrEntry.ascent, descent: tnrEntry.descent, + lineGap: tnrEntry.lineGap, unitsPerEm: tnrEntry.unitsPerEm, + xWidthAvg: tnrEntry.xWidthAvg, category: "serif" as const, + } : { + ascent: 1825, descent: -443, lineGap: 87, unitsPerEm: 2048, xWidthAvg: 819.72, category: "serif" as const, + }; + + return font === "Arial" ? _arialMetrics : _timesNewRomanMetrics; +} + +// --------------------------------------------------------------------------- +// Capsize math — identical to Next.js's calculateSizeAdjustValues +// --------------------------------------------------------------------------- + +function formatOverrideValue(val: number): string { + return Math.abs(val * 100).toFixed(2); +} + +interface SizeAdjustValues { + sizeAdjust: string; + ascentOverride: string; + descentOverride: string; + lineGapOverride: string; + fallbackFont: string; +} + +/** + * Calculate size-adjust values for a fallback font. + * + * The math matches Next.js (packages/next/src/server/font-utils.ts) and + * capsize exactly: + * sizeAdjust = (font.xWidthAvg / font.unitsPerEm) / (fallback.xWidthAvg / fallback.unitsPerEm) + * ascentOverride = ascent / (unitsPerEm * sizeAdjust) + * descentOverride = |descent| / (unitsPerEm * sizeAdjust) + * lineGapOverride = lineGap / (unitsPerEm * sizeAdjust) + */ +export async function calculateSizeAdjustValues( + metrics: FontMetrics, + fallbackBase?: "Arial" | "Times New Roman", +): Promise { + const fallback = resolveFallbackBase(metrics, fallbackBase); + const fallbackMetrics = await getSystemFontMetrics(fallback); + + const mainFontAvgWidth = metrics.xWidthAvg / metrics.unitsPerEm; + const fallbackFontAvgWidth = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm; + const sizeAdjust = metrics.xWidthAvg ? mainFontAvgWidth / fallbackFontAvgWidth : 1; + + return { + sizeAdjust: formatOverrideValue(sizeAdjust), + ascentOverride: formatOverrideValue(metrics.ascent / (metrics.unitsPerEm * sizeAdjust)), + descentOverride: formatOverrideValue(metrics.descent / (metrics.unitsPerEm * sizeAdjust)), + lineGapOverride: formatOverrideValue(metrics.lineGap / (metrics.unitsPerEm * sizeAdjust)), + fallbackFont: fallback, + }; +} + +/** + * Determine which system font to use as the fallback base. + * If explicitly specified, use that. Otherwise infer from font category. + */ +function resolveFallbackBase( + metrics: FontMetrics, + explicit?: "Arial" | "Times New Roman", +): "Arial" | "Times New Roman" { + if (explicit) return explicit; + return metrics.category === "serif" ? "Times New Roman" : "Arial"; +} + +// --------------------------------------------------------------------------- +// Fallback @font-face CSS generation +// --------------------------------------------------------------------------- + +/** + * Generate a fallback @font-face CSS declaration. + * + * Produces CSS like: + * @font-face { + * font-family: '__Inter_a3b2c1 Fallback'; + * src: local("Arial"); + * ascent-override: 96.83%; + * descent-override: 24.15%; + * line-gap-override: 0.00%; + * size-adjust: 107.64%; + * } + */ +export async function generateFallbackFontFace( + metrics: FontMetrics, + hashedFamily: string, + fallbackBase?: "Arial" | "Times New Roman", +): Promise { + if (!metrics.xWidthAvg || !metrics.unitsPerEm) return undefined; + + const values = await calculateSizeAdjustValues(metrics, fallbackBase); + const fallbackFamily = `${hashedFamily} Fallback`; + + const css = `@font-face { + font-family: '${fallbackFamily}'; + src: local("${values.fallbackFont}"); + ascent-override: ${values.ascentOverride}%; + descent-override: ${values.descentOverride}%; + line-gap-override: ${values.lineGapOverride}%; + size-adjust: ${values.sizeAdjust}%; +}`; + + return { css, fallbackFamily, fallbackFont: values.fallbackFont }; +} + +// --------------------------------------------------------------------------- +// Google font metrics lookup +// --------------------------------------------------------------------------- + +/** + * Look up precalculated metrics for a Google Font by family name. + * + * Uses @capsizecss/metrics which contains data for 1900+ Google Fonts. + * Returns undefined if the font isn't in the database. + */ +export async function getGoogleFontMetrics(fontFamily: string): Promise { + // Import dynamically — this is build-time only, never bundled + const { entireMetricsCollection } = await import("@capsizecss/metrics/entireMetricsCollection"); + // The collection's per-font types are overly specific; cast through unknown + // to treat it as a simple string-keyed record. + const collection = entireMetricsCollection as unknown as Record; + + // Convert family name to camelCase key (same as Next.js formatName) + const key = fontFamily + .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => + index === 0 ? word.toLowerCase() : word.toUpperCase(), + ) + .replace(/\s+/g, ""); + + const entry = collection[key]; + if (!entry) return undefined; + + return { + ascent: entry.ascent, + descent: entry.descent, + lineGap: entry.lineGap, + unitsPerEm: entry.unitsPerEm, + xWidthAvg: entry.xWidthAvg, + category: entry.category as FontMetrics["category"], + }; +} + +// --------------------------------------------------------------------------- +// Local font metrics extraction +// --------------------------------------------------------------------------- + +/** + * Characters used to calculate average width, weighted by letter frequency. + * Same string as Next.js (packages/font/src/local/get-fallback-metrics-from-font-file.ts). + */ +const AVG_CHARACTERS = "aaabcdeeeefghiijklmnnoopqrrssttuvwxyz "; + +/** Minimal font interface matching the subset of fontkitten's Font we use. */ +interface FontkitFont { + ascent: number; + descent: number; + lineGap: number; + unitsPerEm: number; + glyphsForString(s: string): Array<{ codePoints: number[]; advanceWidth: number }>; + hasGlyphForCodePoint(cp: number): boolean; +} + +/** + * Extract font metrics from a font file buffer using fontkitten. + * + * Matches Next.js behavior: reads ascent, descent, lineGap, unitsPerEm from + * the font's OS/2 table, then calculates xWidthAvg using a letter-frequency- + * weighted string. + */ +export async function getLocalFontMetrics( + fontBuffer: Buffer, +): Promise { + // Dynamic import — fontkitten is pure JS (no native bindings), build-time only + let create: (buf: Buffer) => FontkitFont; + try { + const mod = await import("fontkitten"); + create = mod.create as (buf: Buffer) => FontkitFont; + } catch { + return undefined; + } + + try { + const font = create(fontBuffer); + const xWidthAvg = calcAverageWidth(font); + + return { + ascent: font.ascent, + descent: font.descent, + lineGap: font.lineGap, + unitsPerEm: font.unitsPerEm, + xWidthAvg: xWidthAvg ?? 0, + }; + } catch { + return undefined; + } +} + +/** + * Calculate the average character width using letter-frequency weighting. + * Same algorithm as Next.js (packages/font/src/local/get-fallback-metrics-from-font-file.ts). + */ +function calcAverageWidth(font: FontkitFont): number | undefined { + try { + const glyphs = font.glyphsForString(AVG_CHARACTERS); + const hasAllChars = glyphs + .flatMap((glyph) => glyph.codePoints) + .every((codePoint) => font.hasGlyphForCodePoint(codePoint)); + + if (!hasAllChars) return undefined; + + const widths = glyphs.map((glyph) => glyph.advanceWidth); + const totalWidth = widths.reduce((sum, width) => sum + width, 0); + return totalWidth / widths.length; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Font-family hashing +// --------------------------------------------------------------------------- + +/** + * Generate a hashed font-family name from CSS content. + * + * Matches Next.js: sha1(css).hex().slice(0, 6). + * Result: `__Inter_a3b2c1` + */ +export function hashFontFamily(family: string, css: string): string { + const hash = createHash("sha1").update(css).digest("hex").slice(0, 6); + return `__${family.replace(/\s+/g, "_")}_${hash}`; +} + +/** + * Generate hashed class names from a font-family hash. + * + * Matches Next.js css-loader getLocalIdent: + * className -> `__className_` + * variable -> `__variable_` + */ +export function hashClassNames(css: string): { className: string; variable: string } { + const hash = createHash("sha1").update(css).digest("hex").slice(0, 6); + return { + className: `__className_${hash}`, + variable: `__variable_${hash}`, + }; +} + +// --------------------------------------------------------------------------- +// Pick font file for fallback generation (local fonts with multiple sources) +// --------------------------------------------------------------------------- + +/** + * Pick the font file closest to normal weight (400) and normal style. + * Same algorithm as Next.js (packages/font/src/local/pick-font-file-for-fallback-generation.ts). + */ +export function pickFontFileForFallbackGeneration< + T extends { weight?: string; style?: string }, +>(fontFiles: T[]): T { + if (fontFiles.length === 0) { + throw new Error("pickFontFileForFallbackGeneration: fontFiles must not be empty"); + } + const NORMAL_WEIGHT = 400; + + function getDistanceFromNormalWeight(weight?: string): number { + if (!weight) return 0; + const parts = weight.trim().split(/ +/).map((w) => + w === "normal" ? NORMAL_WEIGHT : w === "bold" ? 700 : Number(w), + ); + const [first, second] = parts; + if (Number.isNaN(first)) return 0; + if (second === undefined || Number.isNaN(second)) return first - NORMAL_WEIGHT; + + // Variable font range + if (first <= NORMAL_WEIGHT && second >= NORMAL_WEIGHT) return 0; + const d1 = first - NORMAL_WEIGHT; + const d2 = second - NORMAL_WEIGHT; + return Math.abs(d1) < Math.abs(d2) ? d1 : d2; + } + + return fontFiles.reduce((best, current) => { + if (!best) return current; + const bestDist = getDistanceFromNormalWeight(best.weight); + const currDist = getDistanceFromNormalWeight(current.weight); + + if (bestDist === currDist && (current.style === undefined || current.style === "normal")) { + return current; + } + if (Math.abs(currDist) < Math.abs(bestDist)) return current; + if (Math.abs(bestDist) === Math.abs(currDist) && currDist < bestDist) return current; + return best; + }); +} + +// --------------------------------------------------------------------------- +// Rewrite font-family in @font-face CSS +// --------------------------------------------------------------------------- + +/** + * Rewrite all font-family declarations in @font-face blocks to use a hashed name. + * + * Input: `@font-face { font-family: 'Inter'; ... }` + * Output: `@font-face { font-family: '__Inter_a3b2c1'; ... }` + */ +export function rewriteFontFamilyInCSS(css: string, originalFamily: string, hashedFamily: string): string { + // Match font-family declarations inside @font-face blocks + // Handle both quoted and unquoted variants + const escaped = originalFamily.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp( + `(font-family\\s*:\\s*)(?:'${escaped}'|"${escaped}"|${escaped})`, + "gi", + ); + return css.replace(re, `$1'${hashedFamily}'`); +} diff --git a/packages/vinext/src/plugins/fonts.ts b/packages/vinext/src/plugins/fonts.ts index cb0a76240..4257e4ae6 100644 --- a/packages/vinext/src/plugins/fonts.ts +++ b/packages/vinext/src/plugins/fonts.ts @@ -24,6 +24,7 @@ import type { Plugin } from "vite"; import { parseAst } from "vite"; +import { createHash } from "node:crypto"; import path from "node:path"; import fs from "node:fs"; import MagicString from "magic-string"; @@ -361,8 +362,10 @@ async function fetchAndCacheFont( } } - // Download font files + // Download font files and copy to public/fonts/ for web access fs.mkdirSync(fontDir, { recursive: true }); + const publicFontsDir = path.join(path.dirname(path.dirname(cacheDir)), "public", "fonts"); + fs.mkdirSync(publicFontsDir, { recursive: true }); for (const [fontUrl, filename] of urls) { const filePath = path.join(fontDir, filename); if (!fs.existsSync(filePath)) { @@ -372,8 +375,13 @@ async function fetchAndCacheFont( fs.writeFileSync(filePath, buffer); } } - // Rewrite CSS to use absolute path (Vite will resolve /@fs/ for dev, or asset for build) - css = css.split(fontUrl).join(filePath); + // Copy to public/fonts/ so they're served as static assets + const publicPath = path.join(publicFontsDir, filename); + if (fs.existsSync(filePath) && !fs.existsSync(publicPath)) { + fs.copyFileSync(filePath, publicPath); + } + // Rewrite CSS to use /fonts/ web path + css = css.split(fontUrl).join(`/fonts/${filename}`); } // Cache the rewritten CSS @@ -564,7 +572,7 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st const fontLocals = new Map(); const proxyObjectLocals = new Set(); - const importRe = /^[ \t]*import\s+([^;]+?)\s+from\s*(["'])next\/font\/google\2\s*;?/gm; + const importRe = /^[ \t]*import\s+([^;\n]+?)\s+from\s*(["'])next\/font\/google\2\s*;?/gm; let importMatch; while ((importMatch = importRe.exec(code)) !== null) { const [fullMatch, clause] = importMatch; @@ -664,7 +672,7 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st return; // Can't parse options statically, skip } - // Build the Google Fonts CSS URL + // Build the Google Fonts CSS URL (manual to avoid URLSearchParams encoding issues) const weights = options.weight ? Array.isArray(options.weight) ? options.weight @@ -691,14 +699,9 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st spec += `:wght@${weights.join(";")}`; } } else if (styles.length === 0) { - // Request full variable weight range when no weight specified. - // Without this, Google Fonts returns only weight 400. spec += `:wght@100..900`; } - const params = new URLSearchParams(); - params.set("family", spec); - params.set("display", display); - const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; + const cssUrl = `https://fonts.googleapis.com/css2?family=${spec}&display=${display}`; // Check cache let localCSS = fontCache.get(cssUrl); @@ -706,25 +709,44 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st try { localCSS = await fetchAndCacheFont(cssUrl, family, cacheDir); fontCache.set(cssUrl, localCSS); - } catch { - // Fetch failed (offline?) — fall back to CDN mode + } catch (e) { + console.warn(`[vinext:google-fonts] Failed to self-host "${family}":`, e); return; } } - // Inject _selfHostedCSS into the options object - const escapedCSS = JSON.stringify(localCSS); + // Generate scoped hashed family name (like Next.js __FontName_hash) + const hashedFamily = `__${family.replace(/\s+/g, "_")}_${createHash("md5").update(family).digest("hex").slice(0, 6)}`; + const hashedCSS = localCSS.replace( + new RegExp(`font-family:\\s*'${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}'`, "g"), + `font-family: '${hashedFamily}'`, + ); + + // Generate fallback font with size-adjust metrics + let fallbackProps = ""; + try { + const { getGoogleFontMetrics, generateFallbackFontFace } = await import("../font-metrics.js"); + const metrics = await getGoogleFontMetrics(family); + if (metrics) { + const fallbackResult = await generateFallbackFontFace(metrics, hashedFamily); + if (fallbackResult) { + fallbackProps = `, _fallbackCSS: ${JSON.stringify(fallbackResult.css)}, _fallbackFamily: ${JSON.stringify(fallbackResult.fallbackFamily)}`; + } + } + } catch { + // font-metrics not available — skip fallback generation + } + + // Inject _selfHostedCSS, _hashedFamily, and fallback props + const escapedCSS = JSON.stringify(hashedCSS); + const escapedHashedFamily = JSON.stringify(hashedFamily); const closingBrace = optionsStr.lastIndexOf("}"); const beforeBrace = optionsStr.slice(0, closingBrace).trim(); - // Determine the separator to insert before the new property: - // - Empty string if the object is empty ({ is the last non-whitespace char) - // - Empty string if there's already a trailing comma (avoid double comma) - // - ", " otherwise (before the new property) const separator = beforeBrace.endsWith("{") || beforeBrace.endsWith(",") ? "" : ", "; const optionsWithCSS = optionsStr.slice(0, closingBrace) + separator + - `_selfHostedCSS: ${escapedCSS}` + + `_selfHostedCSS: ${escapedCSS}, _hashedFamily: ${escapedHashedFamily}${fallbackProps}` + optionsStr.slice(closingBrace); const replacement = `${calleeSource}(${optionsWithCSS})`; @@ -732,7 +754,32 @@ export function createGoogleFontsPlugin(fontGoogleShimPath: string, shimsDir: st hasChanges = true; } - if (isBuild) { + // Detect destructured properties from proxy/default imports: + // const { Gabarito } = googleFonts; + // const { Instrument_Serif: InstrumentSerif } = googleFonts; + if (proxyObjectLocals.size > 0) { + const proxyNames = Array.from(proxyObjectLocals) + .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const destructureRe = new RegExp( + `(?:const|let|var)\\s*\\{([^}]+)\\}\\s*=\\s*(?:${proxyNames})\\s*;?`, + "g", + ); + let destructureMatch; + while ((destructureMatch = destructureRe.exec(code)) !== null) { + const specifiers = destructureMatch[1]; + for (const spec of specifiers.split(",")) { + const parts = spec.trim().split(/\s*:\s*/); + const imported = parts[0].trim(); + const local = parts.length > 1 ? parts[1].trim() : imported; + if (imported && !GOOGLE_FONT_UTILITY_EXPORTS.has(imported)) { + fontLocals.set(local, imported); + } + } + } + } + + { // Match: Identifier( — where the argument starts with { // The regex intentionally does NOT capture the options object; we use // _findBalancedObject() to handle nested braces correctly. diff --git a/packages/vinext/src/shims/font-google-base.ts b/packages/vinext/src/shims/font-google-base.ts index 77f3028c9..f4090c2bc 100644 --- a/packages/vinext/src/shims/font-google-base.ts +++ b/packages/vinext/src/shims/font-google-base.ts @@ -364,27 +364,50 @@ function injectSelfHostedCSS(css: string): void { document.head.appendChild(style); } -export type FontLoader = (options?: FontOptions & { _selfHostedCSS?: string }) => FontResult; +interface BuildInjectedOptions { + _selfHostedCSS?: string; + _hashedFamily?: string; + _fallbackCSS?: string; + _fallbackFamily?: string; +} + +export type FontLoader = (options?: FontOptions & BuildInjectedOptions) => FontResult; export function createFontLoader(family: string): FontLoader { - return function fontLoader(options: FontOptions & { _selfHostedCSS?: string } = {}): FontResult { + return function fontLoader(options: FontOptions & BuildInjectedOptions = {}): FontResult { const id = classCounter++; - const className = `__font_${family.toLowerCase().replace(/\s+/g, "_")}_${id}`; const fallback = options.fallback ?? ["sans-serif"]; - // Sanitize each fallback name to prevent CSS injection via crafted values - const fontFamily = `'${escapeCSSString(family)}', ${fallback.map(sanitizeFallback).join(", ")}`; - // Validate CSS variable name — reject anything that could inject CSS. - // Fall back to auto-generated name if invalid. const defaultVarName = toVarName(family); const cssVarName = options.variable ? (sanitizeCSSVarName(options.variable) ?? defaultVarName) : defaultVarName; - // In Next.js, `variable` returns a CLASS NAME that sets the CSS variable. - // Users apply this class to set the CSS variable on that element. + + // Self-hosted mode with hashed family (Next.js parity) + if (options._selfHostedCSS && options._hashedFamily) { + const hashedFamily = options._hashedFamily; + const fallbackFamily = options._fallbackFamily; + const className = `__font_${family.toLowerCase().replace(/\s+/g, "_")}_${id}`; + const variableClassName = `__variable_${family.toLowerCase().replace(/\s+/g, "_")}_${id}`; + + const parts: string[] = [`'${escapeCSSString(hashedFamily)}'`]; + if (fallbackFamily) parts.push(`'${escapeCSSString(fallbackFamily)}'`); + parts.push(...fallback.map(sanitizeFallback)); + const fontFamily = parts.join(", "); + + injectSelfHostedCSS(options._selfHostedCSS); + if (options._fallbackCSS) injectSelfHostedCSS(options._fallbackCSS); + injectClassNameRule(className, fontFamily); + injectVariableClassRule(variableClassName, cssVarName, fontFamily); + + return { className, style: { fontFamily }, variable: variableClassName }; + } + + const className = `__font_${family.toLowerCase().replace(/\s+/g, "_")}_${id}`; + const fontFamily = `'${escapeCSSString(family)}', ${fallback.map(sanitizeFallback).join(", ")}`; const variableClassName = `__variable_${family.toLowerCase().replace(/\s+/g, "_")}_${id}`; + // Self-hosted mode without hashing (legacy/dev) if (options._selfHostedCSS) { - // Self-hosted mode: inject local @font-face CSS instead of CDN link injectSelfHostedCSS(options._selfHostedCSS); } else { // CDN mode: inject to Google Fonts diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index bb1c1ab1f..dbeff954d 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -764,10 +764,12 @@ export function usePathname(): string { } const renderSnapshot = useClientNavigationRenderSnapshot(); // Client-side: use the hook system for reactivity + // Use client snapshot for server snapshot too — during hydration, + // _getServerContext() is null and falls back to "/", causing mismatch. const pathname = React.useSyncExternalStore( subscribeToNavigation, getPathnameSnapshot, - () => _getServerContext()?.pathname ?? "/", + getPathnameSnapshot, ); // Prefer the render snapshot during an active navigation transition so // hooks return the pending URL, not the stale committed one. After commit, @@ -794,7 +796,7 @@ export function useSearchParams(): ReadonlyURLSearchParams { const searchParams = React.useSyncExternalStore( subscribeToNavigation, getSearchParamsSnapshot, - getServerSearchParamsSnapshot, + getSearchParamsSnapshot, ); if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return renderSnapshot.searchParams; @@ -818,7 +820,7 @@ export function useParams< const params = React.useSyncExternalStore( subscribeToNavigation, getClientParamsSnapshot as () => T, - getServerParamsSnapshot as () => T, + getClientParamsSnapshot as () => T, ); if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return renderSnapshot.params as T; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b5d5fbb5..22421b14b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@capsizecss/metrics': + specifier: ^3.4.0 + version: 3.7.0 '@cloudflare/kumo': specifier: ^1.6.0 version: 1.6.0 @@ -765,6 +768,9 @@ importers: packages/vinext: dependencies: + '@capsizecss/metrics': + specifier: 'catalog:' + version: 3.7.0 '@unpic/react': specifier: 'catalog:' version: 1.0.2(next@16.1.7(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1227,6 +1233,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@capsizecss/metrics@3.7.0': + resolution: {integrity: sha512-NHdEMrl/zd2XgiSv2xHRF/FxGc2OTBKjhPzr9SgbHzqmoTVn8BbRK88Dtq0m65idX/RMD7ptyVdbGHFcvlErSw==} + '@cloudflare/kumo@1.6.0': resolution: {integrity: sha512-1Sy8kgfHNkze+NEfu/6cNzwOb0hemGm1mUNGU9GVmAnHemLOaXixosslM/o38TbUTEEs48yTRrDy0WFGgSTFWg==} hasBin: true @@ -5370,6 +5379,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@capsizecss/metrics@3.7.0': {} + '@cloudflare/kumo@1.6.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 687a4d0cd..20d422fbd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - benchmarks/vinext catalog: + "@capsizecss/metrics": ^3.4.0 "@cloudflare/kumo": ^1.6.0 "@cloudflare/vite-plugin": ^1.25.0 "@cloudflare/workers-types": ^4.0.0 From 6ad88173fe0bdc97850fe8a97d97380feaa15e08 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Thu, 2 Apr 2026 14:41:31 +0000 Subject: [PATCH 2/2] fix(deploy): patch dist/server/wrangler.json with env configs from user's wrangler.jsonc The @cloudflare/vite-plugin strips env-specific sections when generating the output wrangler.json, so `wrangler deploy --env production` couldn't resolve environment-specific vars/bindings. Now vinext deploy reads the user's original wrangler.jsonc, properly parses JSONC (preserving strings), and merges the env section into dist/server/wrangler.json before deploying. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/vinext/src/deploy.ts | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 827e7e5ef..e2d97c1d3 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -1360,6 +1360,73 @@ export async function deploy(options: DeployOptions): Promise { } } + // Step 6c: Patch dist/server/wrangler.json with env configs from the user's + // wrangler.jsonc. The @cloudflare/vite-plugin strips env-specific sections + // when generating the output config, so `wrangler deploy --env ` can't + // resolve environment-specific vars/bindings from the generated file alone. + { + const distWranglerPath = path.join(root, "dist", "server", "wrangler.json"); + if (fs.existsSync(distWranglerPath)) { + const userConfigPath = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] + .map((f) => path.join(root, f)) + .find((f) => fs.existsSync(f)); + + if (userConfigPath && (userConfigPath.endsWith(".json") || userConfigPath.endsWith(".jsonc"))) { + try { + const userRaw = fs.readFileSync(userConfigPath, "utf-8"); + // Strip JSONC comments while preserving strings (// inside "..." is not a comment) + let userClean = ""; + let inString = false; + let i = 0; + while (i < userRaw.length) { + if (inString) { + if (userRaw[i] === "\\" && i + 1 < userRaw.length) { + userClean += userRaw[i] + userRaw[i + 1]; + i += 2; + } else if (userRaw[i] === '"') { + userClean += '"'; + inString = false; + i++; + } else { + userClean += userRaw[i]; + i++; + } + } else if (userRaw[i] === '"') { + userClean += '"'; + inString = true; + i++; + } else if (userRaw[i] === "/" && userRaw[i + 1] === "/") { + // Skip to end of line + while (i < userRaw.length && userRaw[i] !== "\n") i++; + } else if (userRaw[i] === "/" && userRaw[i + 1] === "*") { + i += 2; + while (i < userRaw.length && !(userRaw[i] === "*" && userRaw[i + 1] === "/")) i++; + i += 2; + } else { + userClean += userRaw[i]; + i++; + } + } + // Remove trailing commas + userClean = userClean.replace(/,\s*([\]}])/g, "$1"); + const userConfig = JSON.parse(userClean); + + if (userConfig.env) { + const distConfig = JSON.parse(fs.readFileSync(distWranglerPath, "utf-8")); + distConfig.env = userConfig.env; + // Also ensure migrations are carried over + if (userConfig.migrations && !distConfig.migrations?.length) { + distConfig.migrations = userConfig.migrations; + } + fs.writeFileSync(distWranglerPath, JSON.stringify(distConfig)); + } + } catch { + // Non-fatal — deploy will proceed with existing config + } + } + } + } + // Step 7: Deploy via wrangler const url = runWranglerDeploy(root, { preview: options.preview ?? false,