| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import { toPath } from "../../FQN.ts"; |
| 4 | import type { Input } from "../../Input.ts"; |
| 5 | import * as Namespace from "../../Namespace.ts"; |
| 6 | import * as Output from "../../Output.ts"; |
| 7 | import { Stack } from "../../Stack.ts"; |
| 8 | import { Stage } from "../../Stage.ts"; |
| 9 | import { KvEntries } from "../CloudFront/KvEntries.ts"; |
| 10 | import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts"; |
| 11 | import type { WebsiteDomainProps, WebsiteRouterDomainProps } from "./shared.ts"; |
| 12 | |
| 13 | /** |
| 14 | * Narrow a normalized `domain` prop to its Router-attached shape. |
| 15 | * @internal |
| 16 | */ |
| 17 | export const asRouterDomain = ( |
| 18 | domain: WebsiteDomainProps | undefined, |
| 19 | ): WebsiteRouterDomainProps | undefined => |
| 20 | domain && "router" in domain && domain.router |
| 21 | ? (domain as WebsiteRouterDomainProps) |
| 22 | : undefined; |
| 23 | |
| 24 | /** |
| 25 | * Escape a host pattern into the regex form the Router's edge function |
| 26 | * matches against. Mirrors `toHostPatternRegex` in StaticSite.ts. |
| 27 | */ |
| 28 | const toHostPatternRegex = (pattern: string) => |
| 29 | pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); |
| 30 | |
| 31 | /** |
| 32 | * Register a **dev-mode** site with its `AWS.Website.Router`. |
| 33 | * |
| 34 | * `alchemy dev` replaces a site's S3 bucket + asset upload with the |
| 35 | * framework's own dev server, but the Router-facing subgraph stays exactly |
| 36 | * the same resource types as a live deploy — `KvRoutesUpdate` route entries |
| 37 | * (byte-identical to live) plus a `KvEntries` metadata blob. Only the |
| 38 | * metadata *contents* differ: instead of an `s3` origin plus a file |
| 39 | * manifest, dev writes a `servers` origin pointing at the local dev |
| 40 | * server, which `routeSite()` in cfcode.ts already handles (every KV file |
| 41 | * lookup misses, so it falls through to `setUrlOrigin`). |
| 42 | * |
| 43 | * There is deliberately no dev-only resource type and no `ctx.dev` branch |
| 44 | * inside `Router`: switching between `alchemy dev` and `alchemy deploy` |
| 45 | * must be a `providerMode` change on the same graph, not a different graph. |
| 46 | * |
| 47 | * Must be called from inside the site's own namespace (the composites call |
| 48 | * it after `Namespace.push(id)`) so the KV namespace hash and the child |
| 49 | * resource FQNs match the live path exactly. |
| 50 | * |
| 51 | * @returns the site's URLs, computed the same way the live Router |
| 52 | * attachment computes them. |
| 53 | * @internal |
| 54 | */ |
| 55 | export const registerDevRouterRoute = Effect.fn("AWS.Website.DevRouterRoute")( |
| 56 | function* ( |
| 57 | domain: WebsiteRouterDomainProps, |
| 58 | /** The local dev server's URL (e.g. `http://localhost:5173`). */ |
| 59 | devUrl: Input<string | undefined>, |
| 60 | ) { |
| 61 | const router = domain.router; |
| 62 | const stack = yield* Stack; |
| 63 | const stage = yield* Stage; |
| 64 | const ns = yield* Namespace.CurrentNamespace; |
| 65 | const fqn = ns ? toPath(ns).join("/") : ""; |
| 66 | const kvNamespace = createHash("md5") |
| 67 | .update(`${stack.name}-${stage}-${fqn}`) |
| 68 | .digest("hex") |
| 69 | .substring(0, 4); |
| 70 | |
| 71 | const routerPathPrefix = domain.path |
| 72 | ? "/" + domain.path.replace(/^\//, "").replace(/\/$/, "") |
| 73 | : undefined; |
| 74 | |
| 75 | // One KV route entry per host pattern — identical shape, ids, and |
| 76 | // ordering to the live attachment in `makeKvSite`. |
| 77 | const hostPatterns: [id: string, pattern: string | undefined][] = [ |
| 78 | ["RoutesUpdate", domain.name], |
| 79 | ...(domain.aliases ?? []).map((alias, index): [string, string] => [ |
| 80 | `RoutesUpdateAlias${index + 1}`, |
| 81 | alias, |
| 82 | ]), |
| 83 | ...(domain.redirects ?? []).map((redirect, index): [string, string] => [ |
| 84 | `RoutesUpdateRedirect${index + 1}`, |
| 85 | redirect, |
| 86 | ]), |
| 87 | ]; |
| 88 | |
| 89 | yield* Effect.forEach( |
| 90 | hostPatterns, |
| 91 | ([routeId, pattern]) => |
| 92 | KvRoutesUpdate(routeId, { |
| 93 | store: router.kvStoreArn, |
| 94 | namespace: router.kvNamespace as any, |
| 95 | key: "routes", |
| 96 | entry: [ |
| 97 | "site", |
| 98 | kvNamespace, |
| 99 | pattern ? toHostPatternRegex(pattern) : "", |
| 100 | routerPathPrefix ?? "/", |
| 101 | ].join(","), |
| 102 | }), |
| 103 | { concurrency: "unbounded" }, |
| 104 | ); |
| 105 | |
| 106 | const redirect = |
| 107 | domain.redirects?.length && domain.name |
| 108 | ? { hosts: domain.redirects, to: domain.name } |
| 109 | : undefined; |
| 110 | |
| 111 | yield* KvEntries("KvEntries", { |
| 112 | store: router.kvStoreArn, |
| 113 | namespace: kvNamespace, |
| 114 | entries: { |
| 115 | metadata: Output.map( |
| 116 | Output.asOutput(devUrl as any) as Output.Output<string | undefined>, |
| 117 | (resolved) => { |
| 118 | if (!resolved) { |
| 119 | throw new Error( |
| 120 | "A Router-attached site needs a dev server URL to route to during `alchemy dev` — set `dev.url` (or make the dev command print its own localhost URL).", |
| 121 | ); |
| 122 | } |
| 123 | return JSON.stringify({ |
| 124 | base: |
| 125 | routerPathPrefix && routerPathPrefix !== "/" |
| 126 | ? routerPathPrefix |
| 127 | : undefined, |
| 128 | // `[[host]]` — one server, no geo coordinates, so |
| 129 | // `findNearestServer` returns it unconditionally. The host |
| 130 | // carries the dev server's port; the emulated edge resolves |
| 131 | // loopback origins to a container-reachable address. |
| 132 | servers: [[new URL(resolved).host]], |
| 133 | // Dev servers speak plain HTTP. `setUrlOrigin` in cfcode.ts |
| 134 | // already threads `metadata.origin` through to |
| 135 | // `cf.updateRequestOrigin`. |
| 136 | origin: { protocol: "http" }, |
| 137 | redirect, |
| 138 | }); |
| 139 | }, |
| 140 | ), |
| 141 | }, |
| 142 | purge: true, |
| 143 | }); |
| 144 | |
| 145 | // The site's own `url` output stays the dev server's localhost address |
| 146 | // (the point of `alchemy dev` is the HMR server); the Router serves the |
| 147 | // same content at `router.url + domain.path`. |
| 148 | return kvNamespace; |
| 149 | }, |
| 150 | ); |