| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 3 | import type { InputProps } from "../../Input.ts"; |
| 4 | import { effectClass } from "../../Util/effect.ts"; |
| 5 | import type { Providers } from "../Providers.ts"; |
| 6 | import type { AssetsConfig } from "../Workers/Assets.ts"; |
| 7 | import { |
| 8 | Worker, |
| 9 | type NormalizedBindings, |
| 10 | type WorkerAssetsConfig, |
| 11 | type WorkerBindingProps, |
| 12 | type WorkerProps, |
| 13 | } from "../Workers/Worker.ts"; |
| 14 | |
| 15 | export interface SvelteKitProps< |
| 16 | Bindings extends WorkerBindingProps = {}, |
| 17 | > extends Omit< |
| 18 | WorkerProps<Bindings>, |
| 19 | "vite" | "main" | "assets" | "source" | "script" | "bundle" |
| 20 | > { |
| 21 | /** |
| 22 | * SvelteKit project root (the directory containing `package.json` and |
| 23 | * `src/routes`). Relative paths resolve from the process working |
| 24 | * directory. |
| 25 | * @default process.cwd() |
| 26 | */ |
| 27 | rootDir?: string; |
| 28 | /** |
| 29 | * Controls which files are content-hashed to decide whether a rebuild is |
| 30 | * needed. By default every non-gitignored file under `rootDir` (plus the |
| 31 | * nearest package-manager lockfile) is hashed; narrow the scope with |
| 32 | * `include`/`exclude` globs when the project sits in a large repository. |
| 33 | */ |
| 34 | memo?: MemoOptions; |
| 35 | /** |
| 36 | * SvelteKit config overrides for the `sveltekit(...)` plugin call, merged |
| 37 | * over the options of the user's own call (these win). JSON-serializable |
| 38 | * only — no `preprocess`/`vitePlugin`/functions; construction-time options |
| 39 | * (`preprocess`, `extensions`, `compilerOptions`, `vitePlugin`) can only |
| 40 | * apply when no user `vite.config.*` exists. The `adapter` field is |
| 41 | * always owned by alchemy. |
| 42 | */ |
| 43 | kit?: Record<string, unknown>; |
| 44 | /** |
| 45 | * Optional configuration for static asset routing behavior. |
| 46 | * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc. |
| 47 | */ |
| 48 | assets?: AssetsConfig; |
| 49 | } |
| 50 | |
| 51 | // These options are inspected while constructing the Worker. Resolve them in |
| 52 | // the outer props Effect; pass-through properties can remain deferred Inputs. |
| 53 | type SvelteKitInput<Bindings extends WorkerBindingProps> = InputProps< |
| 54 | SvelteKitProps<Bindings>, |
| 55 | "assets" |
| 56 | >; |
| 57 | |
| 58 | /** |
| 59 | * A Cloudflare Worker deployed from a SvelteKit project. |
| 60 | * |
| 61 | * `SvelteKit` builds the app with SvelteKit's own Vite pipeline and a |
| 62 | * wrangler-free in-memory Cloudflare adapter, then re-bundles the |
| 63 | * Node-flavored server output for workerd. A project-owned |
| 64 | * `vite.config.*` loads natively (its `sveltekit(...)` options apply) — |
| 65 | * no `svelte.config.js` (kit v3 dropped it), no |
| 66 | * `@sveltejs/adapter-cloudflare`, no Wrangler configuration required. |
| 67 | * Client assets and prerendered pages are deployed as Worker static |
| 68 | * assets; dynamic routes are served by the generated Worker. |
| 69 | * |
| 70 | * The `@alchemy.run/frontend-frameworks` package must be installed in your |
| 71 | * project — its `/sveltekit` export is loaded dynamically at deploy time. |
| 72 | * |
| 73 | * Input files are content-hashed (respecting `.gitignore` by default) so |
| 74 | * unchanged projects skip the build and deploy entirely. |
| 75 | * |
| 76 | * SvelteKit's server code runs under `nodejs_compat` (the server graph is |
| 77 | * built for Node), so the flag is always included in the Worker's |
| 78 | * compatibility flags. |
| 79 | * |
| 80 | * Note on local dev: `alchemy dev` runs SvelteKit's own Vite dev server |
| 81 | * (Node SSR with full HMR). `platform.env` carries the Worker's real |
| 82 | * Cloudflare bindings (KV, R2, D1, ...) served by the cloudflare-runtime |
| 83 | * platform proxy, with literal `env` values (strings and secrets) |
| 84 | * overlaid. |
| 85 | * |
| 86 | * |
| 87 | * ### Deploying a SvelteKit App |
| 88 | * A single call builds and deploys the app — server-rendered routes, |
| 89 | * prerendered pages, and client assets included. |
| 90 | * |
| 91 | * **Example:** Basic SvelteKit site |
| 92 | * ```typescript |
| 93 | * const site = yield* Cloudflare.Website.SvelteKit("Website"); |
| 94 | * ``` |
| 95 | * |
| 96 | * ### Bindings |
| 97 | * Values passed via `env` are exposed to server routes through |
| 98 | * SvelteKit's `platform.env`. |
| 99 | * |
| 100 | * **Example:** Reading env from a server route |
| 101 | * ```typescript |
| 102 | * const site = yield* Cloudflare.Website.SvelteKit("Website", { |
| 103 | * env: { |
| 104 | * API_KEY: Config.Redacted("API_KEY"), |
| 105 | * }, |
| 106 | * }); |
| 107 | * |
| 108 | * // src/routes/+page.server.ts |
| 109 | * // export const load = ({ platform }) => ({ |
| 110 | * // hasKey: platform?.env?.API_KEY !== undefined, |
| 111 | * // }); |
| 112 | * ``` |
| 113 | * |
| 114 | * ### Kit Options and 404 Handling |
| 115 | * Kit options live in the `sveltekit(...)` call in your |
| 116 | * `vite.config.ts`, which loads natively. Fallback-page behavior is |
| 117 | * driven by the platform-native `assets.notFoundHandling` knob — the |
| 118 | * build generates the matching fallback page (rendering the app shell, |
| 119 | * so kit's own error page shows). |
| 120 | * |
| 121 | * **Example:** App-shell 404 fallback |
| 122 | * ```typescript |
| 123 | * const site = yield* Cloudflare.Website.SvelteKit("Website", { |
| 124 | * assets: { |
| 125 | * notFoundHandling: "404-page", |
| 126 | * }, |
| 127 | * }); |
| 128 | * ``` |
| 129 | * |
| 130 | * The `kit` prop is a deploy-time override bag merged over your own |
| 131 | * `sveltekit(...)` options (the prop wins) — useful for per-stage values |
| 132 | * the config file can't compute. JSON-serializable values only. |
| 133 | * |
| 134 | * **Example:** Deploy-time kit overrides |
| 135 | * ```typescript |
| 136 | * const site = yield* Cloudflare.Website.SvelteKit("Website", { |
| 137 | * kit: { |
| 138 | * paths: { base: "/docs" }, |
| 139 | * }, |
| 140 | * }); |
| 141 | * ``` |
| 142 | * |
| 143 | * ### Custom Rebuild Scope |
| 144 | * By default, every non-gitignored file is hashed to decide whether a |
| 145 | * rebuild is needed. Use `memo` to narrow the scope when the project |
| 146 | * lives in a large repository. |
| 147 | * |
| 148 | * **Example:** Narrowing the memo scope |
| 149 | * ```typescript |
| 150 | * const site = yield* Cloudflare.Website.SvelteKit("Website", { |
| 151 | * memo: { |
| 152 | * include: ["src/**", "static/**", "package.json"], |
| 153 | * }, |
| 154 | * }); |
| 155 | * ``` |
| 156 | * |
| 157 | * ### Class Form |
| 158 | * Calling `SvelteKit` with no arguments returns a constructor you can |
| 159 | * `extend` to declare the Worker as a named class. The class is both an |
| 160 | * `Effect` you can `yield*` to deploy and a type you can reference |
| 161 | * elsewhere — useful when other resources need to bind to this Worker. |
| 162 | * |
| 163 | * **Example:** Declaring a Worker class |
| 164 | * ```typescript |
| 165 | * class Website extends Cloudflare.Website.SvelteKit<Website>()( |
| 166 | * "Website", |
| 167 | * ) {} |
| 168 | * |
| 169 | * const site = yield* Website; |
| 170 | * ``` |
| 171 | * |
| 172 | * @resource |
| 173 | * @product Website |
| 174 | * @category Workers & Compute |
| 175 | */ |
| 176 | export const SvelteKit: { |
| 177 | <Self>(): { |
| 178 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 179 | id: string, |
| 180 | propsEff?: |
| 181 | | SvelteKitInput<Bindings> |
| 182 | | Effect.Effect<SvelteKitInput<Bindings>, never, Req>, |
| 183 | ): Effect.Effect<Self, never, Req | Providers> & { |
| 184 | new (): Worker<{ |
| 185 | [ |
| 186 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 187 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 188 | }>; |
| 189 | }; |
| 190 | }; |
| 191 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 192 | id: string, |
| 193 | propsEff?: |
| 194 | | SvelteKitInput<Bindings> |
| 195 | | Effect.Effect<SvelteKitInput<Bindings>, never, Req>, |
| 196 | ): Effect.Effect< |
| 197 | Worker<{ |
| 198 | [ |
| 199 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 200 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 201 | }>, |
| 202 | never, |
| 203 | Req | Providers |
| 204 | >; |
| 205 | } = (<const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 206 | id?: string, |
| 207 | propsEff?: |
| 208 | | SvelteKitInput<Bindings> |
| 209 | | Effect.Effect<SvelteKitInput<Bindings>, never, Req>, |
| 210 | ) => |
| 211 | id === undefined |
| 212 | ? <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 213 | id: string, |
| 214 | propsEff?: |
| 215 | | SvelteKitInput<Bindings> |
| 216 | | Effect.Effect<SvelteKitInput<Bindings>, never, Req>, |
| 217 | ) => effectClass(SvelteKit(id, propsEff)) |
| 218 | : Worker( |
| 219 | id, |
| 220 | Effect.map( |
| 221 | Effect.isEffect(propsEff) ? propsEff : Effect.succeed(propsEff), |
| 222 | (props) => ({ |
| 223 | ...props, |
| 224 | // SvelteKit's server graph is built for Node and needs |
| 225 | // `nodejs_compat` — `getCompatibility` already adds it to every |
| 226 | // non-python Worker. |
| 227 | assets: props?.assets, |
| 228 | source: { |
| 229 | provider: "@alchemy.run/frontend-frameworks/sveltekit/source", |
| 230 | devMode: "server", |
| 231 | rootDir: props?.rootDir, |
| 232 | options: { |
| 233 | rootDir: props?.rootDir, |
| 234 | memo: props?.memo, |
| 235 | kit: props?.kit, |
| 236 | // The adapter's build-time page GENERATION (404.html / |
| 237 | // app-shell index.html) is derived from the one |
| 238 | // platform-native knob, `assets.notFoundHandling`, so a |
| 239 | // single prop configures generation AND serving — the two |
| 240 | // halves can never disagree. The generated 404-page |
| 241 | // renders the app shell so kit's own error page shows. |
| 242 | ...(props?.assets?.notFoundHandling !== undefined && |
| 243 | props.assets.notFoundHandling !== "none" |
| 244 | ? { |
| 245 | adapter: { |
| 246 | notFoundHandling: props.assets.notFoundHandling, |
| 247 | fallback: "spa", |
| 248 | }, |
| 249 | } |
| 250 | : {}), |
| 251 | }, |
| 252 | }, |
| 253 | }), |
| 254 | ), |
| 255 | )) as any; |