1import * as NodePath from "node:path";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6import * as Schedule from "effect/Schedule";
7import * as Semaphore from "effect/Semaphore";
8import * as FrameworkCore from "../core/index.ts";
9import {
10 Framework,
11 FrameworkError,
12 type DeployTarget,
13 type DeployTargetInput,
14} from "../core/index.ts";
15import {
16 findPresetConflict,
17 hasForeignNitroPlugin,
18 markInjectedPlugins,
19 pluginConflictMessage,
20 presetConflictMessage,
21 resolveNitroConfig,
22 resolveNitroOutputDirs,
23 type NitroConfigSlice,
24} from "./UserConfig.ts";
25
26/**
27 * The structural slice of the project's `vite` module this package drives.
28 * Typed structurally so the package carries no dependency on vite — the
29 * *project's* install is always the one loaded.
30 *
31 * `createBuilder` (not `build`) is the entry point: SolidStart declares a
32 * `builder.buildApp` that builds the client and SSR environments (and, with
33 * the nitro plugin, runs nitro over the SSR bundle). Vite's legacy `build()`
34 * only builds the default environment, so it would silently produce the
35 * client bundle and nothing else.
36 */
37export interface SolidStartViteModule {
38 readonly version?: string;
39 readonly createBuilder: (
40 config: Record<string, unknown>,
41 ) => Promise<SolidStartViteBuilder>;
42 readonly createServer: (
43 config: Record<string, unknown>,
44 ) => Promise<SolidStartViteDevServer>;
45}
46
47/** The structural slice of a vite builder this package reads. */
48export interface SolidStartViteBuilder {
49 readonly buildApp: () => Promise<unknown>;
50}
51
52/** The structural slice of a Vite dev server this package reads. */
53export interface SolidStartViteDevServer {
54 readonly listen: () => Promise<unknown>;
55 readonly close: () => Promise<void>;
56 readonly resolvedUrls?:
57 | { readonly local: ReadonlyArray<string> }
58 | null
59 | undefined;
60}
61
62/**
63 * The structural slice of the project's `@solidjs/vite-plugin-nitro-2`
64 * module this package drives.
65 */
66export interface SolidStartNitroPluginModule {
67 readonly nitroV2Plugin: (nitroConfig?: Record<string, unknown>) => unknown;
68}
69
70/** Inputs the framework passes when consulting the target's nitro hook. */
71export interface SolidStartNitroContext {
72 /** Absolute project root. */
73 readonly root: string;
74}
75
76/**
77 * The configuration this package assembles from {@link SolidStartOptions}
78 * and hands to a deploy-target factory. The target treats it as its
79 * `DeployTarget.config`; the framework half never inspects a resolved
80 * target's config.
81 */
82export interface SolidStartTargetConfig {
83 /**
84 * Nitro options forwarded into the appended `nitroV2Plugin(...)` instance
85 * (prerendering, route rules, storage, ...). `preset` is owned by the
86 * deploy target and may not be set here.
87 *
88 * Carried on the target config so wholesale targets that re-run the
89 * framework in a child process (the AWS target) can reconstruct the
90 * framework with the same options.
91 */
92 readonly nitro?: Record<string, unknown> | undefined;
93}
94
95/**
96 * A deploy target for SolidStart: the generic `DeployTarget` seams plus the
97 * nitro hooks the SolidStart build needs — the deployment preset to build
98 * with, and a last-word pass over the nitro config the integration hands to
99 * the SolidStart nitro plugin.
100 */
101export interface SolidStartTarget extends DeployTarget<SolidStartTargetConfig> {
102 /** The nitro deployment preset this target builds with (e.g. `"aws-lambda"`). */
103 readonly nitroPreset: string;
104 /**
105 * Last-word mutation of the nitro config, run after the caller's `nitro`
106 * overrides and after the preset is enforced (e.g. the AWS target forces
107 * `awsLambda.streaming`).
108 */
109 readonly configureNitro?:
110 | ((nitroConfig: NitroConfigSlice, context: SolidStartNitroContext) => void)
111 | undefined;
112}
113
114/**
115 * How a deploy target is passed to this package: a `SolidStartTarget` value,
116 * a factory `(config) => SolidStartTarget`, or a module specifier resolved
117 * from the *project's* `node_modules`.
118 */
119export type SolidStartTargetInput = DeployTargetInput<
120 SolidStartTarget,
121 SolidStartTargetConfig
122>;
123
124/**
125 * The default deploy target: this package's own AWS Lambda target module,
126 * loaded from the project's dependency tree. (Cloudflare deploys SolidStart
127 * through its native Vite integration — `Cloudflare.Website.Vite` — so no
128 * Cloudflare target exists here.)
129 */
130export const DEFAULT_TARGET_SPECIFIER =
131 "@alchemy.run/frontend-frameworks/solidstart/aws";
132
133/** The specifier the project's SolidStart nitro plugin is loaded from. */
134export const NITRO_PLUGIN_SPECIFIER = "@solidjs/vite-plugin-nitro-2";
135
136export interface SolidStartOptions {
137 /**
138 * Project root (the directory containing `vite.config.*`).
139 * @default process.cwd()
140 */
141 readonly root?: string | undefined;
142 /**
143 * The deploy target the build is produced for. Accepts a target value, a
144 * `(config) => target` factory, or a module specifier string.
145 * @default "@alchemy.run/frontend-frameworks/solidstart/aws"
146 */
147 readonly target?: SolidStartTargetInput | undefined;
148 /**
149 * Nitro options forwarded into the appended `nitroV2Plugin(...)` instance.
150 * `preset` is owned by the deploy target and may not be set here.
151 */
152 readonly nitro?: Record<string, unknown> | undefined;
153 readonly dev?:
154 | {
155 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
156 readonly port?: number | undefined;
157 }
158 | undefined;
159}
160
161const fail = (message: string, cause?: unknown) =>
162 new FrameworkError({ framework: "solidstart", message, cause });
163
164/**
165 * Serializes cwd swaps across concurrent dev-server starts in one process (the
166 * same discipline as the Waku framework's lock). Without it two interleaved
167 * acquire/release pairs leave the process cwd pointing at the wrong project:
168 * A chdir(/orig -> /a); B captures previous=/a, chdir(/b); A restores /orig;
169 * B restores /a.
170 */
171const cwdLock = Semaphore.makeUnsafe(1);
172
173/**
174 * Run `effect` with the process working directory set to the project root,
175 * restoring it afterwards. A no-op (and lock-free) when the cwd already is the
176 * root — the case for the isolated build child process.
177 *
178 * SolidStart 2's `solidStart()` plugin resolves the app root, the route
179 * directory, and the `~` alias from `process.cwd()` at config-load time —
180 * not from vite's `root` — so it only finds the app when the process is IN
181 * the project. Production builds are already isolated in a child process
182 * whose cwd IS the root (see `aws.ts`), which makes this a no-op there; the
183 * dev server runs in the caller's process (alchemy's dev sidecar), so the
184 * cwd is aligned only across the window in which vite evaluates the config
185 * module, and restored immediately after.
186 *
187 * Consequence, upstream and unavoidable while the plugin keys off the cwd:
188 * one process can host the dev server of ONE SolidStart project at a time.
189 */
190const inProjectCwd = <A, E, R>(
191 root: string,
192 effect: Effect.Effect<A, E, R>,
193): Effect.Effect<A, E | FrameworkError, R> =>
194 Effect.flatMap(
195 Effect.sync(() => process.cwd() === NodePath.resolve(root)),
196 (isRoot) =>
197 isRoot
198 ? effect
199 : Semaphore.withPermits(
200 cwdLock,
201 1,
202 )(
203 Effect.acquireUseRelease(
204 Effect.try({
205 try: () => {
206 const previous = process.cwd();
207 process.chdir(root);
208 return previous;
209 },
210 catch: (error) =>
211 fail(`Failed to enter the project directory ${root}`, error),
212 }),
213 () => effect,
214 (previous) =>
215 Effect.sync(() => {
216 try {
217 if (process.cwd() !== previous) {
218 process.chdir(previous);
219 }
220 } catch {
221 // restoring the cwd is best-effort
222 }
223 }),
224 ),
225 ),
226 );
227
228/**
229 * Build the `Framework` service implementation for a SolidStart project.
230 *
231 * - `build` resolves the deploy target, then either delegates wholesale
232 * (`target.build`) or drives the PROJECT's own Vite install
233 * programmatically: `createBuilder({ root, plugins: [nitroV2Plugin(...)] })`
234 * followed by `buildApp()`. The project's `vite.config.*` loads natively
235 * (its `solidStart()` plugin is untouched); the integration APPENDS a
236 * `nitroV2Plugin` instance — loaded from the project's own
237 * `node_modules` — carrying the deploy target's preset. Inline plugins are
238 * merged after the config file's, so the appended instance's
239 * `builder.buildApp` gets the last word. The `BuildOutput` is read from
240 * nitro's `.output`: `serverModules` entry-first from `.output/server`,
241 * `clientDirectory` = `.output/public` (prerendered pages included).
242 * - `dev` runs the project's own Vite dev server (SolidStart's dev-server
243 * plugin serves SSR through it, with native HMR), scoped — closing the
244 * Scope closes the server. Nitro plays no part in dev.
245 *
246 * This module is target-agnostic: everything AWS-specific lives in
247 * `@alchemy.run/frontend-frameworks/solidstart/aws`.
248 */
249export const make: (
250 options?: SolidStartOptions,
251) => Effect.Effect<
252 Framework["Service"],
253 never,
254 FileSystem.FileSystem | Path.Path
255> = Effect.fnUntraced(function* (options?: SolidStartOptions) {
256 const fs = yield* FileSystem.FileSystem;
257 const path = yield* Path.Path;
258 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
259
260 const targetConfig: SolidStartTargetConfig = { nitro: options?.nitro };
261
262 const resolveTarget = (root: string) =>
263 FrameworkCore.resolveDeployTarget<SolidStartTarget, SolidStartTargetConfig>(
264 root,
265 options?.target ?? DEFAULT_TARGET_SPECIFIER,
266 targetConfig,
267 ).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
268
269 const requireNitroPreset = (target: SolidStartTarget) =>
270 typeof target.nitroPreset === "string" && target.nitroPreset.length > 0
271 ? Effect.succeed(target)
272 : Effect.fail(
273 fail(
274 `The resolved "${target.platform}" deploy target does not declare the nitro ` +
275 "preset hook (`nitroPreset`) and has no wholesale `build`",
276 ),
277 );
278
279 const loadVite = (root: string) =>
280 FrameworkCore.loadProjectModule<SolidStartViteModule>(root, "vite").pipe(
281 Effect.mapError((error) =>
282 fail("Failed to load the project's Vite install", error.cause),
283 ),
284 );
285
286 const loadNitroPlugin = (root: string) =>
287 FrameworkCore.loadProjectModule<SolidStartNitroPluginModule>(
288 root,
289 NITRO_PLUGIN_SPECIFIER,
290 ).pipe(
291 Effect.mapError((error) =>
292 fail(
293 `Failed to load the project's "${NITRO_PLUGIN_SPECIFIER}" — install it alongside ` +
294 "`@solidjs/start`; it is the server half of a SolidStart build",
295 error.cause,
296 ),
297 ),
298 );
299
300 const build: Framework["Service"]["build"] = Effect.fn(
301 function* (buildOptions) {
302 const root = buildOptions?.root ?? baseRoot;
303 const target = yield* resolveTarget(root);
304 const targetContext = {
305 root,
306 framework: "solidstart",
307 env: buildOptions?.env,
308 };
309
310 // Wholesale build takeover (the AWS target uses this seam to run the
311 // build in a disposable child process — vite.config.* executes user
312 // plugins that must not touch the engine process).
313 if (target.build !== undefined) {
314 return yield* target.build(targetContext).pipe(
315 Effect.mapError((error) => fail(error.message, error.cause)),
316 Effect.provideService(FileSystem.FileSystem, fs),
317 Effect.provideService(Path.Path, path),
318 );
319 }
320
321 yield* requireNitroPreset(target);
322
323 // Conflict policy: the deploy target owns the preset, so a foreign one
324 // is rejected with an actionable error instead of being replaced — a
325 // build for a different preset would never be deployed.
326 const conflict = findPresetConflict(options?.nitro, target.nitroPreset);
327 if (conflict !== undefined) {
328 return yield* Effect.fail(
329 fail(presetConflictMessage(conflict, target.nitroPreset)),
330 );
331 }
332
333 const vite = yield* loadVite(root);
334 const nitro = yield* loadNitroPlugin(root);
335
336 const nitroConfig = resolveNitroConfig({
337 preset: target.nitroPreset,
338 rootDir: root,
339 nitro: options?.nitro,
340 configure: (config) => target.configureNitro?.(config, { root }),
341 });
342
343 // Detected inside vite's own config resolution (the project's
344 // `vite.config.*` has been loaded and merged by then), reported as a
345 // FrameworkError once the build promise rejects.
346 const detected = { conflict: false };
347 const plugins = markInjectedPlugins([
348 {
349 name: "alchemy:solidstart-nitro-conflict",
350 enforce: "pre" as const,
351 config: (config: { readonly plugins?: unknown }) => {
352 if (hasForeignNitroPlugin(config.plugins)) {
353 detected.conflict = true;
354 throw new Error(pluginConflictMessage());
355 }
356 return null;
357 },
358 },
359 nitro.nitroV2Plugin(nitroConfig),
360 ]);
361
362 yield* inProjectCwd(
363 root,
364 Effect.tryPromise({
365 try: async () => {
366 const builder = await vite.createBuilder({
367 root,
368 // Fixture clones have no local node_modules, so Vite would
369 // otherwise share `packages/alchemy/node_modules/.vite` with
370 // every other website test. Vocs writes shiki there; the
371 // SolidStart nitro graph then emits 11MB of grammars and
372 // SSR GET `/` hangs. Pin the cache under this project.
373 cacheDir: path.join(root, "node_modules", ".vite"),
374 // Warn-level: rolldown-vite's native progress reporter writes
375 // straight to the fd, corrupting hosting-process reporters
376 // that can only intercept JS-level writers.
377 logLevel: "warn",
378 plugins,
379 });
380 return await builder.buildApp();
381 },
382 catch: (error) =>
383 detected.conflict
384 ? fail(pluginConflictMessage(), error)
385 : fail("Failed to build", error),
386 }),
387 );
388
389 const dirs = resolveNitroOutputDirs(nitroConfig, root, (...segments) =>
390 path.resolve(...segments),
391 );
392 const output = yield* readNitroOutput(dirs).pipe(
393 Effect.provideService(FileSystem.FileSystem, fs),
394 );
395
396 // The finishing-pass seam (none needed for the aws-lambda preset — the
397 // output is already a self-contained Node deployment unit — but the
398 // contract is honored for targets that do post-process).
399 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
400 ...targetContext,
401 entry: path.join(dirs.serverDir, "index.mjs"),
402 }).pipe(
403 Effect.mapError((error) => fail(error.message, error.cause)),
404 Effect.provideService(FileSystem.FileSystem, fs),
405 Effect.provideService(Path.Path, path),
406 );
407 },
408 );
409
410 const devInProcess: Framework["Service"]["dev"] = Effect.fn(
411 function* (devOptions) {
412 const root = devOptions?.root ?? baseRoot;
413 const vite = yield* loadVite(root);
414 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral port
415 // on older Vite — see `resolveViteDevPort`.
416 const port = yield* FrameworkCore.resolveViteDevPort(
417 vite.version,
418 devOptions?.port ?? options?.dev?.port,
419 );
420 const host = devOptions?.host;
421
422 const server = yield* Effect.acquireRelease(
423 inProjectCwd(
424 root,
425 Effect.tryPromise({
426 try: async () => {
427 const server = await vite.createServer({
428 root,
429 cacheDir: path.join(root, "node_modules", ".vite"),
430 logLevel: "warn",
431 server: {
432 port,
433 ...(host !== undefined ? { host } : undefined),
434 },
435 });
436 await server.listen();
437 return server;
438 },
439 catch: (error) =>
440 fail("Failed to start the SolidStart dev server", error),
441 }),
442 ),
443 (server) =>
444 Effect.promise(async () => {
445 try {
446 await server.close();
447 } catch {
448 // teardown is best-effort
449 }
450 }),
451 );
452
453 const resolved = server.resolvedUrls?.local[0];
454 if (resolved === undefined) {
455 return yield* Effect.fail(
456 fail("Could not determine the dev server URL"),
457 );
458 }
459 // Vite reports its local URL with a trailing slash. SolidStart runs with
460 // `appType: "custom"`, so its router sees the raw pathname and a caller
461 // that appends a path (`${url}/about`) would request `//about` and get a
462 // 404. Hand back an origin that concatenates correctly.
463 const url = resolved.endsWith("/") ? resolved.slice(0, -1) : resolved;
464
465 // Bounded readiness probe: any HTTP response counts (vite serves
466 // lazily; we only need the listener to answer).
467 yield* Effect.tryPromise({
468 try: async () => {
469 const response = await fetch(url, { redirect: "manual" });
470 await response.arrayBuffer().catch(() => {});
471 },
472 catch: (error) =>
473 fail("The dev server did not become reachable", error),
474 }).pipe(
475 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
476 );
477
478 return { url };
479 },
480 );
481
482 // SolidStart's `solidStart()` plugin resolves the app from `process.cwd()`
483 // at config-load time, so its startup needs `cwd === root`. That must not
484 // happen in a process hosting other sites (the alchemy dev sidecar) — run
485 // the dev server in a dedicated child whose cwd IS the root instead (see
486 // core/DevChild.ts). Inside that child (or when the options cannot cross
487 // the process boundary, e.g. a deploy-target VALUE from the e2e harness)
488 // the in-process path runs directly.
489 const dev: Framework["Service"]["dev"] = (devOptions) => {
490 const root = devOptions?.root ?? baseRoot;
491 const port = devOptions?.port ?? options?.dev?.port;
492 if (
493 FrameworkCore.isInsideDevChild() ||
494 !FrameworkCore.isJsonSerializable(options)
495 ) {
496 return devInProcess(devOptions);
497 }
498 return FrameworkCore.runDevChild({
499 framework: "solidstart",
500 module: "@alchemy.run/frontend-frameworks/solidstart",
501 callerUrl: import.meta.url,
502 rootDir: root,
503 makeOptions: { ...options, root },
504 devOptions: {
505 root,
506 ...(port !== undefined ? { port } : {}),
507 ...(devOptions?.host !== undefined ? { host: devOptions.host } : {}),
508 },
509 });
510 };
511
512 return Framework.of({ build, dev });
513});
514
515/** The nitro server entry's module name within the `BuildOutput` (POSIX). */
516export const SERVER_ENTRY_NAME = "server/index.mjs";
517
518/** The resolved nitro output directories. */
519export interface NitroOutputDirs {
520 /** Root output directory (`.output`). */
521 readonly dir: string;
522 /** Server bundle directory (`.output/server`). */
523 readonly serverDir: string;
524 /** Static assets directory (`.output/public`, prerendered pages included). */
525 readonly publicDir: string;
526}
527
528/**
529 * Map nitro's on-disk `.output` onto the `BuildOutput` contract:
530 * `serverModules` entry-first from the server directory (POSIX names,
531 * sha256-hashed), `clientDirectory` = the public directory. Nitro's output
532 * is self-contained (externals are copied into `.output/server/node_modules`),
533 * so there are no external workspace roots to watch.
534 */
535export const readNitroOutput = (
536 dirs: NitroOutputDirs,
537): Effect.Effect<
538 FrameworkCore.BuildOutput,
539 FrameworkError,
540 FileSystem.FileSystem
541> =>
542 Effect.gen(function* () {
543 const modules = yield* FrameworkCore.readServerModulesFromDisk({
544 directory: dirs.serverDir,
545 prefix: "server",
546 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
547 if (modules.length === 0) {
548 return yield* Effect.fail(
549 fail(
550 `The SolidStart build produced no server modules in ${dirs.serverDir}`,
551 ),
552 );
553 }
554 const serverModules = FrameworkCore.sortServerModules(
555 modules,
556 SERVER_ENTRY_NAME,
557 );
558 if (serverModules[0]?.name !== SERVER_ENTRY_NAME) {
559 return yield* Effect.fail(
560 fail(
561 `The SolidStart build produced no "${SERVER_ENTRY_NAME}" entry in ${dirs.serverDir}`,
562 ),
563 );
564 }
565 return {
566 distDirectory: dirs.dir,
567 clientDirectory: dirs.publicDir,
568 serverModules,
569 externalWorkspaces: new Set<string>(),
570 } satisfies FrameworkCore.BuildOutput;
571 });
572
573/**
574 * A `Layer` providing framework-core's `Framework` service for a SolidStart
575 * project — the fully-typed entrypoint for `e2e.config.ts` and alchemy's
576 * `AWS.Website.SolidStart` composite.
577 */
578export const layer = (
579 options?: SolidStartOptions,
580): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
581 Layer.effect(Framework, make(options));