1import * as FrameworkCore from "../core/index.ts";
2import {
3 Framework,
4 FrameworkError,
5 type DeployTarget,
6 type DeployTargetError,
7 type DeployTargetInput,
8} from "../core/index.ts";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Schedule from "effect/Schedule";
14import type * as Scope from "effect/Scope";
15import {
16 enforceNitroConfig,
17 findPresetConflict,
18 makeNuxtOverrides,
19 presetConflictMessage,
20 type NitroConfigSlice,
21 type NuxtConfigLayer,
22} from "./UserConfig.ts";
23
24/**
25 * The structural slice of the project's `@nuxt/kit` module this package
26 * drives. Typed structurally so the package carries no dependency on
27 * `@nuxt/kit` — the *project's* install is always the one loaded.
28 */
29export interface NuxtKitModule {
30 readonly loadNuxt: (options: {
31 readonly cwd: string;
32 readonly dev?: boolean;
33 readonly ready?: boolean;
34 readonly overrides?: Record<string, unknown>;
35 }) => Promise<NuxtInstance>;
36 readonly buildNuxt: (nuxt: NuxtInstance) => Promise<unknown>;
37}
38
39/** The structural slice of a loaded Nuxt instance this package reads. */
40export interface NuxtInstance {
41 readonly options: {
42 readonly _layers?: ReadonlyArray<NuxtConfigLayer> | undefined;
43 readonly [key: string]: unknown;
44 };
45 readonly hook: (
46 name: string,
47 fn: (...args: Array<never>) => unknown,
48 ) => unknown;
49 readonly ready: () => Promise<void>;
50 readonly close: () => Promise<void>;
51 /**
52 * The dev middleware server (nitro's dev server; present after `ready()`
53 * when loaded with `dev: true`). `listen` is listhen-backed: it binds a
54 * real HTTP listener serving the dev app.
55 */
56 readonly server?:
57 | {
58 readonly listen: (
59 port: number,
60 opts?: { readonly hostname?: string },
61 ) => Promise<NuxtDevListener>;
62 }
63 | undefined;
64}
65
66/** The structural slice of the listhen listener `nuxt.server.listen` returns. */
67export interface NuxtDevListener {
68 readonly url?: string | undefined;
69 readonly close?: (() => Promise<void>) | undefined;
70}
71
72/** The structural slice of a Nitro instance (`nitro:init` hook payload). */
73interface NitroInstanceSlice {
74 readonly options: {
75 entry?: string;
76 readonly output: {
77 readonly dir: string;
78 readonly serverDir: string;
79 readonly publicDir: string;
80 };
81 };
82}
83
84/** Inputs the framework passes when consulting the target's nitro hook. */
85export interface NuxtNitroContext {
86 /** Absolute project root. */
87 readonly root: string;
88 /**
89 * Absolute path of the USER's worker entry (the generic user-entry seam,
90 * `target.entry` resolved against the root), when one is configured. The
91 * target maps it onto nitro's entry/exports seam — the user module wraps
92 * nitro's emitted handler and adds its own exports (Durable Objects, ...).
93 */
94 readonly entry?: string | undefined;
95}
96
97/**
98 * The configuration this package assembles from {@link NuxtOptions} and hands
99 * to a deploy-target factory. The target treats it as its
100 * `DeployTarget.config`; the framework half never inspects a resolved
101 * target's config.
102 */
103export interface NuxtTargetConfig {
104 /** Compatibility date the target carries for serve/deploy. */
105 readonly compatibilityDate?: string | undefined;
106 /** Compatibility flags the target carries for serve/deploy. */
107 readonly compatibilityFlags?: Array<string> | undefined;
108 /**
109 * The USER's worker entry (the custom-entry carriage): a module that wraps
110 * nitro's emitted entry and adds its own exports (Durable Object classes,
111 * ...). Relative paths resolve against the project root.
112 */
113 readonly main?: string | undefined;
114 /**
115 * Whether the server build includes Node.js compatibility (nitro's hybrid
116 * workerd node-compat). The deployed worker must enable the
117 * `nodejs_compat` compatibility flag when this is on.
118 * @default true (applied by the Cloudflare target)
119 */
120 readonly nodeCompat?: boolean | undefined;
121 /**
122 * The nuxt config overrides the framework half was constructed with
123 * ({@link NuxtOptions.nuxt}). Carried on the target config so wholesale
124 * targets that re-run the framework in a child process (the AWS target —
125 * `loadNuxt` executes user config/modules that may mutate the process)
126 * can reconstruct the framework with the same options.
127 */
128 readonly nuxt?: Record<string, unknown> | undefined;
129}
130
131/** Inputs the framework passes when asking the target for its dev platform. */
132export interface NuxtDevPlatformContext {
133 /** Absolute project root. */
134 readonly root: string;
135 /**
136 * Literal env overrides for the dev platform — a same-named literal wins
137 * over any platform-served value.
138 */
139 readonly env?: Record<string, unknown> | undefined;
140 /**
141 * Target-specific binding specs the dev platform should serve (opaque to
142 * the framework half — the Cloudflare target takes `cloudflare-runtime`
143 * binding hooks and serves them through its platform proxy).
144 */
145 readonly bindings?: ReadonlyArray<unknown> | undefined;
146 /**
147 * Pre-built runtime-services context for the dev platform's proxy (opaque
148 * to the framework half — see `NuxtOptions.dev.services`).
149 */
150 readonly services?: unknown;
151}
152
153/**
154 * What a target's dev platform contributes to the dev server: extra nitro
155 * plugins and `runtimeConfig` to inject through the `loadNuxt` overrides
156 * (the Cloudflare target injects its `event.context.cloudflare` bridge
157 * plugin plus the platform-proxy connect info).
158 */
159export interface NuxtDevPlatform {
160 /** Absolute paths of nitro plugins to inject into the dev server. */
161 readonly nitroPlugins?: ReadonlyArray<string> | undefined;
162 /** `runtimeConfig` injected over the integration overrides. */
163 readonly runtimeConfig?: Record<string, unknown> | undefined;
164}
165
166/**
167 * A deploy target for Nuxt: the generic `DeployTarget` seams plus the
168 * framework-specific hooks nitro-driven builds and dev servers need — the
169 * nitro deployment preset to build with, a last-word pass over the resolved
170 * nitro config, and the dev-platform seam.
171 */
172export interface NuxtTarget extends DeployTarget<NuxtTargetConfig> {
173 /** The nitro deployment preset this target builds with (e.g. `"cloudflare_module"`). */
174 readonly nitroPreset: string;
175 /**
176 * Last-word mutation of the resolved nitro config, run from the
177 * `nitro:config` hook after the user's config, layers, and modules have
178 * all contributed (e.g. the Cloudflare target forces
179 * `cloudflare.deployConfig: false` and wires the user entry).
180 */
181 readonly configureNitro?:
182 | ((nitroConfig: NitroConfigSlice, context: NuxtNitroContext) => void)
183 | undefined;
184 /**
185 * The target's dev-platform story: acquire whatever serves the platform
186 * environment in dev (scoped — closing the Scope tears it down AFTER the
187 * dev server closes) and return the injection the dev server needs. When
188 * absent, `dev` runs the plain framework dev server with no platform
189 * bridge.
190 */
191 readonly devPlatform?:
192 | ((
193 context: NuxtDevPlatformContext,
194 ) => Effect.Effect<NuxtDevPlatform, DeployTargetError, Scope.Scope>)
195 | undefined;
196}
197
198/**
199 * How a deploy target is passed to this package: a `NuxtTarget` value, a
200 * factory `(config) => NuxtTarget`, or a module specifier resolved from the
201 * *project's* `node_modules` (default-export — or named export `target` — a
202 * value or factory).
203 */
204export type NuxtTargetInput = DeployTargetInput<NuxtTarget, NuxtTargetConfig>;
205
206/**
207 * The default deploy target: this package's own Cloudflare Workers target
208 * module (`src/cloudflare.ts`), loaded from the project's dependency tree.
209 */
210export const DEFAULT_TARGET_SPECIFIER =
211 "@alchemy.run/frontend-frameworks/nuxt/cloudflare";
212
213export interface NuxtOptions {
214 /**
215 * Project root (the directory containing `nuxt.config.ts`).
216 * @default process.cwd()
217 */
218 readonly root?: string | undefined;
219 /**
220 * The deploy target the build is produced for. Accepts a target value, a
221 * `(config) => target` factory, or a module specifier string.
222 * @default "@alchemy.run/frontend-frameworks/nuxt/cloudflare"
223 */
224 readonly target?: NuxtTargetInput | undefined;
225 /** Compatibility date forwarded to the target via its config. */
226 readonly compatibilityDate?: string | undefined;
227 /**
228 * Compatibility flags forwarded to the target via its config. Nitro's
229 * workerd build uses hybrid Node.js compatibility, so on workerd-style
230 * targets `nodejs_compat` is effectively required.
231 * @default ["nodejs_compat"] (applied by the Cloudflare target)
232 */
233 readonly compatibilityFlags?: Array<string> | undefined;
234 /**
235 * The USER's worker entry (the custom-entry carriage — see
236 * {@link NuxtTargetConfig.main}). Relative paths resolve against the root.
237 */
238 readonly main?: string | undefined;
239 /**
240 * Nuxt config overrides merged over the project's own `nuxt.config.ts`
241 * (highest-priority c12 layer; integration wins). The user's file remains
242 * authoritative for everything not named here; `nitro.preset` is always
243 * owned by the deploy target.
244 */
245 readonly nuxt?: Record<string, unknown> | undefined;
246 readonly dev?:
247 | {
248 /** Default dev-server port (overridden by `FrameworkDevOptions.port`). */
249 readonly port?: number | undefined;
250 /**
251 * Literal values exposed on the dev platform env
252 * (`event.context.cloudflare.env` on the Cloudflare target).
253 * Applied on top of the values served for {@link bindings} — a
254 * same-named literal always wins.
255 */
256 readonly env?: Record<string, unknown> | undefined;
257 /**
258 * Target-specific binding specs the dev platform should serve.
259 * Opaque to the framework half; the Cloudflare target accepts
260 * `cloudflare-runtime` binding hooks (`Text.local`,
261 * `KvNamespace.local`, …) and serves them into nitro's dev SSR
262 * worker thread through a workerd-backed platform proxy.
263 */
264 readonly bindings?: ReadonlyArray<unknown> | undefined;
265 /**
266 * A pre-built runtime-services context the dev platform should host
267 * its proxy in (opaque to the framework half; the Cloudflare target
268 * takes a `cloudflare-runtime` `Context<RuntimeServices>`). Embedders
269 * that already run a runtime stack — alchemy's dev sidecar — pass it
270 * so remote()-lowered bindings resolve; without it the platform is
271 * local-only.
272 */
273 readonly services?: unknown;
274 }
275 | undefined;
276}
277
278const fail = (message: string, cause?: unknown) =>
279 new FrameworkError({ framework: "nuxt", message, cause });
280
281/**
282 * The specifier the project's Nuxt kit is loaded from. `@nuxt/kit` itself is
283 * not directly resolvable from a project root under isolated installs (it's
284 * nuxt's own dependency), but nuxt's universally-present `./kit` subpath
285 * (`nuxt/kit.js`, a plain `export * from "@nuxt/kit"`) is — and it resolves
286 * the project's own kit instance.
287 */
288export const NUXT_KIT_SPECIFIER = "nuxt/kit";
289
290/**
291 * Build the `Framework` service implementation for a Nuxt project.
292 *
293 * - `build` resolves the deploy target, then either delegates wholesale
294 * (`target.build`) or drives the PROJECT's `@nuxt/kit` programmatically:
295 * `loadNuxt({ cwd, dev: false, ready: false, overrides })` — the project's
296 * `nuxt.config.ts` loads natively through c12 — hooks are registered
297 * BEFORE `nuxt.ready()` (with `ready: true` they'd fire inside `loadNuxt`
298 * and be missed), then `buildNuxt`. The nitro preset is the target's
299 * (`nitro:config` gets the last word); a user-configured foreign preset
300 * fails with an actionable error. The `BuildOutput` is read from nitro's
301 * `.output`: `serverModules` entry-first from `.output/server`,
302 * `clientDirectory` = `.output/public`, both taken from the resolved
303 * nitro output options (`nitro:init`).
304 * - `dev` runs Nuxt's own dev server programmatically (`loadNuxt({ dev:
305 * true, ready: false })` → hooks → `ready()` → `nuxt.server.listen` →
306 * `buildNuxt`, the same flow `nuxi dev` drives), scoped — closing the
307 * Scope closes the listener, the Nuxt instance, and then the target's
308 * dev platform. The target's `devPlatform` seam contributes nitro
309 * plugins + `runtimeConfig` (the Cloudflare target injects its
310 * `event.context.cloudflare` bridge served through cloudflare-runtime's
311 * platform proxy — SSR runs in nitro's Node worker THREAD, wrangler-free).
312 *
313 * This module is target-agnostic: everything Cloudflare-specific lives in
314 * `@alchemy.run/frontend-frameworks/nuxt/cloudflare`.
315 */
316export const make: (
317 options?: NuxtOptions,
318) => Effect.Effect<
319 Framework["Service"],
320 never,
321 FileSystem.FileSystem | Path.Path
322> = Effect.fnUntraced(function* (options?: NuxtOptions) {
323 const fs = yield* FileSystem.FileSystem;
324 const path = yield* Path.Path;
325 const baseRoot = options?.root ?? (yield* Effect.sync(() => process.cwd()));
326
327 const targetConfig: NuxtTargetConfig = {
328 compatibilityDate: options?.compatibilityDate,
329 compatibilityFlags: options?.compatibilityFlags,
330 main: options?.main,
331 nuxt: options?.nuxt,
332 };
333
334 const resolveTarget = (root: string) =>
335 FrameworkCore.resolveDeployTarget<NuxtTarget, NuxtTargetConfig>(
336 root,
337 options?.target ?? DEFAULT_TARGET_SPECIFIER,
338 targetConfig,
339 ).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
340
341 const requireNitroPreset = (target: NuxtTarget) =>
342 typeof target.nitroPreset === "string" && target.nitroPreset.length > 0
343 ? Effect.succeed(target)
344 : Effect.fail(
345 fail(
346 `The resolved "${target.platform}" deploy target does not declare the nitro ` +
347 "preset hook (`nitroPreset`) and has no wholesale `build`",
348 ),
349 );
350
351 const loadNuxtKit = (root: string) =>
352 FrameworkCore.loadProjectModule<NuxtKitModule>(
353 root,
354 NUXT_KIT_SPECIFIER,
355 ).pipe(
356 Effect.mapError((error) =>
357 fail("Failed to load the project's Nuxt kit", error.cause),
358 ),
359 );
360
361 const build: Framework["Service"]["build"] = Effect.fn(
362 function* (buildOptions) {
363 const root = buildOptions?.root ?? baseRoot;
364 const target = yield* resolveTarget(root);
365 const targetContext = { root, framework: "nuxt", env: buildOptions?.env };
366
367 // Wholesale build takeover (targets that own the entire pipeline).
368 if (target.build !== undefined) {
369 return yield* target.build(targetContext).pipe(
370 Effect.mapError((error) => fail(error.message, error.cause)),
371 Effect.provideService(FileSystem.FileSystem, fs),
372 Effect.provideService(Path.Path, path),
373 );
374 }
375
376 yield* requireNitroPreset(target);
377 const entry = FrameworkCore.resolveDeployTargetEntry(target, { root });
378 const kit = yield* loadNuxtKit(root);
379
380 // The user's nuxt.config.ts loads natively; our injection rides the
381 // highest-priority overrides layer. `ready: false` so hooks registered
382 // below actually fire (ready: true runs them inside loadNuxt).
383 const nuxt = yield* Effect.tryPromise({
384 try: async () =>
385 await kit.loadNuxt({
386 cwd: root,
387 dev: false,
388 ready: false,
389 overrides: makeNuxtOverrides({
390 nitroPreset: target.nitroPreset,
391 nuxtConfig: options?.nuxt,
392 }),
393 }),
394 catch: (error) => fail("Failed to load the Nuxt project", error),
395 });
396
397 const buildResult = yield* Effect.gen(function* () {
398 // Conflict policy: the overrides layer shadows a user-set preset in
399 // the merged options, so the user's raw value is read from the
400 // project's own config layers and rejected with an actionable error
401 // instead of being silently replaced.
402 const conflict = findPresetConflict(
403 nuxt.options._layers,
404 target.nitroPreset,
405 );
406 if (conflict !== undefined) {
407 return yield* Effect.fail(
408 fail(presetConflictMessage(conflict, target.nitroPreset)),
409 );
410 }
411
412 // Registered BEFORE nuxt.ready(): nitro:config fires while ready()
413 // initializes nitro.
414 let outputDirs: NitroInstanceSlice["options"]["output"] | undefined;
415 yield* Effect.sync(() => {
416 nuxt.hook("nitro:config", (nitroConfig: NitroConfigSlice) => {
417 enforceNitroConfig(nitroConfig, {
418 preset: target.nitroPreset,
419 configure: (config) =>
420 target.configureNitro?.(config, { root, entry }),
421 });
422 });
423 nuxt.hook("nitro:init", (nitro: NitroInstanceSlice) => {
424 outputDirs = nitro.options.output;
425 // The user-entry seam: nitro bundles `options.entry` as THE
426 // worker entry, so the user module's exports (default handler +
427 // Durable Objects, ...) are the worker's exports. Set on the
428 // INSTANCE options — not the nitro config — because nitro's
429 // prerenderer clones `options._config` for its Node-preset
430 // sub-build, and a workerd-flavored user entry (importing
431 // `cloudflare:` modules) must not leak into that clone.
432 if (entry !== undefined) {
433 nitro.options.entry = entry;
434 }
435 });
436 });
437
438 yield* Effect.tryPromise({
439 try: async () => {
440 await nuxt.ready();
441 await kit.buildNuxt(nuxt);
442 },
443 catch: (error) => fail("Failed to build", error),
444 });
445
446 const output = outputDirs ?? {
447 dir: path.resolve(root, ".output"),
448 serverDir: path.resolve(root, ".output", "server"),
449 publicDir: path.resolve(root, ".output", "public"),
450 };
451 return output;
452 }).pipe(
453 Effect.ensuring(
454 Effect.promise(async () => {
455 try {
456 await nuxt.close();
457 } catch {
458 // teardown is best-effort
459 }
460 }),
461 ),
462 );
463
464 const output = yield* readNitroOutput(buildResult).pipe(
465 Effect.provideService(FileSystem.FileSystem, fs),
466 );
467
468 // The finishing-pass seam (none needed for cloudflare_module — the
469 // output is already workerd ESM — but the contract is honored for
470 // targets that do post-process).
471 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
472 ...targetContext,
473 entry: path.join(buildResult.serverDir, "index.mjs"),
474 }).pipe(
475 Effect.mapError((error) => fail(error.message, error.cause)),
476 Effect.provideService(FileSystem.FileSystem, fs),
477 Effect.provideService(Path.Path, path),
478 );
479 },
480 );
481
482 const devInProcess: Framework["Service"]["dev"] = Effect.fn(
483 function* (devOptions) {
484 const root = devOptions?.root ?? baseRoot;
485 const target = yield* resolveTarget(root);
486 // Resolve the project's kit before anything expensive: a root without
487 // a Nuxt install fails fast, without spawning the dev platform.
488 const kit = yield* loadNuxtKit(root);
489
490 // The dev platform is acquired BEFORE the server so its finalizer
491 // runs LAST (finalizers are LIFO): the platform (e.g. the Cloudflare
492 // target's platform-proxy workerd instance) outlives the last
493 // in-flight request of the closing dev server.
494 const platform =
495 target.devPlatform !== undefined
496 ? yield* target
497 .devPlatform({
498 root,
499 env: options?.dev?.env,
500 bindings: options?.dev?.bindings,
501 services: options?.dev?.services,
502 })
503 .pipe(
504 Effect.mapError((error) => fail(error.message, error.cause)),
505 )
506 : undefined;
507
508 // The user's nuxt.config.ts loads natively; the dev injection rides
509 // the overrides layer. No nitro preset here — nitro's dev flow runs
510 // its own Node dev preset (the SSR worker thread); the deploy preset
511 // only matters for `build`. Telemetry/devtools are disabled: this
512 // dev server runs headless under alchemy / the e2e harness.
513 //
514 // Injected plugins live under node_modules (this package's dist), so
515 // their directories must be forced into `nitro.externals.inline`:
516 // nitro's externals pass would otherwise keep the plugin external and
517 // node would resolve its `nitropack/runtime` import against the raw
518 // package, whose `#nitro-internal-virtual/*` imports only exist
519 // inside the dev bundle.
520 const nitroPlugins = platform?.nitroPlugins;
521 const nuxt = yield* Effect.tryPromise({
522 try: async () =>
523 await kit.loadNuxt({
524 cwd: root,
525 dev: true,
526 ready: false,
527 overrides: makeNuxtOverrides({
528 nuxtConfig: {
529 telemetry: false,
530 devtools: { enabled: false },
531 ...options?.nuxt,
532 },
533 nitroPlugins,
534 nitroExternalsInline: nitroPlugins?.map((plugin) =>
535 path.dirname(plugin),
536 ),
537 runtimeConfig: platform?.runtimeConfig,
538 }),
539 }),
540 catch: (error) => fail("Failed to load the Nuxt project", error),
541 });
542 yield* Effect.addFinalizer(() =>
543 Effect.promise(async () => {
544 try {
545 await nuxt.close();
546 } catch {
547 // teardown is best-effort
548 }
549 }),
550 );
551
552 yield* Effect.tryPromise({
553 try: () => nuxt.ready(),
554 catch: (error) =>
555 fail("Failed to initialize the Nuxt dev server", error),
556 });
557
558 const server = nuxt.server;
559 if (server === undefined || typeof server.listen !== "function") {
560 return yield* Effect.fail(
561 fail(
562 "The loaded Nuxt instance exposes no dev server (`nuxt.server.listen`)",
563 ),
564 );
565 }
566 // `nuxt.server.listen` is listhen-backed. listhen hunts upward from
567 // 3000 when NO port is given — colliding with (or IPv6-shadowing)
568 // user-facing `alchemy dev` proxy ports — while an explicit `port: 0`
569 // resolves to get-port-please's `getRandomPort`, the same
570 // probe-and-release `findEphemeralPort` does. Probe explicitly for
571 // uniformity with the Vite-based frameworks (see DevPort's TODO).
572 const port =
573 (devOptions?.port ?? options?.dev?.port) ||
574 (yield* FrameworkCore.findEphemeralPort());
575 const host = devOptions?.host;
576 const listener = yield* Effect.acquireRelease(
577 Effect.tryPromise({
578 // Nitro's dev-server `listen` is listhen-backed; the second
579 // argument merges into listhen's options (older versions ignore
580 // it, degrading to the default host).
581 try: () =>
582 server.listen(
583 port,
584 host !== undefined ? { hostname: host } : undefined,
585 ),
586 catch: (error) =>
587 fail("Failed to start the dev server listener", error),
588 }),
589 (listener) =>
590 Effect.promise(async () => {
591 try {
592 await listener.close?.();
593 } catch {
594 // teardown is best-effort
595 }
596 }),
597 );
598 const url = listener.url;
599 if (url === undefined) {
600 return yield* Effect.fail(
601 fail("Could not determine the dev server URL"),
602 );
603 }
604
605 // The initial dev build — same flow as `nuxi dev` (which awaits
606 // buildNuxt after wiring its listener); the promise resolves once
607 // the dev bundles are ready, watchers keep running in background.
608 yield* Effect.tryPromise({
609 try: () => kit.buildNuxt(nuxt),
610 catch: (error) => fail("Failed to build the dev server", error),
611 });
612
613 // Bounded readiness probe: any HTTP response counts (vite serves
614 // lazily; we only need the listener to answer).
615 yield* Effect.tryPromise({
616 try: async () => {
617 const response = await fetch(url, { redirect: "manual" });
618 await response.arrayBuffer().catch(() => {});
619 },
620 catch: (error) =>
621 fail("The dev server did not become reachable", error),
622 }).pipe(
623 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
624 );
625
626 return { url };
627 },
628 );
629
630 // Nuxt's kit/module caches and Nitro dev runtime are shared within a
631 // process. Concurrent projects can report readiness while serving a
632 // different project's build, so give each dev server its own process.
633 const dev: Framework["Service"]["dev"] = (devOptions) => {
634 if (
635 FrameworkCore.isInsideDevChild() ||
636 !FrameworkCore.isJsonSerializable(options)
637 ) {
638 return devInProcess(devOptions);
639 }
640 const root = devOptions?.root ?? baseRoot;
641 const port = devOptions?.port ?? options?.dev?.port;
642 return FrameworkCore.runDevChild({
643 framework: "nuxt",
644 module: "@alchemy.run/frontend-frameworks/nuxt",
645 callerUrl: import.meta.url,
646 rootDir: root,
647 makeOptions: { ...options, root },
648 devOptions: {
649 root,
650 ...(port !== undefined ? { port } : {}),
651 ...(devOptions?.host !== undefined ? { host: devOptions.host } : {}),
652 },
653 });
654 };
655
656 return Framework.of({ build, dev });
657});
658
659/** The nitro server entry's module name within the `BuildOutput` (POSIX). */
660export const SERVER_ENTRY_NAME = "server/index.mjs";
661
662/** The resolved nitro output directories (`nitro.options.output`). */
663export interface NitroOutputDirs {
664 /** Root output directory (`.output`). */
665 readonly dir: string;
666 /** Server bundle directory (`.output/server`). */
667 readonly serverDir: string;
668 /** Static assets directory (`.output/public`, prerendered pages included). */
669 readonly publicDir: string;
670}
671
672/**
673 * Map nitro's on-disk `.output` onto the `BuildOutput` contract:
674 * `serverModules` entry-first from the server directory (POSIX names,
675 * sha256-hashed), `clientDirectory` = the public directory. The
676 * `cloudflare_module` output is already self-contained workerd ESM — no
677 * finishing pass, and no external workspace roots (nitro inlines workspace
678 * imports into the server chunks).
679 */
680export const readNitroOutput = (
681 dirs: NitroOutputDirs,
682): Effect.Effect<
683 FrameworkCore.BuildOutput,
684 FrameworkError,
685 FileSystem.FileSystem
686> =>
687 Effect.gen(function* () {
688 const modules = yield* FrameworkCore.readServerModulesFromDisk({
689 directory: dirs.serverDir,
690 prefix: "server",
691 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
692 if (modules.length === 0) {
693 return yield* Effect.fail(
694 fail(`The Nuxt build produced no server modules in ${dirs.serverDir}`),
695 );
696 }
697 const serverModules = FrameworkCore.sortServerModules(
698 modules,
699 SERVER_ENTRY_NAME,
700 );
701 if (serverModules[0]?.name !== SERVER_ENTRY_NAME) {
702 return yield* Effect.fail(
703 fail(
704 `The Nuxt build produced no "${SERVER_ENTRY_NAME}" entry in ${dirs.serverDir}`,
705 ),
706 );
707 }
708 return {
709 distDirectory: dirs.dir,
710 clientDirectory: dirs.publicDir,
711 serverModules,
712 externalWorkspaces: new Set<string>(),
713 } satisfies FrameworkCore.BuildOutput;
714 });
715
716/**
717 * A `Layer` providing framework-core's `Framework` service for a Nuxt
718 * project — the fully-typed entrypoint for `e2e.config.ts` (harness form 4)
719 * and alchemy's `Website.Nuxt` composite.
720 */
721export const layer = (
722 options?: NuxtOptions,
723): Layer.Layer<Framework, never, FileSystem.FileSystem | Path.Path> =>
724 Layer.effect(Framework, make(options));