Skip to content

Comments

feat: nitro preview#4024

Merged
pi0 merged 11 commits intomainfrom
feat/nitro-preview
Feb 18, 2026
Merged

feat: nitro preview#4024
pi0 merged 11 commits intomainfrom
feat/nitro-preview

Conversation

@pi0
Copy link
Member

@pi0 pi0 commented Feb 11, 2026

This PR adds new nitro preview command to preview the production build using srvx/loader or wrangler for cloudflare.

(extracted from vite plugin to also reuse for a temporary prerendering solution in next steps)

TODO:

  • preview command works with default targets
  • Cloudflare wait-for-port issue
  • Migrate vite plugin to new preview utils

@vercel
Copy link

vercel bot commented Feb 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nitro.build Ready Ready Preview, Comment Feb 18, 2026 10:34pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a new "preview" CLI command and creates a centralized preview server infrastructure for Nitro. It introduces a new preview.ts module with startPreview function to handle preview instance initialization, including build info display, environment variable loading, and both in-process and external preview server execution paths. The Vite preview plugin is refactored to use this centralized handler, and build output messages are simplified to reference the new npx nitro preview command.

Changes

Cohort / File(s) Summary
CLI Preview Command
src/cli/commands/preview.ts, src/cli/index.ts
Adds new "preview" subcommand to Nitro CLI that extends common arguments with port and host, resolves root directory, starts preview server via startPreview, serves with srvx middleware, and handles SIGINT graceful shutdown.
Core Preview Server Infrastructure
src/preview.ts
Introduces PreviewInstance interface and startPreview function to centralize preview server bootstrap. Loads Nitro build info, displays build metadata in console box, loads .env variables (.env.preview, .env.production, .env), handles in-process execution with server entry and static asset serving, and spawns external preview process for Cloudflare preset with port detection and signal handling.
Vite Preview Plugin Refactor
src/build/vite/preview.ts
Replaces legacy build info and environment variable loading, external preview spawning, and static asset handling with centralized startPreview invocation. Adds preview lifecycle management with close on server shutdown and WebSocket upgrade routing through the Nitro preview handler.
Build Output Message Simplification
src/build/rolldown/prod.ts, src/build/rollup/prod.ts, src/build/vite/prod.ts
Replaces conditional preview command hints (previously referencing buildInfo.commands!.preview) with fixed messages directing users to `npx nitro preview`, removing dynamic path rewriting logic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • feat(vite): use srvx loader for preview #3999: Modifies Vite preview pipeline to use in-process srvx-based preview instead of spawning external process, overlapping with the preview server infrastructure refactoring in this PR.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'feat:' prefix and clearly describes the main addition of a new nitro preview command.
Description check ✅ Passed The description is directly related to the changeset, explaining the purpose of the new preview command and its implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/nitro-preview

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/preview.ts`:
- Line 143: The waitForPort call in src/preview.ts currently uses retries: 20
and delay: 500 (≈10s) which is too short for Wrangler/Miniflare; update the call
to use a longer timeout or make it configurable: introduce a configurable
timeout (env var or option) such as PREVIEW_PORT_TIMEOUT_MS or
PREVIEW_PORT_RETRIES and compute either retries/delay to achieve a sensible
default (e.g., 60s+) and replace await waitForPort(randomPort, { retries: 20,
delay: 500 }) with await waitForPort(randomPort, { retries: computedRetries,
delay: computedDelay }) so callers/CI can override the wait time; ensure the new
config is documented where preview startup is invoked and default fallback is
applied when the env/option is absent.
- Around line 36-46: The loaded .env entries from loadPreviewDotEnv are not
being applied to process.env for in-process previews, so modify the preview
startup to merge those entries into process.env before launching the in-process
server path: after retrieving dotEnvEntries (the variable returned by
loadPreviewDotEnv), iterate over dotEnvEntries and assign process.env[key] = val
for each pair (but avoid overriding existing platform-managed envs if needed),
ensuring this happens before calling runPreviewCommand and before the
non-Cloudflare in-process branch that loads the server entry; keep the
Cloudflare/subprocess flow unchanged since those vars are already passed to the
subprocess.
- Line 107: The current naive split of opts.command into arg0 and args (const
[arg0, ...args] = opts.command.split(" ")) will break on quoted paths/arguments
with spaces; replace it with a proper shell-style argument parser (e.g., use
shell-quote.parse or string-argv) so quoted segments stay intact: parse
opts.command into an array and then assign const [arg0, ...args] from that
parsed array (update the code around opts.command, arg0, and args in
preview.ts).
- Around line 147-150: The current fetch handler returns fetch(url + req.url),
which breaks because ServerRequest.req.url is a full URL and also discards
method/headers/body; to fix, build the target URL by taking the incoming
request's path + search (e.g., new URL(req.url).pathname + new
URL(req.url).search) and resolve that against your base `url` variable, then
create a new Request using that resolved URL and forward req.method,
req.headers, and the body (where present) before calling `fetch`; update the
async fetch(req: ServerRequest) implementation to construct and pass this new
Request so headers, method, and body are preserved.
🧹 Nitpick comments (1)
src/preview.ts (1)

131-135: Consider also handling SIGTERM.

SIGTERM is the standard signal sent by process managers and container runtimes to request graceful shutdown, but it's not listed here. If the parent receives SIGTERM, the child process will be orphaned.

Proposed fix
-  for (const sig of ["SIGINT", "SIGHUP"] as const) {
+  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
     process.once(sig, () => {
       killChild(sig);
     });
   }

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 18, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nitrojs/nitro@4024

commit: 84334bc

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/unit/proxy.test.ts (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Use pathe instead of node:path.

🛠️ Proposed fix
-import { join } from "node:path";
+import { join } from "pathe";

As per coding guidelines: "Use pathe for cross-platform path operations instead of node:path".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/proxy.test.ts` at line 3, Replace the node:path import with pathe:
change the import of join from "node:path" to import { join } from "pathe" and
keep all uses of join in this file (e.g., in test/unit/proxy.test.ts) unchanged
so path operations become cross-platform via the pathe library.
🧹 Nitpick comments (1)
src/runner/proxy.ts (1)

