Profile img

Hi, I'm who's behind Fedify, Hollo, BotKit, and this website, Hackers' Pub! My main account is at @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

Fedify, Hollo, BotKit, 그리고 보고 계신 이 사이트 Hackers' Pub을 만들고 있습니다. 제 메인 계정은: @hongminhee洪 民憙 (Hong Minhee) :nonbinary:.

FedifyHolloBotKit、そしてこのサイト、Hackers' Pubを作っています。私のメインアカウントは「@hongminhee洪 民憙 (Hong Minhee) :nonbinary:」に。

Website
hongminhee.org
GitHub
@dahlia
Hollo
@hongminhee@hollo.social
DEV
@hongminhee
velog
@hongminhee
Qiita
@hongminhee
Zenn
@hongminhee
Matrix
@hongminhee:matrix.org
X
@hongminhee

GLM-4.7의 성능이 그렇게나 좋다고 들어서 요금제를 보니 상당히 파격적인 가격이라 조금 시도해 봤다. 우선 LogTape에 있던 이슈 하나를 수행하게 했고, 혹시 몰라서 Claude Code에서 Claude 4.5 Opus로 PLAN.md 계획 파일을 꽤 꼼꼼하게 만들게 한 뒤, 그걸 참고하게 했다. 그럼에도 불구하고:

  • 모든 export되는 API에 대해서는 JSDoc 주석을 써야 한다는 당연한 절차를 수행하지 않음 (코드베이스의 다른 코드는 다 그렇게 되어 있는데도 눈치가 없음)
  • JSDoc 주석을 쓰랬더니 Python docstring 스타일로 정의부 “안쪽”에 주석을 씀…
  • Deno.env 같은 특정 런타임에 의존적인 API를 씀 (코드베이스의 다른 코드는 그런 API 안 쓰고 있음)
  • 아주 기본적인 JavaScript 구문 오류를 냄 (예를 들면 세미콜론 빼먹는 식의) → 이것 때문에 상당히 토큰 소모를 많이 함
  • 한국어가 살짝 귀여움 (“나옵니다”가 아니라 “나옴니다”라고 쓰는 식)
  • 결국에는 JavaScript 구문 오류를 못 고쳐서 테스트 스위트도 아예 못 돌리는데 전체 작업이 완료되었다고 스스로 결론 내림

소문난 잔치에 먹을 게 없다더니, 역시나 벤치마크만 보고 모델을 골라서는 안 되겠다는 교훈만 재확인 한 것 같다.

오늘은 OpenCode에서 공짜로 제공하길래 MiniMax M2.1로 코딩을 좀 해봤다. 몇 시간 정도 해본 느낌으로는 GLM-4.7보다는 훨씬 나았고, 체감상으로는 대충 Claude Sonnet 4와 비슷한 정도로 말귀를 잘 알아듣는 느낌이었다. 컨텍스트 윈도가 긴 것도 장점이었다. 다만, 컨텍스트가 좀 길어지니까 끝도 없이 삽질을 반복하게 되어서, 그 쯤에서 모델을 GPT-5.1 Codex Max로 바꿔서 진행했다. GPT-5.1 Codex Max로 삽질 구간 벗어난 뒤에 금방 다시 MiniMax M2.1로 돌아와서 계속 코딩을 했고, 전반적으로 싼 값을 감안하면 굉장히 좋다고 느꼈다.

요즘에는 평소에 Claude Opus 4.5를 주력으로 사용하니까, 아무래도 비교가 될 수밖에 없었는데:

  • 역시나 눈치라고 해야 하나, 센스는 떨어진다. Claude Sonnet 4.5보다도 떨어지는 듯. 이를테면 Markdown 문서를 수정하도록 지시하면 기존의 일관성 있게 잘 짜여 있던 문서 서식이 금방 무너지는 게 느껴진다.
  • AGENTS.md의 세세한 지시를 좀 뭉개는 느낌이 있다. 예를 들면 TypeScript 코딩할 때 any 타입을 쓰지 말라고 했음에도 무시하고 사용한다든가. Claude 계열 모델들에서는 이런 건 잘 못 겪는다.
  • 작업의 맥락보다 이미 학습되어 있는 자신의 지식을 더 따르는 느낌이 있다. 이를테면 일부러 여러 JavaScript 런타임에서 두루 돌아가게 하려고 Deno API를 안 쓰고 Node.js API를 써서 짜 둔 코드베이스에서 갑자기 Deno API를 꺼내서 쓰기 시작하는 식이다. 이것도 눈치 문제로 볼 수도 있을 듯.
  • 그렇게 중요하진 않지만 자연어 응답에 언어가 조금 섞인다. 특히 국한문혼용체가 종종 나온다. 나로서는 오히려 좋다(?). 그런데 자세히 보면 대륙에서 쓰는 간화자가 아니라서, 중국어가 섞이는 건 아닌 것 같다. 아마도 일본어 아니면 대만/홍콩의 중국어가 섞이는 것 같다. 아니면 정말로 국한문혼용체일지도? 그리고 아랍어도 한 번 섞이는 걸 봤다.
  • 속도는 그냥저냥 쓸만하지만 딱히 빠른 것도 아닌데, 이건 OpenCode에서 공짜로 제공하는 걸 써서 그럴 수도 있다. Claude Opus 4.5보다는 약간 느리다고 느꼈지만, 이것도 그냥 체감이라 정확하진 않다. 삽질하는 걸 더 많이 봐서 느리다고 착각한 걸 수도 있고.

일단은 OpenCode에서 공짜로 제공하는 동안은 좀 더 써 볼 생각이다. 돈 내고 쓸 생각이 있냐 하면, 그건 좀 고민이 된다. 코딩 요금제를 보면 5시간에 300 프롬프트짜리가 월 20불 정도 된다. 지금은 Claude Max 요금제를 쓰고 있는데, 아무래도 부담이 좀 되긴 해서, Claude Pro로 내리고 MiniMax를 섞어서 쓰면 어떨까 생각만 해보고 있다.

6

洪 民憙 (Hong Minhee) shared the below article:

도커로 구축한 랩에서 혼자 실습하며 배우는 네트워크 프로토콜 입문 #5-2 DNS

자손킴 @jasonkim@hackers.pub

이번 섹션은 DNS에 대해서 알아본다. 책의 내용을 기반으로 패킷 분석은 생략하고 DNS의 발전과정, 존파일의 구체적인 예시와 보안 관련 내용을 짧게 추가하여 정리했다.

1장에서는 L2의 MAC 주소에 대해서 이야기했고, 2장에서는 L3의 IP주소에 대해서 이야기했다. IP 주소를 MAC 주소로 변환하기 위해서 ARP(Address Resolution Protocol)를 사용한다.

인터넷에서 다른 단말기에 접속하기 위해서는 IP 주소를 알아야 한다. 그러나 IP 주소는 숫자의 나열이기 때문에 서로 다른 단말기의 IP 주소를 외우고 있는 것은 힘들 뿐더러 IP 주소가 바뀔 경우 다시 외워야 한다는 번거로움이 크다.

DNS는 이러한 불편함을 해결하기 위해 만들어졌다.

DNS(Domain Name System)

인터넷의 전신인 아파넷(ARPANET)에서는 모든 컴퓨터의 이름과 지금의 IP 주소격인 Host Number를 HOSTS.TXT라는 파일에 기록하여 관리했다. 이 파일은 스탠포드 연구소(SRI)의 네트워크 정보 센터(NIC)에서 관리했고, 네트워크 관리자들이 주기적으로 FTP를 통해 이 파일을 다운로드하여 자신의 시스템에 복사했다.

1980년대 초에 네트워크가 급성장하면서 중앙집중식으로 관리되는 HOSTS.TXT는 확장성의 한계에 직면하게 되고 Paul Mockapetris가 RFC 882와 RFC 883을 통해 DNS를 제안한다. 이후 개정을 통해 RFC1034, RFC1035로 표준화 되었다.

DNS는 사람이 읽을 수 있는 도메인 이름을 컴퓨터가 통신에 사용하는 IP 주소로 변환해주는 분산 데이터베이스 시스템이다. 흔히 인터넷의 전화번호부에 비유되며 전 세계에 분산된 수많은 DNS 서버들이 협력하여 도메인 이름에 대한 질의에 응답한다.

도메인 이름

도메인은 계층 구조로 이루어져 있다. 가장 상위에는 루트 도메인이 있고 그 아래로 최상위 도메인(TLD, Top-Level Domain), 2레벨 도메인(2LD, 2nd Level Domain), 3레벨 도메인 순으로 내려간다. 예를 들어 www.example.com이라는 도메인이 있을 때 루트 도메인은 맨 끝의 점(.)으로 표현되며 보통 생략된다.

도메인 이름에서 점(.)으로 구분되는 각각의 부분을 라벨(label)이라고 부른다. www.example.com에서 www, example, com이 각각 하나의 라벨이다. 루트 도메인까지 포함하여 완전하게 표기된 도메인 이름을 FQDN(Fully Qualified Domain Name)이라고 한다.

도메인 이름은 일반적으로 호스트 부분과 도메인 부분으로 나눌 수 있다. www.example.com에서 www는 호스트 부분으로 특정 서버나 서비스를 가리키고, example.com은 도메인 부분으로 해당 조직이나 서비스의 영역을 나타낸다.

최상위 도메인(TLD)은 크게 일반 최상위 도메인(gTLD, Generic Top-Level Domain)과 국가 코드 최상위 도메인(ccTLD, Country Code Top-Level Domain)으로 구분된다. 일반 최상위 도메인은 특정 국가에 속하지 않고 용도나 분야에 따라 구분되는 도메인이다. 국가 코드 최상위 도메인은 ISO 3166-1 alpha-2 표준에 기반한 두 글자 코드로 각 국가나 지역을 나타내며 각 국가의 도메인 등록 기관이 해당 ccTLD의 정책을 독자적으로 관리한다.

DNS의 도메인 체계는 트리 형태의 계층 구조를 이룬다. 맨 꼭대기에는 루트 도메인이 위치하며 이 루트로부터 각 TLD가 가지처럼 뻗어나가고 각 TLD 아래에는 다시 수많은 2단계 도메인이, 그 아래에는 3단계 도메인이 연결되는 식이다. 이 구조에서 각 노드는 자신의 하위 도메인에 대한 정보를 관리하는 권한을 갖는다.

이름 풀이

IP 주소와 도메인 이름을 서로 변환하는 과정이다. 이름 풀이에는 hosts파일이나 DNS를 사용하며 /etc/nsswitch.conf 등에 의해 우선순위가 정해진다.

hosts 파일을 이용한 이름 풀이

hosts 파일은 IP 주소와 호스트 이름의 매핑을 한 줄에 하나씩 기록하는 단순한 텍스트 파일로, 127.0.0.1 localhost처럼 IP 주소 뒤에 공백을 두고 호스트 이름을 적는 형식이다. hosts 파일은 네트워크 연결 없이도 동작하고 설정에 따라서는 DNS보다 우선 적용된다.

운영체제는 이름 풀이 요청이 들어오면 먼저 이 파일을 확인하고 일치하는 항목이 있으면 DNS 질의 없이 바로 해당 IP 주소를 반환한다.

DNS를 이용한 이름 풀이

DNS를 이용한 이름 풀이는 여러 구성요소의 협력으로 이루어진다. 먼저 DNS 클라이언트(일명: 스터브 리졸버(Stub Resolver))는 사용자 컴퓨터의 운영체제에 내장된 DNS 클라이언트로 애플리케이션의 이름 풀이 요청을 받아 DNS 서버에 질의를 보내는 역할을 한다. 스터브 리졸버는 단순히 질의를 전달하고 응답을 받아오는 역할만 한다.

재귀 리졸버(Recursive Resolver) 또는 캐싱 네임서버는 DNS 클라이언트의 재귀 쿼리를 받아 인터넷에 있는 권한이 있는 네임서버에 이름 풀이 요청(반복 쿼리)를 보낸다.

권한이 있는 네임서버(Authoritative Name Server)는 특정 도메인에 대한 공식적인 DNS 레코드를 보유한 서버로 해당 도메인에 대한 질의에 최종적인 답변을 제공한다. 권한이 있는 네임서버는 존 파일(Zone File)이라는 텍스트 파일에 도메인 정보를 저장하며, 이 존 파일에는 리소스 레코드(Resource Record)라고 불리는 개별 항목들이 포함된다. A 레코드는 도메인 이름을 IPv4 주소로 매핑하고 AAAA 레코드는 IPv6 주소로 매핑한다. CNAME 레코드는 별칭을 정의하며 MX 레코드는 메일 서버, NS 레코드는 해당 도메인의 네임서버를 지정한다.

루트 네임서버는 DNS 계층의 최상위에서 TLD 네임서버의 위치를 알려주고 TLD 네임서버는 각 최상위 도메인에 등록된 도메인들의 권한이 있는 네임서버 위치를 안내한다.

DNS의 동작 방식

사용자가 웹 브라우저에 www.example.com을 입력하면 DNS를 통한 이름 풀이 과정이 시작된다. 먼저 운영체제의 스터브 리졸버가 설정된 재귀 리졸버에게 해당 도메인의 IP 주소를 요청한다. 재귀 리졸버는 자신의 캐시를 확인하여 이전에 조회한 적이 있는 도메인이라면 캐시된 결과를 즉시 반환한다.

캐시에 없는 경우 재귀 리졸버는 루트 네임서버에 질의를 보낸다. 루트 네임서버는 www.example.com의 IP 주소를 직접 알지 못하지만 com TLD를 담당하는 네임서버의 주소를 알려준다. 재귀 리졸버는 이어서 com TLD 네임서버에 질의하고 TLD 네임서버는 example.com 도메인의 권한이 있는 네임서버 주소를 응답한다. 마지막으로 재귀 리졸버가 해당 권한이 있는 네임서버에 질의하면 이 서버가 www.example.com의 실제 IP 주소를 담은 최종 답변을 반환한다.

재귀 리졸버는 이 결과를 캐시에 저장한 뒤 클라이언트에게 전달하여 전체 이름 풀이 과정이 완료된다. 캐시에 저장된 레코드는 TTL(Time To Live)이라는 유효 시간 동안 유지되며 TTL이 만료되면 다시 질의를 수행해야 한다.

재귀 쿼리와 반복 쿼리

DNS 질의는 재귀 쿼리(Recursive Query)와 반복 쿼리(Iterative Query)로 구분되며 이름 풀이 과정의 각 단계에서 서로 다른 방식이 사용된다.

재귀 쿼리는 클라이언트가 리졸버에게 최종 답변을 요구하는 질의 방식이다. 스터브 리졸버가 재귀 리졸버에게 질의를 보낼 때 이 방식을 사용한다. 재귀 쿼리를 받은 리졸버는 어떤 방법을 쓰든 완전한 답변을 돌려주거나 찾을 수 없다는 오류를 반환해야 하는 의무를 지게 된다. 클라이언트 입장에서는 한 번의 질의로 최종 결과를 받을 수 있으므로 구현이 단순해지고 복잡한 이름 풀이 로직은 전적으로 재귀 리졸버가 처리하게 된다.

반복 쿼리는 재귀 쿼리와 달리 상대 서버에게 최선의 답변만을 요청하는 방식이다. 재귀 리졸버가 루트 네임서버, TLD 네임서버, 권한이 있는 네임서버에 질의할 때 이 방식을 사용한다. 반복 쿼리를 받은 서버는 자신이 알고 있는 범위 내에서 답을 주거나 더 잘 알 것 같은 다른 서버의 주소를 알려주는 참조 응답(Referral)을 반환한다. 재귀 리졸버는 이 참조 응답을 따라가며 여러 서버에 순차적으로 질의하여 최종 답변을 찾아낸다.

정리하면, 일반적인 DNS 이름 풀이 과정에서 스터브 리졸버와 재귀 리졸버 사이에는 재귀 쿼리가 사용되고 재귀 리졸버와 각종 네임서버들 사이에는 반복 쿼리가 사용된다. 루트 네임서버, TLD 네임서버, 권한이 있는 네임서버와 같은 서버들은 모두 반복 쿼리만을 처리하며 다른 서버를 대신해서 질의를 수행하지 않는다.

A 레코드와 CNAME

리소스 레코드에는 여러 레코드 타입이 있지만 가장 기본이 되는 것은 A 레코드와 CNAME 레코드다.

A 레코드(Address Record)는 도메인 이름을 IPv4 주소에 직접 매핑한다. 사용자가 도메인에 접속할 때 최종적으로 필요한 것이 바로 이 A 레코드에 담긴 IP 주소다. 하나의 도메인에 여러 개의 A 레코드를 설정하여 트래픽을 분산시킬 수도 있다.

