| ... | ... | @@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 14 | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | 15 | import { vi } from "vite-plus/test"; |
| 16 | 16 | import * as Context from "effect/Context"; |
| 17 | import * as Data from "effect/Data"; |
| 17 | 18 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; |
| 18 | 19 | import * as DateTime from "effect/DateTime"; |
| 20 | import * as Deferred from "effect/Deferred"; |
| 19 | 21 | import * as Duration from "effect/Duration"; |
| 20 | 22 | import * as Effect from "effect/Effect"; |
| 21 | 23 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -26,6 +28,7 @@ import * as Redacted from "effect/Redacted"; |
| 26 | 28 | import * as TestClock from "effect/testing/TestClock"; |
| 27 | 29 | import * as Tracer from "effect/Tracer"; |
| 28 | 30 | import * as Etag from "effect/http/Etag"; |
| 31 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 29 | 32 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 30 | 33 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 31 | 34 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| ... | ... | @@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => { |
| 1319 | 1322 | |
| 1320 | 1323 | it.effect("redirects the relay root to the API docs", () => |
| 1321 | 1324 | Effect.gen(function* () { |
| 1322 | | const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); |
| 1323 | 1325 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1324 | 1326 | Layer.mergeAll( |
| 1325 | 1327 | RelayHttpApi.layerDocsRedirectRoute, |
| ... | ... | @@ -1327,9 +1329,7 @@ describe("relay routing fallback", () => { |
| 1327 | 1329 | RelayHttpApi.layerCors, |
| 1328 | 1330 | ), |
| 1329 | 1331 | ); |
| 1330 | | const response = yield* httpEffect.pipe( |
| 1331 | | Effect.provideService(HttpServerRequest.HttpServerRequest, request), |
| 1332 | | ); |
| 1332 | const response = yield* sendRelayRequest(httpEffect, new Request("https://relay.test/")); |
| 1333 | 1333 | |
| 1334 | 1334 | expect(response.status).toBe(302); |
| 1335 | 1335 | expect(response.headers.location).toBe("/docs"); |
| ... | ... | @@ -1339,18 +1339,118 @@ describe("relay routing fallback", () => { |
| 1339 | 1339 | |
| 1340 | 1340 | it.effect("returns a CORS-compatible 404 response for unmatched paths", () => |
| 1341 | 1341 | Effect.gen(function* () { |
| 1342 | | const request = HttpServerRequest.fromWeb( |
| 1342 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1343 | Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), |
| 1344 | ); |
| 1345 | const response = yield* sendRelayRequest( |
| 1346 | httpEffect, |
| 1343 | 1347 | new Request("https://relay.test/v1/environmentsd", { method: "GET" }), |
| 1344 | 1348 | ); |
| 1349 | |
| 1350 | expect(response.status).toBe(404); |
| 1351 | expect(response.headers["access-control-allow-origin"]).toBe("*"); |
| 1352 | }).pipe(Effect.scoped), |
| 1353 | ); |
| 1354 | }); |
| 1355 | |
| 1356 | describe("relay CORS", () => { |
| 1357 | const origin = "https://app.t3.codes"; |
| 1358 | |
| 1359 | class HandlerFailed extends Data.TaggedError("HandlerFailed") {} |
| 1360 | |
| 1361 | it.effect("answers preflight requests without reaching a route", () => |
| 1362 | Effect.gen(function* () { |
| 1345 | 1363 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1346 | 1364 | Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), |
| 1347 | 1365 | ); |
| 1348 | | const response = yield* httpEffect.pipe( |
| 1349 | | Effect.provideService(HttpServerRequest.HttpServerRequest, request), |
| 1366 | const response = yield* sendRelayRequest( |
| 1367 | httpEffect, |
| 1368 | new Request("https://relay.test/v1/client/environments", { |
| 1369 | method: "OPTIONS", |
| 1370 | headers: { |
| 1371 | origin, |
| 1372 | "access-control-request-method": "POST", |
| 1373 | "access-control-request-headers": "authorization,dpop,content-type", |
| 1374 | }, |
| 1375 | }), |
| 1350 | 1376 | ); |
| 1351 | 1377 | |
| 1352 | | expect(response.status).toBe(404); |
| 1378 | expect(response.status).toBe(204); |
| 1379 | expect( |
| 1380 | response.headers["access-control-allow-methods"]?.split(",").map((method) => method.trim()), |
| 1381 | ).toEqual(["GET", "POST", "DELETE", "OPTIONS"]); |
| 1382 | expect(response.headers).toMatchObject({ |
| 1383 | "access-control-allow-origin": "*", |
| 1384 | "access-control-allow-headers": "authorization,b3,traceparent,content-type,dpop", |
| 1385 | "access-control-expose-headers": "traceparent,www-authenticate", |
| 1386 | "access-control-max-age": "86400", |
| 1387 | }); |
| 1388 | }).pipe(Effect.scoped), |
| 1389 | ); |
| 1390 | |
| 1391 | it.effect("adds CORS headers to handler failures and defects", () => |
| 1392 | Effect.gen(function* () { |
| 1393 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1394 | Layer.mergeAll( |
| 1395 | HttpRouter.add("GET", "/v1/fail", Effect.fail(new HandlerFailed())), |
| 1396 | HttpRouter.add("GET", "/v1/die", Effect.die(new Error("handler defect"))), |
| 1397 | RelayHttpApi.layerNotFoundRoute, |
| 1398 | RelayHttpApi.layerCors, |
| 1399 | ), |
| 1400 | ); |
| 1401 | for (const path of ["/v1/fail", "/v1/die"]) { |
| 1402 | const response = yield* sendRelayRequest( |
| 1403 | httpEffect, |
| 1404 | new Request(`https://relay.test${path}`, { headers: { origin } }), |
| 1405 | ); |
| 1406 | |
| 1407 | expect(response.status).toBe(500); |
| 1408 | expect(response.headers["access-control-allow-origin"]).toBe("*"); |
| 1409 | expect(response.headers["access-control-expose-headers"]).toBe( |
| 1410 | "traceparent,www-authenticate", |
| 1411 | ); |
| 1412 | } |
| 1413 | }).pipe(Effect.scoped), |
| 1414 | ); |
| 1415 | |
| 1416 | it.effect("adds CORS headers to the request deadline response", () => |
| 1417 | Effect.gen(function* () { |
| 1418 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1419 | Layer.mergeAll( |
| 1420 | HttpRouter.add("GET", "/v1/hang", Effect.never), |
| 1421 | RelayHttpApi.layerNotFoundRoute, |
| 1422 | RelayHttpApi.layerCors, |
| 1423 | ), |
| 1424 | ); |
| 1425 | const fiber = yield* sendRelayRequest( |
| 1426 | traceRelayHttpRequestWith(httpEffect, Layer.empty), |
| 1427 | new Request("https://relay.test/v1/hang", { headers: { origin } }), |
| 1428 | ).pipe(Effect.forkChild); |
| 1429 | yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS)); |
| 1430 | const response = yield* Fiber.join(fiber); |
| 1431 | |
| 1432 | expect(response.status).toBe(504); |
| 1353 | 1433 | expect(response.headers["access-control-allow-origin"]).toBe("*"); |
| 1434 | expect(response.headers["access-control-expose-headers"]).toBe( |
| 1435 | "traceparent,www-authenticate", |
| 1436 | ); |
| 1354 | 1437 | }).pipe(Effect.scoped), |
| 1355 | 1438 | ); |
| 1356 | 1439 | }); |
| 1440 | |
| 1441 | // Sends a request through Effect's request handler, which applies pre-response |
| 1442 | // handlers to the response it sends, as the Workers runtime does. |
| 1443 | const sendRelayRequest = Effect.fnUntraced(function* <E, R>( |
| 1444 | httpEffect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, |
| 1445 | request: Request, |
| 1446 | ) { |
| 1447 | const sent = yield* Deferred.make<HttpServerResponse.HttpServerResponse>(); |
| 1448 | yield* HttpEffect.toHandled(httpEffect, (_request, response) => |
| 1449 | Deferred.succeed(sent, response), |
| 1450 | ).pipe( |
| 1451 | Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), |
| 1452 | // A handler failure still fails this effect after its 500 has been sent. |
| 1453 | Effect.exit, |
| 1454 | ); |
| 1455 | return yield* Deferred.await(sent); |
| 1456 | }); |