@iskra-bun/core 0.1.1 → 0.3.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,29 +1,84 @@
1
1
  import { z } from 'zod';
2
2
 
3
- export const AppConfigSchema = z.object({
4
- name: z.string().default('IskraApp'),
5
- debug: z.boolean().default(false),
6
- logger: z.object({
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
@@ -26,8 +26,14 @@ export const ErrorCodes = {
26
26
  UNAUTHORIZED: 'UNAUTHORIZED',
27
27
  FORBIDDEN: 'FORBIDDEN',
28
28
  NOT_FOUND: 'NOT_FOUND',
29
+ METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
29
30
  CONFLICT: 'CONFLICT',
31
+ PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
32
+ UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE',
30
33
  VALIDATION_ERROR: 'VALIDATION_ERROR',
34
+ RATE_LIMITED: 'RATE_LIMITED',
35
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
36
+ TIMEOUT: 'TIMEOUT',
31
37
 
32
38
  // Data
33
39
  DATABASE_ERROR: 'DATABASE_ERROR',
@@ -50,7 +56,23 @@ export const ErrorCodes = {
50
56
  SOCKET_MESSAGE_ERROR: 'SOCKET_MESSAGE_ERROR',
51
57
  } as const;
52
58
 
53
- export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
59
+ /**
60
+ * Every error code, as keys: Iskra's own (`ErrorCodes`) and the ones an app
61
+ * adds by merging into this interface, which `ErrorCode` then accepts:
62
+ *
63
+ * ```ts
64
+ * declare module '@iskra-bun/core' {
65
+ * interface ErrorCodeRegistry {
66
+ * ORDER_LOCKED: true;
67
+ * }
68
+ * }
69
+ * throw new HttpError(423, 'The order is being edited', { code: 'ORDER_LOCKED' });
70
+ * ```
71
+ */
72
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- extended by declaration merging
73
+ export interface ErrorCodeRegistry extends Record<(typeof ErrorCodes)[keyof typeof ErrorCodes], true> {}
74
+
75
+ export type ErrorCode = keyof ErrorCodeRegistry & string;
54
76
 
55
77
  export interface IskraErrorOptions {
56
78
  code: ErrorCode;
@@ -97,7 +119,11 @@ export class ConfigError extends IskraError {
97
119
 
98
120
  export class DriverError extends IskraError {
99
121
  constructor(message: string, options?: { code?: ErrorCode; cause?: Error; context?: Record<string, unknown> }) {
100
- super(message, { code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED, cause: options?.cause, context: options?.context });
122
+ super(message, {
123
+ code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED,
124
+ cause: options?.cause,
125
+ context: options?.context,
126
+ });
101
127
  this.name = 'DriverError';
102
128
  }
103
129
  }
@@ -116,7 +142,10 @@ export class PluginError extends IskraError {
116
142
  export class LifecycleError extends IskraError {
117
143
  public readonly failures: PromiseRejectedResult[];
118
144
 
119
- constructor(message: string, options: { failures?: PromiseRejectedResult[]; cause?: Error; context?: Record<string, unknown> }) {
145
+ constructor(
146
+ message: string,
147
+ options: { failures?: PromiseRejectedResult[]; cause?: Error; context?: Record<string, unknown> },
148
+ ) {
120
149
  super(message, { code: ErrorCodes.LIFECYCLE_STOP_FAILED, cause: options.cause, context: options.context });
121
150
  this.name = 'LifecycleError';
122
151
  this.failures = options.failures ?? [];
package/src/index.ts CHANGED
@@ -3,5 +3,7 @@ export * from './types';
3
3
  export * from './logger';
4
4
  export * from './errors';
5
5
  export * from './otel';
6
+ export { traced, type TracedOptions } from './traced';
7
+ export * from './env';
6
8
  export * from './config/loader';
7
9
  export * from './config/schema';
@@ -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
- const isDev = process.env.NODE_ENV !== 'production';
5
- return pino({
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
- redact: {
9
- paths: [
10
- 'password',
11
- '*.password',
12
- 'pass',
13
- '*.pass',
14
- 'apiKey',
15
- '*.apiKey',
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
- ...(isDev && {
28
- transport: {
29
- target: 'pino-pretty',
30
- options: {
31
- colorize: true
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;