CNAME 레코드(Canonical Name Record)는 도메인 이름을 다른 도메인 이름으로 매핑하는 별칭 레코드다. CNAME은 IP 주소를 직접 가리키지 않고 다른 도메인을 가리킨다. 리졸버가 CNAME 레코드를 만나면 해당 별칭이 가리키는 도메인에 대해 다시 질의를 수행하여 최종적으로 A 레코드를 찾아낸다. CNAME은 여러 서브도메인이 같은 서버를 가리켜야 할 때 유용하다. 서버의 IP 주소가 변경되더라도 A 레코드 하나만 수정하면 해당 도메인을 가리키는 모든 CNAME이 자동으로 새 IP를 따라가게 된다.

다음은 example.com 도메인의 존 파일 예시다.

$TTL 3600
@       IN  SOA   ns1.example.com. admin.example.com. (
                  2024010101  ; Serial
                  7200        ; Refresh
                  3600        ; Retry
                  1209600     ; Expire
                  3600 )      ; Minimum TTL

@       IN  NS    ns1.example.com.
@       IN  NS    ns2.example.com.

@       IN  A     93.184.216.34
www     IN  CNAME example.com.
blog    IN  CNAME example.com.
api     IN  A     93.184.216.50

; 외부 서비스 연동
cdn     IN  CNAME d1234abcd.cloudfront.net.
app     IN  CNAME my-app.vercel.app.
storage IN  CNAME my-bucket.s3.amazonaws.com.

ns1     IN  A     93.184.216.10
ns2     IN  A     93.184.216.11

이 존 파일에서 @는 현재 도메인인 example.com을 의미한다. example.com은 A 레코드를 통해 93.184.216.34라는 IP 주소에 직접 매핑되어 있다. www.example.comblog.example.com은 CNAME 레코드로 example.com을 가리키므로, 결과적으로 같은 IP 주소로 연결된다. 반면 api.example.com은 별도의 A 레코드를 가지고 있어 다른 IP 주소를 사용한다.

CNAME은 같은 도메인 내의 다른 이름뿐 아니라 완전히 다른 도메인을 가리킬 수도 있다. 위 예시에서 cdn, app, storage 서브도메인은 각각 외부 서비스 제공자의 도메인을 가리킨다. 이 방식의 장점은 외부 서비스의 실제 IP 주소를 알 필요가 없다는 것이다. 서비스 제공자가 내부적으로 서버를 이전하거나 IP를 변경하더라도 사용자의 DNS 설정은 그대로 유지된다. 서비스 제공자의 네임서버가 항상 현재 유효한 IP 주소를 응답하기 때문이다.

DNS와 보안

DoT와 DoH

전통적인 DNS 질의는 암호화되지 않은 평문으로 전송된다. 이는 네트워크 경로 상의 누군가가 사용자가 어떤 도메인에 접속하려는지 엿볼 수 있고 응답을 조작할 수도 있다는 의미다.

DoT(DNS over TLS)와 DoH(DNS over HTTPS)는 이 문제를 해결하기 위해 DNS 질의를 암호화한다. DoT는 853번 포트를 사용하여 TLS로 DNS 트래픽을 감싸고 DoH는 일반 HTTPS 트래픽과 동일한 443번 포트를 사용한다. DoH는 일반 웹 트래픽과 구분이 어려워 차단하기 힘들다는 특징이 있다.

SNI와 ECH

DNS 질의를 암호화하더라도 여전히 접속하려는 도메인이 노출되는 지점이 있다. HTTPS 연결을 시작할 때 클라이언트는 SNI(Server Name Indication)라는 필드에 접속하려는 도메인 이름을 평문으로 보낸다. 하나의 IP 주소에서 여러 도메인을 호스팅하는 서버가 어떤 인증서를 제시할지 알아야 하기 때문이다.

ECH(Encrypted Client Hello)는 SNI를 포함한 ClientHello 메시지 전체를 암호화하여 이 문제를 해결한다. ECH를 사용하면 네트워크 관찰자는 사용자가 어떤 도메인에 접속하는지 알 수 없게 된다.

Read more →
2

@hongminhee洪 民憙 (Hong Minhee) 是的,Ruby 是我最熟悉的编程语言,自制编程语言过程充满挑战和乐趣。多年以前,我还是编程新手时,也尝试了解 Lisphp 的实现方式,但由于能力所限,未能做到;有一段时间使用过 CoffeeScript,得知作者起初也是以 Ruby 作为实现语言,我想对于现在的我,Ruby 是最适合的选择。感谢您为编程爱好者提供这样的交流平台,祝新的一年万事如意!

0

@hongminhee洪 民憙 (Hong Minhee) 是的,Ruby 是我最熟悉的编程语言,自制编程语言过程充满挑战和乐趣。多年以前,我还是编程新手时,也尝试了解 Lisphp 的实现方式,但由于能力所限,未能做到;有一段时间使用过 CoffeeScript,得知作者起初也是以 Ruby 作为实现语言,我想对于现在的我,Ruby 是最适合的选择。感谢您为编程爱好者提供这样的交流平台,祝新的一年万事如意!

1

인터페이스가 구린 라이브러리를 쓸때는 반!드!시! 심호흡을 하고 멀쩡한 인터페이스의 wrapper를 만든후에! 기능 개발을 합시다. 절대 대충 꾸역꾸역 만들어보자고 덤비면 안됩니다. 결국 후회합니다.

  • V모사의 AI 라이브러리를 쓰다가
4

바이브코딩이 취미가 되어가는 것일까요? 그새 또 뭔가 하나를 뚝딱여왔습니다... be-music-script라는 동인 리듬게임 에뮬레이터 비슷한 친구의 플레이 로그를 정리해주는 서비스를 만들어봤어요. 많은 리듬게임 유저들은 자기가 얼마나 잘했는지 자랑하고 싶어하는데, db 파일을 읽은 뒤 당일의 멋진 성과들을 정리해주는 서비스입니다. 제가 쓰려고 만든건데 이것도 결국 엔지니어링? 결과물인걸까? 싶어 올려봅니다.

요즘 AI의 도움 덕분에 아이디어를 구현하는게 두렵지 않아졌다는 기분이 드네요. https://sonohoshi.github.io/gosubms/

be-music-script라는 동인 리듬게임 에뮬레이터 비슷한 친구의 플레이 로그를 정리해주는 서비스, '고수븜스'의 스크린샷입니다.
제 플레이 데이터를 이용, 오늘 어떤 기록을 경신하였는지 보여주고 있습니다.
3

Claude Code의 인기와 함께 터미널에서 한글을 쓰는 모습을 자주 볼 수 있습니다. 하지만 터미널에서 쓰이는 한글은 글자간의 간격이 넓어 보기 좋지 않은 경우가 많습니다. 왜 이런 걸까요?

흔하게 쓰는 코드용 글꼴은 로마자, 숫자, 특수기호만을 다룰 뿐 한글은 다루지 않습니다. 그래서 터미널은 한글 표시를 하기 위해 대체 글꼴을 사용합니다. 대체 글꼴은 보통 OS의 기본 글꼴일 것입니다. 가변폭 글꼴이겠죠. 터미널은 이를 일부러 고정폭으로 만들기 위해 한글 한 자에 로마자 2자 폭을 할당하는데 이 과정에서 여백이 추가되면서 자간이 넓은 어색한 한글을 보게 되는 것입니다.

해결책은 한글 고정폭 글꼴을 사용하는 것입니다. 한글 고정폭 글꼴은 한글 1자를 로마자 2자 폭에 맞춰 만들었으므로 터미널이 더 이상 여백을 만들지 않습니다. 이러한 한글 고정폭 글꼴이 많진 않습니다. 10종이 안 되는 것 같네요. 저는 그중 Monoplex를 사용하고 있습니다. @hongminhee洪 民憙 (Hong Minhee) 님은 Sarasa Gothic을 사용하신다고 하네요. 적은 수의 글꼴이지만 맘에 드시는 걸 찾으셔서 예쁜 한글 출력을 보시면 좋겠습니다.

한글 고정폭 글꼴 적용이 안 된 터미널 화면한글 고정폭 글꼴 적용이 된 터미널 화면
3
1
4

Designing type-safe sync/async mode support in TypeScript

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

I recently added sync/async mode support to Optique, a type-safe CLI parser for TypeScript. It turned out to be one of the trickier features I've implemented—the object() combinator alone needed to compute a combined mode from all its child parsers, and TypeScript's inference kept hitting edge cases.

What is Optique?

Optique is a type-safe, combinatorial CLI parser for TypeScript, inspired by Haskell's optparse-applicative. Instead of decorators or builder patterns, you compose small parsers into larger ones using combinators, and TypeScript infers the result types.

Here's a quick taste:

import { object } from "@optique/core/constructs";
import { argument, option } from "@optique/core/primitives";
import { string, integer } from "@optique/core/valueparser";
import { run } from "@optique/run";

const cli = object({
  name: argument(string()),
  count: option("-n", "--count", integer()),
});

// TypeScript infers: { name: string; count: number | undefined }
const result = run(cli);  // sync by default

The type inference works through arbitrarily deep compositions—in most cases, you don't need explicit type annotations.

How it started

Lucas Garron (@lgarron) opened an issue requesting async support for shell completions. He wanted to provide Tab-completion suggestions by running shell commands like git for-each-ref to list branches and tags.

// Lucas's example: fetching Git branches and tags in parallel
const [branches, tags] = await Promise.all([
  $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(),
  $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(),
]);

At first, I didn't like the idea. Optique's entire API was synchronous, which made it simpler to reason about and avoided the “async infection” problem where one async function forces everything upstream to become async. I argued that shell completion should be near-instantaneous, and if you need async data, you should cache it at startup.

But Lucas pushed back. The filesystem is a database, and many useful completions inherently require async work—Git refs change constantly, and pre-caching everything at startup doesn't scale for large repos. Fair point.

What I needed to solve

So, how do you support both sync and async execution modes in a composable parser library while maintaining type safety?

The key requirements were:

  • parse() returns T or Promise<T>
  • complete() returns T or Promise<T>
  • suggest() returns Iterable<T> or AsyncIterable<T>
  • When combining parsers, if any parser is async, the combined result must be async
  • Existing sync code should continue to work unchanged

The fourth requirement is the tricky one. Consider this:

const syncParser = flag("--verbose");
const asyncParser = option("--branch", asyncValueParser);

// What's the type of this?
const combined = object({ verbose: syncParser, branch: asyncParser });

The combined parser should be async because one of its fields is async. This means we need type-level logic to compute the combined mode.

Five design options

I explored five different approaches, each with its own trade-offs.

Option A: conditional types with mode parameter

Add a mode type parameter to Parser and use conditional types:

type Mode = "sync" | "async";

type ModeValue<M extends Mode, T> = M extends "async" ? Promise<T> : T;

interface Parser<M extends Mode, TValue, TState> {
  parse(context: ParserContext<TState>): ModeValue<M, ParserResult<TState>>;
  // ...
}

The challenge is computing combined modes:

type CombineModes<T extends Record<string, Parser<any, any, any>>> =
  T[keyof T] extends Parser<infer M, any, any>
    ? M extends "async" ? "async" : "sync"
    : never;

Option B: mode parameter with default value

A variant of Option A, but place the mode parameter first with a default of "sync":

interface Parser<M extends Mode = "sync", TValue, TState> {
  readonly $mode: M;
  // ...
}

The default value maintains backward compatibility—existing user code keeps working without changes.

Option C: separate interfaces

Define completely separate Parser and AsyncParser interfaces with explicit conversion:

interface Parser<TValue, TState> { /* sync methods */ }
interface AsyncParser<TValue, TState> { /* async methods */ }

function toAsync<T, S>(parser: Parser<T, S>): AsyncParser<T, S>;

Simpler to understand, but requires code duplication and explicit conversions.

Option D: union return types for suggest() only

The minimal approach. Only allow suggest() to be async:

interface Parser<TValue, TState> {
  parse(context: ParserContext<TState>): ParserResult<TState>;  // always sync
  suggest(context: ParserContext<TState>, prefix: string):
    Iterable<Suggestion> | AsyncIterable<Suggestion>;  // can be either
}

This addresses the original use case but doesn't help if async parse() is ever needed.

Option E: fp-ts style HKT simulation

Use the technique from fp-ts to simulate Higher-Kinded Types:

interface URItoKind<A> {
  Identity: A;
  Promise: Promise<A>;
}

type Kind<F extends keyof URItoKind<any>, A> = URItoKind<A>[F];

interface Parser<F extends keyof URItoKind<any>, TValue, TState> {
  parse(context: ParserContext<TState>): Kind<F, ParserResult<TState>>;
}

The most flexible approach, but with a steep learning curve.

Testing the idea

Rather than commit to an approach based on theoretical analysis, I created a prototype to test how well TypeScript handles the type inference in practice. I published my findings in the GitHub issue:

Both approaches correctly handle the “any async → all async” rule at the type level. (…) Complex conditional types like ModeValue<CombineParserModes<T>, ParserResult<TState>> sometimes require explicit type casting in the implementation. This only affects library internals. The user-facing API remains clean.

The prototype validated that Option B (explicit mode parameter with default) would work. I chose it for these reasons:

  • Backward compatible: The default "sync" keeps existing code working
  • Explicit: The mode is visible in both types and runtime (via a $mode property)
  • Debuggable: Easy to inspect the current mode at runtime
  • Better IDE support: Type information is more predictable

How CombineModes works

The CombineModes type computes whether a combined parser should be sync or async:

type CombineModes<T extends readonly Mode[]> = "async" extends T[number]
  ? "async"
  : "sync";

This type checks if "async" is present anywhere in the tuple of modes. If so, the result is "async"; otherwise, it's "sync".

For combinators like object(), I needed to extract modes from parser objects and combine them:

// Extract the mode from a single parser
type ParserMode<T> = T extends Parser<infer M, unknown, unknown> ? M : never;

// Combine modes from all values in a record of parsers
type CombineObjectModes<T extends Record<string, Parser<Mode, unknown, unknown>>> =
  CombineModes<{ [K in keyof T]: ParserMode<T[K]> }[keyof T][]>;

Runtime implementation

The type system handles compile-time safety, but the implementation also needs runtime logic. Each parser has a $mode property that indicates its execution mode:

const syncParser = option("-n", "--name", string());
console.log(syncParser.$mode);  // "sync"

const asyncParser = option("-b", "--branch", asyncValueParser);
console.log(asyncParser.$mode);  // "async"

Combinators compute their mode at construction time:

function object<T extends Record<string, Parser<Mode, unknown, unknown>>>(
  parsers: T
): Parser<CombineObjectModes<T>, ObjectValue<T>, ObjectState<T>> {
  const parserKeys = Reflect.ownKeys(parsers);
  const combinedMode: Mode = parserKeys.some(
    (k) => parsers[k as keyof T].$mode === "async"
  ) ? "async" : "sync";

  // ... implementation
}

Refining the API

Lucas suggested an important refinement during our discussion. Instead of having run() automatically choose between sync and async based on the parser mode, he proposed separate functions:

Perhaps run(…) could be automatic, and runSync(…) and runAsync(…) could enforce that the inferred type matches what is expected.

So we ended up with:

  • run(): automatic based on parser mode
  • runSync(): enforces sync mode at compile time
  • runAsync(): enforces async mode at compile time
// Automatic: returns T for sync parsers, Promise<T> for async
const result1 = run(syncParser);  // string
const result2 = run(asyncParser);  // Promise<string>

// Explicit: compile-time enforcement
const result3 = runSync(syncParser);  // string
const result4 = runAsync(asyncParser);  // Promise<string>

// Compile error: can't use runSync with async parser
const result5 = runSync(asyncParser);  // Type error!

I applied the same pattern to parse()/parseSync()/parseAsync() and suggest()/suggestSync()/suggestAsync() in the facade functions.

Creating async value parsers

With the new API, creating an async value parser for Git branches looks like this:

import type { Suggestion } from "@optique/core/parser";
import type { ValueParser, ValueParserResult } from "@optique/core/valueparser";

