Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

extract server feat to hono from remix #6

Merged
merged 15 commits into from
Sep 25, 2024
4 changes: 2 additions & 2 deletions apps/api-hono/.env.local
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
APP_ENV=local
WEBAPP_URL=http://localhost:3000
WEBAPP_URL=http://localhost:5173

# POSTGRES
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb

# `openssl rand -base64 32`
SESSION_SECRET="your-secret"
SESSION_SECRET="4a65NUK/3bo1fYAMtO578fPo7lA90emCWrt0WwS3Ld4="

DATABASE_URL="postgresql://postgres:postgres@localhost:5455/mydb?schema=public"

Expand Down
32 changes: 28 additions & 4 deletions apps/api-hono/package.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,50 @@
{
"name": "api-hono",
"name": "@repo/api-hono",
"scripts": {
"dev": "dotenv -e .env.local -- tsx watch src/index.ts"
"dev": "run-p dev:* export-type",
"dev:server": "dotenv -e .env.local -- tsx watch src/index.ts",
"dev-email": "dotenv -e .env.local -- email dev --dir ./src/infrastructure/email/templates",
"export-type": "tsup",
"migrate": "dotenv -e .env.local -- prisma migrate dev",
"migrate:reset": "dotenv -e .env.local -- prisma migrate reset",
"prisma:studio": "dotenv -e .env.development -- prisma studio",
"seed": "dotenv -e .env.local -- tsx prisma/seed/index.ts",
"postinstall": "prisma generate",
"typecheck": "tsc"
},
"dependencies": {
"@google-cloud/error-reporting": "3.0.5",
"@google-cloud/logging": "11.2.0",
"@google-cloud/logging-winston": "6.0.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "2.3.0",
"@hono/node-server": "1.13.1",
"@hono/trpc-server": "0.3.2",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/instrumentation-http": "0.53.0",
"@opentelemetry/instrumentation-pino": "0.42.0",
"@opentelemetry/instrumentation-winston": "0.40.0",
"@opentelemetry/sdk-node": "0.53.0",
"@prisma/client": "5.19.1",
"@prisma/instrumentation": "5.19.1",
"@react-email/components": "0.0.25",
"@trpc/server": "11.0.0-rc.502",
"chalk": "5.3.0",
"date-fns": "3.6.0",
"dotenv-cli": "7.4.2",
"hono": "4.6.3",
"hono-rate-limiter": "0.4.0",
"prisma": "5.19.1",
"react": "18.3.1",
"react-email": "3.0.1",
"tsx": "4.19.1",
"valibot": "0.41.0",
"winston": "3.14.2"
},
"devDependencies": {
"@types/node": "22.6.1"
"@types/node": "22.6.1",
"@types/react": "18.3.5",
"npm-run-all": "4.1.5",
"tsup": "8.3.0",
"typescript": "5.6.2",
"vitest": "2.0.5"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- CreateEnum
CREATE TYPE "VerificationType" AS ENUM ('EMAIL', 'PHONE');
CREATE TYPE "VerificationType" AS ENUM ('EMAIL_SIGN_UP', 'EMAIL_SIGN_IN');

-- CreateTable
CREATE TABLE "User" (
Expand All @@ -25,7 +25,7 @@ CREATE TABLE "Session" (
-- CreateTable
CREATE TABLE "Verification" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"type" "VerificationType" NOT NULL,
"to" TEXT NOT NULL,
"token" VARCHAR(128) NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
Expand All @@ -47,7 +47,7 @@ CREATE INDEX "Session_userId_idx" ON "Session"("userId");
CREATE UNIQUE INDEX "Verification_token_key" ON "Verification"("token");

-- CreateIndex
CREATE INDEX "Verification_to_token_idx" ON "Verification"("to", "token");
CREATE INDEX "Verification_to_token_type_idx" ON "Verification"("to", "token", "type");

-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,21 @@ model Session {
}

enum VerificationType {
EMAIL
PHONE
EMAIL_SIGN_UP
EMAIL_SIGN_IN
}

model Verification {
id String @id @default(cuid())
type String
id String @id @default(cuid())
type VerificationType
to String
token String @unique @db.VarChar(128)
token String @unique @db.VarChar(128)
expiresAt DateTime
usedAt DateTime?
attempt Int @default(0)
attempt Int @default(0)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([to, token])
@@index([to, token, type])
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ async function seed() {
name: '1',
},
})
await prisma.user.create({
data: {
email: '[email protected]',
},
})
}

seed()
30 changes: 25 additions & 5 deletions apps/api-hono/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { env } from '@/lib/env'
import { factory } from '@/lib/hono'
import { cookieEmailVerification } from '@/middlewares/cookie-email-verification'
import { cookieSession } from '@/middlewares/cookie-session'
import { generalRateLimit } from '@/middlewares/general-rate-limit'
import { httpRedirect } from '@/middlewares/http-redirect'
import { requestSpan } from '@/middlewares/request-span'
import { appRouter } from '@/trpc'
import { createContext } from '@/trpc/trpc'
import { serve } from '@hono/node-server'
import { trpcServer } from '@hono/trpc-server'
import { compress } from 'hono/compress'
import { cors } from 'hono/cors'
import { logger as requestLogger } from 'hono/logger'
import { requestId } from 'hono/request-id'
import { secureHeaders } from 'hono/secure-headers'
import { logger } from './lib/logger'
import { initOpenTelemetry } from './lib/open-telemetry'

initOpenTelemetry()

const newApp = () => {
Expand All @@ -22,23 +27,38 @@ const newApp = () => {
}),
)
app.use(httpRedirect)
app.use(cookieSession)
app.use(cookieEmailVerification)
app.use(requestId())
app.use(generalRateLimit)
app.use(cors())
app.use(
cors({
origin: env.WEBAPP_URL,
credentials: true,
}),
)
app.use(secureHeaders({ removePoweredBy: true }))
app.use(compress())

app.get('/', (c) => {
return c.text('Hello Hono!')
})

app.use(
'/api/trpc/*',
trpcServer({
endpoint: '/api/trpc',
router: appRouter,
createContext,
}),
)

return app
}

