This commit is contained in:
2026-02-16 22:03:40 +03:00
commit d632bf0f40
16 changed files with 772 additions and 0 deletions

10
src/config.ts Normal file
View File

@@ -0,0 +1,10 @@
const EXPIRE = 30 * 60 // 30min
export const config = {
accessSecret: process.env.JWT_SECRET ?? 'JWTAccessSecret',
refreshSecret: process.env.REFRESH_SECRET ?? 'JWTRefreshSecret',
accessTokenExpiry: `${EXPIRE}s`,
cookieExpiry: EXPIRE,
refreshTokenExpiry: '7d',
port: parseInt(process.env.PORT || '3000'),
}

9
src/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
export enum HttpStatusCodes {
OK = 200,
CREATED = 201,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
INTERNAL_SERVER_ERROR = 500,
}

95
src/grpc/client.ts Normal file
View File

@@ -0,0 +1,95 @@
import path from 'path'
import protoLoader from '@grpc/proto-loader'
import grpc from '@grpc/grpc-js'
interface Version {
id: number
version: string
}
interface VersionDto {}
interface GetUserByEmail {
email: string
}
export interface User {
id: number
email: string
token?: string
description?: string
is_admin?: boolean
}
interface ListChatDto {
page: number
user_ids?: number[]
chat_ids?: string[]
}
interface Chat {
id: string
type_id: number
users: User[]
}
interface Response<T> {
data: T
}
enum Services {
getVersion = 'getVersion',
createUser = 'createUser',
getUserByEmail = 'getUserByEmail',
listUser = 'listUser',
listChat = 'listChat',
}
class GrpcClient {
messageClient: any
constructor() {
console.log('Grpc Client init')
const PROTO_PATH = path.resolve('./proto/message.proto')
console.log('PROTO_PATH', PROTO_PATH)
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
})
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition)
const messageProto = protoDescriptor.message as any
this.messageClient = new messageProto.MessageService('localhost:8070', grpc.credentials.createInsecure())
}
private toPromise<T, R>(client: any, methodName: Services) {
return (request: T): Promise<Response<R>> => {
return new Promise((resolve, reject) => {
client[methodName](request, (error: Error, response: Response<R>) => {
if (error) reject(error.message)
else resolve(response)
})
})
}
}
getVersion(dto: VersionDto) {
return this.toPromise<VersionDto, Version>(this.messageClient, Services.getVersion)(dto)
}
getUserByEmail(dto: GetUserByEmail) {
return this.toPromise<GetUserByEmail, User>(this.messageClient, Services.getUserByEmail)(dto)
}
getChatsByUser(dto: ListChatDto) {
return this.toPromise<ListChatDto, Chat[]>(this.messageClient, Services.listChat)(dto)
}
}
export const grpcClient = new GrpcClient()

64
src/handles.ts Normal file
View File

@@ -0,0 +1,64 @@
import { HttpStatusCodes } from './constants.ts'
import type { LoginDto, WebSocketData } from './types/types.ts'
import { createAccessToken, verifyAccessToken } from './utils/jwt.ts'
import { grpcClient } from './grpc/client.ts'
import { config } from './config.ts'
export async function loginRequest(req: Request) {
try {
const body: LoginDto = await req.json()
const versionResponse = await grpcClient.getVersion({})
console.log(versionResponse.data)
const { email } = body
if (!email) return Response.json({ message: 'email required' }, { status: HttpStatusCodes.BAD_REQUEST })
const userResponse = await grpcClient.getUserByEmail({ email: 'vadim.olonin@gmail.com' })
const user = userResponse.data
if (!user) return Response.json({ message: 'Invalid email or password' }, { status: HttpStatusCodes.NOT_FOUND })
const accessToken = await createAccessToken(user.id, user.email)
const expires = new Date(Date.now() + config.cookieExpiry * 1000)
const sessionCookie = new Bun.Cookie('token', accessToken.token, {
path: '/',
expires: expires,
// maxAge: config.cookieExpiry,
httpOnly: true,
// secure: true,
sameSite: 'strict',
})
return Response.json(
{
accessToken: accessToken.token,
tokenType: 'Bearer',
expires: expires,
},
{ status: HttpStatusCodes.CREATED, headers: { 'Set-Cookie': sessionCookie.toString() } },
)
} catch (error) {
console.log({ error })
return Response.json({ message: 'Login failed' }, { status: HttpStatusCodes.BAD_REQUEST })
}
}
export async function wsRequest(req: Request, server: Bun.Server<WebSocketData>) {
try {
const cookieHeader = req.headers.get('cookie') ?? ''
const cookies = new Bun.CookieMap(cookieHeader)
const token = cookies.get('token')
if (!token) return Response.json({ message: 'Invalid token' }, { status: HttpStatusCodes.NOT_FOUND })
const payload = await verifyAccessToken(token)
const success = server.upgrade(req, { data: { userId: +payload.sub } })
if (success) return undefined
return new Response('Upgrade failed', { status: HttpStatusCodes.INTERNAL_SERVER_ERROR })
} catch (error) {
console.log(error)
return new Response('Upgrade failed', { status: HttpStatusCodes.INTERNAL_SERVER_ERROR })
}
}