function gitRef(): ValueParser<"async", string> {
  return {
    $mode: "async",
    metavar: "REF",
    parse(input: string): Promise<ValueParserResult<string>> {
      return Promise.resolve({ success: true, value: input });
    },
    format(value: string): string {
      return value;
    },
    async *suggest(prefix: string): AsyncIterable<Suggestion> {
      const { $ } = await import("bun");
      const [branches, tags] = await Promise.all([
        $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(),
        $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(),
      ]);
      for (const ref of [...branches.split("\n"), ...tags.split("\n")]) {
        const trimmed = ref.trim();
        if (trimmed && trimmed.startsWith(prefix)) {
          yield { kind: "literal", text: trimmed };
        }
      }
    },
  };
}

Notice that parse() returns Promise.resolve() even though it's synchronous. This is because the ValueParser<"async", T> type requires all methods to use async signatures. Lucas pointed out this is a minor ergonomic issue. If only suggest() needs to be async, you still have to wrap parse() in a Promise.

I considered per-method mode granularity (e.g., ValueParser<ParseMode, SuggestMode, T>), but the implementation complexity would multiply substantially. For now, the workaround is simple enough:

// Option 1: Use Promise.resolve()
parse(input) {
  return Promise.resolve({ success: true, value: input });
}

// Option 2: Mark as async and suppress the linter
// biome-ignore lint/suspicious/useAwait: sync implementation in async ValueParser
async parse(input) {
  return { success: true, value: input };
}

What it cost

Supporting dual modes added significant complexity to Optique's internals. Every combinator needed updates:

  • Type signatures grew more complex with mode parameters
  • Mode propagation logic had to be added to every combinator
  • Dual implementations were needed for sync and async code paths
  • Type casts were sometimes necessary in the implementation to satisfy TypeScript

For example, the object() combinator went from around 100 lines to around 250 lines. The internal implementation uses conditional logic based on the combined mode:

if (combinedMode === "async") {
  return {
    $mode: "async" as M,
    // ... async implementation with Promise chains
    async parse(context) {
      // ... await each field's parse result
    },
  };
} else {
  return {
    $mode: "sync" as M,
    // ... sync implementation
    parse(context) {
      // ... directly call each field's parse
    },
  };
}

This duplication is the cost of supporting both modes without runtime overhead for sync-only use cases.

Lessons learned

Listen to users, but validate with prototypes

My initial instinct was to resist async support. Lucas's persistence and concrete examples changed my mind, but I validated the approach with a prototype before committing. The prototype revealed practical issues (like TypeScript inference limits) that pure design analysis would have missed.

Backward compatibility is worth the complexity

Making "sync" the default mode meant existing code continued to work unchanged. This was a deliberate choice. Breaking changes should require user action, not break silently.

Unified mode vs per-method granularity

I chose unified mode (all methods share the same sync/async mode) over per-method granularity. This means users occasionally write Promise.resolve() for methods that don't actually need async, but the alternative was multiplicative complexity in the type system.

Designing in public

The entire design process happened in a public GitHub issue. Lucas, Giuseppe, and others contributed ideas that shaped the final API. The runSync()/runAsync() distinction came directly from Lucas's feedback.

Conclusion

This was one of the more challenging features I've implemented in Optique. TypeScript's type system is powerful enough to encode the “any async means all async” rule at compile time, but getting there required careful design work and prototyping.

What made it work: conditional types like ModeValue<M, T> can bridge the gap between sync and async worlds. You pay for it with implementation complexity, but the user-facing API stays clean and type-safe.

Optique 0.9.0 with async support is currently in pre-release testing. If you'd like to try it, check out PR #70 or install the pre-release:

npm  add       @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212
deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212

Feedback is welcome!

Read more →
4

Optique 0.9.0 pre-release is ready for testing!

The big new feature: sync/async mode support. You can now build CLI parsers with async value parsing and suggestions—perfect for shell completions that need to run commands (like listing Git branches/tags).

The API automatically propagates async mode through combinators, so you only decide sync vs async at the leaf level.

Try it:

npm  add       @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212
deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212

I'd love feedback before merging! Especially interested in:

  • API ergonomics
  • Edge cases I might have missed
  • TypeScript inference issues

Docs:

PR: https://github.com/dahlia/optique/pull/70

0

Optique 0.9.0 pre-release is ready for testing!

The big new feature: sync/async mode support. You can now build CLI parsers with async value parsing and suggestions—perfect for shell completions that need to run commands (like listing Git branches/tags).

The API automatically propagates async mode through combinators, so you only decide sync vs async at the leaf level.

Try it:

npm  add       @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212
deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212

I'd love feedback before merging! Especially interested in:

  • API ergonomics
  • Edge cases I might have missed
  • TypeScript inference issues

Docs:

PR: https://github.com/dahlia/optique/pull/70

Optique 0.9.0 프리릴리스 테스트 중입니다!

이번 주요 기능은 동기/비동기 모드 지원입니다. 이제 비동기 값 파싱과 자동완성을 지원하는 CLI 파서를 만들 수 있습니다. Git 브랜치/태그 목록처럼 셸 명령 실행이 필요한 자동완성에 딱이에요.

컴비네이터를 통해 async 모드가 자동으로 전파되기 때문에, 개발자는 말단 파서에서만 동기/비동기를 결정하면 됩니다.

설치:

npm  add       @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212
deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212

머지 전에 피드백 주시면 정말 감사하겠습니다! 특히 이런 부분이 궁금해요:

  • API 사용성
  • 에지 케이스
  • TypeScript 타입 추론 문제

문서:

PR: https://github.com/dahlia/optique/pull/70

0
2
0
0
2

WinGet도 요즈음은 Microsoft Store와 비슷한 정책을 적용하기 시작해서, 자동화된 검사 과정에서 식탁보 새 버전 매니페스트 등록이 막혔었는데, Moderator께서 정확한 판단을 내려주신 덕분에 무사히 새 버전이 게시되었습니다.

안타깝게도 Microsoft Store에는 식탁보를 등록하는 것이 계속 어려울 것으로 보입니다만, 대신 WinGet에 등록이 가능할테니 UniGetUI 등의 수단을 이용해서 커맨드라인 없이 식탁보를 쉽게 설치할 수 있는 방법을 조만간 공식 가이드로 내도록 하겠습니다.

https://github.com/microsoft/winget-pkgs/pull/326694

2
1

Logging in Node.js (or Deno or Bun or edge functions) in 2026

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

It's 2 AM. Something is wrong in production. Users are complaining, but you're not sure what's happening—your only clues are a handful of console.log statements you sprinkled around during development. Half of them say things like “here” or “this works.” The other half dump entire objects that scroll off the screen. Good luck.

We've all been there. And yet, setting up “proper” logging often feels like overkill. Traditional logging libraries like winston or Pino come with their own learning curves, configuration formats, and assumptions about how you'll deploy your app. If you're working with edge functions or trying to keep your bundle small, adding a logging library can feel like bringing a sledgehammer to hang a picture frame.

I'm a fan of the “just enough” approach—more than raw console.log, but without the weight of a full-blown logging framework. We'll start from console.log(), understand its real limitations (not the exaggerated ones), and work toward a setup that's actually useful. I'll be using LogTape for the examples—it's a zero-dependency logging library that works across Node.js, Deno, Bun, and edge functions, and stays out of your way when you don't need it.

Starting with console methods—and where they fall short

The console object is JavaScript's great equalizer. It's built-in, it works everywhere, and it requires zero setup. You even get basic severity levels: console.debug(), console.info(), console.warn(), and console.error(). In browser DevTools and some terminal environments, these show up with different colors or icons.

console.debug("Connecting to database...");
console.info("Server started on port 3000");
console.warn("Cache miss for user 123");
console.error("Failed to process payment");

For small scripts or quick debugging, this is perfectly fine. But once your application grows beyond a few files, the cracks start to show:

No filtering without code changes. Want to hide debug messages in production? You'll need to wrap every console.debug() call in a conditional, or find-and-replace them all. There's no way to say “show me only warnings and above” at runtime.

Everything goes to the console. What if you want to write logs to a file? Send errors to Sentry? Stream logs to CloudWatch? You'd have to replace every console.* call with something else—and hope you didn't miss any.

No context about where logs come from. When your app has dozens of modules, a log message like “Connection failed” doesn't tell you much. Was it the database? The cache? A third-party API? You end up prefixing every message manually: console.error("[database] Connection failed").

No structured data. Modern log analysis tools work best with structured data (JSON). But console.log("User logged in", { userId: 123 }) just prints User logged in { userId: 123 } as a string—not very useful for querying later.

Libraries pollute your logs. If you're using a library that logs with console.*, those messages show up whether you want them or not. And if you're writing a library, your users might not appreciate unsolicited log messages.

What you actually need from a logging system

Before diving into code, let's think about what would actually solve the problems above. Not a wish list of features, but the practical stuff that makes a difference when you're debugging at 2 AM or trying to understand why requests are slow.

Log levels with filtering

A logging system should let you categorize messages by severity—trace, debug, info, warning, error, fatal—and then filter them based on what you need. During development, you want to see everything. In production, maybe just warnings and above. The key is being able to change this without touching your code.

Categories

When your app grows beyond a single file, you need to know where logs are coming from. A good logging system lets you tag logs with categories like ["my-app", "database"] or ["my-app", "auth", "oauth"]. Even better, it lets you set different log levels for different categories—maybe you want debug logs from the database module but only warnings from everything else.

Sinks (multiple output destinations)

“Sink” is just a fancy word for “where logs go.” You might want logs to go to the console during development, to files in production, and to an external service like Sentry or CloudWatch for errors. A good logging system lets you configure multiple sinks and route different logs to different destinations.

Structured logging

Instead of logging strings, you log objects with properties. This makes logs machine-readable and queryable:

// Instead of this:
logger.info("User 123 logged in from 192.168.1.1");

// You do this:
logger.info("User logged in", { userId: 123, ip: "192.168.1.1" });

Now you can search for all logs where userId === 123 or filter by IP address.

Context for request tracing

In a web server, you often want all logs from a single request to share a common identifier (like a request ID). This makes it possible to trace a request's journey through your entire system.

Getting started with LogTape

There are plenty of logging libraries out there. winston has been around forever and has a plugin for everything. Pino is fast and outputs JSON. bunyan, log4js, signale—the list goes on.

So why LogTape? A few reasons stood out to me:

Zero dependencies. Not “few dependencies”—actually zero. In an era where a single npm install can pull in hundreds of packages, this matters for security, bundle size, and not having to wonder why your lockfile just changed.

Works everywhere. The same code runs on Node.js, Deno, Bun, browsers, and edge functions like Cloudflare Workers. No polyfills, no conditional imports, no “this feature only works on Node.”

Doesn't force itself on users. If you're writing a library, you can add logging without your users ever knowing—unless they want to see the logs. This is a surprisingly rare feature.

Let's set it up:

npm add @logtape/logtape       # npm
pnpm add @logtape/logtape      # pnpm
yarn add @logtape/logtape      # Yarn
deno add jsr:@logtape/logtape  # Deno
bun add @logtape/logtape       # Bun

Configuration happens once, at your application's entry point:

import { configure, getConsoleSink, getLogger } from "@logtape/logtape";

await configure({
  sinks: {
    console: getConsoleSink(),  // Where logs go
  },
  loggers: [
    { category: ["my-app"], lowestLevel: "debug", sinks: ["console"] },  // What to log
  ],
});

// Now you can log from anywhere in your app:
const logger = getLogger(["my-app", "server"]);
logger.info`Server started on port 3000`;
logger.debug`Request received: ${{ method: "GET", path: "/api/users" }}`;

Notice a few things:

  1. Configuration is explicit. You decide where logs go (sinks) and which logs to show (lowestLevel).
  2. Categories are hierarchical. The logger ["my-app", "server"] inherits settings from ["my-app"].
  3. Template literals work. You can use backticks for a natural logging syntax.

Categories and filtering: Controlling log verbosity

Here's a scenario: you're debugging a database issue. You want to see every query, every connection attempt, every retry. But you don't want to wade through thousands of HTTP request logs to find them.

Categories let you solve this. Instead of one global log level, you can set different verbosity for different parts of your application.

await configure({
  sinks: {
    console: getConsoleSink(),
  },
  loggers: [
    { category: ["my-app"], lowestLevel: "info", sinks: ["console"] },  // Default: info and above
    { category: ["my-app", "database"], lowestLevel: "debug", sinks: ["console"] },  // DB module: show debug too
  ],
});

Now when you log from different parts of your app:

// In your database module:
const dbLogger = getLogger(["my-app", "database"]);
dbLogger.debug`Executing query: ${sql}`;  // This shows up

// In your HTTP module:
const httpLogger = getLogger(["my-app", "http"]);
httpLogger.debug`Received request`;  // This is filtered out (below "info")
httpLogger.info`GET /api/users 200`;  // This shows up

Controlling third-party library logs

If you're using libraries that also use LogTape, you can control their logs separately:

await configure({
  sinks: { console: getConsoleSink() },
  loggers: [
    { category: ["my-app"], lowestLevel: "debug", sinks: ["console"] },
    // Only show warnings and above from some-library
    { category: ["some-library"], lowestLevel: "warning", sinks: ["console"] },
  ],
});

The root logger

Sometimes you want a catch-all configuration. The root logger (empty category []) catches everything:

await configure({
  sinks: { console: getConsoleSink() },
  loggers: [
    // Catch all logs at info level
    { category: [], lowestLevel: "info", sinks: ["console"] },
    // But show debug for your app
    { category: ["my-app"], lowestLevel: "debug", sinks: ["console"] },
  ],
});

Log levels and when to use them

LogTape has six log levels. Choosing the right one isn't just about severity—it's about who needs to see the message and when.

Level When to use it
trace Very detailed diagnostic info. Loop iterations, function entry/exit. Usually only enabled when hunting a specific bug.
debug Information useful during development. Variable values, state changes, flow control decisions.
info Normal operational messages. “Server started,” “User logged in,” “Job completed.”
warning Something unexpected happened, but the app can continue. Deprecated API usage, retry attempts, missing optional config.
error Something failed. An operation couldn't complete, but the app is still running.
fatal The app is about to crash or is in an unrecoverable state.
const logger = getLogger(["my-app"]);

logger.trace`Entering processUser function`;
logger.debug`Processing user ${{ userId: 123 }}`;
logger.info`User successfully created`;
logger.warn`Rate limit approaching: ${980}/1000 requests`;
logger.error`Failed to save user: ${error.message}`;
logger.fatal`Database connection lost, shutting down`;

A good rule of thumb: in production, you typically run at info or warning level. During development or when debugging, you drop down to debug or trace.

Structured logging: Beyond plain text

At some point, you'll want to search your logs. “Show me all errors from the payment service in the last hour.” “Find all requests from user 12345.” “What's the average response time for the /api/users endpoint?”

If your logs are plain text strings, these queries are painful. You end up writing regexes, hoping the log format is consistent, and cursing past-you for not thinking ahead.

Structured logging means attaching data to your logs as key-value pairs, not just embedding them in strings. This makes logs machine-readable and queryable.

LogTape supports two syntaxes for this:

Template literals (great for simple messages)

const userId = 123;
const action = "login";
logger.info`User ${userId} performed ${action}`;

Message templates with properties (great for structured data)

logger.info("User performed action", {
  userId: 123,
  action: "login",
  ip: "192.168.1.1",
  timestamp: new Date().toISOString(),
});

You can reference properties in your message using placeholders:

logger.info("User {userId} logged in from {ip}", {
  userId: 123,
  ip: "192.168.1.1",
});
// Output: User 123 logged in from 192.168.1.1

Nested property access

LogTape supports dot notation and array indexing in placeholders:

logger.info("Order {order.id} placed by {order.customer.name}", {
  order: {
    id: "ORD-001",
    customer: { name: "Alice", email: "alice@example.com" },
  },
});

logger.info("First item: {items[0].name}", {
  items: [{ name: "Widget", price: 9.99 }],
});

Machine-readable output with JSON Lines

For production, you often want logs as JSON (one object per line). LogTape has a built-in formatter for this:

import { configure, getConsoleSink, jsonLinesFormatter } from "@logtape/logtape";

await configure({
  sinks: {
    console: getConsoleSink({ formatter: jsonLinesFormatter }),
  },
  loggers: [
    { category: [], lowestLevel: "info", sinks: ["console"] },
  ],
});

Output:

{"@timestamp":"2026-01-15T10:30:00.000Z","level":"INFO","message":"User logged in","logger":"my-app","properties":{"userId":123}}

Sending logs to different destinations (sinks)

So far we've been sending everything to the console. That's fine for development, but in production you'll likely want logs to go elsewhere—or to multiple places at once.

Think about it: console output disappears when the process restarts. If your server crashes at 3 AM, you want those logs to be somewhere persistent. And when an error occurs, you might want it to show up in your error tracking service immediately, not just sit in a log file waiting for someone to grep through it.

