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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions src/build/rolldown/prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,7 @@ export async function buildProduction(nitro: Nitro, config: RolldownOptions) {
const rewriteRelativePaths = (input: string) => {
return input.replace(/([\s:])\.\/(\S*)/g, `$1${rOutput}/$2`);
};
if (buildInfo.commands!.preview) {
nitro.logger.success(
`You can preview this build using \`${rewriteRelativePaths(buildInfo.commands!.preview)}\``
);
}
nitro.logger.success(`You can preview this build using \`npx nitro preview\``);
if (buildInfo.commands!.deploy) {
nitro.logger.success(
`You can deploy this build using \`${rewriteRelativePaths(buildInfo.commands!.deploy)}\``
Expand Down
6 changes: 1 addition & 5 deletions src/build/rollup/prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,7 @@ export async function buildProduction(nitro: Nitro, rollupConfig: RollupConfig)
const rewriteRelativePaths = (input: string) => {
return input.replace(/([\s:])\.\/(\S*)/g, `$1${rOutput}/$2`);
};
if (buildInfo.commands!.preview) {
nitro.logger.success(
`You can preview this build using \`${rewriteRelativePaths(buildInfo.commands!.preview)}\``
);
}
nitro.logger.success(`You can preview this build using \`npx nitro preview\``);
if (buildInfo.commands!.deploy) {
nitro.logger.success(
`You can deploy this build using \`${rewriteRelativePaths(buildInfo.commands!.deploy)}\``
Expand Down
175 changes: 20 additions & 155 deletions src/build/vite/preview.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import type { Plugin as VitePlugin, PreviewServer } from "vite";
import type { Plugin as VitePlugin } from "vite";
import type { NitroPluginContext } from "./types.ts";
import { spawn } from "node:child_process";
import consola from "consola";
import { join, resolve } from "pathe";
import { prettyPath } from "../../utils/fs.ts";
import { getBuildInfo } from "../info.ts";
import { startPreview } from "../../preview.ts";

export function nitroPreviewPlugin(ctx: NitroPluginContext): VitePlugin {
return {
Expand All @@ -20,162 +16,31 @@ export function nitroPreviewPlugin(ctx: NitroPluginContext): VitePlugin {
},

async configurePreviewServer(server) {
const { outputDir, buildInfo } = await getBuildInfo(server.config.root);
if (!buildInfo) {
throw this.error("Cannot load nitro build info. Make sure to build first.");
}

const info = [
["Build Directory:", prettyPath(outputDir)],
["Date:", buildInfo.date && new Date(buildInfo.date).toLocaleString()],
["Nitro Version:", buildInfo.versions.nitro],
["Nitro Preset:", buildInfo.preset],
buildInfo.framework?.name !== "nitro" && [
"Framework:",
buildInfo.framework?.name +
(buildInfo.framework?.version ? ` (v${buildInfo.framework.version})` : ""),
],
].filter((i) => i && i[1]) as [string, string][];
consola.box({
title: " [Build Info] ",
message: info.map((i) => `- ${i[0]} ${i[1]}`).join("\n"),
// Init Nitro preview handler
const preview = await startPreview({
rootDir: server.config.root,
loader: { nodeServer: server.httpServer },
});

// Load .env files for preview mode
const dotEnvEntries = await loadPreviewDotEnv(server.config.root);
if (dotEnvEntries.length > 0) {
consola.box({
title: " [Environment Variables] ",
message: [
"Loaded variables from .env files (preview mode only).",
"Set platform environment variables for production:",
...dotEnvEntries.map(([key, val]) => ` - ${key}`),
].join("\n"),
});
}

// Currently cloudflare preset strictly requires preview command
if (buildInfo.preset.includes("cloudflare")) {
if (!buildInfo.commands?.preview) {
throw this.error(
`No nitro build preview command found for the "${buildInfo.preset}" preset.`
);
}
await runPreviewCommand({
server,
command: buildInfo.commands.preview,
cwd: server.config.root,
});
return;
}
// Close preview server when Vite's preview server is closed
server.httpServer.once("close", async () => {
await preview.close();
});

// Import handler and use in-process function calling
// Handle all requests with Nitro preview handler (also handles production static assets)
const { NodeRequest, sendNodeResponse } = await import("srvx/node");
server.middlewares.use(async (req, res, next) => {
const nodeReq = new NodeRequest({ req, res });
const previewRes: Response = await preview.fetch(nodeReq);
await sendNodeResponse(res, previewRes).catch(next);
});

if (buildInfo.publicDir) {
const { serveStatic } = await import("srvx/static");
const staticHandler = serveStatic({ dir: join(outputDir, buildInfo.publicDir) });

server.middlewares.use(async (req, res, next) => {
const nodeReq = new NodeRequest({ req, res });
const staticRes: Response | undefined = await staticHandler(
nodeReq,
() => undefined as any
);
if (staticRes) {
await sendNodeResponse(res, staticRes).catch(next);
} else {
next();
}
});
}

if (buildInfo.serverEntry) {
const { loadServerEntry } = await import("srvx/loader");
const entryPath = resolve(outputDir, buildInfo.serverEntry);
const entry = await loadServerEntry({ entry: entryPath });
if (entry.notFound || !entry.fetch) {
throw new Error(`Cannot load nitro server entry: ${entryPath}`);
}
server.middlewares.use(async (req, res, next) => {
const nodeReq = new NodeRequest({ req, res });
await sendNodeResponse(res, await entry.fetch!(nodeReq)).catch(next);
// Handle WebSocket upgrade requests with Nitro preview handler if supported
if (preview.upgrade) {
server.httpServer.on("upgrade", (req, socket, head) => {
preview.upgrade!(req, socket, head);
});
return;
}
},
} satisfies VitePlugin;
}

async function loadPreviewDotEnv(root: string): Promise<[string, string][]> {
const { loadDotenv } = await import("c12");
const env = await loadDotenv({
cwd: root,
fileName: [".env.preview", ".env.production", ".env"],
});
return Object.entries(env).filter(([_key, val]) => val) as [string, string][];
}

async function runPreviewCommand(opts: {
server: PreviewServer;
command: string;
cwd: string;
env?: [string, string][];
}) {
const [arg0, ...args] = opts.command.split(" ");

consola.info(`Spawning preview server...`);
consola.info(opts.command);
console.log("");

const { getRandomPort, waitForPort } = await import("get-port-please");
const randomPort = await getRandomPort();
const child = spawn(arg0, [...args, "--port", String(randomPort)], {
stdio: "inherit",
cwd: opts.cwd,
env: {
...process.env,
...Object.fromEntries(opts.env ?? []),
PORT: String(randomPort),
},
});

const killChild = (signal: NodeJS.Signals) => {
if (child && !child.killed) {
child.kill(signal);
}
};

for (const sig of ["SIGINT", "SIGHUP"] as const) {
process.once(sig, () => {
consola.info(`Stopping preview server...`);
killChild(sig);
process.exit();
});
}

opts.server.httpServer.once("close", () => {
killChild("SIGTERM");
});

child.once("exit", (code) => {
if (code && code !== 0) {
consola.error(`[nitro] Preview server exited with code ${code}`);
}
});

const { createProxyServer } = await import("httpxy");
const proxy = createProxyServer({
target: `http://localhost:${randomPort}`,
});

opts.server.middlewares.use((req, res, next) => {
if (child && !child.killed) {
proxy.web(req, res).catch(next);
} else {
res.end(`Nitro preview server is not running.`);
}
});

await waitForPort(randomPort, { retries: 20, delay: 500 });
}
6 changes: 1 addition & 5 deletions src/build/vite/prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ export async function buildEnvironments(ctx: NitroPluginContext, builder: ViteBu
};

if (!isTest && !isCI) console.log();
if (nitro.options.commands.preview) {
nitro.logger.success(
`You can preview this build using \`${rewriteRelativePaths(nitro.options.commands.preview)}\``
);
}
nitro.logger.success(`You can preview this build using \`npx vite preview\``);
if (nitro.options.commands.deploy) {
nitro.logger.success(
`You can deploy this build using \`${rewriteRelativePaths(nitro.options.commands.deploy)}\``
Expand Down
48 changes: 48 additions & 0 deletions src/cli/commands/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { defineCommand } from "citty";
import { resolve } from "pathe";
import { commonArgs } from "../common.ts";
import { startPreview } from "../../preview.ts";
import { serve } from "srvx";
import { log } from "srvx/log";

export default defineCommand({
meta: {
name: "preview",
description: "Start a local server to preview the built server",
},
args: {
...commonArgs,
port: { type: "string", description: "specify port" },
host: { type: "string", description: "specify hostname" },
},
async run({ args }) {
const rootDir = resolve((args.dir || args._dir || ".") as string);

const server = serve({
fetch(req) {
return preview.fetch(req);
},
middleware: [log()],
gracefulShutdown: false,
port: args.port,
hostname: args.host,
});

const preview = await startPreview({
rootDir,
loader: { srvxServer: server },
});
Comment on lines +21 to +34
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.

⚠️ Potential issue | 🟠 Major

TDZ race: preview is accessed inside fetch before const declaration executes.

preview is declared with const on line 31 but referenced in the fetch closure on line 23. If any request arrives between serve() starting the listener and await startPreview() resolving, accessing preview throws a ReferenceError due to the Temporal Dead Zone.

Since serve() needs to be created first (to pass server into startPreview via loader), use a let variable declared before serve():

Proposed fix
+  let preview: Awaited<ReturnType<typeof startPreview>>;
+
   const server = serve({
-    fetch(req) {
-      return preview.fetch(req);
+    async fetch(req) {
+      if (!preview) {
+        return new Response("Server is starting...", { status: 503 });
+      }
+      return preview!.fetch(req);
     },
     middleware: [log()],
     gracefulShutdown: false,
     port: args.port,
     hostname: args.host,
   });

-  const preview = await startPreview({
+  preview = await startPreview({
     rootDir,
     loader: { srvxServer: server },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const server = serve({
fetch(req) {
return preview.fetch(req);
},
middleware: [log()],
gracefulShutdown: false,
port: args.port,
hostname: args.host,
});
const preview = await startPreview({
rootDir,
loader: { srvxServer: server },
});
let preview: Awaited<ReturnType<typeof startPreview>>;
const server = serve({
async fetch(req) {
if (!preview) {
return new Response("Server is starting...", { status: 503 });
}
return preview!.fetch(req);
},
middleware: [log()],
gracefulShutdown: false,
port: args.port,
hostname: args.host,
});
preview = await startPreview({
rootDir,
loader: { srvxServer: server },
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/commands/preview.ts` around lines 21 - 34, Declare preview with let
before calling serve so the fetch closure doesn't access a const in the TDZ;
e.g., move the declaration of preview (from const preview = ...) to a prior let
preview: ReturnType<typeof startPreview> | undefined (or any), then call serve({
fetch(req) { return preview!.fetch(req); }, ... }) and after that assign preview
= await startPreview({ rootDir, loader: { srvxServer: server } }); ensuring you
update the type annotation if needed and keep the same identifiers preview,
serve, server, and startPreview.


if (preview.upgrade) {
server.node?.server?.on("upgrade", (req, socket, head) => {
preview.upgrade!(req, socket, head);
});
}

process.on("SIGINT", async () => {
await server.close();
await preview.close();
process.exit(0);
});
},
});
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const main = defineCommand({
build: () => import("./commands/build.ts").then((r) => r.default),
prepare: () => import("./commands/prepare.ts").then((r) => r.default),
task: () => import("./commands/task/index.ts").then((r) => r.default),
preview: () => import("./commands/preview.ts").then((r) => r.default),
},
});

Expand Down
Loading
Loading