@iskra-bun/core 0.1.1 → 0.2.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/CHANGELOG.md +66 -0
- package/README.md +3 -2
- package/dist/index.d.ts +648 -107
- package/dist/index.js +481 -61
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/app.ts +219 -21
- package/src/config/loader.ts +9 -2
- package/src/config/schema.ts +78 -23
- package/src/env.ts +31 -0
- package/src/errors.ts +9 -2
- package/src/index.ts +1 -0
- package/src/logger/index.ts +190 -28
- package/src/otel.ts +221 -25
- package/src/types.ts +61 -8
package/src/config/schema.ts
CHANGED
|
@@ -1,29 +1,84 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
level: z.string().default('info')
|
|
8
|
-
}).default({}),
|
|
9
|
-
otel: z.object({
|
|
10
|
-
enabled: z.boolean().default(true),
|
|
11
|
-
endpoint: z.string().default('http://localhost:4318'),
|
|
12
|
-
serviceName: z.string().optional(),
|
|
13
|
-
serviceVersion: z.string().default('0.1.0'),
|
|
14
|
-
environment: z.string().optional(),
|
|
15
|
-
metricIntervalMs: z.number().default(60_000),
|
|
16
|
-
resourceAttributes: z.record(z.string()).optional(),
|
|
17
|
-
instrumentations: z.record(z.object({ enabled: z.boolean().optional() })).optional(),
|
|
18
|
-
}).optional(),
|
|
19
|
-
processes: z.record(z.object({
|
|
20
|
-
command: z.string(),
|
|
21
|
-
args: z.array(z.string()).optional(),
|
|
22
|
-
mode: z.enum(['daemon', 'oneshot', 'stdio']).default('daemon'),
|
|
23
|
-
restartOnCrash: z.boolean().default(false),
|
|
24
|
-
env: z.record(z.string()).optional()
|
|
25
|
-
})).optional()
|
|
3
|
+
const RestartBackoffSchema = z.object({
|
|
4
|
+
initialMs: z.number().positive().optional(),
|
|
5
|
+
maxMs: z.number().positive().optional(),
|
|
6
|
+
factor: z.number().positive().optional(),
|
|
26
7
|
});
|
|
27
8
|
|
|
9
|
+
// Sections owned by the kits are validated here for shape only, and every
|
|
10
|
+
// object is `.passthrough()`: AppConfig promises `[key: string]: any`, and a
|
|
11
|
+
// plain z.object() silently strips unknown keys — which used to drop `db`,
|
|
12
|
+
// `kv` and `socket` from app.config.ts entirely.
|
|
13
|
+
export const AppConfigSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
name: z.string().default('IskraApp'),
|
|
16
|
+
debug: z.boolean().default(false),
|
|
17
|
+
logger: z
|
|
18
|
+
.object({
|
|
19
|
+
level: z.string().default('info'),
|
|
20
|
+
})
|
|
21
|
+
.passthrough()
|
|
22
|
+
.default({}),
|
|
23
|
+
otel: z
|
|
24
|
+
.object({
|
|
25
|
+
enabled: z.boolean().default(true),
|
|
26
|
+
endpoint: z.string().default('http://localhost:4318'),
|
|
27
|
+
serviceName: z.string().optional(),
|
|
28
|
+
serviceVersion: z.string().default('0.1.0'),
|
|
29
|
+
environment: z.string().optional(),
|
|
30
|
+
metricIntervalMs: z.number().default(60_000),
|
|
31
|
+
resourceAttributes: z.record(z.string()).optional(),
|
|
32
|
+
// passthrough: the instrumentations' own options (hooks, redactedQueryParams) were stripped.
|
|
33
|
+
instrumentations: z.record(z.object({ enabled: z.boolean().optional() }).passthrough()).optional(),
|
|
34
|
+
})
|
|
35
|
+
.passthrough()
|
|
36
|
+
.optional(),
|
|
37
|
+
shutdownSignals: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
38
|
+
shutdownTimeoutMs: z.number().positive().optional(),
|
|
39
|
+
processes: z
|
|
40
|
+
.record(
|
|
41
|
+
z
|
|
42
|
+
.object({
|
|
43
|
+
command: z.string(),
|
|
44
|
+
args: z.array(z.string()).optional(),
|
|
45
|
+
mode: z.enum(['daemon', 'oneshot', 'stdio']).default('daemon'),
|
|
46
|
+
restartOnCrash: z.boolean().default(false),
|
|
47
|
+
maxRestarts: z.number().int().nonnegative().optional(),
|
|
48
|
+
restartCooldown: z.number().nonnegative().optional(),
|
|
49
|
+
restartBackoff: RestartBackoffSchema.optional(),
|
|
50
|
+
env: z.record(z.string()).optional(),
|
|
51
|
+
inheritEnv: z.union([z.boolean(), z.array(z.string())]).optional(),
|
|
52
|
+
maxPendingStdinBytes: z.number().int().positive().optional(),
|
|
53
|
+
})
|
|
54
|
+
.passthrough(),
|
|
55
|
+
)
|
|
56
|
+
.optional(),
|
|
57
|
+
socket: z
|
|
58
|
+
.object({
|
|
59
|
+
enabled: z.boolean(),
|
|
60
|
+
port: z.number().optional(),
|
|
61
|
+
adapter: z.enum(['bun', 'socket.io']).optional(),
|
|
62
|
+
})
|
|
63
|
+
.passthrough()
|
|
64
|
+
.optional(),
|
|
65
|
+
kv: z
|
|
66
|
+
.object({
|
|
67
|
+
driver: z.enum(['memory', 'redis']),
|
|
68
|
+
connection: z.any().optional(),
|
|
69
|
+
})
|
|
70
|
+
.passthrough()
|
|
71
|
+
.optional(),
|
|
72
|
+
db: z
|
|
73
|
+
.object({
|
|
74
|
+
driver: z.enum(['postgres', 'mysql', 'sqlite', 'libsql']),
|
|
75
|
+
url: z.string(),
|
|
76
|
+
authToken: z.string().optional(),
|
|
77
|
+
})
|
|
78
|
+
.passthrough()
|
|
79
|
+
.optional(),
|
|
80
|
+
})
|
|
81
|
+
.passthrough();
|
|
82
|
+
|
|
28
83
|
export type AppConfigInput = z.input<typeof AppConfigSchema>;
|
|
29
84
|
export type AppConfigOutput = z.output<typeof AppConfigSchema>;
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deployment environment, read when called: `NODE_ENV` through a computed
|
|
3
|
+
* key, because `bun build` replaces a literal `process.env.NODE_ENV` with its
|
|
4
|
+
* value at build time ("development" when unset), so a compiled binary ignored
|
|
5
|
+
* the NODE_ENV it ran with.
|
|
6
|
+
*/
|
|
7
|
+
const NODE_ENV = 'NODE_ENV';
|
|
8
|
+
|
|
9
|
+
/** `NODE_ENV` as the process runs with it, or undefined when unset or empty. */
|
|
10
|
+
export function nodeEnv(): string | undefined {
|
|
11
|
+
return process.env[NODE_ENV] || undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether development conveniences apply (pretty logs, http auth URLs, sample
|
|
16
|
+
* secrets, `disableCSRFCheck`): only with `NODE_ENV` set to `development` or
|
|
17
|
+
* `test`.
|
|
18
|
+
*/
|
|
19
|
+
export function isDevelopmentEnv(env: string | undefined = nodeEnv()): boolean {
|
|
20
|
+
return env === 'development' || env === 'test';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether production safeguards apply: in every environment but `development`
|
|
25
|
+
* and `test`, including an unset NODE_ENV and names like `staging`. They used
|
|
26
|
+
* to apply only to NODE_ENV=production exactly, so a deploy that forgot it
|
|
27
|
+
* sent non-Secure cookies and accepted placeholder secrets.
|
|
28
|
+
*/
|
|
29
|
+
export function isProductionEnv(env: string | undefined = nodeEnv()): boolean {
|
|
30
|
+
return !isDevelopmentEnv(env);
|
|
31
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -97,7 +97,11 @@ export class ConfigError extends IskraError {
|
|
|
97
97
|
|
|
98
98
|
export class DriverError extends IskraError {
|
|
99
99
|
constructor(message: string, options?: { code?: ErrorCode; cause?: Error; context?: Record<string, unknown> }) {
|
|
100
|
-
super(message, {
|
|
100
|
+
super(message, {
|
|
101
|
+
code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED,
|
|
102
|
+
cause: options?.cause,
|
|
103
|
+
context: options?.context,
|
|
104
|
+
});
|
|
101
105
|
this.name = 'DriverError';
|
|
102
106
|
}
|
|
103
107
|
}
|
|
@@ -116,7 +120,10 @@ export class PluginError extends IskraError {
|
|
|
116
120
|
export class LifecycleError extends IskraError {
|
|
117
121
|
public readonly failures: PromiseRejectedResult[];
|
|
118
122
|
|
|
119
|
-
constructor(
|
|
123
|
+
constructor(
|
|
124
|
+
message: string,
|
|
125
|
+
options: { failures?: PromiseRejectedResult[]; cause?: Error; context?: Record<string, unknown> },
|
|
126
|
+
) {
|
|
120
127
|
super(message, { code: ErrorCodes.LIFECYCLE_STOP_FAILED, cause: options.cause, context: options.context });
|
|
121
128
|
this.name = 'LifecycleError';
|
|
122
129
|
this.failures = options.failures ?? [];
|
package/src/index.ts
CHANGED
package/src/logger/index.ts
CHANGED
|
@@ -1,38 +1,200 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
|
+
import pretty from 'pino-pretty';
|
|
3
|
+
import { isDevelopmentEnv } from '../env';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Field names whose values are replaced with `[REDACTED]`, at any depth. Keys
|
|
7
|
+
* are compared lowercased and without `-` or `_`, so `apiKey`, `api_key` and
|
|
8
|
+
* `X-API-Key` are the same key.
|
|
9
|
+
*/
|
|
10
|
+
const REDACTED_KEYS = [
|
|
11
|
+
'password',
|
|
12
|
+
'pass',
|
|
13
|
+
'passwd',
|
|
14
|
+
'apiKey',
|
|
15
|
+
'apiSecret',
|
|
16
|
+
'token',
|
|
17
|
+
'authToken',
|
|
18
|
+
'accessToken',
|
|
19
|
+
'refreshToken',
|
|
20
|
+
'idToken',
|
|
21
|
+
'secret',
|
|
22
|
+
'clientSecret',
|
|
23
|
+
'secretKey',
|
|
24
|
+
'privateKey',
|
|
25
|
+
'authorization',
|
|
26
|
+
'proxyAuthorization',
|
|
27
|
+
'cookie',
|
|
28
|
+
'setCookie',
|
|
29
|
+
'sessionId',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Keys ending in one of these are redacted too (normalized the same way):
|
|
34
|
+
* `dbPassword`, `x-api-key`, `x-auth-token`, `AWS_SECRET_ACCESS_KEY`,
|
|
35
|
+
* `webhookSecret`.
|
|
36
|
+
*/
|
|
37
|
+
const REDACTED_SUFFIXES = ['password', 'passwd', 'secret', 'token', 'apikey', 'secretkey', 'privatekey', 'accesskey'];
|
|
38
|
+
|
|
39
|
+
const normalizeKey = (key: string): string => key.toLowerCase().replace(/[-_]/g, '');
|
|
40
|
+
const SENSITIVE = new Set<string>(REDACTED_KEYS.map(normalizeKey));
|
|
41
|
+
const isSensitive = (key: string): boolean => {
|
|
42
|
+
const normalized = normalizeKey(key);
|
|
43
|
+
return SENSITIVE.has(normalized) || REDACTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
|
44
|
+
};
|
|
45
|
+
const CENSOR = '[REDACTED]';
|
|
46
|
+
/** Objects deeper than this are logged as they are. */
|
|
47
|
+
const MAX_DEPTH = 8;
|
|
48
|
+
const IN_PROGRESS = Symbol('in progress');
|
|
49
|
+
|
|
50
|
+
/** The prototype of what pino's err serializer returns (an error's fields, causes included). */
|
|
51
|
+
const SERIALIZED_ERROR_PROTO = Object.getPrototypeOf(pino.stdSerializers.err(new Error()));
|
|
52
|
+
|
|
53
|
+
const isPlainObject = (value: object): boolean => {
|
|
54
|
+
const proto = Object.getPrototypeOf(value);
|
|
55
|
+
return proto === Object.prototype || proto === null || proto === SERIALIZED_ERROR_PROTO;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Query parameter names whose values are masked in messages. */
|
|
59
|
+
const SECRET_PARAM = /pass|secret|token|key|sig|auth/i;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Passwords in `scheme://user:password@host` and secret-looking query
|
|
63
|
+
* parameters (`?authToken=`, `&X-Amz-Signature=`) of a message: drivers put
|
|
64
|
+
* the connection string in the errors they throw. Bounded quantifiers keep
|
|
65
|
+
* both patterns linear on long input.
|
|
66
|
+
*/
|
|
67
|
+
function maskCredentials(text: string): string {
|
|
68
|
+
return text
|
|
69
|
+
.replace(/\b([a-z][\w+.-]{0,30}:\/\/[^\s:@/]{0,256}):[^\s@/]{1,256}@/gi, '$1:[REDACTED]@')
|
|
70
|
+
.replace(/([?&])([\w.-]{1,100})=([^&\s'"#]+)/g, (match, sep: string, name: string) =>
|
|
71
|
+
SECRET_PARAM.test(name) ? `${sep}${name}=[REDACTED]` : match,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An Error as pino's err serializer writes it (type, message and stack with
|
|
77
|
+
* their causes, and its own fields), scrubbed. pino serializes errors after
|
|
78
|
+
* the log formatter runs, so an HTTP client's error (`config.headers
|
|
79
|
+
* .Authorization`) or a Redis error (`command.args` of AUTH) went out as is.
|
|
80
|
+
*/
|
|
81
|
+
function scrubError(err: Error, depth: number, done: WeakMap<object, unknown>): unknown {
|
|
82
|
+
const serialized = { ...pino.stdSerializers.err(err) } as Record<string, unknown>;
|
|
83
|
+
if (typeof serialized.message === 'string') serialized.message = maskCredentials(serialized.message);
|
|
84
|
+
if (typeof serialized.stack === 'string') serialized.stack = maskCredentials(serialized.stack);
|
|
85
|
+
// Deep: an error keeps the client's objects (axios's `request`, whose
|
|
86
|
+
// `_options.headers` hold the Authorization header), which are class
|
|
87
|
+
// instances that JSON.stringify writes out in full.
|
|
88
|
+
return scrubEntries(serialized, depth, done, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replaces the value of every sensitive key, at any depth, in a copy of the
|
|
93
|
+
* objects that contain one (the caller's objects are not modified). pino's
|
|
94
|
+
* own `redact` has no recursive wildcard, and listing each key at every depth
|
|
95
|
+
* made logging 14 times slower. Errors are serialized and scrubbed here (see
|
|
96
|
+
* scrubError), and objects with a toJSON() are scrubbed as what it returns;
|
|
97
|
+
* other class instances are left to pino, except inside an error.
|
|
98
|
+
*/
|
|
99
|
+
function scrub(value: unknown, depth: number, done: WeakMap<object, unknown>, deep = false): unknown {
|
|
100
|
+
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value;
|
|
101
|
+
if (done.has(value)) {
|
|
102
|
+
const result = done.get(value);
|
|
103
|
+
// Still being walked: a cycle, which pino would print as [Circular].
|
|
104
|
+
return result === IN_PROGRESS ? '[Circular]' : result;
|
|
105
|
+
}
|
|
106
|
+
const isError = value instanceof Error;
|
|
107
|
+
const toJSON = (value as { toJSON?: unknown }).toJSON;
|
|
108
|
+
const walk = isError || Array.isArray(value) || isPlainObject(value) || typeof toJSON === 'function' || deep;
|
|
109
|
+
if (!walk) return value;
|
|
110
|
+
done.set(value, IN_PROGRESS);
|
|
111
|
+
let result: unknown;
|
|
112
|
+
if (isError) {
|
|
113
|
+
result = scrubError(value, depth, done);
|
|
114
|
+
} else if (Array.isArray(value)) {
|
|
115
|
+
let copy: unknown[] | undefined;
|
|
116
|
+
value.forEach((item, i) => {
|
|
117
|
+
const scrubbed = scrub(item, depth + 1, done, deep);
|
|
118
|
+
if (scrubbed !== item) (copy ??= value.slice())[i] = scrubbed;
|
|
119
|
+
});
|
|
120
|
+
result = copy ?? value;
|
|
121
|
+
} else if (isPlainObject(value)) {
|
|
122
|
+
result = scrubEntries(value as Record<string, unknown>, depth, done, deep);
|
|
123
|
+
} else if (typeof toJSON === 'function') {
|
|
124
|
+
// Written as its toJSON(), which is what JSON.stringify writes: an axios
|
|
125
|
+
// error's `config.headers` is an AxiosHeaders instance whose
|
|
126
|
+
// Authorization header went out as is.
|
|
127
|
+
result = scrub(toJSON.call(value), depth, done, deep);
|
|
128
|
+
} else {
|
|
129
|
+
// Inside an error: another class instance, as JSON.stringify writes it
|
|
130
|
+
// (its own enumerable fields).
|
|
131
|
+
result = scrubEntries({ ...(value as Record<string, unknown>) }, depth, done, deep);
|
|
132
|
+
}
|
|
133
|
+
done.set(value, result);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** An object with its sensitive keys censored and its values scrubbed; a copy if anything changed. */
|
|
138
|
+
function scrubEntries(
|
|
139
|
+
value: Record<string, unknown>,
|
|
140
|
+
depth: number,
|
|
141
|
+
done: WeakMap<object, unknown>,
|
|
142
|
+
deep = false,
|
|
143
|
+
): Record<string, unknown> {
|
|
144
|
+
let copy: Record<string, unknown> | undefined;
|
|
145
|
+
for (const [key, item] of Object.entries(value)) {
|
|
146
|
+
const scrubbed = isSensitive(key) ? CENSOR : scrub(item, depth + 1, done, deep);
|
|
147
|
+
if (scrubbed !== item) (copy ??= { ...value })[key] = scrubbed;
|
|
148
|
+
}
|
|
149
|
+
// A nested serialized error (pino's prototype) becomes a plain object either way.
|
|
150
|
+
return copy ?? (Object.getPrototypeOf(value) === SERIALIZED_ERROR_PROTO ? { ...value } : value);
|
|
151
|
+
}
|
|
2
152
|
|
|
3
153
|
export const createLogger = (name: string, level: string = 'info') => {
|
|
4
|
-
|
|
5
|
-
|
|
154
|
+
// Pretty output only in development/test: JSON otherwise, NODE_ENV unset included.
|
|
155
|
+
const isDev = isDevelopmentEnv();
|
|
156
|
+
const options: pino.LoggerOptions = {
|
|
6
157
|
name,
|
|
7
158
|
level,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'*.apiSecret',
|
|
17
|
-
'token',
|
|
18
|
-
'*.token',
|
|
19
|
-
'*.authToken',
|
|
20
|
-
'secret',
|
|
21
|
-
'*.secret',
|
|
22
|
-
'config.env',
|
|
23
|
-
'*.data'
|
|
24
|
-
],
|
|
25
|
-
censor: '[REDACTED]'
|
|
159
|
+
formatters: {
|
|
160
|
+
log: (object) => scrub(object, 0, new WeakMap()) as Record<string, unknown>,
|
|
161
|
+
},
|
|
162
|
+
// The formatter above already serialized errors (scrubbed): pino's own
|
|
163
|
+
// err serializer would take that plain object for an error again and
|
|
164
|
+
// rewrite its `type` as "Object".
|
|
165
|
+
serializers: {
|
|
166
|
+
err: (value: unknown) => (value instanceof Error ? scrub(value, 0, new WeakMap()) : value),
|
|
26
167
|
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
168
|
+
hooks: {
|
|
169
|
+
// Messages are strings the formatter never sees: mask connection
|
|
170
|
+
// string passwords there too, including the message pino takes
|
|
171
|
+
// from an error logged on its own (`logger.error(err)`).
|
|
172
|
+
logMethod(args, method) {
|
|
173
|
+
const masked: unknown[] = args.map((arg) => (typeof arg === 'string' ? maskCredentials(arg) : arg));
|
|
174
|
+
if (masked[0] instanceof Error && typeof masked[1] !== 'string') {
|
|
175
|
+
masked.splice(1, 0, maskCredentials(masked[0].message));
|
|
32
176
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
177
|
+
return method.apply(this, masked as Parameters<typeof method>);
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
redact: {
|
|
181
|
+
paths: ['config.env', '*.data'],
|
|
182
|
+
censor: CENSOR,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
// pino-pretty as an in-process stream, not a `transport`: a transport runs
|
|
186
|
+
// in a worker thread that loads the module by name at runtime, which fails
|
|
187
|
+
// in a `bun build --compile` binary and crashed it at startup.
|
|
188
|
+
const logger = isDev ? pino(options, pretty({ colorize: true })) : pino(options);
|
|
189
|
+
|
|
190
|
+
// Bindings (`logger.child({ ... })`) do not go through formatters.log:
|
|
191
|
+
// scrub them here. A child's own children inherit this child().
|
|
192
|
+
type Child = (this: pino.Logger, bindings: pino.Bindings, options?: object) => pino.Logger;
|
|
193
|
+
const child = logger.child as unknown as Child;
|
|
194
|
+
logger.child = function (this: pino.Logger, bindings: pino.Bindings, childOptions?: object) {
|
|
195
|
+
return child.call(this, scrub(bindings, 0, new WeakMap()) as pino.Bindings, childOptions);
|
|
196
|
+
} as unknown as typeof logger.child;
|
|
197
|
+
return logger;
|
|
36
198
|
};
|
|
37
199
|
|
|
38
200
|
export type Logger = pino.Logger;
|