1import * as Effect from "effect/Effect";
2import { createHash } from "node:crypto";
3import { toPath } from "../../FQN.ts";
4import type { Input } from "../../Input.ts";
5import * as Namespace from "../../Namespace.ts";
6import * as Output from "../../Output.ts";
7import { Stack } from "../../Stack.ts";
8import { Stage } from "../../Stage.ts";
9import { KvEntries } from "../CloudFront/KvEntries.ts";
10import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts";
11import type { WebsiteDomainProps, WebsiteRouterDomainProps } from "./shared.ts";
12
13/**
14 * Narrow a normalized `domain` prop to its Router-attached shape.
15 * @internal
16 */
17export 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 */
28const 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 */
55export 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);