@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.
- package/README.md +5 -5
- package/package.json +8 -5
- package/CHANGELOG.md +0 -109
- package/docs/request-scoped-logging.md +0 -167
- package/src/ServiceDiscovery.ts +0 -252
- package/src/__tests__/context.spec.ts +0 -485
- package/src/__tests__/index.spec.ts +0 -564
- package/src/__tests__/middy.spec.ts +0 -218
- package/src/__tests__/trpc.spec.ts +0 -218
- package/src/context.ts +0 -222
- package/src/index.ts +0 -17
- package/src/middy.ts +0 -226
- package/src/trpc.ts +0 -190
- package/src/types.ts +0 -92
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -13
|
@@ -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';
|