| 1 | import * as AWS from "@/AWS"; |
| 2 | import { flociServices } from "@/AWS/Local/FlociServices.ts"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as cloudfront from "@distilled.cloud/aws/cloudfront"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * as Data from "effect/Data"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import * as pathe from "pathe"; |
| 14 | import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts"; |
| 15 | |
| 16 | // `dev: true` runs local providers behind the RPC sidecar proxy by default, |
| 17 | // matching the process topology of the real `alchemy dev` command. |
| 18 | // |
| 19 | // These cases need floci's emulated CloudFront edge, which arrives with the |
| 20 | // next `DEFAULT_FLOCI_IMAGE` pin bump. Until then, run them against a locally |
| 21 | // built emulator: `ALCHEMY_FLOCI_IMAGE=floci:cf-edge pnpm test …`. |
| 22 | const { test } = Test.make({ providers: AWS.providers(), dev: true }); |
| 23 | |
| 24 | const fixtureDir = pathe.resolve( |
| 25 | import.meta.dirname, |
| 26 | "fixtures", |
| 27 | "staticsite-dev", |
| 28 | ); |
| 29 | const viteFixtureDir = pathe.resolve( |
| 30 | import.meta.dirname, |
| 31 | "fixtures", |
| 32 | "vite-app", |
| 33 | ); |
| 34 | // Clone under the alchemy package so `vite` resolves from the workspace's |
| 35 | // hoisted node_modules (the fixture has no node_modules of its own). |
| 36 | const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); |
| 37 | |
| 38 | const htmlPage = (marker: string) => `<!doctype html> |
| 39 | <html> |
| 40 | <head><title>${marker}</title></head> |
| 41 | <body><h1>${marker}</h1></body> |
| 42 | </html> |
| 43 | `; |
| 44 | |
| 45 | /** |
| 46 | * Fetch the Router the way a developer does: open its URL. |
| 47 | * |
| 48 | * No Host header, no emulator endpoint, no TLS exception — `router.url` under |
| 49 | * `alchemy dev` is a plain `http://localhost:<port>` the emulated distribution |
| 50 | * is served on, so a browser (and this test) can just GET it. Needing anything |
| 51 | * else here would mean the dev URL is not really usable. |
| 52 | */ |
| 53 | const fetchRouter = Effect.fn("fetchRouter")(function* ( |
| 54 | routerUrl: string, |
| 55 | path: string, |
| 56 | ) { |
| 57 | const client = yield* HttpClient.HttpClient; |
| 58 | return yield* client |
| 59 | .get(`${routerUrl}${path}`) |
| 60 | .pipe( |
| 61 | Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 6 }), |
| 62 | ); |
| 63 | }); |
| 64 | |
| 65 | /** |
| 66 | * Build one Router-attached dev site: a cloned fixture whose long-lived dev |
| 67 | * server serves `site/<prefix>/index.html`, so the site behaves like a real |
| 68 | * static host mounted under the Router's path prefix. |
| 69 | */ |
| 70 | const makeSiteFixture = Effect.fn("makeSiteFixture")(function* ( |
| 71 | prefix: string, |
| 72 | marker: string, |
| 73 | ) { |
| 74 | const fs = yield* FileSystem.FileSystem; |
| 75 | const path = yield* Path.Path; |
| 76 | const cwd = yield* cloneFixture(fixtureDir, { |
| 77 | prefix: `alchemy-aws-router-dev-${prefix}-`, |
| 78 | tempRoot, |
| 79 | entries: ["serve.mjs", "site"], |
| 80 | }); |
| 81 | yield* fs.makeDirectory(path.join(cwd, "site", prefix), { recursive: true }); |
| 82 | yield* fs.writeFileString( |
| 83 | path.join(cwd, "site", prefix, "index.html"), |
| 84 | htmlPage(marker), |
| 85 | ); |
| 86 | return cwd; |
| 87 | }); |
| 88 | |
| 89 | /** |
| 90 | * What the Router answered with, when it isn't what the test expected. |
| 91 | * |
| 92 | * A typed failure (rather than an `expect` throw) so the assertion can be |
| 93 | * retried: the dev server behind the mount may still be warming up, and a |
| 94 | * live edit becomes visible one file-watcher tick after the write. |
| 95 | */ |
| 96 | class RouterBodyMismatch extends Data.TaggedError("RouterBodyMismatch")<{ |
| 97 | url: string; |
| 98 | status: number; |
| 99 | problem: string; |
| 100 | bodyExcerpt: string; |
| 101 | }> {} |
| 102 | |
| 103 | /** |
| 104 | * GET `path` through the Router and assert what came back — status, markers |
| 105 | * that must be present, and markers that must NOT be (the negative half is |
| 106 | * what pins longest-prefix routing: a body is only proof if it could have |
| 107 | * come from the *wrong* origin and didn't). |
| 108 | */ |
| 109 | const expectRouterBody = Effect.fn("expectRouterBody")(function* ( |
| 110 | routerUrl: string, |
| 111 | path: string, |
| 112 | options: { |
| 113 | readonly includes?: ReadonlyArray<string>; |
| 114 | readonly excludes?: ReadonlyArray<string>; |
| 115 | readonly status?: number; |
| 116 | }, |
| 117 | ) { |
| 118 | const client = yield* HttpClient.HttpClient; |
| 119 | const url = `${routerUrl}${path}`; |
| 120 | const status = options.status ?? 200; |
| 121 | return yield* Effect.gen(function* () { |
| 122 | const response = yield* client.get(url); |
| 123 | const body = yield* response.text; |
| 124 | const missing = (options.includes ?? []).filter((m) => !body.includes(m)); |
| 125 | const present = (options.excludes ?? []).filter((m) => body.includes(m)); |
| 126 | if ( |
| 127 | response.status !== status || |
| 128 | missing.length > 0 || |
| 129 | present.length > 0 |
| 130 | ) { |
| 131 | return yield* Effect.fail( |
| 132 | new RouterBodyMismatch({ |
| 133 | url, |
| 134 | status: response.status, |
| 135 | problem: [ |
| 136 | response.status !== status |
| 137 | ? `expected status ${status}` |
| 138 | : undefined, |
| 139 | missing.length > 0 ? `missing ${missing.join(", ")}` : undefined, |
| 140 | present.length > 0 |
| 141 | ? `unexpectedly present ${present.join(", ")}` |
| 142 | : undefined, |
| 143 | ] |
| 144 | .filter(Boolean) |
| 145 | .join("; "), |
| 146 | bodyExcerpt: body.slice(0, 240), |
| 147 | }), |
| 148 | ); |
| 149 | } |
| 150 | return body; |
| 151 | }).pipe( |
| 152 | Effect.retry({ |
| 153 | schedule: Schedule.exponential("400 millis", 1.4), |
| 154 | times: 8, |
| 155 | }), |
| 156 | Effect.tapError((error) => |
| 157 | Effect.logError(`expectRouterBody(${path}) failed`, error), |
| 158 | ), |
| 159 | ); |
| 160 | }); |
| 161 | |
| 162 | /** |
| 163 | * Clone the plain-Vite fixture and stamp per-site markers into it, so a body |
| 164 | * served through the Router identifies WHICH dev server produced it. |
| 165 | * |
| 166 | * Returns the paths of the two files the live-edit assertions rewrite. |
| 167 | */ |
| 168 | const makeViteFixture = Effect.fn("makeViteFixture")(function* ( |
| 169 | slug: string, |
| 170 | marker: string, |
| 171 | ) { |
| 172 | const fs = yield* FileSystem.FileSystem; |
| 173 | const path = yield* Path.Path; |
| 174 | const cwd = yield* cloneFixture(viteFixtureDir, { |
| 175 | prefix: `alchemy-aws-router-vite-${slug}-`, |
| 176 | tempRoot, |
| 177 | entries: [".gitignore", "package.json", "index.html", "src", "public"], |
| 178 | }); |
| 179 | const indexPath = path.join(cwd, "index.html"); |
| 180 | const mainPath = path.join(cwd, "src", "main.ts"); |
| 181 | const index = yield* fs.readFileString(indexPath); |
| 182 | yield* fs.writeFileString( |
| 183 | indexPath, |
| 184 | index.replaceAll("VITE_AWS_PAGE_MARKER", `${marker}_PAGE`), |
| 185 | ); |
| 186 | const main = yield* fs.readFileString(mainPath); |
| 187 | yield* fs.writeFileString( |
| 188 | mainPath, |
| 189 | main.replaceAll("VITE_AWS_MODULE_MARKER", `${marker}_MODULE`), |
| 190 | ); |
| 191 | return { cwd, indexPath, mainPath }; |
| 192 | }); |
| 193 | |
| 194 | describe( |
| 195 | "AWS.Website.Router local", |
| 196 | { tags: ["provider:aws", "provider:aws:website"] }, |
| 197 | () => { |
| 198 | /** |
| 199 | * The whole point of the local Router: two sites, one Router, real HTTP |
| 200 | * through the emulated CloudFront distribution, each path prefix reaching |
| 201 | * its own dev server. No CloudFront deploy — the distribution, its KV |
| 202 | * store, its viewer-request function and the routing all run in floci. |
| 203 | */ |
| 204 | test.provider( |
| 205 | "two dev sites route through one emulated distribution", |
| 206 | (stack) => |
| 207 | Effect.gen(function* () { |
| 208 | yield* stack.destroy(); |
| 209 | |
| 210 | const cwdA = yield* makeSiteFixture("site-a", "router-dev-site-a"); |
| 211 | const cwdB = yield* makeSiteFixture("site-b", "router-dev-site-b"); |
| 212 | |
| 213 | const deployed = yield* stack.deploy( |
| 214 | Effect.gen(function* () { |
| 215 | const router = yield* AWS.Website.Router("DevRouter", {}); |
| 216 | const siteA = yield* AWS.Website.StaticSite("SiteA", { |
| 217 | path: cwdA, |
| 218 | dev: { |
| 219 | command: "bun serve.mjs", |
| 220 | env: { DEV_MARKER: "site-a" }, |
| 221 | }, |
| 222 | domain: { router, path: "/site-a" }, |
| 223 | }); |
| 224 | const siteB = yield* AWS.Website.StaticSite("SiteB", { |
| 225 | path: cwdB, |
| 226 | dev: { |
| 227 | command: "bun serve.mjs", |
| 228 | env: { DEV_MARKER: "site-b" }, |
| 229 | }, |
| 230 | domain: { router, path: "/site-b" }, |
| 231 | }); |
| 232 | return { |
| 233 | routerUrl: router.url, |
| 234 | distributionId: router.distributionId, |
| 235 | kvNamespace: router.kvNamespace, |
| 236 | siteA: { url: siteA.url, kvNamespace: siteA.kvNamespace }, |
| 237 | siteB: { url: siteB.url, kvNamespace: siteB.kvNamespace }, |
| 238 | }; |
| 239 | }), |
| 240 | ); |
| 241 | |
| 242 | const routerUrl = deployed.routerUrl as string; |
| 243 | // The Router is a real (emulated) CloudFront distribution — the same |
| 244 | // resource graph a deploy produces, not a dev-only substitute — served |
| 245 | // on a local port of its own, because `E….cloudfront.net` resolves to |
| 246 | // nothing on a developer's machine. |
| 247 | expect(routerUrl).toMatch(/^http:\/\/localhost:\d+$/); |
| 248 | // Each site kept its own dev server as its `url` (HMR is the point of |
| 249 | // dev) while still registering with the Router. |
| 250 | expect(deployed.siteA.url).toMatch(/^http:\/\/localhost:\d+/); |
| 251 | expect(deployed.siteB.url).toMatch(/^http:\/\/localhost:\d+/); |
| 252 | expect(deployed.siteA.url).not.toBe(deployed.siteB.url); |
| 253 | expect(deployed.siteA.kvNamespace).toBeDefined(); |
| 254 | expect(deployed.siteB.kvNamespace).not.toBe( |
| 255 | deployed.siteA.kvNamespace, |
| 256 | ); |
| 257 | |
| 258 | // ── Routing: each prefix reaches its own dev server ──────────────── |
| 259 | const a = yield* fetchRouter(routerUrl, "/site-a/"); |
| 260 | expect(a.status).toBe(200); |
| 261 | expect(yield* a.text).toContain("router-dev-site-a"); |
| 262 | |
| 263 | const b = yield* fetchRouter(routerUrl, "/site-b/"); |
| 264 | expect(b.status).toBe(200); |
| 265 | expect(yield* b.text).toContain("router-dev-site-b"); |
| 266 | |
| 267 | // A mount is a path SEGMENT prefix, not a string prefix: `/site-a-x` |
| 268 | // shares seven characters with `/site-a` and must not be routed to it. |
| 269 | const neighbour = yield* fetchRouter(routerUrl, "/site-a-x"); |
| 270 | expect(yield* neighbour.text).not.toContain("router-dev-site-a"); |
| 271 | |
| 272 | // ── The origin received the request CloudFront would have sent ───── |
| 273 | const echo = yield* fetchRouter(routerUrl, "/site-a/__echo"); |
| 274 | expect(echo.status).toBe(200); |
| 275 | const echoed = (yield* echo.json) as { |
| 276 | marker: string; |
| 277 | path: string; |
| 278 | headers: Record<string, string>; |
| 279 | }; |
| 280 | expect(echoed.marker).toBe("site-a"); |
| 281 | expect(echoed.path).toBe("/site-a/__echo"); |
| 282 | // `routeSite` sets x-forwarded-host to the viewer's Host before it |
| 283 | // rewrites the origin — the site's dev server sees the hostname the |
| 284 | // request arrived on, exactly as a deployed server origin would. |
| 285 | expect(echoed.headers["x-forwarded-host"]).toBe( |
| 286 | new URL(routerUrl).host, |
| 287 | ); |
| 288 | |
| 289 | // ── Live edit: the dev server reads from disk per request, so the |
| 290 | // next edge request serves the new content without re-applying ───── |
| 291 | const fs = yield* FileSystem.FileSystem; |
| 292 | const path = yield* Path.Path; |
| 293 | yield* fs.writeFileString( |
| 294 | path.join(cwdA, "site", "site-a", "index.html"), |
| 295 | htmlPage("router-dev-site-a-v2"), |
| 296 | ); |
| 297 | const edited = yield* fetchRouter(routerUrl, "/site-a/"); |
| 298 | expect(yield* edited.text).toContain("router-dev-site-a-v2"); |
| 299 | |
| 300 | yield* stack.destroy(); |
| 301 | }), |
| 302 | { tags: ["local"], timeout: 300_000 }, |
| 303 | ); |
| 304 | |
| 305 | /** |
| 306 | * The framework path — the one a real `alchemy dev` user is on. |
| 307 | * |
| 308 | * A framework dev server is not a hand-written fixture: it binds where the |
| 309 | * framework decides (Vite picks `[::1]`), serves modules the HTML it |
| 310 | * generated points at, and rewrites those URLs by `base`. The emulated edge |
| 311 | * runs in a container and reaches the host through its gateway, so a |
| 312 | * loopback-only listener answers nothing and every request through the |
| 313 | * Router 502s. |
| 314 | * |
| 315 | * So this case is deliberately end-to-end through `router.url` only: the |
| 316 | * HTML shell, the module URLs that HTML actually references, and a live |
| 317 | * edit landing on a later request. Nothing here inspects the bind address |
| 318 | * (not portable) — the route working IS the proof. |
| 319 | */ |
| 320 | test.provider( |
| 321 | "a Vite dev server serves its whole mount through the Router", |
| 322 | (stack) => |
| 323 | Effect.gen(function* () { |
| 324 | const fs = yield* FileSystem.FileSystem; |
| 325 | |
| 326 | yield* stack.destroy(); |
| 327 | |
| 328 | const root = yield* makeViteFixture("root", "ROUTER_VITE_ROOT"); |
| 329 | const app = yield* makeViteFixture("app", "ROUTER_VITE_APP"); |
| 330 | |
| 331 | const deployed = yield* stack.deploy( |
| 332 | Effect.gen(function* () { |
| 333 | const router = yield* AWS.Website.Router("ViteRouter", {}); |
| 334 | const rootSite = yield* AWS.Website.Vite("RootSite", { |
| 335 | rootDir: root.cwd, |
| 336 | domain: { router }, |
| 337 | }); |
| 338 | const appSite = yield* AWS.Website.Vite("AppSite", { |
| 339 | rootDir: app.cwd, |
| 340 | domain: { router, path: "/app" }, |
| 341 | // The Router forwards the FULL uri to the origin — it never |
| 342 | // strips the mount prefix — so the dev server has to serve at |
| 343 | // the prefix. `vite.base` is what makes Vite do that, for both |
| 344 | // the module URLs it writes into the HTML and the requests it |
| 345 | // then has to answer. Passed as the deploy-time override bag |
| 346 | // (merged over the fixture's config file, which has none). |
| 347 | vite: { base: "/app/" }, |
| 348 | }); |
| 349 | return { |
| 350 | routerUrl: router.url, |
| 351 | rootUrl: rootSite.url, |
| 352 | appUrl: appSite.url, |
| 353 | }; |
| 354 | }), |
| 355 | ); |
| 356 | |
| 357 | const routerUrl = deployed.routerUrl as string; |
| 358 | expect(routerUrl).toMatch(/^http:\/\/localhost:\d+$/); |
| 359 | // Each site's own `url` is still its dev server (HMR is the point). |
| 360 | expect(deployed.rootUrl).toMatch(/^http:\/\/localhost:\d+/); |
| 361 | expect(deployed.appUrl).toMatch(/^http:\/\/localhost:\d+/); |
| 362 | expect(deployed.appUrl).not.toBe(deployed.rootUrl); |
| 363 | |
| 364 | // ── The HTML shell at each mount, from its own dev server ────────── |
| 365 | const rootHtml = yield* expectRouterBody(routerUrl, "/", { |
| 366 | includes: ["ROUTER_VITE_ROOT_PAGE"], |
| 367 | excludes: ["ROUTER_VITE_APP_PAGE"], |
| 368 | }); |
| 369 | const appHtml = yield* expectRouterBody(routerUrl, "/app/", { |
| 370 | includes: ["ROUTER_VITE_APP_PAGE"], |
| 371 | excludes: ["ROUTER_VITE_ROOT_PAGE"], |
| 372 | }); |
| 373 | |
| 374 | // Vite rewrote the asset URLs by `base`, so the browser's next hops |
| 375 | // are the prefixed ones. Assert that BEFORE fetching them — otherwise |
| 376 | // the fetches below could pass against URLs no real browser requests. |
| 377 | expect(appHtml).toContain('src="/app/src/main.ts"'); |
| 378 | expect(appHtml).toContain("/app/@vite/client"); |
| 379 | expect(rootHtml).toContain('src="/src/main.ts"'); |
| 380 | expect(rootHtml).toContain("/@vite/client"); |
| 381 | |
| 382 | // ── The assets that HTML points at, through the Router ───────────── |
| 383 | // A mount only works if its sub-resources resolve; an index page that |
| 384 | // renders while its modules 404 is a broken site. |
| 385 | yield* expectRouterBody(routerUrl, "/app/src/main.ts", { |
| 386 | includes: ["ROUTER_VITE_APP_MODULE"], |
| 387 | excludes: ["ROUTER_VITE_ROOT_MODULE"], |
| 388 | }); |
| 389 | yield* expectRouterBody(routerUrl, "/app/@vite/client", { |
| 390 | includes: ["export"], |
| 391 | }); |
| 392 | yield* expectRouterBody(routerUrl, "/src/main.ts", { |
| 393 | includes: ["ROUTER_VITE_ROOT_MODULE"], |
| 394 | excludes: ["ROUTER_VITE_APP_MODULE"], |
| 395 | }); |
| 396 | yield* expectRouterBody(routerUrl, "/@vite/client", { |
| 397 | includes: ["export"], |
| 398 | }); |
| 399 | |
| 400 | // ── Longest-prefix routing: neither mount answers the other's ────── |
| 401 | // The root site matches `/` and would happily serve `/app/...` if the |
| 402 | // longer prefix didn't win; the app site must not leak out of `/app`. |
| 403 | yield* expectRouterBody(routerUrl, "/app/src/main.ts", { |
| 404 | excludes: ["ROUTER_VITE_ROOT_MODULE"], |
| 405 | }); |
| 406 | // `/app-other` shares a string prefix with `/app` but is NOT under the |
| 407 | // mount — it belongs to the root site (whose SPA fallback answers it). |
| 408 | yield* expectRouterBody(routerUrl, "/app-other", { |
| 409 | includes: ["ROUTER_VITE_ROOT_PAGE"], |
| 410 | excludes: ["ROUTER_VITE_APP_PAGE"], |
| 411 | }); |
| 412 | |
| 413 | // ── Live edit: the origin is a RUNNING dev server, not a copy ────── |
| 414 | // Nothing is re-applied; the next request through the edge has to |
| 415 | // show the new bytes. `?v=2` also defeats any cache in between. |
| 416 | const editedMain = (yield* fs.readFileString(app.mainPath)).replace( |
| 417 | "ROUTER_VITE_APP_MODULE", |
| 418 | "ROUTER_VITE_APP_MODULE_V2", |
| 419 | ); |
| 420 | yield* fs.writeFileString(app.mainPath, editedMain); |
| 421 | yield* expectRouterBody(routerUrl, "/app/src/main.ts?v=2", { |
| 422 | includes: ["ROUTER_VITE_APP_MODULE_V2"], |
| 423 | }); |
| 424 | |
| 425 | const editedIndex = (yield* fs.readFileString( |
| 426 | root.indexPath, |
| 427 | )).replace("ROUTER_VITE_ROOT_PAGE", "ROUTER_VITE_ROOT_PAGE_V2"); |
| 428 | yield* fs.writeFileString(root.indexPath, editedIndex); |
| 429 | yield* expectRouterBody(routerUrl, "/?v=2", { |
| 430 | includes: ["ROUTER_VITE_ROOT_PAGE_V2"], |
| 431 | }); |
| 432 | |
| 433 | yield* stack.destroy(); |
| 434 | }), |
| 435 | // 300s like the sibling cases: fixture clone + `bun install` + vite dev |
| 436 | // boot through the emulated edge stack up under whole-suite concurrency |
| 437 | // (the test itself runs in ~6s in isolation). |
| 438 | { tags: ["local"], timeout: 300_000 }, |
| 439 | ); |
| 440 | |
| 441 | /** |
| 442 | * The emulated runtime must be at least as restrictive as CloudFront's. |
| 443 | * CloudFront Functions are not Node: there is no `fetch`. Code that reaches |
| 444 | * for one has to fail locally, or `alchemy dev` becomes a way to ship |
| 445 | * broken edge code. |
| 446 | */ |
| 447 | test.provider( |
| 448 | "edge code that reaches outside the CloudFront runtime fails locally", |
| 449 | (stack) => |
| 450 | Effect.gen(function* () { |
| 451 | yield* stack.destroy(); |
| 452 | |
| 453 | const deployed = yield* stack.deploy( |
| 454 | Effect.gen(function* () { |
| 455 | const router = yield* AWS.Website.Router("SandboxRouter", { |
| 456 | edge: { |
| 457 | viewerRequest: { |
| 458 | injection: `await fetch("https://example.com/");`, |
| 459 | }, |
| 460 | }, |
| 461 | }); |
| 462 | return { routerUrl: router.url }; |
| 463 | }), |
| 464 | ); |
| 465 | |
| 466 | const response = yield* fetchRouter( |
| 467 | deployed.routerUrl as string, |
| 468 | "/anything", |
| 469 | ); |
| 470 | expect(response.status).toBe(502); |
| 471 | const body = yield* response.text; |
| 472 | expect(body).toContain("fetch is not defined"); |
| 473 | expect(body).toContain("not Node.js"); |
| 474 | |
| 475 | yield* stack.destroy(); |
| 476 | }), |
| 477 | { tags: ["local"], timeout: 180_000 }, |
| 478 | ); |
| 479 | |
| 480 | /** |
| 481 | * `TestFunction` and the edge are backed by the same runtime, so the request |
| 482 | * object the API reports is the request the origin actually received. That |
| 483 | * is what makes a local result comparable to the same call against real AWS |
| 484 | * (which runs the function in AWS's own engine against the DEVELOPMENT |
| 485 | * stage — seconds, no distribution deploy). |
| 486 | */ |
| 487 | test.provider( |
| 488 | "TestFunction agrees with the edge", |
| 489 | (stack) => |
| 490 | Effect.gen(function* () { |
| 491 | yield* stack.destroy(); |
| 492 | |
| 493 | const cwd = yield* makeSiteFixture("docs", "router-parity-docs"); |
| 494 | |
| 495 | const deployed = yield* stack.deploy( |
| 496 | Effect.gen(function* () { |
| 497 | const router = yield* AWS.Website.Router("ParityRouter", {}); |
| 498 | yield* AWS.Website.StaticSite("Docs", { |
| 499 | path: cwd, |
| 500 | dev: { command: "bun serve.mjs", env: { DEV_MARKER: "docs" } }, |
| 501 | domain: { router, path: "/docs" }, |
| 502 | }); |
| 503 | return { routerUrl: router.url }; |
| 504 | }), |
| 505 | ); |
| 506 | |
| 507 | const routerUrl = deployed.routerUrl as string; |
| 508 | const host = new URL(routerUrl).host; |
| 509 | |
| 510 | // What the edge produced, as observed by the origin itself. |
| 511 | const echo = yield* fetchRouter(routerUrl, "/docs/__echo?q=1"); |
| 512 | const echoed = (yield* echo.json) as { |
| 513 | path: string; |
| 514 | headers: Record<string, string>; |
| 515 | }; |
| 516 | |
| 517 | // The same event, through the TestFunction API. The out-of-band SDK |
| 518 | // calls are pinned to the emulator explicitly — the test process's |
| 519 | // own distilled clients otherwise point at the real account. |
| 520 | const result = yield* Effect.gen(function* () { |
| 521 | const functions = yield* cloudfront.listFunctions({}); |
| 522 | const summary = functions.FunctionList?.Items?.find( |
| 523 | (item) => |
| 524 | item.FunctionConfig.Comment === "ParityRouter viewer request", |
| 525 | ); |
| 526 | expect(summary).toBeDefined(); |
| 527 | const described = yield* cloudfront.describeFunction({ |
| 528 | Name: summary!.Name, |
| 529 | Stage: "DEVELOPMENT", |
| 530 | }); |
| 531 | return yield* cloudfront.testFunction({ |
| 532 | Name: summary!.Name, |
| 533 | IfMatch: described.ETag!, |
| 534 | Stage: "DEVELOPMENT", |
| 535 | EventObject: new TextEncoder().encode( |
| 536 | JSON.stringify({ |
| 537 | version: "1.0", |
| 538 | context: { eventType: "viewer-request" }, |
| 539 | viewer: { ip: "127.0.0.1" }, |
| 540 | request: { |
| 541 | method: "GET", |
| 542 | uri: "/docs/__echo", |
| 543 | querystring: { q: { value: "1" } }, |
| 544 | headers: { host: { value: host } }, |
| 545 | cookies: {}, |
| 546 | }, |
| 547 | }), |
| 548 | ), |
| 549 | }); |
| 550 | }).pipe(Effect.provide(flociServices())); |
| 551 | |
| 552 | expect(result.TestResult?.FunctionErrorMessage).toBeUndefined(); |
| 553 | // `FunctionOutput` is modelled as a sensitive string, so it arrives |
| 554 | // wrapped — unwrap before parsing. |
| 555 | const rawOutput = result.TestResult?.FunctionOutput; |
| 556 | const output = JSON.parse( |
| 557 | rawOutput === undefined |
| 558 | ? "{}" |
| 559 | : typeof rawOutput === "string" |
| 560 | ? rawOutput |
| 561 | : Redacted.value(rawOutput), |
| 562 | ) as { request?: { uri: string; headers: Record<string, any> } }; |
| 563 | expect(output.request?.uri).toBe(echoed.path); |
| 564 | expect(output.request?.headers["x-forwarded-host"]?.value).toBe( |
| 565 | echoed.headers["x-forwarded-host"], |
| 566 | ); |
| 567 | |
| 568 | yield* stack.destroy(); |
| 569 | }), |
| 570 | { tags: ["provider:aws:cloudfront", "local"], timeout: 300_000 }, |
| 571 | ); |
| 572 | |
| 573 | /** |
| 574 | * The oracle for everything above: run the same function through AWS's own |
| 575 | * engine and compare. `TestFunction` executes against the `DEVELOPMENT` |
| 576 | * stage, so this costs seconds and never touches a distribution. |
| 577 | * |
| 578 | * Gated because it needs real credentials — `AWS_TEST_CLOUDFRONT_FUNCTION=1 |
| 579 | * pnpm test test/AWS/Website/Router.local.test.ts --profile testing`. |
| 580 | */ |
| 581 | test.provider.skipIf(process.env.AWS_TEST_CLOUDFRONT_FUNCTION !== "1")( |
| 582 | "the emulated runtime agrees with the real CloudFront runtime", |
| 583 | () => |
| 584 | Effect.gen(function* () { |
| 585 | const name = "alchemy-cf-runtime-parity"; |
| 586 | const event = new TextEncoder().encode( |
| 587 | JSON.stringify({ |
| 588 | version: "1.0", |
| 589 | context: { eventType: "viewer-request" }, |
| 590 | viewer: { ip: "127.0.0.1" }, |
| 591 | request: { |
| 592 | method: "GET", |
| 593 | uri: "/docs", |
| 594 | querystring: { q: { value: "1" } }, |
| 595 | headers: { host: { value: "example.cloudfront.net" } }, |
| 596 | cookies: {}, |
| 597 | }, |
| 598 | }), |
| 599 | ); |
| 600 | |
| 601 | /** Create, test, and delete a function against whichever endpoint is in scope. */ |
| 602 | const runThere = Effect.fn("runThere")(function* ( |
| 603 | suffix: string, |
| 604 | code: string, |
| 605 | ) { |
| 606 | const created = yield* cloudfront.createFunction({ |
| 607 | Name: `${name}-${suffix}`, |
| 608 | FunctionConfig: { |
| 609 | Comment: "parity", |
| 610 | Runtime: "cloudfront-js-2.0", |
| 611 | }, |
| 612 | FunctionCode: new TextEncoder().encode(code), |
| 613 | }); |
| 614 | const result = yield* cloudfront.testFunction({ |
| 615 | Name: `${name}-${suffix}`, |
| 616 | IfMatch: created.ETag!, |
| 617 | Stage: "DEVELOPMENT", |
| 618 | EventObject: event, |
| 619 | }); |
| 620 | yield* cloudfront |
| 621 | .deleteFunction({ |
| 622 | Name: `${name}-${suffix}`, |
| 623 | IfMatch: created.ETag!, |
| 624 | }) |
| 625 | .pipe(Effect.ignore); |
| 626 | const raw = result.TestResult?.FunctionOutput; |
| 627 | return { |
| 628 | error: result.TestResult?.FunctionErrorMessage, |
| 629 | output: |
| 630 | raw === undefined |
| 631 | ? undefined |
| 632 | : typeof raw === "string" |
| 633 | ? raw |
| 634 | : Redacted.value(raw), |
| 635 | }; |
| 636 | }); |
| 637 | |
| 638 | // A well-behaved function: both engines must produce the same request. |
| 639 | const wellBehaved = `async function handler(event) { |
| 640 | event.request.headers["x-forwarded-host"] = event.request.headers.host; |
| 641 | event.request.uri = event.request.uri + "/index.html"; |
| 642 | return event.request; |
| 643 | }`; |
| 644 | const local = yield* runThere("ok", wellBehaved).pipe( |
| 645 | Effect.provide(flociServices()), |
| 646 | ); |
| 647 | const real = yield* runThere("ok", wellBehaved); |
| 648 | expect(local.error).toBeUndefined(); |
| 649 | expect(real.error).toBeUndefined(); |
| 650 | expect(JSON.parse(local.output!)).toEqual(JSON.parse(real.output!)); |
| 651 | |
| 652 | // Reaching outside the runtime: both engines must report an error. |
| 653 | const escaping = `async function handler(event) { |
| 654 | await fetch("https://example.com/"); |
| 655 | return event.request; |
| 656 | }`; |
| 657 | const localError = yield* runThere("escape", escaping).pipe( |
| 658 | Effect.provide(flociServices()), |
| 659 | ); |
| 660 | const realError = yield* runThere("escape", escaping); |
| 661 | expect(localError.error).toBeDefined(); |
| 662 | expect(realError.error).toBeDefined(); |
| 663 | }), |
| 664 | { tags: ["provider:aws:cloudfront", "live"], timeout: 180_000 }, |
| 665 | ); |
| 666 | }, |
| 667 | ); |