@elysiumjs/core 0.1.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/src/module.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { Class } from 'type-fest';
16
+ import type { Route } from './http';
17
+
18
+ import { Elysia } from 'elysia';
19
+ import { assign } from 'radash';
20
+
21
+ import { applyMiddlewares } from './middleware';
22
+ import { nextTick, Symbols } from './utils';
23
+
24
+ /**
25
+ * Properties required when declaring a module using the `@module()` decorator.
26
+ * @author Axel Nana <axel.nana@workbud.com>
27
+ */
28
+ export type ModuleProps = {
29
+ /**
30
+ * The list of controllers provided by the module.
31
+ */
32
+ controllers?: Class<any>[];
33
+ };
34
+
35
+ /**
36
+ * Type for a module class.
37
+ * @author Axel Nana <axel.nana@workbud.com>
38
+ */
39
+ export type ModuleClass = Class<Module>;
40
+
41
+ /**
42
+ * Base class for all modules.
43
+ * @author Axel Nana <axel.nana@workbud.com>
44
+ *
45
+ * This class provides base features for modules, such as hooks and lifecycle methods.
46
+ */
47
+ export abstract class Module {
48
+ /**
49
+ * Marks a class as a module.
50
+ * @author Axel Nana <axel.nana@workbud.com>
51
+ * @param options The decorator options.
52
+ */
53
+ public static register(options: ModuleProps = {}) {
54
+ return function (target: ModuleClass) {
55
+ async function handleModule(m: Module): Promise<Elysia<Route>> {
56
+ // TODO: Use the logger service here
57
+ console.log(`Registering module ${target.name}`);
58
+ await nextTick();
59
+
60
+ const props = assign({ controllers: [] }, options) as Required<ModuleProps>;
61
+
62
+ const plugin: Elysia<Route> = new Elysia({ name: target.name });
63
+ plugin.decorate('module', m);
64
+
65
+ const middlewares = Reflect.getMetadata(Symbols.middlewares, target) ?? [];
66
+ applyMiddlewares(middlewares, plugin);
67
+
68
+ for (const controller of props.controllers) {
69
+ const app = Reflect.getMetadata(Symbols.elysiaPlugin, controller);
70
+ if (app === undefined) {
71
+ // TODO: Use the logger service here
72
+ console.error(
73
+ `Invalid controller class ${controller.name} registered in module ${target.name}. Ensure that you either used the @http(), or @websocket() decorators on the controller.`
74
+ );
75
+ process.exit(1);
76
+ } else {
77
+ // TODO: Add middlewares here
78
+ plugin.use(await app());
79
+ }
80
+ }
81
+
82
+ return plugin;
83
+ }
84
+
85
+ Reflect.defineMetadata(Symbols.elysiaPlugin, handleModule, target);
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Hooks that are executed before the module is registered.
91
+ */
92
+ public beforeRegister(): void {}
93
+
94
+ /**
95
+ * Hooks that are executed after the module is registered.
96
+ */
97
+ public afterRegister(): void {}
98
+ }
package/src/redis.ts ADDED
@@ -0,0 +1,407 @@
1
+ // Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { KeyvStoreAdapter } from 'cacheable';
16
+ import type { KeyvEntry, StoredData } from 'keyv';
17
+
18
+ import { EventEmitter } from 'node:events';
19
+
20
+ import { omit } from 'radash';
21
+
22
+ import { Event } from './event';
23
+ import { Service } from './service';
24
+
25
+ /**
26
+ * Properties used to create a new redis connection.
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ */
29
+ export type RedisConnectionProps = Bun.RedisOptions;
30
+
31
+ /**
32
+ * Keyv adapter for redis. This adapter is based on the Bun's Redis client.
33
+ * @author Axel Nana <axel.nana@workbud.com>
34
+ */
35
+ export class KeyvRedis extends EventEmitter implements KeyvStoreAdapter {
36
+ #client?: Bun.RedisClient;
37
+ #namespace: string | undefined;
38
+ #keyPrefixSeparator = '::';
39
+ #clearBatchSize = 1000;
40
+ #useUnlink = true;
41
+ #noNamespaceAffectsAll = false;
42
+
43
+ #connection: string;
44
+
45
+ constructor(props: {
46
+ /**
47
+ * The Redis connection to use.
48
+ */
49
+ connection: string;
50
+
51
+ /**
52
+ * Namespace for the connection.
53
+ */
54
+ namespace?: string;
55
+
56
+ /**
57
+ * Separator to use between namespace and key.
58
+ */
59
+ keyPrefixSeparator?: string;
60
+
61
+ /**
62
+ * Number of keys to delete in a single batch.
63
+ */
64
+ clearBatchSize?: number;
65
+
66
+ /**
67
+ * Enable Unlink instead of using Del for clearing keys. This is more performant but may not be supported by all Redis versions.
68
+ */
69
+ useUnlink?: boolean;
70
+
71
+ /**
72
+ * Whether to allow clearing all keys when no namespace is set.
73
+ * If set to true and no namespace is set, iterate() will return all keys.
74
+ * Defaults to `false`.
75
+ */
76
+ noNamespaceAffectsAll?: boolean;
77
+ }) {
78
+ super();
79
+
80
+ Event.once('elysium:app:launched', () => {
81
+ this.#client = Redis.getConnection(props.connection);
82
+ });
83
+
84
+ this.#connection = props.connection;
85
+ this.#namespace = props.namespace;
86
+ this.#keyPrefixSeparator = props.keyPrefixSeparator ?? '::';
87
+ this.#clearBatchSize = props.clearBatchSize ?? 1000;
88
+ this.#useUnlink = props.useUnlink ?? true;
89
+ this.#noNamespaceAffectsAll = props.noNamespaceAffectsAll ?? false;
90
+
91
+ this.on('error', (error) => {
92
+ Event.emit('elysium:error', error);
93
+ });
94
+ }
95
+
96
+ public get client() {
97
+ return this.#client!;
98
+ }
99
+
100
+ /**
101
+ * Get the options for the adapter.
102
+ */
103
+ public get opts() {
104
+ return {
105
+ namespace: this.#namespace,
106
+ keyPrefixSeparator: this.#keyPrefixSeparator,
107
+ clearBatchSize: this.#clearBatchSize,
108
+ noNamespaceAffectsAll: this.#noNamespaceAffectsAll,
109
+ dialect: 'redis',
110
+ url: this.#connection
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Gets the namespace for the adapter. If undefined, it will not use a namespace including keyPrefixing.
116
+ * @default undefined
117
+ */
118
+ public get namespace(): string | undefined {
119
+ return this.#namespace;
120
+ }
121
+
122
+ /**
123
+ * Sets the namespace for the adapter. If undefined, it will not use a namespace including keyPrefixing.
124
+ */
125
+ public set namespace(value: string | undefined) {
126
+ this.#namespace = value;
127
+ }
128
+
129
+ public async get<Value>(key: string): Promise<StoredData<Value> | undefined> {
130
+ key = this.createKeyPrefix(key, this.#namespace);
131
+ const value = await this.client.get(key);
132
+
133
+ return value === null ? undefined : (value as Value);
134
+ }
135
+
136
+ public async set(key: string, value: any, ttl?: number) {
137
+ key = this.createKeyPrefix(key, this.#namespace);
138
+
139
+ if (ttl) {
140
+ await this.client.set(key, value, 'PX', ttl);
141
+ } else {
142
+ await this.client.set(key, value);
143
+ }
144
+ }
145
+
146
+ public async setMany(values: Array<KeyvEntry>): Promise<void> {
147
+ // TODO: Use MULTI when available in Bun
148
+ const multi = [];
149
+
150
+ for (const { key, value, ttl } of values) {
151
+ const prefixedKey = this.createKeyPrefix(key, this.#namespace);
152
+ if (ttl) {
153
+ // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-argument
154
+ multi.push(this.client.set(prefixedKey, value, 'PX', ttl));
155
+ } else {
156
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
157
+ multi.push(this.client.set(prefixedKey, value));
158
+ }
159
+ }
160
+
161
+ await Promise.all(multi);
162
+ }
163
+
164
+ public async delete(key: string): Promise<boolean> {
165
+ key = this.createKeyPrefix(key, this.#namespace);
166
+ // TODO: Use UNLINK when available in Bun
167
+ const deleted = await (this.#useUnlink
168
+ ? this.client.send('UNLINK', [key])
169
+ : this.client.del(key));
170
+
171
+ return deleted > 0;
172
+ }
173
+
174
+ public async clear(): Promise<void> {
175
+ try {
176
+ if (!this.#namespace && this.#noNamespaceAffectsAll) {
177
+ await this.client.send('FLUSHDB', []);
178
+ return;
179
+ }
180
+
181
+ let cursor = '0';
182
+ const batchSize = this.#clearBatchSize;
183
+ const match = this.#namespace ? `${this.#namespace}${this.#keyPrefixSeparator}*` : '*';
184
+ const deletePromises = [];
185
+
186
+ do {
187
+ const result = await this.client.send('SCAN', [
188
+ cursor,
189
+ 'MATCH',
190
+ match,
191
+ 'COUNT',
192
+ batchSize.toString(),
193
+ 'TYPE',
194
+ 'string'
195
+ ]);
196
+
197
+ // Parse the result
198
+ let [newCursor, keys] = result;
199
+ cursor = newCursor.toString();
200
+
201
+ if (keys.length === 0) {
202
+ continue;
203
+ }
204
+
205
+ if (!this.#namespace) {
206
+ keys = keys.filter((key: string) => !key.includes(this.#keyPrefixSeparator));
207
+ }
208
+
209
+ deletePromises.push(keys.map((key: string) => this.client.del(key)));
210
+ } while (cursor !== '0');
211
+
212
+ await Promise.all(deletePromises);
213
+ } catch (error) {
214
+ this.emit('error', error);
215
+ }
216
+ }
217
+
218
+ public async has(key: string): Promise<boolean> {
219
+ key = this.createKeyPrefix(key, this.#namespace);
220
+ return this.client.exists(key);
221
+ }
222
+
223
+ public async hasMany(keys: string[]): Promise<boolean[]> {
224
+ const multi = [];
225
+
226
+ for (const key of keys) {
227
+ const prefixedKey = this.createKeyPrefix(key, this.#namespace);
228
+ multi.push(this.client.exists(prefixedKey));
229
+ }
230
+
231
+ return Promise.all(multi);
232
+ }
233
+
234
+ public async getMany<Value>(keys: string[]): Promise<Array<StoredData<Value | undefined>>> {
235
+ if (keys.length === 0) {
236
+ return [];
237
+ }
238
+
239
+ keys = keys.map((key) => this.createKeyPrefix(key, this.#namespace));
240
+ const values = await this.client.mget(...keys);
241
+
242
+ return values as Value[];
243
+ }
244
+
245
+ public async disconnect(): Promise<void> {
246
+ this.client.close();
247
+ }
248
+
249
+ public async deleteMany(keys: string[]): Promise<boolean> {
250
+ let result = false;
251
+
252
+ // TODO: Use MULTI when available in Bun
253
+ const multi = [];
254
+
255
+ for (const key of keys) {
256
+ const prefixedKey = this.createKeyPrefix(key, this.#namespace);
257
+ if (this.#useUnlink) {
258
+ multi.push(this.client.send('UNLINK', [prefixedKey]));
259
+ } else {
260
+ multi.push(this.client.del(prefixedKey));
261
+ }
262
+ }
263
+
264
+ const results = await Promise.all(multi);
265
+
266
+ for (const deleted of results) {
267
+ if (typeof deleted === 'number' && deleted > 0) {
268
+ result = true;
269
+ }
270
+ }
271
+
272
+ return result;
273
+ }
274
+
275
+ public async *iterator<Value>(
276
+ namespace?: string
277
+ ): AsyncGenerator<Array<string | Awaited<Value> | undefined>, void> {
278
+ const match = namespace ? `${namespace}${this.#keyPrefixSeparator}*` : '*';
279
+ let cursor = '0';
280
+
281
+ do {
282
+ const result = await this.client.send('SCAN', [cursor, 'MATCH', match, 'TYPE', 'string']);
283
+
284
+ // Parse the result
285
+ let [newCursor, keys] = result;
286
+ cursor = newCursor.toString();
287
+
288
+ if (!namespace && !this.#noNamespaceAffectsAll) {
289
+ keys = keys.filter((key: string) => !key.includes(this.#keyPrefixSeparator));
290
+ }
291
+
292
+ if (keys.length > 0) {
293
+ const values = await this.client.mget(...keys);
294
+ for (const i of keys.keys()) {
295
+ const key = this.getKeyWithoutPrefix(keys[i], namespace);
296
+ const value = values[i];
297
+ yield [key, value!];
298
+ }
299
+ }
300
+ } while (cursor !== '0');
301
+ }
302
+
303
+ /**
304
+ * Helper function to create a key with a namespace.
305
+ * @param key The key to prefix.
306
+ * @param namespace The namespace to prefix the key with.
307
+ * @returns The key with the namespace such as 'namespace::key'.
308
+ */
309
+ public createKeyPrefix(key: string, namespace?: string): string {
310
+ return !!namespace ? `${namespace}${this.#keyPrefixSeparator}${key}` : key;
311
+ }
312
+
313
+ /**
314
+ * Helper function to remove the namespace from a key.
315
+ * @param key The key to remove the namespace from.
316
+ * @param namespace The namespace to remove from the key.
317
+ * @returns The key without the namespace.
318
+ */
319
+ public getKeyWithoutPrefix(key: string, namespace?: string): string {
320
+ return !!namespace ? key.replace(`${namespace}${this.#keyPrefixSeparator}`, '') : key;
321
+ }
322
+ }
323
+
324
+ export namespace Redis {
325
+ /**
326
+ * Creates a service name for a redis connection.
327
+ * @author Axel Nana <axel.nana@workbud.com>
328
+ * @param name The name of the connection.
329
+ * @returns A service name for the connection.
330
+ */
331
+ const getConnectionName = (name: string) => {
332
+ return `redis.connection.${name}`;
333
+ };
334
+
335
+ /**
336
+ * Retrieves the client for a redis connection.
337
+ * @author Axel Nana <axel.nana@workbud.com>
338
+ * @param name The name of the connection.
339
+ * @returns The Bun.RedisClient for the connection with the given name.
340
+ */
341
+ export const getConnection = (name: string) => {
342
+ if (!Service.exists(getConnectionName(name))) {
343
+ // TODO: Use logger service here
344
+ console.error(
345
+ `No connection with name ${name} found. Please make sure to register the connection before using it.`
346
+ );
347
+ process.exit(1);
348
+ }
349
+
350
+ return Service.get<Bun.RedisClient>(getConnectionName(name))!;
351
+ };
352
+
353
+ /**
354
+ * Creates and registers a new redis connection.
355
+ * @author Axel Nana <axel.nana@workbud.com>
356
+ *
357
+ * This will make the registered connection available for dependency injection with
358
+ * the key `redis.connection.{name}`, where `{name}` is replaced with the given name.
359
+ *
360
+ * @param name The name of the connection.
361
+ * @param config The configuration for the redis connection.
362
+ * @returns The newly created and registered redis connection.
363
+ */
364
+ export const registerConnection = (name: string, config: Bun.RedisOptions) => {
365
+ if (Service.exists(getConnectionName(name))) {
366
+ // TODO: Use logger service here
367
+ console.error(`A connection with the name ${name} has already been registered.`);
368
+ process.exit(1);
369
+ }
370
+
371
+ return Service.instance(
372
+ getConnectionName(name),
373
+ new Bun.RedisClient(config.url, omit(config, ['url']))
374
+ );
375
+ };
376
+
377
+ /**
378
+ * Checks if a redis connection with the given name exists.
379
+ * @author Axel Nana <axel.nana@workbud.com>
380
+ * @param name The name of the connection to check.
381
+ * @returns `true` if the connection exists, `false` otherwise.
382
+ */
383
+ export const connectionExists = (name: string) => {
384
+ return Service.exists(getConnectionName(name));
385
+ };
386
+
387
+ /**
388
+ * Retrieves the default redis connection.
389
+ * @author Axel Nana <axel.nana@workbud.com>
390
+ * @returns The default redis connection.
391
+ */
392
+ export const getDefaultConnection = () => {
393
+ return getConnection('default');
394
+ };
395
+
396
+ /**
397
+ * Sets the default redis connection.
398
+ * @author Axel Nana <axel.nana@workbud.com>
399
+ * @param name The name of the connection to set as default.
400
+ * @returns The default redis connection.
401
+ */
402
+ export const setDefaultConnection = (name: string) => {
403
+ const serviceName = getConnectionName('default');
404
+ Service.remove(serviceName);
405
+ return Service.instance(serviceName, getConnection(name));
406
+ };
407
+ }