diff --git a/apps/api/package.json b/apps/api/package.json index 4c569142..981fd63a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,6 +34,7 @@ "test:unit": "vitest run" }, "dependencies": { + "@hono/node-server": "^1.14.1", "@hono/zod-validator": "^0.4.3", "@paralleldrive/cuid2": "^2.2.2", "@workos-inc/node": "^7.47.0", diff --git a/apps/api/src/api-v1/health-check.ts b/apps/api/src/api-v1/health-check.ts new file mode 100644 index 00000000..507689ab --- /dev/null +++ b/apps/api/src/api-v1/health-check.ts @@ -0,0 +1,5 @@ +import type { Context } from 'hono' + +export async function healthCheck(c: Context) { + return c.json({ status: 'ok' }) +} diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts new file mode 100644 index 00000000..5a0a1cf7 --- /dev/null +++ b/apps/api/src/api-v1/index.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono' + +import type { AuthenticatedEnv } from '@/lib/types' +import * as middleware from '@/lib/middleware' + +import { healthCheck } from './health-check' + +export const apiV1 = new Hono() + +const pub = new Hono() +const pri = new Hono() + +pub.get('/health', healthCheck) + +apiV1.route('', pub) +apiV1.use(middleware.authenticate) +apiV1.use(middleware.team) +apiV1.use(middleware.me) +apiV1.route('', pri) diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 25df2a02..c33c1386 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -7,7 +7,8 @@ export const envSchema = z.object({ .enum(['development', 'test', 'production']) .default('development'), DATABASE_URL: z.string().url(), - JWT_SECRET: z.string() + JWT_SECRET: z.string(), + PORT: z.number().default(3000) }) // eslint-disable-next-line no-process-env diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 686da14e..3201a874 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -1,11 +1,13 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status' + export class HttpError extends Error { - readonly statusCode: number + readonly statusCode: ContentfulStatusCode constructor({ statusCode = 500, message }: { - statusCode?: number + statusCode?: ContentfulStatusCode message: string }) { super(message) diff --git a/apps/api/src/lib/middleware/authenticate.ts b/apps/api/src/lib/middleware/authenticate.ts index 21d2edb2..5f2d0eb6 100644 --- a/apps/api/src/lib/middleware/authenticate.ts +++ b/apps/api/src/lib/middleware/authenticate.ts @@ -13,18 +13,19 @@ const jwtMiddleware = jwt({ }) export const authenticate = createMiddleware( - async (ctx, next) => { + async function authenticateMiddleware(ctx, next) { console.log(`[${ctx.req.method}] ${ctx.req.url}`) await jwtMiddleware(ctx, async () => { const payload = ctx.get('jwtPayload') - if (payload.type === 'user') { - const user = await db.query.users.findFirst({ - where: eq(schema.users.id, payload.userId) - }) - assert(user, 401, 'Unauthorized') - ctx.set('user', user) - } + assert(payload, 401, 'Unauthorized') + assert(payload.type === 'user', 401, 'Unauthorized') + + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, payload.userId) + }) + assert(user, 401, 'Unauthorized') + ctx.set('user', user) await next() }) diff --git a/apps/api/src/lib/middleware/error-handler.ts b/apps/api/src/lib/middleware/error-handler.ts new file mode 100644 index 00000000..63c6337d --- /dev/null +++ b/apps/api/src/lib/middleware/error-handler.ts @@ -0,0 +1,36 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import { createMiddleware } from 'hono/factory' +import { HTTPException } from 'hono/http-exception' + +import type { AuthenticatedEnv } from '@/lib/types' + +import { HttpError } from '../errors' + +export const errorHandler = createMiddleware( + async function errorHandlerMiddleware(ctx, next) { + try { + await next() + + if (!ctx.res.status) { + throw new HttpError({ statusCode: 404, message: 'Not Found' }) + } + } catch (err) { + let message = 'Internal Server Error' + let status: ContentfulStatusCode = 500 + + if (err instanceof HTTPException) { + message = err.message + status = err.status + } else if (err instanceof HttpError) { + message = err.message + status = err.statusCode + } + + if (status >= 500) { + console.error('http error', status, message) + } + + ctx.json({ error: message }, status) + } + } +) diff --git a/apps/api/src/lib/middleware/index.ts b/apps/api/src/lib/middleware/index.ts index 8fcf35c4..53d01efb 100644 --- a/apps/api/src/lib/middleware/index.ts +++ b/apps/api/src/lib/middleware/index.ts @@ -1,3 +1,5 @@ export * from './authenticate' +export * from './error-handler' export * from './me' +export * from './response-time' export * from './team' diff --git a/apps/api/src/lib/middleware/me.ts b/apps/api/src/lib/middleware/me.ts index a9ac1106..f352c9e9 100644 --- a/apps/api/src/lib/middleware/me.ts +++ b/apps/api/src/lib/middleware/me.ts @@ -2,13 +2,16 @@ import { createMiddleware } from 'hono/factory' import type { AuthenticatedEnv } from '@/lib/types' -export const me = createMiddleware(async (ctx, next) => { - const user = ctx.get('user') - const regex = /^\/(me)(\/|$)/ +export const me = createMiddleware( + async function meMiddleware(ctx, next) { + const user = ctx.get('user') + const regex = /^\/(me)(\/|$)/ - if (user && regex.test(ctx.req.path)) { - ctx.req.path = ctx.req.path.replace(regex, `/users/${user.id}$2`) + if (user && regex.test(ctx.req.path)) { + // TODO: redirect instead? + ctx.req.path = ctx.req.path.replace(regex, `/users/${user.id}$2`) + } + + await next() } - - await next() -}) +) diff --git a/apps/api/src/lib/middleware/response-time.ts b/apps/api/src/lib/middleware/response-time.ts new file mode 100644 index 00000000..5f2b4e03 --- /dev/null +++ b/apps/api/src/lib/middleware/response-time.ts @@ -0,0 +1,12 @@ +import { createMiddleware } from 'hono/factory' + +import type { AuthenticatedEnv } from '@/lib/types' + +export const responseTime = createMiddleware( + async function responseTimeMiddleware(ctx, next) { + const start = Date.now() + await next() + const duration = Date.now() - start + ctx.res.headers.set('X-Response-Time', `${duration}ms`) + } +) diff --git a/apps/api/src/lib/middleware/team.ts b/apps/api/src/lib/middleware/team.ts index 149678bf..5360542a 100644 --- a/apps/api/src/lib/middleware/team.ts +++ b/apps/api/src/lib/middleware/team.ts @@ -7,24 +7,26 @@ import { db, schema } from '@/db' import { aclTeamMember } from '../acl-team-member' import { assert } from '../utils' -export const team = createMiddleware(async (ctx, next) => { - const teamId = ctx.req.query('teamId') - const user = ctx.get('user') +export const team = createMiddleware( + async function teamMiddleware(ctx, next) { + const teamId = ctx.req.query('teamId') + const user = ctx.get('user') - if (teamId && user) { - const teamMember = await db.query.teamMembers.findFirst({ - where: and( - eq(schema.teamMembers.teamId, teamId), - eq(schema.teamMembers.userId, user.id) - ), - with: { team: true } - }) - assert(teamMember, 401, 'Unauthorized') + if (teamId && user) { + const teamMember = await db.query.teamMembers.findFirst({ + where: and( + eq(schema.teamMembers.teamId, teamId), + eq(schema.teamMembers.userId, user.id) + ), + with: { team: true } + }) + assert(teamMember, 401, 'Unauthorized') - await aclTeamMember(ctx, teamMember) + await aclTeamMember(ctx, teamMember) - ctx.set('teamMember', teamMember) + ctx.set('teamMember', teamMember) + } + + await next() } - - await next() -}) +) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f0629bf9..a96142d4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,16 +1,22 @@ +import { serve } from '@hono/node-server' import { Hono } from 'hono' +import { compress } from 'hono/compress' +import { cors } from 'hono/cors' +import { apiV1 } from '@/api-v1' +import { env } from '@/lib/env' import * as middleware from '@/lib/middleware' -import type { AuthenticatedEnv } from './lib/types' - export const app = new Hono() -const pub = new Hono() -const pri = new Hono() +app.use(compress()) +app.use(middleware.responseTime) +app.use(middleware.errorHandler) +app.use(cors()) -app.route('/', pub) -app.use('*', middleware.authenticate) -app.use('*', middleware.team) -app.use('*', middleware.me) -app.route('/', pri) +app.route('/v1', apiV1) + +serve({ + fetch: app.fetch, + port: env.PORT +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e9541c3..5bdb5dea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: apps/api: dependencies: + '@hono/node-server': + specifier: ^1.14.1 + version: 1.14.1(hono@4.7.7) '@hono/zod-validator': specifier: ^0.4.3 version: 0.4.3(hono@4.7.7)(zod@3.24.3) @@ -507,6 +510,12 @@ packages: prettier: '>= 3' typescript: '>= 5' + '@hono/node-server@1.14.1': + resolution: {integrity: sha512-vmbuM+HPinjWzPe7FFPWMMQMsbKE9gDPhaH0FFdqbGpkT5lp++tcWDTxwBl5EgS5y6JVgIaCdjeHRfQ4XRBRjQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/zod-validator@0.4.3': resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} peerDependencies: @@ -3151,6 +3160,10 @@ snapshots: - supports-color - vitest + '@hono/node-server@1.14.1(hono@4.7.7)': + dependencies: + hono: 4.7.7 + '@hono/zod-validator@0.4.3(hono@4.7.7)(zod@3.24.3)': dependencies: hono: 4.7.7