1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";
4import * as Path from "effect/Path";
5import * as Schedule from "effect/Schedule";
6import * as FrameworkCore from "../core/index.ts";
7import {
8 Framework,
9 FrameworkError,
10 type DeployTarget,
11 type DeployTargetInput,
12} from "../core/index.ts";
13
14/**
15 * The structural slice of the project's `vite` module this package drives.
16 * Typed structurally so the package carries no dependency on vite — the
17 * *project's* install is always the one loaded.
18 *
19 * `build` (not `createBuilder`) is the entry point: React Router v7's
20 * `@react-router/dev` plugin drives a two-pass build — `vite build` for the
21 * `client` environment, then `vite build` with `build.ssr` for the server
22 * environment — which is exactly what `react-router build` runs. (The
23 * Vite Environment API path exists only behind the framework's
24 * `future.v8_viteEnvironmentApi` flag and for RSC; see the module doc on
25 * {@link make}.)
26 */
27export interface ReactRouterViteModule {
28 readonly version?: string;
29 readonly build: (config: Record<string, unknown>) => Promise<unknown>;
30 readonly createServer: (
31 config: Record<string, unknown>,
32 ) => Promise<ReactRouterViteDevServer>;
33}
34
35/** The structural slice of a Vite dev server this package reads. */
36export interface ReactRouterViteDevServer {
37 readonly listen: () => Promise<unknown>;
38 readonly close: () => Promise<void>;
39 readonly resolvedUrls?:
40 | { readonly local: ReadonlyArray<string> }
41 | null
42 | undefined;
43}
44
45/** The slice of a resolved Vite config the output-directory capture reads. */
46export interface ResolvedViteBuildSlice {
47 readonly root: string;
48 readonly build: { readonly outDir: string };
49}
50
51/**
52 * The configuration this package assembles from {@link ReactRouterOptions}
53 * and hands to a deploy-target factory. The target treats it as its
54 * `DeployTarget.config`; the framework half never inspects a resolved
55 * target's config.
56 */
57export interface ReactRouterTargetConfig {
58 /**
59 * Build output directory relative to the project root — the parent of
60 * `client/` and `server/`. Mirrors `react-router.config.ts`'s
61 * `buildDirectory`, and is only a FALLBACK: the integration prefers the
62 * directories it observes on the resolved Vite config.
63 * @default "build"
64 */
65 readonly buildDirectory?: string | undefined;
66}
67
68/**
69 * A deploy target for React Router: the generic `DeployTarget` seams plus
70 * the one framework-specific hook the build needs — the file name of the
71 * server entry the server build emits, which the target's finishing pass
72 * wraps.
73 */
74export interface ReactRouterTarget extends DeployTarget<ReactRouterTargetConfig> {
75 /**
76 * The server entry file name within the server output directory. React
77 * Router names it after `react-router.config.ts`'s `serverBuildFile`
78 * (default `index.js`). Omitted targets fall back to
79 * {@link DEFAULT_SERVER_BUILD_FILE}, and a build that emits exactly one
80 * top-level module under the server directory uses that instead when the
81 * expected name is absent.
82 */
83 readonly serverEntryFileName?: string | undefined;
84}
85
86/**
87 * How a deploy target is passed to this package: a `ReactRouterTarget`
88 * value, a factory `(config) => ReactRouterTarget`, or a module specifier
89 * resolved from the *project's* `node_modules`.
90 */
91export type ReactRouterTargetInput = DeployTargetInput<
92 ReactRouterTarget,
93 ReactRouterTargetConfig
94>;
95
96/**
97 * The default deploy target: this package's own AWS Lambda target module,
98 * loaded from the project's dependency tree. (Cloudflare deploys React
99 * Router through its native Vite integration — `Cloudflare.Website.Vite` —
100 * so no Cloudflare target exists here.)
101 */
102export const DEFAULT_TARGET_SPECIFIER =
103 "@alchemy.run/frontend-frameworks/react-router/aws";
104
105/** The Vite plugin package a React Router project must install. */
106export const REACT_ROUTER_PLUGIN_SPECIFIER = "@react-router/dev";
107
108/** The default root output directory (the parent of `client/` and `server/`). */
109export const DEFAULT_BUILD_DIRECTORY = "build";
110
111/** The default server entry file name inside the server output directory. */
112export const DEFAULT_SERVER_BUILD_FILE = "index.js";
113
114/**
115 * React Router's own virtual module holding the compiled server build — the
116 * `ServerBuild` manifest (`{ entry, routes, assets, basename, ... }`), NOT a
117 * request handler.
118 */
119export const REACT_ROUTER_SERVER_BUILD_ID = "virtual:react-router/server-build";
120
121/**
122 * The virtual module this integration makes the server build's rollup input:
123 * it turns the `ServerBuild` manifest into a web-standard fetch handler.
124 */
125export const SERVER_ENTRY_ID = "virtual:alchemy/react-router-server-entry";
126
127/** Rollup's convention for a plugin-owned virtual module id. */
128const RESOLVED_SERVER_ENTRY_ID = `\0${SERVER_ENTRY_ID}`;
129
130export interface ReactRouterOptions {
131 /**
132 * Project root (the directory containing `vite.config.*`).
133 * @default process.cwd()
134 */
135 readonly root?: string | undefined;
136 /**
137 * The deploy target the build is produced for. Accepts a target value, a
138 * `(config) => target` factory, or a module specifier string.
139 * @default "@alchemy.run/frontend-frameworks/react-router/aws"
140 */
141 readonly target?: ReactRouterTargetInput | undefined;
142 /**
143 * Build output directory relative to the project root — the parent of
144 * `client/` and `server/`. Mirrors `react-router.config.ts`'s
145 * `buildDirectory`; used only as a fallback when the resolved Vite config
146 * could not be observed.
147 * @default "build"
148 */
149 readonly buildDirectory?: string | undefined;
150 readonly dev?:
151 | {
152 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
153 readonly port?: number | undefined;
154 }
155 | undefined;
156}
157
158const fail = (message: string, cause?: unknown) =>
159 new FrameworkError({ framework: "react-router", message, cause });
160
161/**
162 * The source of the virtual server entry ({@link SERVER_ENTRY_ID}).
163 *
164 * React Router's server build is a manifest, not a handler: it has no
165 * default export and nothing to call. `createRequestHandler` from
166 * `react-router` turns it into `(request) => Promise<Response>`, and this
167 * module exposes that as the `{ fetch }` shape every deploy target's
168 * finishing pass already knows how to wrap.
169 *
170 * This wrapping happens at BUILD time (as the server build's rollup input)
171 * rather than as a file emitted afterwards, because `react-router` itself
172 * must be part of the emitted bundle — the deployed server directory ships
173 * without a `node_modules` tree.
174 */
175export const serverEntrySource = (mode: string = "production"): string =>
176 `// Auto-generated by @alchemy.run/frontend-frameworks/react-router — do not edit.
177import * as build from "${REACT_ROUTER_SERVER_BUILD_ID}";
178import { createRequestHandler } from "react-router";
179
180const handleRequest = createRequestHandler(build, ${JSON.stringify(mode)});
181
182export default {
183 fetch(request) {
184 return handleRequest(request);
185 },
186};
187`;
188
189/** The structural slice of a Vite plugin this integration constructs. */
190export interface InlineVitePlugin {
191 readonly name: string;
192 readonly enforce?: "pre" | "post" | undefined;
193 readonly resolveId?: ((id: string) => string | undefined) | undefined;
194 readonly load?: ((id: string) => string | undefined) | undefined;
195 readonly configResolved?:
196 | ((config: ResolvedViteBuildSlice) => void)
197 | undefined;
198}
199
200/**
201 * The Vite plugin serving {@link SERVER_ENTRY_ID}. `enforce: "pre"` so the
202 * virtual id resolves before any project plugin sees it.
203 */
204export const serverEntryPlugin = (options?: {
205 readonly mode?: string | undefined;
206}): InlineVitePlugin => ({
207 name: "alchemy:react-router-server-entry",
208 enforce: "pre",
209 resolveId: (id) =>
210 id === SERVER_ENTRY_ID ? RESOLVED_SERVER_ENTRY_ID : undefined,
211 load: (id) =>
212 id === RESOLVED_SERVER_ENTRY_ID
213 ? serverEntrySource(options?.mode)
214 : undefined,
215});
216
217/** A mutable slot the {@link captureOutDirPlugin} writes the resolved outDir into. */
218export interface OutDirCapture {
219 outDir?: string | undefined;
220}
221
222/**
223 * The Vite plugin that observes the RESOLVED output directory of a build.
224 *
225 * React Router derives both output directories from `react-router.config.ts`
226 * (`buildDirectory`), which is TypeScript the integration must not evaluate
227 * itself. Reading them off the config Vite actually resolved is exact, free,
228 * and honors every project override.
229 */
230export const captureOutDirPlugin = (
231 capture: OutDirCapture,
232): InlineVitePlugin => ({
233 name: "alchemy:react-router-capture-outdir",
234 enforce: "post",
235 configResolved: (config) => {
236 capture.outDir = config.build.outDir;
237 },
238});
239
240/**
241 * The inline config for the CLIENT pass, merged over the project's own
242 * `vite.config.*` (which loads natively, plugins included).
243 */
244export const inlineClientBuildConfig = (
245 root: string,
246 plugins: ReadonlyArray<InlineVitePlugin>,
247): Record<string, unknown> => ({
248 root,
249 mode: "production",
250 // Warn-level: rolldown-vite's native progress reporter writes straight to
251 // the fd, corrupting hosting-process reporters that can only intercept
252 // JS-level writers.
253 logLevel: "warn",
254 plugins,
255});
256
257/**
258 * The inline config for the SERVER pass.
259 *
260 * Two overrides carry the whole AWS story:
261 *
262 * - `build.ssr` selects React Router's server environment (the flag its
263 * plugin branches on), and `build.rollupOptions.input` replaces the
264 * framework's `virtual:react-router/server-build` input with the fetch
265 * handler wrapper ({@link serverEntrySource}) — the plugin honors a
266 * user-provided input verbatim.
267 * - `noExternal` is load-bearing: Vite externalizes `node_modules` imports
268 * from SSR bundles by default, so a stock React Router server build emits
269 * `import { ServerRouter } from "react-router"` and is only runnable next
270 * to an installed dependency tree. A Lambda ships the server directory
271 * alone, so the bundle must be self-contained — only `node:` builtins
272 * stay external. Declared on both the legacy `ssr` key and the
273 * environment key so it holds across Vite majors.
274 */
275export const inlineServerBuildConfig = (
276 root: string,
277 plugins: ReadonlyArray<InlineVitePlugin>,
278): Record<string, unknown> => ({
279 root,
280 mode: "production",
281 logLevel: "warn",
282 build: {
283 ssr: true,
284 rollupOptions: { input: SERVER_ENTRY_ID },
285 },
286 ssr: { noExternal: true },
287 environments: { ssr: { resolve: { noExternal: true } } },
288 plugins,
289});
290
291/**
292 * Build the `Framework` service implementation for a React Router v7
293 * project (framework mode).
294 *
295 * - `build` resolves the deploy target, then either delegates wholesale
296 * (`target.build`) or drives the PROJECT's own Vite install
297 * programmatically, mirroring what `react-router build` does: the client
298 * pass first (it writes the asset manifest the server pass reads), then
299 * the server pass with `build.ssr`. `reactRouter()` in the project's
300 * `vite.config.ts` owns routing, typegen, and both environments — there
301 * is no adapter to select and no plugin to inject beyond the fetch-handler
302 * entry ({@link serverEntrySource}) and the self-contained SSR bundle
303 * ({@link inlineServerBuildConfig}). The `BuildOutput` is read from disk:
304 * `serverModules` entry-first from the server directory,
305 * `clientDirectory` = the client directory, both observed off the
306 * resolved Vite config.
307 * - `dev` runs the project's own Vite dev server (React Router's plugin
308 * serves SSR, resource routes, and typegen through it with native HMR),
309 * scoped — closing the Scope closes the server.
310 *
311 * NOT supported: React Server Components / multi-environment builds (React
312 * Router's `unstable` RSC plugin, or `future.v8_viteEnvironmentApi`). Those
313 * emit more than one server environment, which this two-pass build does not
314 * assemble; the build fails with an actionable error when no single server
315 * entry is produced.
316 *
317 * This module is target-agnostic: everything AWS-specific lives in
318 * `@alchemy.run/frontend-frameworks/react-router/aws`.
319 */
320export const make: (
321 options?: ReactRouterOptions,
322) => Effect.Effect<
323 Framework["Service"],
324 never,
325 FileSystem.FileSystem | Path.Path
326> = Effect.fnUntraced(function* (options?: ReactRouterOptions) {
327 const fs = yield* FileSystem.FileSystem;
328 const path = yield* Path.Path;
329 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
330
331 const targetConfig: ReactRouterTargetConfig = {
332 buildDirectory: options?.buildDirectory,
333 };
334
335 const resolveTarget = (root: string) =>
336 FrameworkCore.resolveDeployTarget<
337 ReactRouterTarget,
338 ReactRouterTargetConfig
339 >(root, options?.target ?? DEFAULT_TARGET_SPECIFIER, targetConfig).pipe(
340 Effect.mapError((error) => fail(error.message, error.cause)),
341 );
342
343 const loadVite = (root: string) =>
344 FrameworkCore.loadProjectModule<ReactRouterViteModule>(root, "vite").pipe(
345 Effect.mapError((error) =>
346 fail("Failed to load the project's Vite install", error.cause),
347 ),
348 );
349
350 const removeViteManifests = (directories: ReadonlyArray<string>) =>
351 Effect.forEach(directories, (directory) =>
352 fs
353 .remove(path.join(directory, ".vite"), { recursive: true })
354 .pipe(Effect.ignore),
355 );
356
357 const build: Framework["Service"]["build"] = Effect.fn(
358 function* (buildOptions) {
359 const root = buildOptions?.root ?? baseRoot;
360 const target = yield* resolveTarget(root);
361 const targetContext = {
362 root,
363 framework: "react-router",
364 env: buildOptions?.env,
365 };
366
367 // Wholesale build takeover (the AWS target uses this seam to run the
368 // build in a disposable child process — vite.config.* executes user
369 // plugins that must not touch the engine process, and React Router's
370 // config loader resolves `react-router.config.ts` from the cwd).
371 if (target.build !== undefined) {
372 return yield* target.build(targetContext).pipe(
373 Effect.mapError((error) => fail(error.message, error.cause)),
374 Effect.provideService(FileSystem.FileSystem, fs),
375 Effect.provideService(Path.Path, path),
376 );
377 }
378
379 const vite = yield* loadVite(root);
380 const clientCapture: OutDirCapture = {};
381 const serverCapture: OutDirCapture = {};
382
383 yield* Effect.tryPromise({
384 try: () =>
385 vite.build(
386 inlineClientBuildConfig(root, [captureOutDirPlugin(clientCapture)]),
387 ),
388 catch: (error) => fail("Failed to build the client", error),
389 });
390 yield* Effect.tryPromise({
391 try: () =>
392 vite.build(
393 inlineServerBuildConfig(root, [
394 serverEntryPlugin(),
395 captureOutDirPlugin(serverCapture),
396 ]),
397 ),
398 catch: (error) => fail("Failed to build the server", error),
399 });
400
401 const fallbackDir = path.resolve(
402 root,
403 options?.buildDirectory ?? DEFAULT_BUILD_DIRECTORY,
404 );
405 const clientDir =
406 clientCapture.outDir !== undefined
407 ? path.resolve(root, clientCapture.outDir)
408 : path.join(fallbackDir, "client");
409 const serverDir =
410 serverCapture.outDir !== undefined
411 ? path.resolve(root, serverCapture.outDir)
412 : path.join(fallbackDir, "server");
413
414 // `react-router build` deletes the per-environment Vite manifests once
415 // the server pass has consumed the client one; ours would otherwise
416 // ship to the CDN as a public `/.vite/manifest.json`.
417 yield* removeViteManifests([clientDir, serverDir]);
418
419 const output = yield* readReactRouterOutput({
420 dir: path.dirname(serverDir),
421 serverDir,
422 clientDir,
423 serverEntryFileName: target.serverEntryFileName,
424 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
425
426 const entryName = output.serverModules?.[0]?.name;
427 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
428 ...targetContext,
429 ...(entryName !== undefined
430 ? { entry: path.join(output.distDirectory ?? root, entryName) }
431 : undefined),
432 }).pipe(
433 Effect.mapError((error) => fail(error.message, error.cause)),
434 Effect.provideService(FileSystem.FileSystem, fs),
435 Effect.provideService(Path.Path, path),
436 );
437 },
438 );
439
440 const dev: Framework["Service"]["dev"] = Effect.fn(function* (devOptions) {
441 const root = devOptions?.root ?? baseRoot;
442 const vite = yield* loadVite(root);
443 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral port
444 // on older Vite — see `resolveViteDevPort`.
445 const port = yield* FrameworkCore.resolveViteDevPort(
446 vite.version,
447 devOptions?.port ?? options?.dev?.port,
448 );
449 const host = devOptions?.host;
450
451 const server = yield* Effect.acquireRelease(
452 Effect.tryPromise({
453 try: async () => {
454 const server = await vite.createServer({
455 root,
456 logLevel: "warn",
457 server: {
458 port,
459 ...(host !== undefined ? { host } : undefined),
460 },
461 });
462 await server.listen();
463 return server;
464 },
465 catch: (error) =>
466 fail("Failed to start the React Router dev server", error),
467 }),
468 (server) =>
469 Effect.promise(async () => {
470 try {
471 await server.close();
472 } catch {
473 // teardown is best-effort
474 }
475 }),
476 );
477
478 const resolved = server.resolvedUrls?.local[0];
479 if (resolved === undefined) {
480 return yield* Effect.fail(fail("Could not determine the dev server URL"));
481 }
482 // Vite reports its local URL with a trailing slash; hand back an origin
483 // that concatenates correctly (`${url}/about`, not `//about`).
484 const url = resolved.endsWith("/") ? resolved.slice(0, -1) : resolved;
485
486 // Bounded readiness probe: any HTTP response counts (vite serves
487 // lazily; we only need the listener to answer).
488 yield* Effect.tryPromise({
489 try: async () => {
490 const response = await fetch(url, { redirect: "manual" });
491 await response.arrayBuffer().catch(() => {});
492 },
493 catch: (error) => fail("The dev server did not become reachable", error),
494 }).pipe(
495 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
496 );
497
498 return { url };
499 });
500
501 return Framework.of({ build, dev });
502});
503
504/** The resolved React Router build output directories. */
505export interface ReactRouterOutputDirs {
506 /** Root output directory (`<root>/build`) — the parent of both. */
507 readonly dir: string;
508 /** Server bundle directory (`<root>/build/server`). */
509 readonly serverDir: string;
510 /** Client assets directory (`<root>/build/client`), `public/` copied in. */
511 readonly clientDir: string;
512 /**
513 * The emitted server entry file name within `serverDir`.
514 * @default "index.js"
515 */
516 readonly serverEntryFileName?: string | undefined;
517}
518
519/**
520 * Pick the server entry among the modules read off disk.
521 *
522 * The expected name wins. Otherwise, a build whose server directory holds
523 * exactly one top-level module (chunks live under `assets/`) is
524 * unambiguous — that module is the entry, which covers a project that sets
525 * `serverBuildFile` to something other than `index.js`.
526 */
527export const selectServerEntryName = (
528 moduleNames: ReadonlyArray<string>,
529 expected: string,
530): string | undefined => {
531 if (moduleNames.includes(expected)) return expected;
532 const topLevel = moduleNames.filter(
533 (name) =>
534 !name.slice("server/".length).includes("/") &&
535 (name.endsWith(".js") || name.endsWith(".mjs")),
536 );
537 return topLevel.length === 1 ? topLevel[0] : undefined;
538};
539
540/**
541 * Map React Router's on-disk build tree onto the `BuildOutput` contract:
542 * `serverModules` entry-first from `<buildDirectory>/server` (POSIX names,
543 * sha256-hashed), `clientDirectory` = `<buildDirectory>/client`. The server
544 * bundle is built with `noExternal`, so it is self-contained (only `node:`
545 * builtins external) and there are no external workspace roots to watch.
546 */
547export const readReactRouterOutput = (
548 dirs: ReactRouterOutputDirs,
549): Effect.Effect<
550 FrameworkCore.BuildOutput,
551 FrameworkError,
552 FileSystem.FileSystem
553> =>
554 Effect.gen(function* () {
555 const expected = `server/${dirs.serverEntryFileName ?? DEFAULT_SERVER_BUILD_FILE}`;
556 const modules = yield* FrameworkCore.readServerModulesFromDisk({
557 directory: dirs.serverDir,
558 prefix: "server",
559 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
560 if (modules.length === 0) {
561 return yield* Effect.fail(
562 fail(
563 `The React Router build produced no server modules in ${dirs.serverDir}`,
564 ),
565 );
566 }
567 const entryName = selectServerEntryName(
568 modules.map((module) => module.name),
569 expected,
570 );
571 if (entryName === undefined) {
572 return yield* Effect.fail(
573 fail(
574 `The React Router build produced no "${expected}" entry in ${dirs.serverDir} — ` +
575 "React Server Components and multi-environment builds are not supported yet.",
576 ),
577 );
578 }
579 return {
580 distDirectory: dirs.dir,
581 clientDirectory: dirs.clientDir,
582 serverModules: FrameworkCore.sortServerModules(modules, entryName),
583 externalWorkspaces: new Set<string>(),
584 } satisfies FrameworkCore.BuildOutput;
585 });
586
587/**
588 * A `Layer` providing framework-core's `Framework` service for a React
589 * Router project — the fully-typed entrypoint for `e2e.config.ts` and
590 * alchemy's `AWS.Website.ReactRouter` composite.
591 */
592export const layer = (
593 options?: ReactRouterOptions,
594): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
595 Layer.effect(Framework, make(options));