@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/app.ts ADDED
@@ -0,0 +1,503 @@
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 { ElysiaSwaggerConfig } from '@elysiajs/swagger';
16
+ import type { ElysiaConfig, ErrorContext, PreContext } from 'elysia';
17
+ import type { CommandClass } from './command';
18
+ import type { DatabaseConnectionProps } from './database';
19
+ import type { Route, Singleton } from './http';
20
+ import type { ModuleClass } from './module';
21
+ import type { RedisConnectionProps } from './redis';
22
+
23
+ import { AsyncLocalStorage } from 'node:async_hooks';
24
+ import { parseArgs } from 'node:util';
25
+
26
+ import { swagger as swaggerPlugin } from '@elysiajs/swagger';
27
+ import { Elysia } from 'elysia';
28
+ import { assign } from 'radash';
29
+
30
+ import { ConsoleFormat, InteractsWithConsole } from './console';
31
+ import { Database } from './database';
32
+ import { Event } from './event';
33
+ import { applyMiddlewares } from './middleware';
34
+ import { Redis } from './redis';
35
+ import { Service } from './service';
36
+ import { Symbols } from './utils';
37
+
38
+ /**
39
+ * Properties required when declaring an app using the `@app()` decorator.
40
+ * @author Axel Nana <axel.nana@workbud.com>
41
+ */
42
+ export type AppProps = {
43
+ /**
44
+ * Enables or disables debug mode.
45
+ */
46
+ debug?: boolean;
47
+
48
+ /**
49
+ * The Elysia server configuration.
50
+ */
51
+ server?: ElysiaConfig<Route> & {
52
+ /**
53
+ * The port to listen on.
54
+ */
55
+ port?: number;
56
+ };
57
+
58
+ /**
59
+ * The list of modules provided the app.
60
+ */
61
+ modules?: ModuleClass[];
62
+
63
+ /**
64
+ * The list of CLI commands provided the app.
65
+ */
66
+ commands?: CommandClass[];
67
+
68
+ /**
69
+ * The database configuration for the app.
70
+ */
71
+ database?: {
72
+ /**
73
+ * The default connection name.
74
+ */
75
+ default: string;
76
+
77
+ /**
78
+ * The list of connections.
79
+ */
80
+ connections: Record<string, DatabaseConnectionProps>;
81
+ };
82
+
83
+ /**
84
+ * The redis configuration for the app.
85
+ */
86
+ redis?: {
87
+ /**
88
+ * The default connection name.
89
+ */
90
+ default: string;
91
+
92
+ /**
93
+ * The list of connections.
94
+ */
95
+ connections: Record<string, RedisConnectionProps>;
96
+ };
97
+
98
+ /**
99
+ * Swagger documentation configuration.
100
+ *
101
+ * Set it to `false` to disable Swagger documentation.
102
+ */
103
+ swagger?: ElysiaSwaggerConfig<Route> | false;
104
+ };
105
+
106
+ /**
107
+ * Type for the application context.
108
+ * @author Axel Nana <axel.nana@workbud.com>
109
+ */
110
+ export type AppContext = AsyncLocalStorage<Map<'tenant' | 'http:context' | 'db:tx', unknown>>;
111
+
112
+ /**
113
+ * Base class for the application main entry.
114
+ * @author Axel Nana <axel.nana@workbud.com>
115
+ */
116
+ export abstract class Application extends InteractsWithConsole {
117
+ // @ts-expect-error The property is not initialized in the constructor.
118
+ #elysia: Elysia<Route>;
119
+ #debug: boolean;
120
+
121
+ private readonly _appContextStorage: AppContext = new AsyncLocalStorage();
122
+
123
+ /**
124
+ * Gets the running application instance.
125
+ */
126
+ public static get instance(): Application {
127
+ return Service.get<Application>('elysium.app')!;
128
+ }
129
+
130
+ /**
131
+ * Gets the application context shared between asynchronous operations.
132
+ */
133
+ public static get context(): AppContext {
134
+ return Application.instance._appContextStorage;
135
+ }
136
+
137
+ /**
138
+ * Marks a class as the application main entry.
139
+ * @param props The decorator options.
140
+ */
141
+ public static register(props: AppProps = {}): ClassDecorator {
142
+ return function (target) {
143
+ props = assign<AppProps>({ modules: [], database: undefined, swagger: false }, props);
144
+ props = assign<AppProps>(Reflect.getMetadata(Symbols.app, target) ?? {}, props);
145
+ Reflect.defineMetadata(Symbols.app, props, target);
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Creates a new instance of the application.
151
+ */
152
+ public constructor() {
153
+ super();
154
+
155
+ Service.instance('elysium.app', this);
156
+
157
+ const { debug, database, redis } = Reflect.getMetadata(
158
+ Symbols.app,
159
+ this.constructor
160
+ ) as AppProps;
161
+
162
+ this.#debug = debug ?? false;
163
+
164
+ if (redis) {
165
+ for (const connectionName in redis.connections) {
166
+ Redis.registerConnection(connectionName, redis.connections[connectionName]);
167
+ }
168
+
169
+ if (Redis.connectionExists(redis.default)) {
170
+ Redis.setDefaultConnection(redis.default);
171
+ }
172
+ }
173
+
174
+ if (database) {
175
+ for (const connectionName in database.connections) {
176
+ Database.registerConnection(connectionName, database.connections[connectionName]);
177
+ }
178
+
179
+ if (Database.connectionExists(database.default)) {
180
+ Database.setDefaultConnection(database.default);
181
+ }
182
+ }
183
+
184
+ // Run the application
185
+ this.run().then(() => Event.emit('elysium:app:launched', this, this));
186
+ }
187
+
188
+ /**
189
+ * Gets the Elysia instance.
190
+ */
191
+ public get elysia(): Elysia<Route> {
192
+ return this.#elysia;
193
+ }
194
+
195
+ /**
196
+ * Gets the debug mode flag.
197
+ */
198
+ public get isDebug(): boolean {
199
+ return this.#debug;
200
+ }
201
+
202
+ /**
203
+ * Sets the debug mode flag.
204
+ * @param debug The debug mode flag.
205
+ */
206
+ public set isDebug(debug: boolean) {
207
+ this.#debug = debug;
208
+ }
209
+
210
+ /**
211
+ * Hook that is executed when an error occurs.
212
+ * @param e The error context.
213
+ * @returns Whether to continue the error propagation.
214
+ */
215
+ protected async onError(e: ErrorContext): Promise<boolean> {
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Hook that is executed when the application starts.
221
+ * @param elysia The Elysia instance.
222
+ */
223
+ protected async onStart(elysia: Elysia<Route>): Promise<void> {}
224
+
225
+ /**
226
+ * Hook that is executed when the application stops.
227
+ * @param elysia The Elysia instance.
228
+ */
229
+ protected async onStop(elysia: Elysia<Route>): Promise<void> {}
230
+
231
+ protected async run(): Promise<void> {
232
+ const argv = process.argv.slice(2);
233
+
234
+ const action = argv[0];
235
+
236
+ if (!action) {
237
+ this.commandDescribe();
238
+ } else {
239
+ const { commands } = Reflect.getMetadata(Symbols.app, this.constructor) as AppProps;
240
+
241
+ switch (action) {
242
+ case 'serve': {
243
+ return this.commandServe();
244
+ }
245
+ case 'exec': {
246
+ const command = argv[1];
247
+ const args = argv.slice(2);
248
+
249
+ return this.commandExec(command, args);
250
+ }
251
+ case 'help': {
252
+ const command = argv[1];
253
+
254
+ if (command) {
255
+ const commandClass = commands?.find((commandClass) => commandClass.command === command);
256
+
257
+ if (commandClass) {
258
+ const commandInstance = Service.make(commandClass);
259
+ this.write(await commandInstance.help());
260
+ return process.exit(0);
261
+ }
262
+
263
+ console.error(`Command <${command}> not found.`);
264
+ } else {
265
+ console.error('No command provided. Usage: styx help <command>');
266
+ }
267
+
268
+ this.commandList();
269
+ break;
270
+ }
271
+ case 'list': {
272
+ this.write(this.bold('Available commands:'));
273
+ this.commandList();
274
+ break;
275
+ }
276
+ case 'work': {
277
+ return this.commandWork(argv.slice(1));
278
+ }
279
+ default: {
280
+ console.error(`Invalid command: ${this.bold(action)}`);
281
+
282
+ this.commandDescribe();
283
+ break;
284
+ }
285
+ }
286
+ }
287
+
288
+ return process.exit(0);
289
+ }
290
+
291
+ private async commandWork(argv: string[]) {
292
+ this.info('Starting worker process...');
293
+
294
+ // Parse queue arguments
295
+ const { values } = parseArgs({
296
+ args: argv,
297
+ options: {
298
+ queue: {
299
+ type: 'string',
300
+ multiple: true,
301
+ default: ['default'],
302
+ short: 'q'
303
+ },
304
+ concurrency: {
305
+ type: 'string',
306
+ default: '1',
307
+ short: 'c'
308
+ },
309
+ ['max-retries']: {
310
+ type: 'string',
311
+ default: '5',
312
+ short: 'r'
313
+ },
314
+ ['retry-delay']: {
315
+ type: 'string',
316
+ default: '5000',
317
+ short: 'd'
318
+ },
319
+ ['pause-on-error']: {
320
+ type: 'boolean',
321
+ default: false,
322
+ short: 'p'
323
+ }
324
+ }
325
+ });
326
+
327
+ // Import worker-specific code
328
+ const { Worker } = await import('./worker');
329
+
330
+ // Start the worker with the specified queues
331
+ const queues = values.queue
332
+ .map((queue) => queue.split(','))
333
+ .reduce((acc, queues) => acc.concat(queues), []);
334
+
335
+ const worker = Worker.spawn(self as unknown as globalThis.Worker, queues, {
336
+ concurrency: parseInt(values.concurrency, 10),
337
+ maxRetries: parseInt(values['max-retries'], 10),
338
+ retryDelay: parseInt(values['retry-delay'], 10),
339
+ pauseOnError: values['pause-on-error']
340
+ });
341
+
342
+ this.success(
343
+ `Worker process ${this.format(worker.id, ConsoleFormat.GREEN)} started with queues: ${queues.map((q) => this.format(q, ConsoleFormat.CYAN)).join(', ')}`
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Executes a command.
349
+ * @param command The command to execute.
350
+ * @param argv The command line arguments.
351
+ */
352
+ private async commandExec(command: string, argv: string[]): Promise<void> {
353
+ const { commands } = Reflect.getMetadata(Symbols.app, this.constructor) as AppProps;
354
+
355
+ // Find the command class
356
+ const commandClass = commands?.find((commandClass) => commandClass.command === command);
357
+
358
+ if (!commandClass) {
359
+ console.error(`Command ${command} not found`);
360
+ this.commandList();
361
+ return process.exit(1);
362
+ }
363
+
364
+ try {
365
+ // Create the command instance
366
+ const commandInstance = Service.make(commandClass);
367
+
368
+ // Run the command
369
+ const initialized = await commandInstance.init(...argv);
370
+
371
+ if (initialized) {
372
+ await commandInstance.run();
373
+ } else {
374
+ const help = await commandInstance.help();
375
+ this.write(help);
376
+ }
377
+
378
+ return process.exit(0);
379
+ } catch (error: any) {
380
+ console.error(error.message);
381
+ return process.exit(1);
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Starts the server on the specified port.
387
+ */
388
+ private async commandServe(): Promise<void> {
389
+ const { server, modules, swagger } = Reflect.getMetadata(
390
+ Symbols.app,
391
+ this.constructor
392
+ ) as AppProps;
393
+
394
+ Event.emit('elysium:server:before-init', this, this);
395
+
396
+ this.#elysia = new Elysia(server);
397
+
398
+ this.#elysia
399
+ .onRequest((c: PreContext<Singleton>) => {
400
+ c.tenant = c.request.headers.get('x-tenant-id') ?? 'public';
401
+
402
+ if (this.isDebug) {
403
+ // TODO: Use the logger service here
404
+ console.log(c.request.method, c.request.url);
405
+ }
406
+ })
407
+ .onError(async (e) => {
408
+ if (await this.onError(e)) {
409
+ Event.emit('elysium:error', e);
410
+ }
411
+ })
412
+ .onStart(async (elysia) => {
413
+ await this.onStart(elysia);
414
+ Event.emit('elysium:server:start', elysia, this);
415
+ })
416
+ .onStop(async (elysia) => {
417
+ await this.onStop(elysia);
418
+ Event.emit('elysium:server:stop', elysia, this);
419
+ this._appContextStorage.disable();
420
+ });
421
+
422
+ Event.emit('elysium:server:after-init', this, this);
423
+
424
+ if (swagger) {
425
+ this.#elysia.use(await swaggerPlugin(swagger));
426
+ }
427
+
428
+ const middlewares = Reflect.getMetadata(Symbols.middlewares, this.constructor) ?? [];
429
+ applyMiddlewares(middlewares, this.#elysia);
430
+
431
+ for (const moduleClass of modules!) {
432
+ const plugin = Reflect.getMetadata(Symbols.elysiaPlugin, moduleClass);
433
+ if (plugin === undefined) {
434
+ // TODO: Use the logger service here
435
+ console.error(
436
+ `Invalid module class ${moduleClass.name} registered in app ${this.constructor.name}. Ensure that you used the @module() decorator on the module.`
437
+ );
438
+ return process.exit(1);
439
+ }
440
+
441
+ const module = Service.bind(moduleClass);
442
+
443
+ module.beforeRegister();
444
+ this.#elysia.use(await plugin(module));
445
+ module.afterRegister();
446
+ }
447
+
448
+ // Register the server
449
+ this.#elysia.listen(server?.port ?? (parseInt(process.env.PORT!, 10) || 3000));
450
+
451
+ process.on('SIGINT', () => {
452
+ this.#elysia.stop();
453
+ return process.exit(0);
454
+ });
455
+ }
456
+
457
+ /**
458
+ * Describes the CLI and available commands.
459
+ */
460
+ private commandDescribe() {
461
+ this.section(
462
+ `${this.bold('Usage:')} ${this.format('styx', ConsoleFormat.MAGENTA)} <command> [options]`
463
+ );
464
+
465
+ this.section(this.bold('Commands:'));
466
+ this.commandDescription('serve', '', 'Starts the server.');
467
+ this.commandDescription('exec', '<command> [options]', 'Executes a command.');
468
+ this.commandDescription('work', '[options]', 'Starts a worker process.');
469
+ this.commandDescription('help', '<command>', 'Displays help for a command.');
470
+ this.commandDescription('list', '', 'List all available commands.');
471
+ }
472
+
473
+ private commandList() {
474
+ const { commands } = Reflect.getMetadata(Symbols.app, this.constructor) as AppProps;
475
+
476
+ const groups = (commands ?? []).reduce((acc, commandClass) => {
477
+ const groupKey = commandClass.command.split(':')[0];
478
+ const group = acc.get(groupKey) ?? [];
479
+ group.push(commandClass);
480
+ acc.set(groupKey, group);
481
+ return acc;
482
+ }, new Map<string, CommandClass[]>());
483
+
484
+ for (const [group, groupCommands] of groups) {
485
+ this.section(this.format(group, ConsoleFormat.BLUE));
486
+ for (const commandClass of groupCommands) {
487
+ this.commandDescription(commandClass.command, '', commandClass.description);
488
+ }
489
+ }
490
+ }
491
+
492
+ private commandDescription(
493
+ command: string,
494
+ args: string,
495
+ description: string,
496
+ width: number = InteractsWithConsole.SPACE_WIDTH
497
+ ) {
498
+ args = args.length === 0 ? ' ' : ` ${args} `;
499
+ this.write(
500
+ ` ${this.format(command, ConsoleFormat.CYAN)}${args}${'∙'.repeat(width - command.length - args.length)} ${description}`
501
+ );
502
+ }
503
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,218 @@
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 'keyv';
16
+
17
+ import { createCache } from 'cache-manager';
18
+ import { KeyvCacheableMemory } from 'cacheable';
19
+ import { Keyv } from 'keyv';
20
+ import { alphabetical } from 'radash';
21
+
22
+ import { KeyvRedis } from './redis';
23
+
24
+ /**
25
+ * Interface for a cache storage.
26
+ * @author Axel Nana <axel.nana@workbud.com>
27
+ */
28
+ interface CacheInterface {
29
+ /**
30
+ * Retrieves the cached data for a given key.
31
+ * @typeParam T The type of the cached data.
32
+ *
33
+ * @param key The key of the cached data.
34
+ */
35
+ get<T>(key: string): Promise<T | null>;
36
+
37
+ /**
38
+ * Retrieves the cached data for a given list of keys.
39
+ * @typeParam T The type of the cached data.
40
+ *
41
+ * @param keys The list of cached data keys.
42
+ */
43
+ mget<T>(keys: string[]): Promise<Array<T | null>>;
44
+
45
+ /**
46
+ * Gets the TTL of the cached data with the given key.
47
+ * @param key The key of the cached data.
48
+ * @returns The TTL of the cached data, or `null` if the data does not exist.
49
+ */
50
+ ttl(key: string): Promise<number | null>;
51
+
52
+ /**
53
+ * Caches data under the given key and an optional TTL.
54
+ * @typeParam T The type of the data to cache.
55
+ *
56
+ * @param key The key of the cached data.
57
+ * @param data The data to cache.
58
+ * @param ttl The optional TTL of the cached data. If not defined, the data is cached forever.
59
+ * @returns The cached data.
60
+ */
61
+ set<T>(key: string, data: T, ttl?: number): Promise<T>;
62
+
63
+ /**
64
+ * Caches data under the given list of keys and an optional TTL.
65
+ * @typeParam T The type of the data to cache.
66
+ *
67
+ * @param data The list of data to cache.
68
+ * @returns The list of cached data.
69
+ */
70
+ mset<T>(
71
+ data: Array<{
72
+ key: string;
73
+ value: T;
74
+ ttl?: number;
75
+ }>
76
+ ): Promise<
77
+ Array<{
78
+ key: string;
79
+ value: T;
80
+ ttl?: number;
81
+ }>
82
+ >;
83
+
84
+ /**
85
+ * Deletes the cached data with the given key.
86
+ * @typeParam T The type of the data to cache.
87
+ *
88
+ * @param key The key of the cached data.
89
+ * @returns `true` if the data was deleted, `false` otherwise.
90
+ */
91
+ del(key: string): Promise<boolean>;
92
+
93
+ /**
94
+ * Deletes the cached data with the given list of keys.
95
+ * @typeParam T The type of the data to cache.
96
+ *
97
+ * @param keys The list of cached data keys.
98
+ * @returns `true` if the data was deleted, `false` otherwise.
99
+ */
100
+ mdel(keys: string[]): Promise<boolean>;
101
+
102
+ /**
103
+ * Deletes all cached data.
104
+ * @returns `true` if the data was deleted, `false` otherwise.
105
+ */
106
+ clear(): Promise<boolean>;
107
+
108
+ /**
109
+ * Creates a sub-cache with the given tags.
110
+ * @param tags The list of tags for the sub-cache.
111
+ */
112
+ tags(...tags: string[]): Omit<CacheInterface, 'tags'>;
113
+ }
114
+
115
+ /**
116
+ * Makes a cache interface wrapping the given store.
117
+ * @author Axel Nana <axel.nana@workburd.com>
118
+ * @param store The cache storage to use in the interface.
119
+ * @returns A new `CacheInterface`.
120
+ */
121
+ const makeCacheInterface = (store: KeyvStoreAdapter): CacheInterface => {
122
+ const base = createCache({
123
+ stores: [
124
+ new Keyv({
125
+ store,
126
+ namespace: 'cache'
127
+ })
128
+ ]
129
+ });
130
+
131
+ const tagsCache = new Map<string, Omit<CacheInterface, 'tags'>>();
132
+
133
+ return {
134
+ ...base,
135
+ tags(...tags) {
136
+ const namespace = `cache__${alphabetical(tags, (item) => item).join('__')}`;
137
+ let cache = tagsCache.get(namespace);
138
+
139
+ if (!cache) {
140
+ cache = createCache({
141
+ stores: [
142
+ new Keyv({
143
+ store,
144
+ namespace
145
+ })
146
+ ]
147
+ });
148
+
149
+ tagsCache.set(namespace, cache);
150
+ }
151
+
152
+ return cache;
153
+ }
154
+ };
155
+ };
156
+
157
+ export abstract class Cache {
158
+ static #redis: CacheInterface | undefined;
159
+ static #memory: CacheInterface | undefined;
160
+
161
+ /**
162
+ * A weak cache storage for simple non-intrusive caching of data.
163
+ * @author Axel Nana <axel.nana@workbud.com>
164
+ */
165
+ public static readonly weak = Object.seal({
166
+ /**
167
+ * INTERNAL USAGE ONLY.
168
+ * @private
169
+ */
170
+ map: new WeakMap<object, unknown>(),
171
+
172
+ /**
173
+ * Gets the cached data for a given context.
174
+ * @param ctx The cache context.
175
+ * @returns The cached data.
176
+ */
177
+ get<T extends object>(ctx: object): T {
178
+ if (!this.map.has(ctx)) this.map.set(ctx, {});
179
+ return this.map.get(ctx) as T;
180
+ },
181
+
182
+ /**
183
+ * Deletes the cached data for a given context.
184
+ * @param ctx The cache context.
185
+ */
186
+ del(ctx: object): void {
187
+ this.map.delete(ctx);
188
+ }
189
+ });
190
+
191
+ /**
192
+ * Redis-based cache storage.
193
+ * @author Axel Nana <axel.nana@workbud.com>
194
+ */
195
+ public static get redis(): CacheInterface {
196
+ if (!Cache.#redis) {
197
+ Cache.#redis = makeCacheInterface(
198
+ new KeyvRedis({
199
+ connection: 'cache'
200
+ })
201
+ );
202
+ }
203
+
204
+ return Cache.#redis;
205
+ }
206
+
207
+ /**
208
+ * Memory-based cache storage.
209
+ * @author Axel Nana <axel.nana@workbud.com>
210
+ */
211
+ public static get memory(): CacheInterface {
212
+ if (!Cache.#memory) {
213
+ Cache.#memory = makeCacheInterface(new KeyvCacheableMemory({ ttl: 60000, lruSize: 5000 }));
214
+ }
215
+
216
+ return Cache.#memory;
217
+ }
218
+ }