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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.idea/
.env

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": false,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 140,
"tabWidth": 2
}

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
# chat
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

110
bun.lock Normal file
View File

@@ -0,0 +1,110 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "chat",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"bcrypt": "^6.0.0",
"jose": "^6.1.3",
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "latest",
"prettier": "3.8.1",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
}
}

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "chat",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "latest",
"prettier": "3.8.1"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"bcrypt": "^6.0.0",
"jose": "^6.1.3"
}
}

199
proto/message.proto Normal file
View File

@@ -0,0 +1,199 @@
syntax = "proto3";
option go_package = "./";
package message;
import "google/protobuf/empty.proto";
service MessageService {
rpc CreateUser(CreateUserRequest) returns(CreateUserResponse);
rpc UpdateUser(UpdateUserRequest) returns(UpdateUserResponse);
rpc GetUserByToken(GetUserByTokenRequest) returns(GetUserResponse);
rpc GetUserByEmail(GetUserByEmailRequest) returns(GetUserResponse);
rpc ListUser(ListUserRequest) returns(ListUserResponse);
rpc CreateChat(CreateChatRequest) returns(CreateChatResponse);
rpc UpdateChat(UpdateChatRequest) returns(UpdateChatResponse);
rpc GetChat(GetChatRequest) returns(GetChatResponse);
rpc ListChat(ListChatRequest) returns(ListChatResponse);
rpc CreateMessage(CreateMessageRequest) returns(CreateMessageResponse);
rpc UpdateMessage(UpdateMessageRequest) returns(UpdateMessageResponse);
rpc GetMessage(GetMessageRequest) returns(GetMessageResponse);
rpc ListMessage(ListMessageRequest) returns(ListMessageResponse);
rpc GetVersion(google.protobuf.Empty) returns(GetVersionResponse);
}
// User
message CreateUserRequest {
string email = 1;
}
message CreateUserResponse {
User data = 1;
}
message UpdateUserRequest {
int32 id = 1;
optional string token = 2;
optional string description = 3;
}
message UpdateUserResponse {
User data = 1;
}
message GetUserByTokenRequest {
string token = 1;
}
message GetUserByEmailRequest {
string email = 1;
}
message GetUserResponse {
User data = 1;
}
message ListUserRequest {
int32 page = 1;
repeated int32 user_ids = 2;
}
message ListUserResponse {
repeated User data = 1;
}
message User {
int32 id = 1;
optional string token = 2;
string email = 3;
optional string description = 4;
}
message UserForChatResponse {
int32 id = 1;
optional string token = 2;
string email = 3;
optional string description = 4;
optional bool is_admin = 5;
}
// User end
// Chat
message CreateChatRequest {
repeated UserForChat users = 1;
// int32 type_id = 2;
}
message UserForChat {
int32 user_id = 1;
optional bool is_admin = 2;
}
message CreateChatResponse {
Chat data = 1;
}
message UpdateChatRequest {
string id = 1;
repeated UserForChat users = 2;
}
message UpdateChatResponse {
Chat data = 1;
}
message GetChatRequest {
string id = 1;
}
message GetChatResponse {
Chat data = 1;
}
message ListChatRequest {
int32 page = 1;
repeated int32 user_ids = 2;
repeated string chat_ids = 3;
}
message ListChatResponse {
repeated Chat data = 1;
}
message Chat {
string id = 1;
int32 type_id = 3;
repeated UserForChatResponse users = 5;
}
// Chat end
// Message
message CreateMessageRequest {
string chat_id = 2;
int32 user_id = 3;
optional string text = 4;
optional string image = 5;
optional string video = 6;
optional string file = 7;
}
message CreateMessageResponse {
Message data = 1;
}
message UpdateMessageRequest {
int32 id = 1;
string chat_id = 2;
optional string text = 3;
optional string image = 4;
optional string video = 5;
optional string file = 6;
}
message UpdateMessageResponse {
Message data = 1;
}
message GetMessageRequest {
int32 id = 1;
}
message GetMessageResponse {
Message data = 1;
}
message ListMessageRequest {
int32 page = 1;
string chat_id = 2;
}
message ListMessageResponse {
repeated Message data = 1;
}
message Message {
int32 id = 1;
int32 user_id = 2;
optional string text = 3;
optional string image = 4;
optional string video = 5;
optional string file = 6;
}
// Message end
// Version
message GetVersionResponse {
Version data = 1;
}
message Version {
int32 id = 1;
string version = 2;
}
// Version end

7
req.http Normal file
View File

@@ -0,0 +1,7 @@
### Send POST request with json body
POST http://localhost:3000/login
Content-Type: application/json
{
"login": "foobar"
}

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()
}

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"dom"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}