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 { Certificate } from "../ACM/Certificate.ts";
10import {
11 Distribution,
12 type DistributionBehavior,
13} from "../CloudFront/Distribution.ts";
14import { Function as CloudFrontFunction } from "../CloudFront/Function.ts";
15import { Invalidation } from "../CloudFront/Invalidation.ts";
16import { KeyValueStore } from "../CloudFront/KeyValueStore.ts";
17import { KvEntries } from "../CloudFront/KvEntries.ts";
18import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts";
19import { CachePolicy } from "../CloudFront/CachePolicy.ts";
20import { MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID } from "../CloudFront/ManagedPolicies.ts";
21import type { PolicyStatement } from "../IAM/Policy.ts";
22import { Record as Route53Record } from "../Route53/Record.ts";
23import { Records as Route53Records } from "../Route53/Records.ts";
24import type { Bucket } from "../S3/Bucket.ts";
25import { buildHostRedirectInjection, CF_ROUTER_INJECTION } from "./cfcode.ts";
26import { normalizeWebsiteDomain, type RouterProps } from "./shared.ts";
27
28/**
29 * Shared CloudFront front door with KV-based dynamic routing.
30 *
31 * `Router` owns a single CloudFront distribution with a placeholder origin.
32 * Routes are registered lazily via KV entries. A CloudFront Function reads the
33 * KV store at the edge and dynamically sets the origin using
34 * `cf.updateRequestOrigin()`.
35 *
36 * Sites register themselves by writing their file manifest and metadata into
37 * the Router's KV store. The Router's CF function matches incoming requests to
38 * routes by host pattern and path prefix, then delegates to `routeSite()` for
39 * static site routing or directly sets URL/S3 origins.
40 * ### Creating Routers
41 * **Example:** Basic Router
42 * ```typescript
43 * const router = yield* Router("WebsiteRouter", {
44 * domain: { name: "example.com", hostedZoneId },
45 * });
46 * ```
47 *
48 * ### Inline Routes
49 * **Example:** URL And Bucket Routes
50 * ```typescript
51 * const router = yield* Router("WebsiteRouter", {
52 * routes: {
53 * "/api/*": { url: api.functionUrl },
54 * "/*": { bucket: assetsBucket },
55 * },
56 * });
57 * ```
58 *
59 * ### Attaching Sites
60 * **Example:** Serve A StaticSite Through The Router
61 * ```typescript
62 * const router = yield* Router("WebsiteRouter", {
63 * invalidation: { paths: "all", wait: true },
64 * });
65 *
66 * // The site registers itself in the Router's KV store; no new
67 * // distribution is created.
68 * const docs = yield* AWS.Website.StaticSite("DocsSite", {
69 * path: "./docs/dist",
70 * domain: {
71 * router,
72 * path: "/docs",
73 * },
74 * });
75 * ```
76 *
77 * @resource
78 */
79export const Router = Effect.fn("AWS.Website.Router")(
80 function* (id: string, props: RouterProps) {
81 const domain = normalizeWebsiteDomain(props.domain);
82
83 if (domain && domain.dns === false && !domain.cert) {
84 return yield* Effect.die(
85 "Router domain configuration with `dns: false` requires `cert`.",
86 );
87 }
88 if (props.cloudfrontUrl === false && !domain) {
89 return yield* Effect.die(
90 `"cloudfrontUrl: false" requires a "domain" — without one the Router would be unreachable (the CloudFront default domain is its only URL).`,
91 );
92 }
93 if (domain?.redirects?.length && domain.name.includes("*")) {
94 return yield* Effect.die(
95 `"domain.redirects" requires a concrete (non-wildcard) "domain.name" to redirect to.`,
96 );
97 }
98
99 // The managed certificate (when the Router owns one) doubles as a bind
100 // target for attached-site hostnames — keep the resource handle distinct
101 // from the viewer-certificate value, which may be a user-provided ARN.
102 const managedCertificate =
103 domain && !domain.cert
104 ? yield* Certificate("Certificate", {
105 domainName: domain.name,
106 subjectAlternativeNames: [
107 ...(domain.aliases ?? []),
108 ...(domain.redirects ?? []),
109 ],
110 hostedZoneId: domain.hostedZoneId,
111 tags: props.tags,
112 })
113 : undefined;
114 const certificate =
115 managedCertificate ??
116 (domain?.cert ? { certificateArn: domain.cert } : undefined);
117
118 const stack = yield* Stack;
119 const stage = yield* Stage;
120 const ns = yield* Namespace.CurrentNamespace;
121 const fqn = ns ? toPath(ns).join("/") : id;
122 const kvNamespace = createHash("md5")
123 .update(`${stack.name}-${stage}-${fqn}`)
124 .digest("hex")
125 .substring(0, 4);
126
127 const kvStore = yield* KeyValueStore("KvStore", {});
128
129 const viewerRequest = yield* CloudFrontFunction("ViewerRequest", {
130 comment: `${id} viewer request`,
131 code: buildRouterRequestFunctionCode({
132 kvNamespace,
133 userInjection: props.edge?.viewerRequest?.injection,
134 hostRedirect: domain
135 ? {
136 to: domain.name,
137 hosts: domain.redirects ?? [],
138 cloudfrontDefault: props.cloudfrontUrl === false,
139 }
140 : undefined,
141 }),
142 keyValueStoreArns: [kvStore.keyValueStoreArn],
143 });
144
145 const viewerResponse = props.edge?.viewerResponse
146 ? yield* CloudFrontFunction("ViewerResponse", {
147 comment: `${id} viewer response`,
148 code: buildRouterResponseFunctionCode(
149 props.edge.viewerResponse.injection,
150 ),
151 keyValueStoreArns: props.edge.viewerResponse.keyValueStoreArn
152 ? [props.edge.viewerResponse.keyValueStoreArn as any]
153 : undefined,
154 })
155 : undefined;
156
157 const functionAssociations: DistributionBehavior["functionAssociations"] = [
158 {
159 eventType: "viewer-request" as const,
160 functionArn: viewerRequest.functionArn as any,
161 },
162 ...(viewerResponse
163 ? [
164 {
165 eventType: "viewer-response" as const,
166 functionArn: viewerResponse.functionArn as any,
167 },
168 ]
169 : []),
170 ];
171
172 const inlineRouteEntries: Record<string, Input<string>> = {};
173 const routeBuckets: Bucket[] = [];
174
175 if (props.routes) {
176 let routeIndex = 0;
177 for (const [pattern, route] of Object.entries(props.routes)) {
178 routeIndex++;
179 const routeNs = createHash("md5")
180 .update(`${stack.name}-${stage}-${fqn}:route:${routeIndex}`)
181 .digest("hex")
182 .substring(0, 4);
183
184 if (typeof route === "string" || "url" in (route as any)) {
185 const url = typeof route === "string" ? route : (route as any).url;
186 const host = typeof url === "string" ? new URL(url).host : url;
187 inlineRouteEntries[`${routeNs}:metadata`] = stringifyResolvedString(
188 host,
189 (resolvedHost) =>
190 JSON.stringify({
191 host: resolvedHost,
192 origin: (route as any).origin,
193 rewrite: (route as any).rewrite,
194 }),
195 );
196 yield* KvRoutesUpdate(`Route${routeIndex}`, {
197 store: kvStore.keyValueStoreArn as any,
198 namespace: kvNamespace,
199 key: "routes",
200 entry: `url,${routeNs},,${normalizePattern(pattern)}`,
201 });
202 } else {
203 const bucketRoute = route as any;
204 if (typeof bucketRoute.bucket !== "string") {
205 routeBuckets.push(bucketRoute.bucket as Bucket);
206 }
207 const bucketDomain =
208 typeof bucketRoute.bucket === "string"
209 ? bucketRoute.bucket
210 : bucketRoute.bucket.bucketRegionalDomainName;
211 inlineRouteEntries[`${routeNs}:metadata`] = stringifyResolvedString(
212 bucketDomain,
213 (resolvedDomain) =>
214 JSON.stringify({
215 domain: resolvedDomain,
216 origin: bucketRoute.origin,
217 rewrite: bucketRoute.rewrite,
218 }),
219 );
220 yield* KvRoutesUpdate(`Route${routeIndex}`, {
221 store: kvStore.keyValueStoreArn as any,
222 namespace: kvNamespace,
223 key: "routes",
224 entry: `bucket,${routeNs},,${normalizePattern(pattern)}`,
225 });
226 }
227 }
228 }
229
230 if (Object.keys(inlineRouteEntries).length > 0) {
231 yield* KvEntries("InlineRouteEntries", {
232 store: kvStore.keyValueStoreArn as any,
233 namespace: kvNamespace,
234 entries: inlineRouteEntries,
235 });
236 }
237
238 // One behavior serves every attached site — static AND server-rendered
239 // — so the cache policy must not cache responses that carry no
240 // Cache-Control (SSR pages), while still honoring the immutable
241 // Cache-Control the asset uploader sets. Managed CachingOptimized
242 // would cache header-less SSR responses for a day. The
243 // AllViewerExceptHostHeader origin-request policy forwards viewer
244 // headers/cookies/query to server origins (required for Lambda URLs,
245 // whose Host must stay the function URL's own domain).
246 const cachePolicy = yield* CachePolicy("CachePolicy", {
247 comment: `${id} router cache policy`,
248 minTTL: 0,
249 defaultTTL: 0,
250 maxTTL: "365 days",
251 parametersInCacheKeyAndForwardedToOrigin: {
252 EnableAcceptEncodingGzip: true,
253 EnableAcceptEncodingBrotli: true,
254 QueryStringsConfig: { QueryStringBehavior: "all" },
255 HeadersConfig: { HeaderBehavior: "none" },
256 CookiesConfig: { CookieBehavior: "none" },
257 },
258 });
259
260 const distribution = yield* Distribution("Distribution", {
261 aliases: domain
262 ? [domain.name, ...(domain.aliases ?? []), ...(domain.redirects ?? [])]
263 : undefined,
264 origins: [
265 {
266 id: "default",
267 domainName: "placeholder.alchemy.run",
268 customOriginConfig: {
269 httpPort: 80,
270 httpsPort: 443,
271 originProtocolPolicy: "https-only",
272 originReadTimeout: "20 seconds",
273 originSslProtocols: ["TLSv1.2"],
274 },
275 },
276 ],
277 defaultCacheBehavior: {
278 targetOriginId: "default",
279 viewerProtocolPolicy: "redirect-to-https",
280 allowedMethods: [
281 "DELETE",
282 "GET",
283 "HEAD",
284 "OPTIONS",
285 "PATCH",
286 "POST",
287 "PUT",
288 ],
289 cachedMethods: ["GET", "HEAD"],
290 compress: true,
291 cachePolicyId: cachePolicy.cachePolicyId,
292 originRequestPolicyId: MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID,
293 functionAssociations,
294 },
295 viewerCertificate: certificate
296 ? {
297 acmCertificateArn: (certificate as any).certificateArn,
298 sslSupportMethod: "sni-only",
299 minimumProtocolVersion: "TLSv1.2_2021",
300 }
301 : undefined,
302 tags: props.tags,
303 });
304
305 // Inline bucket routes are served through the router's distribution with
306 // OAC-signed requests (see `setS3Origin` in cfcode.ts) — each bucket must
307 // allow this distribution or every request 403s.
308 yield* Effect.forEach(routeBuckets, (routeBucket) => {
309 const bucketPolicy: PolicyStatement = {
310 Effect: "Allow",
311 Principal: {
312 Service: "cloudfront.amazonaws.com",
313 },
314 Action: ["s3:GetObject"],
315 Resource: [Output.interpolate`${routeBucket.bucketArn}/*` as any],
316 Condition: {
317 StringEquals: {
318 "AWS:SourceArn": distribution.distributionArn as any,
319 },
320 },
321 };
322 return routeBucket.bind`AWS.S3.Policy(CloudFront, ${routeBucket})`({
323 policyStatements: [bucketPolicy],
324 });
325 });
326
327 const records =
328 domain && domain.dns !== false
329 ? yield* Effect.forEach(
330 [
331 domain.name,
332 ...(domain.aliases ?? []),
333 ...(domain.redirects ?? []),
334 ],
335 (name, index) =>
336 Route53Record(`AliasRecord${index + 1}`, {
337 // Optional — the Record provider infers the most specific
338 // public zone containing `name` when omitted.
339 hostedZoneId: domain.hostedZoneId,
340 name,
341 type: "A",
342 aliasTarget: {
343 hostedZoneId: distribution.hostedZoneId,
344 dnsName: distribution.domainName,
345 },
346 }),
347 { concurrency: "unbounded" },
348 )
349 : [];
350
351 // Bind target for attached-site hostnames: a record set (initially
352 // empty) that same-stack sites bind their concrete hostnames onto, each
353 // becoming an A-alias record pointing at this distribution (see
354 // `WebsiteRouterBindTargets`).
355 const siteRecords =
356 domain && domain.dns !== false
357 ? yield* Route53Records("SiteAliasRecords", {
358 // Optional — the Records provider infers the zone from the
359 // first bound hostname when omitted.
360 hostedZoneId: domain.hostedZoneId,
361 type: "A",
362 aliasTarget: {
363 hostedZoneId: distribution.hostedZoneId,
364 dnsName: distribution.domainName,
365 },
366 })
367 : undefined;
368
369 const invalidation =
370 props.invalidation === false || !props.invalidation
371 ? undefined
372 : yield* Invalidation("Invalidation", {
373 distributionId: distribution.distributionId,
374 version: createHash("sha256")
375 .update(JSON.stringify(inlineRouteEntries))
376 .digest("hex"),
377 wait: props.invalidation.wait,
378 paths:
379 props.invalidation.paths === "all" || !props.invalidation.paths
380 ? ["/*"]
381 : Array.isArray(props.invalidation.paths)
382 ? props.invalidation.paths
383 : ["/*"],
384 });
385
386 // Precedence: the canonical domain, then aliases in declaration order,
387 // then the distribution's own URL (only while `cloudfrontUrl` is
388 // enabled). Redirect hostnames never appear.
389 //
390 // `distribution.url` rather than an interpolated
391 // `https://${distribution.domainName}`: the distribution knows its own
392 // address, which is `https://{id}.cloudfront.net` on AWS and
393 // `http://localhost:{port}` under `alchemy dev`, where that AWS hostname
394 // resolves to nothing.
395 const urls: Input<string>[] = domain
396 ? [
397 Output.interpolate`https://${domain.name}`,
398 ...(domain.aliases ?? []).map((alias) => `https://${alias}`),
399 ...(props.cloudfrontUrl !== false ? [distribution.url] : []),
400 ]
401 : [distribution.url];
402
403 return {
404 certificate,
405 distribution,
406 records,
407 invalidation,
408 kvStoreArn: kvStore.keyValueStoreArn as Input<string>,
409 kvNamespace,
410 distributionId: distribution.distributionId as Input<string>,
411 distributionArn: distribution.distributionArn as Input<string>,
412 /**
413 * Same-stack bind targets for attached-site hostnames (see
414 * `WebsiteRouterBindTargets` in shared.ts): a site declaring
415 * `domain: { name, router }` binds its concrete hostnames onto the
416 * distribution (alias), the managed certificate (SAN), and the
417 * Route 53 record set. Only populated when the Router owns a
418 * `domain` — without one there is no viewer certificate to cover
419 * bound aliases.
420 */
421 bindTargets: domain
422 ? {
423 distribution,
424 certificate: managedCertificate,
425 records: siteRecords,
426 }
427 : undefined,
428 /**
429 * The most significant URL the Router serves at — always `urls[0]`.
430 */
431 url: urls[0],
432 /**
433 * Every URL the Router serves at, most significant first —
434 * `[https://<domain.name>?, ...aliases, <CloudFront default
435 * domain>?]` (the default domain only while `cloudfrontUrl` is
436 * enabled). Redirect hostnames never appear — they serve no content.
437 */
438 urls,
439 };
440 },
441 (effect, id: string, _props: RouterProps) => effect.pipe(Namespace.push(id)),
442);
443
444const buildRouterRequestFunctionCode = ({
445 kvNamespace,
446 userInjection,
447 hostRedirect,
448}: {
449 kvNamespace: string;
450 userInjection?: string;
451 hostRedirect?: {
452 to: string;
453 hosts: string[];
454 cloudfrontDefault: boolean;
455 };
456}) => `import cf from "cloudfront";
457async function handler(event) {
458 ${userInjection ?? ""}
459 ${
460 hostRedirect
461 ? buildHostRedirectInjection({
462 to: hostRedirect.to,
463 hosts: hostRedirect.hosts,
464 cloudfrontDefault: hostRedirect.cloudfrontDefault,
465 })
466 : ""
467 }
468 ${CF_ROUTER_INJECTION}
469
470 async function getRoutes() {
471 var routerNS = "${kvNamespace}";
472 var routes = [];
473 try {
474 var v = await cf.kvs().get(routerNS + ":routes");
475 routes = JSON.parse(v);
476 if (routes.parts) {
477 var chunkPromises = [];
478 for (var i = 0; i < routes.parts; i++) {
479 chunkPromises.push(cf.kvs().get(routerNS + ":routes:" + i));
480 }
481 var chunks = await Promise.all(chunkPromises);
482 routes = JSON.parse(chunks.join(""));
483 }
484 } catch (e) {}
485 return routes;
486 }
487
488 async function matchRoute(routes) {
489 var requestHost = event.request.headers.host.value;
490 var requestHostWithEscapedDots = requestHost.replace(/\\./g, "\\\\.");
491 var requestHostRegexPattern = "^" + requestHost + "$";
492 var match;
493 routes.forEach(function(r) {
494 var parts = r.split(",");
495 var type = parts[0];
496 var routeNs = parts[1];
497 var host = parts[2];
498 var hostLength = host.length;
499 var path = parts[3];
500 var pathLength = path.length;
501 if (match && (hostLength < match.hostLength || (hostLength === match.hostLength && pathLength < match.pathLength))) return;
502 var hostMatches = host === "" || host === requestHostWithEscapedDots || (host.includes("*") && new RegExp(host).test(requestHostRegexPattern));
503 if (!hostMatches) return;
504 var pathMatches = event.request.uri.startsWith(path) && (event.request.uri === path || path.endsWith('/') || event.request.uri[path.length] === '/' || path === '/');
505 if (!pathMatches) return;
506 match = { type: type, routeNs: routeNs, host: host, hostLength: hostLength, path: path, pathLength: pathLength };
507 });
508 if (match) {
509 try {
510 var type = match.type;
511 var routeNs = match.routeNs;
512 var v = await cf.kvs().get(routeNs + ":metadata");
513 return { type: type, routeNs: routeNs, metadata: JSON.parse(v) };
514 } catch (e) {}
515 }
516 }
517
518 var routes = await getRoutes();
519 var route = await matchRoute(routes);
520 if (!route) return event.request;
521 if (route.metadata.rewrite) {
522 var rw = route.metadata.rewrite;
523 event.request.uri = event.request.uri.replace(new RegExp(rw.regex), rw.to);
524 }
525 if (route.type === "url") setUrlOrigin(route.metadata.host, route.metadata.origin);
526 if (route.type === "bucket") setS3Origin(route.metadata.domain, route.metadata.origin);
527 if (route.type === "site") {
528 var response = await routeSite(route.routeNs, route.metadata);
529 return response || event.request;
530 }
531 return event.request;
532}`;
533
534const buildRouterResponseFunctionCode = (userInjection?: string) =>
535 `import cf from "cloudfront";
536async function handler(event) {
537 ${userInjection ?? ""}
538 return event.response;
539}`;
540
541const normalizePattern = (pattern: string) => {
542 if (pattern === "/" || pattern === "/*") return "/";
543 return pattern.replace(/\/?\*$/, "");
544};
545
546const stringifyResolvedString = (
547 value: Input<string>,
548 build: (resolved: string) => string,
549): Input<string> =>
550 typeof value === "string"
551 ? build(value)
552 : Effect.isEffect(value)
553 ? value.pipe(Effect.map((resolved) => build(resolved)))
554 : value.pipe(Output.map((resolved) => build(resolved)));