| 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 { Certificate } from "../ACM/Certificate.ts"; |
| 10 | import { |
| 11 | Distribution, |
| 12 | type DistributionBehavior, |
| 13 | } from "../CloudFront/Distribution.ts"; |
| 14 | import { Function as CloudFrontFunction } from "../CloudFront/Function.ts"; |
| 15 | import { Invalidation } from "../CloudFront/Invalidation.ts"; |
| 16 | import { KeyValueStore } from "../CloudFront/KeyValueStore.ts"; |
| 17 | import { KvEntries } from "../CloudFront/KvEntries.ts"; |
| 18 | import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts"; |
| 19 | import { CachePolicy } from "../CloudFront/CachePolicy.ts"; |
| 20 | import { MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID } from "../CloudFront/ManagedPolicies.ts"; |
| 21 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 22 | import { Record as Route53Record } from "../Route53/Record.ts"; |
| 23 | import { Records as Route53Records } from "../Route53/Records.ts"; |
| 24 | import type { Bucket } from "../S3/Bucket.ts"; |
| 25 | import { buildHostRedirectInjection, CF_ROUTER_INJECTION } from "./cfcode.ts"; |
| 26 | import { 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 | */ |
| 79 | export 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 | |
| 444 | const 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"; |
| 457 | async 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 | |
| 534 | const buildRouterResponseFunctionCode = (userInjection?: string) => |
| 535 | `import cf from "cloudfront"; |
| 536 | async function handler(event) { |
| 537 | ${userInjection ?? ""} |
| 538 | return event.response; |
| 539 | }`; |
| 540 | |
| 541 | const normalizePattern = (pattern: string) => { |
| 542 | if (pattern === "/" || pattern === "/*") return "/"; |
| 543 | return pattern.replace(/\/?\*$/, ""); |
| 544 | }; |
| 545 | |
| 546 | const 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))); |