| 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 | const VOCS_SOURCE_PROVIDER = "@alchemy.run/frontend-frameworks/vocs/source"; |
| 16 | |
| 17 | export interface VocsProps< |
| 18 | Bindings extends WorkerBindingProps = {}, |
| 19 | > extends Omit< |
| 20 | WorkerProps<Bindings>, |
| 21 | "vite" | "main" | "assets" | "source" | "script" | "bundle" |
| 22 | > { |
| 23 | /** |
| 24 | * Root directory of the Vocs project. |
| 25 | * @default process.cwd() |
| 26 | */ |
| 27 | rootDir?: string; |
| 28 | /** |
| 29 | * Vocs build output directory, relative to {@link rootDir}. Set this when |
| 30 | * `vocs.config.*` customizes `outDir` so generated output stays outside the |
| 31 | * rebuild hash. |
| 32 | * @default "dist" |
| 33 | */ |
| 34 | outDir?: string; |
| 35 | /** |
| 36 | * Controls which files feed the content hash that decides whether Vocs |
| 37 | * rebuilds. By default every non-gitignored file under {@link rootDir} is |
| 38 | * hashed, plus the nearest package-manager lockfile. |
| 39 | */ |
| 40 | memo?: MemoOptions; |
| 41 | /** |
| 42 | * Static asset routing configuration. Vocs links prerendered pages without |
| 43 | * trailing slashes, so extensionless routing is enabled by default. |
| 44 | * @default { htmlHandling: "drop-trailing-slash" } |
| 45 | */ |
| 46 | assets?: AssetsConfig; |
| 47 | } |
| 48 | |
| 49 | // These options are inspected while constructing the Worker. Resolve them in |
| 50 | // the outer props Effect; pass-through properties can remain deferred Inputs. |
| 51 | type VocsInput<Bindings extends WorkerBindingProps> = InputProps< |
| 52 | VocsProps<Bindings>, |
| 53 | "assets" |
| 54 | >; |
| 55 | |
| 56 | /** |
| 57 | * A Cloudflare Worker deployed from a [Vocs](https://vocs.dev) documentation project. |
| 58 | * |
| 59 | * Vocs' `vocs.config.*` loads natively. Alchemy runs Vocs' Waku/RSC build, |
| 60 | * deploys its server environments as a Worker, and publishes the client and |
| 61 | * prerendered output as static assets. No Vite or Wrangler config is required. |
| 62 | * |
| 63 | * Requires `@alchemy.run/frontend-frameworks`, `vocs`, and Vocs' Waku peer |
| 64 | * dependencies in the project. |
| 65 | * |
| 66 | * Input files are content-hashed (respecting `.gitignore` by default), so an |
| 67 | * unchanged project skips its build and deployment. Vocs' server runtime uses |
| 68 | * Node APIs, enabled by the Worker's compatibility date |
| 69 | * flags automatically. |
| 70 | * |
| 71 | * ### Deploying a Vocs Site |
| 72 | * A single resource builds the documentation project and deploys its server |
| 73 | * runtime, prerendered pages, generated files, and public assets. |
| 74 | * |
| 75 | * **Example:** Vocs documentation site |
| 76 | * ```typescript |
| 77 | * const docs = yield* Cloudflare.Website.Vocs("Docs", { |
| 78 | * rootDir: "./docs", |
| 79 | * }); |
| 80 | * ``` |
| 81 | * |
| 82 | * ### Bindings |
| 83 | * Pass Cloudflare resources through `env` like any other Worker. Server-side |
| 84 | * Vocs and MDX code can access them from `cloudflare:workers`. |
| 85 | * |
| 86 | * **Example:** Vocs with a KV namespace |
| 87 | * ```typescript |
| 88 | * const searchCache = yield* Cloudflare.KV.Namespace("SearchCache"); |
| 89 | * |
| 90 | * const docs = yield* Cloudflare.Website.Vocs("Docs", { |
| 91 | * rootDir: "./docs", |
| 92 | * env: { |
| 93 | * SEARCH_CACHE: searchCache, |
| 94 | * }, |
| 95 | * }); |
| 96 | * ``` |
| 97 | * |
| 98 | * ### Custom Build Output |
| 99 | * Vocs configuration continues to own the output directory. When |
| 100 | * `vocs.config.*` changes `outDir`, mirror that value on the resource so the |
| 101 | * generated directory is excluded from the rebuild hash and read correctly. |
| 102 | * |
| 103 | * **Example:** Custom output directory |
| 104 | * ```typescript |
| 105 | * // vocs.config.ts: defineConfig({ outDir: "build" }) |
| 106 | * const docs = yield* Cloudflare.Website.Vocs("Docs", { |
| 107 | * rootDir: "./docs", |
| 108 | * outDir: "build", |
| 109 | * }); |
| 110 | * ``` |
| 111 | * |
| 112 | * ### Custom Rebuild Scope |
| 113 | * Use `memo` to narrow the files that trigger a rebuild in large projects. |
| 114 | * |
| 115 | * **Example:** Narrowing the memo scope |
| 116 | * ```typescript |
| 117 | * const docs = yield* Cloudflare.Website.Vocs("Docs", { |
| 118 | * rootDir: "./docs", |
| 119 | * memo: { |
| 120 | * include: ["src/**", "public/**", "vocs.config.ts", "package.json"], |
| 121 | * }, |
| 122 | * }); |
| 123 | * ``` |
| 124 | * |
| 125 | * ### Class Form |
| 126 | * Calling `Vocs` without arguments returns a constructor for declaring the |
| 127 | * deployed Worker as a named class. |
| 128 | * |
| 129 | * **Example:** Declaring a Vocs Worker class |
| 130 | * ```typescript |
| 131 | * class Docs extends Cloudflare.Website.Vocs<Docs>()("Docs", { |
| 132 | * rootDir: "./docs", |
| 133 | * }) {} |
| 134 | * |
| 135 | * const docs = yield* Docs; |
| 136 | * ``` |
| 137 | * |
| 138 | * @resource |
| 139 | * @product Website |
| 140 | * @category Workers & Compute |
| 141 | */ |
| 142 | export const Vocs: { |
| 143 | <Self>(): { |
| 144 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 145 | id: string, |
| 146 | propsEff?: |
| 147 | | VocsInput<Bindings> |
| 148 | | Effect.Effect<VocsInput<Bindings>, never, Req>, |
| 149 | ): Effect.Effect<Self, never, Req | Providers> & { |
| 150 | new (): Worker<{ |
| 151 | [ |
| 152 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 153 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 154 | }>; |
| 155 | }; |
| 156 | }; |
| 157 | <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 158 | id: string, |
| 159 | propsEff?: |
| 160 | | VocsInput<Bindings> |
| 161 | | Effect.Effect<VocsInput<Bindings>, never, Req>, |
| 162 | ): Effect.Effect< |
| 163 | Worker<{ |
| 164 | [ |
| 165 | binding in keyof NormalizedBindings<Bindings, WorkerAssetsConfig> |
| 166 | ]: NormalizedBindings<Bindings, WorkerAssetsConfig>[binding]; |
| 167 | }>, |
| 168 | never, |
| 169 | Req | Providers |
| 170 | >; |
| 171 | } = (<const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 172 | id?: string, |
| 173 | propsEff?: |
| 174 | | VocsInput<Bindings> |
| 175 | | Effect.Effect<VocsInput<Bindings>, never, Req>, |
| 176 | ) => |
| 177 | id === undefined |
| 178 | ? <const Bindings extends WorkerBindingProps = {}, Req = never>( |
| 179 | id: string, |
| 180 | propsEff?: |
| 181 | | VocsInput<Bindings> |
| 182 | | Effect.Effect<VocsInput<Bindings>, never, Req>, |
| 183 | ) => effectClass(Vocs(id, propsEff)) |
| 184 | : Worker( |
| 185 | id, |
| 186 | Effect.map( |
| 187 | Effect.isEffect(propsEff) ? propsEff : Effect.succeed(propsEff), |
| 188 | (props) => ({ |
| 189 | ...props, |
| 190 | // The Worker compatibility resolver enables Node.js APIs from |
| 191 | // the date (or adds the flag when a caller pins an older date). |
| 192 | assets: { |
| 193 | htmlHandling: "drop-trailing-slash" as const, |
| 194 | ...props?.assets, |
| 195 | }, |
| 196 | main: undefined!, |
| 197 | source: { |
| 198 | provider: VOCS_SOURCE_PROVIDER, |
| 199 | devMode: "server", |
| 200 | options: { |
| 201 | rootDir: props?.rootDir, |
| 202 | outDir: props?.outDir, |
| 203 | memo: props?.memo, |
| 204 | }, |
| 205 | }, |
| 206 | }), |
| 207 | ), |
| 208 | )) as any; |