authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 22:08:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 22:08:43-07:00
log0ecb78ed0d351b65ef68262f632359223f9c389b
tree0ee30d75ce5493b68ee68b9bb142ad6d4b0d604f
parent25f0b2a1b7323563aa5d4e7f70e78a3889c5918e
signature Signed by PGP key B5690EEEBB952194

fix(relay): error and deadline responses carry CORS headers (#16253)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 127 insertions(+), 39 deletions(-)

infra/relay/src/hooks/HookForwarder.test.ts+8-1
...@@ -19,6 +19,7 @@ import * as HttpClientError from "effect/http/HttpClientError";...@@ -19,6 +19,7 @@ import * as HttpClientError from "effect/http/HttpClientError";
19import type * as HttpClientRequest from "effect/http/HttpClientRequest";19import type * as HttpClientRequest from "effect/http/HttpClientRequest";
20import * as HttpClientResponse from "effect/http/HttpClientResponse";20import * as HttpClientResponse from "effect/http/HttpClientResponse";
21import * as Etag from "effect/http/Etag";21import * as Etag from "effect/http/Etag";
22import * as HttpEffect from "effect/http/HttpEffect";
22import * as HttpRouter from "effect/http/HttpRouter";23import * as HttpRouter from "effect/http/HttpRouter";
23import * as HttpApi from "effect/http-api/HttpApi";24import * as HttpApi from "effect/http-api/HttpApi";
24import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";25import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
...@@ -171,15 +172,21 @@ function makeHarness(options: Harness = {}) {...@@ -171,15 +172,21 @@ function makeHarness(options: Harness = {}) {
171 RelayHttpApi.layerCors,172 RelayHttpApi.layerCors,
172 ),173 ),
173 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));174 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));
175 // Goes through Effect's request handler, which applies pre-response handlers
176 // (such as CORS) to the response it sends, as the Workers runtime does.
174 const send = (request: Request) =>177 const send = (request: Request) =>
175 Effect.gen(function* () {178 Effect.gen(function* () {
176 const handler = yield* httpEffect;179 const handler = yield* httpEffect;
177 return yield* handler.pipe(180 const sent = yield* Deferred.make<HttpServerResponse.HttpServerResponse>();
181 yield* HttpEffect.toHandled(handler, (_request, response) =>
182 Deferred.succeed(sent, response),
183 ).pipe(
178 Effect.provideService(184 Effect.provideService(
179 HttpServerRequest.HttpServerRequest,185 HttpServerRequest.HttpServerRequest,
180 HttpServerRequest.fromWeb(request),186 HttpServerRequest.fromWeb(request),
181 ),187 ),
182 );188 );
189 return yield* Deferred.await(sent);
183 });190 });
184 return { sent, rateLimitKeys, held, send, httpEffect };191 return { sent, rateLimitKeys, held, send, httpEffect };
185}192}
infra/relay/src/http/Api.test.ts+108-8
...@@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -14,8 +14,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
14import { describe, expect, it } from "@effect/vitest";14import { describe, expect, it } from "@effect/vitest";
15import { vi } from "vite-plus/test";15import { vi } from "vite-plus/test";
16import * as Context from "effect/Context";16import * as Context from "effect/Context";
17import * as Data from "effect/Data";
17import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";18import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
18import * as DateTime from "effect/DateTime";19import * as DateTime from "effect/DateTime";
20import * as Deferred from "effect/Deferred";
19import * as Duration from "effect/Duration";21import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
21import * as Fiber from "effect/Fiber";23import * 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";
26import * as TestClock from "effect/testing/TestClock";28import * as TestClock from "effect/testing/TestClock";
27import * as Tracer from "effect/Tracer";29import * as Tracer from "effect/Tracer";
28import * as Etag from "effect/http/Etag";30import * as Etag from "effect/http/Etag";
31import * as HttpEffect from "effect/http/HttpEffect";
29import * as HttpRouter from "effect/http/HttpRouter";32import * as HttpRouter from "effect/http/HttpRouter";
30import * as HttpServerRequest from "effect/http/HttpServerRequest";33import * as HttpServerRequest from "effect/http/HttpServerRequest";
31import * as HttpServerResponse from "effect/http/HttpServerResponse";34import * as HttpServerResponse from "effect/http/HttpServerResponse";
...@@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => {...@@ -1319,7 +1322,6 @@ describe("relay routing fallback", () => {
13191322
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 );
13331333
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", () => {
13391339
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
1356describe("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 );
13511377
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.
1443const 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});
infra/relay/src/http/Api.ts+11-30
...@@ -88,28 +88,6 @@ export const RELAY_HTTP_ROUTER_CONFIG = {...@@ -88,28 +88,6 @@ export const RELAY_HTTP_ROUTER_CONFIG = {
88 maxParamLength: 512,88 maxParamLength: 512,
89} as const;89} as const;
9090
91const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;
92const relayCorsAllowedHeaders = [
93 "authorization",
94 "b3",
95 "traceparent",
96 "content-type",
97 "dpop",
98] as const;
99const relayCorsExposedHeaders = ["traceparent", "www-authenticate"] as const;
100
101const relayCorsHeaders = {
102 "access-control-allow-origin": "*",
103 "access-control-expose-headers": relayCorsExposedHeaders.join(","),
104} as const;
105
106const relayCorsPreflightHeaders = {
107 ...relayCorsHeaders,
108 "access-control-allow-methods": relayCorsAllowedMethods.join(","),
109 "access-control-allow-headers": relayCorsAllowedHeaders.join(","),
110 "access-control-max-age": "86400",
111} as const;
112
113const decodeManagedTunnelRecoveryProof = Schema.decodeUnknownEffect(91const decodeManagedTunnelRecoveryProof = Schema.decodeUnknownEffect(
114 RelayManagedEndpointRecoveryProofPayload,92 RelayManagedEndpointRecoveryProofPayload,
115);93);
...@@ -146,6 +124,16 @@ const appendRelayTraceContextResponseHeader = Effect.gen(function* () {...@@ -146,6 +124,16 @@ const appendRelayTraceContextResponseHeader = Effect.gen(function* () {
146 );124 );
147}).pipe(Effect.ignore);125}).pipe(Effect.ignore);
148126
127const relayCorsMiddleware = HttpMiddleware.cors({
128 allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
129 allowedHeaders: ["authorization", "b3", "traceparent", "content-type", "dpop"],
130 exposedHeaders: ["traceparent", "www-authenticate"],
131 maxAge: 86_400,
132});
133
134// The CORS headers come from a pre-response handler, so they reach every response
135// the request sends: handler failures and defects, and the deadline 504 that
136// `traceRelayHttpRequest` produces outside the router.
149export const layerCors = HttpRouter.middleware(137export const layerCors = HttpRouter.middleware(
150 Effect.fnUntraced(function* <E, R>(138 Effect.fnUntraced(function* <E, R>(
151 httpEffect: Effect.Effect<139 httpEffect: Effect.Effect<
...@@ -159,14 +147,7 @@ export const layerCors = HttpRouter.middleware(...@@ -159,14 +147,7 @@ export const layerCors = HttpRouter.middleware(
159 if (isRelayHookPath(request.url)) {147 if (isRelayHookPath(request.url)) {
160 return yield* httpEffect;148 return yield* httpEffect;
161 }149 }
162 if (request.method === "OPTIONS") {150 return yield* relayCorsMiddleware(httpEffect);
163 return HttpServerResponse.empty({
164 status: 204,
165 headers: relayCorsPreflightHeaders,
166 });
167 }
168 const response = yield* httpEffect;
169 return HttpServerResponse.setHeaders(response, relayCorsHeaders);
170 }),151 }),
171 { global: true },152 { global: true },
172);153);