| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import type * as Scope from "effect/Scope"; |
| 6 | import { describe, expect, it } from "vitest"; |
| 7 | import { |
| 8 | generateLambdaEntry, |
| 9 | LAMBDA_ADAPTER_FILE_NAME, |
| 10 | LAMBDA_ENTRY_FILE_NAME, |
| 11 | makeAwsTarget, |
| 12 | SERVER_ENTRY_FILE_NAME, |
| 13 | } from "../aws.ts"; |
| 14 | import { fromHarnessOptions } from "../index.ts"; |
| 15 | import { |
| 16 | DEFAULT_TARGET_SPECIFIER, |
| 17 | inlineBuildConfig, |
| 18 | make, |
| 19 | readTanStackStartOutput, |
| 20 | selectServerEntryName, |
| 21 | type TanStackStartTarget, |
| 22 | } from "../TanStackStart.ts"; |
| 23 | |
| 24 | const 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 | |
| 33 | describe("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 | |
| 154 | describe("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 | |
| 180 | describe("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 | |
| 202 | describe("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 | |
| 228 | describe("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 | |
| 245 | describe("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 | |
| 308 | describe("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 | |
| 343 | describe("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. */ |
| 353 | const finishOnly = (target: TanStackStartTarget): TanStackStartTarget => { |
| 354 | const { build: _build, ...rest } = target; |
| 355 | return rest; |
| 356 | }; |