diff --git a/README.md b/README.md index c9f52ed0..7e1e7c37 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,27 @@ Yes. Add the [Nitro](https://v3.nitro.build/) Vite plugin alongside vinext, and **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. +## Type Safety + +As a short-term bridge for route-aware helpers like `PageProps` and `LayoutProps`, vinext can reuse Next.js's own type generation when `next` is already installed in the project. During development, the Vite plugin runs [`next typegen`](https://nextjs.org/docs/app/api-reference/cli/next#next-typegen-options) automatically on a best-effort basis. + +This is intended to improve developer experience in the near term, not as the long-term design. Native Vinext type generation is tracked in [#664](https://github.com/cloudflare/vinext/issues/664). + +If you want to turn this off, disable it in your Vite config: + +```ts +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ typegen: false })], +}); +``` + +If you rely on these helpers in CI or a separate type-check step, your project must also have `next` installed (typically as a `devDependency`) so `next typegen` is available. That is a temporary tradeoff of this bridge while Vinext-native type generation is still in progress. + +vinext only auto-runs this bridge in dev mode, and only when `next` is installed in the project. + ## Deployment ### Cloudflare Workers diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index db807334..55a823ea 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -55,6 +55,7 @@ import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; +import { createNextTypegenController } from "./typegen.js"; import tsconfigPaths from "vite-tsconfig-paths"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; @@ -832,6 +833,15 @@ export interface VinextOptions { * @default true */ react?: VitePluginReactOptions | boolean; + /** + * Run `next typegen` automatically in development when Next.js is installed. + * This generates route-aware helpers like `PageProps`/`LayoutProps`. + * Temporary stopgap until Vinext ships native type generation: + * https://github.com/cloudflare/vinext/issues/664 + * Set to `false` to disable automatic type generation. + * @default true + */ + typegen?: boolean; /** * Experimental vinext-only feature flags. */ @@ -2333,6 +2343,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { configureServer(server: ViteDevServer) { // Watch pages directory for file additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; + const typegen = createNextTypegenController({ + root, + enabled: options.typegen !== false, + }); + const closeServer = server.close.bind(server); + + typegen.start(); + server.close = async () => { + typegen.close(); + await closeServer(); + }; // Build a long-lived ModuleRunner for loading all Pages Router modules // (middleware, API routes, SSR page rendering) on every request. @@ -2384,19 +2405,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { server.watcher.on("add", (filePath: string) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); + typegen.schedule(); } if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) { invalidateAppRouteCache(); invalidateRscEntryModule(); + typegen.schedule(); } }); server.watcher.on("unlink", (filePath: string) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); + typegen.schedule(); } if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) { invalidateAppRouteCache(); invalidateRscEntryModule(); + typegen.schedule(); } }); diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts new file mode 100644 index 00000000..ffa8f597 --- /dev/null +++ b/packages/vinext/src/typegen.ts @@ -0,0 +1,139 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; + +/** + * Temporary bridge for route-aware type generation. + * + * Vinext should eventually generate these types from its own route tree + * instead of delegating to `next typegen`, but this improves dev-time DX in + * the meantime without making Next.js a hard requirement for startup. + * + * Tracking issue: https://github.com/cloudflare/vinext/issues/664 + */ +export interface NextTypegenControllerOptions { + root: string; + enabled?: boolean; + debounceMs?: number; + logger?: Pick; + resolveNextBin?: (root: string) => string | null; + spawnImpl?: typeof spawn; +} + +export interface NextTypegenController { + start(): void; + schedule(): void; + close(): void; +} + +export function resolveNextTypegenBin(root: string): string | null { + try { + const projectRequire = createRequire(path.join(root, "package.json")); + return projectRequire.resolve("next/dist/bin/next"); + } catch { + return null; + } +} + +export function createNextTypegenController( + options: NextTypegenControllerOptions, +): NextTypegenController { + const { + root, + enabled = true, + debounceMs = 150, + logger = console, + resolveNextBin = resolveNextTypegenBin, + spawnImpl = spawn, + } = options; + + let timer: ReturnType | null = null; + let child: ChildProcess | null = null; + let pending = false; + let nextBin: string | null | undefined; + let missingNextLogged = false; + let firstSuccessLogged = false; + let closed = false; + + function getNextBin(): string | null { + if (nextBin !== undefined) return nextBin; + nextBin = resolveNextBin(root); + if (!nextBin && !missingNextLogged) { + missingNextLogged = true; + logger.info("[vinext] Skipping dev typegen: `next` is not installed in this project."); + } + return nextBin; + } + + function run(): void { + if (!enabled || closed) return; + + const resolvedNextBin = getNextBin(); + if (!resolvedNextBin) return; + + if (child) { + pending = true; + return; + } + + child = spawnImpl(process.execPath, [resolvedNextBin, "typegen"], { + cwd: root, + stdio: "ignore", + }); + + child.once("error", (error) => { + logger.warn(`[vinext] Failed to run \`next typegen\`: ${error.message}`); + }); + + child.once("exit", (code, signal) => { + child = null; + + if (closed) return; + + if (code !== 0) { + const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`; + logger.warn(`[vinext] \`next typegen\` exited with ${detail}.`); + } else if (!firstSuccessLogged) { + firstSuccessLogged = true; + logger.info("[vinext] `next typegen` completed."); + } + + if (pending) { + pending = false; + run(); + } + }); + } + + function scheduleWithDelay(delayMs: number): void { + if (!enabled || closed) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + run(); + }, delayMs); + } + + return { + start() { + // Defer the first run so configureServer can finish without waiting for + // `next typegen` to complete synchronously. + scheduleWithDelay(0); + }, + schedule() { + scheduleWithDelay(debounceMs); + }, + close() { + closed = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (child) { + child.removeAllListeners(); + child.kill(); + child = null; + } + }, + }; +} diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts new file mode 100644 index 00000000..a9596182 --- /dev/null +++ b/tests/typegen.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createServer, type ViteDevServer } from "vite-plus"; +import vinext from "../packages/vinext/src/index.js"; +import { + createNextTypegenController, + resolveNextTypegenBin, +} from "../packages/vinext/src/typegen.js"; + +function createTempProject(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeFile(root: string, relativePath: string, content: string): void { + const fullPath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf8"); +} + +function readCount(root: string): number { + const countFile = path.join(root, ".typegen-count"); + if (!fs.existsSync(countFile)) return 0; + return Number(fs.readFileSync(countFile, "utf8")); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Timed out waiting for condition"); +} + +function setupPagesProject(root: string): void { + writeFile( + root, + "package.json", + JSON.stringify({ + name: "typegen-test", + version: "1.0.0", + dependencies: { + react: "^19.2.0", + "react-dom": "^19.2.0", + }, + }), + ); + writeFile(root, "pages/index.tsx", "export default function Page() { return
hi
; }\n"); +} + +/** Install a fake `next` binary that increments a counter file instead of running real typegen. */ +function installFakeNext(root: string): void { + writeFile( + root, + "node_modules/next/package.json", + JSON.stringify({ name: "next", version: "15.0.0" }), + ); + writeFile( + root, + "node_modules/next/dist/bin/next.js", + `const fs = require("node:fs"); +const path = require("node:path"); +const countFile = path.join(process.cwd(), ".typegen-count"); +const current = fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, "utf8")) : 0; +fs.writeFileSync(countFile, String(current + 1)); +`, + ); +} + +async function startServer( + root: string, + typegen: boolean | undefined = undefined, +): Promise { + const server = await createServer({ + root, + configFile: false, + logLevel: "silent", + plugins: [vinext({ appDir: root, typegen })], + server: { port: 0 }, + }); + await server.listen(); + return server; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("next typegen controller", () => { + it("resolves the local Next.js bin from the project root", () => { + const root = createTempProject("vinext-typegen-bin-"); + try { + setupPagesProject(root); + installFakeNext(root); + + expect(resolveNextTypegenBin(root)).toBe( + path.join(root, "node_modules/next/dist/bin/next.js"), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("logs once and skips when next is missing", async () => { + const info = vi.fn(); + const controller = createNextTypegenController({ + root: "/tmp/no-next", + logger: { info, warn: vi.fn() }, + resolveNextBin: () => null, + }); + + controller.start(); + controller.schedule(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(info).toHaveBeenCalledTimes(1); + }); +}); + +describe("vinext dev typegen integration", () => { + it("runs the project-local `next typegen` command on dev server startup by default", async () => { + const root = createTempProject("vinext-typegen-start-"); + let server: ViteDevServer | null = null; + try { + setupPagesProject(root); + installFakeNext(root); + + server = await startServer(root); + await waitFor(() => readCount(root) >= 1); + + expect(readCount(root)).toBeGreaterThanOrEqual(1); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("can disable automatic typegen", async () => { + const root = createTempProject("vinext-typegen-disabled-"); + let server: ViteDevServer | null = null; + try { + setupPagesProject(root); + installFakeNext(root); + + server = await startServer(root, false); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(readCount(root)).toBe(0); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("reruns the project-local `next typegen` command when a route file is added", async () => { + const root = createTempProject("vinext-typegen-watch-"); + let server: ViteDevServer | null = null; + try { + setupPagesProject(root); + installFakeNext(root); + + server = await startServer(root); + await waitFor(() => readCount(root) >= 1); + + writeFile( + root, + "pages/about.tsx", + "export default function About() { return
about
; }\n", + ); + + await waitFor(() => readCount(root) >= 2); + expect(readCount(root)).toBeGreaterThanOrEqual(2); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("starts cleanly when next is not installed", async () => { + const root = createTempProject("vinext-typegen-missing-"); + let server: ViteDevServer | null = null; + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + try { + setupPagesProject(root); + + server = await startServer(root); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(info).toHaveBeenCalledWith( + "[vinext] Skipping dev typegen: `next` is not installed in this project.", + ); + } finally { + await server?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +});