@hiliosai/sdk 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.
Files changed (35) hide show
  1. package/package.json +27 -0
  2. package/src/configs/index.ts +1 -0
  3. package/src/configs/permissions.ts +109 -0
  4. package/src/env.ts +12 -0
  5. package/src/errors/auth.error.ts +33 -0
  6. package/src/errors/index.ts +2 -0
  7. package/src/errors/permission.error.ts +17 -0
  8. package/src/examples/cache-usage.example.ts +293 -0
  9. package/src/index.ts +5 -0
  10. package/src/middlewares/cache.middleware.ts +278 -0
  11. package/src/middlewares/context-helpers.middleware.ts +159 -0
  12. package/src/middlewares/datasource.middleware.ts +71 -0
  13. package/src/middlewares/health.middleware.ts +134 -0
  14. package/src/middlewares/index.ts +6 -0
  15. package/src/middlewares/memoize.middleware.ts +33 -0
  16. package/src/middlewares/permissions.middleware.ts +162 -0
  17. package/src/service/define-integration.ts +237 -0
  18. package/src/service/define-service.ts +77 -0
  19. package/src/service/example-user/datasources/index.ts +8 -0
  20. package/src/service/example-user/datasources/user.datasource.ts +7 -0
  21. package/src/service/example-user/user.service.ts +31 -0
  22. package/src/service/example-user/utils.ts +0 -0
  23. package/src/types/context.ts +41 -0
  24. package/src/types/datasource.ts +23 -0
  25. package/src/types/index.ts +8 -0
  26. package/src/types/integration.ts +48 -0
  27. package/src/types/message.ts +95 -0
  28. package/src/types/platform.ts +39 -0
  29. package/src/types/service.ts +208 -0
  30. package/src/types/tenant.ts +4 -0
  31. package/src/types/user.ts +16 -0
  32. package/src/utils/context-cache.ts +70 -0
  33. package/src/utils/permission-calculator.ts +62 -0
  34. package/tsconfig.json +13 -0
  35. package/tsup.config.ts +6 -0
