From 02e758793e49844628ff42407477a570d6670211 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:13:40 -0700 Subject: [PATCH 1/4] fix(nitro): support React Router Vite builds --- .changeset/react-router-nitro-cleanup.md | 5 + .../content/docs/v5/getting-started/index.mdx | 7 + .../content/docs/v5/getting-started/meta.json | 1 + .../v5/getting-started/react-router/index.mdx | 44 ++++ .../v5/getting-started/react-router/meta.json | 5 + .../v5/getting-started/react-router/v7.mdx | 248 ++++++++++++++++++ .../v5/getting-started/react-router/v8.mdx | 237 +++++++++++++++++ packages/nitro/src/builders.ts | 96 +++++-- packages/nitro/src/index.test.ts | 112 +++++++- packages/nitro/src/index.ts | 2 + packages/nitro/src/vite.ts | 16 +- 11 files changed, 743 insertions(+), 30 deletions(-) create mode 100644 .changeset/react-router-nitro-cleanup.md create mode 100644 docs/content/docs/v5/getting-started/react-router/index.mdx create mode 100644 docs/content/docs/v5/getting-started/react-router/meta.json create mode 100644 docs/content/docs/v5/getting-started/react-router/v7.mdx create mode 100644 docs/content/docs/v5/getting-started/react-router/v8.mdx diff --git a/.changeset/react-router-nitro-cleanup.md b/.changeset/react-router-nitro-cleanup.md new file mode 100644 index 0000000000..90740c15b5 --- /dev/null +++ b/.changeset/react-router-nitro-cleanup.md @@ -0,0 +1,5 @@ +--- +'@workflow/nitro': patch +--- + +Dispose watch-mode build contexts when Vite closes a temporary Nitro server. diff --git a/docs/content/docs/v5/getting-started/index.mdx b/docs/content/docs/v5/getting-started/index.mdx index 0c8b28165a..2d2df98e0c 100644 --- a/docs/content/docs/v5/getting-started/index.mdx +++ b/docs/content/docs/v5/getting-started/index.mdx @@ -9,6 +9,7 @@ related: --- import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStack, Vite, Express, Nest, Fastify, Python } from "@/app/[lang]/(home)/components/frameworks"; +import { SiReactrouter } from "@icons-pack/react-simple-icons"; @@ -20,6 +21,12 @@ import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStac Vite + +
+ + React Router +
+
diff --git a/docs/content/docs/v5/getting-started/meta.json b/docs/content/docs/v5/getting-started/meta.json index 426a227339..f54abe5908 100644 --- a/docs/content/docs/v5/getting-started/meta.json +++ b/docs/content/docs/v5/getting-started/meta.json @@ -2,6 +2,7 @@ "title": "Getting Started", "pages": [ "next", + "react-router", "astro", "express", "fastify", diff --git a/docs/content/docs/v5/getting-started/react-router/index.mdx b/docs/content/docs/v5/getting-started/react-router/index.mdx new file mode 100644 index 0000000000..bf07e428f1 --- /dev/null +++ b/docs/content/docs/v5/getting-started/react-router/index.mdx @@ -0,0 +1,44 @@ +--- +title: React Router +description: Run durable workflows in a React Router framework-mode app using Nitro. +type: overview +summary: Choose your React Router version and connect React Router, Nitro, and Workflow SDK. +related: + - /docs/getting-started/nitro + - /docs/getting-started/vite + - /docs/foundations/workflows-and-steps +--- + +React Router framework mode builds the browser application and its server-rendering code, but it still needs a server to receive requests. [Nitro](https://v3.nitro.build) provides that server. Workflow SDK integrates with Nitro to add the durable workflow routes and build artifacts. + +The three pieces share one Vite build: + +1. **React Router** builds your routes, loaders, actions, and browser assets. +2. **Nitro** runs the React Router request handler and any routes in `server/routes`. +3. **Workflow SDK** finds files with `"use workflow"` and `"use step"`, then adds its runtime routes to Nitro. + +Choose the guide that matches your React Router major version: + + + + + + + + These guides require **Nitro v3**. Nitro v2 does not provide the Vite + environment integration used by this setup. + + +## What the bridge does + +The setup adds a small `server/ssr.ts` file. It turns React Router's generated server build into a standard Fetch API handler that Nitro can run. The Vite config then points Nitro's server and public output at the same `build` directory React Router uses. + +This is configuration in your application, not a separate React Router adapter. Your React Router routes remain React Router routes, while Nitro owns the HTTP server and Workflow SDK uses Nitro's lifecycle and routing. diff --git a/docs/content/docs/v5/getting-started/react-router/meta.json b/docs/content/docs/v5/getting-started/react-router/meta.json new file mode 100644 index 0000000000..7da9151a4c --- /dev/null +++ b/docs/content/docs/v5/getting-started/react-router/meta.json @@ -0,0 +1,5 @@ +{ + "title": "React Router", + "pages": ["v7", "v8"], + "defaultOpen": true +} diff --git a/docs/content/docs/v5/getting-started/react-router/v7.mdx b/docs/content/docs/v5/getting-started/react-router/v7.mdx new file mode 100644 index 0000000000..2fc63810df --- /dev/null +++ b/docs/content/docs/v5/getting-started/react-router/v7.mdx @@ -0,0 +1,248 @@ +--- +title: React Router v7 +description: Add durable workflows to a React Router v7 framework-mode app using Nitro v3. +type: guide +summary: Enable the Vite Environment API and configure React Router v7, Nitro v3, and Workflow SDK. +prerequisites: + - /docs/getting-started/react-router +related: + - /docs/getting-started/nitro + - /docs/foundations/workflows-and-steps +--- + +This guide starts with an existing React Router v7 framework-mode app. It uses the Vite Environment API available in recent React Router v7 releases, including v7.18.1. + + + + + +## Install Nitro and Workflow SDK + +Install the two additional packages: + +```package-install +npm install nitro workflow +``` + + + Use Nitro v3 for this integration. You can check the installed version with + `npm ls nitro`. + + + + + + +## Enable the Vite Environment API + +React Router v7 keeps the Vite Environment API behind a future flag. Enable it and set an explicit build directory: + +```typescript title="react-router.config.ts" lineNumbers +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + buildDirectory: "build", // [!code highlight] + future: { + v8_viteEnvironmentApi: true, // [!code highlight] + }, +} satisfies Config; +``` + + + The `v8_viteEnvironmentApi` flag is required on React Router v7. Without it, + React Router uses its older build pipeline and cannot share Nitro's custom + server environment correctly. + + + + + + +## Create the React Router server handler + +Create `server/ssr.ts`: + +```typescript title="server/ssr.ts" lineNumbers +import { createRequestHandler } from "react-router"; + +export default { + fetch: createRequestHandler( + () => import("virtual:react-router/server-build"), + import.meta.env.MODE, + ), +}; +``` + +React Router's Vite plugin generates `virtual:react-router/server-build`. This file gives Nitro a normal `fetch` handler that forwards each application request to that generated server build. + + + + + +## Configure Vite + +Update `vite.config.ts`: + +```typescript title="vite.config.ts" lineNumbers +import { reactRouter } from "@react-router/dev/vite"; +import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; +import { workflow } from "workflow/vite"; +import reactRouterConfig from "./react-router.config"; + +export default defineConfig({ + plugins: [ + reactRouter(), + nitro({ + serverDir: "./server", + output: { + dir: reactRouterConfig.buildDirectory, + serverDir: `${reactRouterConfig.buildDirectory}/server`, + publicDir: `${reactRouterConfig.buildDirectory}/client`, + }, + }), + workflow({ dirs: ["workflows"] }), + ], + environments: { + ssr: { + build: { + rollupOptions: { + input: "./server/ssr.ts", + }, + }, + }, + }, +}); +``` + +Each part has one job: + +- `reactRouter()` creates the client and server-rendering environments. +- `nitro()` runs `server/ssr.ts` and writes its output beside React Router's output. +- `workflow({ dirs: ["workflows"] })` compiles only your workflow source directory and adds Workflow SDK's routes to Nitro. +- The custom `ssr` input tells Vite which file connects Nitro to React Router. + + + Keep `dirs: ["workflows"]`. Without a source-only directory, a later build + can scan the generated `build` directory and try to compile server output as + workflow source. + + +If your config already has plugins such as Tailwind CSS, keep them in the array. Preserve the relative order of `reactRouter()`, `nitro()`, and `workflow()` shown above. + + + + + +## Use Vite for the app scripts + +Run the shared build through Vite so all three plugins participate: + +```json title="package.json" lineNumbers +{ + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node ./build/server/index.mjs", + "typegen": "react-router typegen" + } +} +``` + + + + + +## Create a workflow + +Create `workflows/greeting.ts`: + +```typescript title="workflows/greeting.ts" lineNumbers +export async function greetingWorkflow(name: string) { + "use workflow"; + + return greet(name); +} + +async function greet(name: string) { + "use step"; + + return `Hello, ${name}!`; +} +``` + + + + + +## Start the workflow from a Nitro route + +Create `server/routes/api/greeting.post.ts`: + +```typescript title="server/routes/api/greeting.post.ts" lineNumbers +import { defineHandler } from "nitro"; +import { start } from "workflow/api"; +import { greetingWorkflow } from "../../../workflows/greeting"; + +export default defineHandler(async (event) => { + const { name } = (await event.req.json()) as { name: string }; + const run = await start(greetingWorkflow, [name]); + + return { runId: run.runId }; +}); +``` + +React Router continues to handle your application routes. Nitro handles this server route at `POST /api/greeting`, as well as Workflow SDK's internal routes. + + + + + +## Run the app + +Start development: + +```bash +npm run dev +``` + +Then start a workflow: + +```bash +curl -X POST \ + -H "content-type: application/json" \ + -d '{"name":"Workflow"}' \ + http://localhost:3000/api/greeting +``` + +For a production check, run: + +```bash +npm run build +npm run start +``` + +You can inspect local runs with `npx workflow web`. + + + + + +## Troubleshooting + +### Vite reports an invalid SSR input or `path.replace is not a function` + +Confirm that `future.v8_viteEnvironmentApi` is `true` in `react-router.config.ts`. That flag makes React Router v7 use the environment-aware build pipeline required by Nitro. + +### React Router pages return 404 + +Confirm that `environments.ssr.build.rollupOptions.input` points to `./server/ssr.ts` and that the file exports the `fetch` handler shown above. + +### A second build tries to compile files under `build/server` + +Confirm that the Workflow plugin uses `workflow({ dirs: ["workflows"] })`. Also remove the existing `build` directory once before rebuilding. + +### `vite build` finishes output but does not exit + +Upgrade to a Workflow SDK version that includes Nitro build-context cleanup and use a current Nitro v3 release. diff --git a/docs/content/docs/v5/getting-started/react-router/v8.mdx b/docs/content/docs/v5/getting-started/react-router/v8.mdx new file mode 100644 index 0000000000..a7e601dbed --- /dev/null +++ b/docs/content/docs/v5/getting-started/react-router/v8.mdx @@ -0,0 +1,237 @@ +--- +title: React Router v8 +description: Add durable workflows to a React Router v8 framework-mode app using Nitro v3. +type: guide +summary: Configure React Router v8, Nitro v3, and Workflow SDK in one Vite build. +prerequisites: + - /docs/getting-started/react-router +related: + - /docs/getting-started/nitro + - /docs/foundations/workflows-and-steps +--- + +This guide starts with an existing React Router v8 framework-mode app. You will add Nitro as the server, connect React Router's server build to it, and enable Workflow SDK. + + + + + +## Install Nitro and Workflow SDK + +Install the two additional packages: + +```package-install +npm install nitro workflow +``` + + + Use Nitro v3 for this integration. You can check the installed version with + `npm ls nitro`. + + + + + + +## Use a shared build directory + +Set an explicit build directory in your React Router config: + +```typescript title="react-router.config.ts" lineNumbers +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + buildDirectory: "build", // [!code highlight] +} satisfies Config; +``` + +React Router will place browser assets in `build/client`. Nitro will place the runnable server in `build/server`. + + + + + +## Create the React Router server handler + +Create `server/ssr.ts`: + +```typescript title="server/ssr.ts" lineNumbers +import { createRequestHandler } from "react-router"; + +export default { + fetch: createRequestHandler( + () => import("virtual:react-router/server-build"), + import.meta.env.MODE, + ), +}; +``` + +React Router's Vite plugin generates `virtual:react-router/server-build`. This file gives Nitro a normal `fetch` handler that forwards each application request to that generated server build. + + + + + +## Configure Vite + +Update `vite.config.ts`: + +```typescript title="vite.config.ts" lineNumbers +import { reactRouter } from "@react-router/dev/vite"; +import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; +import { workflow } from "workflow/vite"; +import reactRouterConfig from "./react-router.config"; + +export default defineConfig({ + plugins: [ + reactRouter(), + nitro({ + serverDir: "./server", + output: { + dir: reactRouterConfig.buildDirectory, + serverDir: `${reactRouterConfig.buildDirectory}/server`, + publicDir: `${reactRouterConfig.buildDirectory}/client`, + }, + }), + workflow({ dirs: ["workflows"] }), + ], + environments: { + ssr: { + build: { + rollupOptions: { + input: "./server/ssr.ts", + }, + }, + }, + }, +}); +``` + +Each part has one job: + +- `reactRouter()` creates the client and server-rendering environments. +- `nitro()` runs `server/ssr.ts` and writes its output beside React Router's output. +- `workflow({ dirs: ["workflows"] })` compiles only your workflow source directory and adds Workflow SDK's routes to Nitro. +- The custom `ssr` input tells Vite which file connects Nitro to React Router. + + + Keep `dirs: ["workflows"]`. Without a source-only directory, a later build + can scan the generated `build` directory and try to compile server output as + workflow source. + + +If your config already has plugins such as Tailwind CSS, keep them in the array. Preserve the relative order of `reactRouter()`, `nitro()`, and `workflow()` shown above. + + + + + +## Use Vite for the app scripts + +Run the shared build through Vite so all three plugins participate: + +```json title="package.json" lineNumbers +{ + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node ./build/server/index.mjs", + "typegen": "react-router typegen" + } +} +``` + + + + + +## Create a workflow + +Create `workflows/greeting.ts`: + +```typescript title="workflows/greeting.ts" lineNumbers +export async function greetingWorkflow(name: string) { + "use workflow"; + + return greet(name); +} + +async function greet(name: string) { + "use step"; + + return `Hello, ${name}!`; +} +``` + + + + + +## Start the workflow from a Nitro route + +Create `server/routes/api/greeting.post.ts`: + +```typescript title="server/routes/api/greeting.post.ts" lineNumbers +import { defineHandler } from "nitro"; +import { start } from "workflow/api"; +import { greetingWorkflow } from "../../../workflows/greeting"; + +export default defineHandler(async (event) => { + const { name } = (await event.req.json()) as { name: string }; + const run = await start(greetingWorkflow, [name]); + + return { runId: run.runId }; +}); +``` + +React Router continues to handle your application routes. Nitro handles this server route at `POST /api/greeting`, as well as Workflow SDK's internal routes. + + + + + +## Run the app + +Start development: + +```bash +npm run dev +``` + +Then start a workflow: + +```bash +curl -X POST \ + -H "content-type: application/json" \ + -d '{"name":"Workflow"}' \ + http://localhost:3000/api/greeting +``` + +For a production check, run: + +```bash +npm run build +npm run start +``` + +You can inspect local runs with `npx workflow web`. + + + + + +## Troubleshooting + +### React Router pages return 404 + +Confirm that `environments.ssr.build.rollupOptions.input` points to `./server/ssr.ts` and that the file exports the `fetch` handler shown above. + +### A second build tries to compile files under `build/server` + +Confirm that the Workflow plugin uses `workflow({ dirs: ["workflows"] })`. Also remove the existing `build` directory once before rebuilding. + +### `vite build` finishes output but does not exit + +Upgrade to a Workflow SDK version that includes Nitro build-context cleanup and use a current Nitro v3 release. diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 8667feb574..9b074e4848 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -64,6 +64,8 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { export class LocalBuilder extends BaseBuilder { #outDir: string; + #buildContexts: Array<{ dispose(): Promise }> = []; + #closed = false; constructor(nitro: Nitro) { const outDir = join(nitro.options.buildDir, 'workflow'); super({ @@ -85,6 +87,10 @@ export class LocalBuilder extends BaseBuilder { #buildQueue: Promise = Promise.resolve(); override build(): Promise { + if (this.#closed) { + return Promise.reject(new Error('Cannot build after builder is closed')); + } + const next = this.#buildQueue.then( () => this.#buildOnce(), () => this.#buildOnce() @@ -96,6 +102,30 @@ export class LocalBuilder extends BaseBuilder { return next; } + close(): Promise { + this.#closed = true; + const next = this.#buildQueue.then( + () => this.#disposeBuildContexts(), + () => this.#disposeBuildContexts() + ); + this.#buildQueue = next.catch(() => {}); + return next; + } + + async #disposeBuildContexts(): Promise { + const contexts = this.#buildContexts; + this.#buildContexts = []; + await Promise.all(contexts.map((context) => context.dispose())); + } + + async #replaceBuildContexts( + contexts: Array<{ dispose(): Promise }> + ): Promise { + const previousContexts = this.#buildContexts; + this.#buildContexts = contexts; + await Promise.all(previousContexts.map((context) => context.dispose())); + } + async #buildOnce(): Promise { const inputFiles = await this.getInputFiles(); await mkdir(this.#outDir, { recursive: true }); @@ -104,34 +134,46 @@ export class LocalBuilder extends BaseBuilder { // name in its import statement, so we build directly to final names. // (The V1 atomic tmp-file pattern doesn't work here because renaming // the steps file would leave the flow route's import stale.) - const { manifest } = await this.createCombinedBundle({ - inputFiles, - stepsOutfile: join(this.#outDir, 'steps.mjs'), - flowOutfile: join(this.#outDir, 'workflows.mjs'), - format: 'esm', - // bundleFinalOutput: false — Nitro externalizes the workflow build dir - // during dev, and its own rollup pipeline handles bundling for prod. - // Using true causes "Dynamic require of X is not supported" errors - // because esbuild wraps CJS require() calls in ESM output. - bundleFinalOutput: false, - externalizeNonSteps: true, - // In dev, Nitro dynamically imports the generated workflow files from - // disk, so there is no later Rollup pass to resolve externalized local - // TypeScript imports. In prod, Nitro/Rollup handles those imports. - bundleTransitiveLocalStepDependencies: this.config.watch, - }); + const { manifest, stepsContext, interimBundleCtx } = + await this.createCombinedBundle({ + inputFiles, + stepsOutfile: join(this.#outDir, 'steps.mjs'), + flowOutfile: join(this.#outDir, 'workflows.mjs'), + format: 'esm', + // bundleFinalOutput: false — Nitro externalizes the workflow build dir + // during dev, and its own rollup pipeline handles bundling for prod. + // Using true causes "Dynamic require of X is not supported" errors + // because esbuild wraps CJS require() calls in ESM output. + bundleFinalOutput: false, + externalizeNonSteps: true, + // In dev, Nitro dynamically imports the generated workflow files from + // disk, so there is no later Rollup pass to resolve externalized local + // TypeScript imports. In prod, Nitro/Rollup handles those imports. + bundleTransitiveLocalStepDependencies: this.config.watch, + }); - await this.createWebhookBundle({ - outfile: join(this.#outDir, 'webhook.mjs'), - bundle: false, - }); + const buildContexts = [stepsContext, interimBundleCtx].filter( + (context): context is NonNullable => context != null + ); - // Generate manifest - const workflowBundlePath = join(this.#outDir, 'workflows.mjs'); - await this.createManifest({ - workflowBundlePath, - manifestDir: this.#outDir, - manifest, - }); + try { + await this.createWebhookBundle({ + outfile: join(this.#outDir, 'webhook.mjs'), + bundle: false, + }); + + // Generate manifest + const workflowBundlePath = join(this.#outDir, 'workflows.mjs'); + await this.createManifest({ + workflowBundlePath, + manifestDir: this.#outDir, + manifest, + }); + } catch (error) { + await Promise.all(buildContexts.map((context) => context.dispose())); + throw error; + } + + await this.#replaceBuildContexts(buildContexts); } } diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index d5aea69863..c07b38ef69 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -1,7 +1,8 @@ -import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; -import { describe, expect, it } from 'vitest'; +import { BaseBuilder, WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; +import { describe, expect, it, vi } from 'vitest'; import { LocalBuilder, VercelBuilder } from './builders.js'; import nitroModule from './index.js'; +import { workflow as viteWorkflow } from './vite.js'; type StubOptions = { routing: boolean; @@ -115,6 +116,113 @@ describe('@workflow/nitro virtual handlers', () => { }); }); +describe('@workflow/nitro builder lifecycle', () => { + it('disposes watch-mode build contexts when Nitro closes', async () => { + const hooks = new Map Promise | void>>(); + const nitro = createNitroStub({ routing: true, dev: true }); + nitro.hooks.hook = (name: string, hook: () => Promise | void) => { + const registered = hooks.get(name) ?? []; + registered.push(hook); + hooks.set(name, registered); + }; + + const firstStepsContext = { dispose: vi.fn(async () => {}) }; + const firstWorkflowsContext = { dispose: vi.fn(async () => {}) }; + const secondStepsContext = { dispose: vi.fn(async () => {}) }; + const secondWorkflowsContext = { dispose: vi.fn(async () => {}) }; + const getInputFiles = vi + .spyOn(BaseBuilder.prototype as any, 'getInputFiles') + .mockResolvedValue([]); + const createCombinedBundle = vi + .spyOn(BaseBuilder.prototype as any, 'createCombinedBundle') + .mockResolvedValueOnce({ + manifest: {}, + stepsContext: firstStepsContext, + interimBundleCtx: firstWorkflowsContext, + }) + .mockResolvedValueOnce({ + manifest: {}, + stepsContext: secondStepsContext, + interimBundleCtx: secondWorkflowsContext, + }); + const createWebhookBundle = vi + .spyOn(BaseBuilder.prototype as any, 'createWebhookBundle') + .mockResolvedValue(undefined); + const createManifest = vi + .spyOn(BaseBuilder.prototype as any, 'createManifest') + .mockResolvedValue(undefined); + + try { + await nitroModule.setup(nitro); + + for (const hook of hooks.get('build:before') ?? []) { + await hook(); + } + + expect(firstStepsContext.dispose).not.toHaveBeenCalled(); + expect(firstWorkflowsContext.dispose).not.toHaveBeenCalled(); + + // The first reload is intentionally skipped because build:before already + // ran. The next reload replaces the active watch contexts. + for (const hook of hooks.get('dev:reload') ?? []) { + await hook(); + await hook(); + } + + expect(firstStepsContext.dispose).toHaveBeenCalledOnce(); + expect(firstWorkflowsContext.dispose).toHaveBeenCalledOnce(); + expect(secondStepsContext.dispose).not.toHaveBeenCalled(); + expect(secondWorkflowsContext.dispose).not.toHaveBeenCalled(); + + for (const hook of hooks.get('close') ?? []) { + await hook(); + } + + expect(secondStepsContext.dispose).toHaveBeenCalledOnce(); + expect(secondWorkflowsContext.dispose).toHaveBeenCalledOnce(); + } finally { + getInputFiles.mockRestore(); + createCombinedBundle.mockRestore(); + createWebhookBundle.mockRestore(); + createManifest.mockRestore(); + } + }); + + it('closes a dev Nitro instance when its Vite plugin container ends', async () => { + const closeHooks: Array<() => Promise | void> = []; + const nitro = createNitroStub({ routing: true, dev: true }); + nitro.hooks.hook = (name: string, hook: () => Promise | void) => { + if (name === 'close') closeHooks.push(hook); + }; + nitro.close = vi.fn(async () => { + for (const hook of closeHooks) await hook(); + }); + + const plugin = viteWorkflow().find( + (candidate) => candidate.name === 'workflow:nitro' + ) as any; + + await plugin.nitro.setup(nitro); + await plugin.buildEnd(); + await plugin.buildEnd(); + + expect(nitro.close).toHaveBeenCalledOnce(); + }); + + it('leaves production Nitro shutdown to the production build lifecycle', async () => { + const nitro = createNitroStub({ routing: true, dev: false }); + nitro.close = vi.fn(async () => {}); + const plugin = viteWorkflow().find( + (candidate) => candidate.name === 'workflow:nitro' + ) as any; + + await plugin.nitro.setup(nitro); + await plugin.buildEnd(); + + expect(nitro.close).not.toHaveBeenCalled(); + }); +}); + describe('@workflow/nitro world target bundling', () => { it('forces workflow SDK packages inline in production builds so aliases can resolve', async () => { const rollupBeforeHooks: Array<(nitro: any, config: any) => void> = []; diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 15f3e161bc..1935afb464 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -294,6 +294,8 @@ export default { const builder = new LocalBuilder(nitro); let isInitialBuild = true; + nitro.hooks.hook('close', () => builder.close()); + nitro.hooks.hook('build:before', async () => { await builder.build(); diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index 893e5a732e..d04e8c9865 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -17,6 +17,8 @@ import nitroModule from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder; + let nitroInstance: Nitro | undefined; + let closePromise: Promise | undefined; let workflowBuildDir: string; const enqueue = createBuildQueue(); @@ -78,6 +80,7 @@ export function workflow(options?: ModuleOptions): Plugin[] { name: 'workflow:nitro', nitro: { setup: (nitro: Nitro) => { + nitroInstance = nitro; // Capture the workflow build directory for exclusion workflowBuildDir = join(nitro.options.buildDir, 'workflow'); nitro.options.workflow = { @@ -86,11 +89,22 @@ export function workflow(options?: ModuleOptions): Plugin[] { _vite: true, }; if (nitro.options.dev) { - builder = new LocalBuilder(nitro); + const hotUpdateBuilder = new LocalBuilder(nitro); + builder = hotUpdateBuilder; + nitro.hooks.hook('close', () => hotUpdateBuilder.close()); } return nitroModule.setup(nitro); }, }, + async buildEnd() { + // Framework plugins can create temporary Vite dev servers during a + // production build. Close those Nitro instances when their Vite plugin + // container ends so watch-mode builders release their esbuild contexts. + if (nitroInstance?.options.dev) { + closePromise ??= nitroInstance.close(); + await closePromise; + } + }, // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. // For workflow routes, we override to send an empty body to prevent Hono/Vite's SPA fallback. configureServer(server) { From e506f400cb50282b7a208c5241c323d5db5b8af8 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:44:56 -0700 Subject: [PATCH 2/4] refactor(nitro): simplify React Router cleanup --- .changeset/react-router-nitro-cleanup.md | 2 +- .../v5/getting-started/react-router/index.mdx | 13 +- .../v5/getting-started/react-router/v7.mdx | 103 +++++++-------- .../v5/getting-started/react-router/v8.mdx | 93 +++++++------- packages/nitro/src/builders.ts | 97 +++++---------- packages/nitro/src/index.test.ts | 117 +++--------------- packages/nitro/src/index.ts | 2 - packages/nitro/src/vite.ts | 16 +-- 8 files changed, 138 insertions(+), 305 deletions(-) diff --git a/.changeset/react-router-nitro-cleanup.md b/.changeset/react-router-nitro-cleanup.md index 90740c15b5..3fd356cf6c 100644 --- a/.changeset/react-router-nitro-cleanup.md +++ b/.changeset/react-router-nitro-cleanup.md @@ -2,4 +2,4 @@ '@workflow/nitro': patch --- -Dispose watch-mode build contexts when Vite closes a temporary Nitro server. +Dispose Nitro's temporary watch-mode build contexts after each build. diff --git a/docs/content/docs/v5/getting-started/react-router/index.mdx b/docs/content/docs/v5/getting-started/react-router/index.mdx index bf07e428f1..0dcb81c548 100644 --- a/docs/content/docs/v5/getting-started/react-router/index.mdx +++ b/docs/content/docs/v5/getting-started/react-router/index.mdx @@ -19,18 +19,7 @@ The three pieces share one Vite build: Choose the guide that matches your React Router major version: - - - - + These guides require **Nitro v3**. Nitro v2 does not provide the Vite diff --git a/docs/content/docs/v5/getting-started/react-router/v7.mdx b/docs/content/docs/v5/getting-started/react-router/v7.mdx index 2fc63810df..eac49a3d5b 100644 --- a/docs/content/docs/v5/getting-started/react-router/v7.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v7.mdx @@ -10,7 +10,7 @@ related: - /docs/foundations/workflows-and-steps --- -This guide starts with an existing React Router v7 framework-mode app. It uses the Vite Environment API available in recent React Router v7 releases, including v7.18.1. +This guide starts with an existing React Router v7 framework-mode app. It is verified with v7.18.1; if your config does not recognize `v8_viteEnvironmentApi`, update to the latest v7 release. @@ -18,16 +18,43 @@ This guide starts with an existing React Router v7 framework-mode app. It uses t ## Install Nitro and Workflow SDK -Install the two additional packages: + -```package-install + + +```bash npm install nitro workflow ``` - - Use Nitro v3 for this integration. You can check the installed version with - `npm ls nitro`. - + + + + +```bash +pnpm add nitro workflow +``` + + + + + +```bash +bun add nitro workflow +``` + + + + + +```bash +yarn add nitro workflow +``` + + + + + +This integration requires Nitro v3. @@ -35,7 +62,7 @@ npm install nitro workflow ## Enable the Vite Environment API -React Router v7 keeps the Vite Environment API behind a future flag. Enable it and set an explicit build directory: +React Router v7 keeps the Vite Environment API behind a future flag. Enable the required flag and set an explicit build directory: ```typescript title="react-router.config.ts" lineNumbers import type { Config } from "@react-router/dev/config"; @@ -49,12 +76,6 @@ export default { } satisfies Config; ``` - - The `v8_viteEnvironmentApi` flag is required on React Router v7. Without it, - React Router uses its older build pipeline and cannot share Nitro's custom - server environment correctly. - - @@ -74,7 +95,7 @@ export default { }; ``` -React Router's Vite plugin generates `virtual:react-router/server-build`. This file gives Nitro a normal `fetch` handler that forwards each application request to that generated server build. +This adapts React Router's generated server build to the Fetch API handler Nitro expects. @@ -116,39 +137,7 @@ export default defineConfig({ }); ``` -Each part has one job: - -- `reactRouter()` creates the client and server-rendering environments. -- `nitro()` runs `server/ssr.ts` and writes its output beside React Router's output. -- `workflow({ dirs: ["workflows"] })` compiles only your workflow source directory and adds Workflow SDK's routes to Nitro. -- The custom `ssr` input tells Vite which file connects Nitro to React Router. - - - Keep `dirs: ["workflows"]`. Without a source-only directory, a later build - can scan the generated `build` directory and try to compile server output as - workflow source. - - -If your config already has plugins such as Tailwind CSS, keep them in the array. Preserve the relative order of `reactRouter()`, `nitro()`, and `workflow()` shown above. - - - - - -## Use Vite for the app scripts - -Run the shared build through Vite so all three plugins participate: - -```json title="package.json" lineNumbers -{ - "scripts": { - "dev": "vite dev", - "build": "vite build", - "start": "node ./build/server/index.mjs", - "typegen": "react-router typegen" - } -} -``` +Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Preserve the relative order of the three plugins; any other Vite plugins can remain. @@ -201,10 +190,10 @@ React Router continues to handle your application routes. Nitro handles this ser ## Run the app -Start development: +Start the development server: ```bash -npm run dev +pnpm vite dev ``` Then start a workflow: @@ -216,14 +205,14 @@ curl -X POST \ http://localhost:3000/api/greeting ``` -For a production check, run: +Build and start the production server: ```bash -npm run build -npm run start +pnpm vite build +node ./build/server/index.mjs ``` -You can inspect local runs with `npx workflow web`. +You can inspect local runs with `pnpm workflow web`. @@ -233,15 +222,15 @@ You can inspect local runs with `npx workflow web`. ### Vite reports an invalid SSR input or `path.replace is not a function` -Confirm that `future.v8_viteEnvironmentApi` is `true` in `react-router.config.ts`. That flag makes React Router v7 use the environment-aware build pipeline required by Nitro. +Set `future.v8_viteEnvironmentApi` to `true` in `react-router.config.ts`. ### React Router pages return 404 -Confirm that `environments.ssr.build.rollupOptions.input` points to `./server/ssr.ts` and that the file exports the `fetch` handler shown above. +Check that the `ssr` environment input points to `./server/ssr.ts`. ### A second build tries to compile files under `build/server` -Confirm that the Workflow plugin uses `workflow({ dirs: ["workflows"] })`. Also remove the existing `build` directory once before rebuilding. +Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory once, and rebuild. ### `vite build` finishes output but does not exit diff --git a/docs/content/docs/v5/getting-started/react-router/v8.mdx b/docs/content/docs/v5/getting-started/react-router/v8.mdx index a7e601dbed..21ee41fdc9 100644 --- a/docs/content/docs/v5/getting-started/react-router/v8.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v8.mdx @@ -10,7 +10,7 @@ related: - /docs/foundations/workflows-and-steps --- -This guide starts with an existing React Router v8 framework-mode app. You will add Nitro as the server, connect React Router's server build to it, and enable Workflow SDK. +This guide starts with an existing React Router v8 framework-mode app. @@ -18,16 +18,43 @@ This guide starts with an existing React Router v8 framework-mode app. You will ## Install Nitro and Workflow SDK -Install the two additional packages: + -```package-install + + +```bash npm install nitro workflow ``` - - Use Nitro v3 for this integration. You can check the installed version with - `npm ls nitro`. - + + + + +```bash +pnpm add nitro workflow +``` + + + + + +```bash +bun add nitro workflow +``` + + + + + +```bash +yarn add nitro workflow +``` + + + + + +This integration requires Nitro v3. @@ -67,7 +94,7 @@ export default { }; ``` -React Router's Vite plugin generates `virtual:react-router/server-build`. This file gives Nitro a normal `fetch` handler that forwards each application request to that generated server build. +This adapts React Router's generated server build to the Fetch API handler Nitro expects. @@ -109,39 +136,7 @@ export default defineConfig({ }); ``` -Each part has one job: - -- `reactRouter()` creates the client and server-rendering environments. -- `nitro()` runs `server/ssr.ts` and writes its output beside React Router's output. -- `workflow({ dirs: ["workflows"] })` compiles only your workflow source directory and adds Workflow SDK's routes to Nitro. -- The custom `ssr` input tells Vite which file connects Nitro to React Router. - - - Keep `dirs: ["workflows"]`. Without a source-only directory, a later build - can scan the generated `build` directory and try to compile server output as - workflow source. - - -If your config already has plugins such as Tailwind CSS, keep them in the array. Preserve the relative order of `reactRouter()`, `nitro()`, and `workflow()` shown above. - - - - - -## Use Vite for the app scripts - -Run the shared build through Vite so all three plugins participate: - -```json title="package.json" lineNumbers -{ - "scripts": { - "dev": "vite dev", - "build": "vite build", - "start": "node ./build/server/index.mjs", - "typegen": "react-router typegen" - } -} -``` +Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Preserve the relative order of the three plugins; any other Vite plugins can remain. @@ -194,10 +189,10 @@ React Router continues to handle your application routes. Nitro handles this ser ## Run the app -Start development: +Start the development server: ```bash -npm run dev +pnpm vite dev ``` Then start a workflow: @@ -209,14 +204,14 @@ curl -X POST \ http://localhost:3000/api/greeting ``` -For a production check, run: +Build and start the production server: ```bash -npm run build -npm run start +pnpm vite build +node ./build/server/index.mjs ``` -You can inspect local runs with `npx workflow web`. +You can inspect local runs with `pnpm workflow web`. @@ -226,11 +221,11 @@ You can inspect local runs with `npx workflow web`. ### React Router pages return 404 -Confirm that `environments.ssr.build.rollupOptions.input` points to `./server/ssr.ts` and that the file exports the `fetch` handler shown above. +Check that the `ssr` environment input points to `./server/ssr.ts`. ### A second build tries to compile files under `build/server` -Confirm that the Workflow plugin uses `workflow({ dirs: ["workflows"] })`. Also remove the existing `build` directory once before rebuilding. +Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory once, and rebuild. ### `vite build` finishes output but does not exit diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 9b074e4848..7e9ceb9c74 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -64,8 +64,6 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { export class LocalBuilder extends BaseBuilder { #outDir: string; - #buildContexts: Array<{ dispose(): Promise }> = []; - #closed = false; constructor(nitro: Nitro) { const outDir = join(nitro.options.buildDir, 'workflow'); super({ @@ -87,10 +85,6 @@ export class LocalBuilder extends BaseBuilder { #buildQueue: Promise = Promise.resolve(); override build(): Promise { - if (this.#closed) { - return Promise.reject(new Error('Cannot build after builder is closed')); - } - const next = this.#buildQueue.then( () => this.#buildOnce(), () => this.#buildOnce() @@ -102,30 +96,6 @@ export class LocalBuilder extends BaseBuilder { return next; } - close(): Promise { - this.#closed = true; - const next = this.#buildQueue.then( - () => this.#disposeBuildContexts(), - () => this.#disposeBuildContexts() - ); - this.#buildQueue = next.catch(() => {}); - return next; - } - - async #disposeBuildContexts(): Promise { - const contexts = this.#buildContexts; - this.#buildContexts = []; - await Promise.all(contexts.map((context) => context.dispose())); - } - - async #replaceBuildContexts( - contexts: Array<{ dispose(): Promise }> - ): Promise { - const previousContexts = this.#buildContexts; - this.#buildContexts = contexts; - await Promise.all(previousContexts.map((context) => context.dispose())); - } - async #buildOnce(): Promise { const inputFiles = await this.getInputFiles(); await mkdir(this.#outDir, { recursive: true }); @@ -134,46 +104,37 @@ export class LocalBuilder extends BaseBuilder { // name in its import statement, so we build directly to final names. // (The V1 atomic tmp-file pattern doesn't work here because renaming // the steps file would leave the flow route's import stale.) - const { manifest, stepsContext, interimBundleCtx } = - await this.createCombinedBundle({ - inputFiles, - stepsOutfile: join(this.#outDir, 'steps.mjs'), - flowOutfile: join(this.#outDir, 'workflows.mjs'), - format: 'esm', - // bundleFinalOutput: false — Nitro externalizes the workflow build dir - // during dev, and its own rollup pipeline handles bundling for prod. - // Using true causes "Dynamic require of X is not supported" errors - // because esbuild wraps CJS require() calls in ESM output. - bundleFinalOutput: false, - externalizeNonSteps: true, - // In dev, Nitro dynamically imports the generated workflow files from - // disk, so there is no later Rollup pass to resolve externalized local - // TypeScript imports. In prod, Nitro/Rollup handles those imports. - bundleTransitiveLocalStepDependencies: this.config.watch, - }); - - const buildContexts = [stepsContext, interimBundleCtx].filter( - (context): context is NonNullable => context != null - ); + const build = await this.createCombinedBundle({ + inputFiles, + stepsOutfile: join(this.#outDir, 'steps.mjs'), + flowOutfile: join(this.#outDir, 'workflows.mjs'), + format: 'esm', + // bundleFinalOutput: false — Nitro externalizes the workflow build dir + // during dev, and its own rollup pipeline handles bundling for prod. + // Using true causes "Dynamic require of X is not supported" errors + // because esbuild wraps CJS require() calls in ESM output. + bundleFinalOutput: false, + externalizeNonSteps: true, + // In dev, Nitro dynamically imports the generated workflow files from + // disk, so there is no later Rollup pass to resolve externalized local + // TypeScript imports. In prod, Nitro/Rollup handles those imports. + bundleTransitiveLocalStepDependencies: this.config.watch, + }); + const { manifest, stepsContext, interimBundleCtx } = build; - try { - await this.createWebhookBundle({ - outfile: join(this.#outDir, 'webhook.mjs'), - bundle: false, - }); + await Promise.all([stepsContext?.dispose(), interimBundleCtx?.dispose()]); - // Generate manifest - const workflowBundlePath = join(this.#outDir, 'workflows.mjs'); - await this.createManifest({ - workflowBundlePath, - manifestDir: this.#outDir, - manifest, - }); - } catch (error) { - await Promise.all(buildContexts.map((context) => context.dispose())); - throw error; - } + await this.createWebhookBundle({ + outfile: join(this.#outDir, 'webhook.mjs'), + bundle: false, + }); - await this.#replaceBuildContexts(buildContexts); + // Generate manifest + const workflowBundlePath = join(this.#outDir, 'workflows.mjs'); + await this.createManifest({ + workflowBundlePath, + manifestDir: this.#outDir, + manifest, + }); } } diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index c07b38ef69..26ffcfd5cf 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -1,8 +1,7 @@ -import { BaseBuilder, WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; +import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; import { describe, expect, it, vi } from 'vitest'; import { LocalBuilder, VercelBuilder } from './builders.js'; import nitroModule from './index.js'; -import { workflow as viteWorkflow } from './vite.js'; type StubOptions = { routing: boolean; @@ -117,109 +116,25 @@ describe('@workflow/nitro virtual handlers', () => { }); describe('@workflow/nitro builder lifecycle', () => { - it('disposes watch-mode build contexts when Nitro closes', async () => { - const hooks = new Map Promise | void>>(); - const nitro = createNitroStub({ routing: true, dev: true }); - nitro.hooks.hook = (name: string, hook: () => Promise | void) => { - const registered = hooks.get(name) ?? []; - registered.push(hook); - hooks.set(name, registered); - }; - - const firstStepsContext = { dispose: vi.fn(async () => {}) }; - const firstWorkflowsContext = { dispose: vi.fn(async () => {}) }; - const secondStepsContext = { dispose: vi.fn(async () => {}) }; - const secondWorkflowsContext = { dispose: vi.fn(async () => {}) }; - const getInputFiles = vi - .spyOn(BaseBuilder.prototype as any, 'getInputFiles') - .mockResolvedValue([]); - const createCombinedBundle = vi - .spyOn(BaseBuilder.prototype as any, 'createCombinedBundle') - .mockResolvedValueOnce({ - manifest: {}, - stepsContext: firstStepsContext, - interimBundleCtx: firstWorkflowsContext, - }) - .mockResolvedValueOnce({ + it('disposes temporary build contexts after each build', async () => { + const dispose = vi.fn(async () => {}); + const builder = new LocalBuilder( + createNitroStub({ routing: true, dev: true }) + ); + Object.assign(builder, { + getInputFiles: async () => [], + createCombinedBundle: async () => ({ manifest: {}, - stepsContext: secondStepsContext, - interimBundleCtx: secondWorkflowsContext, - }); - const createWebhookBundle = vi - .spyOn(BaseBuilder.prototype as any, 'createWebhookBundle') - .mockResolvedValue(undefined); - const createManifest = vi - .spyOn(BaseBuilder.prototype as any, 'createManifest') - .mockResolvedValue(undefined); - - try { - await nitroModule.setup(nitro); - - for (const hook of hooks.get('build:before') ?? []) { - await hook(); - } - - expect(firstStepsContext.dispose).not.toHaveBeenCalled(); - expect(firstWorkflowsContext.dispose).not.toHaveBeenCalled(); - - // The first reload is intentionally skipped because build:before already - // ran. The next reload replaces the active watch contexts. - for (const hook of hooks.get('dev:reload') ?? []) { - await hook(); - await hook(); - } - - expect(firstStepsContext.dispose).toHaveBeenCalledOnce(); - expect(firstWorkflowsContext.dispose).toHaveBeenCalledOnce(); - expect(secondStepsContext.dispose).not.toHaveBeenCalled(); - expect(secondWorkflowsContext.dispose).not.toHaveBeenCalled(); - - for (const hook of hooks.get('close') ?? []) { - await hook(); - } - - expect(secondStepsContext.dispose).toHaveBeenCalledOnce(); - expect(secondWorkflowsContext.dispose).toHaveBeenCalledOnce(); - } finally { - getInputFiles.mockRestore(); - createCombinedBundle.mockRestore(); - createWebhookBundle.mockRestore(); - createManifest.mockRestore(); - } - }); - - it('closes a dev Nitro instance when its Vite plugin container ends', async () => { - const closeHooks: Array<() => Promise | void> = []; - const nitro = createNitroStub({ routing: true, dev: true }); - nitro.hooks.hook = (name: string, hook: () => Promise | void) => { - if (name === 'close') closeHooks.push(hook); - }; - nitro.close = vi.fn(async () => { - for (const hook of closeHooks) await hook(); + stepsContext: { dispose }, + interimBundleCtx: { dispose }, + }), + createWebhookBundle: async () => {}, + createManifest: async () => {}, }); - const plugin = viteWorkflow().find( - (candidate) => candidate.name === 'workflow:nitro' - ) as any; - - await plugin.nitro.setup(nitro); - await plugin.buildEnd(); - await plugin.buildEnd(); - - expect(nitro.close).toHaveBeenCalledOnce(); - }); - - it('leaves production Nitro shutdown to the production build lifecycle', async () => { - const nitro = createNitroStub({ routing: true, dev: false }); - nitro.close = vi.fn(async () => {}); - const plugin = viteWorkflow().find( - (candidate) => candidate.name === 'workflow:nitro' - ) as any; - - await plugin.nitro.setup(nitro); - await plugin.buildEnd(); + await builder.build(); - expect(nitro.close).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 1935afb464..15f3e161bc 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -294,8 +294,6 @@ export default { const builder = new LocalBuilder(nitro); let isInitialBuild = true; - nitro.hooks.hook('close', () => builder.close()); - nitro.hooks.hook('build:before', async () => { await builder.build(); diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index d04e8c9865..893e5a732e 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -17,8 +17,6 @@ import nitroModule from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder; - let nitroInstance: Nitro | undefined; - let closePromise: Promise | undefined; let workflowBuildDir: string; const enqueue = createBuildQueue(); @@ -80,7 +78,6 @@ export function workflow(options?: ModuleOptions): Plugin[] { name: 'workflow:nitro', nitro: { setup: (nitro: Nitro) => { - nitroInstance = nitro; // Capture the workflow build directory for exclusion workflowBuildDir = join(nitro.options.buildDir, 'workflow'); nitro.options.workflow = { @@ -89,22 +86,11 @@ export function workflow(options?: ModuleOptions): Plugin[] { _vite: true, }; if (nitro.options.dev) { - const hotUpdateBuilder = new LocalBuilder(nitro); - builder = hotUpdateBuilder; - nitro.hooks.hook('close', () => hotUpdateBuilder.close()); + builder = new LocalBuilder(nitro); } return nitroModule.setup(nitro); }, }, - async buildEnd() { - // Framework plugins can create temporary Vite dev servers during a - // production build. Close those Nitro instances when their Vite plugin - // container ends so watch-mode builders release their esbuild contexts. - if (nitroInstance?.options.dev) { - closePromise ??= nitroInstance.close(); - await closePromise; - } - }, // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. // For workflow routes, we override to send an empty body to prevent Hono/Vite's SPA fallback. configureServer(server) { From b2e31a03c7e444cbbe31532998ea9c20d880c8aa Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:04:23 -0700 Subject: [PATCH 3/4] docs(react-router): specify cleanup version --- docs/content/docs/v5/getting-started/react-router/v7.mdx | 2 +- docs/content/docs/v5/getting-started/react-router/v8.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/v5/getting-started/react-router/v7.mdx b/docs/content/docs/v5/getting-started/react-router/v7.mdx index eac49a3d5b..e9885bb363 100644 --- a/docs/content/docs/v5/getting-started/react-router/v7.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v7.mdx @@ -234,4 +234,4 @@ Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory o ### `vite build` finishes output but does not exit -Upgrade to a Workflow SDK version that includes Nitro build-context cleanup and use a current Nitro v3 release. +Use `workflow@5.0.0-beta.33` or later with Nitro v3. diff --git a/docs/content/docs/v5/getting-started/react-router/v8.mdx b/docs/content/docs/v5/getting-started/react-router/v8.mdx index 21ee41fdc9..77b6b8b276 100644 --- a/docs/content/docs/v5/getting-started/react-router/v8.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v8.mdx @@ -229,4 +229,4 @@ Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory o ### `vite build` finishes output but does not exit -Upgrade to a Workflow SDK version that includes Nitro build-context cleanup and use a current Nitro v3 release. +Use `workflow@5.0.0-beta.33` or later with Nitro v3. From fbb8267ce51cc188a8c2a3a872d67d409ce583f7 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:47:13 -0700 Subject: [PATCH 4/4] fix(nitro): close temporary Vite servers --- .changeset/react-router-nitro-cleanup.md | 2 +- .../docs/v5/getting-started/react-router/v7.mdx | 2 +- .../docs/v5/getting-started/react-router/v8.mdx | 2 +- packages/nitro/src/index.test.ts | 14 ++++++++++++++ packages/nitro/src/vite.ts | 7 +++++++ 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.changeset/react-router-nitro-cleanup.md b/.changeset/react-router-nitro-cleanup.md index 3fd356cf6c..66ce8c2360 100644 --- a/.changeset/react-router-nitro-cleanup.md +++ b/.changeset/react-router-nitro-cleanup.md @@ -2,4 +2,4 @@ '@workflow/nitro': patch --- -Dispose Nitro's temporary watch-mode build contexts after each build. +Clean up temporary Nitro Vite servers and Workflow build contexts after builds. diff --git a/docs/content/docs/v5/getting-started/react-router/v7.mdx b/docs/content/docs/v5/getting-started/react-router/v7.mdx index e9885bb363..b49789c8b4 100644 --- a/docs/content/docs/v5/getting-started/react-router/v7.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v7.mdx @@ -137,7 +137,7 @@ export default defineConfig({ }); ``` -Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Preserve the relative order of the three plugins; any other Vite plugins can remain. +Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Place `reactRouter()` before `nitro()` in the plugin array. diff --git a/docs/content/docs/v5/getting-started/react-router/v8.mdx b/docs/content/docs/v5/getting-started/react-router/v8.mdx index 77b6b8b276..1407e39da1 100644 --- a/docs/content/docs/v5/getting-started/react-router/v8.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v8.mdx @@ -136,7 +136,7 @@ export default defineConfig({ }); ``` -Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Preserve the relative order of the three plugins; any other Vite plugins can remain. +Keep `dirs: ["workflows"]` so subsequent builds do not scan generated files under `build`. Place `reactRouter()` before `nitro()` in the plugin array. diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 26ffcfd5cf..ca9ecc8d35 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -2,6 +2,7 @@ import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders'; import { describe, expect, it, vi } from 'vitest'; import { LocalBuilder, VercelBuilder } from './builders.js'; import nitroModule from './index.js'; +import { workflow as viteWorkflow } from './vite.js'; type StubOptions = { routing: boolean; @@ -116,6 +117,19 @@ describe('@workflow/nitro virtual handlers', () => { }); describe('@workflow/nitro builder lifecycle', () => { + it('closes a development Nitro instance with its Vite plugin container', async () => { + const nitro = createNitroStub({ routing: true, dev: true }); + nitro.close = vi.fn(async () => {}); + const plugin = viteWorkflow().find( + (candidate) => candidate.name === 'workflow:nitro' + ) as any; + + await plugin.nitro.setup(nitro); + await plugin.buildEnd?.(); + + expect(nitro.close).toHaveBeenCalledOnce(); + }); + it('disposes temporary build contexts after each build', async () => { const dispose = vi.fn(async () => {}); const builder = new LocalBuilder( diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index 893e5a732e..6a6116ba18 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -17,6 +17,7 @@ import nitroModule from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder; + let devNitro: Nitro | undefined; let workflowBuildDir: string; const enqueue = createBuildQueue(); @@ -86,11 +87,17 @@ export function workflow(options?: ModuleOptions): Plugin[] { _vite: true, }; if (nitro.options.dev) { + devNitro = nitro; builder = new LocalBuilder(nitro); } return nitroModule.setup(nitro); }, }, + async buildEnd() { + const nitro = devNitro; + devNitro = undefined; + await nitro?.close(); + }, // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. // For workflow routes, we override to send an empty body to prevent Hono/Vite's SPA fallback. configureServer(server) {