Skip to content

Commit b5ebf68

Browse files
authored
feat: mv Singleton from egg (#288)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added singleton management functionality to the application. - Enhanced logging capabilities for core system components. - Introduced new methods for managing application-wide singleton instances. - **Improvements** - Expanded module exports to include singleton-related utilities. - Provided more explicit interfaces for accessing loggers. - **Bug Fixes** - Improved error handling and validation in singleton instance creation. - **Tests** - Added extensive test coverage for the `Singleton` class, validating various creation scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c07821d commit b5ebf68

File tree

7 files changed

+584
-4
lines changed

7 files changed

+584
-4
lines changed

Diff for: package.json

-3
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
"engines": {
88
"node": ">= 18.19.0"
99
},
10-
"tnpm": {
11-
"mode": "npm"
12-
},
1310
"description": "A core plugin framework based on @eggjs/koa",
1411
"scripts": {
1512
"clean": "rimraf dist",

Diff for: src/egg.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
MiddlewareFunc as KoaMiddlewareFunc,
1010
Next,
1111
} from '@eggjs/koa';
12-
import { EggConsoleLogger } from 'egg-logger';
12+
import { EggConsoleLogger, Logger } from 'egg-logger';
1313
import { RegisterOptions, ResourcesController, EggRouter as Router } from '@eggjs/router';
1414
import type { ReadyFunctionArg } from 'get-ready';
1515
import { BaseContextClass } from './base_context_class.js';
@@ -19,6 +19,9 @@ import { Lifecycle } from './lifecycle.js';
1919
import { EggLoader } from './loader/egg_loader.js';
2020
import utils from './utils/index.js';
2121
import { EggAppConfig } from './types.js';
22+
import {
23+
Singleton, type SingletonCreateMethod, type SingletonOptions,
24+
} from './singleton.js';
2225

2326
const debug = debuglog('@eggjs/core/egg');
2427

@@ -185,6 +188,34 @@ export class EggCore extends KoaApplication {
185188
});
186189
}
187190