This is where sinks come in. A sink is just a function that receives log records and does something with them. LogTape comes with several built-in sinks, and creating your own is trivial.

Console sink

The simplest sink—outputs to the console:

import { getConsoleSink } from "@logtape/logtape";

const consoleSink = getConsoleSink();

File sink

For writing logs to files, install the @logtape/file package:

npm add @logtape/file
import { getFileSink, getRotatingFileSink } from "@logtape/file";

// Simple file sink
const fileSink = getFileSink("app.log");

// Rotating file sink (rotates when file reaches 10MB, keeps 5 old files)
const rotatingFileSink = getRotatingFileSink("app.log", {
  maxSize: 10 * 1024 * 1024,  // 10MB
  maxFiles: 5,
});

Why rotating files? Without rotation, your log file grows indefinitely until it fills up the disk. With rotation, old logs are automatically archived and eventually deleted, keeping disk usage under control. This is especially important for long-running servers.

External services

For production systems, you often want logs to go to specialized services that provide search, alerting, and visualization. LogTape has packages for popular services:

// OpenTelemetry (for observability platforms like Jaeger, Honeycomb, Datadog)
import { getOpenTelemetrySink } from "@logtape/otel";

// Sentry (for error tracking with stack traces and context)
import { getSentrySink } from "@logtape/sentry";

// AWS CloudWatch Logs (for AWS-native log aggregation)
import { getCloudWatchLogsSink } from "@logtape/cloudwatch-logs";

The OpenTelemetry sink is particularly useful if you're already using OpenTelemetry for tracing—your logs will automatically correlate with your traces, making debugging distributed systems much easier.

Multiple sinks

Here's where things get interesting. You can send different logs to different destinations based on their level or category:

await configure({
  sinks: {
    console: getConsoleSink(),
    file: getFileSink("app.log"),
    errors: getSentrySink(),
  },
  loggers: [
    { category: [], lowestLevel: "info", sinks: ["console", "file"] },  // Everything to console + file
    { category: [], lowestLevel: "error", sinks: ["errors"] },  // Errors also go to Sentry
  ],
});

Notice that a log record can go to multiple sinks. An error log in this configuration goes to the console, the file, and Sentry. This lets you have comprehensive local logs while also getting immediate alerts for critical issues.

Custom sinks

Sometimes you need to send logs somewhere that doesn't have a pre-built sink. Maybe you have an internal logging service, or you want to send logs to a Slack channel, or store them in a database.

A sink is just a function that takes a LogRecord. That's it:

import type { Sink } from "@logtape/logtape";

const slackSink: Sink = (record) => {
  // Only send errors and fatals to Slack
  if (record.level === "error" || record.level === "fatal") {
    fetch("https://hooks.slack.com/services/YOUR/WEBHOOK/URL", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        text: `[${record.level.toUpperCase()}] ${record.message.join("")}`,
      }),
    });
  }
};

The simplicity of sink functions means you can integrate LogTape with virtually any logging backend in just a few lines of code.

Request tracing with contexts

Here's a scenario you've probably encountered: a user reports an error, you check the logs, and you find a sea of interleaved messages from dozens of concurrent requests. Which log lines belong to the user's request? Good luck figuring that out.

This is where request tracing comes in. The idea is simple: assign a unique identifier to each request, and include that identifier in every log message produced while handling that request. Now you can filter your logs by request ID and see exactly what happened, in order, for that specific request.

LogTape supports this through contexts—a way to attach properties to log messages without passing them around explicitly.

Explicit context

The simplest approach is to create a logger with attached properties using .with():

function handleRequest(req: Request) {
  const requestId = crypto.randomUUID();
  const logger = getLogger(["my-app", "http"]).with({ requestId });

  logger.info`Request received`;  // Includes requestId automatically
  processRequest(req, logger);
  logger.info`Request completed`;  // Also includes requestId
}

This works well when you're passing the logger around explicitly. But what about code that's deeper in your call stack? What about code in libraries that don't know about your logger instance?

Implicit context

This is where implicit contexts shine. Using withContext(), you can set properties that automatically appear in all log messages within a callback—even in nested function calls, async operations, and third-party libraries (as long as they use LogTape).

First, enable implicit contexts in your configuration:

import { configure, getConsoleSink } from "@logtape/logtape";
import { AsyncLocalStorage } from "node:async_hooks";

await configure({
  sinks: { console: getConsoleSink() },
  loggers: [
    { category: ["my-app"], lowestLevel: "debug", sinks: ["console"] },
  ],
  contextLocalStorage: new AsyncLocalStorage(),
});

Then use withContext() in your request handler:

import { withContext, getLogger } from "@logtape/logtape";

function handleRequest(req: Request) {
  const requestId = crypto.randomUUID();

  return withContext({ requestId }, async () => {
    // Every log message in this callback includes requestId—automatically
    const logger = getLogger(["my-app"]);
    logger.info`Processing request`;

    await validateInput(req);   // Logs here include requestId
    await processBusinessLogic(req);  // Logs here too
    await saveToDatabase(req);  // And here

    logger.info`Request complete`;
  });
}

The magic is that validateInput, processBusinessLogic, and saveToDatabase don't need to know anything about the request ID. They just call getLogger() and log normally, and the request ID appears in their logs automatically. This works even across async boundaries—the context follows the execution flow, not the call stack.

This is incredibly powerful for debugging. When something goes wrong, you can search for the request ID and see every log message from every module that was involved in handling that request.

Framework integrations

Setting up request tracing manually can be tedious. LogTape has dedicated packages for popular frameworks that handle this automatically:

// Express
import { expressLogger } from "@logtape/express";
app.use(expressLogger());

// Fastify
import { getLogTapeFastifyLogger } from "@logtape/fastify";
const app = Fastify({ loggerInstance: getLogTapeFastifyLogger() });

// Hono
import { honoLogger } from "@logtape/hono";
app.use(honoLogger());

// Koa
import { koaLogger } from "@logtape/koa";
app.use(koaLogger());

These middlewares automatically generate request IDs, set up implicit contexts, and log request/response information. You get comprehensive request logging with a single line of code.

Using LogTape in libraries vs applications

If you've ever used a library that spams your console with unwanted log messages, you know how annoying it can be. And if you've ever tried to add logging to your own library, you've faced a dilemma: should you use console.log() and annoy your users? Require them to install and configure a specific logging library? Or just... not log anything?

LogTape solves this with its library-first design. Libraries can add as much logging as they want, and it costs their users nothing unless they explicitly opt in.

If you're writing a library

The rule is simple: use getLogger() to log, but never call configure(). Configuration is the application's responsibility, not the library's.

// my-library/src/database.ts
import { getLogger } from "@logtape/logtape";

const logger = getLogger(["my-library", "database"]);

export function connect(url: string) {
  logger.debug`Connecting to ${url}`;
  // ... connection logic ...
  logger.info`Connected successfully`;
}

What happens when someone uses your library?

If they haven't configured LogTape, nothing happens. The log calls are essentially no-ops—no output, no errors, no performance impact. Your library works exactly as if the logging code wasn't there.

If they have configured LogTape, they get full control. They can see your library's debug logs if they're troubleshooting an issue, or silence them entirely if they're not interested. They decide, not you.

This is fundamentally different from using console.log() in a library. With console.log(), your users have no choice—they see your logs whether they want to or not. With LogTape, you give them the power to decide.

If you're writing an application

You configure LogTape once in your entry point. This single configuration controls logging for your entire application, including any libraries that use LogTape:

await configure({
  sinks: { console: getConsoleSink() },
  loggers: [
    { category: ["my-app"], lowestLevel: "debug", sinks: ["console"] },  // Your app: verbose
    { category: ["my-library"], lowestLevel: "warning", sinks: ["console"] },  // Library: quiet
    { category: ["noisy-library"], lowestLevel: "fatal", sinks: [] },  // That one library: silent
  ],
});

This separation of concerns—libraries log, applications configure—makes for a much healthier ecosystem. Library authors can add detailed logging for debugging without worrying about annoying their users. Application developers can tune logging to their needs without digging through library code.

Migrating from another logger?

If your application already uses winston, Pino, or another logging library, you don't have to migrate everything at once. LogTape provides adapters that route LogTape logs to your existing logging setup:

import { install } from "@logtape/adaptor-winston";
import winston from "winston";

install(winston.createLogger({ /* your existing config */ }));

This is particularly useful when you want to use a library that uses LogTape, but you're not ready to switch your whole application over. The library's logs will flow through your existing winston (or Pino) configuration, and you can migrate gradually if you choose to.

Production considerations

Development and production have different needs. During development, you want verbose logs, pretty formatting, and immediate feedback. In production, you care about performance, reliability, and not leaking sensitive data. Here are some things to keep in mind.

Non-blocking mode

By default, logging is synchronous—when you call logger.info(), the message is written to the sink before the function returns. This is fine for development, but in a high-throughput production environment, the I/O overhead of writing every log message can add up.

Non-blocking mode buffers log messages and writes them in the background:

const consoleSink = getConsoleSink({ nonBlocking: true });
const fileSink = getFileSink("app.log", { nonBlocking: true });

The tradeoff is that logs might be slightly delayed, and if your process crashes, some buffered logs might be lost. But for most production workloads, the performance benefit is worth it.

Sensitive data redaction

Logs have a way of ending up in unexpected places—log aggregation services, debugging sessions, support tickets. If you're logging request data, user information, or API responses, you might accidentally expose sensitive information like passwords, API keys, or personal data.

LogTape's @logtape/redaction package helps you catch these before they become a problem:

import {
  redactByPattern,
  EMAIL_ADDRESS_PATTERN,
  CREDIT_CARD_NUMBER_PATTERN,
  type RedactionPattern,
} from "@logtape/redaction";
import { defaultConsoleFormatter, configure, getConsoleSink } from "@logtape/logtape";

const BEARER_TOKEN_PATTERN: RedactionPattern = {
  pattern: /Bearer [A-Za-z0-9\-._~+\/]+=*/g,
  replacement: "[REDACTED]",
};

const formatter = redactByPattern(defaultConsoleFormatter, [
  EMAIL_ADDRESS_PATTERN,
  CREDIT_CARD_NUMBER_PATTERN,
  BEARER_TOKEN_PATTERN,
]);

await configure({
  sinks: {
    console: getConsoleSink({ formatter }),
  },
  // ...
});

With this configuration, email addresses, credit card numbers, and bearer tokens are automatically replaced with [REDACTED] in your log output. The @logtape/redaction package comes with built-in patterns for common sensitive data types, and you can define custom patterns for anything else. It's not foolproof—you should still be mindful of what you log—but it provides a safety net.

See the redaction documentation for more patterns and field-based redaction.

Edge functions and serverless

Edge functions (Cloudflare Workers, Vercel Edge Functions, etc.) have a unique constraint: they can be terminated immediately after returning a response. If you have buffered logs that haven't been flushed yet, they'll be lost.

The solution is to explicitly flush logs before returning:

import { configure, dispose } from "@logtape/logtape";

export default {
  async fetch(request, env, ctx) {
    await configure({ /* ... */ });
    
    // ... handle request ...
    
    ctx.waitUntil(dispose());  // Flush logs before worker terminates
    
    return new Response("OK");
  },
};

The dispose() function flushes all buffered logs and cleans up resources. By passing it to ctx.waitUntil(), you ensure the worker stays alive long enough to finish writing logs, even after the response has been sent.

Wrapping up

Logging isn't glamorous, but it's one of those things that makes a huge difference when something goes wrong. The setup I've described here—categories for organization, structured data for queryability, contexts for request tracing—isn't complicated, but it's a significant step up from scattered console.log statements.

LogTape isn't the only way to achieve this, but I've found it hits a nice sweet spot: powerful enough for production use, simple enough that you're not fighting the framework, and light enough that you don't feel guilty adding it to a library.

If you want to dig deeper, the LogTape documentation covers advanced topics like custom filters, the “fingers crossed” pattern for buffering debug logs until an error occurs, and more sink options. The GitHub repository is also a good place to report issues or see what's coming next.

Now go add some proper logging to that side project you've been meaning to clean up. Your future 2 AM self will thank you.

Read more →
1
3
0
2

洪 民憙 (Hong Minhee) shared the below article:

`X-Frame-Options` 의 악몽에서 깨어나세요, 프록시 서버 개발기

소피아 @async3619@hackers.pub

X-Frame-Options 의 악몽에서 깨어나세요, 프록시 서버 개발기

X-Frame-Options? 그게 뭔가요?

우리가 아는 몇몇 대형 웹 서비스들(유튜브 등)은 보통의 경우 다른 웹 사이트에서 iframe 요소를 통해 임베딩 되는 것을 거부하지 않습니다. 다만, 몇몇 웹 서비스는 다른 웹 페이지에서 iframe 요소를 통해 표시되길 거부합니다. 제품 정책 및 보안상의 이유로 표시를 거부하는 목적이 있겠습니다.

이러한 니즈를 충족시킬 수 있는 HTTP 헤더가 X-Frame-Options 입니다. 이 헤더의 값을 SAMEORIGIN 내지는 DENY로 설정하면, 직관적인 값에 따라 알맞게 프레임 내 임베딩 가능 여부를 결정할 수 있습니다.

네이버 웹 사이트의 Response Header 목록, X-Frame-Options가 명시되어 있다.

위 이미지에서 볼 수 있듯, 네이버는 X-Frame-Options 헤더를 명시함으로써 그 어떠한 출처에서도 프레임 내에 임베딩 되는 것을 거부한 상황입니다. #

왜 이걸 우회하나요?

image

웹 페이지 내에 다른 웹 페이지가 임베딩 되어 '미리보기' 처럼 제공되는 경험을 보신 적이 있으신가요? Omakase AI는 자사 프로덕트의 데모를 위와 같이 제공하고 있습니다. 캡쳐하여 실시간으로 전송되는 영상의 화면 위에, 자사 컨텐츠를 올려두어 실제 적용 시에 어떤 네러티브를 제공할지 데모 형식으로 보여줍니다.

문제는 이 모든 경험이 영상을 통해 진행 된다는 점 입니다. 영상 전송은 필연적으로 지연 시간이 존재할 수 밖에 없습니다. 여러분이 스크롤을 내리고, 클릭을 하는 등의 작은 인터렉션 하나가, 큰 지연 시간 뒤에 처리가 된다고 하면 유저는 답답하고 매끄럽지 않음을 느낄 것이고 이는 곧 이탈로 이어질 가능성이 있습니다. (구글은 비슷한 맥락에서 Core Web Vitals로써 INP를 설명하고 있습니다)

따라서 저는 영상을 보내는 나이브한 방법 이외의 유저의 브라우저에서 외부 웹 서비스를 표시할 좋은 방법을 찾아야 했고, 그것이 바로 프레임 내지는 iframe을 사용하는 방법 이었습니다. 이런 맥락에서 X-Frame-Options를 우회할 필요가 생긴 것 입니다.

어떻게 했나요?

중간자의 필요성

image

우리는 정상적인 방법으로는 제 3자 출처의 요청을 가로채어 응답 데이터 및 헤더를 조작할 수 없음을 잘 알고 있습니다. 여기서 필요한게 중간자 (Man in the Middle) 입니다. 누군가를 클라이언트 - 원격지 서버 사이에 두어, 서로에게 오가는 요청과 응답을 수정하는 작업을 수행하도록 하는 것 입니다. 그렇게 하면, 둘은 각자 수신한 요청과 응답이 모두 원본인지, 수정한 것인지 알 수 있는 방법은 거의 없을 것 입니다.[1]

image

이 중간자 역할을 하는 프록시 서버를 중간에 두어 요청을 모두 프록시 서버를 거치도록 하는 방법을 이용하는 것 입니다. 간단하게는 X-Frame-Options 응답 헤더의 제거가 있을 것 입니다. 중간자가 X-Frame-Options 헤더를 제거함으로 응답을 수신하는 클라이언트 브라우저의 iframe 요소는 큰 문제 없이 내용을 표시할 수 있게 됩니다.

여기서 끝이 아니다. URL을 조작하기

처음에는 그저 GET Query Parameter로 프록시 서버에게 어떤 원격 URL을 프록시 할 것인지 명시하도록 구현 했습니다. 예를 들면 다음과 같은 형식이 될 수 있겠습니다:

https://example.com/proxy?target=https://www.naver.com/...

