@naturalcycles/nodejs-lib 15.123.1 → 15.125.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,4 +1,11 @@
1
+ import type { LogEntry } from '@naturalcycles/js-lib/log';
2
+ import type { InspectAnyOptions } from '../string/inspect.js';
1
3
  /**
2
4
  * CommonLogger that logs to process.stdout directly (bypassing console.log)!
3
5
  */
4
6
  export declare const stdoutLogger: any;
7
+ /**
8
+ * Renders a LogEntry as one line: the msg, the inspected err and the inspected remaining fields,
9
+ * separated by a space.
10
+ */
11
+ export declare function logEntryToString({ level: _level, msg, err, ...fields }: LogEntry, opt?: InspectAnyOptions): string;
@@ -3,6 +3,20 @@ import { _inspect } from '../string/inspect.js';
3
3
  /**
4
4
  * CommonLogger that logs to process.stdout directly (bypassing console.log)!
5
5
  */
6
- export const stdoutLogger = commonLoggerCreate((_level, args) => {
7
- process.stdout.write(args.map(a => _inspect(a)).join(' ') + '\n');
6
+ export const stdoutLogger = commonLoggerCreate(entry => {
7
+ process.stdout.write(logEntryToString(entry) + '\n');
8
8
  });
9
+ /**
10
+ * Renders a LogEntry as one line: the msg, the inspected err and the inspected remaining fields,
11
+ * separated by a space.
12
+ */
13
+ export function logEntryToString({ level: _level, msg, err, ...fields }, opt = {}) {
14
+ const parts = [];
15
+ if (msg !== undefined)
16
+ parts.push(msg);
17
+ if (err !== undefined)
18
+ parts.push(_inspect(err, { includeErrorStack: true, ...opt }));
19
+ if (Object.keys(fields).length)
20
+ parts.push(_inspect(fields, opt));
21
+ return parts.join(' ');
22
+ }
@@ -7,7 +7,7 @@ export interface RunScriptOptions {
7
7
  */
8
8
  noExit?: boolean;
9
9
  /**
10
- * Default to `console`
10
+ * Default to `consoleLogger`
11
11
  */
12
12
  logger?: CommonLogger;
13
13
  /**
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import { AsyncManager } from '@naturalcycles/js-lib';
3
+ import { consoleLogger } from '@naturalcycles/js-lib/log';
3
4
  import { setGlobalStringifyFunction } from '@naturalcycles/js-lib/string/stringify.js';
4
5
  import { dimGrey } from '../colors/colors.js';
5
6
  import { loadEnvFileIfExists } from '../node.util.js';
@@ -27,13 +28,13 @@ const { DEBUG_RUN_SCRIPT } = process.env;
27
28
  export function runScript(fn, opt = {}) {
28
29
  checkAndlogEnvironment();
29
30
  setGlobalStringifyFunction(inspectStringifyFn);
30
- const { logger = console, noExit, registerUncaughtExceptionHandlers = true } = opt;
31
+ const { logger = consoleLogger, noExit, registerUncaughtExceptionHandlers = true } = opt;
31
32
  if (registerUncaughtExceptionHandlers || DEBUG_RUN_SCRIPT) {
32
33
  process.on('uncaughtException', err => {
33
- logger.error('runScript uncaughtException:', err);
34
+ logger.error({ msg: 'runScript uncaughtException:', err });
34
35
  });
35
36
  process.on('unhandledRejection', err => {
36
- logger.error('runScript unhandledRejection:', err);
37
+ logger.error({ msg: 'runScript unhandledRejection:', err });
37
38
  });
38
39
  }
39
40
  if (DEBUG_RUN_SCRIPT) {
@@ -54,7 +55,7 @@ export function runScript(fn, opt = {}) {
54
55
  }
55
56
  }
56
57
  catch (err) {
57
- logger.error('runScript error:', err);
58
+ logger.error({ msg: 'runScript error:', err });
58
59
  process.exitCode = 1;
59
60
  if (!noExit) {
60
61
  setImmediate(() => process.exit(1));
@@ -1,7 +1,7 @@
1
1
  import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js';
2
2
  import { getFetcher } from '@naturalcycles/js-lib/http';
3
- import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log';
4
- import { _omit } from '@naturalcycles/js-lib/object/object.util.js';
3
+ import { commonLoggerCreate, consoleLogger, createCommonLoggerAtLevel, } from '@naturalcycles/js-lib/log';
4
+ import { _filterFalsyValues, _omit } from '@naturalcycles/js-lib/object/object.util.js';
5
5
  import { PQueue } from '@naturalcycles/js-lib/promise/pQueue.js';
6
6
  import { _inspect } from '../index.js';
7
7
  const GAE = !!process.env['GAE_INSTANCE'];
@@ -31,7 +31,7 @@ export class SlackService {
31
31
  constructor(cfg) {
32
32
  this.cfg = {
33
33
  messagePrefixHook: slackDefaultMessagePrefixHook,
34
- logger: console,
34
+ logger: consoleLogger,
35
35
  ...cfg,
36
36
  inspectOptions: {
37
37
  ...INSPECT_OPT,
@@ -60,7 +60,8 @@ export class SlackService {
60
60
  if (ctx !== undefined) {
61
61
  Object.assign(msg, { ctx });
62
62
  }
63
- this.cfg.logger.log(...[msg.items, msg.kv, msg.attachments, msg.mentions].filter(Boolean));
63
+ const { items, kv, attachments, mentions } = msg;
64
+ this.cfg.logger.log(_filterFalsyValues({ msg: _inspect(items, inspectOptions), kv, attachments, mentions }));
64
65
  if (!webhookUrl)
65
66
  return;
66
67
  // Transform msg.kv into msg.attachments
@@ -122,12 +123,22 @@ export class SlackService {
122
123
  const q = new PQueue({
123
124
  concurrency: 1,
124
125
  });
125
- return createCommonLoggerAtLevel({
126
- debug: (...args) => q.push(() => this.send({ items: args, channel: debugChannel || defaultChannel })),
127
- log: (...args) => q.push(() => this.send({ items: args, channel: logChannel || defaultChannel })),
128
- warn: (...args) => q.push(() => this.send({ items: args, channel: warnChannel || defaultChannel })),
129
- error: (...args) => q.push(() => this.send({ items: args, channel: errorChannel || defaultChannel })),
130
- }, minLogLevel);
126
+ const channelByLevel = {
127
+ debug: debugChannel || defaultChannel,
128
+ log: logChannel || defaultChannel,
129
+ warn: warnChannel || defaultChannel,
130
+ error: errorChannel || defaultChannel,
131
+ };
132
+ return createCommonLoggerAtLevel(commonLoggerCreate(({ level, msg, err, ...fields }) => {
133
+ const items = [];
134
+ if (msg !== undefined)
135
+ items.push(msg);
136
+ if (err !== undefined)
137
+ items.push(err);
138
+ if (Object.keys(fields).length)
139
+ items.push(fields);
140
+ void q.push(() => this.send({ items, channel: channelByLevel[level] }));
141
+ }), minLogLevel);
131
142
  }
132
143
  }
133
144
  export function slackDefaultMessagePrefixHook(msg) {
@@ -1,6 +1,7 @@
1
1
  import { inspect } from 'node:util';
2
2
  import { _hc, _mb } from '@naturalcycles/js-lib';
3
3
  import { _since, localTime } from '@naturalcycles/js-lib/datetime';
4
+ import { consoleLogger } from '@naturalcycles/js-lib/log';
4
5
  import { SimpleMovingAverage } from '@naturalcycles/js-lib/math';
5
6
  import { boldWhite, dimGrey, hasColors, white, yellow } from '../colors/colors.js';
6
7
  const inspectOpt = {
@@ -17,7 +18,7 @@ export class ProgressLogger {
17
18
  logEvery: 1000,
18
19
  logSizesBuffer: 100_000,
19
20
  chunkSize: 1,
20
- logger: console,
21
+ logger: consoleLogger,
21
22
  logProgress: cfg.logProgress !== false && cfg.logEvery !== 0,
22
23
  ...cfg,
23
24
  };
@@ -100,7 +101,7 @@ export class ProgressLogger {
100
101
  this.cfg.onProgressDone?.(o);
101
102
  }
102
103
  catch (err) {
103
- logger.error(err);
104
+ logger.error({ err });
104
105
  }
105
106
  }
106
107
  }
@@ -38,7 +38,7 @@ export interface TransformOptions {
38
38
  */
39
39
  highWaterMark?: number;
40
40
  /**
41
- * Defaults to `console`.
41
+ * Defaults to `consoleLogger`.
42
42
  */
43
43
  logger?: CommonLogger;
44
44
  /**
@@ -85,7 +85,7 @@ export function transformMap(mapper, opt = {}) {
85
85
  }
86
86
  }
87
87
  catch (err) {
88
- logger.error(err);
88
+ logger.error({ err });
89
89
  errors++;
90
90
  logErrorStats();
91
91
  if (onError) {
@@ -167,7 +167,7 @@ export function transformMap(mapper, opt = {}) {
167
167
  });
168
168
  }
169
169
  catch (err) {
170
- logger.error(err);
170
+ logger.error({ err });
171
171
  }
172
172
  }
173
173
  }
@@ -1,5 +1,6 @@
1
1
  import { Transform } from 'node:stream';
2
2
  import { ErrorMode } from '@naturalcycles/js-lib/error/errorMode.js';
3
+ import { consoleLogger } from '@naturalcycles/js-lib/log';
3
4
  /**
4
5
  * Simplest version of `transformMap`.
5
6
  * errorMode: IMMEDIATE
@@ -11,7 +12,7 @@ import { ErrorMode } from '@naturalcycles/js-lib/error/errorMode.js';
11
12
  */
12
13
  export function transformMapSimple(mapper, opt = {}) {
13
14
  let index = -1;
14
- const { errorMode = ErrorMode.THROW_IMMEDIATELY, logger = console, objectMode = true, highWaterMark = 1, } = opt;
15
+ const { errorMode = ErrorMode.THROW_IMMEDIATELY, logger = consoleLogger, objectMode = true, highWaterMark = 1, } = opt;
15
16
  return new Transform({
16
17
  objectMode,
17
18
  highWaterMark,
@@ -20,7 +21,7 @@ export function transformMapSimple(mapper, opt = {}) {
20
21
  cb(null, mapper(chunk, ++index));
21
22
  }
22
23
  catch (err) {
23
- logger.error(err);
24
+ logger.error({ err });
24
25
  if (errorMode === ErrorMode.SUPPRESS) {
25
26
  cb(); // suppress the error
26
27
  }
@@ -48,7 +48,7 @@ export function transformMapSync(mapper, opt = {}) {
48
48
  cb(); // done processing
49
49
  }
50
50
  catch (err) {
51
- logger.error(err);
51
+ logger.error({ err });
52
52
  errors++;
53
53
  logErrorStats();
54
54
  if (onError) {
@@ -70,7 +70,7 @@ export function transformMapSync(mapper, opt = {}) {
70
70
  });
71
71
  }
72
72
  catch (err) {
73
- logger.error(err);
73
+ logger.error({ err });
74
74
  }
75
75
  // Emit error immediately
76
76
  return cb(err);
@@ -96,7 +96,7 @@ export function transformMapSync(mapper, opt = {}) {
96
96
  });
97
97
  }
98
98
  catch (err) {
99
- logger.error(err);
99
+ logger.error({ err });
100
100
  }
101
101
  // emit Aggregated error
102
102
  cb(new AggregateError(collectedErrors, `transformMapSync resulted in ${collectedErrors.length} error(s)`));
@@ -114,7 +114,7 @@ export function transformMapSync(mapper, opt = {}) {
114
114
  });
115
115
  }
116
116
  catch (err) {
117
- logger.error(err);
117
+ logger.error({ err });
118
118
  }
119
119
  cb();
120
120
  }
@@ -4,15 +4,16 @@ import type { TransformTyped } from '../stream.model.js';
4
4
  *
5
5
  * Useful for reading NDJSON files from fs.
6
6
  *
7
- * Same as binarySplit, but optimized (hard-coded) to split on NEWLINE (aka `\n`).
8
- * (+5-10% _pipeline speedup measured, compared to generic `binarySplit` on variable length delimiter)
7
+ * Same as `transformSplit('\n')`.
8
+ * (It used to be a separate hard-coded implementation, +5-10% faster than the generic one,
9
+ * but since the generic one searches 1-byte separators by byte value there's no difference anymore)
9
10
  */
10
11
  export declare function transformSplitOnNewline(): TransformTyped<Buffer, Buffer>;
11
12
  /**
12
13
  * Input: stream (objectMode=false) of arbitrary string|Buffer chunks, like when read from fs
13
14
  * Output: stream (objectMode=true) or string|Buffer chunks split by `separator` (@default to `\n`)
14
15
  *
15
- * Please use slightly more optimized `transformSplitOnNewline` for NDJSON file parsing.
16
- * (+5-10% _pipeline speedup measured!)
16
+ * Empty lines (consecutive separators) are skipped.
17
+ * Trailing data without a separator is emitted on flush.
17
18
  */
18
19
  export declare function transformSplit(separator?: string): TransformTyped<Buffer, Buffer>;
@@ -1,133 +1,90 @@
1
1
  import { Transform } from 'node:stream';
2
2
  // The code below is carefully adopted from: https://github.com/max-mapper/binary-split
3
+ // (including the v2.0.0 performance improvements: native Buffer#indexOf and linear-time long lines)
3
4
  /**
4
5
  * Transforms input Buffer/string stream into Buffer chunks (objectMode: true) split by newLine.
5
6
  *
6
7
  * Useful for reading NDJSON files from fs.
7
8
  *
8
- * Same as binarySplit, but optimized (hard-coded) to split on NEWLINE (aka `\n`).
9
- * (+5-10% _pipeline speedup measured, compared to generic `binarySplit` on variable length delimiter)
9
+ * Same as `transformSplit('\n')`.
10
+ * (It used to be a separate hard-coded implementation, +5-10% faster than the generic one,
11
+ * but since the generic one searches 1-byte separators by byte value there's no difference anymore)
10
12
  */
11
13
  export function transformSplitOnNewline() {
12
- let buffered;
13
- return new Transform({
14
- writableObjectMode: false,
15
- writableHighWaterMark: 64 * 1024,
16
- readableObjectMode: true,
17
- transform(buf, _enc, cb) {
18
- let offset = 0;
19
- let lastMatch = 0;
20
- if (buffered) {
21
- buf = Buffer.concat([buffered, buf]);
22
- offset = buffered.length;
23
- buffered = undefined;
24
- }
25
- while (true) {
26
- const idx = firstNewlineMatch(buf, offset);
27
- if (idx !== -1 && idx < buf.length) {
28
- if (lastMatch !== idx) {
29
- this.push(buf.slice(lastMatch, idx));
30
- }
31
- offset = idx + 1;
32
- lastMatch = offset;
33
- }
34
- else {
35
- buffered = buf.slice(lastMatch);
36
- break;
37
- }
38
- }
39
- cb();
40
- },
41
- flush(done) {
42
- if (buffered && buffered.length > 0)
43
- this.push(buffered);
44
- done();
45
- },
46
- });
14
+ return transformSplit('\n');
47
15
  }
48
16
  /**
49
17
  * Input: stream (objectMode=false) of arbitrary string|Buffer chunks, like when read from fs
50
18
  * Output: stream (objectMode=true) or string|Buffer chunks split by `separator` (@default to `\n`)
51
19
  *
52
- * Please use slightly more optimized `transformSplitOnNewline` for NDJSON file parsing.
53
- * (+5-10% _pipeline speedup measured!)
20
+ * Empty lines (consecutive separators) are skipped.
21
+ * Trailing data without a separator is emitted on flush.
54
22
  */
55
23
  export function transformSplit(separator = '\n') {
56
24
  const matcher = Buffer.from(separator);
57
- let buffered;
25
+ if (matcher.length === 0) {
26
+ throw new Error('transformSplit: separator must not be empty');
27
+ }
28
+ const matcherLength = matcher.length;
29
+ // indexOf with a byte value is much faster than with a 1-byte Buffer, which matters for short lines
30
+ const needle = matcherLength === 1 ? matcher[0] : matcher;
31
+ // a multi-byte separator may straddle a chunk boundary, so we keep that many trailing bytes around
32
+ const overlap = matcherLength - 1;
33
+ // Unterminated data is collected as a list of chunks and concatenated only once a separator arrives,
34
+ // so a long line spanning many chunks is copied once rather than on every chunk (linear, not quadratic)
35
+ let pending = [];
36
+ let pendingLength = 0;
37
+ let edge; // last `overlap` bytes of pending data, only tracked when overlap > 0
58
38
  return new Transform({
59
- readableObjectMode: true,
39
+ writableObjectMode: false,
60
40
  writableHighWaterMark: 64 * 1024,
61
- transform(buf, _enc, done) {
41
+ readableObjectMode: true,
42
+ transform(chunk, _enc, cb) {
43
+ let buf = chunk;
62
44
  let offset = 0;
63
- let lastMatch = 0;
64
- if (buffered) {
65
- buf = Buffer.concat([buffered, buf]);
66
- offset = buffered.length;
67
- buffered = undefined;
68
- }
69
- while (true) {
70
- const idx = firstMatch(buf, offset - matcher.length + 1, matcher);
71
- if (idx !== -1 && idx < buf.length) {
72
- if (lastMatch !== idx) {
73
- this.push(buf.slice(lastMatch, idx));
45
+ if (pendingLength > 0) {
46
+ const straddles = overlap > 0 && Buffer.concat([edge, chunk.subarray(0, overlap)]).includes(matcher);
47
+ const idxInChunk = straddles ? -1 : chunk.indexOf(needle);
48
+ if (!straddles && idxInChunk === -1) {
49
+ // No separator yet: keep accumulating without copying
50
+ pending.push(chunk);
51
+ pendingLength += chunk.length;
52
+ if (overlap > 0) {
53
+ edge = Buffer.concat([edge, chunk.subarray(-overlap)]).subarray(-overlap);
74
54
  }
75
- offset = idx + matcher.length;
76
- lastMatch = offset;
55
+ cb();
56
+ return;
77
57
  }
78
- else {
79
- buffered = buf.slice(lastMatch);
80
- break;
58
+ pending.push(chunk);
59
+ buf = Buffer.concat(pending, pendingLength + chunk.length);
60
+ // Start scanning where the first separator can possibly be
61
+ offset = straddles ? Math.max(0, pendingLength - overlap) : pendingLength + idxInChunk;
62
+ pending = [];
63
+ pendingLength = 0;
64
+ }
65
+ let start = 0;
66
+ let idx = buf.indexOf(needle, offset);
67
+ while (idx !== -1) {
68
+ if (idx > start) {
69
+ this.push(buf.subarray(start, idx));
81
70
  }
71
+ start = idx + matcherLength;
72
+ idx = buf.indexOf(needle, start);
82
73
  }
83
- done();
74
+ if (start < buf.length) {
75
+ const rest = buf.subarray(start);
76
+ pending.push(rest);
77
+ pendingLength = rest.length;
78
+ if (overlap > 0)
79
+ edge = rest.subarray(-overlap);
80
+ }
81
+ cb();
84
82
  },
85
- flush(done) {
86
- if (buffered && buffered.length > 0)
87
- this.push(buffered);
88
- done();
83
+ flush(cb) {
84
+ if (pendingLength > 0) {
85
+ this.push(Buffer.concat(pending, pendingLength));
86
+ }
87
+ cb();
89
88
  },
90
89
  });
91
90
  }
92
- // const NEWLINE = Buffer.from('\n')
93
- // const NEWLINE_CODE = NEWLINE[0]! // it is `10`
94
- const NEWLINE_CODE = 10;
95
- /**
96
- * Same as firstMatch, but optimized (hard-coded) to find NEWLINE (aka `\n`).
97
- */
98
- function firstNewlineMatch(buf, offset) {
99
- const bufLength = buf.length;
100
- if (offset >= bufLength)
101
- return -1;
102
- for (let i = offset; i < bufLength; i++) {
103
- if (buf[i] === NEWLINE_CODE) {
104
- return i;
105
- }
106
- }
107
- return -1; // this code is unreachable, because i is guaranteed to be found in the loop above
108
- }
109
- function firstMatch(buf, offset, matcher) {
110
- if (offset >= buf.length)
111
- return -1;
112
- let i;
113
- for (i = offset; i < buf.length; i++) {
114
- if (buf[i] === matcher[0]) {
115
- if (matcher.length > 1) {
116
- let fullMatch = true;
117
- let j = i;
118
- for (let k = 0; j < i + matcher.length; j++, k++) {
119
- if (buf[j] !== matcher[k]) {
120
- fullMatch = false;
121
- break;
122
- }
123
- }
124
- if (fullMatch)
125
- return j - matcher.length;
126
- }
127
- else {
128
- break;
129
- }
130
- }
131
- }
132
- return i + matcher.length - 1;
133
- }
@@ -1,4 +1,5 @@
1
1
  import { Transform } from 'node:stream';
2
+ import { consoleLogger } from '@naturalcycles/js-lib/log';
2
3
  /**
3
4
  * Similar to RxJS `tap` - allows to run a function for each stream item, without affecting the result.
4
5
  * Item is passed through to the output.
@@ -6,7 +7,7 @@ import { Transform } from 'node:stream';
6
7
  * Can also act as a counter, since `index` is passed to `fn`
7
8
  */
8
9
  export function transformTap(fn, opt = {}) {
9
- const { logger = console, highWaterMark = 1 } = opt;
10
+ const { logger = consoleLogger, highWaterMark = 1 } = opt;
10
11
  let index = -1;
11
12
  return new Transform({
12
13
  objectMode: true,
@@ -16,7 +17,7 @@ export function transformTap(fn, opt = {}) {
16
17
  await fn(chunk, ++index);
17
18
  }
18
19
  catch (err) {
19
- logger.error(err);
20
+ logger.error({ err });
20
21
  // suppressed error
21
22
  }
22
23
  cb(null, chunk);
@@ -27,7 +28,7 @@ export function transformTap(fn, opt = {}) {
27
28
  * Sync version of transformTap
28
29
  */
29
30
  export function transformTapSync(fn, opt = {}) {
30
- const { logger = console, highWaterMark = 1 } = opt;
31
+ const { logger = consoleLogger, highWaterMark = 1 } = opt;
31
32
  let index = -1;
32
33
  return new Transform({
33
34
  objectMode: true,
@@ -37,7 +38,7 @@ export function transformTapSync(fn, opt = {}) {
37
38
  fn(chunk, ++index);
38
39
  }
39
40
  catch (err) {
40
- logger.error(err);
41
+ logger.error({ err });
41
42
  // suppressed error
42
43
  }
43
44
  cb(null, chunk);
@@ -34,7 +34,8 @@ export function transformThrottle(opt) {
34
34
  if (!start) {
35
35
  start = localTime.nowUnixMillis();
36
36
  timeout = setTimeout(() => onInterval(), interval * 1000);
37
- logger.log(`${localTime.now().toPretty()} transformThrottle started with`, {
37
+ logger.log({
38
+ msg: `${localTime.now().toPretty()} transformThrottle started with`,
38
39
  throughput,
39
40
  interval,
40
41
  rps: Math.round(throughput / interval),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.123.1",
4
+ "version": "15.125.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -1,9 +1,26 @@
1
1
  import { commonLoggerCreate } from '@naturalcycles/js-lib/log'
2
+ import type { LogEntry } from '@naturalcycles/js-lib/log'
2
3
  import { _inspect } from '../string/inspect.js'
4
+ import type { InspectAnyOptions } from '../string/inspect.js'
3
5
 
4
6
  /**
5
7
  * CommonLogger that logs to process.stdout directly (bypassing console.log)!
6
8
  */
7
- export const stdoutLogger = commonLoggerCreate((_level, args) => {
8
- process.stdout.write(args.map(a => _inspect(a)).join(' ') + '\n')
9
+ export const stdoutLogger = commonLoggerCreate(entry => {
10
+ process.stdout.write(logEntryToString(entry) + '\n')
9
11
  })
12
+
13
+ /**
14
+ * Renders a LogEntry as one line: the msg, the inspected err and the inspected remaining fields,
15
+ * separated by a space.
16
+ */
17
+ export function logEntryToString(
18
+ { level: _level, msg, err, ...fields }: LogEntry,
19
+ opt: InspectAnyOptions = {},
20
+ ): string {
21
+ const parts: string[] = []
22
+ if (msg !== undefined) parts.push(msg)
23
+ if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, ...opt }))
24
+ if (Object.keys(fields).length) parts.push(_inspect(fields, opt))
25
+ return parts.join(' ')
26
+ }
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os'
2
2
  import { AsyncManager } from '@naturalcycles/js-lib'
3
+ import { consoleLogger } from '@naturalcycles/js-lib/log'
3
4
  import type { CommonLogger } from '@naturalcycles/js-lib/log'
4
5
  import { setGlobalStringifyFunction } from '@naturalcycles/js-lib/string/stringify.js'
5
6
  import type { AnyObject } from '@naturalcycles/js-lib/types'
@@ -18,7 +19,7 @@ export interface RunScriptOptions {
18
19
  noExit?: boolean
19
20
 
20
21
  /**
21
- * Default to `console`
22
+ * Default to `consoleLogger`
22
23
  */
23
24
  logger?: CommonLogger
24
25
 
@@ -53,14 +54,14 @@ export function runScript(fn: (...args: any[]) => any, opt: RunScriptOptions = {
53
54
  checkAndlogEnvironment()
54
55
  setGlobalStringifyFunction(inspectStringifyFn)
55
56
 
56
- const { logger = console, noExit, registerUncaughtExceptionHandlers = true } = opt
57
+ const { logger = consoleLogger, noExit, registerUncaughtExceptionHandlers = true } = opt
57
58
 
58
59
  if (registerUncaughtExceptionHandlers || DEBUG_RUN_SCRIPT) {
59
60
  process.on('uncaughtException', err => {
60
- logger.error('runScript uncaughtException:', err)
61
+ logger.error({ msg: 'runScript uncaughtException:', err })
61
62
  })
62
63
  process.on('unhandledRejection', err => {
63
- logger.error('runScript unhandledRejection:', err)
64
+ logger.error({ msg: 'runScript unhandledRejection:', err })
64
65
  })
65
66
  }
66
67
 
@@ -85,7 +86,7 @@ export function runScript(fn: (...args: any[]) => any, opt: RunScriptOptions = {
85
86
  setImmediate(() => process.exit(0))
86
87
  }
87
88
  } catch (err) {
88
- logger.error('runScript error:', err)
89
+ logger.error({ msg: 'runScript error:', err })
89
90
  process.exitCode = 1
90
91
  if (!noExit) {
91
92
  setImmediate(() => process.exit(1))
@@ -1,9 +1,13 @@
1
1
  import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js'
2
2
  import { getFetcher } from '@naturalcycles/js-lib/http'
3
3
  import type { Fetcher } from '@naturalcycles/js-lib/http'
4
- import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log'
4
+ import {
5
+ commonLoggerCreate,
6
+ consoleLogger,
7
+ createCommonLoggerAtLevel,
8
+ } from '@naturalcycles/js-lib/log'
5
9
  import type { CommonLogger, CommonLogLevel } from '@naturalcycles/js-lib/log'
6
- import { _omit } from '@naturalcycles/js-lib/object/object.util.js'
10
+ import { _filterFalsyValues, _omit } from '@naturalcycles/js-lib/object/object.util.js'
7
11
  import { PQueue } from '@naturalcycles/js-lib/promise/pQueue.js'
8
12
  import type { AnyObject } from '@naturalcycles/js-lib/types'
9
13
  import { _inspect } from '../index.js'
@@ -45,7 +49,7 @@ export class SlackService<CTX = any> {
45
49
  constructor(cfg: Partial<SlackServiceCfg<CTX>>) {
46
50
  this.cfg = {
47
51
  messagePrefixHook: slackDefaultMessagePrefixHook,
48
- logger: console,
52
+ logger: consoleLogger,
49
53
  ...cfg,
50
54
  inspectOptions: {
51
55
  ...INSPECT_OPT,
@@ -82,7 +86,10 @@ export class SlackService<CTX = any> {
82
86
  Object.assign(msg, { ctx })
83
87
  }
84
88
 
85
- this.cfg.logger.log(...[msg.items, msg.kv, msg.attachments, msg.mentions].filter(Boolean))
89
+ const { items, kv, attachments, mentions } = msg
90
+ this.cfg.logger.log(
91
+ _filterFalsyValues({ msg: _inspect(items, inspectOptions), kv, attachments, mentions }),
92
+ )
86
93
 
87
94
  if (!webhookUrl) return
88
95
 
@@ -163,17 +170,21 @@ export class SlackService<CTX = any> {
163
170
  concurrency: 1,
164
171
  })
165
172
 
173
+ const channelByLevel: Record<CommonLogLevel, string> = {
174
+ debug: debugChannel || defaultChannel,
175
+ log: logChannel || defaultChannel,
176
+ warn: warnChannel || defaultChannel,
177
+ error: errorChannel || defaultChannel,
178
+ }
179
+
166
180
  return createCommonLoggerAtLevel(
167
- {
168
- debug: (...args) =>
169
- q.push(() => this.send({ items: args, channel: debugChannel || defaultChannel })),
170
- log: (...args) =>
171
- q.push(() => this.send({ items: args, channel: logChannel || defaultChannel })),
172
- warn: (...args) =>
173
- q.push(() => this.send({ items: args, channel: warnChannel || defaultChannel })),
174
- error: (...args) =>
175
- q.push(() => this.send({ items: args, channel: errorChannel || defaultChannel })),
176
- },
181
+ commonLoggerCreate(({ level, msg, err, ...fields }) => {
182
+ const items: unknown[] = []
183
+ if (msg !== undefined) items.push(msg)
184
+ if (err !== undefined) items.push(err)
185
+ if (Object.keys(fields).length) items.push(fields)
186
+ void q.push(() => this.send({ items, channel: channelByLevel[level] }))
187
+ }),
177
188
  minLogLevel,
178
189
  )
179
190
  }
@@ -2,6 +2,7 @@ import type { InspectOptions } from 'node:util'
2
2
  import { inspect } from 'node:util'
3
3
  import { _hc, _mb } from '@naturalcycles/js-lib'
4
4
  import { _since, localTime } from '@naturalcycles/js-lib/datetime'
5
+ import { consoleLogger } from '@naturalcycles/js-lib/log'
5
6
  import type { CommonLogger } from '@naturalcycles/js-lib/log'
6
7
  import { SimpleMovingAverage } from '@naturalcycles/js-lib/math'
7
8
  import type { AnyObject, PositiveInteger, UnixTimestampMillis } from '@naturalcycles/js-lib/types'
@@ -140,7 +141,7 @@ export class ProgressLogger<T> implements Disposable {
140
141
  logEvery: 1000,
141
142
  logSizesBuffer: 100_000,
142
143
  chunkSize: 1,
143
- logger: console,
144
+ logger: consoleLogger,
144
145
  logProgress: cfg.logProgress !== false && cfg.logEvery !== 0,
145
146
  ...cfg,
146
147
  }
@@ -258,7 +259,7 @@ export class ProgressLogger<T> implements Disposable {
258
259
  try {
259
260
  this.cfg.onProgressDone?.(o)
260
261
  } catch (err) {
261
- logger.error(err)
262
+ logger.error({ err })
262
263
  }
263
264
  }
264
265
  }
@@ -66,7 +66,7 @@ export interface TransformOptions {
66
66
  highWaterMark?: number
67
67
 
68
68
  /**
69
- * Defaults to `console`.
69
+ * Defaults to `consoleLogger`.
70
70
  */
71
71
  logger?: CommonLogger
72
72
 
@@ -186,7 +186,7 @@ export function transformMap<IN = any, OUT = IN>(
186
186
  this.push(res)
187
187
  }
188
188
  } catch (err) {
189
- logger.error(err)
189
+ logger.error({ err })
190
190
  errors++
191
191
  logErrorStats()
192
192
 
@@ -278,7 +278,7 @@ export function transformMap<IN = any, OUT = IN>(
278
278
  started,
279
279
  })
280
280
  } catch (err) {
281
- logger.error(err)
281
+ logger.error({ err })
282
282
  }
283
283
  }
284
284
  }
@@ -1,5 +1,6 @@
1
1
  import { Transform } from 'node:stream'
2
2
  import { ErrorMode } from '@naturalcycles/js-lib/error/errorMode.js'
3
+ import { consoleLogger } from '@naturalcycles/js-lib/log'
3
4
  import type { IndexedMapper } from '@naturalcycles/js-lib/types'
4
5
  import type { TransformOptions, TransformTyped } from '../stream.model.js'
5
6
 
@@ -28,7 +29,7 @@ export function transformMapSimple<IN = any, OUT = IN>(
28
29
  let index = -1
29
30
  const {
30
31
  errorMode = ErrorMode.THROW_IMMEDIATELY,
31
- logger = console,
32
+ logger = consoleLogger,
32
33
  objectMode = true,
33
34
  highWaterMark = 1,
34
35
  } = opt
@@ -40,7 +41,7 @@ export function transformMapSimple<IN = any, OUT = IN>(
40
41
  try {
41
42
  cb(null, mapper(chunk, ++index))
42
43
  } catch (err) {
43
- logger.error(err)
44
+ logger.error({ err })
44
45
 
45
46
  if (errorMode === ErrorMode.SUPPRESS) {
46
47
  cb() // suppress the error
@@ -120,7 +120,7 @@ export function transformMapSync<IN = any, OUT = IN>(
120
120
 
121
121
  cb() // done processing
122
122
  } catch (err) {
123
- logger.error(err)
123
+ logger.error({ err })
124
124
  errors++
125
125
 
126
126
  logErrorStats()
@@ -144,7 +144,7 @@ export function transformMapSync<IN = any, OUT = IN>(
144
144
  started,
145
145
  })
146
146
  } catch (err) {
147
- logger.error(err)
147
+ logger.error({ err })
148
148
  }
149
149
 
150
150
  // Emit error immediately
@@ -174,7 +174,7 @@ export function transformMapSync<IN = any, OUT = IN>(
174
174
  started,
175
175
  })
176
176
  } catch (err) {
177
- logger.error(err)
177
+ logger.error({ err })
178
178
  }
179
179
 
180
180
  // emit Aggregated error
@@ -197,7 +197,7 @@ export function transformMapSync<IN = any, OUT = IN>(
197
197
  started,
198
198
  })
199
199
  } catch (err) {
200
- logger.error(err)
200
+ logger.error({ err })
201
201
  }
202
202
 
203
203
  cb()
@@ -2,142 +2,101 @@ import { Transform } from 'node:stream'
2
2
  import type { TransformTyped } from '../stream.model.js'
3
3
 
4
4
  // The code below is carefully adopted from: https://github.com/max-mapper/binary-split
5
+ // (including the v2.0.0 performance improvements: native Buffer#indexOf and linear-time long lines)
5
6
 
6
7
  /**
7
8
  * Transforms input Buffer/string stream into Buffer chunks (objectMode: true) split by newLine.
8
9
  *
9
10
  * Useful for reading NDJSON files from fs.
10
11
  *
11
- * Same as binarySplit, but optimized (hard-coded) to split on NEWLINE (aka `\n`).
12
- * (+5-10% _pipeline speedup measured, compared to generic `binarySplit` on variable length delimiter)
12
+ * Same as `transformSplit('\n')`.
13
+ * (It used to be a separate hard-coded implementation, +5-10% faster than the generic one,
14
+ * but since the generic one searches 1-byte separators by byte value there's no difference anymore)
13
15
  */
14
16
  export function transformSplitOnNewline(): TransformTyped<Buffer, Buffer> {
15
- let buffered: Buffer | undefined
16
-
17
- return new Transform({
18
- writableObjectMode: false,
19
- writableHighWaterMark: 64 * 1024,
20
- readableObjectMode: true,
21
-
22
- transform(buf: Buffer, _enc, cb) {
23
- let offset = 0
24
- let lastMatch = 0
25
- if (buffered) {
26
- buf = Buffer.concat([buffered, buf])
27
- offset = buffered.length
28
- buffered = undefined
29
- }
30
-
31
- while (true) {
32
- const idx = firstNewlineMatch(buf, offset)
33
- if (idx !== -1 && idx < buf.length) {
34
- if (lastMatch !== idx) {
35
- this.push(buf.slice(lastMatch, idx))
36
- }
37
- offset = idx + 1
38
- lastMatch = offset
39
- } else {
40
- buffered = buf.slice(lastMatch)
41
- break
42
- }
43
- }
44
-
45
- cb()
46
- },
47
-
48
- flush(done) {
49
- if (buffered && buffered.length > 0) this.push(buffered)
50
- done()
51
- },
52
- })
17
+ return transformSplit('\n')
53
18
  }
54
19
 
55
20
  /**
56
21
  * Input: stream (objectMode=false) of arbitrary string|Buffer chunks, like when read from fs
57
22
  * Output: stream (objectMode=true) or string|Buffer chunks split by `separator` (@default to `\n`)
58
23
  *
59
- * Please use slightly more optimized `transformSplitOnNewline` for NDJSON file parsing.
60
- * (+5-10% _pipeline speedup measured!)
24
+ * Empty lines (consecutive separators) are skipped.
25
+ * Trailing data without a separator is emitted on flush.
61
26
  */
62
27
  export function transformSplit(separator = '\n'): TransformTyped<Buffer, Buffer> {
63
28
  const matcher = Buffer.from(separator)
64
- let buffered: Buffer | undefined
29
+ if (matcher.length === 0) {
30
+ throw new Error('transformSplit: separator must not be empty')
31
+ }
32
+ const matcherLength = matcher.length
33
+ // indexOf with a byte value is much faster than with a 1-byte Buffer, which matters for short lines
34
+ const needle = matcherLength === 1 ? matcher[0]! : matcher
35
+ // a multi-byte separator may straddle a chunk boundary, so we keep that many trailing bytes around
36
+ const overlap = matcherLength - 1
37
+
38
+ // Unterminated data is collected as a list of chunks and concatenated only once a separator arrives,
39
+ // so a long line spanning many chunks is copied once rather than on every chunk (linear, not quadratic)
40
+ let pending: Buffer[] = []
41
+ let pendingLength = 0
42
+ let edge: Buffer | undefined // last `overlap` bytes of pending data, only tracked when overlap > 0
65
43
 
66
44
  return new Transform({
67
- readableObjectMode: true,
45
+ writableObjectMode: false,
68
46
  writableHighWaterMark: 64 * 1024,
47
+ readableObjectMode: true,
69
48
 
70
- transform(buf: Buffer, _enc, done) {
49
+ transform(chunk: Buffer, _enc, cb) {
50
+ let buf = chunk
71
51
  let offset = 0
72
- let lastMatch = 0
73
- if (buffered) {
74
- buf = Buffer.concat([buffered, buf])
75
- offset = buffered.length
76
- buffered = undefined
77
- }
78
52
 
79
- while (true) {
80
- const idx = firstMatch(buf, offset - matcher.length + 1, matcher)
81
- if (idx !== -1 && idx < buf.length) {
82
- if (lastMatch !== idx) {
83
- this.push(buf.slice(lastMatch, idx))
53
+ if (pendingLength > 0) {
54
+ const straddles =
55
+ overlap > 0 && Buffer.concat([edge!, chunk.subarray(0, overlap)]).includes(matcher)
56
+ const idxInChunk = straddles ? -1 : chunk.indexOf(needle)
57
+ if (!straddles && idxInChunk === -1) {
58
+ // No separator yet: keep accumulating without copying
59
+ pending.push(chunk)
60
+ pendingLength += chunk.length
61
+ if (overlap > 0) {
62
+ edge = Buffer.concat([edge!, chunk.subarray(-overlap)]).subarray(-overlap)
84
63
  }
85
- offset = idx + matcher.length
86
- lastMatch = offset
87
- } else {
88
- buffered = buf.slice(lastMatch)
89
- break
64
+ cb()
65
+ return
90
66
  }
67
+ pending.push(chunk)
68
+ buf = Buffer.concat(pending, pendingLength + chunk.length)
69
+ // Start scanning where the first separator can possibly be
70
+ offset = straddles ? Math.max(0, pendingLength - overlap) : pendingLength + idxInChunk
71
+ pending = []
72
+ pendingLength = 0
91
73
  }
92
74
 
93
- done()
94
- },
95
-
96
- flush(done) {
97
- if (buffered && buffered.length > 0) this.push(buffered)
98
- done()
99
- },
100
- })
101
- }
75
+ let start = 0
76
+ let idx = buf.indexOf(needle, offset)
77
+ while (idx !== -1) {
78
+ if (idx > start) {
79
+ this.push(buf.subarray(start, idx))
80
+ }
81
+ start = idx + matcherLength
82
+ idx = buf.indexOf(needle, start)
83
+ }
102
84
 
103
- // const NEWLINE = Buffer.from('\n')
104
- // const NEWLINE_CODE = NEWLINE[0]! // it is `10`
105
- const NEWLINE_CODE = 10
85
+ if (start < buf.length) {
86
+ const rest = buf.subarray(start)
87
+ pending.push(rest)
88
+ pendingLength = rest.length
89
+ if (overlap > 0) edge = rest.subarray(-overlap)
90
+ }
106
91
 
107
- /**
108
- * Same as firstMatch, but optimized (hard-coded) to find NEWLINE (aka `\n`).
109
- */
110
- function firstNewlineMatch(buf: Buffer, offset: number): number {
111
- const bufLength = buf.length
112
- if (offset >= bufLength) return -1
113
- for (let i = offset; i < bufLength; i++) {
114
- if (buf[i] === NEWLINE_CODE) {
115
- return i
116
- }
117
- }
118
- return -1 // this code is unreachable, because i is guaranteed to be found in the loop above
119
- }
92
+ cb()
93
+ },
120
94
 
121
- function firstMatch(buf: Buffer, offset: number, matcher: Buffer): number {
122
- if (offset >= buf.length) return -1
123
- let i: number
124
- for (i = offset; i < buf.length; i++) {
125
- if (buf[i] === matcher[0]) {
126
- if (matcher.length > 1) {
127
- let fullMatch = true
128
- let j = i
129
- for (let k = 0; j < i + matcher.length; j++, k++) {
130
- if (buf[j] !== matcher[k]) {
131
- fullMatch = false
132
- break
133
- }
134
- }
135
- if (fullMatch) return j - matcher.length
136
- } else {
137
- break
95
+ flush(cb) {
96
+ if (pendingLength > 0) {
97
+ this.push(Buffer.concat(pending, pendingLength))
138
98
  }
139
- }
140
- }
141
-
142
- return i + matcher.length - 1
99
+ cb()
100
+ },
101
+ })
143
102
  }
@@ -1,4 +1,5 @@
1
1
  import { Transform } from 'node:stream'
2
+ import { consoleLogger } from '@naturalcycles/js-lib/log'
2
3
  import type { AsyncIndexedMapper, IndexedMapper } from '@naturalcycles/js-lib/types'
3
4
  import type { TransformOptions, TransformTyped } from '../stream.model.js'
4
5
 
@@ -12,7 +13,7 @@ export function transformTap<IN>(
12
13
  fn: AsyncIndexedMapper<IN, any>,
13
14
  opt: TransformOptions = {},
14
15
  ): TransformTyped<IN, IN> {
15
- const { logger = console, highWaterMark = 1 } = opt
16
+ const { logger = consoleLogger, highWaterMark = 1 } = opt
16
17
  let index = -1
17
18
 
18
19
  return new Transform({
@@ -22,7 +23,7 @@ export function transformTap<IN>(
22
23
  try {
23
24
  await fn(chunk, ++index)
24
25
  } catch (err) {
25
- logger.error(err)
26
+ logger.error({ err })
26
27
  // suppressed error
27
28
  }
28
29
 
@@ -38,7 +39,7 @@ export function transformTapSync<IN>(
38
39
  fn: IndexedMapper<IN, any>,
39
40
  opt: TransformOptions = {},
40
41
  ): TransformTyped<IN, IN> {
41
- const { logger = console, highWaterMark = 1 } = opt
42
+ const { logger = consoleLogger, highWaterMark = 1 } = opt
42
43
  let index = -1
43
44
 
44
45
  return new Transform({
@@ -48,7 +49,7 @@ export function transformTapSync<IN>(
48
49
  try {
49
50
  fn(chunk, ++index)
50
51
  } catch (err) {
51
- logger.error(err)
52
+ logger.error({ err })
52
53
  // suppressed error
53
54
  }
54
55
 
@@ -56,7 +56,8 @@ export function transformThrottle<T>(opt: TransformThrottleOptions): TransformTy
56
56
  if (!start) {
57
57
  start = localTime.nowUnixMillis()
58
58
  timeout = setTimeout(() => onInterval(), interval * 1000)
59
- logger.log(`${localTime.now().toPretty()} transformThrottle started with`, {
59
+ logger.log({
60
+ msg: `${localTime.now().toPretty()} transformThrottle started with`,
60
61
  throughput,
61
62
  interval,
62
63
  rps: Math.round(throughput / interval),