191+
get logger(): Logger {
192+
return this.console;
193+
}
194+
195+
get coreLogger(): Logger {
196+
return this.console;
197+
}
198+
199+
/**
200+
* create a singleton instance
201+
* @param {String} name - unique name for singleton
202+
* @param {Function|AsyncFunction} create - method will be invoked when singleton instance create
203+
*/
204+
addSingleton(name: string, create: SingletonCreateMethod) {
205+
const options: SingletonOptions = {
206+
name,
207+
create,
208+
app: this,
209+
};
210+
const singleton = new Singleton(options);
211+
const initPromise = singleton.init();
212+
if (initPromise) {
213+
this.beforeStart(async () => {
214+
await initPromise;
215+
});
216+
}
217+
}
218+
188219
/**
189220
* override koa's app.use, support generator function
190221
* @since 1.0.0

Diff for: src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { utils };
55
export * from './egg.js';
66
export * from './base_context_class.js';
77
export * from './lifecycle.js';
8+
export * from './singleton.js';
89
export * from './loader/egg_loader.js';
910
export * from './loader/file_loader.js';
1011
export * from './loader/context_loader.js';

Diff for: src/singleton.ts

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import assert from 'node:assert';
2+
import { isAsyncFunction } from 'is-type-of';
3+
import type { EggCore } from './egg.js';
4+
5+
export type SingletonCreateMethod =
6+
(config: Record<string, any>, app: EggCore, clientName: string) => unknown | Promise<unknown>;
7+
8+
export interface SingletonOptions {
9+
name: string;
10+
app: EggCore;
11+
create: SingletonCreateMethod;
12+
}
13+
14+
export class Singleton<T = any> {
15+
readonly clients = new Map<string, T>();
16+
readonly app: EggCore;
17+
readonly create: SingletonCreateMethod;
18+
readonly name: string;
19+
readonly options: Record<string, any>;
20+
21+
constructor(options: SingletonOptions) {
22+
assert(options.name, '[@eggjs/core/singleton] Singleton#constructor options.name is required');
23+
assert(options.app, '[@eggjs/core/singleton] Singleton#constructor options.app is required');
24+
assert(options.create, '[@eggjs/core/singleton] Singleton#constructor options.create is required');
25+
assert(!(options.name in options.app), `[@eggjs/core/singleton] ${options.name} is already exists in app`);
26+
this.app = options.app;
27+
this.name = options.name;
28+
this.create = options.create;
29+
this.options = options.app.config[this.name] ?? {};
30+
}
31+
32+
init() {
33+
return isAsyncFunction(this.create) ? this.initAsync() : this.initSync();
34+
}
35+
36+
initSync() {
37+
const options = this.options;
38+
assert(!(options.client && options.clients),
39+
`[@eggjs/core/singleton] ${this.name} can not set options.client and options.clients both`);
40+
41+
// alias app[name] as client, but still support createInstance method
42+
if (options.client) {
43+
const client = this.createInstance(options.client, options.name);
44+
this.#setClientToApp(client);
45+
this.#extendDynamicMethods(client);
46+
return;
47+
}
48+
49+
// multi client, use app[name].getSingletonInstance(id)
50+
if (options.clients) {
51+
Object.keys(options.clients).forEach(id => {
52+
const client = this.createInstance(options.clients[id], id);
53+
this.clients.set(id, client);
54+
});
55+
this.#setClientToApp(this);
56+
return;
57+
}
58+
59+
// no config.clients and config.client
60+
this.#setClientToApp(this);
61+
}
62+
63+
async initAsync() {
64+
const options = this.options;
65+
assert(!(options.client && options.clients),
66+
`[@eggjs/core/singleton] ${this.name} can not set options.client and options.clients both`);
67+
68+
// alias app[name] as client, but still support createInstance method
69+
if (options.client) {
70+
const client = await this.createInstanceAsync(options.client, options.name);
71+
this.#setClientToApp(client);
72+
this.#extendDynamicMethods(client);
73+
return;
74+
}
75+
76+
// multi client, use app[name].getInstance(id)
77+
if (options.clients) {
78+
await Promise.all(Object.keys(options.clients).map((id: string) => {
79+
return this.createInstanceAsync(options.clients[id], id)
80+
.then(client => this.clients.set(id, client));
81+
}));
82+
this.#setClientToApp(this);
83+
return;
84+
}
85+
86+
// no config.clients and config.client
87+
this.#setClientToApp(this);
88+
}
89+
90+
#setClientToApp(client: unknown) {
91+
Reflect.set(this.app, this.name, client);
92+
}
93+
94+
/**
95+
* @deprecated please use `getSingletonInstance(id)` instead
96+
*/
97+
get(id: string) {
98+
return this.clients.get(id)!;
99+
}
100+
101+
/**
102+
* Get singleton instance by id
103+
*/
104+
getSingletonInstance(id: string) {
105+
return this.clients.get(id)!;
106+
}
107+
108+
createInstance(config: Record<string, any>, clientName: string) {
109+
// async creator only support createInstanceAsync
110+
assert(!isAsyncFunction(this.create),
111+
`[@eggjs/core/singleton] ${this.name} only support synchronous creation, please use createInstanceAsync`);
112+
// options.default will be merge in to options.clients[id]
113+
config = {
114+
...this.options.default,
115+
...config,
116+
};
117+
return (this.create as SingletonCreateMethod)(config, this.app, clientName) as T;
118+
}
119+
120+
async createInstanceAsync(config: Record<string, any>, clientName: string) {
121+
// options.default will be merge in to options.clients[id]
122+
config = {
123+
...this.options.default,
124+
...config,
125+
};
126+
return await this.create(config, this.app, clientName) as T;
127+
}
128+
129+
#extendDynamicMethods(client: any) {
130+
assert(!client.createInstance, '[@eggjs/core/singleton] singleton instance should not have createInstance method');
131+
assert(!client.createInstanceAsync, '[@eggjs/core/singleton] singleton instance should not have createInstanceAsync method');
132+
133+
try {
134+
let extendable = client;
135+
// Object.preventExtensions() or Object.freeze()
136+
if (!Object.isExtensible(client) || Object.isFrozen(client)) {
137+
// eslint-disable-next-line no-proto
138+
extendable = client.__proto__ || client;
139+
}
140+
extendable.createInstance = this.createInstance.bind(this);
141+
extendable.createInstanceAsync = this.createInstanceAsync.bind(this);
142+
} catch (err) {
143+
this.app.coreLogger.warn(
144+
'[@eggjs/core/singleton] %s dynamic create is disabled because of client is un-extendable',
145+
this.name);
146+
this.app.coreLogger.warn(err);
147+
}
148+
}
149+
}

Diff for: test/egg.test.ts

+8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ describe('test/egg.test.ts', () => {
3434
assert.equal(app.options.baseDir, process.cwd());
3535
});
3636

37+
it('should export logger and coreLogger', () => {
38+
app = new EggCore();
39+
assert.equal(typeof app.logger.info, 'function');
40+
assert.equal(typeof app.coreLogger.error, 'function');
41+
app.logger.info('hello egg logger info level');
42+
app.coreLogger.warn('hello egg coreLogger warn level');
43+
});
44+
3745
it('should set default application when no type', () => {
3846
app = new EggCore();
3947
assert.equal(app.type, 'application');

Diff for: test/index.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('test/index.test.ts', () => {
2929
'Request',
3030
'Response',
3131
'Router',
32+
'Singleton',
3233
'Timing',
3334
'utils',
3435
]);

0 commit comments

Comments
 (0)