보통의 경우에는 별 문제 없이 동작 했습니다만, 재앙은 그다지 먼 곳에 있지 않았습니다. 만약 다음과 같은 코드가 원격지 웹 서비스 코드에 있다고 해봅시다. 아래 코드는 무신사 웹 페이지의 빌드된 소스코드 입니다:

import {E as k0} from "./vendor/react-error-boundary.js";
import {b as o0, d as Wt} from "./vendor/react-router.js";
import {d as F0} from "./vendor/dayjs.js";
import {L as Qt} from "./vendor/lottie.js";
import "./vendor/scheduler.js";
import "./vendor/prop-types.js";
import "./vendor/react-fast-compare.js";
import "./vendor/invariant.js";
import "./vendor/shallowequal.js";
import "./vendor/@remix-run.js";
import "./vendor/tslib.js";
import "./vendor/@emotion.js";
import "./vendor/stylis.js";
import "./vendor/framer-motion.js";
import "./vendor/motion-utils.js";
import "./vendor/motion-dom.js";

무신사는 내부적으로 ESM을 사용해서, import 구문을 통해 필요한 에셋을 불러오는 코드를 사용중에 있습니다. 문제는 여기서 발생합니다. import 의 대상이 되는 소스코드가 상대 경로를 따르게 되어 아래와 같은 결과를 초래하게 됩니다.

https://example.com/proxy?target=https://www.naver.com/...

위와 같은 URL을 표시하고 있는 `iframe` 요소에서,
`import "./vendor/framer-motion.js"` 구문을 만난다면..

https://example.com/proxy/vendor/framer-motion.js 를 요청하게 됨.

이를 해결하기 위해, 프록시 된 대상 URL에 대한 개념의 도입이 필요 했습니다. 상대 경로 진입에도 안정적으로 작동할 수 있는 새로운 방식의 접근이 필요 했습니다. 저는

  1. 상대 경로 접근에도 안전하며,
  2. 원본 URL의 구조를 그대로 남길 수 있는 구조

를 생각해 냈어야 했고, 그 결과는 이렇습니다. https://section.blog.naver.com/BlogHome.naver?directoryNo=0&currentPage=1&groupId=0 를 예시로 들면, 프록시화 (Proxified) 된 URL은 다음과 같은 것 입니다.

https://example.com/proxy/section/blog/naver/com/_/BlogHome.naver?...

URL hostname. 구분자를 /로 치환하고, 이후의 모든 pathname, search 등은 모두 _ 구분자 뒤로 넘김으로서 URL의 원형을 유지할 수 있게 됩니다. 추가적으로 상대 경로 접근에도 안전한 URL을 만들 수 있습니다.

최종 보스가 남아있다. HTML API 후킹하기

우리는 상대 경로 문제를 해결하기 위해 URL을 프록시화 하는 방법을 사용했고, 이는 제대로 동작하는 듯 해보였습니다. 악몽은 React, Vue 등의 SPA 웹 앱을 프록시하여 표시하는 데에서 시작 되었습니다.

React, Vue 와 같은 프레임워크들은 History API 및 window.location 객체를 기반으로 한 Routing 기능을 제공하고 있습니다.[2] 이 말은, 결국엔 어떤 프레임워크가 되었든 저수준 빌트인 자바스크립트 API를 사용할 수 밖에 없다는 것을 의미 합니다. 그렇다면 직관적으로 생각 해봤을 때,

window (및 globalThis) 객체의 location 속성의 값을 변경해주면 되지 않겠나?

라고 생각할 수 있습니다. 그러나 이는 불가능 합니다.

image

어떠한 이유 때문인지는 알 길이 없었지만, 자바스크립트는 그렇게 만만한 존재가 아니었습니다. 다른 좋은 방법을 찾아야 할 필요가 있었고, 결론에 도달하는 데에는 오랜 시간이 걸리지 않았습니다. 그것은 바로 원격지 웹 페이지에서 실행되는 모든 스크립트의 window.location 객체 접근을 감시하면 어떨지에 대한 아이디어 였습니다.

번들된 소스코드의 경우 대체적으로 다음과 같은 형식을 가지게 됩니다:

const l = window.location;
/* ... */ l.pathname /* ... */

여기서 우리는 변수 lwindow.location의 별칭인지 소스코드만 분석해서는 알기 매우 어렵습니다. 따라서, babel을 사용해서 소스코드를 AST로 분석하고, 모든 프로퍼티 접근을 특정 함수 호출로 변환하면, '특정 함수'에서 모든 것을 처리할 수 있으니 좋을 것 같다는 생각이 있었고, 실행에 옮겼습니다:

const l = window.location;
l.pathname;

// 위 코드는 아래와 같이 변환됨

const l = __internal_get__(window, 'location');
__internal_get__(l, 'pathname');

__internal_get__ 함수 내부에서 첫번째 인자가 window.location 과 동일한 인스턴스를 가지고 있는지 비교하거나, 두번째 인자인 프로퍼티 키를 비교해서 href 등의 값이라면, 원하는 값을 반환하도록 후킹 함수를 만들 수 있겠습니다.

function __internal_get__(owner, propertyKey) {
  if (owner === window.location) {
    return {
      get href() { /* proxified 된 url을 기반으로 Router를 속이는 URL을 반환하는 로직 */ }
    }
  }

  // ...
}

마치며

글에 열거한 내용 이외에도 정말 많은 기술이 사용 되었는데, 아주 재밌는 경험 이었습니다. 혹여나 이러한 비슷한 기능을 하는 기능을 개발할 일이 있으시다면, 도움이 됐으면 좋겠습니다.


  1. 여기서 거의 라는 표현을 사용한 이유는, 제 짧은 식견에서 보자면 비슷한 맥락에서 사용하는 기법으로 integrity 속성이 있을 수 있겠습니다. ↩︎

  2. 예를 들면, location.pathname을 읽어 현재 Route가 어떤 Route인지 감지하는 등의 동작이 있겠음. ↩︎

Read more →
4

Sending emails in Node.js, Deno, and Bun in 2026: a practical guide

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

So you need to send emails from your JavaScript application. Email remains one of the most essential features in web apps—welcome emails, password resets, notifications—but the ecosystem is fragmented. Nodemailer doesn't work on edge functions. Each provider has its own SDK. And if you're using Deno or Bun, good luck finding libraries that actually work.

This guide covers how to send emails across modern JavaScript runtimes using Upyo, a cross-runtime email library.

Disclosure

I'm the author of Upyo. This guide focuses on Upyo because I built it to solve problems I kept running into, but you should know that going in. If you're looking for alternatives: Nodemailer is the established choice for Node.js (though it doesn't work on Deno/Bun/edge), and most email providers offer their own official SDKs.

TL;DR for the impatient

If you just want working code, here's the quickest path to sending an email:

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";

const transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: {
    user: "your-email@gmail.com",
    pass: "your-app-password", // Not your regular password!
  },
});

const message = createMessage({
  from: "your-email@gmail.com",
  to: "recipient@example.com",
  subject: "Hello from my app!",
  content: { text: "This is my first email." },
});

const receipt = await transport.send(message);
if (receipt.successful) {
  console.log("Sent:", receipt.messageId);
} else {
  console.log("Failed:", receipt.errorMessages);
}

Install with:

npm add @upyo/core @upyo/smtp

That's it. This exact code works on Node.js, Deno, and Bun. But if you want to understand what's happening and explore more powerful options, read on.


Why Upyo?

  • Cross-runtime: Works on Node.js, Deno, Bun, and edge functions with the same API
  • Zero dependencies: Keeps your bundle small
  • Provider independence: Switch between SMTP, Mailgun, Resend, SendGrid, or Amazon SES without changing your application code
  • Type-safe: Full TypeScript support with discriminated unions for error handling
  • Built for testing: Includes a mock transport for unit tests

Part 1: Getting started with Gmail SMTP

Let's start with the most accessible option: Gmail's SMTP server. It's free, requires no additional accounts, and works great for development and low-volume production use.

Step 1: Generate a Gmail app password

Gmail doesn't allow you to use your regular password for SMTP. You need to create an app-specific password:

  1. Go to your Google Account
  2. Navigate to Security2-Step Verification (enable it if you haven't)
  3. At the bottom, click App passwords
  4. Select Mail and your device, then click Generate
  5. Copy the 16-character password

Step 2: Install dependencies

Choose your runtime and package manager:

Node.js

npm add @upyo/core @upyo/smtp
# or: pnpm add @upyo/core @upyo/smtp
# or: yarn add @upyo/core @upyo/smtp

Deno

deno add jsr:@upyo/core jsr:@upyo/smtp

Bun

bun add @upyo/core @upyo/smtp

The same code works across all three runtimes—that's the beauty of Upyo.

Step 3: Send your first email

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";

// Create the transport (reuse this for multiple emails)
const transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: {
    user: "your-email@gmail.com",
    pass: "abcd efgh ijkl mnop", // Your app password
  },
});

// Create and send a message
const message = createMessage({
  from: "your-email@gmail.com",
  to: "recipient@example.com",
  subject: "Welcome to my app!",
  content: {
    text: "Thanks for signing up. We're excited to have you!",
    html: "<h1>Welcome!</h1><p>Thanks for signing up. We're excited to have you!</p>",
  },
});

const receipt = await transport.send(message);

if (receipt.successful) {
  console.log("Email sent successfully! Message ID:", receipt.messageId);
} else {
  console.error("Failed to send email:", receipt.errorMessages.join(", "));
}

// Don't forget to close connections when done
await transport.closeAllConnections();

Let me highlight a few important details:

  • secure: true with port 465: This establishes a TLS-encrypted connection from the start. Gmail requires encryption, so this combination is essential.
  • Separate text and html content: Always provide both. Some email clients don't render HTML, and spam filters look more favorably on emails with plain text alternatives.
  • The receipt pattern: Upyo uses discriminated unions for type-safe error handling. When receipt.successful is true, you get messageId. When it's false, you get errorMessages. This makes it impossible to forget error handling.
  • Closing connections: SMTP maintains persistent TCP connections. Always close them when you're done, or use await using (shown next) to handle this automatically.

Pro tip: automatic resource cleanup with await using

Managing resources manually is error-prone—what if an exception occurs before closeAllConnections() is called? Modern JavaScript (ES2024) solves this with explicit resource management.

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";

// Transport is automatically disposed when it goes out of scope
await using transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: {
    user: "your-email@gmail.com",
    pass: "your-app-password",
  },
});

const message = createMessage({
  from: "your-email@gmail.com",
  to: "recipient@example.com",
  subject: "Hello!",
  content: { text: "This email was sent with automatic cleanup!" },
});

await transport.send(message);
// No need to call `closeAllConnections()` - it happens automatically!

The await using keyword tells JavaScript to call the transport's cleanup method when execution leaves this scope—even if an error is thrown. This pattern is similar to Python's with statement or C#'s using block. It's supported in Node.js 22+, Deno, and Bun.

What if your environment doesn't support await using?

For older Node.js versions or environments without ES2024 support, use try/finally to ensure cleanup:

const transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: { user: "your-email@gmail.com", pass: "your-app-password" },
});

try {
  await transport.send(message);
} finally {
  await transport.closeAllConnections();
}

This achieves the same result—cleanup happens whether the send succeeds or throws an error.


Part 2: Adding attachments and rich content

Real-world emails often need more than plain text.

HTML emails with inline images

Inline images appear directly in the email body rather than as downloadable attachments. The trick is to reference them using a Content-ID (CID) URL scheme.

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
import { readFile } from "node:fs/promises";

await using transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: { user: "your-email@gmail.com", pass: "your-app-password" },
});

// Read your logo file
const logoContent = await readFile("./assets/logo.png");

const message = createMessage({
  from: "your-email@gmail.com",
  to: "customer@example.com",
  subject: "Your order confirmation",
  content: {
    html: `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <img src="cid:company-logo" alt="Company Logo" style="width: 150px;">
        <h1>Order Confirmed!</h1>
        <p>Thank you for your purchase. Your order #12345 has been confirmed.</p>
      </div>
    `,
    text: "Order Confirmed! Thank you for your purchase. Your order #12345 has been confirmed.",
  },
  attachments: [
    {
      filename: "logo.png",
      content: logoContent,
      contentType: "image/png",
      contentId: "company-logo", // Referenced as cid:company-logo in HTML
      inline: true,
    },
  ],
});

await transport.send(message);

Key points about inline images:

  • contentId: This is the identifier you use in the HTML's src="cid:..." attribute. It can be any unique string.
  • inline: true: This tells the email client to display the image within the message body, not as a separate attachment.
  • Always include alt text: Some email clients block images by default, so the alt text ensures your message is still understandable.

File attachments

For regular attachments that recipients can download, use the standard File API. This approach works across all JavaScript runtimes.

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
import { readFile } from "node:fs/promises";

await using transport = new SmtpTransport({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: { user: "your-email@gmail.com", pass: "your-app-password" },
});

// Read files to attach
const invoicePdf = await readFile("./invoices/invoice-2024-001.pdf");
const reportXlsx = await readFile("./reports/monthly-report.xlsx");

const message = createMessage({
  from: "billing@yourcompany.com",
  to: "client@example.com",
  cc: "accounting@yourcompany.com",
  subject: "Invoice #2024-001",
  content: {
    text: "Please find your invoice and monthly report attached.",
  },
  attachments: [
    new File([invoicePdf], "invoice-2024-001.pdf", { type: "application/pdf" }),
    new File([reportXlsx], "monthly-report.xlsx", {
      type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    }),
  ],
  priority: "high", // Sets email priority headers
});

await transport.send(message);

A few notes on attachments:

  • MIME types matter: Setting the correct type helps email clients display the right icon and open the file with the appropriate application.
  • priority: "high": This sets the X-Priority header, which some email clients use to highlight important messages. Use it sparingly—overuse can trigger spam filters.

Multiple recipients with different roles

Email supports several recipient types, each with different visibility rules:

import { createMessage } from "@upyo/core";

const message = createMessage({
  from: { name: "Support Team", address: "support@yourcompany.com" },
  to: [
    "primary-recipient@example.com",
    { name: "John Smith", address: "john@example.com" },
  ],
  cc: "manager@yourcompany.com",
  bcc: ["archive@yourcompany.com", "compliance@yourcompany.com"],
  replyTo: "no-reply@yourcompany.com",
  subject: "Your support ticket has been updated",
  content: { text: "We've responded to your ticket #5678." },
});

Understanding recipient types:

  • to: Primary recipients. Everyone can see who else is in this field.
  • cc (Carbon Copy): Secondary recipients. Visible to all recipients—use for people who should be informed but aren't the primary audience.
  • bcc (Blind Carbon Copy): Hidden recipients. No one can see BCC addresses—useful for archiving or compliance without revealing internal processes.
  • replyTo: Where replies should go. Useful when sending from a no-reply address but wanting responses to reach a real inbox.

You can specify addresses as simple strings ("email@example.com") or as objects with name and address properties for display names.


Part 3: Moving to production with email service providers

Gmail SMTP is great for getting started, but for production applications, you'll want a dedicated email service provider. Here's why:

  • Higher sending limits: Gmail caps you at ~500 emails/day for personal accounts
  • Better deliverability: Dedicated services maintain sender reputation and handle bounces properly
  • Analytics and tracking: See who opened your emails, clicked links, etc.
  • Webhook notifications: Get real-time callbacks for delivery events
  • No dependency on personal accounts: Production systems shouldn't rely on someone's Gmail

The best part? With Upyo, switching providers requires minimal code changes—just swap the transport.

Option A: Resend (modern and developer-friendly)

Resend is a newer email service with an excellent developer experience.

npm add @upyo/resend
import { createMessage } from "@upyo/core";
import { ResendTransport } from "@upyo/resend";

const transport = new ResendTransport({
  apiKey: process.env.RESEND_API_KEY!,
});

const message = createMessage({
  from: "hello@yourdomain.com", // Must be verified in Resend
  to: "user@example.com",
  subject: "Welcome aboard!",
  content: {
    text: "Thanks for joining us!",
    html: "<h1>Welcome!</h1><p>Thanks for joining us!</p>",
  },
  tags: ["onboarding", "welcome"], // For analytics
});

const receipt = await transport.send(message);

if (receipt.successful) {
  console.log("Sent via Resend:", receipt.messageId);
}

Notice how similar this looks to the SMTP example? The only differences are the import and the transport configuration. Your message creation and sending logic stays exactly the same—that's Upyo's transport abstraction at work.

Option B: SendGrid (enterprise-grade)

SendGrid is a popular choice for high-volume senders, offering advanced analytics, template management, and a generous free tier.

SendGrid is a popular choice for high-volume senders.