@@ -0,0 +1,278 @@
1
+ import KeyvRedis from '@keyv/redis';
2
+ import Keyv from 'keyv';
3
+ import {REDIS_URL} from '../env';
4
+ import type {AppContext} from '../types';
5
+
6
+ export interface CacheOptions {
7
+ redisUrl?: string;
8
+ ttl?: number; // Default TTL in milliseconds
9
+ namespace?: string;
10
+ serialize?: (value: unknown) => string;
11
+ deserialize?: (value: string) => unknown;
12
+ }
13
+
14
+ export interface CacheHelpers {
15
+ get<T = unknown>(key: string): Promise<T | undefined>;
16
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<boolean>;
17
+ delete(key: string): Promise<boolean>;
18
+ clear(): Promise<void>;
19
+ has(key: string): Promise<boolean>;
20
+ mget<T = unknown>(...keys: string[]): Promise<Array<T | undefined>>;
21
+ mset(entries: Record<string, unknown>, ttl?: number): Promise<boolean>;
22
+ mdel(...keys: string[]): Promise<boolean>;
23
+ wrap<T = unknown>(
24
+ key: string,
25
+ factory: () => Promise<T> | T,
26
+ ttl?: number
27
+ ): Promise<T>;
28
+ }
29
+
30
+ export class CacheService {
31
+ private keyv: Keyv;
32
+ private defaultTtl: number;
33
+
34
+ constructor(options: CacheOptions = {}) {
35
+ const {
36
+ redisUrl = REDIS_URL,
37
+ ttl = 5 * 60 * 1000, // 5 minutes default
38
+ namespace = 'cache',
39
+ } = options;
40
+
41
+ this.defaultTtl = ttl;
42
+
43
+ // Configure store based on Redis availability
44
+ const store = redisUrl ? new KeyvRedis(redisUrl) : new Map(); // In-memory fallback
45
+
46
+ this.keyv = new Keyv({
47
+ store,
48
+ namespace,
49
+ ttl,
50
+ });
51
+
52
+ // Error handling
53
+ this.keyv.on('error', () => {
54
+ // Silently handle errors - could be logged to a logger if available
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Get a value from cache
60
+ */
61
+ async get<T = unknown>(key: string): Promise<T | undefined> {
62
+ try {
63
+ return await this.keyv.get<T>(key);
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Set a value in cache
71
+ */
72
+ async set<T = unknown>(
73
+ key: string,
74
+ value: T,
75
+ ttl?: number
76
+ ): Promise<boolean> {
77
+ try {
78
+ return await this.keyv.set(key, value, ttl);
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Delete a key from cache
86
+ */
87
+ async delete(key: string): Promise<boolean> {
88
+ try {
89
+ return await this.keyv.delete(key);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Clear all keys with the namespace
97
+ */
98
+ async clear(): Promise<void> {
99
+ try {
100
+ await this.keyv.clear();
101
+ } catch {
102
+ // Silently handle errors
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Check if a key exists
108
+ */
109
+ async has(key: string): Promise<boolean> {
110
+ try {
111
+ return await this.keyv.has(key);
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Get multiple values at once
119
+ */
120
+ async mget<T = unknown>(...keys: string[]): Promise<Array<T | undefined>> {
121
+ try {
122
+ const promises = keys.map((key) => this.get<T>(key));
123
+ return await Promise.all(promises);
124
+ } catch {
125
+ return keys.map(() => undefined);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Set multiple values at once
131
+ */
132
+ async mset(entries: Record<string, unknown>, ttl?: number): Promise<boolean> {
133
+ try {
134
+ const promises = Object.entries(entries).map(([key, value]) =>
135
+ this.set(key, value, ttl)
136
+ );
137
+ const results = await Promise.all(promises);
138
+ return results.every(Boolean);
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Delete multiple keys at once
146
+ */
147
+ async mdel(...keys: string[]): Promise<boolean> {
148
+ try {
149
+ const promises = keys.map((key) => this.delete(key));
150
+ const results = await Promise.all(promises);
151
+ return results.every(Boolean);
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Wrap pattern: Get from cache or calculate and cache
159
+ */
160
+ async wrap<T = unknown>(
161
+ key: string,
162
+ factory: () => Promise<T> | T,
163
+ ttl?: number
164
+ ): Promise<T> {
165
+ try {
166
+ // Try to get from cache
167
+ const cached = await this.get<T>(key);
168
+ if (cached !== undefined) {
169
+ return cached;
170
+ }
171
+
172
+ // Calculate value
173
+ const value = await factory();
174
+
175
+ // Cache the value
176
+ await this.set(key, value, ttl ?? this.defaultTtl);
177
+
178
+ return value;
179
+ } catch {
180
+ // In case of error, still try to return the factory value
181
+ return await factory();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Disconnect the cache (for cleanup)
187
+ */
188
+ async disconnect(): Promise<void> {
189
+ try {
190
+ await this.keyv.disconnect();
191
+ } catch {
192
+ // Silently handle errors
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Create cache middleware with specific options for a service
199
+ */
200
+ export function createCacheMiddleware(options: CacheOptions = {}) {
201
+ let cacheInstance: CacheService | null = null;
202
+
203
+ return {
204
+ /**
205
+ * Initialize cache on service start
206
+ */
207
+ started() {
208
+ cacheInstance = new CacheService(options);
209
+ },
210
+
211
+ /**
212
+ * Add cache helpers to context
213
+ */
214
+ localAction(handler: (...args: unknown[]) => unknown) {
215
+ return function CacheMiddlewareWrapper(this: unknown, ctx: AppContext) {
216
+ cacheInstance ??= new CacheService(options);
217
+
218
+ const instance = cacheInstance;
219
+
220
+ // Create scoped cache helpers
221
+ const cache: CacheHelpers = {
222
+ get: <T = unknown>(key: string) => instance.get<T>(key),
223
+
224
+ set: <T = unknown>(key: string, value: T, ttl?: number) =>
225
+ instance.set(key, value, ttl),
226
+
227
+ delete: (key: string) => instance.delete(key),
228
+
229
+ clear: () => instance.clear(),
230
+
231
+ has: (key: string) => instance.has(key),
232
+
233
+ mget: <T = unknown>(...keys: string[]) => instance.mget<T>(...keys),
234
+
235
+ mset: (entries: Record<string, unknown>, ttl?: number) =>
236
+ instance.mset(entries, ttl),
237
+
238
+ mdel: (...keys: string[]) => instance.mdel(...keys),
239
+
240
+ wrap: <T = unknown>(
241
+ key: string,
242
+ factory: () => Promise<T> | T,
243
+ ttl?: number
244
+ ) => instance.wrap<T>(key, factory, ttl),
245
+ };
246
+
247
+ // Add cache to context
248
+ ctx.cache = cache;
249
+
250
+ // Call the original handler
251
+ return handler.call(this, ctx);
252
+ };
253
+ },
254
+
255
+ /**
256
+ * Cleanup on service stop
257
+ */
258
+ stopped() {
259
+ if (cacheInstance) {
260
+ return cacheInstance.disconnect();
261
+ }
262
+ },
263
+ };
264
+ }
265
+
266
+ // Default cache middleware (backwards compatibility)
267
+ export const CacheMiddleware = createCacheMiddleware();
268
+
269
+ // Singleton instance for direct usage
270
+ let globalCacheInstance: CacheService | null = null;
271
+
272
+ export const getCacheInstance = (options?: CacheOptions) => {
273
+ if (options) {
274
+ return new CacheService(options);
275
+ }
276
+ globalCacheInstance ??= new CacheService();
277
+ return globalCacheInstance;
278
+ };
@@ -0,0 +1,159 @@
1
+ import type {AppContext, Tenant, User, UserRole} from '../types';
2
+ import {AuthenticationError, TenantError} from '../errors';
3
+ import {ContextCache} from '../utils/context-cache';
4
+ import {PermissionCalculator} from '../utils/permission-calculator';
5
+
6
+ export const ContextHelpersMiddleware = {
7
+ // Add helper functions to context before action handlers
8
+ localAction(handler: (...args: unknown[]) => unknown) {
9
+ return function ContextHelpersWrapper(this: unknown, ctx: AppContext) {
10
+ const cache = ContextCache.getInstance();
11
+
12
+ // Memoized permission checking with caching
13
+ const memoizedPermissions = new Map<string, boolean>();
14
+
15
+ ctx.hasPermission = function (permission: string): boolean {
16
+ // Check in-request memoization first
17
+ if (memoizedPermissions.has(permission)) {
18
+ const cachedResult = memoizedPermissions.get(permission);
19
+ return cachedResult === true;
20
+ }
21
+
22
+ const user = ctx.meta.user;
23
+ if (!user) {
24
+ memoizedPermissions.set(permission, false);
25
+ return false;
26
+ }
27
+
28
+ // Use optimized permission calculator
29
+ const result = PermissionCalculator.hasPermission(user, permission);
30
+ memoizedPermissions.set(permission, result);
31
+ return result;
32
+ };
33
+
34
+ // Cached user permissions getter
35
+ ctx.getUserPermissions = async function (): Promise<string[]> {
36
+ const user = ctx.meta.user;
37
+ if (!user) return [];
38
+
39
+ const cacheKey = `permissions:${user.id}:${JSON.stringify(user.roles)}`;
40
+ return cache.get(cacheKey, () => {
41
+ return PermissionCalculator.calculateUserPermissions(user);
42
+ });
43
+ };
44
+
45
+ ctx.hasRole = function (role: keyof typeof UserRole): boolean {
46
+ const user = ctx.ensureUser();
47
+ return Array.isArray(user.roles) && user.roles.includes(role);
48
+ };
49
+
50
+ ctx.isTenantMember = function (): boolean {
51
+ const user = ctx.ensureUser();
52
+ return !!(
53
+ user.tenantId &&
54
+ ctx.meta.tenantId &&
55
+ user.tenantId === ctx.meta.tenantId
56
+ );
57
+ };
58
+
59
+ ctx.isTenantOwner = function (): boolean {
60
+ return ctx.isTenantMember() && ctx.hasRole('OWNER');
61
+ };
62
+
63
+ ctx.ensureUser = (): User => {
64
+ if (!ctx.meta.user) {
65
+ ctx.broker.logger.error('Authentication required', {
66
+ action: ctx.action?.name,
67
+ requestId: ctx.meta.requestId,
68
+ userAgent: ctx.meta.userAgent,
69
+ ip: ctx.meta.clientIP,
70
+ });
71
+
72
+ throw new AuthenticationError('Authentication required', {
73
+ code: 'AUTH_REQUIRED',
74
+ statusCode: 401,
75
+ requestId: ctx.meta.requestId,
76
+ });
77
+ }
78
+ return ctx.meta.user;
79
+ };
80
+
81
+ ctx.ensureTenant = (): Tenant => {
82
+ if (!ctx.meta.tenantId) {
83
+ ctx.broker.logger.error('Tenant required', {
84
+ action: ctx.action?.name,
85
+ userId: ctx.meta.user?.id,
86
+ requestId: ctx.meta.requestId,
87
+ });
88
+
89
+ throw new TenantError('Tenant required', {
90
+ code: 'TENANT_REQUIRED',
91
+ statusCode: 401,
92
+ tenantId: ctx.meta.tenantId,
93
+ requestId: ctx.meta.requestId,
94
+ });
95
+ }
96
+
97
+ // Return a proper Tenant object with the ID
98
+ return {
99
+ id: ctx.meta.tenantId,
100
+ name: ctx.meta.tenantName ?? '',
101
+ } as Tenant;
102
+ };
103
+
104
+ // Enhanced audit logging function
105
+ ctx.auditLog = function (
106
+ action: string,
107
+ resource?: unknown,
108
+ metadata?: Record<string, unknown>
109
+ ): void {
110
+ ctx.broker.logger.info('Audit log', {
111
+ action,
112
+ resource: resource
113
+ ? {type: typeof resource, id: (resource as Record<string, unknown>).id}
114
+ : undefined,
115
+ userId: ctx.meta.user?.id,
116
+ tenantId: ctx.meta.tenantId,
117
+ requestId: ctx.meta.requestId,
118
+ timestamp: new Date().toISOString(),
119
+ ...metadata,
120
+ });
121
+ };
122
+
123
+ // Enhanced error creation with context
124
+ ctx.createError = function (
125
+ message: string,
126
+ code: string,
127
+ statusCode = 400
128
+ ): Error {
129
+ const errorData = {
130
+ code,
131
+ statusCode,
132
+ userId: ctx.meta.user?.id,
133
+ tenantId: ctx.meta.tenantId,
134
+ requestId: ctx.meta.requestId,
135
+ action: ctx.action?.name,
136
+ timestamp: new Date().toISOString(),
137
+ };
138
+
139
+ ctx.broker.logger.warn('Context error created', {
140
+ message,
141
+ ...errorData,
142
+ });
143
+
144
+ if (code === 'AUTH_REQUIRED') {
145
+ return new AuthenticationError(message, errorData);
146
+ }
147
+
148
+ if (code === 'TENANT_REQUIRED') {
149
+ return new TenantError(message, errorData);
150
+ }
151
+
152
+ return new Error(message);
153
+ };
154
+
155
+ // Call the original handler
156
+ return handler.call(this, ctx);
157
+ };
158
+ },
159
+ };
@@ -0,0 +1,71 @@
1
+ import type {Context} from 'moleculer';
2
+
3
+ /**
4
+ * Datasource constructor registry type
5
+ */
6
+ export interface DatasourceConstructorRegistry {
7
+ [key: string]: new () => object;
8
+ }
9
+
10
+ /**
11
+ * Datasource instance registry type
12
+ */
13
+ export interface DatasourceInstanceRegistry {
14
+ [key: string]: object;
15
+ }
16
+
17
+ /**
18
+ * Datasource context extension
19
+ */
20
+ export interface DatasourceContext extends Context {
21
+ datasources: DatasourceInstanceRegistry;
22
+ }
23
+
24
+ /**
25
+ * Initialize all datasources
26
+ */
27
+ function initializeDatasources(
28
+ constructorRegistry: DatasourceConstructorRegistry
29
+ ): DatasourceInstanceRegistry {
30
+ const initializedDatasources: DatasourceInstanceRegistry = {};
31
+
32
+ for (const [key, DatasourceClass] of Object.entries(constructorRegistry)) {
33
+ initializedDatasources[key] = new DatasourceClass();
34
+ }
35
+
36
+ return initializedDatasources;
37
+ }
38
+
39
+ /**
40
+ * Datasource middleware that injects datasources into the context
41
+ */
42
+ export function createDatasourceMiddleware(
43
+ datasources: DatasourceConstructorRegistry
44
+ ) {
45
+ const initializedDatasources = initializeDatasources(datasources);
46
+
47
+ return {
48
+ localAction(handler: (...args: unknown[]) => unknown) {
49
+ return function DatasourceWrapper(this: unknown, ctx: Context) {
50
+ // Inject datasources into context
51
+ (ctx as DatasourceContext).datasources = initializedDatasources;
52
+ return handler.call(this, ctx);
53
+ };
54
+ },
55
+
56
+ remoteAction(handler: (...args: unknown[]) => unknown) {
57
+ return function DatasourceWrapper(this: unknown, ctx: Context) {
58
+ // Inject datasources into context for remote actions too
59
+ (ctx as DatasourceContext).datasources = initializedDatasources;
60
+ return handler.call(this, ctx);
61
+ };
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Type helper for services that use datasources
68
+ */
69
+ export type ServiceWithDatasources<T extends DatasourceInstanceRegistry> = {
70
+ datasources: T;
71
+ };
@@ -0,0 +1,134 @@
1
+ import env from '@ltv/env';
2
+ import http from 'http';
3
+ import type {Service} from 'moleculer';
4
+
5
+ const HEALTH_CHECK_PORT = env.int('HEALTH_CHECK_PORT', 3301);
6
+ const HEALTH_CHECK_READINESS_PATH = env.string(
7
+ 'HEALTH_CHECK_READINESS_PATH',
8
+ '/readyz'
9
+ );
10
+ const HEALTH_CHECK_LIVENESS_PATH = env.string(
11
+ 'HEALTH_CHECK_LIVENESS_PATH',
12
+ '/livez'
13
+ );
14
+
15
+ interface HealthCheckOptions {
16
+ port?: number;
17
+ readiness?: {
18
+ path?: string;
19
+ };
20
+ liveness?: {
21
+ path?: string;
22
+ };
23
+ }
24
+
25
+ interface HealthCheckConfig {
26
+ port: number;
27
+ readiness: {
28
+ path: string;
29
+ };
30
+ liveness: {
31
+ path: string;
32
+ };
33
+ }
34
+
35
+ // Default configuration constants
36
+ export const HEALTH_CHECK_DEFAULTS = {
37
+ PORT: HEALTH_CHECK_PORT,
38
+ READINESS_PATH: HEALTH_CHECK_READINESS_PATH,
39
+ LIVENESS_PATH: HEALTH_CHECK_LIVENESS_PATH,
40
+ } as const;
41
+
42
+ export function CreateHealthCheckMiddleware(opts: HealthCheckOptions = {}) {
43
+ // Merge user options with defaults
44
+ const config: HealthCheckConfig = {
45
+ port: opts.port ?? HEALTH_CHECK_DEFAULTS.PORT,
46
+ readiness: {
47
+ path: opts.readiness?.path ?? HEALTH_CHECK_DEFAULTS.READINESS_PATH,
48
+ },
49
+ liveness: {
50
+ path: opts.liveness?.path ?? HEALTH_CHECK_DEFAULTS.LIVENESS_PATH,
51
+ },
52
+ };
53
+
54
+ type HealthState = 'down' | 'starting' | 'up' | 'stopping';
55
+
56
+ let state: HealthState = 'down';
57
+ let server: http.Server;
58
+
59
+ function handler(req: http.IncomingMessage, res: http.ServerResponse) {
60
+ // Prevent headers from being sent multiple times
61
+ if (res.headersSent) {
62
+ return;
63
+ }
64
+
65
+ if (req.url === config.readiness.path || req.url === config.liveness.path) {
66
+ const resHeader = {
67
+ 'Content-Type': 'application/json; charset=utf-8',
68
+ };
69
+
70
+ const content = {
71
+ state,
72
+ uptime: process.uptime(),
73
+ timestamp: Date.now(),
74
+ };
75
+
76
+ if (req.url === config.readiness.path) {
77
+ // Readiness if the broker started successfully.
78
+ // https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes
79
+ res.writeHead(state === 'up' ? 200 : 503, resHeader);
80
+ } else {
81
+ // Liveness if the broker is not stopped.
82
+ // https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-command
83
+ res.writeHead(state !== 'down' ? 200 : 503, resHeader);
84
+ }
85
+
86
+ res.end(JSON.stringify(content, null, 2));
87
+ } else {
88
+ res.writeHead(404, http.STATUS_CODES[404] ?? 'Not Found', {});
89
+ res.end();
90
+ }
91
+ }
92
+
93
+ return {
94
+ created(broker: Service) {
95
+ state = 'starting';
96
+
97
+ server = http.createServer(handler);
98
+ server.listen(config.port, (err?: Error | undefined) => {
99
+ if (err) {
100
+ return broker.logger.error(
101
+ 'Unable to start health-check server',
102
+ err
103
+ );
104
+ }
105
+
106
+ broker.logger.info('');
107
+ broker.logger.info('K8s health-check server listening on');
108
+ broker.logger.info(
109
+ ` http://localhost:${config.port}${config.readiness.path}`
110
+ );
111
+ broker.logger.info(
112
+ ` http://localhost:${config.port}${config.liveness.path}`
113
+ );
114
+ broker.logger.info('');
115
+ });
116
+ },
117
+
118
+ // After broker started
119
+ started() {
120
+ state = 'up';
121
+ },
122
+
123
+ // Before broker stopping
124
+ stopping() {
125
+ state = 'stopping';
126
+ },
127
+
128
+ // After broker stopped
129
+ stopped() {
130
+ state = 'down';
131
+ server.close();
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,6 @@
1
+ export * from './datasource.middleware';
2
+ export * from './memoize.middleware';
3
+ export * from './health.middleware';
4
+ export * from './permissions.middleware';
5
+ export * from './context-helpers.middleware';
6
+ export * from './cache.middleware';
@@ -0,0 +1,33 @@
1
+ import type {ServiceSchema, ServiceSettingSchema} from 'moleculer';
2
+
3
+ export interface MemoizeMixinOptions {
4
+ ttl?: number;
5
+ }
6
+
7
+ export function MemoizeMixin(
8
+ options?: MemoizeMixinOptions
9
+ ): ServiceSchema<ServiceSettingSchema> {
10
+ return {
11
+ name: '',
12
+ methods: {
13
+ async memoize(name: string, params: unknown, fn: () => Promise<unknown>) {
14
+ if (!this.broker.cacher) return fn();
15
+
16
+ const key = this.broker.cacher.defaultKeygen(
17
+ `${this.name}:memoize-${name}`,
18
+ params as object | null,
19
+ {},
20
+ []
21
+ );
22
+
23
+ let res = await this.broker.cacher.get(key);
24
+ if (res) return res;
25
+
26
+ res = (await fn()) as unknown as object;
27
+ this.broker.cacher.set(key, res, options?.ttl);
28
+
29
+ return res;
30
+ },
31
+ },
32
+ };
33
+ }