@twasik4/pocket-service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # Pocket Service
2
+
3
+ Pocket service is a wrapper that provides you a variety of options for a easy, boiler plate free, typescript service.
4
+
5
+ Pocket service comes with the following features built in:
6
+
7
+ - Express API with a few built in endpoints
8
+ - Full type support via a builder pattern
9
+ - An optional mongo module
10
+ - An optional clickhouse module
11
+ - An optional redis module
12
+ - An optional redis worker module for consuming redis streams
13
+ - A way to add your own modules/startup hooks that run during the build phase
14
+
15
+ ## Creating a service
16
+
17
+ Creating a service is done with the service class. It will streamline the creation process and reduce boilerplate.
18
+
19
+ The following is the most basic service you can create.
20
+
21
+ ```ts
22
+ const service = new Service().build();
23
+ ```
24
+
25
+ This basic service will include an express server with a few [built in endpoints](#built-in-endpoints) as well as a built in winston instance for logging.
26
+
27
+ ### Built-in Endpoints
28
+
29
+ The following are the base routes available:
30
+
31
+ | Method | Route | Description |
32
+ | ------ | ------- | ------------------------------------------ |
33
+ | GET | / | OK Check |
34
+ | GET | /health | Used for health information on the service |
35
+
36
+ ### Modules
37
+
38
+ 1. withMongo()
39
+
40
+ A full MongoDB instance with a custom collection abstraction that offers full type support and auto completion.
41
+
42
+ 2. withRedis()
43
+
44
+ A full Redis instance, this has built in service registration.
45
+
46
+ 3. withClickhouse()
47
+
48
+ A WIP, this will be similar to the mongo abstraction and offer strong typing for queries.
49
+
50
+ 4. withWorkers()
51
+
52
+ This adds built in workers to consume Redis streams off the main loop.
53
+
54
+ The following additional routes will get added to express:
55
+
56
+ | Method | Route | Description |
57
+ | ------ | ------------------------------ | --------------------------------------------------- |
58
+ | GET | /workers | Used for worker health information |
59
+ | GET | /workers/:stream | Gets all messages from this services stream |
60
+ | POST | /workers/:stream/:id/ack | Force acknowledge a message in this services stream |
61
+ | GET | /workers/:stream/dlq | Gets all messages from this services DLQ stream |
62
+ | POST | /workers/:stream/dlq/:id/retry | Force retry a message in this services DLQ stream |
63
+
64
+ 5. withModules(customModules: CustomModule[])
65
+
66
+ Use this to register custom modules to the service.
67
+
68
+ ### Configurations
69
+
70
+ 1. withName()
71
+
72
+ Sets the name of the service, used primarily with service registration via Redis. Defaults to a random byte string.
73
+
74
+ ```ts
75
+ const service = new Service().withName("Service Name").build();
76
+ ```
77
+
78
+ 2. withPort()
79
+
80
+ Set the port of the express server. Defaults to 3100.
81
+
82
+ ```ts
83
+ const service = new Service().withPort(8111).build();
84
+ ```
85
+
86
+ Setting the port to the above would make the express instance listen on that port. So your root endpoint would be http://localhost:8111/ and display the following
87
+
88
+ ```ts
89
+ {
90
+ "isSuccess": true,
91
+ "message": "Service Name service is running",
92
+ "uptime": {
93
+ "days": 0,
94
+ "hours": 0,
95
+ "minutes": 0,
96
+ "seconds": 4
97
+ }
98
+ }
99
+ ```
100
+
101
+ 3. withUrl()
102
+
103
+ Sets the base url of the service. Useful if you use service registration via Redis.
104
+
105
+ ```ts
106
+ const service = new Service().withUrl("https://my-domain.com").build();
107
+ ```
108
+
109
+ 4. withExpressOptions()
110
+
111
+ Manually configure various express options like CORS, custom middleware, etc.
112
+
113
+ ```ts
114
+ const service = new Service()
115
+ .withExpressOptions({
116
+ asJson: true,
117
+ corsWhitelist: ["http://localhost:5173"],
118
+ credentials: true,
119
+ })
120
+ .build();
121
+ ```
122
+
123
+ The above code snippet would parse all incoming bodies with the express JSON parser as well as enforce CORS, whitelisting localhost on 5173 and pass credentials.
124
+
125
+ 5. addRouter()
126
+
127
+ Registers a custom router with express. This offers ways to set additional metadata and offers the ability to add validation via Zod schemas.
128
+
129
+ An example auth router could look like the following.
130
+
131
+ ```ts
132
+ const { router, routes, addRoute } = createMetaRouter();
133
+
134
+ addRoute(
135
+ {
136
+ fullPath: "/login",
137
+ method: "POST",
138
+ requireAuth: false,
139
+ bodyValidator: z.object({
140
+ email: z.email(),
141
+ password: z.string(),
142
+ }),
143
+ meta: {
144
+ description: "Login endpoint",
145
+ },
146
+ },
147
+ async (req, res) => {
148
+ // contents here
149
+ res
150
+ .status(200)
151
+ .json({ isSuccess: true, message: "Successfully logged in." });
152
+ },
153
+ );
154
+
155
+ addRoute(
156
+ {
157
+ fullPath: "/register",
158
+ method: "POST",
159
+ requireAuth: false,
160
+ bodyValidator: z.object({
161
+ email: z.email(),
162
+ password: z.string().min(6),
163
+ }),
164
+ meta: {
165
+ description: "Register endpoint",
166
+ },
167
+ },
168
+ async (req, res) => {
169
+ // contents here
170
+ res.status(200).json({ isSuccess: true, message: "Registered" });
171
+ },
172
+ );
173
+
174
+ addRoute(
175
+ {
176
+ fullPath: "/logout",
177
+ method: "POST",
178
+ requireAuth: true,
179
+ bodyValidator: z.object({
180
+ refreshToken: z.string(),
181
+ }),
182
+ meta: {
183
+ description: "Logout endpoint",
184
+ },
185
+ },
186
+ async (req, res) => {
187
+ // contents here
188
+ res.status(200).json({ isSuccess: true, message: "Logged out" });
189
+ },
190
+ );
191
+
192
+ addRoute(
193
+ {
194
+ fullPath: "/me",
195
+ method: "GET",
196
+ requireAuth: true,
197
+ meta: {
198
+ description: "Get current authenticated user info",
199
+ },
200
+ },
201
+ async (req, res) => {
202
+ // contents here
203
+ res.status(200).json({ isSuccess: true, message: "Me" });
204
+ },
205
+ );
206
+
207
+ addRoute(
208
+ {
209
+ fullPath: "/refresh",
210
+ method: "POST",
211
+ requireAuth: false,
212
+ meta: {
213
+ description: "Register endpoint",
214
+ },
215
+ },
216
+ async (req, res) => {
217
+ // contents here
218
+ res.json({ isSuccess: true, message: "Token refreshed" });
219
+ },
220
+ );
221
+
222
+ export { router as AuthRouter, routes as AuthRoutes };
223
+ ```
224
+
225
+ ### Methods
226
+
227
+ 1. shutdown()
228
+
229
+ This shuts down the service, gracefully stopping all modules and the service itself.
230
+
231
+ 2. build()
232
+
233
+ The build phase, this is where all modules get initialized and loaded as well as what starts up the express server.
234
+
235
+ 3. heartbeat()
236
+
237
+ A method to start an interval for service registration with Redis. This gets automatically called during the build phase if the Redis module has been loaded.
238
+
239
+ 4. register()
240
+
241
+ A method to immediately register the service with Redis.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@twasik4/pocket-service",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "type": "module",
7
+ "peerDependencies": {
8
+ "@clickhouse/client": "^1.12.1",
9
+ "cors": "^2.8.5",
10
+ "express": "^5.1.0",
11
+ "mongodb": "^6.20.0",
12
+ "redis": "^5.8.2",
13
+ "winston": "^3.17.0",
14
+ "winston-transport": "^4.9.0",
15
+ "zod": "^4.1.12"
16
+ },
17
+ "devDependencies": {
18
+ "@clickhouse/client": "^1.12.1",
19
+ "@types/cors": "^2.8.19",
20
+ "@types/express": "^5.0.3",
21
+ "@types/node": "^24.5.2",
22
+ "@types/winston": "^2.4.4",
23
+ "cors": "^2.8.5",
24
+ "express": "^5.1.0",
25
+ "mongodb": "^6.20.0",
26
+ "mongodb-memory-server": "^11.0.1",
27
+ "redis": "^5.8.2",
28
+ "vitest": "^3.2.4",
29
+ "winston": "^3.17.0",
30
+ "winston-transport": "^4.9.0",
31
+ "zod": "^4.1.12"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "dev": "tsup --watch",
36
+ "test": "vitest",
37
+ "test:watch": "vitest --watch"
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./workers";
@@ -0,0 +1,159 @@
1
+ import { createClient } from "redis";
2
+ import { parentPort, workerData } from "worker_threads";
3
+ import { RedisStreamEventHandler } from "./workers/stream-handler";
4
+
5
+ (async () => {
6
+ const { name, redisUrl, handlers, index, subscriptions } = workerData as {
7
+ name: string;
8
+ redisUrl: string;
9
+ handlers: Record<string, string>;
10
+ index: number;
11
+ subscriptions: string[];
12
+ };
13
+ const redis = createClient({ url: redisUrl });
14
+ await redis.connect();
15
+
16
+ parentPort?.postMessage({
17
+ level: "info",
18
+ message: `Connected to Redis at ${redisUrl}`,
19
+ });
20
+
21
+ const loadedHandlers = new Map();
22
+
23
+ for (const [_, filePath] of Object.entries(handlers)) {
24
+ const mod = await import(filePath);
25
+ if (mod.default?.type && mod.default?.execute) {
26
+ loadedHandlers.set(mod.default.type, mod.default);
27
+ }
28
+ }
29
+
30
+ parentPort?.postMessage({
31
+ level: "create",
32
+ loadedHandlers: Object.keys(loadedHandlers),
33
+ });
34
+
35
+ const stream = `${name}:events`;
36
+ const group = `${name}-group`;
37
+ const consumer = `${name}-worker-${index}`;
38
+ const dlqStream = `dlq:${stream}`;
39
+
40
+ for (const streamName of subscriptions) {
41
+ // TODO: infer from handlers
42
+ try {
43
+ await redis.xGroupCreate(streamName, group, "0", {
44
+ MKSTREAM: true,
45
+ });
46
+ } catch (error) {
47
+ if ((error as Error).message.includes("BUSYGROUP")) {
48
+ parentPort?.postMessage({
49
+ level: "warn",
50
+ message: `Group ${group} already exists for stream ${streamName}, skipping creation`,
51
+ });
52
+ } else {
53
+ parentPort?.postMessage({
54
+ level: "error",
55
+ message: `Error creating group: ${(error as Error).message}`,
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ while (true) {
62
+ const keys = subscriptions.map((s) => ({ key: s, id: ">" })); // TODO: infer from handlers
63
+ const res = await redis.xReadGroup(group, consumer, keys, {
64
+ COUNT: 10,
65
+ BLOCK: 5000,
66
+ });
67
+
68
+ if (!res) {
69
+ continue;
70
+ }
71
+
72
+ if (Array.isArray(res)) {
73
+ for (const { name: streamName, messages } of res as {
74
+ name: string;
75
+ messages: {
76
+ id: string;
77
+ message: { eventType: string };
78
+ }[];
79
+ }[]) {
80
+ for (const { id, message } of messages) {
81
+ const eventType = message.eventType;
82
+ const handler = loadedHandlers.get(
83
+ eventType,
84
+ ) as RedisStreamEventHandler;
85
+
86
+ if (!handler) {
87
+ parentPort?.postMessage({
88
+ level: "error",
89
+ message: `No handler found for event type: ${eventType}`,
90
+ });
91
+ await redis.xAck(stream, group, id);
92
+ continue;
93
+ }
94
+
95
+ const timeoutMs = 3000;
96
+ const maxRetries = 3;
97
+ let attempts = 0;
98
+
99
+ while (attempts < maxRetries) {
100
+ attempts++;
101
+ try {
102
+ const result = await Promise.race([
103
+ await handler.execute(message, {
104
+ emit: async (ev) => {
105
+ redis.xAdd(
106
+ `${ev.type.split(":")[0]}:events`,
107
+ "*",
108
+ ev as Record<string, any>,
109
+ );
110
+ },
111
+ log: {
112
+ info: (msg) =>
113
+ parentPort?.postMessage({ level: "info", message: msg }),
114
+ warn: (msg) =>
115
+ parentPort?.postMessage({ level: "warn", message: msg }),
116
+ error: (msg) =>
117
+ parentPort?.postMessage({ level: "error", message: msg }),
118
+ },
119
+ service: streamName,
120
+ }),
121
+ new Promise((resolve) =>
122
+ setTimeout(() => resolve("timeout"), timeoutMs),
123
+ ),
124
+ ]);
125
+
126
+ if (result === "timeout") {
127
+ throw new Error(
128
+ `Message processing timed out after ${timeoutMs}ms`,
129
+ );
130
+ }
131
+
132
+ await redis.xAck(group, group, id);
133
+ parentPort?.postMessage({
134
+ level: "info",
135
+ message: `Message ${id} processed successfully after ${attempts} attempts`,
136
+ });
137
+ return;
138
+ } catch (error) {
139
+ parentPort?.postMessage({
140
+ level: "error",
141
+ message: `Error processing message ${id}: ${
142
+ error instanceof Error ? error.message : error
143
+ }`,
144
+ });
145
+ if (attempts === maxRetries) {
146
+ await redis.xAdd(dlqStream, "*", {
147
+ originalId: id,
148
+ message: JSON.stringify(message),
149
+ error: error instanceof Error ? error.message : String(error),
150
+ timestamp: new Date().toISOString(),
151
+ });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ })();
@@ -0,0 +1,76 @@
1
+ import { createClient } from "@clickhouse/client";
2
+
3
+ export interface ClickhouseTable<TSchema extends Record<string, any>> {
4
+ name: string;
5
+ createSQL: string;
6
+ schema: () => TSchema;
7
+ }
8
+
9
+ export interface ClickhouseConfig<
10
+ TTables extends Record<string, ClickhouseTable<any>>
11
+ > {
12
+ url: string;
13
+ username?: string;
14
+ password?: string;
15
+ tables: TTables;
16
+ }
17
+
18
+ export function createTypedClickhouse<
19
+ TTables extends Record<string, ClickhouseTable<any>>
20
+ >(config: ClickhouseConfig<TTables>) {
21
+ const client = createClient({
22
+ host: config.url,
23
+ username: config.username,
24
+ password: config.password,
25
+ });
26
+
27
+ async function ensureTables() {
28
+ for (const { createSQL } of Object.values(config.tables || {})) {
29
+ await client.command({ query: createSQL });
30
+ }
31
+ }
32
+
33
+ const api = Object.fromEntries(
34
+ Object.entries(config.tables || {}).map(([key, table]) => {
35
+ type Row = ReturnType<typeof table.schema>;
36
+ type Filter = Partial<Row>;
37
+
38
+ const select = async (where?: Filter): Promise<Row[]> => {
39
+ let query = `SELECT * FROM ${table.name}`;
40
+ if (where && Object.keys(where).length > 0) {
41
+ const conditions = Object.keys(where)
42
+ .map(([k, value]) => {
43
+ if (typeof value === "string") return `${k} = '${value}'`;
44
+ return `${k} = ${value}`;
45
+ })
46
+ .join(" AND ");
47
+ query += ` WHERE ${conditions}`;
48
+ }
49
+ const res = await client.query({
50
+ query,
51
+ });
52
+ const rows = await res.json<{ data: any[] }>();
53
+ return rows.data as Row[];
54
+ };
55
+
56
+ const insert = async (rows: Row[]) => {
57
+ await client.insert({
58
+ table: table.name,
59
+ values: rows,
60
+ format: "JSONEachRow",
61
+ });
62
+ };
63
+
64
+ return [key, { select, insert }];
65
+ })
66
+ ) as {
67
+ [K in keyof TTables]: {
68
+ select(
69
+ where?: Partial<ReturnType<TTables[K]["schema"]>>
70
+ ): Promise<ReturnType<TTables[K]["schema"]>[]>;
71
+ insert(rows: ReturnType<TTables[K]["schema"]>[]): Promise<void>;
72
+ };
73
+ };
74
+
75
+ return { client, ensureTables, ...api };
76
+ }