142-155: Optional: remove the redundant duplex field from toInit.

Lines 80–82 of fetchAddress unconditionally set duplex = "half" whenever init.body is truthy, regardless of how init was assembled. The duplex: init.body ? "half" : undefined inside toInit's Request branch is therefore always either a no-op (body present → overwritten moments later) or introduces an explicit undefined key (no body → fetchAddress won't set it anyway). Removing it keeps toInit's responsibility clear — normalise a Request to RequestInit-shaped properties; let fetchAddress own duplex.

♻️ Proposed cleanup
 function toInit(init?: RequestInit | Request): RequestInit | undefined {
   if (!init) {
     return undefined;
   }
   if (init instanceof Request) {
     return {
       method: init.method,
       headers: init.headers,
       body: init.body,
-      duplex: init.body ? "half" : undefined,
     } as RequestInit;
   }
   return init;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runner/proxy.ts` around lines 142 - 155, Remove the redundant duplex
field from the Request branch inside toInit so it only normalizes method,
headers, and body when given a Request; leave responsibility for setting duplex
to fetchAddress (which already sets duplex = "half" based on init.body),
ensuring fetchAddress still inspects the returned RequestInit.body to set duplex
as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/preview.ts`:
- Line 112: Replace the raw console.log("") call with the project logging
utility: change the console.log("") expression to use consola (e.g.,
consola.info("") or consola.log("")) and ensure consola is imported (import
consola from 'consola') or use the existing consola instance in the module;
update the single occurrence of console.log("") to use consola so it follows the
coding guideline.
- Around line 132-136: The signal-forwarding loop in preview.ts registers
handlers for "SIGINT" and "SIGHUP" but omits "SIGTERM", so the child process may
be orphaned; update the for-loop that calls process.once(...) (the array of
signals) to include "SIGTERM" alongside "SIGINT" and "SIGHUP" so killChild(sig)
is invoked for SIGTERM as well, ensuring graceful shutdown propagation to the
child process.

---

Outside diff comments:
In `@test/unit/proxy.test.ts`:
- Line 3: Replace the node:path import with pathe: change the import of join
from "node:path" to import { join } from "pathe" and keep all uses of join in
this file (e.g., in test/unit/proxy.test.ts) unchanged so path operations become
cross-platform via the pathe library.

---

Duplicate comments:
In `@src/preview.ts`:
- Line 108: The current naive split in the preview command parsing (const [arg0,
...args] = opts.command.split(" ")) fails on quoted arguments or paths with
spaces; replace this with a proper shell-argument parser (e.g., use string-argv
or shell-quote) to parse opts.command into an argv array, then assign arg0 =
argv[0] and args = argv.slice(1); update the import to bring in the chosen
parser and adjust any downstream uses that expect separate arg0/args.
- Around line 36-47: dotEnvEntries loaded by loadPreviewDotEnv are never written
into the current process environment before the in-process preview path runs;
update process.env with those entries immediately after dotEnvEntries is
obtained (before the child-vs-in-process branch) by iterating dotEnvEntries and
assigning process.env[key] = val for each entry so the server entry loaded by
the in-process path (the module loaded at line 66) sees the variables; keep this
assignment minimal and colocated near the dotEnvEntries usage to avoid
duplicating logic with the child-process branch.
- Line 144: The current waitForPort call uses retries: 20 and delay: 500 (total
~10s) which is too short for Wrangler startup; update the
waitForPort(randomPort, { retries: 20, delay: 500, host: "localhost" })
invocation to increase the timeout (for example increase retries to ~120, or
delay to 1000 and retries to ~60, or replace with a configurable constant like
WAIT_FOR_PORT_RETRIES/WAIT_FOR_PORT_DELAY) so total wait is ~60s+; ensure
randomPort and host remain the same and consider making the timeout values
configurable or using exponential backoff to avoid hardcoding.

---

Nitpick comments:
In `@src/runner/proxy.ts`:
- Around line 142-155: Remove the redundant duplex field from the Request branch
inside toInit so it only normalizes method, headers, and body when given a
Request; leave responsibility for setting duplex to fetchAddress (which already
sets duplex = "half" based on init.body), ensuring fetchAddress still inspects
the returned RequestInit.body to set duplex as before.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/preview.ts (1)

82-88: Avoid the as any cast for the next callback.

The () => undefined as any silences a real type mismatch on the serveStatic next-handler. A typed alternative avoids suppressing TypeScript checks:

♻️ Proposed refactor
-      const staticRes: Response | undefined = await staticHandler(req, () => undefined as any);
+      const staticRes: Response | undefined = await staticHandler(req, () =>
+        undefined as unknown as Response
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/preview.ts` around lines 82 - 88, The code passes an untyped "() =>
undefined as any" into staticHandler, suppressing TypeScript checks; fix by
creating a properly typed no-op next callback using the actual parameter type of
staticHandler (e.g., const noopNext: Parameters<typeof staticHandler>[1] = () =>
undefined;) and then call staticHandler(req, noopNext) inside fetchHandler (keep
fetchHandler and originalFetchHandler unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/preview.ts`:
- Around line 65-69: The loop that merges dotEnvEntries into process.env uses a
falsy check (!process.env[key]) which accidentally treats empty strings as unset
and overwrites platform-set empty values; change the guard in the loop that
iterates dotEnvEntries to only set process.env[key] when process.env[key] ===
undefined (i.e., replace the falsy check with a strict undefined check) so
existing empty-string environment variables are preserved; reference the loop
iterating over dotEnvEntries and the process.env assignment to locate and update
the condition.
- Around line 153-162: The proxyFetch call is confusing because proxyFetch({
port: randomPort, host: "localhost" }, targetURL) ignores the hostname in
targetURL; update the fetch implementation to pass only the path+search as the
second argument (e.g., use reqURL.pathname + reqURL.search) or, if you prefer an
absolute string, pass a dummy hostname (e.g., "http://internal" +
reqURL.pathname + reqURL.search) so the intent is clear; change the variables
used in the fetch function (reqURL, targetURL) and the proxyFetch invocation
accordingly to remove the redundant/ignored hostname while keeping the same
port/host routing via randomPort and "localhost".

---

Duplicate comments:
In `@src/preview.ts`:
- Around line 137-141: The current signal forwarding loop in preview.ts only
registers SIGINT and SIGHUP, so SIGTERM sent by process managers won't be
forwarded; update the signal list used in the for loop to include "SIGTERM"
(i.e., for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"] as const)) so
process.once(...) calls will also invoke killChild(sig) for SIGTERM, ensuring
the child process receives the termination signal via the existing killChild
handler.
- Line 113: The code uses naive tokenization via opts.command.split(" ") which
breaks on quoted arguments and paths with spaces; replace this with a proper
shell-style parser (e.g., use a shell-words/shell-quote parser or a small
utility parseCommand) and then destructure the result into const [arg0, ...args]
so that quoted segments are preserved as single arguments; update the usage
around opts.command, the const [arg0, ...args] assignment, and any callers that
rely on this behavior (in src/preview.ts) to use the parsed array instead of
split(" ").
- Line 117: Replace the raw console.log("") call with the project's consola
logger: change the console.log("") invocation to use consola (e.g.,
consola.log("") or consola.info("")) and ensure consola is imported at the top
(import { consola } from 'consola') or the existing logger instance is used if
one exists; update the single raw call in preview.ts where console.log("")
appears to use consola instead.
- Line 149: The current waitForPort(randomPort, { retries: 20, delay: 500, host:
"localhost" }) caps waiting at ~10s which is too short for Wrangler; update the
wait call to allow a longer startup window (e.g. increase retries and/or delay
or use the library's timeout/timeoutMs option if available) so Cloudflare
startup can complete reliably; modify the options passed to waitForPort (the
call using randomPort) to a larger total timeout (e.g. ~60s+) and remove the
TODO comment referencing the wait-for-port issue.

---

Nitpick comments:
In `@src/preview.ts`:
- Around line 82-88: The code passes an untyped "() => undefined as any" into
staticHandler, suppressing TypeScript checks; fix by creating a properly typed
no-op next callback using the actual parameter type of staticHandler (e.g.,
const noopNext: Parameters<typeof staticHandler>[1] = () => undefined;) and then
call staticHandler(req, noopNext) inside fetchHandler (keep fetchHandler and
originalFetchHandler unchanged).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/build/vite/preview.ts (1)

29-33: Missing SIGTERM in signal handlers — same issue as in src/preview.ts.

This is consistent with the pattern in runPreviewCommand (lines 149-153 of src/preview.ts), but SIGTERM is the standard signal from process managers (systemd, Kubernetes, Docker). The preview server's close handler won't fire when the parent process receives SIGTERM.

Proposed fix
-    for (const sig of ["SIGINT", "SIGHUP"] as const) {
+    for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"] as const) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/build/vite/preview.ts` around lines 29 - 33, The signal handler loop that
registers process.on for ["SIGINT", "SIGHUP"] should also include "SIGTERM" so
the preview server's close logic is invoked for termination from process
managers; update the array in the loop that registers process.on(...) (the one
that calls await preview.close()) to include "SIGTERM" (matching the pattern
used by runPreviewCommand) so preview.close() runs on SIGTERM as well.
src/cli/commands/preview.ts (1)

42-46: Consider adding SIGTERM for container/process-manager environments.

Only SIGINT is handled. In production-like preview scenarios (Docker, CI), SIGTERM is the standard graceful shutdown signal. Without it, the server and preview child processes may be orphaned.

Proposed fix
-  process.on("SIGINT", async () => {
-    await server.close();
-    await preview.close();
-    process.exit(0);
-  });
+  for (const sig of ["SIGINT", "SIGTERM"] as const) {
+    process.on(sig, async () => {
+      await server.close();
+      await preview.close();
+      process.exit(0);
+    });
+  }
🤖 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 42 - 46, The current code only
registers a SIGINT handler (process.on("SIGINT", ...)) so add SIGTERM handling
by extracting the cleanup logic into a single async function (e.g.,
gracefulShutdown or handleShutdown) that awaits server.close() and
preview.close() and then calls process.exit(0); register that function for both
process.on("SIGINT", ...) and process.on("SIGTERM", ...); also guard the
function with a simple boolean (e.g., isShuttingDown) to prevent
double-invocation if both signals arrive.
src/preview.ts (1)

155-161: Child crash before port is ready causes a silent 10s hang instead of a fast failure.

If the spawned process exits with an error before waitForPort succeeds, the code waits the full retry window (20 × 500ms = 10s) before timing out. Linking the child's exit event to reject waitForPort early would improve developer experience.

Proposed approach
+  const childExited = new Promise<never>((_, reject) => {
+    child.once("exit", (code) => {
+      if (code && code !== 0) {
+        reject(new Error(`Preview server exited with code ${code}`));
+      }
+    });
+  });
+
-  child.once("exit", (code) => {
-    if (code && code !== 0) {
-      consola.error(`[nitro] Preview server exited with code ${code}`);
-    }
-  });
-
-  await waitForPort(randomPort, { retries: 20, delay: 500, host: "localhost" });
+  await Promise.race([
+    waitForPort(randomPort, { retries: 20, delay: 500, host: "localhost" }),
+    childExited,
+  ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/preview.ts` around lines 155 - 161, The current logic waits the full
waitForPort retry window even if the spawned process (child) already exited, so
change the flow to race waitForPort(randomPort, ...) against the child's exit:
create a Promise that listens to child.once('exit', (code) => { ... }) and
rejects immediately when code is non-zero (or if the child was killed), then
Promise.race that with waitForPort(randomPort, { retries: 20, delay: 500, host:
"localhost" }); ensure the exit listener is cleaned up when waitForPort succeeds
and the waitForPort promise is cleaned up if the exit handler fires, and surface
the child's exit code/error (used in consola.error) on rejection for faster
failure feedback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/commands/preview.ts`:
- Around line 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.

In `@src/preview.ts`:
- Around line 103-108: The in-process preview returned by loadServerEntry does
not expose an upgrade handler, so WebSocket upgrade events are dropped; modify
the PreviewInstance returned in src/preview.ts to include an async upgrade(req,
socket, head) method that forwards to the underlying entry.upgrade if present
(or wraps proxyUpgrade like spawned previews do), i.e., detect and expose
entry.upgrade from loadServerEntry (or call proxyUpgrade with the same semantics
used by spawned previews) so server.node?.server?.on("upgrade", ...) sees
preview.upgrade and can proxy WebSocket connections; ensure the new upgrade
handler preserves the same parameters and error handling as the existing
proxyUpgrade path.

---

Duplicate comments:
In `@src/preview.ts`:
- Around line 76-81: The loop that copies dotEnvEntries into process.env (inside
the buildInfo.serverEntry conditional) currently treats empty-string values as
unset by checking if (!process.env[key]); change that check to test for
undefined (process.env[key] === undefined) so platform-set empty-string
variables are preserved; update the for-loop handling of dotEnvEntries
accordingly (refer to buildInfo.serverEntry, dotEnvEntries, and
process.env[key]).
- Line 129: Replace the direct console call in src/preview.ts (the
console.log("") statement) with the project's consola logger: import consola if
it's not already imported and use an appropriate consola method (e.g.,
consola.info or consola.log) instead of console.log(""); ensure the import/usage
follows existing project logging conventions so build/dev logs use consola
uniformly.
- Around line 149-153: The signal-forwarding loop in src/preview.ts only listens
for "SIGINT" and "SIGHUP", so the child process isn't forwarded "SIGTERM" (used
by systemd/Kubernetes/Docker); update the array passed to the loop (the
["SIGINT","SIGHUP"] constant) to include "SIGTERM" and ensure the existing
process.once(sig, () => killChild(sig)) behavior is applied to it as well so
killChild receives and forwards "SIGTERM" to the child.
- Around line 120-125: The command parsing in runPreviewCommand currently uses
naive opts.command.split(" "), which breaks on quoted arguments or paths with
spaces; replace this with a shell-aware argument parser (e.g., use a small
helper or a library such as shell-quote or string-argv) to parse opts.command
into argv while preserving quoted segments and escapes, then continue to
destructure into [arg0, ...args] as before so subsequent code using arg0 and
args works correctly.

---

Nitpick comments:
In `@src/build/vite/preview.ts`:
- Around line 29-33: The signal handler loop that registers process.on for
["SIGINT", "SIGHUP"] should also include "SIGTERM" so the preview server's close
logic is invoked for termination from process managers; update the array in the
loop that registers process.on(...) (the one that calls await preview.close())
to include "SIGTERM" (matching the pattern used by runPreviewCommand) so
preview.close() runs on SIGTERM as well.

In `@src/cli/commands/preview.ts`:
- Around line 42-46: The current code only registers a SIGINT handler
(process.on("SIGINT", ...)) so add SIGTERM handling by extracting the cleanup
logic into a single async function (e.g., gracefulShutdown or handleShutdown)
that awaits server.close() and preview.close() and then calls process.exit(0);
register that function for both process.on("SIGINT", ...) and
process.on("SIGTERM", ...); also guard the function with a simple boolean (e.g.,
isShuttingDown) to prevent double-invocation if both signals arrive.

In `@src/preview.ts`:
- Around line 155-161: The current logic waits the full waitForPort retry window
even if the spawned process (child) already exited, so change the flow to race
waitForPort(randomPort, ...) against the child's exit: create a Promise that
listens to child.once('exit', (code) => { ... }) and rejects immediately when
code is non-zero (or if the child was killed), then Promise.race that with
waitForPort(randomPort, { retries: 20, delay: 500, host: "localhost" }); ensure
the exit listener is cleaned up when waitForPort succeeds and the waitForPort
promise is cleaned up if the exit handler fires, and surface the child's exit
code/error (used in consola.error) on rejection for faster failure feedback.

Comment on lines +21 to +34
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 },
});
Copy link

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.

@pi0 pi0 merged commit 775e512 into main Feb 18, 2026
7 of 8 checks passed
@pi0 pi0 deleted the feat/nitro-preview branch February 18, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant