1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import type * as Scope from "effect/Scope";
6import { describe, expect, it } from "vitest";
7import {
8 generateLambdaEntry,
9 LAMBDA_ADAPTER_FILE_NAME,
10 LAMBDA_ENTRY_FILE_NAME,
11 makeAwsTarget,
12 SERVER_ENTRY_FILE_NAME,
13} from "../aws.ts";
14import { fromHarnessOptions } from "../index.ts";
15import {
16 DEFAULT_BUILD_DIRECTORY,
17 DEFAULT_SERVER_BUILD_FILE,
18 DEFAULT_TARGET_SPECIFIER,
19 inlineClientBuildConfig,
20 inlineServerBuildConfig,
21 make,
22 REACT_ROUTER_SERVER_BUILD_ID,
23 readReactRouterOutput,
24 selectServerEntryName,
25 SERVER_ENTRY_ID,
26 serverEntryPlugin,
27 serverEntrySource,
28 type ReactRouterTarget,
29} from "../ReactRouter.ts";
30
31const runWithNode = <A, E>(
32 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
33): Promise<A> =>
34 Effect.runPromise(
35 Effect.scoped(effect).pipe(
36 Effect.provide(NodeServices.layer),
37 ) as Effect.Effect<A, E>,
38 );
39
40describe("readReactRouterOutput", () => {
41 it("maps a build tree onto the BuildOutput contract, entry first", async () => {
42 const output = await runWithNode(
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const dir = yield* fs.makeTempDirectoryScoped({
47 prefix: "react-router-output-",
48 });
49 const serverDir = path.join(dir, "server");
50 const clientDir = path.join(dir, "client");
51 yield* fs.makeDirectory(path.join(serverDir, "assets"), {
52 recursive: true,
53 });
54 yield* fs.makeDirectory(clientDir, { recursive: true });
55 // alphabetically before index.js, to prove entry-first sorting
56 yield* fs.writeFileString(
57 path.join(serverDir, "assets", "chunk-abc.js"),
58 "export const a = 1;",
59 );
60 yield* fs.writeFileString(
61 path.join(serverDir, DEFAULT_SERVER_BUILD_FILE),
62 "export const entry = {};",
63 );
64 yield* fs.writeFileString(
65 path.join(clientDir, "robots.txt"),
66 "User-agent: *\n",
67 );
68 return yield* readReactRouterOutput({ dir, serverDir, clientDir });
69 }),
70 );
71 expect(output.serverModules?.map((module) => module.name)).toEqual([
72 "server/index.js",
73 "server/assets/chunk-abc.js",
74 ]);
75 expect(output.serverModules?.[0]?.hash).toMatch(/^[0-9a-f]{64}$/);
76 expect(output.clientDirectory?.endsWith("client")).toBe(true);
77 expect(output.distDirectory).toBeDefined();
78 expect(output.externalWorkspaces.size).toBe(0);
79 });
80
81 it("honors a serverBuildFile override", async () => {
82 const output = await runWithNode(
83 Effect.gen(function* () {
84 const fs = yield* FileSystem.FileSystem;
85 const path = yield* Path.Path;
86 const dir = yield* fs.makeTempDirectoryScoped({
87 prefix: "react-router-output-",
88 });
89 const serverDir = path.join(dir, "server");
90 yield* fs.makeDirectory(serverDir, { recursive: true });
91 yield* fs.writeFileString(
92 path.join(serverDir, "app.js"),
93 "export const entry = {};",
94 );
95 yield* fs.writeFileString(
96 path.join(serverDir, "index.js"),
97 "export const other = {};",
98 );
99 return yield* readReactRouterOutput({
100 dir,
101 serverDir,
102 clientDir: path.join(dir, "client"),
103 serverEntryFileName: "app.js",
104 });
105 }),
106 );
107 expect(output.serverModules?.[0]?.name).toBe("server/app.js");
108 });
109
110 it("falls back to the single top-level module when the entry is renamed", async () => {
111 const output = await runWithNode(
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const dir = yield* fs.makeTempDirectoryScoped({
116 prefix: "react-router-output-",
117 });
118 const serverDir = path.join(dir, "server");
119 yield* fs.makeDirectory(path.join(serverDir, "assets"), {
120 recursive: true,
121 });
122 yield* fs.writeFileString(
123 path.join(serverDir, "assets", "chunk-abc.js"),
124 "export const a = 1;",
125 );
126 yield* fs.writeFileString(
127 path.join(serverDir, "server.js"),
128 "export const entry = {};",
129 );
130 return yield* readReactRouterOutput({
131 dir,
132 serverDir,
133 clientDir: path.join(dir, "client"),
134 });
135 }),
136 );
137 expect(output.serverModules?.[0]?.name).toBe("server/server.js");
138 });
139
140 it("names RSC / multi-environment builds as the unsupported case", async () => {
141 const error = await runWithNode(
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const dir = yield* fs.makeTempDirectoryScoped({
146 prefix: "react-router-output-",
147 });
148 const serverDir = path.join(dir, "server");
149 yield* fs.makeDirectory(serverDir, { recursive: true });
150 yield* fs.writeFileString(
151 path.join(serverDir, "rsc.js"),
152 "export const a = 1;",
153 );
154 yield* fs.writeFileString(
155 path.join(serverDir, "ssr.js"),
156 "export const b = 1;",
157 );
158 return yield* readReactRouterOutput({
159 dir,
160 serverDir,
161 clientDir: path.join(dir, "client"),
162 }).pipe(Effect.flip);
163 }),
164 );
165 expect(error._tag).toBe("FrameworkError");
166 expect(error.message).toContain('no "server/index.js" entry');
167 expect(error.message).toContain("React Server Components");
168 });
169
170 it("fails when the build produced no server modules", async () => {
171 const error = await runWithNode(
172 Effect.gen(function* () {
173 const fs = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const dir = yield* fs.makeTempDirectoryScoped({
176 prefix: "react-router-output-",
177 });
178 const serverDir = path.join(dir, "server");
179 yield* fs.makeDirectory(serverDir, { recursive: true });
180 return yield* readReactRouterOutput({
181 dir,
182 serverDir,
183 clientDir: path.join(dir, "client"),
184 }).pipe(Effect.flip);
185 }),
186 );
187 expect(error.message).toContain("no server modules");
188 });
189});
190
191describe("selectServerEntryName", () => {
192 it("prefers the expected name over a lone sibling", () => {
193 expect(
194 selectServerEntryName(
195 ["server/index.js", "server/other.js"],
196 "server/index.js",
197 ),
198 ).toBe("server/index.js");
199 });
200
201 it("ignores nested chunks when counting top-level modules", () => {
202 expect(
203 selectServerEntryName(
204 ["server/assets/a.js", "server/assets/b.js", "server/app.js"],
205 "server/index.js",
206 ),
207 ).toBe("server/app.js");
208 });
209
210 it("is undefined when the choice is ambiguous", () => {
211 expect(
212 selectServerEntryName(["server/a.js", "server/b.js"], "server/index.js"),
213 ).toBeUndefined();
214 });
215});
216
217describe("serverEntrySource", () => {
218 it("turns the ServerBuild manifest into a fetch handler", () => {
219 const source = serverEntrySource();
220 // React Router's server build is a manifest with no default export;
221 // `createRequestHandler` is what makes it callable.
222 expect(source).toContain(
223 `import * as build from "${REACT_ROUTER_SERVER_BUILD_ID}"`,
224 );
225 expect(source).toContain(
226 'import { createRequestHandler } from "react-router"',
227 );
228 expect(source).toContain('createRequestHandler(build, "production")');
229 expect(source).toContain("export default {");
230 });
231
232 it("forwards the build mode", () => {
233 expect(serverEntrySource("development")).toContain(
234 'createRequestHandler(build, "development")',
235 );
236 });
237});
238
239describe("serverEntryPlugin", () => {
240 it("resolves and loads the virtual entry, and nothing else", () => {
241 const plugin = serverEntryPlugin();
242 expect(plugin.enforce).toBe("pre");
243 const resolved = plugin.resolveId!(SERVER_ENTRY_ID);
244 expect(resolved).toBe(`\0${SERVER_ENTRY_ID}`);
245 expect(plugin.resolveId!("react-router")).toBeUndefined();
246 expect(plugin.load!(resolved!)).toContain("createRequestHandler");
247 expect(plugin.load!(SERVER_ENTRY_ID)).toBeUndefined();
248 });
249});
250
251describe("inlineServerBuildConfig", () => {
252 it("selects the server environment and replaces its rollup input", () => {
253 const config = inlineServerBuildConfig("/app", []) as {
254 build: { ssr: boolean; rollupOptions: { input: string } };
255 ssr: { noExternal: boolean };
256 environments: { ssr: { resolve: { noExternal: boolean } } };
257 root: string;
258 };
259 // `build.ssr` is the flag React Router's plugin branches on; the input
260 // swap is honored verbatim by its server-environment resolver.
261 expect(config.build.ssr).toBe(true);
262 expect(config.build.rollupOptions.input).toBe(SERVER_ENTRY_ID);
263 // Vite externalizes node_modules from SSR bundles by default; a Lambda
264 // ships the server directory alone, so it must be self-contained.
265 expect(config.ssr.noExternal).toBe(true);
266 expect(config.environments.ssr.resolve.noExternal).toBe(true);
267 expect(config.root).toBe("/app");
268 });
269});
270
271describe("inlineClientBuildConfig", () => {
272 it("builds the client environment without SSR overrides", () => {
273 const config = inlineClientBuildConfig("/app", []) as {
274 root: string;
275 build?: unknown;
276 ssr?: unknown;
277 };
278 expect(config.root).toBe("/app");
279 expect(config.build).toBeUndefined();
280 expect(config.ssr).toBeUndefined();
281 });
282});
283
284describe("generateLambdaEntry", () => {
285 it("wraps the server entry's fetch handler for streaming by default", () => {
286 const source = generateLambdaEntry({
287 streaming: true,
288 serverEntryFileName: SERVER_ENTRY_FILE_NAME,
289 });
290 expect(source).toContain(`import * as serverEntry from "./index.js"`);
291 expect(source).toContain(
292 `import { toLambdaHandler } from "./${LAMBDA_ADAPTER_FILE_NAME}"`,
293 );
294 expect(source).toContain("export const handler = toLambdaHandler(");
295 // A project entry may default-export the bare fetch function.
296 expect(source).toContain('typeof entry === "function"');
297 });
298
299 it("emits the buffered wrapper when streaming is disabled", () => {
300 const source = generateLambdaEntry({
301 streaming: false,
302 serverEntryFileName: "app.js",
303 });
304 expect(source).toContain("toBufferedLambdaHandler");
305 expect(source).not.toContain("toLambdaHandler(fetchHandler)");
306 expect(source).toContain(`from "./app.js"`);
307 });
308});
309
310describe("makeAwsTarget", () => {
311 it("declares the Lambda packaging seams", () => {
312 const target = makeAwsTarget();
313 expect(target.platform).toBe("aws");
314 expect(target.serverEntryFileName).toBe(DEFAULT_SERVER_BUILD_FILE);
315 expect(target.bundle?.conditions).toEqual(["node", "import", "module"]);
316 expect(target.bundle?.external).toEqual(["@aws-sdk/"]);
317 expect(typeof target.finish).toBe("function");
318 // The wholesale build hook runs the build in a disposable child process.
319 expect(typeof target.build).toBe("function");
320 });
321
322 it("carries the buildDirectory and streaming overrides on its config", () => {
323 const config = makeAwsTarget({
324 buildDirectory: "dist",
325 streaming: false,
326 }).config;
327 expect(config.buildDirectory).toBe("dist");
328 expect(config.streaming).toBe(false);
329 });
330});
331
332describe("finish", () => {
333 it("emits the Lambda entry, the ESM marker, and the adapter copy", async () => {
334 const output = await runWithNode(
335 Effect.gen(function* () {
336 const fs = yield* FileSystem.FileSystem;
337 const path = yield* Path.Path;
338 const dir = yield* fs.makeTempDirectoryScoped({
339 prefix: "react-router-finish-",
340 });
341 const serverDir = path.join(dir, "server");
342 yield* fs.makeDirectory(serverDir, { recursive: true });
343 yield* fs.writeFileString(
344 path.join(serverDir, SERVER_ENTRY_FILE_NAME),
345 "export default { fetch() {} };",
346 );
347 const target = makeAwsTarget();
348 return yield* target.finish!(
349 {
350 distDirectory: dir,
351 clientDirectory: path.join(dir, "client"),
352 serverModules: [],
353 externalWorkspaces: new Set<string>(),
354 },
355 {
356 root: dir,
357 framework: "react-router",
358 entry: path.join(serverDir, SERVER_ENTRY_FILE_NAME),
359 },
360 );
361 }),
362 );
363 const names = output.serverModules?.map((module) => module.name) ?? [];
364 // The Lambda entry sorts first — it becomes the deployed handler module.
365 expect(names[0]).toBe(`server/${LAMBDA_ENTRY_FILE_NAME}`);
366 expect(names).toContain("server/package.json");
367 expect(names).toContain(`server/${LAMBDA_ADAPTER_FILE_NAME}`);
368 // `lambda.mjs`, not `index.mjs`: the server build already owns index.js.
369 expect(names).toContain(`server/${SERVER_ENTRY_FILE_NAME}`);
370 const manifest = output.serverModules?.find(
371 (module) => module.name === "server/package.json",
372 );
373 expect(String(manifest?.content)).toContain('"type":"module"');
374 });
375
376 it("fails when the framework produced no on-disk entry", async () => {
377 const error = await runWithNode(
378 Effect.gen(function* () {
379 const target = makeAwsTarget();
380 return yield* target.finish!(
381 {
382 distDirectory: "/tmp",
383 clientDirectory: undefined,
384 serverModules: [],
385 externalWorkspaces: new Set<string>(),
386 },
387 { root: "/tmp", framework: "react-router" },
388 ).pipe(Effect.flip);
389 }),
390 );
391 expect(error._tag).toBe("DeployTargetError");
392 expect(error.message).toContain("no on-disk server entry");
393 });
394});
395
396describe("make", () => {
397 it("defaults to this package's AWS deploy target", () => {
398 expect(DEFAULT_TARGET_SPECIFIER).toBe(
399 "@alchemy.run/frontend-frameworks/react-router/aws",
400 );
401 expect(DEFAULT_BUILD_DIRECTORY).toBe("build");
402 });
403
404 it("build fails with a descriptive FrameworkError outside a Vite project", async () => {
405 // /tmp has no vite install: loading the project's Vite is the first step
406 // after the target resolves and must surface a FrameworkError.
407 const result = await runWithNode(
408 Effect.gen(function* () {
409 const framework = yield* make({
410 root: "/tmp/does-not-matter",
411 target: finishOnly(makeAwsTarget()),
412 });
413 return yield* Effect.result(
414 framework.build({ root: "/tmp/does-not-matter" }),
415 );
416 }),
417 );
418 expect(result._tag).toBe("Failure");
419 if (result._tag === "Failure") {
420 expect(result.failure._tag).toBe("FrameworkError");
421 expect(result.failure.framework).toBe("react-router");
422 }
423 });
424
425 it("exposes the framework contract (build + dev)", async () => {
426 const framework = await runWithNode(make({ root: "/tmp/does-not-matter" }));
427 expect(typeof framework.build).toBe("function");
428 expect(typeof framework.dev).toBe("function");
429 });
430});
431
432describe("fromHarnessOptions", () => {
433 it("forwards the harness's buildDirectory override", () => {
434 expect(
435 fromHarnessOptions({ reactRouter: { buildDirectory: "dist" } })
436 .buildDirectory,
437 ).toBe("dist");
438 expect(fromHarnessOptions({}).buildDirectory).toBeUndefined();
439 });
440});
441
442/** Drop the wholesale `build` hook so the in-process pipeline runs. */
443const finishOnly = (target: ReactRouterTarget): ReactRouterTarget => {
444 const { build: _build, ...rest } = target;
445 return rest;
446};