|
| 1 | +import { IncomingMessage, ServerResponse } from 'http'; |
| 2 | +import { z, ZodTypeAny } from 'zod'; |
| 3 | +import { ProcedureRouterRecord, initTRPC } from '@trpc/server'; |
| 4 | +import { |
| 5 | + CreateHTTPContextOptions, |
| 6 | + createHTTPServer, |
| 7 | +} from '@trpc/server/adapters/standalone'; |
| 8 | +import * as jwt from 'jsonwebtoken'; |
| 9 | +import { Storage } from '../../storage/index.js'; |
| 10 | +import { UserInputSchema, User, UsersDb } from './db.js'; |
| 11 | + |
| 12 | +export interface APIContext { |
| 13 | + login?: string; |
| 14 | + req: IncomingMessage; |
| 15 | + res: ServerResponse; |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * API server initialization options type |
| 20 | + */ |
| 21 | +export interface NodeApiServerOptions { |
| 22 | + /** Instance of storage used for persisting the state of the API server */ |
| 23 | + storage: Storage; |
| 24 | + /** Prefix used for the storage key to avoid potential key collisions */ |
| 25 | + prefix: string; |
| 26 | + /** NodeApiServer HTTP port */ |
| 27 | + port: number; |
| 28 | + /** Passwords hashing salt */ |
| 29 | + salt: string; |
| 30 | + /** App secret */ |
| 31 | + secret: string; |
| 32 | + /** Access token expiration time */ |
| 33 | + expire?: string | number; |
| 34 | +} |
| 35 | + |
| 36 | +export class NodeApiServer { |
| 37 | + /** Storage instance for persisting the state of the API server */ |
| 38 | + private storage: Storage; |
| 39 | + /** Specific key prefix for the storage key to avoid potential key collisions */ |
| 40 | + private prefix: string; |
| 41 | + /** NodeApiServer HTTP port */ |
| 42 | + private port: number; |
| 43 | + /** Users storage instance */ |
| 44 | + private users: UsersDb; |
| 45 | + /** App secret */ |
| 46 | + private secret: string; |
| 47 | + /** Access token expiration time */ |
| 48 | + expire: string | number; |
| 49 | + /** tRPC builder */ |
| 50 | + private trpc; |
| 51 | + /** HTTP server */ |
| 52 | + private server?: ReturnType<typeof createHTTPServer>; |
| 53 | + /** tRPC router records */ |
| 54 | + private routes: ProcedureRouterRecord; //Record<string, AnyProcedure>; |
| 55 | + |
| 56 | + /** |
| 57 | + * Creates an instance of NodeApiServerOptions. |
| 58 | + * |
| 59 | + * @param {NodeApiServerOptions} options |
| 60 | + * @memberof NodeApiServer |
| 61 | + */ |
| 62 | + constructor(options: NodeApiServerOptions) { |
| 63 | + const { storage, prefix, port, salt, secret, expire } = options; |
| 64 | + |
| 65 | + // @todo Validate NodeApiServerOptions |
| 66 | + |
| 67 | + this.prefix = `${prefix}_api_`; |
| 68 | + this.storage = storage; |
| 69 | + this.port = port; |
| 70 | + this.secret = secret; |
| 71 | + this.expire = expire ?? '1h'; |
| 72 | + this.users = new UsersDb({ storage, prefix, salt }); |
| 73 | + this.trpc = initTRPC.context<APIContext>().create(); |
| 74 | + this.routes = {}; |
| 75 | + this.userRegisterRoute(); |
| 76 | + this.userLoginRoute(); |
| 77 | + } |
| 78 | + |
| 79 | + private async updateAccessToken(user: User, res: ServerResponse) { |
| 80 | + const accessToken = jwt.sign({ login: user.login }, this.secret, { |
| 81 | + expiresIn: this.expire, |
| 82 | + }); |
| 83 | + |
| 84 | + await this.users.set({ |
| 85 | + ...user, |
| 86 | + jwt: accessToken, |
| 87 | + }); |
| 88 | + |
| 89 | + // Set ACCESS_TOKEN HTTP-only cookie |
| 90 | + res.setHeader('Set-Cookie', `jwt=${accessToken}; HttpOnly`); |
| 91 | + } |
| 92 | + |
| 93 | + private async createContext({ req, res }: CreateHTTPContextOptions) { |
| 94 | + const ctx: APIContext = { |
| 95 | + req, |
| 96 | + res, |
| 97 | + }; |
| 98 | + |
| 99 | + if (req.headers.authorization) { |
| 100 | + const { login } = jwt.verify( |
| 101 | + req.headers.authorization.split(' ')[1], |
| 102 | + this.secret, |
| 103 | + ) as APIContext; |
| 104 | + |
| 105 | + if (login) { |
| 106 | + const user = await this.users.get(login); |
| 107 | + |
| 108 | + if (user) { |
| 109 | + await this.updateAccessToken(user, ctx.res); |
| 110 | + ctx.login = login; |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + return ctx; |
| 116 | + } |
| 117 | + |
| 118 | + private userRegisterRoute() { |
| 119 | + this.addMutation( |
| 120 | + 'user.register', |
| 121 | + async ({ input }) => { |
| 122 | + const { login, password } = input as unknown as z.infer< |
| 123 | + typeof UserInputSchema |
| 124 | + >; |
| 125 | + await this.users.add(login, password); |
| 126 | + }, |
| 127 | + UserInputSchema, |
| 128 | + ); |
| 129 | + } |
| 130 | + |
| 131 | + private userLoginRoute() { |
| 132 | + this.addMutation( |
| 133 | + 'user.login', |
| 134 | + async ({ input, ctx }) => { |
| 135 | + const { login, password } = input as unknown as z.infer< |
| 136 | + typeof UserInputSchema |
| 137 | + >; |
| 138 | + |
| 139 | + const user = await this.users.get(login); |
| 140 | + |
| 141 | + if (user.login !== UsersDb.hashPassword(password, this.users.salt)) { |
| 142 | + throw new Error('Invalid login or password'); |
| 143 | + } |
| 144 | + |
| 145 | + await this.updateAccessToken(user, ctx.res); |
| 146 | + }, |
| 147 | + UserInputSchema, |
| 148 | + ); |
| 149 | + } |
| 150 | + |
| 151 | + addQuery( |
| 152 | + name: string, |
| 153 | + resolver: Parameters<typeof this.trpc.procedure.query>[0], |
| 154 | + inputSchema: ZodTypeAny, |
| 155 | + ) { |
| 156 | + this.routes = { |
| 157 | + ...this.routes, |
| 158 | + [name]: this.trpc.procedure.input(inputSchema).query(resolver), |
| 159 | + }; |
| 160 | + } |
| 161 | + |
| 162 | + addMutation( |
| 163 | + name: string, |
| 164 | + resolver: Parameters<typeof this.trpc.procedure.mutation>[0], |
| 165 | + inputSchema: ZodTypeAny, |
| 166 | + ) { |
| 167 | + this.routes = { |
| 168 | + ...this.routes, |
| 169 | + [name]: this.trpc.procedure.input(inputSchema).mutation(resolver), |
| 170 | + }; |
| 171 | + } |
| 172 | + |
| 173 | + addSubscription( |
| 174 | + name: string, |
| 175 | + resolver: Parameters<typeof this.trpc.procedure.subscription>[0], |
| 176 | + inputSchema: ZodTypeAny, |
| 177 | + ) { |
| 178 | + this.routes = { |
| 179 | + ...this.routes, |
| 180 | + [name]: this.trpc.procedure.input(inputSchema).subscription(resolver), |
| 181 | + }; |
| 182 | + } |
| 183 | + |
| 184 | + start() { |
| 185 | + this.server = createHTTPServer({ |
| 186 | + router: this.trpc.router(this.routes), |
| 187 | + createContext: this.createContext.bind(this), |
| 188 | + }); |
| 189 | + this.server.listen(this.port); |
| 190 | + } |
| 191 | + |
| 192 | + async stop() { |
| 193 | + await new Promise<void>((resolve, reject) => { |
| 194 | + if (this.server) { |
| 195 | + this.server.server.close((error) => { |
| 196 | + if (error) { |
| 197 | + return reject(error); |
| 198 | + } |
| 199 | + resolve(); |
| 200 | + }); |
| 201 | + } else { |
| 202 | + resolve(); |
| 203 | + } |
| 204 | + }); |
| 205 | + } |
| 206 | +} |
0 commit comments