Hono JS라는 걸 발견했는데 이게 꽤 쓸만해 보인다. 간결하고 있을 건 다 있다는 느낌이다. https://hono.dev/ 여기서 관리하고 있고 https://thevalleyofcode.com/hono 여기에서 스펙을 좀 더 쉽게 확인할 수 있다. middle ware, basic auth, compress, firebase auth 까지 지원을하고 logger, ssl도 자체 지원한다. Hono는 Bun or Deno와 사용하면 최고의 성능을 내는 것 같다. Nest js에 만족하고 있고, 극강의 퍼포먼스가 필요한 것은 아니라서 일단 관심만 두고 있다. Hono는 Node js도 지원하기 때문에 사이드 프로젝트가 있다면 흥미가 당긴다.
https://medium.com/deno-the-complete-reference/performance-of-hono-framework-with-deno-170ea929c49e
https://medium.com/deno-the-complete-reference/performance-of-hono-framework-with-bun-b6e592cc113a
https://github.com/SaltyAom/bun-http-framework-benchmark
https://hono.dev/docs/concepts/motivation
https://github.com/honojs/hono
Macmini m1에서 테스트 했고, 기본적인 가이드는 공식 사이트와 https://thevalleyofcode.com/hono 에서 확인 가능한데, 나는 내가 궁금한 것 위주로 테스트 했다.
Hono 프로젝트 생성
bun , nodejs, deno, 등 여러 플랫폼에서 실행할 수 있는 기본 프로젝트를 생성해준다.
dajkim76@Kims-Mac-mini ~/test % bun create hono hono_test
create-hono version 0.10.0
✔ Using target directory … hono_test
? Which template do you want to use? bun
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? bun
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd hono_test
dajkim76@Kims-Mac-mini ~/test % cd hono_test
dajkim76@Kims-Mac-mini ~/test/hono_test % cat package.json
{
"name": "hono_test",
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"hono": "^4.4.13"
},
"devDependencies": {
"@types/bun": "latest"
}
}%
dajkim76@Kims-Mac-mini ~/test/hono_test % bun dev
$ bun run --hot src/index.ts
Started server http://localhost:3000
bun으로 돌아가는 프로젝트로 생성된 기본 코드는
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
export default app
https (TLS) 지원
export default app을 아래로 바꾸면 tls 지원 끝
export default {
port: 3000,
fetch: app.fetch,
tls: {
cert: Bun.file("/Users/dajkim76/test/server.crt"),
key: Bun.file("/Users/dajkim76/test/server.key"),
},
}
compression (gzip 지원)
hono/compress가 bun에서는 아직 미지원이다.
(https://hono.dev/docs/middleware/builtin/compress
https://github.com/oven-sh/bun/issues/1723 )
https://www.npmjs.com/package/bun-compression 를 이용해서 gzip을 지원할 수 있을 듯..
bun add bun-compression
import { compress } from 'bun-compression'
const app = new Hono()
app.use("*", compress())
Postman으로 응답 해서 Content-Encoding gzip 확인
근데 테스트 결과.. bun-compression 미들웨어를 사용하면 httpCode 응답이 200으로 고정되는 문제가 있다. 404 not found 요청도 마찬가지. c.status(500) 이런게 클라이언트까지 전달되지 않았다. 이유는 모름.
https://github.com/oven-sh/bun/issues/1723#issuecomment-1774174194 이쪽 코드를 참고하여 해결했다.
import { compress } from 'hono/compress' 를 그대로 사용하면서, 미 구현된 CompressionStream과 DecompressionStream 을 핵으로 구현한다.
import { compress } from 'hono/compress'
// @bun
/*! MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
import zlib from 'node:zlib'
// fyi, Byte streams aren't really implemented anywhere yet
// It only exist as a issue: https://github.com/WICG/compression/issues/31
const make = (ctx, handle) => Object.assign(ctx, {
writable: new WritableStream({
write: chunk => handle.write(chunk),
close: () => handle.end()
}),
readable: new ReadableStream({
type: 'bytes',
start (ctrl) {
handle.on('data', chunk => ctrl.enqueue(chunk))
handle.once('end', () => ctrl.close())
}
})
})
globalThis.CompressionStream ??= class CompressionStream {
constructor(format) {
make(this, format === 'deflate' ? zlib.createDeflate() :
format === 'gzip' ? zlib.createGzip() : zlib.createDeflateRaw())
}
}
globalThis.DecompressionStream ??= class DecompressionStream {
constructor(format) {
make(this, format === 'deflate' ? zlib.createInflate() :
format === 'gzip' ? zlib.createGunzip() :
zlib.createInflateRaw())
}
}
const app = new Hono()
app.use(compress())
httpCode도 정상 동작하고 헤더에 gzip도 보인다. 요청시 gzip 인코딩도 될 것 같은데 테스트는 안 해봄.
Get Remote IP from HonoJS with Bun
의외로 이게 JS 런타임마다 일관적이지 않다. 뭐 당연하겠지만 그래서 프레임웍마다 구현 가이드가 다 다르고 친절하지도 않다.
https://bun.sh/blog/bun-v1.0.4#implement-server-requestip 여기를 참고하면 requestIP라는 함수가 제공되는 것을 알 수 있고, Bun 런타임에서의 request 객체를 파라미터로 받는다. 따라서 Hono 어딘가에 requestIP 함수가 있을 것이고 어딘가에 rawRequest 같은게 있어야 한다.
app.get('/', (c) => {
console.log(c);
console.log(c.req);
로 두 가지를 찾아보면
Context {
env: DebugHTTPSServer {
address: {
address: "::",
family: "IPv6",
port: 3000,
},
development: true,
fetch: [Function: fetch],
hostname: "localhost",
id: "[http]-tcp:localhost:3000",
pendingRequests: 1,
pendingWebSockets: 0,
port: 3000,
protocol: "https",
publish: [Function: publish],
ref: [Function: ref],
reload: [Function: reload],
requestIP: [Function: requestIP], <<<------------------------------- requestIP 함수
HonoRequest {
raw: Request (0 KB) { <<<-------------------------------- rawRequest 객체
method: "GET",
따라서 아래처럼 호출해보면
app.get('/', (c) => {
//console.log(c.req);
const ip = c.env.requestIP(c.req.raw);
console.log(ip);
console.log(ip.address);
return c.text('Hello Hono! :' + ip.address)
})
이렇게 응답함..
{
address: "::ffff:192.168.0.191",
family: "IPv6",
port: 57296,
}
::ffff:192.168.0.191
Logger
기본 logger, 별로 기능 없는
import { logger } from "hono/logger"
const app = new Hono()
app.use("*", logger())
실행 화면
<-- GET /hello
--> GET /hello 200 2ms
<-- GET /hello
--> GET /hello 200 2ms
https://github.com/honojs/hono/blob/main/src/middleware/logger/index.ts logger의 실제 구현부가 매우 간단하고. 날짜, IP, user-Agent 표시도 없고 당연히 file로 쓰는 옵션도 없다. 코드를 참고해서 직접 구현해야 한다..
custom-logger.ts
import { MiddlewareHandler } from "hono/types"
export const customLogger = (): MiddlewareHandler => {
return async function logger(c, next) {
const { method, path } = c.req
const userAgent = c.req.header('user-agent')
const ip = c.env.requestIP(c.req.raw).address
const datetime = new Date().toLocaleString()
await next()
console.log(datetime, method, path, c.res.status, ip, userAgent)
}
}
index.ts
import { customLogger } from './custom-logger';
app.use(customLogger()
동작확인
7/13/2024, 12:28:14 AM GET / 200 ::1 PostmanRuntime/7.37.3
7/13/2024, 12:28:16 AM GET / 200 ::1 PostmanRuntime/7.37.3
7/13/2024, 12:28:16 AM GET / 200 ::1 PostmanRuntime/7.37.3
7/13/2024, 12:28:20 AM GET / 200 ::ffff:192.168.0.191 Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36
파일로 쓰기 구현은 설계부터 하자. 당분간 리다이렉트로 땜빵..
기본 인증 basicAuth
import { basicAuth } from 'hono/basic-auth'
const app = new Hono()
app.use('/admin/*', basicAuth({
username: 'test1',
password: 'test1'
}));
app.get('/admin', (c) => {
//console.log(c.req);
return c.text('Logged in')
});
잘 동작 함
Rate limit
https://github.com/rhinobase/hono-rate-limiter
bun add hono-rate-limiter 로 6초에 10번의 요청시 에러 응답
import { rateLimiter } from "hono-rate-limiter";
const app = new Hono()
const limiter = rateLimiter({
windowMs: 6 * 1000,
limit: 10,
standardHeaders: "draft-6",
keyGenerator: (c) => {
const ip = c.env.requestIP(c.req.raw).address;
console.log("reqIP:"+ip);
return ip;
}, // Method to generate custom identifiers for clients.
});
app.use(limiter);
테스트 결과 Limit에 도달시 응답 body에 text로 나간다.
Too many requests, please try again later.
http 코드는 429가 내려온다. 하지만 타이밍이 좀 이상하다. 응답을 서버가 지연시키고 있는 듯한 느낌. https://github.com/rhinobase/hono-rate-limiter/blob/main/packages/core/src/core.ts. 코드를 보니 options.message 와 options.handler로 적당히 커스터마이징도 가능할 듯 하다.
message: async (c) => {
return {code: 429, message: "too many requests"};
},
이렇게 하거나 handler를 직접 넣어주면 휠씬 다양하게 대응 가능
handler: async (c, _, options) => {
// TODO: 이겨서 ip를 저장해 두었다가 1시간 동안 request 블럭시키는 것도 가능할 듯.
// 로그가 안 찍혀서 직접 찍는다. 근데 logger에서 왜 안 찍히지.
const { method, path } = c.req
const userAgent = c.req.header('user-agent')
const ip = c.env.requestIP(c.req.raw).address
const datetime = new Date().toLocaleString()
console.log(datetime, method, path, options.statusCode, ip, userAgent)
// Json으로 응답하자
return c.json({code: 429, message: "block too many requests"}, options.statusCode)
}
Error Handling
https://hono.dev/docs/api/exception
// 나만의 에러 정의..
class ErrorCodeException extends Error {
readonly code: number;
constructor(code: number, message: string) {
super(message)
this.code = code;
}
}
app.onError((err, c) => {
if (err instanceof ErrorCodeException) {
console.log(`ErrorCodeException: ${err.code} ${err.message}`);
return c.json({code: err.code, message: err.message}, 500);
}
if (err instanceof HTTPException) {
// Get the custom response
console.log(`HTTPException: ${err.message}`);
return c.json({code: err.status, message: err.message}, err.status);
}
//...
})
어딘가에서 에러를 던지면..
throw new ErrorCodeException(1234, `error remote ip: ${ip}`);
HttpCode 500에 json body로 응답한다.
{
"code": 1234,
"message": "error remote ip ::1"
}
HTTPException과 ErrorCodeException과 UnhandledException (이건 nodejs와 동일 할 듯) 처리하고 로그 남길 수 있다면 큰 문제 없을 듯. 중대한 오류일 때는 반드시 파일로 남기거나 Discord로 알리는 코드는 nestjs와 다르지 않을 듯..
Firebase IdToken Auth
https://github.com/honojs/middleware/tree/main/packages/firebase-auth
테스트 안 해봄. 되겠지 뭐..
Cron
가벼운 프레임웍이라서 cron job 실행은 지원하지 않는다. 별도의 process로 구현하던지 Linux cron을 이용하던지..
소감
전체적 느낌적으로 Elysia보다 나은 것 같다. node, deno에서도 동작하고 여러 플랫폼에서 다양하게 지원되는 가벼운 프레임웍이면서 커스터 마이징도 쉽다. bun과 사용되면 성능도 상위권이다. ( node를 사용한다면 NestJS with fastify 가 나을 수 있다. Elysia도 bun에서는 상위권 성능이지만 Hono와는 대동소이하다.) https://github.com/honojs/hono 에서 확인해보니 Github Star와 Fork 카운트가 Hono가 두 배 더 많다. 릴리스도 훨씬 더 많이 했다. 코드 기여자도 더 많다. 따라서 정성적, 정량적 모두에서 Hono에 호감이 더 간다.