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 * `createBuilder` (not `build`) is the entry point: TanStack Start's plugin
20 * declares a `builder.buildApp` that builds the `client` and `ssr`
21 * environments (plus the server-function provider environment when RSC is
22 * enabled). Vite's legacy `build()` only builds the default environment, so
23 * it would silently produce the client bundle and nothing else.
24 */
25export interface TanStackStartViteModule {
26 readonly version?: string;
27 readonly createBuilder: (
28 config: Record<string, unknown>,
29 ) => Promise<TanStackStartViteBuilder>;
30 readonly createServer: (
31 config: Record<string, unknown>,
32 ) => Promise<TanStackStartViteDevServer>;
33}
34
35/** The structural slice of a vite builder this package reads. */
36export interface TanStackStartViteBuilder {
37 readonly buildApp: () => Promise<unknown>;
38}
39
40/** The structural slice of a Vite dev server this package reads. */
41export interface TanStackStartViteDevServer {
42 readonly listen: () => Promise<unknown>;
43 readonly close: () => Promise<void>;
44 readonly resolvedUrls?:
45 | { readonly local: ReadonlyArray<string> }
46 | null
47 | undefined;
48}
49
50/**
51 * The configuration this package assembles from
52 * {@link TanStackStartOptions} and hands to a deploy-target factory. The
53 * target treats it as its `DeployTarget.config`; the framework half never
54 * inspects a resolved target's config.
55 */
56export interface TanStackStartTargetConfig {
57 /**
58 * Build output directory relative to the project root — the parent of
59 * `client/` and `server/`. Mirrors the project config's `build.outDir`.
60 * @default "dist"
61 */
62 readonly outDir?: string | undefined;
63}
64
65/**
66 * A deploy target for TanStack Start: the generic `DeployTarget` seams plus
67 * the one framework-specific hook the build needs — the file name of the
68 * server entry the SSR environment emits, which the target's finishing pass
69 * wraps.
70 */
71export interface TanStackStartTarget extends DeployTarget<TanStackStartTargetConfig> {
72 /**
73 * The server entry file name within the SSR output directory. TanStack
74 * Start's SSR environment builds a single rollup input — the resolved
75 * server entry (`src/server.ts`, else the framework's default one) — so
76 * the emitted chunk is named after it (`server.js`). Omitted targets fall
77 * back to {@link DEFAULT_SERVER_ENTRY_FILE_NAME}, and a build that emits
78 * exactly one top-level module under the server directory uses that
79 * instead when the expected name is absent.
80 */
81 readonly serverEntryFileName?: string | undefined;
82}
83
84/**
85 * How a deploy target is passed to this package: a `TanStackStartTarget`
86 * value, a factory `(config) => TanStackStartTarget`, or a module specifier
87 * resolved from the *project's* `node_modules`.
88 */
89export type TanStackStartTargetInput = DeployTargetInput<
90 TanStackStartTarget,
91 TanStackStartTargetConfig
92>;
93
94/**
95 * The default deploy target: this package's own AWS Lambda target module,
96 * loaded from the project's dependency tree. (Cloudflare deploys TanStack
97 * Start through its native Vite integration — `Cloudflare.Website.Vite` —
98 * so no Cloudflare target exists here.)
99 */
100export const DEFAULT_TARGET_SPECIFIER =
101 "@alchemy.run/frontend-frameworks/tanstack-start/aws";
102
103/** The vite plugin package a TanStack Start project must install. */
104export const TANSTACK_START_PLUGIN_SPECIFIER = "@tanstack/start-plugin-core";
105
106/** The default root output directory (the parent of `client/` and `server/`). */
107export const DEFAULT_OUT_DIR = "dist";
108
109/** The default server entry file name inside the SSR output directory. */
110export const DEFAULT_SERVER_ENTRY_FILE_NAME = "server.js";
111
112export interface TanStackStartOptions {
113 /**
114 * Project root (the directory containing `vite.config.*`).
115 * @default process.cwd()
116 */
117 readonly root?: string | undefined;
118 /**
119 * The deploy target the build is produced for. Accepts a target value, a
120 * `(config) => target` factory, or a module specifier string.
121 * @default "@alchemy.run/frontend-frameworks/tanstack-start/aws"
122 */
123 readonly target?: TanStackStartTargetInput | undefined;
124 /**
125 * Build output directory relative to the project root — the parent of
126 * `client/` and `server/`. Must match the project's `build.outDir` when it
127 * sets one.
128 * @default "dist"
129 */
130 readonly outDir?: string | undefined;
131 readonly dev?:
132 | {
133 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
134 readonly port?: number | undefined;
135 }
136 | undefined;
137}
138
139const fail = (message: string, cause?: unknown) =>
140 new FrameworkError({ framework: "tanstack-start", message, cause });
141
142/**
143 * The inline config the integration merges over the project's own
144 * `vite.config.*`.
145 *
146 * `resolve.noExternal: true` on the SSR environment is the load-bearing
147 * override: vite externalizes `node_modules` imports from SSR bundles by
148 * default, so a stock TanStack Start server build emits
149 * `import { RouterProvider } from "@tanstack/react-router"` and is only
150 * runnable next to an installed dependency tree. A Lambda ships the server
151 * directory alone, so the SSR bundle must be self-contained — only `node:`
152 * builtins stay external. Declared on both the legacy `ssr` key and the
153 * environment key so it holds across Vite majors.
154 */
155export const inlineBuildConfig = (
156 root: string,
157 outDir: string | undefined,
158): Record<string, unknown> => ({
159 root,
160 // Warn-level: rolldown-vite's native progress reporter writes straight to
161 // the fd, corrupting hosting-process reporters that can only intercept
162 // JS-level writers.
163 logLevel: "warn",
164 ...(outDir !== undefined ? { build: { outDir } } : undefined),
165 ssr: { noExternal: true },
166 environments: { ssr: { resolve: { noExternal: true } } },
167});
168
169/**
170 * Build the `Framework` service implementation for a TanStack Start project.
171 *
172 * - `build` resolves the deploy target, then either delegates wholesale
173 * (`target.build`) or drives the PROJECT's own Vite install
174 * programmatically: `createBuilder({ root, ... })` followed by
175 * `buildApp()`. TanStack Start is pure Vite — `tanstackStart()` in the
176 * project's `vite.config.ts` owns routing, the client/`ssr` environments,
177 * and the server entry — so there is no adapter to select and no plugin to
178 * inject; the integration only forces the SSR bundle to be self-contained
179 * (see {@link inlineBuildConfig}). The `BuildOutput` is read from disk:
180 * `serverModules` entry-first from `<outDir>/server`, `clientDirectory` =
181 * `<outDir>/client`.
182 * - `dev` runs the project's own Vite dev server (TanStack Start's
183 * dev-server plugin serves SSR, server routes, and server functions
184 * through it with native HMR), scoped — closing the Scope closes the
185 * server.
186 *
187 * This module is target-agnostic: everything AWS-specific lives in
188 * `@alchemy.run/frontend-frameworks/tanstack-start/aws`.
189 */
190export const make: (
191 options?: TanStackStartOptions,
192) => Effect.Effect<
193 Framework["Service"],
194 never,
195 FileSystem.FileSystem | Path.Path
196> = Effect.fnUntraced(function* (options?: TanStackStartOptions) {
197 const fs = yield* FileSystem.FileSystem;
198 const path = yield* Path.Path;
199 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
200
201 const targetConfig: TanStackStartTargetConfig = { outDir: options?.outDir };
202
203 const resolveTarget = (root: string) =>
204 FrameworkCore.resolveDeployTarget<
205 TanStackStartTarget,
206 TanStackStartTargetConfig
207 >(root, options?.target ?? DEFAULT_TARGET_SPECIFIER, targetConfig).pipe(
208 Effect.mapError((error) => fail(error.message, error.cause)),
209 );
210
211 const loadVite = (root: string) =>
212 FrameworkCore.loadProjectModule<TanStackStartViteModule>(root, "vite").pipe(
213 Effect.mapError((error) =>
214 fail("Failed to load the project's Vite install", error.cause),
215 ),
216 );
217
218 const build: Framework["Service"]["build"] = Effect.fn(
219 function* (buildOptions) {
220 const root = buildOptions?.root ?? baseRoot;
221 const target = yield* resolveTarget(root);
222 const targetContext = {
223 root,
224 framework: "tanstack-start",
225 env: buildOptions?.env,
226 };
227
228 // Wholesale build takeover (the AWS target uses this seam to run the
229 // build in a disposable child process — vite.config.* executes user
230 // plugins that must not touch the engine process, and TanStack
231 // Start's plugin crawls framework packages from `process.cwd()`).
232 if (target.build !== undefined) {
233 return yield* target.build(targetContext).pipe(
234 Effect.mapError((error) => fail(error.message, error.cause)),
235 Effect.provideService(FileSystem.FileSystem, fs),
236 Effect.provideService(Path.Path, path),
237 );
238 }
239
240 const vite = yield* loadVite(root);
241 yield* Effect.tryPromise({
242 try: async () => {
243 const builder = await vite.createBuilder(
244 inlineBuildConfig(root, options?.outDir),
245 );
246 return await builder.buildApp();
247 },
248 catch: (error) => fail("Failed to build", error),
249 });
250
251 const dir = path.resolve(root, options?.outDir ?? DEFAULT_OUT_DIR);
252 const output = yield* readTanStackStartOutput({
253 dir,
254 serverDir: path.join(dir, "server"),
255 clientDir: path.join(dir, "client"),
256 serverEntryFileName: target.serverEntryFileName,
257 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
258
259 const entryName = output.serverModules?.[0]?.name;
260 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
261 ...targetContext,
262 ...(entryName !== undefined
263 ? { entry: path.join(dir, entryName) }
264 : undefined),
265 }).pipe(
266 Effect.mapError((error) => fail(error.message, error.cause)),
267 Effect.provideService(FileSystem.FileSystem, fs),
268 Effect.provideService(Path.Path, path),
269 );
270 },
271 );
272
273 const dev: Framework["Service"]["dev"] = Effect.fn(function* (devOptions) {
274 const root = devOptions?.root ?? baseRoot;
275 const vite = yield* loadVite(root);
276 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral port
277 // on older Vite — see `resolveViteDevPort`.
278 const port = yield* FrameworkCore.resolveViteDevPort(
279 vite.version,
280 devOptions?.port ?? options?.dev?.port,
281 );
282 const host = devOptions?.host;
283
284 const server = yield* Effect.acquireRelease(
285 Effect.tryPromise({
286 try: async () => {
287 const server = await vite.createServer({
288 root,
289 logLevel: "warn",
290 server: {
291 port,
292 ...(host !== undefined ? { host } : undefined),
293 },
294 });
295 await server.listen();
296 return server;
297 },
298 catch: (error) =>
299 fail("Failed to start the TanStack Start dev server", error),
300 }),
301 (server) =>
302 Effect.promise(async () => {
303 try {
304 await server.close();
305 } catch {
306 // teardown is best-effort
307 }
308 }),
309 );
310
311 const resolved = server.resolvedUrls?.local[0];
312 if (resolved === undefined) {
313 return yield* Effect.fail(fail("Could not determine the dev server URL"));
314 }
315 // Vite reports its local URL with a trailing slash; hand back an origin
316 // that concatenates correctly (`${url}/about`, not `//about`).
317 const url = resolved.endsWith("/") ? resolved.slice(0, -1) : resolved;
318
319 // Bounded readiness probe: any HTTP response counts (vite serves
320 // lazily; we only need the listener to answer).
321 yield* Effect.tryPromise({
322 try: async () => {
323 const response = await fetch(url, { redirect: "manual" });
324 await response.arrayBuffer().catch(() => {});
325 },
326 catch: (error) => fail("The dev server did not become reachable", error),
327 }).pipe(
328 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
329 );
330
331 return { url };
332 });
333
334 return Framework.of({ build, dev });
335});
336
337/** The resolved TanStack Start build output directories. */
338export interface TanStackStartOutputDirs {
339 /** Root output directory (`<root>/dist`). */
340 readonly dir: string;
341 /** SSR bundle directory (`<root>/dist/server`). */
342 readonly serverDir: string;
343 /** Client assets directory (`<root>/dist/client`), `public/` copied in. */
344 readonly clientDir: string;
345 /**
346 * The emitted server entry file name within `serverDir`.
347 * @default "server.js"
348 */
349 readonly serverEntryFileName?: string | undefined;
350}
351
352/**
353 * Pick the server entry among the modules read off disk.
354 *
355 * The expected name wins. Otherwise, a build whose server directory holds
356 * exactly one top-level module (chunks live under `assets/`) is
357 * unambiguous — that module is the entry, which covers a project that names
358 * its server entry something other than `server.ts`.
359 */
360export const selectServerEntryName = (
361 moduleNames: ReadonlyArray<string>,
362 expected: string,
363): string | undefined => {
364 if (moduleNames.includes(expected)) return expected;
365 const topLevel = moduleNames.filter(
366 (name) =>
367 !name.slice("server/".length).includes("/") &&
368 (name.endsWith(".js") || name.endsWith(".mjs")),
369 );
370 return topLevel.length === 1 ? topLevel[0] : undefined;
371};
372
373/**
374 * Map TanStack Start's on-disk `dist` onto the `BuildOutput` contract:
375 * `serverModules` entry-first from `<outDir>/server` (POSIX names,
376 * sha256-hashed), `clientDirectory` = `<outDir>/client`. The SSR bundle is
377 * built with `resolve.noExternal: true`, so it is self-contained (only
378 * `node:` builtins external) and there are no external workspace roots to
379 * watch.
380 */
381export const readTanStackStartOutput = (
382 dirs: TanStackStartOutputDirs,
383): Effect.Effect<
384 FrameworkCore.BuildOutput,
385 FrameworkError,
386 FileSystem.FileSystem
387> =>
388 Effect.gen(function* () {
389 const expected = `server/${dirs.serverEntryFileName ?? DEFAULT_SERVER_ENTRY_FILE_NAME}`;
390 const modules = yield* FrameworkCore.readServerModulesFromDisk({
391 directory: dirs.serverDir,
392 prefix: "server",
393 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
394 if (modules.length === 0) {
395 return yield* Effect.fail(
396 fail(
397 `The TanStack Start build produced no server modules in ${dirs.serverDir}`,
398 ),
399 );
400 }
401 const entryName = selectServerEntryName(
402 modules.map((module) => module.name),
403 expected,
404 );
405 if (entryName === undefined) {
406 return yield* Effect.fail(
407 fail(
408 `The TanStack Start build produced no "${expected}" entry in ${dirs.serverDir} — ` +
409 "is the SSR environment's rollup input the project's server entry?",
410 ),
411 );
412 }
413 return {
414 distDirectory: dirs.dir,
415 clientDirectory: dirs.clientDir,
416 serverModules: FrameworkCore.sortServerModules(modules, entryName),
417 externalWorkspaces: new Set<string>(),
418 } satisfies FrameworkCore.BuildOutput;
419 });
420
421/**
422 * A `Layer` providing framework-core's `Framework` service for a TanStack
423 * Start project — the fully-typed entrypoint for `e2e.config.ts` and
424 * alchemy's `AWS.Website.TanStackStart` composite.
425 */
426export const layer = (
427 options?: TanStackStartOptions,
428): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
429 Layer.effect(Framework, make(options));