const app = newApp()
const port = 3033
logger.info(`Server is running on port ${port}`)
logger.info(`Server is running on port ${env.PORT}`)
serve({
fetch: app.fetch,
port,
port: env.PORT,
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateRandomURLString } from '@/.server/utils/auth'
import { env } from '@/lib/env'
import { generateRandomURLString } from '@/utils/auth'
import {
Body,
Button,
Expand All @@ -14,7 +15,7 @@ import {
Text,
} from '@react-email/components'

const baseUrl = 'http://localhost:3000'
const baseUrl = env.WEBAPP_URL
const serviceName = '@isoppp/remix-starter'

type Props = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateRandomURLString } from '@/.server/utils/auth'
import { env } from '@/lib/env'
import { generateRandomURLString } from '@/utils/auth'
import {
Body,
Button,
Expand All @@ -13,15 +14,14 @@ import {
Section,
Text,
} from '@react-email/components'
import type { FC } from 'react'

const baseUrl = 'http://localhost:3000'
const baseUrl = env.WEBAPP_URL
const serviceName = '@isoppp/remix-starter'

type Props = {
pathname: string
}
export const SignUpVerification: FC<Props> = ({ pathname = `/signup/${generateRandomURLString()}` }) => {
export const SignUpVerification = ({ pathname = `/signup/${generateRandomURLString()}` }: Props) => {
const href = baseUrl + pathname
return (
<Html>
Expand Down
87 changes: 87 additions & 0 deletions apps/api-hono/src/lib/crypt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import crypto from 'node:crypto'

// Encrypt using AES-256-GCM
function encrypt(text: string, secret: string): string {
const key = crypto.scryptSync(secret, 'salt', 32)
const iv = crypto.randomBytes(12) // 12 bytes IV is recommended for GCM mode
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
return `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`
}

// Decrypt using AES-256-GCM
function decrypt(text: string, secret: string): string {
if (!text || typeof text !== 'string') {
throw new TypeError('Invalid encrypted text provided')
}

const key = crypto.scryptSync(secret, 'salt', 32)
const parts = text.split(':')
if (parts.length !== 3) {
throw new TypeError('Invalid encrypted text format')
}

const [ivHex, encryptedHex, authTagHex] = parts
const iv = Buffer.from(ivHex, 'hex')
const encryptedText = Buffer.from(encryptedHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
return decrypted.toString('utf8')
}

/**
* Sign and encrypt a value
*
* @param val - The object to be encrypted
* @param secret - The secret key
* @return - The encrypted and signed string
*/
export function sign<T>(val: T, secret: string): string {
if (secret == null) throw new TypeError('Secret key must be provided.')

// Convert value to JSON string
const jsonVal = JSON.stringify(val)

// Encrypt the value
const encryptedVal = encrypt(jsonVal, secret)

// Generate signature
const signature = crypto.createHmac('sha256', secret).update(encryptedVal).digest('base64').replace(/=+$/, '')

return `${encryptedVal}.${signature}`
}

/**
* Verify signature and decrypt the value
*
* @param input - The encrypted and signed string
* @param secret - The secret key
* @return - The decrypted object if signature is valid, null otherwise
*/
export function unsign<T>(input: string, secret: string): T | null {
if (typeof input !== 'string') throw new TypeError('Signed string must be provided.')
if (secret == null) throw new TypeError('Secret key must be provided.')

const lastIndex = input.lastIndexOf('.')
const encryptedVal = input.slice(0, lastIndex)
const signature = input.slice(lastIndex + 1)

// Generate expected signature
const expectedSignature = crypto.createHmac('sha256', secret).update(encryptedVal).digest('base64').replace(/=+$/, '')

// Check if signatures match
if (signature !== expectedSignature) return null

const decryptedJson = decrypt(encryptedVal, secret)

try {
return JSON.parse(decryptedJson) as T
} catch {
return null
}
}
12 changes: 12 additions & 0 deletions apps/api-hono/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as v from 'valibot'

const envSchema = v.object({
APP_ENV: v.picklist(['local', 'test', 'development', 'staging', 'production']),
WEBAPP_URL: v.pipe(v.string(), v.minLength(1)),
SESSION_SECRET: v.pipe(v.string(), v.minLength(1)),
PORT: v.optional(v.number(), 3000),
})

const env = v.parse(envSchema, process.env)

export { env }
8 changes: 1 addition & 7 deletions apps/api-hono/src/lib/open-telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'
import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { PrismaInstrumentation } from '@prisma/instrumentation'
Expand All @@ -11,12 +10,7 @@ export const initOpenTelemetry = () => {
traceExporter: new TraceExporter({
keyFile: process.env.GOOGLE_APPLICATION_CREDENTIALS,
}),
instrumentations: [
new HttpInstrumentation(),
new PinoInstrumentation(),
new WinstonInstrumentation(),
new PrismaInstrumentation(),
],
instrumentations: [new HttpInstrumentation(), new WinstonInstrumentation(), new PrismaInstrumentation()],
})

sdk.start()
Expand Down
File renamed without changes.
Loading
Loading