--- description: >- 이 튜토리얼은 Fedify 프레임워크를 사용하여 소규모 연합 서버를 구축하는 과정을 단계적으로 안내합니다. 이건 Fedify 프레임워크를 사용하여 소규모 연합서버를 구축하고자 하는 개발자를 위한 문서입니다. --- Fedify 기본 개념 배우기 ============================= 이 튜토리얼에서는 Fedify 프레임워크의 기본 개념을 이해하기 위해 팔로우 요청만 처리할 수 있는 소규모 연합 서버를 구축합니다. 이 서버는 간단하지만 ActivityPub 프로토콜과 Fedify 프레임워크의 주요 기능(액터, 활동 송수신, 인박스 등)을 다룹니다. 이 튜토리얼에서는 [`fedify init`](https://fedify.dev/cli#fedify-init-initializing-a-fedify-project) 명령어로 생성된 빠른 시작 템플릿을 사용하지 않습니다. 대신 보일러플레이트 코드를 제외하고 처음부터 직접 프로젝트를 구축하여 Fedify 프레임워크의 작동 방식을 깊이 이해할 수 있도록 합니다. 사전 지식 이 튜토리얼을 시작하기 위해서는 다음과 같은 기본 지식이 필요합니다: - JavaScript에 대한 기초 이해 - 커맨드라인 인터페이스 사용 경험 - 간단한 웹 서버 앱 개발 경험 하지만, ActivityPub 프로토콜이나 TypeScript에 익숙하지 않아도 걱정하지 않아도 됩니다. 필요한 내용은 진행하면서 설명합니다. 우리가 생성할 프로젝트 ------------------ 우리는 다른 서버에서 들어오는 팔로우 요청을 수락할 수 있는 소규모 연합 서버를 구축합니다. 이 서버는 단일 액터(즉, 계정)를 가지며, 팔로우 요청을 수신하는 인박스(inbox)을 포함합니다. 서버가 팔로우 요청을 받으면 요청을 수락하는 활동(accept activity)을 송신자에게 반환합니다. 서버의 홈 페이지에는 액터의 팔로워 목록이 표시됩니다. 새 프로젝트 생성하기 ---------------------- > [!TIP] > 그러나 [Deno] or [Bun] 과 같은 TypeScript 기반 환경을 사용하는 것을 추천합니다. > 그러나 [Node.js] 를 선호하면, 사용할 수 있습니다. 새 프로젝트 디렉토리를 생성하고 프로젝트를 초기화하세요: ::: code-group ~~~~ sh [Deno] mkdir follow-server cd follow-server/ echo '{ "unstable": ["kv", "temporal"] }' > deno.json deno add jsr:@fedify/fedify ~~~~ ~~~~ sh [Bun] mkdir follow-server cd follow-server/ echo '{ "type": "module" }' > package.json bun add @fedify/fedify @deno/kv ~~~~ ~~~~ sh [Node.js] mkdir follow-server cd follow-server/ echo '{ "type": "module" }' > package.json npm add -D typescript tsx @types/node npm add @fedify/fedify @deno/kv @hono/node-server ~~~~ ::: 위 명령어를 실행하면, 선택한 환경에 따라 *deno.json* (Deno 사용 시) 또는 package.json 파일 (Node.js 또는 Bun 사용시)이 프로젝트 디렉토리에 생성됩니다. 아래는 가독성을 위해 포맷된 예시입니다.: ::: code-group ~~~~ json [Deno] { "unstable": ["kv", "temporal"], "imports": { "@fedify/fedify": "jsr:@fedify/fedify@^1.1.0" } } ~~~~ ~~~ json [Bun] { "type": "module", "dependencies": { "@deno/kv": "^0.8.1", "@fedify/fedify": "^1.1.0" } } ~~~ ~~~ json [Node.js] { "type": "module", "devDependencies": { "@types/node": "^20.12.7", "tsx": "^4.8.2", "typescript": "^5.4.5" }, "dependencies": { "@deno/kv": "^0.8.1", "@fedify/fedify": "^1.1.0", "@hono/node-server": "^1.11.1" } } ~~~ ::: > [!NOTE] > deno.json 파일의 [`"unstable"`] 필드는 Fedify가 사용하는 > [`Temporal`] API를 활성화하기 위해 필요합니다. > 이는 Deno의 불안정 기능 중 하나이며, 2024년 7월 기준으로 활성화가 필요합니다. > `"temporal"` 을 `"unstable"` 필드에 추가하면 Fedifiy 를 문제없이 사용할 수 있습니다. > [!NOTE] > Bun 과 Node.js에서는 you need to add *package.json* 파일에 [`"type": "module"`] 을 추가해야 합니다. > Fedify는 ESM 전용 패키지이기 때문입니다. > [!TIP] > Node.js에서 *[tsx]*와 *@types/node*를 추가해야 하는 이유는 무엇일까요? > Fedify는 TypeScript로 작성되었으며, Node.js는 기본적으로 TypeScript를 지원하지 않기 때문입니다. > *tsx*와 *@types/node*를 추가하면 Node.js 환경에서도 TypeScript를 번거로움 없이 사용할 수 있습니다. [^2]: 실제 버전은 지금 튜토리얼을 읽고 있는 시점의 가장 최신 Fedify 프레임워크의 버전에 따라서 달라질 수 있습니다. [Deno]: https://deno.com/ [Bun]: https://bun.sh/ [Node.js]: https://nodejs.org/ [`"unstable"`]: https://docs.deno.com/runtime/manual/tools/unstable_flags/#configuring-flags-in-deno.json [`Temporal`]: https://tc39.es/proposal-temporal/docs/ [`"type": "module"`]: https://nodejs.org/api/packages.html#type [tsx]: https://tsx.is/ 서버 생성하기 ------------------- 이제, 서버 스크립트를 생성해봅시다. *server.ts* 라는 이름의 서버를 프로젝트 디렉토리에 생성하고 아래의 코드를 작성해주세요: ::: code-group ~~~~ typescript twoslash [Deno] Deno.serve(request => new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }) ); ~~~~ ~~~~ typescript twoslash [Bun] import "@types/bun"; // ---cut-before--- Bun.serve({ port: 8000, fetch(request) { return new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }); } }); ~~~~ ~~~~ typescript twoslash [Node.js] import { serve } from "@hono/node-server"; serve({ port: 8000, fetch(request) { return new Response("Hello, world", { headers: { "Content-Type": "text/plain" } }); } }); ~~~~ ::: 이것은 모든 요청에 대해 <q>Hello, world</q>라는 응답을 반환하는 간단한 HTTP 서버입니다. 다음 명령을 실행하여 서버를 실행할 수 있습니다: ::: code-group ~~~~ sh [Deno] deno run -A server.ts ~~~~ ~~~~ sh [Bun] bun server.ts ~~~~ ~~~~ sh [Node.js] node --import tsx server.ts ~~~~ :::: 이제 웹 브라우저를 열고 <http://localhost:8000/> 이동하세요. <q>Hello, world</q> 메시지를 볼 수 있을 것입니다. 짐작할 수 있듯이, [`Deno.serve()`] (Deno의 경우), [`Bun.serve()`] (Bun의 경우), 그리고 [`serve()`] (Node.js의 경우)는 HTTP 서버를 생성하는 함수입니다. 이 함수들은 [`Request`] 객체를 받고 [`Response`] 객체를 반환하는 콜백 함수를 인자로 받습니다. 반환된 `Response` 객체는 클라이언트로 전송됩니다. 이 서버는 아직 연합(federated) 서버는 아니지만, 연합 서버를 구축하기 위한 좋은 시작점이 될 수 있습니다. [`Deno.serve()`]: https://docs.deno.com/api/deno/~/Deno.serve [`Bun.serve()`]: https://bun.sh/docs/api/http#bun-serve [`serve()`]: https://github.com/honojs/node-server?tab=readme-ov-file#usage [`Request`]: https://developer.mozilla.org/en-US/docs/Web/API/Request [`Response`]: https://developer.mozilla.org/en-US/docs/Web/API/Response `Federation` 객체 ------------------- 서버를 연합(federated) 서버로 만들기 위해 Fedify 프레임워크에서 제공하는 `Federation` 객체를 사용해야 합니다. `Federation` 객체는 ActivityPub 활동과 액터를 처리하는 주요 객체입니다. 서버 스크립트를 수정하여 `Federation` 객체를 사용하는 방법은 다음과 같습니다: ~~~~ typescript twoslash import { createFederation, MemoryKvStore } from "@fedify/fedify"; const federation = createFederation<void>({ kv: new MemoryKvStore(), }); ~~~~ 위 코드에서, Fedify 프레임워크의 `createFederation()` 함수를 가져와 새 `Federation` 객체를 생성합니다. `createFederation()` 함수에 객체를 전달하며, 이는 구성(configuration) 객체입니다. `kv` 속성은 `Federation` 객체의 내부 데이터를 저장하는 데 사용되는 키-값 저장소(key-value store)입니다. 여기서는 `MemoryKvStore`를 사용하여 키-값 저장소를 엽니다. > [!IMPORTANT] > `MemoryKvStore`테스트 및 개발 목적으로 사용되므로, 프로덕션 환경에서는 `DenoKvStore` (Deno의 경우) 나 > [`RedisKvStore`] ([@fedify/redis] 패키지) 나 [`PostgresKvStore`] > ([@fedify/postgres] 패키지)를 사용하는 것이 좋습니다. > > 자세한 내용은 [*Key–value store* section](https://fedify.dev/manual/kv)을 참조하세요. 그럼, 우리는 들어오는`Request` 을 `Federation.fetch()` 메서드에 전달합니다: ::: code-group ~~~~ typescript{2} twoslash [Deno] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- Deno.serve( request => federation.fetch(request, { contextData: undefined }) ); ~~~~ ~~~~ typescript{4} twoslash [Bun] import "@types/bun"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- Bun.serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ~~~~ ~~~~ typescript{6} twoslash [Node.js] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { serve } from "@hono/node-server"; serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ~~~~ ::: `Federation.fetch()` 메서드는 들어오는 `Request`와 몇몇 옵션을 받습니다. 여기서는 `contextData`로 `undefined`를 전달합니다. 왜냐하면 여기에 어떤 컨텍스트 데이터도 공유하지 않아도 되기 때문입니다. > [!TIP] > `Federation` 객체는 `TContextData`라는 이름의 타입 매개변수를 가지는 제네릭 클래스입니다. `TContextData` 타입은 액터 디스패처, 인박스 리스너, 및 다른 콜백 함수들 사이에서 공유되는 컨텍스트 데이터의 타입입니다. 필요한 경우가 아니라면 `TContextData` 타입이 `void`가 될 수 있지만, 컨텍스트 데이터를 공유해야 하는 경우라면 어떤 타입이든 될 수 있습니다. > > 더 많은 정보는 [*`TContextData`* 섹션](https://fedify.dev/manual/federation#tcontextdata)을 참조하세요. 이제 `Federation` 객체는 들어오는 요청을 처리할 준비가 되었습니다. 다음 단계로 넘어가 봅시다. > [!TIP] > 필수는 아니지만, 서버에서 무슨 일이 일어나는지 확인하기 위해 로거를 설정하는 것을 강력하게 추천합니다. 로거를 설정하려면 먼저 [LogTape]를 설치해야 합니다: > > ::: code-group > > ~~~~ sh [Deno] > deno add jsr:@logtape/logtape > ~~~~ > > ~~~~ sh [Bun] > bun add @logtape/logtape > ~~~~ > > ~~~~ sh [Node.js] > npm add @logtape/logtape > ~~~~ > > ::: > > 다음으로, *server.ts* 파일의 맨 위에서 [`configure()`] 함수를 호출하여 로거를 설정할 수 있습니다: > > ~~~~ typescript twoslash > import { configure, getConsoleSink } from "@logtape/logtape"; > > await configure({ > sinks: { console: getConsoleSink() }, > filters: {}, > loggers: [ > { category: "fedify", sinks: ["console"], lowestLevel: "info" }, > ], > }); > ~~~~ [@fedify/redis]: https://github.com/dahlia/fedify-redis [`RedisKvStore`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisKvStore [@fedify/postgres]: https://github.com/dahlia/fedify-postgres [`PostgresKvStore`]: https://jsr.io/@fedify/postgres/doc/kv/~/PostgresKvStore [LogTape]: https://logtape.org/ [`configure()`]: https://jsr.io/@logtape/logtape/doc/~/configure 액터 디스패처(Actor dispatcher) ---------------- `Federation` 객체는 다른 서버에서 들어오는 활동을 처리하기 위해 액터 디스패처가 필요합니다. 액터 디스패처(Actor dispatcher)는 서버에 있는 액터에게 주소가 지정된 들어오는 활동이 있을 때 호출되는 함수입니다. 이전에도 언급했듯이, 서버에는 단 하나의 액터(즉, 계정)만이 있을 것입니다. 우리는 그 식별자를 *me*로 명명하겠습니다 (원하는 식별자를 선택할 수 있습니다). 우리 서버에 대한 액터 디스패처를 만들어보겠습니다: ::: code-group ~~~~ typescript{7-16} twoslash [Deno] import { createFederation, MemoryKvStore, Person } from "@fedify/fedify"; const federation = createFederation<void>({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); Deno.serve( request => federation.fetch(request, { contextData: undefined }) ); ~~~~ ~~~~ typescript{7-16} twoslash [Bun] import "@types/bun"; // ---cut-before--- import { createFederation, MemoryKvStore, Person } from "@fedify/fedify"; const federation = createFederation<void>({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); Bun.serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ~~~~ ~~~~ typescript{8-17} twoslash [Node.js] import { createFederation, MemoryKvStore, Person } from "@fedify/fedify"; import { serve } from "@hono/node-server"; const federation = createFederation<void>({ kv: new MemoryKvStore(), }); federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; // Other than "me" is not found. return new Person({ id: ctx.getActorUri(identifier), name: "Me", // Display name summary: "This is me!", // Bio preferredUsername: identifier, // Bare handle url: new URL("/", ctx.url), }); }); serve({ port: 8000, fetch(request) { return federation.fetch(request, { contextData: undefined }); } }); ~~~~ ::: 좋습니다, 서버에 액터가 있습니다. 이제 WebFinger를 통해 액터를 쿼리하여 작동 여부를 확인해봅시다. 다음 명령을 실행하여 서버를 실행하세요: ::: code-group ~~~~ sh [Deno] deno run -A server.ts ~~~~ ~~~~ sh [Bun] bun run server.ts ~~~~ ~~~~ sh [Node.js] node --import tsx server.ts ~~~~ ::: 이제, 새로운 터미널 세션을 열어서, 아래의 커맨드로 액터를 쿼리해봅시다.:[^3] ~~~~ sh curl http://localhost:8000/.well-known/webfinger?resource=acct:me@localhost:8000 ~~~~ 응답은 이렇게 와야 합니다. (가독성을 위한 포맷팅): ~~~~ json { "subject": "acct:me@localhost:8000", "aliases": [ "http://localhost:8000/users/me" ], "links": [ { "rel": "self", "href": "http://localhost:8000/users/me", "type": "application/activity+json" }, { "rel": "http://webfinger.net/rel/profile-page", "href": "http://localhost:8000/" } ] } ~~~~ 위 응답은 서버에 액터 *me*가 발견되었으며, 그 정식 URI는 `http://localhost:8000/users/me`입니다. 이제 액터의 정식 URI를 쿼리해 보겠습니다. (참고로, 요청에는 `Accept: application/activity+json` 헤더가 포함되어 있습니다):[^3] ~~~~ sh curl -H"Accept: application/activity+json" http://localhost:8000/users/me ~~~~ 응답은 이렇게 와야 합니다. (가독성을 위한 포맷팅): ~~~~ json { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "toot": "http://joinmastodon.org/ns#", "discoverable": "toot:discoverable", "suspended": "toot:suspended", "memorial": "toot:memorial", "indexable": "toot:indexable" } ], "id": "http://localhost:8000/users/me", "type": "Person", "name": "Me", "preferredUsername": "me", "summary": "This is me!", "url": "http://localhost:8000/" } ~~~~ 응답은 액터 *me*의 기본 정보를 보여줍니다. 지금까지 우리가 한 요청들은 일반적인 ActivityPub 구현이 액터(즉, 계정)를 조회할 때 뒤에서 하는 작업들입니다. 예를 들어, Mastodon에서 액터의 전체 핸들로 검색할 때, Mastodon 인스턴스는 WebFinger 엔드포인트를 쿼리하여 액터의 정식 URI를 찾은 다음, 정식 URI를 쿼리하여 액터의 프로필을 가져옵니다. 그러나, 아직도 다른 ActivityPub 서버에서 액터 *me*를 팔로우할 수는 없습니다. 왜냐하면, 우리의 서버가 아직 공개 인터넷에 노출되지 않았기 때문입니다. 다음 섹션에서 이에 대해 다룰 것입니다. > [!TIP] > 액터 디스패처에 대해 더 궁금하다면, 매뉴얼의 [*액터 디스패처* 섹션](https://fedify.dev/manual/actor)을 > 참조하세요. [^3]: 시스템에 [curl]이 설치되어 있다고 가정하고 있습니다. curl을 설치되어 있지 않으면, 먼저 설치해야 합니다. [curl]: https://curl.se/ 서버를 퍼블릭 인터넷에 노출시키기 ------------------------------------------ 서버를 퍼블릭 인터넷에 노출시키려면 일반적으로 서버의 IP 주소로 가리키는 DNS 레코드가 구성된 적절한 도메인 이름이 필요합니다. 그러나 로컬 개발을 위해선, [`fedify tunnel`](https://fedify.dev/cli#fedify-tunnel-exposing-a-local-http-server-to-the-public-internet) 명령어를 사용하여 서버를 임시로 공인 인터넷에 노출시킬 수 있습니다. `fedify tunnel`을 사용하려면 먼저 `fedify` 명령어가 설치되어 있는지 확인하세요. 아직 설치하지 않았다면, [*`fedify`: CLI toolchain* 섹션](https://fedify.dev/cli#installation)에 있는 설치 매뉴얼을 따라 설치하세요. `fedify` 명령어를 설치한 후, 다음 명령어를 실행하여 서버를 공인 인터넷에 노출시킬 수 있습니다 (참고로, 서버가 계속 실행되도록 새로운 터미널 세션에서 이 명령어를 실행해야 합니다): ~~~~ sh fedify tunnel 8000 ~~~~ 위 명령어는 서버를 공인 인터넷에 노출시킬 겁니다. 인터넷에서 서버에 접근할 수 있는 퍼블릭 URL을 볼 수 있습니다, 예를 들어: ~~~~ console ✔ 8000번 포트의 로컬 서버가 이제 퍼블릭으로 접근할 수 있습니다: https://e875a03fc2a35b.lhr.life/ 터널을 닫으려면 ^C를 누르세요. ~~~~ > [!참고] > `fedify tunnel`을 프로덕션 사용에 의존하지 마세요. 이는 로컬 개발 목적으로만 사용됩니다. 이 명령어가 제공하는 도메인 이름은 임시며, 명령어를 다시 시작할 때마다 변경됩니다. 그러나 `fedify tunnel`이 퍼블릭 인터넷과 서버 사이의 리버스 프록시라는 점을 고려하면, 서버는 여전히 HTTPS를 통해 공인 인터넷에 노출된 사실을 인지하지 못합니다. 이를 서버가 인지하게 하려면 `Federation` 앞에 [x-forwarded-fetch] 미들웨어를 배치해야 합니다. 이를 위해 패키지를 설치해야 합니다: ::: code-group ~~~~ sh [Deno] deno add jsr:@hongminhee/x-forwarded-fetch ~~~~ ~~~~ sh [Bun] bun add x-forwarded-fetch ~~~~ ~~~~ sh [Node.js] npm add x-forwarded-fetch ~~~~ ::: 그런 다음, 패키지를 가져와서 `Federation.fetch()` 메서드 앞에 `behindProxy()` 미들웨어를 배치하세요: ::: code-group ~~~~ typescript{1,4} twoslash [Deno] // @noErrors: 2300 2307 import { behindProxy } from "x-forwarded-fetch"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { behindProxy } from "@hongminhee/x-forwarded-fetch"; Deno.serve( behindProxy(request => federation.fetch(request, { contextData: undefined })) ); ~~~~ ~~~~ typescript{1,5} twoslash [Bun] import "@types/bun"; import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { behindProxy } from "x-forwarded-fetch"; Bun.serve({ port: 8000, fetch: behindProxy((request) => federation.fetch(request, { contextData: undefined })), }); ~~~~ ~~~~ typescript{2,6} twoslash [Node.js] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { serve } from "@hono/node-server"; import { behindProxy } from "x-forwarded-fetch"; serve({ port: 8000, fetch: behindProxy((request) => federation.fetch(request, { contextData: undefined })), }); ~~~~ ::: 서버를 다시 시작하려면 <kbd>^C</kbd>를 눌러 서버를 중지하고 다시 서버를 실행해야 합니다: ::: code-group ~~~~ sh [Deno] deno run -A server.ts ~~~~ ~~~~ sh [Bun] bun run server.ts ~~~~ ~~~~ sh [Node.js] node --import tsx server.ts ~~~~ ::: 다시 *me* 액터를 쿼리해봅시다. 이번에는 퍼블릭 URL로 쿼리해봅시다 (`fedify tunnel`이 제공하는 도메인 이름으로 변경하세요):[^3] ~~~~ sh curl https://e875a03fc2a35b.lhr.life/.well-known/webfinger?resource=acct:me@e875a03fc2a35b.lhr.life curl -H"Accept: application/activity+json" https://e875a03fc2a35b.lhr.life/users/me ~~~~ 작동하나요? 그렇다면 축하합니다! 이제 서버가 공인 인터넷에 노출되었습니다. 그러나 아직 다른 ActivityPub 서버에서 액터 *me*를 팔로우할 수는 없습니다. 왜냐하면 우리 서버는 아직 팔로우 요청을 수락하지 않기 때문입니다. > [!TIP] > `fedify tunnel`의 대안이 있습니다. 자세한 내용은 매뉴얼의 [*로컬 서버를 퍼블릭으로 노출하는 방법* 섹션](https://fedify.dev/manual/test#exposing-a-local-server-to-the-public)을 참조하세요. [x-forwarded-fetch]: https://github.com/dahlia/x-forwarded-fetch 인박스 리스너 -------------- ActivityPub에서 [인박스]은 다른 액터로부터 들어오는 활동을 받는 곳입니다. 다른 서버에서 들어오는 팔로우 요청을 수락하려면 액터 *me*에 대한 인박스 리스너를 등록해야 합니다. 액터 *me*에 대한 인박스 리스너를 등록해봅시다. 우선 Fedify 프레임워크에서는 모든 활동이 클래스로 표현됩니다. `Follow` 클래스는 `Follow` 활동을 나타냅니다. 우리는 들어오는 팔로우 요청을 처리하기 위해 `Follow` 클래스를 사용할 것입니다: ~~~~ typescript twoslash import { createFederation, Follow, // [!code highlight] Person, MemoryKvStore, } from "@fedify/fedify"; ~~~~ 다음으로, `Follow` 활동에 대한 인박스 리스너를 등록합니다: ~~~~ typescript{3-11} twoslash import { type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); console.debug(follower); }); ~~~~ 하지만 위 코드는 콘솔에 팔로워의 정보를 출력하는 것 외에 아무것도 하지 않습니다. 다음 섹션에서 팔로우 요청을 받으면 송신자에게 수락 활동을 반환할 것입니다. 하지만 여기서는 팔로워가 누구인지 확인하는 데 사용됩니다. 인박스 리스너를 테스트하려면 액터 *me*는 액터 객체에 자신의 인박스 URI를 지정해야 합니다. 액터 디스패처를 수정하여 인박스 URI를 포함해봅시다: ~~~~ typescript twoslash import { type Federation, Person } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), inbox: ctx.getInboxUri(identifier), // 인박스 URI // [!code highlight] }); }); ~~~~ 이제 서버를 다시 시작하고 Mastodon 인스턴스(또는 계정이 있는 다른 ActivityPub 서버)에서 액터 *me*를 다시 찾아보겠습니다. 액터 *me*를 찾으려면 액터의 전체 핸들로 검색해야 합니다. (예: *@me@your-server-domain*): ![Mastodon에서 검색 결과; 액터 @me가 나타납니다.](https://hackmd.io/@OKSUchun/fedify-tutorials-results) 액터 *me*를 찾으면 액터의 프로필을 클릭한 다음 *Follow* 버튼을 클릭하세요. 서버가 실행되는 콘솔에서 액터 *me*에게 팔로우 요청을 보내는 Mastodon 계정을 볼 수 있을 것입니다: ~~~~ Person { id: URL "...", name: "...", ... omitted for brevity ... } ~~~~ 하지만 서버는 아직 송신자에게 수락 활동을 반환하지 않습니다. 다음 섹션에서 이를 다룰 것입니다. > [!TIP] > 인박스 리스너에 대해 더 궁금하다면, 매뉴얼의 > [*인박스 리스너* 섹션](https://fedify.dev/manual/inbox)을 참조하세요. [inbox]: https://www.w3.org/TR/activitypub/#inbox 키 페어 생성하기 --------------------- 활동을 전송하려면 먼저, 서버가 개인 키로 활동에 서명할 수 있도록 액터 *me*에 대한 키페어을 생성해야 합니다. 다행히도, Fedify는 키를 생성하고 내보내고 가져오는 데 도움이 되는 헬퍼 함수를 제공합니다: ~~~~ typescript{3-5} twoslash import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, Follow, Person, MemoryKvStore, } from "@fedify/fedify"; ~~~~ 여담으로, 키페어을 언제 생성해야 할까요? 일반적으로는 액터가 생성될 때 키페어을 생성해야 합니다. 우리의 경우에는 액터 *me*가 처음으로 전송될 때 키페어을 생성합니다. 그리고 나서, 서버가 나중에 키페어을 사용할 수 있도록 키페어을 키-값 저장소에 저장합니다. `~ActorCallbackSetters.setKeyPairsDispatcher()` 메서드는 액터에 대한 키페어 디스패처를 설정하는 데 사용됩니다. 키페어 디스패처는 액터의 키페어이 필요한 경우 호출되는 함수입니다. 액터 *me*에 대한 키페어 디스패처를 설정해 봅시다. `~ActorCallbackSetters.setKeyPairsDispatcher()` 메서드는 `Federation.setActorDispatcher()` 메서드 다음에 체이닝해야 합니다: ::: code-group ~~~~ typescript{13-14,17-37} twoslash [Deno] import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, Person, } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- const kv = await Deno.openKv(); // Open the key-value store federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), inbox: ctx.getInboxUri(identifier), // 액터의 공용 키; 다음에 정의할 키 페어 디스패처에 의해 제공됩니다: publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // "me"가 아닌 것은 찾을 수 없습니다. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // 처음에 새로운 키 페어를 생성합니다: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // 생성된 키 페어를 JWK 형식으로 Deno KV 데이터베이스에 저장합니다: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Deno KV 데이터베이스에서 키 페어를 로드합니다: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ~~~~ ~~~~ typescript{15-16,19-39} twoslash [Bun] import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, Person, } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { serialize as encodeV8, deserialize as decodeV8 } from "node:v8"; import { openKv } from "@deno/kv"; // 키-값 저장소를 엽니다: const kv = await openKv("kv.db", { encodeV8, decodeV8 }); federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), inbox: ctx.getInboxUri(identifier), // 액터의 공용 키; 다음에 정의할 키 페어 디스패처에 의해 제공됩니다: publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // "me"가 아닌 것은 찾을 수 없습니다. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // 처음에 새로운 키 페어를 생성합니다: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // 생성된 키 페어를 JWK 형식으로 Deno KV 데이터베이스에 저장합니다: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Deno KV 데이터베이스에서 키 페어를 로드합니다: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ~~~~ ~~~~ typescript{15-16,19-39} twoslash [Node.js] import { exportJwk, generateCryptoKeyPair, importJwk, type Federation, Person, } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- import { openKv } from "@deno/kv"; const kv = await openKv("kv.db"); // 키-값 저장소를 엽니다 federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { if (identifier !== "me") return null; return new Person({ id: ctx.getActorUri(identifier), name: "Me", summary: "This is me!", preferredUsername: identifier, url: new URL("/", ctx.url), inbox: ctx.getInboxUri(identifier), // 액터의 공용 키; 다음에 정의할 키 페어 디스패처에 의해 제공됩니다: publicKeys: (await ctx.getActorKeyPairs(identifier)) .map(keyPair => keyPair.cryptographicKey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { if (identifier != "me") return []; // "me"가 아닌 것은 찾을 수 없습니다. const entry = await kv.get<{ privateKey: JsonWebKey; publicKey: JsonWebKey; }>(["key"]); if (entry == null || entry.value == null) { // 처음에 새로운 키 페어를 생성합니다: const { privateKey, publicKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); // 생성된 키 페어를 JWK 형식으로 Deno KV 데이터베이스에 저장합니다: await kv.set( ["key"], { privateKey: await exportJwk(privateKey), publicKey: await exportJwk(publicKey), } ); return [{ privateKey, publicKey }]; } // Deno KV 데이터베이스에서 키 페어를 로드합니다: const privateKey = await importJwk(entry.value.privateKey, "private"); const publicKey = await importJwk(entry.value.publicKey, "public"); return [{ privateKey, publicKey }]; }); ~~~~ ::: 위 코드에서, 우리는 `~ActorCallbackSetters.setKeyPairsDispatcher()` 메서드를 사용하여 액터 *me*에 대한 키 페어 디스패처를 설정합니다. 액터의 키 페어가 필요한 경우에 키 페어 디스패처가 호출됩니다. 키 페어 디스패처는 액터의 개인 키와 공용 키를 포함하는 객체의 배열을 반환해야 합니다. 이 경우, 우리는 처음에 새로운 키 페어를 생성하고 키-값 저장소에 저장합니다. 액터 *me*가 다시 디스패치되면, 키 페어 디스패처는 키-값 저장소에서 키 페어를 로드합니다. > [!NOTE] > 이 튜토리얼에서 우리는 Deno KV 데이터베이스를 사용하지만, 키 페어를 저장하는 데 사용할 수 있는 다른 데이터베이스를 선호할 수 있습니다. 키-값 저장소는 단지 예시입니다. 서버를 다시 시작하고 `curl`을 사용하여 액터 *me*에 대한 HTTP 요청을 보내보세요. 이제 응답에 공용 키가 포함된 액터 *me*를 볼 수 있습니다: ~~~~ json {16-21} { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "toot": "http://joinmastodon.org/ns#", "discoverable": "toot:discoverable", "suspended": "toot:suspended", "memorial": "toot:memorial", "indexable": "toot:indexable" } ], "id": "https://e875a03fc2a35b.lhr.life/users/me", "type": "Person", "inbox": "https://e875a03fc2a35b.lhr.life/users/me/inbox", "publicKey": { "id": "https://e875a03fc2a35b.lhr.life/users/me#main-key", "type": "CryptographicKey", "owner": "https://e875a03fc2a35b.lhr.life/users/me", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0kptPO/arJVTv1qBzISP\nhJC8MZSut20FHZuJFON/kTscQT19eP2zGC9qDnQVl1vOXrvFybPWMjQP4p2x1/VM\np0wnY2EzKsdU4+lKfHsjd0VU2+TJvPtZ/AqJAG3PLMXeN7E5RpeUTwdTr9fkyrHE\n0M8n8yWG1AMtXp5pzhR/Le8uHmuSjbgJxIZPZOj8T6ZdMXKxudF0H/i0IB60lN9D\nt5tOzajmE5jvZD0mapdIDhghidGBu77fgopKmBtNn3IDjLJLXIh3dp7NICl1czHB\ntVtU1c2kmNPXq1WSndQgokN4CXNoy/BqTKo4VhIOWWb/oGaTZOWflFM5EXWTJUxK\n8JFyCD/1KVJXYEd662y+r400oDJqHKHhG78yud83PD4bpbJm/t7BD7RgO95g/rpN\nwi8mjLQVp7Y9ttXGf3lEgbBPZfPr0pm3X4ppoDAwtzVO7RmfboSb9ECa9uwQc1VG\nse3yNi7bDrHIu+HjBzk+glELcW2Hj4t4s/PPX9g0fH3UHgME1Pysz3Y8OZZeJlTu\n1yYcCg9X/dMV1qxxon6b8XhIEttW+RZjJunmtzOt1sKf2NM2jPXv+ZmFRao1eOzo\nvcVI/eeXV+1LDhHtTQJGnLObqnHnVdg3Qiaao176KOxrKh4/l6kJmaq/pw8+ZSkE\nzxUovxHGCJ0UqqgcaPsBsJMCAwEAAQ==\n-----END PUBLIC KEY-----" }, "name": "Me", "preferredUsername": "me", "summary": "This is me!", "url": "https://e875a03fc2a35b.lhr.life/" } ~~~~ 좋아요, 이제 액터 *me*의 공용 키를 가지고 있습니다. 이제 팔로우 요청을 받으면 보낸 사람에게 수락 활동을 보내는 다음 섹션으로 넘어갑니다. > [!TIP] > 키 페어 디스패처에 대해 더 궁금하다면, 매뉴얼의 [*액터의 공용 키*](https://fedify.dev/manual/actor.md#public-keys-of-an-actor) 섹션을 참조하세요. `Accept` 활동 전송하기 ---------------------------- 서버가 팔로우 요청을 받으면, 보낸 사람에게 `Accept` 또는 `Reject` 활동을 보내야 합니다. `Accept` 활동은 팔로우 요청에 대한 응답이며, 팔로우 요청이 수락되었음을 나타냅니다. Fedify 프레임워크에서 `Accept` 클래스를 가져오겠습니다: ~~~~ typescript twoslash import { createFederation, exportJwk, generateCryptoKeyPair, importJwk, Accept, // [!code highlight] Follow, Person, } from "@fedify/fedify"; ~~~~ 그런 다음, 팔로우 요청을 받으면 팔로워에게 `Accept` 활동을 보내도록 인박스 리스너를 수정하겠습니다: ~~~~ typescript{10-17} twoslash import { Accept, type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; // ---cut-before--- federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); if (follower == null) return; // 서버가 `Follow` 활동을 받으면, `Accept` 또는 `Reject` 활동으로 응답해야 합니다. 이 경우, 서버는 팔로우 요청을 자동으로 수락합니다: await ctx.sendActivity( { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); }); ~~~~ 서버를 다시 시작하고, Mastodon 계정에서 액터 *me*에게 팔로우 요청을 보내보세요. 이제 서버가 팔로우 요청을 즉시 수락하는 것을 볼 수 있어야 합니다. > [!TIP] > 활동을 보내는 것에 대해 더 궁금하다면, 매뉴얼의 [*활동 보내기* 섹션](https://fedify.dev/manual/send)을 참조하세요. 팔로워 목록 만들기 ----------------- 서버는 홈 페이지에서 액터의 팔로워를 나열해야 합니다. 이를 위해 팔로워를 키-값 저장소에 저장해야 합니다. 각 팔로우 활동의 ID를 키로, 팔로워의 액터 ID를 값으로 저장하겠습니다: ~~~~ typescript{16-17} twoslash import { Accept, type Federation, Follow } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; const kv = await Deno.openKv(); // ---cut-before--- federation .setInboxListeners("/users/{identifier}/inbox", "/inbox") .on(Follow, async (ctx, follow) => { if (follow.id == null || follow.actorId == null || follow.objectId == null) { return; } const parsed = ctx.parseUri(follow.objectId); if (parsed?.type !== "actor" || parsed.identifier !== "me") return; const follower = await follow.getActor(ctx); if (follower == null) return; await ctx.sendActivity( { identifier: parsed.identifier }, follower, new Accept({ actor: follow.objectId, object: follow }), ); // 팔로워를 키-값 저장소에 저장합니다: await kv.set(["followers", follow.id.href], follow.actorId.href); }); ~~~~ 이제 홈 페이지를 수정하여 액터의 팔로워를 표시하도록 스크립트를 수정하겠습니다: ::: code-group ~~~~ typescript{2-16} twoslash [Deno] import type { Federation } from "@fedify/fedify"; const federation = null as unknown as Federation<void>; const kv = await Deno.openKv(); // ---cut-before--- Deno.serve(async (request) => { const url = new URL(request.url); // 홈페이지: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list<string>({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `<ul>${followers.map((f) => `<li>${f}</li>`)}</ul>`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // 연합 관련 요청은 Federation 객체에 의해 처리됩니다: return await federation.fetch(request, { contextData: undefined }); }); ~~~~ ~~~~ typescript{4-18} twoslash [Bun] import "@types/bun"; import type { Federation } from "@fedify/fedify"; import { openKv } from "@deno/kv"; const federation = null as unknown as Federation<void>; const kv = await openKv(); // ---cut-before--- Bun.serve({ port: 8000, async fetch(request) { const url = new URL(request.url); // 홈페이지: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list<string>({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `<ul>${followers.map((f) => `<li>${f}</li>`)}</ul>`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // 연합 관련 요청은 Federation 객체에 의해 처리됩니다: return await federation.fetch(request, { contextData: undefined }); } }); ~~~~ ~~~~ typescript{4-18} twoslash [Node.js] import { serve } from "@hono/node-server"; import type { Federation } from "@fedify/fedify"; import { openKv } from "@deno/kv"; const federation = null as unknown as Federation<void>; const kv = await openKv(); // ---cut-before--- serve({ port: 8000, async fetch(request) { const url = new URL(request.url); // 홈페이지: if (url.pathname === "/") { const followers: string[] = []; for await (const entry of kv.list<string>({ prefix: ["followers"] })) { if (followers.includes(entry.value)) continue; followers.push(entry.value); } return new Response( `<ul>${followers.map((f) => `<li>${f}</li>`)}</ul>`, { headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } // 연합 관련 요청은 Federation 객체에 의해 처리됩니다: return await federation.fetch(request, { contextData: undefined }); } }); ~~~~ ::: 위 코드는 홈페이지에 액터의 팔로워를 나열합니다. 팔로워들은 키-값 저장소에 저장되어 있으며, 키-값 저장소에서 팔로워들을 가져와 홈페이지에 표시합니다. 서버를 다시 시작하고 웹 브라우저에서 홈페이지로 이동하세요. 그러면 액터의 팔로워들이 목록에 표시됩니다. > [!NOTE] > 위 코드가 연합 관련 요청보다 *먼저* 홈페이지를 처리하는 것은 예시입니다. 실제 애플리케이션에서는 [Hono]와 같은 웹 프레임워크와 Fedify를 통합해야 합니다. 여기서 요청 처리 순서는 반대로 됩니다: 연합 관련 요청이 *다른 웹 관련 요청보다 먼저* 처리됩니다. > > 더 많은 정보는 [*통합* 섹션] (https://fedify.dev/manual/integration)을 참조하세요. [Hono]: https://hono.dev/ 마무리하며 ----------- 축하합니다! 팔로우 요청을 수락하고 액터의 팔로워를 나열할 수 있는 작은 연합 서버를 구축했습니다. ActivityPub 프로토콜과 Fedify 프레임워크의 주요 기능을 배워보았습니다. 예를 들어 액터, 활동의 전송 및 수신, 인박스 등이 있습니다. 이 튜토리얼에서는 다음 주제들을 다루었습니다: - 새로운 프로젝트 생성하기 - 서버 생성하기 - `Federation` 객체 - 액터 디스패처 - 서버를 퍼블릭 인터넷에 노출시키기 - 인박스 리스너 - 키 페어 생성하기 - `Accept` 활동 전송하기 - 팔로워 목록 만들기 서버를 더 많은 기능으로 확장할 수 있습니다. 예를 들어 다른 활동을 전송하거나, 다른 유형의 활동을 처리하거나, 다른 콜백 함수를 구현하는 등이 있습니다. Fedify 프레임워크는 연합 서버를 구축하는 데 필요한 다양한 기능을 제공합니다. 이를 [매뉴얼] (https://fedify.dev/manual/federation)과 [API 참조] (https://jsr.io/@fedify/fedify) 를 읽어보면 더 많은 정보를 얻을 수 있습니다. 질문이나 피드백이 있으면, [Fedify 커뮤니티](https://matrix.to/#/#fedify:matrix.org)에서 Matrix를 통해, 또는 [Discord 서버](https://discord.gg/bhtwpzURwd)에서, 또는 [GitHub Discussions](https://github.com/dahlia/fedify/discussions)에서 자유롭게 물어보세요. [API 참조]: https://jsr.io/@fedify/fedify [Fedify 커뮤니티]: https://matrix.to/#/#fedify:matrix.org [Discord 서버]: https://discord.gg/bhtwpzURwd [GitHub Discussions]: https://github.com/dahlia/fedify/discussions [매뉴얼]: https://fedify.dev/manual/federation 연습 문제 --------- - 언팔로우 기능 구현해보기: `Undo` 활동을 리슨하며, 서버가 `Undo` 활동을 받으면 키-값 저장소에서 팔로워를 제거합니다. - 웹 프레임워크와의 통합해보기: 위의 예에서는 Deno의 경우 `Deno.serve()`, Bun의 경우 `Bun.serve()`, Node.js의 경우 `serve()`에 전달된 콜백 함수 내부에 홈페이지를 하드코딩했습니다. 실제 애플리케이션에서는 HTML 템플릿을 렌더링하고 미디어 파일을 처리해야 하므로, 이를 충분히 처리할 수 없습니다. 대신, [Hono] 또는 [Fresh]와 같은 웹 프레임워크를 사용하여 적절한 라우팅 시스템과 [JSX] 템플릿을 통해 HTML을 생성할 수 있습니다. 더 많은 정보는 매뉴얼의 [*통합* 섹션](https://github.com/dahlia/fedify/blob/main/docs/manual/integration.md)을 참조하세요. [Hono]: https://hono.dev/ [Fresh]: https://fresh.deno.dev/ [JSX]: https://facebook.github.io/jsx/