init
This commit is contained in:
10
src/config.ts
Normal file
10
src/config.ts
Normal 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
9
src/constants.ts
Normal 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
95
src/grpc/client.ts
Normal 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
64
src/handles.ts
Normal 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
80
src/index.ts
Normal 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
25
src/types/types.ts
Normal 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
77
src/utils/jwt.ts
Normal 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
18
src/utils/utils.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user