@geekmidas/services 9.0.2 → 10.0.0-alpha.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.
@@ -1,218 +0,0 @@
1
- import { EnvironmentParser } from '@geekmidas/envkit';
2
- import type { Logger } from '@geekmidas/logger';
3
- import middy from '@middy/core';
4
- import type { Context } from 'aws-lambda';
5
- import { beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { serviceContext } from '../context';
7
- import { addServices, requestContext, withServices } from '../middy';
8
- import { ServiceDiscovery } from '../ServiceDiscovery';
9
- import type { Service } from '../types';
10
-
11
- const createMockContext = (): Context =>
12
- ({
13
- functionName: 'standalone',
14
- functionVersion: '1',
15
- invokedFunctionArn:
16
- 'arn:aws:lambda:us-east-1:123456789012:function:standalone',
17
- memoryLimitInMB: '128',
18
- awsRequestId: 'aws-req-1',
19
- logGroupName: '/aws/lambda/standalone',
20
- logStreamName: 'stream',
21
- getRemainingTimeInMillis: () => 30000,
22
- done: vi.fn(),
23
- fail: vi.fn(),
24
- succeed: vi.fn(),
25
- callbackWaitsForEmptyEventLoop: true,
26
- }) as unknown as Context;
27
-
28
- /**
29
- * Invoke a Middy-wrapped handler with a mock Lambda context. The service
30
- * middlewares augment the context type to require `services`, but at runtime
31
- * they populate it before the handler runs — so the mock starts without it and
32
- * we invoke through a loosely-typed shim.
33
- */
34
- const invoke = (
35
- handler: (event: any, context: any) => any,
36
- event: unknown = {},
37
- ): Promise<unknown> => Promise.resolve(handler(event, createMockContext()));
38
-
39
- function makeSpyLogger(): Logger {
40
- const logger: Logger = {
41
- trace: vi.fn(),
42
- debug: vi.fn(),
43
- info: vi.fn(),
44
- warn: vi.fn(),
45
- error: vi.fn(),
46
- fatal: vi.fn(),
47
- child: vi.fn(() => logger),
48
- };
49
- return logger;
50
- }
51
-
52
- interface Greeter {
53
- greet(): string;
54
- registeredRequestId: string;
55
- }
56
-
57
- class GreeterService implements Service<'greeter', Greeter> {
58
- serviceName = 'greeter' as const;
59
- async register(): Promise<Greeter> {
60
- // Service code can read the request context during registration.
61
- const registeredRequestId = serviceContext.getRequestId();
62
- return {
63
- registeredRequestId,
64
- greet() {
65
- serviceContext.getLogger().info('greeting');
66
- return 'hello';
67
- },
68
- };
69
- }
70
- }
71
-
72
- interface Counter {
73
- next(): number;
74
- }
75
-
76
- class CounterService implements Service<'counter', Counter> {
77
- serviceName = 'counter' as const;
78
- async register(): Promise<Counter> {
79
- let n = 0;
80
- return { next: () => ++n };
81
- }
82
- }
83
-
84
- describe('middy adaptor', () => {
85
- beforeEach(() => {
86
- // ServiceDiscovery is a process-wide singleton that caches resolved
87
- // instances; reset so each test starts clean.
88
- ServiceDiscovery.reset();
89
- });
90
-
91
- describe('requestContext', () => {
92
- it('makes serviceContext available inside a plain handler', async () => {
93
- const logger = makeSpyLogger();
94
- let seenRequestId: string | undefined;
95
- let hadContext = false;
96
-
97
- const handler = middy(async () => {
98
- hadContext = serviceContext.hasContext();
99
- seenRequestId = serviceContext.getRequestId();
100
- serviceContext.getLogger().info('tick');
101
- }).use(requestContext({ logger }));
102
-
103
- await invoke(handler);
104
-
105
- expect(hadContext).toBe(true);
106
- expect(seenRequestId).toBe('aws-req-1');
107
- expect(logger.info).toHaveBeenCalledWith('tick');
108
- });
109
-
110
- it('gives each invocation its own fresh context', async () => {
111
- const seen: string[] = [];
112
- let counter = 0;
113
- const handler = middy(async () => {
114
- seen.push(serviceContext.getRequestId());
115
- }).use(
116
- requestContext({
117
- logger: makeSpyLogger(),
118
- getRequestId: () => `req-${++counter}`,
119
- }),
120
- );
121
-
122
- await invoke(handler);
123
- await invoke(handler);
124
-
125
- // The second invocation must not inherit the first's request id.
126
- expect(seen).toEqual(['req-1', 'req-2']);
127
- });
128
-
129
- it('propagates handler errors', async () => {
130
- const handler = middy(async () => {
131
- throw new Error('boom');
132
- }).use(requestContext({ logger: makeSpyLogger() }));
133
-
134
- await expect(invoke(handler)).rejects.toThrow('boom');
135
- });
136
-
137
- it('derives the request id via getRequestId override', async () => {
138
- let seen: string | undefined;
139
- const handler = middy(async () => {
140
- seen = serviceContext.getRequestId();
141
- }).use(
142
- requestContext({
143
- logger: makeSpyLogger(),
144
- getRequestId: () => 'custom-id',
145
- }),
146
- );
147
-
148
- await invoke(handler);
149
- expect(seen).toBe('custom-id');
150
- });
151
- });
152
-
153
- describe('addServices', () => {
154
- it('resolves services onto event.services (paired with requestContext)', async () => {
155
- const envParser = new EnvironmentParser({});
156
- const logger = makeSpyLogger();
157
- let greeting: string | undefined;
158
- let counted: number | undefined;
159
- let registeredRequestId: string | undefined;
160
-
161
- const handler = middy(async (event: { services: any }) => {
162
- registeredRequestId = event.services.greeter.registeredRequestId;
163
- greeting = event.services.greeter.greet();
164
- counted = event.services.counter.next();
165
- })
166
- .use(requestContext({ logger, getRequestId: () => 'shared-id' }))
167
- .use(
168
- addServices([new GreeterService(), new CounterService()], {
169
- envParser,
170
- }),
171
- );
172
-
173
- await invoke(handler);
174
-
175
- expect(greeting).toBe('hello');
176
- expect(counted).toBe(1);
177
- // register() and the service method ran inside the requestContext.
178
- expect(registeredRequestId).toBe('shared-id');
179
- expect(logger.info).toHaveBeenCalledWith('greeting');
180
- });
181
-
182
- it('resolves context-free services without a requestContext', async () => {
183
- const envParser = new EnvironmentParser({});
184
- let counted: number | undefined;
185
-
186
- const handler = middy(async (event: { services: any }) => {
187
- counted = event.services.counter.next();
188
- }).use(addServices([new CounterService()], { envParser }));
189
-
190
- await invoke(handler);
191
-
192
- expect(counted).toBe(1);
193
- });
194
- });
195
-
196
- describe('withServices', () => {
197
- it('establishes context and resolves services out of the box', async () => {
198
- const envParser = new EnvironmentParser({});
199
- let greeting: string | undefined;
200
- let registeredRequestId: string | undefined;
201
-
202
- const handler = middy(async (event: { services: any }) => {
203
- registeredRequestId = event.services.greeter.registeredRequestId;
204
- greeting = event.services.greeter.greet();
205
- }).use(
206
- withServices([new GreeterService()], {
207
- logger: makeSpyLogger(),
208
- envParser,
209
- }),
210
- );
211
-
212
- await invoke(handler);
213
-
214
- expect(greeting).toBe('hello');
215
- expect(registeredRequestId).toBe('aws-req-1');
216
- });
217
- });
218
- });
@@ -1,218 +0,0 @@
1
- import { EnvironmentParser } from '@geekmidas/envkit';
2
- import type { Logger } from '@geekmidas/logger';
3
- import { initTRPC } from '@trpc/server';
4
- import { beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { serviceContext } from '../context';
6
- import { ServiceDiscovery } from '../ServiceDiscovery';
7
- import {
8
- createRequestContextMiddleware,
9
- createServicesMiddleware,
10
- } from '../trpc';
11
- import type { Service } from '../types';
12
-
13
- beforeEach(() => {
14
- // ServiceDiscovery is a process-wide singleton that caches resolved
15
- // instances. Reset so each test gets a fresh registry.
16
- ServiceDiscovery.reset();
17
- });
18
-
19
- function makeLogger(): Logger {
20
- const logger: Logger = {
21
- debug: vi.fn(),
22
- info: vi.fn(),
23
- warn: vi.fn(),
24
- error: vi.fn(),
25
- fatal: vi.fn(),
26
- trace: vi.fn(),
27
- child: vi.fn(() => logger),
28
- };
29
- return logger;
30
- }
31
-
32
- describe('createServicesMiddleware (with envParser)', () => {
33
- it('resolves services and merges them onto ctx', async () => {
34
- const databaseService = {
35
- serviceName: 'database' as const,
36
- register: () => ({ query: (sql: string) => `result: ${sql}` }),
37
- } satisfies Service<'database', { query: (sql: string) => string }>;
38
-
39
- const t = initTRPC.context<{ logger: Logger }>().create();
40
- const withServices = createServicesMiddleware(
41
- t.middleware,
42
- new EnvironmentParser({}),
43
- );
44
-
45
- const caller = t.router({
46
- run: t.procedure
47
- .use(withServices([databaseService]))
48
- .query(({ ctx }) => ctx.database.query('select 1')),
49
- });
50
-
51
- const result = await caller.createCaller({ logger: makeLogger() }).run();
52
-
53
- expect(result).toBe('result: select 1');
54
- });
55
-
56
- it('makes serviceContext.getLogger() readable from inside a service method', async () => {
57
- let observedLogger: Logger | null = null;
58
- const dbService = {
59
- serviceName: 'database' as const,
60
- register: () => ({
61
- touch() {
62
- observedLogger = serviceContext.getLogger();
63
- observedLogger.info('touched');
64
- return 'ok';
65
- },
66
- }),
67
- } satisfies Service<'database', { touch: () => string }>;
68
-
69
- const t = initTRPC.context<{ logger: Logger }>().create();
70
- const withServices = createServicesMiddleware(
71
- t.middleware,
72
- new EnvironmentParser({}),
73
- );
74
-
75
- const requestLogger = makeLogger();
76
-
77
- const caller = t.router({
78
- run: t.procedure
79
- .use(withServices([dbService]))
80
- .query(({ ctx }) => ctx.database.touch()),
81
- });
82
-
83
- await caller.createCaller({ logger: requestLogger }).run();
84
-
85
- // getLogger() returns a request-scoped proxy (not the raw logger), so we
86
- // assert it delegates to the request logger rather than checking identity.
87
- expect(observedLogger).not.toBeNull();
88
- expect(requestLogger.info).toHaveBeenCalledWith('touched');
89
- });
90
-
91
- it('throws cleanly when service code runs outside the procedure', () => {
92
- // Sanity check that serviceContext stays scoped to the procedure call —
93
- // reads outside any procedure must throw.
94
- expect(() => serviceContext.getLogger()).toThrow();
95
- });
96
- });
97
-
98
- describe('createServicesMiddleware (context-supplied discovery)', () => {
99
- it('uses ctx.serviceDiscovery when no envParser is provided', async () => {
100
- const cacheService = {
101
- serviceName: 'cache' as const,
102
- register: () => ({ get: (key: string) => `cached:${key}` }),
103
- } satisfies Service<'cache', { get: (key: string) => string }>;
104
-
105
- type Ctx = { logger: Logger; serviceDiscovery: ServiceDiscovery };
106
- const t = initTRPC.context<Ctx>().create();
107
- const withServices = createServicesMiddleware<Ctx, object>(t.middleware);
108
-
109
- const caller = t.router({
110
- run: t.procedure
111
- .use(withServices([cacheService]))
112
- .query(({ ctx }) => ctx.cache.get('k')),
113
- });
114
-
115
- const discovery = ServiceDiscovery.getInstance(new EnvironmentParser({}));
116
-
117
- const result = await caller
118
- .createCaller({ logger: makeLogger(), serviceDiscovery: discovery })
119
- .run();
120
-
121
- expect(result).toBe('cached:k');
122
- });
123
- });
124
-
125
- describe('createRequestContextMiddleware', () => {
126
- it('exposes ctx.logger via serviceContext.getLogger() for the handler', async () => {
127
- const t = initTRPC.context<{ logger: Logger }>().create();
128
- const withRequestContext = createRequestContextMiddleware(t.middleware);
129
-
130
- let observed: Logger | null = null;
131
- const caller = t.router({
132
- run: t.procedure.use(withRequestContext).query(() => {
133
- observed = serviceContext.getLogger();
134
- observed.info('handled');
135
- return 'done';
136
- }),
137
- });
138
-
139
- const requestLogger = makeLogger();
140
- const result = await caller.createCaller({ logger: requestLogger }).run();
141
-
142
- expect(result).toBe('done');
143
- // The request-scoped proxy delegates to ctx.logger.
144
- expect(observed).not.toBeNull();
145
- expect(requestLogger.info).toHaveBeenCalledWith('handled');
146
- });
147
-
148
- it('auto-generates requestId and startTime when ctx does not provide them', async () => {
149
- const t = initTRPC.context<{ logger: Logger }>().create();
150
- const withRequestContext = createRequestContextMiddleware(t.middleware);
151
-
152
- let requestId: string | null = null;
153
- let startTime: number | null = null;
154
-
155
- const caller = t.router({
156
- run: t.procedure.use(withRequestContext).query(() => {
157
- requestId = serviceContext.getRequestId();
158
- startTime = serviceContext.getRequestStartTime();
159
- return 'ok';
160
- }),
161
- });
162
-
163
- await caller.createCaller({ logger: makeLogger() }).run();
164
-
165
- expect(requestId).toBeTypeOf('string');
166
- expect(requestId!.length).toBeGreaterThan(0);
167
- expect(typeof startTime).toBe('number');
168
- });
169
-
170
- it('honors caller-supplied requestId and startTime when present on ctx', async () => {
171
- type Ctx = { logger: Logger; requestId?: string; startTime?: number };
172
- const t = initTRPC.context<Ctx>().create();
173
- const withRequestContext = createRequestContextMiddleware(t.middleware);
174
-
175
- let observedId: string | null = null;
176
- let observedStart: number | null = null;
177
-
178
- const caller = t.router({
179
- run: t.procedure.use(withRequestContext).query(() => {
180
- observedId = serviceContext.getRequestId();
181
- observedStart = serviceContext.getRequestStartTime();
182
- return 'ok';
183
- }),
184
- });
185
-
186
- await caller
187
- .createCaller({
188
- logger: makeLogger(),
189
- requestId: 'req_abc',
190
- startTime: 12345,
191
- })
192
- .run();
193
-
194
- expect(observedId).toBe('req_abc');
195
- expect(observedStart).toBe(12345);
196
- });
197
- });
198
-
199
- describe('tagging for tooling', () => {
200
- it('attaches the resolved services tuple to the inner middleware', () => {
201
- const svc = {
202
- serviceName: 'sample' as const,
203
- register: () => ({}),
204
- } satisfies Service<'sample', {}>;
205
-
206
- const t = initTRPC.context<{ logger: Logger }>().create();
207
- const withServices = createServicesMiddleware(
208
- t.middleware,
209
- new EnvironmentParser({}),
210
- );
211
-
212
- const builder = withServices([svc]);
213
- const middlewares = (
214
- builder as unknown as { _middlewares: Array<{ _services?: Service[] }> }
215
- )._middlewares;
216
- expect(middlewares?.at(-1)?._services).toEqual([svc]);
217
- });
218
- });
package/src/context.ts DELETED
@@ -1,222 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import type { Logger } from '@geekmidas/logger';
3
- import type { ServiceContext } from './types';
4
-
5
- /**
6
- * Internal storage for request context data.
7
- * Not exported - services use ServiceContext interface.
8
- */
9
- export interface RequestContextData {
10
- logger: Logger;
11
- requestId: string;
12
- startTime: number;
13
- }
14
-
15
- /**
16
- * Internal AsyncLocalStorage instance for request context.
17
- * Not exported - use runWithRequestContext() to establish context
18
- * and serviceContext to access it.
19
- */
20
- const requestContextStorage = new AsyncLocalStorage<RequestContextData>();
21
-
22
- /**
23
- * Resolve the logger for the current request, or throw if there is none.
24
- */
25
- function resolveRequestLogger(): Logger {
26
- const store = requestContextStorage.getStore();
27
- if (!store) {
28
- throw new Error(
29
- 'ServiceContext.getLogger() called outside request context. ' +
30
- 'Ensure code runs within runWithRequestContext().',
31
- );
32
- }
33
- return store.logger;
34
- }
35
-
36
- /**
37
- * Create a Logger that re-resolves its underlying logger on every call instead
38
- * of capturing it once.
39
- *
40
- * This is what makes it safe for a **singleton** service to grab the logger a
41
- * single time (e.g. during `register()`, which `ServiceDiscovery` only runs
42
- * once and then caches) and reuse that reference for every request: each log
43
- * call resolves the *current* request's logger from `AsyncLocalStorage`, so
44
- * requests no longer inherit the first request's logger (and its `requestId`,
45
- * user bindings, etc.).
46
- *
47
- * Implemented as a `Proxy` rather than a fixed list of methods so it forwards
48
- * the *entire* surface of whatever logger is supplied — including members
49
- * beyond the base `Logger` interface (e.g. a richer pino-backed logger's
50
- * `flush()` or `level`) and any methods added to `Logger` in the future.
51
- *
52
- * @param bindings - `child()` bindings applied, in order, on top of the
53
- * resolved logger before each call.
54
- */
55
- function createRequestScopedLogger(bindings: object[] = []): Logger {
56
- // Memoise the resolved (optionally child) logger per underlying base logger
57
- // so we don't rebuild the child chain on every access within a request.
58
- // Recomputed whenever the current request's logger changes — there is no
59
- // await between the check and use, so this is safe under concurrency.
60
- let cachedBase: Logger | undefined;
61
- let cachedResolved: Logger | undefined;
62
-
63
- const resolve = (): Logger => {
64
- const base = resolveRequestLogger();
65
- if (base !== cachedBase) {
66
- cachedBase = base;
67
- cachedResolved = bindings.reduce<Logger>(
68
- (log, obj) => log.child(obj),
69
- base,
70
- );
71
- }
72
- return cachedResolved as Logger;
73
- };
74
-
75
- return new Proxy({} as Logger, {
76
- get(_target, prop) {
77
- // `child()` must stay request-scoped: return a new proxy carrying the
78
- // extra binding, NOT the underlying logger's child (which would freeze
79
- // to the current request).
80
- if (prop === 'child') {
81
- return (obj: object) => createRequestScopedLogger([...bindings, obj]);
82
- }
83
- // Never look like a thenable, and don't answer symbol/inspection probes
84
- // (util.inspect, Symbol.toPrimitive, etc.) with bound functions.
85
- if (prop === 'then' || typeof prop === 'symbol') {
86
- return undefined;
87
- }
88
- const value = (resolve() as unknown as Record<string, unknown>)[prop];
89
- // Functions are re-resolved at *call* time so detached references
90
- // (`const info = logger.info`) still target the current request's
91
- // logger. Non-function members (e.g. `level`) forward as their live
92
- // value on the current request's logger.
93
- return typeof value === 'function'
94
- ? (...args: unknown[]) => {
95
- // Re-resolve at call time so detached references target the
96
- // current request's logger, and invoke with that logger as
97
- // `this`: pino's log methods read internal state off the
98
- // receiver (`this[Symbol(pino.msgPrefix)]`), so calling them
99
- // unbound throws "Cannot read properties of undefined".
100
- const resolved = resolve();
101
- const fn = (resolved as unknown as Record<string, unknown>)[
102
- prop
103
- ] as (...a: unknown[]) => unknown;
104
- return fn.apply(resolved, args);
105
- }
106
- : value;
107
- },
108
- // Keep `'prop' in logger` / hasOwnProperty truthful against the underlying
109
- // logger so feature-detection works.
110
- has(_target, prop) {
111
- if (prop === 'child') return true;
112
- if (prop === 'then' || typeof prop === 'symbol') return false;
113
- return prop in (resolve() as object);
114
- },
115
- });
116
- }
117
-
118
- /**
119
- * Stable, process-wide request-scoped logger proxy. Shared across requests on
120
- * purpose — it carries no request state itself, delegating to the current
121
- * `AsyncLocalStorage` store on each call.
122
- */
123
- const requestScopedLogger = createRequestScopedLogger();
124
-
125
- /**
126
- * ServiceContext implementation.
127
- * Singleton that reads from AsyncLocalStorage.
128
- * Methods throw if called outside a request context (catches bugs early).
129
- */
130
- export const serviceContext: ServiceContext = {
131
- getLogger() {
132
- // Throw eagerly if there is no context, preserving the "catch bugs early"
133
- // contract for callers that read the logger at an unexpected time.
134
- resolveRequestLogger();
135
- // Return the shared proxy rather than the raw `store.logger`. A service
136
- // that captures this once still logs against the correct per-request
137
- // logger because the proxy re-resolves on every call.
138
- return requestScopedLogger;
139
- },
140
-
141
- getRequestId() {
142
- const store = requestContextStorage.getStore();
143
- if (!store) {
144
- throw new Error(
145
- 'ServiceContext.getRequestId() called outside request context. ' +
146
- 'Ensure code runs within runWithRequestContext().',
147
- );
148
- }
149
- return store.requestId;
150
- },
151
-
152
- getRequestStartTime() {
153
- const store = requestContextStorage.getStore();
154
- if (!store) {
155
- throw new Error(
156
- 'ServiceContext.getRequestStartTime() called outside request context. ' +
157
- 'Ensure code runs within runWithRequestContext().',
158
- );
159
- }
160
- return store.startTime;
161
- },
162
-
163
- hasContext() {
164
- return requestContextStorage.getStore() !== undefined;
165
- },
166
- };
167
-
168
- /**
169
- * Run a function with request context.
170
- * Used by endpoint/function/subscriber adaptors.
171
- *
172
- * @param data - Request context data (logger, requestId, startTime)
173
- * @param fn - Function to run with context
174
- * @returns Result of the function
175
- *
176
- * @example
177
- * ```typescript
178
- * const result = await runWithRequestContext(
179
- * { logger, requestId, startTime: Date.now() },
180
- * async () => {
181
- * // Inside here, serviceContext.getLogger() returns `logger`
182
- * // serviceContext.getRequestId() returns `requestId`
183
- * return await handleRequest();
184
- * }
185
- * );
186
- * ```
187
- */
188
- export function runWithRequestContext<T>(
189
- data: RequestContextData,
190
- fn: () => T | Promise<T>,
191
- ): T | Promise<T> {
192
- return requestContextStorage.run(data, fn);
193
- }
194
-
195
- /**
196
- * Mutate the current async task's store so that subsequent code in this task
197
- * (and any descendants) sees the supplied request context.
198
- *
199
- * Unlike `runWithRequestContext`, this does not scope the context to a
200
- * callback — useful when the caller can't wrap a function, for example in a
201
- * Vitest fixture that suspends on `use()` and yields control to the test
202
- * runner before the test body executes.
203
- *
204
- * **Test setup only.** In production handlers, prefer `runWithRequestContext`
205
- * so the frame is automatically cleaned up.
206
- */
207
- export function enterRequestContext(data: RequestContextData): void {
208
- requestContextStorage.enterWith(data);
209
- }
210
-
211
- /**
212
- * Clear the request context for the current async task. Pairs with
213
- * `enterRequestContext`. After calling, `serviceContext.hasContext()` returns
214
- * false for the remainder of the current async resource.
215
- */
216
- export function exitRequestContext(): void {
217
- // AsyncLocalStorage<T>.enterWith requires T, but Node accepts undefined at
218
- // runtime — passing it resets getStore() back to undefined.
219
- (requestContextStorage as unknown as AsyncLocalStorage<unknown>).enterWith(
220
- undefined,
221
- );
222
- }
package/src/index.ts DELETED
@@ -1,17 +0,0 @@
1
- // Re-export types
2
-
3
- // Re-export context utilities
4
- export {
5
- enterRequestContext,
6
- exitRequestContext,
7
- type RequestContextData,
8
- runWithRequestContext,
9
- serviceContext,
10
- } from './context';
11
- // Re-export ServiceDiscovery and utility types
12
- export {
13
- type ExtractServiceNames,
14
- ServiceDiscovery,
15
- type ServiceRecord,
16
- } from './ServiceDiscovery';
17
- export type { Service, ServiceContext, ServiceRegisterOptions } from './types';