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_TARGET_SPECIFIER,
17 inlineBuildConfig,
18 make,
19 readTanStackStartOutput,
20 selectServerEntryName,
21 type TanStackStartTarget,
22} from "../TanStackStart.ts";
23
24const runWithNode = <A, E>(
25 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
26): Promise<A> =>
27 Effect.runPromise(
28 Effect.scoped(effect).pipe(
29 Effect.provide(NodeServices.layer),
30 ) as Effect.Effect<A, E>,
31 );
32
33describe("readTanStackStartOutput", () => {
34 it("maps a dist tree onto the BuildOutput contract, entry first", async () => {
35 const output = await runWithNode(
36 Effect.gen(function* () {
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const dir = yield* fs.makeTempDirectoryScoped({
40 prefix: "tanstack-start-output-",
41 });
42 const serverDir = path.join(dir, "server");
43 const clientDir = path.join(dir, "client");
44 yield* fs.makeDirectory(path.join(serverDir, "assets"), {
45 recursive: true,
46 });
47 yield* fs.makeDirectory(clientDir, { recursive: true });
48 // alphabetically before server.js, to prove entry-first sorting
49 yield* fs.writeFileString(
50 path.join(serverDir, "assets", "router-abc.js"),
51 "export const a = 1;",
52 );
53 yield* fs.writeFileString(
54 path.join(serverDir, "server.js"),
55 "export default { fetch() {} };",
56 );
57 yield* fs.writeFileString(
58 path.join(clientDir, "robots.txt"),
59 "User-agent: *\n",
60 );
61 return yield* readTanStackStartOutput({ dir, serverDir, clientDir });
62 }),
63 );
64 expect(output.serverModules?.map((module) => module.name)).toEqual([
65 "server/server.js",
66 "server/assets/router-abc.js",
67 ]);
68 expect(output.serverModules?.[0]?.hash).toMatch(/^[0-9a-f]{64}$/);
69 expect(output.clientDirectory?.endsWith("client")).toBe(true);
70 expect(output.distDirectory).toBeDefined();
71 expect(output.externalWorkspaces.size).toBe(0);
72 });
73
74 it("falls back to the single top-level module when the entry is renamed", async () => {
75 const output = await runWithNode(
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const dir = yield* fs.makeTempDirectoryScoped({
80 prefix: "tanstack-start-output-",
81 });
82 const serverDir = path.join(dir, "server");
83 yield* fs.makeDirectory(path.join(serverDir, "assets"), {
84 recursive: true,
85 });
86 yield* fs.writeFileString(
87 path.join(serverDir, "assets", "chunk-abc.js"),
88 "export const a = 1;",
89 );
90 yield* fs.writeFileString(
91 path.join(serverDir, "entry-server.js"),
92 "export default { fetch() {} };",
93 );
94 return yield* readTanStackStartOutput({
95 dir,
96 serverDir,
97 clientDir: path.join(dir, "client"),
98 });
99 }),
100 );
101 expect(output.serverModules?.[0]?.name).toBe("server/entry-server.js");
102 });
103
104 it("fails actionably when no server entry can be identified", async () => {
105 const error = await runWithNode(
106 Effect.gen(function* () {
107 const fs = yield* FileSystem.FileSystem;
108 const path = yield* Path.Path;
109 const dir = yield* fs.makeTempDirectoryScoped({
110 prefix: "tanstack-start-output-",
111 });
112 const serverDir = path.join(dir, "server");
113 yield* fs.makeDirectory(serverDir, { recursive: true });
114 yield* fs.writeFileString(
115 path.join(serverDir, "one.js"),
116 "export const a = 1;",
117 );
118 yield* fs.writeFileString(
119 path.join(serverDir, "two.js"),
120 "export const b = 1;",
121 );
122 return yield* readTanStackStartOutput({
123 dir,
124 serverDir,
125 clientDir: path.join(dir, "client"),
126 }).pipe(Effect.flip);
127 }),
128 );
129 expect(error._tag).toBe("FrameworkError");
130 expect(error.message).toContain('no "server/server.js" entry');
131 });
132
133 it("fails when the build produced no server modules", async () => {
134 const error = await runWithNode(
135 Effect.gen(function* () {
136 const fs = yield* FileSystem.FileSystem;
137 const path = yield* Path.Path;
138 const dir = yield* fs.makeTempDirectoryScoped({
139 prefix: "tanstack-start-output-",
140 });
141 const serverDir = path.join(dir, "server");
142 yield* fs.makeDirectory(serverDir, { recursive: true });
143 return yield* readTanStackStartOutput({
144 dir,
145 serverDir,
146 clientDir: path.join(dir, "client"),
147 }).pipe(Effect.flip);
148 }),
149 );
150 expect(error.message).toContain("no server modules");
151 });
152});
153
154describe("selectServerEntryName", () => {
155 it("prefers the expected name over a lone sibling", () => {
156 expect(
157 selectServerEntryName(
158 ["server/server.js", "server/other.js"],
159 "server/server.js",
160 ),
161 ).toBe("server/server.js");
162 });
163
164 it("ignores nested chunks when counting top-level modules", () => {
165 expect(
166 selectServerEntryName(
167 ["server/assets/a.js", "server/assets/b.js", "server/entry.js"],
168 "server/server.js",
169 ),
170 ).toBe("server/entry.js");
171 });
172
173 it("is undefined when the choice is ambiguous", () => {
174 expect(
175 selectServerEntryName(["server/a.js", "server/b.js"], "server/server.js"),
176 ).toBeUndefined();
177 });
178});
179
180describe("inlineBuildConfig", () => {
181 it("forces a self-contained SSR bundle", () => {
182 const config = inlineBuildConfig("/app", undefined) as {
183 environments: { ssr: { resolve: { noExternal: boolean } } };
184 build?: unknown;
185 root: string;
186 };
187 // Vite externalizes node_modules from SSR bundles by default; a Lambda
188 // ships the server directory alone, so it must be self-contained.
189 expect(config.environments.ssr.resolve.noExternal).toBe(true);
190 expect(config.root).toBe("/app");
191 expect(config.build).toBeUndefined();
192 });
193
194 it("forwards an outDir override", () => {
195 const config = inlineBuildConfig("/app", "build") as {
196 build: { outDir: string };
197 };
198 expect(config.build.outDir).toBe("build");
199 });
200});
201
202describe("generateLambdaEntry", () => {
203 it("wraps the server entry's fetch handler for streaming by default", () => {
204 const source = generateLambdaEntry({
205 streaming: true,
206 serverEntryFileName: SERVER_ENTRY_FILE_NAME,
207 });
208 expect(source).toContain(`import * as serverEntry from "./server.js"`);
209 expect(source).toContain(
210 `import { toLambdaHandler } from "./${LAMBDA_ADAPTER_FILE_NAME}"`,
211 );
212 expect(source).toContain("export const handler = toLambdaHandler(");
213 // A custom server entry may default-export the bare fetch function.
214 expect(source).toContain('typeof entry === "function"');
215 });
216
217 it("emits the buffered wrapper when streaming is disabled", () => {
218 const source = generateLambdaEntry({
219 streaming: false,
220 serverEntryFileName: "entry-server.js",
221 });
222 expect(source).toContain("toBufferedLambdaHandler");
223 expect(source).not.toContain("toLambdaHandler(fetchHandler)");
224 expect(source).toContain(`from "./entry-server.js"`);
225 });
226});
227
228describe("makeAwsTarget", () => {
229 it("declares the Lambda packaging seams", () => {
230 const target = makeAwsTarget();
231 expect(target.platform).toBe("aws");
232 expect(target.serverEntryFileName).toBe(SERVER_ENTRY_FILE_NAME);
233 expect(target.bundle?.conditions).toEqual(["node", "import", "module"]);
234 expect(target.bundle?.external).toEqual(["@aws-sdk/"]);
235 expect(typeof target.finish).toBe("function");
236 // The wholesale build hook runs the build in a disposable child process.
237 expect(typeof target.build).toBe("function");
238 });
239
240 it("carries the outDir override on its config", () => {
241 expect(makeAwsTarget({ outDir: "build" }).config.outDir).toBe("build");
242 });
243});
244
245describe("finish", () => {
246 it("emits the Lambda entry, the ESM marker, and the adapter copy", async () => {
247 const output = await runWithNode(
248 Effect.gen(function* () {
249 const fs = yield* FileSystem.FileSystem;
250 const path = yield* Path.Path;
251 const dir = yield* fs.makeTempDirectoryScoped({
252 prefix: "tanstack-start-finish-",
253 });
254 const serverDir = path.join(dir, "server");
255 yield* fs.makeDirectory(serverDir, { recursive: true });
256 yield* fs.writeFileString(
257 path.join(serverDir, SERVER_ENTRY_FILE_NAME),
258 "export default { fetch() {} };",
259 );
260 const target = makeAwsTarget();
261 return yield* target.finish!(
262 {
263 distDirectory: dir,
264 clientDirectory: path.join(dir, "client"),
265 serverModules: [],
266 externalWorkspaces: new Set<string>(),
267 },
268 {
269 root: dir,
270 framework: "tanstack-start",
271 entry: path.join(serverDir, SERVER_ENTRY_FILE_NAME),
272 },
273 );
274 }),
275 );
276 const names = output.serverModules?.map((module) => module.name) ?? [];
277 // The Lambda entry sorts first — it becomes the deployed handler module.
278 expect(names[0]).toBe(`server/${LAMBDA_ENTRY_FILE_NAME}`);
279 expect(names).toContain("server/package.json");
280 expect(names).toContain(`server/${LAMBDA_ADAPTER_FILE_NAME}`);
281 expect(names).toContain(`server/${SERVER_ENTRY_FILE_NAME}`);
282 const manifest = output.serverModules?.find(
283 (module) => module.name === "server/package.json",
284 );
285 expect(String(manifest?.content)).toContain('"type":"module"');
286 });
287
288 it("fails when the framework produced no on-disk entry", async () => {
289 const error = await runWithNode(
290 Effect.gen(function* () {
291 const target = makeAwsTarget();
292 return yield* target.finish!(
293 {
294 distDirectory: "/tmp",
295 clientDirectory: undefined,
296 serverModules: [],
297 externalWorkspaces: new Set<string>(),
298 },
299 { root: "/tmp", framework: "tanstack-start" },
300 ).pipe(Effect.flip);
301 }),
302 );
303 expect(error._tag).toBe("DeployTargetError");
304 expect(error.message).toContain("no on-disk server entry");
305 });
306});
307
308describe("make", () => {
309 it("defaults to this package's AWS deploy target", () => {
310 expect(DEFAULT_TARGET_SPECIFIER).toBe(
311 "@alchemy.run/frontend-frameworks/tanstack-start/aws",
312 );
313 });
314
315 it("build fails with a descriptive FrameworkError outside a Vite project", async () => {
316 // /tmp has no vite install: loading the project's Vite is the first step
317 // after the target resolves and must surface a FrameworkError.
318 const result = await runWithNode(
319 Effect.gen(function* () {
320 const framework = yield* make({
321 root: "/tmp/does-not-matter",
322 target: finishOnly(makeAwsTarget()),
323 });
324 return yield* Effect.result(
325 framework.build({ root: "/tmp/does-not-matter" }),
326 );
327 }),
328 );
329 expect(result._tag).toBe("Failure");
330 if (result._tag === "Failure") {
331 expect(result.failure._tag).toBe("FrameworkError");
332 expect(result.failure.framework).toBe("tanstack-start");
333 }
334 });
335
336 it("exposes the framework contract (build + dev)", async () => {
337 const framework = await runWithNode(make({ root: "/tmp/does-not-matter" }));
338 expect(typeof framework.build).toBe("function");
339 expect(typeof framework.dev).toBe("function");
340 });
341});
342
343describe("fromHarnessOptions", () => {
344 it("forwards the harness's outDir override", () => {
345 expect(
346 fromHarnessOptions({ tanstackStart: { outDir: "build" } }).outDir,
347 ).toBe("build");
348 expect(fromHarnessOptions({}).outDir).toBeUndefined();
349 });
350});
351
352/** Drop the wholesale `build` hook so the in-process pipeline runs. */
353const finishOnly = (target: TanStackStartTarget): TanStackStartTarget => {
354 const { build: _build, ...rest } = target;
355 return rest;
356};