From d632bf0f40dd76a0605dc0654184de880a7af987 Mon Sep 17 00:00:00 2001 From: Vadim Date: Mon, 16 Feb 2026 22:03:40 +0300 Subject: [PATCH] init --- .gitignore | 4 + .prettierrc | 7 ++ README.md | 15 ++++ bun.lock | 110 ++++++++++++++++++++++++ package.json | 20 +++++ proto/message.proto | 199 ++++++++++++++++++++++++++++++++++++++++++++ req.http | 7 ++ src/config.ts | 10 +++ src/constants.ts | 9 ++ src/grpc/client.ts | 95 +++++++++++++++++++++ src/handles.ts | 64 ++++++++++++++ src/index.ts | 80 ++++++++++++++++++ src/types/types.ts | 25 ++++++ src/utils/jwt.ts | 77 +++++++++++++++++ src/utils/utils.ts | 18 ++++ tsconfig.json | 32 +++++++ 16 files changed, 772 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 proto/message.proto create mode 100644 req.http create mode 100644 src/config.ts create mode 100644 src/constants.ts create mode 100644 src/grpc/client.ts create mode 100644 src/handles.ts create mode 100644 src/index.ts create mode 100644 src/types/types.ts create mode 100644 src/utils/jwt.ts create mode 100644 src/utils/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d3bfb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.idea/ +.env diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c810597 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 140, + "tabWidth": 2 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9bb509 --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..7c99120 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1dbf602 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/proto/message.proto b/proto/message.proto new file mode 100644 index 0000000..7d11b9d --- /dev/null +++ b/proto/message.proto @@ -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 \ No newline at end of file diff --git a/req.http b/req.http new file mode 100644 index 0000000..2e1b406 --- /dev/null +++ b/req.http @@ -0,0 +1,7 @@ +### Send POST request with json body +POST http://localhost:3000/login +Content-Type: application/json + +{ + "login": "foobar" +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c8c630a --- /dev/null +++ b/src/config.ts @@ -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'), +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..a8147ba --- /dev/null +++ b/src/constants.ts @@ -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, +} diff --git a/src/grpc/client.ts b/src/grpc/client.ts new file mode 100644 index 0000000..589a3f4 --- /dev/null +++ b/src/grpc/client.ts @@ -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 { + 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(client: any, methodName: Services) { + return (request: T): Promise> => { + return new Promise((resolve, reject) => { + client[methodName](request, (error: Error, response: Response) => { + if (error) reject(error.message) + else resolve(response) + }) + }) + } + } + + getVersion(dto: VersionDto) { + return this.toPromise(this.messageClient, Services.getVersion)(dto) + } + + getUserByEmail(dto: GetUserByEmail) { + return this.toPromise(this.messageClient, Services.getUserByEmail)(dto) + } + + getChatsByUser(dto: ListChatDto) { + return this.toPromise(this.messageClient, Services.listChat)(dto) + } +} + +export const grpcClient = new GrpcClient() diff --git a/src/handles.ts b/src/handles.ts new file mode 100644 index 0000000..990fa6d --- /dev/null +++ b/src/handles.ts @@ -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) { + 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 }) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b45b795 --- /dev/null +++ b/src/index.ts @@ -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}`) diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..fcebed8 --- /dev/null +++ b/src/types/types.ts @@ -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 diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..ae1edc5 --- /dev/null +++ b/src/utils/jwt.ts @@ -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 +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..96e275f --- /dev/null +++ b/src/utils/utils.ts @@ -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() +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..de9e359 --- /dev/null +++ b/tsconfig.json @@ -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 + } +}