Skip to content

Commit 2e3914f

Browse files
committed
feat: 🎸 Added a prtotype of the NodeApiServer class
1 parent c7a32e1 commit 2e3914f

File tree

1 file changed

+206
-0
lines changed

1 file changed

+206
-0
lines changed

‎src/node/api/index.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)