#microblog

๐Ÿซง socialcoding..'s avatar
๐Ÿซง socialcoding..

@smallcircles@social.coop ยท Reply to Michael's post

@michael @haraldkliems @organicmaps @openstreetmap @mangroveReviews @sudoer777 @CoMaps @Dorothea

These are great , and also typical candidates to file an issue for at the ideas repository, at..

codeberg.org/fediverse/fediver

As long-time advocate and passionado I've seen countless times on nifty ideas, only for it to fade out, and be forgotten again, until the next person throws it into ephemeral timeline voids.

flo's avatar
flo

@fasnix@iceshrimp.de

Das FediVerse:
Da gibt es diejenigen, die "Fรคden" aus drei, vier, fรผnf, zehn Beitrรคgen auf einmal verรถffentlichen (ja, hallo mastodons 500 -Zeichen-MICROblogging-Limit, "Notizzettel",
@crossgolf_rebel sie liebevoll nennt),

und dann gibt es welche, die einfach Fedizens blockieren, weil diese halt MACROblogging-Plattformen, wie zB iceshrimp, misskey, friendica, hubzilla, WordPress, Ghost, uvm nutzen und darรผber 3000, 5000, 10000 und mehr Zeichen schreiben.

Ich hรคtte ja gern eine Instanz, die sich 140.social nennt und - ihr erkennt es vielleicht am Namen - maximal 140 Zeichen erlaubt, ganz wie Twitter in dessen frรผhen Tagen
๐Ÿ˜‰

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: English (์˜์–ด), ๆ—ฅๆœฌ่ชž (์ผ๋ณธ์–ด).

์•ˆ๋‚ด

๋งŒ์•ฝ ์—ฐํ•ฉ์šฐ์ฃผ(fediverse)๋‚˜ ActivityPub ๊ฐ™์€ ์šฉ์–ด๊ฐ€ ์ƒ์†Œํ•˜๋‹ค๋ฉด, ๊ด€๋ จ ๊ฒ€์ƒ‰์„ ์ข€ ๋” ํ•˜๊ณ  ๋‚˜์„œ ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•  ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ActivityPub ์„œ๋ฒ„ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Fedify๋ฅผ ์ด์šฉํ•˜์—ฌ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ํ”„๋กœํ† ์ฝœ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ(microblog)๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify์˜ ๊ธฐ๋ฐ˜ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค๋Š” Fedify์˜ ํ™œ์šฉ๋ฒ•์— ์ข€ ๋” ์ง‘์ค‘ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

Fedify๋Š” ActivityPub์ด๋‚˜ ๊ทธ ์™ธ ํ‘œ์ค€(์ด์นญํ•˜์—ฌ ใ€Œ์—ฐํ•ฉ์šฐ์ฃผใ€๋ผ ๋ถˆ๋ฆฌ๋Š”)์„ ์ด์šฉํ•˜์—ฌ ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค ๋•Œ์˜ ๋ณต์žกํ•จ์ด๋‚˜ ๋ฒˆ๊ฑฐ๋กœ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์—†์• ๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด Fedify์˜ ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค.

Fedify ํ”„๋กœ์ ํŠธ์— ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์…จ๋‹ค๋ฉด, ์•„๋ž˜์˜ ์ž๋ฃŒ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”:

Fedify๋‚˜ ๋ณธ ํŠœํ† ๋ฆฌ์–ผ์— ๋Œ€ํ•œ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ, ํ”ผ๋“œ๋ฐฑ ๋“ฑ์€ GitHub Discussions(์˜์–ด)์— ์˜ฌ๋ ค ์ฃผ์‹œ๊ฑฐ๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ @fedify(์˜์–ด ๋ฐ ํ•œ๊ตญ์–ด)๋กœ ๋ฉ˜์…˜ ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ์•„๋‹ˆ๋ฉด Fedify ํ”„๋กœ์ ํŠธ์˜ Discord ์„œ๋ฒ„์— ๋“ค์–ด์˜ค์…”์„œ #fedify-general-ko ์ฑ„๋„(ํ•œ๊ตญ์–ด)์—์„œ ๋ง์”€ํ•˜์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

๋Œ€์ƒ ๋…์ž

์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify๋ฅผ ๋ฐฐ์›Œ์„œ ActivityPub ์„œ๋ฒ„ ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ณ  ์‹ถ์€ ๋ถ„๋“ค์„ ๋Œ€์ƒ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด HTML์ด๋‚˜ HTTP๋ฅผ ์ด์šฉํ•˜์—ฌ ์›น์•ฑ์„ ์ œ์ž‘ํ•ด ๋ณธ ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ, ๋ช…๋ นํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๋‚˜ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋“ฑ์„ ์ดํ•ดํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ TypeScript๋‚˜ JSX, ActivityPub, Fedify ๋“ฑ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ํ•„์š”ํ•œ ๋งŒํผ ๊ฐ€๋ฅด์ณ ๋“œ๋ฆด ๊ฒƒ์ด๋‹ˆ ๋ชฐ๋ผ๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณธ ๊ฒฝํ—˜์€ ํ•„์š” ์—†์ง€๋งŒ, ๊ทธ๋ž˜๋„ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ํ•˜๋‚˜ ์ •๋„๋Š” ์จ๋ดค๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ž˜์•ผ ์šฐ๋ฆฌ๊ฐ€ ๋ฌด์—‡์„ ๋งŒ๋“œ๋ ค๊ณ  ํ•˜๋Š”์ง€ ๊ฐ์ด ์žกํžˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๋ชฉํ‘œ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Fedify๋ฅผ ์ด์šฉํ•ด ActivityPub์œผ๋กœ ๋‹ค๋ฅธ ์—ฐํ•ฉํ˜• ์†Œํ”„ํŠธ์›จ์–ด ๋ฐ ์„œ๋น„์Šค์™€ ์†Œํ†ต ๊ฐ€๋Šฅํ•œ ์ผ์ธ์šฉ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ด ์†Œํ”„ํŠธ์›จ์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

  • ์‚ฌ์šฉ์ž๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์ด ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒ”๋กœ์›Œ๋Š” ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœํ•˜๋‹ค๊ฐ€ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๋ฌผ์€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ • ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ •์ด ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์„ ์‹œ๊ฐ„์ˆœ ๋ชฉ๋ก์œผ๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŠœํ† ๋ฆฌ์–ผ์„ ๋‹จ์ˆœํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ ์ œ์•ฝ์„ ๋‘ก๋‹ˆ๋‹ค.

  • ๊ณ„์ • ํ”„๋กœํ•„(์†Œ๊ฐœ๋ฌธ, ์‚ฌ์ง„ ๋“ฑ)์€ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ๋งŒ๋“  ๊ณ„์ •์€ ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ์˜ฌ๋ฆฐ ๊ฒŒ์‹œ๋ฌผ์€ ๊ณ ์น˜๊ฑฐ๋‚˜ ์ง€์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ํŒ”๋กœํ•œ ๋‹ค๋ฅธ ๊ณ„์ •์€ ํŒ”๋กœ์ž‰์„ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ข‹์•„์š”, ๊ณต์œ , ๋Œ“๊ธ€์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ ๋“ฑ์˜ ๋ณด์•ˆ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก , ํŠœํ† ๋ฆฌ์–ผ์„ ๋๊นŒ์ง€ ์ง„ํ–‰ํ•œ ๋’ค ๊ธฐ๋Šฅ์„ ๋ง๋ถ™์ด๋Š” ๊ฒƒ์€ ์–ผ๋งˆ๋“ ์ง€ ํ•˜์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์—ฐ์Šต์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” GitHub ์ €์žฅ์†Œ์— ์˜ฌ๋ผ์™€ ์žˆ์œผ๋ฉฐ, ๊ฐ ๊ตฌํ˜„ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ์ปค๋ฐ‹์ด ๋‚˜๋‰˜์–ด์ ธ ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์…‹์—…

Node.js ์„ค์น˜ํ•˜๊ธฐ

Fedify๋Š” Deno, Bun, Node.js, ์ด ์„ธ ๊ฐ€์ง€ JavaScript ๋Ÿฐํƒ€์ž„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ์ค‘์—์„œ Node.js๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์“ฐ์ด๋ฏ€๋กœ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Node.js๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

JavaScript ๋Ÿฐํƒ€์ž„์ด๋ž€ JavaScript ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ํ”Œ๋žซํผ์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์›น๋ธŒ๋ผ์šฐ์ €๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜์ด๋ฉฐ, ๋ช…๋ น์ค„์ด๋‚˜ ์„œ๋ฒ„์—์„œ๋Š” Node.js ๋“ฑ์ด ๋„๋ฆฌ ์“ฐ์ž…๋‹ˆ๋‹ค. ์ตœ๊ทผ์—๋Š” Cloudflare Workers ๊ฐ™์€ ํด๋ผ์šฐ๋“œ ์—์ง€ ํ•จ์ˆ˜๋“ค๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜๋กœ ๊ฐ๊ด‘ ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fedify๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Node.js 22.0.0 ์ด์ƒ์˜ ๋ฒ„์ „์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜๋ฒ•์ด ์žˆ์œผ๋‹ˆ ์ž์‹ ์—๊ฐ€ ๊ฐ€์žฅ ์•Œ๋งž๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ Node.js๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

Node.js๊ฐ€ ์„ค์น˜๋˜๋ฉด node ๋ช…๋ น์–ด์™€ npm ๋ช…๋ น์–ด๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค:

node --version
npm --version

fedify ๋ช…๋ น์–ด ์„ค์น˜

Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์…‹์—…ํ•˜๊ธฐ ์œ„ํ•ด fedify ๋ช…๋ น์–ด๋ฅผ ์‹œ์Šคํ…œ์— ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ, npm ๋ช…๋ น์œผ๋กœ ๊นŒ๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊ฐ„ํŽธํ•ฉ๋‹ˆ๋‹ค:

npm install -g @fedify/cli

์„ค์น˜๊ฐ€ ๋˜์—ˆ๋‹ค๋ฉด, fedify ๋ช…๋ น์–ด๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ fedify ๋ช…๋ น์–ด์˜ ๋ฒ„์ „์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

fedify --version

๊ฒฐ๊ณผ๋กœ ๋‚˜์˜จ ๋ฒ„์ „ ๋ฒˆํ˜ธ๊ฐ€ 1.0.0 ์ด์ƒ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. ๊ทธ๋ณด๋‹ค ์˜›๋‚  ๋ฒ„์ „์ด๋ฉด ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ œ๋Œ€๋กœ ๋”ฐ๋ผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

fedify init์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

์ƒˆ Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•ด, ์ž‘์—…ํ•  ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ •ํ•ฉ์‹œ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” microblog๋ผ๊ณ  ๋ช…๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. fedify init ๋ช…๋ น ๋’ค์— ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ ๊ณ  ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค (๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค):

fedify init microblog

fedify init ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ช‡ ๊ฐ€์ง€ ์งˆ๋ฌธ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค. ์ฐจ๋ก€๋Œ€๋กœ Node.js, npm, Hono, In-memory, In-process๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค:

             ___      _____        _ _  __
            /'_')    |  ___|__  __| (_)/ _|_   _
     .-^^^-/  /      | |_ / _ \/ _` | | |_| | | |
   __/       /       |  _|  __/ (_| | |  _| |_| |
  <__.|_|-|_|        |_|  \___|\__,_|_|_|  \__, |
                                           |___/

? Choose the JavaScript runtime to use
  Deno
  Bun
โฏ Node.js

? Choose the package manager to use
โฏ npm
  Yarn
  pnpm

? Choose the web framework to integrate Fedify with
  Bare-bones
  Fresh
โฏ Hono
  Express
  Nitro

? Choose the key-value store to use for caching
โฏ In-memory
  Redis
  PostgreSQL
  Deno KV

? Choose the message queue to use for background jobs
โฏ In-process
  Redis
  PostgreSQL
  Deno KV

์•ˆ๋‚ด

Fedify๋Š” ํ’€ ์Šคํƒ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์•„๋‹Œ, ActivityPub ์„œ๋ฒ„ ๊ตฌํ˜„์— ํŠนํ™”๋œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋‹ค๋ฅธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์™€ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๊ฒƒ์„ ์—ผ๋‘์— ๋‘๊ณ  ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ Hono๋ฅผ ์ฑ„ํƒํ•˜์—ฌ Fedify์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์ž ์‹œ ํ›„ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ ์•ˆ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • .vscode/ โ€” Visual Studio Code ๊ด€๋ จ ์„ค์ •๋“ค
    • extensions.json โ€” Visual Studio Code ์ถ”์ฒœ ํ™•์žฅ
    • settings.json โ€” Visual Studio Code ์„ค์ •
  • node_modules/ โ€” ์˜์กด ํŒจํ‚ค์ง€๋“ค์ด ์„ค์น˜๋˜๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ (๋‚ด๋ถ€ ์ƒ๋žต)
  • src/ โ€” ์†Œ์Šค ์ฝ”๋“œ
    • app.tsx โ€” ActivityPub๊ณผ ๊ด€๋ จ ์—†๋Š” ์„œ๋ฒ„
    • federation.ts โ€” ActivityPub ์„œ๋ฒ„
    • index.ts โ€” ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
    • logging.ts โ€” ๋กœ๊น… ์„ค์ •
  • biome.json โ€” ํฌ๋งคํ„ฐ ๋ฐ ๋ฆฐํŠธ ์„ค์ •
  • package.json โ€” ํŒจํ‚ค์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
  • tsconfig.json โ€” TypeScript ์„ค์ •

์ง์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒ ์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” JavaScript๊ฐ€ ์•„๋‹Œ TypeScript๋ฅผ ์“ฐ๊ธฐ ๋•Œ๋ฌธ์— .js ํŒŒ์ผ์ด ์•„๋‹Œ .ts ๋ฐ .tsx ํŒŒ์ผ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒ์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” ๋™์ž‘ํ•˜๋Š” ๋ฐ๋ชจ์ž…๋‹ˆ๋‹ค. ์šฐ์„ ์€ ์ด ์ƒํƒœ๋กœ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

npm run dev

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด Ctrl+C ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ธฐ ์ „๊นŒ์ง€๋Š” ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ฑ„๋กœ ์žˆ์Šต๋‹ˆ๋‹ค:

Server started at http://0.0.0.0:8000

์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด๊ณ  ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/john

์œ„ ๋ช…๋ น์€ ์šฐ๋ฆฌ๊ฐ€ ๋กœ์ปฌ์— ๋„์šด ActivityPub ์„œ๋ฒ„์˜ ํ•œ ์•กํ„ฐ(actor)๋ฅผ ์กฐํšŒํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ์•กํ„ฐ๋Š” ์—ฌ๋Ÿฌ ActivityPub ์„œ๋ฒ„๋“ค ์‚ฌ์ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ณ„์ •์ด๋ผ๊ณ  ๋ณด์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋˜๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/john",
  name: "john",
  preferredUsername: "john"
}

์ด ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด /users/john ๊ฒฝ๋กœ์— ์œ„์น˜ํ•œ ์•กํ„ฐ ๊ฐ์ฒด์˜ ์ข…๋ฅ˜๊ฐ€ Person์ด๋ฉฐ, ๊ทธ ID๋Š” http://localhost:8000/users/john, ์ด๋ฆ„์€ john, ์‚ฌ์šฉ์ž๋ช…๋„ john์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

fedify lookup์€ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ์ด๋Š” Mastodon์—์„œ ํ•ด๋‹น URI๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌผ๋ก , ํ˜„์žฌ ์—ฌ๋Ÿฌ๋ถ„์˜ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•„์ง Mastodon์—์„œ ๊ฒ€์ƒ‰ํ•ด๋„ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค์ง€๋Š” ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.)

์—ฌ๋Ÿฌ๋ถ„์ด fedify lookup ๋ช…๋ น์–ด๋ณด๋‹ค curl์„ ๋” ์„ ํ˜ธํ•˜์‹ ๋‹ค๋ฉด, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ๋„ ์•กํ„ฐ ์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (-H ์˜ต์…˜์œผ๋กœ Accept ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋Š” ๊ฒƒ์— ์ฃผ์˜ํ•˜์‹ญ์‹œ์˜ค):

curl -H"Accept: application/activity+json" http://localhost:8000/users/john

๋‹จ, ์œ„์™€ ๊ฐ™์ด ์กฐํšŒํ•  ๊ฒฝ์šฐ ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋งจ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜๊ธฐ ์–ด๋ ค์šด JSON ํ˜•์‹์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์‹œ์Šคํ…œ์— jq ๋ช…๋ น์–ด๋„ ํ•จ๊ป˜ ๊น”๋ ค์žˆ๋‹ค๋ฉด, curl๊ณผ jq๋ฅผ ํ•จ๊ป˜ ์“ธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

Visual Studio Code๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋Š” ๋™์•ˆ์—๋Š” Visual Studio Code๋ฅผ ์จ๋ณด์‹ค ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์šฐ๋ฆฌ๋Š” TypeScript๋ฅผ ์จ์•ผ ํ•˜๋Š”๋ฐ, Visual Studio Code๋Š” ํ˜„์กดํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„ํŽธํ•˜๋ฉด์„œ๋„ ๋›ฐ์–ด๋‚œ TypeScript IDE์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ์…‹์—…์— ์ด๋ฏธ Visual Studio Code ์„ค์ •์ด ๊ฐ–์ถฐ์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํฌ๋งคํ„ฐ๋‚˜ ๋ฆฐํŠธ ๋“ฑ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š”๋„ ์—†์Šต๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

Visual Studio์™€ ํ—ท๊ฐˆ๋ฆฌ์‹œ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. Visual Studio Code์™€ Visual Studio๋Š” ๋ธŒ๋žœ๋“œ๋งŒ ๊ณต์œ ํ•  ๋ฟ ์„œ๋กœ ์™„์ „ํžˆ ๋‹ค๋ฅธ ์†Œํ”„ํŠธ์›จ์–ด์ž…๋‹ˆ๋‹ค.

Visual Studio Code๋ฅผ ์„ค์น˜ํ•˜์‹  ๋‹ค์Œ, ํŒŒ์ผ โ†’ ํด๋” ์—ด๊ธฐโ€ฆ ๋ฉ”๋‰ด๋ฅผ ๋ˆŒ๋Ÿฌ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์‹ญ์‹œ์˜ค.

๋งŒ์•ฝ ์šฐํ•˜๋‹จ์— ใ€Œ์ด ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ๋Œ€ํ•œ ๊ถŒ์žฅ๋˜๋Š” biomejs์˜ โ€˜Biomeโ€™ ํ™•์žฅ์„(๋ฅผ) ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?ใ€๋ผ๊ณ  ๋ฌป๋Š” ์ฐฝ์ด ๋œจ๋ฉด ์„ค์น˜ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ•ด๋‹น ํ™•์žฅ์„ ์„ค์น˜ํ•˜์„ธ์š”. ์ด ํ™•์žฅ์„ ์„ค์น˜ํ•˜๋ฉด TypeScript ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ๋“ค์—ฌ์“ฐ๊ธฐ๋‚˜ ๋„์–ด์“ฐ๊ธฐ ๊ฐ™์€ ์ฝ”๋“œ ์Šคํƒ€์ผ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๊ฐ€ ์„œ์‹ํ™” ๋ฉ๋‹ˆ๋‹ค.

ํŒ

์—ฌ๋Ÿฌ๋ถ„์ด ์ถฉ์„ฑ์Šค๋Ÿฌ์šด Emacs ๋˜๋Š” Vim ์‚ฌ์šฉ์ž๋ผ๋ฉด, ์“ฐ๋˜ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๋ฅผ ์“ฐ๋Š” ๊ฒƒ์„ ๋ง๋ฆฌ์ง€ ์•Š๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TypeScript LSP ์„ค์ •์€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. TypeScript LSP ์„ค์ • ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์ƒ์‚ฐ์„ฑ์˜ ์ฐจ์ด๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์„ ์ˆ˜ ์ง€์‹

TypeScript

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ ์ „์—, ๊ฐ„๋‹จํžˆ TypeScript์— ๋Œ€ํ•ด ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์—ฌ๋Ÿฌ๋ถ„์ด ์ด๋ฏธ TypeScript์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด ์ด ์žฅ์€ ๋„˜๊ธฐ์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

TypeScript๋Š” JavaScript์— ์ •์  ํƒ€์ž… ๊ฒ€์‚ฌ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. TypeScript ๋ฌธ๋ฒ•์€ JavaScript ๋ฌธ๋ฒ•๊ณผ ๊ฑฐ์˜ ๊ฐ™์ง€๋งŒ, ๋ณ€์ˆ˜๋‚˜ ํ•จ์ˆ˜ ๋ฌธ๋ฒ•์— ํƒ€์ž…์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ํฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ์ง€์ •์€ ๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋’ค์— ์ฝœ๋ก (:)์„ ๋ถ™์—ฌ์„œ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋Š” foo ๋ณ€์ˆ˜๊ฐ€ ๋ฌธ์ž์—ด(string)์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค:

let foo: string;

๋งŒ์•ฝ ์œ„์™€ ๊ฐ™์ด ์„ ์–ธ๋œ foo ๋ณ€์ˆ˜์— ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ๊ฐ’์„ ๋Œ€์ž…ํ•˜๋ ค๊ณ  ํ•˜๋ฉด Visual Studio Code๊ฐ€ ์‹คํ–‰ํ•ด๋ณด๊ธฐ ์ „์— ๋ฏธ๋ฆฌ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๊ทธ์–ด์ฃผ๋ฉฐ ํƒ€์ž… ์˜ค๋ฅ˜๋ฅผ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

foo = 123;
// ts(2322): 'number' ํ˜•์‹์€ 'string' ํ˜•์‹์— ํ• ๋‹นํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ฝ”๋”ฉํ•˜๋ฉด์„œ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๋งŒ๋‚˜๋ฉด ์ง€๋‚˜์น˜์ง€ ์•Š๋„๋ก ํ•˜์‹ญ์‹œ์˜ค. ๋ฌด์‹œํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉด ๊ทธ ๋ถ€๋ถ„์—์„œ ์‹ค์ œ๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.

TypeScript๋กœ ์ฝ”๋”ฉ์„ ํ•˜๋ฉฐ ๋งˆ์ฃผ์น˜๋Š” ๊ฐ€์žฅ ํ”ํ•œ ํƒ€์ž… ์˜ค๋ฅ˜์˜ ์œ ํ˜•์€ ๋ฐ”๋กœ null ๊ฐ€๋Šฅ์„ฑ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด bar ๋ณ€์ˆ˜๋Š” ๋ฌธ์ž์—ด(string)์ผ ์ˆ˜๋„ ์žˆ์ง€๋งŒ null์ผ ์ˆ˜๋„ ์žˆ๋‹ค(string | null)๊ณ  ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:

const bar: string | null = someFunction();

๋งŒ์•ฝ ์ด ๋ณ€์ˆ˜์˜ ๋‚ด์šฉ์—์„œ ๊ฐ€์žฅ ์ฒซ ๊ธ€์ž๋ฅผ ๊บผ๋‚ด๋ ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ์“ด๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”?

const firstChar = bar.charAr(0);
// ts(18047): 'bar'์€(๋Š”) 'null'์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. bar๊ฐ€ ์–ด์ฉ” ๋•Œ๋Š” null์ผ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ๊ฒฝ์šฐ์— null.charAt(0)์„ ํ˜ธ์ถœํ•˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ผ๋Š” ์ด์•ผ๊ธฐ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๊ฒฝ์šฐ์— ์•„๋ž˜์™€ ๊ฐ™์ด null์ธ ๊ฒฝ์šฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

const firstChar = bar === null ? "" : bar.charAr(0);

์ด์™€ ๊ฐ™์ด TypeScript๋Š” ์ฝ”๋”ฉํ•  ๋•Œ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ๋˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๋ฅผ ๋– ์˜ฌ๋ฆฌ๊ฒŒ ํ•ด์„œ ๋ฒ„๊ทธ๋ฅผ ๋ฏธ์—ฐ์— ๋ฐฉ์ง€ํ•˜๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

๋˜, TypeScript์˜ ๋ถ€์ˆ˜์ ์ธ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” ์ž๋™ ์™„์„ฑ์ด ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, foo.๊นŒ์ง€ ์ž…๋ ฅํ•˜๋ฉด ๋ฌธ์ž์—ด ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ง„ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์ด ๋‚˜์™€์„œ ๊ทธ ์ค‘์—์„œ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ผ์ผํžˆ ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์ง€ ์•Š๊ณ ์„œ๋„ ๋น ๋ฅด๊ฒŒ ์ฝ”๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋ฉด์„œ TypeScript์˜ ๋งค๋ ฅ๋„ ํ•จ๊ป˜ ๋А๋ผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ฌด์—‡๋ณด๋‹ค Fedify๋Š” TypeScript์™€ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ€์žฅ ๊ฒฝํ—˜์ด ์ข‹์œผ๋‹ˆ๊นŒ์š”.

ํŒ

TypeScript๋ฅผ ์ œ๋Œ€๋กœ ์ฐฌ์ฐฌํžˆ ๋ฐฐ์›Œ๋ณด๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, ๊ณต์‹ TypeScript ํ•ธ๋“œ๋ถ์„ ์ฝ์œผ์‹ค ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ „๋ถ€ ์ฝ๋Š”๋ฐ ์•ฝ 30๋ถ„ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค.

JSX

JSX๋Š” JavaScript ์ฝ”๋“œ ์•ˆ์— XML ๋˜๋Š” HTML์„ ์ง‘์–ด๋„ฃ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•œ JavaScript์˜ ๋ฌธ๋ฒ• ํ™•์žฅ์ž…๋‹ˆ๋‹ค. TypeScript์—์„œ๋„ ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด ๊ฒฝ์šฐ์—๋Š” TSX๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ๋ชจ๋“  HTML์„ JSX ๋ฌธ๋ฒ•์„ ํ†ตํ•ด JavaScript ์ฝ”๋“œ ์•ˆ์— ์ž‘์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. JSX์— ์ด๋ฏธ ์ต์ˆ™ํ•œ ๋ถ„๋“ค์€ ์ด ์žฅ์„ ๋„˜๊ธฐ์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <div> ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๋Š” HTML ํŠธ๋ฆฌ๋ฅผ html ๋ณ€์ˆ˜์— ๋Œ€์ž…ํ•ฉ๋‹ˆ๋‹ค:

const html = <div>
  <p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p>
</div>;

์ค‘๊ด„ํ˜ธ๋ฅผ ํ†ตํ•ด JavaScript ํ‘œํ˜„์‹์„ ๋„ฃ๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฝ”๋“œ๋Š” ๋ฌผ๋ก  getName() ํ•จ์ˆ˜๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค):

const html = <div title={"์•ˆ๋…•, " + getName() + "!"}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</div>;

JSX์˜ ํŠน์ง• ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ(component)๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์ž์‹ ๋งŒ์˜ ํƒœ๊ทธ๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋Š” ํ‰๋ฒ”ํ•œ JavaScript ํ•จ์ˆ˜๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค (์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„์€ ์ผ๋ฐ˜์ ์œผ๋กœ PascalCase ์Šคํƒ€์ผ์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค):

import type { FC } from "hono/jsx";

function getName() {
  return "JSX";
}

interface ContainerProps {
  name: string;
}

const Container: FC<ContainerProps> = (props) => {
  return <div title={"์•ˆ๋…•, " + props.name + "!"}>{props.children}</div>;
};

const html = <Container name={getName()}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</Container>;

์œ„ ์ฝ”๋“œ์—์„œ FC๋Š” ์šฐ๋ฆฌ๊ฐ€ ์“ธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ธ Hono์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋„์™€์ค๋‹ˆ๋‹ค. FC๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž…(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ˜๋Ÿผ ํ™”์‚ด๊ด„ํ˜ธ ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ํƒ€์ž…๋“ค์ด ๋ฐ”๋กœ ํƒ€์ž… ์ธ์ž๋“ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํƒ€์ž… ์ธ์ž๋กœ ํ”„๋กญ(props) ํ˜•์‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กญ์ด๋ž€, ์ปดํฌ๋„ŒํŠธ์—๊ฒŒ ๋„˜๊ฒจ ์ค„ ๋งค๊ฐœ๋ณ€์ˆ˜๋“ค์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ง์ž…๋‹ˆ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กญ ํ˜•์‹์œผ๋กœ ContainerProps ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์„ ์–ธํ•˜๊ณ  ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ €๋„ค๋ฆญ ํƒ€์ž…์˜ ํƒ€์ž… ์ธ์ž๋Š” ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‰ผํ‘œ๋กœ ๊ฐ ์ธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Foo<A, B>๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž… Foo์— ํƒ€์ž… ์ธ์ž A์™€ B๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ, ์ €๋„ค๋ฆญ ํ•จ์ˆ˜๋ผ๋Š” ๊ฒƒ๋„ ์žˆ์œผ๋ฉฐ, someFunction<A, B>(foo, bar)์™€ ๊ฐ™์ด ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ธ์ž๊ฐ€ ํ•˜๋‚˜์ผ ๋•Œ๋Š” ํƒ€์ž… ์ธ์ž๋ฅผ ๊ฐ์‹ธ๋Š” ํ™”์‚ด๊ด„ํ˜ธ๊ฐ€ ๋งˆ์น˜ XML/HTML ํƒœ๊ทธ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ, JSX์˜ ๊ธฐ๋Šฅ๊ณผ๋Š” ์•„๋ฌด ๊ด€๋ จ์ด ์—†์Šต๋‹ˆ๋‹ค.

FC<ContainerProps>
์ €๋„ค๋ฆญ ํƒ€์ž… FC์— ํƒ€์ž… ์ธ์ž ContainerProps๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ.
<Container>
<Container>๋ผ๋Š” ์ด๋ฆ„์˜ ์ปดํฌ๋„ŒํŠธ ํƒœ๊ทธ๋ฅผ ์—ฐ ๊ฒƒ. </Container>๋กœ ๋‹ซ์•„์•ผ ํ•จ.

ํ”„๋กญ์œผ๋กœ ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ๋“ค ์ค‘ children์€ ํŠน๋ณ„ํžˆ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์—˜๋ฆฌ๋จผํŠธ๋“ค์ด children ํ”„๋กญ์œผ๋กœ ๋„˜์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์œ„ ์ฝ”๋“œ์—์„œ html ๋ณ€์ˆ˜์—๋Š” <div title="์•ˆ๋…•, JSX!"><p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p></div>๋ผ๋Š” HTML ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŒŒ์ผ ์•ˆ์— JSX๋กœ <Layout> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

import type { FC } from "hono/jsx";

export const Layout: FC = (props) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="color-scheme" content="light dark" />
      <title>Microblog</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
      />
    </head>
    <body>
      <main class="container">{props.children}</main>
    </body>
  </html>
);

๋””์ž์ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ๊ณต์„ ๋“ค์ด์ง€ ์•Š๊ธฐ ์œ„ํ•ด, Pico CSS๋ผ๋Š” CSS ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ TypeScript์˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ธฐ๊ฐ€ ์ถ”๋ก ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ, ์œ„์˜ props ๊ฐ™์ด ํƒ€์ž… ํ‘œ๊ธฐ๋ฅผ ์ƒ๋žตํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํƒ€์ž… ํ‘œ๊ธฐ๊ฐ€ ์ƒ๋žต๋œ ๊ฒฝ์šฐ์—๋„, Visual Studio Code์—์„œ ๋ณ€์ˆ˜ ์ด๋ฆ„ ์œ„์— ๋งˆ์šฐ์Šค ์ปค์„œ๋ฅผ ๊ฐ€์ ธ๋‹ค ๋Œ€๋ฉด ํ•ด๋‹น ๋ณ€์ˆ˜๊ฐ€ ์–ด๋–ค ํƒ€์ž…์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ ๋‹ค์Œ, ๊ฐ™์€ ํŒŒ์ผ์—์„œ ๋ ˆ์ด์•„์›ƒ ์•ˆ์— ๋“ค์–ด๊ฐˆ <SetupForm> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

JSX์—์„œ๋Š” ์ตœ์ƒ์œ„์— ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ๋Š”๋ฐ, <SetupForm> ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” <h1>๊ณผ <form> ๋‘ ๊ฐœ์˜ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ์ตœ์ƒ์œ„์— ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด๋ฅผ ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ์ฒ˜๋Ÿผ ๋ฌถ์–ด์ฃผ๊ธฐ ์œ„ํ•ด์„œ ๋นˆ ํƒœ๊ทธ ๋ชจ์–‘์˜ <>์™€ </>๋กœ ๊ฐ์ŒŒ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”„๋ž˜๊ทธ๋จผํŠธ(fragment)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. src/app.tsx ํŒŒ์ผ์—์„œ ์•ž์„œ ์ •์˜ํ•œ ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ /setup ํŽ˜์ด์ง€์—์„œ ์•ž์„œ ๋งŒ๋“  ๊ณ„์ • ์ƒ์„ฑ ์–‘์‹์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) =>
  c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  ),
);

์ž, ๊ทธ๋Ÿผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋ณด์—ฌ์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•ˆ๋‚ด

JSX๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์†Œ์Šค ํŒŒ์ผ์˜ ํ™•์žฅ์ž๊ฐ€ .jsx ๋˜๋Š” .tsx์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์žฅ์—์„œ ํŽธ์ง‘ํ•œ ๋‘ ํŒŒ์ผ ๋ชจ๋‘ ํ™•์žฅ์ž๊ฐ€ .tsx๋ผ๋Š” ์‚ฌ์‹ค์— ์ฃผ์˜ํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์…‹์—…

์ž, ๋ณด์ด๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๋™์ž‘์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ณณ์ด ํ•„์š”ํ•œ๋ฐ, SQLite๋ฅผ ์“ฐ๋„๋ก ํ•ฉ์‹œ๋‹ค. SQLite๋Š” ์ž‘์€ ๊ทœ๋ชจ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์•Œ๋งž๋Š” ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ž…๋‹ˆ๋‹ค.

์šฐ์„  ๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ด์„ ํ…Œ์ด๋ธ”์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. ์•ž์œผ๋กœ ๋ชจ๋“  ํ…Œ์ด๋ธ” ์„ ์–ธ์€ src/schema.sql ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋Š” users ํ…Œ์ด๋ธ”์— ๋‹ด์Šต๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS users (
  id       INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
  username TEXT    NOT NULL UNIQUE      CHECK (trim(lower(username)) = username
                                               AND username <> ''
                                               AND length(username) <= 50)
);

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ฏ€๋กœ, ๊ธฐ๋ณธ ํ‚ค์ธ id ์นผ๋Ÿผ์ด 1 ์ด์™ธ์˜ ๊ฐ’์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ๊ฑธ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ users ํ…Œ์ด๋ธ”์—๋Š” ๋‘˜ ์ด์ƒ์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ด์„ ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ณ„์ • ์•„์ด๋””๋ฅผ ๋‹ด์„ username ์นผ๋Ÿผ์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋‚˜ ๋„ˆ๋ฌด ๊ธด ๋ฌธ์ž์—ด์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ์คฌ์Šต๋‹ˆ๋‹ค.

์ด์ œ users ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ src/schema.sql ํŒŒ์ผ์„ ์‹คํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด sqlite3 ๋ช…๋ น์–ด๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, SQLite ์›น์‚ฌ์ดํŠธ์—์„œ ๋ฐ›๊ฑฐ๋‚˜ ๊ฐ ํ”Œ๋žซํผ์˜ ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋กœ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. macOS์˜ ๊ฒฝ์šฐ์—๋Š” ์šด์˜์ฒด์ œ์— ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ๋”ฐ๋กœ ๋ฐ›์„ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ๋ฐ›์„ ๊ฒฝ์šฐ ์šด์˜์ฒด์ œ์— ๋งž๋Š” sqlite-tools-*.zip ํŒŒ์ผ์„ ๋ฐ›์•„์„œ ์••์ถ•์„ ํ•ด์ œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์„ค์น˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

sudo apt install sqlite3  # Debian ๋ฐ Ubuntu
sudo dnf install sqlite   # Fedora ๋ฐ RHEL
choco install sqlite  # Chocolatey
scoop install sqlite  # Scoop
winget install SQLite.SQLite  # Windows Package Manager

์ž, sqlite3 ๋ช…๋ น์–ด๊ฐ€ ์ค€๋น„๋˜์—ˆ๋‹ค๋ฉด ์ด์ œ ์ด๋ฅผ ์ด์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด microblog.sqlite3 ํŒŒ์ผ์ด ์ƒ๊ธฐ๋Š”๋ฐ, ์ด ์•ˆ์— SQLite ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

์•ฑ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ

์ด์ œ ์ €ํฌ๊ฐ€ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•  ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. Node.js์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SQLite ๋“œ๋ผ์ด๋ฒ„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, ์ €ํฌ๋Š” better-sqlite3 ํŒจํ‚ค์ง€๋ฅผ ์“ฐ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€๋Š” npm ๋ช…๋ น์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊น” ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

npm add better-sqlite3
npm add --save-dev @types/better-sqlite3

ํŒ

@types/better-sqlite3 ํŒจํ‚ค์ง€๋Š” TypeScript๋ฅผ ์œ„ํ•ด better-sqlite ํŒจํ‚ค์ง€์˜ API์— ๋Œ€ํ•œ ํƒ€์ž… ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ Visual Studio Code์—์„œ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™ ์™„์„ฑ์ด๋‚˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด์™€ ๊ฐ™์ด @types/ ๋ฒ”์œ„ ์•ˆ์— ์žˆ๋Š” ํŒจํ‚ค์ง€๋ฅผ Definitely Typed ํŒจํ‚ค์ง€๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ TypeScript๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š์•˜์„ ๋•Œ, ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ํƒ€์ž… ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ ๊ธฐ์ž…ํ•˜์—ฌ ํŒจํ‚ค์ง€๋กœ ๋งŒ๋“  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ, ์ด ํŒจํ‚ค์ง€๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งญ์‹œ๋‹ค. src/db.ts๋ผ๋Š” ์ƒˆ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค:

import Database from "better-sqlite3";

const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export default db;

ํŒ

์ฐธ๊ณ ๋กœ db.pragma() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•œ ์„ค์ •์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์ง€๋‹™๋‹ˆ๋‹ค:

journal_mode = WAL
SQLite์—์„œ ์›์ž์  ์ปค๋ฐ‹ ๋ฐ ๋กค๋ฐฑ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ ์„ ํ–‰ ๊ธฐ์ž… ๋ชจ๋“œ๋ฅผ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ชจ๋“œ๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ ๋กค๋ฐฑ ์ €๋„ ๋ชจ๋“œ์— ๋น„ํ•ด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ์—์„œ ๋” ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.
foreign_keys = ON
SQLite์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด ์„ค์ •์„ ์ผœ๋ฉด ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋˜์–ด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ์ง€ํ‚ค๋Š” ๋ฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  users ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. src/schema.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด User ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface User {
  id: number;
  username: string;
}

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ–ˆ์œผ๋‹ˆ, ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ฝ์ž…ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…์— ์“ฐ์ผ db ๊ฐ์ฒด์™€ User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import db from "./db.ts";
import type { User } from "./schema.ts";

POST /setup ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
  return c.redirect("/");
});

์•ž์„œ ๋งŒ๋“ค์—ˆ๋˜ GET /setup ํ•ธ๋“ค๋Ÿฌ์—๋„ ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});

ํ…Œ์ŠคํŠธ

์ด์ œ ๊ณ„์ • ์ƒ์„ฑ ๊ธฐ๋Šฅ์ด ์–ผ์ถ” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ, ํ•œ ๋ฒˆ ์จ ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๊ณ„์ •์„ ์ƒ์„ฑํ•ด ๋ณด์„ธ์š”. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•ž์œผ๋กœ ์•„์ด๋””๋กœ johndoe๋ฅผ ์ผ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋˜์—ˆ๋‹ค๋ฉด, SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‚˜ ํ™•์ธ๋„ ํ•ด ๋ด…๋‹ˆ๋‹ค:

echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3

๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค (๋ฌผ๋ก , johndoe๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 johndoe

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ด์ œ ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ ๊ณ„์ • ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค. ๋น„๋ก ๋ณด์—ฌ ์ค„ ์ •๋ณด๊ฐ€ ๊ฑฐ์˜ ์—†์ง€๋งŒ์š”.

์ด๋ฒˆ์—๋„ ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์ž‘์—…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์— <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  handle: string;
}

export const Profile: FC<ProfileProps> = ({ name, handle }) => (
  <>
    <hgroup>
      <h1>{name}</h1>
      <p style="user-select: all;">{handle}</p>
    </hgroup>
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์—์„œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” GET /users/{username} ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.username} handle={handle} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด ์ด์ œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด ๋ด์•ผ๊ฒ ์ฃ ? ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe (๊ณ„์ • ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ํ–ˆ์„ ๊ฒฝ์šฐ; ์•„๋‹ˆ๋ผ๋ฉด URL์„ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค) ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ณด์„ธ์š”. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ

์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค(fediverse handle), ์ค„์—ฌ์„œ ํ•ธ๋“ค์ด๋ž€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด์—์„œ ๊ณ„์ •์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ณ ์œ ํ•œ ์ฃผ์†Œ ๊ฐ™์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด @hongminhee@fosstodon.org์ฒ˜๋Ÿผ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ƒ๊ฒผ๋Š”๋ฐ, ์‹ค์ œ ๊ตฌ์„ฑ๋„ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•ฉ๋‹ˆ๋‹ค. ๋งจ ์ฒ˜์Œ์— @์ด ์˜ค๊ณ , ๊ทธ ๋‹ค์Œ์— ์ด๋ฆ„, ๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ @์ด ์˜จ ๋’ค, ๋งˆ์ง€๋ง‰์— ๊ณ„์ •์ด ์†ํ•œ ์„œ๋ฒ„์˜ ๋„๋ฉ”์ธ ์ด๋ฆ„์ด ์˜ต๋‹ˆ๋‹ค. ๋•Œ๋•Œ๋กœ ๋งจ ์•ž์˜ @์ด ์ƒ๋žต๋˜๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ํ•ธ๋“ค์€ WebFinger์™€ acct: URI ํ˜•์‹์ด๋ผ๋Š” ๋‘ ๊ฐœ์˜ ํ‘œ์ค€์œผ๋กœ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. Fedify๊ฐ€ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ง„ํ–‰ํ•˜๋Š” ๋™์•ˆ ์—ฌ๋Ÿฌ๋ถ„์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์•Œ์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

์•กํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

ActivityPub์€ ๊ทธ ์ด๋ฆ„์—์„œ๋„ ๋“œ๋Ÿฌ๋‚˜๋“ฏ, ์•กํ‹ฐ๋น„ํ‹ฐ(activity)๋ฅผ ์ฃผ๊ณ  ๋ฐ›๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค. ๊ธ€์“ฐ๊ธฐ, ๊ธ€ ๊ณ ์น˜๊ธฐ, ๊ธ€ ์ง€์šฐ๊ธฐ, ๊ธ€์— ์ข‹์•„์š” ์ฐ๊ธฐ, ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ, ํ”„๋กœํ•„ ๊ณ ์น˜๊ธฐโ€ฆ ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ ์ผ์–ด๋‚˜๋Š” ๋ชจ๋“  ์ผ๋“ค์„ ์•กํ‹ฐ๋น„ํ‹ฐ๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋ชจ๋“  ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์•กํ„ฐ(actor)์—์„œ ์•กํ„ฐ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํ™๊ธธ๋™์ด ๊ธ€์„ ์“ฐ๋ฉด ใ€Œ๊ธ€์“ฐ๊ธฐใ€(Create(Note)) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ™๊ธธ๋™์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์˜ ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๊ธ€์— ์ž„๊บฝ์ •์ด ์ข‹์•„์š”๋ฅผ ์ฐ์œผ๋ฉด ใ€Œ์ข‹์•„์š”ใ€(Like) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž„๊บฝ์ •์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ActivityPub์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๊ฑธ์Œ์€ ์•กํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

fedify init ๋ช…๋ น์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ๋ชจ ์•ฑ์— ์ด๋ฏธ ์•„์ฃผ ๊ฐ„๋‹จํ•œ ์•กํ„ฐ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธด ํ•˜์ง€๋งŒ, Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ์‹ค์ œ์˜ ์†Œํ”„ํŠธ์›จ์–ด๋“ค๊ณผ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•กํ„ฐ๋ฅผ ์ข€ ๋” ์ œ๋Œ€๋กœ ๊ตฌํ˜„ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋‹จ, ํ˜„์žฌ์˜ ๊ตฌํ˜„์„ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณผ๊นŒ์š”? src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด๋ด…์‹œ๋‹ค:

import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";

const logger = getLogger("microblog");

const federation = createFederation({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
});

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: identifier,
  });
});

export default federation;

์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๋ถ€๋ถ„์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ๋‹ค๋ฅธ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์„œ๋ฒ„์˜ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์“ธ URL๊ณผ ๊ทธ ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์šฐ๋ฆฌ๊ฐ€ ์•ž์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ /users/johndoe๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ identifier ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ "johndoe"๋ผ๋Š” ๋ฌธ์ž์—ด ๊ฐ’์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋Š” Person ํด๋ž˜์Šค์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์กฐํšŒํ•œ ์•กํ„ฐ์˜ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

ctx ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ๋Š” Context ๊ฐ์ฒด๊ฐ€ ๋„˜์–ด์˜ค๋Š”๋ฐ, ActivityPub ํ”„๋กœํ† ์ฝœ๊ณผ ๊ด€๋ จ๋œ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์œ„ ์ฝ”๋“œ์—์„œ ์“ฐ์ด๊ณ  ์žˆ๋Š” getActorUri() ๋ฉ”์„œ๋“œ๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌ๋œ identifier๊ฐ€ ๋“ค์–ด๊ฐ„ ์•กํ„ฐ์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด URI๋Š” Person ๊ฐ์ฒด์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋กœ ์“ฐ์ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ๋ณด์‹œ๋ฉด ์•Œ๊ฒ ์ง€๋งŒ, ํ˜„์žฌ๋Š” /users/ ๊ฒฝ๋กœ ๋’ค์— ์–ด๋–ค ํ•ธ๋“ค์ด ์˜ค๋“  ๋ถ€๋ฅด๋Š” ๋Œ€๋กœ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์ง€์–ด๋‚ด์„œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ณ ์ณ๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ…Œ์ด๋ธ”์€ ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •๋งŒ ๋‹ด๋Š” users ํ…Œ์ด๋ธ”๊ณผ ๋‹ฌ๋ฆฌ, ์—ฐํ•ฉ๋˜๋Š” ์„œ๋ฒ„๋“ค์— ์†ํ•œ ์›๊ฒฉ ์•กํ„ฐ๋“ค๊นŒ์ง€๋„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. src/schema.sql ํŒŒ์ผ์— ๋‹ค์Œ SQL์„ ๋ง๋ถ™์ด์„ธ์š”:

CREATE TABLE IF NOT EXISTS actors (
  id               INTEGER NOT NULL PRIMARY KEY,
  user_id          INTEGER          REFERENCES users (id),
  uri              TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  handle           TEXT    NOT NULL UNIQUE CHECK (handle <> ''),
  name             TEXT,
  inbox_url        TEXT    NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%'
                                                  OR inbox_url LIKE 'http://%'),
  shared_inbox_url TEXT                    CHECK (shared_inbox_url
                                                  LIKE 'https://%'
                                                  OR shared_inbox_url
                                                  LIKE 'http://%'),
  url              TEXT                    CHECK (url LIKE 'https://%'
                                                  OR url LIKE 'http://%'),
  created          TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                           CHECK (created <> '')
);
  • user_id ์นผ๋Ÿผ์€ users ์นผ๋Ÿผ๊ณผ ์—ฐ๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ์™ธ๋ž˜ ํ‚ค์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๊ฐ€ ์›๊ฒฉ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” NULL์ด ๋“ค์–ด๊ฐ€์ง€๋งŒ, ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •์ด๋ผ๋ฉด ํ•ด๋‹น ๊ณ„์ •์˜ users.id ๊ฐ’์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ์•กํ„ฐ ID๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•กํ„ฐ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ActivityPub ๊ฐ์ฒด๋Š” URI ํ˜•ํƒœ์˜ ๊ณ ์œ  ID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • handle ์นผ๋Ÿผ์€ @johndoe@example.com ๋ชจ์–‘์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • name ์นผ๋Ÿผ์€ UI์— ํ‘œ์‹œ๋˜๋Š” ์ด๋ฆ„์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋ณดํ†ต ํ’€๋„ค์ž„์ด๋‚˜ ๋‹‰๋„ค์ž„์ด ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๊ฒ ์ฃ . ๋‹ค๋งŒ, ActivityPub ๋ช…์„ธ์— ๋”ฐ๋ผ ์ด ์นผ๋Ÿผ์€ ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ(inbox) URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ์ˆ˜์‹ ํ•จ์ด ๋ฌด์—‡์ธ์ง€์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค๋งŒ, ํ˜„์žฌ๋กœ์„œ๋Š” ์•กํ„ฐ์—๊ฒŒ ํ•„์ˆ˜์ ์œผ๋กœ ์กด์žฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ๋งŒ ์•Œ์•„ ๋‘์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด ์นผ๋Ÿผ ์—ญ์‹œ ๋นŒ ์ˆ˜๋„ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • shared_inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ(shared inbox) URL์„ ๋‹ด๋Š”๋ฐ, ์ด ์—ญ์‹œ ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜๋Š” ์•„๋‹ˆ๋ฉฐ, ๋”ฐ๋ผ์„œ ๋นŒ ์ˆ˜ ์žˆ๊ณ  ์นผ๋Ÿผ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ๊ฐ™์€ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ URL์ด๋ž€ ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด์„œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ URL์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ID์™€ ํ”„๋กœํ•„ URL์ด ๋™์ผํ•œ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ์„œ๋น„์Šค์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ ๊ฒฝ์šฐ์— ์ด ์นผ๋Ÿผ์— ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์€ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋œ ์‹œ์ ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฝ์ž… ์‹œ์  ์‹œ๊ฐ์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.

์ž, ์ด์ œ src/schema.sql ํŒŒ์ผ์„ microblog.sqlite3 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์— ์ ์šฉํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

ํŒ

์•ž์„œ users ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•  ๋•Œ CREATE TABLE IF NOT EXISTS ๋ฌธ์„ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  actors ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  ํƒ€์ž…๋„ src/schema.ts์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Actor {
  id: number;
  user_id: number | null;
  uri: string;
  handle: string;
  name: string | null;
  inbox_url: string;
  shared_inbox_url: string | null;
  url: string | null;
  created: string;
}

์•กํ„ฐ ๋ ˆ์ฝ”๋“œ

ํ˜„์žฌ users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜ ์žˆ๊ธด ํ•˜์ง€๋งŒ, ์ด์™€ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์—๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ actors ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ์„œ users์™€ actors ์–‘์ชฝ์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx์— ์žˆ๋Š” SetupForm์—์„œ ์•„์ด๋””์™€ ํ•จ๊ป˜ actors.name ์นผ๋Ÿผ์— ๋“ค์–ด๊ฐˆ ์ด๋ฆ„๋„ ์ž…๋ ฅ ๋ฐ›๋„๋ก ํ•ฉ์‹œ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
        <label>
          Name <input type="text" name="name" required />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

์•ž์„œ ์ •์˜ํ•œ Actor ํƒ€์ž…์„ src/app.tsx์—์„œ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

์ด์ œ ์ž…๋ ฅ ๋ฐ›์€ ์ด๋ฆ„์„ ๋น„๋กฏํ•ด ํ•„์š”ํ•œ ์ •๋ณด๋“ค์„ actors ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ๋กœ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ๋ฅผ POST /setup ํ•ธ๋“ค๋Ÿฌ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  const name = form.get("name");
  if (typeof name !== "string" || name.trim() === "") {
    return c.redirect("/setup");
  }
  const url = new URL(c.req.url);
  const handle = `@${username}@${url.host}`;
  const ctx = fedi.createContext(c.req.raw, undefined);
  db.transaction(() => {
    db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
      username,
    );
    db.prepare(
      `
      INSERT OR REPLACE INTO actors
        (user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
      VALUES (1, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      ctx.getActorUri(username).href,
      handle,
      name,
      ctx.getInboxUri(username).href,
      ctx.getInboxUri().href,
      ctx.getActorUri(username).href,
    );
  })();
  return c.redirect("/");
});

๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•  ๋•Œ, users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์„ ๋•Œ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์—†์–ด๋„ ์•„์ง ๊ณ„์ •์ด ์—†๋Š” ๊ฒƒ์œผ๋กœ ํŒ์ •ํ•˜๋„๋ก ๊ณ ์ณค์Šต๋‹ˆ๋‹ค. ๊ฐ™์€ ์กฐ๊ฑด์„ GET /setup ํ•ธ๋“ค๋Ÿฌ ๋ฐ GET /users/{username} ํ•ธ๋“ค๋Ÿฌ์—๋„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // Check if the user already exists
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});
app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE username = ?
      `,
    )
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.name ?? user.username} handle={handle} />
    </Layout>,
  );
});

ํŒ

TypeScript์—์„œ A & B๋Š” A ํƒ€์ž…์ธ ๋™์‹œ์— B ํƒ€์ž…์ธ ๊ฐ์ฒด๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, { a: number } & { b: string } ํƒ€์ž…์ด ์žˆ๋‹ค๊ณ  ํ•  ๋•Œ, { a: 123 }์ด๋‚˜ { b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ•˜์ง€๋งŒ, { a: 123, b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด, ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ์•„๋ž˜์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

setInboxListeners() ๋ฉ”์„œ๋“œ๋Š” ์ง€๊ธˆ์œผ๋กœ์„œ๋Š” ์‹ ๊ฒฝ ์“ฐ์ง€ ๋งˆ์„ธ์š”. ์ด ์—ญ์‹œ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•œ getInboxUri() ๋ฉ”์„œ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋ ค๋ฉด ์œ„ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

์ฝ”๋“œ๋ฅผ ๋ชจ๋‘ ๊ณ ์ณค๋‹ค๋ฉด, ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด์„œ ๋‹ค์‹œ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค๊ณ  ๋ ˆ์ฝ”๋“œ๋„ ์ฑ„์› ์œผ๋‹ˆ, ๋‹ค์‹œ src/federation.ts ํŒŒ์ผ์„ ๊ณ ์ณ๋ด…์‹œ๋‹ค. ๋จผ์ € db ๊ฐ์ฒด์™€ Endpoints ๋ฐ Actor๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";

ํ•„์š”ํ•œ ๊ฒƒ๋“ค์„ importํ–ˆ์œผ๋‹ˆ setActorDispatcher() ๋ฉ”์„œ๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค:

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE users.username = ?
      `,
    )
    .get(identifier);
  if (user == null) return null;

  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: user.name,
    inbox: ctx.getInboxUri(identifier),
    endpoints: new Endpoints({
      sharedInbox: ctx.getInboxUri(),
    }),
    url: ctx.getActorUri(identifier),
  });
});

๋ฐ”๋€ ์ฝ”๋“œ์—์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ users ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์—ฌ ํ˜„์žฌ ์„œ๋ฒ„์— ์žˆ๋Š” ๊ณ„์ •์ด ์•„๋‹ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, GET /users/johndoe (๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ์ •ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ๊ฒฝ์šฐ) ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” ์˜ฌ๋ฐ”๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์™€ ํ•จ๊ป˜ ์‘๋‹ตํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ์™ธ์˜ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” 404 Not Found๋ฅผ ์‘๋‹ตํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Person ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ถ€๋ถ„๋„ ์–ด๋–ป๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‚˜ ์‚ดํŽด๋ด…์‹œ๋‹ค. ๋จผ์ € name ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” actors.name ์นผ๋Ÿผ์˜ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. inbox์™€ endpoints ์†์„ฑ์€ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. url ์†์„ฑ์€ ์ด ๊ณ„์ •์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด๋Š”๋ฐ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•กํ„ฐ ID์™€ ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ์ผ์น˜์‹œํ‚ค๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

๋ˆˆ์ฐ๋ฏธ๊ฐ€ ์ข‹์€ ๋ถ„๋“ค์€ ๋ˆˆ์น˜์ฑ„์…จ๊ฒ ์ง€๋งŒ, Hono์™€ Fedify ์–‘์ชฝ์—์„œ GET /users/{identifier}์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฒน์ณ์„œ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ํ•ด๋‹น ์š”์ฒญ์„ ์‹ค์ œ๋กœ ๋ณด๋‚ด๋ฉด ์–ด๋А ์ชฝ์—์„œ ์‘๋‹ตํ•˜๊ฒŒ ๋ ๊นŒ์š”? ์ •๋‹ต์€ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Accept: text/html ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Hono ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. Accept: application/activity+json ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Fedify ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต์„ ์ฃผ๋Š” ๋ฐฉ์‹์„ HTTP ๋‚ด์šฉ ํ˜‘์ƒ(content negotiation)์ด๋ผ๊ณ  ํ•˜๋ฉฐ, Fedify ์ž์ฒด์—์„œ ๋‚ด์šฉ ํ˜‘์ƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์ข€ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ๋Š”, ๋ชจ๋“  ์š”์ฒญ์€ Fedify๋ฅผ ํ•œ ๋ฒˆ ๊ฑฐ์น˜๊ฒŒ ๋˜๋ฉฐ, ActivityPub๊ณผ ๊ด€๋ จ๋œ ์š”์ฒญ์ด ์•„๋‹ ๊ฒฝ์šฐ ์—ฐ๋™๋œ ํ”„๋ ˆ์ž„์›Œํฌ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Hono์—๊ฒŒ ์š”์ฒญ์„ ๊ฑด๋‚ด์ฃผ๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

Fedify์—์„œ๋Š” ๋ชจ๋“  URI ๋ฐ URL์„ URL ์ธ์Šคํ„ด์Šค๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ทธ๋Ÿผ ํ•œ ๋ฒˆ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ํ…Œ์ŠคํŠธํ•ด ๋ณผ๊นŒ์š”?

์„œ๋ฒ„๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด์–ด ์•„๋ž˜ ๋ช…๋ น์„ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/alice

alice์ด๋ผ๋Š” ๊ณ„์ •์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์•„๊นŒ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Failed to fetch the object.
It may be a private object.  Try with -a/--authorized-fetch.

๊ทธ๋Ÿผ johndoe ๊ณ„์ •๋„ ์กฐํšŒํ•ด ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์ด์ œ๋Š” ๊ฒฐ๊ณผ๊ฐ€ ์ž˜ ๋‚˜์˜ต๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

์•”ํ˜ธ ํ‚ค ์Œ๋“ค

๊ทธ ๋‹ค์Œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์„œ๋ช…์„ ์œ„ํ•œ ์•กํ„ฐ์˜ ์•”ํ˜ธ ํ‚ค๋“ค์ž…๋‹ˆ๋‹ค. ActivityPub์€ ์•กํ„ฐ๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋Š”๋ฐ, ์ด ๋•Œ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ •๋ง๋กœ ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ๋งŒ๋“ค์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋””์ง€ํ„ธ ์„œ๋ช…์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์•กํ„ฐ๋Š” ์ง์ด ๋งž๋Š” ์ž์‹ ๋งŒ์˜ ๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค) ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ๋งŒ๋“ค์–ด ๊ฐ–๊ณ  ์žˆ๊ณ , ๊ทธ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๋„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๊ณต๊ฐœํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ๋“ค์€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ ๋ฐœ์‹ ์ž์˜ ๊ณต๊ฐœ ํ‚ค์™€ ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์„œ๋ช…์„ ๋Œ€์กฐํ•˜์—ฌ ๊ทธ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •๋ง๋กœ ๋ฐœ์‹ ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒŒ ๋งž๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ช…๊ณผ ์„œ๋ช… ๋Œ€์กฐ๋Š” Fedify๊ฐ€ ์•Œ์•„์„œ ํ•ด ์ฃผ์ง€๋งŒ, ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ณด์กดํ•˜๋Š” ๊ฒƒ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์…”์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค)๋Š” ์ด๋ฆ„์—์„œ ๋“œ๋Ÿฌ๋‚˜๋“ฏ ์„œ๋ช…ํ•  ์ฃผ์ฒด ์ด์™ธ์—๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, ๊ณต๊ฐœ ํ‚ค๋Š” ๊ทธ ์šฉ๋„ ์ž์ฒด๊ฐ€ ๊ณต๊ฐœํ•˜๊ธฐ ์œ„ํ•จ์ด๋ฏ€๋กœ ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ €์žฅํ•  keys ํ…Œ์ด๋ธ”์„ src/schema.sql์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS keys (
  user_id     INTEGER NOT NULL REFERENCES users (id),
  type        TEXT    NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
  private_key TEXT    NOT NULL CHECK (private_key <> ''),
  public_key  TEXT    NOT NULL CHECK (public_key <> ''),
  created     TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
  PRIMARY KEY (user_id, type)
);

ํ…Œ์ด๋ธ”์„ ์œ ์‹ฌํžˆ ์‚ดํŽด๋ณด๋ฉด, type ์นผ๋Ÿผ์—๋Š” ์˜ค์ง ๋‘ ์ข…๋ฅ˜์˜ ๊ฐ’๋งŒ ํ—ˆ์šฉ๋œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜๋‚˜๋Š” RSA-PKCS#1-v1.5 ํ˜•์‹์ด๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” Ed25519 ํ˜•์‹์ž…๋‹ˆ๋‹ค. (๊ฐ๊ฐ์ด ๋ฌด์—‡์„ ๋œปํ•˜๋Š”์ง€๋Š” ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ค‘์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.) ๊ธฐ๋ณธ ํ‚ค๊ฐ€ (user_id, type)์— ๊ฑธ๋ ค ์žˆ์œผ๋‹ˆ, ํ•œ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•ด ์ตœ๋Œ€ ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์ˆ˜๋Š” ์—†์ง€๋งŒ, 2024๋…„ 9์›” ํ˜„์žฌ ActivityPub ๋„คํŠธ์›Œํฌ๋Š” RSA-PKCS-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-v1.5 ํ˜•์‹๋งŒ ๋ฐ›์•„๋“ค์ด๊ณ  ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” Ed25519 ํ˜•์‹์„ ๋ฐ›์•„๋“ค์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์–‘์ชฝ ๋ชจ๋‘์™€ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ๋ชจ๋‘ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

private_key ๋ฐ public_key ์นผ๋Ÿผ์€ ๋ฌธ์ž์—ด์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์— JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์„ ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. ๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค๋ฅผ JSON์œผ๋กœ ์ธ์ฝ”๋”ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋’ค์—์„œ ์ฐจ์ฐจ ๋‹ค๋ฃจ๊ฒŒ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ keys ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

keys ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  Key ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Key {
  user_id: number;
  type: "RSASSA-PKCS1-v1_5" | "Ed25519";
  private_key: string;
  public_key: string;
  created: string;
}

ํ‚ค ์Œ ๋””์ŠคํŒจ์ฒ˜

์ด์ œ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด๊ณ  Fedify์—์„œ ์ œ๊ณต๋˜๋Š” exportJwk(), generateCryptoKeyPair(), importJwk() ํ•จ์ˆ˜๋“ค๊ณผ ์•ž์„œ ์ •์˜ํ•œ Key ํƒ€์ž…์„ importํ•ฉ์‹œ๋‹ค:

import {
  Endpoints,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ๋ถ€๋ถ„์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User & Actor>(
        `
        SELECT * FROM users
        JOIN actors ON (users.id = actors.user_id)
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    if (user == null) return null;

    const keys = await ctx.getActorKeyPairs(identifier);
    return new Person({
      id: ctx.getActorUri(identifier),
      preferredUsername: identifier,
      name: user.name,
      inbox: ctx.getInboxUri(identifier),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      url: ctx.getActorUri(identifier),
      publicKey: keys[0].cryptographicKey,
      assertionMethods: keys.map((k) => k.multikey),
    });
  })
  .setKeyPairsDispatcher(async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
      .get(identifier);
    if (user == null) return [];
    const rows = db
      .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
      .all(user.id);
    const keys = Object.fromEntries(
      rows.map((row) => [row.type, row]),
    ) as Record<Key["type"], Key>;
    const pairs: CryptoKeyPair[] = [];
    // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์›ํ•˜๋Š” ๋‘ ํ‚ค ํ˜•์‹ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์— ๋Œ€ํ•ด
    // ํ‚ค ์Œ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ:
    for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
      if (keys[keyType] == null) {
        logger.debug(
          "The user {identifier} does not have an {keyType} key; creating one...",
          { identifier, keyType },
        );
        const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
        db.prepare(
          `
          INSERT INTO keys (user_id, type, private_key, public_key)
          VALUES (?, ?, ?, ?)
          `,
        ).run(
          user.id,
          keyType,
          JSON.stringify(await exportJwk(privateKey)),
          JSON.stringify(await exportJwk(publicKey)),
        );
        pairs.push({ privateKey, publicKey });
      } else {
        pairs.push({
          privateKey: await importJwk(
            JSON.parse(keys[keyType].private_key),
            "private",
          ),
          publicKey: await importJwk(
            JSON.parse(keys[keyType].public_key),
            "public",
          ),
        });
      }
    }
    return pairs;
  });

์šฐ์„  ๊ฐ€์žฅ ๋จผ์ € ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๊ฒƒ์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์— ์—ฐ๋‹ฌ์•„ ํ˜ธ์ถœ๋˜๊ณ  ์žˆ๋Š” setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜์—์„œ ๋ฐ˜ํ™˜๋œ ํ‚ค ์Œ๋“ค์„ ๊ณ„์ •์— ์—ฐ๊ฒฐํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ‚ค ์Œ๋“ค์„ ์—ฐ๊ฒฐํ•ด์•ผ Fedify๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ•  ๋•Œ ์ž๋™์œผ๋กœ ๋“ฑ๋ก๋œ ๊ฐœ์ธ ํ‚ค๋“ค๋กœ ๋””์ง€ํ„ธ ์„œ๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

generateCryptoKeyPair() ํ•จ์ˆ˜๋Š” ์ƒˆ๋กœ์šด ๊ฐœ์ธ ํ‚ค ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜์—ฌ CryptoKeyPair ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋กœ CryptoKeyPair ํƒ€์ž…์€ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ˜•์‹์ž…๋‹ˆ๋‹ค.

exportJwk() ํ•จ์ˆ˜๋Š” CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JWK ํ˜•์‹์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ์•”ํ˜ธ ํ‚ค๋ฅผ JSON์œผ๋กœ ํ‘œํ˜„ํ•˜๋Š” ํ‘œ์ค€์ ์ธ ํ˜•์‹์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. CryptoKey๋Š” ์•”ํ˜ธ ํ‚ค๋ฅผ JavaScript ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์›น ํ‘œ์ค€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.

importJwk() ํ•จ์ˆ˜๋Š” JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„๋œ ํ‚ค๋ฅผ CryptoKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. exportJwk() ํ•จ์ˆ˜์˜ ๋ฐ˜๋Œ€๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ setActorDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ˆˆ์„ ๋Œ๋ฆฝ์‹œ๋‹ค. getActorKeyPairs()๋ผ๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์“ฐ์ด๊ณ  ์žˆ๋Š”๋ฐ, ์ด ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฆ„๊ณผ ๊ฐ™์ด ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์€ ๋ฐ”๋กœ ์•ž์—์„œ ์‚ดํŽด๋ณธ setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ถˆ๋Ÿฌ์˜จ ๋ฐ”๋กœ ๊ทธ ํ‚ค ์Œ๋“ค์ž…๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” RSA-PKCS-v1.5์™€ Ed25519 ํ˜•์‹์œผ๋กœ ๋œ ๋‘ ์Œ์˜ ํ‚ค๋ฅผ ๋ถˆ๋Ÿฌ์™”์œผ๋ฏ€๋กœ, getActorKeyPairs() ๋ฉ”์„œ๋“œ๋Š” ๋‘ ํ‚ค ์Œ์˜ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ฐฐ์—ด์˜ ์›์†Œ๋Š” ํ‚ค ์Œ์„ ์—ฌ๋Ÿฌ ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด์ธ๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

interface ActorKeyPair {
  privateKey: CryptoKey;              // ๊ฐœ์ธ ํ‚ค
  publicKey: CryptoKey;               // ๊ณต๊ฐœ ํ‚ค
  keyId: URL;                         // ํ‚ค์˜ ๊ณ ์œ  ์‹๋ณ„ URI
  cryptographicKey: CryptographicKey; // ๊ณต๊ฐœ ํ‚ค์˜ ๋‹ค๋ฅธ ํ˜•์‹
  multikey: Multikey;                 // ๊ณต๊ฐœ ํ‚ค์˜ ๋˜ ๋‹ค๋ฅธ ํ˜•์‹
}

CryptoKey์™€ CryptographicKey์™€ Multikey๊ฐ€ ๊ฐ๊ฐ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ์ง€, ์™œ ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ ํ˜•์‹์ด ์žˆ์–ด์•ผ ํ•˜๋Š”์ง€๋Š” ์ด ์ž๋ฆฌ์—์„œ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋ณต์žกํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ง€๊ธˆ์€ Person ๊ฐ์ฒด๋ฅผ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ publicKey ์†์„ฑ์€ CryptographicKey ํ˜•์‹์„ ๋ฐ›๊ณ  assertionMethods ์†์„ฑ์€ MultiKey[] (Multikey์˜ ๋ฐฐ์—ด์„ TypeScript์—์„œ ์ด๋ ‡๊ฒŒ ํ‘œ๊ธฐ) ํ˜•์‹์„ ๋ฐ›๋Š”๋‹ค๋Š” ๊ฒƒ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•ฉ์‹œ๋‹ค.

๊ทธ๋‚˜์ €๋‚˜, Person ๊ฐ์ฒด์—๋Š” ์™œ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๊ฐ–๋Š” ์†์„ฑ์ด publicKey์™€ assertionMethods๋กœ ๋‘ ๊ฐœ๋‚˜ ์žˆ์„๊นŒ์š”? ActivityPub์—๋Š” ์›๋ž˜ publicKey ์†์„ฑ๋งŒ ์žˆ์—ˆ์ง€๋งŒ, ๋‚˜์ค‘์— ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก assertionMethods ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-v1.5 ํ‚ค ์Œ์ด, ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— Ed25519 ํ‚ค ์Œ์ด ๋“ค์–ด๊ฐ).

ํŒ

์‚ฌ์‹ค publicKey ์†์„ฑ๋„ ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋‹ด์„ ์ˆ˜๋Š” ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด๋“ค์ด ์ด๋ฏธ publicKey ์†์„ฑ์—๋Š” ๋‹จ ํ•˜๋‚˜์˜ ํ‚ค๋งŒ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ด๋ผ๋Š” ์ „์ œ ํ•˜์— ๊ตฌํ˜„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜ค์ž‘๋™ํ•  ๋•Œ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assertionMethods๋ผ๋Š” ์ƒˆ๋กœ์šด ์†์„ฑ์ด ์ œ์•ˆ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ด์— ๊ด€ํ•ด ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์‹  ๋ถ„๋“ค์€ FEP-521a ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ…Œ์ŠคํŠธ

์ž, ์•กํ„ฐ ๊ฐ์ฒด์— ์•”ํ˜ธ ํ‚ค๋“ค์„ ๋“ฑ๋กํ–ˆ์œผ๋ฏ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

fedify lookup http://localhost:8000/users/johndoe

์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  publicKey: CryptographicKey {
    id: URL "http://localhost:8000/users/johndoe#main-key",
    owner: URL "http://localhost:8000/users/johndoe",
    publicKey: CryptoKey {
      type: "public",
      extractable: true,
      algorithm: {
        name: "RSASSA-PKCS1-v1_5",
        modulusLength: 4096,
        publicExponent: Uint8Array(3) [ 1, 0, 1 ],
        hash: { name: "SHA-256" }
      },
      usages: [ "verify" ]
    }
  },
  assertionMethods: [
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#main-key",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: {
          name: "RSASSA-PKCS1-v1_5",
          modulusLength: 4096,
          publicExponent: Uint8Array(3) [ 1, 0, 1 ],
          hash: { name: "SHA-256" }
        },
        usages: [ "verify" ]
      }
    },
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#key-2",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: { name: "Ed25519" },
        usages: [ "verify" ]
      }
    }
  ],
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

Person ๊ฐ์ฒด์˜ publicKey ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ Multikey ๊ฐ์ฒด๊ฐ€ ๋‘˜ ๋“ค์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Mastodon๊ณผ ์—ฐ๋™

์ด์ œ ์‹ค์ œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค.

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ

์•„์‰ฝ๊ฒŒ๋„ ํ˜„์žฌ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ๋งˆ๋‹ค ์–ด๋”˜๊ฐ€์— ๋ฐฐํฌํ•ด์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํŽธํ•˜๊ฒ ์ฃ . ๋ฐฐํฌํ•˜์ง€ ์•Š๊ณ  ๋ฐ”๋กœ ์ธํ„ฐ๋„ท์— ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๋…ธ์ถœํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์–ผ๋งˆ๋‚˜ ์ข‹์„๊นŒ์š”?

์—ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋Ÿด ๋•Œ ์“ฐ๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ํ„ฐ๋ฏธ๋„์—์„œ ์ƒˆ ํƒญ์„ ์—ฐ ๋’ค, ์ด ๋ช…๋ น์–ด ๋’ค์— ๋กœ์ปฌ ์„œ๋ฒ„์˜ ํฌํŠธ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค:

fedify tunnel 8000

๊ทธ๋Ÿฌ๋ฉด ํ•œ ๋ฒˆ ์“ฐ๊ณ  ๋ฒ„๋ฆด ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๋งŒ๋“ค์–ด์„œ ๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์ค‘๊ณ„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Your local server at 8000 is now publicly accessible:

https://temp-address.serveo.net/

Press ^C to close the tunnel.

๋ฌผ๋ก , ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ๋Š” ์œ„ URL๊ณผ๋Š” ๋‹ค๋ฅธ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๊ณ ์œ ํ•œ URL์ด ์ถœ๋ ฅ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/users/johndoe(์—ฌ๋Ÿฌ๋ถ„์˜ ๊ณ ์œ  ์ž„์‹œ ๋„๋ฉ”์ธ์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ์—ด์–ด์„œ ์ž˜ ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์œผ๋กœ ๋…ธ์ถœ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์œ„ ์›น ํŽ˜์ด์ง€์— ๋ณด์ด๋Š” ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ณต์‚ฌํ•œ ๋’ค, Mastodon์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋ณด๋Š” ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ๊นŒ์ง€์ž…๋‹ˆ๋‹ค. ์•„์ง ํŒ”๋กœ๋Š” ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ์‹œ๋„ํ•˜์ง€ ๋งˆ์„ธ์š”! ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•  ์ˆ˜ ์žˆ์œผ๋ ค๋ฉด, ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์•ˆ๋‚ด

fedify tunnel ๋ช…๋ น์€ ํ•œ๋™์•ˆ ์“ฐ์ด์ง€ ์•Š์œผ๋ฉด ์ €์ ˆ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊น๋‹ˆ๋‹ค. ๊ทธ๋Ÿด ๋•Œ๋Š”, Ctrl+C ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ˆ ๋‹ค์Œ, fedify tunnel 8000 ๋ช…๋ น์„ ๋‹ค์‹œ ์ณ์„œ ์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ์„ ๋งบ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ

ActivityPub์—์„œ ์ˆ˜์‹ ํ•จ(inbox)์€ ์•กํ„ฐ๊ฐ€ ๋‹ค๋ฅธ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์•กํ„ฐ๋Š” ์ž์‹ ์˜ ์ˆ˜์‹ ํ•จ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋Š” HTTP POST ์š”์ฒญ์„ ํ†ตํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฑฐ๋‚˜, ๊ธ€์„ ์“ฐ๊ฑฐ๋‚˜, ๋Œ“๊ธ€์„ ๋‹ค๋Š” ๋“ฑ์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ•  ๋•Œ ํ•ด๋‹น ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์ˆ˜์‹ ์ž์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์˜จ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ ์ ˆํžˆ ์‘๋‹ตํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ  ์—ฐํ•ฉ ๋„คํŠธ์›Œํฌ์˜ ์ผ๋ถ€๋กœ ๊ธฐ๋Šฅํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์€ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์ง€๊ธˆ์€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

์ž์‹ ์„ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์›Œ)๊ณผ ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์ž‰)์„ ๋‹ด๊ธฐ ์œ„ํ•ด src/schema.sql ํŒŒ์ผ์— follows ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS follows (
  following_id INTEGER          REFERENCES actors (id),
  follower_id  INTEGER          REFERENCES actors (id),
  created      TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                CHECK (created <> ''),
  PRIMARY KEY (following_id, follower_id)
);

์ด๋ฒˆ์—๋„ src/schema.sql์„ ์‹คํ–‰ํ•˜์—ฌ follows ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

src/schema.ts ํŒŒ์ผ์„ ์—ด๊ณ  follows ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…๋„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Follow {
  following_id: number;
  follower_id: number;
  created: string;
}

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

์ด์ œ ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ์‹ค์€ ์•ž์„œ ์ด๋ฏธ src/federation.ts ํŒŒ์ผ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋ฐ” ์žˆ์Šต๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

์œ„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ์— ์•ž์„œ, Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Accept ๋ฐ Follow ํด๋ž˜์Šค์™€ getActorHandle() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  setInboxListeners() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    if (follow.objectId == null) {
      logger.debug("The Follow object does not have an object: {follow}", {
        follow,
      });
      return;
    }
    const object = ctx.parseUri(follow.objectId);
    if (object == null || object.type !== "actor") {
      logger.debug("The Follow object's object is not an actor: {follow}", {
        follow,
      });
      return;
    }
    const follower = await follow.getActor();
    if (follower?.id == null || follower.inboxId == null) {
      logger.debug("The Follow object does not have an actor: {follow}", {
        follow,
      });
      return;
    }
    const followingId = db
      .prepare<unknown[], Actor>(
        `
        SELECT * FROM actors
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(object.identifier)?.id;
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
      return;
    }
    const followerId = db
      .prepare<unknown[], Actor>(
        `
        -- ํŒ”๋กœ์›Œ ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        follower.id.href,
        await getActorHandle(follower),
        follower.name?.toString(),
        follower.inboxId.href,
        follower.endpoints?.sharedInbox?.href,
        follower.url?.href,
      )?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    const accept = new Accept({
      actor: follow.objectId,
      to: follow.actorId,
      object: follow,
    });
    await ctx.sendActivity(object, follower, accept);
  });

์ž, ์ฝ”๋“œ๋ฅผ ์ฐฌ์ฐฌํžˆ ์‚ดํŽด๋ด…์‹œ๋‹ค. on() ๋ฉ”์„œ๋“œ๋Š” ํŠน์ •ํ•œ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ๋œปํ•˜๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํŒ”๋กœ์›Œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ ๋’ค, ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ ์ˆ˜๋ฝ์„ ๋œปํ•˜๋Š” Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋‚ด๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

follow.objectId์—๋Š” ํŒ”๋กœ ๋Œ€์ƒ์ธ ์•กํ„ฐ์˜ URI๊ฐ€ ๋“ค์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด ์•ˆ์— ๋“  URI๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

getActorHandle() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ์•กํ„ฐ ๊ฐ์ฒด๋กœ๋ถ€ํ„ฐ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๊ตฌํ•˜์—ฌ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์•„์ง ์—†๋‹ค๋ฉด ๋จผ์ € ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ๋’ค, follows ํ…Œ์ด๋ธ”์— ํŒ”๋กœ์›Œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋ก์ด ์™„๋ฃŒ๋˜๋ฉด, sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ฒซ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐœ์‹ ์ž, ๋‘˜์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ˆ˜์‹ ์ž, ์…‹์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ผ ์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy

์ž, ๊ทธ๋Ÿผ ํŒ”๋กœ ์š”์ฒญ์ด ์ œ๋Œ€๋กœ ์ˆ˜์‹ ๋˜๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

๋ณดํ†ต์˜ Mastodon ์„œ๋ฒ„์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๊ดœ์ฐฎ๊ธด ํ•˜์ง€๋งŒ, ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ์˜ค๊ฐ€๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ActivityPub.Academy ์„œ๋ฒ„๋ฅผ ์ด์šฉํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ActivityPub.Academy๋Š” ๊ต์œก ๋ฐ ๋””๋ฒ„๊น… ์šฉ๋„์˜ ํŠน์ˆ˜ํ•œ Mastodon ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ show source๋ฅผ ๋ˆ„๋ฅธ ํ™”๋ฉด

ํ…Œ์ŠคํŠธ

์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ๊ฑธ ํ™•์ธํ–ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ์ €ํฌ๊ฐ€ ์ง  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๋จผ์ € follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€ ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค (๋ฌผ๋ก , ์‹œ๊ฐ์€ ๋‹ค๋ฅด๊ฒ ์ฃ ?):

following_id follower_id created
1 2 2024-09-01 10:19:41

๊ณผ์—ฐ actors ํ…Œ์ด๋ธ”์—๋„ ์ƒˆ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id user_id uri handle name inbox_url shared_inbox_url url created
2 https://activitypub.academy/users/dobussia_dovornath @dobussia_dovornath@activitypub.academy Dobussia Dovornath https://activitypub.academy/users/dobussia_dovornath/inbox https://activitypub.academy/inbox https://activitypub.academy/@dobussia_dovornath 2024-09-01 10:19:41

๋‹ค์‹œ, ActivityPub.Academy์˜ Activity Log๋ฅผ ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์—์„œ ๋ณด๋‚ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์œ„์™€ ๊ฐ™์ด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
  "type": "Undo",
  "actor": "https://activitypub.academy/users/dobussia_dovornath",
  "object": {
    "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
    "type": "Follow",
    "actor": "https://activitypub.academy/users/dobussia_dovornath",
    "object": "https://temp-address.serveo.net/users/johndoe"
  }
}

์œ„ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ์•„๊นŒ ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์™”๋˜ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ˆ˜์‹ ํ•จ์—์„œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์•„๋ฌด ๊ฒƒ๋„ ์ •์˜ํ•˜์ง€ ์•Š์•˜๊ธฐ์— ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํŒ”๋กœ ์ทจ์†Œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Undo ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  on(Follow, ...) ๋’ค์— ์—ฐ๋‹ฌ์•„ on(Undo, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต๋จ ...
  })
  .on(Undo, async (ctx, undo) => {
    const object = await undo.getObject();
    if (!(object instanceof Follow)) return;
    if (undo.actorId == null || object.objectId == null) return;
    const parsed = ctx.parseUri(object.objectId);
    if (parsed == null || parsed.type !== "actor") return;
    db.prepare(
      `
      DELETE FROM follows
      WHERE following_id = (
        SELECT actors.id
        FROM actors
        JOIN users ON actors.user_id = users.id
        WHERE users.username = ?
      ) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
      `,
    ).run(parsed.identifier, undo.actorId.href);
  });

์ด๋ฒˆ์—๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋ณด๋‹ค ์ฝ”๋“œ๊ฐ€ ์งง์Šต๋‹ˆ๋‹ค. Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ๋“  ๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์ธ์ง€ ํ™•์ธํ•œ ๋’ค, parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์ทจ์†Œํ•˜๋ ค๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ํŒ”๋กœ ๋Œ€์ƒ์ด ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•˜๊ณ , follows ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์•„๊นŒ ActivityPub.Academy์—์„œ ์ด๋ฏธ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ฒ„๋ ค์„œ ํ•œ ๋ฒˆ ๋” ์–ธํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์–ด์ฉ” ์ˆ˜ ์—†์ด ๋‹ค์‹œ ํŒ”๋กœํ•œ ๋’ค, ์–ธํŒ”๋กœํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทธ์— ์•ž์„œ, follows ํ…Œ์ด๋ธ”์„ ๋น„์›Œ ์ค„ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํŒ”๋กœ ์š”์ฒญ์ด ์™”์„ ๋•Œ ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฏ€๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

sqlite3 ๋ช…๋ น์–ด๋ฅผ ์ด์šฉํ•ด follows ํ…Œ์ด๋ธ”์„ ๋น„์›์‹œ๋‹ค:

echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

following_id follower_id created
1 2 2024-09-02 01:05:17

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ sqlite3 ๋ช…๋ น์œผ๋กœ ๋ณด๋Š” ๊ฑด ์„ฑ๊ฐ€์‹œ๋‹ˆ, ์›น์œผ๋กœ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ์‹œ๋‹ค.

์šฐ์„  src/views.tsx ํŒŒ์ผ์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. Actor ํƒ€์ž…์„ importํ•ด์ฃผ์„ธ์š”:

import type { Actor } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <FollowerList> ์ปดํฌ๋„ŒํŠธ์™€ <ActorLink> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowerListProps {
  followers: Actor[];
}

export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
  <>
    <h2>Followers</h2>
    <ul>
      {followers.map((follower) => (
        <li key={follower.id}>
          <ActorLink actor={follower} />
        </li>
      ))}
    </ul>
  </>
);

export interface ActorLinkProps {
  actor: Actor;
}

export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
  const href = actor.url ?? actor.uri;
  return actor.name == null ? (
    <a href={href} class="secondary">
      {actor.handle}
    </a>
  ) : (
    <>
      <a href={href}>{actor.name}</a>{" "}
      <small>
        (
        <a href={href} class="secondary">
          {actor.handle}
        </a>
        )
      </small>
    </>
  );
};

<ActorLink> ์ปดํฌ๋„ŒํŠธ๋Š” ํ•˜๋‚˜์˜ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋„ŒํŠธ๋Š” <ActorList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ž…๋‹ˆ๋‹ค. ๋ณด๋‹ค์‹œํ”ผ JSX์—๋Š” ์กฐ๊ฑด๋ฌธ์ด๋‚˜ ๋ฐ˜๋ณต๋ฌธ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ผํ•ญ ์—ฐ์‚ฐ์ž์™€ Array.map() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ญ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowerList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/followers์— ๋Œ€ํ•œ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/followers", async (c) => {
  const followers = db
    .prepare<unknown[], Actor>(
      `
      SELECT followers.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = following.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowerList followers={followers} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ, ์ž˜ ๋ณด์ด๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ํŒ”๋กœ์›Œ๊ฐ€ ์žˆ์–ด์•ผ ํ• ํ…Œ๋‹ˆ, fedify tunnel์„ ์ผ  ์ฑ„๋กœ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„๋‚˜ ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•ฉ์‹œ๋‹ค. ํŒ”๋กœ ์š”์ฒญ์ด ์ˆ˜๋ฝ๋œ ๋’ค ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/followers ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

ํŒ”๋กœ์›Œ ๋ชฉ๋ก ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ํŒ”๋กœ์›Œ ์ˆ˜๋„ ํ‘œ์‹œํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ๋‹ค์‹œ ์—ด๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

ProfileProps์—๋Š” ๋‘ ๊ฐœ์˜ ํ”„๋กญ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. followers๋Š” ๋ง ๊ทธ๋Œ€๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋‹ด๋Š” ํ”„๋กญ์ž…๋‹ˆ๋‹ค. username์€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์œผ๋กœ ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์œ„ํ•ด URL์— ๋“ค์–ด๊ฐˆ ์•„์ด๋””๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ src/app.tsx ํŒŒ์ผ๋กœ ๋Œ์•„๊ฐ€, GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      JOIN actors ON follows.following_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        followers={followers}
      />
    </Layout>,
  );
});

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ˆ์˜ follows ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋ฅผ ์„ธ๋Š” SQL์ด ์ถ”๊ฐ€๋˜์—ˆ๊ตฐ์š”. ์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜

๊ทธ๋Ÿฐ๋ฐ ํ•œ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ActivityPub.Academy๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด๋ด…์‹œ๋‹ค. (์กฐํšŒํ•˜๋Š” ๋ฒ•์€ ์ด์ œ ๋‹ค ์•„์‹œ์ฃ ? ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ๋œ ์ƒํƒœ์—์„œ, ์•กํ„ฐ ํ•ธ๋“ค์„ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ์น˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.) Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Mastodon์—์„œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

๋ฐ”๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ 0์œผ๋กœ ๋‚˜์˜จ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ActivityPub์„ ํ†ตํ•ด ๋…ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋ ค๋ฉด ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Recipient ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์•„๋ž˜์ชฝ์— ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setFollowersDispatcher(
    "/users/{identifier}/followers",
    (ctx, identifier, cursor) => {
      const followers = db
        .prepare<unknown[], Actor>(
          `
          SELECT followers.*
          FROM follows
          JOIN actors AS followers ON follows.follower_id = followers.id
          JOIN actors AS following ON follows.following_id = following.id
          JOIN users ON users.id = following.user_id
          WHERE users.username = ?
          ORDER BY follows.created DESC
          `,
        )
        .all(identifier);
      const items: Recipient[] = followers.map((f) => ({
        id: new URL(f.uri),
        inboxId: new URL(f.inbox_url),
        endpoints:
          f.shared_inbox_url == null
            ? null
            : { sharedInbox: new URL(f.shared_inbox_url) },
      }));
      return { items };
    },
  )
  .setCounter((ctx, identifier) => {
    const result = db
      .prepare<unknown[], { cnt: number }>(
        `
        SELECT count(*) AS cnt
        FROM follows
        JOIN actors ON actors.id = follows.following_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    return result == null ? 0 : result.cnt;
  });

setFollowersDispatcher() ๋ฉ”์„œ๋“œ์—์„œ๋Š” GET /users/{identifier}/followers ์š”์ฒญ์ด ์™”์„ ๋•Œ ์‘๋‹ตํ•  ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. SQL์ด ์กฐ๊ธˆ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ •๋ฆฌํ•˜์ž๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. items์—๋Š” Recipient ๊ฐ์ฒด๋“ค์„ ๋‹ด๋Š”๋ฐ, Recipient ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

export interface Recipient {
  readonly id: URL | null;
  readonly inboxId: URL | null;
  readonly endpoints?: {
    sharedInbox: URL | null;
  } | null;
}

id ์†์„ฑ์—๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  IRI๊ฐ€ ๋“ค์–ด๊ฐ€๊ณ , inboxId์—๋Š” ์•กํ„ฐ์˜ ๊ฐœ์ธ ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. endpoints.sharedInbox์—๋Š” ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” actors ํ…Œ์ด๋ธ”์— ๊ทธ ๋ชจ๋“  ์ •๋ณด๋ฅผ ๋‹ค ๋‹ด๊ณ  ์žˆ์œผ๋‹ˆ, ํ•ด๋‹น ์ •๋ณด๋“ค๋กœ items ๋ฐฐ์—ด์„ ์ฑ„์›Œ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

setCounter() ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์˜ ์ „์ฒด ์ˆ˜๋Ÿ‰์„ ๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋„ SQL์ด ์กฐ๊ธˆ ๋ณต์žกํ•˜๊ธด ํ•˜์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, fedify lookup ๋ช…๋ น์„ ์‚ฌ์šฉํ•ฉ์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe/followers

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
OrderedCollection {
  totalItems: 1,
  items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}

๊ทธ๋Ÿฐ๋ฐ, ์ด๋ ‡๊ฒŒ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ค์–ด ๋†“๊ธฐ๋งŒ ํ•ด์„œ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์–ด๋”” ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์— ๋งํฌ๋ฅผ ๊ฑธ์–ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ... ์ƒ๋žต ...
    return new Person({
      // ... ์ƒ๋žต ...
      followers: ctx.getFollowersUri(identifier), 
    });
  })

์•กํ„ฐ๋„ fedify lookup์œผ๋กœ ์กฐํšŒํ•˜์—ฌ ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์•„๋ž˜์™€ ๊ฐ™์ด ๊ฒฐ๊ณผ์— "followers" ์†์„ฑ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  ... ์ƒ๋žต ...
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  followers: URL "http://localhost:8000/users/johndoe/followers",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋‹ค์‹œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํŒ”๋กœ์›Œ ์ˆ˜๋Š” ์—ฌ์ „ํžˆ 0์œผ๋กœ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . ์ด๋Š” Mastodon์ด ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์บ์‹œ(cache)ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๊ธด ํ•˜์ง€๋งŒ F5 ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์‰ฝ์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค:

  • ํ•œ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์€ ์ผ์ฃผ์ผ์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Mastodon์€ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์บ์‹œ๋ฅผ ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์ดํ›„ 7์ผ์ด ์ง€๋‚  ๋•Œ ๋‚ ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ๋˜ ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์€ Update ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‚ ๋ฆฌ๋Š” ๊ฒƒ์ธ๋ฐ, ๊ท€์ฐฎ์€ ์ฝ”๋”ฉ์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋‹ˆ๋ฉด ์•„์ง ์บ์‹œ๊ฐ€ ๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•ด ๋ณด๋Š” ๊ฒƒ๋„ ํ•œ ๋ฐฉ๋ฒ•์ด๊ฒ ์ฃ .
  • ๋งˆ์ง€๋ง‰ ๋ฐฉ๋ฒ•์€ fedify tunnel์„ ๊ป๋‹ค ์ผœ์„œ ์ƒˆ๋กœ์šด ์ž„์‹œ ๋„๋ฉ”์ธ์„ ํ• ๋‹น ๋ฐ›๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ •ํ™•ํ•œ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์ง์ ‘ ํ™•์ธํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์ œ๊ฐ€ ๋‚˜์—ดํ•œ ๋ฐฉ๋ฒ•๋“ค ์ค‘ ํ•˜๋‚˜๋ฅผ ์‹œ๋„ํ•ด ๋ณด์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ

์ž, ์ด์ œ ๋“œ๋””์–ด ๊ฒŒ์‹œ๋ฌผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์™”์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ๋ธ”๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ž‘์„ฑ๋œ ๊ฒŒ์‹œ๋ฌผ๋„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์—ผ๋‘์— ๋‘๊ณ  ์„ค๊ณ„ํ•ด ๋ด…์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋ฐ”๋กœ posts ํ…Œ์ด๋ธ”๋ถ€ํ„ฐ ๋งŒ๋“ญ์‹œ๋‹ค. src/schema.sql ํŒŒ์ผ์„ ์—ด์–ด ์•„๋ž˜ SQL์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS posts (
  id       INTEGER NOT NULL PRIMARY KEY,
  uri      TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  actor_id INTEGER NOT NULL REFERENCES actors (id),
  content  TEXT    NOT NULL,
  url      TEXT             CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
  created  TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
  • id ์นผ๋Ÿผ์€ ํ…Œ์ด๋ธ”์˜ ๊ธฐ๋ณธ ํ‚ค์ž…๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•ž์„œ ๋งํ–ˆ๋‹ค์‹œํ”ผ ActivityPub ๊ฐ์ฒด๋Š” ๋ชจ๋‘ ๊ณ ์œ ํ•œ URI๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • actor_id ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • content ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์—๋Š” ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œ์‹œํ•˜๋Š” URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ URI์™€ ์›น ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๋Š” ํŽ˜์ด์ง€์˜ URL์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„ ์นผ๋Ÿผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋น„์–ด ์žˆ์„ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์‹œ๊ฐ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.

SQL์„ ์‹คํ–‰ํ•˜์—ฌ posts ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

posts ํ…Œ์ด๋ธ”์— ์ €์žฅ๋  ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•˜๋Š” Post ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Post {
  id: number;
  uri: string;
  actor_id: number;
  content: string;
  url: string | null;
  created: string;
}

์ฒซ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์–‘์‹์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ๊ฒ ์ฃ ? ๊ทธ๋Ÿฌ๊ณ  ๋ณด๋‹ˆ, ์•„์ง๊นŒ์ง€ ์ฒซ ํŽ˜์ด์ง€๋„ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ฒซ ํŽ˜์ด์ง€์— ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      <h1>{user.name}'s microblog</h1>
      <p>
        <a href={`/users/${user.username}`}>{user.name}'s profile</a>
      </p>
    </hgroup>
    <form method="post" action={`/users/${user.username}/posts`}>
      <fieldset>
        <label>
          <textarea name="content" required={true} placeholder="What's up?" />
        </label>
      </fieldset>
      <input type="submit" value="Post" />
    </form>
  </>
);

๊ทธ ๋‹ค์Œ์—๋Š” src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ์ด๋ฏธ ์žˆ๋Š” GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ:

app.get("/", (c) => c.text("Hello, Fedify!"));

์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์ณ์ค๋‹ˆ๋‹ค:

app.get("/", (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT users.*, actors.*
      FROM users
      JOIN actors ON users.id = actors.user_id
      LIMIT 1
      `,
    )
    .get();
  if (user == null) return c.redirect("/setup");

  return c.html(
    <Layout>
      <Home user={user} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด, ํ•œ ๋ฒˆ ์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ๋‚˜์˜ค๋‚˜ ํ™•์ธํ•ฉ์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค:

์ฒซ ํŽ˜์ด์ง€

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ posts ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์œ„ ์ฝ”๋“œ๋Š” ์•„์ง ๋ณ„ ์—ญํ• ์„ ํ•˜์ง„ ์•Š์ง€๋งŒ, ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ ํ˜•์‹์„ ์ •ํ•˜๋Š” ๋ฐ์— ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๋‚˜์ค‘์— ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ActivityPub์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์˜ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ์ฃผ๊ณ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ‰๋ฌธ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ ๋ฐ›์€ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, <, >์™€ ๊ฐ™์€ ๋ฌธ์ž๋“ค์„ HTML์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ HTML ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” stringify-entities ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add stringify-entities

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค.

import { stringifyEntities } from "stringify-entities";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  const username = c.req.param("username");
  const actor = db
    .prepare<unknown[], Actor>(
      `
      SELECT actors.*
      FROM actors
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ?
      `,
    )
    .get(username);
  if (actor == null) return c.redirect("/setup");
  const form = await c.req.formData();
  const content = form.get("content")?.toString();
  if (content == null || content.trim() === "") {
    return c.text("Content is required", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const url: string | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return url;
  })();
  if (url == null) return c.text("Failed to create post", 500);
  return c.redirect(url);
});

ํ‰๋ฒ”ํ•˜๊ฒŒ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์ฝ”๋“œ์ด๊ธด ํ•˜์ง€๋งŒ ํ•œ ๊ฐ€์ง€ ํŠน์ดํ•œ ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œํ˜„ํ•˜๋Š” ActivityPub ๊ฐ์ฒด์˜ URI๋ฅผ ๊ตฌํ•˜๋ ค๋ฉด posts.id๊ฐ€ ๋จผ์ € ๊ฒฐ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, posts.uri ์นผ๋Ÿผ์— https://localhost/๋ผ๋Š” ์ž„์‹œ URI๋ฅผ ๋จผ์ € ์ง‘์–ด ๋„ฃ์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค, ๊ฒฐ์ •๋œ posts.id๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ getObjectUri() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ URI๋ฅผ ๊ตฌํ•ด์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ฐ ๋’ค, ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ์ค‘

Post ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ฉด, ์•ˆํƒ€๊น๊ฒŒ๋„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

404 Not Found

์™œ๋ƒํ•˜๋ฉด ๊ฒŒ์‹œ๋ฌผ ํผ๋จธ๋งํฌ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์•„์ง ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ posts ํ…Œ์ด๋ธ”์—๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋งŒ๋“ค์–ด์กŒ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

๊ทธ๋Ÿผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๋‚˜ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

id uri actor_id content url created
1 http://localhost:8000/users/johndoe/posts/1 1 It's my first post! http://localhost:8000/users/johndoe/posts/1 2024-09-02 08:10:55

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ํ›„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚˜์ง€ ์•Š๋„๋ก, ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด Post ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <PostPage> ์ปดํฌ๋„ŒํŠธ ๋ฐ <PostView> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

export interface PostViewProps {
  post: Post & Actor;
}

export const PostView: FC<PostViewProps> = ({ post }) => (
  <article>
    <header>
      <ActorLink actor={post} />
    </header>
    {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
    <footer>
      <a href={post.url ?? post.uri}>
        <time datetime={new Date(post.created).toISOString()}>
          {post.created}
        </time>
      </a>
    </footer>
  </article>
);

์ด์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์™€ <PostPage> ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  ์•ž์„œ ์ •์˜ํ•œ <PostPage> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  const post = db
    .prepare<unknown[], Post & Actor & User>(
      `
      SELECT users.*, actors.*, posts.*
      FROM posts
      JOIN actors ON actors.id = posts.actor_id
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ? AND posts.id = ?
      `,
    )
    .get(c.req.param("username"), c.req.param("id"));
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      WHERE follows.following_id = ?
      `,
    )
    .get(post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์•„๊นŒ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฌ๋˜ http://localhost:8000/users/johndoe/posts/1 ํŽ˜์ด์ง€๋ฅผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

Note ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜

๊ทธ๋Ÿผ ์ด์ œ ๊ฒŒ์‹œ๋ฌผ์„ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ๋จผ์ € fedify tunnel์„ ์ด์šฉํ•˜์—ฌ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๊ทธ ์ƒํƒœ์—์„œ, Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ธ€์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

๋นˆ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์•ˆํƒ€๊น๊ฒŒ๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด ํ˜•์‹์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด๋กœ ๋…ธ์ถœํ•ด ๋ด…์‹œ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Fedify์—์„œ ์‹œ๊ฐ์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ๋Š” Temporal API๊ฐ€ ์•„์ง Node.js์— ๋‚ด์žฅ๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฅผ ํด๋ฆฌํ•„(polyfill)ํ•ด์ฃผ๋Š” @js-temporal/polyfill ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add @js-temporal/polyfill

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Temporal } from "@js-temporal/polyfill";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” PUBLIC_COLLECTION ์ƒ์ˆ˜๋„ importํ•ฉ๋‹ˆ๋‹ค.

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";
import type {
  Actor,
  Key,
  Post,
  User,
} from "./schema.ts";

๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ์˜ ๊ฒŒ์‹œ๋ฌผ์ฒ˜๋Ÿผ ์งง์€ ๊ธ€์€ ActivityPub์—์„œ ๋ณดํ†ต Note๋กœ ํ‘œํ˜„๋ฉ๋‹ˆ๋‹ค. Note ํด๋ž˜์Šค์— ๋Œ€ํ•œ ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๋Š” ์ด๋ฏธ ๋นˆ ๊ตฌํ˜„์ด๋‚˜๋งˆ ๋งŒ๋“ค์–ด ๋‘์—ˆ์—ˆ์ฃ :

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์ด๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    const post = db
      .prepare<unknown[], Post>(
        `
        SELECT posts.*
        FROM posts
        JOIN actors ON actors.id = posts.actor_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ? AND posts.id = ?
        `,
      )
      .get(values.identifier, values.id);
    if (post == null) return null;
    return new Note({
      id: ctx.getObjectUri(Note, values),
      attribution: ctx.getActorUri(values.identifier),
      to: PUBLIC_COLLECTION,
      cc: ctx.getFollowersUri(values.identifier),
      content: post.content,
      mediaType: "text/html",
      published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
      url: ctx.getObjectUri(Note, values),
    });
  },
);

Note ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ฑ„์›Œ์ง€๋Š” ์†์„ฑ ๊ฐ’๋“ค์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค:

  • attribution ์†์„ฑ์— ctx.getActorUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์˜ ์ž‘์„ฑ์ž๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • to ์†์„ฑ์— PUBLIC_COLLECTION์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ์ „์ฒด ๊ณต๊ฐœ ๊ฒŒ์‹œ๋ฌผ์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • cc ์†์„ฑ์— ctx.getFollowersUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ, ์ด ์ž์ฒด๋กœ๋Š” ํฐ ์˜๋ฏธ๋Š” ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ ํ•œ ๋ฒˆ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ๋ฐœ์‹ 

ํ•˜์ง€๋งŒ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœ ํ•ด๋„, ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด Mastodon ํƒ€์ž„๋ผ์ธ์— ์˜ฌ๋ผ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด Mastodon์ด ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์•Œ์•„์„œ ๋ฐ›์•„๊ฐ€๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์ชฝ์—์„œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜์—ฌ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ ์ƒ์„ฑ์‹œ์— Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Create, Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  // ... ์ƒ๋žต ...
  const ctx = fedi.createContext(c.req.raw, undefined);
  const post: Post | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return post;
  })();
  if (post == null) return c.text("Failed to create post", 500);
  const noteArgs = { identifier: username, id: post.id.toString() };
  const note = await ctx.getObject(Note, noteArgs);
  await ctx.sendActivity(
    { identifier: username },
    "followers",
    new Create({
      id: new URL("#activity", note?.id ?? undefined),
      object: note,
      actors: note?.attributionIds,
      tos: note?.toIds,
      ccs: note?.ccIds,
    }),
  );
  return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});

getObject() ๋ฉ”์„œ๋“œ๋Š” ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” Note ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ object ์†์„ฑ์— ๋„ฃ์Šต๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ˆ˜์‹ ์ž๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” tos (to์˜ ๋ณต์ˆ˜ํ˜•) ๋ฐ ccs (cc์˜ ๋ณต์ˆ˜ํ˜•) ์†์„ฑ์€ Note ๊ฐ์ฒด์™€ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ id๋Š” ์ž„์˜์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ์ง€์–ด๋‚ด์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด์˜ id ์†์„ฑ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URI๊ฐ€ ๋“ค์–ด๊ฐˆ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ๊ณ ์œ ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

sendActivity() ๋ฉ”์„œ๋“œ์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์—๋Š” ์ˆ˜์‹ ์ž๊ฐ€ ๋“ค์–ด๊ฐ€๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ๋Š” "followers"๋ผ๋Š” ํŠน์ˆ˜ํ•œ ์˜ต์…˜์„ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ์˜ต์…˜์„ ์ง€์ •ํ•˜๋ฉด ์•ž์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ชจ๋“  ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ตฌํ˜„์„ ๋๋ƒˆ์œผ๋‹ˆ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ์‹œํ‚จ ์ฑ„, ActivityPub.Academy๋กœ ๋“ค์–ด๊ฐ€ @johndoe@temp-address.serveo.net(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ ํ• ๋‹น๋œ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ํŒ”๋กœํ•ฉ๋‹ˆ๋‹ค. ํŒ”๋กœ์›Œ ๋ชฉ๋ก์—์„œ ํŒ”๋กœ ์š”์ฒญ์ด ํ™•์‹คํžˆ ์ˆ˜๋ฝ๋œ ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ localhost๊ฐ€ ์•„๋‹Œ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ ID๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๊ธฐ์ค€์œผ๋กœ URI๋ฅผ ๊ตฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๊ฐ”๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์˜ Activity Log๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

ActivityPub.Academy์˜ ํƒ€์ž„๋ผ์ธ์—์„œ ์ƒ์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ž˜ ๋ณด์ธ๋‹ค.

ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ๋‚ด ๊ฒŒ์‹œ๋ฌผ ๋ชฉ๋ก

ํ˜„์žฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—๋Š” ์ด๋ฆ„๊ณผ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค, ํŒ”๋กœ์›Œ ์ˆ˜๋งŒ ๋‚˜์˜ฌ ๋ฟ ์ •์ž‘ ๊ฒŒ์‹œ๋ฌผ์€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์„ ๋ณด์—ฌ์ค์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ  <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface PostListProps {
  posts: (Post & Actor)[];
}

export const PostList: FC<PostListProps> = ({ posts }) => (
  <>
    {posts.map((post) => (
      <div key={post.id}>
        <PostView post={post} />
      </div>
    ))}
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ๋ฐฉ๊ธˆ ์ •์˜ํ•œ <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

์ด๋ฏธ ์žˆ๋Š” GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE actors.user_id = ?
      ORDER BY posts.created DESC
      `,
    )
    .all(user.user_id);
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      // ... ์ƒ๋žต ...
      <PostList posts={posts} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

๋ณ€๊ฒฝ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์ด ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ”๋กœ

ํ˜„์žฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์„ ์ˆ˜๋Š” ์žˆ์ง€๋งŒ, ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์—๊ฒŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜๋Š” ์—†์Šต๋‹ˆ๋‹ค. ํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋„ ๋ณผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž, ๊ทธ๋Ÿผ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ์‹œ๋‹ค.

UI ๋จผ์ € ๋งŒ๋“ญ์‹œ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ์ด๋ฏธ ์žˆ๋Š” <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      {/* ... ์ƒ๋žต ... */}
    </hgroup>
    <form method="post" action={`/users/${user.username}/following`}>
      {/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ€ role=group์„ ์š”๊ตฌํ•จ */}
      <fieldset role="group">
        <input
          type="text"
          name="actor"
          required={true}
          placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
        />
        <input type="submit" value="Follow" />
      </fieldset>
    </form>
    <form method="post" action={`/users/${user.username}/posts`}>
      {/* ... ์ƒ๋žต ... */}
    </form>
  </>
);

์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ฒผ์œผ๋‹ˆ ์‹ค์ œ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งค ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Follow ํด๋ž˜์Šค์™€ isActor() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Create,
  Follow,
  isActor,
  Note,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/following ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/following", async (c) => {
  const username = c.req.param("username");
  const form = await c.req.formData();
  const handle = form.get("actor");
  if (typeof handle !== "string") {
    return c.text("Invalid actor handle or URL", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const actor = await lookupObject(handle.trim());
  if (!isActor(actor)) {
    return c.text("Invalid actor handle or URL", 400);
  }
  await ctx.sendActivity(
    { identifier: username },
    actor,
    new Follow({
      actor: ctx.getActorUri(username),
      object: actor.id,
      to: actor.id,
    }),
  );
  return c.text("Successfully sent a follow request");
});

lookupObject() ํ•จ์ˆ˜๋Š” ์•กํ„ฐ๋ฅผ ๋น„๋กฏํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ž…๋ ฅ์œผ๋กœ ActivityPub ๊ฐ์ฒด์˜ ๊ณ ์œ  URI๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ฐ›๊ณ , ์กฐํšŒํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

isActor() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ActivityPub ๊ฐ์ฒด๊ฐ€ ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์ด ์ฝ”๋“œ์—์„œ๋Š” sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์กฐํšŒํ•œ ์•กํ„ฐ์—๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์•„์ง follows ํ…Œ์ด๋ธ”์— ์•„๋ฌด๋Ÿฐ ๋ ˆ์ฝ”๋“œ๋„ ์ถ”๊ฐ€ํ•˜์ง„ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ƒ๋Œ€๋กœ๋ถ€ํ„ฐ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๊ณ  ๋‚˜์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ตฌํ˜„ํ•œ ํŒ”๋กœ ์š”์ฒญ ๊ธฐ๋Šฅ์ด ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒˆ์—๋„ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•ด์•ผ ํ•˜๋ฏ€๋กœ, fedify tunnel ๋ช…๋ น์„ ์ด์šฉํ•ด ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€ ์ƒ์— ๋‚˜ํƒ€๋‚œ ๋„์ฐฉํ•œ ํŒ”๋กœ ์š”์ฒญ

Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํ•˜์ง€๋งŒ ์•„์ง ์ˆ˜์‹ ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์— ๋Œ€ํ•ด ์•„๋ฌด๋Ÿฐ ํ–‰๋™๋„ ์ทจํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ด ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify์—์„œ ์ œ๊ณตํ•˜๋Š” isActor() ํ•จ์ˆ˜ ๋ฐ Actor ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

์ด ์†Œ์Šค ํŒŒ์ผ ์•ˆ์—์„œ Actor ํƒ€์ž…์˜ ์ด๋ฆ„์ด ๊ฒน์น˜๋ฏ€๋กœ APActor๋ผ๋Š” ๋ณ„๋ช…์„ ์ง€์–ด์คฌ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ, ์ฒ˜์Œ ๋งˆ์ฃผํ•œ ์•กํ„ฐ ์ •๋ณด๋ฅผ actors ํ…Œ์ด๋ธ”์— ๋„ฃ๋Š” ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์—ฌ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค. ์•„๋ž˜ ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

async function persistActor(actor: APActor): Promise<Actor | null> {
  if (actor.id == null || actor.inboxId == null) {
    logger.debug("Actor is missing required fields: {actor}", { actor });
    return null;
  }
  return (
    db
      .prepare<unknown[], Actor>(
        `
        -- ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        actor.id.href,
        await getActorHandle(actor),
        actor.name?.toString(),
        actor.inboxId.href,
        actor.endpoints?.sharedInbox?.href,
        actor.url?.href,
      ) ?? null
  );
}

์ •์˜ํ•œ persistActor() ํ•จ์ˆ˜๋Š” ์ธ์ž๋กœ ๋“ค์–ด์˜จ ์•กํ„ฐ ๊ฐ์ฒด์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ actors ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ํ…Œ์ด๋ธ”์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์˜ on(Follow, ...) ๋ถ€๋ถ„์—์„œ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ persistActor() ํ•จ์ˆ˜๋ฅผ ์“ฐ๊ฒŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต ...
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
    }
    const followerId = (await persistActor(follower))?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    // ... ์ƒ๋žต ...
  })

๋ฆฌํŒฉํ„ฐ๋ง์„ ๋๋ƒˆ์œผ๋‹ˆ ์ˆ˜์‹ ํ•จ์— Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์•˜์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

  .on(Accept, async (ctx, accept) => {
    const follow = await accept.getObject();
    if (!(follow instanceof Follow)) return;
    const following = await accept.getActor();
    if (!isActor(following)) return;
    const follower = follow.actorId;
    if (follower == null) return;
    const parsed = ctx.parseUri(follower);
    if (parsed == null || parsed.type !== "actor") return;
    const followingId = (await persistActor(following))?.id;
    if (followingId == null) return;
    db.prepare(
      `
      INSERT INTO follows (following_id, follower_id)
      VALUES (
        ?,
        (
          SELECT actors.id
          FROM actors
          JOIN users ON actors.user_id = users.id
          WHERE users.username = ?
        )
      )
      `,
    ).run(followingId, parsed.identifier);
  });

์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๊ธธ์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ์œผ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ(follower)์™€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์€ ์•กํ„ฐ(following)๋ฅผ ๊ตฌํ•˜๊ณ  follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์ด์ œ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„๊นŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ ActivityPub.Academy ์ชฝ์—์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•˜๊ณ  Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ด๋ฏธ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ์ƒํƒœ์—์„œ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋„ ๋ฌด์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๋’ค ๋‹ค์‹œ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์—์„œ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ์ƒํƒœ์—์„œ, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ActivityPub.Academy์˜ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋ฉด, ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Activity Log์— Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋„์ฐฉํ•œ ํ›„ ๋‹ต์žฅ์œผ๋กœ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‚˜ ์ง์ ‘ ํ™•์ธ์„ ํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

์„ฑ๊ณตํ–ˆ๋‹ค๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค (following_id ์นผ๋Ÿผ์— ๋“  ๊ฐ’์€ ๋‹ค์†Œ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค):

following_id follower_id created
3 1 2024-09-02 14:11:17

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowingListProps {
  following: Actor[];
}

export const FollowingList: FC<FollowingListProps> = ({ following }) => (
  <>
    <h2>Following</h2>
    <ul>
      {following.map((actor) => (
        <li key={actor.id}>
          <ActorLink actor={actor} />
        </li>
      ))}
    </ul>
  </>
);

๊ทธ ๋‹ค์Œ, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  FollowingList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/following ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/following", async (c) => {
  const following = db
    .prepare<unknown[], Actor>(
      `
      SELECT following.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = followers.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowingList following={following} />
    </Layout>,
  );
});

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/following ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

ํŒ”๋กœ์ž‰ ์ˆ˜

ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ํŒ”๋กœ์ž‰ ์ˆ˜๋„ ํ‘œ์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  following: number;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  following,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

<PostPage> ์ปดํฌ๋„ŒํŠธ๋„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      following={props.following}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

๊ทธ๋Ÿผ ์ด์ œ ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์กฐํšŒํ•˜์—ฌ ํŒ”๋กœ์ž‰ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET /users/{username} ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following } = db
    .prepare<unknown[], { following: number }>(
      `
      SELECT count(*) AS following
      FROM follows
      JOIN actors ON follows.follower_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        following={following}
        followers={followers}
      />
      <PostList posts={posts} />
    </Layout>,
  );
});

GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  // ... ์ƒ๋žต ...
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following, followers } = db
    .prepare<unknown[], { following: number; followers: number }>(
      `
      SELECT sum(follows.follower_id = ?) AS following,
             sum(follows.following_id = ?) AS followers
      FROM follows
      `,
    )
    .get(post.actor_id, post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        following={following}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๋‹ค ์ˆ˜์ •๋˜์—ˆ๋‹ค๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํƒ€์ž„๋ผ์ธ

๋งŽ์€ ๊ฒƒ๋“ค์„ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, ์•„์ง ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด์ง€๋Š” ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌํƒœ๊นŒ์ง€์˜ ๊ณผ์ •์—์„œ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค์‹œํ”ผ, ์šฐ๋ฆฌ๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์“ธ ๋•Œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ–ˆ๋˜ ๊ฒƒ๊ณผ ๊ฐ™์ด, ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•ด์•ผ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ค ์ผ์ด ์ผ์–ด๋‚˜๋Š”์ง€ ๋ณด๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Create,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ์— on(Create, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

  .on(Create, async (ctx, create) => {
    const object = await create.getObject();
    if (!(object instanceof Note)) return;
    const actor = create.actorId;
    if (actor == null) return;
    const author = await object.getAttribution();
    if (!isActor(author) || author.id?.href !== actor.href) return;
    const actorId = (await persistActor(author))?.id;
    if (actorId == null) return;
    if (object.id == null) return;
    const content = object.content?.toString();
    db.prepare(
      "INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
    ).run(object.id.href, actorId, content, object.url?.href);
  });

getAttribution() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ธ€์“ด์ด๋ฅผ ๊ตฌํ•œ ๋’ค, persistActor() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ์•„์ง actors ํ…Œ์ด๋ธ”์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  posts ํ…Œ์ด๋ธ”์— ์ƒˆ ๋ ˆ์ฝ”๋“œ๋ฅผ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์‹œ ํ•œ ๋ฒˆ ActivityPub.Academy์— ๋“ค์–ด๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. Activity Log๋ฅผ ์—ด์–ด Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋˜์—ˆ๋Š”์ง€ ์ฒดํฌํ•œ ๋’ค, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ posts ํ…Œ์ด๋ธ”์— ์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3

์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

id uri actor_id content url created
3 https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 3 <p>Would it send a Create(Note) activity?</p> https://activitypub.academy/@algusia_draneoll/113068684551948316 2024-09-02 15:33:32

์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ ํ‘œ์‹œ

์ž, ์ด์ œ ์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ์„ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋กœ ์ถ”๊ฐ€ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๊ทธ ๋ ˆ์ฝ”๋“œ๋“ค์„ ์ž˜ ํ‘œ์‹œํ•ด ์ฃผ๋Š” ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. ํ”ํžˆ ใ€Œํƒ€์ž„๋ผ์ธใ€์ด๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps extends PostListProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user, posts }) => (
  <>
    {/* ... ์ƒ๋žต ... */}
    <PostList posts={posts} />
  </>
);

๊ทธ ๋’ค, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/", (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.redirect("/setup");

  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE posts.actor_id = ? OR posts.actor_id IN (
        SELECT following_id
        FROM follows
        WHERE follower_id = ?
      )
      ORDER BY posts.created DESC
      `,
    )
    .all(user.id, user.id);
  return c.html(
    <Layout>
      <Home user={user} posts={posts} />
    </Layout>,
  );
});

์ž, ์ด์ œ ๋‹ค ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ํƒ€์ž„๋ผ์ธ์„ ๊ฐ์ƒํ•ฉ์‹œ๋‹ค:

์ฒซ ํŽ˜์ด์ง€์—์„œ ๋ณด์ด๋Š” ํƒ€์ž„๋ผ์ธ

์œ„์™€ ๊ฐ™์ด ์›๊ฒฉ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๊ณผ ๋กœ์ปฌ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ž˜ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค๊ฐ€์š”? ๋งˆ์Œ์— ๋“œ์‹œ๋‚˜์š”?

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์ด๊ฒŒ ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์™„์„ฑ์‹œํ‚ค๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ฐœ์„ ํ•  ์ 

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด ์™„์„ฑํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ์•„์‰ฝ๊ฒŒ๋„ ์•„์ง ์‹ค์‚ฌ์šฉ์—๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํŠนํžˆ, ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ์ทจ์•ฝ์ ์ด ๋งŽ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋งŒ๋“  ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์ข€ ๋” ๋ฐœ์ „์‹œํ‚ค๊ณ  ์‹ถ์€ ๋ถ„๋“ค์€, ์•„๋ž˜ ๊ณผ์ œ๋“ค์„ ์ง์ ‘ ํ•ด๊ฒฐํ•ด ๋ณด์…”๋„ ์ข‹์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

  • ํ˜„์žฌ๋Š” ์•„๋ฌด๋Ÿฐ ์ธ์ฆ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ๋ˆ„๊ตฌ๋ผ๋„ URL๋งŒ ์•Œ๋ฉด ๊ธ€์„ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๊ณผ์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ด๋ฅผ ๋ฐฉ์ง€ํ•ด ๋ณผ๊นŒ์š”?

  • ํ˜„์žฌ์˜ ๊ตฌํ˜„์€ ActivityPub์„ ํ†ตํ•ด ๋ฐ›์€ Note ๊ฐ์ฒด ์•ˆ์— ๋“ค์–ด ์žˆ๋Š” HTML์„ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์•…์˜์ ์ธ ActivityPub ์„œ๋ฒ„๊ฐ€ <script>while (true) alert('๋ฉ”๋กฑ'); ๊ฐ™์€ HTML์„ ํฌํ•จํ•œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ XSS ์ทจ์•ฝ์ ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ทจ์•ฝ์ ์€ ์–ด๋–ป๊ฒŒ ๋ง‰์„ ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋‹ค์Œ SQL์„ ์‹คํ–‰ํ•˜์—ฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟ” ๋ด…์‹œ๋‹ค:

    UPDATE actors SET name = 'Renamed' WHERE id = 1;

    ์ด๋ ‡๊ฒŒ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟจ์„ ๋•Œ, ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๋ฐ”๋€ ์ด๋ฆ„์ด ์ ์šฉ๋ ๊นŒ์š”? ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด์•ผ ๋ณ€๊ฒฝ์ด ์ ์šฉ๋ ๊นŒ์š”?

  • ์•กํ„ฐ์— ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•ด ๋ด…์‹œ๋‹ค. ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ถ๊ธˆํ•˜๋‹ค๋ฉด, fedify lookup ๋ช…๋ น์œผ๋กœ ์ด๋ฏธ ํ”„๋กœํ•„ ์‚ฌ์ง„์ด ์žˆ๋Š” ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณด์„ธ์š”.

  • ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€๊ฐ€ ์ฒจ๋ถ€๋œ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ํƒ€์ž„๋ผ์ธ์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • ๊ฒŒ์‹œ๋ฌผ ๋‚ด์—์„œ ๋‹ค๋ฅธ ์•กํ„ฐ๋ฅผ ๋ฉ˜์…˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ด…์‹œ๋‹ค. ๋ฉ˜์…˜ํ•œ ์ƒ๋Œ€ํ•œํ…Œ ์•Œ๋ฆผ์ด ๊ฐ€๋„๋ก ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

Dirk Blank :nonazis: ๐Ÿ‡ฉ๐Ÿ‡ช's avatar
Dirk Blank :nonazis: ๐Ÿ‡ฉ๐Ÿ‡ช

@dirkblank@nrw.social ยท Reply to Merle Meyerdierks's post

@merleeperlee.bsky.social

Naja wenn von den 40 Millionen Usern auf auch nur 50% aktiv sind es auch nur noch 20 Millionen aktive User.

Davon ab, ist das nicht nur . Es gibt auch u.a. Fediverse was anderes als nur !

Bluesky wird mehr in den Medien erwรคhnt als das Fediverse. Wahrscheinlich weil das Wesen des Fediverse noch nicht verstanden wird!

Wieviel der Gmail Accounts sind denn Karteileichen?

Jansens Pott's avatar
Jansens Pott

@blog@www.jansens-pott.de

Neu auf Jansens Pott:

Kurzgedanken: Mastodon fรผhrt Zitat-Funktion ein

Nรคchste Woche wird es ein Update bei Mastodon geben und damit wird dann auch die Zitat-Funktion eingefรผhrt.

Ich bin da immer noch skeptisch, ob das eine gute Idee ist. Ich dachte lange, dass diese Zitatfunktion mit ein Grund dafรผr war, dass [โ€ฆ]

jansens-pott.de/kurzgedanken-m

TechnoDragon's avatar
TechnoDragon

@TechnoDragon@dragon-of-electric-ciphers.x10.mx

I am Not Reading Your Manifestos on a Micro-Blogging Platform

I really can’t bring myself to scroll through your ten-part thread on a microblogging implementation of the fediverse. There is a reason why the default character limit of Mastodon is 500 characters. See this? This is a blog post via WordPress. You can see it on the fediverse because it’s federated. It is one long post. Not a billion replies to myself stitched together.

If you’re going to write a thousand-plus words split into 500-character chunks, why not just start a blog? You know, the kind of place that’s actually meant for long-form content? By breaking your thoughts up across posts, you’re actually creating entropy, which is the opposite of information, because responses will be scattered across chunks and some responses will get boosted and others not or some might be federated and others not. That is objectively noisy which inversely affects the information that you are trying to communicate.

If you’re going to make people scroll through your unsolicited manifesto one awkward chunk at a time, at least think about putting it somewhere that’s designed for actual reading. A blog. A real blog. With paragraphs, some structure, and maybe even a heading or two.

Ghost and WordPress support federation. Hell, NodeBB even supports federation. Microblogging is meant to imitate how conversations flow. I sure hope you people don’t actually speak like you microblog in real life. If you do, that means you do not actually know how to speak to people. Rather, your social interactions involve randomly launching into monologues as if you were a main character in a television show.

Honestly, I’m not going to read your thread, because if you cannot be bothered to post long-form content in a blog, I cannot be bothered to read it.

Jansens Pott's avatar
Jansens Pott

@blog@www.jansens-pott.de

Neu auf Jansens Pott:

Kurzgedanken: Radfahrer – denkt doch wenigstens ein Bisschen mit

Ich fahre ja gerne und recht viel mit dem Rad. Daraus resultiert, dass ich beim Autofahren sehr auf Radfahrer achte, nicht mit zu geringem Abstand รผberhole und auch gerne und oft Rรผcksicht nehme.

Das bringt nur alles nichts, wenn Radfahrer [โ€ฆ]

jansens-pott.de/kurzgedanken-r

Fรผller รผber offenem Buch mit handgeschriebenem Text.
ALT text detailsFรผller รผber offenem Buch mit handgeschriebenem Text.
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: English (์˜์–ด), ๆ—ฅๆœฌ่ชž (์ผ๋ณธ์–ด).

์•ˆ๋‚ด

๋งŒ์•ฝ ์—ฐํ•ฉ์šฐ์ฃผ(fediverse)๋‚˜ ActivityPub ๊ฐ™์€ ์šฉ์–ด๊ฐ€ ์ƒ์†Œํ•˜๋‹ค๋ฉด, ๊ด€๋ จ ๊ฒ€์ƒ‰์„ ์ข€ ๋” ํ•˜๊ณ  ๋‚˜์„œ ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•  ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ActivityPub ์„œ๋ฒ„ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Fedify๋ฅผ ์ด์šฉํ•˜์—ฌ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ํ”„๋กœํ† ์ฝœ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ(microblog)๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify์˜ ๊ธฐ๋ฐ˜ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค๋Š” Fedify์˜ ํ™œ์šฉ๋ฒ•์— ์ข€ ๋” ์ง‘์ค‘ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

Fedify๋Š” ActivityPub์ด๋‚˜ ๊ทธ ์™ธ ํ‘œ์ค€(์ด์นญํ•˜์—ฌ ใ€Œ์—ฐํ•ฉ์šฐ์ฃผใ€๋ผ ๋ถˆ๋ฆฌ๋Š”)์„ ์ด์šฉํ•˜์—ฌ ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค ๋•Œ์˜ ๋ณต์žกํ•จ์ด๋‚˜ ๋ฒˆ๊ฑฐ๋กœ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์—†์• ๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด Fedify์˜ ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค.

Fedify ํ”„๋กœ์ ํŠธ์— ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์…จ๋‹ค๋ฉด, ์•„๋ž˜์˜ ์ž๋ฃŒ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”:

Fedify๋‚˜ ๋ณธ ํŠœํ† ๋ฆฌ์–ผ์— ๋Œ€ํ•œ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ, ํ”ผ๋“œ๋ฐฑ ๋“ฑ์€ GitHub Discussions(์˜์–ด)์— ์˜ฌ๋ ค ์ฃผ์‹œ๊ฑฐ๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ @fedify(์˜์–ด ๋ฐ ํ•œ๊ตญ์–ด)๋กœ ๋ฉ˜์…˜ ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ์•„๋‹ˆ๋ฉด Fedify ํ”„๋กœ์ ํŠธ์˜ Discord ์„œ๋ฒ„์— ๋“ค์–ด์˜ค์…”์„œ #fedify-general-ko ์ฑ„๋„(ํ•œ๊ตญ์–ด)์—์„œ ๋ง์”€ํ•˜์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

๋Œ€์ƒ ๋…์ž

์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify๋ฅผ ๋ฐฐ์›Œ์„œ ActivityPub ์„œ๋ฒ„ ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ณ  ์‹ถ์€ ๋ถ„๋“ค์„ ๋Œ€์ƒ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด HTML์ด๋‚˜ HTTP๋ฅผ ์ด์šฉํ•˜์—ฌ ์›น์•ฑ์„ ์ œ์ž‘ํ•ด ๋ณธ ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ, ๋ช…๋ นํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๋‚˜ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋“ฑ์„ ์ดํ•ดํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ TypeScript๋‚˜ JSX, ActivityPub, Fedify ๋“ฑ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ํ•„์š”ํ•œ ๋งŒํผ ๊ฐ€๋ฅด์ณ ๋“œ๋ฆด ๊ฒƒ์ด๋‹ˆ ๋ชฐ๋ผ๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณธ ๊ฒฝํ—˜์€ ํ•„์š” ์—†์ง€๋งŒ, ๊ทธ๋ž˜๋„ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ํ•˜๋‚˜ ์ •๋„๋Š” ์จ๋ดค๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ž˜์•ผ ์šฐ๋ฆฌ๊ฐ€ ๋ฌด์—‡์„ ๋งŒ๋“œ๋ ค๊ณ  ํ•˜๋Š”์ง€ ๊ฐ์ด ์žกํžˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๋ชฉํ‘œ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Fedify๋ฅผ ์ด์šฉํ•ด ActivityPub์œผ๋กœ ๋‹ค๋ฅธ ์—ฐํ•ฉํ˜• ์†Œํ”„ํŠธ์›จ์–ด ๋ฐ ์„œ๋น„์Šค์™€ ์†Œํ†ต ๊ฐ€๋Šฅํ•œ ์ผ์ธ์šฉ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ด ์†Œํ”„ํŠธ์›จ์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

  • ์‚ฌ์šฉ์ž๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์ด ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒ”๋กœ์›Œ๋Š” ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœํ•˜๋‹ค๊ฐ€ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๋ฌผ์€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ • ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ •์ด ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์„ ์‹œ๊ฐ„์ˆœ ๋ชฉ๋ก์œผ๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŠœํ† ๋ฆฌ์–ผ์„ ๋‹จ์ˆœํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ ์ œ์•ฝ์„ ๋‘ก๋‹ˆ๋‹ค.

  • ๊ณ„์ • ํ”„๋กœํ•„(์†Œ๊ฐœ๋ฌธ, ์‚ฌ์ง„ ๋“ฑ)์€ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ๋งŒ๋“  ๊ณ„์ •์€ ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ์˜ฌ๋ฆฐ ๊ฒŒ์‹œ๋ฌผ์€ ๊ณ ์น˜๊ฑฐ๋‚˜ ์ง€์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ํŒ”๋กœํ•œ ๋‹ค๋ฅธ ๊ณ„์ •์€ ํŒ”๋กœ์ž‰์„ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ข‹์•„์š”, ๊ณต์œ , ๋Œ“๊ธ€์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ ๋“ฑ์˜ ๋ณด์•ˆ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก , ํŠœํ† ๋ฆฌ์–ผ์„ ๋๊นŒ์ง€ ์ง„ํ–‰ํ•œ ๋’ค ๊ธฐ๋Šฅ์„ ๋ง๋ถ™์ด๋Š” ๊ฒƒ์€ ์–ผ๋งˆ๋“ ์ง€ ํ•˜์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์—ฐ์Šต์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” GitHub ์ €์žฅ์†Œ์— ์˜ฌ๋ผ์™€ ์žˆ์œผ๋ฉฐ, ๊ฐ ๊ตฌํ˜„ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ์ปค๋ฐ‹์ด ๋‚˜๋‰˜์–ด์ ธ ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์…‹์—…

Node.js ์„ค์น˜ํ•˜๊ธฐ

Fedify๋Š” Deno, Bun, Node.js, ์ด ์„ธ ๊ฐ€์ง€ JavaScript ๋Ÿฐํƒ€์ž„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ์ค‘์—์„œ Node.js๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์“ฐ์ด๋ฏ€๋กœ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Node.js๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

JavaScript ๋Ÿฐํƒ€์ž„์ด๋ž€ JavaScript ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ํ”Œ๋žซํผ์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์›น๋ธŒ๋ผ์šฐ์ €๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜์ด๋ฉฐ, ๋ช…๋ น์ค„์ด๋‚˜ ์„œ๋ฒ„์—์„œ๋Š” Node.js ๋“ฑ์ด ๋„๋ฆฌ ์“ฐ์ž…๋‹ˆ๋‹ค. ์ตœ๊ทผ์—๋Š” Cloudflare Workers ๊ฐ™์€ ํด๋ผ์šฐ๋“œ ์—์ง€ ํ•จ์ˆ˜๋“ค๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜๋กœ ๊ฐ๊ด‘ ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fedify๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Node.js 22.0.0 ์ด์ƒ์˜ ๋ฒ„์ „์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜๋ฒ•์ด ์žˆ์œผ๋‹ˆ ์ž์‹ ์—๊ฐ€ ๊ฐ€์žฅ ์•Œ๋งž๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ Node.js๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

Node.js๊ฐ€ ์„ค์น˜๋˜๋ฉด node ๋ช…๋ น์–ด์™€ npm ๋ช…๋ น์–ด๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค:

node --version
npm --version

fedify ๋ช…๋ น์–ด ์„ค์น˜

Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์…‹์—…ํ•˜๊ธฐ ์œ„ํ•ด fedify ๋ช…๋ น์–ด๋ฅผ ์‹œ์Šคํ…œ์— ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ, npm ๋ช…๋ น์œผ๋กœ ๊นŒ๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊ฐ„ํŽธํ•ฉ๋‹ˆ๋‹ค:

npm install -g @fedify/cli

์„ค์น˜๊ฐ€ ๋˜์—ˆ๋‹ค๋ฉด, fedify ๋ช…๋ น์–ด๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ fedify ๋ช…๋ น์–ด์˜ ๋ฒ„์ „์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

fedify --version

๊ฒฐ๊ณผ๋กœ ๋‚˜์˜จ ๋ฒ„์ „ ๋ฒˆํ˜ธ๊ฐ€ 1.0.0 ์ด์ƒ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. ๊ทธ๋ณด๋‹ค ์˜›๋‚  ๋ฒ„์ „์ด๋ฉด ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ œ๋Œ€๋กœ ๋”ฐ๋ผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

fedify init์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

์ƒˆ Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•ด, ์ž‘์—…ํ•  ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ •ํ•ฉ์‹œ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” microblog๋ผ๊ณ  ๋ช…๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. fedify init ๋ช…๋ น ๋’ค์— ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ ๊ณ  ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค (๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค):

fedify init microblog

fedify init ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ช‡ ๊ฐ€์ง€ ์งˆ๋ฌธ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค. ์ฐจ๋ก€๋Œ€๋กœ Node.js, npm, Hono, In-memory, In-process๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค:

             ___      _____        _ _  __
            /'_')    |  ___|__  __| (_)/ _|_   _
     .-^^^-/  /      | |_ / _ \/ _` | | |_| | | |
   __/       /       |  _|  __/ (_| | |  _| |_| |
  <__.|_|-|_|        |_|  \___|\__,_|_|_|  \__, |
                                           |___/

? Choose the JavaScript runtime to use
  Deno
  Bun
โฏ Node.js

? Choose the package manager to use
โฏ npm
  Yarn
  pnpm

? Choose the web framework to integrate Fedify with
  Bare-bones
  Fresh
โฏ Hono
  Express
  Nitro

? Choose the key-value store to use for caching
โฏ In-memory
  Redis
  PostgreSQL
  Deno KV

? Choose the message queue to use for background jobs
โฏ In-process
  Redis
  PostgreSQL
  Deno KV

์•ˆ๋‚ด

Fedify๋Š” ํ’€ ์Šคํƒ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์•„๋‹Œ, ActivityPub ์„œ๋ฒ„ ๊ตฌํ˜„์— ํŠนํ™”๋œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋‹ค๋ฅธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์™€ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๊ฒƒ์„ ์—ผ๋‘์— ๋‘๊ณ  ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ Hono๋ฅผ ์ฑ„ํƒํ•˜์—ฌ Fedify์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์ž ์‹œ ํ›„ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ ์•ˆ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • .vscode/ โ€” Visual Studio Code ๊ด€๋ จ ์„ค์ •๋“ค
    • extensions.json โ€” Visual Studio Code ์ถ”์ฒœ ํ™•์žฅ
    • settings.json โ€” Visual Studio Code ์„ค์ •
  • node_modules/ โ€” ์˜์กด ํŒจํ‚ค์ง€๋“ค์ด ์„ค์น˜๋˜๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ (๋‚ด๋ถ€ ์ƒ๋žต)
  • src/ โ€” ์†Œ์Šค ์ฝ”๋“œ
    • app.tsx โ€” ActivityPub๊ณผ ๊ด€๋ จ ์—†๋Š” ์„œ๋ฒ„
    • federation.ts โ€” ActivityPub ์„œ๋ฒ„
    • index.ts โ€” ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
    • logging.ts โ€” ๋กœ๊น… ์„ค์ •
  • biome.json โ€” ํฌ๋งคํ„ฐ ๋ฐ ๋ฆฐํŠธ ์„ค์ •
  • package.json โ€” ํŒจํ‚ค์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
  • tsconfig.json โ€” TypeScript ์„ค์ •

์ง์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒ ์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” JavaScript๊ฐ€ ์•„๋‹Œ TypeScript๋ฅผ ์“ฐ๊ธฐ ๋•Œ๋ฌธ์— .js ํŒŒ์ผ์ด ์•„๋‹Œ .ts ๋ฐ .tsx ํŒŒ์ผ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒ์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” ๋™์ž‘ํ•˜๋Š” ๋ฐ๋ชจ์ž…๋‹ˆ๋‹ค. ์šฐ์„ ์€ ์ด ์ƒํƒœ๋กœ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

npm run dev

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด Ctrl+C ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ธฐ ์ „๊นŒ์ง€๋Š” ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ฑ„๋กœ ์žˆ์Šต๋‹ˆ๋‹ค:

Server started at http://0.0.0.0:8000

์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด๊ณ  ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/john

์œ„ ๋ช…๋ น์€ ์šฐ๋ฆฌ๊ฐ€ ๋กœ์ปฌ์— ๋„์šด ActivityPub ์„œ๋ฒ„์˜ ํ•œ ์•กํ„ฐ(actor)๋ฅผ ์กฐํšŒํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ์•กํ„ฐ๋Š” ์—ฌ๋Ÿฌ ActivityPub ์„œ๋ฒ„๋“ค ์‚ฌ์ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ณ„์ •์ด๋ผ๊ณ  ๋ณด์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋˜๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/john",
  name: "john",
  preferredUsername: "john"
}

์ด ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด /users/john ๊ฒฝ๋กœ์— ์œ„์น˜ํ•œ ์•กํ„ฐ ๊ฐ์ฒด์˜ ์ข…๋ฅ˜๊ฐ€ Person์ด๋ฉฐ, ๊ทธ ID๋Š” http://localhost:8000/users/john, ์ด๋ฆ„์€ john, ์‚ฌ์šฉ์ž๋ช…๋„ john์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

fedify lookup์€ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ์ด๋Š” Mastodon์—์„œ ํ•ด๋‹น URI๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌผ๋ก , ํ˜„์žฌ ์—ฌ๋Ÿฌ๋ถ„์˜ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•„์ง Mastodon์—์„œ ๊ฒ€์ƒ‰ํ•ด๋„ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค์ง€๋Š” ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.)

์—ฌ๋Ÿฌ๋ถ„์ด fedify lookup ๋ช…๋ น์–ด๋ณด๋‹ค curl์„ ๋” ์„ ํ˜ธํ•˜์‹ ๋‹ค๋ฉด, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ๋„ ์•กํ„ฐ ์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (-H ์˜ต์…˜์œผ๋กœ Accept ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋Š” ๊ฒƒ์— ์ฃผ์˜ํ•˜์‹ญ์‹œ์˜ค):

curl -H"Accept: application/activity+json" http://localhost:8000/users/john

๋‹จ, ์œ„์™€ ๊ฐ™์ด ์กฐํšŒํ•  ๊ฒฝ์šฐ ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋งจ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜๊ธฐ ์–ด๋ ค์šด JSON ํ˜•์‹์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์‹œ์Šคํ…œ์— jq ๋ช…๋ น์–ด๋„ ํ•จ๊ป˜ ๊น”๋ ค์žˆ๋‹ค๋ฉด, curl๊ณผ jq๋ฅผ ํ•จ๊ป˜ ์“ธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

Visual Studio Code๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋Š” ๋™์•ˆ์—๋Š” Visual Studio Code๋ฅผ ์จ๋ณด์‹ค ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์šฐ๋ฆฌ๋Š” TypeScript๋ฅผ ์จ์•ผ ํ•˜๋Š”๋ฐ, Visual Studio Code๋Š” ํ˜„์กดํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„ํŽธํ•˜๋ฉด์„œ๋„ ๋›ฐ์–ด๋‚œ TypeScript IDE์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ์…‹์—…์— ์ด๋ฏธ Visual Studio Code ์„ค์ •์ด ๊ฐ–์ถฐ์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํฌ๋งคํ„ฐ๋‚˜ ๋ฆฐํŠธ ๋“ฑ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š”๋„ ์—†์Šต๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

Visual Studio์™€ ํ—ท๊ฐˆ๋ฆฌ์‹œ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. Visual Studio Code์™€ Visual Studio๋Š” ๋ธŒ๋žœ๋“œ๋งŒ ๊ณต์œ ํ•  ๋ฟ ์„œ๋กœ ์™„์ „ํžˆ ๋‹ค๋ฅธ ์†Œํ”„ํŠธ์›จ์–ด์ž…๋‹ˆ๋‹ค.

Visual Studio Code๋ฅผ ์„ค์น˜ํ•˜์‹  ๋‹ค์Œ, ํŒŒ์ผ โ†’ ํด๋” ์—ด๊ธฐโ€ฆ ๋ฉ”๋‰ด๋ฅผ ๋ˆŒ๋Ÿฌ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์‹ญ์‹œ์˜ค.

๋งŒ์•ฝ ์šฐํ•˜๋‹จ์— ใ€Œ์ด ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ๋Œ€ํ•œ ๊ถŒ์žฅ๋˜๋Š” biomejs์˜ โ€˜Biomeโ€™ ํ™•์žฅ์„(๋ฅผ) ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?ใ€๋ผ๊ณ  ๋ฌป๋Š” ์ฐฝ์ด ๋œจ๋ฉด ์„ค์น˜ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ•ด๋‹น ํ™•์žฅ์„ ์„ค์น˜ํ•˜์„ธ์š”. ์ด ํ™•์žฅ์„ ์„ค์น˜ํ•˜๋ฉด TypeScript ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ๋“ค์—ฌ์“ฐ๊ธฐ๋‚˜ ๋„์–ด์“ฐ๊ธฐ ๊ฐ™์€ ์ฝ”๋“œ ์Šคํƒ€์ผ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๊ฐ€ ์„œ์‹ํ™” ๋ฉ๋‹ˆ๋‹ค.

ํŒ

์—ฌ๋Ÿฌ๋ถ„์ด ์ถฉ์„ฑ์Šค๋Ÿฌ์šด Emacs ๋˜๋Š” Vim ์‚ฌ์šฉ์ž๋ผ๋ฉด, ์“ฐ๋˜ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๋ฅผ ์“ฐ๋Š” ๊ฒƒ์„ ๋ง๋ฆฌ์ง€ ์•Š๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TypeScript LSP ์„ค์ •์€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. TypeScript LSP ์„ค์ • ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์ƒ์‚ฐ์„ฑ์˜ ์ฐจ์ด๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์„ ์ˆ˜ ์ง€์‹

TypeScript

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ ์ „์—, ๊ฐ„๋‹จํžˆ TypeScript์— ๋Œ€ํ•ด ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์—ฌ๋Ÿฌ๋ถ„์ด ์ด๋ฏธ TypeScript์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด ์ด ์žฅ์€ ๋„˜๊ธฐ์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

TypeScript๋Š” JavaScript์— ์ •์  ํƒ€์ž… ๊ฒ€์‚ฌ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. TypeScript ๋ฌธ๋ฒ•์€ JavaScript ๋ฌธ๋ฒ•๊ณผ ๊ฑฐ์˜ ๊ฐ™์ง€๋งŒ, ๋ณ€์ˆ˜๋‚˜ ํ•จ์ˆ˜ ๋ฌธ๋ฒ•์— ํƒ€์ž…์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ํฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ์ง€์ •์€ ๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋’ค์— ์ฝœ๋ก (:)์„ ๋ถ™์—ฌ์„œ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋Š” foo ๋ณ€์ˆ˜๊ฐ€ ๋ฌธ์ž์—ด(string)์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค:

let foo: string;

๋งŒ์•ฝ ์œ„์™€ ๊ฐ™์ด ์„ ์–ธ๋œ foo ๋ณ€์ˆ˜์— ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ๊ฐ’์„ ๋Œ€์ž…ํ•˜๋ ค๊ณ  ํ•˜๋ฉด Visual Studio Code๊ฐ€ ์‹คํ–‰ํ•ด๋ณด๊ธฐ ์ „์— ๋ฏธ๋ฆฌ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๊ทธ์–ด์ฃผ๋ฉฐ ํƒ€์ž… ์˜ค๋ฅ˜๋ฅผ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

foo = 123;
// ts(2322): 'number' ํ˜•์‹์€ 'string' ํ˜•์‹์— ํ• ๋‹นํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ฝ”๋”ฉํ•˜๋ฉด์„œ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๋งŒ๋‚˜๋ฉด ์ง€๋‚˜์น˜์ง€ ์•Š๋„๋ก ํ•˜์‹ญ์‹œ์˜ค. ๋ฌด์‹œํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉด ๊ทธ ๋ถ€๋ถ„์—์„œ ์‹ค์ œ๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.

TypeScript๋กœ ์ฝ”๋”ฉ์„ ํ•˜๋ฉฐ ๋งˆ์ฃผ์น˜๋Š” ๊ฐ€์žฅ ํ”ํ•œ ํƒ€์ž… ์˜ค๋ฅ˜์˜ ์œ ํ˜•์€ ๋ฐ”๋กœ null ๊ฐ€๋Šฅ์„ฑ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด bar ๋ณ€์ˆ˜๋Š” ๋ฌธ์ž์—ด(string)์ผ ์ˆ˜๋„ ์žˆ์ง€๋งŒ null์ผ ์ˆ˜๋„ ์žˆ๋‹ค(string | null)๊ณ  ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:

const bar: string | null = someFunction();

๋งŒ์•ฝ ์ด ๋ณ€์ˆ˜์˜ ๋‚ด์šฉ์—์„œ ๊ฐ€์žฅ ์ฒซ ๊ธ€์ž๋ฅผ ๊บผ๋‚ด๋ ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ์“ด๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”?

const firstChar = bar.charAr(0);
// ts(18047): 'bar'์€(๋Š”) 'null'์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. bar๊ฐ€ ์–ด์ฉ” ๋•Œ๋Š” null์ผ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ๊ฒฝ์šฐ์— null.charAt(0)์„ ํ˜ธ์ถœํ•˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ผ๋Š” ์ด์•ผ๊ธฐ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๊ฒฝ์šฐ์— ์•„๋ž˜์™€ ๊ฐ™์ด null์ธ ๊ฒฝ์šฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

const firstChar = bar === null ? "" : bar.charAr(0);

์ด์™€ ๊ฐ™์ด TypeScript๋Š” ์ฝ”๋”ฉํ•  ๋•Œ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ๋˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๋ฅผ ๋– ์˜ฌ๋ฆฌ๊ฒŒ ํ•ด์„œ ๋ฒ„๊ทธ๋ฅผ ๋ฏธ์—ฐ์— ๋ฐฉ์ง€ํ•˜๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

๋˜, TypeScript์˜ ๋ถ€์ˆ˜์ ์ธ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” ์ž๋™ ์™„์„ฑ์ด ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, foo.๊นŒ์ง€ ์ž…๋ ฅํ•˜๋ฉด ๋ฌธ์ž์—ด ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ง„ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์ด ๋‚˜์™€์„œ ๊ทธ ์ค‘์—์„œ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ผ์ผํžˆ ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์ง€ ์•Š๊ณ ์„œ๋„ ๋น ๋ฅด๊ฒŒ ์ฝ”๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋ฉด์„œ TypeScript์˜ ๋งค๋ ฅ๋„ ํ•จ๊ป˜ ๋А๋ผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ฌด์—‡๋ณด๋‹ค Fedify๋Š” TypeScript์™€ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ€์žฅ ๊ฒฝํ—˜์ด ์ข‹์œผ๋‹ˆ๊นŒ์š”.

ํŒ

TypeScript๋ฅผ ์ œ๋Œ€๋กœ ์ฐฌ์ฐฌํžˆ ๋ฐฐ์›Œ๋ณด๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, ๊ณต์‹ TypeScript ํ•ธ๋“œ๋ถ์„ ์ฝ์œผ์‹ค ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ „๋ถ€ ์ฝ๋Š”๋ฐ ์•ฝ 30๋ถ„ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค.

JSX

JSX๋Š” JavaScript ์ฝ”๋“œ ์•ˆ์— XML ๋˜๋Š” HTML์„ ์ง‘์–ด๋„ฃ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•œ JavaScript์˜ ๋ฌธ๋ฒ• ํ™•์žฅ์ž…๋‹ˆ๋‹ค. TypeScript์—์„œ๋„ ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด ๊ฒฝ์šฐ์—๋Š” TSX๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ๋ชจ๋“  HTML์„ JSX ๋ฌธ๋ฒ•์„ ํ†ตํ•ด JavaScript ์ฝ”๋“œ ์•ˆ์— ์ž‘์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. JSX์— ์ด๋ฏธ ์ต์ˆ™ํ•œ ๋ถ„๋“ค์€ ์ด ์žฅ์„ ๋„˜๊ธฐ์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <div> ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๋Š” HTML ํŠธ๋ฆฌ๋ฅผ html ๋ณ€์ˆ˜์— ๋Œ€์ž…ํ•ฉ๋‹ˆ๋‹ค:

const html = <div>
  <p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p>
</div>;

์ค‘๊ด„ํ˜ธ๋ฅผ ํ†ตํ•ด JavaScript ํ‘œํ˜„์‹์„ ๋„ฃ๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฝ”๋“œ๋Š” ๋ฌผ๋ก  getName() ํ•จ์ˆ˜๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค):

const html = <div title={"์•ˆ๋…•, " + getName() + "!"}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</div>;

JSX์˜ ํŠน์ง• ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ(component)๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์ž์‹ ๋งŒ์˜ ํƒœ๊ทธ๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋Š” ํ‰๋ฒ”ํ•œ JavaScript ํ•จ์ˆ˜๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค (์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„์€ ์ผ๋ฐ˜์ ์œผ๋กœ PascalCase ์Šคํƒ€์ผ์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค):

import type { FC } from "hono/jsx";

function getName() {
  return "JSX";
}

interface ContainerProps {
  name: string;
}

const Container: FC<ContainerProps> = (props) => {
  return <div title={"์•ˆ๋…•, " + props.name + "!"}>{props.children}</div>;
};

const html = <Container name={getName()}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</Container>;

์œ„ ์ฝ”๋“œ์—์„œ FC๋Š” ์šฐ๋ฆฌ๊ฐ€ ์“ธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ธ Hono์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋„์™€์ค๋‹ˆ๋‹ค. FC๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž…(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ˜๋Ÿผ ํ™”์‚ด๊ด„ํ˜ธ ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ํƒ€์ž…๋“ค์ด ๋ฐ”๋กœ ํƒ€์ž… ์ธ์ž๋“ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํƒ€์ž… ์ธ์ž๋กœ ํ”„๋กญ(props) ํ˜•์‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กญ์ด๋ž€, ์ปดํฌ๋„ŒํŠธ์—๊ฒŒ ๋„˜๊ฒจ ์ค„ ๋งค๊ฐœ๋ณ€์ˆ˜๋“ค์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ง์ž…๋‹ˆ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กญ ํ˜•์‹์œผ๋กœ ContainerProps ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์„ ์–ธํ•˜๊ณ  ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ €๋„ค๋ฆญ ํƒ€์ž…์˜ ํƒ€์ž… ์ธ์ž๋Š” ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‰ผํ‘œ๋กœ ๊ฐ ์ธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Foo<A, B>๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž… Foo์— ํƒ€์ž… ์ธ์ž A์™€ B๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ, ์ €๋„ค๋ฆญ ํ•จ์ˆ˜๋ผ๋Š” ๊ฒƒ๋„ ์žˆ์œผ๋ฉฐ, someFunction<A, B>(foo, bar)์™€ ๊ฐ™์ด ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ธ์ž๊ฐ€ ํ•˜๋‚˜์ผ ๋•Œ๋Š” ํƒ€์ž… ์ธ์ž๋ฅผ ๊ฐ์‹ธ๋Š” ํ™”์‚ด๊ด„ํ˜ธ๊ฐ€ ๋งˆ์น˜ XML/HTML ํƒœ๊ทธ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ, JSX์˜ ๊ธฐ๋Šฅ๊ณผ๋Š” ์•„๋ฌด ๊ด€๋ จ์ด ์—†์Šต๋‹ˆ๋‹ค.

FC<ContainerProps>
์ €๋„ค๋ฆญ ํƒ€์ž… FC์— ํƒ€์ž… ์ธ์ž ContainerProps๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ.
<Container>
<Container>๋ผ๋Š” ์ด๋ฆ„์˜ ์ปดํฌ๋„ŒํŠธ ํƒœ๊ทธ๋ฅผ ์—ฐ ๊ฒƒ. </Container>๋กœ ๋‹ซ์•„์•ผ ํ•จ.

ํ”„๋กญ์œผ๋กœ ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ๋“ค ์ค‘ children์€ ํŠน๋ณ„ํžˆ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์—˜๋ฆฌ๋จผํŠธ๋“ค์ด children ํ”„๋กญ์œผ๋กœ ๋„˜์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์œ„ ์ฝ”๋“œ์—์„œ html ๋ณ€์ˆ˜์—๋Š” <div title="์•ˆ๋…•, JSX!"><p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p></div>๋ผ๋Š” HTML ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŒŒ์ผ ์•ˆ์— JSX๋กœ <Layout> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

import type { FC } from "hono/jsx";

export const Layout: FC = (props) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="color-scheme" content="light dark" />
      <title>Microblog</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
      />
    </head>
    <body>
      <main class="container">{props.children}</main>
    </body>
  </html>
);

๋””์ž์ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ๊ณต์„ ๋“ค์ด์ง€ ์•Š๊ธฐ ์œ„ํ•ด, Pico CSS๋ผ๋Š” CSS ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ TypeScript์˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ธฐ๊ฐ€ ์ถ”๋ก ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ, ์œ„์˜ props ๊ฐ™์ด ํƒ€์ž… ํ‘œ๊ธฐ๋ฅผ ์ƒ๋žตํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํƒ€์ž… ํ‘œ๊ธฐ๊ฐ€ ์ƒ๋žต๋œ ๊ฒฝ์šฐ์—๋„, Visual Studio Code์—์„œ ๋ณ€์ˆ˜ ์ด๋ฆ„ ์œ„์— ๋งˆ์šฐ์Šค ์ปค์„œ๋ฅผ ๊ฐ€์ ธ๋‹ค ๋Œ€๋ฉด ํ•ด๋‹น ๋ณ€์ˆ˜๊ฐ€ ์–ด๋–ค ํƒ€์ž…์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ ๋‹ค์Œ, ๊ฐ™์€ ํŒŒ์ผ์—์„œ ๋ ˆ์ด์•„์›ƒ ์•ˆ์— ๋“ค์–ด๊ฐˆ <SetupForm> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

JSX์—์„œ๋Š” ์ตœ์ƒ์œ„์— ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ๋Š”๋ฐ, <SetupForm> ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” <h1>๊ณผ <form> ๋‘ ๊ฐœ์˜ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ์ตœ์ƒ์œ„์— ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด๋ฅผ ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ์ฒ˜๋Ÿผ ๋ฌถ์–ด์ฃผ๊ธฐ ์œ„ํ•ด์„œ ๋นˆ ํƒœ๊ทธ ๋ชจ์–‘์˜ <>์™€ </>๋กœ ๊ฐ์ŒŒ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”„๋ž˜๊ทธ๋จผํŠธ(fragment)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. src/app.tsx ํŒŒ์ผ์—์„œ ์•ž์„œ ์ •์˜ํ•œ ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ /setup ํŽ˜์ด์ง€์—์„œ ์•ž์„œ ๋งŒ๋“  ๊ณ„์ • ์ƒ์„ฑ ์–‘์‹์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) =>
  c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  ),
);

์ž, ๊ทธ๋Ÿผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋ณด์—ฌ์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•ˆ๋‚ด

JSX๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์†Œ์Šค ํŒŒ์ผ์˜ ํ™•์žฅ์ž๊ฐ€ .jsx ๋˜๋Š” .tsx์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์žฅ์—์„œ ํŽธ์ง‘ํ•œ ๋‘ ํŒŒ์ผ ๋ชจ๋‘ ํ™•์žฅ์ž๊ฐ€ .tsx๋ผ๋Š” ์‚ฌ์‹ค์— ์ฃผ์˜ํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์…‹์—…

์ž, ๋ณด์ด๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๋™์ž‘์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ณณ์ด ํ•„์š”ํ•œ๋ฐ, SQLite๋ฅผ ์“ฐ๋„๋ก ํ•ฉ์‹œ๋‹ค. SQLite๋Š” ์ž‘์€ ๊ทœ๋ชจ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์•Œ๋งž๋Š” ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ž…๋‹ˆ๋‹ค.

์šฐ์„  ๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ด์„ ํ…Œ์ด๋ธ”์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. ์•ž์œผ๋กœ ๋ชจ๋“  ํ…Œ์ด๋ธ” ์„ ์–ธ์€ src/schema.sql ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋Š” users ํ…Œ์ด๋ธ”์— ๋‹ด์Šต๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS users (
  id       INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
  username TEXT    NOT NULL UNIQUE      CHECK (trim(lower(username)) = username
                                               AND username <> ''
                                               AND length(username) <= 50)
);

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ฏ€๋กœ, ๊ธฐ๋ณธ ํ‚ค์ธ id ์นผ๋Ÿผ์ด 1 ์ด์™ธ์˜ ๊ฐ’์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ๊ฑธ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ users ํ…Œ์ด๋ธ”์—๋Š” ๋‘˜ ์ด์ƒ์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ด์„ ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ณ„์ • ์•„์ด๋””๋ฅผ ๋‹ด์„ username ์นผ๋Ÿผ์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋‚˜ ๋„ˆ๋ฌด ๊ธด ๋ฌธ์ž์—ด์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ์คฌ์Šต๋‹ˆ๋‹ค.

์ด์ œ users ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ src/schema.sql ํŒŒ์ผ์„ ์‹คํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด sqlite3 ๋ช…๋ น์–ด๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, SQLite ์›น์‚ฌ์ดํŠธ์—์„œ ๋ฐ›๊ฑฐ๋‚˜ ๊ฐ ํ”Œ๋žซํผ์˜ ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋กœ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. macOS์˜ ๊ฒฝ์šฐ์—๋Š” ์šด์˜์ฒด์ œ์— ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ๋”ฐ๋กœ ๋ฐ›์„ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ๋ฐ›์„ ๊ฒฝ์šฐ ์šด์˜์ฒด์ œ์— ๋งž๋Š” sqlite-tools-*.zip ํŒŒ์ผ์„ ๋ฐ›์•„์„œ ์••์ถ•์„ ํ•ด์ œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์„ค์น˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

sudo apt install sqlite3  # Debian ๋ฐ Ubuntu
sudo dnf install sqlite   # Fedora ๋ฐ RHEL
choco install sqlite  # Chocolatey
scoop install sqlite  # Scoop
winget install SQLite.SQLite  # Windows Package Manager

์ž, sqlite3 ๋ช…๋ น์–ด๊ฐ€ ์ค€๋น„๋˜์—ˆ๋‹ค๋ฉด ์ด์ œ ์ด๋ฅผ ์ด์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด microblog.sqlite3 ํŒŒ์ผ์ด ์ƒ๊ธฐ๋Š”๋ฐ, ์ด ์•ˆ์— SQLite ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

์•ฑ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ

์ด์ œ ์ €ํฌ๊ฐ€ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•  ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. Node.js์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SQLite ๋“œ๋ผ์ด๋ฒ„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, ์ €ํฌ๋Š” better-sqlite3 ํŒจํ‚ค์ง€๋ฅผ ์“ฐ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€๋Š” npm ๋ช…๋ น์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊น” ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

npm add better-sqlite3
npm add --save-dev @types/better-sqlite3

ํŒ

@types/better-sqlite3 ํŒจํ‚ค์ง€๋Š” TypeScript๋ฅผ ์œ„ํ•ด better-sqlite ํŒจํ‚ค์ง€์˜ API์— ๋Œ€ํ•œ ํƒ€์ž… ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ Visual Studio Code์—์„œ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™ ์™„์„ฑ์ด๋‚˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด์™€ ๊ฐ™์ด @types/ ๋ฒ”์œ„ ์•ˆ์— ์žˆ๋Š” ํŒจํ‚ค์ง€๋ฅผ Definitely Typed ํŒจํ‚ค์ง€๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ TypeScript๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š์•˜์„ ๋•Œ, ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ํƒ€์ž… ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ ๊ธฐ์ž…ํ•˜์—ฌ ํŒจํ‚ค์ง€๋กœ ๋งŒ๋“  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ, ์ด ํŒจํ‚ค์ง€๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งญ์‹œ๋‹ค. src/db.ts๋ผ๋Š” ์ƒˆ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค:

import Database from "better-sqlite3";

const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export default db;

ํŒ

์ฐธ๊ณ ๋กœ db.pragma() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•œ ์„ค์ •์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์ง€๋‹™๋‹ˆ๋‹ค:

journal_mode = WAL
SQLite์—์„œ ์›์ž์  ์ปค๋ฐ‹ ๋ฐ ๋กค๋ฐฑ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ ์„ ํ–‰ ๊ธฐ์ž… ๋ชจ๋“œ๋ฅผ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ชจ๋“œ๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ ๋กค๋ฐฑ ์ €๋„ ๋ชจ๋“œ์— ๋น„ํ•ด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ์—์„œ ๋” ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.
foreign_keys = ON
SQLite์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด ์„ค์ •์„ ์ผœ๋ฉด ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋˜์–ด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ์ง€ํ‚ค๋Š” ๋ฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  users ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. src/schema.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด User ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface User {
  id: number;
  username: string;
}

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ–ˆ์œผ๋‹ˆ, ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ฝ์ž…ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…์— ์“ฐ์ผ db ๊ฐ์ฒด์™€ User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import db from "./db.ts";
import type { User } from "./schema.ts";

POST /setup ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
  return c.redirect("/");
});

์•ž์„œ ๋งŒ๋“ค์—ˆ๋˜ GET /setup ํ•ธ๋“ค๋Ÿฌ์—๋„ ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});

ํ…Œ์ŠคํŠธ

์ด์ œ ๊ณ„์ • ์ƒ์„ฑ ๊ธฐ๋Šฅ์ด ์–ผ์ถ” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ, ํ•œ ๋ฒˆ ์จ ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๊ณ„์ •์„ ์ƒ์„ฑํ•ด ๋ณด์„ธ์š”. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•ž์œผ๋กœ ์•„์ด๋””๋กœ johndoe๋ฅผ ์ผ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋˜์—ˆ๋‹ค๋ฉด, SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‚˜ ํ™•์ธ๋„ ํ•ด ๋ด…๋‹ˆ๋‹ค:

echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3

๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค (๋ฌผ๋ก , johndoe๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 johndoe

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ด์ œ ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ ๊ณ„์ • ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค. ๋น„๋ก ๋ณด์—ฌ ์ค„ ์ •๋ณด๊ฐ€ ๊ฑฐ์˜ ์—†์ง€๋งŒ์š”.

์ด๋ฒˆ์—๋„ ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์ž‘์—…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์— <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  handle: string;
}

export const Profile: FC<ProfileProps> = ({ name, handle }) => (
  <>
    <hgroup>
      <h1>{name}</h1>
      <p style="user-select: all;">{handle}</p>
    </hgroup>
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์—์„œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” GET /users/{username} ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.username} handle={handle} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด ์ด์ œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด ๋ด์•ผ๊ฒ ์ฃ ? ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe (๊ณ„์ • ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ํ–ˆ์„ ๊ฒฝ์šฐ; ์•„๋‹ˆ๋ผ๋ฉด URL์„ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค) ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ณด์„ธ์š”. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ

์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค(fediverse handle), ์ค„์—ฌ์„œ ํ•ธ๋“ค์ด๋ž€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด์—์„œ ๊ณ„์ •์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ณ ์œ ํ•œ ์ฃผ์†Œ ๊ฐ™์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด @hongminhee@fosstodon.org์ฒ˜๋Ÿผ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ƒ๊ฒผ๋Š”๋ฐ, ์‹ค์ œ ๊ตฌ์„ฑ๋„ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•ฉ๋‹ˆ๋‹ค. ๋งจ ์ฒ˜์Œ์— @์ด ์˜ค๊ณ , ๊ทธ ๋‹ค์Œ์— ์ด๋ฆ„, ๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ @์ด ์˜จ ๋’ค, ๋งˆ์ง€๋ง‰์— ๊ณ„์ •์ด ์†ํ•œ ์„œ๋ฒ„์˜ ๋„๋ฉ”์ธ ์ด๋ฆ„์ด ์˜ต๋‹ˆ๋‹ค. ๋•Œ๋•Œ๋กœ ๋งจ ์•ž์˜ @์ด ์ƒ๋žต๋˜๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ํ•ธ๋“ค์€ WebFinger์™€ acct: URI ํ˜•์‹์ด๋ผ๋Š” ๋‘ ๊ฐœ์˜ ํ‘œ์ค€์œผ๋กœ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. Fedify๊ฐ€ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ง„ํ–‰ํ•˜๋Š” ๋™์•ˆ ์—ฌ๋Ÿฌ๋ถ„์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์•Œ์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

์•กํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

ActivityPub์€ ๊ทธ ์ด๋ฆ„์—์„œ๋„ ๋“œ๋Ÿฌ๋‚˜๋“ฏ, ์•กํ‹ฐ๋น„ํ‹ฐ(activity)๋ฅผ ์ฃผ๊ณ  ๋ฐ›๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค. ๊ธ€์“ฐ๊ธฐ, ๊ธ€ ๊ณ ์น˜๊ธฐ, ๊ธ€ ์ง€์šฐ๊ธฐ, ๊ธ€์— ์ข‹์•„์š” ์ฐ๊ธฐ, ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ, ํ”„๋กœํ•„ ๊ณ ์น˜๊ธฐโ€ฆ ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ ์ผ์–ด๋‚˜๋Š” ๋ชจ๋“  ์ผ๋“ค์„ ์•กํ‹ฐ๋น„ํ‹ฐ๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋ชจ๋“  ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์•กํ„ฐ(actor)์—์„œ ์•กํ„ฐ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํ™๊ธธ๋™์ด ๊ธ€์„ ์“ฐ๋ฉด ใ€Œ๊ธ€์“ฐ๊ธฐใ€(Create(Note)) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ™๊ธธ๋™์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์˜ ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๊ธ€์— ์ž„๊บฝ์ •์ด ์ข‹์•„์š”๋ฅผ ์ฐ์œผ๋ฉด ใ€Œ์ข‹์•„์š”ใ€(Like) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž„๊บฝ์ •์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ActivityPub์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๊ฑธ์Œ์€ ์•กํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

fedify init ๋ช…๋ น์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ๋ชจ ์•ฑ์— ์ด๋ฏธ ์•„์ฃผ ๊ฐ„๋‹จํ•œ ์•กํ„ฐ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธด ํ•˜์ง€๋งŒ, Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ์‹ค์ œ์˜ ์†Œํ”„ํŠธ์›จ์–ด๋“ค๊ณผ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•กํ„ฐ๋ฅผ ์ข€ ๋” ์ œ๋Œ€๋กœ ๊ตฌํ˜„ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋‹จ, ํ˜„์žฌ์˜ ๊ตฌํ˜„์„ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณผ๊นŒ์š”? src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด๋ด…์‹œ๋‹ค:

import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";

const logger = getLogger("microblog");

const federation = createFederation({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
});

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: identifier,
  });
});

export default federation;

์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๋ถ€๋ถ„์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ๋‹ค๋ฅธ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์„œ๋ฒ„์˜ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์“ธ URL๊ณผ ๊ทธ ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์šฐ๋ฆฌ๊ฐ€ ์•ž์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ /users/johndoe๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ identifier ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ "johndoe"๋ผ๋Š” ๋ฌธ์ž์—ด ๊ฐ’์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋Š” Person ํด๋ž˜์Šค์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์กฐํšŒํ•œ ์•กํ„ฐ์˜ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

ctx ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ๋Š” Context ๊ฐ์ฒด๊ฐ€ ๋„˜์–ด์˜ค๋Š”๋ฐ, ActivityPub ํ”„๋กœํ† ์ฝœ๊ณผ ๊ด€๋ จ๋œ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์œ„ ์ฝ”๋“œ์—์„œ ์“ฐ์ด๊ณ  ์žˆ๋Š” getActorUri() ๋ฉ”์„œ๋“œ๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌ๋œ identifier๊ฐ€ ๋“ค์–ด๊ฐ„ ์•กํ„ฐ์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด URI๋Š” Person ๊ฐ์ฒด์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋กœ ์“ฐ์ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ๋ณด์‹œ๋ฉด ์•Œ๊ฒ ์ง€๋งŒ, ํ˜„์žฌ๋Š” /users/ ๊ฒฝ๋กœ ๋’ค์— ์–ด๋–ค ํ•ธ๋“ค์ด ์˜ค๋“  ๋ถ€๋ฅด๋Š” ๋Œ€๋กœ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์ง€์–ด๋‚ด์„œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ณ ์ณ๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ…Œ์ด๋ธ”์€ ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •๋งŒ ๋‹ด๋Š” users ํ…Œ์ด๋ธ”๊ณผ ๋‹ฌ๋ฆฌ, ์—ฐํ•ฉ๋˜๋Š” ์„œ๋ฒ„๋“ค์— ์†ํ•œ ์›๊ฒฉ ์•กํ„ฐ๋“ค๊นŒ์ง€๋„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. src/schema.sql ํŒŒ์ผ์— ๋‹ค์Œ SQL์„ ๋ง๋ถ™์ด์„ธ์š”:

CREATE TABLE IF NOT EXISTS actors (
  id               INTEGER NOT NULL PRIMARY KEY,
  user_id          INTEGER          REFERENCES users (id),
  uri              TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  handle           TEXT    NOT NULL UNIQUE CHECK (handle <> ''),
  name             TEXT,
  inbox_url        TEXT    NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%'
                                                  OR inbox_url LIKE 'http://%'),
  shared_inbox_url TEXT                    CHECK (shared_inbox_url
                                                  LIKE 'https://%'
                                                  OR shared_inbox_url
                                                  LIKE 'http://%'),
  url              TEXT                    CHECK (url LIKE 'https://%'
                                                  OR url LIKE 'http://%'),
  created          TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                           CHECK (created <> '')
);
  • user_id ์นผ๋Ÿผ์€ users ์นผ๋Ÿผ๊ณผ ์—ฐ๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ์™ธ๋ž˜ ํ‚ค์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๊ฐ€ ์›๊ฒฉ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” NULL์ด ๋“ค์–ด๊ฐ€์ง€๋งŒ, ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •์ด๋ผ๋ฉด ํ•ด๋‹น ๊ณ„์ •์˜ users.id ๊ฐ’์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ์•กํ„ฐ ID๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•กํ„ฐ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ActivityPub ๊ฐ์ฒด๋Š” URI ํ˜•ํƒœ์˜ ๊ณ ์œ  ID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • handle ์นผ๋Ÿผ์€ @johndoe@example.com ๋ชจ์–‘์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • name ์นผ๋Ÿผ์€ UI์— ํ‘œ์‹œ๋˜๋Š” ์ด๋ฆ„์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋ณดํ†ต ํ’€๋„ค์ž„์ด๋‚˜ ๋‹‰๋„ค์ž„์ด ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๊ฒ ์ฃ . ๋‹ค๋งŒ, ActivityPub ๋ช…์„ธ์— ๋”ฐ๋ผ ์ด ์นผ๋Ÿผ์€ ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ(inbox) URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ์ˆ˜์‹ ํ•จ์ด ๋ฌด์—‡์ธ์ง€์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค๋งŒ, ํ˜„์žฌ๋กœ์„œ๋Š” ์•กํ„ฐ์—๊ฒŒ ํ•„์ˆ˜์ ์œผ๋กœ ์กด์žฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ๋งŒ ์•Œ์•„ ๋‘์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด ์นผ๋Ÿผ ์—ญ์‹œ ๋นŒ ์ˆ˜๋„ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • shared_inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ(shared inbox) URL์„ ๋‹ด๋Š”๋ฐ, ์ด ์—ญ์‹œ ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜๋Š” ์•„๋‹ˆ๋ฉฐ, ๋”ฐ๋ผ์„œ ๋นŒ ์ˆ˜ ์žˆ๊ณ  ์นผ๋Ÿผ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ๊ฐ™์€ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ URL์ด๋ž€ ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด์„œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ URL์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ID์™€ ํ”„๋กœํ•„ URL์ด ๋™์ผํ•œ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ์„œ๋น„์Šค์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ ๊ฒฝ์šฐ์— ์ด ์นผ๋Ÿผ์— ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์€ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋œ ์‹œ์ ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฝ์ž… ์‹œ์  ์‹œ๊ฐ์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.

์ž, ์ด์ œ src/schema.sql ํŒŒ์ผ์„ microblog.sqlite3 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์— ์ ์šฉํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

ํŒ

์•ž์„œ users ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•  ๋•Œ CREATE TABLE IF NOT EXISTS ๋ฌธ์„ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  actors ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  ํƒ€์ž…๋„ src/schema.ts์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Actor {
  id: number;
  user_id: number | null;
  uri: string;
  handle: string;
  name: string | null;
  inbox_url: string;
  shared_inbox_url: string | null;
  url: string | null;
  created: string;
}

์•กํ„ฐ ๋ ˆ์ฝ”๋“œ

ํ˜„์žฌ users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜ ์žˆ๊ธด ํ•˜์ง€๋งŒ, ์ด์™€ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์—๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ actors ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ์„œ users์™€ actors ์–‘์ชฝ์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx์— ์žˆ๋Š” SetupForm์—์„œ ์•„์ด๋””์™€ ํ•จ๊ป˜ actors.name ์นผ๋Ÿผ์— ๋“ค์–ด๊ฐˆ ์ด๋ฆ„๋„ ์ž…๋ ฅ ๋ฐ›๋„๋ก ํ•ฉ์‹œ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
        <label>
          Name <input type="text" name="name" required />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

์•ž์„œ ์ •์˜ํ•œ Actor ํƒ€์ž…์„ src/app.tsx์—์„œ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

์ด์ œ ์ž…๋ ฅ ๋ฐ›์€ ์ด๋ฆ„์„ ๋น„๋กฏํ•ด ํ•„์š”ํ•œ ์ •๋ณด๋“ค์„ actors ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ๋กœ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ๋ฅผ POST /setup ํ•ธ๋“ค๋Ÿฌ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  const name = form.get("name");
  if (typeof name !== "string" || name.trim() === "") {
    return c.redirect("/setup");
  }
  const url = new URL(c.req.url);
  const handle = `@${username}@${url.host}`;
  const ctx = fedi.createContext(c.req.raw, undefined);
  db.transaction(() => {
    db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
      username,
    );
    db.prepare(
      `
      INSERT OR REPLACE INTO actors
        (user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
      VALUES (1, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      ctx.getActorUri(username).href,
      handle,
      name,
      ctx.getInboxUri(username).href,
      ctx.getInboxUri().href,
      ctx.getActorUri(username).href,
    );
  })();
  return c.redirect("/");
});

๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•  ๋•Œ, users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์„ ๋•Œ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์—†์–ด๋„ ์•„์ง ๊ณ„์ •์ด ์—†๋Š” ๊ฒƒ์œผ๋กœ ํŒ์ •ํ•˜๋„๋ก ๊ณ ์ณค์Šต๋‹ˆ๋‹ค. ๊ฐ™์€ ์กฐ๊ฑด์„ GET /setup ํ•ธ๋“ค๋Ÿฌ ๋ฐ GET /users/{username} ํ•ธ๋“ค๋Ÿฌ์—๋„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // Check if the user already exists
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});
app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE username = ?
      `,
    )
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.name ?? user.username} handle={handle} />
    </Layout>,
  );
});

ํŒ

TypeScript์—์„œ A & B๋Š” A ํƒ€์ž…์ธ ๋™์‹œ์— B ํƒ€์ž…์ธ ๊ฐ์ฒด๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, { a: number } & { b: string } ํƒ€์ž…์ด ์žˆ๋‹ค๊ณ  ํ•  ๋•Œ, { a: 123 }์ด๋‚˜ { b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ•˜์ง€๋งŒ, { a: 123, b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด, ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ์•„๋ž˜์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

setInboxListeners() ๋ฉ”์„œ๋“œ๋Š” ์ง€๊ธˆ์œผ๋กœ์„œ๋Š” ์‹ ๊ฒฝ ์“ฐ์ง€ ๋งˆ์„ธ์š”. ์ด ์—ญ์‹œ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•œ getInboxUri() ๋ฉ”์„œ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋ ค๋ฉด ์œ„ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

์ฝ”๋“œ๋ฅผ ๋ชจ๋‘ ๊ณ ์ณค๋‹ค๋ฉด, ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด์„œ ๋‹ค์‹œ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค๊ณ  ๋ ˆ์ฝ”๋“œ๋„ ์ฑ„์› ์œผ๋‹ˆ, ๋‹ค์‹œ src/federation.ts ํŒŒ์ผ์„ ๊ณ ์ณ๋ด…์‹œ๋‹ค. ๋จผ์ € db ๊ฐ์ฒด์™€ Endpoints ๋ฐ Actor๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";

ํ•„์š”ํ•œ ๊ฒƒ๋“ค์„ importํ–ˆ์œผ๋‹ˆ setActorDispatcher() ๋ฉ”์„œ๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค:

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE users.username = ?
      `,
    )
    .get(identifier);
  if (user == null) return null;

  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: user.name,
    inbox: ctx.getInboxUri(identifier),
    endpoints: new Endpoints({
      sharedInbox: ctx.getInboxUri(),
    }),
    url: ctx.getActorUri(identifier),
  });
});

๋ฐ”๋€ ์ฝ”๋“œ์—์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ users ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์—ฌ ํ˜„์žฌ ์„œ๋ฒ„์— ์žˆ๋Š” ๊ณ„์ •์ด ์•„๋‹ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, GET /users/johndoe (๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ์ •ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ๊ฒฝ์šฐ) ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” ์˜ฌ๋ฐ”๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์™€ ํ•จ๊ป˜ ์‘๋‹ตํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ์™ธ์˜ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” 404 Not Found๋ฅผ ์‘๋‹ตํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Person ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ถ€๋ถ„๋„ ์–ด๋–ป๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‚˜ ์‚ดํŽด๋ด…์‹œ๋‹ค. ๋จผ์ € name ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” actors.name ์นผ๋Ÿผ์˜ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. inbox์™€ endpoints ์†์„ฑ์€ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. url ์†์„ฑ์€ ์ด ๊ณ„์ •์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด๋Š”๋ฐ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•กํ„ฐ ID์™€ ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ์ผ์น˜์‹œํ‚ค๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

๋ˆˆ์ฐ๋ฏธ๊ฐ€ ์ข‹์€ ๋ถ„๋“ค์€ ๋ˆˆ์น˜์ฑ„์…จ๊ฒ ์ง€๋งŒ, Hono์™€ Fedify ์–‘์ชฝ์—์„œ GET /users/{identifier}์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฒน์ณ์„œ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ํ•ด๋‹น ์š”์ฒญ์„ ์‹ค์ œ๋กœ ๋ณด๋‚ด๋ฉด ์–ด๋А ์ชฝ์—์„œ ์‘๋‹ตํ•˜๊ฒŒ ๋ ๊นŒ์š”? ์ •๋‹ต์€ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Accept: text/html ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Hono ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. Accept: application/activity+json ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Fedify ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต์„ ์ฃผ๋Š” ๋ฐฉ์‹์„ HTTP ๋‚ด์šฉ ํ˜‘์ƒ(content negotiation)์ด๋ผ๊ณ  ํ•˜๋ฉฐ, Fedify ์ž์ฒด์—์„œ ๋‚ด์šฉ ํ˜‘์ƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์ข€ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ๋Š”, ๋ชจ๋“  ์š”์ฒญ์€ Fedify๋ฅผ ํ•œ ๋ฒˆ ๊ฑฐ์น˜๊ฒŒ ๋˜๋ฉฐ, ActivityPub๊ณผ ๊ด€๋ จ๋œ ์š”์ฒญ์ด ์•„๋‹ ๊ฒฝ์šฐ ์—ฐ๋™๋œ ํ”„๋ ˆ์ž„์›Œํฌ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Hono์—๊ฒŒ ์š”์ฒญ์„ ๊ฑด๋‚ด์ฃผ๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

Fedify์—์„œ๋Š” ๋ชจ๋“  URI ๋ฐ URL์„ URL ์ธ์Šคํ„ด์Šค๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ทธ๋Ÿผ ํ•œ ๋ฒˆ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ํ…Œ์ŠคํŠธํ•ด ๋ณผ๊นŒ์š”?

์„œ๋ฒ„๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด์–ด ์•„๋ž˜ ๋ช…๋ น์„ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/alice

alice์ด๋ผ๋Š” ๊ณ„์ •์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์•„๊นŒ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Failed to fetch the object.
It may be a private object.  Try with -a/--authorized-fetch.

๊ทธ๋Ÿผ johndoe ๊ณ„์ •๋„ ์กฐํšŒํ•ด ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์ด์ œ๋Š” ๊ฒฐ๊ณผ๊ฐ€ ์ž˜ ๋‚˜์˜ต๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

์•”ํ˜ธ ํ‚ค ์Œ๋“ค

๊ทธ ๋‹ค์Œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์„œ๋ช…์„ ์œ„ํ•œ ์•กํ„ฐ์˜ ์•”ํ˜ธ ํ‚ค๋“ค์ž…๋‹ˆ๋‹ค. ActivityPub์€ ์•กํ„ฐ๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋Š”๋ฐ, ์ด ๋•Œ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ •๋ง๋กœ ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ๋งŒ๋“ค์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋””์ง€ํ„ธ ์„œ๋ช…์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์•กํ„ฐ๋Š” ์ง์ด ๋งž๋Š” ์ž์‹ ๋งŒ์˜ ๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค) ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ๋งŒ๋“ค์–ด ๊ฐ–๊ณ  ์žˆ๊ณ , ๊ทธ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๋„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๊ณต๊ฐœํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ๋“ค์€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ ๋ฐœ์‹ ์ž์˜ ๊ณต๊ฐœ ํ‚ค์™€ ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์„œ๋ช…์„ ๋Œ€์กฐํ•˜์—ฌ ๊ทธ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •๋ง๋กœ ๋ฐœ์‹ ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒŒ ๋งž๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ช…๊ณผ ์„œ๋ช… ๋Œ€์กฐ๋Š” Fedify๊ฐ€ ์•Œ์•„์„œ ํ•ด ์ฃผ์ง€๋งŒ, ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ณด์กดํ•˜๋Š” ๊ฒƒ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์…”์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค)๋Š” ์ด๋ฆ„์—์„œ ๋“œ๋Ÿฌ๋‚˜๋“ฏ ์„œ๋ช…ํ•  ์ฃผ์ฒด ์ด์™ธ์—๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, ๊ณต๊ฐœ ํ‚ค๋Š” ๊ทธ ์šฉ๋„ ์ž์ฒด๊ฐ€ ๊ณต๊ฐœํ•˜๊ธฐ ์œ„ํ•จ์ด๋ฏ€๋กœ ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ €์žฅํ•  keys ํ…Œ์ด๋ธ”์„ src/schema.sql์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS keys (
  user_id     INTEGER NOT NULL REFERENCES users (id),
  type        TEXT    NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
  private_key TEXT    NOT NULL CHECK (private_key <> ''),
  public_key  TEXT    NOT NULL CHECK (public_key <> ''),
  created     TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
  PRIMARY KEY (user_id, type)
);

ํ…Œ์ด๋ธ”์„ ์œ ์‹ฌํžˆ ์‚ดํŽด๋ณด๋ฉด, type ์นผ๋Ÿผ์—๋Š” ์˜ค์ง ๋‘ ์ข…๋ฅ˜์˜ ๊ฐ’๋งŒ ํ—ˆ์šฉ๋œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜๋‚˜๋Š” RSA-PKCS#1-v1.5 ํ˜•์‹์ด๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” Ed25519 ํ˜•์‹์ž…๋‹ˆ๋‹ค. (๊ฐ๊ฐ์ด ๋ฌด์—‡์„ ๋œปํ•˜๋Š”์ง€๋Š” ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ค‘์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.) ๊ธฐ๋ณธ ํ‚ค๊ฐ€ (user_id, type)์— ๊ฑธ๋ ค ์žˆ์œผ๋‹ˆ, ํ•œ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•ด ์ตœ๋Œ€ ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์ˆ˜๋Š” ์—†์ง€๋งŒ, 2024๋…„ 9์›” ํ˜„์žฌ ActivityPub ๋„คํŠธ์›Œํฌ๋Š” RSA-PKCS-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-v1.5 ํ˜•์‹๋งŒ ๋ฐ›์•„๋“ค์ด๊ณ  ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” Ed25519 ํ˜•์‹์„ ๋ฐ›์•„๋“ค์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์–‘์ชฝ ๋ชจ๋‘์™€ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ๋ชจ๋‘ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

private_key ๋ฐ public_key ์นผ๋Ÿผ์€ ๋ฌธ์ž์—ด์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์— JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์„ ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. ๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค๋ฅผ JSON์œผ๋กœ ์ธ์ฝ”๋”ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋’ค์—์„œ ์ฐจ์ฐจ ๋‹ค๋ฃจ๊ฒŒ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ keys ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

keys ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  Key ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Key {
  user_id: number;
  type: "RSASSA-PKCS1-v1_5" | "Ed25519";
  private_key: string;
  public_key: string;
  created: string;
}

ํ‚ค ์Œ ๋””์ŠคํŒจ์ฒ˜

์ด์ œ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด๊ณ  Fedify์—์„œ ์ œ๊ณต๋˜๋Š” exportJwk(), generateCryptoKeyPair(), importJwk() ํ•จ์ˆ˜๋“ค๊ณผ ์•ž์„œ ์ •์˜ํ•œ Key ํƒ€์ž…์„ importํ•ฉ์‹œ๋‹ค:

import {
  Endpoints,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ๋ถ€๋ถ„์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User & Actor>(
        `
        SELECT * FROM users
        JOIN actors ON (users.id = actors.user_id)
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    if (user == null) return null;

    const keys = await ctx.getActorKeyPairs(identifier);
    return new Person({
      id: ctx.getActorUri(identifier),
      preferredUsername: identifier,
      name: user.name,
      inbox: ctx.getInboxUri(identifier),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      url: ctx.getActorUri(identifier),
      publicKey: keys[0].cryptographicKey,
      assertionMethods: keys.map((k) => k.multikey),
    });
  })
  .setKeyPairsDispatcher(async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
      .get(identifier);
    if (user == null) return [];
    const rows = db
      .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
      .all(user.id);
    const keys = Object.fromEntries(
      rows.map((row) => [row.type, row]),
    ) as Record<Key["type"], Key>;
    const pairs: CryptoKeyPair[] = [];
    // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์›ํ•˜๋Š” ๋‘ ํ‚ค ํ˜•์‹ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์— ๋Œ€ํ•ด
    // ํ‚ค ์Œ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ:
    for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
      if (keys[keyType] == null) {
        logger.debug(
          "The user {identifier} does not have an {keyType} key; creating one...",
          { identifier, keyType },
        );
        const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
        db.prepare(
          `
          INSERT INTO keys (user_id, type, private_key, public_key)
          VALUES (?, ?, ?, ?)
          `,
        ).run(
          user.id,
          keyType,
          JSON.stringify(await exportJwk(privateKey)),
          JSON.stringify(await exportJwk(publicKey)),
        );
        pairs.push({ privateKey, publicKey });
      } else {
        pairs.push({
          privateKey: await importJwk(
            JSON.parse(keys[keyType].private_key),
            "private",
          ),
          publicKey: await importJwk(
            JSON.parse(keys[keyType].public_key),
            "public",
          ),
        });
      }
    }
    return pairs;
  });

์šฐ์„  ๊ฐ€์žฅ ๋จผ์ € ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๊ฒƒ์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์— ์—ฐ๋‹ฌ์•„ ํ˜ธ์ถœ๋˜๊ณ  ์žˆ๋Š” setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜์—์„œ ๋ฐ˜ํ™˜๋œ ํ‚ค ์Œ๋“ค์„ ๊ณ„์ •์— ์—ฐ๊ฒฐํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ‚ค ์Œ๋“ค์„ ์—ฐ๊ฒฐํ•ด์•ผ Fedify๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ•  ๋•Œ ์ž๋™์œผ๋กœ ๋“ฑ๋ก๋œ ๊ฐœ์ธ ํ‚ค๋“ค๋กœ ๋””์ง€ํ„ธ ์„œ๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

generateCryptoKeyPair() ํ•จ์ˆ˜๋Š” ์ƒˆ๋กœ์šด ๊ฐœ์ธ ํ‚ค ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜์—ฌ CryptoKeyPair ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋กœ CryptoKeyPair ํƒ€์ž…์€ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ˜•์‹์ž…๋‹ˆ๋‹ค.

exportJwk() ํ•จ์ˆ˜๋Š” CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JWK ํ˜•์‹์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ์•”ํ˜ธ ํ‚ค๋ฅผ JSON์œผ๋กœ ํ‘œํ˜„ํ•˜๋Š” ํ‘œ์ค€์ ์ธ ํ˜•์‹์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. CryptoKey๋Š” ์•”ํ˜ธ ํ‚ค๋ฅผ JavaScript ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์›น ํ‘œ์ค€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.

importJwk() ํ•จ์ˆ˜๋Š” JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„๋œ ํ‚ค๋ฅผ CryptoKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. exportJwk() ํ•จ์ˆ˜์˜ ๋ฐ˜๋Œ€๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ setActorDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ˆˆ์„ ๋Œ๋ฆฝ์‹œ๋‹ค. getActorKeyPairs()๋ผ๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์“ฐ์ด๊ณ  ์žˆ๋Š”๋ฐ, ์ด ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฆ„๊ณผ ๊ฐ™์ด ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์€ ๋ฐ”๋กœ ์•ž์—์„œ ์‚ดํŽด๋ณธ setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ถˆ๋Ÿฌ์˜จ ๋ฐ”๋กœ ๊ทธ ํ‚ค ์Œ๋“ค์ž…๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” RSA-PKCS-v1.5์™€ Ed25519 ํ˜•์‹์œผ๋กœ ๋œ ๋‘ ์Œ์˜ ํ‚ค๋ฅผ ๋ถˆ๋Ÿฌ์™”์œผ๋ฏ€๋กœ, getActorKeyPairs() ๋ฉ”์„œ๋“œ๋Š” ๋‘ ํ‚ค ์Œ์˜ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ฐฐ์—ด์˜ ์›์†Œ๋Š” ํ‚ค ์Œ์„ ์—ฌ๋Ÿฌ ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด์ธ๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

interface ActorKeyPair {
  privateKey: CryptoKey;              // ๊ฐœ์ธ ํ‚ค
  publicKey: CryptoKey;               // ๊ณต๊ฐœ ํ‚ค
  keyId: URL;                         // ํ‚ค์˜ ๊ณ ์œ  ์‹๋ณ„ URI
  cryptographicKey: CryptographicKey; // ๊ณต๊ฐœ ํ‚ค์˜ ๋‹ค๋ฅธ ํ˜•์‹
  multikey: Multikey;                 // ๊ณต๊ฐœ ํ‚ค์˜ ๋˜ ๋‹ค๋ฅธ ํ˜•์‹
}

CryptoKey์™€ CryptographicKey์™€ Multikey๊ฐ€ ๊ฐ๊ฐ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ์ง€, ์™œ ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ ํ˜•์‹์ด ์žˆ์–ด์•ผ ํ•˜๋Š”์ง€๋Š” ์ด ์ž๋ฆฌ์—์„œ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋ณต์žกํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ง€๊ธˆ์€ Person ๊ฐ์ฒด๋ฅผ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ publicKey ์†์„ฑ์€ CryptographicKey ํ˜•์‹์„ ๋ฐ›๊ณ  assertionMethods ์†์„ฑ์€ MultiKey[] (Multikey์˜ ๋ฐฐ์—ด์„ TypeScript์—์„œ ์ด๋ ‡๊ฒŒ ํ‘œ๊ธฐ) ํ˜•์‹์„ ๋ฐ›๋Š”๋‹ค๋Š” ๊ฒƒ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•ฉ์‹œ๋‹ค.

๊ทธ๋‚˜์ €๋‚˜, Person ๊ฐ์ฒด์—๋Š” ์™œ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๊ฐ–๋Š” ์†์„ฑ์ด publicKey์™€ assertionMethods๋กœ ๋‘ ๊ฐœ๋‚˜ ์žˆ์„๊นŒ์š”? ActivityPub์—๋Š” ์›๋ž˜ publicKey ์†์„ฑ๋งŒ ์žˆ์—ˆ์ง€๋งŒ, ๋‚˜์ค‘์— ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก assertionMethods ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-v1.5 ํ‚ค ์Œ์ด, ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— Ed25519 ํ‚ค ์Œ์ด ๋“ค์–ด๊ฐ).

ํŒ

์‚ฌ์‹ค publicKey ์†์„ฑ๋„ ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋‹ด์„ ์ˆ˜๋Š” ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด๋“ค์ด ์ด๋ฏธ publicKey ์†์„ฑ์—๋Š” ๋‹จ ํ•˜๋‚˜์˜ ํ‚ค๋งŒ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ด๋ผ๋Š” ์ „์ œ ํ•˜์— ๊ตฌํ˜„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜ค์ž‘๋™ํ•  ๋•Œ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assertionMethods๋ผ๋Š” ์ƒˆ๋กœ์šด ์†์„ฑ์ด ์ œ์•ˆ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ด์— ๊ด€ํ•ด ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์‹  ๋ถ„๋“ค์€ FEP-521a ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ…Œ์ŠคํŠธ

์ž, ์•กํ„ฐ ๊ฐ์ฒด์— ์•”ํ˜ธ ํ‚ค๋“ค์„ ๋“ฑ๋กํ–ˆ์œผ๋ฏ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

fedify lookup http://localhost:8000/users/johndoe

์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  publicKey: CryptographicKey {
    id: URL "http://localhost:8000/users/johndoe#main-key",
    owner: URL "http://localhost:8000/users/johndoe",
    publicKey: CryptoKey {
      type: "public",
      extractable: true,
      algorithm: {
        name: "RSASSA-PKCS1-v1_5",
        modulusLength: 4096,
        publicExponent: Uint8Array(3) [ 1, 0, 1 ],
        hash: { name: "SHA-256" }
      },
      usages: [ "verify" ]
    }
  },
  assertionMethods: [
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#main-key",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: {
          name: "RSASSA-PKCS1-v1_5",
          modulusLength: 4096,
          publicExponent: Uint8Array(3) [ 1, 0, 1 ],
          hash: { name: "SHA-256" }
        },
        usages: [ "verify" ]
      }
    },
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#key-2",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: { name: "Ed25519" },
        usages: [ "verify" ]
      }
    }
  ],
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

Person ๊ฐ์ฒด์˜ publicKey ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ Multikey ๊ฐ์ฒด๊ฐ€ ๋‘˜ ๋“ค์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Mastodon๊ณผ ์—ฐ๋™

์ด์ œ ์‹ค์ œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค.

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ

์•„์‰ฝ๊ฒŒ๋„ ํ˜„์žฌ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ๋งˆ๋‹ค ์–ด๋”˜๊ฐ€์— ๋ฐฐํฌํ•ด์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํŽธํ•˜๊ฒ ์ฃ . ๋ฐฐํฌํ•˜์ง€ ์•Š๊ณ  ๋ฐ”๋กœ ์ธํ„ฐ๋„ท์— ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๋…ธ์ถœํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์–ผ๋งˆ๋‚˜ ์ข‹์„๊นŒ์š”?

์—ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋Ÿด ๋•Œ ์“ฐ๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ํ„ฐ๋ฏธ๋„์—์„œ ์ƒˆ ํƒญ์„ ์—ฐ ๋’ค, ์ด ๋ช…๋ น์–ด ๋’ค์— ๋กœ์ปฌ ์„œ๋ฒ„์˜ ํฌํŠธ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค:

fedify tunnel 8000

๊ทธ๋Ÿฌ๋ฉด ํ•œ ๋ฒˆ ์“ฐ๊ณ  ๋ฒ„๋ฆด ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๋งŒ๋“ค์–ด์„œ ๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์ค‘๊ณ„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Your local server at 8000 is now publicly accessible:

https://temp-address.serveo.net/

Press ^C to close the tunnel.

๋ฌผ๋ก , ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ๋Š” ์œ„ URL๊ณผ๋Š” ๋‹ค๋ฅธ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๊ณ ์œ ํ•œ URL์ด ์ถœ๋ ฅ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/users/johndoe(์—ฌ๋Ÿฌ๋ถ„์˜ ๊ณ ์œ  ์ž„์‹œ ๋„๋ฉ”์ธ์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ์—ด์–ด์„œ ์ž˜ ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์œผ๋กœ ๋…ธ์ถœ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์œ„ ์›น ํŽ˜์ด์ง€์— ๋ณด์ด๋Š” ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ณต์‚ฌํ•œ ๋’ค, Mastodon์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋ณด๋Š” ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ๊นŒ์ง€์ž…๋‹ˆ๋‹ค. ์•„์ง ํŒ”๋กœ๋Š” ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ์‹œ๋„ํ•˜์ง€ ๋งˆ์„ธ์š”! ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•  ์ˆ˜ ์žˆ์œผ๋ ค๋ฉด, ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์•ˆ๋‚ด

fedify tunnel ๋ช…๋ น์€ ํ•œ๋™์•ˆ ์“ฐ์ด์ง€ ์•Š์œผ๋ฉด ์ €์ ˆ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊น๋‹ˆ๋‹ค. ๊ทธ๋Ÿด ๋•Œ๋Š”, Ctrl+C ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ˆ ๋‹ค์Œ, fedify tunnel 8000 ๋ช…๋ น์„ ๋‹ค์‹œ ์ณ์„œ ์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ์„ ๋งบ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ

ActivityPub์—์„œ ์ˆ˜์‹ ํ•จ(inbox)์€ ์•กํ„ฐ๊ฐ€ ๋‹ค๋ฅธ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์•กํ„ฐ๋Š” ์ž์‹ ์˜ ์ˆ˜์‹ ํ•จ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋Š” HTTP POST ์š”์ฒญ์„ ํ†ตํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฑฐ๋‚˜, ๊ธ€์„ ์“ฐ๊ฑฐ๋‚˜, ๋Œ“๊ธ€์„ ๋‹ค๋Š” ๋“ฑ์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ•  ๋•Œ ํ•ด๋‹น ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์ˆ˜์‹ ์ž์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์˜จ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ ์ ˆํžˆ ์‘๋‹ตํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ  ์—ฐํ•ฉ ๋„คํŠธ์›Œํฌ์˜ ์ผ๋ถ€๋กœ ๊ธฐ๋Šฅํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์€ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์ง€๊ธˆ์€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

์ž์‹ ์„ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์›Œ)๊ณผ ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์ž‰)์„ ๋‹ด๊ธฐ ์œ„ํ•ด src/schema.sql ํŒŒ์ผ์— follows ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS follows (
  following_id INTEGER          REFERENCES actors (id),
  follower_id  INTEGER          REFERENCES actors (id),
  created      TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                CHECK (created <> ''),
  PRIMARY KEY (following_id, follower_id)
);

์ด๋ฒˆ์—๋„ src/schema.sql์„ ์‹คํ–‰ํ•˜์—ฌ follows ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

src/schema.ts ํŒŒ์ผ์„ ์—ด๊ณ  follows ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…๋„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Follow {
  following_id: number;
  follower_id: number;
  created: string;
}

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

์ด์ œ ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ์‹ค์€ ์•ž์„œ ์ด๋ฏธ src/federation.ts ํŒŒ์ผ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋ฐ” ์žˆ์Šต๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

์œ„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ์— ์•ž์„œ, Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Accept ๋ฐ Follow ํด๋ž˜์Šค์™€ getActorHandle() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  setInboxListeners() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    if (follow.objectId == null) {
      logger.debug("The Follow object does not have an object: {follow}", {
        follow,
      });
      return;
    }
    const object = ctx.parseUri(follow.objectId);
    if (object == null || object.type !== "actor") {
      logger.debug("The Follow object's object is not an actor: {follow}", {
        follow,
      });
      return;
    }
    const follower = await follow.getActor();
    if (follower?.id == null || follower.inboxId == null) {
      logger.debug("The Follow object does not have an actor: {follow}", {
        follow,
      });
      return;
    }
    const followingId = db
      .prepare<unknown[], Actor>(
        `
        SELECT * FROM actors
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(object.identifier)?.id;
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
      return;
    }
    const followerId = db
      .prepare<unknown[], Actor>(
        `
        -- ํŒ”๋กœ์›Œ ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        follower.id.href,
        await getActorHandle(follower),
        follower.name?.toString(),
        follower.inboxId.href,
        follower.endpoints?.sharedInbox?.href,
        follower.url?.href,
      )?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    const accept = new Accept({
      actor: follow.objectId,
      to: follow.actorId,
      object: follow,
    });
    await ctx.sendActivity(object, follower, accept);
  });

์ž, ์ฝ”๋“œ๋ฅผ ์ฐฌ์ฐฌํžˆ ์‚ดํŽด๋ด…์‹œ๋‹ค. on() ๋ฉ”์„œ๋“œ๋Š” ํŠน์ •ํ•œ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ๋œปํ•˜๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํŒ”๋กœ์›Œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ ๋’ค, ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ ์ˆ˜๋ฝ์„ ๋œปํ•˜๋Š” Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋‚ด๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

follow.objectId์—๋Š” ํŒ”๋กœ ๋Œ€์ƒ์ธ ์•กํ„ฐ์˜ URI๊ฐ€ ๋“ค์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด ์•ˆ์— ๋“  URI๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

getActorHandle() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ์•กํ„ฐ ๊ฐ์ฒด๋กœ๋ถ€ํ„ฐ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๊ตฌํ•˜์—ฌ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์•„์ง ์—†๋‹ค๋ฉด ๋จผ์ € ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ๋’ค, follows ํ…Œ์ด๋ธ”์— ํŒ”๋กœ์›Œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋ก์ด ์™„๋ฃŒ๋˜๋ฉด, sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ฒซ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐœ์‹ ์ž, ๋‘˜์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ˆ˜์‹ ์ž, ์…‹์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ผ ์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy

์ž, ๊ทธ๋Ÿผ ํŒ”๋กœ ์š”์ฒญ์ด ์ œ๋Œ€๋กœ ์ˆ˜์‹ ๋˜๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

๋ณดํ†ต์˜ Mastodon ์„œ๋ฒ„์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๊ดœ์ฐฎ๊ธด ํ•˜์ง€๋งŒ, ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ์˜ค๊ฐ€๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ActivityPub.Academy ์„œ๋ฒ„๋ฅผ ์ด์šฉํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ActivityPub.Academy๋Š” ๊ต์œก ๋ฐ ๋””๋ฒ„๊น… ์šฉ๋„์˜ ํŠน์ˆ˜ํ•œ Mastodon ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ show source๋ฅผ ๋ˆ„๋ฅธ ํ™”๋ฉด

ํ…Œ์ŠคํŠธ

์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ๊ฑธ ํ™•์ธํ–ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ์ €ํฌ๊ฐ€ ์ง  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๋จผ์ € follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€ ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค (๋ฌผ๋ก , ์‹œ๊ฐ์€ ๋‹ค๋ฅด๊ฒ ์ฃ ?):

following_id follower_id created
1 2 2024-09-01 10:19:41

๊ณผ์—ฐ actors ํ…Œ์ด๋ธ”์—๋„ ์ƒˆ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id user_id uri handle name inbox_url shared_inbox_url url created
2 https://activitypub.academy/users/dobussia_dovornath @dobussia_dovornath@activitypub.academy Dobussia Dovornath https://activitypub.academy/users/dobussia_dovornath/inbox https://activitypub.academy/inbox https://activitypub.academy/@dobussia_dovornath 2024-09-01 10:19:41

๋‹ค์‹œ, ActivityPub.Academy์˜ Activity Log๋ฅผ ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์—์„œ ๋ณด๋‚ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์œ„์™€ ๊ฐ™์ด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
  "type": "Undo",
  "actor": "https://activitypub.academy/users/dobussia_dovornath",
  "object": {
    "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
    "type": "Follow",
    "actor": "https://activitypub.academy/users/dobussia_dovornath",
    "object": "https://temp-address.serveo.net/users/johndoe"
  }
}

์œ„ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ์•„๊นŒ ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์™”๋˜ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ˆ˜์‹ ํ•จ์—์„œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์•„๋ฌด ๊ฒƒ๋„ ์ •์˜ํ•˜์ง€ ์•Š์•˜๊ธฐ์— ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํŒ”๋กœ ์ทจ์†Œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Undo ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  on(Follow, ...) ๋’ค์— ์—ฐ๋‹ฌ์•„ on(Undo, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต๋จ ...
  })
  .on(Undo, async (ctx, undo) => {
    const object = await undo.getObject();
    if (!(object instanceof Follow)) return;
    if (undo.actorId == null || object.objectId == null) return;
    const parsed = ctx.parseUri(object.objectId);
    if (parsed == null || parsed.type !== "actor") return;
    db.prepare(
      `
      DELETE FROM follows
      WHERE following_id = (
        SELECT actors.id
        FROM actors
        JOIN users ON actors.user_id = users.id
        WHERE users.username = ?
      ) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
      `,
    ).run(parsed.identifier, undo.actorId.href);
  });

์ด๋ฒˆ์—๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋ณด๋‹ค ์ฝ”๋“œ๊ฐ€ ์งง์Šต๋‹ˆ๋‹ค. Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ๋“  ๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์ธ์ง€ ํ™•์ธํ•œ ๋’ค, parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์ทจ์†Œํ•˜๋ ค๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ํŒ”๋กœ ๋Œ€์ƒ์ด ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•˜๊ณ , follows ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์•„๊นŒ ActivityPub.Academy์—์„œ ์ด๋ฏธ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ฒ„๋ ค์„œ ํ•œ ๋ฒˆ ๋” ์–ธํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์–ด์ฉ” ์ˆ˜ ์—†์ด ๋‹ค์‹œ ํŒ”๋กœํ•œ ๋’ค, ์–ธํŒ”๋กœํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทธ์— ์•ž์„œ, follows ํ…Œ์ด๋ธ”์„ ๋น„์›Œ ์ค„ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํŒ”๋กœ ์š”์ฒญ์ด ์™”์„ ๋•Œ ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฏ€๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

sqlite3 ๋ช…๋ น์–ด๋ฅผ ์ด์šฉํ•ด follows ํ…Œ์ด๋ธ”์„ ๋น„์›์‹œ๋‹ค:

echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

following_id follower_id created
1 2 2024-09-02 01:05:17

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ sqlite3 ๋ช…๋ น์œผ๋กœ ๋ณด๋Š” ๊ฑด ์„ฑ๊ฐ€์‹œ๋‹ˆ, ์›น์œผ๋กœ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ์‹œ๋‹ค.

์šฐ์„  src/views.tsx ํŒŒ์ผ์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. Actor ํƒ€์ž…์„ importํ•ด์ฃผ์„ธ์š”:

import type { Actor } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <FollowerList> ์ปดํฌ๋„ŒํŠธ์™€ <ActorLink> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowerListProps {
  followers: Actor[];
}

export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
  <>
    <h2>Followers</h2>
    <ul>
      {followers.map((follower) => (
        <li key={follower.id}>
          <ActorLink actor={follower} />
        </li>
      ))}
    </ul>
  </>
);

export interface ActorLinkProps {
  actor: Actor;
}

export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
  const href = actor.url ?? actor.uri;
  return actor.name == null ? (
    <a href={href} class="secondary">
      {actor.handle}
    </a>
  ) : (
    <>
      <a href={href}>{actor.name}</a>{" "}
      <small>
        (
        <a href={href} class="secondary">
          {actor.handle}
        </a>
        )
      </small>
    </>
  );
};

<ActorLink> ์ปดํฌ๋„ŒํŠธ๋Š” ํ•˜๋‚˜์˜ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋„ŒํŠธ๋Š” <ActorList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ž…๋‹ˆ๋‹ค. ๋ณด๋‹ค์‹œํ”ผ JSX์—๋Š” ์กฐ๊ฑด๋ฌธ์ด๋‚˜ ๋ฐ˜๋ณต๋ฌธ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ผํ•ญ ์—ฐ์‚ฐ์ž์™€ Array.map() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ญ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowerList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/followers์— ๋Œ€ํ•œ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/followers", async (c) => {
  const followers = db
    .prepare<unknown[], Actor>(
      `
      SELECT followers.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = following.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowerList followers={followers} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ, ์ž˜ ๋ณด์ด๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ํŒ”๋กœ์›Œ๊ฐ€ ์žˆ์–ด์•ผ ํ• ํ…Œ๋‹ˆ, fedify tunnel์„ ์ผ  ์ฑ„๋กœ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„๋‚˜ ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•ฉ์‹œ๋‹ค. ํŒ”๋กœ ์š”์ฒญ์ด ์ˆ˜๋ฝ๋œ ๋’ค ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/followers ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

ํŒ”๋กœ์›Œ ๋ชฉ๋ก ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ํŒ”๋กœ์›Œ ์ˆ˜๋„ ํ‘œ์‹œํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ๋‹ค์‹œ ์—ด๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

ProfileProps์—๋Š” ๋‘ ๊ฐœ์˜ ํ”„๋กญ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. followers๋Š” ๋ง ๊ทธ๋Œ€๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋‹ด๋Š” ํ”„๋กญ์ž…๋‹ˆ๋‹ค. username์€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์œผ๋กœ ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์œ„ํ•ด URL์— ๋“ค์–ด๊ฐˆ ์•„์ด๋””๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ src/app.tsx ํŒŒ์ผ๋กœ ๋Œ์•„๊ฐ€, GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      JOIN actors ON follows.following_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        followers={followers}
      />
    </Layout>,
  );
});

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ˆ์˜ follows ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋ฅผ ์„ธ๋Š” SQL์ด ์ถ”๊ฐ€๋˜์—ˆ๊ตฐ์š”. ์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜

๊ทธ๋Ÿฐ๋ฐ ํ•œ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ActivityPub.Academy๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด๋ด…์‹œ๋‹ค. (์กฐํšŒํ•˜๋Š” ๋ฒ•์€ ์ด์ œ ๋‹ค ์•„์‹œ์ฃ ? ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ๋œ ์ƒํƒœ์—์„œ, ์•กํ„ฐ ํ•ธ๋“ค์„ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ์น˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.) Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Mastodon์—์„œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

๋ฐ”๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ 0์œผ๋กœ ๋‚˜์˜จ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ActivityPub์„ ํ†ตํ•ด ๋…ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋ ค๋ฉด ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Recipient ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์•„๋ž˜์ชฝ์— ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setFollowersDispatcher(
    "/users/{identifier}/followers",
    (ctx, identifier, cursor) => {
      const followers = db
        .prepare<unknown[], Actor>(
          `
          SELECT followers.*
          FROM follows
          JOIN actors AS followers ON follows.follower_id = followers.id
          JOIN actors AS following ON follows.following_id = following.id
          JOIN users ON users.id = following.user_id
          WHERE users.username = ?
          ORDER BY follows.created DESC
          `,
        )
        .all(identifier);
      const items: Recipient[] = followers.map((f) => ({
        id: new URL(f.uri),
        inboxId: new URL(f.inbox_url),
        endpoints:
          f.shared_inbox_url == null
            ? null
            : { sharedInbox: new URL(f.shared_inbox_url) },
      }));
      return { items };
    },
  )
  .setCounter((ctx, identifier) => {
    const result = db
      .prepare<unknown[], { cnt: number }>(
        `
        SELECT count(*) AS cnt
        FROM follows
        JOIN actors ON actors.id = follows.following_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    return result == null ? 0 : result.cnt;
  });

setFollowersDispatcher() ๋ฉ”์„œ๋“œ์—์„œ๋Š” GET /users/{identifier}/followers ์š”์ฒญ์ด ์™”์„ ๋•Œ ์‘๋‹ตํ•  ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. SQL์ด ์กฐ๊ธˆ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ •๋ฆฌํ•˜์ž๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. items์—๋Š” Recipient ๊ฐ์ฒด๋“ค์„ ๋‹ด๋Š”๋ฐ, Recipient ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

export interface Recipient {
  readonly id: URL | null;
  readonly inboxId: URL | null;
  readonly endpoints?: {
    sharedInbox: URL | null;
  } | null;
}

id ์†์„ฑ์—๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  IRI๊ฐ€ ๋“ค์–ด๊ฐ€๊ณ , inboxId์—๋Š” ์•กํ„ฐ์˜ ๊ฐœ์ธ ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. endpoints.sharedInbox์—๋Š” ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” actors ํ…Œ์ด๋ธ”์— ๊ทธ ๋ชจ๋“  ์ •๋ณด๋ฅผ ๋‹ค ๋‹ด๊ณ  ์žˆ์œผ๋‹ˆ, ํ•ด๋‹น ์ •๋ณด๋“ค๋กœ items ๋ฐฐ์—ด์„ ์ฑ„์›Œ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

setCounter() ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์˜ ์ „์ฒด ์ˆ˜๋Ÿ‰์„ ๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋„ SQL์ด ์กฐ๊ธˆ ๋ณต์žกํ•˜๊ธด ํ•˜์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, fedify lookup ๋ช…๋ น์„ ์‚ฌ์šฉํ•ฉ์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe/followers

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
OrderedCollection {
  totalItems: 1,
  items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}

๊ทธ๋Ÿฐ๋ฐ, ์ด๋ ‡๊ฒŒ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ค์–ด ๋†“๊ธฐ๋งŒ ํ•ด์„œ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์–ด๋”” ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์— ๋งํฌ๋ฅผ ๊ฑธ์–ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ... ์ƒ๋žต ...
    return new Person({
      // ... ์ƒ๋žต ...
      followers: ctx.getFollowersUri(identifier), 
    });
  })

์•กํ„ฐ๋„ fedify lookup์œผ๋กœ ์กฐํšŒํ•˜์—ฌ ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์•„๋ž˜์™€ ๊ฐ™์ด ๊ฒฐ๊ณผ์— "followers" ์†์„ฑ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  ... ์ƒ๋žต ...
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  followers: URL "http://localhost:8000/users/johndoe/followers",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋‹ค์‹œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํŒ”๋กœ์›Œ ์ˆ˜๋Š” ์—ฌ์ „ํžˆ 0์œผ๋กœ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . ์ด๋Š” Mastodon์ด ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์บ์‹œ(cache)ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๊ธด ํ•˜์ง€๋งŒ F5 ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์‰ฝ์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค:

  • ํ•œ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์€ ์ผ์ฃผ์ผ์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Mastodon์€ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์บ์‹œ๋ฅผ ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์ดํ›„ 7์ผ์ด ์ง€๋‚  ๋•Œ ๋‚ ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ๋˜ ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์€ Update ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‚ ๋ฆฌ๋Š” ๊ฒƒ์ธ๋ฐ, ๊ท€์ฐฎ์€ ์ฝ”๋”ฉ์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋‹ˆ๋ฉด ์•„์ง ์บ์‹œ๊ฐ€ ๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•ด ๋ณด๋Š” ๊ฒƒ๋„ ํ•œ ๋ฐฉ๋ฒ•์ด๊ฒ ์ฃ .
  • ๋งˆ์ง€๋ง‰ ๋ฐฉ๋ฒ•์€ fedify tunnel์„ ๊ป๋‹ค ์ผœ์„œ ์ƒˆ๋กœ์šด ์ž„์‹œ ๋„๋ฉ”์ธ์„ ํ• ๋‹น ๋ฐ›๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ •ํ™•ํ•œ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์ง์ ‘ ํ™•์ธํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์ œ๊ฐ€ ๋‚˜์—ดํ•œ ๋ฐฉ๋ฒ•๋“ค ์ค‘ ํ•˜๋‚˜๋ฅผ ์‹œ๋„ํ•ด ๋ณด์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ

์ž, ์ด์ œ ๋“œ๋””์–ด ๊ฒŒ์‹œ๋ฌผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์™”์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ๋ธ”๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ž‘์„ฑ๋œ ๊ฒŒ์‹œ๋ฌผ๋„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์—ผ๋‘์— ๋‘๊ณ  ์„ค๊ณ„ํ•ด ๋ด…์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋ฐ”๋กœ posts ํ…Œ์ด๋ธ”๋ถ€ํ„ฐ ๋งŒ๋“ญ์‹œ๋‹ค. src/schema.sql ํŒŒ์ผ์„ ์—ด์–ด ์•„๋ž˜ SQL์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS posts (
  id       INTEGER NOT NULL PRIMARY KEY,
  uri      TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  actor_id INTEGER NOT NULL REFERENCES actors (id),
  content  TEXT    NOT NULL,
  url      TEXT             CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
  created  TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
  • id ์นผ๋Ÿผ์€ ํ…Œ์ด๋ธ”์˜ ๊ธฐ๋ณธ ํ‚ค์ž…๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•ž์„œ ๋งํ–ˆ๋‹ค์‹œํ”ผ ActivityPub ๊ฐ์ฒด๋Š” ๋ชจ๋‘ ๊ณ ์œ ํ•œ URI๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • actor_id ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • content ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์—๋Š” ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œ์‹œํ•˜๋Š” URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ URI์™€ ์›น ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๋Š” ํŽ˜์ด์ง€์˜ URL์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„ ์นผ๋Ÿผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋น„์–ด ์žˆ์„ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์‹œ๊ฐ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.

SQL์„ ์‹คํ–‰ํ•˜์—ฌ posts ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

posts ํ…Œ์ด๋ธ”์— ์ €์žฅ๋  ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•˜๋Š” Post ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Post {
  id: number;
  uri: string;
  actor_id: number;
  content: string;
  url: string | null;
  created: string;
}

์ฒซ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์–‘์‹์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ๊ฒ ์ฃ ? ๊ทธ๋Ÿฌ๊ณ  ๋ณด๋‹ˆ, ์•„์ง๊นŒ์ง€ ์ฒซ ํŽ˜์ด์ง€๋„ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ฒซ ํŽ˜์ด์ง€์— ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      <h1>{user.name}'s microblog</h1>
      <p>
        <a href={`/users/${user.username}`}>{user.name}'s profile</a>
      </p>
    </hgroup>
    <form method="post" action={`/users/${user.username}/posts`}>
      <fieldset>
        <label>
          <textarea name="content" required={true} placeholder="What's up?" />
        </label>
      </fieldset>
      <input type="submit" value="Post" />
    </form>
  </>
);

๊ทธ ๋‹ค์Œ์—๋Š” src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ์ด๋ฏธ ์žˆ๋Š” GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ:

app.get("/", (c) => c.text("Hello, Fedify!"));

์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์ณ์ค๋‹ˆ๋‹ค:

app.get("/", (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT users.*, actors.*
      FROM users
      JOIN actors ON users.id = actors.user_id
      LIMIT 1
      `,
    )
    .get();
  if (user == null) return c.redirect("/setup");

  return c.html(
    <Layout>
      <Home user={user} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด, ํ•œ ๋ฒˆ ์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ๋‚˜์˜ค๋‚˜ ํ™•์ธํ•ฉ์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค:

์ฒซ ํŽ˜์ด์ง€

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ posts ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์œ„ ์ฝ”๋“œ๋Š” ์•„์ง ๋ณ„ ์—ญํ• ์„ ํ•˜์ง„ ์•Š์ง€๋งŒ, ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ ํ˜•์‹์„ ์ •ํ•˜๋Š” ๋ฐ์— ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๋‚˜์ค‘์— ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ActivityPub์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์˜ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ์ฃผ๊ณ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ‰๋ฌธ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ ๋ฐ›์€ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, <, >์™€ ๊ฐ™์€ ๋ฌธ์ž๋“ค์„ HTML์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ HTML ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” stringify-entities ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add stringify-entities

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค.

import { stringifyEntities } from "stringify-entities";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  const username = c.req.param("username");
  const actor = db
    .prepare<unknown[], Actor>(
      `
      SELECT actors.*
      FROM actors
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ?
      `,
    )
    .get(username);
  if (actor == null) return c.redirect("/setup");
  const form = await c.req.formData();
  const content = form.get("content")?.toString();
  if (content == null || content.trim() === "") {
    return c.text("Content is required", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const url: string | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return url;
  })();
  if (url == null) return c.text("Failed to create post", 500);
  return c.redirect(url);
});

ํ‰๋ฒ”ํ•˜๊ฒŒ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์ฝ”๋“œ์ด๊ธด ํ•˜์ง€๋งŒ ํ•œ ๊ฐ€์ง€ ํŠน์ดํ•œ ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œํ˜„ํ•˜๋Š” ActivityPub ๊ฐ์ฒด์˜ URI๋ฅผ ๊ตฌํ•˜๋ ค๋ฉด posts.id๊ฐ€ ๋จผ์ € ๊ฒฐ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, posts.uri ์นผ๋Ÿผ์— https://localhost/๋ผ๋Š” ์ž„์‹œ URI๋ฅผ ๋จผ์ € ์ง‘์–ด ๋„ฃ์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค, ๊ฒฐ์ •๋œ posts.id๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ getObjectUri() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ URI๋ฅผ ๊ตฌํ•ด์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ฐ ๋’ค, ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ์ค‘

Post ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ฉด, ์•ˆํƒ€๊น๊ฒŒ๋„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

404 Not Found

์™œ๋ƒํ•˜๋ฉด ๊ฒŒ์‹œ๋ฌผ ํผ๋จธ๋งํฌ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์•„์ง ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ posts ํ…Œ์ด๋ธ”์—๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋งŒ๋“ค์–ด์กŒ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

๊ทธ๋Ÿผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๋‚˜ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

id uri actor_id content url created
1 http://localhost:8000/users/johndoe/posts/1 1 It's my first post! http://localhost:8000/users/johndoe/posts/1 2024-09-02 08:10:55

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ํ›„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚˜์ง€ ์•Š๋„๋ก, ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด Post ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <PostPage> ์ปดํฌ๋„ŒํŠธ ๋ฐ <PostView> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

export interface PostViewProps {
  post: Post & Actor;
}

export const PostView: FC<PostViewProps> = ({ post }) => (
  <article>
    <header>
      <ActorLink actor={post} />
    </header>
    {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
    <footer>
      <a href={post.url ?? post.uri}>
        <time datetime={new Date(post.created).toISOString()}>
          {post.created}
        </time>
      </a>
    </footer>
  </article>
);

์ด์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์™€ <PostPage> ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  ์•ž์„œ ์ •์˜ํ•œ <PostPage> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  const post = db
    .prepare<unknown[], Post & Actor & User>(
      `
      SELECT users.*, actors.*, posts.*
      FROM posts
      JOIN actors ON actors.id = posts.actor_id
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ? AND posts.id = ?
      `,
    )
    .get(c.req.param("username"), c.req.param("id"));
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      WHERE follows.following_id = ?
      `,
    )
    .get(post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์•„๊นŒ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฌ๋˜ http://localhost:8000/users/johndoe/posts/1 ํŽ˜์ด์ง€๋ฅผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

Note ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜

๊ทธ๋Ÿผ ์ด์ œ ๊ฒŒ์‹œ๋ฌผ์„ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ๋จผ์ € fedify tunnel์„ ์ด์šฉํ•˜์—ฌ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๊ทธ ์ƒํƒœ์—์„œ, Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ธ€์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

๋นˆ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์•ˆํƒ€๊น๊ฒŒ๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด ํ˜•์‹์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด๋กœ ๋…ธ์ถœํ•ด ๋ด…์‹œ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Fedify์—์„œ ์‹œ๊ฐ์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ๋Š” Temporal API๊ฐ€ ์•„์ง Node.js์— ๋‚ด์žฅ๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฅผ ํด๋ฆฌํ•„(polyfill)ํ•ด์ฃผ๋Š” @js-temporal/polyfill ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add @js-temporal/polyfill

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Temporal } from "@js-temporal/polyfill";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” PUBLIC_COLLECTION ์ƒ์ˆ˜๋„ importํ•ฉ๋‹ˆ๋‹ค.

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";
import type {
  Actor,
  Key,
  Post,
  User,
} from "./schema.ts";

๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ์˜ ๊ฒŒ์‹œ๋ฌผ์ฒ˜๋Ÿผ ์งง์€ ๊ธ€์€ ActivityPub์—์„œ ๋ณดํ†ต Note๋กœ ํ‘œํ˜„๋ฉ๋‹ˆ๋‹ค. Note ํด๋ž˜์Šค์— ๋Œ€ํ•œ ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๋Š” ์ด๋ฏธ ๋นˆ ๊ตฌํ˜„์ด๋‚˜๋งˆ ๋งŒ๋“ค์–ด ๋‘์—ˆ์—ˆ์ฃ :

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์ด๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    const post = db
      .prepare<unknown[], Post>(
        `
        SELECT posts.*
        FROM posts
        JOIN actors ON actors.id = posts.actor_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ? AND posts.id = ?
        `,
      )
      .get(values.identifier, values.id);
    if (post == null) return null;
    return new Note({
      id: ctx.getObjectUri(Note, values),
      attribution: ctx.getActorUri(values.identifier),
      to: PUBLIC_COLLECTION,
      cc: ctx.getFollowersUri(values.identifier),
      content: post.content,
      mediaType: "text/html",
      published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
      url: ctx.getObjectUri(Note, values),
    });
  },
);

Note ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ฑ„์›Œ์ง€๋Š” ์†์„ฑ ๊ฐ’๋“ค์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค:

  • attribution ์†์„ฑ์— ctx.getActorUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์˜ ์ž‘์„ฑ์ž๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • to ์†์„ฑ์— PUBLIC_COLLECTION์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ์ „์ฒด ๊ณต๊ฐœ ๊ฒŒ์‹œ๋ฌผ์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • cc ์†์„ฑ์— ctx.getFollowersUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ, ์ด ์ž์ฒด๋กœ๋Š” ํฐ ์˜๋ฏธ๋Š” ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ ํ•œ ๋ฒˆ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ๋ฐœ์‹ 

ํ•˜์ง€๋งŒ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœ ํ•ด๋„, ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด Mastodon ํƒ€์ž„๋ผ์ธ์— ์˜ฌ๋ผ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด Mastodon์ด ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์•Œ์•„์„œ ๋ฐ›์•„๊ฐ€๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์ชฝ์—์„œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜์—ฌ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ ์ƒ์„ฑ์‹œ์— Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Create, Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  // ... ์ƒ๋žต ...
  const ctx = fedi.createContext(c.req.raw, undefined);
  const post: Post | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return post;
  })();
  if (post == null) return c.text("Failed to create post", 500);
  const noteArgs = { identifier: username, id: post.id.toString() };
  const note = await ctx.getObject(Note, noteArgs);
  await ctx.sendActivity(
    { identifier: username },
    "followers",
    new Create({
      id: new URL("#activity", note?.id ?? undefined),
      object: note,
      actors: note?.attributionIds,
      tos: note?.toIds,
      ccs: note?.ccIds,
    }),
  );
  return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});

getObject() ๋ฉ”์„œ๋“œ๋Š” ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” Note ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ object ์†์„ฑ์— ๋„ฃ์Šต๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ˆ˜์‹ ์ž๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” tos (to์˜ ๋ณต์ˆ˜ํ˜•) ๋ฐ ccs (cc์˜ ๋ณต์ˆ˜ํ˜•) ์†์„ฑ์€ Note ๊ฐ์ฒด์™€ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ id๋Š” ์ž„์˜์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ์ง€์–ด๋‚ด์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด์˜ id ์†์„ฑ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URI๊ฐ€ ๋“ค์–ด๊ฐˆ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ๊ณ ์œ ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

sendActivity() ๋ฉ”์„œ๋“œ์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์—๋Š” ์ˆ˜์‹ ์ž๊ฐ€ ๋“ค์–ด๊ฐ€๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ๋Š” "followers"๋ผ๋Š” ํŠน์ˆ˜ํ•œ ์˜ต์…˜์„ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ์˜ต์…˜์„ ์ง€์ •ํ•˜๋ฉด ์•ž์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ชจ๋“  ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ตฌํ˜„์„ ๋๋ƒˆ์œผ๋‹ˆ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ์‹œํ‚จ ์ฑ„, ActivityPub.Academy๋กœ ๋“ค์–ด๊ฐ€ @johndoe@temp-address.serveo.net(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ ํ• ๋‹น๋œ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ํŒ”๋กœํ•ฉ๋‹ˆ๋‹ค. ํŒ”๋กœ์›Œ ๋ชฉ๋ก์—์„œ ํŒ”๋กœ ์š”์ฒญ์ด ํ™•์‹คํžˆ ์ˆ˜๋ฝ๋œ ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ localhost๊ฐ€ ์•„๋‹Œ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ ID๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๊ธฐ์ค€์œผ๋กœ URI๋ฅผ ๊ตฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๊ฐ”๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์˜ Activity Log๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

ActivityPub.Academy์˜ ํƒ€์ž„๋ผ์ธ์—์„œ ์ƒ์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ž˜ ๋ณด์ธ๋‹ค.

ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ๋‚ด ๊ฒŒ์‹œ๋ฌผ ๋ชฉ๋ก

ํ˜„์žฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—๋Š” ์ด๋ฆ„๊ณผ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค, ํŒ”๋กœ์›Œ ์ˆ˜๋งŒ ๋‚˜์˜ฌ ๋ฟ ์ •์ž‘ ๊ฒŒ์‹œ๋ฌผ์€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์„ ๋ณด์—ฌ์ค์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ  <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface PostListProps {
  posts: (Post & Actor)[];
}

export const PostList: FC<PostListProps> = ({ posts }) => (
  <>
    {posts.map((post) => (
      <div key={post.id}>
        <PostView post={post} />
      </div>
    ))}
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ๋ฐฉ๊ธˆ ์ •์˜ํ•œ <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

์ด๋ฏธ ์žˆ๋Š” GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE actors.user_id = ?
      ORDER BY posts.created DESC
      `,
    )
    .all(user.user_id);
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      // ... ์ƒ๋žต ...
      <PostList posts={posts} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

๋ณ€๊ฒฝ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์ด ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ”๋กœ

ํ˜„์žฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์„ ์ˆ˜๋Š” ์žˆ์ง€๋งŒ, ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์—๊ฒŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜๋Š” ์—†์Šต๋‹ˆ๋‹ค. ํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋„ ๋ณผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž, ๊ทธ๋Ÿผ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ์‹œ๋‹ค.

UI ๋จผ์ € ๋งŒ๋“ญ์‹œ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ์ด๋ฏธ ์žˆ๋Š” <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      {/* ... ์ƒ๋žต ... */}
    </hgroup>
    <form method="post" action={`/users/${user.username}/following`}>
      {/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ€ role=group์„ ์š”๊ตฌํ•จ */}
      <fieldset role="group">
        <input
          type="text"
          name="actor"
          required={true}
          placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
        />
        <input type="submit" value="Follow" />
      </fieldset>
    </form>
    <form method="post" action={`/users/${user.username}/posts`}>
      {/* ... ์ƒ๋žต ... */}
    </form>
  </>
);

์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ฒผ์œผ๋‹ˆ ์‹ค์ œ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งค ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Follow ํด๋ž˜์Šค์™€ isActor() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Create,
  Follow,
  isActor,
  Note,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/following ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/following", async (c) => {
  const username = c.req.param("username");
  const form = await c.req.formData();
  const handle = form.get("actor");
  if (typeof handle !== "string") {
    return c.text("Invalid actor handle or URL", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const actor = await lookupObject(handle.trim());
  if (!isActor(actor)) {
    return c.text("Invalid actor handle or URL", 400);
  }
  await ctx.sendActivity(
    { identifier: username },
    actor,
    new Follow({
      actor: ctx.getActorUri(username),
      object: actor.id,
      to: actor.id,
    }),
  );
  return c.text("Successfully sent a follow request");
});

lookupObject() ํ•จ์ˆ˜๋Š” ์•กํ„ฐ๋ฅผ ๋น„๋กฏํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ž…๋ ฅ์œผ๋กœ ActivityPub ๊ฐ์ฒด์˜ ๊ณ ์œ  URI๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ฐ›๊ณ , ์กฐํšŒํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

isActor() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ActivityPub ๊ฐ์ฒด๊ฐ€ ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์ด ์ฝ”๋“œ์—์„œ๋Š” sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์กฐํšŒํ•œ ์•กํ„ฐ์—๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์•„์ง follows ํ…Œ์ด๋ธ”์— ์•„๋ฌด๋Ÿฐ ๋ ˆ์ฝ”๋“œ๋„ ์ถ”๊ฐ€ํ•˜์ง„ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ƒ๋Œ€๋กœ๋ถ€ํ„ฐ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๊ณ  ๋‚˜์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ตฌํ˜„ํ•œ ํŒ”๋กœ ์š”์ฒญ ๊ธฐ๋Šฅ์ด ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒˆ์—๋„ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•ด์•ผ ํ•˜๋ฏ€๋กœ, fedify tunnel ๋ช…๋ น์„ ์ด์šฉํ•ด ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€ ์ƒ์— ๋‚˜ํƒ€๋‚œ ๋„์ฐฉํ•œ ํŒ”๋กœ ์š”์ฒญ

Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํ•˜์ง€๋งŒ ์•„์ง ์ˆ˜์‹ ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์— ๋Œ€ํ•ด ์•„๋ฌด๋Ÿฐ ํ–‰๋™๋„ ์ทจํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ด ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify์—์„œ ์ œ๊ณตํ•˜๋Š” isActor() ํ•จ์ˆ˜ ๋ฐ Actor ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

์ด ์†Œ์Šค ํŒŒ์ผ ์•ˆ์—์„œ Actor ํƒ€์ž…์˜ ์ด๋ฆ„์ด ๊ฒน์น˜๋ฏ€๋กœ APActor๋ผ๋Š” ๋ณ„๋ช…์„ ์ง€์–ด์คฌ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ, ์ฒ˜์Œ ๋งˆ์ฃผํ•œ ์•กํ„ฐ ์ •๋ณด๋ฅผ actors ํ…Œ์ด๋ธ”์— ๋„ฃ๋Š” ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์—ฌ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค. ์•„๋ž˜ ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

async function persistActor(actor: APActor): Promise<Actor | null> {
  if (actor.id == null || actor.inboxId == null) {
    logger.debug("Actor is missing required fields: {actor}", { actor });
    return null;
  }
  return (
    db
      .prepare<unknown[], Actor>(
        `
        -- ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        actor.id.href,
        await getActorHandle(actor),
        actor.name?.toString(),
        actor.inboxId.href,
        actor.endpoints?.sharedInbox?.href,
        actor.url?.href,
      ) ?? null
  );
}

์ •์˜ํ•œ persistActor() ํ•จ์ˆ˜๋Š” ์ธ์ž๋กœ ๋“ค์–ด์˜จ ์•กํ„ฐ ๊ฐ์ฒด์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ actors ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ํ…Œ์ด๋ธ”์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์˜ on(Follow, ...) ๋ถ€๋ถ„์—์„œ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ persistActor() ํ•จ์ˆ˜๋ฅผ ์“ฐ๊ฒŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต ...
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
    }
    const followerId = (await persistActor(follower))?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    // ... ์ƒ๋žต ...
  })

๋ฆฌํŒฉํ„ฐ๋ง์„ ๋๋ƒˆ์œผ๋‹ˆ ์ˆ˜์‹ ํ•จ์— Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์•˜์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

  .on(Accept, async (ctx, accept) => {
    const follow = await accept.getObject();
    if (!(follow instanceof Follow)) return;
    const following = await accept.getActor();
    if (!isActor(following)) return;
    const follower = follow.actorId;
    if (follower == null) return;
    const parsed = ctx.parseUri(follower);
    if (parsed == null || parsed.type !== "actor") return;
    const followingId = (await persistActor(following))?.id;
    if (followingId == null) return;
    db.prepare(
      `
      INSERT INTO follows (following_id, follower_id)
      VALUES (
        ?,
        (
          SELECT actors.id
          FROM actors
          JOIN users ON actors.user_id = users.id
          WHERE users.username = ?
        )
      )
      `,
    ).run(followingId, parsed.identifier);
  });

์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๊ธธ์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ์œผ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ(follower)์™€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์€ ์•กํ„ฐ(following)๋ฅผ ๊ตฌํ•˜๊ณ  follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์ด์ œ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„๊นŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ ActivityPub.Academy ์ชฝ์—์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•˜๊ณ  Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ด๋ฏธ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ์ƒํƒœ์—์„œ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋„ ๋ฌด์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๋’ค ๋‹ค์‹œ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์—์„œ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ์ƒํƒœ์—์„œ, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ActivityPub.Academy์˜ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋ฉด, ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Activity Log์— Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋„์ฐฉํ•œ ํ›„ ๋‹ต์žฅ์œผ๋กœ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‚˜ ์ง์ ‘ ํ™•์ธ์„ ํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

์„ฑ๊ณตํ–ˆ๋‹ค๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค (following_id ์นผ๋Ÿผ์— ๋“  ๊ฐ’์€ ๋‹ค์†Œ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค):

following_id follower_id created
3 1 2024-09-02 14:11:17

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowingListProps {
  following: Actor[];
}

export const FollowingList: FC<FollowingListProps> = ({ following }) => (
  <>
    <h2>Following</h2>
    <ul>
      {following.map((actor) => (
        <li key={actor.id}>
          <ActorLink actor={actor} />
        </li>
      ))}
    </ul>
  </>
);

๊ทธ ๋‹ค์Œ, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  FollowingList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/following ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/following", async (c) => {
  const following = db
    .prepare<unknown[], Actor>(
      `
      SELECT following.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = followers.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowingList following={following} />
    </Layout>,
  );
});

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/following ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

ํŒ”๋กœ์ž‰ ์ˆ˜

ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ํŒ”๋กœ์ž‰ ์ˆ˜๋„ ํ‘œ์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  following: number;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  following,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

<PostPage> ์ปดํฌ๋„ŒํŠธ๋„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      following={props.following}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

๊ทธ๋Ÿผ ์ด์ œ ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์กฐํšŒํ•˜์—ฌ ํŒ”๋กœ์ž‰ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET /users/{username} ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following } = db
    .prepare<unknown[], { following: number }>(
      `
      SELECT count(*) AS following
      FROM follows
      JOIN actors ON follows.follower_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        following={following}
        followers={followers}
      />
      <PostList posts={posts} />
    </Layout>,
  );
});

GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  // ... ์ƒ๋žต ...
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following, followers } = db
    .prepare<unknown[], { following: number; followers: number }>(
      `
      SELECT sum(follows.follower_id = ?) AS following,
             sum(follows.following_id = ?) AS followers
      FROM follows
      `,
    )
    .get(post.actor_id, post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        following={following}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๋‹ค ์ˆ˜์ •๋˜์—ˆ๋‹ค๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํƒ€์ž„๋ผ์ธ

๋งŽ์€ ๊ฒƒ๋“ค์„ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, ์•„์ง ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด์ง€๋Š” ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌํƒœ๊นŒ์ง€์˜ ๊ณผ์ •์—์„œ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค์‹œํ”ผ, ์šฐ๋ฆฌ๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์“ธ ๋•Œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ–ˆ๋˜ ๊ฒƒ๊ณผ ๊ฐ™์ด, ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•ด์•ผ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ค ์ผ์ด ์ผ์–ด๋‚˜๋Š”์ง€ ๋ณด๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Create,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ์— on(Create, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

  .on(Create, async (ctx, create) => {
    const object = await create.getObject();
    if (!(object instanceof Note)) return;
    const actor = create.actorId;
    if (actor == null) return;
    const author = await object.getAttribution();
    if (!isActor(author) || author.id?.href !== actor.href) return;
    const actorId = (await persistActor(author))?.id;
    if (actorId == null) return;
    if (object.id == null) return;
    const content = object.content?.toString();
    db.prepare(
      "INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
    ).run(object.id.href, actorId, content, object.url?.href);
  });

getAttribution() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ธ€์“ด์ด๋ฅผ ๊ตฌํ•œ ๋’ค, persistActor() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ์•„์ง actors ํ…Œ์ด๋ธ”์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  posts ํ…Œ์ด๋ธ”์— ์ƒˆ ๋ ˆ์ฝ”๋“œ๋ฅผ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์‹œ ํ•œ ๋ฒˆ ActivityPub.Academy์— ๋“ค์–ด๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. Activity Log๋ฅผ ์—ด์–ด Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋˜์—ˆ๋Š”์ง€ ์ฒดํฌํ•œ ๋’ค, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ posts ํ…Œ์ด๋ธ”์— ์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3

์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

id uri actor_id content url created
3 https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 3 <p>Would it send a Create(Note) activity?</p> https://activitypub.academy/@algusia_draneoll/113068684551948316 2024-09-02 15:33:32

์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ ํ‘œ์‹œ

์ž, ์ด์ œ ์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ์„ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋กœ ์ถ”๊ฐ€ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๊ทธ ๋ ˆ์ฝ”๋“œ๋“ค์„ ์ž˜ ํ‘œ์‹œํ•ด ์ฃผ๋Š” ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. ํ”ํžˆ ใ€Œํƒ€์ž„๋ผ์ธใ€์ด๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps extends PostListProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user, posts }) => (
  <>
    {/* ... ์ƒ๋žต ... */}
    <PostList posts={posts} />
  </>
);

๊ทธ ๋’ค, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/", (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.redirect("/setup");

  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE posts.actor_id = ? OR posts.actor_id IN (
        SELECT following_id
        FROM follows
        WHERE follower_id = ?
      )
      ORDER BY posts.created DESC
      `,
    )
    .all(user.id, user.id);
  return c.html(
    <Layout>
      <Home user={user} posts={posts} />
    </Layout>,
  );
});

์ž, ์ด์ œ ๋‹ค ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ํƒ€์ž„๋ผ์ธ์„ ๊ฐ์ƒํ•ฉ์‹œ๋‹ค:

์ฒซ ํŽ˜์ด์ง€์—์„œ ๋ณด์ด๋Š” ํƒ€์ž„๋ผ์ธ

์œ„์™€ ๊ฐ™์ด ์›๊ฒฉ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๊ณผ ๋กœ์ปฌ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ž˜ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค๊ฐ€์š”? ๋งˆ์Œ์— ๋“œ์‹œ๋‚˜์š”?

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์ด๊ฒŒ ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์™„์„ฑ์‹œํ‚ค๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ฐœ์„ ํ•  ์ 

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด ์™„์„ฑํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ์•„์‰ฝ๊ฒŒ๋„ ์•„์ง ์‹ค์‚ฌ์šฉ์—๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํŠนํžˆ, ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ์ทจ์•ฝ์ ์ด ๋งŽ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋งŒ๋“  ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์ข€ ๋” ๋ฐœ์ „์‹œํ‚ค๊ณ  ์‹ถ์€ ๋ถ„๋“ค์€, ์•„๋ž˜ ๊ณผ์ œ๋“ค์„ ์ง์ ‘ ํ•ด๊ฒฐํ•ด ๋ณด์…”๋„ ์ข‹์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

  • ํ˜„์žฌ๋Š” ์•„๋ฌด๋Ÿฐ ์ธ์ฆ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ๋ˆ„๊ตฌ๋ผ๋„ URL๋งŒ ์•Œ๋ฉด ๊ธ€์„ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๊ณผ์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ด๋ฅผ ๋ฐฉ์ง€ํ•ด ๋ณผ๊นŒ์š”?

  • ํ˜„์žฌ์˜ ๊ตฌํ˜„์€ ActivityPub์„ ํ†ตํ•ด ๋ฐ›์€ Note ๊ฐ์ฒด ์•ˆ์— ๋“ค์–ด ์žˆ๋Š” HTML์„ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์•…์˜์ ์ธ ActivityPub ์„œ๋ฒ„๊ฐ€ <script>while (true) alert('๋ฉ”๋กฑ'); ๊ฐ™์€ HTML์„ ํฌํ•จํ•œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ XSS ์ทจ์•ฝ์ ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ทจ์•ฝ์ ์€ ์–ด๋–ป๊ฒŒ ๋ง‰์„ ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋‹ค์Œ SQL์„ ์‹คํ–‰ํ•˜์—ฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟ” ๋ด…์‹œ๋‹ค:

    UPDATE actors SET name = 'Renamed' WHERE id = 1;

    ์ด๋ ‡๊ฒŒ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟจ์„ ๋•Œ, ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๋ฐ”๋€ ์ด๋ฆ„์ด ์ ์šฉ๋ ๊นŒ์š”? ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด์•ผ ๋ณ€๊ฒฝ์ด ์ ์šฉ๋ ๊นŒ์š”?

  • ์•กํ„ฐ์— ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•ด ๋ด…์‹œ๋‹ค. ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ถ๊ธˆํ•˜๋‹ค๋ฉด, fedify lookup ๋ช…๋ น์œผ๋กœ ์ด๋ฏธ ํ”„๋กœํ•„ ์‚ฌ์ง„์ด ์žˆ๋Š” ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณด์„ธ์š”.

  • ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€๊ฐ€ ์ฒจ๋ถ€๋œ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ํƒ€์ž„๋ผ์ธ์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • ๊ฒŒ์‹œ๋ฌผ ๋‚ด์—์„œ ๋‹ค๋ฅธ ์•กํ„ฐ๋ฅผ ๋ฉ˜์…˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ด…์‹œ๋‹ค. ๋ฉ˜์…˜ํ•œ ์ƒ๋Œ€ํ•œํ…Œ ์•Œ๋ฆผ์ด ๊ฐ€๋„๋ก ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: English (์˜์–ด), ๆ—ฅๆœฌ่ชž (์ผ๋ณธ์–ด).

์•ˆ๋‚ด

๋งŒ์•ฝ ์—ฐํ•ฉ์šฐ์ฃผ(fediverse)๋‚˜ ActivityPub ๊ฐ™์€ ์šฉ์–ด๊ฐ€ ์ƒ์†Œํ•˜๋‹ค๋ฉด, ๊ด€๋ จ ๊ฒ€์ƒ‰์„ ์ข€ ๋” ํ•˜๊ณ  ๋‚˜์„œ ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•  ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ActivityPub ์„œ๋ฒ„ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Fedify๋ฅผ ์ด์šฉํ•˜์—ฌ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ํ”„๋กœํ† ์ฝœ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ(microblog)๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify์˜ ๊ธฐ๋ฐ˜ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค๋Š” Fedify์˜ ํ™œ์šฉ๋ฒ•์— ์ข€ ๋” ์ง‘์ค‘ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

Fedify๋Š” ActivityPub์ด๋‚˜ ๊ทธ ์™ธ ํ‘œ์ค€(์ด์นญํ•˜์—ฌ ใ€Œ์—ฐํ•ฉ์šฐ์ฃผใ€๋ผ ๋ถˆ๋ฆฌ๋Š”)์„ ์ด์šฉํ•˜์—ฌ ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค ๋•Œ์˜ ๋ณต์žกํ•จ์ด๋‚˜ ๋ฒˆ๊ฑฐ๋กœ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์—†์• ๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด Fedify์˜ ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค.

Fedify ํ”„๋กœ์ ํŠธ์— ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์…จ๋‹ค๋ฉด, ์•„๋ž˜์˜ ์ž๋ฃŒ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”:

Fedify๋‚˜ ๋ณธ ํŠœํ† ๋ฆฌ์–ผ์— ๋Œ€ํ•œ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ, ํ”ผ๋“œ๋ฐฑ ๋“ฑ์€ GitHub Discussions(์˜์–ด)์— ์˜ฌ๋ ค ์ฃผ์‹œ๊ฑฐ๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ @fedify(์˜์–ด ๋ฐ ํ•œ๊ตญ์–ด)๋กœ ๋ฉ˜์…˜ ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ์•„๋‹ˆ๋ฉด Fedify ํ”„๋กœ์ ํŠธ์˜ Discord ์„œ๋ฒ„์— ๋“ค์–ด์˜ค์…”์„œ #fedify-general-ko ์ฑ„๋„(ํ•œ๊ตญ์–ด)์—์„œ ๋ง์”€ํ•˜์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

๋Œ€์ƒ ๋…์ž

์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify๋ฅผ ๋ฐฐ์›Œ์„œ ActivityPub ์„œ๋ฒ„ ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ณ  ์‹ถ์€ ๋ถ„๋“ค์„ ๋Œ€์ƒ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด HTML์ด๋‚˜ HTTP๋ฅผ ์ด์šฉํ•˜์—ฌ ์›น์•ฑ์„ ์ œ์ž‘ํ•ด ๋ณธ ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ, ๋ช…๋ นํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๋‚˜ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋“ฑ์„ ์ดํ•ดํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ TypeScript๋‚˜ JSX, ActivityPub, Fedify ๋“ฑ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ํ•„์š”ํ•œ ๋งŒํผ ๊ฐ€๋ฅด์ณ ๋“œ๋ฆด ๊ฒƒ์ด๋‹ˆ ๋ชฐ๋ผ๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณธ ๊ฒฝํ—˜์€ ํ•„์š” ์—†์ง€๋งŒ, ๊ทธ๋ž˜๋„ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ํ•˜๋‚˜ ์ •๋„๋Š” ์จ๋ดค๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ž˜์•ผ ์šฐ๋ฆฌ๊ฐ€ ๋ฌด์—‡์„ ๋งŒ๋“œ๋ ค๊ณ  ํ•˜๋Š”์ง€ ๊ฐ์ด ์žกํžˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๋ชฉํ‘œ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Fedify๋ฅผ ์ด์šฉํ•ด ActivityPub์œผ๋กœ ๋‹ค๋ฅธ ์—ฐํ•ฉํ˜• ์†Œํ”„ํŠธ์›จ์–ด ๋ฐ ์„œ๋น„์Šค์™€ ์†Œํ†ต ๊ฐ€๋Šฅํ•œ ์ผ์ธ์šฉ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ด ์†Œํ”„ํŠธ์›จ์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

  • ์‚ฌ์šฉ์ž๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์ด ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒ”๋กœ์›Œ๋Š” ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœํ•˜๋‹ค๊ฐ€ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๋ฌผ์€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ • ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ •์ด ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์„ ์‹œ๊ฐ„์ˆœ ๋ชฉ๋ก์œผ๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŠœํ† ๋ฆฌ์–ผ์„ ๋‹จ์ˆœํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ ์ œ์•ฝ์„ ๋‘ก๋‹ˆ๋‹ค.

  • ๊ณ„์ • ํ”„๋กœํ•„(์†Œ๊ฐœ๋ฌธ, ์‚ฌ์ง„ ๋“ฑ)์€ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ๋งŒ๋“  ๊ณ„์ •์€ ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ์˜ฌ๋ฆฐ ๊ฒŒ์‹œ๋ฌผ์€ ๊ณ ์น˜๊ฑฐ๋‚˜ ์ง€์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ํŒ”๋กœํ•œ ๋‹ค๋ฅธ ๊ณ„์ •์€ ํŒ”๋กœ์ž‰์„ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ข‹์•„์š”, ๊ณต์œ , ๋Œ“๊ธ€์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ ๋“ฑ์˜ ๋ณด์•ˆ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก , ํŠœํ† ๋ฆฌ์–ผ์„ ๋๊นŒ์ง€ ์ง„ํ–‰ํ•œ ๋’ค ๊ธฐ๋Šฅ์„ ๋ง๋ถ™์ด๋Š” ๊ฒƒ์€ ์–ผ๋งˆ๋“ ์ง€ ํ•˜์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์—ฐ์Šต์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” GitHub ์ €์žฅ์†Œ์— ์˜ฌ๋ผ์™€ ์žˆ์œผ๋ฉฐ, ๊ฐ ๊ตฌํ˜„ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ์ปค๋ฐ‹์ด ๋‚˜๋‰˜์–ด์ ธ ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์…‹์—…

Node.js ์„ค์น˜ํ•˜๊ธฐ

Fedify๋Š” Deno, Bun, Node.js, ์ด ์„ธ ๊ฐ€์ง€ JavaScript ๋Ÿฐํƒ€์ž„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ์ค‘์—์„œ Node.js๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์“ฐ์ด๋ฏ€๋กœ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Node.js๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

JavaScript ๋Ÿฐํƒ€์ž„์ด๋ž€ JavaScript ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ํ”Œ๋žซํผ์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์›น๋ธŒ๋ผ์šฐ์ €๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜์ด๋ฉฐ, ๋ช…๋ น์ค„์ด๋‚˜ ์„œ๋ฒ„์—์„œ๋Š” Node.js ๋“ฑ์ด ๋„๋ฆฌ ์“ฐ์ž…๋‹ˆ๋‹ค. ์ตœ๊ทผ์—๋Š” Cloudflare Workers ๊ฐ™์€ ํด๋ผ์šฐ๋“œ ์—์ง€ ํ•จ์ˆ˜๋“ค๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜๋กœ ๊ฐ๊ด‘ ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fedify๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Node.js 22.0.0 ์ด์ƒ์˜ ๋ฒ„์ „์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜๋ฒ•์ด ์žˆ์œผ๋‹ˆ ์ž์‹ ์—๊ฐ€ ๊ฐ€์žฅ ์•Œ๋งž๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ Node.js๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

Node.js๊ฐ€ ์„ค์น˜๋˜๋ฉด node ๋ช…๋ น์–ด์™€ npm ๋ช…๋ น์–ด๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค:

node --version
npm --version

fedify ๋ช…๋ น์–ด ์„ค์น˜

Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์…‹์—…ํ•˜๊ธฐ ์œ„ํ•ด fedify ๋ช…๋ น์–ด๋ฅผ ์‹œ์Šคํ…œ์— ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ, npm ๋ช…๋ น์œผ๋กœ ๊นŒ๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊ฐ„ํŽธํ•ฉ๋‹ˆ๋‹ค:

npm install -g @fedify/cli

์„ค์น˜๊ฐ€ ๋˜์—ˆ๋‹ค๋ฉด, fedify ๋ช…๋ น์–ด๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ fedify ๋ช…๋ น์–ด์˜ ๋ฒ„์ „์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

fedify --version

๊ฒฐ๊ณผ๋กœ ๋‚˜์˜จ ๋ฒ„์ „ ๋ฒˆํ˜ธ๊ฐ€ 1.0.0 ์ด์ƒ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. ๊ทธ๋ณด๋‹ค ์˜›๋‚  ๋ฒ„์ „์ด๋ฉด ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ œ๋Œ€๋กœ ๋”ฐ๋ผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

fedify init์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

์ƒˆ Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•ด, ์ž‘์—…ํ•  ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ •ํ•ฉ์‹œ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” microblog๋ผ๊ณ  ๋ช…๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. fedify init ๋ช…๋ น ๋’ค์— ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ ๊ณ  ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค (๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค):

fedify init microblog

fedify init ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ช‡ ๊ฐ€์ง€ ์งˆ๋ฌธ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค. ์ฐจ๋ก€๋Œ€๋กœ Node.js, npm, Hono, In-memory, In-process๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค:

             ___      _____        _ _  __
            /'_')    |  ___|__  __| (_)/ _|_   _
     .-^^^-/  /      | |_ / _ \/ _` | | |_| | | |
   __/       /       |  _|  __/ (_| | |  _| |_| |
  <__.|_|-|_|        |_|  \___|\__,_|_|_|  \__, |
                                           |___/

? Choose the JavaScript runtime to use
  Deno
  Bun
โฏ Node.js

? Choose the package manager to use
โฏ npm
  Yarn
  pnpm

? Choose the web framework to integrate Fedify with
  Bare-bones
  Fresh
โฏ Hono
  Express
  Nitro

? Choose the key-value store to use for caching
โฏ In-memory
  Redis
  PostgreSQL
  Deno KV

? Choose the message queue to use for background jobs
โฏ In-process
  Redis
  PostgreSQL
  Deno KV

์•ˆ๋‚ด

Fedify๋Š” ํ’€ ์Šคํƒ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์•„๋‹Œ, ActivityPub ์„œ๋ฒ„ ๊ตฌํ˜„์— ํŠนํ™”๋œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋‹ค๋ฅธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์™€ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๊ฒƒ์„ ์—ผ๋‘์— ๋‘๊ณ  ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ Hono๋ฅผ ์ฑ„ํƒํ•˜์—ฌ Fedify์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์ž ์‹œ ํ›„ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ ์•ˆ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • .vscode/ โ€” Visual Studio Code ๊ด€๋ จ ์„ค์ •๋“ค
    • extensions.json โ€” Visual Studio Code ์ถ”์ฒœ ํ™•์žฅ
    • settings.json โ€” Visual Studio Code ์„ค์ •
  • node_modules/ โ€” ์˜์กด ํŒจํ‚ค์ง€๋“ค์ด ์„ค์น˜๋˜๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ (๋‚ด๋ถ€ ์ƒ๋žต)
  • src/ โ€” ์†Œ์Šค ์ฝ”๋“œ
    • app.tsx โ€” ActivityPub๊ณผ ๊ด€๋ จ ์—†๋Š” ์„œ๋ฒ„
    • federation.ts โ€” ActivityPub ์„œ๋ฒ„
    • index.ts โ€” ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
    • logging.ts โ€” ๋กœ๊น… ์„ค์ •
  • biome.json โ€” ํฌ๋งคํ„ฐ ๋ฐ ๋ฆฐํŠธ ์„ค์ •
  • package.json โ€” ํŒจํ‚ค์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
  • tsconfig.json โ€” TypeScript ์„ค์ •

์ง์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒ ์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” JavaScript๊ฐ€ ์•„๋‹Œ TypeScript๋ฅผ ์“ฐ๊ธฐ ๋•Œ๋ฌธ์— .js ํŒŒ์ผ์ด ์•„๋‹Œ .ts ๋ฐ .tsx ํŒŒ์ผ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒ์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” ๋™์ž‘ํ•˜๋Š” ๋ฐ๋ชจ์ž…๋‹ˆ๋‹ค. ์šฐ์„ ์€ ์ด ์ƒํƒœ๋กœ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

npm run dev

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด Ctrl+C ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ธฐ ์ „๊นŒ์ง€๋Š” ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ฑ„๋กœ ์žˆ์Šต๋‹ˆ๋‹ค:

Server started at http://0.0.0.0:8000

์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด๊ณ  ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/john

์œ„ ๋ช…๋ น์€ ์šฐ๋ฆฌ๊ฐ€ ๋กœ์ปฌ์— ๋„์šด ActivityPub ์„œ๋ฒ„์˜ ํ•œ ์•กํ„ฐ(actor)๋ฅผ ์กฐํšŒํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ์•กํ„ฐ๋Š” ์—ฌ๋Ÿฌ ActivityPub ์„œ๋ฒ„๋“ค ์‚ฌ์ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ณ„์ •์ด๋ผ๊ณ  ๋ณด์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋˜๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/john",
  name: "john",
  preferredUsername: "john"
}

์ด ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด /users/john ๊ฒฝ๋กœ์— ์œ„์น˜ํ•œ ์•กํ„ฐ ๊ฐ์ฒด์˜ ์ข…๋ฅ˜๊ฐ€ Person์ด๋ฉฐ, ๊ทธ ID๋Š” http://localhost:8000/users/john, ์ด๋ฆ„์€ john, ์‚ฌ์šฉ์ž๋ช…๋„ john์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

fedify lookup์€ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ์ด๋Š” Mastodon์—์„œ ํ•ด๋‹น URI๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌผ๋ก , ํ˜„์žฌ ์—ฌ๋Ÿฌ๋ถ„์˜ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•„์ง Mastodon์—์„œ ๊ฒ€์ƒ‰ํ•ด๋„ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค์ง€๋Š” ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.)

์—ฌ๋Ÿฌ๋ถ„์ด fedify lookup ๋ช…๋ น์–ด๋ณด๋‹ค curl์„ ๋” ์„ ํ˜ธํ•˜์‹ ๋‹ค๋ฉด, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ๋„ ์•กํ„ฐ ์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (-H ์˜ต์…˜์œผ๋กœ Accept ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋Š” ๊ฒƒ์— ์ฃผ์˜ํ•˜์‹ญ์‹œ์˜ค):

curl -H"Accept: application/activity+json" http://localhost:8000/users/john

๋‹จ, ์œ„์™€ ๊ฐ™์ด ์กฐํšŒํ•  ๊ฒฝ์šฐ ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋งจ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜๊ธฐ ์–ด๋ ค์šด JSON ํ˜•์‹์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์‹œ์Šคํ…œ์— jq ๋ช…๋ น์–ด๋„ ํ•จ๊ป˜ ๊น”๋ ค์žˆ๋‹ค๋ฉด, curl๊ณผ jq๋ฅผ ํ•จ๊ป˜ ์“ธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

Visual Studio Code๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋Š” ๋™์•ˆ์—๋Š” Visual Studio Code๋ฅผ ์จ๋ณด์‹ค ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์šฐ๋ฆฌ๋Š” TypeScript๋ฅผ ์จ์•ผ ํ•˜๋Š”๋ฐ, Visual Studio Code๋Š” ํ˜„์กดํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„ํŽธํ•˜๋ฉด์„œ๋„ ๋›ฐ์–ด๋‚œ TypeScript IDE์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ์…‹์—…์— ์ด๋ฏธ Visual Studio Code ์„ค์ •์ด ๊ฐ–์ถฐ์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํฌ๋งคํ„ฐ๋‚˜ ๋ฆฐํŠธ ๋“ฑ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š”๋„ ์—†์Šต๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

Visual Studio์™€ ํ—ท๊ฐˆ๋ฆฌ์‹œ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. Visual Studio Code์™€ Visual Studio๋Š” ๋ธŒ๋žœ๋“œ๋งŒ ๊ณต์œ ํ•  ๋ฟ ์„œ๋กœ ์™„์ „ํžˆ ๋‹ค๋ฅธ ์†Œํ”„ํŠธ์›จ์–ด์ž…๋‹ˆ๋‹ค.

Visual Studio Code๋ฅผ ์„ค์น˜ํ•˜์‹  ๋‹ค์Œ, ํŒŒ์ผ โ†’ ํด๋” ์—ด๊ธฐโ€ฆ ๋ฉ”๋‰ด๋ฅผ ๋ˆŒ๋Ÿฌ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์‹ญ์‹œ์˜ค.

๋งŒ์•ฝ ์šฐํ•˜๋‹จ์— ใ€Œ์ด ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ๋Œ€ํ•œ ๊ถŒ์žฅ๋˜๋Š” biomejs์˜ โ€˜Biomeโ€™ ํ™•์žฅ์„(๋ฅผ) ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?ใ€๋ผ๊ณ  ๋ฌป๋Š” ์ฐฝ์ด ๋œจ๋ฉด ์„ค์น˜ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ•ด๋‹น ํ™•์žฅ์„ ์„ค์น˜ํ•˜์„ธ์š”. ์ด ํ™•์žฅ์„ ์„ค์น˜ํ•˜๋ฉด TypeScript ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ๋“ค์—ฌ์“ฐ๊ธฐ๋‚˜ ๋„์–ด์“ฐ๊ธฐ ๊ฐ™์€ ์ฝ”๋“œ ์Šคํƒ€์ผ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๊ฐ€ ์„œ์‹ํ™” ๋ฉ๋‹ˆ๋‹ค.

ํŒ

์—ฌ๋Ÿฌ๋ถ„์ด ์ถฉ์„ฑ์Šค๋Ÿฌ์šด Emacs ๋˜๋Š” Vim ์‚ฌ์šฉ์ž๋ผ๋ฉด, ์“ฐ๋˜ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๋ฅผ ์“ฐ๋Š” ๊ฒƒ์„ ๋ง๋ฆฌ์ง€ ์•Š๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TypeScript LSP ์„ค์ •์€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. TypeScript LSP ์„ค์ • ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์ƒ์‚ฐ์„ฑ์˜ ์ฐจ์ด๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์„ ์ˆ˜ ์ง€์‹

TypeScript

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ ์ „์—, ๊ฐ„๋‹จํžˆ TypeScript์— ๋Œ€ํ•ด ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์—ฌ๋Ÿฌ๋ถ„์ด ์ด๋ฏธ TypeScript์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด ์ด ์žฅ์€ ๋„˜๊ธฐ์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

TypeScript๋Š” JavaScript์— ์ •์  ํƒ€์ž… ๊ฒ€์‚ฌ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. TypeScript ๋ฌธ๋ฒ•์€ JavaScript ๋ฌธ๋ฒ•๊ณผ ๊ฑฐ์˜ ๊ฐ™์ง€๋งŒ, ๋ณ€์ˆ˜๋‚˜ ํ•จ์ˆ˜ ๋ฌธ๋ฒ•์— ํƒ€์ž…์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ํฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ์ง€์ •์€ ๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋’ค์— ์ฝœ๋ก (:)์„ ๋ถ™์—ฌ์„œ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋Š” foo ๋ณ€์ˆ˜๊ฐ€ ๋ฌธ์ž์—ด(string)์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค:

let foo: string;

๋งŒ์•ฝ ์œ„์™€ ๊ฐ™์ด ์„ ์–ธ๋œ foo ๋ณ€์ˆ˜์— ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ๊ฐ’์„ ๋Œ€์ž…ํ•˜๋ ค๊ณ  ํ•˜๋ฉด Visual Studio Code๊ฐ€ ์‹คํ–‰ํ•ด๋ณด๊ธฐ ์ „์— ๋ฏธ๋ฆฌ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๊ทธ์–ด์ฃผ๋ฉฐ ํƒ€์ž… ์˜ค๋ฅ˜๋ฅผ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

foo = 123;
// ts(2322): 'number' ํ˜•์‹์€ 'string' ํ˜•์‹์— ํ• ๋‹นํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ฝ”๋”ฉํ•˜๋ฉด์„œ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๋งŒ๋‚˜๋ฉด ์ง€๋‚˜์น˜์ง€ ์•Š๋„๋ก ํ•˜์‹ญ์‹œ์˜ค. ๋ฌด์‹œํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉด ๊ทธ ๋ถ€๋ถ„์—์„œ ์‹ค์ œ๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.

TypeScript๋กœ ์ฝ”๋”ฉ์„ ํ•˜๋ฉฐ ๋งˆ์ฃผ์น˜๋Š” ๊ฐ€์žฅ ํ”ํ•œ ํƒ€์ž… ์˜ค๋ฅ˜์˜ ์œ ํ˜•์€ ๋ฐ”๋กœ null ๊ฐ€๋Šฅ์„ฑ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด bar ๋ณ€์ˆ˜๋Š” ๋ฌธ์ž์—ด(string)์ผ ์ˆ˜๋„ ์žˆ์ง€๋งŒ null์ผ ์ˆ˜๋„ ์žˆ๋‹ค(string | null)๊ณ  ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:

const bar: string | null = someFunction();

๋งŒ์•ฝ ์ด ๋ณ€์ˆ˜์˜ ๋‚ด์šฉ์—์„œ ๊ฐ€์žฅ ์ฒซ ๊ธ€์ž๋ฅผ ๊บผ๋‚ด๋ ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ์“ด๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”?

const firstChar = bar.charAr(0);
// ts(18047): 'bar'์€(๋Š”) 'null'์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. bar๊ฐ€ ์–ด์ฉ” ๋•Œ๋Š” null์ผ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ๊ฒฝ์šฐ์— null.charAt(0)์„ ํ˜ธ์ถœํ•˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ผ๋Š” ์ด์•ผ๊ธฐ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๊ฒฝ์šฐ์— ์•„๋ž˜์™€ ๊ฐ™์ด null์ธ ๊ฒฝ์šฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

const firstChar = bar === null ? "" : bar.charAr(0);

์ด์™€ ๊ฐ™์ด TypeScript๋Š” ์ฝ”๋”ฉํ•  ๋•Œ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ๋˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๋ฅผ ๋– ์˜ฌ๋ฆฌ๊ฒŒ ํ•ด์„œ ๋ฒ„๊ทธ๋ฅผ ๋ฏธ์—ฐ์— ๋ฐฉ์ง€ํ•˜๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

๋˜, TypeScript์˜ ๋ถ€์ˆ˜์ ์ธ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” ์ž๋™ ์™„์„ฑ์ด ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, foo.๊นŒ์ง€ ์ž…๋ ฅํ•˜๋ฉด ๋ฌธ์ž์—ด ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ง„ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์ด ๋‚˜์™€์„œ ๊ทธ ์ค‘์—์„œ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ผ์ผํžˆ ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์ง€ ์•Š๊ณ ์„œ๋„ ๋น ๋ฅด๊ฒŒ ์ฝ”๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋ฉด์„œ TypeScript์˜ ๋งค๋ ฅ๋„ ํ•จ๊ป˜ ๋А๋ผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ฌด์—‡๋ณด๋‹ค Fedify๋Š” TypeScript์™€ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ€์žฅ ๊ฒฝํ—˜์ด ์ข‹์œผ๋‹ˆ๊นŒ์š”.

ํŒ

TypeScript๋ฅผ ์ œ๋Œ€๋กœ ์ฐฌ์ฐฌํžˆ ๋ฐฐ์›Œ๋ณด๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, ๊ณต์‹ TypeScript ํ•ธ๋“œ๋ถ์„ ์ฝ์œผ์‹ค ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ „๋ถ€ ์ฝ๋Š”๋ฐ ์•ฝ 30๋ถ„ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค.

JSX

JSX๋Š” JavaScript ์ฝ”๋“œ ์•ˆ์— XML ๋˜๋Š” HTML์„ ์ง‘์–ด๋„ฃ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•œ JavaScript์˜ ๋ฌธ๋ฒ• ํ™•์žฅ์ž…๋‹ˆ๋‹ค. TypeScript์—์„œ๋„ ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด ๊ฒฝ์šฐ์—๋Š” TSX๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ๋ชจ๋“  HTML์„ JSX ๋ฌธ๋ฒ•์„ ํ†ตํ•ด JavaScript ์ฝ”๋“œ ์•ˆ์— ์ž‘์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. JSX์— ์ด๋ฏธ ์ต์ˆ™ํ•œ ๋ถ„๋“ค์€ ์ด ์žฅ์„ ๋„˜๊ธฐ์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <div> ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๋Š” HTML ํŠธ๋ฆฌ๋ฅผ html ๋ณ€์ˆ˜์— ๋Œ€์ž…ํ•ฉ๋‹ˆ๋‹ค:

const html = <div>
  <p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p>
</div>;

์ค‘๊ด„ํ˜ธ๋ฅผ ํ†ตํ•ด JavaScript ํ‘œํ˜„์‹์„ ๋„ฃ๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฝ”๋“œ๋Š” ๋ฌผ๋ก  getName() ํ•จ์ˆ˜๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค):

const html = <div title={"์•ˆ๋…•, " + getName() + "!"}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</div>;

JSX์˜ ํŠน์ง• ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ(component)๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์ž์‹ ๋งŒ์˜ ํƒœ๊ทธ๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋Š” ํ‰๋ฒ”ํ•œ JavaScript ํ•จ์ˆ˜๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค (์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„์€ ์ผ๋ฐ˜์ ์œผ๋กœ PascalCase ์Šคํƒ€์ผ์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค):

import type { FC } from "hono/jsx";

function getName() {
  return "JSX";
}

interface ContainerProps {
  name: string;
}

const Container: FC<ContainerProps> = (props) => {
  return <div title={"์•ˆ๋…•, " + props.name + "!"}>{props.children}</div>;
};

const html = <Container name={getName()}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</Container>;

์œ„ ์ฝ”๋“œ์—์„œ FC๋Š” ์šฐ๋ฆฌ๊ฐ€ ์“ธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ธ Hono์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋„์™€์ค๋‹ˆ๋‹ค. FC๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž…(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ˜๋Ÿผ ํ™”์‚ด๊ด„ํ˜ธ ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ํƒ€์ž…๋“ค์ด ๋ฐ”๋กœ ํƒ€์ž… ์ธ์ž๋“ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํƒ€์ž… ์ธ์ž๋กœ ํ”„๋กญ(props) ํ˜•์‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กญ์ด๋ž€, ์ปดํฌ๋„ŒํŠธ์—๊ฒŒ ๋„˜๊ฒจ ์ค„ ๋งค๊ฐœ๋ณ€์ˆ˜๋“ค์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ง์ž…๋‹ˆ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กญ ํ˜•์‹์œผ๋กœ ContainerProps ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์„ ์–ธํ•˜๊ณ  ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ €๋„ค๋ฆญ ํƒ€์ž…์˜ ํƒ€์ž… ์ธ์ž๋Š” ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‰ผํ‘œ๋กœ ๊ฐ ์ธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Foo<A, B>๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž… Foo์— ํƒ€์ž… ์ธ์ž A์™€ B๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ, ์ €๋„ค๋ฆญ ํ•จ์ˆ˜๋ผ๋Š” ๊ฒƒ๋„ ์žˆ์œผ๋ฉฐ, someFunction<A, B>(foo, bar)์™€ ๊ฐ™์ด ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ธ์ž๊ฐ€ ํ•˜๋‚˜์ผ ๋•Œ๋Š” ํƒ€์ž… ์ธ์ž๋ฅผ ๊ฐ์‹ธ๋Š” ํ™”์‚ด๊ด„ํ˜ธ๊ฐ€ ๋งˆ์น˜ XML/HTML ํƒœ๊ทธ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ, JSX์˜ ๊ธฐ๋Šฅ๊ณผ๋Š” ์•„๋ฌด ๊ด€๋ จ์ด ์—†์Šต๋‹ˆ๋‹ค.

FC<ContainerProps>
์ €๋„ค๋ฆญ ํƒ€์ž… FC์— ํƒ€์ž… ์ธ์ž ContainerProps๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ.
<Container>
<Container>๋ผ๋Š” ์ด๋ฆ„์˜ ์ปดํฌ๋„ŒํŠธ ํƒœ๊ทธ๋ฅผ ์—ฐ ๊ฒƒ. </Container>๋กœ ๋‹ซ์•„์•ผ ํ•จ.

ํ”„๋กญ์œผ๋กœ ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ๋“ค ์ค‘ children์€ ํŠน๋ณ„ํžˆ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์—˜๋ฆฌ๋จผํŠธ๋“ค์ด children ํ”„๋กญ์œผ๋กœ ๋„˜์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์œ„ ์ฝ”๋“œ์—์„œ html ๋ณ€์ˆ˜์—๋Š” <div title="์•ˆ๋…•, JSX!"><p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p></div>๋ผ๋Š” HTML ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŒŒ์ผ ์•ˆ์— JSX๋กœ <Layout> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

import type { FC } from "hono/jsx";

export const Layout: FC = (props) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="color-scheme" content="light dark" />
      <title>Microblog</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
      />
    </head>
    <body>
      <main class="container">{props.children}</main>
    </body>
  </html>
);

๋””์ž์ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ๊ณต์„ ๋“ค์ด์ง€ ์•Š๊ธฐ ์œ„ํ•ด, Pico CSS๋ผ๋Š” CSS ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ TypeScript์˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ธฐ๊ฐ€ ์ถ”๋ก ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ, ์œ„์˜ props ๊ฐ™์ด ํƒ€์ž… ํ‘œ๊ธฐ๋ฅผ ์ƒ๋žตํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํƒ€์ž… ํ‘œ๊ธฐ๊ฐ€ ์ƒ๋žต๋œ ๊ฒฝ์šฐ์—๋„, Visual Studio Code์—์„œ ๋ณ€์ˆ˜ ์ด๋ฆ„ ์œ„์— ๋งˆ์šฐ์Šค ์ปค์„œ๋ฅผ ๊ฐ€์ ธ๋‹ค ๋Œ€๋ฉด ํ•ด๋‹น ๋ณ€์ˆ˜๊ฐ€ ์–ด๋–ค ํƒ€์ž…์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ ๋‹ค์Œ, ๊ฐ™์€ ํŒŒ์ผ์—์„œ ๋ ˆ์ด์•„์›ƒ ์•ˆ์— ๋“ค์–ด๊ฐˆ <SetupForm> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

JSX์—์„œ๋Š” ์ตœ์ƒ์œ„์— ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ๋Š”๋ฐ, <SetupForm> ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” <h1>๊ณผ <form> ๋‘ ๊ฐœ์˜ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ์ตœ์ƒ์œ„์— ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด๋ฅผ ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ์ฒ˜๋Ÿผ ๋ฌถ์–ด์ฃผ๊ธฐ ์œ„ํ•ด์„œ ๋นˆ ํƒœ๊ทธ ๋ชจ์–‘์˜ <>์™€ </>๋กœ ๊ฐ์ŒŒ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”„๋ž˜๊ทธ๋จผํŠธ(fragment)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. src/app.tsx ํŒŒ์ผ์—์„œ ์•ž์„œ ์ •์˜ํ•œ ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ /setup ํŽ˜์ด์ง€์—์„œ ์•ž์„œ ๋งŒ๋“  ๊ณ„์ • ์ƒ์„ฑ ์–‘์‹์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) =>
  c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  ),
);

์ž, ๊ทธ๋Ÿผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋ณด์—ฌ์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•ˆ๋‚ด

JSX๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์†Œ์Šค ํŒŒ์ผ์˜ ํ™•์žฅ์ž๊ฐ€ .jsx ๋˜๋Š” .tsx์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์žฅ์—์„œ ํŽธ์ง‘ํ•œ ๋‘ ํŒŒ์ผ ๋ชจ๋‘ ํ™•์žฅ์ž๊ฐ€ .tsx๋ผ๋Š” ์‚ฌ์‹ค์— ์ฃผ์˜ํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์…‹์—…

์ž, ๋ณด์ด๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๋™์ž‘์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ณณ์ด ํ•„์š”ํ•œ๋ฐ, SQLite๋ฅผ ์“ฐ๋„๋ก ํ•ฉ์‹œ๋‹ค. SQLite๋Š” ์ž‘์€ ๊ทœ๋ชจ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์•Œ๋งž๋Š” ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ž…๋‹ˆ๋‹ค.

์šฐ์„  ๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ด์„ ํ…Œ์ด๋ธ”์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. ์•ž์œผ๋กœ ๋ชจ๋“  ํ…Œ์ด๋ธ” ์„ ์–ธ์€ src/schema.sql ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋Š” users ํ…Œ์ด๋ธ”์— ๋‹ด์Šต๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS users (
  id       INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
  username TEXT    NOT NULL UNIQUE      CHECK (trim(lower(username)) = username
                                               AND username <> ''
                                               AND length(username) <= 50)
);

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ฏ€๋กœ, ๊ธฐ๋ณธ ํ‚ค์ธ id ์นผ๋Ÿผ์ด 1 ์ด์™ธ์˜ ๊ฐ’์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ๊ฑธ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ users ํ…Œ์ด๋ธ”์—๋Š” ๋‘˜ ์ด์ƒ์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ด์„ ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ณ„์ • ์•„์ด๋””๋ฅผ ๋‹ด์„ username ์นผ๋Ÿผ์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋‚˜ ๋„ˆ๋ฌด ๊ธด ๋ฌธ์ž์—ด์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ์คฌ์Šต๋‹ˆ๋‹ค.

์ด์ œ users ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ src/schema.sql ํŒŒ์ผ์„ ์‹คํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด sqlite3 ๋ช…๋ น์–ด๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, SQLite ์›น์‚ฌ์ดํŠธ์—์„œ ๋ฐ›๊ฑฐ๋‚˜ ๊ฐ ํ”Œ๋žซํผ์˜ ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋กœ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. macOS์˜ ๊ฒฝ์šฐ์—๋Š” ์šด์˜์ฒด์ œ์— ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ๋”ฐ๋กœ ๋ฐ›์„ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ๋ฐ›์„ ๊ฒฝ์šฐ ์šด์˜์ฒด์ œ์— ๋งž๋Š” sqlite-tools-*.zip ํŒŒ์ผ์„ ๋ฐ›์•„์„œ ์••์ถ•์„ ํ•ด์ œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์„ค์น˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

sudo apt install sqlite3  # Debian ๋ฐ Ubuntu
sudo dnf install sqlite   # Fedora ๋ฐ RHEL
choco install sqlite  # Chocolatey
scoop install sqlite  # Scoop
winget install SQLite.SQLite  # Windows Package Manager

์ž, sqlite3 ๋ช…๋ น์–ด๊ฐ€ ์ค€๋น„๋˜์—ˆ๋‹ค๋ฉด ์ด์ œ ์ด๋ฅผ ์ด์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด microblog.sqlite3 ํŒŒ์ผ์ด ์ƒ๊ธฐ๋Š”๋ฐ, ์ด ์•ˆ์— SQLite ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

์•ฑ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ

์ด์ œ ์ €ํฌ๊ฐ€ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•  ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. Node.js์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SQLite ๋“œ๋ผ์ด๋ฒ„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, ์ €ํฌ๋Š” better-sqlite3 ํŒจํ‚ค์ง€๋ฅผ ์“ฐ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€๋Š” npm ๋ช…๋ น์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊น” ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

npm add better-sqlite3
npm add --save-dev @types/better-sqlite3

ํŒ

@types/better-sqlite3 ํŒจํ‚ค์ง€๋Š” TypeScript๋ฅผ ์œ„ํ•ด better-sqlite ํŒจํ‚ค์ง€์˜ API์— ๋Œ€ํ•œ ํƒ€์ž… ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ Visual Studio Code์—์„œ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™ ์™„์„ฑ์ด๋‚˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด์™€ ๊ฐ™์ด @types/ ๋ฒ”์œ„ ์•ˆ์— ์žˆ๋Š” ํŒจํ‚ค์ง€๋ฅผ Definitely Typed ํŒจํ‚ค์ง€๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ TypeScript๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š์•˜์„ ๋•Œ, ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ํƒ€์ž… ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ ๊ธฐ์ž…ํ•˜์—ฌ ํŒจํ‚ค์ง€๋กœ ๋งŒ๋“  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ, ์ด ํŒจํ‚ค์ง€๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งญ์‹œ๋‹ค. src/db.ts๋ผ๋Š” ์ƒˆ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค:

import Database from "better-sqlite3";

const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export default db;

ํŒ

์ฐธ๊ณ ๋กœ db.pragma() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•œ ์„ค์ •์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์ง€๋‹™๋‹ˆ๋‹ค:

journal_mode = WAL
SQLite์—์„œ ์›์ž์  ์ปค๋ฐ‹ ๋ฐ ๋กค๋ฐฑ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ ์„ ํ–‰ ๊ธฐ์ž… ๋ชจ๋“œ๋ฅผ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ชจ๋“œ๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ ๋กค๋ฐฑ ์ €๋„ ๋ชจ๋“œ์— ๋น„ํ•ด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ์—์„œ ๋” ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.
foreign_keys = ON
SQLite์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด ์„ค์ •์„ ์ผœ๋ฉด ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋˜์–ด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ์ง€ํ‚ค๋Š” ๋ฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  users ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. src/schema.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด User ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface User {
  id: number;
  username: string;
}

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ–ˆ์œผ๋‹ˆ, ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ฝ์ž…ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…์— ์“ฐ์ผ db ๊ฐ์ฒด์™€ User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import db from "./db.ts";
import type { User } from "./schema.ts";

POST /setup ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
  return c.redirect("/");
});

์•ž์„œ ๋งŒ๋“ค์—ˆ๋˜ GET /setup ํ•ธ๋“ค๋Ÿฌ์—๋„ ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});

ํ…Œ์ŠคํŠธ

์ด์ œ ๊ณ„์ • ์ƒ์„ฑ ๊ธฐ๋Šฅ์ด ์–ผ์ถ” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ, ํ•œ ๋ฒˆ ์จ ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๊ณ„์ •์„ ์ƒ์„ฑํ•ด ๋ณด์„ธ์š”. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•ž์œผ๋กœ ์•„์ด๋””๋กœ johndoe๋ฅผ ์ผ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋˜์—ˆ๋‹ค๋ฉด, SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‚˜ ํ™•์ธ๋„ ํ•ด ๋ด…๋‹ˆ๋‹ค:

echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3

๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค (๋ฌผ๋ก , johndoe๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 johndoe

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ด์ œ ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ ๊ณ„์ • ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค. ๋น„๋ก ๋ณด์—ฌ ์ค„ ์ •๋ณด๊ฐ€ ๊ฑฐ์˜ ์—†์ง€๋งŒ์š”.

์ด๋ฒˆ์—๋„ ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์ž‘์—…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์— <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  handle: string;
}

export const Profile: FC<ProfileProps> = ({ name, handle }) => (
  <>
    <hgroup>
      <h1>{name}</h1>
      <p style="user-select: all;">{handle}</p>
    </hgroup>
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์—์„œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” GET /users/{username} ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.username} handle={handle} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด ์ด์ œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด ๋ด์•ผ๊ฒ ์ฃ ? ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe (๊ณ„์ • ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ํ–ˆ์„ ๊ฒฝ์šฐ; ์•„๋‹ˆ๋ผ๋ฉด URL์„ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค) ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ณด์„ธ์š”. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ

์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค(fediverse handle), ์ค„์—ฌ์„œ ํ•ธ๋“ค์ด๋ž€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด์—์„œ ๊ณ„์ •์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ณ ์œ ํ•œ ์ฃผ์†Œ ๊ฐ™์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด @hongminhee@fosstodon.org์ฒ˜๋Ÿผ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ƒ๊ฒผ๋Š”๋ฐ, ์‹ค์ œ ๊ตฌ์„ฑ๋„ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•ฉ๋‹ˆ๋‹ค. ๋งจ ์ฒ˜์Œ์— @์ด ์˜ค๊ณ , ๊ทธ ๋‹ค์Œ์— ์ด๋ฆ„, ๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ @์ด ์˜จ ๋’ค, ๋งˆ์ง€๋ง‰์— ๊ณ„์ •์ด ์†ํ•œ ์„œ๋ฒ„์˜ ๋„๋ฉ”์ธ ์ด๋ฆ„์ด ์˜ต๋‹ˆ๋‹ค. ๋•Œ๋•Œ๋กœ ๋งจ ์•ž์˜ @์ด ์ƒ๋žต๋˜๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ํ•ธ๋“ค์€ WebFinger์™€ acct: URI ํ˜•์‹์ด๋ผ๋Š” ๋‘ ๊ฐœ์˜ ํ‘œ์ค€์œผ๋กœ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. Fedify๊ฐ€ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ง„ํ–‰ํ•˜๋Š” ๋™์•ˆ ์—ฌ๋Ÿฌ๋ถ„์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์•Œ์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

์•กํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

ActivityPub์€ ๊ทธ ์ด๋ฆ„์—์„œ๋„ ๋“œ๋Ÿฌ๋‚˜๋“ฏ, ์•กํ‹ฐ๋น„ํ‹ฐ(activity)๋ฅผ ์ฃผ๊ณ  ๋ฐ›๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค. ๊ธ€์“ฐ๊ธฐ, ๊ธ€ ๊ณ ์น˜๊ธฐ, ๊ธ€ ์ง€์šฐ๊ธฐ, ๊ธ€์— ์ข‹์•„์š” ์ฐ๊ธฐ, ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ, ํ”„๋กœํ•„ ๊ณ ์น˜๊ธฐโ€ฆ ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ ์ผ์–ด๋‚˜๋Š” ๋ชจ๋“  ์ผ๋“ค์„ ์•กํ‹ฐ๋น„ํ‹ฐ๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋ชจ๋“  ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์•กํ„ฐ(actor)์—์„œ ์•กํ„ฐ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํ™๊ธธ๋™์ด ๊ธ€์„ ์“ฐ๋ฉด ใ€Œ๊ธ€์“ฐ๊ธฐใ€(Create(Note)) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ™๊ธธ๋™์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์˜ ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๊ธ€์— ์ž„๊บฝ์ •์ด ์ข‹์•„์š”๋ฅผ ์ฐ์œผ๋ฉด ใ€Œ์ข‹์•„์š”ใ€(Like) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž„๊บฝ์ •์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ActivityPub์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๊ฑธ์Œ์€ ์•กํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

fedify init ๋ช…๋ น์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ๋ชจ ์•ฑ์— ์ด๋ฏธ ์•„์ฃผ ๊ฐ„๋‹จํ•œ ์•กํ„ฐ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธด ํ•˜์ง€๋งŒ, Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ์‹ค์ œ์˜ ์†Œํ”„ํŠธ์›จ์–ด๋“ค๊ณผ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•กํ„ฐ๋ฅผ ์ข€ ๋” ์ œ๋Œ€๋กœ ๊ตฌํ˜„ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋‹จ, ํ˜„์žฌ์˜ ๊ตฌํ˜„์„ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณผ๊นŒ์š”? src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด๋ด…์‹œ๋‹ค:

import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";

const logger = getLogger("microblog");

const federation = createFederation({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
});

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: identifier,
  });
});

export default federation;

์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๋ถ€๋ถ„์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ๋‹ค๋ฅธ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์„œ๋ฒ„์˜ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์“ธ URL๊ณผ ๊ทธ ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์šฐ๋ฆฌ๊ฐ€ ์•ž์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ /users/johndoe๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ identifier ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ "johndoe"๋ผ๋Š” ๋ฌธ์ž์—ด ๊ฐ’์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋Š” Person ํด๋ž˜์Šค์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์กฐํšŒํ•œ ์•กํ„ฐ์˜ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

ctx ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ๋Š” Context ๊ฐ์ฒด๊ฐ€ ๋„˜์–ด์˜ค๋Š”๋ฐ, ActivityPub ํ”„๋กœํ† ์ฝœ๊ณผ ๊ด€๋ จ๋œ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์œ„ ์ฝ”๋“œ์—์„œ ์“ฐ์ด๊ณ  ์žˆ๋Š” getActorUri() ๋ฉ”์„œ๋“œ๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌ๋œ identifier๊ฐ€ ๋“ค์–ด๊ฐ„ ์•กํ„ฐ์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด URI๋Š” Person ๊ฐ์ฒด์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋กœ ์“ฐ์ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ๋ณด์‹œ๋ฉด ์•Œ๊ฒ ์ง€๋งŒ, ํ˜„์žฌ๋Š” /users/ ๊ฒฝ๋กœ ๋’ค์— ์–ด๋–ค ํ•ธ๋“ค์ด ์˜ค๋“  ๋ถ€๋ฅด๋Š” ๋Œ€๋กœ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์ง€์–ด๋‚ด์„œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ณ ์ณ๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ…Œ์ด๋ธ”์€ ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •๋งŒ ๋‹ด๋Š” users ํ…Œ์ด๋ธ”๊ณผ ๋‹ฌ๋ฆฌ, ์—ฐํ•ฉ๋˜๋Š” ์„œ๋ฒ„๋“ค์— ์†ํ•œ ์›๊ฒฉ ์•กํ„ฐ๋“ค๊นŒ์ง€๋„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. src/schema.sql ํŒŒ์ผ์— ๋‹ค์Œ SQL์„ ๋ง๋ถ™์ด์„ธ์š”:

CREATE TABLE IF NOT EXISTS actors (
  id               INTEGER NOT NULL PRIMARY KEY,
  user_id          INTEGER          REFERENCES users (id),
  uri              TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  handle           TEXT    NOT NULL UNIQUE CHECK (handle <> ''),
  name             TEXT,
  inbox_url        TEXT    NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%'
                                                  OR inbox_url LIKE 'http://%'),
  shared_inbox_url TEXT                    CHECK (shared_inbox_url
                                                  LIKE 'https://%'
                                                  OR shared_inbox_url
                                                  LIKE 'http://%'),
  url              TEXT                    CHECK (url LIKE 'https://%'
                                                  OR url LIKE 'http://%'),
  created          TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                           CHECK (created <> '')
);
  • user_id ์นผ๋Ÿผ์€ users ์นผ๋Ÿผ๊ณผ ์—ฐ๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ์™ธ๋ž˜ ํ‚ค์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๊ฐ€ ์›๊ฒฉ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” NULL์ด ๋“ค์–ด๊ฐ€์ง€๋งŒ, ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •์ด๋ผ๋ฉด ํ•ด๋‹น ๊ณ„์ •์˜ users.id ๊ฐ’์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ์•กํ„ฐ ID๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•กํ„ฐ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ActivityPub ๊ฐ์ฒด๋Š” URI ํ˜•ํƒœ์˜ ๊ณ ์œ  ID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • handle ์นผ๋Ÿผ์€ @johndoe@example.com ๋ชจ์–‘์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • name ์นผ๋Ÿผ์€ UI์— ํ‘œ์‹œ๋˜๋Š” ์ด๋ฆ„์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋ณดํ†ต ํ’€๋„ค์ž„์ด๋‚˜ ๋‹‰๋„ค์ž„์ด ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๊ฒ ์ฃ . ๋‹ค๋งŒ, ActivityPub ๋ช…์„ธ์— ๋”ฐ๋ผ ์ด ์นผ๋Ÿผ์€ ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ(inbox) URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ์ˆ˜์‹ ํ•จ์ด ๋ฌด์—‡์ธ์ง€์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค๋งŒ, ํ˜„์žฌ๋กœ์„œ๋Š” ์•กํ„ฐ์—๊ฒŒ ํ•„์ˆ˜์ ์œผ๋กœ ์กด์žฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ๋งŒ ์•Œ์•„ ๋‘์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด ์นผ๋Ÿผ ์—ญ์‹œ ๋นŒ ์ˆ˜๋„ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • shared_inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ(shared inbox) URL์„ ๋‹ด๋Š”๋ฐ, ์ด ์—ญ์‹œ ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜๋Š” ์•„๋‹ˆ๋ฉฐ, ๋”ฐ๋ผ์„œ ๋นŒ ์ˆ˜ ์žˆ๊ณ  ์นผ๋Ÿผ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ๊ฐ™์€ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ URL์ด๋ž€ ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด์„œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ URL์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ID์™€ ํ”„๋กœํ•„ URL์ด ๋™์ผํ•œ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ์„œ๋น„์Šค์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ ๊ฒฝ์šฐ์— ์ด ์นผ๋Ÿผ์— ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์€ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋œ ์‹œ์ ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฝ์ž… ์‹œ์  ์‹œ๊ฐ์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.

์ž, ์ด์ œ src/schema.sql ํŒŒ์ผ์„ microblog.sqlite3 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์— ์ ์šฉํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

ํŒ

์•ž์„œ users ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•  ๋•Œ CREATE TABLE IF NOT EXISTS ๋ฌธ์„ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  actors ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  ํƒ€์ž…๋„ src/schema.ts์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Actor {
  id: number;
  user_id: number | null;
  uri: string;
  handle: string;
  name: string | null;
  inbox_url: string;
  shared_inbox_url: string | null;
  url: string | null;
  created: string;
}

์•กํ„ฐ ๋ ˆ์ฝ”๋“œ

ํ˜„์žฌ users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜ ์žˆ๊ธด ํ•˜์ง€๋งŒ, ์ด์™€ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์—๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ actors ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ์„œ users์™€ actors ์–‘์ชฝ์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx์— ์žˆ๋Š” SetupForm์—์„œ ์•„์ด๋””์™€ ํ•จ๊ป˜ actors.name ์นผ๋Ÿผ์— ๋“ค์–ด๊ฐˆ ์ด๋ฆ„๋„ ์ž…๋ ฅ ๋ฐ›๋„๋ก ํ•ฉ์‹œ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
        <label>
          Name <input type="text" name="name" required />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

์•ž์„œ ์ •์˜ํ•œ Actor ํƒ€์ž…์„ src/app.tsx์—์„œ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

์ด์ œ ์ž…๋ ฅ ๋ฐ›์€ ์ด๋ฆ„์„ ๋น„๋กฏํ•ด ํ•„์š”ํ•œ ์ •๋ณด๋“ค์„ actors ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ๋กœ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ๋ฅผ POST /setup ํ•ธ๋“ค๋Ÿฌ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  const name = form.get("name");
  if (typeof name !== "string" || name.trim() === "") {
    return c.redirect("/setup");
  }
  const url = new URL(c.req.url);
  const handle = `@${username}@${url.host}`;
  const ctx = fedi.createContext(c.req.raw, undefined);
  db.transaction(() => {
    db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
      username,
    );
    db.prepare(
      `
      INSERT OR REPLACE INTO actors
        (user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
      VALUES (1, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      ctx.getActorUri(username).href,
      handle,
      name,
      ctx.getInboxUri(username).href,
      ctx.getInboxUri().href,
      ctx.getActorUri(username).href,
    );
  })();
  return c.redirect("/");
});

๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•  ๋•Œ, users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์„ ๋•Œ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์—†์–ด๋„ ์•„์ง ๊ณ„์ •์ด ์—†๋Š” ๊ฒƒ์œผ๋กœ ํŒ์ •ํ•˜๋„๋ก ๊ณ ์ณค์Šต๋‹ˆ๋‹ค. ๊ฐ™์€ ์กฐ๊ฑด์„ GET /setup ํ•ธ๋“ค๋Ÿฌ ๋ฐ GET /users/{username} ํ•ธ๋“ค๋Ÿฌ์—๋„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // Check if the user already exists
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});
app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE username = ?
      `,
    )
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.name ?? user.username} handle={handle} />
    </Layout>,
  );
});

ํŒ

TypeScript์—์„œ A & B๋Š” A ํƒ€์ž…์ธ ๋™์‹œ์— B ํƒ€์ž…์ธ ๊ฐ์ฒด๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, { a: number } & { b: string } ํƒ€์ž…์ด ์žˆ๋‹ค๊ณ  ํ•  ๋•Œ, { a: 123 }์ด๋‚˜ { b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ•˜์ง€๋งŒ, { a: 123, b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด, ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ์•„๋ž˜์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

setInboxListeners() ๋ฉ”์„œ๋“œ๋Š” ์ง€๊ธˆ์œผ๋กœ์„œ๋Š” ์‹ ๊ฒฝ ์“ฐ์ง€ ๋งˆ์„ธ์š”. ์ด ์—ญ์‹œ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•œ getInboxUri() ๋ฉ”์„œ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋ ค๋ฉด ์œ„ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

์ฝ”๋“œ๋ฅผ ๋ชจ๋‘ ๊ณ ์ณค๋‹ค๋ฉด, ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด์„œ ๋‹ค์‹œ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค๊ณ  ๋ ˆ์ฝ”๋“œ๋„ ์ฑ„์› ์œผ๋‹ˆ, ๋‹ค์‹œ src/federation.ts ํŒŒ์ผ์„ ๊ณ ์ณ๋ด…์‹œ๋‹ค. ๋จผ์ € db ๊ฐ์ฒด์™€ Endpoints ๋ฐ Actor๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";

ํ•„์š”ํ•œ ๊ฒƒ๋“ค์„ importํ–ˆ์œผ๋‹ˆ setActorDispatcher() ๋ฉ”์„œ๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค:

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE users.username = ?
      `,
    )
    .get(identifier);
  if (user == null) return null;

  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: user.name,
    inbox: ctx.getInboxUri(identifier),
    endpoints: new Endpoints({
      sharedInbox: ctx.getInboxUri(),
    }),
    url: ctx.getActorUri(identifier),
  });
});

๋ฐ”๋€ ์ฝ”๋“œ์—์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ users ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์—ฌ ํ˜„์žฌ ์„œ๋ฒ„์— ์žˆ๋Š” ๊ณ„์ •์ด ์•„๋‹ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, GET /users/johndoe (๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ์ •ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ๊ฒฝ์šฐ) ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” ์˜ฌ๋ฐ”๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์™€ ํ•จ๊ป˜ ์‘๋‹ตํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ์™ธ์˜ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” 404 Not Found๋ฅผ ์‘๋‹ตํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Person ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ถ€๋ถ„๋„ ์–ด๋–ป๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‚˜ ์‚ดํŽด๋ด…์‹œ๋‹ค. ๋จผ์ € name ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” actors.name ์นผ๋Ÿผ์˜ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. inbox์™€ endpoints ์†์„ฑ์€ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. url ์†์„ฑ์€ ์ด ๊ณ„์ •์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด๋Š”๋ฐ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•กํ„ฐ ID์™€ ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ์ผ์น˜์‹œํ‚ค๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

๋ˆˆ์ฐ๋ฏธ๊ฐ€ ์ข‹์€ ๋ถ„๋“ค์€ ๋ˆˆ์น˜์ฑ„์…จ๊ฒ ์ง€๋งŒ, Hono์™€ Fedify ์–‘์ชฝ์—์„œ GET /users/{identifier}์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฒน์ณ์„œ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ํ•ด๋‹น ์š”์ฒญ์„ ์‹ค์ œ๋กœ ๋ณด๋‚ด๋ฉด ์–ด๋А ์ชฝ์—์„œ ์‘๋‹ตํ•˜๊ฒŒ ๋ ๊นŒ์š”? ์ •๋‹ต์€ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Accept: text/html ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Hono ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. Accept: application/activity+json ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Fedify ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต์„ ์ฃผ๋Š” ๋ฐฉ์‹์„ HTTP ๋‚ด์šฉ ํ˜‘์ƒ(content negotiation)์ด๋ผ๊ณ  ํ•˜๋ฉฐ, Fedify ์ž์ฒด์—์„œ ๋‚ด์šฉ ํ˜‘์ƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์ข€ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ๋Š”, ๋ชจ๋“  ์š”์ฒญ์€ Fedify๋ฅผ ํ•œ ๋ฒˆ ๊ฑฐ์น˜๊ฒŒ ๋˜๋ฉฐ, ActivityPub๊ณผ ๊ด€๋ จ๋œ ์š”์ฒญ์ด ์•„๋‹ ๊ฒฝ์šฐ ์—ฐ๋™๋œ ํ”„๋ ˆ์ž„์›Œํฌ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Hono์—๊ฒŒ ์š”์ฒญ์„ ๊ฑด๋‚ด์ฃผ๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

Fedify์—์„œ๋Š” ๋ชจ๋“  URI ๋ฐ URL์„ URL ์ธ์Šคํ„ด์Šค๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ทธ๋Ÿผ ํ•œ ๋ฒˆ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ํ…Œ์ŠคํŠธํ•ด ๋ณผ๊นŒ์š”?

์„œ๋ฒ„๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด์–ด ์•„๋ž˜ ๋ช…๋ น์„ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/alice

alice์ด๋ผ๋Š” ๊ณ„์ •์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์•„๊นŒ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Failed to fetch the object.
It may be a private object.  Try with -a/--authorized-fetch.

๊ทธ๋Ÿผ johndoe ๊ณ„์ •๋„ ์กฐํšŒํ•ด ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์ด์ œ๋Š” ๊ฒฐ๊ณผ๊ฐ€ ์ž˜ ๋‚˜์˜ต๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

์•”ํ˜ธ ํ‚ค ์Œ๋“ค

๊ทธ ๋‹ค์Œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์„œ๋ช…์„ ์œ„ํ•œ ์•กํ„ฐ์˜ ์•”ํ˜ธ ํ‚ค๋“ค์ž…๋‹ˆ๋‹ค. ActivityPub์€ ์•กํ„ฐ๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋Š”๋ฐ, ์ด ๋•Œ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ •๋ง๋กœ ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ๋งŒ๋“ค์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋””์ง€ํ„ธ ์„œ๋ช…์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์•กํ„ฐ๋Š” ์ง์ด ๋งž๋Š” ์ž์‹ ๋งŒ์˜ ๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค) ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ๋งŒ๋“ค์–ด ๊ฐ–๊ณ  ์žˆ๊ณ , ๊ทธ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๋„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๊ณต๊ฐœํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ๋“ค์€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ ๋ฐœ์‹ ์ž์˜ ๊ณต๊ฐœ ํ‚ค์™€ ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์„œ๋ช…์„ ๋Œ€์กฐํ•˜์—ฌ ๊ทธ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •๋ง๋กœ ๋ฐœ์‹ ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒŒ ๋งž๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ช…๊ณผ ์„œ๋ช… ๋Œ€์กฐ๋Š” Fedify๊ฐ€ ์•Œ์•„์„œ ํ•ด ์ฃผ์ง€๋งŒ, ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ณด์กดํ•˜๋Š” ๊ฒƒ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์…”์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค)๋Š” ์ด๋ฆ„์—์„œ ๋“œ๋Ÿฌ๋‚˜๋“ฏ ์„œ๋ช…ํ•  ์ฃผ์ฒด ์ด์™ธ์—๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, ๊ณต๊ฐœ ํ‚ค๋Š” ๊ทธ ์šฉ๋„ ์ž์ฒด๊ฐ€ ๊ณต๊ฐœํ•˜๊ธฐ ์œ„ํ•จ์ด๋ฏ€๋กœ ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ €์žฅํ•  keys ํ…Œ์ด๋ธ”์„ src/schema.sql์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS keys (
  user_id     INTEGER NOT NULL REFERENCES users (id),
  type        TEXT    NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
  private_key TEXT    NOT NULL CHECK (private_key <> ''),
  public_key  TEXT    NOT NULL CHECK (public_key <> ''),
  created     TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
  PRIMARY KEY (user_id, type)
);

ํ…Œ์ด๋ธ”์„ ์œ ์‹ฌํžˆ ์‚ดํŽด๋ณด๋ฉด, type ์นผ๋Ÿผ์—๋Š” ์˜ค์ง ๋‘ ์ข…๋ฅ˜์˜ ๊ฐ’๋งŒ ํ—ˆ์šฉ๋œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜๋‚˜๋Š” RSA-PKCS#1-v1.5 ํ˜•์‹์ด๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” Ed25519 ํ˜•์‹์ž…๋‹ˆ๋‹ค. (๊ฐ๊ฐ์ด ๋ฌด์—‡์„ ๋œปํ•˜๋Š”์ง€๋Š” ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ค‘์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.) ๊ธฐ๋ณธ ํ‚ค๊ฐ€ (user_id, type)์— ๊ฑธ๋ ค ์žˆ์œผ๋‹ˆ, ํ•œ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•ด ์ตœ๋Œ€ ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์ˆ˜๋Š” ์—†์ง€๋งŒ, 2024๋…„ 9์›” ํ˜„์žฌ ActivityPub ๋„คํŠธ์›Œํฌ๋Š” RSA-PKCS-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-v1.5 ํ˜•์‹๋งŒ ๋ฐ›์•„๋“ค์ด๊ณ  ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” Ed25519 ํ˜•์‹์„ ๋ฐ›์•„๋“ค์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์–‘์ชฝ ๋ชจ๋‘์™€ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ๋ชจ๋‘ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

private_key ๋ฐ public_key ์นผ๋Ÿผ์€ ๋ฌธ์ž์—ด์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์— JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์„ ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. ๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค๋ฅผ JSON์œผ๋กœ ์ธ์ฝ”๋”ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋’ค์—์„œ ์ฐจ์ฐจ ๋‹ค๋ฃจ๊ฒŒ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ keys ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

keys ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  Key ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Key {
  user_id: number;
  type: "RSASSA-PKCS1-v1_5" | "Ed25519";
  private_key: string;
  public_key: string;
  created: string;
}

ํ‚ค ์Œ ๋””์ŠคํŒจ์ฒ˜

์ด์ œ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด๊ณ  Fedify์—์„œ ์ œ๊ณต๋˜๋Š” exportJwk(), generateCryptoKeyPair(), importJwk() ํ•จ์ˆ˜๋“ค๊ณผ ์•ž์„œ ์ •์˜ํ•œ Key ํƒ€์ž…์„ importํ•ฉ์‹œ๋‹ค:

import {
  Endpoints,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ๋ถ€๋ถ„์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User & Actor>(
        `
        SELECT * FROM users
        JOIN actors ON (users.id = actors.user_id)
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    if (user == null) return null;

    const keys = await ctx.getActorKeyPairs(identifier);
    return new Person({
      id: ctx.getActorUri(identifier),
      preferredUsername: identifier,
      name: user.name,
      inbox: ctx.getInboxUri(identifier),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      url: ctx.getActorUri(identifier),
      publicKey: keys[0].cryptographicKey,
      assertionMethods: keys.map((k) => k.multikey),
    });
  })
  .setKeyPairsDispatcher(async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
      .get(identifier);
    if (user == null) return [];
    const rows = db
      .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
      .all(user.id);
    const keys = Object.fromEntries(
      rows.map((row) => [row.type, row]),
    ) as Record<Key["type"], Key>;
    const pairs: CryptoKeyPair[] = [];
    // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์›ํ•˜๋Š” ๋‘ ํ‚ค ํ˜•์‹ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์— ๋Œ€ํ•ด
    // ํ‚ค ์Œ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ:
    for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
      if (keys[keyType] == null) {
        logger.debug(
          "The user {identifier} does not have an {keyType} key; creating one...",
          { identifier, keyType },
        );
        const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
        db.prepare(
          `
          INSERT INTO keys (user_id, type, private_key, public_key)
          VALUES (?, ?, ?, ?)
          `,
        ).run(
          user.id,
          keyType,
          JSON.stringify(await exportJwk(privateKey)),
          JSON.stringify(await exportJwk(publicKey)),
        );
        pairs.push({ privateKey, publicKey });
      } else {
        pairs.push({
          privateKey: await importJwk(
            JSON.parse(keys[keyType].private_key),
            "private",
          ),
          publicKey: await importJwk(
            JSON.parse(keys[keyType].public_key),
            "public",
          ),
        });
      }
    }
    return pairs;
  });

์šฐ์„  ๊ฐ€์žฅ ๋จผ์ € ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๊ฒƒ์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์— ์—ฐ๋‹ฌ์•„ ํ˜ธ์ถœ๋˜๊ณ  ์žˆ๋Š” setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜์—์„œ ๋ฐ˜ํ™˜๋œ ํ‚ค ์Œ๋“ค์„ ๊ณ„์ •์— ์—ฐ๊ฒฐํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ‚ค ์Œ๋“ค์„ ์—ฐ๊ฒฐํ•ด์•ผ Fedify๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ•  ๋•Œ ์ž๋™์œผ๋กœ ๋“ฑ๋ก๋œ ๊ฐœ์ธ ํ‚ค๋“ค๋กœ ๋””์ง€ํ„ธ ์„œ๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

generateCryptoKeyPair() ํ•จ์ˆ˜๋Š” ์ƒˆ๋กœ์šด ๊ฐœ์ธ ํ‚ค ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜์—ฌ CryptoKeyPair ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋กœ CryptoKeyPair ํƒ€์ž…์€ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ˜•์‹์ž…๋‹ˆ๋‹ค.

exportJwk() ํ•จ์ˆ˜๋Š” CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JWK ํ˜•์‹์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ์•”ํ˜ธ ํ‚ค๋ฅผ JSON์œผ๋กœ ํ‘œํ˜„ํ•˜๋Š” ํ‘œ์ค€์ ์ธ ํ˜•์‹์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. CryptoKey๋Š” ์•”ํ˜ธ ํ‚ค๋ฅผ JavaScript ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์›น ํ‘œ์ค€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.

importJwk() ํ•จ์ˆ˜๋Š” JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„๋œ ํ‚ค๋ฅผ CryptoKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. exportJwk() ํ•จ์ˆ˜์˜ ๋ฐ˜๋Œ€๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ setActorDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ˆˆ์„ ๋Œ๋ฆฝ์‹œ๋‹ค. getActorKeyPairs()๋ผ๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์“ฐ์ด๊ณ  ์žˆ๋Š”๋ฐ, ์ด ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฆ„๊ณผ ๊ฐ™์ด ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์€ ๋ฐ”๋กœ ์•ž์—์„œ ์‚ดํŽด๋ณธ setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ถˆ๋Ÿฌ์˜จ ๋ฐ”๋กœ ๊ทธ ํ‚ค ์Œ๋“ค์ž…๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” RSA-PKCS-v1.5์™€ Ed25519 ํ˜•์‹์œผ๋กœ ๋œ ๋‘ ์Œ์˜ ํ‚ค๋ฅผ ๋ถˆ๋Ÿฌ์™”์œผ๋ฏ€๋กœ, getActorKeyPairs() ๋ฉ”์„œ๋“œ๋Š” ๋‘ ํ‚ค ์Œ์˜ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ฐฐ์—ด์˜ ์›์†Œ๋Š” ํ‚ค ์Œ์„ ์—ฌ๋Ÿฌ ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด์ธ๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

interface ActorKeyPair {
  privateKey: CryptoKey;              // ๊ฐœ์ธ ํ‚ค
  publicKey: CryptoKey;               // ๊ณต๊ฐœ ํ‚ค
  keyId: URL;                         // ํ‚ค์˜ ๊ณ ์œ  ์‹๋ณ„ URI
  cryptographicKey: CryptographicKey; // ๊ณต๊ฐœ ํ‚ค์˜ ๋‹ค๋ฅธ ํ˜•์‹
  multikey: Multikey;                 // ๊ณต๊ฐœ ํ‚ค์˜ ๋˜ ๋‹ค๋ฅธ ํ˜•์‹
}

CryptoKey์™€ CryptographicKey์™€ Multikey๊ฐ€ ๊ฐ๊ฐ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ์ง€, ์™œ ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ ํ˜•์‹์ด ์žˆ์–ด์•ผ ํ•˜๋Š”์ง€๋Š” ์ด ์ž๋ฆฌ์—์„œ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋ณต์žกํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ง€๊ธˆ์€ Person ๊ฐ์ฒด๋ฅผ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ publicKey ์†์„ฑ์€ CryptographicKey ํ˜•์‹์„ ๋ฐ›๊ณ  assertionMethods ์†์„ฑ์€ MultiKey[] (Multikey์˜ ๋ฐฐ์—ด์„ TypeScript์—์„œ ์ด๋ ‡๊ฒŒ ํ‘œ๊ธฐ) ํ˜•์‹์„ ๋ฐ›๋Š”๋‹ค๋Š” ๊ฒƒ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•ฉ์‹œ๋‹ค.

๊ทธ๋‚˜์ €๋‚˜, Person ๊ฐ์ฒด์—๋Š” ์™œ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๊ฐ–๋Š” ์†์„ฑ์ด publicKey์™€ assertionMethods๋กœ ๋‘ ๊ฐœ๋‚˜ ์žˆ์„๊นŒ์š”? ActivityPub์—๋Š” ์›๋ž˜ publicKey ์†์„ฑ๋งŒ ์žˆ์—ˆ์ง€๋งŒ, ๋‚˜์ค‘์— ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก assertionMethods ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-v1.5 ํ‚ค ์Œ์ด, ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— Ed25519 ํ‚ค ์Œ์ด ๋“ค์–ด๊ฐ).

ํŒ

์‚ฌ์‹ค publicKey ์†์„ฑ๋„ ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋‹ด์„ ์ˆ˜๋Š” ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด๋“ค์ด ์ด๋ฏธ publicKey ์†์„ฑ์—๋Š” ๋‹จ ํ•˜๋‚˜์˜ ํ‚ค๋งŒ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ด๋ผ๋Š” ์ „์ œ ํ•˜์— ๊ตฌํ˜„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜ค์ž‘๋™ํ•  ๋•Œ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assertionMethods๋ผ๋Š” ์ƒˆ๋กœ์šด ์†์„ฑ์ด ์ œ์•ˆ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ด์— ๊ด€ํ•ด ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์‹  ๋ถ„๋“ค์€ FEP-521a ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ…Œ์ŠคํŠธ

์ž, ์•กํ„ฐ ๊ฐ์ฒด์— ์•”ํ˜ธ ํ‚ค๋“ค์„ ๋“ฑ๋กํ–ˆ์œผ๋ฏ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

fedify lookup http://localhost:8000/users/johndoe

์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  publicKey: CryptographicKey {
    id: URL "http://localhost:8000/users/johndoe#main-key",
    owner: URL "http://localhost:8000/users/johndoe",
    publicKey: CryptoKey {
      type: "public",
      extractable: true,
      algorithm: {
        name: "RSASSA-PKCS1-v1_5",
        modulusLength: 4096,
        publicExponent: Uint8Array(3) [ 1, 0, 1 ],
        hash: { name: "SHA-256" }
      },
      usages: [ "verify" ]
    }
  },
  assertionMethods: [
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#main-key",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: {
          name: "RSASSA-PKCS1-v1_5",
          modulusLength: 4096,
          publicExponent: Uint8Array(3) [ 1, 0, 1 ],
          hash: { name: "SHA-256" }
        },
        usages: [ "verify" ]
      }
    },
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#key-2",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: { name: "Ed25519" },
        usages: [ "verify" ]
      }
    }
  ],
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

Person ๊ฐ์ฒด์˜ publicKey ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ Multikey ๊ฐ์ฒด๊ฐ€ ๋‘˜ ๋“ค์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Mastodon๊ณผ ์—ฐ๋™

์ด์ œ ์‹ค์ œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค.

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ

์•„์‰ฝ๊ฒŒ๋„ ํ˜„์žฌ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ๋งˆ๋‹ค ์–ด๋”˜๊ฐ€์— ๋ฐฐํฌํ•ด์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํŽธํ•˜๊ฒ ์ฃ . ๋ฐฐํฌํ•˜์ง€ ์•Š๊ณ  ๋ฐ”๋กœ ์ธํ„ฐ๋„ท์— ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๋…ธ์ถœํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์–ผ๋งˆ๋‚˜ ์ข‹์„๊นŒ์š”?

์—ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋Ÿด ๋•Œ ์“ฐ๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ํ„ฐ๋ฏธ๋„์—์„œ ์ƒˆ ํƒญ์„ ์—ฐ ๋’ค, ์ด ๋ช…๋ น์–ด ๋’ค์— ๋กœ์ปฌ ์„œ๋ฒ„์˜ ํฌํŠธ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค:

fedify tunnel 8000

๊ทธ๋Ÿฌ๋ฉด ํ•œ ๋ฒˆ ์“ฐ๊ณ  ๋ฒ„๋ฆด ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๋งŒ๋“ค์–ด์„œ ๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์ค‘๊ณ„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Your local server at 8000 is now publicly accessible:

https://temp-address.serveo.net/

Press ^C to close the tunnel.

๋ฌผ๋ก , ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ๋Š” ์œ„ URL๊ณผ๋Š” ๋‹ค๋ฅธ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๊ณ ์œ ํ•œ URL์ด ์ถœ๋ ฅ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/users/johndoe(์—ฌ๋Ÿฌ๋ถ„์˜ ๊ณ ์œ  ์ž„์‹œ ๋„๋ฉ”์ธ์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ์—ด์–ด์„œ ์ž˜ ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์œผ๋กœ ๋…ธ์ถœ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์œ„ ์›น ํŽ˜์ด์ง€์— ๋ณด์ด๋Š” ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ณต์‚ฌํ•œ ๋’ค, Mastodon์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋ณด๋Š” ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ๊นŒ์ง€์ž…๋‹ˆ๋‹ค. ์•„์ง ํŒ”๋กœ๋Š” ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ์‹œ๋„ํ•˜์ง€ ๋งˆ์„ธ์š”! ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•  ์ˆ˜ ์žˆ์œผ๋ ค๋ฉด, ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์•ˆ๋‚ด

fedify tunnel ๋ช…๋ น์€ ํ•œ๋™์•ˆ ์“ฐ์ด์ง€ ์•Š์œผ๋ฉด ์ €์ ˆ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊น๋‹ˆ๋‹ค. ๊ทธ๋Ÿด ๋•Œ๋Š”, Ctrl+C ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ˆ ๋‹ค์Œ, fedify tunnel 8000 ๋ช…๋ น์„ ๋‹ค์‹œ ์ณ์„œ ์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ์„ ๋งบ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ

ActivityPub์—์„œ ์ˆ˜์‹ ํ•จ(inbox)์€ ์•กํ„ฐ๊ฐ€ ๋‹ค๋ฅธ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์•กํ„ฐ๋Š” ์ž์‹ ์˜ ์ˆ˜์‹ ํ•จ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋Š” HTTP POST ์š”์ฒญ์„ ํ†ตํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฑฐ๋‚˜, ๊ธ€์„ ์“ฐ๊ฑฐ๋‚˜, ๋Œ“๊ธ€์„ ๋‹ค๋Š” ๋“ฑ์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ•  ๋•Œ ํ•ด๋‹น ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์ˆ˜์‹ ์ž์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์˜จ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ ์ ˆํžˆ ์‘๋‹ตํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ  ์—ฐํ•ฉ ๋„คํŠธ์›Œํฌ์˜ ์ผ๋ถ€๋กœ ๊ธฐ๋Šฅํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์€ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์ง€๊ธˆ์€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

์ž์‹ ์„ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์›Œ)๊ณผ ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์ž‰)์„ ๋‹ด๊ธฐ ์œ„ํ•ด src/schema.sql ํŒŒ์ผ์— follows ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS follows (
  following_id INTEGER          REFERENCES actors (id),
  follower_id  INTEGER          REFERENCES actors (id),
  created      TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                CHECK (created <> ''),
  PRIMARY KEY (following_id, follower_id)
);

์ด๋ฒˆ์—๋„ src/schema.sql์„ ์‹คํ–‰ํ•˜์—ฌ follows ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

src/schema.ts ํŒŒ์ผ์„ ์—ด๊ณ  follows ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…๋„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Follow {
  following_id: number;
  follower_id: number;
  created: string;
}

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

์ด์ œ ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ์‹ค์€ ์•ž์„œ ์ด๋ฏธ src/federation.ts ํŒŒ์ผ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋ฐ” ์žˆ์Šต๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

์œ„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ์— ์•ž์„œ, Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Accept ๋ฐ Follow ํด๋ž˜์Šค์™€ getActorHandle() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  setInboxListeners() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    if (follow.objectId == null) {
      logger.debug("The Follow object does not have an object: {follow}", {
        follow,
      });
      return;
    }
    const object = ctx.parseUri(follow.objectId);
    if (object == null || object.type !== "actor") {
      logger.debug("The Follow object's object is not an actor: {follow}", {
        follow,
      });
      return;
    }
    const follower = await follow.getActor();
    if (follower?.id == null || follower.inboxId == null) {
      logger.debug("The Follow object does not have an actor: {follow}", {
        follow,
      });
      return;
    }
    const followingId = db
      .prepare<unknown[], Actor>(
        `
        SELECT * FROM actors
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(object.identifier)?.id;
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
      return;
    }
    const followerId = db
      .prepare<unknown[], Actor>(
        `
        -- ํŒ”๋กœ์›Œ ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        follower.id.href,
        await getActorHandle(follower),
        follower.name?.toString(),
        follower.inboxId.href,
        follower.endpoints?.sharedInbox?.href,
        follower.url?.href,
      )?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    const accept = new Accept({
      actor: follow.objectId,
      to: follow.actorId,
      object: follow,
    });
    await ctx.sendActivity(object, follower, accept);
  });

์ž, ์ฝ”๋“œ๋ฅผ ์ฐฌ์ฐฌํžˆ ์‚ดํŽด๋ด…์‹œ๋‹ค. on() ๋ฉ”์„œ๋“œ๋Š” ํŠน์ •ํ•œ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ๋œปํ•˜๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํŒ”๋กœ์›Œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ ๋’ค, ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ ์ˆ˜๋ฝ์„ ๋œปํ•˜๋Š” Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋‚ด๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

follow.objectId์—๋Š” ํŒ”๋กœ ๋Œ€์ƒ์ธ ์•กํ„ฐ์˜ URI๊ฐ€ ๋“ค์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด ์•ˆ์— ๋“  URI๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

getActorHandle() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ์•กํ„ฐ ๊ฐ์ฒด๋กœ๋ถ€ํ„ฐ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๊ตฌํ•˜์—ฌ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์•„์ง ์—†๋‹ค๋ฉด ๋จผ์ € ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ๋’ค, follows ํ…Œ์ด๋ธ”์— ํŒ”๋กœ์›Œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋ก์ด ์™„๋ฃŒ๋˜๋ฉด, sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ฒซ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐœ์‹ ์ž, ๋‘˜์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ˆ˜์‹ ์ž, ์…‹์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ผ ์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy

์ž, ๊ทธ๋Ÿผ ํŒ”๋กœ ์š”์ฒญ์ด ์ œ๋Œ€๋กœ ์ˆ˜์‹ ๋˜๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

๋ณดํ†ต์˜ Mastodon ์„œ๋ฒ„์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๊ดœ์ฐฎ๊ธด ํ•˜์ง€๋งŒ, ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ์˜ค๊ฐ€๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ActivityPub.Academy ์„œ๋ฒ„๋ฅผ ์ด์šฉํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ActivityPub.Academy๋Š” ๊ต์œก ๋ฐ ๋””๋ฒ„๊น… ์šฉ๋„์˜ ํŠน์ˆ˜ํ•œ Mastodon ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ show source๋ฅผ ๋ˆ„๋ฅธ ํ™”๋ฉด

ํ…Œ์ŠคํŠธ

์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ๊ฑธ ํ™•์ธํ–ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ์ €ํฌ๊ฐ€ ์ง  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๋จผ์ € follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€ ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค (๋ฌผ๋ก , ์‹œ๊ฐ์€ ๋‹ค๋ฅด๊ฒ ์ฃ ?):

following_id follower_id created
1 2 2024-09-01 10:19:41

๊ณผ์—ฐ actors ํ…Œ์ด๋ธ”์—๋„ ์ƒˆ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id user_id uri handle name inbox_url shared_inbox_url url created
2 https://activitypub.academy/users/dobussia_dovornath @dobussia_dovornath@activitypub.academy Dobussia Dovornath https://activitypub.academy/users/dobussia_dovornath/inbox https://activitypub.academy/inbox https://activitypub.academy/@dobussia_dovornath 2024-09-01 10:19:41

๋‹ค์‹œ, ActivityPub.Academy์˜ Activity Log๋ฅผ ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์—์„œ ๋ณด๋‚ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์œ„์™€ ๊ฐ™์ด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
  "type": "Undo",
  "actor": "https://activitypub.academy/users/dobussia_dovornath",
  "object": {
    "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
    "type": "Follow",
    "actor": "https://activitypub.academy/users/dobussia_dovornath",
    "object": "https://temp-address.serveo.net/users/johndoe"
  }
}

์œ„ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ์•„๊นŒ ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์™”๋˜ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ˆ˜์‹ ํ•จ์—์„œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์•„๋ฌด ๊ฒƒ๋„ ์ •์˜ํ•˜์ง€ ์•Š์•˜๊ธฐ์— ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํŒ”๋กœ ์ทจ์†Œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Undo ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  on(Follow, ...) ๋’ค์— ์—ฐ๋‹ฌ์•„ on(Undo, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต๋จ ...
  })
  .on(Undo, async (ctx, undo) => {
    const object = await undo.getObject();
    if (!(object instanceof Follow)) return;
    if (undo.actorId == null || object.objectId == null) return;
    const parsed = ctx.parseUri(object.objectId);
    if (parsed == null || parsed.type !== "actor") return;
    db.prepare(
      `
      DELETE FROM follows
      WHERE following_id = (
        SELECT actors.id
        FROM actors
        JOIN users ON actors.user_id = users.id
        WHERE users.username = ?
      ) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
      `,
    ).run(parsed.identifier, undo.actorId.href);
  });

์ด๋ฒˆ์—๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋ณด๋‹ค ์ฝ”๋“œ๊ฐ€ ์งง์Šต๋‹ˆ๋‹ค. Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ๋“  ๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์ธ์ง€ ํ™•์ธํ•œ ๋’ค, parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์ทจ์†Œํ•˜๋ ค๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ํŒ”๋กœ ๋Œ€์ƒ์ด ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•˜๊ณ , follows ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์•„๊นŒ ActivityPub.Academy์—์„œ ์ด๋ฏธ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ฒ„๋ ค์„œ ํ•œ ๋ฒˆ ๋” ์–ธํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์–ด์ฉ” ์ˆ˜ ์—†์ด ๋‹ค์‹œ ํŒ”๋กœํ•œ ๋’ค, ์–ธํŒ”๋กœํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทธ์— ์•ž์„œ, follows ํ…Œ์ด๋ธ”์„ ๋น„์›Œ ์ค„ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํŒ”๋กœ ์š”์ฒญ์ด ์™”์„ ๋•Œ ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฏ€๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

sqlite3 ๋ช…๋ น์–ด๋ฅผ ์ด์šฉํ•ด follows ํ…Œ์ด๋ธ”์„ ๋น„์›์‹œ๋‹ค:

echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

following_id follower_id created
1 2 2024-09-02 01:05:17

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ sqlite3 ๋ช…๋ น์œผ๋กœ ๋ณด๋Š” ๊ฑด ์„ฑ๊ฐ€์‹œ๋‹ˆ, ์›น์œผ๋กœ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ์‹œ๋‹ค.

์šฐ์„  src/views.tsx ํŒŒ์ผ์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. Actor ํƒ€์ž…์„ importํ•ด์ฃผ์„ธ์š”:

import type { Actor } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <FollowerList> ์ปดํฌ๋„ŒํŠธ์™€ <ActorLink> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowerListProps {
  followers: Actor[];
}

export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
  <>
    <h2>Followers</h2>
    <ul>
      {followers.map((follower) => (
        <li key={follower.id}>
          <ActorLink actor={follower} />
        </li>
      ))}
    </ul>
  </>
);

export interface ActorLinkProps {
  actor: Actor;
}

export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
  const href = actor.url ?? actor.uri;
  return actor.name == null ? (
    <a href={href} class="secondary">
      {actor.handle}
    </a>
  ) : (
    <>
      <a href={href}>{actor.name}</a>{" "}
      <small>
        (
        <a href={href} class="secondary">
          {actor.handle}
        </a>
        )
      </small>
    </>
  );
};

<ActorLink> ์ปดํฌ๋„ŒํŠธ๋Š” ํ•˜๋‚˜์˜ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋„ŒํŠธ๋Š” <ActorList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ž…๋‹ˆ๋‹ค. ๋ณด๋‹ค์‹œํ”ผ JSX์—๋Š” ์กฐ๊ฑด๋ฌธ์ด๋‚˜ ๋ฐ˜๋ณต๋ฌธ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ผํ•ญ ์—ฐ์‚ฐ์ž์™€ Array.map() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ญ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowerList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/followers์— ๋Œ€ํ•œ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/followers", async (c) => {
  const followers = db
    .prepare<unknown[], Actor>(
      `
      SELECT followers.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = following.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowerList followers={followers} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ, ์ž˜ ๋ณด์ด๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ํŒ”๋กœ์›Œ๊ฐ€ ์žˆ์–ด์•ผ ํ• ํ…Œ๋‹ˆ, fedify tunnel์„ ์ผ  ์ฑ„๋กœ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„๋‚˜ ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•ฉ์‹œ๋‹ค. ํŒ”๋กœ ์š”์ฒญ์ด ์ˆ˜๋ฝ๋œ ๋’ค ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/followers ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

ํŒ”๋กœ์›Œ ๋ชฉ๋ก ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ํŒ”๋กœ์›Œ ์ˆ˜๋„ ํ‘œ์‹œํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ๋‹ค์‹œ ์—ด๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

ProfileProps์—๋Š” ๋‘ ๊ฐœ์˜ ํ”„๋กญ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. followers๋Š” ๋ง ๊ทธ๋Œ€๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋‹ด๋Š” ํ”„๋กญ์ž…๋‹ˆ๋‹ค. username์€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์œผ๋กœ ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์œ„ํ•ด URL์— ๋“ค์–ด๊ฐˆ ์•„์ด๋””๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ src/app.tsx ํŒŒ์ผ๋กœ ๋Œ์•„๊ฐ€, GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      JOIN actors ON follows.following_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        followers={followers}
      />
    </Layout>,
  );
});

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ˆ์˜ follows ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋ฅผ ์„ธ๋Š” SQL์ด ์ถ”๊ฐ€๋˜์—ˆ๊ตฐ์š”. ์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜

๊ทธ๋Ÿฐ๋ฐ ํ•œ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ActivityPub.Academy๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด๋ด…์‹œ๋‹ค. (์กฐํšŒํ•˜๋Š” ๋ฒ•์€ ์ด์ œ ๋‹ค ์•„์‹œ์ฃ ? ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ๋œ ์ƒํƒœ์—์„œ, ์•กํ„ฐ ํ•ธ๋“ค์„ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ์น˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.) Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Mastodon์—์„œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

๋ฐ”๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ 0์œผ๋กœ ๋‚˜์˜จ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ActivityPub์„ ํ†ตํ•ด ๋…ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋ ค๋ฉด ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Recipient ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์•„๋ž˜์ชฝ์— ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setFollowersDispatcher(
    "/users/{identifier}/followers",
    (ctx, identifier, cursor) => {
      const followers = db
        .prepare<unknown[], Actor>(
          `
          SELECT followers.*
          FROM follows
          JOIN actors AS followers ON follows.follower_id = followers.id
          JOIN actors AS following ON follows.following_id = following.id
          JOIN users ON users.id = following.user_id
          WHERE users.username = ?
          ORDER BY follows.created DESC
          `,
        )
        .all(identifier);
      const items: Recipient[] = followers.map((f) => ({
        id: new URL(f.uri),
        inboxId: new URL(f.inbox_url),
        endpoints:
          f.shared_inbox_url == null
            ? null
            : { sharedInbox: new URL(f.shared_inbox_url) },
      }));
      return { items };
    },
  )
  .setCounter((ctx, identifier) => {
    const result = db
      .prepare<unknown[], { cnt: number }>(
        `
        SELECT count(*) AS cnt
        FROM follows
        JOIN actors ON actors.id = follows.following_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    return result == null ? 0 : result.cnt;
  });

setFollowersDispatcher() ๋ฉ”์„œ๋“œ์—์„œ๋Š” GET /users/{identifier}/followers ์š”์ฒญ์ด ์™”์„ ๋•Œ ์‘๋‹ตํ•  ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. SQL์ด ์กฐ๊ธˆ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ •๋ฆฌํ•˜์ž๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. items์—๋Š” Recipient ๊ฐ์ฒด๋“ค์„ ๋‹ด๋Š”๋ฐ, Recipient ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

export interface Recipient {
  readonly id: URL | null;
  readonly inboxId: URL | null;
  readonly endpoints?: {
    sharedInbox: URL | null;
  } | null;
}

id ์†์„ฑ์—๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  IRI๊ฐ€ ๋“ค์–ด๊ฐ€๊ณ , inboxId์—๋Š” ์•กํ„ฐ์˜ ๊ฐœ์ธ ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. endpoints.sharedInbox์—๋Š” ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” actors ํ…Œ์ด๋ธ”์— ๊ทธ ๋ชจ๋“  ์ •๋ณด๋ฅผ ๋‹ค ๋‹ด๊ณ  ์žˆ์œผ๋‹ˆ, ํ•ด๋‹น ์ •๋ณด๋“ค๋กœ items ๋ฐฐ์—ด์„ ์ฑ„์›Œ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

setCounter() ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์˜ ์ „์ฒด ์ˆ˜๋Ÿ‰์„ ๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋„ SQL์ด ์กฐ๊ธˆ ๋ณต์žกํ•˜๊ธด ํ•˜์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, fedify lookup ๋ช…๋ น์„ ์‚ฌ์šฉํ•ฉ์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe/followers

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
OrderedCollection {
  totalItems: 1,
  items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}

๊ทธ๋Ÿฐ๋ฐ, ์ด๋ ‡๊ฒŒ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ค์–ด ๋†“๊ธฐ๋งŒ ํ•ด์„œ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์–ด๋”” ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์— ๋งํฌ๋ฅผ ๊ฑธ์–ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ... ์ƒ๋žต ...
    return new Person({
      // ... ์ƒ๋žต ...
      followers: ctx.getFollowersUri(identifier), 
    });
  })

์•กํ„ฐ๋„ fedify lookup์œผ๋กœ ์กฐํšŒํ•˜์—ฌ ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์•„๋ž˜์™€ ๊ฐ™์ด ๊ฒฐ๊ณผ์— "followers" ์†์„ฑ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  ... ์ƒ๋žต ...
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  followers: URL "http://localhost:8000/users/johndoe/followers",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋‹ค์‹œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํŒ”๋กœ์›Œ ์ˆ˜๋Š” ์—ฌ์ „ํžˆ 0์œผ๋กœ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . ์ด๋Š” Mastodon์ด ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์บ์‹œ(cache)ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๊ธด ํ•˜์ง€๋งŒ F5 ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์‰ฝ์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค:

  • ํ•œ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์€ ์ผ์ฃผ์ผ์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Mastodon์€ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์บ์‹œ๋ฅผ ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์ดํ›„ 7์ผ์ด ์ง€๋‚  ๋•Œ ๋‚ ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ๋˜ ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์€ Update ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‚ ๋ฆฌ๋Š” ๊ฒƒ์ธ๋ฐ, ๊ท€์ฐฎ์€ ์ฝ”๋”ฉ์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋‹ˆ๋ฉด ์•„์ง ์บ์‹œ๊ฐ€ ๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•ด ๋ณด๋Š” ๊ฒƒ๋„ ํ•œ ๋ฐฉ๋ฒ•์ด๊ฒ ์ฃ .
  • ๋งˆ์ง€๋ง‰ ๋ฐฉ๋ฒ•์€ fedify tunnel์„ ๊ป๋‹ค ์ผœ์„œ ์ƒˆ๋กœ์šด ์ž„์‹œ ๋„๋ฉ”์ธ์„ ํ• ๋‹น ๋ฐ›๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ •ํ™•ํ•œ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์ง์ ‘ ํ™•์ธํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์ œ๊ฐ€ ๋‚˜์—ดํ•œ ๋ฐฉ๋ฒ•๋“ค ์ค‘ ํ•˜๋‚˜๋ฅผ ์‹œ๋„ํ•ด ๋ณด์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ

์ž, ์ด์ œ ๋“œ๋””์–ด ๊ฒŒ์‹œ๋ฌผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์™”์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ๋ธ”๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ž‘์„ฑ๋œ ๊ฒŒ์‹œ๋ฌผ๋„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์—ผ๋‘์— ๋‘๊ณ  ์„ค๊ณ„ํ•ด ๋ด…์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋ฐ”๋กœ posts ํ…Œ์ด๋ธ”๋ถ€ํ„ฐ ๋งŒ๋“ญ์‹œ๋‹ค. src/schema.sql ํŒŒ์ผ์„ ์—ด์–ด ์•„๋ž˜ SQL์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS posts (
  id       INTEGER NOT NULL PRIMARY KEY,
  uri      TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  actor_id INTEGER NOT NULL REFERENCES actors (id),
  content  TEXT    NOT NULL,
  url      TEXT             CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
  created  TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
  • id ์นผ๋Ÿผ์€ ํ…Œ์ด๋ธ”์˜ ๊ธฐ๋ณธ ํ‚ค์ž…๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•ž์„œ ๋งํ–ˆ๋‹ค์‹œํ”ผ ActivityPub ๊ฐ์ฒด๋Š” ๋ชจ๋‘ ๊ณ ์œ ํ•œ URI๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • actor_id ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • content ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์—๋Š” ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œ์‹œํ•˜๋Š” URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ URI์™€ ์›น ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๋Š” ํŽ˜์ด์ง€์˜ URL์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„ ์นผ๋Ÿผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋น„์–ด ์žˆ์„ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์‹œ๊ฐ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.

SQL์„ ์‹คํ–‰ํ•˜์—ฌ posts ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

posts ํ…Œ์ด๋ธ”์— ์ €์žฅ๋  ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•˜๋Š” Post ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Post {
  id: number;
  uri: string;
  actor_id: number;
  content: string;
  url: string | null;
  created: string;
}

์ฒซ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์–‘์‹์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ๊ฒ ์ฃ ? ๊ทธ๋Ÿฌ๊ณ  ๋ณด๋‹ˆ, ์•„์ง๊นŒ์ง€ ์ฒซ ํŽ˜์ด์ง€๋„ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ฒซ ํŽ˜์ด์ง€์— ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      <h1>{user.name}'s microblog</h1>
      <p>
        <a href={`/users/${user.username}`}>{user.name}'s profile</a>
      </p>
    </hgroup>
    <form method="post" action={`/users/${user.username}/posts`}>
      <fieldset>
        <label>
          <textarea name="content" required={true} placeholder="What's up?" />
        </label>
      </fieldset>
      <input type="submit" value="Post" />
    </form>
  </>
);

๊ทธ ๋‹ค์Œ์—๋Š” src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ์ด๋ฏธ ์žˆ๋Š” GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ:

app.get("/", (c) => c.text("Hello, Fedify!"));

์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์ณ์ค๋‹ˆ๋‹ค:

app.get("/", (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT users.*, actors.*
      FROM users
      JOIN actors ON users.id = actors.user_id
      LIMIT 1
      `,
    )
    .get();
  if (user == null) return c.redirect("/setup");

  return c.html(
    <Layout>
      <Home user={user} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด, ํ•œ ๋ฒˆ ์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ๋‚˜์˜ค๋‚˜ ํ™•์ธํ•ฉ์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค:

์ฒซ ํŽ˜์ด์ง€

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ posts ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์œ„ ์ฝ”๋“œ๋Š” ์•„์ง ๋ณ„ ์—ญํ• ์„ ํ•˜์ง„ ์•Š์ง€๋งŒ, ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ ํ˜•์‹์„ ์ •ํ•˜๋Š” ๋ฐ์— ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๋‚˜์ค‘์— ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ActivityPub์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์˜ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ์ฃผ๊ณ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ‰๋ฌธ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ ๋ฐ›์€ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, <, >์™€ ๊ฐ™์€ ๋ฌธ์ž๋“ค์„ HTML์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ HTML ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” stringify-entities ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add stringify-entities

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค.

import { stringifyEntities } from "stringify-entities";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  const username = c.req.param("username");
  const actor = db
    .prepare<unknown[], Actor>(
      `
      SELECT actors.*
      FROM actors
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ?
      `,
    )
    .get(username);
  if (actor == null) return c.redirect("/setup");
  const form = await c.req.formData();
  const content = form.get("content")?.toString();
  if (content == null || content.trim() === "") {
    return c.text("Content is required", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const url: string | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return url;
  })();
  if (url == null) return c.text("Failed to create post", 500);
  return c.redirect(url);
});

ํ‰๋ฒ”ํ•˜๊ฒŒ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์ฝ”๋“œ์ด๊ธด ํ•˜์ง€๋งŒ ํ•œ ๊ฐ€์ง€ ํŠน์ดํ•œ ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œํ˜„ํ•˜๋Š” ActivityPub ๊ฐ์ฒด์˜ URI๋ฅผ ๊ตฌํ•˜๋ ค๋ฉด posts.id๊ฐ€ ๋จผ์ € ๊ฒฐ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, posts.uri ์นผ๋Ÿผ์— https://localhost/๋ผ๋Š” ์ž„์‹œ URI๋ฅผ ๋จผ์ € ์ง‘์–ด ๋„ฃ์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค, ๊ฒฐ์ •๋œ posts.id๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ getObjectUri() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ URI๋ฅผ ๊ตฌํ•ด์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ฐ ๋’ค, ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ์ค‘

Post ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ฉด, ์•ˆํƒ€๊น๊ฒŒ๋„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

404 Not Found

์™œ๋ƒํ•˜๋ฉด ๊ฒŒ์‹œ๋ฌผ ํผ๋จธ๋งํฌ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์•„์ง ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ posts ํ…Œ์ด๋ธ”์—๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋งŒ๋“ค์–ด์กŒ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

๊ทธ๋Ÿผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๋‚˜ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

id uri actor_id content url created
1 http://localhost:8000/users/johndoe/posts/1 1 It's my first post! http://localhost:8000/users/johndoe/posts/1 2024-09-02 08:10:55

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ํ›„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚˜์ง€ ์•Š๋„๋ก, ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด Post ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <PostPage> ์ปดํฌ๋„ŒํŠธ ๋ฐ <PostView> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

export interface PostViewProps {
  post: Post & Actor;
}

export const PostView: FC<PostViewProps> = ({ post }) => (
  <article>
    <header>
      <ActorLink actor={post} />
    </header>
    {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
    <footer>
      <a href={post.url ?? post.uri}>
        <time datetime={new Date(post.created).toISOString()}>
          {post.created}
        </time>
      </a>
    </footer>
  </article>
);

์ด์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์™€ <PostPage> ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  ์•ž์„œ ์ •์˜ํ•œ <PostPage> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  const post = db
    .prepare<unknown[], Post & Actor & User>(
      `
      SELECT users.*, actors.*, posts.*
      FROM posts
      JOIN actors ON actors.id = posts.actor_id
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ? AND posts.id = ?
      `,
    )
    .get(c.req.param("username"), c.req.param("id"));
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      WHERE follows.following_id = ?
      `,
    )
    .get(post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์•„๊นŒ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฌ๋˜ http://localhost:8000/users/johndoe/posts/1 ํŽ˜์ด์ง€๋ฅผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

Note ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜

๊ทธ๋Ÿผ ์ด์ œ ๊ฒŒ์‹œ๋ฌผ์„ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ๋จผ์ € fedify tunnel์„ ์ด์šฉํ•˜์—ฌ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๊ทธ ์ƒํƒœ์—์„œ, Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ธ€์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

๋นˆ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์•ˆํƒ€๊น๊ฒŒ๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด ํ˜•์‹์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด๋กœ ๋…ธ์ถœํ•ด ๋ด…์‹œ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Fedify์—์„œ ์‹œ๊ฐ์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ๋Š” Temporal API๊ฐ€ ์•„์ง Node.js์— ๋‚ด์žฅ๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฅผ ํด๋ฆฌํ•„(polyfill)ํ•ด์ฃผ๋Š” @js-temporal/polyfill ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add @js-temporal/polyfill

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Temporal } from "@js-temporal/polyfill";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” PUBLIC_COLLECTION ์ƒ์ˆ˜๋„ importํ•ฉ๋‹ˆ๋‹ค.

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";
import type {
  Actor,
  Key,
  Post,
  User,
} from "./schema.ts";

๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ์˜ ๊ฒŒ์‹œ๋ฌผ์ฒ˜๋Ÿผ ์งง์€ ๊ธ€์€ ActivityPub์—์„œ ๋ณดํ†ต Note๋กœ ํ‘œํ˜„๋ฉ๋‹ˆ๋‹ค. Note ํด๋ž˜์Šค์— ๋Œ€ํ•œ ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๋Š” ์ด๋ฏธ ๋นˆ ๊ตฌํ˜„์ด๋‚˜๋งˆ ๋งŒ๋“ค์–ด ๋‘์—ˆ์—ˆ์ฃ :

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์ด๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    const post = db
      .prepare<unknown[], Post>(
        `
        SELECT posts.*
        FROM posts
        JOIN actors ON actors.id = posts.actor_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ? AND posts.id = ?
        `,
      )
      .get(values.identifier, values.id);
    if (post == null) return null;
    return new Note({
      id: ctx.getObjectUri(Note, values),
      attribution: ctx.getActorUri(values.identifier),
      to: PUBLIC_COLLECTION,
      cc: ctx.getFollowersUri(values.identifier),
      content: post.content,
      mediaType: "text/html",
      published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
      url: ctx.getObjectUri(Note, values),
    });
  },
);

Note ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ฑ„์›Œ์ง€๋Š” ์†์„ฑ ๊ฐ’๋“ค์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค:

  • attribution ์†์„ฑ์— ctx.getActorUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์˜ ์ž‘์„ฑ์ž๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • to ์†์„ฑ์— PUBLIC_COLLECTION์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ์ „์ฒด ๊ณต๊ฐœ ๊ฒŒ์‹œ๋ฌผ์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • cc ์†์„ฑ์— ctx.getFollowersUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ, ์ด ์ž์ฒด๋กœ๋Š” ํฐ ์˜๋ฏธ๋Š” ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ ํ•œ ๋ฒˆ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ๋ฐœ์‹ 

ํ•˜์ง€๋งŒ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœ ํ•ด๋„, ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด Mastodon ํƒ€์ž„๋ผ์ธ์— ์˜ฌ๋ผ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด Mastodon์ด ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์•Œ์•„์„œ ๋ฐ›์•„๊ฐ€๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์ชฝ์—์„œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜์—ฌ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ ์ƒ์„ฑ์‹œ์— Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Create, Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  // ... ์ƒ๋žต ...
  const ctx = fedi.createContext(c.req.raw, undefined);
  const post: Post | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return post;
  })();
  if (post == null) return c.text("Failed to create post", 500);
  const noteArgs = { identifier: username, id: post.id.toString() };
  const note = await ctx.getObject(Note, noteArgs);
  await ctx.sendActivity(
    { identifier: username },
    "followers",
    new Create({
      id: new URL("#activity", note?.id ?? undefined),
      object: note,
      actors: note?.attributionIds,
      tos: note?.toIds,
      ccs: note?.ccIds,
    }),
  );
  return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});

getObject() ๋ฉ”์„œ๋“œ๋Š” ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” Note ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ object ์†์„ฑ์— ๋„ฃ์Šต๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ˆ˜์‹ ์ž๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” tos (to์˜ ๋ณต์ˆ˜ํ˜•) ๋ฐ ccs (cc์˜ ๋ณต์ˆ˜ํ˜•) ์†์„ฑ์€ Note ๊ฐ์ฒด์™€ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ id๋Š” ์ž„์˜์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ์ง€์–ด๋‚ด์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด์˜ id ์†์„ฑ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URI๊ฐ€ ๋“ค์–ด๊ฐˆ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ๊ณ ์œ ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

sendActivity() ๋ฉ”์„œ๋“œ์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์—๋Š” ์ˆ˜์‹ ์ž๊ฐ€ ๋“ค์–ด๊ฐ€๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ๋Š” "followers"๋ผ๋Š” ํŠน์ˆ˜ํ•œ ์˜ต์…˜์„ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ์˜ต์…˜์„ ์ง€์ •ํ•˜๋ฉด ์•ž์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ชจ๋“  ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ตฌํ˜„์„ ๋๋ƒˆ์œผ๋‹ˆ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ์‹œํ‚จ ์ฑ„, ActivityPub.Academy๋กœ ๋“ค์–ด๊ฐ€ @johndoe@temp-address.serveo.net(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ ํ• ๋‹น๋œ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ํŒ”๋กœํ•ฉ๋‹ˆ๋‹ค. ํŒ”๋กœ์›Œ ๋ชฉ๋ก์—์„œ ํŒ”๋กœ ์š”์ฒญ์ด ํ™•์‹คํžˆ ์ˆ˜๋ฝ๋œ ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ localhost๊ฐ€ ์•„๋‹Œ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ ID๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๊ธฐ์ค€์œผ๋กœ URI๋ฅผ ๊ตฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๊ฐ”๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์˜ Activity Log๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

ActivityPub.Academy์˜ ํƒ€์ž„๋ผ์ธ์—์„œ ์ƒ์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ž˜ ๋ณด์ธ๋‹ค.

ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ๋‚ด ๊ฒŒ์‹œ๋ฌผ ๋ชฉ๋ก

ํ˜„์žฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—๋Š” ์ด๋ฆ„๊ณผ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค, ํŒ”๋กœ์›Œ ์ˆ˜๋งŒ ๋‚˜์˜ฌ ๋ฟ ์ •์ž‘ ๊ฒŒ์‹œ๋ฌผ์€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์„ ๋ณด์—ฌ์ค์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ  <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface PostListProps {
  posts: (Post & Actor)[];
}

export const PostList: FC<PostListProps> = ({ posts }) => (
  <>
    {posts.map((post) => (
      <div key={post.id}>
        <PostView post={post} />
      </div>
    ))}
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ๋ฐฉ๊ธˆ ์ •์˜ํ•œ <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

์ด๋ฏธ ์žˆ๋Š” GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE actors.user_id = ?
      ORDER BY posts.created DESC
      `,
    )
    .all(user.user_id);
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      // ... ์ƒ๋žต ...
      <PostList posts={posts} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

๋ณ€๊ฒฝ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์ด ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ”๋กœ

ํ˜„์žฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์„ ์ˆ˜๋Š” ์žˆ์ง€๋งŒ, ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์—๊ฒŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜๋Š” ์—†์Šต๋‹ˆ๋‹ค. ํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋„ ๋ณผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž, ๊ทธ๋Ÿผ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ์‹œ๋‹ค.

UI ๋จผ์ € ๋งŒ๋“ญ์‹œ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ์ด๋ฏธ ์žˆ๋Š” <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      {/* ... ์ƒ๋žต ... */}
    </hgroup>
    <form method="post" action={`/users/${user.username}/following`}>
      {/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ€ role=group์„ ์š”๊ตฌํ•จ */}
      <fieldset role="group">
        <input
          type="text"
          name="actor"
          required={true}
          placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
        />
        <input type="submit" value="Follow" />
      </fieldset>
    </form>
    <form method="post" action={`/users/${user.username}/posts`}>
      {/* ... ์ƒ๋žต ... */}
    </form>
  </>
);

์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ฒผ์œผ๋‹ˆ ์‹ค์ œ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งค ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Follow ํด๋ž˜์Šค์™€ isActor() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Create,
  Follow,
  isActor,
  Note,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/following ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/following", async (c) => {
  const username = c.req.param("username");
  const form = await c.req.formData();
  const handle = form.get("actor");
  if (typeof handle !== "string") {
    return c.text("Invalid actor handle or URL", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const actor = await lookupObject(handle.trim());
  if (!isActor(actor)) {
    return c.text("Invalid actor handle or URL", 400);
  }
  await ctx.sendActivity(
    { identifier: username },
    actor,
    new Follow({
      actor: ctx.getActorUri(username),
      object: actor.id,
      to: actor.id,
    }),
  );
  return c.text("Successfully sent a follow request");
});

lookupObject() ํ•จ์ˆ˜๋Š” ์•กํ„ฐ๋ฅผ ๋น„๋กฏํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ž…๋ ฅ์œผ๋กœ ActivityPub ๊ฐ์ฒด์˜ ๊ณ ์œ  URI๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ฐ›๊ณ , ์กฐํšŒํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

isActor() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ActivityPub ๊ฐ์ฒด๊ฐ€ ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์ด ์ฝ”๋“œ์—์„œ๋Š” sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์กฐํšŒํ•œ ์•กํ„ฐ์—๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์•„์ง follows ํ…Œ์ด๋ธ”์— ์•„๋ฌด๋Ÿฐ ๋ ˆ์ฝ”๋“œ๋„ ์ถ”๊ฐ€ํ•˜์ง„ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ƒ๋Œ€๋กœ๋ถ€ํ„ฐ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๊ณ  ๋‚˜์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ตฌํ˜„ํ•œ ํŒ”๋กœ ์š”์ฒญ ๊ธฐ๋Šฅ์ด ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒˆ์—๋„ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•ด์•ผ ํ•˜๋ฏ€๋กœ, fedify tunnel ๋ช…๋ น์„ ์ด์šฉํ•ด ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€ ์ƒ์— ๋‚˜ํƒ€๋‚œ ๋„์ฐฉํ•œ ํŒ”๋กœ ์š”์ฒญ

Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํ•˜์ง€๋งŒ ์•„์ง ์ˆ˜์‹ ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์— ๋Œ€ํ•ด ์•„๋ฌด๋Ÿฐ ํ–‰๋™๋„ ์ทจํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ด ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify์—์„œ ์ œ๊ณตํ•˜๋Š” isActor() ํ•จ์ˆ˜ ๋ฐ Actor ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

์ด ์†Œ์Šค ํŒŒ์ผ ์•ˆ์—์„œ Actor ํƒ€์ž…์˜ ์ด๋ฆ„์ด ๊ฒน์น˜๋ฏ€๋กœ APActor๋ผ๋Š” ๋ณ„๋ช…์„ ์ง€์–ด์คฌ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ, ์ฒ˜์Œ ๋งˆ์ฃผํ•œ ์•กํ„ฐ ์ •๋ณด๋ฅผ actors ํ…Œ์ด๋ธ”์— ๋„ฃ๋Š” ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์—ฌ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค. ์•„๋ž˜ ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

async function persistActor(actor: APActor): Promise<Actor | null> {
  if (actor.id == null || actor.inboxId == null) {
    logger.debug("Actor is missing required fields: {actor}", { actor });
    return null;
  }
  return (
    db
      .prepare<unknown[], Actor>(
        `
        -- ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        actor.id.href,
        await getActorHandle(actor),
        actor.name?.toString(),
        actor.inboxId.href,
        actor.endpoints?.sharedInbox?.href,
        actor.url?.href,
      ) ?? null
  );
}

์ •์˜ํ•œ persistActor() ํ•จ์ˆ˜๋Š” ์ธ์ž๋กœ ๋“ค์–ด์˜จ ์•กํ„ฐ ๊ฐ์ฒด์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ actors ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ํ…Œ์ด๋ธ”์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์˜ on(Follow, ...) ๋ถ€๋ถ„์—์„œ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ persistActor() ํ•จ์ˆ˜๋ฅผ ์“ฐ๊ฒŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต ...
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
    }
    const followerId = (await persistActor(follower))?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    // ... ์ƒ๋žต ...
  })

๋ฆฌํŒฉํ„ฐ๋ง์„ ๋๋ƒˆ์œผ๋‹ˆ ์ˆ˜์‹ ํ•จ์— Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์•˜์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

  .on(Accept, async (ctx, accept) => {
    const follow = await accept.getObject();
    if (!(follow instanceof Follow)) return;
    const following = await accept.getActor();
    if (!isActor(following)) return;
    const follower = follow.actorId;
    if (follower == null) return;
    const parsed = ctx.parseUri(follower);
    if (parsed == null || parsed.type !== "actor") return;
    const followingId = (await persistActor(following))?.id;
    if (followingId == null) return;
    db.prepare(
      `
      INSERT INTO follows (following_id, follower_id)
      VALUES (
        ?,
        (
          SELECT actors.id
          FROM actors
          JOIN users ON actors.user_id = users.id
          WHERE users.username = ?
        )
      )
      `,
    ).run(followingId, parsed.identifier);
  });

์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๊ธธ์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ์œผ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ(follower)์™€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์€ ์•กํ„ฐ(following)๋ฅผ ๊ตฌํ•˜๊ณ  follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์ด์ œ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„๊นŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ ActivityPub.Academy ์ชฝ์—์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•˜๊ณ  Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ด๋ฏธ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ์ƒํƒœ์—์„œ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋„ ๋ฌด์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๋’ค ๋‹ค์‹œ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์—์„œ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ์ƒํƒœ์—์„œ, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ActivityPub.Academy์˜ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋ฉด, ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Activity Log์— Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋„์ฐฉํ•œ ํ›„ ๋‹ต์žฅ์œผ๋กœ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‚˜ ์ง์ ‘ ํ™•์ธ์„ ํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

์„ฑ๊ณตํ–ˆ๋‹ค๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค (following_id ์นผ๋Ÿผ์— ๋“  ๊ฐ’์€ ๋‹ค์†Œ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค):

following_id follower_id created
3 1 2024-09-02 14:11:17

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowingListProps {
  following: Actor[];
}

export const FollowingList: FC<FollowingListProps> = ({ following }) => (
  <>
    <h2>Following</h2>
    <ul>
      {following.map((actor) => (
        <li key={actor.id}>
          <ActorLink actor={actor} />
        </li>
      ))}
    </ul>
  </>
);

๊ทธ ๋‹ค์Œ, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  FollowingList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/following ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/following", async (c) => {
  const following = db
    .prepare<unknown[], Actor>(
      `
      SELECT following.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = followers.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowingList following={following} />
    </Layout>,
  );
});

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/following ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

ํŒ”๋กœ์ž‰ ์ˆ˜

ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ํŒ”๋กœ์ž‰ ์ˆ˜๋„ ํ‘œ์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  following: number;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  following,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

<PostPage> ์ปดํฌ๋„ŒํŠธ๋„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      following={props.following}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

๊ทธ๋Ÿผ ์ด์ œ ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์กฐํšŒํ•˜์—ฌ ํŒ”๋กœ์ž‰ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET /users/{username} ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following } = db
    .prepare<unknown[], { following: number }>(
      `
      SELECT count(*) AS following
      FROM follows
      JOIN actors ON follows.follower_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        following={following}
        followers={followers}
      />
      <PostList posts={posts} />
    </Layout>,
  );
});

GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  // ... ์ƒ๋žต ...
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following, followers } = db
    .prepare<unknown[], { following: number; followers: number }>(
      `
      SELECT sum(follows.follower_id = ?) AS following,
             sum(follows.following_id = ?) AS followers
      FROM follows
      `,
    )
    .get(post.actor_id, post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        following={following}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๋‹ค ์ˆ˜์ •๋˜์—ˆ๋‹ค๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํƒ€์ž„๋ผ์ธ

๋งŽ์€ ๊ฒƒ๋“ค์„ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, ์•„์ง ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด์ง€๋Š” ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌํƒœ๊นŒ์ง€์˜ ๊ณผ์ •์—์„œ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค์‹œํ”ผ, ์šฐ๋ฆฌ๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์“ธ ๋•Œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ–ˆ๋˜ ๊ฒƒ๊ณผ ๊ฐ™์ด, ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•ด์•ผ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ค ์ผ์ด ์ผ์–ด๋‚˜๋Š”์ง€ ๋ณด๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Create,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ์— on(Create, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

  .on(Create, async (ctx, create) => {
    const object = await create.getObject();
    if (!(object instanceof Note)) return;
    const actor = create.actorId;
    if (actor == null) return;
    const author = await object.getAttribution();
    if (!isActor(author) || author.id?.href !== actor.href) return;
    const actorId = (await persistActor(author))?.id;
    if (actorId == null) return;
    if (object.id == null) return;
    const content = object.content?.toString();
    db.prepare(
      "INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
    ).run(object.id.href, actorId, content, object.url?.href);
  });

getAttribution() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ธ€์“ด์ด๋ฅผ ๊ตฌํ•œ ๋’ค, persistActor() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ์•„์ง actors ํ…Œ์ด๋ธ”์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  posts ํ…Œ์ด๋ธ”์— ์ƒˆ ๋ ˆ์ฝ”๋“œ๋ฅผ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์‹œ ํ•œ ๋ฒˆ ActivityPub.Academy์— ๋“ค์–ด๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. Activity Log๋ฅผ ์—ด์–ด Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋˜์—ˆ๋Š”์ง€ ์ฒดํฌํ•œ ๋’ค, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ posts ํ…Œ์ด๋ธ”์— ์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3

์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

id uri actor_id content url created
3 https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 3 <p>Would it send a Create(Note) activity?</p> https://activitypub.academy/@algusia_draneoll/113068684551948316 2024-09-02 15:33:32

์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ ํ‘œ์‹œ

์ž, ์ด์ œ ์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ์„ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋กœ ์ถ”๊ฐ€ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๊ทธ ๋ ˆ์ฝ”๋“œ๋“ค์„ ์ž˜ ํ‘œ์‹œํ•ด ์ฃผ๋Š” ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. ํ”ํžˆ ใ€Œํƒ€์ž„๋ผ์ธใ€์ด๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps extends PostListProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user, posts }) => (
  <>
    {/* ... ์ƒ๋žต ... */}
    <PostList posts={posts} />
  </>
);

๊ทธ ๋’ค, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/", (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.redirect("/setup");

  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE posts.actor_id = ? OR posts.actor_id IN (
        SELECT following_id
        FROM follows
        WHERE follower_id = ?
      )
      ORDER BY posts.created DESC
      `,
    )
    .all(user.id, user.id);
  return c.html(
    <Layout>
      <Home user={user} posts={posts} />
    </Layout>,
  );
});

์ž, ์ด์ œ ๋‹ค ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ํƒ€์ž„๋ผ์ธ์„ ๊ฐ์ƒํ•ฉ์‹œ๋‹ค:

์ฒซ ํŽ˜์ด์ง€์—์„œ ๋ณด์ด๋Š” ํƒ€์ž„๋ผ์ธ

์œ„์™€ ๊ฐ™์ด ์›๊ฒฉ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๊ณผ ๋กœ์ปฌ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ž˜ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค๊ฐ€์š”? ๋งˆ์Œ์— ๋“œ์‹œ๋‚˜์š”?

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์ด๊ฒŒ ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์™„์„ฑ์‹œํ‚ค๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ฐœ์„ ํ•  ์ 

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด ์™„์„ฑํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ์•„์‰ฝ๊ฒŒ๋„ ์•„์ง ์‹ค์‚ฌ์šฉ์—๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํŠนํžˆ, ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ์ทจ์•ฝ์ ์ด ๋งŽ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋งŒ๋“  ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์ข€ ๋” ๋ฐœ์ „์‹œํ‚ค๊ณ  ์‹ถ์€ ๋ถ„๋“ค์€, ์•„๋ž˜ ๊ณผ์ œ๋“ค์„ ์ง์ ‘ ํ•ด๊ฒฐํ•ด ๋ณด์…”๋„ ์ข‹์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

  • ํ˜„์žฌ๋Š” ์•„๋ฌด๋Ÿฐ ์ธ์ฆ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ๋ˆ„๊ตฌ๋ผ๋„ URL๋งŒ ์•Œ๋ฉด ๊ธ€์„ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๊ณผ์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ด๋ฅผ ๋ฐฉ์ง€ํ•ด ๋ณผ๊นŒ์š”?

  • ํ˜„์žฌ์˜ ๊ตฌํ˜„์€ ActivityPub์„ ํ†ตํ•ด ๋ฐ›์€ Note ๊ฐ์ฒด ์•ˆ์— ๋“ค์–ด ์žˆ๋Š” HTML์„ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์•…์˜์ ์ธ ActivityPub ์„œ๋ฒ„๊ฐ€ <script>while (true) alert('๋ฉ”๋กฑ'); ๊ฐ™์€ HTML์„ ํฌํ•จํ•œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ XSS ์ทจ์•ฝ์ ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ทจ์•ฝ์ ์€ ์–ด๋–ป๊ฒŒ ๋ง‰์„ ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋‹ค์Œ SQL์„ ์‹คํ–‰ํ•˜์—ฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟ” ๋ด…์‹œ๋‹ค:

    UPDATE actors SET name = 'Renamed' WHERE id = 1;

    ์ด๋ ‡๊ฒŒ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟจ์„ ๋•Œ, ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๋ฐ”๋€ ์ด๋ฆ„์ด ์ ์šฉ๋ ๊นŒ์š”? ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด์•ผ ๋ณ€๊ฒฝ์ด ์ ์šฉ๋ ๊นŒ์š”?

  • ์•กํ„ฐ์— ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•ด ๋ด…์‹œ๋‹ค. ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ถ๊ธˆํ•˜๋‹ค๋ฉด, fedify lookup ๋ช…๋ น์œผ๋กœ ์ด๋ฏธ ํ”„๋กœํ•„ ์‚ฌ์ง„์ด ์žˆ๋Š” ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณด์„ธ์š”.

  • ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€๊ฐ€ ์ฒจ๋ถ€๋œ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ํƒ€์ž„๋ผ์ธ์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • ๊ฒŒ์‹œ๋ฌผ ๋‚ด์—์„œ ๋‹ค๋ฅธ ์•กํ„ฐ๋ฅผ ๋ฉ˜์…˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ด…์‹œ๋‹ค. ๋ฉ˜์…˜ํ•œ ์ƒ๋Œ€ํ•œํ…Œ ์•Œ๋ฆผ์ด ๊ฐ€๋„๋ก ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: English (์˜์–ด), ๆ—ฅๆœฌ่ชž (์ผ๋ณธ์–ด).

์•ˆ๋‚ด

๋งŒ์•ฝ ์—ฐํ•ฉ์šฐ์ฃผ(fediverse)๋‚˜ ActivityPub ๊ฐ™์€ ์šฉ์–ด๊ฐ€ ์ƒ์†Œํ•˜๋‹ค๋ฉด, ๊ด€๋ จ ๊ฒ€์ƒ‰์„ ์ข€ ๋” ํ•˜๊ณ  ๋‚˜์„œ ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•  ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ActivityPub ์„œ๋ฒ„ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Fedify๋ฅผ ์ด์šฉํ•˜์—ฌ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ํ”„๋กœํ† ์ฝœ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ(microblog)๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify์˜ ๊ธฐ๋ฐ˜ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค๋Š” Fedify์˜ ํ™œ์šฉ๋ฒ•์— ์ข€ ๋” ์ง‘์ค‘ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

Fedify๋Š” ActivityPub์ด๋‚˜ ๊ทธ ์™ธ ํ‘œ์ค€(์ด์นญํ•˜์—ฌ ใ€Œ์—ฐํ•ฉ์šฐ์ฃผใ€๋ผ ๋ถˆ๋ฆฌ๋Š”)์„ ์ด์šฉํ•˜์—ฌ ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค ๋•Œ์˜ ๋ณต์žกํ•จ์ด๋‚˜ ๋ฒˆ๊ฑฐ๋กœ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์—†์• ๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด Fedify์˜ ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค.

Fedify ํ”„๋กœ์ ํŠธ์— ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์…จ๋‹ค๋ฉด, ์•„๋ž˜์˜ ์ž๋ฃŒ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”:

Fedify๋‚˜ ๋ณธ ํŠœํ† ๋ฆฌ์–ผ์— ๋Œ€ํ•œ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ, ํ”ผ๋“œ๋ฐฑ ๋“ฑ์€ GitHub Discussions(์˜์–ด)์— ์˜ฌ๋ ค ์ฃผ์‹œ๊ฑฐ๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ @fedify(์˜์–ด ๋ฐ ํ•œ๊ตญ์–ด)๋กœ ๋ฉ˜์…˜ ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ์•„๋‹ˆ๋ฉด Fedify ํ”„๋กœ์ ํŠธ์˜ Discord ์„œ๋ฒ„์— ๋“ค์–ด์˜ค์…”์„œ #fedify-general-ko ์ฑ„๋„(ํ•œ๊ตญ์–ด)์—์„œ ๋ง์”€ํ•˜์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

๋Œ€์ƒ ๋…์ž

์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify๋ฅผ ๋ฐฐ์›Œ์„œ ActivityPub ์„œ๋ฒ„ ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ณ  ์‹ถ์€ ๋ถ„๋“ค์„ ๋Œ€์ƒ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด HTML์ด๋‚˜ HTTP๋ฅผ ์ด์šฉํ•˜์—ฌ ์›น์•ฑ์„ ์ œ์ž‘ํ•ด ๋ณธ ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ, ๋ช…๋ นํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๋‚˜ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋“ฑ์„ ์ดํ•ดํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ TypeScript๋‚˜ JSX, ActivityPub, Fedify ๋“ฑ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ํ•„์š”ํ•œ ๋งŒํผ ๊ฐ€๋ฅด์ณ ๋“œ๋ฆด ๊ฒƒ์ด๋‹ˆ ๋ชฐ๋ผ๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณธ ๊ฒฝํ—˜์€ ํ•„์š” ์—†์ง€๋งŒ, ๊ทธ๋ž˜๋„ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ํ•˜๋‚˜ ์ •๋„๋Š” ์จ๋ดค๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ž˜์•ผ ์šฐ๋ฆฌ๊ฐ€ ๋ฌด์—‡์„ ๋งŒ๋“œ๋ ค๊ณ  ํ•˜๋Š”์ง€ ๊ฐ์ด ์žกํžˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๋ชฉํ‘œ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Fedify๋ฅผ ์ด์šฉํ•ด ActivityPub์œผ๋กœ ๋‹ค๋ฅธ ์—ฐํ•ฉํ˜• ์†Œํ”„ํŠธ์›จ์–ด ๋ฐ ์„œ๋น„์Šค์™€ ์†Œํ†ต ๊ฐ€๋Šฅํ•œ ์ผ์ธ์šฉ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ด ์†Œํ”„ํŠธ์›จ์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

  • ์‚ฌ์šฉ์ž๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์ด ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒ”๋กœ์›Œ๋Š” ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœํ•˜๋‹ค๊ฐ€ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๋ฌผ์€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ • ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ •์ด ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์„ ์‹œ๊ฐ„์ˆœ ๋ชฉ๋ก์œผ๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŠœํ† ๋ฆฌ์–ผ์„ ๋‹จ์ˆœํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ ์ œ์•ฝ์„ ๋‘ก๋‹ˆ๋‹ค.

  • ๊ณ„์ • ํ”„๋กœํ•„(์†Œ๊ฐœ๋ฌธ, ์‚ฌ์ง„ ๋“ฑ)์€ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ๋งŒ๋“  ๊ณ„์ •์€ ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ์˜ฌ๋ฆฐ ๊ฒŒ์‹œ๋ฌผ์€ ๊ณ ์น˜๊ฑฐ๋‚˜ ์ง€์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ํŒ”๋กœํ•œ ๋‹ค๋ฅธ ๊ณ„์ •์€ ํŒ”๋กœ์ž‰์„ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ข‹์•„์š”, ๊ณต์œ , ๋Œ“๊ธ€์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ ๋“ฑ์˜ ๋ณด์•ˆ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก , ํŠœํ† ๋ฆฌ์–ผ์„ ๋๊นŒ์ง€ ์ง„ํ–‰ํ•œ ๋’ค ๊ธฐ๋Šฅ์„ ๋ง๋ถ™์ด๋Š” ๊ฒƒ์€ ์–ผ๋งˆ๋“ ์ง€ ํ•˜์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์—ฐ์Šต์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” GitHub ์ €์žฅ์†Œ์— ์˜ฌ๋ผ์™€ ์žˆ์œผ๋ฉฐ, ๊ฐ ๊ตฌํ˜„ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ์ปค๋ฐ‹์ด ๋‚˜๋‰˜์–ด์ ธ ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์…‹์—…

Node.js ์„ค์น˜ํ•˜๊ธฐ

Fedify๋Š” Deno, Bun, Node.js, ์ด ์„ธ ๊ฐ€์ง€ JavaScript ๋Ÿฐํƒ€์ž„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ์ค‘์—์„œ Node.js๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์“ฐ์ด๋ฏ€๋กœ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Node.js๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

JavaScript ๋Ÿฐํƒ€์ž„์ด๋ž€ JavaScript ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ํ”Œ๋žซํผ์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์›น๋ธŒ๋ผ์šฐ์ €๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜์ด๋ฉฐ, ๋ช…๋ น์ค„์ด๋‚˜ ์„œ๋ฒ„์—์„œ๋Š” Node.js ๋“ฑ์ด ๋„๋ฆฌ ์“ฐ์ž…๋‹ˆ๋‹ค. ์ตœ๊ทผ์—๋Š” Cloudflare Workers ๊ฐ™์€ ํด๋ผ์šฐ๋“œ ์—์ง€ ํ•จ์ˆ˜๋“ค๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜๋กœ ๊ฐ๊ด‘ ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fedify๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Node.js 22.0.0 ์ด์ƒ์˜ ๋ฒ„์ „์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜๋ฒ•์ด ์žˆ์œผ๋‹ˆ ์ž์‹ ์—๊ฐ€ ๊ฐ€์žฅ ์•Œ๋งž๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ Node.js๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

Node.js๊ฐ€ ์„ค์น˜๋˜๋ฉด node ๋ช…๋ น์–ด์™€ npm ๋ช…๋ น์–ด๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค:

node --version
npm --version

fedify ๋ช…๋ น์–ด ์„ค์น˜

Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์…‹์—…ํ•˜๊ธฐ ์œ„ํ•ด fedify ๋ช…๋ น์–ด๋ฅผ ์‹œ์Šคํ…œ์— ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ, npm ๋ช…๋ น์œผ๋กœ ๊นŒ๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊ฐ„ํŽธํ•ฉ๋‹ˆ๋‹ค:

npm install -g @fedify/cli

์„ค์น˜๊ฐ€ ๋˜์—ˆ๋‹ค๋ฉด, fedify ๋ช…๋ น์–ด๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ fedify ๋ช…๋ น์–ด์˜ ๋ฒ„์ „์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

fedify --version

๊ฒฐ๊ณผ๋กœ ๋‚˜์˜จ ๋ฒ„์ „ ๋ฒˆํ˜ธ๊ฐ€ 1.0.0 ์ด์ƒ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. ๊ทธ๋ณด๋‹ค ์˜›๋‚  ๋ฒ„์ „์ด๋ฉด ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ œ๋Œ€๋กœ ๋”ฐ๋ผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

fedify init์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

์ƒˆ Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•ด, ์ž‘์—…ํ•  ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ •ํ•ฉ์‹œ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” microblog๋ผ๊ณ  ๋ช…๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. fedify init ๋ช…๋ น ๋’ค์— ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ ๊ณ  ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค (๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค):

fedify init microblog

fedify init ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ช‡ ๊ฐ€์ง€ ์งˆ๋ฌธ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค. ์ฐจ๋ก€๋Œ€๋กœ Node.js, npm, Hono, In-memory, In-process๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค:

             ___      _____        _ _  __
            /'_')    |  ___|__  __| (_)/ _|_   _
     .-^^^-/  /      | |_ / _ \/ _` | | |_| | | |
   __/       /       |  _|  __/ (_| | |  _| |_| |
  <__.|_|-|_|        |_|  \___|\__,_|_|_|  \__, |
                                           |___/

? Choose the JavaScript runtime to use
  Deno
  Bun
โฏ Node.js

? Choose the package manager to use
โฏ npm
  Yarn
  pnpm

? Choose the web framework to integrate Fedify with
  Bare-bones
  Fresh
โฏ Hono
  Express
  Nitro

? Choose the key-value store to use for caching
โฏ In-memory
  Redis
  PostgreSQL
  Deno KV

? Choose the message queue to use for background jobs
โฏ In-process
  Redis
  PostgreSQL
  Deno KV

์•ˆ๋‚ด

Fedify๋Š” ํ’€ ์Šคํƒ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์•„๋‹Œ, ActivityPub ์„œ๋ฒ„ ๊ตฌํ˜„์— ํŠนํ™”๋œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋‹ค๋ฅธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์™€ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๊ฒƒ์„ ์—ผ๋‘์— ๋‘๊ณ  ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ Hono๋ฅผ ์ฑ„ํƒํ•˜์—ฌ Fedify์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์ž ์‹œ ํ›„ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ ์•ˆ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • .vscode/ โ€” Visual Studio Code ๊ด€๋ จ ์„ค์ •๋“ค
    • extensions.json โ€” Visual Studio Code ์ถ”์ฒœ ํ™•์žฅ
    • settings.json โ€” Visual Studio Code ์„ค์ •
  • node_modules/ โ€” ์˜์กด ํŒจํ‚ค์ง€๋“ค์ด ์„ค์น˜๋˜๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ (๋‚ด๋ถ€ ์ƒ๋žต)
  • src/ โ€” ์†Œ์Šค ์ฝ”๋“œ
    • app.tsx โ€” ActivityPub๊ณผ ๊ด€๋ จ ์—†๋Š” ์„œ๋ฒ„
    • federation.ts โ€” ActivityPub ์„œ๋ฒ„
    • index.ts โ€” ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
    • logging.ts โ€” ๋กœ๊น… ์„ค์ •
  • biome.json โ€” ํฌ๋งคํ„ฐ ๋ฐ ๋ฆฐํŠธ ์„ค์ •
  • package.json โ€” ํŒจํ‚ค์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
  • tsconfig.json โ€” TypeScript ์„ค์ •

์ง์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒ ์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” JavaScript๊ฐ€ ์•„๋‹Œ TypeScript๋ฅผ ์“ฐ๊ธฐ ๋•Œ๋ฌธ์— .js ํŒŒ์ผ์ด ์•„๋‹Œ .ts ๋ฐ .tsx ํŒŒ์ผ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒ์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” ๋™์ž‘ํ•˜๋Š” ๋ฐ๋ชจ์ž…๋‹ˆ๋‹ค. ์šฐ์„ ์€ ์ด ์ƒํƒœ๋กœ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

npm run dev

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด Ctrl+C ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ธฐ ์ „๊นŒ์ง€๋Š” ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ฑ„๋กœ ์žˆ์Šต๋‹ˆ๋‹ค:

Server started at http://0.0.0.0:8000

์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด๊ณ  ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/john

์œ„ ๋ช…๋ น์€ ์šฐ๋ฆฌ๊ฐ€ ๋กœ์ปฌ์— ๋„์šด ActivityPub ์„œ๋ฒ„์˜ ํ•œ ์•กํ„ฐ(actor)๋ฅผ ์กฐํšŒํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ์•กํ„ฐ๋Š” ์—ฌ๋Ÿฌ ActivityPub ์„œ๋ฒ„๋“ค ์‚ฌ์ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ณ„์ •์ด๋ผ๊ณ  ๋ณด์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋˜๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/john",
  name: "john",
  preferredUsername: "john"
}

์ด ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด /users/john ๊ฒฝ๋กœ์— ์œ„์น˜ํ•œ ์•กํ„ฐ ๊ฐ์ฒด์˜ ์ข…๋ฅ˜๊ฐ€ Person์ด๋ฉฐ, ๊ทธ ID๋Š” http://localhost:8000/users/john, ์ด๋ฆ„์€ john, ์‚ฌ์šฉ์ž๋ช…๋„ john์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

fedify lookup์€ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ์ด๋Š” Mastodon์—์„œ ํ•ด๋‹น URI๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌผ๋ก , ํ˜„์žฌ ์—ฌ๋Ÿฌ๋ถ„์˜ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•„์ง Mastodon์—์„œ ๊ฒ€์ƒ‰ํ•ด๋„ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค์ง€๋Š” ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.)

์—ฌ๋Ÿฌ๋ถ„์ด fedify lookup ๋ช…๋ น์–ด๋ณด๋‹ค curl์„ ๋” ์„ ํ˜ธํ•˜์‹ ๋‹ค๋ฉด, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ๋„ ์•กํ„ฐ ์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (-H ์˜ต์…˜์œผ๋กœ Accept ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋Š” ๊ฒƒ์— ์ฃผ์˜ํ•˜์‹ญ์‹œ์˜ค):

curl -H"Accept: application/activity+json" http://localhost:8000/users/john

๋‹จ, ์œ„์™€ ๊ฐ™์ด ์กฐํšŒํ•  ๊ฒฝ์šฐ ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋งจ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜๊ธฐ ์–ด๋ ค์šด JSON ํ˜•์‹์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์‹œ์Šคํ…œ์— jq ๋ช…๋ น์–ด๋„ ํ•จ๊ป˜ ๊น”๋ ค์žˆ๋‹ค๋ฉด, curl๊ณผ jq๋ฅผ ํ•จ๊ป˜ ์“ธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

Visual Studio Code๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋Š” ๋™์•ˆ์—๋Š” Visual Studio Code๋ฅผ ์จ๋ณด์‹ค ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์šฐ๋ฆฌ๋Š” TypeScript๋ฅผ ์จ์•ผ ํ•˜๋Š”๋ฐ, Visual Studio Code๋Š” ํ˜„์กดํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„ํŽธํ•˜๋ฉด์„œ๋„ ๋›ฐ์–ด๋‚œ TypeScript IDE์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ์…‹์—…์— ์ด๋ฏธ Visual Studio Code ์„ค์ •์ด ๊ฐ–์ถฐ์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํฌ๋งคํ„ฐ๋‚˜ ๋ฆฐํŠธ ๋“ฑ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š”๋„ ์—†์Šต๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

Visual Studio์™€ ํ—ท๊ฐˆ๋ฆฌ์‹œ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. Visual Studio Code์™€ Visual Studio๋Š” ๋ธŒ๋žœ๋“œ๋งŒ ๊ณต์œ ํ•  ๋ฟ ์„œ๋กœ ์™„์ „ํžˆ ๋‹ค๋ฅธ ์†Œํ”„ํŠธ์›จ์–ด์ž…๋‹ˆ๋‹ค.

Visual Studio Code๋ฅผ ์„ค์น˜ํ•˜์‹  ๋‹ค์Œ, ํŒŒ์ผ โ†’ ํด๋” ์—ด๊ธฐโ€ฆ ๋ฉ”๋‰ด๋ฅผ ๋ˆŒ๋Ÿฌ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์‹ญ์‹œ์˜ค.

๋งŒ์•ฝ ์šฐํ•˜๋‹จ์— ใ€Œ์ด ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ๋Œ€ํ•œ ๊ถŒ์žฅ๋˜๋Š” biomejs์˜ โ€˜Biomeโ€™ ํ™•์žฅ์„(๋ฅผ) ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?ใ€๋ผ๊ณ  ๋ฌป๋Š” ์ฐฝ์ด ๋œจ๋ฉด ์„ค์น˜ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ•ด๋‹น ํ™•์žฅ์„ ์„ค์น˜ํ•˜์„ธ์š”. ์ด ํ™•์žฅ์„ ์„ค์น˜ํ•˜๋ฉด TypeScript ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ๋“ค์—ฌ์“ฐ๊ธฐ๋‚˜ ๋„์–ด์“ฐ๊ธฐ ๊ฐ™์€ ์ฝ”๋“œ ์Šคํƒ€์ผ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๊ฐ€ ์„œ์‹ํ™” ๋ฉ๋‹ˆ๋‹ค.

ํŒ

์—ฌ๋Ÿฌ๋ถ„์ด ์ถฉ์„ฑ์Šค๋Ÿฌ์šด Emacs ๋˜๋Š” Vim ์‚ฌ์šฉ์ž๋ผ๋ฉด, ์“ฐ๋˜ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๋ฅผ ์“ฐ๋Š” ๊ฒƒ์„ ๋ง๋ฆฌ์ง€ ์•Š๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TypeScript LSP ์„ค์ •์€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. TypeScript LSP ์„ค์ • ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์ƒ์‚ฐ์„ฑ์˜ ์ฐจ์ด๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์„ ์ˆ˜ ์ง€์‹

TypeScript

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ ์ „์—, ๊ฐ„๋‹จํžˆ TypeScript์— ๋Œ€ํ•ด ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์—ฌ๋Ÿฌ๋ถ„์ด ์ด๋ฏธ TypeScript์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด ์ด ์žฅ์€ ๋„˜๊ธฐ์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

TypeScript๋Š” JavaScript์— ์ •์  ํƒ€์ž… ๊ฒ€์‚ฌ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. TypeScript ๋ฌธ๋ฒ•์€ JavaScript ๋ฌธ๋ฒ•๊ณผ ๊ฑฐ์˜ ๊ฐ™์ง€๋งŒ, ๋ณ€์ˆ˜๋‚˜ ํ•จ์ˆ˜ ๋ฌธ๋ฒ•์— ํƒ€์ž…์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ํฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ์ง€์ •์€ ๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋’ค์— ์ฝœ๋ก (:)์„ ๋ถ™์—ฌ์„œ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋Š” foo ๋ณ€์ˆ˜๊ฐ€ ๋ฌธ์ž์—ด(string)์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค:

let foo: string;

๋งŒ์•ฝ ์œ„์™€ ๊ฐ™์ด ์„ ์–ธ๋œ foo ๋ณ€์ˆ˜์— ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ๊ฐ’์„ ๋Œ€์ž…ํ•˜๋ ค๊ณ  ํ•˜๋ฉด Visual Studio Code๊ฐ€ ์‹คํ–‰ํ•ด๋ณด๊ธฐ ์ „์— ๋ฏธ๋ฆฌ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๊ทธ์–ด์ฃผ๋ฉฐ ํƒ€์ž… ์˜ค๋ฅ˜๋ฅผ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

foo = 123;
// ts(2322): 'number' ํ˜•์‹์€ 'string' ํ˜•์‹์— ํ• ๋‹นํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ฝ”๋”ฉํ•˜๋ฉด์„œ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๋งŒ๋‚˜๋ฉด ์ง€๋‚˜์น˜์ง€ ์•Š๋„๋ก ํ•˜์‹ญ์‹œ์˜ค. ๋ฌด์‹œํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉด ๊ทธ ๋ถ€๋ถ„์—์„œ ์‹ค์ œ๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.

TypeScript๋กœ ์ฝ”๋”ฉ์„ ํ•˜๋ฉฐ ๋งˆ์ฃผ์น˜๋Š” ๊ฐ€์žฅ ํ”ํ•œ ํƒ€์ž… ์˜ค๋ฅ˜์˜ ์œ ํ˜•์€ ๋ฐ”๋กœ null ๊ฐ€๋Šฅ์„ฑ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด bar ๋ณ€์ˆ˜๋Š” ๋ฌธ์ž์—ด(string)์ผ ์ˆ˜๋„ ์žˆ์ง€๋งŒ null์ผ ์ˆ˜๋„ ์žˆ๋‹ค(string | null)๊ณ  ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:

const bar: string | null = someFunction();

๋งŒ์•ฝ ์ด ๋ณ€์ˆ˜์˜ ๋‚ด์šฉ์—์„œ ๊ฐ€์žฅ ์ฒซ ๊ธ€์ž๋ฅผ ๊บผ๋‚ด๋ ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ์“ด๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”?

const firstChar = bar.charAr(0);
// ts(18047): 'bar'์€(๋Š”) 'null'์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. bar๊ฐ€ ์–ด์ฉ” ๋•Œ๋Š” null์ผ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ๊ฒฝ์šฐ์— null.charAt(0)์„ ํ˜ธ์ถœํ•˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ผ๋Š” ์ด์•ผ๊ธฐ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๊ฒฝ์šฐ์— ์•„๋ž˜์™€ ๊ฐ™์ด null์ธ ๊ฒฝ์šฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

const firstChar = bar === null ? "" : bar.charAr(0);

์ด์™€ ๊ฐ™์ด TypeScript๋Š” ์ฝ”๋”ฉํ•  ๋•Œ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ๋˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๋ฅผ ๋– ์˜ฌ๋ฆฌ๊ฒŒ ํ•ด์„œ ๋ฒ„๊ทธ๋ฅผ ๋ฏธ์—ฐ์— ๋ฐฉ์ง€ํ•˜๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

๋˜, TypeScript์˜ ๋ถ€์ˆ˜์ ์ธ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” ์ž๋™ ์™„์„ฑ์ด ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, foo.๊นŒ์ง€ ์ž…๋ ฅํ•˜๋ฉด ๋ฌธ์ž์—ด ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ง„ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์ด ๋‚˜์™€์„œ ๊ทธ ์ค‘์—์„œ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ผ์ผํžˆ ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์ง€ ์•Š๊ณ ์„œ๋„ ๋น ๋ฅด๊ฒŒ ์ฝ”๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋ฉด์„œ TypeScript์˜ ๋งค๋ ฅ๋„ ํ•จ๊ป˜ ๋А๋ผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ฌด์—‡๋ณด๋‹ค Fedify๋Š” TypeScript์™€ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ€์žฅ ๊ฒฝํ—˜์ด ์ข‹์œผ๋‹ˆ๊นŒ์š”.

ํŒ

TypeScript๋ฅผ ์ œ๋Œ€๋กœ ์ฐฌ์ฐฌํžˆ ๋ฐฐ์›Œ๋ณด๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, ๊ณต์‹ TypeScript ํ•ธ๋“œ๋ถ์„ ์ฝ์œผ์‹ค ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ „๋ถ€ ์ฝ๋Š”๋ฐ ์•ฝ 30๋ถ„ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค.

JSX

JSX๋Š” JavaScript ์ฝ”๋“œ ์•ˆ์— XML ๋˜๋Š” HTML์„ ์ง‘์–ด๋„ฃ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•œ JavaScript์˜ ๋ฌธ๋ฒ• ํ™•์žฅ์ž…๋‹ˆ๋‹ค. TypeScript์—์„œ๋„ ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด ๊ฒฝ์šฐ์—๋Š” TSX๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ๋ชจ๋“  HTML์„ JSX ๋ฌธ๋ฒ•์„ ํ†ตํ•ด JavaScript ์ฝ”๋“œ ์•ˆ์— ์ž‘์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. JSX์— ์ด๋ฏธ ์ต์ˆ™ํ•œ ๋ถ„๋“ค์€ ์ด ์žฅ์„ ๋„˜๊ธฐ์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <div> ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๋Š” HTML ํŠธ๋ฆฌ๋ฅผ html ๋ณ€์ˆ˜์— ๋Œ€์ž…ํ•ฉ๋‹ˆ๋‹ค:

const html = <div>
  <p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p>
</div>;

์ค‘๊ด„ํ˜ธ๋ฅผ ํ†ตํ•ด JavaScript ํ‘œํ˜„์‹์„ ๋„ฃ๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฝ”๋“œ๋Š” ๋ฌผ๋ก  getName() ํ•จ์ˆ˜๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค):

const html = <div title={"์•ˆ๋…•, " + getName() + "!"}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</div>;

JSX์˜ ํŠน์ง• ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ(component)๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์ž์‹ ๋งŒ์˜ ํƒœ๊ทธ๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋Š” ํ‰๋ฒ”ํ•œ JavaScript ํ•จ์ˆ˜๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค (์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„์€ ์ผ๋ฐ˜์ ์œผ๋กœ PascalCase ์Šคํƒ€์ผ์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค):

import type { FC } from "hono/jsx";

function getName() {
  return "JSX";
}

interface ContainerProps {
  name: string;
}

const Container: FC<ContainerProps> = (props) => {
  return <div title={"์•ˆ๋…•, " + props.name + "!"}>{props.children}</div>;
};

const html = <Container name={getName()}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</Container>;

์œ„ ์ฝ”๋“œ์—์„œ FC๋Š” ์šฐ๋ฆฌ๊ฐ€ ์“ธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ธ Hono์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋„์™€์ค๋‹ˆ๋‹ค. FC๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž…(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ˜๋Ÿผ ํ™”์‚ด๊ด„ํ˜ธ ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ํƒ€์ž…๋“ค์ด ๋ฐ”๋กœ ํƒ€์ž… ์ธ์ž๋“ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํƒ€์ž… ์ธ์ž๋กœ ํ”„๋กญ(props) ํ˜•์‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กญ์ด๋ž€, ์ปดํฌ๋„ŒํŠธ์—๊ฒŒ ๋„˜๊ฒจ ์ค„ ๋งค๊ฐœ๋ณ€์ˆ˜๋“ค์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ง์ž…๋‹ˆ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กญ ํ˜•์‹์œผ๋กœ ContainerProps ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์„ ์–ธํ•˜๊ณ  ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ €๋„ค๋ฆญ ํƒ€์ž…์˜ ํƒ€์ž… ์ธ์ž๋Š” ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‰ผํ‘œ๋กœ ๊ฐ ์ธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Foo<A, B>๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž… Foo์— ํƒ€์ž… ์ธ์ž A์™€ B๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ, ์ €๋„ค๋ฆญ ํ•จ์ˆ˜๋ผ๋Š” ๊ฒƒ๋„ ์žˆ์œผ๋ฉฐ, someFunction<A, B>(foo, bar)์™€ ๊ฐ™์ด ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ธ์ž๊ฐ€ ํ•˜๋‚˜์ผ ๋•Œ๋Š” ํƒ€์ž… ์ธ์ž๋ฅผ ๊ฐ์‹ธ๋Š” ํ™”์‚ด๊ด„ํ˜ธ๊ฐ€ ๋งˆ์น˜ XML/HTML ํƒœ๊ทธ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ, JSX์˜ ๊ธฐ๋Šฅ๊ณผ๋Š” ์•„๋ฌด ๊ด€๋ จ์ด ์—†์Šต๋‹ˆ๋‹ค.

FC<ContainerProps>
์ €๋„ค๋ฆญ ํƒ€์ž… FC์— ํƒ€์ž… ์ธ์ž ContainerProps๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ.
<Container>
<Container>๋ผ๋Š” ์ด๋ฆ„์˜ ์ปดํฌ๋„ŒํŠธ ํƒœ๊ทธ๋ฅผ ์—ฐ ๊ฒƒ. </Container>๋กœ ๋‹ซ์•„์•ผ ํ•จ.

ํ”„๋กญ์œผ๋กœ ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ๋“ค ์ค‘ children์€ ํŠน๋ณ„ํžˆ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์—˜๋ฆฌ๋จผํŠธ๋“ค์ด children ํ”„๋กญ์œผ๋กœ ๋„˜์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์œ„ ์ฝ”๋“œ์—์„œ html ๋ณ€์ˆ˜์—๋Š” <div title="์•ˆ๋…•, JSX!"><p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p></div>๋ผ๋Š” HTML ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŒŒ์ผ ์•ˆ์— JSX๋กœ <Layout> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

import type { FC } from "hono/jsx";

export const Layout: FC = (props) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="color-scheme" content="light dark" />
      <title>Microblog</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
      />
    </head>
    <body>
      <main class="container">{props.children}</main>
    </body>
  </html>
);

๋””์ž์ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ๊ณต์„ ๋“ค์ด์ง€ ์•Š๊ธฐ ์œ„ํ•ด, Pico CSS๋ผ๋Š” CSS ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ TypeScript์˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ธฐ๊ฐ€ ์ถ”๋ก ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ, ์œ„์˜ props ๊ฐ™์ด ํƒ€์ž… ํ‘œ๊ธฐ๋ฅผ ์ƒ๋žตํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํƒ€์ž… ํ‘œ๊ธฐ๊ฐ€ ์ƒ๋žต๋œ ๊ฒฝ์šฐ์—๋„, Visual Studio Code์—์„œ ๋ณ€์ˆ˜ ์ด๋ฆ„ ์œ„์— ๋งˆ์šฐ์Šค ์ปค์„œ๋ฅผ ๊ฐ€์ ธ๋‹ค ๋Œ€๋ฉด ํ•ด๋‹น ๋ณ€์ˆ˜๊ฐ€ ์–ด๋–ค ํƒ€์ž…์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ ๋‹ค์Œ, ๊ฐ™์€ ํŒŒ์ผ์—์„œ ๋ ˆ์ด์•„์›ƒ ์•ˆ์— ๋“ค์–ด๊ฐˆ <SetupForm> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

JSX์—์„œ๋Š” ์ตœ์ƒ์œ„์— ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ๋Š”๋ฐ, <SetupForm> ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” <h1>๊ณผ <form> ๋‘ ๊ฐœ์˜ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ์ตœ์ƒ์œ„์— ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด๋ฅผ ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ์ฒ˜๋Ÿผ ๋ฌถ์–ด์ฃผ๊ธฐ ์œ„ํ•ด์„œ ๋นˆ ํƒœ๊ทธ ๋ชจ์–‘์˜ <>์™€ </>๋กœ ๊ฐ์ŒŒ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”„๋ž˜๊ทธ๋จผํŠธ(fragment)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. src/app.tsx ํŒŒ์ผ์—์„œ ์•ž์„œ ์ •์˜ํ•œ ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ /setup ํŽ˜์ด์ง€์—์„œ ์•ž์„œ ๋งŒ๋“  ๊ณ„์ • ์ƒ์„ฑ ์–‘์‹์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) =>
  c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  ),
);

์ž, ๊ทธ๋Ÿผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋ณด์—ฌ์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•ˆ๋‚ด

JSX๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์†Œ์Šค ํŒŒ์ผ์˜ ํ™•์žฅ์ž๊ฐ€ .jsx ๋˜๋Š” .tsx์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์žฅ์—์„œ ํŽธ์ง‘ํ•œ ๋‘ ํŒŒ์ผ ๋ชจ๋‘ ํ™•์žฅ์ž๊ฐ€ .tsx๋ผ๋Š” ์‚ฌ์‹ค์— ์ฃผ์˜ํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์…‹์—…

์ž, ๋ณด์ด๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๋™์ž‘์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ณณ์ด ํ•„์š”ํ•œ๋ฐ, SQLite๋ฅผ ์“ฐ๋„๋ก ํ•ฉ์‹œ๋‹ค. SQLite๋Š” ์ž‘์€ ๊ทœ๋ชจ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์•Œ๋งž๋Š” ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ž…๋‹ˆ๋‹ค.

์šฐ์„  ๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ด์„ ํ…Œ์ด๋ธ”์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. ์•ž์œผ๋กœ ๋ชจ๋“  ํ…Œ์ด๋ธ” ์„ ์–ธ์€ src/schema.sql ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋Š” users ํ…Œ์ด๋ธ”์— ๋‹ด์Šต๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS users (
  id       INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
  username TEXT    NOT NULL UNIQUE      CHECK (trim(lower(username)) = username
                                               AND username <> ''
                                               AND length(username) <= 50)
);

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ฏ€๋กœ, ๊ธฐ๋ณธ ํ‚ค์ธ id ์นผ๋Ÿผ์ด 1 ์ด์™ธ์˜ ๊ฐ’์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ๊ฑธ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ users ํ…Œ์ด๋ธ”์—๋Š” ๋‘˜ ์ด์ƒ์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ด์„ ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ณ„์ • ์•„์ด๋””๋ฅผ ๋‹ด์„ username ์นผ๋Ÿผ์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋‚˜ ๋„ˆ๋ฌด ๊ธด ๋ฌธ์ž์—ด์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ์คฌ์Šต๋‹ˆ๋‹ค.

์ด์ œ users ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ src/schema.sql ํŒŒ์ผ์„ ์‹คํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด sqlite3 ๋ช…๋ น์–ด๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, SQLite ์›น์‚ฌ์ดํŠธ์—์„œ ๋ฐ›๊ฑฐ๋‚˜ ๊ฐ ํ”Œ๋žซํผ์˜ ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋กœ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. macOS์˜ ๊ฒฝ์šฐ์—๋Š” ์šด์˜์ฒด์ œ์— ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ๋”ฐ๋กœ ๋ฐ›์„ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ๋ฐ›์„ ๊ฒฝ์šฐ ์šด์˜์ฒด์ œ์— ๋งž๋Š” sqlite-tools-*.zip ํŒŒ์ผ์„ ๋ฐ›์•„์„œ ์••์ถ•์„ ํ•ด์ œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์„ค์น˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

sudo apt install sqlite3  # Debian ๋ฐ Ubuntu
sudo dnf install sqlite   # Fedora ๋ฐ RHEL
choco install sqlite  # Chocolatey
scoop install sqlite  # Scoop
winget install SQLite.SQLite  # Windows Package Manager

์ž, sqlite3 ๋ช…๋ น์–ด๊ฐ€ ์ค€๋น„๋˜์—ˆ๋‹ค๋ฉด ์ด์ œ ์ด๋ฅผ ์ด์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด microblog.sqlite3 ํŒŒ์ผ์ด ์ƒ๊ธฐ๋Š”๋ฐ, ์ด ์•ˆ์— SQLite ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

์•ฑ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ

์ด์ œ ์ €ํฌ๊ฐ€ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•  ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. Node.js์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SQLite ๋“œ๋ผ์ด๋ฒ„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, ์ €ํฌ๋Š” better-sqlite3 ํŒจํ‚ค์ง€๋ฅผ ์“ฐ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€๋Š” npm ๋ช…๋ น์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊น” ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

npm add better-sqlite3
npm add --save-dev @types/better-sqlite3

ํŒ

@types/better-sqlite3 ํŒจํ‚ค์ง€๋Š” TypeScript๋ฅผ ์œ„ํ•ด better-sqlite ํŒจํ‚ค์ง€์˜ API์— ๋Œ€ํ•œ ํƒ€์ž… ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ Visual Studio Code์—์„œ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™ ์™„์„ฑ์ด๋‚˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด์™€ ๊ฐ™์ด @types/ ๋ฒ”์œ„ ์•ˆ์— ์žˆ๋Š” ํŒจํ‚ค์ง€๋ฅผ Definitely Typed ํŒจํ‚ค์ง€๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ TypeScript๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š์•˜์„ ๋•Œ, ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ํƒ€์ž… ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ ๊ธฐ์ž…ํ•˜์—ฌ ํŒจํ‚ค์ง€๋กœ ๋งŒ๋“  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ, ์ด ํŒจํ‚ค์ง€๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งญ์‹œ๋‹ค. src/db.ts๋ผ๋Š” ์ƒˆ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค:

import Database from "better-sqlite3";

const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export default db;

ํŒ

์ฐธ๊ณ ๋กœ db.pragma() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•œ ์„ค์ •์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์ง€๋‹™๋‹ˆ๋‹ค:

journal_mode = WAL
SQLite์—์„œ ์›์ž์  ์ปค๋ฐ‹ ๋ฐ ๋กค๋ฐฑ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ ์„ ํ–‰ ๊ธฐ์ž… ๋ชจ๋“œ๋ฅผ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ชจ๋“œ๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ ๋กค๋ฐฑ ์ €๋„ ๋ชจ๋“œ์— ๋น„ํ•ด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ์—์„œ ๋” ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.
foreign_keys = ON
SQLite์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด ์„ค์ •์„ ์ผœ๋ฉด ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋˜์–ด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ์ง€ํ‚ค๋Š” ๋ฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  users ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. src/schema.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด User ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface User {
  id: number;
  username: string;
}

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ–ˆ์œผ๋‹ˆ, ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ฝ์ž…ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…์— ์“ฐ์ผ db ๊ฐ์ฒด์™€ User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import db from "./db.ts";
import type { User } from "./schema.ts";

POST /setup ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
  return c.redirect("/");
});

์•ž์„œ ๋งŒ๋“ค์—ˆ๋˜ GET /setup ํ•ธ๋“ค๋Ÿฌ์—๋„ ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});

ํ…Œ์ŠคํŠธ

์ด์ œ ๊ณ„์ • ์ƒ์„ฑ ๊ธฐ๋Šฅ์ด ์–ผ์ถ” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ, ํ•œ ๋ฒˆ ์จ ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๊ณ„์ •์„ ์ƒ์„ฑํ•ด ๋ณด์„ธ์š”. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•ž์œผ๋กœ ์•„์ด๋””๋กœ johndoe๋ฅผ ์ผ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋˜์—ˆ๋‹ค๋ฉด, SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‚˜ ํ™•์ธ๋„ ํ•ด ๋ด…๋‹ˆ๋‹ค:

echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3

๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค (๋ฌผ๋ก , johndoe๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 johndoe

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ด์ œ ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ ๊ณ„์ • ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค. ๋น„๋ก ๋ณด์—ฌ ์ค„ ์ •๋ณด๊ฐ€ ๊ฑฐ์˜ ์—†์ง€๋งŒ์š”.

์ด๋ฒˆ์—๋„ ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์ž‘์—…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์— <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  handle: string;
}

export const Profile: FC<ProfileProps> = ({ name, handle }) => (
  <>
    <hgroup>
      <h1>{name}</h1>
      <p style="user-select: all;">{handle}</p>
    </hgroup>
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์—์„œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” GET /users/{username} ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.username} handle={handle} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด ์ด์ œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด ๋ด์•ผ๊ฒ ์ฃ ? ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe (๊ณ„์ • ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ํ–ˆ์„ ๊ฒฝ์šฐ; ์•„๋‹ˆ๋ผ๋ฉด URL์„ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค) ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ณด์„ธ์š”. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ

์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค(fediverse handle), ์ค„์—ฌ์„œ ํ•ธ๋“ค์ด๋ž€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด์—์„œ ๊ณ„์ •์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ณ ์œ ํ•œ ์ฃผ์†Œ ๊ฐ™์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด @hongminhee@fosstodon.org์ฒ˜๋Ÿผ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ƒ๊ฒผ๋Š”๋ฐ, ์‹ค์ œ ๊ตฌ์„ฑ๋„ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•ฉ๋‹ˆ๋‹ค. ๋งจ ์ฒ˜์Œ์— @์ด ์˜ค๊ณ , ๊ทธ ๋‹ค์Œ์— ์ด๋ฆ„, ๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ @์ด ์˜จ ๋’ค, ๋งˆ์ง€๋ง‰์— ๊ณ„์ •์ด ์†ํ•œ ์„œ๋ฒ„์˜ ๋„๋ฉ”์ธ ์ด๋ฆ„์ด ์˜ต๋‹ˆ๋‹ค. ๋•Œ๋•Œ๋กœ ๋งจ ์•ž์˜ @์ด ์ƒ๋žต๋˜๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ํ•ธ๋“ค์€ WebFinger์™€ acct: URI ํ˜•์‹์ด๋ผ๋Š” ๋‘ ๊ฐœ์˜ ํ‘œ์ค€์œผ๋กœ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. Fedify๊ฐ€ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ง„ํ–‰ํ•˜๋Š” ๋™์•ˆ ์—ฌ๋Ÿฌ๋ถ„์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์•Œ์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

์•กํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

ActivityPub์€ ๊ทธ ์ด๋ฆ„์—์„œ๋„ ๋“œ๋Ÿฌ๋‚˜๋“ฏ, ์•กํ‹ฐ๋น„ํ‹ฐ(activity)๋ฅผ ์ฃผ๊ณ  ๋ฐ›๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค. ๊ธ€์“ฐ๊ธฐ, ๊ธ€ ๊ณ ์น˜๊ธฐ, ๊ธ€ ์ง€์šฐ๊ธฐ, ๊ธ€์— ์ข‹์•„์š” ์ฐ๊ธฐ, ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ, ํ”„๋กœํ•„ ๊ณ ์น˜๊ธฐโ€ฆ ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ ์ผ์–ด๋‚˜๋Š” ๋ชจ๋“  ์ผ๋“ค์„ ์•กํ‹ฐ๋น„ํ‹ฐ๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋ชจ๋“  ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์•กํ„ฐ(actor)์—์„œ ์•กํ„ฐ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํ™๊ธธ๋™์ด ๊ธ€์„ ์“ฐ๋ฉด ใ€Œ๊ธ€์“ฐ๊ธฐใ€(Create(Note)) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ™๊ธธ๋™์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์˜ ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๊ธ€์— ์ž„๊บฝ์ •์ด ์ข‹์•„์š”๋ฅผ ์ฐ์œผ๋ฉด ใ€Œ์ข‹์•„์š”ใ€(Like) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž„๊บฝ์ •์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ActivityPub์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๊ฑธ์Œ์€ ์•กํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

fedify init ๋ช…๋ น์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ๋ชจ ์•ฑ์— ์ด๋ฏธ ์•„์ฃผ ๊ฐ„๋‹จํ•œ ์•กํ„ฐ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธด ํ•˜์ง€๋งŒ, Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ์‹ค์ œ์˜ ์†Œํ”„ํŠธ์›จ์–ด๋“ค๊ณผ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•กํ„ฐ๋ฅผ ์ข€ ๋” ์ œ๋Œ€๋กœ ๊ตฌํ˜„ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋‹จ, ํ˜„์žฌ์˜ ๊ตฌํ˜„์„ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณผ๊นŒ์š”? src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด๋ด…์‹œ๋‹ค:

import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";

const logger = getLogger("microblog");

const federation = createFederation({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
});

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: identifier,
  });
});

export default federation;

์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๋ถ€๋ถ„์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ๋‹ค๋ฅธ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์„œ๋ฒ„์˜ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์“ธ URL๊ณผ ๊ทธ ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์šฐ๋ฆฌ๊ฐ€ ์•ž์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ /users/johndoe๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ identifier ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ "johndoe"๋ผ๋Š” ๋ฌธ์ž์—ด ๊ฐ’์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋Š” Person ํด๋ž˜์Šค์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์กฐํšŒํ•œ ์•กํ„ฐ์˜ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

ctx ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ๋Š” Context ๊ฐ์ฒด๊ฐ€ ๋„˜์–ด์˜ค๋Š”๋ฐ, ActivityPub ํ”„๋กœํ† ์ฝœ๊ณผ ๊ด€๋ จ๋œ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์œ„ ์ฝ”๋“œ์—์„œ ์“ฐ์ด๊ณ  ์žˆ๋Š” getActorUri() ๋ฉ”์„œ๋“œ๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌ๋œ identifier๊ฐ€ ๋“ค์–ด๊ฐ„ ์•กํ„ฐ์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด URI๋Š” Person ๊ฐ์ฒด์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋กœ ์“ฐ์ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ๋ณด์‹œ๋ฉด ์•Œ๊ฒ ์ง€๋งŒ, ํ˜„์žฌ๋Š” /users/ ๊ฒฝ๋กœ ๋’ค์— ์–ด๋–ค ํ•ธ๋“ค์ด ์˜ค๋“  ๋ถ€๋ฅด๋Š” ๋Œ€๋กœ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์ง€์–ด๋‚ด์„œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ณ ์ณ๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ…Œ์ด๋ธ”์€ ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •๋งŒ ๋‹ด๋Š” users ํ…Œ์ด๋ธ”๊ณผ ๋‹ฌ๋ฆฌ, ์—ฐํ•ฉ๋˜๋Š” ์„œ๋ฒ„๋“ค์— ์†ํ•œ ์›๊ฒฉ ์•กํ„ฐ๋“ค๊นŒ์ง€๋„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. src/schema.sql ํŒŒ์ผ์— ๋‹ค์Œ SQL์„ ๋ง๋ถ™์ด์„ธ์š”:

CREATE TABLE IF NOT EXISTS actors (
  id               INTEGER NOT NULL PRIMARY KEY,
  user_id          INTEGER          REFERENCES users (id),
  uri              TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  handle           TEXT    NOT NULL UNIQUE CHECK (handle <> ''),
  name             TEXT,
  inbox_url        TEXT    NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%'
                                                  OR inbox_url LIKE 'http://%'),
  shared_inbox_url TEXT                    CHECK (shared_inbox_url
                                                  LIKE 'https://%'
                                                  OR shared_inbox_url
                                                  LIKE 'http://%'),
  url              TEXT                    CHECK (url LIKE 'https://%'
                                                  OR url LIKE 'http://%'),
  created          TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                           CHECK (created <> '')
);
  • user_id ์นผ๋Ÿผ์€ users ์นผ๋Ÿผ๊ณผ ์—ฐ๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ์™ธ๋ž˜ ํ‚ค์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๊ฐ€ ์›๊ฒฉ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” NULL์ด ๋“ค์–ด๊ฐ€์ง€๋งŒ, ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •์ด๋ผ๋ฉด ํ•ด๋‹น ๊ณ„์ •์˜ users.id ๊ฐ’์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ์•กํ„ฐ ID๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•กํ„ฐ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ActivityPub ๊ฐ์ฒด๋Š” URI ํ˜•ํƒœ์˜ ๊ณ ์œ  ID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • handle ์นผ๋Ÿผ์€ @johndoe@example.com ๋ชจ์–‘์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • name ์นผ๋Ÿผ์€ UI์— ํ‘œ์‹œ๋˜๋Š” ์ด๋ฆ„์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋ณดํ†ต ํ’€๋„ค์ž„์ด๋‚˜ ๋‹‰๋„ค์ž„์ด ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๊ฒ ์ฃ . ๋‹ค๋งŒ, ActivityPub ๋ช…์„ธ์— ๋”ฐ๋ผ ์ด ์นผ๋Ÿผ์€ ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ(inbox) URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ์ˆ˜์‹ ํ•จ์ด ๋ฌด์—‡์ธ์ง€์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค๋งŒ, ํ˜„์žฌ๋กœ์„œ๋Š” ์•กํ„ฐ์—๊ฒŒ ํ•„์ˆ˜์ ์œผ๋กœ ์กด์žฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ๋งŒ ์•Œ์•„ ๋‘์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด ์นผ๋Ÿผ ์—ญ์‹œ ๋นŒ ์ˆ˜๋„ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • shared_inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ(shared inbox) URL์„ ๋‹ด๋Š”๋ฐ, ์ด ์—ญ์‹œ ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜๋Š” ์•„๋‹ˆ๋ฉฐ, ๋”ฐ๋ผ์„œ ๋นŒ ์ˆ˜ ์žˆ๊ณ  ์นผ๋Ÿผ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ๊ฐ™์€ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ URL์ด๋ž€ ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด์„œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ URL์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ID์™€ ํ”„๋กœํ•„ URL์ด ๋™์ผํ•œ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ์„œ๋น„์Šค์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ ๊ฒฝ์šฐ์— ์ด ์นผ๋Ÿผ์— ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์€ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋œ ์‹œ์ ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฝ์ž… ์‹œ์  ์‹œ๊ฐ์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.

์ž, ์ด์ œ src/schema.sql ํŒŒ์ผ์„ microblog.sqlite3 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์— ์ ์šฉํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

ํŒ

์•ž์„œ users ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•  ๋•Œ CREATE TABLE IF NOT EXISTS ๋ฌธ์„ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  actors ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  ํƒ€์ž…๋„ src/schema.ts์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Actor {
  id: number;
  user_id: number | null;
  uri: string;
  handle: string;
  name: string | null;
  inbox_url: string;
  shared_inbox_url: string | null;
  url: string | null;
  created: string;
}

์•กํ„ฐ ๋ ˆ์ฝ”๋“œ

ํ˜„์žฌ users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜ ์žˆ๊ธด ํ•˜์ง€๋งŒ, ์ด์™€ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์—๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ actors ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ์„œ users์™€ actors ์–‘์ชฝ์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx์— ์žˆ๋Š” SetupForm์—์„œ ์•„์ด๋””์™€ ํ•จ๊ป˜ actors.name ์นผ๋Ÿผ์— ๋“ค์–ด๊ฐˆ ์ด๋ฆ„๋„ ์ž…๋ ฅ ๋ฐ›๋„๋ก ํ•ฉ์‹œ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
        <label>
          Name <input type="text" name="name" required />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

์•ž์„œ ์ •์˜ํ•œ Actor ํƒ€์ž…์„ src/app.tsx์—์„œ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

์ด์ œ ์ž…๋ ฅ ๋ฐ›์€ ์ด๋ฆ„์„ ๋น„๋กฏํ•ด ํ•„์š”ํ•œ ์ •๋ณด๋“ค์„ actors ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ๋กœ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ๋ฅผ POST /setup ํ•ธ๋“ค๋Ÿฌ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  const name = form.get("name");
  if (typeof name !== "string" || name.trim() === "") {
    return c.redirect("/setup");
  }
  const url = new URL(c.req.url);
  const handle = `@${username}@${url.host}`;
  const ctx = fedi.createContext(c.req.raw, undefined);
  db.transaction(() => {
    db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
      username,
    );
    db.prepare(
      `
      INSERT OR REPLACE INTO actors
        (user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
      VALUES (1, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      ctx.getActorUri(username).href,
      handle,
      name,
      ctx.getInboxUri(username).href,
      ctx.getInboxUri().href,
      ctx.getActorUri(username).href,
    );
  })();
  return c.redirect("/");
});

๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•  ๋•Œ, users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์„ ๋•Œ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์—†์–ด๋„ ์•„์ง ๊ณ„์ •์ด ์—†๋Š” ๊ฒƒ์œผ๋กœ ํŒ์ •ํ•˜๋„๋ก ๊ณ ์ณค์Šต๋‹ˆ๋‹ค. ๊ฐ™์€ ์กฐ๊ฑด์„ GET /setup ํ•ธ๋“ค๋Ÿฌ ๋ฐ GET /users/{username} ํ•ธ๋“ค๋Ÿฌ์—๋„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // Check if the user already exists
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});
app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE username = ?
      `,
    )
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.name ?? user.username} handle={handle} />
    </Layout>,
  );
});

ํŒ

TypeScript์—์„œ A & B๋Š” A ํƒ€์ž…์ธ ๋™์‹œ์— B ํƒ€์ž…์ธ ๊ฐ์ฒด๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, { a: number } & { b: string } ํƒ€์ž…์ด ์žˆ๋‹ค๊ณ  ํ•  ๋•Œ, { a: 123 }์ด๋‚˜ { b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ•˜์ง€๋งŒ, { a: 123, b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด, ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ์•„๋ž˜์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

setInboxListeners() ๋ฉ”์„œ๋“œ๋Š” ์ง€๊ธˆ์œผ๋กœ์„œ๋Š” ์‹ ๊ฒฝ ์“ฐ์ง€ ๋งˆ์„ธ์š”. ์ด ์—ญ์‹œ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•œ getInboxUri() ๋ฉ”์„œ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋ ค๋ฉด ์œ„ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

์ฝ”๋“œ๋ฅผ ๋ชจ๋‘ ๊ณ ์ณค๋‹ค๋ฉด, ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด์„œ ๋‹ค์‹œ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค๊ณ  ๋ ˆ์ฝ”๋“œ๋„ ์ฑ„์› ์œผ๋‹ˆ, ๋‹ค์‹œ src/federation.ts ํŒŒ์ผ์„ ๊ณ ์ณ๋ด…์‹œ๋‹ค. ๋จผ์ € db ๊ฐ์ฒด์™€ Endpoints ๋ฐ Actor๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";

ํ•„์š”ํ•œ ๊ฒƒ๋“ค์„ importํ–ˆ์œผ๋‹ˆ setActorDispatcher() ๋ฉ”์„œ๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค:

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE users.username = ?
      `,
    )
    .get(identifier);
  if (user == null) return null;

  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: user.name,
    inbox: ctx.getInboxUri(identifier),
    endpoints: new Endpoints({
      sharedInbox: ctx.getInboxUri(),
    }),
    url: ctx.getActorUri(identifier),
  });
});

๋ฐ”๋€ ์ฝ”๋“œ์—์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ users ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์—ฌ ํ˜„์žฌ ์„œ๋ฒ„์— ์žˆ๋Š” ๊ณ„์ •์ด ์•„๋‹ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, GET /users/johndoe (๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ์ •ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ๊ฒฝ์šฐ) ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” ์˜ฌ๋ฐ”๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์™€ ํ•จ๊ป˜ ์‘๋‹ตํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ์™ธ์˜ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” 404 Not Found๋ฅผ ์‘๋‹ตํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Person ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ถ€๋ถ„๋„ ์–ด๋–ป๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‚˜ ์‚ดํŽด๋ด…์‹œ๋‹ค. ๋จผ์ € name ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” actors.name ์นผ๋Ÿผ์˜ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. inbox์™€ endpoints ์†์„ฑ์€ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. url ์†์„ฑ์€ ์ด ๊ณ„์ •์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด๋Š”๋ฐ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•กํ„ฐ ID์™€ ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ์ผ์น˜์‹œํ‚ค๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

๋ˆˆ์ฐ๋ฏธ๊ฐ€ ์ข‹์€ ๋ถ„๋“ค์€ ๋ˆˆ์น˜์ฑ„์…จ๊ฒ ์ง€๋งŒ, Hono์™€ Fedify ์–‘์ชฝ์—์„œ GET /users/{identifier}์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฒน์ณ์„œ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ํ•ด๋‹น ์š”์ฒญ์„ ์‹ค์ œ๋กœ ๋ณด๋‚ด๋ฉด ์–ด๋А ์ชฝ์—์„œ ์‘๋‹ตํ•˜๊ฒŒ ๋ ๊นŒ์š”? ์ •๋‹ต์€ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Accept: text/html ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Hono ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. Accept: application/activity+json ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Fedify ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต์„ ์ฃผ๋Š” ๋ฐฉ์‹์„ HTTP ๋‚ด์šฉ ํ˜‘์ƒ(content negotiation)์ด๋ผ๊ณ  ํ•˜๋ฉฐ, Fedify ์ž์ฒด์—์„œ ๋‚ด์šฉ ํ˜‘์ƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์ข€ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ๋Š”, ๋ชจ๋“  ์š”์ฒญ์€ Fedify๋ฅผ ํ•œ ๋ฒˆ ๊ฑฐ์น˜๊ฒŒ ๋˜๋ฉฐ, ActivityPub๊ณผ ๊ด€๋ จ๋œ ์š”์ฒญ์ด ์•„๋‹ ๊ฒฝ์šฐ ์—ฐ๋™๋œ ํ”„๋ ˆ์ž„์›Œํฌ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Hono์—๊ฒŒ ์š”์ฒญ์„ ๊ฑด๋‚ด์ฃผ๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

Fedify์—์„œ๋Š” ๋ชจ๋“  URI ๋ฐ URL์„ URL ์ธ์Šคํ„ด์Šค๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ทธ๋Ÿผ ํ•œ ๋ฒˆ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ํ…Œ์ŠคํŠธํ•ด ๋ณผ๊นŒ์š”?

์„œ๋ฒ„๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด์–ด ์•„๋ž˜ ๋ช…๋ น์„ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/alice

alice์ด๋ผ๋Š” ๊ณ„์ •์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์•„๊นŒ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Failed to fetch the object.
It may be a private object.  Try with -a/--authorized-fetch.

๊ทธ๋Ÿผ johndoe ๊ณ„์ •๋„ ์กฐํšŒํ•ด ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์ด์ œ๋Š” ๊ฒฐ๊ณผ๊ฐ€ ์ž˜ ๋‚˜์˜ต๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

์•”ํ˜ธ ํ‚ค ์Œ๋“ค

๊ทธ ๋‹ค์Œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์„œ๋ช…์„ ์œ„ํ•œ ์•กํ„ฐ์˜ ์•”ํ˜ธ ํ‚ค๋“ค์ž…๋‹ˆ๋‹ค. ActivityPub์€ ์•กํ„ฐ๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋Š”๋ฐ, ์ด ๋•Œ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ •๋ง๋กœ ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ๋งŒ๋“ค์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋””์ง€ํ„ธ ์„œ๋ช…์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์•กํ„ฐ๋Š” ์ง์ด ๋งž๋Š” ์ž์‹ ๋งŒ์˜ ๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค) ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ๋งŒ๋“ค์–ด ๊ฐ–๊ณ  ์žˆ๊ณ , ๊ทธ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๋„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๊ณต๊ฐœํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ๋“ค์€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ ๋ฐœ์‹ ์ž์˜ ๊ณต๊ฐœ ํ‚ค์™€ ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์„œ๋ช…์„ ๋Œ€์กฐํ•˜์—ฌ ๊ทธ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •๋ง๋กœ ๋ฐœ์‹ ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒŒ ๋งž๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ช…๊ณผ ์„œ๋ช… ๋Œ€์กฐ๋Š” Fedify๊ฐ€ ์•Œ์•„์„œ ํ•ด ์ฃผ์ง€๋งŒ, ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ณด์กดํ•˜๋Š” ๊ฒƒ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์…”์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค)๋Š” ์ด๋ฆ„์—์„œ ๋“œ๋Ÿฌ๋‚˜๋“ฏ ์„œ๋ช…ํ•  ์ฃผ์ฒด ์ด์™ธ์—๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, ๊ณต๊ฐœ ํ‚ค๋Š” ๊ทธ ์šฉ๋„ ์ž์ฒด๊ฐ€ ๊ณต๊ฐœํ•˜๊ธฐ ์œ„ํ•จ์ด๋ฏ€๋กœ ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ €์žฅํ•  keys ํ…Œ์ด๋ธ”์„ src/schema.sql์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS keys (
  user_id     INTEGER NOT NULL REFERENCES users (id),
  type        TEXT    NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
  private_key TEXT    NOT NULL CHECK (private_key <> ''),
  public_key  TEXT    NOT NULL CHECK (public_key <> ''),
  created     TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
  PRIMARY KEY (user_id, type)
);

ํ…Œ์ด๋ธ”์„ ์œ ์‹ฌํžˆ ์‚ดํŽด๋ณด๋ฉด, type ์นผ๋Ÿผ์—๋Š” ์˜ค์ง ๋‘ ์ข…๋ฅ˜์˜ ๊ฐ’๋งŒ ํ—ˆ์šฉ๋œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜๋‚˜๋Š” RSA-PKCS#1-v1.5 ํ˜•์‹์ด๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” Ed25519 ํ˜•์‹์ž…๋‹ˆ๋‹ค. (๊ฐ๊ฐ์ด ๋ฌด์—‡์„ ๋œปํ•˜๋Š”์ง€๋Š” ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ค‘์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.) ๊ธฐ๋ณธ ํ‚ค๊ฐ€ (user_id, type)์— ๊ฑธ๋ ค ์žˆ์œผ๋‹ˆ, ํ•œ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•ด ์ตœ๋Œ€ ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์ˆ˜๋Š” ์—†์ง€๋งŒ, 2024๋…„ 9์›” ํ˜„์žฌ ActivityPub ๋„คํŠธ์›Œํฌ๋Š” RSA-PKCS-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-v1.5 ํ˜•์‹๋งŒ ๋ฐ›์•„๋“ค์ด๊ณ  ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” Ed25519 ํ˜•์‹์„ ๋ฐ›์•„๋“ค์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์–‘์ชฝ ๋ชจ๋‘์™€ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ๋ชจ๋‘ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

private_key ๋ฐ public_key ์นผ๋Ÿผ์€ ๋ฌธ์ž์—ด์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์— JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์„ ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. ๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค๋ฅผ JSON์œผ๋กœ ์ธ์ฝ”๋”ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋’ค์—์„œ ์ฐจ์ฐจ ๋‹ค๋ฃจ๊ฒŒ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ keys ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

keys ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  Key ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Key {
  user_id: number;
  type: "RSASSA-PKCS1-v1_5" | "Ed25519";
  private_key: string;
  public_key: string;
  created: string;
}

ํ‚ค ์Œ ๋””์ŠคํŒจ์ฒ˜

์ด์ œ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด๊ณ  Fedify์—์„œ ์ œ๊ณต๋˜๋Š” exportJwk(), generateCryptoKeyPair(), importJwk() ํ•จ์ˆ˜๋“ค๊ณผ ์•ž์„œ ์ •์˜ํ•œ Key ํƒ€์ž…์„ importํ•ฉ์‹œ๋‹ค:

import {
  Endpoints,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ๋ถ€๋ถ„์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User & Actor>(
        `
        SELECT * FROM users
        JOIN actors ON (users.id = actors.user_id)
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    if (user == null) return null;

    const keys = await ctx.getActorKeyPairs(identifier);
    return new Person({
      id: ctx.getActorUri(identifier),
      preferredUsername: identifier,
      name: user.name,
      inbox: ctx.getInboxUri(identifier),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      url: ctx.getActorUri(identifier),
      publicKey: keys[0].cryptographicKey,
      assertionMethods: keys.map((k) => k.multikey),
    });
  })
  .setKeyPairsDispatcher(async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
      .get(identifier);
    if (user == null) return [];
    const rows = db
      .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
      .all(user.id);
    const keys = Object.fromEntries(
      rows.map((row) => [row.type, row]),
    ) as Record<Key["type"], Key>;
    const pairs: CryptoKeyPair[] = [];
    // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์›ํ•˜๋Š” ๋‘ ํ‚ค ํ˜•์‹ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์— ๋Œ€ํ•ด
    // ํ‚ค ์Œ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ:
    for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
      if (keys[keyType] == null) {
        logger.debug(
          "The user {identifier} does not have an {keyType} key; creating one...",
          { identifier, keyType },
        );
        const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
        db.prepare(
          `
          INSERT INTO keys (user_id, type, private_key, public_key)
          VALUES (?, ?, ?, ?)
          `,
        ).run(
          user.id,
          keyType,
          JSON.stringify(await exportJwk(privateKey)),
          JSON.stringify(await exportJwk(publicKey)),
        );
        pairs.push({ privateKey, publicKey });
      } else {
        pairs.push({
          privateKey: await importJwk(
            JSON.parse(keys[keyType].private_key),
            "private",
          ),
          publicKey: await importJwk(
            JSON.parse(keys[keyType].public_key),
            "public",
          ),
        });
      }
    }
    return pairs;
  });

์šฐ์„  ๊ฐ€์žฅ ๋จผ์ € ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๊ฒƒ์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์— ์—ฐ๋‹ฌ์•„ ํ˜ธ์ถœ๋˜๊ณ  ์žˆ๋Š” setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜์—์„œ ๋ฐ˜ํ™˜๋œ ํ‚ค ์Œ๋“ค์„ ๊ณ„์ •์— ์—ฐ๊ฒฐํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ‚ค ์Œ๋“ค์„ ์—ฐ๊ฒฐํ•ด์•ผ Fedify๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ•  ๋•Œ ์ž๋™์œผ๋กœ ๋“ฑ๋ก๋œ ๊ฐœ์ธ ํ‚ค๋“ค๋กœ ๋””์ง€ํ„ธ ์„œ๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

generateCryptoKeyPair() ํ•จ์ˆ˜๋Š” ์ƒˆ๋กœ์šด ๊ฐœ์ธ ํ‚ค ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜์—ฌ CryptoKeyPair ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋กœ CryptoKeyPair ํƒ€์ž…์€ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ˜•์‹์ž…๋‹ˆ๋‹ค.

exportJwk() ํ•จ์ˆ˜๋Š” CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JWK ํ˜•์‹์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ์•”ํ˜ธ ํ‚ค๋ฅผ JSON์œผ๋กœ ํ‘œํ˜„ํ•˜๋Š” ํ‘œ์ค€์ ์ธ ํ˜•์‹์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. CryptoKey๋Š” ์•”ํ˜ธ ํ‚ค๋ฅผ JavaScript ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์›น ํ‘œ์ค€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.

importJwk() ํ•จ์ˆ˜๋Š” JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„๋œ ํ‚ค๋ฅผ CryptoKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. exportJwk() ํ•จ์ˆ˜์˜ ๋ฐ˜๋Œ€๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ setActorDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ˆˆ์„ ๋Œ๋ฆฝ์‹œ๋‹ค. getActorKeyPairs()๋ผ๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์“ฐ์ด๊ณ  ์žˆ๋Š”๋ฐ, ์ด ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฆ„๊ณผ ๊ฐ™์ด ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์€ ๋ฐ”๋กœ ์•ž์—์„œ ์‚ดํŽด๋ณธ setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ถˆ๋Ÿฌ์˜จ ๋ฐ”๋กœ ๊ทธ ํ‚ค ์Œ๋“ค์ž…๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” RSA-PKCS-v1.5์™€ Ed25519 ํ˜•์‹์œผ๋กœ ๋œ ๋‘ ์Œ์˜ ํ‚ค๋ฅผ ๋ถˆ๋Ÿฌ์™”์œผ๋ฏ€๋กœ, getActorKeyPairs() ๋ฉ”์„œ๋“œ๋Š” ๋‘ ํ‚ค ์Œ์˜ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ฐฐ์—ด์˜ ์›์†Œ๋Š” ํ‚ค ์Œ์„ ์—ฌ๋Ÿฌ ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด์ธ๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

interface ActorKeyPair {
  privateKey: CryptoKey;              // ๊ฐœ์ธ ํ‚ค
  publicKey: CryptoKey;               // ๊ณต๊ฐœ ํ‚ค
  keyId: URL;                         // ํ‚ค์˜ ๊ณ ์œ  ์‹๋ณ„ URI
  cryptographicKey: CryptographicKey; // ๊ณต๊ฐœ ํ‚ค์˜ ๋‹ค๋ฅธ ํ˜•์‹
  multikey: Multikey;                 // ๊ณต๊ฐœ ํ‚ค์˜ ๋˜ ๋‹ค๋ฅธ ํ˜•์‹
}

CryptoKey์™€ CryptographicKey์™€ Multikey๊ฐ€ ๊ฐ๊ฐ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ์ง€, ์™œ ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ ํ˜•์‹์ด ์žˆ์–ด์•ผ ํ•˜๋Š”์ง€๋Š” ์ด ์ž๋ฆฌ์—์„œ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋ณต์žกํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ง€๊ธˆ์€ Person ๊ฐ์ฒด๋ฅผ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ publicKey ์†์„ฑ์€ CryptographicKey ํ˜•์‹์„ ๋ฐ›๊ณ  assertionMethods ์†์„ฑ์€ MultiKey[] (Multikey์˜ ๋ฐฐ์—ด์„ TypeScript์—์„œ ์ด๋ ‡๊ฒŒ ํ‘œ๊ธฐ) ํ˜•์‹์„ ๋ฐ›๋Š”๋‹ค๋Š” ๊ฒƒ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•ฉ์‹œ๋‹ค.

๊ทธ๋‚˜์ €๋‚˜, Person ๊ฐ์ฒด์—๋Š” ์™œ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๊ฐ–๋Š” ์†์„ฑ์ด publicKey์™€ assertionMethods๋กœ ๋‘ ๊ฐœ๋‚˜ ์žˆ์„๊นŒ์š”? ActivityPub์—๋Š” ์›๋ž˜ publicKey ์†์„ฑ๋งŒ ์žˆ์—ˆ์ง€๋งŒ, ๋‚˜์ค‘์— ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก assertionMethods ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-v1.5 ํ‚ค ์Œ์ด, ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— Ed25519 ํ‚ค ์Œ์ด ๋“ค์–ด๊ฐ).

ํŒ

์‚ฌ์‹ค publicKey ์†์„ฑ๋„ ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋‹ด์„ ์ˆ˜๋Š” ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด๋“ค์ด ์ด๋ฏธ publicKey ์†์„ฑ์—๋Š” ๋‹จ ํ•˜๋‚˜์˜ ํ‚ค๋งŒ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ด๋ผ๋Š” ์ „์ œ ํ•˜์— ๊ตฌํ˜„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜ค์ž‘๋™ํ•  ๋•Œ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assertionMethods๋ผ๋Š” ์ƒˆ๋กœ์šด ์†์„ฑ์ด ์ œ์•ˆ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ด์— ๊ด€ํ•ด ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์‹  ๋ถ„๋“ค์€ FEP-521a ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ…Œ์ŠคํŠธ

์ž, ์•กํ„ฐ ๊ฐ์ฒด์— ์•”ํ˜ธ ํ‚ค๋“ค์„ ๋“ฑ๋กํ–ˆ์œผ๋ฏ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

fedify lookup http://localhost:8000/users/johndoe

์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  publicKey: CryptographicKey {
    id: URL "http://localhost:8000/users/johndoe#main-key",
    owner: URL "http://localhost:8000/users/johndoe",
    publicKey: CryptoKey {
      type: "public",
      extractable: true,
      algorithm: {
        name: "RSASSA-PKCS1-v1_5",
        modulusLength: 4096,
        publicExponent: Uint8Array(3) [ 1, 0, 1 ],
        hash: { name: "SHA-256" }
      },
      usages: [ "verify" ]
    }
  },
  assertionMethods: [
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#main-key",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: {
          name: "RSASSA-PKCS1-v1_5",
          modulusLength: 4096,
          publicExponent: Uint8Array(3) [ 1, 0, 1 ],
          hash: { name: "SHA-256" }
        },
        usages: [ "verify" ]
      }
    },
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#key-2",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: { name: "Ed25519" },
        usages: [ "verify" ]
      }
    }
  ],
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

Person ๊ฐ์ฒด์˜ publicKey ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ Multikey ๊ฐ์ฒด๊ฐ€ ๋‘˜ ๋“ค์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Mastodon๊ณผ ์—ฐ๋™

์ด์ œ ์‹ค์ œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค.

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ

์•„์‰ฝ๊ฒŒ๋„ ํ˜„์žฌ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ๋งˆ๋‹ค ์–ด๋”˜๊ฐ€์— ๋ฐฐํฌํ•ด์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํŽธํ•˜๊ฒ ์ฃ . ๋ฐฐํฌํ•˜์ง€ ์•Š๊ณ  ๋ฐ”๋กœ ์ธํ„ฐ๋„ท์— ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๋…ธ์ถœํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์–ผ๋งˆ๋‚˜ ์ข‹์„๊นŒ์š”?

์—ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋Ÿด ๋•Œ ์“ฐ๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ํ„ฐ๋ฏธ๋„์—์„œ ์ƒˆ ํƒญ์„ ์—ฐ ๋’ค, ์ด ๋ช…๋ น์–ด ๋’ค์— ๋กœ์ปฌ ์„œ๋ฒ„์˜ ํฌํŠธ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค:

fedify tunnel 8000

๊ทธ๋Ÿฌ๋ฉด ํ•œ ๋ฒˆ ์“ฐ๊ณ  ๋ฒ„๋ฆด ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๋งŒ๋“ค์–ด์„œ ๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์ค‘๊ณ„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Your local server at 8000 is now publicly accessible:

https://temp-address.serveo.net/

Press ^C to close the tunnel.

๋ฌผ๋ก , ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ๋Š” ์œ„ URL๊ณผ๋Š” ๋‹ค๋ฅธ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๊ณ ์œ ํ•œ URL์ด ์ถœ๋ ฅ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/users/johndoe(์—ฌ๋Ÿฌ๋ถ„์˜ ๊ณ ์œ  ์ž„์‹œ ๋„๋ฉ”์ธ์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ์—ด์–ด์„œ ์ž˜ ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์œผ๋กœ ๋…ธ์ถœ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์œ„ ์›น ํŽ˜์ด์ง€์— ๋ณด์ด๋Š” ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ณต์‚ฌํ•œ ๋’ค, Mastodon์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋ณด๋Š” ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ๊นŒ์ง€์ž…๋‹ˆ๋‹ค. ์•„์ง ํŒ”๋กœ๋Š” ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ์‹œ๋„ํ•˜์ง€ ๋งˆ์„ธ์š”! ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•  ์ˆ˜ ์žˆ์œผ๋ ค๋ฉด, ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์•ˆ๋‚ด

fedify tunnel ๋ช…๋ น์€ ํ•œ๋™์•ˆ ์“ฐ์ด์ง€ ์•Š์œผ๋ฉด ์ €์ ˆ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊น๋‹ˆ๋‹ค. ๊ทธ๋Ÿด ๋•Œ๋Š”, Ctrl+C ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ˆ ๋‹ค์Œ, fedify tunnel 8000 ๋ช…๋ น์„ ๋‹ค์‹œ ์ณ์„œ ์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ์„ ๋งบ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ

ActivityPub์—์„œ ์ˆ˜์‹ ํ•จ(inbox)์€ ์•กํ„ฐ๊ฐ€ ๋‹ค๋ฅธ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์•กํ„ฐ๋Š” ์ž์‹ ์˜ ์ˆ˜์‹ ํ•จ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋Š” HTTP POST ์š”์ฒญ์„ ํ†ตํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฑฐ๋‚˜, ๊ธ€์„ ์“ฐ๊ฑฐ๋‚˜, ๋Œ“๊ธ€์„ ๋‹ค๋Š” ๋“ฑ์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ•  ๋•Œ ํ•ด๋‹น ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์ˆ˜์‹ ์ž์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์˜จ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ ์ ˆํžˆ ์‘๋‹ตํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ  ์—ฐํ•ฉ ๋„คํŠธ์›Œํฌ์˜ ์ผ๋ถ€๋กœ ๊ธฐ๋Šฅํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์€ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์ง€๊ธˆ์€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

์ž์‹ ์„ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์›Œ)๊ณผ ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์ž‰)์„ ๋‹ด๊ธฐ ์œ„ํ•ด src/schema.sql ํŒŒ์ผ์— follows ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS follows (
  following_id INTEGER          REFERENCES actors (id),
  follower_id  INTEGER          REFERENCES actors (id),
  created      TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                CHECK (created <> ''),
  PRIMARY KEY (following_id, follower_id)
);

์ด๋ฒˆ์—๋„ src/schema.sql์„ ์‹คํ–‰ํ•˜์—ฌ follows ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

src/schema.ts ํŒŒ์ผ์„ ์—ด๊ณ  follows ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…๋„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Follow {
  following_id: number;
  follower_id: number;
  created: string;
}

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

์ด์ œ ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ์‹ค์€ ์•ž์„œ ์ด๋ฏธ src/federation.ts ํŒŒ์ผ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋ฐ” ์žˆ์Šต๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

์œ„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ์— ์•ž์„œ, Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Accept ๋ฐ Follow ํด๋ž˜์Šค์™€ getActorHandle() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  setInboxListeners() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    if (follow.objectId == null) {
      logger.debug("The Follow object does not have an object: {follow}", {
        follow,
      });
      return;
    }
    const object = ctx.parseUri(follow.objectId);
    if (object == null || object.type !== "actor") {
      logger.debug("The Follow object's object is not an actor: {follow}", {
        follow,
      });
      return;
    }
    const follower = await follow.getActor();
    if (follower?.id == null || follower.inboxId == null) {
      logger.debug("The Follow object does not have an actor: {follow}", {
        follow,
      });
      return;
    }
    const followingId = db
      .prepare<unknown[], Actor>(
        `
        SELECT * FROM actors
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(object.identifier)?.id;
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
      return;
    }
    const followerId = db
      .prepare<unknown[], Actor>(
        `
        -- ํŒ”๋กœ์›Œ ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        follower.id.href,
        await getActorHandle(follower),
        follower.name?.toString(),
        follower.inboxId.href,
        follower.endpoints?.sharedInbox?.href,
        follower.url?.href,
      )?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    const accept = new Accept({
      actor: follow.objectId,
      to: follow.actorId,
      object: follow,
    });
    await ctx.sendActivity(object, follower, accept);
  });

์ž, ์ฝ”๋“œ๋ฅผ ์ฐฌ์ฐฌํžˆ ์‚ดํŽด๋ด…์‹œ๋‹ค. on() ๋ฉ”์„œ๋“œ๋Š” ํŠน์ •ํ•œ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ๋œปํ•˜๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํŒ”๋กœ์›Œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ ๋’ค, ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ ์ˆ˜๋ฝ์„ ๋œปํ•˜๋Š” Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋‚ด๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

follow.objectId์—๋Š” ํŒ”๋กœ ๋Œ€์ƒ์ธ ์•กํ„ฐ์˜ URI๊ฐ€ ๋“ค์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด ์•ˆ์— ๋“  URI๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

getActorHandle() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ์•กํ„ฐ ๊ฐ์ฒด๋กœ๋ถ€ํ„ฐ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๊ตฌํ•˜์—ฌ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์•„์ง ์—†๋‹ค๋ฉด ๋จผ์ € ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ๋’ค, follows ํ…Œ์ด๋ธ”์— ํŒ”๋กœ์›Œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋ก์ด ์™„๋ฃŒ๋˜๋ฉด, sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ฒซ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐœ์‹ ์ž, ๋‘˜์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ˆ˜์‹ ์ž, ์…‹์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ผ ์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy

์ž, ๊ทธ๋Ÿผ ํŒ”๋กœ ์š”์ฒญ์ด ์ œ๋Œ€๋กœ ์ˆ˜์‹ ๋˜๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

๋ณดํ†ต์˜ Mastodon ์„œ๋ฒ„์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๊ดœ์ฐฎ๊ธด ํ•˜์ง€๋งŒ, ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ์˜ค๊ฐ€๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ActivityPub.Academy ์„œ๋ฒ„๋ฅผ ์ด์šฉํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ActivityPub.Academy๋Š” ๊ต์œก ๋ฐ ๋””๋ฒ„๊น… ์šฉ๋„์˜ ํŠน์ˆ˜ํ•œ Mastodon ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ show source๋ฅผ ๋ˆ„๋ฅธ ํ™”๋ฉด

ํ…Œ์ŠคํŠธ

์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ๊ฑธ ํ™•์ธํ–ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ์ €ํฌ๊ฐ€ ์ง  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๋จผ์ € follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€ ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค (๋ฌผ๋ก , ์‹œ๊ฐ์€ ๋‹ค๋ฅด๊ฒ ์ฃ ?):

following_id follower_id created
1 2 2024-09-01 10:19:41

๊ณผ์—ฐ actors ํ…Œ์ด๋ธ”์—๋„ ์ƒˆ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id user_id uri handle name inbox_url shared_inbox_url url created
2 https://activitypub.academy/users/dobussia_dovornath @dobussia_dovornath@activitypub.academy Dobussia Dovornath https://activitypub.academy/users/dobussia_dovornath/inbox https://activitypub.academy/inbox https://activitypub.academy/@dobussia_dovornath 2024-09-01 10:19:41

๋‹ค์‹œ, ActivityPub.Academy์˜ Activity Log๋ฅผ ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์—์„œ ๋ณด๋‚ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์œ„์™€ ๊ฐ™์ด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
  "type": "Undo",
  "actor": "https://activitypub.academy/users/dobussia_dovornath",
  "object": {
    "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
    "type": "Follow",
    "actor": "https://activitypub.academy/users/dobussia_dovornath",
    "object": "https://temp-address.serveo.net/users/johndoe"
  }
}

์œ„ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ์•„๊นŒ ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์™”๋˜ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ˆ˜์‹ ํ•จ์—์„œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์•„๋ฌด ๊ฒƒ๋„ ์ •์˜ํ•˜์ง€ ์•Š์•˜๊ธฐ์— ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํŒ”๋กœ ์ทจ์†Œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Undo ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  on(Follow, ...) ๋’ค์— ์—ฐ๋‹ฌ์•„ on(Undo, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต๋จ ...
  })
  .on(Undo, async (ctx, undo) => {
    const object = await undo.getObject();
    if (!(object instanceof Follow)) return;
    if (undo.actorId == null || object.objectId == null) return;
    const parsed = ctx.parseUri(object.objectId);
    if (parsed == null || parsed.type !== "actor") return;
    db.prepare(
      `
      DELETE FROM follows
      WHERE following_id = (
        SELECT actors.id
        FROM actors
        JOIN users ON actors.user_id = users.id
        WHERE users.username = ?
      ) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
      `,
    ).run(parsed.identifier, undo.actorId.href);
  });

์ด๋ฒˆ์—๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋ณด๋‹ค ์ฝ”๋“œ๊ฐ€ ์งง์Šต๋‹ˆ๋‹ค. Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ๋“  ๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์ธ์ง€ ํ™•์ธํ•œ ๋’ค, parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์ทจ์†Œํ•˜๋ ค๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ํŒ”๋กœ ๋Œ€์ƒ์ด ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•˜๊ณ , follows ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์•„๊นŒ ActivityPub.Academy์—์„œ ์ด๋ฏธ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ฒ„๋ ค์„œ ํ•œ ๋ฒˆ ๋” ์–ธํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์–ด์ฉ” ์ˆ˜ ์—†์ด ๋‹ค์‹œ ํŒ”๋กœํ•œ ๋’ค, ์–ธํŒ”๋กœํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทธ์— ์•ž์„œ, follows ํ…Œ์ด๋ธ”์„ ๋น„์›Œ ์ค„ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํŒ”๋กœ ์š”์ฒญ์ด ์™”์„ ๋•Œ ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฏ€๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

sqlite3 ๋ช…๋ น์–ด๋ฅผ ์ด์šฉํ•ด follows ํ…Œ์ด๋ธ”์„ ๋น„์›์‹œ๋‹ค:

echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

following_id follower_id created
1 2 2024-09-02 01:05:17

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ sqlite3 ๋ช…๋ น์œผ๋กœ ๋ณด๋Š” ๊ฑด ์„ฑ๊ฐ€์‹œ๋‹ˆ, ์›น์œผ๋กœ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ์‹œ๋‹ค.

์šฐ์„  src/views.tsx ํŒŒ์ผ์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. Actor ํƒ€์ž…์„ importํ•ด์ฃผ์„ธ์š”:

import type { Actor } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <FollowerList> ์ปดํฌ๋„ŒํŠธ์™€ <ActorLink> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowerListProps {
  followers: Actor[];
}

export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
  <>
    <h2>Followers</h2>
    <ul>
      {followers.map((follower) => (
        <li key={follower.id}>
          <ActorLink actor={follower} />
        </li>
      ))}
    </ul>
  </>
);

export interface ActorLinkProps {
  actor: Actor;
}

export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
  const href = actor.url ?? actor.uri;
  return actor.name == null ? (
    <a href={href} class="secondary">
      {actor.handle}
    </a>
  ) : (
    <>
      <a href={href}>{actor.name}</a>{" "}
      <small>
        (
        <a href={href} class="secondary">
          {actor.handle}
        </a>
        )
      </small>
    </>
  );
};

<ActorLink> ์ปดํฌ๋„ŒํŠธ๋Š” ํ•˜๋‚˜์˜ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋„ŒํŠธ๋Š” <ActorList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ž…๋‹ˆ๋‹ค. ๋ณด๋‹ค์‹œํ”ผ JSX์—๋Š” ์กฐ๊ฑด๋ฌธ์ด๋‚˜ ๋ฐ˜๋ณต๋ฌธ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ผํ•ญ ์—ฐ์‚ฐ์ž์™€ Array.map() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ญ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowerList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/followers์— ๋Œ€ํ•œ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/followers", async (c) => {
  const followers = db
    .prepare<unknown[], Actor>(
      `
      SELECT followers.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = following.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowerList followers={followers} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ, ์ž˜ ๋ณด์ด๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ํŒ”๋กœ์›Œ๊ฐ€ ์žˆ์–ด์•ผ ํ• ํ…Œ๋‹ˆ, fedify tunnel์„ ์ผ  ์ฑ„๋กœ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„๋‚˜ ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•ฉ์‹œ๋‹ค. ํŒ”๋กœ ์š”์ฒญ์ด ์ˆ˜๋ฝ๋œ ๋’ค ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/followers ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

ํŒ”๋กœ์›Œ ๋ชฉ๋ก ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ํŒ”๋กœ์›Œ ์ˆ˜๋„ ํ‘œ์‹œํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ๋‹ค์‹œ ์—ด๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

ProfileProps์—๋Š” ๋‘ ๊ฐœ์˜ ํ”„๋กญ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. followers๋Š” ๋ง ๊ทธ๋Œ€๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋‹ด๋Š” ํ”„๋กญ์ž…๋‹ˆ๋‹ค. username์€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์œผ๋กœ ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์œ„ํ•ด URL์— ๋“ค์–ด๊ฐˆ ์•„์ด๋””๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ src/app.tsx ํŒŒ์ผ๋กœ ๋Œ์•„๊ฐ€, GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      JOIN actors ON follows.following_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        followers={followers}
      />
    </Layout>,
  );
});

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ˆ์˜ follows ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋ฅผ ์„ธ๋Š” SQL์ด ์ถ”๊ฐ€๋˜์—ˆ๊ตฐ์š”. ์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜

๊ทธ๋Ÿฐ๋ฐ ํ•œ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ActivityPub.Academy๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด๋ด…์‹œ๋‹ค. (์กฐํšŒํ•˜๋Š” ๋ฒ•์€ ์ด์ œ ๋‹ค ์•„์‹œ์ฃ ? ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ๋œ ์ƒํƒœ์—์„œ, ์•กํ„ฐ ํ•ธ๋“ค์„ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ์น˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.) Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Mastodon์—์„œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

๋ฐ”๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ 0์œผ๋กœ ๋‚˜์˜จ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ActivityPub์„ ํ†ตํ•ด ๋…ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋ ค๋ฉด ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Recipient ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์•„๋ž˜์ชฝ์— ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setFollowersDispatcher(
    "/users/{identifier}/followers",
    (ctx, identifier, cursor) => {
      const followers = db
        .prepare<unknown[], Actor>(
          `
          SELECT followers.*
          FROM follows
          JOIN actors AS followers ON follows.follower_id = followers.id
          JOIN actors AS following ON follows.following_id = following.id
          JOIN users ON users.id = following.user_id
          WHERE users.username = ?
          ORDER BY follows.created DESC
          `,
        )
        .all(identifier);
      const items: Recipient[] = followers.map((f) => ({
        id: new URL(f.uri),
        inboxId: new URL(f.inbox_url),
        endpoints:
          f.shared_inbox_url == null
            ? null
            : { sharedInbox: new URL(f.shared_inbox_url) },
      }));
      return { items };
    },
  )
  .setCounter((ctx, identifier) => {
    const result = db
      .prepare<unknown[], { cnt: number }>(
        `
        SELECT count(*) AS cnt
        FROM follows
        JOIN actors ON actors.id = follows.following_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    return result == null ? 0 : result.cnt;
  });

setFollowersDispatcher() ๋ฉ”์„œ๋“œ์—์„œ๋Š” GET /users/{identifier}/followers ์š”์ฒญ์ด ์™”์„ ๋•Œ ์‘๋‹ตํ•  ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. SQL์ด ์กฐ๊ธˆ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ •๋ฆฌํ•˜์ž๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. items์—๋Š” Recipient ๊ฐ์ฒด๋“ค์„ ๋‹ด๋Š”๋ฐ, Recipient ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

export interface Recipient {
  readonly id: URL | null;
  readonly inboxId: URL | null;
  readonly endpoints?: {
    sharedInbox: URL | null;
  } | null;
}

id ์†์„ฑ์—๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  IRI๊ฐ€ ๋“ค์–ด๊ฐ€๊ณ , inboxId์—๋Š” ์•กํ„ฐ์˜ ๊ฐœ์ธ ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. endpoints.sharedInbox์—๋Š” ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” actors ํ…Œ์ด๋ธ”์— ๊ทธ ๋ชจ๋“  ์ •๋ณด๋ฅผ ๋‹ค ๋‹ด๊ณ  ์žˆ์œผ๋‹ˆ, ํ•ด๋‹น ์ •๋ณด๋“ค๋กœ items ๋ฐฐ์—ด์„ ์ฑ„์›Œ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

setCounter() ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์˜ ์ „์ฒด ์ˆ˜๋Ÿ‰์„ ๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋„ SQL์ด ์กฐ๊ธˆ ๋ณต์žกํ•˜๊ธด ํ•˜์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, fedify lookup ๋ช…๋ น์„ ์‚ฌ์šฉํ•ฉ์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe/followers

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
OrderedCollection {
  totalItems: 1,
  items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}

๊ทธ๋Ÿฐ๋ฐ, ์ด๋ ‡๊ฒŒ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ค์–ด ๋†“๊ธฐ๋งŒ ํ•ด์„œ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์–ด๋”” ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์— ๋งํฌ๋ฅผ ๊ฑธ์–ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ... ์ƒ๋žต ...
    return new Person({
      // ... ์ƒ๋žต ...
      followers: ctx.getFollowersUri(identifier), 
    });
  })

์•กํ„ฐ๋„ fedify lookup์œผ๋กœ ์กฐํšŒํ•˜์—ฌ ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์•„๋ž˜์™€ ๊ฐ™์ด ๊ฒฐ๊ณผ์— "followers" ์†์„ฑ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  ... ์ƒ๋žต ...
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  followers: URL "http://localhost:8000/users/johndoe/followers",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋‹ค์‹œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํŒ”๋กœ์›Œ ์ˆ˜๋Š” ์—ฌ์ „ํžˆ 0์œผ๋กœ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . ์ด๋Š” Mastodon์ด ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์บ์‹œ(cache)ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๊ธด ํ•˜์ง€๋งŒ F5 ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์‰ฝ์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค:

  • ํ•œ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์€ ์ผ์ฃผ์ผ์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Mastodon์€ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์บ์‹œ๋ฅผ ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์ดํ›„ 7์ผ์ด ์ง€๋‚  ๋•Œ ๋‚ ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ๋˜ ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์€ Update ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‚ ๋ฆฌ๋Š” ๊ฒƒ์ธ๋ฐ, ๊ท€์ฐฎ์€ ์ฝ”๋”ฉ์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋‹ˆ๋ฉด ์•„์ง ์บ์‹œ๊ฐ€ ๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•ด ๋ณด๋Š” ๊ฒƒ๋„ ํ•œ ๋ฐฉ๋ฒ•์ด๊ฒ ์ฃ .
  • ๋งˆ์ง€๋ง‰ ๋ฐฉ๋ฒ•์€ fedify tunnel์„ ๊ป๋‹ค ์ผœ์„œ ์ƒˆ๋กœ์šด ์ž„์‹œ ๋„๋ฉ”์ธ์„ ํ• ๋‹น ๋ฐ›๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ •ํ™•ํ•œ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์ง์ ‘ ํ™•์ธํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์ œ๊ฐ€ ๋‚˜์—ดํ•œ ๋ฐฉ๋ฒ•๋“ค ์ค‘ ํ•˜๋‚˜๋ฅผ ์‹œ๋„ํ•ด ๋ณด์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ

์ž, ์ด์ œ ๋“œ๋””์–ด ๊ฒŒ์‹œ๋ฌผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์™”์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ๋ธ”๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ž‘์„ฑ๋œ ๊ฒŒ์‹œ๋ฌผ๋„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์—ผ๋‘์— ๋‘๊ณ  ์„ค๊ณ„ํ•ด ๋ด…์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋ฐ”๋กœ posts ํ…Œ์ด๋ธ”๋ถ€ํ„ฐ ๋งŒ๋“ญ์‹œ๋‹ค. src/schema.sql ํŒŒ์ผ์„ ์—ด์–ด ์•„๋ž˜ SQL์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS posts (
  id       INTEGER NOT NULL PRIMARY KEY,
  uri      TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  actor_id INTEGER NOT NULL REFERENCES actors (id),
  content  TEXT    NOT NULL,
  url      TEXT             CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
  created  TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
  • id ์นผ๋Ÿผ์€ ํ…Œ์ด๋ธ”์˜ ๊ธฐ๋ณธ ํ‚ค์ž…๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•ž์„œ ๋งํ–ˆ๋‹ค์‹œํ”ผ ActivityPub ๊ฐ์ฒด๋Š” ๋ชจ๋‘ ๊ณ ์œ ํ•œ URI๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • actor_id ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • content ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์—๋Š” ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œ์‹œํ•˜๋Š” URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ URI์™€ ์›น ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๋Š” ํŽ˜์ด์ง€์˜ URL์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„ ์นผ๋Ÿผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋น„์–ด ์žˆ์„ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์‹œ๊ฐ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.

SQL์„ ์‹คํ–‰ํ•˜์—ฌ posts ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

posts ํ…Œ์ด๋ธ”์— ์ €์žฅ๋  ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•˜๋Š” Post ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Post {
  id: number;
  uri: string;
  actor_id: number;
  content: string;
  url: string | null;
  created: string;
}

์ฒซ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์–‘์‹์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ๊ฒ ์ฃ ? ๊ทธ๋Ÿฌ๊ณ  ๋ณด๋‹ˆ, ์•„์ง๊นŒ์ง€ ์ฒซ ํŽ˜์ด์ง€๋„ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ฒซ ํŽ˜์ด์ง€์— ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      <h1>{user.name}'s microblog</h1>
      <p>
        <a href={`/users/${user.username}`}>{user.name}'s profile</a>
      </p>
    </hgroup>
    <form method="post" action={`/users/${user.username}/posts`}>
      <fieldset>
        <label>
          <textarea name="content" required={true} placeholder="What's up?" />
        </label>
      </fieldset>
      <input type="submit" value="Post" />
    </form>
  </>
);

๊ทธ ๋‹ค์Œ์—๋Š” src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ์ด๋ฏธ ์žˆ๋Š” GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ:

app.get("/", (c) => c.text("Hello, Fedify!"));

์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์ณ์ค๋‹ˆ๋‹ค:

app.get("/", (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT users.*, actors.*
      FROM users
      JOIN actors ON users.id = actors.user_id
      LIMIT 1
      `,
    )
    .get();
  if (user == null) return c.redirect("/setup");

  return c.html(
    <Layout>
      <Home user={user} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด, ํ•œ ๋ฒˆ ์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ๋‚˜์˜ค๋‚˜ ํ™•์ธํ•ฉ์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค:

์ฒซ ํŽ˜์ด์ง€

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ posts ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์œ„ ์ฝ”๋“œ๋Š” ์•„์ง ๋ณ„ ์—ญํ• ์„ ํ•˜์ง„ ์•Š์ง€๋งŒ, ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ ํ˜•์‹์„ ์ •ํ•˜๋Š” ๋ฐ์— ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๋‚˜์ค‘์— ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ActivityPub์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์˜ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ์ฃผ๊ณ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ‰๋ฌธ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ ๋ฐ›์€ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, <, >์™€ ๊ฐ™์€ ๋ฌธ์ž๋“ค์„ HTML์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ HTML ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” stringify-entities ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add stringify-entities

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค.

import { stringifyEntities } from "stringify-entities";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  const username = c.req.param("username");
  const actor = db
    .prepare<unknown[], Actor>(
      `
      SELECT actors.*
      FROM actors
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ?
      `,
    )
    .get(username);
  if (actor == null) return c.redirect("/setup");
  const form = await c.req.formData();
  const content = form.get("content")?.toString();
  if (content == null || content.trim() === "") {
    return c.text("Content is required", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const url: string | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return url;
  })();
  if (url == null) return c.text("Failed to create post", 500);
  return c.redirect(url);
});

ํ‰๋ฒ”ํ•˜๊ฒŒ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์ฝ”๋“œ์ด๊ธด ํ•˜์ง€๋งŒ ํ•œ ๊ฐ€์ง€ ํŠน์ดํ•œ ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œํ˜„ํ•˜๋Š” ActivityPub ๊ฐ์ฒด์˜ URI๋ฅผ ๊ตฌํ•˜๋ ค๋ฉด posts.id๊ฐ€ ๋จผ์ € ๊ฒฐ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, posts.uri ์นผ๋Ÿผ์— https://localhost/๋ผ๋Š” ์ž„์‹œ URI๋ฅผ ๋จผ์ € ์ง‘์–ด ๋„ฃ์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค, ๊ฒฐ์ •๋œ posts.id๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ getObjectUri() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ URI๋ฅผ ๊ตฌํ•ด์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ฐ ๋’ค, ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ์ค‘

Post ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ฉด, ์•ˆํƒ€๊น๊ฒŒ๋„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

404 Not Found

์™œ๋ƒํ•˜๋ฉด ๊ฒŒ์‹œ๋ฌผ ํผ๋จธ๋งํฌ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์•„์ง ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ posts ํ…Œ์ด๋ธ”์—๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋งŒ๋“ค์–ด์กŒ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

๊ทธ๋Ÿผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๋‚˜ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

id uri actor_id content url created
1 http://localhost:8000/users/johndoe/posts/1 1 It's my first post! http://localhost:8000/users/johndoe/posts/1 2024-09-02 08:10:55

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ํ›„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚˜์ง€ ์•Š๋„๋ก, ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด Post ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <PostPage> ์ปดํฌ๋„ŒํŠธ ๋ฐ <PostView> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

export interface PostViewProps {
  post: Post & Actor;
}

export const PostView: FC<PostViewProps> = ({ post }) => (
  <article>
    <header>
      <ActorLink actor={post} />
    </header>
    {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
    <footer>
      <a href={post.url ?? post.uri}>
        <time datetime={new Date(post.created).toISOString()}>
          {post.created}
        </time>
      </a>
    </footer>
  </article>
);

์ด์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์™€ <PostPage> ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  ์•ž์„œ ์ •์˜ํ•œ <PostPage> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  const post = db
    .prepare<unknown[], Post & Actor & User>(
      `
      SELECT users.*, actors.*, posts.*
      FROM posts
      JOIN actors ON actors.id = posts.actor_id
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ? AND posts.id = ?
      `,
    )
    .get(c.req.param("username"), c.req.param("id"));
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      WHERE follows.following_id = ?
      `,
    )
    .get(post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์•„๊นŒ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฌ๋˜ http://localhost:8000/users/johndoe/posts/1 ํŽ˜์ด์ง€๋ฅผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

Note ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜

๊ทธ๋Ÿผ ์ด์ œ ๊ฒŒ์‹œ๋ฌผ์„ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ๋จผ์ € fedify tunnel์„ ์ด์šฉํ•˜์—ฌ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๊ทธ ์ƒํƒœ์—์„œ, Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ธ€์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

๋นˆ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์•ˆํƒ€๊น๊ฒŒ๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด ํ˜•์‹์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด๋กœ ๋…ธ์ถœํ•ด ๋ด…์‹œ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Fedify์—์„œ ์‹œ๊ฐ์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ๋Š” Temporal API๊ฐ€ ์•„์ง Node.js์— ๋‚ด์žฅ๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฅผ ํด๋ฆฌํ•„(polyfill)ํ•ด์ฃผ๋Š” @js-temporal/polyfill ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add @js-temporal/polyfill

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Temporal } from "@js-temporal/polyfill";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” PUBLIC_COLLECTION ์ƒ์ˆ˜๋„ importํ•ฉ๋‹ˆ๋‹ค.

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";
import type {
  Actor,
  Key,
  Post,
  User,
} from "./schema.ts";

๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ์˜ ๊ฒŒ์‹œ๋ฌผ์ฒ˜๋Ÿผ ์งง์€ ๊ธ€์€ ActivityPub์—์„œ ๋ณดํ†ต Note๋กœ ํ‘œํ˜„๋ฉ๋‹ˆ๋‹ค. Note ํด๋ž˜์Šค์— ๋Œ€ํ•œ ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๋Š” ์ด๋ฏธ ๋นˆ ๊ตฌํ˜„์ด๋‚˜๋งˆ ๋งŒ๋“ค์–ด ๋‘์—ˆ์—ˆ์ฃ :

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์ด๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    const post = db
      .prepare<unknown[], Post>(
        `
        SELECT posts.*
        FROM posts
        JOIN actors ON actors.id = posts.actor_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ? AND posts.id = ?
        `,
      )
      .get(values.identifier, values.id);
    if (post == null) return null;
    return new Note({
      id: ctx.getObjectUri(Note, values),
      attribution: ctx.getActorUri(values.identifier),
      to: PUBLIC_COLLECTION,
      cc: ctx.getFollowersUri(values.identifier),
      content: post.content,
      mediaType: "text/html",
      published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
      url: ctx.getObjectUri(Note, values),
    });
  },
);

Note ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ฑ„์›Œ์ง€๋Š” ์†์„ฑ ๊ฐ’๋“ค์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค:

  • attribution ์†์„ฑ์— ctx.getActorUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์˜ ์ž‘์„ฑ์ž๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • to ์†์„ฑ์— PUBLIC_COLLECTION์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ์ „์ฒด ๊ณต๊ฐœ ๊ฒŒ์‹œ๋ฌผ์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • cc ์†์„ฑ์— ctx.getFollowersUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ, ์ด ์ž์ฒด๋กœ๋Š” ํฐ ์˜๋ฏธ๋Š” ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ ํ•œ ๋ฒˆ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ๋ฐœ์‹ 

ํ•˜์ง€๋งŒ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœ ํ•ด๋„, ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด Mastodon ํƒ€์ž„๋ผ์ธ์— ์˜ฌ๋ผ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด Mastodon์ด ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์•Œ์•„์„œ ๋ฐ›์•„๊ฐ€๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์ชฝ์—์„œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜์—ฌ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ ์ƒ์„ฑ์‹œ์— Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Create, Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  // ... ์ƒ๋žต ...
  const ctx = fedi.createContext(c.req.raw, undefined);
  const post: Post | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return post;
  })();
  if (post == null) return c.text("Failed to create post", 500);
  const noteArgs = { identifier: username, id: post.id.toString() };
  const note = await ctx.getObject(Note, noteArgs);
  await ctx.sendActivity(
    { identifier: username },
    "followers",
    new Create({
      id: new URL("#activity", note?.id ?? undefined),
      object: note,
      actors: note?.attributionIds,
      tos: note?.toIds,
      ccs: note?.ccIds,
    }),
  );
  return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});

getObject() ๋ฉ”์„œ๋“œ๋Š” ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” Note ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ object ์†์„ฑ์— ๋„ฃ์Šต๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ˆ˜์‹ ์ž๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” tos (to์˜ ๋ณต์ˆ˜ํ˜•) ๋ฐ ccs (cc์˜ ๋ณต์ˆ˜ํ˜•) ์†์„ฑ์€ Note ๊ฐ์ฒด์™€ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ id๋Š” ์ž„์˜์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ์ง€์–ด๋‚ด์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด์˜ id ์†์„ฑ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URI๊ฐ€ ๋“ค์–ด๊ฐˆ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ๊ณ ์œ ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

sendActivity() ๋ฉ”์„œ๋“œ์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์—๋Š” ์ˆ˜์‹ ์ž๊ฐ€ ๋“ค์–ด๊ฐ€๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ๋Š” "followers"๋ผ๋Š” ํŠน์ˆ˜ํ•œ ์˜ต์…˜์„ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ์˜ต์…˜์„ ์ง€์ •ํ•˜๋ฉด ์•ž์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ชจ๋“  ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ตฌํ˜„์„ ๋๋ƒˆ์œผ๋‹ˆ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ์‹œํ‚จ ์ฑ„, ActivityPub.Academy๋กœ ๋“ค์–ด๊ฐ€ @johndoe@temp-address.serveo.net(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ ํ• ๋‹น๋œ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ํŒ”๋กœํ•ฉ๋‹ˆ๋‹ค. ํŒ”๋กœ์›Œ ๋ชฉ๋ก์—์„œ ํŒ”๋กœ ์š”์ฒญ์ด ํ™•์‹คํžˆ ์ˆ˜๋ฝ๋œ ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ localhost๊ฐ€ ์•„๋‹Œ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ ID๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๊ธฐ์ค€์œผ๋กœ URI๋ฅผ ๊ตฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๊ฐ”๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์˜ Activity Log๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

ActivityPub.Academy์˜ ํƒ€์ž„๋ผ์ธ์—์„œ ์ƒ์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ž˜ ๋ณด์ธ๋‹ค.

ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ๋‚ด ๊ฒŒ์‹œ๋ฌผ ๋ชฉ๋ก

ํ˜„์žฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—๋Š” ์ด๋ฆ„๊ณผ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค, ํŒ”๋กœ์›Œ ์ˆ˜๋งŒ ๋‚˜์˜ฌ ๋ฟ ์ •์ž‘ ๊ฒŒ์‹œ๋ฌผ์€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์„ ๋ณด์—ฌ์ค์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ  <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface PostListProps {
  posts: (Post & Actor)[];
}

export const PostList: FC<PostListProps> = ({ posts }) => (
  <>
    {posts.map((post) => (
      <div key={post.id}>
        <PostView post={post} />
      </div>
    ))}
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ๋ฐฉ๊ธˆ ์ •์˜ํ•œ <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

์ด๋ฏธ ์žˆ๋Š” GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE actors.user_id = ?
      ORDER BY posts.created DESC
      `,
    )
    .all(user.user_id);
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      // ... ์ƒ๋žต ...
      <PostList posts={posts} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

๋ณ€๊ฒฝ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์ด ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ”๋กœ

ํ˜„์žฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์„ ์ˆ˜๋Š” ์žˆ์ง€๋งŒ, ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์—๊ฒŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜๋Š” ์—†์Šต๋‹ˆ๋‹ค. ํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋„ ๋ณผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž, ๊ทธ๋Ÿผ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ์‹œ๋‹ค.

UI ๋จผ์ € ๋งŒ๋“ญ์‹œ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ์ด๋ฏธ ์žˆ๋Š” <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      {/* ... ์ƒ๋žต ... */}
    </hgroup>
    <form method="post" action={`/users/${user.username}/following`}>
      {/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ€ role=group์„ ์š”๊ตฌํ•จ */}
      <fieldset role="group">
        <input
          type="text"
          name="actor"
          required={true}
          placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
        />
        <input type="submit" value="Follow" />
      </fieldset>
    </form>
    <form method="post" action={`/users/${user.username}/posts`}>
      {/* ... ์ƒ๋žต ... */}
    </form>
  </>
);

์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ฒผ์œผ๋‹ˆ ์‹ค์ œ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งค ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Follow ํด๋ž˜์Šค์™€ isActor() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Create,
  Follow,
  isActor,
  Note,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/following ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/following", async (c) => {
  const username = c.req.param("username");
  const form = await c.req.formData();
  const handle = form.get("actor");
  if (typeof handle !== "string") {
    return c.text("Invalid actor handle or URL", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const actor = await lookupObject(handle.trim());
  if (!isActor(actor)) {
    return c.text("Invalid actor handle or URL", 400);
  }
  await ctx.sendActivity(
    { identifier: username },
    actor,
    new Follow({
      actor: ctx.getActorUri(username),
      object: actor.id,
      to: actor.id,
    }),
  );
  return c.text("Successfully sent a follow request");
});

lookupObject() ํ•จ์ˆ˜๋Š” ์•กํ„ฐ๋ฅผ ๋น„๋กฏํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ž…๋ ฅ์œผ๋กœ ActivityPub ๊ฐ์ฒด์˜ ๊ณ ์œ  URI๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ฐ›๊ณ , ์กฐํšŒํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

isActor() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ActivityPub ๊ฐ์ฒด๊ฐ€ ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์ด ์ฝ”๋“œ์—์„œ๋Š” sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์กฐํšŒํ•œ ์•กํ„ฐ์—๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์•„์ง follows ํ…Œ์ด๋ธ”์— ์•„๋ฌด๋Ÿฐ ๋ ˆ์ฝ”๋“œ๋„ ์ถ”๊ฐ€ํ•˜์ง„ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ƒ๋Œ€๋กœ๋ถ€ํ„ฐ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๊ณ  ๋‚˜์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ตฌํ˜„ํ•œ ํŒ”๋กœ ์š”์ฒญ ๊ธฐ๋Šฅ์ด ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒˆ์—๋„ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•ด์•ผ ํ•˜๋ฏ€๋กœ, fedify tunnel ๋ช…๋ น์„ ์ด์šฉํ•ด ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€ ์ƒ์— ๋‚˜ํƒ€๋‚œ ๋„์ฐฉํ•œ ํŒ”๋กœ ์š”์ฒญ

Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํ•˜์ง€๋งŒ ์•„์ง ์ˆ˜์‹ ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์— ๋Œ€ํ•ด ์•„๋ฌด๋Ÿฐ ํ–‰๋™๋„ ์ทจํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ด ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify์—์„œ ์ œ๊ณตํ•˜๋Š” isActor() ํ•จ์ˆ˜ ๋ฐ Actor ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

์ด ์†Œ์Šค ํŒŒ์ผ ์•ˆ์—์„œ Actor ํƒ€์ž…์˜ ์ด๋ฆ„์ด ๊ฒน์น˜๋ฏ€๋กœ APActor๋ผ๋Š” ๋ณ„๋ช…์„ ์ง€์–ด์คฌ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ, ์ฒ˜์Œ ๋งˆ์ฃผํ•œ ์•กํ„ฐ ์ •๋ณด๋ฅผ actors ํ…Œ์ด๋ธ”์— ๋„ฃ๋Š” ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์—ฌ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค. ์•„๋ž˜ ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

async function persistActor(actor: APActor): Promise<Actor | null> {
  if (actor.id == null || actor.inboxId == null) {
    logger.debug("Actor is missing required fields: {actor}", { actor });
    return null;
  }
  return (
    db
      .prepare<unknown[], Actor>(
        `
        -- ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        actor.id.href,
        await getActorHandle(actor),
        actor.name?.toString(),
        actor.inboxId.href,
        actor.endpoints?.sharedInbox?.href,
        actor.url?.href,
      ) ?? null
  );
}

์ •์˜ํ•œ persistActor() ํ•จ์ˆ˜๋Š” ์ธ์ž๋กœ ๋“ค์–ด์˜จ ์•กํ„ฐ ๊ฐ์ฒด์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ actors ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ํ…Œ์ด๋ธ”์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์˜ on(Follow, ...) ๋ถ€๋ถ„์—์„œ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ persistActor() ํ•จ์ˆ˜๋ฅผ ์“ฐ๊ฒŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต ...
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
    }
    const followerId = (await persistActor(follower))?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    // ... ์ƒ๋žต ...
  })

๋ฆฌํŒฉํ„ฐ๋ง์„ ๋๋ƒˆ์œผ๋‹ˆ ์ˆ˜์‹ ํ•จ์— Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์•˜์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

  .on(Accept, async (ctx, accept) => {
    const follow = await accept.getObject();
    if (!(follow instanceof Follow)) return;
    const following = await accept.getActor();
    if (!isActor(following)) return;
    const follower = follow.actorId;
    if (follower == null) return;
    const parsed = ctx.parseUri(follower);
    if (parsed == null || parsed.type !== "actor") return;
    const followingId = (await persistActor(following))?.id;
    if (followingId == null) return;
    db.prepare(
      `
      INSERT INTO follows (following_id, follower_id)
      VALUES (
        ?,
        (
          SELECT actors.id
          FROM actors
          JOIN users ON actors.user_id = users.id
          WHERE users.username = ?
        )
      )
      `,
    ).run(followingId, parsed.identifier);
  });

์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๊ธธ์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ์œผ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ(follower)์™€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์€ ์•กํ„ฐ(following)๋ฅผ ๊ตฌํ•˜๊ณ  follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์ด์ œ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„๊นŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ ActivityPub.Academy ์ชฝ์—์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•˜๊ณ  Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ด๋ฏธ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ์ƒํƒœ์—์„œ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋„ ๋ฌด์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๋’ค ๋‹ค์‹œ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์—์„œ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ์ƒํƒœ์—์„œ, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ActivityPub.Academy์˜ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋ฉด, ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Activity Log์— Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋„์ฐฉํ•œ ํ›„ ๋‹ต์žฅ์œผ๋กœ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‚˜ ์ง์ ‘ ํ™•์ธ์„ ํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

์„ฑ๊ณตํ–ˆ๋‹ค๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค (following_id ์นผ๋Ÿผ์— ๋“  ๊ฐ’์€ ๋‹ค์†Œ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค):

following_id follower_id created
3 1 2024-09-02 14:11:17

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowingListProps {
  following: Actor[];
}

export const FollowingList: FC<FollowingListProps> = ({ following }) => (
  <>
    <h2>Following</h2>
    <ul>
      {following.map((actor) => (
        <li key={actor.id}>
          <ActorLink actor={actor} />
        </li>
      ))}
    </ul>
  </>
);

๊ทธ ๋‹ค์Œ, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  FollowingList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/following ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/following", async (c) => {
  const following = db
    .prepare<unknown[], Actor>(
      `
      SELECT following.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = followers.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowingList following={following} />
    </Layout>,
  );
});

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/following ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

ํŒ”๋กœ์ž‰ ์ˆ˜

ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ํŒ”๋กœ์ž‰ ์ˆ˜๋„ ํ‘œ์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  following: number;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  following,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

<PostPage> ์ปดํฌ๋„ŒํŠธ๋„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      following={props.following}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

๊ทธ๋Ÿผ ์ด์ œ ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์กฐํšŒํ•˜์—ฌ ํŒ”๋กœ์ž‰ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET /users/{username} ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following } = db
    .prepare<unknown[], { following: number }>(
      `
      SELECT count(*) AS following
      FROM follows
      JOIN actors ON follows.follower_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        following={following}
        followers={followers}
      />
      <PostList posts={posts} />
    </Layout>,
  );
});

GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  // ... ์ƒ๋žต ...
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following, followers } = db
    .prepare<unknown[], { following: number; followers: number }>(
      `
      SELECT sum(follows.follower_id = ?) AS following,
             sum(follows.following_id = ?) AS followers
      FROM follows
      `,
    )
    .get(post.actor_id, post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        following={following}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๋‹ค ์ˆ˜์ •๋˜์—ˆ๋‹ค๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํƒ€์ž„๋ผ์ธ

๋งŽ์€ ๊ฒƒ๋“ค์„ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, ์•„์ง ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด์ง€๋Š” ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌํƒœ๊นŒ์ง€์˜ ๊ณผ์ •์—์„œ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค์‹œํ”ผ, ์šฐ๋ฆฌ๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์“ธ ๋•Œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ–ˆ๋˜ ๊ฒƒ๊ณผ ๊ฐ™์ด, ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•ด์•ผ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ค ์ผ์ด ์ผ์–ด๋‚˜๋Š”์ง€ ๋ณด๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Create,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ์— on(Create, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

  .on(Create, async (ctx, create) => {
    const object = await create.getObject();
    if (!(object instanceof Note)) return;
    const actor = create.actorId;
    if (actor == null) return;
    const author = await object.getAttribution();
    if (!isActor(author) || author.id?.href !== actor.href) return;
    const actorId = (await persistActor(author))?.id;
    if (actorId == null) return;
    if (object.id == null) return;
    const content = object.content?.toString();
    db.prepare(
      "INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
    ).run(object.id.href, actorId, content, object.url?.href);
  });

getAttribution() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ธ€์“ด์ด๋ฅผ ๊ตฌํ•œ ๋’ค, persistActor() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ์•„์ง actors ํ…Œ์ด๋ธ”์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  posts ํ…Œ์ด๋ธ”์— ์ƒˆ ๋ ˆ์ฝ”๋“œ๋ฅผ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์‹œ ํ•œ ๋ฒˆ ActivityPub.Academy์— ๋“ค์–ด๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. Activity Log๋ฅผ ์—ด์–ด Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋˜์—ˆ๋Š”์ง€ ์ฒดํฌํ•œ ๋’ค, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ posts ํ…Œ์ด๋ธ”์— ์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3

์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

id uri actor_id content url created
3 https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 3 <p>Would it send a Create(Note) activity?</p> https://activitypub.academy/@algusia_draneoll/113068684551948316 2024-09-02 15:33:32

์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ ํ‘œ์‹œ

์ž, ์ด์ œ ์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ์„ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋กœ ์ถ”๊ฐ€ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๊ทธ ๋ ˆ์ฝ”๋“œ๋“ค์„ ์ž˜ ํ‘œ์‹œํ•ด ์ฃผ๋Š” ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. ํ”ํžˆ ใ€Œํƒ€์ž„๋ผ์ธใ€์ด๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps extends PostListProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user, posts }) => (
  <>
    {/* ... ์ƒ๋žต ... */}
    <PostList posts={posts} />
  </>
);

๊ทธ ๋’ค, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/", (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.redirect("/setup");

  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE posts.actor_id = ? OR posts.actor_id IN (
        SELECT following_id
        FROM follows
        WHERE follower_id = ?
      )
      ORDER BY posts.created DESC
      `,
    )
    .all(user.id, user.id);
  return c.html(
    <Layout>
      <Home user={user} posts={posts} />
    </Layout>,
  );
});

์ž, ์ด์ œ ๋‹ค ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ํƒ€์ž„๋ผ์ธ์„ ๊ฐ์ƒํ•ฉ์‹œ๋‹ค:

์ฒซ ํŽ˜์ด์ง€์—์„œ ๋ณด์ด๋Š” ํƒ€์ž„๋ผ์ธ

์œ„์™€ ๊ฐ™์ด ์›๊ฒฉ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๊ณผ ๋กœ์ปฌ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ž˜ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค๊ฐ€์š”? ๋งˆ์Œ์— ๋“œ์‹œ๋‚˜์š”?

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์ด๊ฒŒ ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์™„์„ฑ์‹œํ‚ค๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ฐœ์„ ํ•  ์ 

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด ์™„์„ฑํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ์•„์‰ฝ๊ฒŒ๋„ ์•„์ง ์‹ค์‚ฌ์šฉ์—๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํŠนํžˆ, ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ์ทจ์•ฝ์ ์ด ๋งŽ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋งŒ๋“  ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์ข€ ๋” ๋ฐœ์ „์‹œํ‚ค๊ณ  ์‹ถ์€ ๋ถ„๋“ค์€, ์•„๋ž˜ ๊ณผ์ œ๋“ค์„ ์ง์ ‘ ํ•ด๊ฒฐํ•ด ๋ณด์…”๋„ ์ข‹์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

  • ํ˜„์žฌ๋Š” ์•„๋ฌด๋Ÿฐ ์ธ์ฆ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ๋ˆ„๊ตฌ๋ผ๋„ URL๋งŒ ์•Œ๋ฉด ๊ธ€์„ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๊ณผ์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ด๋ฅผ ๋ฐฉ์ง€ํ•ด ๋ณผ๊นŒ์š”?

  • ํ˜„์žฌ์˜ ๊ตฌํ˜„์€ ActivityPub์„ ํ†ตํ•ด ๋ฐ›์€ Note ๊ฐ์ฒด ์•ˆ์— ๋“ค์–ด ์žˆ๋Š” HTML์„ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์•…์˜์ ์ธ ActivityPub ์„œ๋ฒ„๊ฐ€ <script>while (true) alert('๋ฉ”๋กฑ'); ๊ฐ™์€ HTML์„ ํฌํ•จํ•œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ XSS ์ทจ์•ฝ์ ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ทจ์•ฝ์ ์€ ์–ด๋–ป๊ฒŒ ๋ง‰์„ ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋‹ค์Œ SQL์„ ์‹คํ–‰ํ•˜์—ฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟ” ๋ด…์‹œ๋‹ค:

    UPDATE actors SET name = 'Renamed' WHERE id = 1;

    ์ด๋ ‡๊ฒŒ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟจ์„ ๋•Œ, ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๋ฐ”๋€ ์ด๋ฆ„์ด ์ ์šฉ๋ ๊นŒ์š”? ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด์•ผ ๋ณ€๊ฒฝ์ด ์ ์šฉ๋ ๊นŒ์š”?

  • ์•กํ„ฐ์— ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•ด ๋ด…์‹œ๋‹ค. ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ถ๊ธˆํ•˜๋‹ค๋ฉด, fedify lookup ๋ช…๋ น์œผ๋กœ ์ด๋ฏธ ํ”„๋กœํ•„ ์‚ฌ์ง„์ด ์žˆ๋Š” ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณด์„ธ์š”.

  • ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€๊ฐ€ ์ฒจ๋ถ€๋œ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ํƒ€์ž„๋ผ์ธ์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • ๊ฒŒ์‹œ๋ฌผ ๋‚ด์—์„œ ๋‹ค๋ฅธ ์•กํ„ฐ๋ฅผ ๋ฉ˜์…˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ด…์‹œ๋‹ค. ๋ฉ˜์…˜ํ•œ ์ƒ๋Œ€ํ•œํ…Œ ์•Œ๋ฆผ์ด ๊ฐ€๋„๋ก ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: English (์˜์–ด), ๆ—ฅๆœฌ่ชž (์ผ๋ณธ์–ด).

์•ˆ๋‚ด

๋งŒ์•ฝ ์—ฐํ•ฉ์šฐ์ฃผ(fediverse)๋‚˜ ActivityPub ๊ฐ™์€ ์šฉ์–ด๊ฐ€ ์ƒ์†Œํ•˜๋‹ค๋ฉด, ๊ด€๋ จ ๊ฒ€์ƒ‰์„ ์ข€ ๋” ํ•˜๊ณ  ๋‚˜์„œ ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•  ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ActivityPub ์„œ๋ฒ„ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Fedify๋ฅผ ์ด์šฉํ•˜์—ฌ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ํ”„๋กœํ† ์ฝœ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ(microblog)๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify์˜ ๊ธฐ๋ฐ˜ ๋™์ž‘ ์›๋ฆฌ๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค๋Š” Fedify์˜ ํ™œ์šฉ๋ฒ•์— ์ข€ ๋” ์ง‘์ค‘ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

Fedify๋Š” ActivityPub์ด๋‚˜ ๊ทธ ์™ธ ํ‘œ์ค€(์ด์นญํ•˜์—ฌ ใ€Œ์—ฐํ•ฉ์šฐ์ฃผใ€๋ผ ๋ถˆ๋ฆฌ๋Š”)์„ ์ด์šฉํ•˜์—ฌ ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ์—ฐํ•ฉ ์„œ๋ฒ„ ์•ฑ์„ ๋งŒ๋“ค ๋•Œ์˜ ๋ณต์žกํ•จ์ด๋‚˜ ๋ฒˆ๊ฑฐ๋กœ์šด ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์—†์• ๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด Fedify์˜ ๋ชฉํ‘œ์ž…๋‹ˆ๋‹ค.

Fedify ํ”„๋กœ์ ํŠธ์— ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์…จ๋‹ค๋ฉด, ์•„๋ž˜์˜ ์ž๋ฃŒ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”:

Fedify๋‚˜ ๋ณธ ํŠœํ† ๋ฆฌ์–ผ์— ๋Œ€ํ•œ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ, ํ”ผ๋“œ๋ฐฑ ๋“ฑ์€ GitHub Discussions(์˜์–ด)์— ์˜ฌ๋ ค ์ฃผ์‹œ๊ฑฐ๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ @fedify(์˜์–ด ๋ฐ ํ•œ๊ตญ์–ด)๋กœ ๋ฉ˜์…˜ ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ์•„๋‹ˆ๋ฉด Fedify ํ”„๋กœ์ ํŠธ์˜ Discord ์„œ๋ฒ„์— ๋“ค์–ด์˜ค์…”์„œ #fedify-general-ko ์ฑ„๋„(ํ•œ๊ตญ์–ด)์—์„œ ๋ง์”€ํ•˜์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

๋Œ€์ƒ ๋…์ž

์ด ํŠœํ† ๋ฆฌ์–ผ์€ Fedify๋ฅผ ๋ฐฐ์›Œ์„œ ActivityPub ์„œ๋ฒ„ ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณด๊ณ  ์‹ถ์€ ๋ถ„๋“ค์„ ๋Œ€์ƒ์œผ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด HTML์ด๋‚˜ HTTP๋ฅผ ์ด์šฉํ•˜์—ฌ ์›น์•ฑ์„ ์ œ์ž‘ํ•ด ๋ณธ ๊ฒฝํ—˜์ด ์žˆ์œผ๋ฉฐ, ๋ช…๋ นํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๋‚˜ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋“ฑ์„ ์ดํ•ดํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ TypeScript๋‚˜ JSX, ActivityPub, Fedify ๋“ฑ์€ ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ํ•„์š”ํ•œ ๋งŒํผ ๊ฐ€๋ฅด์ณ ๋“œ๋ฆด ๊ฒƒ์ด๋‹ˆ ๋ชฐ๋ผ๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ๋งŒ๋“ค์–ด ๋ณธ ๊ฒฝํ—˜์€ ํ•„์š” ์—†์ง€๋งŒ, ๊ทธ๋ž˜๋„ Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ํ•˜๋‚˜ ์ •๋„๋Š” ์จ๋ดค๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ž˜์•ผ ์šฐ๋ฆฌ๊ฐ€ ๋ฌด์—‡์„ ๋งŒ๋“œ๋ ค๊ณ  ํ•˜๋Š”์ง€ ๊ฐ์ด ์žกํžˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๋ชฉํ‘œ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Fedify๋ฅผ ์ด์šฉํ•ด ActivityPub์œผ๋กœ ๋‹ค๋ฅธ ์—ฐํ•ฉํ˜• ์†Œํ”„ํŠธ์›จ์–ด ๋ฐ ์„œ๋น„์Šค์™€ ์†Œํ†ต ๊ฐ€๋Šฅํ•œ ์ผ์ธ์šฉ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ด ์†Œํ”„ํŠธ์›จ์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.

  • ์‚ฌ์šฉ์ž๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์ด ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒ”๋กœ์›Œ๋Š” ์‚ฌ์šฉ์ž๋ฅผ ํŒ”๋กœํ•˜๋‹ค๊ฐ€ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๋ฌผ์€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ • ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ๊ณ„์ •์ด ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์„ ์‹œ๊ฐ„์ˆœ ๋ชฉ๋ก์œผ๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŠœํ† ๋ฆฌ์–ผ์„ ๋‹จ์ˆœํ™”ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ ์ œ์•ฝ์„ ๋‘ก๋‹ˆ๋‹ค.

  • ๊ณ„์ • ํ”„๋กœํ•„(์†Œ๊ฐœ๋ฌธ, ์‚ฌ์ง„ ๋“ฑ)์€ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ๋งŒ๋“  ๊ณ„์ •์€ ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ์˜ฌ๋ฆฐ ๊ฒŒ์‹œ๋ฌผ์€ ๊ณ ์น˜๊ฑฐ๋‚˜ ์ง€์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ํ•œ ๋ฒˆ ํŒ”๋กœํ•œ ๋‹ค๋ฅธ ๊ณ„์ •์€ ํŒ”๋กœ์ž‰์„ ๊ทธ๋งŒ ๋‘˜ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ข‹์•„์š”, ๊ณต์œ , ๋Œ“๊ธ€์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.
  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ ๋“ฑ์˜ ๋ณด์•ˆ ๊ธฐ๋Šฅ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก , ํŠœํ† ๋ฆฌ์–ผ์„ ๋๊นŒ์ง€ ์ง„ํ–‰ํ•œ ๋’ค ๊ธฐ๋Šฅ์„ ๋ง๋ถ™์ด๋Š” ๊ฒƒ์€ ์–ผ๋งˆ๋“ ์ง€ ํ•˜์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์—ฐ์Šต์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์™„์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” GitHub ์ €์žฅ์†Œ์— ์˜ฌ๋ผ์™€ ์žˆ์œผ๋ฉฐ, ๊ฐ ๊ตฌํ˜„ ๋‹จ๊ณ„์— ๋”ฐ๋ผ ์ปค๋ฐ‹์ด ๋‚˜๋‰˜์–ด์ ธ ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ  ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ํ™˜๊ฒฝ ์…‹์—…

Node.js ์„ค์น˜ํ•˜๊ธฐ

Fedify๋Š” Deno, Bun, Node.js, ์ด ์„ธ ๊ฐ€์ง€ JavaScript ๋Ÿฐํƒ€์ž„์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ์ค‘์—์„œ Node.js๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์“ฐ์ด๋ฏ€๋กœ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Node.js๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

JavaScript ๋Ÿฐํƒ€์ž„์ด๋ž€ JavaScript ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ํ”Œ๋žซํผ์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์›น๋ธŒ๋ผ์šฐ์ €๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜์ด๋ฉฐ, ๋ช…๋ น์ค„์ด๋‚˜ ์„œ๋ฒ„์—์„œ๋Š” Node.js ๋“ฑ์ด ๋„๋ฆฌ ์“ฐ์ž…๋‹ˆ๋‹ค. ์ตœ๊ทผ์—๋Š” Cloudflare Workers ๊ฐ™์€ ํด๋ผ์šฐ๋“œ ์—์ง€ ํ•จ์ˆ˜๋“ค๋„ JavaScript ๋Ÿฐํƒ€์ž„์˜ ํ•˜๋‚˜๋กœ ๊ฐ๊ด‘ ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fedify๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Node.js 22.0.0 ์ด์ƒ์˜ ๋ฒ„์ „์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜๋ฒ•์ด ์žˆ์œผ๋‹ˆ ์ž์‹ ์—๊ฐ€ ๊ฐ€์žฅ ์•Œ๋งž๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ Node.js๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

Node.js๊ฐ€ ์„ค์น˜๋˜๋ฉด node ๋ช…๋ น์–ด์™€ npm ๋ช…๋ น์–ด๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค:

node --version
npm --version

fedify ๋ช…๋ น์–ด ์„ค์น˜

Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์…‹์—…ํ•˜๊ธฐ ์œ„ํ•ด fedify ๋ช…๋ น์–ด๋ฅผ ์‹œ์Šคํ…œ์— ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ์„ค์น˜ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ, npm ๋ช…๋ น์œผ๋กœ ๊นŒ๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊ฐ„ํŽธํ•ฉ๋‹ˆ๋‹ค:

npm install -g @fedify/cli

์„ค์น˜๊ฐ€ ๋˜์—ˆ๋‹ค๋ฉด, fedify ๋ช…๋ น์–ด๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ fedify ๋ช…๋ น์–ด์˜ ๋ฒ„์ „์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

fedify --version

๊ฒฐ๊ณผ๋กœ ๋‚˜์˜จ ๋ฒ„์ „ ๋ฒˆํ˜ธ๊ฐ€ 1.0.0 ์ด์ƒ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค. ๊ทธ๋ณด๋‹ค ์˜›๋‚  ๋ฒ„์ „์ด๋ฉด ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ œ๋Œ€๋กœ ๋”ฐ๋ผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

fedify init์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

์ƒˆ Fedify ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์œ„ํ•ด, ์ž‘์—…ํ•  ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ •ํ•ฉ์‹œ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” microblog๋ผ๊ณ  ๋ช…๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. fedify init ๋ช…๋ น ๋’ค์— ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ฒฝ๋กœ๋ฅผ ์ ๊ณ  ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค (๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค):

fedify init microblog

fedify init ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ช‡ ๊ฐ€์ง€ ์งˆ๋ฌธ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค. ์ฐจ๋ก€๋Œ€๋กœ Node.js, npm, Hono, In-memory, In-process๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค:

             ___      _____        _ _  __
            /'_')    |  ___|__  __| (_)/ _|_   _
     .-^^^-/  /      | |_ / _ \/ _` | | |_| | | |
   __/       /       |  _|  __/ (_| | |  _| |_| |
  <__.|_|-|_|        |_|  \___|\__,_|_|_|  \__, |
                                           |___/

? Choose the JavaScript runtime to use
  Deno
  Bun
โฏ Node.js

? Choose the package manager to use
โฏ npm
  Yarn
  pnpm

? Choose the web framework to integrate Fedify with
  Bare-bones
  Fresh
โฏ Hono
  Express
  Nitro

? Choose the key-value store to use for caching
โฏ In-memory
  Redis
  PostgreSQL
  Deno KV

? Choose the message queue to use for background jobs
โฏ In-process
  Redis
  PostgreSQL
  Deno KV

์•ˆ๋‚ด

Fedify๋Š” ํ’€ ์Šคํƒ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์•„๋‹Œ, ActivityPub ์„œ๋ฒ„ ๊ตฌํ˜„์— ํŠนํ™”๋œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ๋‹ค๋ฅธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์™€ ํ•จ๊ป˜ ์“ฐ์ด๋Š” ๊ฒƒ์„ ์—ผ๋‘์— ๋‘๊ณ  ๋งŒ๋“ค์–ด์กŒ์Šต๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ Hono๋ฅผ ์ฑ„ํƒํ•˜์—ฌ Fedify์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์ž ์‹œ ํ›„ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ ์•ˆ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ํŒŒ์ผ๋“ค์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  • .vscode/ โ€” Visual Studio Code ๊ด€๋ จ ์„ค์ •๋“ค
    • extensions.json โ€” Visual Studio Code ์ถ”์ฒœ ํ™•์žฅ
    • settings.json โ€” Visual Studio Code ์„ค์ •
  • node_modules/ โ€” ์˜์กด ํŒจํ‚ค์ง€๋“ค์ด ์„ค์น˜๋˜๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ (๋‚ด๋ถ€ ์ƒ๋žต)
  • src/ โ€” ์†Œ์Šค ์ฝ”๋“œ
    • app.tsx โ€” ActivityPub๊ณผ ๊ด€๋ จ ์—†๋Š” ์„œ๋ฒ„
    • federation.ts โ€” ActivityPub ์„œ๋ฒ„
    • index.ts โ€” ์—”ํŠธ๋ฆฌํฌ์ธํŠธ
    • logging.ts โ€” ๋กœ๊น… ์„ค์ •
  • biome.json โ€” ํฌ๋งคํ„ฐ ๋ฐ ๋ฆฐํŠธ ์„ค์ •
  • package.json โ€” ํŒจํ‚ค์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
  • tsconfig.json โ€” TypeScript ์„ค์ •

์ง์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒ ์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” JavaScript๊ฐ€ ์•„๋‹Œ TypeScript๋ฅผ ์“ฐ๊ธฐ ๋•Œ๋ฌธ์— .js ํŒŒ์ผ์ด ์•„๋‹Œ .ts ๋ฐ .tsx ํŒŒ์ผ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์ƒ์„ฑ๋œ ์†Œ์Šค ์ฝ”๋“œ๋Š” ๋™์ž‘ํ•˜๋Š” ๋ฐ๋ชจ์ž…๋‹ˆ๋‹ค. ์šฐ์„ ์€ ์ด ์ƒํƒœ๋กœ ์ž˜ ๋Œ์•„๊ฐ€๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

npm run dev

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด Ctrl+C ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ธฐ ์ „๊นŒ์ง€๋Š” ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ฑ„๋กœ ์žˆ์Šต๋‹ˆ๋‹ค:

Server started at http://0.0.0.0:8000

์„œ๋ฒ„๊ฐ€ ์‹คํ–‰๋œ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด๊ณ  ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/john

์œ„ ๋ช…๋ น์€ ์šฐ๋ฆฌ๊ฐ€ ๋กœ์ปฌ์— ๋„์šด ActivityPub ์„œ๋ฒ„์˜ ํ•œ ์•กํ„ฐ(actor)๋ฅผ ์กฐํšŒํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ์•กํ„ฐ๋Š” ์—ฌ๋Ÿฌ ActivityPub ์„œ๋ฒ„๋“ค ์‚ฌ์ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๊ณ„์ •์ด๋ผ๊ณ  ๋ณด์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋˜๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/john",
  name: "john",
  preferredUsername: "john"
}

์ด ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด /users/john ๊ฒฝ๋กœ์— ์œ„์น˜ํ•œ ์•กํ„ฐ ๊ฐ์ฒด์˜ ์ข…๋ฅ˜๊ฐ€ Person์ด๋ฉฐ, ๊ทธ ID๋Š” http://localhost:8000/users/john, ์ด๋ฆ„์€ john, ์‚ฌ์šฉ์ž๋ช…๋„ john์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

fedify lookup์€ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ์ด๋Š” Mastodon์—์„œ ํ•ด๋‹น URI๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌผ๋ก , ํ˜„์žฌ ์—ฌ๋Ÿฌ๋ถ„์˜ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์•„์ง Mastodon์—์„œ ๊ฒ€์ƒ‰ํ•ด๋„ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค์ง€๋Š” ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.)

์—ฌ๋Ÿฌ๋ถ„์ด fedify lookup ๋ช…๋ น์–ด๋ณด๋‹ค curl์„ ๋” ์„ ํ˜ธํ•˜์‹ ๋‹ค๋ฉด, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ๋„ ์•กํ„ฐ ์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (-H ์˜ต์…˜์œผ๋กœ Accept ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋Š” ๊ฒƒ์— ์ฃผ์˜ํ•˜์‹ญ์‹œ์˜ค):

curl -H"Accept: application/activity+json" http://localhost:8000/users/john

๋‹จ, ์œ„์™€ ๊ฐ™์ด ์กฐํšŒํ•  ๊ฒฝ์šฐ ๊ทธ ๊ฒฐ๊ณผ๋Š” ๋งจ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜๊ธฐ ์–ด๋ ค์šด JSON ํ˜•์‹์ด ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์‹œ์Šคํ…œ์— jq ๋ช…๋ น์–ด๋„ ํ•จ๊ป˜ ๊น”๋ ค์žˆ๋‹ค๋ฉด, curl๊ณผ jq๋ฅผ ํ•จ๊ป˜ ์“ธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

Visual Studio Code๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๊ฐ€ ์•„๋‹ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋Š” ๋™์•ˆ์—๋Š” Visual Studio Code๋ฅผ ์จ๋ณด์‹ค ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์šฐ๋ฆฌ๋Š” TypeScript๋ฅผ ์จ์•ผ ํ•˜๋Š”๋ฐ, Visual Studio Code๋Š” ํ˜„์กดํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„ํŽธํ•˜๋ฉด์„œ๋„ ๋›ฐ์–ด๋‚œ TypeScript IDE์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์ƒ์„ฑ๋œ ํ”„๋กœ์ ํŠธ ์…‹์—…์— ์ด๋ฏธ Visual Studio Code ์„ค์ •์ด ๊ฐ–์ถฐ์ ธ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํฌ๋งคํ„ฐ๋‚˜ ๋ฆฐํŠธ ๋“ฑ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š”๋„ ์—†์Šต๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

Visual Studio์™€ ํ—ท๊ฐˆ๋ฆฌ์‹œ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. Visual Studio Code์™€ Visual Studio๋Š” ๋ธŒ๋žœ๋“œ๋งŒ ๊ณต์œ ํ•  ๋ฟ ์„œ๋กœ ์™„์ „ํžˆ ๋‹ค๋ฅธ ์†Œํ”„ํŠธ์›จ์–ด์ž…๋‹ˆ๋‹ค.

Visual Studio Code๋ฅผ ์„ค์น˜ํ•˜์‹  ๋‹ค์Œ, ํŒŒ์ผ โ†’ ํด๋” ์—ด๊ธฐโ€ฆ ๋ฉ”๋‰ด๋ฅผ ๋ˆŒ๋Ÿฌ ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์‹ญ์‹œ์˜ค.

๋งŒ์•ฝ ์šฐํ•˜๋‹จ์— ใ€Œ์ด ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ๋Œ€ํ•œ ๊ถŒ์žฅ๋˜๋Š” biomejs์˜ โ€˜Biomeโ€™ ํ™•์žฅ์„(๋ฅผ) ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?ใ€๋ผ๊ณ  ๋ฌป๋Š” ์ฐฝ์ด ๋œจ๋ฉด ์„ค์น˜ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ•ด๋‹น ํ™•์žฅ์„ ์„ค์น˜ํ•˜์„ธ์š”. ์ด ํ™•์žฅ์„ ์„ค์น˜ํ•˜๋ฉด TypeScript ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ๋•Œ ๋“ค์—ฌ์“ฐ๊ธฐ๋‚˜ ๋„์–ด์“ฐ๊ธฐ ๊ฐ™์€ ์ฝ”๋“œ ์Šคํƒ€์ผ๊ณผ ์”จ๋ฆ„ํ•  ํ•„์š” ์—†์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๊ฐ€ ์„œ์‹ํ™” ๋ฉ๋‹ˆ๋‹ค.

ํŒ

์—ฌ๋Ÿฌ๋ถ„์ด ์ถฉ์„ฑ์Šค๋Ÿฌ์šด Emacs ๋˜๋Š” Vim ์‚ฌ์šฉ์ž๋ผ๋ฉด, ์“ฐ๋˜ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ตœ์•  ์—๋””ํ„ฐ๋ฅผ ์“ฐ๋Š” ๊ฒƒ์„ ๋ง๋ฆฌ์ง€ ์•Š๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, TypeScript LSP ์„ค์ •์€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ๊ฒƒ์„ ๊ถŒํ•ฉ๋‹ˆ๋‹ค. TypeScript LSP ์„ค์ • ์—ฌ๋ถ€์— ๋”ฐ๋ผ ์ƒ์‚ฐ์„ฑ์˜ ์ฐจ์ด๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

์„ ์ˆ˜ ์ง€์‹

TypeScript

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ ์ „์—, ๊ฐ„๋‹จํžˆ TypeScript์— ๋Œ€ํ•ด ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์—ฌ๋Ÿฌ๋ถ„์ด ์ด๋ฏธ TypeScript์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด ์ด ์žฅ์€ ๋„˜๊ธฐ์…”๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

TypeScript๋Š” JavaScript์— ์ •์  ํƒ€์ž… ๊ฒ€์‚ฌ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. TypeScript ๋ฌธ๋ฒ•์€ JavaScript ๋ฌธ๋ฒ•๊ณผ ๊ฑฐ์˜ ๊ฐ™์ง€๋งŒ, ๋ณ€์ˆ˜๋‚˜ ํ•จ์ˆ˜ ๋ฌธ๋ฒ•์— ํƒ€์ž…์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ด ํฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ์ง€์ •์€ ๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋’ค์— ์ฝœ๋ก (:)์„ ๋ถ™์—ฌ์„œ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋Š” foo ๋ณ€์ˆ˜๊ฐ€ ๋ฌธ์ž์—ด(string)์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค:

let foo: string;

๋งŒ์•ฝ ์œ„์™€ ๊ฐ™์ด ์„ ์–ธ๋œ foo ๋ณ€์ˆ˜์— ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ๊ฐ’์„ ๋Œ€์ž…ํ•˜๋ ค๊ณ  ํ•˜๋ฉด Visual Studio Code๊ฐ€ ์‹คํ–‰ํ•ด๋ณด๊ธฐ ์ „์— ๋ฏธ๋ฆฌ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๊ทธ์–ด์ฃผ๋ฉฐ ํƒ€์ž… ์˜ค๋ฅ˜๋ฅผ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

foo = 123;
// ts(2322): 'number' ํ˜•์‹์€ 'string' ํ˜•์‹์— ํ• ๋‹นํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์ฝ”๋”ฉํ•˜๋ฉด์„œ ๋นจ๊ฐ„ ๋ฐ‘์ค„์„ ๋งŒ๋‚˜๋ฉด ์ง€๋‚˜์น˜์ง€ ์•Š๋„๋ก ํ•˜์‹ญ์‹œ์˜ค. ๋ฌด์‹œํ•˜๊ณ  ํ”„๋กœ๊ทธ๋žจ์„ ์‹คํ–‰ํ•˜๋ฉด ๊ทธ ๋ถ€๋ถ„์—์„œ ์‹ค์ œ๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.

TypeScript๋กœ ์ฝ”๋”ฉ์„ ํ•˜๋ฉฐ ๋งˆ์ฃผ์น˜๋Š” ๊ฐ€์žฅ ํ”ํ•œ ํƒ€์ž… ์˜ค๋ฅ˜์˜ ์œ ํ˜•์€ ๋ฐ”๋กœ null ๊ฐ€๋Šฅ์„ฑ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด bar ๋ณ€์ˆ˜๋Š” ๋ฌธ์ž์—ด(string)์ผ ์ˆ˜๋„ ์žˆ์ง€๋งŒ null์ผ ์ˆ˜๋„ ์žˆ๋‹ค(string | null)๊ณ  ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:

const bar: string | null = someFunction();

๋งŒ์•ฝ ์ด ๋ณ€์ˆ˜์˜ ๋‚ด์šฉ์—์„œ ๊ฐ€์žฅ ์ฒซ ๊ธ€์ž๋ฅผ ๊บผ๋‚ด๋ ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ์“ด๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”?

const firstChar = bar.charAr(0);
// ts(18047): 'bar'์€(๋Š”) 'null'์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. bar๊ฐ€ ์–ด์ฉ” ๋•Œ๋Š” null์ผ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ๊ฒฝ์šฐ์— null.charAt(0)์„ ํ˜ธ์ถœํ•˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ผ๋Š” ์ด์•ผ๊ธฐ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๊ฒฝ์šฐ์— ์•„๋ž˜์™€ ๊ฐ™์ด null์ธ ๊ฒฝ์šฐ์˜ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.

const firstChar = bar === null ? "" : bar.charAr(0);

์ด์™€ ๊ฐ™์ด TypeScript๋Š” ์ฝ”๋”ฉํ•  ๋•Œ ๋ฏธ์ฒ˜ ์ƒ๊ฐํ•˜์ง€ ๋ชปํ–ˆ๋˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๋ฅผ ๋– ์˜ฌ๋ฆฌ๊ฒŒ ํ•ด์„œ ๋ฒ„๊ทธ๋ฅผ ๋ฏธ์—ฐ์— ๋ฐฉ์ง€ํ•˜๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

๋˜, TypeScript์˜ ๋ถ€์ˆ˜์ ์ธ ์žฅ์  ์ค‘ ํ•˜๋‚˜๋Š” ์ž๋™ ์™„์„ฑ์ด ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, foo.๊นŒ์ง€ ์ž…๋ ฅํ•˜๋ฉด ๋ฌธ์ž์—ด ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ง„ ๋ฉ”์„œ๋“œ ๋ชฉ๋ก์ด ๋‚˜์™€์„œ ๊ทธ ์ค‘์—์„œ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ผ์ผํžˆ ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜์ง€ ์•Š๊ณ ์„œ๋„ ๋น ๋ฅด๊ฒŒ ์ฝ”๋”ฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ๋”ฐ๋ผํ•˜๋ฉด์„œ TypeScript์˜ ๋งค๋ ฅ๋„ ํ•จ๊ป˜ ๋А๋ผ์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋ฌด์—‡๋ณด๋‹ค Fedify๋Š” TypeScript์™€ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ€์žฅ ๊ฒฝํ—˜์ด ์ข‹์œผ๋‹ˆ๊นŒ์š”.

ํŒ

TypeScript๋ฅผ ์ œ๋Œ€๋กœ ์ฐฌ์ฐฌํžˆ ๋ฐฐ์›Œ๋ณด๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, ๊ณต์‹ TypeScript ํ•ธ๋“œ๋ถ์„ ์ฝ์œผ์‹ค ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ „๋ถ€ ์ฝ๋Š”๋ฐ ์•ฝ 30๋ถ„ ์ •๋„ ์†Œ์š”๋ฉ๋‹ˆ๋‹ค.

JSX

JSX๋Š” JavaScript ์ฝ”๋“œ ์•ˆ์— XML ๋˜๋Š” HTML์„ ์ง‘์–ด๋„ฃ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•œ JavaScript์˜ ๋ฌธ๋ฒ• ํ™•์žฅ์ž…๋‹ˆ๋‹ค. TypeScript์—์„œ๋„ ์“ธ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ด ๊ฒฝ์šฐ์—๋Š” TSX๋ผ๊ณ  ๋ถ€๋ฅด๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ๋ชจ๋“  HTML์„ JSX ๋ฌธ๋ฒ•์„ ํ†ตํ•ด JavaScript ์ฝ”๋“œ ์•ˆ์— ์ž‘์„ฑํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. JSX์— ์ด๋ฏธ ์ต์ˆ™ํ•œ ๋ถ„๋“ค์€ ์ด ์žฅ์„ ๋„˜๊ธฐ์…”๋„ ๋ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <div> ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๋Š” HTML ํŠธ๋ฆฌ๋ฅผ html ๋ณ€์ˆ˜์— ๋Œ€์ž…ํ•ฉ๋‹ˆ๋‹ค:

const html = <div>
  <p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p>
</div>;

์ค‘๊ด„ํ˜ธ๋ฅผ ํ†ตํ•ด JavaScript ํ‘œํ˜„์‹์„ ๋„ฃ๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฝ”๋“œ๋Š” ๋ฌผ๋ก  getName() ํ•จ์ˆ˜๊ฐ€ ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค):

const html = <div title={"์•ˆ๋…•, " + getName() + "!"}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</div>;

JSX์˜ ํŠน์ง• ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ(component)๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์ž์‹ ๋งŒ์˜ ํƒœ๊ทธ๋ฅผ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๋Š” ํ‰๋ฒ”ํ•œ JavaScript ํ•จ์ˆ˜๋กœ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•„๋ž˜ ์ฝ”๋“œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค (์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„์€ ์ผ๋ฐ˜์ ์œผ๋กœ PascalCase ์Šคํƒ€์ผ์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค):

import type { FC } from "hono/jsx";

function getName() {
  return "JSX";
}

interface ContainerProps {
  name: string;
}

const Container: FC<ContainerProps> = (props) => {
  return <div title={"์•ˆ๋…•, " + props.name + "!"}>{props.children}</div>;
};

const html = <Container name={getName()}>
  <p id="greet">์•ˆ๋…•, <strong>{getName()}</strong>!</p>
</Container>;

์œ„ ์ฝ”๋“œ์—์„œ FC๋Š” ์šฐ๋ฆฌ๊ฐ€ ์“ธ ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ธ Hono์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋„์™€์ค๋‹ˆ๋‹ค. FC๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž…(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ˜๋Ÿผ ํ™”์‚ด๊ด„ํ˜ธ ์•ˆ์— ๋“ค์–ด๊ฐ€๋Š” ํƒ€์ž…๋“ค์ด ๋ฐ”๋กœ ํƒ€์ž… ์ธ์ž๋“ค์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํƒ€์ž… ์ธ์ž๋กœ ํ”„๋กญ(props) ํ˜•์‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กญ์ด๋ž€, ์ปดํฌ๋„ŒํŠธ์—๊ฒŒ ๋„˜๊ฒจ ์ค„ ๋งค๊ฐœ๋ณ€์ˆ˜๋“ค์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ง์ž…๋‹ˆ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” <Container> ์ปดํฌ๋„ŒํŠธ์˜ ํ”„๋กญ ํ˜•์‹์œผ๋กœ ContainerProps ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์„ ์–ธํ•˜๊ณ  ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ €๋„ค๋ฆญ ํƒ€์ž…์˜ ํƒ€์ž… ์ธ์ž๋Š” ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‰ผํ‘œ๋กœ ๊ฐ ์ธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Foo<A, B>๋Š” ์ €๋„ค๋ฆญ ํƒ€์ž… Foo์— ํƒ€์ž… ์ธ์ž A์™€ B๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ, ์ €๋„ค๋ฆญ ํ•จ์ˆ˜๋ผ๋Š” ๊ฒƒ๋„ ์žˆ์œผ๋ฉฐ, someFunction<A, B>(foo, bar)์™€ ๊ฐ™์ด ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ธ์ž๊ฐ€ ํ•˜๋‚˜์ผ ๋•Œ๋Š” ํƒ€์ž… ์ธ์ž๋ฅผ ๊ฐ์‹ธ๋Š” ํ™”์‚ด๊ด„ํ˜ธ๊ฐ€ ๋งˆ์น˜ XML/HTML ํƒœ๊ทธ์ฒ˜๋Ÿผ ๋ณด์ด์ง€๋งŒ, JSX์˜ ๊ธฐ๋Šฅ๊ณผ๋Š” ์•„๋ฌด ๊ด€๋ จ์ด ์—†์Šต๋‹ˆ๋‹ค.

FC<ContainerProps>
์ €๋„ค๋ฆญ ํƒ€์ž… FC์— ํƒ€์ž… ์ธ์ž ContainerProps๋ฅผ ๋Œ€์ž…ํ•œ ๊ฒƒ.
<Container>
<Container>๋ผ๋Š” ์ด๋ฆ„์˜ ์ปดํฌ๋„ŒํŠธ ํƒœ๊ทธ๋ฅผ ์—ฐ ๊ฒƒ. </Container>๋กœ ๋‹ซ์•„์•ผ ํ•จ.

ํ”„๋กญ์œผ๋กœ ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ๋“ค ์ค‘ children์€ ํŠน๋ณ„ํžˆ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์‹ ์—˜๋ฆฌ๋จผํŠธ๋“ค์ด children ํ”„๋กญ์œผ๋กœ ๋„˜์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ์œ„ ์ฝ”๋“œ์—์„œ html ๋ณ€์ˆ˜์—๋Š” <div title="์•ˆ๋…•, JSX!"><p id="greet">์•ˆ๋…•, <strong>JSX</strong>!</p></div>๋ผ๋Š” HTML ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŒŒ์ผ ์•ˆ์— JSX๋กœ <Layout> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

import type { FC } from "hono/jsx";

export const Layout: FC = (props) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta name="color-scheme" content="light dark" />
      <title>Microblog</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
      />
    </head>
    <body>
      <main class="container">{props.children}</main>
    </body>
  </html>
);

๋””์ž์ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ๊ณต์„ ๋“ค์ด์ง€ ์•Š๊ธฐ ์œ„ํ•ด, Pico CSS๋ผ๋Š” CSS ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

๋ณ€์ˆ˜๋‚˜ ๋งค๊ฐœ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ TypeScript์˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ธฐ๊ฐ€ ์ถ”๋ก ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ, ์œ„์˜ props ๊ฐ™์ด ํƒ€์ž… ํ‘œ๊ธฐ๋ฅผ ์ƒ๋žตํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํƒ€์ž… ํ‘œ๊ธฐ๊ฐ€ ์ƒ๋žต๋œ ๊ฒฝ์šฐ์—๋„, Visual Studio Code์—์„œ ๋ณ€์ˆ˜ ์ด๋ฆ„ ์œ„์— ๋งˆ์šฐ์Šค ์ปค์„œ๋ฅผ ๊ฐ€์ ธ๋‹ค ๋Œ€๋ฉด ํ•ด๋‹น ๋ณ€์ˆ˜๊ฐ€ ์–ด๋–ค ํƒ€์ž…์ธ์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ ๋‹ค์Œ, ๊ฐ™์€ ํŒŒ์ผ์—์„œ ๋ ˆ์ด์•„์›ƒ ์•ˆ์— ๋“ค์–ด๊ฐˆ <SetupForm> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

JSX์—์„œ๋Š” ์ตœ์ƒ์œ„์— ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ๋งŒ ๋‘˜ ์ˆ˜ ์žˆ๋Š”๋ฐ, <SetupForm> ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” <h1>๊ณผ <form> ๋‘ ๊ฐœ์˜ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ์ตœ์ƒ์œ„์— ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์ด๋ฅผ ํ•˜๋‚˜์˜ ์—˜๋ฆฌ๋จผํŠธ์ฒ˜๋Ÿผ ๋ฌถ์–ด์ฃผ๊ธฐ ์œ„ํ•ด์„œ ๋นˆ ํƒœ๊ทธ ๋ชจ์–‘์˜ <>์™€ </>๋กœ ๊ฐ์ŒŒ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”„๋ž˜๊ทธ๋จผํŠธ(fragment)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

์ด์ œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. src/app.tsx ํŒŒ์ผ์—์„œ ์•ž์„œ ์ •์˜ํ•œ ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ๋‚˜์„œ /setup ํŽ˜์ด์ง€์—์„œ ์•ž์„œ ๋งŒ๋“  ๊ณ„์ • ์ƒ์„ฑ ์–‘์‹์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) =>
  c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  ),
);

์ž, ๊ทธ๋Ÿผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋ณด์—ฌ์•ผ ์ •์ƒ์ž…๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•ˆ๋‚ด

JSX๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์†Œ์Šค ํŒŒ์ผ์˜ ํ™•์žฅ์ž๊ฐ€ .jsx ๋˜๋Š” .tsx์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์žฅ์—์„œ ํŽธ์ง‘ํ•œ ๋‘ ํŒŒ์ผ ๋ชจ๋‘ ํ™•์žฅ์ž๊ฐ€ .tsx๋ผ๋Š” ์‚ฌ์‹ค์— ์ฃผ์˜ํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์…‹์—…

์ž, ๋ณด์ด๋Š” ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๋™์ž‘์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ณณ์ด ํ•„์š”ํ•œ๋ฐ, SQLite๋ฅผ ์“ฐ๋„๋ก ํ•ฉ์‹œ๋‹ค. SQLite๋Š” ์ž‘์€ ๊ทœ๋ชจ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์•Œ๋งž๋Š” ๊ด€๊ณ„ํ˜• ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์ž…๋‹ˆ๋‹ค.

์šฐ์„  ๊ณ„์ • ์ •๋ณด๋ฅผ ๋‹ด์„ ํ…Œ์ด๋ธ”์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. ์•ž์œผ๋กœ ๋ชจ๋“  ํ…Œ์ด๋ธ” ์„ ์–ธ์€ src/schema.sql ํŒŒ์ผ์— ์ž‘์„ฑํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ณ„์ • ์ •๋ณด๋Š” users ํ…Œ์ด๋ธ”์— ๋‹ด์Šต๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS users (
  id       INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
  username TEXT    NOT NULL UNIQUE      CHECK (trim(lower(username)) = username
                                               AND username <> ''
                                               AND length(username) <= 50)
);

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๊ณ„์ •๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ฏ€๋กœ, ๊ธฐ๋ณธ ํ‚ค์ธ id ์นผ๋Ÿผ์ด 1 ์ด์™ธ์˜ ๊ฐ’์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ๊ฑธ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ์จ users ํ…Œ์ด๋ธ”์—๋Š” ๋‘˜ ์ด์ƒ์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ด์„ ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ณ„์ • ์•„์ด๋””๋ฅผ ๋‹ด์„ username ์นผ๋Ÿผ์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋‚˜ ๋„ˆ๋ฌด ๊ธด ๋ฌธ์ž์—ด์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋„๋ก ์ œ์•ฝ์„ ์คฌ์Šต๋‹ˆ๋‹ค.

์ด์ œ users ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ src/schema.sql ํŒŒ์ผ์„ ์‹คํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด sqlite3 ๋ช…๋ น์–ด๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, SQLite ์›น์‚ฌ์ดํŠธ์—์„œ ๋ฐ›๊ฑฐ๋‚˜ ๊ฐ ํ”Œ๋žซํผ์˜ ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋กœ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. macOS์˜ ๊ฒฝ์šฐ์—๋Š” ์šด์˜์ฒด์ œ์— ๋‚ด์žฅ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ๋”ฐ๋กœ ๋ฐ›์„ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ง์ ‘ ๋ฐ›์„ ๊ฒฝ์šฐ ์šด์˜์ฒด์ œ์— ๋งž๋Š” sqlite-tools-*.zip ํŒŒ์ผ์„ ๋ฐ›์•„์„œ ์••์ถ•์„ ํ•ด์ œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์„ค์น˜ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

sudo apt install sqlite3  # Debian ๋ฐ Ubuntu
sudo dnf install sqlite   # Fedora ๋ฐ RHEL
choco install sqlite  # Chocolatey
scoop install sqlite  # Scoop
winget install SQLite.SQLite  # Windows Package Manager

์ž, sqlite3 ๋ช…๋ น์–ด๊ฐ€ ์ค€๋น„๋˜์—ˆ๋‹ค๋ฉด ์ด์ œ ์ด๋ฅผ ์ด์šฉํ•ด์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

์œ„ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๋ฉด microblog.sqlite3 ํŒŒ์ผ์ด ์ƒ๊ธฐ๋Š”๋ฐ, ์ด ์•ˆ์— SQLite ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

์•ฑ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ

์ด์ œ ์ €ํฌ๊ฐ€ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•  ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. Node.js์—์„œ SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SQLite ๋“œ๋ผ์ด๋ฒ„ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํ•„์š”ํ•œ๋ฐ์š”, ์ €ํฌ๋Š” better-sqlite3 ํŒจํ‚ค์ง€๋ฅผ ์“ฐ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํŒจํ‚ค์ง€๋Š” npm ๋ช…๋ น์œผ๋กœ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊น” ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

npm add better-sqlite3
npm add --save-dev @types/better-sqlite3

ํŒ

@types/better-sqlite3 ํŒจํ‚ค์ง€๋Š” TypeScript๋ฅผ ์œ„ํ•ด better-sqlite ํŒจํ‚ค์ง€์˜ API์— ๋Œ€ํ•œ ํƒ€์ž… ์ •๋ณด๋ฅผ ๋‹ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ด์•ผ Visual Studio Code์—์„œ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™ ์™„์„ฑ์ด๋‚˜ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

์ด์™€ ๊ฐ™์ด @types/ ๋ฒ”์œ„ ์•ˆ์— ์žˆ๋Š” ํŒจํ‚ค์ง€๋ฅผ Definitely Typed ํŒจํ‚ค์ง€๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ TypeScript๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š์•˜์„ ๋•Œ, ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ํƒ€์ž… ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ ๊ธฐ์ž…ํ•˜์—ฌ ํŒจํ‚ค์ง€๋กœ ๋งŒ๋“  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ–ˆ์œผ๋‹ˆ, ์ด ํŒจํ‚ค์ง€๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งญ์‹œ๋‹ค. src/db.ts๋ผ๋Š” ์ƒˆ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค:

import Database from "better-sqlite3";

const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export default db;

ํŒ

์ฐธ๊ณ ๋กœ db.pragma() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•œ ์„ค์ •์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํšจ๊ณผ๋ฅผ ์ง€๋‹™๋‹ˆ๋‹ค:

journal_mode = WAL
SQLite์—์„œ ์›์ž์  ์ปค๋ฐ‹ ๋ฐ ๋กค๋ฐฑ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋กœ๊ทธ ์„ ํ–‰ ๊ธฐ์ž… ๋ชจ๋“œ๋ฅผ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ชจ๋“œ๋Š” ๊ธฐ๋ณธ๊ฐ’์ธ ๋กค๋ฐฑ ์ €๋„ ๋ชจ๋“œ์— ๋น„ํ•ด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ์—์„œ ๋” ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.
foreign_keys = ON
SQLite์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด ์„ค์ •์„ ์ผœ๋ฉด ์™ธ๋ž˜ ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋˜์–ด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ์ง€ํ‚ค๋Š” ๋ฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  users ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๋Š” ํƒ€์ž…์„ ์„ ์–ธํ•ฉ์‹œ๋‹ค. src/schema.ts ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด User ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface User {
  id: number;
  username: string;
}

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—ฐ๊ฒฐํ–ˆ์œผ๋‹ˆ, ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ฝ์ž…ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…์— ์“ฐ์ผ db ๊ฐ์ฒด์™€ User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import db from "./db.ts";
import type { User } from "./schema.ts";

POST /setup ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
  return c.redirect("/");
});

์•ž์„œ ๋งŒ๋“ค์—ˆ๋˜ GET /setup ํ•ธ๋“ค๋Ÿฌ์—๋„ ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});

ํ…Œ์ŠคํŠธ

์ด์ œ ๊ณ„์ • ์ƒ์„ฑ ๊ธฐ๋Šฅ์ด ์–ผ์ถ” ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ, ํ•œ ๋ฒˆ ์จ ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๊ณ„์ •์„ ์ƒ์„ฑํ•ด ๋ณด์„ธ์š”. ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•ž์œผ๋กœ ์•„์ด๋””๋กœ johndoe๋ฅผ ์ผ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ๋˜์—ˆ๋‹ค๋ฉด, SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‚˜ ํ™•์ธ๋„ ํ•ด ๋ด…๋‹ˆ๋‹ค:

echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3

๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ์‚ฝ์ž…๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค (๋ฌผ๋ก , johndoe๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 johndoe

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ด์ œ ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ ๊ณ„์ • ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค. ๋น„๋ก ๋ณด์—ฌ ์ค„ ์ •๋ณด๊ฐ€ ๊ฑฐ์˜ ์—†์ง€๋งŒ์š”.

์ด๋ฒˆ์—๋„ ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ์ž‘์—…ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์— <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  handle: string;
}

export const Profile: FC<ProfileProps> = ({ name, handle }) => (
  <>
    <hgroup>
      <h1>{name}</h1>
      <p style="user-select: all;">{handle}</p>
    </hgroup>
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์—์„œ ์ •์˜ํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” GET /users/{username} ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.username} handle={handle} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด ์ด์ œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด ๋ด์•ผ๊ฒ ์ฃ ? ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe (๊ณ„์ • ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ํ–ˆ์„ ๊ฒฝ์šฐ; ์•„๋‹ˆ๋ผ๋ฉด URL์„ ๋ฐ”๊ฟ”์•ผ ํ•ฉ๋‹ˆ๋‹ค) ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ณด์„ธ์š”. ์•„๋ž˜์™€ ๊ฐ™์€ ํ™”๋ฉด์ด ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ

์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค(fediverse handle), ์ค„์—ฌ์„œ ํ•ธ๋“ค์ด๋ž€ ์—ฐํ•ฉ์šฐ์ฃผ ๋‚ด์—์„œ ๊ณ„์ •์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ณ ์œ ํ•œ ์ฃผ์†Œ ๊ฐ™์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค๋ฉด @hongminhee@fosstodon.org์ฒ˜๋Ÿผ ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•˜๊ฒŒ ์ƒ๊ฒผ๋Š”๋ฐ, ์‹ค์ œ ๊ตฌ์„ฑ๋„ ์ด๋ฉ”์ผ ์ฃผ์†Œ์™€ ๋น„์Šทํ•ฉ๋‹ˆ๋‹ค. ๋งจ ์ฒ˜์Œ์— @์ด ์˜ค๊ณ , ๊ทธ ๋‹ค์Œ์— ์ด๋ฆ„, ๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ @์ด ์˜จ ๋’ค, ๋งˆ์ง€๋ง‰์— ๊ณ„์ •์ด ์†ํ•œ ์„œ๋ฒ„์˜ ๋„๋ฉ”์ธ ์ด๋ฆ„์ด ์˜ต๋‹ˆ๋‹ค. ๋•Œ๋•Œ๋กœ ๋งจ ์•ž์˜ @์ด ์ƒ๋žต๋˜๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์ ์œผ๋กœ๋Š” ํ•ธ๋“ค์€ WebFinger์™€ acct: URI ํ˜•์‹์ด๋ผ๋Š” ๋‘ ๊ฐœ์˜ ํ‘œ์ค€์œผ๋กœ ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค. Fedify๊ฐ€ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ํŠœํ† ๋ฆฌ์–ผ์„ ์ง„ํ–‰ํ•˜๋Š” ๋™์•ˆ ์—ฌ๋Ÿฌ๋ถ„์€ ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์•Œ์ง€ ์•Š์•„๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

์•กํ„ฐ ๊ตฌํ˜„ํ•˜๊ธฐ

ActivityPub์€ ๊ทธ ์ด๋ฆ„์—์„œ๋„ ๋“œ๋Ÿฌ๋‚˜๋“ฏ, ์•กํ‹ฐ๋น„ํ‹ฐ(activity)๋ฅผ ์ฃผ๊ณ  ๋ฐ›๋Š” ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค. ๊ธ€์“ฐ๊ธฐ, ๊ธ€ ๊ณ ์น˜๊ธฐ, ๊ธ€ ์ง€์šฐ๊ธฐ, ๊ธ€์— ์ข‹์•„์š” ์ฐ๊ธฐ, ๋Œ“๊ธ€ ๋‹ฌ๊ธฐ, ํ”„๋กœํ•„ ๊ณ ์น˜๊ธฐโ€ฆ ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ ์ผ์–ด๋‚˜๋Š” ๋ชจ๋“  ์ผ๋“ค์„ ์•กํ‹ฐ๋น„ํ‹ฐ๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋ชจ๋“  ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์•กํ„ฐ(actor)์—์„œ ์•กํ„ฐ๋กœ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํ™๊ธธ๋™์ด ๊ธ€์„ ์“ฐ๋ฉด ใ€Œ๊ธ€์“ฐ๊ธฐใ€(Create(Note)) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ™๊ธธ๋™์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์˜ ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๊ธ€์— ์ž„๊บฝ์ •์ด ์ข‹์•„์š”๋ฅผ ์ฐ์œผ๋ฉด ใ€Œ์ข‹์•„์š”ใ€(Like) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž„๊บฝ์ •์œผ๋กœ๋ถ€ํ„ฐ ํ™๊ธธ๋™์—๊ฒŒ ์ „์†ก๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ActivityPub์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๊ฑธ์Œ์€ ์•กํ„ฐ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

fedify init ๋ช…๋ น์œผ๋กœ ์ƒ์„ฑ๋œ ๋ฐ๋ชจ ์•ฑ์— ์ด๋ฏธ ์•„์ฃผ ๊ฐ„๋‹จํ•œ ์•กํ„ฐ๊ฐ€ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธด ํ•˜์ง€๋งŒ, Mastodon์ด๋‚˜ Misskey ๊ฐ™์€ ์‹ค์ œ์˜ ์†Œํ”„ํŠธ์›จ์–ด๋“ค๊ณผ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•กํ„ฐ๋ฅผ ์ข€ ๋” ์ œ๋Œ€๋กœ ๊ตฌํ˜„ํ•  ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋‹จ, ํ˜„์žฌ์˜ ๊ตฌํ˜„์„ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณผ๊นŒ์š”? src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด๋ด…์‹œ๋‹ค:

import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";

const logger = getLogger("microblog");

const federation = createFederation({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
});

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: identifier,
  });
});

export default federation;

์šฐ๋ฆฌ๊ฐ€ ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๋ถ€๋ถ„์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ๋‹ค๋ฅธ ActivityPub ์†Œํ”„ํŠธ์›จ์–ด๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์„œ๋ฒ„์˜ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•  ๋•Œ ์“ธ URL๊ณผ ๊ทธ ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์šฐ๋ฆฌ๊ฐ€ ์•ž์„œ ํ–ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ /users/johndoe๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฝœ๋ฐฑ ํ•จ์ˆ˜์˜ identifier ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ "johndoe"๋ผ๋Š” ๋ฌธ์ž์—ด ๊ฐ’์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋Š” Person ํด๋ž˜์Šค์˜ ์ธ์Šคํ„ด์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ ์กฐํšŒํ•œ ์•กํ„ฐ์˜ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

ctx ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ๋Š” Context ๊ฐ์ฒด๊ฐ€ ๋„˜์–ด์˜ค๋Š”๋ฐ, ActivityPub ํ”„๋กœํ† ์ฝœ๊ณผ ๊ด€๋ จ๋œ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ๋‹ด๊ณ  ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์œ„ ์ฝ”๋“œ์—์„œ ์“ฐ์ด๊ณ  ์žˆ๋Š” getActorUri() ๋ฉ”์„œ๋“œ๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌ๋œ identifier๊ฐ€ ๋“ค์–ด๊ฐ„ ์•กํ„ฐ์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด URI๋Š” Person ๊ฐ์ฒด์˜ ๊ณ ์œ  ์‹๋ณ„์ž๋กœ ์“ฐ์ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ๋ณด์‹œ๋ฉด ์•Œ๊ฒ ์ง€๋งŒ, ํ˜„์žฌ๋Š” /users/ ๊ฒฝ๋กœ ๋’ค์— ์–ด๋–ค ํ•ธ๋“ค์ด ์˜ค๋“  ๋ถ€๋ฅด๋Š” ๋Œ€๋กœ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์ง€์–ด๋‚ด์„œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์žˆ๋Š” ๊ณ„์ •์— ๋Œ€ํ•ด์„œ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ณ ์ณ๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ…Œ์ด๋ธ”์€ ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •๋งŒ ๋‹ด๋Š” users ํ…Œ์ด๋ธ”๊ณผ ๋‹ฌ๋ฆฌ, ์—ฐํ•ฉ๋˜๋Š” ์„œ๋ฒ„๋“ค์— ์†ํ•œ ์›๊ฒฉ ์•กํ„ฐ๋“ค๊นŒ์ง€๋„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. src/schema.sql ํŒŒ์ผ์— ๋‹ค์Œ SQL์„ ๋ง๋ถ™์ด์„ธ์š”:

CREATE TABLE IF NOT EXISTS actors (
  id               INTEGER NOT NULL PRIMARY KEY,
  user_id          INTEGER          REFERENCES users (id),
  uri              TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  handle           TEXT    NOT NULL UNIQUE CHECK (handle <> ''),
  name             TEXT,
  inbox_url        TEXT    NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%'
                                                  OR inbox_url LIKE 'http://%'),
  shared_inbox_url TEXT                    CHECK (shared_inbox_url
                                                  LIKE 'https://%'
                                                  OR shared_inbox_url
                                                  LIKE 'http://%'),
  url              TEXT                    CHECK (url LIKE 'https://%'
                                                  OR url LIKE 'http://%'),
  created          TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                           CHECK (created <> '')
);
  • user_id ์นผ๋Ÿผ์€ users ์นผ๋Ÿผ๊ณผ ์—ฐ๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ์™ธ๋ž˜ ํ‚ค์ž…๋‹ˆ๋‹ค. ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๊ฐ€ ์›๊ฒฉ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•  ๊ฒฝ์šฐ์—๋Š” NULL์ด ๋“ค์–ด๊ฐ€์ง€๋งŒ, ํ˜„์žฌ ์ธ์Šคํ„ด์Šค ์„œ๋ฒ„์˜ ๊ณ„์ •์ด๋ผ๋ฉด ํ•ด๋‹น ๊ณ„์ •์˜ users.id ๊ฐ’์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ์•กํ„ฐ ID๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•กํ„ฐ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ActivityPub ๊ฐ์ฒด๋Š” URI ํ˜•ํƒœ์˜ ๊ณ ์œ  ID๋ฅผ ๊ฐ–์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • handle ์นผ๋Ÿผ์€ @johndoe@example.com ๋ชจ์–‘์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • name ์นผ๋Ÿผ์€ UI์— ํ‘œ์‹œ๋˜๋Š” ์ด๋ฆ„์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋ณดํ†ต ํ’€๋„ค์ž„์ด๋‚˜ ๋‹‰๋„ค์ž„์ด ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๊ฒ ์ฃ . ๋‹ค๋งŒ, ActivityPub ๋ช…์„ธ์— ๋”ฐ๋ผ ์ด ์นผ๋Ÿผ์€ ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ(inbox) URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ์ˆ˜์‹ ํ•จ์ด ๋ฌด์—‡์ธ์ง€์— ๋Œ€ํ•ด์„œ๋Š” ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค๋งŒ, ํ˜„์žฌ๋กœ์„œ๋Š” ์•กํ„ฐ์—๊ฒŒ ํ•„์ˆ˜์ ์œผ๋กœ ์กด์žฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ๋งŒ ์•Œ์•„ ๋‘์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด ์นผ๋Ÿผ ์—ญ์‹œ ๋นŒ ์ˆ˜๋„ ์—†๊ณ  ์ค‘๋ณต๋  ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค.
  • shared_inbox_url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ(shared inbox) URL์„ ๋‹ด๋Š”๋ฐ, ์ด ์—ญ์‹œ ์•„๋ž˜์—์„œ ์ œ๋Œ€๋กœ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜๋Š” ์•„๋‹ˆ๋ฉฐ, ๋”ฐ๋ผ์„œ ๋นŒ ์ˆ˜ ์žˆ๊ณ  ์นผ๋Ÿผ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ๊ฐ™์€ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์€ ํ•ด๋‹น ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ URL์ด๋ž€ ์›น๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด์„œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ URL์„ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ID์™€ ํ”„๋กœํ•„ URL์ด ๋™์ผํ•œ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ์„œ๋น„์Šค์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ทธ ๊ฒฝ์šฐ์— ์ด ์นผ๋Ÿผ์— ํ”„๋กœํ•„ URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์€ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋œ ์‹œ์ ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. ๋นŒ ์ˆ˜ ์—†์œผ๋ฉฐ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฝ์ž… ์‹œ์  ์‹œ๊ฐ์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.

์ž, ์ด์ œ src/schema.sql ํŒŒ์ผ์„ microblog.sqlite3 ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์— ์ ์šฉํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

ํŒ

์•ž์„œ users ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•  ๋•Œ CREATE TABLE IF NOT EXISTS ๋ฌธ์„ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  actors ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  ํƒ€์ž…๋„ src/schema.ts์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Actor {
  id: number;
  user_id: number | null;
  uri: string;
  handle: string;
  name: string | null;
  inbox_url: string;
  shared_inbox_url: string | null;
  url: string | null;
  created: string;
}

์•กํ„ฐ ๋ ˆ์ฝ”๋“œ

ํ˜„์žฌ users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜ ์žˆ๊ธด ํ•˜์ง€๋งŒ, ์ด์™€ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์—๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ actors ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ์„œ users์™€ actors ์–‘์ชฝ์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx์— ์žˆ๋Š” SetupForm์—์„œ ์•„์ด๋””์™€ ํ•จ๊ป˜ actors.name ์นผ๋Ÿผ์— ๋“ค์–ด๊ฐˆ ์ด๋ฆ„๋„ ์ž…๋ ฅ ๋ฐ›๋„๋ก ํ•ฉ์‹œ๋‹ค:

export const SetupForm: FC = () => (
  <>
    <h1>Set up your microblog</h1>
    <form method="post" action="/setup">
      <fieldset>
        <label>
          Username{" "}
          <input
            type="text"
            name="username"
            required
            maxlength={50}
            pattern="^[a-z0-9_\-]+$"
          />
        </label>
        <label>
          Name <input type="text" name="name" required />
        </label>
      </fieldset>
      <input type="submit" value="Setup" />
    </form>
  </>
);

์•ž์„œ ์ •์˜ํ•œ Actor ํƒ€์ž…์„ src/app.tsx์—์„œ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

์ด์ œ ์ž…๋ ฅ ๋ฐ›์€ ์ด๋ฆ„์„ ๋น„๋กฏํ•ด ํ•„์š”ํ•œ ์ •๋ณด๋“ค์„ actors ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ๋กœ ๋งŒ๋“œ๋Š” ์ฝ”๋“œ๋ฅผ POST /setup ํ•ธ๋“ค๋Ÿฌ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/setup", async (c) => {
  // ๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  const form = await c.req.formData();
  const username = form.get("username");
  if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
    return c.redirect("/setup");
  }
  const name = form.get("name");
  if (typeof name !== "string" || name.trim() === "") {
    return c.redirect("/setup");
  }
  const url = new URL(c.req.url);
  const handle = `@${username}@${url.host}`;
  const ctx = fedi.createContext(c.req.raw, undefined);
  db.transaction(() => {
    db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
      username,
    );
    db.prepare(
      `
      INSERT OR REPLACE INTO actors
        (user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
      VALUES (1, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      ctx.getActorUri(username).href,
      handle,
      name,
      ctx.getInboxUri(username).href,
      ctx.getInboxUri().href,
      ctx.getActorUri(username).href,
    );
  })();
  return c.redirect("/");
});

๊ณ„์ •์ด ์ด๋ฏธ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•  ๋•Œ, users ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์„ ๋•Œ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ง์ด ๋งž๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์—†์–ด๋„ ์•„์ง ๊ณ„์ •์ด ์—†๋Š” ๊ฒƒ์œผ๋กœ ํŒ์ •ํ•˜๋„๋ก ๊ณ ์ณค์Šต๋‹ˆ๋‹ค. ๊ฐ™์€ ์กฐ๊ฑด์„ GET /setup ํ•ธ๋“ค๋Ÿฌ ๋ฐ GET /users/{username} ํ•ธ๋“ค๋Ÿฌ์—๋„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

app.get("/setup", (c) => {
  // Check if the user already exists
  const user = db
    .prepare<unknown[], User>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      LIMIT 1
      `,
    )
    .get();
  if (user != null) return c.redirect("/");

  return c.html(
    <Layout>
      <SetupForm />
    </Layout>,
  );
});
app.get("/users/:username", async (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE username = ?
      `,
    )
    .get(c.req.param("username"));
  if (user == null) return c.notFound();

  const url = new URL(c.req.url);
  const handle = `@${user.username}@${url.host}`;
  return c.html(
    <Layout>
      <Profile name={user.name ?? user.username} handle={handle} />
    </Layout>,
  );
});

ํŒ

TypeScript์—์„œ A & B๋Š” A ํƒ€์ž…์ธ ๋™์‹œ์— B ํƒ€์ž…์ธ ๊ฐ์ฒด๋ฅผ ๋œปํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, { a: number } & { b: string } ํƒ€์ž…์ด ์žˆ๋‹ค๊ณ  ํ•  ๋•Œ, { a: 123 }์ด๋‚˜ { b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ•˜์ง€๋งŒ, { a: 123, b: "foo" }๋Š” ํ•ด๋‹น ํƒ€์ž…์„ ๋งŒ์กฑํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด, ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ์•„๋ž˜์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

setInboxListeners() ๋ฉ”์„œ๋“œ๋Š” ์ง€๊ธˆ์œผ๋กœ์„œ๋Š” ์‹ ๊ฒฝ ์“ฐ์ง€ ๋งˆ์„ธ์š”. ์ด ์—ญ์‹œ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, ๊ณ„์ • ์ƒ์„ฑ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉํ•œ getInboxUri() ๋ฉ”์„œ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋ ค๋ฉด ์œ„ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

์ฝ”๋“œ๋ฅผ ๋ชจ๋‘ ๊ณ ์ณค๋‹ค๋ฉด, ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/setup ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด์„œ ๋‹ค์‹œ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜

actors ํ…Œ์ด๋ธ”์„ ๋งŒ๋“ค๊ณ  ๋ ˆ์ฝ”๋“œ๋„ ์ฑ„์› ์œผ๋‹ˆ, ๋‹ค์‹œ src/federation.ts ํŒŒ์ผ์„ ๊ณ ์ณ๋ด…์‹œ๋‹ค. ๋จผ์ € db ๊ฐ์ฒด์™€ Endpoints ๋ฐ Actor๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";

ํ•„์š”ํ•œ ๊ฒƒ๋“ค์„ importํ–ˆ์œผ๋‹ˆ setActorDispatcher() ๋ฉ”์„œ๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค:

federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT * FROM users
      JOIN actors ON (users.id = actors.user_id)
      WHERE users.username = ?
      `,
    )
    .get(identifier);
  if (user == null) return null;

  return new Person({
    id: ctx.getActorUri(identifier),
    preferredUsername: identifier,
    name: user.name,
    inbox: ctx.getInboxUri(identifier),
    endpoints: new Endpoints({
      sharedInbox: ctx.getInboxUri(),
    }),
    url: ctx.getActorUri(identifier),
  });
});

๋ฐ”๋€ ์ฝ”๋“œ์—์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ users ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์—ฌ ํ˜„์žฌ ์„œ๋ฒ„์— ์žˆ๋Š” ๊ณ„์ •์ด ์•„๋‹ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, GET /users/johndoe (๊ณ„์ •์„ ์ƒ์„ฑํ•  ๋•Œ ์•„์ด๋””๋ฅผ johndoe๋กœ ์ •ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•  ๊ฒฝ์šฐ) ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” ์˜ฌ๋ฐ”๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์™€ ํ•จ๊ป˜ ์‘๋‹ตํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ์™ธ์˜ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” 404 Not Found๋ฅผ ์‘๋‹ตํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Person ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ถ€๋ถ„๋„ ์–ด๋–ป๊ฒŒ ๋ฐ”๋€Œ์—ˆ๋‚˜ ์‚ดํŽด๋ด…์‹œ๋‹ค. ๋จผ์ € name ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ํ”„๋กœํผํ‹ฐ๋Š” actors.name ์นผ๋Ÿผ์˜ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. inbox์™€ endpoints ์†์„ฑ์€ ์ˆ˜์‹ ํ•จ์— ๋Œ€ํ•ด ์„ค๋ช…ํ•  ๋•Œ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. url ์†์„ฑ์€ ์ด ๊ณ„์ •์˜ ํ”„๋กœํ•„ URL์„ ๋‹ด๋Š”๋ฐ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์•กํ„ฐ ID์™€ ์•กํ„ฐ์˜ ํ”„๋กœํ•„ URL์„ ์ผ์น˜์‹œํ‚ค๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํŒ

๋ˆˆ์ฐ๋ฏธ๊ฐ€ ์ข‹์€ ๋ถ„๋“ค์€ ๋ˆˆ์น˜์ฑ„์…จ๊ฒ ์ง€๋งŒ, Hono์™€ Fedify ์–‘์ชฝ์—์„œ GET /users/{identifier}์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ฒน์ณ์„œ ์ •์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ํ•ด๋‹น ์š”์ฒญ์„ ์‹ค์ œ๋กœ ๋ณด๋‚ด๋ฉด ์–ด๋А ์ชฝ์—์„œ ์‘๋‹ตํ•˜๊ฒŒ ๋ ๊นŒ์š”? ์ •๋‹ต์€ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง„๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Accept: text/html ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Hono ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค. Accept: application/activity+json ํ—ค๋”์™€ ํ•จ๊ป˜ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด Fedify ์ชฝ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ์š”์ฒญ์˜ Accept ํ—ค๋”์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต์„ ์ฃผ๋Š” ๋ฐฉ์‹์„ HTTP ๋‚ด์šฉ ํ˜‘์ƒ(content negotiation)์ด๋ผ๊ณ  ํ•˜๋ฉฐ, Fedify ์ž์ฒด์—์„œ ๋‚ด์šฉ ํ˜‘์ƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ์ข€ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ๋Š”, ๋ชจ๋“  ์š”์ฒญ์€ Fedify๋ฅผ ํ•œ ๋ฒˆ ๊ฑฐ์น˜๊ฒŒ ๋˜๋ฉฐ, ActivityPub๊ณผ ๊ด€๋ จ๋œ ์š”์ฒญ์ด ์•„๋‹ ๊ฒฝ์šฐ ์—ฐ๋™๋œ ํ”„๋ ˆ์ž„์›Œํฌ, ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” Hono์—๊ฒŒ ์š”์ฒญ์„ ๊ฑด๋‚ด์ฃผ๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

Fedify์—์„œ๋Š” ๋ชจ๋“  URI ๋ฐ URL์„ URL ์ธ์Šคํ„ด์Šค๋กœ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ทธ๋Ÿผ ํ•œ ๋ฒˆ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ํ…Œ์ŠคํŠธํ•ด ๋ณผ๊นŒ์š”?

์„œ๋ฒ„๊ฐ€ ์ผœ์ง„ ์ƒํƒœ์—์„œ, ์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ์„ ์—ด์–ด ์•„๋ž˜ ๋ช…๋ น์„ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค:

fedify lookup http://localhost:8000/users/alice

alice์ด๋ผ๋Š” ๊ณ„์ •์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์•„๊นŒ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
Failed to fetch the object.
It may be a private object.  Try with -a/--authorized-fetch.

๊ทธ๋Ÿผ johndoe ๊ณ„์ •๋„ ์กฐํšŒํ•ด ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์ด์ œ๋Š” ๊ฒฐ๊ณผ๊ฐ€ ์ž˜ ๋‚˜์˜ต๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

์•”ํ˜ธ ํ‚ค ์Œ๋“ค

๊ทธ ๋‹ค์Œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์„œ๋ช…์„ ์œ„ํ•œ ์•กํ„ฐ์˜ ์•”ํ˜ธ ํ‚ค๋“ค์ž…๋‹ˆ๋‹ค. ActivityPub์€ ์•กํ„ฐ๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋Š”๋ฐ, ์ด ๋•Œ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ •๋ง๋กœ ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ๋งŒ๋“ค์—ˆ๋‹ค๋Š” ๊ฒƒ์„ ์ฆ๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋””์ง€ํ„ธ ์„œ๋ช…์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด ์•กํ„ฐ๋Š” ์ง์ด ๋งž๋Š” ์ž์‹ ๋งŒ์˜ ๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค) ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ๋งŒ๋“ค์–ด ๊ฐ–๊ณ  ์žˆ๊ณ , ๊ทธ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๋„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๊ณต๊ฐœํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ๋“ค์€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๋•Œ ๋ฐœ์‹ ์ž์˜ ๊ณต๊ฐœ ํ‚ค์™€ ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์„œ๋ช…์„ ๋Œ€์กฐํ•˜์—ฌ ๊ทธ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ •๋ง๋กœ ๋ฐœ์‹ ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒŒ ๋งž๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ช…๊ณผ ์„œ๋ช… ๋Œ€์กฐ๋Š” Fedify๊ฐ€ ์•Œ์•„์„œ ํ•ด ์ฃผ์ง€๋งŒ, ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ณด์กดํ•˜๋Š” ๊ฒƒ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์…”์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

๊ฐœ์ธ ํ‚ค(๋น„๋ฐ€ ํ‚ค)๋Š” ์ด๋ฆ„์—์„œ ๋“œ๋Ÿฌ๋‚˜๋“ฏ ์„œ๋ช…ํ•  ์ฃผ์ฒด ์ด์™ธ์—๋Š” ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, ๊ณต๊ฐœ ํ‚ค๋Š” ๊ทธ ์šฉ๋„ ์ž์ฒด๊ฐ€ ๊ณต๊ฐœํ•˜๊ธฐ ์œ„ํ•จ์ด๋ฏ€๋กœ ๋ˆ„๊ตฌ๋‚˜ ์ ‘๊ทผํ•ด๋„ ๊ดœ์ฐฎ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ €์žฅํ•  keys ํ…Œ์ด๋ธ”์„ src/schema.sql์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS keys (
  user_id     INTEGER NOT NULL REFERENCES users (id),
  type        TEXT    NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
  private_key TEXT    NOT NULL CHECK (private_key <> ''),
  public_key  TEXT    NOT NULL CHECK (public_key <> ''),
  created     TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
  PRIMARY KEY (user_id, type)
);

ํ…Œ์ด๋ธ”์„ ์œ ์‹ฌํžˆ ์‚ดํŽด๋ณด๋ฉด, type ์นผ๋Ÿผ์—๋Š” ์˜ค์ง ๋‘ ์ข…๋ฅ˜์˜ ๊ฐ’๋งŒ ํ—ˆ์šฉ๋œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜๋‚˜๋Š” RSA-PKCS#1-v1.5 ํ˜•์‹์ด๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” Ed25519 ํ˜•์‹์ž…๋‹ˆ๋‹ค. (๊ฐ๊ฐ์ด ๋ฌด์—‡์„ ๋œปํ•˜๋Š”์ง€๋Š” ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ค‘์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.) ๊ธฐ๋ณธ ํ‚ค๊ฐ€ (user_id, type)์— ๊ฑธ๋ ค ์žˆ์œผ๋‹ˆ, ํ•œ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•ด ์ตœ๋Œ€ ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์ˆ˜๋Š” ์—†์ง€๋งŒ, 2024๋…„ 9์›” ํ˜„์žฌ ActivityPub ๋„คํŠธ์›Œํฌ๋Š” RSA-PKCS-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-v1.5 ํ˜•์‹๋งŒ ๋ฐ›์•„๋“ค์ด๊ณ  ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” Ed25519 ํ˜•์‹์„ ๋ฐ›์•„๋“ค์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ์–‘์ชฝ ๋ชจ๋‘์™€ ์†Œํ†ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ์Œ์˜ ํ‚ค๊ฐ€ ๋ชจ๋‘ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

private_key ๋ฐ public_key ์นผ๋Ÿผ์€ ๋ฌธ์ž์—ด์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์— JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์„ ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. ๊ฐœ์ธ ํ‚ค์™€ ๊ณต๊ฐœ ํ‚ค๋ฅผ JSON์œผ๋กœ ์ธ์ฝ”๋”ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋’ค์—์„œ ์ฐจ์ฐจ ๋‹ค๋ฃจ๊ฒŒ ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ keys ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

keys ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•  Key ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Key {
  user_id: number;
  type: "RSASSA-PKCS1-v1_5" | "Ed25519";
  private_key: string;
  public_key: string;
  created: string;
}

ํ‚ค ์Œ ๋””์ŠคํŒจ์ฒ˜

์ด์ œ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด๊ณ  Fedify์—์„œ ์ œ๊ณต๋˜๋Š” exportJwk(), generateCryptoKeyPair(), importJwk() ํ•จ์ˆ˜๋“ค๊ณผ ์•ž์„œ ์ •์˜ํ•œ Key ํƒ€์ž…์„ importํ•ฉ์‹œ๋‹ค:

import {
  Endpoints,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜ ๋ถ€๋ถ„์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User & Actor>(
        `
        SELECT * FROM users
        JOIN actors ON (users.id = actors.user_id)
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    if (user == null) return null;

    const keys = await ctx.getActorKeyPairs(identifier);
    return new Person({
      id: ctx.getActorUri(identifier),
      preferredUsername: identifier,
      name: user.name,
      inbox: ctx.getInboxUri(identifier),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      url: ctx.getActorUri(identifier),
      publicKey: keys[0].cryptographicKey,
      assertionMethods: keys.map((k) => k.multikey),
    });
  })
  .setKeyPairsDispatcher(async (ctx, identifier) => {
    const user = db
      .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
      .get(identifier);
    if (user == null) return [];
    const rows = db
      .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
      .all(user.id);
    const keys = Object.fromEntries(
      rows.map((row) => [row.type, row]),
    ) as Record<Key["type"], Key>;
    const pairs: CryptoKeyPair[] = [];
    // ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์›ํ•˜๋Š” ๋‘ ํ‚ค ํ˜•์‹ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์— ๋Œ€ํ•ด
    // ํ‚ค ์Œ์„ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์—†์œผ๋ฉด ์ƒ์„ฑ ํ›„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ:
    for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
      if (keys[keyType] == null) {
        logger.debug(
          "The user {identifier} does not have an {keyType} key; creating one...",
          { identifier, keyType },
        );
        const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
        db.prepare(
          `
          INSERT INTO keys (user_id, type, private_key, public_key)
          VALUES (?, ?, ?, ?)
          `,
        ).run(
          user.id,
          keyType,
          JSON.stringify(await exportJwk(privateKey)),
          JSON.stringify(await exportJwk(publicKey)),
        );
        pairs.push({ privateKey, publicKey });
      } else {
        pairs.push({
          privateKey: await importJwk(
            JSON.parse(keys[keyType].private_key),
            "private",
          ),
          publicKey: await importJwk(
            JSON.parse(keys[keyType].public_key),
            "public",
          ),
        });
      }
    }
    return pairs;
  });

์šฐ์„  ๊ฐ€์žฅ ๋จผ์ € ์ฃผ๋ชฉํ•ด์•ผ ํ•  ๊ฒƒ์€ setActorDispatcher() ๋ฉ”์„œ๋“œ์— ์—ฐ๋‹ฌ์•„ ํ˜ธ์ถœ๋˜๊ณ  ์žˆ๋Š” setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜์—์„œ ๋ฐ˜ํ™˜๋œ ํ‚ค ์Œ๋“ค์„ ๊ณ„์ •์— ์—ฐ๊ฒฐํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ‚ค ์Œ๋“ค์„ ์—ฐ๊ฒฐํ•ด์•ผ Fedify๊ฐ€ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ•  ๋•Œ ์ž๋™์œผ๋กœ ๋“ฑ๋ก๋œ ๊ฐœ์ธ ํ‚ค๋“ค๋กœ ๋””์ง€ํ„ธ ์„œ๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

generateCryptoKeyPair() ํ•จ์ˆ˜๋Š” ์ƒˆ๋กœ์šด ๊ฐœ์ธ ํ‚ค ๋ฐ ๊ณต๊ฐœ ํ‚ค ์Œ์„ ์ƒ์„ฑํ•˜์—ฌ CryptoKeyPair ๊ฐ์ฒด๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋กœ CryptoKeyPair ํƒ€์ž…์€ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ˜•์‹์ž…๋‹ˆ๋‹ค.

exportJwk() ํ•จ์ˆ˜๋Š” CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JWK ํ˜•์‹์ด ๋ฌด์—‡์ธ์ง€ ์•Œ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ์•”ํ˜ธ ํ‚ค๋ฅผ JSON์œผ๋กœ ํ‘œํ˜„ํ•˜๋Š” ํ‘œ์ค€์ ์ธ ํ˜•์‹์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. CryptoKey๋Š” ์•”ํ˜ธ ํ‚ค๋ฅผ JavaScript ๊ฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์›น ํ‘œ์ค€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.

importJwk() ํ•จ์ˆ˜๋Š” JWK ํ˜•์‹์œผ๋กœ ํ‘œํ˜„๋œ ํ‚ค๋ฅผ CryptoKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. exportJwk() ํ•จ์ˆ˜์˜ ๋ฐ˜๋Œ€๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ setActorDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ˆˆ์„ ๋Œ๋ฆฝ์‹œ๋‹ค. getActorKeyPairs()๋ผ๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์“ฐ์ด๊ณ  ์žˆ๋Š”๋ฐ, ์ด ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฆ„๊ณผ ๊ฐ™์ด ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์•กํ„ฐ์˜ ํ‚ค ์Œ๋“ค์€ ๋ฐ”๋กœ ์•ž์—์„œ ์‚ดํŽด๋ณธ setKeyPairsDispatcher() ๋ฉ”์„œ๋“œ๋กœ ๋ถˆ๋Ÿฌ์˜จ ๋ฐ”๋กœ ๊ทธ ํ‚ค ์Œ๋“ค์ž…๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” RSA-PKCS-v1.5์™€ Ed25519 ํ˜•์‹์œผ๋กœ ๋œ ๋‘ ์Œ์˜ ํ‚ค๋ฅผ ๋ถˆ๋Ÿฌ์™”์œผ๋ฏ€๋กœ, getActorKeyPairs() ๋ฉ”์„œ๋“œ๋Š” ๋‘ ํ‚ค ์Œ์˜ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ฐฐ์—ด์˜ ์›์†Œ๋Š” ํ‚ค ์Œ์„ ์—ฌ๋Ÿฌ ํ˜•์‹์œผ๋กœ ํ‘œํ˜„ํ•œ ๊ฐ์ฒด์ธ๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

interface ActorKeyPair {
  privateKey: CryptoKey;              // ๊ฐœ์ธ ํ‚ค
  publicKey: CryptoKey;               // ๊ณต๊ฐœ ํ‚ค
  keyId: URL;                         // ํ‚ค์˜ ๊ณ ์œ  ์‹๋ณ„ URI
  cryptographicKey: CryptographicKey; // ๊ณต๊ฐœ ํ‚ค์˜ ๋‹ค๋ฅธ ํ˜•์‹
  multikey: Multikey;                 // ๊ณต๊ฐœ ํ‚ค์˜ ๋˜ ๋‹ค๋ฅธ ํ˜•์‹
}

CryptoKey์™€ CryptographicKey์™€ Multikey๊ฐ€ ๊ฐ๊ฐ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ์ง€, ์™œ ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ ํ˜•์‹์ด ์žˆ์–ด์•ผ ํ•˜๋Š”์ง€๋Š” ์ด ์ž๋ฆฌ์—์„œ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋ณต์žกํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ง€๊ธˆ์€ Person ๊ฐ์ฒด๋ฅผ ์ดˆ๊ธฐํ™”ํ•  ๋•Œ publicKey ์†์„ฑ์€ CryptographicKey ํ˜•์‹์„ ๋ฐ›๊ณ  assertionMethods ์†์„ฑ์€ MultiKey[] (Multikey์˜ ๋ฐฐ์—ด์„ TypeScript์—์„œ ์ด๋ ‡๊ฒŒ ํ‘œ๊ธฐ) ํ˜•์‹์„ ๋ฐ›๋Š”๋‹ค๋Š” ๊ฒƒ๋งŒ ์งš๊ณ  ๋„˜์–ด๊ฐ€๋„๋ก ํ•ฉ์‹œ๋‹ค.

๊ทธ๋‚˜์ €๋‚˜, Person ๊ฐ์ฒด์—๋Š” ์™œ ๊ณต๊ฐœ ํ‚ค๋ฅผ ๊ฐ–๋Š” ์†์„ฑ์ด publicKey์™€ assertionMethods๋กœ ๋‘ ๊ฐœ๋‚˜ ์žˆ์„๊นŒ์š”? ActivityPub์—๋Š” ์›๋ž˜ publicKey ์†์„ฑ๋งŒ ์žˆ์—ˆ์ง€๋งŒ, ๋‚˜์ค‘์— ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก assertionMethods ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-v1.5 ํ‚ค ์Œ์ด, ๋‘ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— Ed25519 ํ‚ค ์Œ์ด ๋“ค์–ด๊ฐ).

ํŒ

์‚ฌ์‹ค publicKey ์†์„ฑ๋„ ์—ฌ๋Ÿฌ ํ‚ค๋ฅผ ๋‹ด์„ ์ˆ˜๋Š” ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€๋งŒ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด๋“ค์ด ์ด๋ฏธ publicKey ์†์„ฑ์—๋Š” ๋‹จ ํ•˜๋‚˜์˜ ํ‚ค๋งŒ ๋“ค์–ด๊ฐˆ ๊ฒƒ์ด๋ผ๋Š” ์ „์ œ ํ•˜์— ๊ตฌํ˜„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์˜ค์ž‘๋™ํ•  ๋•Œ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assertionMethods๋ผ๋Š” ์ƒˆ๋กœ์šด ์†์„ฑ์ด ์ œ์•ˆ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์ด์— ๊ด€ํ•ด ๊ด€์‹ฌ์ด ์ƒ๊ธฐ์‹  ๋ถ„๋“ค์€ FEP-521a ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

ํ…Œ์ŠคํŠธ

์ž, ์•กํ„ฐ ๊ฐ์ฒด์— ์•”ํ˜ธ ํ‚ค๋“ค์„ ๋“ฑ๋กํ–ˆ์œผ๋ฏ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ๋‹ค์Œ ๋ช…๋ น์œผ๋กœ ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

fedify lookup http://localhost:8000/users/johndoe

์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ์ถœ๋ ฅ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  id: URL "http://localhost:8000/users/johndoe",
  name: "John Doe",
  url: URL "http://localhost:8000/users/johndoe",
  preferredUsername: "johndoe",
  publicKey: CryptographicKey {
    id: URL "http://localhost:8000/users/johndoe#main-key",
    owner: URL "http://localhost:8000/users/johndoe",
    publicKey: CryptoKey {
      type: "public",
      extractable: true,
      algorithm: {
        name: "RSASSA-PKCS1-v1_5",
        modulusLength: 4096,
        publicExponent: Uint8Array(3) [ 1, 0, 1 ],
        hash: { name: "SHA-256" }
      },
      usages: [ "verify" ]
    }
  },
  assertionMethods: [
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#main-key",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: {
          name: "RSASSA-PKCS1-v1_5",
          modulusLength: 4096,
          publicExponent: Uint8Array(3) [ 1, 0, 1 ],
          hash: { name: "SHA-256" }
        },
        usages: [ "verify" ]
      }
    },
    Multikey {
      id: URL "http://localhost:8000/users/johndoe#key-2",
      controller: URL "http://localhost:8000/users/johndoe",
      publicKey: CryptoKey {
        type: "public",
        extractable: true,
        algorithm: { name: "Ed25519" },
        usages: [ "verify" ]
      }
    }
  ],
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

Person ๊ฐ์ฒด์˜ publicKey ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ Multikey ๊ฐ์ฒด๊ฐ€ ๋‘˜ ๋“ค์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Mastodon๊ณผ ์—ฐ๋™

์ด์ œ ์‹ค์ œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค.

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ

์•„์‰ฝ๊ฒŒ๋„ ํ˜„์žฌ ์„œ๋ฒ„๋Š” ๋กœ์ปฌ์—์„œ๋งŒ ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ๋งˆ๋‹ค ์–ด๋”˜๊ฐ€์— ๋ฐฐํฌํ•ด์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ๋ถˆํŽธํ•˜๊ฒ ์ฃ . ๋ฐฐํฌํ•˜์ง€ ์•Š๊ณ  ๋ฐ”๋กœ ์ธํ„ฐ๋„ท์— ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๋…ธ์ถœํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•ด ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์–ผ๋งˆ๋‚˜ ์ข‹์„๊นŒ์š”?

์—ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋Ÿด ๋•Œ ์“ฐ๋Š” ๋ช…๋ น์–ด์ž…๋‹ˆ๋‹ค. ํ„ฐ๋ฏธ๋„์—์„œ ์ƒˆ ํƒญ์„ ์—ฐ ๋’ค, ์ด ๋ช…๋ น์–ด ๋’ค์— ๋กœ์ปฌ ์„œ๋ฒ„์˜ ํฌํŠธ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค:

fedify tunnel 8000

๊ทธ๋Ÿฌ๋ฉด ํ•œ ๋ฒˆ ์“ฐ๊ณ  ๋ฒ„๋ฆด ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๋งŒ๋“ค์–ด์„œ ๋กœ์ปฌ ์„œ๋ฒ„๋กœ ์ค‘๊ณ„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. ์™ธ๋ถ€์—์„œ๋„ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” URL์ด ์ถœ๋ ฅ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Your local server at 8000 is now publicly accessible:

https://temp-address.serveo.net/

Press ^C to close the tunnel.

๋ฌผ๋ก , ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ๋Š” ์œ„ URL๊ณผ๋Š” ๋‹ค๋ฅธ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๊ณ ์œ ํ•œ URL์ด ์ถœ๋ ฅ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/users/johndoe(์—ฌ๋Ÿฌ๋ถ„์˜ ๊ณ ์œ  ์ž„์‹œ ๋„๋ฉ”์ธ์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ์—ด์–ด์„œ ์ž˜ ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๊ณต๊ฐœ ์ธํ„ฐ๋„ท์œผ๋กœ ๋…ธ์ถœ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์œ„ ์›น ํŽ˜์ด์ง€์— ๋ณด์ด๋Š” ์—ฌ๋Ÿฌ๋ถ„์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ณต์‚ฌํ•œ ๋’ค, Mastodon์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋ณด๋Š” ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ๊นŒ์ง€์ž…๋‹ˆ๋‹ค. ์•„์ง ํŒ”๋กœ๋Š” ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ์‹œ๋„ํ•˜์ง€ ๋งˆ์„ธ์š”! ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•  ์ˆ˜ ์žˆ์œผ๋ ค๋ฉด, ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์•ˆ๋‚ด

fedify tunnel ๋ช…๋ น์€ ํ•œ๋™์•ˆ ์“ฐ์ด์ง€ ์•Š์œผ๋ฉด ์ €์ ˆ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊น๋‹ˆ๋‹ค. ๊ทธ๋Ÿด ๋•Œ๋Š”, Ctrl+C ํ‚ค๋ฅผ ๋ˆŒ๋Ÿฌ ๋ˆ ๋‹ค์Œ, fedify tunnel 8000 ๋ช…๋ น์„ ๋‹ค์‹œ ์ณ์„œ ์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ์„ ๋งบ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ

ActivityPub์—์„œ ์ˆ˜์‹ ํ•จ(inbox)์€ ์•กํ„ฐ๊ฐ€ ๋‹ค๋ฅธ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๋Š” ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์•กํ„ฐ๋Š” ์ž์‹ ์˜ ์ˆ˜์‹ ํ•จ์„ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋Š” HTTP POST ์š”์ฒญ์„ ํ†ตํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” URL์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฑฐ๋‚˜, ๊ธ€์„ ์“ฐ๊ฑฐ๋‚˜, ๋Œ“๊ธ€์„ ๋‹ค๋Š” ๋“ฑ์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ํ•  ๋•Œ ํ•ด๋‹น ์•กํ‹ฐ๋น„ํ‹ฐ๋Š” ์ˆ˜์‹ ์ž์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋Š” ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์˜จ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  ์ ์ ˆํžˆ ์‘๋‹ตํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ ์•กํ„ฐ๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ  ์—ฐํ•ฉ ๋„คํŠธ์›Œํฌ์˜ ์ผ๋ถ€๋กœ ๊ธฐ๋Šฅํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์€ ์—ฌ๋Ÿฌ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์ง€๊ธˆ์€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๊ตฌํ˜„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

์ž์‹ ์„ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์›Œ)๊ณผ ์ž์‹ ์ด ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ๋“ค(ํŒ”๋กœ์ž‰)์„ ๋‹ด๊ธฐ ์œ„ํ•ด src/schema.sql ํŒŒ์ผ์— follows ํ…Œ์ด๋ธ”์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS follows (
  following_id INTEGER          REFERENCES actors (id),
  follower_id  INTEGER          REFERENCES actors (id),
  created      TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP)
                                CHECK (created <> ''),
  PRIMARY KEY (following_id, follower_id)
);

์ด๋ฒˆ์—๋„ src/schema.sql์„ ์‹คํ–‰ํ•˜์—ฌ follows ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

src/schema.ts ํŒŒ์ผ์„ ์—ด๊ณ  follows ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript์—์„œ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…๋„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Follow {
  following_id: number;
  follower_id: number;
  created: string;
}

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

์ด์ œ ์ˆ˜์‹ ํ•จ์„ ๊ตฌํ˜„ํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ์‹ค์€ ์•ž์„œ ์ด๋ฏธ src/federation.ts ํŒŒ์ผ์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ๋ฐ” ์žˆ์Šต๋‹ˆ๋‹ค:

federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");

์œ„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๊ธฐ์— ์•ž์„œ, Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Accept ๋ฐ Follow ํด๋ž˜์Šค์™€ getActorHandle() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  setInboxListeners() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    if (follow.objectId == null) {
      logger.debug("The Follow object does not have an object: {follow}", {
        follow,
      });
      return;
    }
    const object = ctx.parseUri(follow.objectId);
    if (object == null || object.type !== "actor") {
      logger.debug("The Follow object's object is not an actor: {follow}", {
        follow,
      });
      return;
    }
    const follower = await follow.getActor();
    if (follower?.id == null || follower.inboxId == null) {
      logger.debug("The Follow object does not have an actor: {follow}", {
        follow,
      });
      return;
    }
    const followingId = db
      .prepare<unknown[], Actor>(
        `
        SELECT * FROM actors
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(object.identifier)?.id;
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
      return;
    }
    const followerId = db
      .prepare<unknown[], Actor>(
        `
        -- ํŒ”๋กœ์›Œ ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        follower.id.href,
        await getActorHandle(follower),
        follower.name?.toString(),
        follower.inboxId.href,
        follower.endpoints?.sharedInbox?.href,
        follower.url?.href,
      )?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    const accept = new Accept({
      actor: follow.objectId,
      to: follow.actorId,
      object: follow,
    });
    await ctx.sendActivity(object, follower, accept);
  });

์ž, ์ฝ”๋“œ๋ฅผ ์ฐฌ์ฐฌํžˆ ์‚ดํŽด๋ด…์‹œ๋‹ค. on() ๋ฉ”์„œ๋“œ๋Š” ํŠน์ •ํ•œ ์ข…๋ฅ˜์˜ ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ๋œปํ•˜๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ˆ˜์‹ ๋˜์—ˆ์„ ๋•Œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ํŒ”๋กœ์›Œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ ๋’ค, ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ ์ˆ˜๋ฝ์„ ๋œปํ•˜๋Š” Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋‚ด๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

follow.objectId์—๋Š” ํŒ”๋กœ ๋Œ€์ƒ์ธ ์•กํ„ฐ์˜ URI๊ฐ€ ๋“ค์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด ์•ˆ์— ๋“  URI๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

getActorHandle() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ์•กํ„ฐ ๊ฐ์ฒด๋กœ๋ถ€ํ„ฐ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๊ตฌํ•˜์—ฌ ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ actors ํ…Œ์ด๋ธ”์— ์•„์ง ์—†๋‹ค๋ฉด ๋จผ์ € ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ ๋’ค, follows ํ…Œ์ด๋ธ”์— ํŒ”๋กœ์›Œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋ก์ด ์™„๋ฃŒ๋˜๋ฉด, sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ธ ์•กํ„ฐ์—๊ฒŒ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‹ต์žฅ์œผ๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์ฒซ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐœ์‹ ์ž, ๋‘˜์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ˆ˜์‹ ์ž, ์…‹์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ผ ์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy

์ž, ๊ทธ๋Ÿผ ํŒ”๋กœ ์š”์ฒญ์ด ์ œ๋Œ€๋กœ ์ˆ˜์‹ ๋˜๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

๋ณดํ†ต์˜ Mastodon ์„œ๋ฒ„์—์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๊ดœ์ฐฎ๊ธด ํ•˜์ง€๋งŒ, ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ป๊ฒŒ ์˜ค๊ฐ€๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ActivityPub.Academy ์„œ๋ฒ„๋ฅผ ์ด์šฉํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ActivityPub.Academy๋Š” ๊ต์œก ๋ฐ ๋””๋ฒ„๊น… ์šฉ๋„์˜ ํŠน์ˆ˜ํ•œ Mastodon ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ show source๋ฅผ ๋ˆ„๋ฅธ ํ™”๋ฉด

ํ…Œ์ŠคํŠธ

์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ๊ฑธ ํ™•์ธํ–ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ์ €ํฌ๊ฐ€ ์ง  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. ๋จผ์ € follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€ ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค (๋ฌผ๋ก , ์‹œ๊ฐ์€ ๋‹ค๋ฅด๊ฒ ์ฃ ?):

following_id follower_id created
1 2 2024-09-01 10:19:41

๊ณผ์—ฐ actors ํ…Œ์ด๋ธ”์—๋„ ์ƒˆ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ํ™•์ธํ•ฉ์‹œ๋‹ค:

echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id user_id uri handle name inbox_url shared_inbox_url url created
2 https://activitypub.academy/users/dobussia_dovornath @dobussia_dovornath@activitypub.academy Dobussia Dovornath https://activitypub.academy/users/dobussia_dovornath/inbox https://activitypub.academy/inbox https://activitypub.academy/@dobussia_dovornath 2024-09-01 10:19:41

๋‹ค์‹œ, ActivityPub.Academy์˜ Activity Log๋ฅผ ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์—์„œ ๋ณด๋‚ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์œ„์™€ ๊ฐ™์ด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
  "type": "Undo",
  "actor": "https://activitypub.academy/users/dobussia_dovornath",
  "object": {
    "id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
    "type": "Follow",
    "actor": "https://activitypub.academy/users/dobussia_dovornath",
    "object": "https://temp-address.serveo.net/users/johndoe"
  }
}

์œ„ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ์•„๊นŒ ์ˆ˜์‹ ํ•จ์œผ๋กœ ๋“ค์–ด์™”๋˜ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ˆ˜์‹ ํ•จ์—์„œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ–ˆ์„ ๋•Œ์˜ ๋™์ž‘์„ ์•„๋ฌด ๊ฒƒ๋„ ์ •์˜ํ•˜์ง€ ์•Š์•˜๊ธฐ์— ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํŒ”๋กœ ์ทจ์†Œ๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Undo ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  on(Follow, ...) ๋’ค์— ์—ฐ๋‹ฌ์•„ on(Undo, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต๋จ ...
  })
  .on(Undo, async (ctx, undo) => {
    const object = await undo.getObject();
    if (!(object instanceof Follow)) return;
    if (undo.actorId == null || object.objectId == null) return;
    const parsed = ctx.parseUri(object.objectId);
    if (parsed == null || parsed.type !== "actor") return;
    db.prepare(
      `
      DELETE FROM follows
      WHERE following_id = (
        SELECT actors.id
        FROM actors
        JOIN users ON actors.user_id = users.id
        WHERE users.username = ?
      ) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
      `,
    ).run(parsed.identifier, undo.actorId.href);
  });

์ด๋ฒˆ์—๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋ณด๋‹ค ์ฝ”๋“œ๊ฐ€ ์งง์Šต๋‹ˆ๋‹ค. Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์•ˆ์— ๋“  ๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์ธ์ง€ ํ™•์ธํ•œ ๋’ค, parseUri() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์ทจ์†Œํ•˜๋ ค๋Š” Follow ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ํŒ”๋กœ ๋Œ€์ƒ์ด ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•˜๊ณ , follows ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์•„๊นŒ ActivityPub.Academy์—์„œ ์ด๋ฏธ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ๋ฒ„๋ ค์„œ ํ•œ ๋ฒˆ ๋” ์–ธํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์–ด์ฉ” ์ˆ˜ ์—†์ด ๋‹ค์‹œ ํŒ”๋กœํ•œ ๋’ค, ์–ธํŒ”๋กœํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทธ์— ์•ž์„œ, follows ํ…Œ์ด๋ธ”์„ ๋น„์›Œ ์ค„ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ํŒ”๋กœ ์š”์ฒญ์ด ์™”์„ ๋•Œ ์ด๋ฏธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•˜๋ฏ€๋กœ ์˜ค๋ฅ˜๊ฐ€ ๋‚  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

sqlite3 ๋ช…๋ น์–ด๋ฅผ ์ด์šฉํ•ด follows ํ…Œ์ด๋ธ”์„ ๋น„์›์‹œ๋‹ค:

echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

following_id follower_id created
1 2 2024-09-02 01:05:17

๊ทธ๋ฆฌ๊ณ  ๋‹ค์‹œ ์–ธํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ๋’ค, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ•œ ๋ฒˆ ๋” ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ sqlite3 ๋ช…๋ น์œผ๋กœ ๋ณด๋Š” ๊ฑด ์„ฑ๊ฐ€์‹œ๋‹ˆ, ์›น์œผ๋กœ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ์‹œ๋‹ค.

์šฐ์„  src/views.tsx ํŒŒ์ผ์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. Actor ํƒ€์ž…์„ importํ•ด์ฃผ์„ธ์š”:

import type { Actor } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <FollowerList> ์ปดํฌ๋„ŒํŠธ์™€ <ActorLink> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowerListProps {
  followers: Actor[];
}

export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
  <>
    <h2>Followers</h2>
    <ul>
      {followers.map((follower) => (
        <li key={follower.id}>
          <ActorLink actor={follower} />
        </li>
      ))}
    </ul>
  </>
);

export interface ActorLinkProps {
  actor: Actor;
}

export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
  const href = actor.url ?? actor.uri;
  return actor.name == null ? (
    <a href={href} class="secondary">
      {actor.handle}
    </a>
  ) : (
    <>
      <a href={href}>{actor.name}</a>{" "}
      <small>
        (
        <a href={href} class="secondary">
          {actor.handle}
        </a>
        )
      </small>
    </>
  );
};

<ActorLink> ์ปดํฌ๋„ŒํŠธ๋Š” ํ•˜๋‚˜์˜ ์•กํ„ฐ๋ฅผ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋„ŒํŠธ๋Š” <ActorList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ด์šฉํ•˜์—ฌ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ์ž…๋‹ˆ๋‹ค. ๋ณด๋‹ค์‹œํ”ผ JSX์—๋Š” ์กฐ๊ฑด๋ฌธ์ด๋‚˜ ๋ฐ˜๋ณต๋ฌธ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์— ์‚ผํ•ญ ์—ฐ์‚ฐ์ž์™€ Array.map() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ญ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowerList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/followers์— ๋Œ€ํ•œ ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/followers", async (c) => {
  const followers = db
    .prepare<unknown[], Actor>(
      `
      SELECT followers.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = following.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowerList followers={followers} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ, ์ž˜ ๋ณด์ด๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ํŒ”๋กœ์›Œ๊ฐ€ ์žˆ์–ด์•ผ ํ• ํ…Œ๋‹ˆ, fedify tunnel์„ ์ผ  ์ฑ„๋กœ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„๋‚˜ ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ•ฉ์‹œ๋‹ค. ํŒ”๋กœ ์š”์ฒญ์ด ์ˆ˜๋ฝ๋œ ๋’ค ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/followers ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

ํŒ”๋กœ์›Œ ๋ชฉ๋ก ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ํŒ”๋กœ์›Œ ์ˆ˜๋„ ํ‘œ์‹œํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ๋‹ค์‹œ ์—ด๊ณ  <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

ProfileProps์—๋Š” ๋‘ ๊ฐœ์˜ ํ”„๋กญ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. followers๋Š” ๋ง ๊ทธ๋Œ€๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋‹ด๋Š” ํ”„๋กญ์ž…๋‹ˆ๋‹ค. username์€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์œผ๋กœ ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์œ„ํ•ด URL์— ๋“ค์–ด๊ฐˆ ์•„์ด๋””๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ src/app.tsx ํŒŒ์ผ๋กœ ๋Œ์•„๊ฐ€, GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      JOIN actors ON follows.following_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        followers={followers}
      />
    </Layout>,
  );
});

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ˆ์˜ follows ํ…Œ์ด๋ธ”์˜ ๋ ˆ์ฝ”๋“œ ์ˆ˜๋ฅผ ์„ธ๋Š” SQL์ด ์ถ”๊ฐ€๋˜์—ˆ๊ตฐ์š”. ์ž, ๊ทธ๋Ÿผ ์ด์ œ ๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์ผ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

๋ฐ”๋€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜

๊ทธ๋Ÿฐ๋ฐ ํ•œ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ActivityPub.Academy๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด๋ด…์‹œ๋‹ค. (์กฐํšŒํ•˜๋Š” ๋ฒ•์€ ์ด์ œ ๋‹ค ์•„์‹œ์ฃ ? ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ๋œ ์ƒํƒœ์—์„œ, ์•กํ„ฐ ํ•ธ๋“ค์„ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ์น˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.) Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Mastodon์—์„œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

๋ฐ”๋กœ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ 0์œผ๋กœ ๋‚˜์˜จ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ActivityPub์„ ํ†ตํ•ด ๋…ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ActivityPub์—์„œ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ ๋…ธ์ถœํ•˜๋ ค๋ฉด ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Recipient ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์•„๋ž˜์ชฝ์— ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setFollowersDispatcher(
    "/users/{identifier}/followers",
    (ctx, identifier, cursor) => {
      const followers = db
        .prepare<unknown[], Actor>(
          `
          SELECT followers.*
          FROM follows
          JOIN actors AS followers ON follows.follower_id = followers.id
          JOIN actors AS following ON follows.following_id = following.id
          JOIN users ON users.id = following.user_id
          WHERE users.username = ?
          ORDER BY follows.created DESC
          `,
        )
        .all(identifier);
      const items: Recipient[] = followers.map((f) => ({
        id: new URL(f.uri),
        inboxId: new URL(f.inbox_url),
        endpoints:
          f.shared_inbox_url == null
            ? null
            : { sharedInbox: new URL(f.shared_inbox_url) },
      }));
      return { items };
    },
  )
  .setCounter((ctx, identifier) => {
    const result = db
      .prepare<unknown[], { cnt: number }>(
        `
        SELECT count(*) AS cnt
        FROM follows
        JOIN actors ON actors.id = follows.following_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ?
        `,
      )
      .get(identifier);
    return result == null ? 0 : result.cnt;
  });

setFollowersDispatcher() ๋ฉ”์„œ๋“œ์—์„œ๋Š” GET /users/{identifier}/followers ์š”์ฒญ์ด ์™”์„ ๋•Œ ์‘๋‹ตํ•  ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. SQL์ด ์กฐ๊ธˆ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ •๋ฆฌํ•˜์ž๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. items์—๋Š” Recipient ๊ฐ์ฒด๋“ค์„ ๋‹ด๋Š”๋ฐ, Recipient ํƒ€์ž…์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค:

export interface Recipient {
  readonly id: URL | null;
  readonly inboxId: URL | null;
  readonly endpoints?: {
    sharedInbox: URL | null;
  } | null;
}

id ์†์„ฑ์—๋Š” ์•กํ„ฐ์˜ ๊ณ ์œ  IRI๊ฐ€ ๋“ค์–ด๊ฐ€๊ณ , inboxId์—๋Š” ์•กํ„ฐ์˜ ๊ฐœ์ธ ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. endpoints.sharedInbox์—๋Š” ์•กํ„ฐ์˜ ๊ณต์œ  ์ˆ˜์‹ ํ•จ URL์ด ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ์šฐ๋ฆฌ๋Š” actors ํ…Œ์ด๋ธ”์— ๊ทธ ๋ชจ๋“  ์ •๋ณด๋ฅผ ๋‹ค ๋‹ด๊ณ  ์žˆ์œผ๋‹ˆ, ํ•ด๋‹น ์ •๋ณด๋“ค๋กœ items ๋ฐฐ์—ด์„ ์ฑ„์›Œ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

setCounter() ๋ฉ”์„œ๋“œ์—์„œ๋Š” ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์˜ ์ „์ฒด ์ˆ˜๋Ÿ‰์„ ๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋„ SQL์ด ์กฐ๊ธˆ ๋ณต์žกํ•˜๊ธด ํ•˜์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด identifier ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋“ค์–ด์˜จ ์•„์ด๋””๋ฅผ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, fedify lookup ๋ช…๋ น์„ ์‚ฌ์šฉํ•ฉ์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe/followers

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

โœ” Looking up the object...
OrderedCollection {
  totalItems: 1,
  items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}

๊ทธ๋Ÿฐ๋ฐ, ์ด๋ ‡๊ฒŒ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์„ ๋งŒ๋“ค์–ด ๋†“๊ธฐ๋งŒ ํ•ด์„œ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์ด ์–ด๋”” ์žˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•กํ„ฐ ๋””์ŠคํŒจ์ฒ˜์—์„œ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜์— ๋งํฌ๋ฅผ ๊ฑธ์–ด ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ... ์ƒ๋žต ...
    return new Person({
      // ... ์ƒ๋žต ...
      followers: ctx.getFollowersUri(identifier), 
    });
  })

์•กํ„ฐ๋„ fedify lookup์œผ๋กœ ์กฐํšŒํ•˜์—ฌ ๋ด…์‹œ๋‹ค:

fedify lookup http://localhost:8000/users/johndoe

์•„๋ž˜์™€ ๊ฐ™์ด ๊ฒฐ๊ณผ์— "followers" ์†์„ฑ์ด ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค:

โœ” Looking up the object...
Person {
  ... ์ƒ๋žต ...
  inbox: URL "http://localhost:8000/users/johndoe/inbox",
  followers: URL "http://localhost:8000/users/johndoe/followers",
  endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}

๊ทธ๋Ÿผ ์ด์ œ ๋‹ค์‹œ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Mastodon์—์„œ ๋‹ค์‹œ ์กฐํšŒํ•œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„

ํŒ”๋กœ์›Œ ์ˆ˜๋Š” ์—ฌ์ „ํžˆ 0์œผ๋กœ ๋‚˜์˜ค๊ธฐ ๋•Œ๋ฌธ์ด์ฃ . ์ด๋Š” Mastodon์ด ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ์บ์‹œ(cache)ํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๊ธด ํ•˜์ง€๋งŒ F5 ํ‚ค๋ฅผ ๋ˆ„๋ฅด๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์‰ฝ์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค:

  • ํ•œ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์€ ์ผ์ฃผ์ผ์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. Mastodon์€ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์บ์‹œ๋ฅผ ๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์ดํ›„ 7์ผ์ด ์ง€๋‚  ๋•Œ ๋‚ ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • ๋˜ ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์€ Update ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋‚ ๋ฆฌ๋Š” ๊ฒƒ์ธ๋ฐ, ๊ท€์ฐฎ์€ ์ฝ”๋”ฉ์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์•„๋‹ˆ๋ฉด ์•„์ง ์บ์‹œ๊ฐ€ ๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•ด ๋ณด๋Š” ๊ฒƒ๋„ ํ•œ ๋ฐฉ๋ฒ•์ด๊ฒ ์ฃ .
  • ๋งˆ์ง€๋ง‰ ๋ฐฉ๋ฒ•์€ fedify tunnel์„ ๊ป๋‹ค ์ผœ์„œ ์ƒˆ๋กœ์šด ์ž„์‹œ ๋„๋ฉ”์ธ์„ ํ• ๋‹น ๋ฐ›๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ •ํ™•ํ•œ ํŒ”๋กœ์›Œ ์ˆ˜๊ฐ€ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์ง์ ‘ ํ™•์ธํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์ œ๊ฐ€ ๋‚˜์—ดํ•œ ๋ฐฉ๋ฒ•๋“ค ์ค‘ ํ•˜๋‚˜๋ฅผ ์‹œ๋„ํ•ด ๋ณด์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ

์ž, ์ด์ œ ๋“œ๋””์–ด ๊ฒŒ์‹œ๋ฌผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์™”์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ๋ธ”๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ž‘์„ฑ๋œ ๊ฒŒ์‹œ๋ฌผ๋„ ์ €์žฅํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ์—ผ๋‘์— ๋‘๊ณ  ์„ค๊ณ„ํ•ด ๋ด…์‹œ๋‹ค.

ํ…Œ์ด๋ธ” ์ƒ์„ฑ

๋ฐ”๋กœ posts ํ…Œ์ด๋ธ”๋ถ€ํ„ฐ ๋งŒ๋“ญ์‹œ๋‹ค. src/schema.sql ํŒŒ์ผ์„ ์—ด์–ด ์•„๋ž˜ SQL์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

CREATE TABLE IF NOT EXISTS posts (
  id       INTEGER NOT NULL PRIMARY KEY,
  uri      TEXT    NOT NULL UNIQUE CHECK (uri <> ''),
  actor_id INTEGER NOT NULL REFERENCES actors (id),
  content  TEXT    NOT NULL,
  url      TEXT             CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
  created  TEXT    NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
  • id ์นผ๋Ÿผ์€ ํ…Œ์ด๋ธ”์˜ ๊ธฐ๋ณธ ํ‚ค์ž…๋‹ˆ๋‹ค.
  • uri ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์˜ ๊ณ ์œ  URI๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. ์•ž์„œ ๋งํ–ˆ๋‹ค์‹œํ”ผ ActivityPub ๊ฐ์ฒด๋Š” ๋ชจ๋‘ ๊ณ ์œ ํ•œ URI๋ฅผ ๊ฐ€์ ธ์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • actor_id ์นผ๋Ÿผ์€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์•กํ„ฐ๋ฅผ ๊ฐ€๋ฆฌํ‚ต๋‹ˆ๋‹ค.
  • content ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.
  • url ์นผ๋Ÿผ์—๋Š” ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œ์‹œํ•˜๋Š” URL์„ ๋‹ด์Šต๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ URI์™€ ์›น ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๋Š” ํŽ˜์ด์ง€์˜ URL์ด ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„ ์นผ๋Ÿผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋น„์–ด ์žˆ์„ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • created ์นผ๋Ÿผ์—๋Š” ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์‹œ๊ฐ์„ ๋‹ด์Šต๋‹ˆ๋‹ค.

SQL์„ ์‹คํ–‰ํ•˜์—ฌ posts ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•ฉ์‹œ๋‹ค:

sqlite3 microblog.sqlite3 < src/schema.sql

posts ํ…Œ์ด๋ธ”์— ์ €์žฅ๋  ๋ ˆ์ฝ”๋“œ๋ฅผ JavaScript๋กœ ํ‘œํ˜„ํ•˜๋Š” Post ํƒ€์ž…๋„ src/schema.ts ํŒŒ์ผ์— ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface Post {
  id: number;
  uri: string;
  actor_id: number;
  content: string;
  url: string | null;
  created: string;
}

์ฒซ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์–‘์‹์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ๊ฒ ์ฃ ? ๊ทธ๋Ÿฌ๊ณ  ๋ณด๋‹ˆ, ์•„์ง๊นŒ์ง€ ์ฒซ ํŽ˜์ด์ง€๋„ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ฒซ ํŽ˜์ด์ง€์— ๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ์ถ”๊ฐ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด User ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      <h1>{user.name}'s microblog</h1>
      <p>
        <a href={`/users/${user.username}`}>{user.name}'s profile</a>
      </p>
    </hgroup>
    <form method="post" action={`/users/${user.username}/posts`}>
      <fieldset>
        <label>
          <textarea name="content" required={true} placeholder="What's up?" />
        </label>
      </fieldset>
      <input type="submit" value="Post" />
    </form>
  </>
);

๊ทธ ๋‹ค์Œ์—๋Š” src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  ์ด๋ฏธ ์žˆ๋Š” GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ:

app.get("/", (c) => c.text("Hello, Fedify!"));

์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์ณ์ค๋‹ˆ๋‹ค:

app.get("/", (c) => {
  const user = db
    .prepare<unknown[], User & Actor>(
      `
      SELECT users.*, actors.*
      FROM users
      JOIN actors ON users.id = actors.user_id
      LIMIT 1
      `,
    )
    .get();
  if (user == null) return c.redirect("/setup");

  return c.html(
    <Layout>
      <Home user={user} />
    </Layout>,
  );
});

์—ฌ๊ธฐ๊นŒ์ง€ ํ–ˆ๋‹ค๋ฉด, ํ•œ ๋ฒˆ ์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ๋‚˜์˜ค๋‚˜ ํ™•์ธํ•ฉ์‹œ๋‹ค. ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ๋ณด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค:

์ฒซ ํŽ˜์ด์ง€

๋ ˆ์ฝ”๋“œ ์‚ฝ์ž…

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ์–‘์‹์„ ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์‹ค์ œ๋กœ ๊ฒŒ์‹œ๋ฌผ ๋‚ด์šฉ์„ posts ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๋จผ์ € src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";

์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์œ„ ์ฝ”๋“œ๋Š” ์•„์ง ๋ณ„ ์—ญํ• ์„ ํ•˜์ง„ ์•Š์ง€๋งŒ, ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ ํ˜•์‹์„ ์ •ํ•˜๋Š” ๋ฐ์— ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ๊ตฌํ˜„์€ ๋‚˜์ค‘์— ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

ActivityPub์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์˜ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ์ฃผ๊ณ ๋ฐ›์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ‰๋ฌธ ํ˜•์‹์œผ๋กœ ์ž…๋ ฅ ๋ฐ›์€ ๋‚ด์šฉ์„ HTML ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, <, >์™€ ๊ฐ™์€ ๋ฌธ์ž๋“ค์„ HTML์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ HTML ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜ํ•ด์ฃผ๋Š” stringify-entities ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add stringify-entities

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค.

import { stringifyEntities } from "stringify-entities";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Note ํด๋ž˜์Šค๋„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  const username = c.req.param("username");
  const actor = db
    .prepare<unknown[], Actor>(
      `
      SELECT actors.*
      FROM actors
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ?
      `,
    )
    .get(username);
  if (actor == null) return c.redirect("/setup");
  const form = await c.req.formData();
  const content = form.get("content")?.toString();
  if (content == null || content.trim() === "") {
    return c.text("Content is required", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const url: string | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return url;
  })();
  if (url == null) return c.text("Failed to create post", 500);
  return c.redirect(url);
});

ํ‰๋ฒ”ํ•˜๊ฒŒ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์ฝ”๋“œ์ด๊ธด ํ•˜์ง€๋งŒ ํ•œ ๊ฐ€์ง€ ํŠน์ดํ•œ ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ํ‘œํ˜„ํ•˜๋Š” ActivityPub ๊ฐ์ฒด์˜ URI๋ฅผ ๊ตฌํ•˜๋ ค๋ฉด posts.id๊ฐ€ ๋จผ์ € ๊ฒฐ์ •๋˜์–ด ์žˆ์–ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, posts.uri ์นผ๋Ÿผ์— https://localhost/๋ผ๋Š” ์ž„์‹œ URI๋ฅผ ๋จผ์ € ์ง‘์–ด ๋„ฃ์–ด ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ ๋’ค, ๊ฒฐ์ •๋œ posts.id๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ getObjectUri() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ URI๋ฅผ ๊ตฌํ•ด์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ฐ ๋’ค, ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ์ค‘

Post ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•˜๋ฉด, ์•ˆํƒ€๊น๊ฒŒ๋„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

404 Not Found

์™œ๋ƒํ•˜๋ฉด ๊ฒŒ์‹œ๋ฌผ ํผ๋จธ๋งํฌ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์•„์ง ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ posts ํ…Œ์ด๋ธ”์—๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋งŒ๋“ค์–ด์กŒ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

๊ทธ๋Ÿผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๋‚˜ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

id uri actor_id content url created
1 http://localhost:8000/users/johndoe/posts/1 1 It's my first post! http://localhost:8000/users/johndoe/posts/1 2024-09-02 08:10:55

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

๊ฒŒ์‹œ๋ฌผ ์ž‘์„ฑ ํ›„ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚˜์ง€ ์•Š๋„๋ก, ๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ฉ์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด Post ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import type { Actor, Post, User } from "./schema.ts";

๊ทธ๋ฆฌ๊ณ  <PostPage> ์ปดํฌ๋„ŒํŠธ ๋ฐ <PostView> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

export interface PostViewProps {
  post: Post & Actor;
}

export const PostView: FC<PostViewProps> = ({ post }) => (
  <article>
    <header>
      <ActorLink actor={post} />
    </header>
    {/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
    <footer>
      <a href={post.url ?? post.uri}>
        <time datetime={new Date(post.created).toISOString()}>
          {post.created}
        </time>
      </a>
    </footer>
  </article>
);

์ด์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฒŒ์‹œ๋ฌผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์™€ <PostPage> ์ปดํฌ๋„ŒํŠธ๋กœ ๋ Œ๋”๋งํ•ฉ์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  ์•ž์„œ ์ •์˜ํ•œ <PostPage> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  const post = db
    .prepare<unknown[], Post & Actor & User>(
      `
      SELECT users.*, actors.*, posts.*
      FROM posts
      JOIN actors ON actors.id = posts.actor_id
      JOIN users ON users.id = actors.user_id
      WHERE users.username = ? AND posts.id = ?
      `,
    )
    .get(c.req.param("username"), c.req.param("id"));
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { followers } = db
    .prepare<unknown[], { followers: number }>(
      `
      SELECT count(*) AS followers
      FROM follows
      WHERE follows.following_id = ?
      `,
    )
    .get(post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์•„๊นŒ 404 Not Found ์˜ค๋ฅ˜๊ฐ€ ๋‚ฌ๋˜ http://localhost:8000/users/johndoe/posts/1 ํŽ˜์ด์ง€๋ฅผ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ๋ด…์‹œ๋‹ค:

๊ฒŒ์‹œ๋ฌผ ํŽ˜์ด์ง€

Note ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜

๊ทธ๋Ÿผ ์ด์ œ ๊ฒŒ์‹œ๋ฌผ์„ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”? ๋จผ์ € fedify tunnel์„ ์ด์šฉํ•˜์—ฌ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

๊ทธ ์ƒํƒœ์—์„œ, Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ธ€์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

๋นˆ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์•ˆํƒ€๊น๊ฒŒ๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด ํ˜•์‹์œผ๋กœ ๋…ธ์ถœํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ๊ฒŒ์‹œ๋ฌผ์„ ActivityPub ๊ฐ์ฒด๋กœ ๋…ธ์ถœํ•ด ๋ด…์‹œ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. Fedify์—์„œ ์‹œ๊ฐ์„ ํ‘œํ˜„ํ•˜๋Š” ๋ฐ์— ์“ฐ๋Š” Temporal API๊ฐ€ ์•„์ง Node.js์— ๋‚ด์žฅ๋˜์–ด ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฅผ ํด๋ฆฌํ•„(polyfill)ํ•ด์ฃผ๋Š” @js-temporal/polyfill ํŒจํ‚ค์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

npm add @js-temporal/polyfill

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด ์„ค์น˜ํ•œ ํŒจํ‚ค์ง€๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Temporal } from "@js-temporal/polyfill";

Post ํƒ€์ž…๊ณผ Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” PUBLIC_COLLECTION ์ƒ์ˆ˜๋„ importํ•ฉ๋‹ˆ๋‹ค.

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  type Recipient,
} from "@fedify/fedify";
import type {
  Actor,
  Key,
  Post,
  User,
} from "./schema.ts";

๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ์˜ ๊ฒŒ์‹œ๋ฌผ์ฒ˜๋Ÿผ ์งง์€ ๊ธ€์€ ActivityPub์—์„œ ๋ณดํ†ต Note๋กœ ํ‘œํ˜„๋ฉ๋‹ˆ๋‹ค. Note ํด๋ž˜์Šค์— ๋Œ€ํ•œ ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๋Š” ์ด๋ฏธ ๋นˆ ๊ตฌํ˜„์ด๋‚˜๋งˆ ๋งŒ๋“ค์–ด ๋‘์—ˆ์—ˆ์ฃ :

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    return null;
  },
);

์ด๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ๊ณ ์นฉ๋‹ˆ๋‹ค:

federation.setObjectDispatcher(
  Note,
  "/users/{identifier}/posts/{id}",
  (ctx, values) => {
    const post = db
      .prepare<unknown[], Post>(
        `
        SELECT posts.*
        FROM posts
        JOIN actors ON actors.id = posts.actor_id
        JOIN users ON users.id = actors.user_id
        WHERE users.username = ? AND posts.id = ?
        `,
      )
      .get(values.identifier, values.id);
    if (post == null) return null;
    return new Note({
      id: ctx.getObjectUri(Note, values),
      attribution: ctx.getActorUri(values.identifier),
      to: PUBLIC_COLLECTION,
      cc: ctx.getFollowersUri(values.identifier),
      content: post.content,
      mediaType: "text/html",
      published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
      url: ctx.getObjectUri(Note, values),
    });
  },
);

Note ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ์ฑ„์›Œ์ง€๋Š” ์†์„ฑ ๊ฐ’๋“ค์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค:

  • attribution ์†์„ฑ์— ctx.getActorUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์˜ ์ž‘์„ฑ์ž๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • to ์†์„ฑ์— PUBLIC_COLLECTION์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ์ „์ฒด ๊ณต๊ฐœ ๊ฒŒ์‹œ๋ฌผ์ด๋ผ๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.
  • cc ์†์„ฑ์— ctx.getFollowersUri(values.identifier)์„ ๋„ฃ๋Š” ๊ฒƒ์€ ์ด ๊ฒŒ์‹œ๋ฌผ์ด ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์ „๋‹ฌ๋œ๋‹ค๋Š” ๊ฒƒ์„ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ, ์ด ์ž์ฒด๋กœ๋Š” ํฐ ์˜๋ฏธ๋Š” ์—†์Šต๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ๋‹ค์‹œ ํ•œ ๋ฒˆ Mastodon ๊ฒ€์ƒ‰์ฐฝ์— ๊ฒŒ์‹œ๋ฌผ์˜ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ๋ฐœ์‹ 

ํ•˜์ง€๋งŒ Mastodon์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœ ํ•ด๋„, ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด Mastodon ํƒ€์ž„๋ผ์ธ์— ์˜ฌ๋ผ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด Mastodon์ด ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์•Œ์•„์„œ ๋ฐ›์•„๊ฐ€๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•œ ์ชฝ์—์„œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜์—ฌ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฒŒ์‹œ๋ฌผ ์ƒ์„ฑ์‹œ์— Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ๊ณ ์ณ๋ด…์‹œ๋‹ค. src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import { Create, Note } from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/posts ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/posts", async (c) => {
  // ... ์ƒ๋žต ...
  const ctx = fedi.createContext(c.req.raw, undefined);
  const post: Post | null = db.transaction(() => {
    const post = db
      .prepare<unknown[], Post>(
        `
        INSERT INTO posts (uri, actor_id, content)
        VALUES ('https://localhost/', ?, ?)
        RETURNING *
        `,
      )
      .get(actor.id, stringifyEntities(content, { escapeOnly: true }));
    if (post == null) return null;
    const url = ctx.getObjectUri(Note, {
      identifier: username,
      id: post.id.toString(),
    }).href;
    db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
      url,
      url,
      post.id,
    );
    return post;
  })();
  if (post == null) return c.text("Failed to create post", 500);
  const noteArgs = { identifier: username, id: post.id.toString() };
  const note = await ctx.getObject(Note, noteArgs);
  await ctx.sendActivity(
    { identifier: username },
    "followers",
    new Create({
      id: new URL("#activity", note?.id ?? undefined),
      object: note,
      actors: note?.attributionIds,
      tos: note?.toIds,
      ccs: note?.ccIds,
    }),
  );
  return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});

getObject() ๋ฉ”์„œ๋“œ๋Š” ๊ฐ์ฒด ๋””์ŠคํŒจ์ฒ˜๊ฐ€ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” Note ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ๋•Œ object ์†์„ฑ์— ๋„ฃ์Šต๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ์ˆ˜์‹ ์ž๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” tos (to์˜ ๋ณต์ˆ˜ํ˜•) ๋ฐ ccs (cc์˜ ๋ณต์ˆ˜ํ˜•) ์†์„ฑ์€ Note ๊ฐ์ฒด์™€ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์•กํ‹ฐ๋น„ํ‹ฐ์˜ id๋Š” ์ž„์˜์˜ ๊ณ ์œ ํ•œ URI๋ฅผ ์ง€์–ด๋‚ด์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

ํŒ

์•กํ‹ฐ๋น„ํ‹ฐ ๊ฐ์ฒด์˜ id ์†์„ฑ์—๋Š” ๋ฐ˜๋“œ์‹œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ URI๊ฐ€ ๋“ค์–ด๊ฐˆ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ์ € ๊ณ ์œ ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

sendActivity() ๋ฉ”์„œ๋“œ์˜ ๋‘ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ์—๋Š” ์ˆ˜์‹ ์ž๊ฐ€ ๋“ค์–ด๊ฐ€๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ๋Š” "followers"๋ผ๋Š” ํŠน์ˆ˜ํ•œ ์˜ต์…˜์„ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด ์˜ต์…˜์„ ์ง€์ •ํ•˜๋ฉด ์•ž์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํŒ”๋กœ์›Œ ์ปฌ๋ ‰์…˜ ๋””์ŠคํŒจ์ฒ˜๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ชจ๋“  ํŒ”๋กœ์›Œ๋“ค์—๊ฒŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ž, ๊ตฌํ˜„์„ ๋๋ƒˆ์œผ๋‹ˆ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœ์‹œํ‚จ ์ฑ„, ActivityPub.Academy๋กœ ๋“ค์–ด๊ฐ€ @johndoe@temp-address.serveo.net(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์—ฌ๋Ÿฌ๋ถ„์—๊ฒŒ ํ• ๋‹น๋œ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)๋ฅผ ํŒ”๋กœํ•ฉ๋‹ˆ๋‹ค. ํŒ”๋กœ์›Œ ๋ชฉ๋ก์—์„œ ํŒ”๋กœ ์š”์ฒญ์ด ํ™•์‹คํžˆ ์ˆ˜๋ฝ๋œ ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

๊ฒฝ๊ณ 

์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก์„ ํ…Œ์ŠคํŠธํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ localhost๊ฐ€ ์•„๋‹Œ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์ ‘์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ActivityPub ๊ฐ์ฒด์˜ ID๋ฅผ ๊ฒฐ์ •ํ•  ๋•Œ ์š”์ฒญ์ด ๋“ค์–ด์˜จ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ๊ธฐ์ค€์œผ๋กœ URI๋ฅผ ๊ตฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๊ฐ”๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์˜ Activity Log๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

ActivityPub.Academy์˜ ํƒ€์ž„๋ผ์ธ์—์„œ ์ƒ์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ž˜ ๋ณด์ธ๋‹ค.

ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ๋‚ด ๊ฒŒ์‹œ๋ฌผ ๋ชฉ๋ก

ํ˜„์žฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—๋Š” ์ด๋ฆ„๊ณผ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค, ํŒ”๋กœ์›Œ ์ˆ˜๋งŒ ๋‚˜์˜ฌ ๋ฟ ์ •์ž‘ ๊ฒŒ์‹œ๋ฌผ์€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„ ํŽ˜์ด์ง€์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์„ ๋ณด์—ฌ์ค์‹œ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ  <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface PostListProps {
  posts: (Post & Actor)[];
}

export const PostList: FC<PostListProps> = ({ posts }) => (
  <>
    {posts.map((post) => (
      <div key={post.id}>
        <PostView post={post} />
      </div>
    ))}
  </>
);

๊ทธ๋ฆฌ๊ณ  src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ๋ฐฉ๊ธˆ ์ •์˜ํ•œ <PostList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

์ด๋ฏธ ์žˆ๋Š” GET /users/{username} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE actors.user_id = ?
      ORDER BY posts.created DESC
      `,
    )
    .all(user.user_id);
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      // ... ์ƒ๋žต ...
      <PostList posts={posts} />
    </Layout>,
  );
});

๊ทธ๋Ÿผ ์ด์ œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

๋ณ€๊ฒฝ๋œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€

์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋“ค์ด ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒ”๋กœ

ํ˜„์žฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋Š” ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์„ ์ˆ˜๋Š” ์žˆ์ง€๋งŒ, ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์—๊ฒŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ˆ˜๋Š” ์—†์Šต๋‹ˆ๋‹ค. ํŒ”๋กœ๋ฅผ ํ•  ์ˆ˜ ์—†์œผ๋‹ˆ ๋‹ค๋ฅธ ์•กํ„ฐ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๋„ ๋ณผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž, ๊ทธ๋Ÿผ ๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ์‹œ๋‹ค.

UI ๋จผ์ € ๋งŒ๋“ญ์‹œ๋‹ค. src/views.tsx ํŒŒ์ผ์„ ์—ด๊ณ , ์ด๋ฏธ ์žˆ๋Š” <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export const Home: FC<HomeProps> = ({ user }) => (
  <>
    <hgroup>
      {/* ... ์ƒ๋žต ... */}
    </hgroup>
    <form method="post" action={`/users/${user.username}/following`}>
      {/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ€ role=group์„ ์š”๊ตฌํ•จ */}
      <fieldset role="group">
        <input
          type="text"
          name="actor"
          required={true}
          placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
        />
        <input type="submit" value="Follow" />
      </fieldset>
    </form>
    <form method="post" action={`/users/${user.username}/posts`}>
      {/* ... ์ƒ๋žต ... */}
    </form>
  </>
);

์ฒซ ํŽ˜์ด์ง€๊ฐ€ ์ž˜ ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

Follow ์•กํ‹ฐ๋น„ํ‹ฐ ์ „์†ก

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ฒผ์œผ๋‹ˆ ์‹ค์ œ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งค ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด๊ณ  Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Follow ํด๋ž˜์Šค์™€ isActor() ํ•จ์ˆ˜๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Create,
  Follow,
  isActor,
  Note,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  POST /users/{username}/following ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.post("/users/:username/following", async (c) => {
  const username = c.req.param("username");
  const form = await c.req.formData();
  const handle = form.get("actor");
  if (typeof handle !== "string") {
    return c.text("Invalid actor handle or URL", 400);
  }
  const ctx = fedi.createContext(c.req.raw, undefined);
  const actor = await lookupObject(handle.trim());
  if (!isActor(actor)) {
    return c.text("Invalid actor handle or URL", 400);
  }
  await ctx.sendActivity(
    { identifier: username },
    actor,
    new Follow({
      actor: ctx.getActorUri(username),
      object: actor.id,
      to: actor.id,
    }),
  );
  return c.text("Successfully sent a follow request");
});

lookupObject() ํ•จ์ˆ˜๋Š” ์•กํ„ฐ๋ฅผ ๋น„๋กฏํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ž…๋ ฅ์œผ๋กœ ActivityPub ๊ฐ์ฒด์˜ ๊ณ ์œ  URI๋‚˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ๋ฐ›๊ณ , ์กฐํšŒํ•œ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

isActor() ํ•จ์ˆ˜๋Š” ์ฃผ์–ด์ง„ ActivityPub ๊ฐ์ฒด๊ฐ€ ์•กํ„ฐ์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์ด ์ฝ”๋“œ์—์„œ๋Š” sendActivity() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด ์กฐํšŒํ•œ ์•กํ„ฐ์—๊ฒŒ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์•„์ง follows ํ…Œ์ด๋ธ”์— ์•„๋ฌด๋Ÿฐ ๋ ˆ์ฝ”๋“œ๋„ ์ถ”๊ฐ€ํ•˜์ง„ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์ƒ๋Œ€๋กœ๋ถ€ํ„ฐ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›๊ณ  ๋‚˜์„œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

๊ตฌํ˜„ํ•œ ํŒ”๋กœ ์š”์ฒญ ๊ธฐ๋Šฅ์ด ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒˆ์—๋„ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ „์†กํ•ด์•ผ ํ•˜๋ฏ€๋กœ, fedify tunnel ๋ช…๋ น์„ ์ด์šฉํ•ด ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ๋’ค, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€ ์ƒ์— ๋‚˜ํƒ€๋‚œ ๋„์ฐฉํ•œ ํŒ”๋กœ ์š”์ฒญ

Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

ํ•˜์ง€๋งŒ ์•„์ง ์ˆ˜์‹ ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์— ๋Œ€ํ•ด ์•„๋ฌด๋Ÿฐ ํ–‰๋™๋„ ์ทจํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ด ๋ถ€๋ถ„์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify์—์„œ ์ œ๊ณตํ•˜๋Š” isActor() ํ•จ์ˆ˜ ๋ฐ Actor ํƒ€์ž…์„ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

์ด ์†Œ์Šค ํŒŒ์ผ ์•ˆ์—์„œ Actor ํƒ€์ž…์˜ ์ด๋ฆ„์ด ๊ฒน์น˜๋ฏ€๋กœ APActor๋ผ๋Š” ๋ณ„๋ช…์„ ์ง€์–ด์คฌ์Šต๋‹ˆ๋‹ค.

๊ตฌํ˜„์— ์•ž์„œ, ์ฒ˜์Œ ๋งˆ์ฃผํ•œ ์•กํ„ฐ ์ •๋ณด๋ฅผ actors ํ…Œ์ด๋ธ”์— ๋„ฃ๋Š” ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์—ฌ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟ”๋ด…์‹œ๋‹ค. ์•„๋ž˜ ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

async function persistActor(actor: APActor): Promise<Actor | null> {
  if (actor.id == null || actor.inboxId == null) {
    logger.debug("Actor is missing required fields: {actor}", { actor });
    return null;
  }
  return (
    db
      .prepare<unknown[], Actor>(
        `
        -- ์•กํ„ฐ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
        INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT (uri) DO UPDATE SET
          handle = excluded.handle,
          name = excluded.name,
          inbox_url = excluded.inbox_url,
          shared_inbox_url = excluded.shared_inbox_url,
          url = excluded.url
        WHERE
          actors.uri = excluded.uri
        RETURNING *
        `,
      )
      .get(
        actor.id.href,
        await getActorHandle(actor),
        actor.name?.toString(),
        actor.inboxId.href,
        actor.endpoints?.sharedInbox?.href,
        actor.url?.href,
      ) ?? null
  );
}

์ •์˜ํ•œ persistActor() ํ•จ์ˆ˜๋Š” ์ธ์ž๋กœ ๋“ค์–ด์˜จ ์•กํ„ฐ ๊ฐ์ฒด์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๋ฅผ actors ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ํ…Œ์ด๋ธ”์— ํ•ด๋‹นํ•˜๋Š” ๋ ˆ์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

์ˆ˜์‹ ํ•จ์˜ on(Follow, ...) ๋ถ€๋ถ„์—์„œ ๊ฐ™์€ ์—ญํ• ์„ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ persistActor() ํ•จ์ˆ˜๋ฅผ ์“ฐ๊ฒŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค:

federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .on(Follow, async (ctx, follow) => {
    // ... ์ƒ๋žต ...
    if (followingId == null) {
      logger.debug(
        "Failed to find the actor to follow in the database: {object}",
        { object },
      );
    }
    const followerId = (await persistActor(follower))?.id;
    db.prepare(
      "INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
    ).run(followingId, followerId);
    // ... ์ƒ๋žต ...
  })

๋ฆฌํŒฉํ„ฐ๋ง์„ ๋๋ƒˆ์œผ๋‹ˆ ์ˆ˜์‹ ํ•จ์— Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐ›์•˜์„ ๋•Œ ์ทจํ•  ํ–‰๋™์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค:

  .on(Accept, async (ctx, accept) => {
    const follow = await accept.getObject();
    if (!(follow instanceof Follow)) return;
    const following = await accept.getActor();
    if (!isActor(following)) return;
    const follower = follow.actorId;
    if (follower == null) return;
    const parsed = ctx.parseUri(follower);
    if (parsed == null || parsed.type !== "actor") return;
    const followingId = (await persistActor(following))?.id;
    if (followingId == null) return;
    db.prepare(
      `
      INSERT INTO follows (following_id, follower_id)
      VALUES (
        ?,
        (
          SELECT actors.id
          FROM actors
          JOIN users ON actors.user_id = users.id
          WHERE users.username = ?
        )
      )
      `,
    ).run(followingId, parsed.identifier);
  });

์œ ํšจ์„ฑ์„ ๊ฒ€์‚ฌํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๊ธธ์ง€๋งŒ ์š”์•ฝํ•˜๋ฉด Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ์œผ๋กœ๋ถ€ํ„ฐ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ธ ์•กํ„ฐ(follower)์™€ ํŒ”๋กœ ์š”์ฒญ์„ ๋ฐ›์€ ์•กํ„ฐ(following)๋ฅผ ๊ตฌํ•˜๊ณ  follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ

์ด์ œ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•˜๋Š”๋ฐ, ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„๊นŒ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒˆ์„ ๋•Œ ActivityPub.Academy ์ชฝ์—์„œ๋Š” ํŒ”๋กœ ์š”์ฒญ์„ ์ˆ˜๋ฝํ•˜๊ณ  Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ด๋ฏธ ๋ณด๋ƒˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ด ์ƒํƒœ์—์„œ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋„ ๋ฌด์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๋’ค ๋‹ค์‹œ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์„œ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์—์„œ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์„ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, fedify tunnel ๋ช…๋ น์œผ๋กœ ๋กœ์ปฌ ์„œ๋ฒ„๋ฅผ ๊ณต๊ฐœ ์ธํ„ฐ๋„ท์— ๋…ธ์ถœํ•œ ์ƒํƒœ์—์„œ, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ https://temp-address.serveo.net/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ€ ActivityPub.Academy์˜ ์ƒˆ ์ž„์‹œ ๊ณ„์ •์— ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

ํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ „์†ก๋˜์—ˆ๋‹ค๋ฉด, ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ Activity Log์— Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋„์ฐฉํ•œ ํ›„ ๋‹ต์žฅ์œผ๋กœ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, follows ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ๋“ค์–ด๊ฐ”๋‚˜ ์ง์ ‘ ํ™•์ธ์„ ํ•ด ๋ด…์‹œ๋‹ค:

echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

์„ฑ๊ณตํ–ˆ๋‹ค๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค (following_id ์นผ๋Ÿผ์— ๋“  ๊ฐ’์€ ๋‹ค์†Œ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค):

following_id follower_id created
3 1 2024-09-02 14:11:17

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ํŒ”๋กœํ•˜๋Š” ์•กํ„ฐ์˜ ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

export interface FollowingListProps {
  following: Actor[];
}

export const FollowingList: FC<FollowingListProps> = ({ following }) => (
  <>
    <h2>Following</h2>
    <ul>
      {following.map((actor) => (
        <li key={actor.id}>
          <ActorLink actor={actor} />
        </li>
      ))}
    </ul>
  </>
);

๊ทธ ๋‹ค์Œ, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด ์•ž์„œ ์ •์˜ํ•œ <FollowingList> ์ปดํฌ๋„ŒํŠธ๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  FollowerList,
  FollowingList,
  Home,
  Layout,
  PostList,
  PostPage,
  Profile,
  SetupForm,
} from "./views.tsx";

๊ทธ๋ฆฌ๊ณ  GET /users/{username}/following ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/following", async (c) => {
  const following = db
    .prepare<unknown[], Actor>(
      `
      SELECT following.*
      FROM follows
      JOIN actors AS followers ON follows.follower_id = followers.id
      JOIN actors AS following ON follows.following_id = following.id
      JOIN users ON users.id = followers.user_id
      WHERE users.username = ?
      ORDER BY follows.created DESC
      `,
    )
    .all(c.req.param("username"));
  return c.html(
    <Layout>
      <FollowingList following={following} />
    </Layout>,
  );
});

์ œ๋Œ€๋กœ ๊ตฌํ˜„๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe/following ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด๋ด…์‹œ๋‹ค:

ํŒ”๋กœ์ž‰ ๋ชฉ๋ก

ํŒ”๋กœ์ž‰ ์ˆ˜

ํŒ”๋กœ์›Œ ์ˆ˜๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ, ํŒ”๋กœ์ž‰ ์ˆ˜๋„ ํ‘œ์‹œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Profile> ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface ProfileProps {
  name: string;
  username: string;
  handle: string;
  following: number;
  followers: number;
}

export const Profile: FC<ProfileProps> = ({
  name,
  username,
  handle,
  following,
  followers,
}) => (
  <>
    <hgroup>
      <h1>
        <a href={`/users/${username}`}>{name}</a>
      </h1>
      <p>
        <span style="user-select: all;">{handle}</span> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <a href={`/users/${username}/followers`}>
          {followers === 1 ? "1 follower" : `${followers} followers`}
        </a>
      </p>
    </hgroup>
  </>
);

<PostPage> ์ปดํฌ๋„ŒํŠธ๋„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface PostPageProps extends ProfileProps, PostViewProps {}

export const PostPage: FC<PostPageProps> = (props) => (
  <>
    <Profile
      name={props.name}
      username={props.username}
      handle={props.handle}
      following={props.following}
      followers={props.followers}
    />
    <PostView post={props.post} />
  </>
);

๊ทธ๋Ÿผ ์ด์ œ ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์กฐํšŒํ•˜์—ฌ ํŒ”๋กœ์ž‰ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET /users/{username} ์š”์ฒญ์— ๋Œ€ํ•œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username", async (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following } = db
    .prepare<unknown[], { following: number }>(
      `
      SELECT count(*) AS following
      FROM follows
      JOIN actors ON follows.follower_id = actors.id
      WHERE actors.user_id = ?
      `,
    )
    .get(user.id)!;
  // ... ์ƒ๋žต ...
  return c.html(
    <Layout>
      <Profile
        name={user.name ?? user.username}
        username={user.username}
        handle={handle}
        following={following}
        followers={followers}
      />
      <PostList posts={posts} />
    </Layout>,
  );
});

GET /users/{username}/posts/{id} ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/users/:username/posts/:id", (c) => {
  // ... ์ƒ๋žต ...
  if (post == null) return c.notFound();

  // biome-ignore lint/style/noNonNullAssertion: ์–ธ์ œ๋‚˜ ํ•˜๋‚˜์˜ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜
  const { following, followers } = db
    .prepare<unknown[], { following: number; followers: number }>(
      `
      SELECT sum(follows.follower_id = ?) AS following,
             sum(follows.following_id = ?) AS followers
      FROM follows
      `,
    )
    .get(post.actor_id, post.actor_id)!;
  return c.html(
    <Layout>
      <PostPage
        name={post.name ?? post.username}
        username={post.username}
        handle={post.handle}
        following={following}
        followers={followers}
        post={post}
      />
    </Layout>,
  );
});

๋‹ค ์ˆ˜์ •๋˜์—ˆ๋‹ค๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/users/johndoe ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํ”„๋กœํ•„ ํŽ˜์ด์ง€

ํƒ€์ž„๋ผ์ธ

๋งŽ์€ ๊ฒƒ๋“ค์„ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, ์•„์ง ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด์ง€๋Š” ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌํƒœ๊นŒ์ง€์˜ ๊ณผ์ •์—์„œ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค์‹œํ”ผ, ์šฐ๋ฆฌ๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์“ธ ๋•Œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ฐœ์‹ ํ–ˆ๋˜ ๊ฒƒ๊ณผ ๊ฐ™์ด, ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•ด์•ผ ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์“ด ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด ๊ตฌ์ฒด์ ์œผ๋กœ ์–ด๋–ค ์ผ์ด ์ผ์–ด๋‚˜๋Š”์ง€ ๋ณด๊ธฐ ์œ„ํ•ด, ActivityPub.Academy์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์งœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ ์ˆ˜์‹ 

src/federation.ts ํŒŒ์ผ์„ ์—ด์–ด Fedify๊ฐ€ ์ œ๊ณตํ•˜๋Š” Create ํด๋ž˜์Šค๋ฅผ importํ•ฉ๋‹ˆ๋‹ค:

import {
  Accept,
  Create,
  Endpoints,
  Follow,
  Note,
  PUBLIC_COLLECTION,
  Person,
  Undo,
  createFederation,
  exportJwk,
  generateCryptoKeyPair,
  getActorHandle,
  importJwk,
  isActor,
  type Actor as APActor,
  type Recipient,
} from "@fedify/fedify";

๊ทธ๋ฆฌ๊ณ  ์ˆ˜์‹ ํ•จ ์ฝ”๋“œ์— on(Create, ...)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

  .on(Create, async (ctx, create) => {
    const object = await create.getObject();
    if (!(object instanceof Note)) return;
    const actor = create.actorId;
    if (actor == null) return;
    const author = await object.getAttribution();
    if (!isActor(author) || author.id?.href !== actor.href) return;
    const actorId = (await persistActor(author))?.id;
    if (actorId == null) return;
    if (object.id == null) return;
    const content = object.content?.toString();
    db.prepare(
      "INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
    ).run(object.id.href, actorId, content, object.url?.href);
  });

getAttribution() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•˜์—ฌ ๊ธ€์“ด์ด๋ฅผ ๊ตฌํ•œ ๋’ค, persistActor() ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ํ•ด๋‹น ์•กํ„ฐ๊ฐ€ ์•„์ง actors ํ…Œ์ด๋ธ”์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  posts ํ…Œ์ด๋ธ”์— ์ƒˆ ๋ ˆ์ฝ”๋“œ๋ฅผ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์‹œ ํ•œ ๋ฒˆ ActivityPub.Academy์— ๋“ค์–ด๊ฐ€ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. Activity Log๋ฅผ ์—ด์–ด Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋˜์—ˆ๋Š”์ง€ ์ฒดํฌํ•œ ๋’ค, ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ posts ํ…Œ์ด๋ธ”์— ์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3

์ •๋ง ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค:

id uri actor_id content url created
3 https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 3 <p>Would it send a Create(Note) activity?</p> https://activitypub.academy/@algusia_draneoll/113068684551948316 2024-09-02 15:33:32

์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ ํ‘œ์‹œ

์ž, ์ด์ œ ์›๊ฒฉ ๊ฒŒ์‹œ๋ฌผ์„ posts ํ…Œ์ด๋ธ”์— ๋ ˆ์ฝ”๋“œ๋กœ ์ถ”๊ฐ€ํ–ˆ์œผ๋‹ˆ, ์ด์ œ ๊ทธ ๋ ˆ์ฝ”๋“œ๋“ค์„ ์ž˜ ํ‘œ์‹œํ•ด ์ฃผ๋Š” ์ผ๋งŒ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค. ํ”ํžˆ ใ€Œํƒ€์ž„๋ผ์ธใ€์ด๋ผ๊ณ  ๋ถˆ๋ฆฌ๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.

๋จผ์ € src/views.tsx ํŒŒ์ผ์„ ์—ด์–ด <Home> ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

export interface HomeProps extends PostListProps {
  user: User & Actor;
}

export const Home: FC<HomeProps> = ({ user, posts }) => (
  <>
    {/* ... ์ƒ๋žต ... */}
    <PostList posts={posts} />
  </>
);

๊ทธ ๋’ค, src/app.tsx ํŒŒ์ผ์„ ์—ด์–ด GET / ์š”์ฒญ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค:

app.get("/", (c) => {
  // ... ์ƒ๋žต ...
  if (user == null) return c.redirect("/setup");

  const posts = db
    .prepare<unknown[], Post & Actor>(
      `
      SELECT actors.*, posts.*
      FROM posts
      JOIN actors ON posts.actor_id = actors.id
      WHERE posts.actor_id = ? OR posts.actor_id IN (
        SELECT following_id
        FROM follows
        WHERE follower_id = ?
      )
      ORDER BY posts.created DESC
      `,
    )
    .all(user.id, user.id);
  return c.html(
    <Layout>
      <Home user={user} posts={posts} />
    </Layout>,
  );
});

์ž, ์ด์ œ ๋‹ค ๊ตฌํ˜„๋˜์—ˆ์œผ๋‹ˆ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ํƒ€์ž„๋ผ์ธ์„ ๊ฐ์ƒํ•ฉ์‹œ๋‹ค:

์ฒซ ํŽ˜์ด์ง€์—์„œ ๋ณด์ด๋Š” ํƒ€์ž„๋ผ์ธ

์œ„์™€ ๊ฐ™์ด ์›๊ฒฉ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ๊ณผ ๋กœ์ปฌ์—์„œ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ์ตœ์‹ ์ˆœ์œผ๋กœ ์ž˜ ํ‘œ์‹œ๋˜๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ค๊ฐ€์š”? ๋งˆ์Œ์— ๋“œ์‹œ๋‚˜์š”?

์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ ๊ตฌํ˜„ํ•  ๊ฒƒ์€ ์ด๊ฒŒ ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„๋งŒ์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์™„์„ฑ์‹œํ‚ค๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๊ฐœ์„ ํ•  ์ 

์ด ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด ์™„์„ฑํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋Š” ์•„์‰ฝ๊ฒŒ๋„ ์•„์ง ์‹ค์‚ฌ์šฉ์—๋Š” ์ ํ•ฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํŠนํžˆ, ๋ณด์•ˆ ์ธก๋ฉด์—์„œ ์ทจ์•ฝ์ ์ด ๋งŽ์ด ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ๋ถ„์ด ๋งŒ๋“  ๋งˆ์ดํฌ๋กœ๋ธ”๋กœ๊ทธ๋ฅผ ์ข€ ๋” ๋ฐœ์ „์‹œํ‚ค๊ณ  ์‹ถ์€ ๋ถ„๋“ค์€, ์•„๋ž˜ ๊ณผ์ œ๋“ค์„ ์ง์ ‘ ํ•ด๊ฒฐํ•ด ๋ณด์…”๋„ ์ข‹์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

  • ํ˜„์žฌ๋Š” ์•„๋ฌด๋Ÿฐ ์ธ์ฆ์ด ์—†๊ธฐ ๋•Œ๋ฌธ์—, ๋ˆ„๊ตฌ๋ผ๋„ URL๋งŒ ์•Œ๋ฉด ๊ธ€์„ ๊ฒŒ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๊ณผ์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ด๋ฅผ ๋ฐฉ์ง€ํ•ด ๋ณผ๊นŒ์š”?

  • ํ˜„์žฌ์˜ ๊ตฌํ˜„์€ ActivityPub์„ ํ†ตํ•ด ๋ฐ›์€ Note ๊ฐ์ฒด ์•ˆ์— ๋“ค์–ด ์žˆ๋Š” HTML์„ ๊ทธ๋Œ€๋กœ ์ถœ๋ ฅํ•˜๊ฒŒ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์•…์˜์ ์ธ ActivityPub ์„œ๋ฒ„๊ฐ€ <script>while (true) alert('๋ฉ”๋กฑ'); ๊ฐ™์€ HTML์„ ํฌํ•จํ•œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ XSS ์ทจ์•ฝ์ ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ทจ์•ฝ์ ์€ ์–ด๋–ป๊ฒŒ ๋ง‰์„ ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • SQLite ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๋‹ค์Œ SQL์„ ์‹คํ–‰ํ•˜์—ฌ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟ” ๋ด…์‹œ๋‹ค:

    UPDATE actors SET name = 'Renamed' WHERE id = 1;

    ์ด๋ ‡๊ฒŒ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ฐ”๊ฟจ์„ ๋•Œ, ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ๋ฐ”๋€ ์ด๋ฆ„์ด ์ ์šฉ๋ ๊นŒ์š”? ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ๋ณด๋‚ด์•ผ ๋ณ€๊ฒฝ์ด ์ ์šฉ๋ ๊นŒ์š”?

  • ์•กํ„ฐ์— ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•ด ๋ด…์‹œ๋‹ค. ํ”„๋กœํ•„ ์‚ฌ์ง„์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ถ๊ธˆํ•˜๋‹ค๋ฉด, fedify lookup ๋ช…๋ น์œผ๋กœ ์ด๋ฏธ ํ”„๋กœํ•„ ์‚ฌ์ง„์ด ์žˆ๋Š” ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณด์„ธ์š”.

  • ๋‹ค๋ฅธ Mastodon ์„œ๋ฒ„์—์„œ ์ด๋ฏธ์ง€๊ฐ€ ์ฒจ๋ถ€๋œ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค. ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ํƒ€์ž„๋ผ์ธ์—์„œ๋Š” ๊ฒŒ์‹œ๋ฌผ์— ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์ฒจ๋ถ€๋œ ์ด๋ฏธ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”?

  • ๊ฒŒ์‹œ๋ฌผ ๋‚ด์—์„œ ๋‹ค๋ฅธ ์•กํ„ฐ๋ฅผ ๋ฉ˜์…˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ด…์‹œ๋‹ค. ๋ฉ˜์…˜ํ•œ ์ƒ๋Œ€ํ•œํ…Œ ์•Œ๋ฆผ์ด ๊ฐ€๋„๋ก ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

Tim Chambers's avatar
Tim Chambers

@tchambers@indieweb.social

A note: I now use for over half my social posting, cross-posting feaatures to Bluesky, Medium, Threads, Tumbr, etcโ€ฆ.& I now use from for over half the time - to read, engage, & reply on Bluesky, Mastodon, and many RSS feeds. Open Social Web FTW! cc: @manton @surf

Seth of the Fediverse's avatar
Seth of the Fediverse

@phillycodehound@indieweb.social ยท Reply to Tim Chambers's post

@tchambers Awesome! is great.

Tim Chambers's avatar
Tim Chambers

@tchambers@indieweb.social

A note: I now use for over half my social posting, cross-posting feaatures to Bluesky, Medium, Threads, Tumbr, etcโ€ฆ.& I now use from for over half the time - to read, engage, & reply on Bluesky, Mastodon, and many RSS feeds. Open Social Web FTW! cc: @manton @surf

Samantha Xavia's avatar
Samantha Xavia

@sam@bikersgo.social

Is Microblog actually a good blogging platform, Only just really have heard of it and not sure about anything about it. Might have to research more into it...


Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

We just finished drafting a new tutorial for ! This tutorial will walk you through the steps of creating your own federated . It's pretty long, though.

Please read it, give us feedback, and have fun!

https://unstable.fedify.dev/tutorial/microblog

Pierre's avatar
Pierre

@okpierre@mastodon.social

Always good to see progress! Only a handful of features left on the roadmap. Hollo is an activitypub powered federated microblog app. Docker image was made available with the latest release

Always good to see progress! Only a handful of features left on the roadmap. Hollo is an activitypub powered federated microblog app. Docker image was made available with the latest release
ALT text detailsAlways good to see progress! Only a handful of features left on the roadmap. Hollo is an activitypub powered federated microblog app. Docker image was made available with the latest release
Juho Hannikainen's avatar
Juho Hannikainen

@jshannikainen@mastodontti.fi

Van pรคivรครค! Olen kajaanilainen muusikko ja kulttuurin sekatyรถntekijรค, lรคhinnรค teatterin parissa tyรถskentelen. Lรคhellรค sydรคntรคni on improvisointi, yhdenvertaisuus, hyvรคt biisit ja monet muut asiat. Mastodonia oon katellu jo jonkun aikaa sivusta, tรคmรค vaikuttaa kiinnostavalta. Viime aikoina oon yksi kerrallaan jรคttรคnyt somealustoja pois tai vรคhemmรคlle, ja tuntuu taas kutkuttavan virkistรคvรคltรค taaplailla uuden alustan kanssa. Tรคssรค alustassa kiinnostaa -aspekti.