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