80
src/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { parseJson } from './utils/utils.ts'
import { HttpStatusCodes } from './constants.ts'
import type { WebSocketData } from './types/types.ts'
import { loginRequest, wsRequest } from './handles.ts'
import { grpcClient } from './grpc/client.ts'
const GROUP = 'group'
const PORT = 3000
const server = Bun.serve({
port: PORT,
async fetch(req, server) {
const url = new URL(req.url)
const pathname = url.pathname
const method = req.method
if (pathname === '/login' && method === 'POST') return loginRequest(req)
if (pathname === '/ws') return wsRequest(req, server)
return new Response('Not found', { status: HttpStatusCodes.NOT_FOUND })
},
websocket: {
data: {} as WebSocketData,
async open(ws) {
try {
const ipAddr = ws.remoteAddress
console.log('ipAddr', ipAddr)
const chatResponse = await grpcClient.getChatsByUser({ page: 0, user_ids: [1] })
chatResponse.data.forEach((el) => ws.subscribe(el.id))
console.log('chats', chatResponse.data)
console.log('subscriptions', ws.subscriptions)
ws.send(JSON.stringify({ type: 'chats', ...chatResponse }))
} catch (error) {
console.log(error)
ws.close(1011, 'error')
}
// server.publish(GROUP, JSON.stringify({
// id: Bun.randomUUIDv7(),
// createdAt: new Date().toISOString(),
// username: ws.data.username,
// text: 'connected'
// }))
},
message(ws, message) {
console.log('Websocket message', message)
// const result = ws.send(message);
// if (typeof message === 'string') {
// const json = parseJson(message)
// server.publish(
// GROUP,
// JSON.stringify({
// id: Bun.randomUUIDv7(),
// createdAt: new Date().toISOString(),
// username: ws.data.username,
// text: json.text,
// }),
// )
// }
// ws.send(response.arrayBuffer()); // ArrayBuffer
// ws.send(new Uint8Array([1, 2, 3])); // TypedArray | DataView
},
close(ws, code, message) {
console.log('close', ws.data)
// ws.unsubscribe(GROUP)
// server.publish(GROUP, ws.data.username + ' has left')
},
drain(ws) {
console.log('Websocket drain', ws.data)
},
},
})
console.log(`Listening on ${server.hostname}:${server.port}`)

25
src/types/types.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface WebSocketData {
chatId?: string
token?: string
userId?: number
}
export type LoginDto = { email: string }
interface ChatData {
type: 'chat'
id: string
}
interface UserData {
type: 'user'
id: number
}
interface MessageData {
type: 'message'
id: number
text: string
}
type WsData = ChatData | UserData | MessageData

77
src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,77 @@
import { type JWTPayload, jwtVerify, SignJWT } from 'jose'
import { config } from '../config.ts'
import { generateUUID } from './utils.ts'
export interface TokenPayload extends JWTPayload {
sub: string // User ID (subject claim)
email: string // User email for convenience
type: 'access' | 'refresh' // Token type for validation
jti: string // Unique token ID for revocation
}
const accessSecret = new TextEncoder().encode(config.accessSecret)
const refreshSecret = new TextEncoder().encode(config.refreshSecret)
export async function createAccessToken(userId: number, email: string) {
const tokenId = generateUUID()
const token = await new SignJWT({
sub: userId.toString(),
email: email,
type: 'access',
jti: tokenId,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(config.accessTokenExpiry)
.setIssuer('bun-auth-service')
.setAudience('bun-api')
.sign(accessSecret)
return { tokenId, token }
}
export async function createRefreshToken(userId: string, email: string): Promise<{ token: string; tokenId: string }> {
const tokenId = generateUUID()
const token = await new SignJWT({
sub: userId,
email: email,
type: 'refresh',
jti: tokenId,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(config.refreshTokenExpiry) // Longer expiry
.setIssuer('bun-auth-service')
.setAudience('bun-api')
.sign(refreshSecret)
return { token, tokenId }
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, accessSecret, {
issuer: 'bun-auth-service',
audience: 'bun-api',
})
if (payload.type !== 'access') {
throw new Error('Invalid token type')
}
return payload as TokenPayload
}
export async function verifyRefreshToken(token: string) {
const { payload } = await jwtVerify(token, refreshSecret, {
issuer: 'bun-auth-service',
audience: 'bun-api',
})
if (payload.type !== 'refresh') {
throw new Error('Invalid token type')
}
return payload as TokenPayload
}

18
src/utils/utils.ts Normal file
View File

@@ -0,0 +1,18 @@
export function parseJson(str: string) {
try {
return JSON.parse(str)
} catch (e: any) {
console.error(e.message)
}
}
function json(data: object, status: number = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
export function generateUUID() {
return Bun.randomUUIDv7()
}