diff --git a/.changeset/react-router-nitro-cleanup.md b/.changeset/react-router-nitro-cleanup.md new file mode 100644 index 0000000000..66ce8c2360 --- /dev/null +++ b/.changeset/react-router-nitro-cleanup.md @@ -0,0 +1,5 @@ +--- +'@workflow/nitro': patch +--- + +Clean up temporary Nitro Vite servers and Workflow build contexts after builds. diff --git a/docs/content/docs/getting-started/index.mdx b/docs/content/docs/getting-started/index.mdx index 15e1aa5b3a..d5a6b5f22d 100644 --- a/docs/content/docs/getting-started/index.mdx +++ b/docs/content/docs/getting-started/index.mdx @@ -9,6 +9,7 @@ related: --- import { Next, Nitro, SvelteKit, Nuxt, Hono, Bun, AstroDark, AstroLight, TanStack, Vite, Express, Nest, Fastify } 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/getting-started/meta.json b/docs/content/docs/getting-started/meta.json index 4a26600051..7411bc2683 100644 --- a/docs/content/docs/getting-started/meta.json +++ b/docs/content/docs/getting-started/meta.json @@ -2,6 +2,7 @@ "title": "Getting Started", "pages": [ "next", + "react-router", "astro", "express", "fastify", diff --git a/docs/content/docs/getting-started/react-router/index.mdx b/docs/content/docs/getting-started/react-router/index.mdx new file mode 100644 index 0000000000..f52c758a63 --- /dev/null +++ b/docs/content/docs/getting-started/react-router/index.mdx @@ -0,0 +1,33 @@ +--- +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. A small Vite plugin keeps React Router's client manifest where React Router expects it, while Nitro produces the output required by the selected deployment preset, such as `.output` for a local Node.js server or `.vercel/output` on Vercel. + +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/getting-started/react-router/meta.json b/docs/content/docs/getting-started/react-router/meta.json new file mode 100644 index 0000000000..7da9151a4c --- /dev/null +++ b/docs/content/docs/getting-started/react-router/meta.json @@ -0,0 +1,5 @@ +{ + "title": "React Router", + "pages": ["v7", "v8"], + "defaultOpen": true +} diff --git a/docs/content/docs/getting-started/react-router/v7.mdx b/docs/content/docs/getting-started/react-router/v7.mdx new file mode 100644 index 0000000000..6ff7653844 --- /dev/null +++ b/docs/content/docs/getting-started/react-router/v7.mdx @@ -0,0 +1,239 @@ +--- +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 is verified with v7.18.1; if your config does not recognize `v8_viteEnvironmentApi`, update to the latest v7 release. + + + + + +## Install Nitro and Workflow SDK + + + + + +```bash +npm install nitro workflow +``` + + + + + +```bash +pnpm add nitro workflow +``` + + + + + +```bash +bun add nitro workflow +``` + + + + + +```bash +yarn add nitro workflow +``` + + + + + +This integration requires Nitro v3. + + + + + +## Enable the Vite Environment API + +React Router v7 keeps the Vite Environment API behind a future flag. Enable the required flag: + +```typescript title="react-router.config.ts" lineNumbers +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + buildDirectory: "build", + future: { + v8_viteEnvironmentApi: true, // [!code highlight] + }, +} satisfies Config; +``` + + + + + +## 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, + ), +}; +``` + +This adapts React Router's generated server build to the Fetch API handler Nitro expects. + + + + + +## 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 { cp } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import { workflow } from "workflow/vite"; +import reactRouterConfig from "./react-router.config"; + +export default defineConfig({ + plugins: [ + reactRouter(), + nitro({ serverDir: "./server" }), + { + name: "react-router-nitro-manifest", + applyToEnvironment: (environment) => environment.name === "client", + async writeBundle(options) { + await cp( + resolve(options.dir!, ".vite"), + resolve(reactRouterConfig.buildDirectory, "client/.vite"), + { recursive: true }, + ); + }, + }, + workflow({ dirs: ["workflows"] }), + ], + environments: { + ssr: { + build: { + rollupOptions: { + input: "./server/ssr.ts", + }, + }, + }, + }, +}); +``` + +The small `react-router-nitro-manifest` plugin copies React Router's Vite manifest back to `build/client` after Nitro writes the client build to its deployment output. Keep `dirs: ["workflows"]` so Workflow SDK only scans your source workflow directory. Do not set Nitro's `output.dir`; Nitro uses it to produce the output expected by each deployment preset. + + + + + +## 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 the development server: + +```bash +pnpm vite dev +``` + +Then start a workflow: + +```bash +curl -X POST \ + -H "content-type: application/json" \ + -d '{"name":"Workflow"}' \ + http://localhost:3000/api/greeting +``` + +Build and start the production server: + +```bash +pnpm vite build +node ./.output/server/index.mjs +``` + +You can inspect local runs with `pnpm workflow web`. + + + + + +## Troubleshooting + +### Vite reports an invalid SSR input or `path.replace is not a function` + +Set `future.v8_viteEnvironmentApi` to `true` in `react-router.config.ts`. + +### React Router pages return 404 + +Check that the `ssr` environment input points to `./server/ssr.ts`. + +### `vite build` finishes output but does not exit + +Use `workflow@4.6.1` or later with Nitro v3. diff --git a/docs/content/docs/getting-started/react-router/v8.mdx b/docs/content/docs/getting-started/react-router/v8.mdx new file mode 100644 index 0000000000..3591b06d4a --- /dev/null +++ b/docs/content/docs/getting-started/react-router/v8.mdx @@ -0,0 +1,232 @@ +--- +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. + + + + + +## Install Nitro and Workflow SDK + + + + + +```bash +npm install nitro workflow +``` + + + + + +```bash +pnpm add nitro workflow +``` + + + + + +```bash +bun add nitro workflow +``` + + + + + +```bash +yarn add nitro workflow +``` + + + + + +This integration requires Nitro v3. + + + + + +## Configure React Router + +Keep server rendering enabled and set an explicit build directory for the manifest bridge: + +```typescript title="react-router.config.ts" lineNumbers +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + buildDirectory: "build", +} satisfies Config; +``` + + + + + +## 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, + ), +}; +``` + +This adapts React Router's generated server build to the Fetch API handler Nitro expects. + + + + + +## 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 { cp } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import { workflow } from "workflow/vite"; +import reactRouterConfig from "./react-router.config"; + +export default defineConfig({ + plugins: [ + reactRouter(), + nitro({ serverDir: "./server" }), + { + name: "react-router-nitro-manifest", + applyToEnvironment: (environment) => environment.name === "client", + async writeBundle(options) { + await cp( + resolve(options.dir!, ".vite"), + resolve(reactRouterConfig.buildDirectory, "client/.vite"), + { recursive: true }, + ); + }, + }, + workflow({ dirs: ["workflows"] }), + ], + environments: { + ssr: { + build: { + rollupOptions: { + input: "./server/ssr.ts", + }, + }, + }, + }, +}); +``` + +The small `react-router-nitro-manifest` plugin copies React Router's Vite manifest back to `build/client` after Nitro writes the client build to its deployment output. Keep `dirs: ["workflows"]` so Workflow SDK only scans your source workflow directory. Do not set Nitro's `output.dir`; Nitro uses it to produce the output expected by each deployment preset. + + + + + +## 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 the development server: + +```bash +pnpm vite dev +``` + +Then start a workflow: + +```bash +curl -X POST \ + -H "content-type: application/json" \ + -d '{"name":"Workflow"}' \ + http://localhost:3000/api/greeting +``` + +Build and start the production server: + +```bash +pnpm vite build +node ./.output/server/index.mjs +``` + +You can inspect local runs with `pnpm workflow web`. + + + + + +## Troubleshooting + +### React Router pages return 404 + +Check that the `ssr` environment input points to `./server/ssr.ts`. + +### `vite build` finishes output but does not exit + +Use `workflow@4.6.1` or later with Nitro v3. diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 1f9f56d2a6..2f372e822a 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -67,23 +67,28 @@ export class LocalBuilder extends BaseBuilder { const inputFiles = await this.getInputFiles(); await mkdir(this.#outDir, { recursive: true }); - const { manifest: workflowsManifest } = await this.createWorkflowsBundle({ - outfile: join(this.#outDir, 'workflows.mjs'), - bundleFinalOutput: false, - format: 'esm', - inputFiles, - }); + const { manifest: workflowsManifest, interimBundleCtx } = + await this.createWorkflowsBundle({ + outfile: join(this.#outDir, 'workflows.mjs'), + bundleFinalOutput: false, + format: 'esm', + inputFiles, + }); - const { manifest: stepsManifest } = await this.createStepsBundle({ - outfile: join(this.#outDir, 'steps.mjs'), - 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, - format: 'esm', - inputFiles, - }); + const { manifest: stepsManifest, context: stepsContext } = + await this.createStepsBundle({ + outfile: join(this.#outDir, 'steps.mjs'), + 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, + format: 'esm', + inputFiles, + }); + + // Close the temporary esbuild build contexts once bundling is done. + await Promise.all([stepsContext?.dispose(), interimBundleCtx?.dispose()]); const webhookRouteFile = join(this.#outDir, 'webhook.mjs'); diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts index 9e616b3152..a562d92cd1 100644 --- a/packages/nitro/src/index.test.ts +++ b/packages/nitro/src/index.test.ts @@ -10,19 +10,21 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Nitro } from 'nitro/types'; -import { describe, expect, it, onTestFinished } from 'vitest'; +import { describe, expect, it, onTestFinished, 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; + dev?: boolean; workspaceDir?: string; workflow?: { dirs?: string[]; runtime?: string }; }; function createNitroStub({ routing, + dev = false, workspaceDir = '/tmp/project', workflow = {}, }: StubOptions) { @@ -31,7 +33,7 @@ function createNitroStub({ options: { alias: {}, buildDir: '/tmp/.nitro', - dev: false, + dev, handlers: [], preset: 'node-server', rootDir: '/tmp/project', @@ -72,6 +74,45 @@ 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 }) as any; + 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( + createNitroStub({ routing: true, dev: true }) + ); + Object.assign(builder, { + getInputFiles: async () => [], + createWorkflowsBundle: async () => ({ + manifest: { steps: {}, workflows: {}, classes: {} }, + interimBundleCtx: { dispose }, + }), + createStepsBundle: async () => ({ + manifest: { steps: {}, workflows: {}, classes: {} }, + context: { dispose }, + }), + createWebhookBundle: async () => {}, + createManifest: async () => {}, + }); + + await builder.build(); + + expect(dispose).toHaveBeenCalledTimes(2); + }); +}); + describe('@workflow/nitro Vercel Build Output API', () => { it('routes workflow HTTP endpoints to generated Vercel functions', async () => { const testRoot = await mkdtemp(join(tmpdir(), 'workflow-nitro-vercel-')); diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index c223cd3ac5..ea1a92640e 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -10,6 +10,7 @@ import nitroModule from './index.js'; export function workflow(options?: ModuleOptions): Plugin[] { let builder: LocalBuilder; + let devNitro: Nitro | undefined; let nitroBuildDir: string; const enqueue = createBuildQueue(); @@ -41,11 +42,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) {