Fedify Issue: Test response forwarding in @fedify/express 해결 과정

2026-10-04 15:22:16 · 조회 23

이번에 OSSCA 2026에 참가하면서 Fedify 프로젝트의 이슈 Test response forwarding in @fedify/express #856 를 맡아 해결한 과정을 블로그에 기록해보고자 합니다.
부정확한 설명이 있을 수 있습니다. 양해바랍니다.

어떤 이슈인가?

해당 이슈는 Fedify의 표준 Web Reponse를 Express Respons 로 정확하게 옮기는지에 대해서 검증하는 테스트를 추가하는 이슈입니다.

코드 이해하기

packages/express/src/index.ts line 223, 응답 변환기

function setEResponse(res: EResponse, response: Response): Promise<void> {
  res.status(response.status);
  response.headers.forEach((value, key) => res.setHeader(key, value));
  if (response.body == null) return Promise.resolve();
  const body = response.body;
  return new Promise((resolve) => {
    const reader = body.getReader();
    reader.read().then(function read({ done, value }) {
      if (done) {
        reader.releaseLock();
        resolve();
        return;
      }
      res.write(Buffer.from(value));
      reader.read().then(read);
    });
  });
}

해당 코드는 Fedify가 반환한 Web API의 Response를 Express의 Response로 옮기는 변환기 코드입니다.
코드의 흐름은 다음과 같습니다.

  1. status를 설정한다. res.status(response.status);
  2. header를 옮긴다. response.headers.forEach((value, key) => res.setHeader(key, value));
  3. body가 없으면 그대로 resolve된 Promise를 반환해준다. if (response.body == null) return Promise.resolve();
  4. 그 후에 body를 청크 단위로 읽는다.
  5. done을 통해 더 읽을 청크가 있는지 확인한다. if (done) {
  6. 더 읽을 청크가 없으면(done이 true이면) reader에 대한 Lock을 해제하고 Promise를 resolve한다.
  7. 더 읽을 청크가 있다면(done이 false라면) res 에 body로 부터 읽은 청크의 값 value를 Buffer로 바꾸어 작성한다. (res는 Express 응답이다.)
  8. 그리고 다음 청크를 읽는다.

packages/express/src/index.ts line 32, integrateFederation

      const response = await federation.fetch(request, {
        contextData,
        onNotFound: () => {
          // If the `federation` object finds a request not responsible for it
          // (i.e., not a federation-related request), it will call the `next`
          // function provided by the Express framework to continue the request
          // handling by the Express:
          notFound = true;
          body?.restore();
          next();
          return new Response("Not found", { status: 404 }); // unused
        },
        onNotAcceptable: () => {
          // Similar to `onNotFound`, but slightly more tricky.
          // When the `federation` object finds a request not acceptable
          // type-wise (i.e., a user-agent doesn't want JSON-LD), it will call
          // the `next` function provided by the Express framework to continue
          // if any route is matched, and otherwise, it will return a 406 Not
          // Acceptable response:
          notAcceptable = true;
          body?.restore();
          next();
          return new Response("Not acceptable", {
            status: 406,
            headers: {
              "Content-Type": "text/plain",
              Vary: "Accept",
            },
          });
        },
      });
      if (notFound || (notAcceptable && req.route != null)) return;
      await setEResponse(res, response);
      // Prevent the Express framework from sending the response again:
      res.end();

Fedify에 요청 보내기

const response = await federation.fetch(request, {

Express 요청을 Wep API의 Request로 변환한 후에 Fedify에 전달합니다.

onNotFound

onNotFound: () => {
  notFound = true;
  body?.restore();
  next();
  return new Response("Not found", { status: 404 });
},

Express 앱 요청 중 federation이 담당할 요청 경로가 아닌 경우에는 onNotFound를 호출합니다.

  1. notFound 변수를 true로 만듬.
  2. body?.restore() 를 통해 Fedify가 읽은 본문을 원래 스트림에 돌려놓는다.
  3. next() 를 호출하여 Express가 복원된 원문을 읽어 요청을 처리하게 만든다.
  4. 그 후에 federation.fetch는 무조건 Response를 반환해야 하기 때문에 404 Response를 형식상으로 반환한다.

이 때 반환하는 Response는 클라이언트 응답으로 사용되지 않습니다.

onNotAcceptable

        onNotAcceptable: () => {
          notAcceptable = true;
          body?.restore();
          next();
          return new Response("Not acceptable", {
            status: 406,
            headers: {
              "Content-Type": "text/plain",
              Vary: "Accept",
            },
          });
        },

federation이 담당하는 요청 경로는 맞지만, federation이 처리할 수 없는 형식일 때 onNotAcceptable을 호출합니다.

  1. notAccpetable 변수를 true로 만든다.
  2. body?.restore() 를 호출하여 Fedify가 읽은 본문을 원래 스트림에 되돌려 놓음.
  3. next() 를 호출하여 Express가 복원된 본문을 읽어 요청을 처리하도록 만듬.
  4. 그 후에 406 Response를 반환한다.

notFound와 notAcceptable 체크

if (notFound || (notAcceptable && req.route != null)) return;

notFound 변수가 true이면, onNotFound가 호출되었다는 뜻이므로 setEResponse 를 실행하지 않고 바로 return을 통해 종료하게 됩니다.
notAcceptable 변수가 true이고, req.route의 값이 존재한다면 해당되는 Express 경로가 존대한다는 뜻이므로 return을 통해 종료하게 됩니다.
만약 req.route의 값이 null 로 존재하지 않는다면 해당되는 Express 경로가 없기 때문에 406 Response를 반환해야 합니다. 그러므로 그 경우는 조기 종료를 해주지 않습니다.

응답 종료

await setEResponse(res, response);
res.end();

앞서 살펴봤던 setEResponse 함수를 통해 response에 내용을 res에 옮깁니다.
response는 Web Response이고, res 는 Express Response 입니다.
이후 res.end(); 를 통해 Express Response를 종료합니다.

코드 수정하기

목표

packages/express/src/index.test.ts 의 기존 테스트 코드를 참고해봅시다.

  test("waits for an async contextDataFactory and passes the resolved value to federation.fetch()", async () => {
    let resolveContextData!: (value: string) => void;
    let fetchCalled = false;

    const mockFederation: MockFederation = {
      fetch(_request, options) {
        fetchCalled = true;
        const { contextData } = options as { contextData: unknown };
        return Promise.resolve(new Response(String(contextData)));
      },
    };

    const contextDataFactory = () =>
      new Promise<string>((resolve) => {
        resolveContextData = resolve;
      });

    const middleware = integrateFederation(
      mockFederation as never,
      contextDataFactory,
    );

    const req = createMockRequest();
    const { response, ended, getBody } = createMockResponse();
    let nextCalled = false;

    middleware(req, response, () => {
      nextCalled = true;
    });

    await Promise.resolve();
    assert.strictEqual(fetchCalled, false);

    resolveContextData("Hello World");
    await ended;

    assert.strictEqual(nextCalled, false);
    assert.strictEqual(getBody(), "Hello World");
  });

대략적인 전체 흐름은 다음과 같습니다.

  1. Mock Federation 객체를 만든다.
  2. integrateFederation 함수에 Mock Federation을 전달하여 middleware를 만든다.
  3. createMockRequest() 로 Mock Request를 생성하고, createMockResponse() 로 Mock Response를 생성한다.
  4. 이를 middleware 에 전달한 후, contextDataFactory가 생성한 Promise가 완료되기 전에 fetch를 호출하지 않는지 검증한다.
  5. resolveContextData 를 호출하여 Promise를 resolve한 뒤에 응답이 종료될때까지 기다린다.
  6. next가 호출되었는지 확인하여 Express 라우터로 넘어가지 않았는지 확인한다.
  7. contextDataFactory 가 만든 "Hello, World" 가 federation.fetch 에 전달되고, mock Federation이 그 값으로 만든 응답을 Express 응답으로 기록하였는지 확인한다.

위 코드를 참고하여 저는 다음의 흐름으로 테스트 코드를 작성하고자 하였습니다.

  1. Mock Federation 객체를 만든다.
  2. integrateFederation 함수에 Mock Federation을 전달하여 middleware를 만든다.
  3. createMockRequest() 로 Mock Request를 생성하고, createMockResponse() 로 Mock Response를 생성한다.
  4. middleware에 Mock Request와 Mock Response를 전달한다.
  5. Mock Federation을 만들 때 우리가 설정한 값이 잘 전달됐는지 검증한다.

Test 함수

test("forwards the Fedify response status, headers, and streamed body to the Express response", async () => {
  ...
});

다중 chunk로 구성된 body 생성

    const encoder = new TextEncoder();
    const body = new ReadableStream<Uint8Array>({
      start(controller) {
        controller.enqueue(encoder.encode("Hello "));
        controller.enqueue(encoder.encode("World"));
        controller.close();
      },
    });

현재 수정 중인 index.test.ts 의 delayBody 함수 부분을 참고해서 다중 chunk로 구성된 body를 생성하였습니다.
본 테스트에서는 다중 chunk로 구성하여 검증하는 것이 더 적합하다고 생각했습니다.
예를 들어, 다음 코드와 같이 단일 chunk만 제대로 옮기고, 다중 chunk는 제대로 옮기지 못 하는 그런 경우가 생길 수도 있으니까요.

function setEResponse(res: EResponse, response: Response): Promise<void> {
  res.status(response.status);
  response.headers.forEach((value, key) => res.setHeader(key, value));
  if (response.body == null) return Promise.resolve();
  const body = response.body;
  return new Promise((resolve) => {
    const reader = body.getReader();
    reader.read().then(function read({ done, value }) {
      if (done) {
        reader.releaseLock();
        resolve();
        return;
      }
      res.write(Buffer.from(value));
      reader.releaseLock();
      resolve();
      return;
    });
  });
}

Mock Federation 생성

    const mockFederation: MockFederation = {
      fetch() {
        return Promise.resolve(
          new Response(body, {
            status: 201,
            headers: {
              "Header-Test": "yes",
            },
          }),
        );
      },
    };

생성한 body와 테스트용 status code와 header가 담긴 Response를 반환해주는 Mock Federation을 생성합니다.

setHeader 함수 수정

그런데 Mock Federation 생성 코드에는 한가지 문제점이 있습니다.
우리가 Mock Federation에 headers 를 작성해서 전달해도 실제 Response에는 header가 설정되지 않습니다.

createMockResponse 의 setHeader 부분을 봅시다.

function createMockResponse():{
  ...
} {
  ...
  const response = {
    ...
    setHeader() {
      return response;
    },
    ...
  };
  ...
}

setHeader는 현재 아무것도 저장하지 않습니다.
그래서 createMockResponse 를 수정해서 header도 기록하고, 이를 반환할 수 있도록 해야 합니다.

function createMockResponse(): {
  response: EResponse;
  ended: Promise<void>;
  getBody(): string;
  getHeader(
    name: string,
  ): string | number | readonly string[] | undefined;
} {
  let body = "";
  const headers = new Map<
    string,
    string | number | readonly string[]
  >();
  ...
  const response = {
    ...
    setHeader(
      name: string,
      value: string | number | readonly string[],
    ) {
      headers.set(name.toLowerCase(), value);
      return response;
    },
    ...
  };
  return {
    response: response as unknown as EResponse,
    ended,
    getBody: () => body,
    getHeader: (name) => headers.get(name.toLowerCase()),
  };
}

middleware 생성 후 Mock Request, Mock Response 전달

    const middleware = integrateFederation(
      mockFederation as never,
      () => undefined,
    );
    const req = createMockRequest();
    const { response, ended, getBody, getHeader } = createMockResponse();

    middleware(req, response, () => {});

검증

    await ended;
    assert.strictEqual(response.statusCode, 201);
    assert.strictEqual(getHeader("Header-Test"), "yes");
    assert.strictEqual(getBody(), "Hello World");

이렇게 작업을 마무리 한 후, 프로젝트 contribute 가이드라인에 따라서 테스트를 진행한 후에 문제가 없음을 확인한 후, PR을 보냈습니다!

Maintainer 승인

Fedify Issue #856 approved

그 후 Maintainer 님이 승인하여 PR이 성공적으로 Merge 되었습니다.

PR #1217 - Test Express response forwarding

이상으로 글을 마칩니다. 읽어주셔서 감사합니다.