On vinext
#ai #cloudflare #nextjs #typescript #vinext
This article was written in reference to https://github.com/cloudflare/vinext, commit 0d5b42c.
Snippets are from vinext and are LLM-generated. Use at your own risk.
All prose is my own and human; nobody has proofread this article.
One software engineer and an AI model at Cloudflare recently released vinext, a drop-in replacement[1] for Next.JS made with $1,100 of tokens in a week.
On Vibe-coding
Going into this, I thought state-of-the-art AI models were generally quite good. I’ve heard many success stories, but I’ve also tried all the major models’ free tiers and they haven’t been much of a help—[2]although I can already feel myself getting sucked into relying on AIs to solve problems. As an example, I had a minor problem where pnpm didn’t work.
I’d previously set pnpm’s minimumReleaseAge setting in some attempt to avoid supply chain attacks (even though I don’t use pnpm):
minimum-release-age=259200
But Arch Linux updated pnpm to a version that was released less than three days ago! To fix this, all I had to do was set the minimumReleaseAgeExclude setting to pnpm, but I couldn’t find any documentation on pnpm’s INI file format.
I asked Claude Sonnet 4.6, and it suggested the right syntax after quite a few wrong syntaxes:
minimum-release-age-exclude[]=pnpm
Here’s the complete transcript, which at the moment does not work (“Claude will return soon”). I guess that’s what happens when you vibe code your user interface!
Downloading
Cloning the Git repository gives me 25 megabytes of source code, 16 of which are taken up by AI-generated images of generic unbranded products such as:
- A basketball (named
balls.png) - Boxing gloves
- Shoes with physics-defying laces
- Two dumbbells
laptop.png, with no discernible features- iPhone 12
Code
Let’s review the code! I only have time for one file, so let’s start with packages/vinext/src/server/prod-server.ts, which states:
/**
* Production server for vinext.
*
* Serves the built output from `vinext build`. Handles:
* [...]
That sounds pretty fundamental to me, so let’s dive in.
The file starts out with the definition of a function, readNodeString:
/** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */
function readNodeStream(req: IncomingMessage): ReadableStream<Uint8Array> {
Now, I didn’t know this, but those of you who know NodeJS may have realized that IncomingMesssage extends NodeJS’s stream.Readable, which has a toWeb method to do this already. Why didn’t vinext just use this instead, you may ask? Well, they do use it. In the same file.
Moving on, next, prod-server.ts defines an options interface.
export interface ProdServerOptions {
/** Port to listen on */
port?: number;
/** Host to bind to */
host?: string;
/** Path to the build output directory */
outDir?: string;
/** Disable compression (default: false) */
noCompression?: boolean;
}
There are two really weird choices I see: negating a boolean like noCompression (I’d prefer the positive compress?: boolean), and why is everything optional? One potential corollary of the parse-don’t-validate mindset is doing, uh, anything but what vinext has done here.
But this whole project is a Next.JS replacement! Maybe this is just the external API. To get an idea for this, let’s check out how vinext uses this type. It’s just used to declare one function:
export async function startProdServer(options: ProdServerOptions = {}) {
and startProdServer is referenced from two different places.
Once in packages/vinext/src/cli.ts:
const { startProdServer } = (1await import(
/* @vite-ignore */ "./server/prod-server.js"
)) as {
startProdServer: 2(opts: {
port: number;
host: string;
outDir: string;
}) => Promise<unknown>;
};
await startProdServer({
port,
host,
outDir: path.resolve(process.cwd(), "dist"),
});
Uh, why are you importing this file at runtime instead of just using a TypeScript import?
This is fine, actually. You don’t know how NextJS works, and besides, importing a file using the await import syntax 1 means this file will only be loaded when it’s first used.
Okay, point taken. But here, startProdServer is typed to not even know about the noCompression argument 2! And here, they definitely could’ve used TypeScript’s import type, which is erased by the TypeScript compiler, to import the ProdServerOptions type. That would’ve had no runtime impact and made this code better-typed.
Let’s look at the next use of startProdServer, in packages/vinext/src/cloudflare/tpr.rs:
const script = [
`import("file://${escapedProdServer}")`,
`.then(m => m.startProdServer({ port: ${port}, host: "127.0.0.1", outDir: "${escapedOutDir}" }))`,
`.catch(e => { console.error("[vinext-tpr] Server failed to start:", e); process.exit(1); });`,
].join("");
const proc = spawn(process.execPath, ["--input-type=module", "-e", script], {
cwd: root,
stdio: "pipe",
env: { ...process.env, NODE_ENV: "production" },
});
This also doesn’t use noCompression, and is somehow even less well-typed than the previous. While we’re here, I will note that I would try as hard as possible to keep this in-process: there really should not be enough globals to require starting a new process to prerender each page.
Moving on, up next we have some utility functions to compress a response:
/** Content types that benefit from compression. */
const COMPRESSIBLE_TYPES = new Set([
"text/html",
// [...]
"application/wasm",
]);
/** Minimum size threshold for compression (in bytes). Below this, compression overhead isn't worth it. */
const COMPRESS_THRESHOLD = 1024;
function createCompressor(encoding: "br" | "gzip" | "deflate"): zlib.BrotliCompress | zlib.Gzip | zlib.Deflate {
// [...]
}
function sendCompressed(
req: IncomingMessage,
// [...]
compress: boolean = true,
): void {
// [...]
}
This seems completely fine to me. Then we have this:
/** Content-type lookup for static assets. */
const CONTENT_TYPES: Record<string, string> = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".html": "text/html",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".webp": "image/webp",
".avif": "image/avif",
".map": "application/json",
};
This is extremely not fine to me. It has .eot, an (apparently) font format I’ve never heard of, while leaving out:
- all audio formats
- all video formats
- some niche image formats (like
.pdf) - WebAssembly files, which will at least be compressed before being sent as
application/octet-stream
And then we have tryServeStatic, which looks like this:
/**
* Try to serve a static file from the client build directory.
* Returns true if the file was served, false otherwise.
*/
function tryServeStatic(
req: IncomingMessage,
res: ServerResponse,
clientDir: string,
pathname: string,
compress: boolean,
): boolean {
Okay, compress isn’t optional anymore, weird. And I’d like to see some Apps Hungarian on clientDir to note it’s already been path.resolve()d, as it is in every codepath that gets here. Because it wasn’t clearly marked, they’re going to immediately resolve it again:
// Resolve the path and guard against directory traversal (e.g. /../../../etc/passwd)
const resolvedClient = 1 path.resolve(clientDir);
let decodedPathname: string;
try {
decodedPathname = 2 decodeURIComponent(pathname);
} catch {
return false;
}
const staticFile = 3 path.resolve(clientDir, "." + decodedPathname);
if (!staticFile.startsWith(resolvedClient + path.sep) && staticFile !== resolvedClient) { 4
return false;
}
if ( 5
pathname === "/" ||
!¯fs.existsSync(staticFile) ||
!fs.statSync(staticFile).isFile()
) {
return false;
}
First off, they’re resolving clientDir twice? 1 3; and then this try/catch is really scary to me. I definitely would try to split this up into a few separate functions. An incomplete sketch follows:
-
getRequestPathname, which takes anIncomingMessage, callsdecodeURIComponent, and sanitizes the path.Sanitizing here should fail if the path contains any characters that any platforms consider special, like
:\?, and additionally locally resolve..s (but without access to the filesystem, likepath.resolvewould.) This would change behavior. Currentvinextwill bail out the instant it touches a symlink, but resolving the path this early means we don’t know where we’ll end up looking.I’d argue that following symlinks is correct when you’re serving static files, but I’m not Cloudflare, either.
-
resolveToStaticFilewhich takes a path and the static file directory and returns some sort of byte stream, -
serveStreamwhich takes a content type and a stream, heuristically compress the response, and send the response body.
At 4 they’re just checking that this file is within the static file root, which is at clientDir. Calling startsWith and then !== is totally fine here—if the static file root is /var/www/my-site, we don’t want to allow /var/www/my-site-2/foo/bar (which would resolve from a pathname of "../my-site-2".) The worse part is that this should be its own function, as this pattern is used again later on in the same file, and this kind of function feels like it might have nonobvious edge cases.
And then we get to the really big issue: 5 we’re calling synchronous functions! These will block the event loop in Node.JS, and the only way to get around this is to run a fleet of microservers at the edge that will automatically scale up and down based on load. Running at the edge will also reduce latency, so your customers will be happier. (For the record, this is a joke—I do not believe Cloudflare is writing worse code to make you start using Cloudflare-or-equivalent services.)
Thankfully we’re only running existsSync and statSync. These probably won’t take very long to complete. And while we’re on the topic, another minor nit is that I’d prefer to skip existsSync and catch the potential error from statSync. Less syscalls is always good.
/**
* Stream a Web Response back to a Node.js ServerResponse.
* Supports streaming compression for SSR responses.
*/
async function sendWebResponse(
webResponse: Response,
req: IncomingMessage,
res: ServerResponse,
compress: boolean,
): Promise<void> {
const status = webResponse.status;
// Collect headers, handling multi-value headers (e.g. Set-Cookie)
const nodeHeaders: Record<string, string | string[]> = {};
webResponse.headers.forEach((value, key) => {
const existing = nodeHeaders[key];
if (existing !== undefined) {
nodeHeaders[key] = Array.isArray(existing)
? [...existing, value]
: [existing, value];
} else {
nodeHeaders[key] = value;
}
});
Uh, this is not how the Headers API works. The Headers type stores an array of all values for each specific key, but automatically joins them for forEach, entries, and get. (getSetCookie is the only way to actually see the separate array, and it only works for the "Set-Cookie" header.)
You do realize this is actually just bad API design, right?
True. Header is a bad API. But also, vinext is using it wrong. Next?
// Check if we should compress the response.
// Skip if the upstream already compressed (avoid double-compression).
const alreadyEncoded = webResponse.headers.has("content-encoding");
const contentType = webResponse.headers.get("content-type") ?? "";
const baseType = contentType.split(";")[0].trim();
const encoding = (compress && !alreadyEncoded) ? negotiateEncoding(req) : null;
const shouldCompress = !!(encoding && COMPRESSIBLE_TYPES.has(baseType));
if (shouldCompress) {
delete nodeHeaders["content-length"];
delete nodeHeaders["Content-Length"];
nodeHeaders["Content-Encoding"] = encoding!;
nodeHeaders["Vary"] = "Accept-Encoding";
}
Please just write one helper function to see if a file should be compressed or not I am begging you but besides that, why are they deleting both "content-length" and "Content-Length" from nodeHeaders? It’s unmodified ever since they copied it from the web Response.headers object, and the Headers type automatically lowercases header keys, so this is just plain misleading.
I’m going to skip a lot because this code is exhausting and I genuinely don’t want you to read it, lest it harm you.
interface AppRouterServerOptions {
port: number;
host: string;
clientDir: string;
rscEntryPath: string;
compress: boolean;
}
This is, finally, a good type. 10/10.
/**
* Start the App Router production server.
*
* The RSC entry (dist/server/index.js) exports a default handler function:
* handler(request: Request) → Promise<Response>
*
* This handler already does everything: route matching, RSC rendering,
* SSR HTML generation (via import("./ssr/index.js")), route handlers,
* server actions, ISR caching, 404s, redirects, etc.
*
* The production server's job is simply to:
* 1. Serve static assets from dist/client/
* 2. Convert Node.js IncomingMessage → Web Request
* 3. Call the RSC handler
* 4. Stream the Web Response back (with optional compression)
*/
async function startAppRouterServer(options: AppRouterServerOptions) {
// [...]
const server = createServer(async (req, res) => {
// [...]
const url = req.url ?? "/";
const pathname = url.split("?")[0];
Again, by parse-don’t-validate I’d have immediately converted this to a URL. It is kind of painful that the URL API requires a host, but eh. What’s next?
// Image optimization passthrough (Node.js prod server has no Images binding;
// serves the original file with cache headers)
if (pathname === IMAGE_OPTIMIZATION_PATH) {
const parsedUrl = new URL(url, "http://localhost");
const params = parseImageParams(parsedUrl);
oh.
Conclusion
Personally, I would not run this server in production. (To be fair, I’d say the same about NextJS for entirely unrelated reasons.)
There is a lot of code here, and it seems like everywhere I look, I see duplicate and/or wasted code. I am fairly confident the rest of the codebase is similar; one other thing that stuck out to me when I was skimming was a regular expression parser to lint against malicious double-repeats. Surely this doesn’t belong in a web server?
The texture of the code is utterly alien to me, and I no longer think state-of-the-art AI models are good.
It’s actually little baffling that these issues are here. Every issue I pointed out is trivial to fix, even by LLM standards; I’m not sure anyone has looked at this code.