Skip to content
Open
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
});

Expand Down
139 changes: 139 additions & 0 deletions packages/vinext/src/typegen.ts
Original file line number Diff line number Diff line change
@@ -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<Console, "info" | "warn">;
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;
}
}
Comment on lines +29 to +36
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should avoid have having a hard dependency/requirement on Next.js for Vinext to be usable

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That makes sense. I agree Vinext itself should not depend on Next.js to be usable.

In this PR, my intent was only to add an optional dev-time integration: if next is installed in the project already, Vinext will opportunistically run next typegen; if it is not installed, startup still succeeds and we skip it.

For now, this is mainly intended as a pragmatic way to improve the developer experience in the short term. We can still follow up later with a native Vinext typegen implementation so this no longer depends on Next.js at all.


export function createNextTypegenController(
options: NextTypegenControllerOptions,
): NextTypegenController {
const {
root,
enabled = true,
debounceMs = 150,
logger = console,
resolveNextBin = resolveNextTypegenBin,
spawnImpl = spawn,
} = options;

let timer: ReturnType<typeof setTimeout> | 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"], {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Passing env: process.env is redundant — spawn inherits the parent environment by default when env is not specified. But more importantly, this means the child process inherits the full parent environment, which may include NODE_OPTIONS, NODE_ENV, etc. that could interfere with next typegen.

Consider either removing the explicit env or being selective about what's inherited.

Suggested change
child = spawnImpl(process.execPath, [resolvedNextBin, "typegen"], {
child = spawnImpl(process.execPath, [resolvedNextBin, "typegen"], {
cwd: root,
stdio: "ignore",
});

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

next typegen exits with code 0 on success. However, it also exits 0 when it has nothing to do (e.g., no next.config.* found, no app/ directory). This means a misconfigured project will silently produce no types with no user feedback.

Consider adding a --verbose or debug-level log when typegen succeeds, so users can confirm it actually ran and generated files. Or at minimum, log on the first successful run:

if (code === 0 && !firstRunLogged) {
  firstRunLogged = true;
  logger.info("[vinext] `next typegen` completed.");
}

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() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

scheduleWithDelay(0) still defers via setTimeout. This means if configureServer calls typegen.start() and the server immediately starts handling requests, there's a window where types haven't been generated yet. This is fine for dev ergonomics (types arrive shortly after), but the name start() implies synchronous initiation. A comment would help:

Suggested change
start() {
start() {
// Deferred to avoid blocking configureServer. Types will be
// generated asynchronously after the event loop yields.
scheduleWithDelay(0);
},

// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

child.kill() sends SIGTERM by default. If next typegen is in the middle of writing files, this could leave partial .d.ts files on disk. This is probably fine in practice since next typegen is fast, but worth noting.

Also, after child.kill(), setting child = null immediately means the exit handler (line 79) will still fire asynchronously and find child === null, then check closed === true and bail. This is correct but fragile — if someone later adds logic after the if (closed) return; check, it could run with stale state. Consider removing the exit listener before killing:

Suggested change
if (child) {
if (child) {
child.removeAllListeners();
child.kill();
child = null;
}

child.removeAllListeners();
child.kill();
child = null;
}
},
};
}
Loading