이번에 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로 옮기는 변환기 코드입니다.
코드의 흐름은 다음과 같습니다.
- status를 설정한다.
res.status(response.status); - header를 옮긴다.
response.headers.forEach((value, key) => res.setHeader(key, value)); - body가 없으면 그대로 resolve된 Promise를 반환해준다.
if (response.body == null) return Promise.resolve(); - 그 후에 body를 청크 단위로 읽는다.
- done을 통해 더 읽을 청크가 있는지 확인한다.
if (done) { - 더 읽을 청크가 없으면(done이 true이면) reader에 대한 Lock을 해제하고 Promise를 resolve한다.
- 더 읽을 청크가 있다면(done이 false라면) res 에 body로 부터 읽은 청크의 값 value를 Buffer로 바꾸어 작성한다. (res는 Express 응답이다.)
- 그리고 다음 청크를 읽는다.
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를 호출합니다.
- notFound 변수를 true로 만듬.
body?.restore()를 통해 Fedify가 읽은 본문을 원래 스트림에 돌려놓는다.next()를 호출하여 Express가 복원된 원문을 읽어 요청을 처리하게 만든다.- 그 후에 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을 호출합니다.
- notAccpetable 변수를 true로 만든다.
body?.restore()를 호출하여 Fedify가 읽은 본문을 원래 스트림에 되돌려 놓음.next()를 호출하여 Express가 복원된 본문을 읽어 요청을 처리하도록 만듬.- 그 후에 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");
});
대략적인 전체 흐름은 다음과 같습니다.
- Mock Federation 객체를 만든다.
integrateFederation함수에 Mock Federation을 전달하여 middleware를 만든다.createMockRequest()로 Mock Request를 생성하고,createMockResponse()로 Mock Response를 생성한다.- 이를
middleware에 전달한 후,contextDataFactory가 생성한 Promise가 완료되기 전에 fetch를 호출하지 않는지 검증한다. resolveContextData를 호출하여 Promise를 resolve한 뒤에 응답이 종료될때까지 기다린다.- next가 호출되었는지 확인하여 Express 라우터로 넘어가지 않았는지 확인한다.
contextDataFactory가 만든"Hello, World"가federation.fetch에 전달되고, mock Federation이 그 값으로 만든 응답을 Express 응답으로 기록하였는지 확인한다.
위 코드를 참고하여 저는 다음의 흐름으로 테스트 코드를 작성하고자 하였습니다.
- Mock Federation 객체를 만든다.
integrateFederation함수에 Mock Federation을 전달하여 middleware를 만든다.createMockRequest()로 Mock Request를 생성하고,createMockResponse()로 Mock Response를 생성한다.- middleware에 Mock Request와 Mock Response를 전달한다.
- 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 승인

그 후 Maintainer 님이 승인하여 PR이 성공적으로 Merge 되었습니다.
PR #1217 - Test Express response forwarding
이상으로 글을 마칩니다. 읽어주셔서 감사합니다.