npm add @upyo/sendgrid
import { createMessage } from "@upyo/core";
import { SendGridTransport } from "@upyo/sendgrid";

const transport = new SendGridTransport({
  apiKey: process.env.SENDGRID_API_KEY!,
  clickTracking: true,
  openTracking: true,
});

const message = createMessage({
  from: "notifications@yourdomain.com",
  to: "user@example.com",
  subject: "Your weekly digest",
  content: {
    html: "<h1>This Week's Highlights</h1><p>Here's what you missed...</p>",
    text: "This Week's Highlights\n\nHere's what you missed...",
  },
  tags: ["digest", "weekly"],
});

await transport.send(message);

Option C: Mailgun (reliable workhorse)

Mailgun offers robust infrastructure with strong EU support—important if you need GDPR-compliant data residency.

npm add @upyo/mailgun
import { createMessage } from "@upyo/core";
import { MailgunTransport } from "@upyo/mailgun";

const transport = new MailgunTransport({
  apiKey: process.env.MAILGUN_API_KEY!,
  domain: "mg.yourdomain.com",
  region: "eu", // or "us"
});

const message = createMessage({
  from: "team@yourdomain.com",
  to: "user@example.com",
  subject: "Important update",
  content: { text: "We have some news to share..." },
});

await transport.send(message);

Option D: Amazon SES (cost-effective at scale)

Amazon SES is incredibly affordable—about $0.10 per 1,000 emails. If you're already in the AWS ecosystem, it integrates seamlessly with IAM, CloudWatch, and other services.

npm add @upyo/ses
import { createMessage } from "@upyo/core";
import { SesTransport } from "@upyo/ses";

const transport = new SesTransport({
  authentication: {
    type: "credentials",
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
  region: "us-east-1",
  configurationSetName: "my-config-set", // Optional: for tracking
});

const message = createMessage({
  from: "alerts@yourdomain.com",
  to: "admin@example.com",
  subject: "System alert",
  content: { text: "CPU usage exceeded 90%" },
  priority: "high",
});

await transport.send(message);

Part 4: Sending emails from edge functions

Here's where many email solutions fall short. Edge functions (Cloudflare Workers, Vercel Edge, Deno Deploy) run in a restricted environment—they can't open raw TCP connections, which means SMTP is not an option.

You must use an HTTP-based transport like Resend, SendGrid, Mailgun, or Amazon SES. The good news? Your code barely changes.

Cloudflare Workers example

// src/index.ts
import { createMessage } from "@upyo/core";
import { ResendTransport } from "@upyo/resend";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const transport = new ResendTransport({
      apiKey: env.RESEND_API_KEY,
    });

    const message = createMessage({
      from: "noreply@yourdomain.com",
      to: "user@example.com",
      subject: "Request received",
      content: { text: "We got your request and are processing it." },
    });

    const receipt = await transport.send(message);

    if (receipt.successful) {
      return new Response(`Email sent: ${receipt.messageId}`);
    } else {
      return new Response(`Failed: ${receipt.errorMessages.join(", ")}`, {
        status: 500,
      });
    }
  },
};

interface Env {
  RESEND_API_KEY: string;
}

Vercel Edge Functions example

// app/api/send-email/route.ts
import { createMessage } from "@upyo/core";
import { SendGridTransport } from "@upyo/sendgrid";

export const runtime = "edge";

export async function POST(request: Request) {
  const { to, subject, body } = await request.json();

  const transport = new SendGridTransport({
    apiKey: process.env.SENDGRID_API_KEY!,
  });

  const message = createMessage({
    from: "app@yourdomain.com",
    to,
    subject,
    content: { text: body },
  });

  const receipt = await transport.send(message);

  if (receipt.successful) {
    return Response.json({ success: true, messageId: receipt.messageId });
  } else {
    return Response.json(
      { success: false, errors: receipt.errorMessages },
      { status: 500 }
    );
  }
}

Deno Deploy example

// main.ts
import { createMessage } from "jsr:@upyo/core";
import { MailgunTransport } from "jsr:@upyo/mailgun";

