| ... | @@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 14 | import { describe, expect, it } from "@effect/vitest"; | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | import { vi } from "vite-plus/test"; | 15 | import { vi } from "vite-plus/test"; |
| 16 | import * as Context from "effect/Context"; | 16 | import * as Context from "effect/Context"; |
| | 17 | import * as Data from "effect/Data"; |
| 17 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | 18 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; |
| 18 | import * as DateTime from "effect/DateTime"; | 19 | import * as DateTime from "effect/DateTime"; |
| | 20 | import * as Deferred from "effect/Deferred"; |
| 19 | import * as Duration from "effect/Duration"; | 21 | import * as Duration from "effect/Duration"; |
| 20 | import * as Effect from "effect/Effect"; | 22 | import * as Effect from "effect/Effect"; |
| 21 | import * as Fiber from "effect/Fiber"; | 23 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -26,6 +28,7 @@ import * as Redacted from "effect/Redacted"; | ... | @@ -26,6 +28,7 @@ import * as Redacted from "effect/Redacted"; |
| 26 | import * as TestClock from "effect/testing/TestClock"; | 28 | import * as TestClock from "effect/testing/TestClock"; |
| 27 | import * as Tracer from "effect/Tracer"; | 29 | import * as Tracer from "effect/Tracer"; |
| 28 | import * as Etag from "effect/http/Etag"; | 30 | import * as Etag from "effect/http/Etag"; |
| | 31 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 29 | import * as HttpRouter from "effect/http/HttpRouter"; | 32 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 30 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; | 33 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 31 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; | 34 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| ... | @@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => { | ... | @@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => { |
| 1319 | | 1322 | |
| 1320 | it.effect("redirects the relay root to the API docs", () => | 1323 | it.effect("redirects the relay root to the API docs", () => |
| 1321 | Effect.gen(function* () { | 1324 | Effect.gen(function* () { |
| 1322 | const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); | | |
| 1323 | const httpEffect = yield* HttpRouter.toHttpEffect( | 1325 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1324 | Layer.mergeAll( | 1326 | Layer.mergeAll( |
| 1325 | RelayHttpApi.layerDocsRedirectRoute, | 1327 | RelayHttpApi.layerDocsRedirectRoute, |
| ... | @@ -1327,9 +1329,7 @@ describe("relay routing fallback", () => { | ... | @@ -1327,9 +1329,7 @@ describe("relay routing fallback", () => { |
| 1327 | RelayHttpApi.layerCors, | 1329 | RelayHttpApi.layerCors, |
| 1328 | ), | 1330 | ), |
| 1329 | ); | 1331 | ); |
| 1330 | const response = yield* httpEffect.pipe( | 1332 | const response = yield* sendRelayRequest(httpEffect, new Request("https://relay.test/")); |
| 1331 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | | |
| 1332 | ); | | |
| 1333 | | 1333 | |
| 1334 | expect(response.status).toBe(302); | 1334 | expect(response.status).toBe(302); |
| 1335 | expect(response.headers.location).toBe("/docs"); | 1335 | expect(response.headers.location).toBe("/docs"); |
| ... | @@ -1339,18 +1339,118 @@ describe("relay routing fallback", () => { | ... | @@ -1339,18 +1339,118 @@ describe("relay routing fallback", () => { |
| 1339 | | 1339 | |
| 1340 | it.effect("returns a CORS-compatible 404 response for unmatched paths", () => | 1340 | it.effect("returns a CORS-compatible 404 response for unmatched paths", () => |
| 1341 | Effect.gen(function* () { | 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 | new Request("https://relay.test/v1/environmentsd", { method: "GET" }), | 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 | const httpEffect = yield* HttpRouter.toHttpEffect( | 1363 | const httpEffect = yield* HttpRouter.toHttpEffect( |
| 1346 | Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), | 1364 | Layer.merge(RelayHttpApi.layerNotFoundRoute, RelayHttpApi.layerCors), |
| 1347 | ); | 1365 | ); |
| 1348 | const response = yield* httpEffect.pipe( | 1366 | const response = yield* sendRelayRequest( |
| 1349 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | 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 | expect(response.headers["access-control-allow-origin"]).toBe("*"); | 1433 | expect(response.headers["access-control-allow-origin"]).toBe("*"); |
| | 1434 | expect(response.headers["access-control-expose-headers"]).toBe( |
| | 1435 | "traceparent,www-authenticate", |
| | 1436 | ); |
| 1354 | }).pipe(Effect.scoped), | 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 | }); |