1/**
2 * `@alchemy.run/frontend-frameworks/nuxt/source` — alchemy Worker source provider for Nuxt
3 * projects.
4 *
5 * The default export satisfies alchemy's `WorkerSourceModule` contract
6 * (`packages/alchemy/src/Cloudflare/Workers/Source.ts`) **structurally**:
7 * the `Source*` shapes below mirror alchemy's `SourceProvider` /
8 * `SourceContext` / `SourceBuildOutput` interfaces rather than importing
9 * them, so this package carries no dependency on alchemy. Version skew
10 * degrades to a load-time `SourceProviderError` on the alchemy side.
11 *
12 * Semantics mirror alchemy's built-in vite source
13 * (`Workers/Sources/Vite.ts`):
14 *
15 * - `build()` runs the Nuxt Framework service (the project's `@nuxt/kit`
16 * driven programmatically with nitro's `cloudflare_module` preset,
17 * wrangler-free) and maps its `BuildOutput` onto the source contract:
18 * `serverModules` (entry first) → bundle files, `clientDirectory`
19 * (`.output/public`) → assets (manifest-hashed, honoring `.assetsignore` /
20 * `_headers` / `_redirects`), project-tree hash → the `input` hash slot.
21 * - `hash()` recomputes the `input` hash **without building** — a memo hash
22 * over the project tree (gitignore-aware by default, narrowable via
23 * `memo.include`/`memo.exclude`), the discovered external workspaces, the
24 * lockfile, this package's version, and the build-affecting options.
25 * - `dev()` starts Nuxt's own dev server (nitro dev, SSR in a Node worker
26 * THREAD, full HMR) with the Worker's bindings served on
27 * `event.context.cloudflare` through cloudflare-runtime's platform proxy
28 * (wrangler-free), and returns the server-mode dev handle. Literal env
29 * values are laid over the proxied bindings; resource bindings round-trip
30 * to the proxy's workerd instance, so dev state is live and shared.
31 */
32import * as Data from "effect/Data";
33import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
35import type * as Path from "effect/Path";
36import type { PlatformError } from "effect/PlatformError";
37import * as Redacted from "effect/Redacted";
38import type * as Scope from "effect/Scope";
39import { glob } from "tinyglobby";
40import * as NodeCrypto from "node:crypto";
41import * as NodePath from "node:path";
42import { fileURLToPath } from "node:url";
43import { runBuildChild } from "../core/BuildChild.ts";
44import { makeCloudflareTarget } from "./cloudflare.ts";
45import { make as makeNuxt, type NuxtOptions } from "./Nuxt.ts";
46
47const PROVIDER = "@alchemy.run/frontend-frameworks/nuxt/source";
48
49// ─────────────────────────────────────────────────────────────────────
50// Structural mirror of alchemy's Worker source contract
51// (packages/alchemy/src/Cloudflare/Workers/Source.ts). Do not import
52// alchemy here — the contract is structural by design.
53// ─────────────────────────────────────────────────────────────────────
54
55/** Mirror of alchemy's `SourceHash`. */
56export interface SourceHash {
57 readonly bundle: string | undefined;
58 readonly assets: string | undefined;
59 readonly input: string | undefined;
60 readonly additionalWorkspaces: Array<string> | undefined;
61}
62
63/** Mirror of alchemy's `Bundle.BundleFile` / `Bundle.BundleOutput`. */
64export interface SourceBundleFile {
65 readonly path: string;
66 readonly content: string | Uint8Array;
67 readonly hash: string;
68}
69
70export interface SourceBundleOutput {
71 readonly files: [SourceBundleFile, ...Array<SourceBundleFile>];
72 readonly hash: string;
73}
74
75/** Mirror of alchemy's `AssetReadResult` (`Workers/Assets.ts`). */
76export interface SourceAssetReadResult {
77 directory: string;
78 config: Record<string, unknown> | undefined;
79 manifest: Record<string, { hash: string; size: number }>;
80 _headers: string | undefined;
81 _redirects: string | undefined;
82 hash: string;
83}
84
85/** Mirror of alchemy's `SourceBuildOutput`. */
86export interface SourceBuildOutput {
87 readonly bundle: SourceBundleOutput | undefined;
88 readonly assets: SourceAssetReadResult | undefined;
89 readonly hash: SourceHash;
90}
91
92/** Mirror of alchemy's `SourceContext` (the subset this source reads). */
93export interface SourceContext {
94 readonly id: string;
95 readonly workerName: string;
96 readonly compatibility: {
97 readonly date: string;
98 readonly flags: Array<string>;
99 };
100 readonly stack: { readonly name: string; readonly stage: string };
101 readonly env: Record<string, unknown> | undefined;
102 readonly assets: Record<string, unknown> | string | undefined;
103}
104
105/** Mirror of alchemy's `DevContext` (the subset this source reads). */
106export interface SourceDevContext extends SourceContext {
107 /**
108 * Runtime wiring derived by alchemy's LocalWorkerProvider. `bindings` are
109 * cloudflare-runtime `BindingHook`s — typed opaquely here to keep this
110 * module free of an alchemy/cloudflare-runtime type dependency.
111 */
112 readonly worker: {
113 readonly name: string;
114 readonly bindings: ReadonlyArray<unknown>;
115 };
116 /**
117 * The host's pre-built `Context<RuntimeServices>` (opaque here). Handed
118 * to the dev platform proxy so remote()-lowered bindings resolve against
119 * the host's runtime stack.
120 */
121 readonly runtimeContext: unknown;
122}
123
124/** Mirror of alchemy's server-mode `SourceDevHandle`. */
125export interface SourceDevHandle {
126 readonly mode: "server";
127 readonly url: URL;
128 readonly serviceBinding?: "http";
129}
130
131/**
132 * Same tag as alchemy's `Cloudflare.Workers.SourceProviderError`, so the
133 * errors this module raises are members of alchemy's closed `SourceError`
134 * union structurally.
135 */
136export class SourceProviderError extends Data.TaggedError(
137 "Cloudflare.Workers.SourceProviderError",
138)<{
139 readonly provider: string;
140 readonly message: string;
141 readonly cause?: unknown;
142}> {}
143
144export type SourceError = SourceProviderError | PlatformError;
145
146type SourceServices = FileSystem.FileSystem | Path.Path;
147
148/** Mirror of alchemy's `SourceProvider` (structural). */
149export interface SourceProvider {
150 readonly ownsAssets: boolean;
151 readonly build: (
152 ctx: SourceContext,
153 ) => Effect.Effect<SourceBuildOutput, SourceError, SourceServices>;
154 readonly hash: (
155 ctx: SourceContext,
156 previous: SourceHash | undefined,
157 ) => Effect.Effect<Partial<SourceHash>, SourceError, SourceServices>;
158 readonly dev: (
159 ctx: SourceDevContext,
160 ) => Effect.Effect<
161 SourceDevHandle,
162 SourceError,
163 SourceServices | Scope.Scope
164 >;
165}
166
167// ─────────────────────────────────────────────────────────────────────
168// Descriptor options (JSON-serializable — they persist in alchemy state
169// and participate in the Worker's metadata hash)
170// ─────────────────────────────────────────────────────────────────────
171
172export interface NuxtMemoOptions {
173 /** Glob patterns of files to hash, relative to `rootDir`. @default all non-gitignored files */
174 readonly include?: Array<string> | undefined;
175 /** Glob patterns to exclude. @default gitignore rules from `rootDir` up to the repo root */
176 readonly exclude?: Array<string> | undefined;
177 /**
178 * Include the nearest package-manager lockfile in the hash.
179 * @default true when both `include` and `exclude` are unset
180 */
181 readonly lockfile?: boolean | undefined;
182}
183
184export interface NuxtSourceOptions {
185 /**
186 * Nuxt project root (the directory containing `nuxt.config.ts`). Relative
187 * paths resolve from the process working directory. @default process.cwd()
188 */
189 readonly rootDir?: string | undefined;
190 /** Narrows the rebuild-detection input hash. */
191 readonly memo?: NuxtMemoOptions | undefined;
192 /**
193 * Nuxt config overrides merged over the project's own `nuxt.config.ts`
194 * (integration wins). Must be JSON-serializable — it persists in state.
195 */
196 readonly nuxt?: Record<string, unknown> | undefined;
197 /**
198 * The USER's worker entry (the custom-entry carriage): a module that wraps
199 * nitro's emitted handler and adds its own exports (Durable Object
200 * classes, ...). Relative paths resolve against `rootDir`.
201 */
202 readonly main?: string | undefined;
203}
204
205// ─────────────────────────────────────────────────────────────────────
206// Hashing (mirrors alchemy's Command/Memo.ts + Util/sha256.ts semantics:
207// machine-independent — only relative paths and file bytes are hashed)
208// ─────────────────────────────────────────────────────────────────────
209
210const sha256Hex = (input: string | Uint8Array): Effect.Effect<string> =>
211 Effect.sync(() =>
212 NodeCrypto.createHash("sha256").update(input).digest("hex"),
213 );
214
215/** Recursively sort object keys so JSON.stringify is order-stable. */
216const stableValue = (value: unknown): unknown => {
217 if (Array.isArray(value)) {
218 return value.map(stableValue);
219 }
220 if (value !== null && typeof value === "object") {
221 return Object.fromEntries(
222 Object.entries(value as Record<string, unknown>)
223 .filter(([, v]) => v !== undefined)
224 .sort(([a], [b]) => a.localeCompare(b))
225 .map(([k, v]) => [k, stableValue(v)]),
226 );
227 }
228 return value;
229};
230
231const sha256Stable = (input: unknown): Effect.Effect<string> =>
232 sha256Hex(JSON.stringify(stableValue(input) ?? null));
233
234/**
235 * Convert gitignore-style rules into glob `ignore` patterns — a copy of
236 * alchemy's `Util/gitignore-rules-to-globs.ts` (common cases only).
237 */
238const gitignoreRulesToGlobs = (rules: ReadonlyArray<string>): Array<string> => {
239 const out: Array<string> = [];
240 for (const raw of rules) {
241 const line = raw.trim();
242 if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) {
243 continue;
244 }
245 let rest = line;
246 const dirOnly = rest.endsWith("/");
247 if (dirOnly) {
248 rest = rest.slice(0, -1);
249 }
250 rest = rest.replace(/\\/g, "/");
251 if (rest.length === 0) continue;
252 const anchored = rest.startsWith("/");
253 if (anchored) {
254 rest = rest.slice(1);
255 }
256 if (rest.length === 0) continue;
257 const hasSlash = rest.includes("/");
258 if (dirOnly) {
259 out.push(anchored || hasSlash ? `${rest}/**` : `**/${rest}/**`);
260 } else if (anchored || hasSlash) {
261 out.push(rest, `${rest}/**`);
262 } else {
263 out.push(`**/${rest}`, `**/${rest}/**`);
264 }
265 }
266 return out;
267};
268
269const findUp = Effect.fnUntraced(function* (
270 fs: FileSystem.FileSystem,
271 cwd: string,
272 filenames: Array<string>,
273) {
274 let dir = cwd;
275 for (;;) {
276 for (const filename of filenames) {
277 const file = NodePath.join(dir, filename);
278 if (yield* fs.exists(file)) {
279 return file;
280 }
281 }
282 const parent = NodePath.dirname(dir);
283 if (parent === dir) {
284 return undefined;
285 }
286 dir = parent;
287 }
288});
289
290/** Collect `.gitignore` rules from `cwd` up to the repo root (parents first). */
291const readGitIgnoreRules = (
292 fs: FileSystem.FileSystem,
293 cwd: string,
294): Effect.Effect<Array<string>, PlatformError> =>
295 Effect.gen(function* () {
296 const rules = yield* fs
297 .readFileString(NodePath.join(cwd, ".gitignore"))
298 .pipe(
299 Effect.map((file) => file.split("\n")),
300 Effect.catchTag("PlatformError", () =>
301 Effect.succeed([] as Array<string>),
302 ),
303 );
304 const parent = NodePath.dirname(cwd);
305 if (parent === cwd || (yield* fs.exists(NodePath.join(cwd, ".git")))) {
306 return rules;
307 }
308 const parentRules = yield* readGitIgnoreRules(fs, parent);
309 return [...parentRules, ...rules];
310 });
311
312/**
313 * Deterministic content hash of the files in `cwd` matched by `memo` —
314 * mirror of alchemy's `hashDirectory` (`Command/Memo.ts`).
315 */
316const hashDirectory = Effect.fnUntraced(function* (
317 cwd: string,
318 memo: NuxtMemoOptions | undefined,
319) {
320 const fs = yield* FileSystem.FileSystem;
321 const include = memo?.include ?? ["**/*"];
322 const exclude = memo?.exclude ?? [
323 "**/.git/**",
324 ...gitignoreRulesToGlobs(yield* readGitIgnoreRules(fs, cwd)),
325 ];
326 const lockfile = memo?.lockfile ?? !(memo?.include || memo?.exclude);
327 const [files, lockfilePath] = yield* Effect.all(
328 [
329 Effect.promise(() =>
330 glob(include, {
331 cwd,
332 ignore: exclude,
333 onlyFiles: true,
334 expandDirectories: false,
335 dot: true,
336 }),
337 ),
338 lockfile
339 ? Effect.map(
340 findUp(fs, cwd, [
341 "bun.lock",
342 "bun.lockb",
343 "package-lock.json",
344 "pnpm-lock.yaml",
345 "yarn.lock",
346 ]),
347 (file) => (file ? NodePath.relative(cwd, file) : undefined),
348 )
349 : Effect.succeed(undefined),
350 ],
351 { concurrency: "unbounded" },
352 );
353 if (lockfilePath && !files.includes(lockfilePath)) {
354 files.push(lockfilePath);
355 }
356 const hashes = yield* Effect.forEach(
357 files.sort(),
358 (file) =>
359 fs.readFile(NodePath.join(cwd, file)).pipe(
360 Effect.flatMap(sha256Hex),
361 Effect.map((hash) => `${file}:${hash}`),
362 ),
363 { concurrency: 16 },
364 );
365 return yield* sha256Stable(hashes);
366});
367
368/** This package's version — part of the `input` hash so an integration upgrade busts the memo. */
369const packageVersion = Effect.gen(function* () {
370 const fs = yield* FileSystem.FileSystem;
371 const dir = NodePath.dirname(fileURLToPath(import.meta.url));
372 const content = yield* fs.readFileString(
373 NodePath.join(dir, "../../package.json"),
374 );
375 return (JSON.parse(content) as { version: string }).version;
376});
377
378/**
379 * The rebuild-free `input` hash: project tree + external workspaces +
380 * package version + build-affecting options. Mirrors alchemy's
381 * `hashViteInput` semantics (`Workers/Sources/Vite.ts`) — never hashes
382 * absolute paths, so relocating an identical tree is a no-op.
383 */
384const hashNuxtInput = Effect.fnUntraced(function* (
385 rootDir: string,
386 options: NuxtSourceOptions,
387 workspaces: Iterable<string>,
388) {
389 const version = yield* packageVersion;
390 const hashWorkspace = (cwd: string, memo?: NuxtMemoOptions) =>
391 hashDirectory(NodePath.resolve(rootDir, cwd), memo).pipe(
392 Effect.map(
393 (hash) =>
394 `${NodePath.relative(rootDir, NodePath.resolve(rootDir, cwd))}:${hash}`,
395 ),
396 );
397 const [root, ...workspaceHashes] = yield* Effect.all(
398 [
399 hashWorkspace(rootDir, options.memo),
400 ...Array.from(workspaces, (cwd) => hashWorkspace(cwd)),
401 ],
402 { concurrency: "unbounded" },
403 );
404 const hash = yield* sha256Stable({
405 version,
406 options: {
407 memo: options.memo,
408 nuxt: options.nuxt,
409 main: options.main,
410 },
411 tree: [root, ...workspaceHashes.sort()],
412 });
413 return {
414 hash,
415 workspaces: Array.from(workspaces, (cwd) =>
416 NodePath.relative(rootDir, NodePath.resolve(rootDir, cwd)),
417 ),
418 };
419});
420
421// ─────────────────────────────────────────────────────────────────────
422// Assets (mirror of alchemy's `readAssets` in `Workers/Assets.ts`:
423// manifest of 32-hex content hashes, honoring `.assetsignore`,
424// `_headers`, `_redirects`; the overall hash excludes the directory
425// path so identical bytes hash identically across machines)
426// ─────────────────────────────────────────────────────────────────────
427
428const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB (Cloudflare limit)
429const MAX_ASSET_COUNT = 20_000;
430
431const maybeReadString = (fs: FileSystem.FileSystem, file: string) =>
432 fs
433 .readFileString(file)
434 .pipe(
435 Effect.catchTag("PlatformError", (error) =>
436 error.reason._tag === "NotFound"
437 ? Effect.succeed(undefined)
438 : Effect.fail(error),
439 ),
440 );
441
442const readAssetsDirectory = Effect.fnUntraced(function* (
443 directory: string,
444 config: Record<string, unknown> | undefined,
445) {
446 const fs = yield* FileSystem.FileSystem;
447 const [ignore, _headers, _redirects] = yield* Effect.all([
448 maybeReadString(fs, NodePath.join(directory, ".assetsignore")),
449 maybeReadString(fs, NodePath.join(directory, "_headers")),
450 maybeReadString(fs, NodePath.join(directory, "_redirects")),
451 ]);
452 const files = yield* Effect.promise(() =>
453 glob(["**/*"], {
454 cwd: directory,
455 ignore: [
456 ".assetsignore",
457 "_headers",
458 "_redirects",
459 ...gitignoreRulesToGlobs(ignore?.split("\n") ?? []),
460 ],
461 onlyFiles: true,
462 expandDirectories: false,
463 dot: true,
464 }),
465 );
466 if (files.length > MAX_ASSET_COUNT) {
467 return yield* Effect.fail(
468 new SourceProviderError({
469 provider: PROVIDER,
470 message: `Too many assets in ${directory} (maximum is ${MAX_ASSET_COUNT}, found ${files.length})`,
471 }),
472 );
473 }
474 const entries = yield* Effect.forEach(
475 files.sort(),
476 Effect.fnUntraced(function* (name) {
477 const file = NodePath.join(directory, name);
478 const content = yield* fs.readFile(file);
479 if (content.byteLength > MAX_ASSET_SIZE) {
480 return yield* Effect.fail(
481 new SourceProviderError({
482 provider: PROVIDER,
483 message: `Asset ${name} is too large (the maximum size is 25 MB)`,
484 }),
485 );
486 }
487 const hash = (yield* sha256Hex(content)).slice(0, 32);
488 return [
489 `/${name.replaceAll("\\", "/")}`,
490 { hash, size: content.byteLength },
491 ] as const;
492 }),
493 { concurrency: 16 },
494 );
495 const manifest = Object.fromEntries(
496 [...entries].sort((a, b) => a[0].localeCompare(b[0])),
497 );
498 const hash = yield* sha256Stable({ config, manifest, _headers, _redirects });
499 return {
500 directory,
501 config,
502 manifest,
503 _headers,
504 _redirects,
505 hash,
506 } satisfies SourceAssetReadResult;
507});
508
509// ─────────────────────────────────────────────────────────────────────
510// The provider
511// ─────────────────────────────────────────────────────────────────────
512
513const wrapFrameworkError = (error: {
514 readonly message: string;
515 readonly cause?: unknown;
516}) =>
517 new SourceProviderError({
518 provider: PROVIDER,
519 message: error.message,
520 cause: error.cause ?? error,
521 });
522
523const assetsConfig = (
524 assets: SourceContext["assets"],
525): Record<string, unknown> | undefined => {
526 if (assets === undefined || typeof assets === "string") {
527 return undefined;
528 }
529 const { directory: _directory, hash: _hash, ...config } = assets;
530 return Object.keys(config).length > 0 ? config : undefined;
531};
532
533/**
534 * Resolve the Worker's literal env values as dev env overrides: strings
535 * pass through, `Redacted<string>`s are unwrapped, everything else
536 * (Effects, resource bindings) is skipped — those are delivered as real
537 * bindings through the platform proxy via `ctx.worker.bindings`. A
538 * same-named literal wins over the proxied value.
539 */
540const resolveDevEnvOverrides = (
541 env: Record<string, unknown> | undefined,
542): Record<string, unknown> => {
543 const out: Record<string, unknown> = {};
544 for (const [key, value] of Object.entries(env ?? {})) {
545 if (typeof value === "string") {
546 out[key] = value;
547 } else if (
548 Redacted.isRedacted(value) &&
549 typeof Redacted.value(value) === "string"
550 ) {
551 out[key] = Redacted.value(value);
552 }
553 }
554 return out;
555};
556
557/**
558 * The production build runs in a disposable child process with
559 * `cwd = project root` (see `core/BuildChild.ts`): `loadNuxt` executes the
560 * user's `nuxt.config.ts` and modules, which may read the cwd, mutate
561 * `process.env`, or `process.chdir` — none of which may touch the engine
562 * process. The shared `core/BuildChildRunner`
563 * entry imports this module in the child and calls the exported
564 * {@link buildInChild}.
565 */
566export interface NuxtBuildChildConfig {
567 readonly rootDir: string;
568 readonly compatibilityDate: string;
569 readonly compatibilityFlags: Array<string>;
570 readonly main: string | undefined;
571 readonly nuxt: Record<string, unknown> | undefined;
572}
573
574export const buildInChild = (config: NuxtBuildChildConfig) =>
575 Effect.gen(function* () {
576 const framework = yield* makeNuxt({
577 root: config.rootDir,
578 target: makeCloudflareTarget,
579 compatibilityDate: config.compatibilityDate,
580 compatibilityFlags: config.compatibilityFlags,
581 main: config.main,
582 nuxt: config.nuxt,
583 });
584 return yield* framework.build({ root: config.rootDir });
585 });
586
587export const makeNuxtSource = (options: NuxtSourceOptions): SourceProvider => {
588 const rootDir = NodePath.resolve(options.rootDir ?? process.cwd());
589 const frameworkOptions = (
590 ctx: SourceContext,
591 dev?: NuxtOptions["dev"],
592 ): NuxtOptions => ({
593 root: rootDir,
594 // This module is Cloudflare-specific by contract (it implements alchemy's
595 // Cloudflare Worker source), so it passes the target factory directly
596 // instead of relying on specifier resolution from the project's
597 // node_modules.
598 target: makeCloudflareTarget,
599 compatibilityDate: ctx.compatibility.date,
600 compatibilityFlags: ctx.compatibility.flags,
601 main: options.main,
602 nuxt: options.nuxt,
603 dev,
604 });
605 return {
606 ownsAssets: true,
607 build: Effect.fnUntraced(function* (ctx) {
608 const output = yield* runBuildChild({
609 module: import.meta.url,
610 rootDir,
611 framework: "nuxt",
612 config: {
613 rootDir,
614 compatibilityDate: ctx.compatibility.date,
615 compatibilityFlags: ctx.compatibility.flags,
616 main: options.main,
617 nuxt: options.nuxt,
618 } satisfies NuxtBuildChildConfig,
619 }).pipe(Effect.mapError(wrapFrameworkError));
620 if (
621 output.serverModules === undefined ||
622 output.serverModules.length === 0
623 ) {
624 return yield* Effect.fail(
625 new SourceProviderError({
626 provider: PROVIDER,
627 message: "The Nuxt build produced no server modules",
628 }),
629 );
630 }
631 if (output.clientDirectory === undefined) {
632 return yield* Effect.fail(
633 new SourceProviderError({
634 provider: PROVIDER,
635 message: "The Nuxt build produced no client directory",
636 }),
637 );
638 }
639 const files = output.serverModules.map((module): SourceBundleFile => ({
640 path: module.name,
641 content: module.content,
642 hash: module.hash,
643 })) as [SourceBundleFile, ...Array<SourceBundleFile>];
644 const [bundleHash, assets, input] = yield* Effect.all(
645 [
646 sha256Stable(files.map((file) => `${file.path}:${file.hash}`)),
647 readAssetsDirectory(
648 NodePath.resolve(rootDir, output.clientDirectory),
649 assetsConfig(ctx.assets),
650 ),
651 hashNuxtInput(rootDir, options, output.externalWorkspaces),
652 ],
653 { concurrency: "unbounded" },
654 );
655 return {
656 bundle: { files, hash: bundleHash },
657 assets,
658 hash: {
659 bundle: bundleHash,
660 assets: assets.hash,
661 input: input.hash,
662 additionalWorkspaces: input.workspaces,
663 },
664 } satisfies SourceBuildOutput;
665 }),
666 hash: Effect.fnUntraced(function* (_ctx, previous) {
667 const { hash, workspaces } = yield* hashNuxtInput(
668 rootDir,
669 options,
670 previous?.additionalWorkspaces ?? [],
671 );
672 return { input: hash, additionalWorkspaces: workspaces };
673 }),
674 dev: Effect.fnUntraced(function* (ctx: SourceDevContext) {
675 const framework = yield* makeNuxt(
676 frameworkOptions(ctx, {
677 env: resolveDevEnvOverrides(ctx.env),
678 bindings: ctx.worker.bindings,
679 // The host's runtime stack (includes remote-bindings support) —
680 // the dev platform proxy is hosted in it instead of the
681 // credential-free internal layer, so `Alchemy.remote()` bindings
682 // resolve in dev.
683 services: ctx.runtimeContext,
684 }),
685 );
686 const server = yield* framework
687 .dev({ root: rootDir, port: 0 })
688 .pipe(Effect.mapError(wrapFrameworkError));
689 return {
690 mode: "server",
691 url: new URL(server.url),
692 serviceBinding: "http",
693 } satisfies SourceDevHandle;
694 }),
695 };
696};
697
698/**
699 * The `WorkerSourceModule` contract: alchemy dynamically imports this module
700 * and calls `make(descriptor.options)`.
701 */
702const sourceModule = {
703 make: (
704 options: unknown,
705 ): Effect.Effect<SourceProvider, SourceProviderError> =>
706 Effect.succeed(makeNuxtSource((options ?? {}) as NuxtSourceOptions)),
707};
708
709export default sourceModule;