Deno.serve(async (request: Request) => {
  if (request.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const { to, subject, body } = await request.json();

  const transport = new MailgunTransport({
    apiKey: Deno.env.get("MAILGUN_API_KEY")!,
    domain: Deno.env.get("MAILGUN_DOMAIN")!,
    region: "us",
  });

  const message = createMessage({
    from: "noreply@yourdomain.com",
    to,
    subject,
    content: { text: body },
  });

  const receipt = await transport.send(message);

  if (receipt.successful) {
    return Response.json({ success: true, messageId: receipt.messageId });
  } else {
    return Response.json(
      { success: false, errors: receipt.errorMessages },
      { status: 500 }
    );
  }
});

Part 5: Improving deliverability with DKIM

Ever wonder why some emails land in spam while others don't? Email authentication plays a huge role. DKIM (DomainKeys Identified Mail) is one of the key mechanisms—it lets you digitally sign your emails so recipients can verify they actually came from your domain and weren't tampered with in transit.

Without DKIM:

  • Your emails are more likely to be flagged as spam
  • Recipients have no way to verify you're really who you claim to be
  • Sophisticated phishing attacks can impersonate your domain

Setting up DKIM with Upyo

First, generate a DKIM key pair. You can use OpenSSL:

# Generate a 2048-bit RSA private key
openssl genrsa -out dkim-private.pem 2048

# Extract the public key
openssl rsa -in dkim-private.pem -pubout -out dkim-public.pem

Then configure your SMTP transport:

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
import { readFileSync } from "node:fs";

const transport = new SmtpTransport({
  host: "smtp.example.com",
  port: 587,
  secure: false,
  auth: {
    user: "user@yourdomain.com",
    pass: "password",
  },
  dkim: {
    signatures: [
      {
        signingDomain: "yourdomain.com",
        selector: "mail", // Creates DNS record at mail._domainkey.yourdomain.com
        privateKey: readFileSync("./dkim-private.pem", "utf8"),
        algorithm: "rsa-sha256", // or "ed25519-sha256" for shorter keys
      },
    ],
  },
});

The key configuration options:

  • signingDomain: Must match your email's "From" domain
  • selector: An arbitrary name that becomes part of your DNS record (e.g., mail creates a record at mail._domainkey.yourdomain.com)
  • algorithm: RSA-SHA256 is widely supported; Ed25519-SHA256 offers shorter keys (see below)

Adding the DNS record

Add a TXT record to your domain's DNS:

  • Name: mail._domainkey (or mail._domainkey.yourdomain.com depending on your DNS provider)
  • Value: v=DKIM1; k=rsa; p=YOUR_PUBLIC_KEY_HERE

Extract the public key value (remove headers, footers, and newlines from the .pem file):

cat dkim-public.pem | grep -v "^-" | tr -d '\n'

Using Ed25519 for shorter keys

RSA-2048 keys are long—about 400 characters for the public key. This can be problematic because DNS TXT records have size limits, and some DNS providers struggle with long records.

Ed25519 provides equivalent security with much shorter keys (around 44 characters). If your email infrastructure supports it, Ed25519 is the modern choice.

# Generate Ed25519 key pair
openssl genpkey -algorithm ed25519 -out dkim-ed25519-private.pem
openssl pkey -in dkim-ed25519-private.pem -pubout -out dkim-ed25519-public.pem
const transport = new SmtpTransport({
  // ... other config
  dkim: {
    signatures: [
      {
        signingDomain: "yourdomain.com",
        selector: "mail2025",
        privateKey: readFileSync("./dkim-ed25519-private.pem", "utf8"),
        algorithm: "ed25519-sha256",
      },
    ],
  },
});

Part 6: Bulk email sending

When you need to send emails to many recipients—newsletters, notifications, marketing campaigns—you have two approaches:

The wrong way: looping with send()

// ❌ Don't do this for bulk sending
for (const subscriber of subscribers) {
  await transport.send(createMessage({
    from: "newsletter@example.com",
    to: subscriber.email,
    subject: "Weekly update",
    content: { text: "..." },
  }));
}

This works, but it's inefficient:

  • Each send() call waits for the previous one to complete
  • No automatic batching or optimization
  • Harder to track overall progress

The right way: using sendMany()

The sendMany() method is designed for bulk operations:

import { createMessage } from "@upyo/core";
import { ResendTransport } from "@upyo/resend";

const transport = new ResendTransport({
  apiKey: process.env.RESEND_API_KEY!,
});

const subscribers = [
  { email: "alice@example.com", name: "Alice" },
  { email: "bob@example.com", name: "Bob" },
  { email: "charlie@example.com", name: "Charlie" },
  // ... potentially thousands more
];

// Create personalized messages
const messages = subscribers.map((subscriber) =>
  createMessage({
    from: "newsletter@yourdomain.com",
    to: subscriber.email,
    subject: "Your weekly digest",
    content: {
      html: `<h1>Hi ${subscriber.name}!</h1><p>Here's what's new this week...</p>`,
      text: `Hi ${subscriber.name}!\n\nHere's what's new this week...`,
    },
    tags: ["newsletter", "weekly"],
  })
);

// Send all messages efficiently
let successCount = 0;
let failureCount = 0;

for await (const receipt of transport.sendMany(messages)) {
  if (receipt.successful) {
    successCount++;
  } else {
    failureCount++;
    console.error("Failed:", receipt.errorMessages.join(", "));
  }
}

console.log(`Sent: ${successCount}, Failed: ${failureCount}`);

Why sendMany() is better:

  • Automatic batching: Some transports (like Resend) combine multiple messages into a single API call
  • Connection reuse: SMTP transport reuses connections from the pool
  • Streaming results: You get receipts as they complete, not all at once
  • Resilient: One failure doesn't stop the rest

Progress tracking for large batches

const totalMessages = messages.length;
let processed = 0;

for await (const receipt of transport.sendMany(messages)) {
  processed++;

  if (processed % 100 === 0) {
    console.log(`Progress: ${processed}/${totalMessages} (${Math.round((processed / totalMessages) * 100)}%)`);
  }

  if (!receipt.successful) {
    console.error(`Message ${processed} failed:`, receipt.errorMessages);
  }
}

console.log("Batch complete!");

When to use send() vs sendMany()

Scenario Use
Single transactional email (welcome, password reset) send()
A few emails (under 10) send() in a loop is fine
Newsletters, bulk notifications sendMany()
Batch processing from a queue sendMany()

Part 7: Testing without sending real emails

Upyo includes a MockTransport for testing:

  • No external dependencies: Tests run offline, in CI, anywhere
  • Deterministic: No flaky tests due to network issues
  • Fast: No HTTP requests or SMTP handshakes
  • Inspectable: You can verify exactly what would have been sent

Basic testing setup

import { createMessage } from "@upyo/core";
import { MockTransport } from "@upyo/mock";
import assert from "node:assert";
import { describe, it, beforeEach } from "node:test";

describe("Email functionality", () => {
  let transport: MockTransport;

  beforeEach(() => {
    transport = new MockTransport();
  });

  it("should send welcome email after registration", async () => {
    // Your application code would call this
    const message = createMessage({
      from: "welcome@yourapp.com",
      to: "newuser@example.com",
      subject: "Welcome to our app!",
      content: { text: "Thanks for signing up!" },
    });

    const receipt = await transport.send(message);

    // Assertions
    assert.strictEqual(receipt.successful, true);
    assert.strictEqual(transport.getSentMessagesCount(), 1);

    const sentMessage = transport.getLastSentMessage();
    assert.strictEqual(sentMessage?.subject, "Welcome to our app!");
    assert.strictEqual(sentMessage?.recipients[0].address, "newuser@example.com");
  });

  it("should handle email failures gracefully", async () => {
    // Simulate a failure
    transport.setNextResponse({
      successful: false,
      errorMessages: ["Invalid recipient address"],
    });

    const message = createMessage({
      from: "test@yourapp.com",
      to: "invalid-email",
      subject: "Test",
      content: { text: "Test" },
    });

    const receipt = await transport.send(message);

    assert.strictEqual(receipt.successful, false);
    assert.ok(receipt.errorMessages.includes("Invalid recipient address"));
  });
});

The key testing methods:

  • getSentMessagesCount(): How many emails were “sent”
  • getLastSentMessage(): The most recent message
  • getSentMessages(): All messages as an array
  • setNextResponse(): Force the next send to succeed or fail with specific errors

Simulating real-world conditions

import { MockTransport } from "@upyo/mock";

// Simulate network delays
const slowTransport = new MockTransport({
  delay: 500, // 500ms delay per email
});

// Simulate random failures (10% failure rate)
const unreliableTransport = new MockTransport({
  failureRate: 0.1,
});

// Simulate variable latency
const realisticTransport = new MockTransport({
  randomDelayRange: { min: 100, max: 500 },
});

Testing async email workflows

import { MockTransport } from "@upyo/mock";

const transport = new MockTransport();

// Start your async operation that sends emails
startUserRegistration("newuser@example.com");

// Wait for the expected emails to be sent
await transport.waitForMessageCount(2, 5000); // Wait for 2 emails, 5s timeout

// Or wait for a specific email
const welcomeEmail = await transport.waitForMessage(
  (msg) => msg.subject.includes("Welcome"),
  3000
);

console.log("Welcome email was sent:", welcomeEmail.subject);

Part 8: Provider failover with PoolTransport

What happens if your email provider goes down? For mission-critical applications, you need redundancy. PoolTransport combines multiple providers with automatic failover—if one fails, it tries the next.

import { PoolTransport } from "@upyo/pool";
import { ResendTransport } from "@upyo/resend";
import { SendGridTransport } from "@upyo/sendgrid";
import { MailgunTransport } from "@upyo/mailgun";
import { createMessage } from "@upyo/core";

// Create multiple transports
const resend = new ResendTransport({ apiKey: process.env.RESEND_API_KEY! });
const sendgrid = new SendGridTransport({ apiKey: process.env.SENDGRID_API_KEY! });
const mailgun = new MailgunTransport({
  apiKey: process.env.MAILGUN_API_KEY!,
  domain: "mg.yourdomain.com",
});

// Combine them with priority-based failover
const transport = new PoolTransport({
  strategy: "priority",
  transports: [
    { transport: resend, priority: 100 },    // Try first
    { transport: sendgrid, priority: 50 },   // Fallback
    { transport: mailgun, priority: 10 },    // Last resort
  ],
  maxRetries: 3,
});

const message = createMessage({
  from: "critical@yourdomain.com",
  to: "admin@example.com",
  subject: "Critical alert",
  content: { text: "This email will try multiple providers if needed." },
});

const receipt = await transport.send(message);
// Automatically tries Resend first, then SendGrid, then Mailgun if others fail

The priority values determine the order—higher numbers are tried first. If Resend fails (network error, rate limit, etc.), the pool automatically retries with SendGrid, then Mailgun.

For more advanced routing strategies (weighted distribution, content-based routing), see the pool transport documentation.


Part 9: Observability with OpenTelemetry

In production, you'll want to track email metrics: send rates, failure rates, latency. Upyo integrates with OpenTelemetry:

import { createOpenTelemetryTransport } from "@upyo/opentelemetry";
import { SmtpTransport } from "@upyo/smtp";

const baseTransport = new SmtpTransport({
  host: "smtp.example.com",
  port: 587,
  auth: { user: "user", pass: "password" },
});

const transport = createOpenTelemetryTransport(baseTransport, {
  serviceName: "email-service",
  tracing: { enabled: true },
  metrics: { enabled: true },
});

// Now all email operations generate traces and metrics automatically
await transport.send(message);

This gives you:

  • Delivery success/failure rates
  • Send operation latency histograms
  • Error classification by type
  • Distributed tracing for debugging

See the OpenTelemetry documentation for details.


Quick reference: choosing the right transport

Scenario Recommended Transport
Development/testing Gmail SMTP or MockTransport
Small production app Resend or SendGrid
High volume (100k+/month) Amazon SES
Edge functions Resend, SendGrid, or Mailgun
Self-hosted infrastructure SMTP with DKIM
Mission-critical PoolTransport with failover
EU data residency Mailgun (EU region) or self-hosted

Wrapping up

This guide covered the most popular transports, but Upyo also supports:

  • JMAP: Modern email protocol (RFC 8620/8621) for JMAP-compatible servers like Fastmail and Stalwart
  • Plunk: Developer-friendly email service with self-hosting option

And you can always create a custom transport for any email service not yet supported.

Resources

  • 📚 Documentation
  • 📦 npm packages
  • 📦 JSR packages
  • 🐙 GitHub repository

Have questions or feedback? Feel free to open an issue.


What's been your biggest pain point when sending emails from JavaScript? Let me know in the comments—I'm curious what challenges others have run into.


Upyo (pronounced /oo-pyo/) comes from the Korean word 郵票, meaning “postage stamp.”

Read more →
2

洪 民憙 (Hong Minhee) shared the below article:

2025 Q4 Review

Jaeyeol Lee @kodingwarrior@hackers.pub

이번 분기는 그렇게 기대는 많이 하지는 않았는데, 이것저것 벌려놓은 일들은 있었어서 이거라도 수습은 해야겠다라는 마음가짐은 했던 것 같다. 벌려놓은 일이 많았던 만큼 역시나 업보를 청산하느라 정신이 없는 나날의 연속이었고, 그러면서도 나름 만족스러운 하루를 보내고 있었다. 그러면서도 여러가지 좋은 소식도 생겼다. 기대했던 것들을 상회하는 좋은 기회를 누리고 있어서 요즘은 매일마다 도파민이 넘치고 있는 삶을 살고 있다. 자세한 얘기는...... 후술하도록 하겠다.

Timeline

이번에도 업보청산하느라 정신없는 나날을 보내고 있었고, 그러면서도 중요한 이벤트도 몇 가지 일어났다.

  • 2025-10-06 (일본 IT 회사와의 첫 캐주얼 면담) : 지난 분기 마지막 쯤에 PyCon JP에서 알게된 좋은 인연을 통해서, 일본 소재의 IT 회사에서 원격으로 캐주얼 면담할 기회를 가질 수 있었다. 듀오링고로 겨우 체득한 어설픈 일본어 구사 실력, 읽고 쓰는 쪽으로는 문제가 없지만 듣고 말하는 쪽으로는 부족한 영어 실력이었기 때문에 여러모로 걱정은 되었는데 한국인 직원 분이 중간에 통역을 해주신 덕분에 나에 대해서 어필할 수도 있고, 일본의 IT 회사는 어떤 분위기인지 알아가는 유익한 시간을 보낼 수 있었다.
    • 이 시점까지만 해도..... 일본으로 꼭 가고 싶은 생각이 들었던 것 같다. 한 6개월 정도는 길게 보고 준비해야겠다는 생각을 했을 정도니까.
  • 2025-10-15 (구직 시작) : OSSCA 프로그램도 슬슬 끝나가고 있었던 시점이어서, 이력서도 완성하고 구직을 시동걸기 시작했다. 이력서는 평소에 했던 것처럼 typst로 깎았는데, 2 column 구조로 어떻게든 공백을 채웠던 것 같다. 이 때 당시까지만 해도, Fedify 프로젝트 기여할 때 NestJS 미들웨어를 개발했던 경험을 살려서 NodeJS 백엔드 개발자 혹은 풀스택 개발자 지원해야겠다는 마음가짐을 가지고 있었던 것 같다. 경력은 사실상 망하긴 했지만 외부활동을 나름? 열심히 했던 덕분인지 생각했던 것보다 채워넣을 수 있는 내용들이 많았다. 한 10명 정도 되는 분들한테 이력서 피드백을 요청했었는데, 전부 긍정적인 평가를 받았다. 이력서는 여기서 볼 수 있다.
  • 2025-10-25 (브라우저 스터디 시작) : Fedify 팀에서의 인연 (그리고, 누군가의 팬클럽 디스코드)으로 알게된 사람들과 밑바닥에서부터 만드는 웹 브라우저 라는 책으로 스터디를 시작했다. 다들 프로그래밍에 어느 정도는 자신있는 사람들이었어서, 매 챕터마다 연습문제를 모두 풀어오는 하드코어한 스터디였다. 풀스택 엔지니어로서의 근무를 희망하는 사람으로서 브라우저의 동작 원리를 제대로 이해하지 않으면 안되겠다는 생각이 들었기 때문에, 구직을 성공하기 전까지는 누구보다 진도를 앞서서 진행하고 매주마다 연습문제를 전부 다 풀고 Hackers Pub에도 풀이글을 꾸준히 게시했던 것 같다. 구직을 성공하기 전까지는.....
    • 어떻게든 16챕터까지 전부 풀어서 이걸로 나만의 스토리를 만들고 싶었던 욕심은 있지만, 2025년 12월 31일 기준, 챕터 5까지만 진도를 끝내고 멈춘 상태인 지금 당장의 나로서는 지금 내가 하고 있는 일에 충실해야겠다는 판단이 들어서 당분간은 미루고 있다.
    • 책 자체는 웹 개발을 공부한다기보다는 브라우저를 진짜로 밑바닥에서부터 만들면서 이해하는 교재이다보니 프론트엔드 개발 비슷한걸 기대한 사람들에게는 약간 당혹감을 느낄 수도 있다. 첫장부터 **"소켓프로그래밍"**으로 직접 HTTP 프로토콜로 요청을 쏘고 HTML 응답을 읽어들여서 화면에 GUI 툴킷으로 어떻게든 뿌리는 **"데스크톱 어플리케이션"**을 만드는 것이기 때문이다. 같이 스터디했던 분의 표현을 빌리자면, 사실상 게임엔진을 만드는 것이랑 유사하다.
    • 코드를 직접 따라치는 것 만으로도 벅차다고 느낄 수도 있긴 한데, 연습문제를 푸는 것도 굉장히 해볼만한 도전과제라고 생각하고 있다. "여기 있는 연습문제를 모두 풀 수 있는 사람이라면 어딜 가도 먹히지 않을까?" 라는 생각도 들곤 하는데, 나 말고도 연습문제를 푸는 스터디를 했던 사람이라면 모두 공감할 것이다.
  • 2025-11-01 (OSSCA 성과 발표회) : 길다면 길었고, 짧다면 짧았던 OSSCA 일정이 끝이 났다. Fedify 팀을 대표해서 OSSCA 기간 동안 어떤 기여를 했는지 발표하는 시간을 가졌다. 여기저기 UbuCon Korea/PyCon Korea/NeovimConf.live 등등에서 발표해본 경험은 있었기 때문에, 내가 발표하는게 맞겠다는 생각이 들어서 자진해서 지원했다. 다만..... OSSCA 성과 발표회에서는 대본만 열심히 준비했고, 대본을 안 보고 발표를 할 수도 있다는 가능성을 생각하지 못해서 만족스러운 수준으로 발표하지는 못했다. OSSCA를 중심으로 오픈소스 기여하는 사람들을 어셈블해서 Hackers Pub을 부흥시키겠다는 원대한 계획이 실현되지는 못해서 아쉬운 감은 없지 않다.
  • 2025-11-04 (미국 스타트업에서의 Work trial 시작) : OSSCA 성과발표회가 있고 얼마 지나지 않아서, 어느날 갑자기 미국의 어떤 스타트업 CTO분한테서 커피챗 관련으로 연락이 왔다. 당장 미국으로 갈 계획까지는 없었지만, 어떻게든 진짜 구직을 해야겠다고 생각하고 있던 입장이었어서 가벼운 마음으로 커피챗을 했던 것 같다. 그리고, 그 다음날 CEO도 같이 껴서 역시 가볍게 커피챗을 진행했는데 커피챗이 끝나자마자? 바로 Work Trial을 시작했다. 매일마다 도파민에 찌든 삶을 살게 된 것은 이때부터가 시작이었다...
  • ⭐️ 2025-11-15 (Work trial 수습 통과) : 2주도 안되는 Work Trial이 진행되는 동안, 정말 많은 일들이 있었던 것 같다. 뭔가 좀 부족하다 싶은 것들을 발견할때마다 PR을 올리긴 했었는데, 진행하게 된 지 거의 3~5일 만에 PR을 많이 올렸다면서 샤라웃을 받기도 했었고, 수습 7일차되는 시점에 외부 시스템을 연동하는 등의 큰 기능을 맡게 되기도 했다. 그리고 12일차에 Work trial 수습 통과했고 정식적인 멤버로 같이 할 수 있게 되었다. 당장은 미국으로 건너갈 조건이 되지 않아서 파트타임으로 일하고 있긴 하지만, 지금까지 받아왔던 대우, 지금까지 근무했던 것보다 훨씬 더 좋은 조건으로 일하고 있어서 너무 만족스럽게 살고 있다. 구직을 본격적으로 시작한지 한 달 만에 자리 잡았다는 것 자체도 사실은 안 믿긴다.
    • 회사에 대해서 자랑을 좀 하자면.......
      1. 최고의 복지는 동료라고 했던가? 누구보다 제품개발에 진심이고 배울 점이 많은 사람들과 함께 하고 있다. 소수 정예로 돌아가는 팀이긴 하지만, 소규모 팀에서만 일한 경험만 남는 것에 대한 불안이 오히려 줄어들었다.
      2. 어떻게 보면 당연하지만, 풀재택이 가능한, 근무조건이 굉장히 자유로운 회사이다. 명목상으로는 근무시간을 11:00 ~ 20:00 정도로 두고 주 40시간 기준으로 계약을 하긴 했지만, 프로젝트 일정에 지장이 생기지만 않는다면 자율적으로 일할 수 있는 회사이다. (물론, 나는 자발적으로 50시간 이상은 채워서 일하고 있다.)
        • 근무조건이 굉장히 자유로워서 두 달 혹은 세 달에 한번 정도는 일본에서 길게 워케이션하는 것도 한번 상상해볼 수 있는 처지가 되었다. 실제로, 11월 21일 ~ 24일에는 Fedify 팀과 같이 후쿠오카 여행을 하다가도 중간에 비는 시간에 회사 업무를 보기도 했었다.
      3. 회사 자체의 비즈니스가 마음에 든다. 교육이라는 분야에 꾸준히 관심은 있었고, 관련 도메인에서 일해본 적이 두-세차례 정도 있었다. 내가 좋아하는 도메인에서, 비전이 있는 사업을 하고 있기 때문에 더욱 몰입해서 일하게 되는 것 같다.
      4. 포괄이라는 개념이 없다. 페이는 시급 단위로 계산하기 때문에, 일을 한 만큼 더 받아갈 수 있다. 그래서, 무리하지 않는 선에서 최대한 땡겼더니 전 직장 대비 60%-75% 까지 더 끌어당겨서 받고 있다.
      5. AI 도구를 굉장히 적극적으로 쓰고 있는 회사이다. 교육 분야를 AI로 개척하는 사업을 하고 있기도 하지만, 그 과정에서 AI를 어떻게 활용할 수 있는지 어깨너머로 배워갈 수 있다. 개발생산성을 높이기 위한 AI 도구 도입도 굉장히 열려있는 편이고, 워크플로우 개선에 대해서도 진지하게 고민을 많이 하고 있는 동료들과 함께하고 있다보니 AI 도구를 더욱 잘 쓰게 되는 것 같다. AI 도구 활용에 있어서 시너지 효과를 낼 수 있는 환경에서 일하고 있다.
      6. 사실 기술 스택에 대해서 별로 호불호 의견이 있는 편도 아니기도 하고 내가 밥벌이 할 수 있으면 그걸로 그만이라는 인식이 없지는 않지만, 내가 원하는 기술 스택으로 일하고 있다. 크게 보면 Python(FastAPI) + React 조합으로 일하고 있는데, 최근에 여러가지 기술적인 도전을 하고 있어서, 필수불가결하게 새로운 스택도 점진적으로 도입하고 있다. 궁금하면? 커피챗으로 문의 😘
    • 어떻게 보면, 나는 운이 좋아서 좋은 조건의 회사에서 일할 수 있게 되었긴 하지만.... 2025년 12월 기준으로 국내에 좋은 일자리가 열려있지 않은 현재 기준으로는, 해외로 눈을 돌리는 것도 나쁘지 않은 선택지인 것 같다.
  • ⭐️ 2025-11-30 (VIMRC 2025 진행) : 2025년 7월 6일부터 시작했던 VIMRC 2025 진행을 무사히 끝냈다. 이번 VIMRC 2025 행사에서 라이트닝토크 발표를 지원해주셨던 오정민님/위성훈님/황정현님/이광효님/이승록님/이효승님께 정말 감사함을 느끼고 있다. 스티커 굿즈를 만들어 주신 어떤 프리랜서 분에게도 감사함을 느끼고 있다. 그리고, 네트워킹 세션을 같이 진행해 주셨던 김대현님한테도 신세를 많이 졌다. 재치있는 굿즈 스티커 디자인을 맡아주신 백송민님도, VIMRC 2025까지 같이 달려주신 다른 vim.kr 모더레이터 분들도 감사...
    • VIMRC 2025 행사는 라이트닝토크도 재밌는 컨텐츠가 많았지만, 네트워킹 시간도 굉장히 알찼다. 일단 내가 있었던 테이블 기준으로는 스플릿 키보드를 직접 커스터마이징해서 시연하는 분이 계셨는데, 현장에 있는 분들이 물개박수를 칠 정도. 김대현님이 있었던 테이블은 Neovim 플러그인 개발자 뿐만이 아니라 Neovim 코어에도 기여했던 분들이 계셨어서 그 쪽도 그 쪽 나름대로 재밌는 컨텐츠가 있었을 것으로 생각된다.
    • VIMRC 2025 공식 일정이 끝나고 난 다음에는 뒷풀이를 2차/3차까지 달렸는데, 9시 30분 카페가 문 닫는 시간까지 서로 인생 살아가는 얘기하다가 Vim 관련 얘기로 컨텐츠가 가득한 알찬 하루를 보낼 수 있었다. 내가 이런 모임을 주최할 수 있었다는 것 자체도 믿기지가 않는다.
    • 한편 아쉬운 부분도 많았다. 단체사진 찍어놓을걸...... 라이트닝토크 발표 현장 녹화도 떠놓을걸..... 행사 현장의 사진을 좀 더 많이 찍어놓을 걸......

크게 보자면 뭔가 좀 적은 것 같긴 한데, 좋은 일들이 가득한 나날이었다. 지금 당장은 회사 쪽 일에 전념하고 싶기도 하고, 향후에 근황 공유를 하더라도 좀 심심할 수 있겠다는 생각은 들고 있다. 한 4년 전 쯤, 리걸테크 분야 회사에서 일했던 것과 비슷한 심정을 느끼고 있는데, 지금 회사에 있는 매 순간순간이 성장하고 있다는 느낌이 들고 있고, 긍정적인 의미로 한계에 도전하게 되는 일들이 많다. 회사의 성장이 곧 나의 성장이라는 확신이 들게 하는 환경이고, 회사 일 자체가 정말 재밌기도 해서 스스로가 워커홀릭이 되어가는게 느껴진다.

참...... 인생 살다가도 모르는 일이다. 3년 내내 어디에다가 말하기도 어려운 힘든 나날을 보내기만 했는데, 문득 좋은 기회가 찾아와서 이렇게 행복하게 살아도 되는건가 싶은 생각을 늘상 하고 있다. 뭐, 지금은 즐겨야지 싶다.

지금 상태를 요약하자면....

  1. 지금까지 받아왔던 대우보다 훨씬 상회하는 조건의 좋은 회사에서 근무할 수 있게 되었다.
  2. 그 이후로 삶이 더더욱 윤택하게 되었고, 매일마다 행복하다는 생각을 요즘 들어서 하게 되었다.
  3. 매사를 조금이나마 긍정적으로 생각할 수 있게 되었다.

그리고, 어쩌다보니 좋은 기회가 생겨서 책을 쓰게 되었는데 조만간 소식을 공유할 수 있으면 좋겠다.

다음엔 뭘 해볼까?

말 그대로 오늘내일하는 삶을 살아왔기 때문에, 회고라는 것도 딱히 생각도 없었고, (있는 그대로 말하면 어지간하면 경악할만한) 열악한 환경에서 살아왔기 때문에 더 나은 미래를 가정한다는 것 자체가 성립이 되지 않았는데, "이제는 진짜 바뀔 수 있다" 라는 확신이 들고 있다. 거의 3-4년을 무기력하고 우울한 삶, 악순환의 고리 속에서 살아왔는데 이제는 좀 달라졌다.

해외 컨퍼런스에 좀 더 적극적으로 참여해보자

올해에 PyCon JP에 참여한 이후로, 해외 컨퍼런스에 좀 더 많이 참여해보고 싶은 생각이 들었다. PyCon JP에서도 좋은 인연들을 알아갈 수 있었고, 좋은 기회를 얻을뻔도 했었다. 심지어, FastAPI 메인테이너인 tiangolo님과도 같이 사진을 찍을 수 있었다. 이런 맛으로 해외 컨퍼런스에 참여하는 것일까? 올해에 참여한 해외 컨퍼런스는 PyCon JP 밖에 없었지만, 내년에는 홍콩에도 가보고 필리핀에도 가보고 PyCon JP는 당연히 참여를 하는거고, 특히 일본에서 열리는 VimConf는 이번에는 반드시 참가하고 말 것이라는 확고한 의지가 있다.

내년에는 그래도, 내가 생각하는 것을 자유롭게 표현할 수 있는 수준으로는 능숙해질 필요는 있어야겠어서 전화영어는 알아보고 있다. 지금 일하고 있는 회사가 현지 직원을 채용하고 있기도 해서, 이제는 영어로 회화하는건 피할 수가 없는 현실이 되었다. 그리고.... 돈 모아서 PyCon US 갈 생각도 이미 하고 있다.

좀 더 좋은 영향력을 행사할 수 있도록 하자

Python 생태계에 언젠가는 기여를 해야겠다고 생각만 해왔던 것 같다. 내가 애정을 가져왔고 지금도 애정을 가지는 언어 생태계에서 밥벌이를 시작한 만큼, 이제는 좀 기여할 수 있는 여지도 명분도 충분히 생겼다. 여러 생태계를 관찰하면서 느낀 점이 있는데, 개발 커뮤니티 생태계가 성숙해지려면 여러가지 요소가 필요하다. 그나마 직관적으로 와닿는 예시를 들자면.. (1) 좋은 일자리, (2) 보고 배울 수 있는 멘토, (3) 질적인 네트워킹, (4) 포용적인 분위기, (5) 상호간 영감을 주면서 시너지를 낼 수 있는 환경 정도 되겠다. 그렇지 않은 환경을 수차례 관찰을 해온 적이 있기 때문에 더욱 실감하고 있는 부분이기도 하다.

커뮤니티를 직접 빌드하는건 이미 벌려놓은게 있으니 지금 하는거라도 잘해야겠다 치더라도, 이미 이렇게 자리를 잡게 된 이상 (내가 하고 있는 일을 더욱 잘해서) Python 생태계의 양질의 일자리를 더 확보하는데 기여를 해야겠고, 어딜 내놔도 부끄러운 사람이 되지 않기 위해 더욱 분발해서 Python 생태계의 보고 배울 수 있는 멘토가 되고 싶고, 필요하다면 여기저기 발표도 해보고 싶다. 2026년 목표는 해외 Python 관련 행사에서 영어로 발표해본다? 정도 될 것 같다.

비즈니스적으로 기여할 수 있는 사람이 되도록 하자

개발자로서 기술만 잘한다고 해서 좋은 결과를 만들 수 있는 건 아니다. 주변 지인분들의 표현을 빌리자면, 개발자도 어떻게 보면 회사의 일원이다. 스스로는 회사의 일원으로서, 특히 어느 정도 연차가 있는 사람으로서, 회사의 비즈니스적인 성장에 기여할 의무는 어느 정도 있다고 생각하는 편이다.

현재 회사에서 일하면서 AI 도구를 활용하거나 워크플로우를 개선하는 과정을 통해, 기술이 어떻게 비즈니스 임팩트를 만드는지 배워가고 있다. 앞으로는 "이 기능이 사용자에게 어떤 가치를 줄까" "이 기술 선택이 비용과 시간 면에서 타당할까"라는 질문을 더 자주 던질 수 있는 엔지니어가 되고 싶다. 좋은 제품은 좋은 코드와 좋은 비즈니스 센스의 조화에서 나온다.

내실을 좀 더 다듬도록 하자

서른이 넘은지 시간이 어느 정도는 지난 지금 생각해보면, 스스로를 돌아볼 기회가 없었다. 그냥 말 그대로 미래라는게 없었다고 생각되었기 때문이다. 좋은 동료와 일하는 경험의 부재, 진지하게 제품 개발에 임할 수 있는 기회의 부재, 대외활동은 적극적으로 해오긴 했지만 결과적으로는 본업의 불만족으로 인한 우울감 때문에 일상을 제대로 유지하기도 어려웠다. 인생이 이따위인데 뭔 회고인가? 라는 꼬인 생각도 했었던 것 같다. 이제는 좀 확실하게 달라졌다.

진심박치기 모드일 때는 다르다고 확신은 하고 있었고, 이젠 진심박치기 모드일 수 있는 기회가 왔다. 해볼 수 있는 것들은 다 해보고 싶고, 본업에 충실한 나로서 제대로 인정받고 싶다. 본업으로 같이 일하고 싶은 동료로서, 사람 대 사람으로서 안심할 수 있는 사람으로서. 내가 인정하는 내가 인정받고 싶은 사람들과 함께 하는 시간이 많아지다보니, 사고의 흐름이 자연스럽게 변하게 된 것 같다.

이번엔 진짜 최선을 다해서 살아볼까싶다.

Read more →
14

strcpy도 사용 금지
------------------------------
- cURL 프로젝트가 기존에
strncpy()를 제거한 데 이어, 이제 *strcpy()도 코드베이스에서 완전히 금지* 함
-
strcpy()는 API가 단순하지만 *버퍼 크기 검증이 분리될 위험* 이 있어, 장기 유지보수 시 안전하지 않음
- 이를 대신해 *
curlx_strcopy()* 라는 새 함수가 도입되어, 대상 버퍼 크기와 문자열 …
------------------------------
https://news.hada.io/topic?id=25474&utm_source=googlechat&utm_medium=bot&utm_campaign=1834

2

2026년 병오년 새해를 맞아 식탁보 1.16.0 버전을 출시했습니다. 이번 버전에서는 폴더 마운트 기능, 그리고 백그라운드 비동기 다운로드를 구현하여 이전보다 최대 30~40% 이상 빨라진 환경 구축 속도를 달성했습니다.

코딩 AI 어시스턴트의 도움을 받아 계속해서 빠른 출시와 적극적인 기능 반영을 이어 나가도록 하겠습니다. 많은 공유와 후원을 계속 부탁드리겠습니다!

https://github.com/yourtablecloth/TableCloth/releases/tag/v1.16.0

5
5
3

VCS와 패키지 매니저가 통합되어야 한단 얘기를 했었는데, shadcn의 인기가 그런 방향에 대한 지지를 간접적으로 보여주고 있다. shadcn은 UI 라이브러리를 만들어봤자 어차피 고쳐쓰는 경우가 많아서 나왔다. 문제는 기존의 npm 패키지 같은 것들은 받는건 쉬운데 그다음에 고치는게 열불터지는 것이다.

  1. 조건에 맞는 소스 코드를 받는 것 2. 그걸 고치는 것, 에서 패키지 매니저는 1은 잘하는데 2를 불편하게 만든다. VCS는 1을 할수있는데, 약간 번거롭게 되어있고(git submodule을 생각해보라), 2는 당연히 쉽다. VCS에서 1을 개선해야한다.
4

오늘은 고등학교 때 만들다 말았던 라이브러리를 얼추 마무리 지었다. Claude랑 같이 스펙 문서 만들고, 이를 바탕으로 작업하도록 했다. 당시에 하라는 FAT 덤프 파싱은 안 하고 Python에서 편하게 파싱하고 싶어서 만들기 시작했던 라이브러리고 취업하게 되서 이후로 안 봤었는데, Claude Code 덕분에 이제는 보내줄(?) 수 있을 것 같다.

근데 당시에 파서 콤비네이터를 몰랐어서 그랬고 지금은 파서 콤비네이터를 쓸 것 같다. 그리고 오늘 작업하면서 보게 된 건데 비슷한(?) 느낌으로 construct[1] 라는 라이브러리의 존재도 알게 되었다.

이제 Python을 잘 안 쓰고, 원래 시작점이었던 포렌식도 안 하니까 쓸 일은 없겠지만 그래도 당시 2019년 Hacktoberfest 시기에 필드 추가 기여도 받아보고 좋은 기억의 라이브러리였다.

https://github.com/moreal/pystructs/


  1. https://github.com/construct/construct ↩︎

5

@jLEE Jaeyoung OpenCode가 특별히 Claude Code보다 좋은 점은 아직 크게 못 느낀 것 같아요. 아, LSP를 지원하다는 것 정도…? 근데 Claude Code도 최근에 LSP 지원이 들어왔다고 듣긴 해서 (LSP를 쓴다는 느낌을 받은 적은 없지만요), 이 부분은 좀 애매하네요. 다만 OpenCode가 오픈 소스인데다 확장성이 좋아서 그 부분을 기대하고 있어요. 아, 그리고 세션 중간에 모델(아예 다른 업체의 모델로도)을 바꿀 수 있는 것도 좋은 것 같습니다.

0
2
0

평소에는 거의 Claude Code만 쓰는데, 오늘은 일부러 OpenCode를 써봤다. OpenCode에서 Claude 4.5 Opus도 써 보고, Gemini 3 Pro도 써 보고, GPT-5.2도 써 봤다. 일단 “말귀”를 잘 알아 듣는다는 점에서는 Claude 4.5 Opus와 GPT-5.2가 괜찮았던 것 같고, Gemini 3 Pro는 여러 측면에서 내 기대와는 좀 다르게 돌아갔던 것 같다. 그리고 문서 작업을 시켜보면 알 수 있는데, Gemini 3 Pro는 글을 상대적으로 못 쓴다. 이래저래 Gemini 3 Pro는 앞으로도 안 쓰게 될 듯.

OpenCode 자체는 잘 만들었다고 느꼈다. Claude Max 플랜을 그대로 쓸 수 있어서, 당분간 Claude Code 대신 메인 에이전트로 사용해 볼 예정.

5

今年の10月から地域の市民向けシビックテック講座に参加してるのですが、そこでたまたま挙がったテーマが個人的にとても良く、だんだん魅了されています。
ちなみにテーマはを消滅危機言語である沖縄語(沖縄方言)を残したい」で、中学生の頃になりたかった言語学者の夢を大変久々に思い出したのでした。
講座修了後も継続して取り組みたいなと思い始めたので、その気持ちに従って図書館通いの日々が始まりました。
言語を扱うので、LLMを調べたり弄ったりする機会もできて、なんだか毎日楽しいよ。

3
0
3

Karpathy의 프로그래밍 관련 발언: "이렇게까지 뒤처진 느낌은 처음이다"
------------------------------
- Andrej Karpathy가 현재의 프로그래밍 환경에서 *자신이 크게 뒤처지고 있다고* 강하게 느낀다고 밝힘
- 프로그래머가 직접 작성하는 코드 비중은 줄어들고, 이미 존재하는 도구와 시스템을 *어떻게 연결하고 조합하느냐* 가 핵심 역량으로 이동 중
- 지난 1년간 등장한 도구들을 제대로 엮기만 해도 *생산성이…
------------------------------
https://news.hada.io/topic?id=25434&utm_source=googlechat&utm_medium=bot&utm_campaign=1834

1

Unity의 Mono 문제: 왜 당신의 C# 코드는 기대보다 느리게 실행되는가
------------------------------
- Unity가 사용하는 *Mono 런타임* 은 최신 .NET 대비 현저히 느린 실행 속도를 보이며, 동일한 C# 코드가 *최대 15배까지* 차이 나는 사례가 있음
- 실제 게임 코드에서 *Mono 기반 Unity 실행은 100초* , 동일 코드의 .NET 실행은 *38초* 로 측정되어, 디버깅과 테스트 효율에도 큰 영향을 줌
- *Release 모드…
------------------------------
https://news.hada.io/topic?id=25421&utm_source=googlechat&utm_medium=bot&utm_campaign=1834

1

洪 民憙 (Hong Minhee) shared the below article:

ActivityPub 서버에서 YouTube 추적 링크 방지하기

고남현 @gnh1201@hackers.pub

ActivityPub 서버에서 YouTube 추적 링크 방지하기

ActivityPub 서버에 공유되는 YouTube 링크는 종종 사용자들 사이에서 개인정보 보호 측면의 우려 사항으로 언급됩니다. 이는 공유된 URL에 포함된 si 파라미터나, YouTube 웹사이트 자체에 내장된 다양한 추적 기술 등 방문자를 추적하는 데 사용될 수 있는 여러 기술적 메커니즘 때문입니다.

현실적으로 볼 때, ActivityPub 프로토콜을 구현하는 프로젝트들이 이 문제에 대해 기본 제공 해결책을 제시할 가능성은 낮습니다. 이는 YouTube라는 특정 서비스에 국한된 문제가 아니라, 보다 광범위한 웹 추적 문제에 해당하기 때문입니다.

그럼에도 불구하고, 서버 관리자는 서버 차원에서 이러한 우려를 완화하기 위한 실질적인 조치를 취할 수 있습니다.

1. 대체 YouTube 프론트엔드 사용

YouTube로 직접 연결하는 대신, 개인정보 보호에 더 우호적인 대체 프론트엔드 사용을 권장할 수 있습니다.

  • DNT-YT (권장 — 이 시나리오를 위해 특별히 설계됨)
  • Piped
  • Invidious

이러한 프론트엔드들은 영상 접근성을 유지하면서도 추적을 줄이거나 제거하는 데 도움을 줍니다.

2. Nginx sub_filter를 사용한 링크 재작성

대체 프론트엔드를 설정한 이후에는, Nginx의 sub_filter 기능을 사용하여 YouTube 링크를 투명하게 재작성할 수 있습니다. 이를 통해 사용자가 원본 YouTube URL에 직접 접근하는 것을 방지하고, 대신 대체 프론트엔드를 통해 영상을 보도록 유도할 수 있습니다.

예시 설정은 다음과 같습니다.

sub_filter 'www.youtube.com/' 'dnt-yt.catswords.net/';
sub_filter 'youtube.com/' 'dnt-yt.catswords.net/';
sub_filter 'www.youtu.be/' 'dnt-yt.catswords.net/';
sub_filter 'youtu.be/' 'dnt-yt.catswords.net/';

결과

youtube.com 또는 youtu.be로 연결되는 링크가 일관되게 대체 프론트엔드 주소로 변경되는 것이 확인되면, 설정은 완료된 것입니다.

이 접근 방식을 실제로 적용한 사례는 아래 링크에서 확인할 수 있습니다.

https://catswords.social/@gnh1201/115801692643125819

Read more →
5

BGE-M3, MarkItDown, 그리고 마크다운 구조 파서를 이용해 시맨틱 청킹을 수행하고, 그 결과를 Parquet 파일에 저장하는 aipack 프레임워크의 첫 버전을 릴리스합니다. 모델과 데이터베이스에 종속되지 않는 중립적 상태를 유지하여 언제든 재사용할 수 있는 파일 포맷을 기반으로 RAG를 구현하고, MCP 서버까지 구동할 수 있도록 설계했습니다.

aipack의 지향점은 NPU나 GPU에 의존하지 않는 RAG를 구현함과 동시에, 향후 다양한 RAG 구조로 확장하기 용이한 환경을 만드는 데 방점이 찍혀 있습니다. "고품질의 Parquet 파일을 만들어낼 수 있다면 무엇이든 할 수 있다"는 전제 아래, 업계에서 흔히 쓰이는 RAG 파이프라인을 디커플링(Decoupling)해본 실험적 프로젝트입니다.

프로젝트에 대한 피드백과 후기, 평가를 공유해 주시면 감사하겠습니다. 또한, 지속 가능한 오픈소스 활동을 위해 후원을 더해 주신다면 큰 힘이 됩니다.

GitHub: https://github.com/rkttu/aipack

3
4
1

Upyo 만들 때도 상당히 바이브 코딩 농도가 높았는데, 지금 만들고 있는 LLM 기반 번역 라이브러리는 그보다도 더 바이크 코딩 농도를 높여서 작업하고 있다. 초기 세팅 및 인터페이스 선언 외에는 내가 직접 짠 코드가 거의 없을 정도. 그래도 어떻게든 돌아가는 게 만들어지는 걸 보니 LLM 성능도 많이 좋아진 것 같다. (코딩 에이전트들의 노하우도 깊어진 것도 한몫 하겠지만.)

@aburkaaburka 🫣 You're judging before you've tried, and that's the disservice. You're assuming your skills will dull if you use an agent, and you're treating that assumption as a conclusion. That's the mistake. Try it first. See what actually happens. Then adjust your thinking based on experience, not fear.

I'm using them for months now, and in no way is it dulling my skills. I haven't learned as much as a programmer in years personally.

2

If you are a programmer and an AI hold-out, and you have some time off during Christmas: gift yourself a 100 USD subscription to Claude Code and … try it. But really try it. Take a week if you can afford it and dive in. It will change your opinion on these tools.

1
1