toolfunnel 0.7.0 → 0.8.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 +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +16 -5
package/src/core/matcher.js
CHANGED
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* matcher.js - does a hook's matcher string fire for a given tool?
|
|
5
|
-
*
|
|
6
|
-
* Mirrors Claude Code's matcher semantics:
|
|
7
|
-
* - "" | undefined | "*" -> always fire (wildcard).
|
|
8
|
-
* - otherwise the matcher is a regex, anchored as a FULL match against toolName
|
|
9
|
-
* (e.g. "Bash|Write|Edit").
|
|
10
|
-
* - tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) pass
|
|
11
|
-
* toolName == null/undefined -> always fire (the matcher is ignored).
|
|
12
|
-
*
|
|
13
|
-
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
// One compile per distinct matcher string. matches() sits on the gate's hot path (every
|
|
17
|
-
// hook x every tool call), and rebuilding `^(?:Bash|Write|Edit)$` per call was pure waste:
|
|
18
|
-
// RegExp construction is a deterministic function of the string and ours carries no flags
|
|
19
|
-
// (no g/y -> no lastIndex state), so memoising by the string can never change an answer.
|
|
20
|
-
// Invalidation is by construction, not by event: a reloaded manifest's new matcher is a
|
|
21
|
-
// NEW string -> a new key; the old entry just goes unused. Failed compiles memoise too
|
|
22
|
-
// (a malformed matcher answers false without a fresh throw per call). The cap is a
|
|
23
|
-
// backstop against degenerate dynamically-generated matchers: past it the memo resets -
|
|
24
|
-
// worst case is exactly the old per-call compile, never unbounded growth or a wrong fire.
|
|
25
|
-
const COMPILE_CACHE_MAX = 256;
|
|
26
|
-
const compileCache = new Map(); // String(matcher) -> { re: RegExp|null, err: string|null }
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The memoised compile shared by matches() / matcherError(). Keyed on String(matcher) so a
|
|
30
|
-
* non-string matcher coerces exactly as the template literal always did.
|
|
31
|
-
* @param {*} matcher
|
|
32
|
-
* @returns {{ re: RegExp|null, err: string|null }}
|
|
33
|
-
*/
|
|
34
|
-
function compiled(matcher) {
|
|
35
|
-
const key = String(matcher);
|
|
36
|
-
let hit = compileCache.get(key);
|
|
37
|
-
if (hit) return hit;
|
|
38
|
-
try {
|
|
39
|
-
hit = { re: new RegExp(`^(?:${key})$`), err: null };
|
|
40
|
-
} catch (err) {
|
|
41
|
-
hit = { re: null, err: (err && err.message) || String(err) };
|
|
42
|
-
}
|
|
43
|
-
if (compileCache.size >= COMPILE_CACHE_MAX) compileCache.clear();
|
|
44
|
-
compileCache.set(key, hit);
|
|
45
|
-
return hit;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @param {string|undefined|null} matcher the hook's matcher string.
|
|
50
|
-
* @param {string|undefined|null} toolName the tool the model requested, or
|
|
51
|
-
* null/undefined for events without a tool.
|
|
52
|
-
* @returns {boolean} true if the hook should fire for this tool.
|
|
53
|
-
*/
|
|
54
|
-
function matches(matcher, toolName) {
|
|
55
|
-
// Wildcard / unset matcher -> always fire, regardless of tool.
|
|
56
|
-
if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Tool-less events: there is nothing to match against, so the matcher is
|
|
61
|
-
// ignored and the hook always fires. This is checked AFTER the wildcard
|
|
62
|
-
// case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
|
|
63
|
-
// (which would otherwise need a string to test).
|
|
64
|
-
if (toolName === undefined || toolName === null) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Anything else is a regex, anchored as a full match against toolName.
|
|
69
|
-
// We wrap in ^(?:...)$ so alternations like "Bash|Write" mean "the whole tool
|
|
70
|
-
// name is one of these", not "contains one of these".
|
|
71
|
-
const c = compiled(matcher);
|
|
72
|
-
if (!c.re) {
|
|
73
|
-
// A malformed matcher must never throw out of the engine, and matches() cannot
|
|
74
|
-
// claim a fire it could not evaluate - so this answers false. That is NOT
|
|
75
|
-
// permission to proceed: callers that gate on the answer must distinguish
|
|
76
|
-
// "did not match" from "could not be read" via matcherError() (hook-engine
|
|
77
|
-
// denies the call; hook-loader.addEntry refuses to store the matcher at all).
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return c.re.test(toolName);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Why does this matcher NOT compile? Returns null for a wildcard/unset matcher or one that
|
|
86
|
-
* compiles under the same ^(?:...)$ anchoring matches() applies; otherwise the compile error's
|
|
87
|
-
* message. This is the discriminator between "did not match" and "could not be read":
|
|
88
|
-
* authoring surfaces (hook-loader.addEntry, and through it the UI and tf_hook_add) use it to
|
|
89
|
-
* REFUSE a pattern that can never be evaluated, and hook-engine.fire treats a non-null answer
|
|
90
|
-
* on a tool-bearing event as an unreadable gate and denies the call (matcher-compile.test.js).
|
|
91
|
-
*
|
|
92
|
-
* @param {string|undefined|null} m the hook's matcher string.
|
|
93
|
-
* @returns {string|null} the compile problem, or null when the matcher is usable.
|
|
94
|
-
*/
|
|
95
|
-
function matcherError(m) {
|
|
96
|
-
if (m === undefined || m === null || m === '' || m === '*') return null;
|
|
97
|
-
return compiled(m).err;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
module.exports = { matches, matcherError };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* matcher.js - does a hook's matcher string fire for a given tool?
|
|
5
|
+
*
|
|
6
|
+
* Mirrors Claude Code's matcher semantics:
|
|
7
|
+
* - "" | undefined | "*" -> always fire (wildcard).
|
|
8
|
+
* - otherwise the matcher is a regex, anchored as a FULL match against toolName
|
|
9
|
+
* (e.g. "Bash|Write|Edit").
|
|
10
|
+
* - tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) pass
|
|
11
|
+
* toolName == null/undefined -> always fire (the matcher is ignored).
|
|
12
|
+
*
|
|
13
|
+
* CommonJS only. Zero host imports (core/ must run headless under node --test).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// One compile per distinct matcher string. matches() sits on the gate's hot path (every
|
|
17
|
+
// hook x every tool call), and rebuilding `^(?:Bash|Write|Edit)$` per call was pure waste:
|
|
18
|
+
// RegExp construction is a deterministic function of the string and ours carries no flags
|
|
19
|
+
// (no g/y -> no lastIndex state), so memoising by the string can never change an answer.
|
|
20
|
+
// Invalidation is by construction, not by event: a reloaded manifest's new matcher is a
|
|
21
|
+
// NEW string -> a new key; the old entry just goes unused. Failed compiles memoise too
|
|
22
|
+
// (a malformed matcher answers false without a fresh throw per call). The cap is a
|
|
23
|
+
// backstop against degenerate dynamically-generated matchers: past it the memo resets -
|
|
24
|
+
// worst case is exactly the old per-call compile, never unbounded growth or a wrong fire.
|
|
25
|
+
const COMPILE_CACHE_MAX = 256;
|
|
26
|
+
const compileCache = new Map(); // String(matcher) -> { re: RegExp|null, err: string|null }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The memoised compile shared by matches() / matcherError(). Keyed on String(matcher) so a
|
|
30
|
+
* non-string matcher coerces exactly as the template literal always did.
|
|
31
|
+
* @param {*} matcher
|
|
32
|
+
* @returns {{ re: RegExp|null, err: string|null }}
|
|
33
|
+
*/
|
|
34
|
+
function compiled(matcher) {
|
|
35
|
+
const key = String(matcher);
|
|
36
|
+
let hit = compileCache.get(key);
|
|
37
|
+
if (hit) return hit;
|
|
38
|
+
try {
|
|
39
|
+
hit = { re: new RegExp(`^(?:${key})$`), err: null };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
hit = { re: null, err: (err && err.message) || String(err) };
|
|
42
|
+
}
|
|
43
|
+
if (compileCache.size >= COMPILE_CACHE_MAX) compileCache.clear();
|
|
44
|
+
compileCache.set(key, hit);
|
|
45
|
+
return hit;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string|undefined|null} matcher the hook's matcher string.
|
|
50
|
+
* @param {string|undefined|null} toolName the tool the model requested, or
|
|
51
|
+
* null/undefined for events without a tool.
|
|
52
|
+
* @returns {boolean} true if the hook should fire for this tool.
|
|
53
|
+
*/
|
|
54
|
+
function matches(matcher, toolName) {
|
|
55
|
+
// Wildcard / unset matcher -> always fire, regardless of tool.
|
|
56
|
+
if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Tool-less events: there is nothing to match against, so the matcher is
|
|
61
|
+
// ignored and the hook always fires. This is checked AFTER the wildcard
|
|
62
|
+
// case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
|
|
63
|
+
// (which would otherwise need a string to test).
|
|
64
|
+
if (toolName === undefined || toolName === null) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Anything else is a regex, anchored as a full match against toolName.
|
|
69
|
+
// We wrap in ^(?:...)$ so alternations like "Bash|Write" mean "the whole tool
|
|
70
|
+
// name is one of these", not "contains one of these".
|
|
71
|
+
const c = compiled(matcher);
|
|
72
|
+
if (!c.re) {
|
|
73
|
+
// A malformed matcher must never throw out of the engine, and matches() cannot
|
|
74
|
+
// claim a fire it could not evaluate - so this answers false. That is NOT
|
|
75
|
+
// permission to proceed: callers that gate on the answer must distinguish
|
|
76
|
+
// "did not match" from "could not be read" via matcherError() (hook-engine
|
|
77
|
+
// denies the call; hook-loader.addEntry refuses to store the matcher at all).
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return c.re.test(toolName);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why does this matcher NOT compile? Returns null for a wildcard/unset matcher or one that
|
|
86
|
+
* compiles under the same ^(?:...)$ anchoring matches() applies; otherwise the compile error's
|
|
87
|
+
* message. This is the discriminator between "did not match" and "could not be read":
|
|
88
|
+
* authoring surfaces (hook-loader.addEntry, and through it the UI and tf_hook_add) use it to
|
|
89
|
+
* REFUSE a pattern that can never be evaluated, and hook-engine.fire treats a non-null answer
|
|
90
|
+
* on a tool-bearing event as an unreadable gate and denies the call (matcher-compile.test.js).
|
|
91
|
+
*
|
|
92
|
+
* @param {string|undefined|null} m the hook's matcher string.
|
|
93
|
+
* @returns {string|null} the compile problem, or null when the matcher is usable.
|
|
94
|
+
*/
|
|
95
|
+
function matcherError(m) {
|
|
96
|
+
if (m === undefined || m === null || m === '' || m === '*') return null;
|
|
97
|
+
return compiled(m).err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { matches, matcherError };
|
package/src/core/metrics.js
CHANGED
|
@@ -1,75 +1,90 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* metrics.js - in-memory, per-process tool-call counters (lightweight observability).
|
|
5
|
-
*
|
|
6
|
-
* A privileged gateway should be able to answer "what has run through me, and how often did it
|
|
7
|
-
* fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
|
|
8
|
-
* lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
|
|
9
|
-
* `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
|
|
10
|
-
* and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
|
|
11
|
-
* to disk when enabled) - the two are complementary: the log is the durable per-event record, this
|
|
12
|
-
* is the cheap live aggregate.
|
|
13
|
-
*
|
|
14
|
-
* Design rules (same discipline as logger.js):
|
|
15
|
-
* - Zero dependencies. Plain objects + integers.
|
|
16
|
-
* - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
|
|
17
|
-
* - In-memory only; no files, no timers, no network.
|
|
18
|
-
*
|
|
19
|
-
* CommonJS only.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* metrics.js - in-memory, per-process tool-call counters (lightweight observability).
|
|
5
|
+
*
|
|
6
|
+
* A privileged gateway should be able to answer "what has run through me, and how often did it
|
|
7
|
+
* fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
|
|
8
|
+
* lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
|
|
9
|
+
* `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
|
|
10
|
+
* and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
|
|
11
|
+
* to disk when enabled) - the two are complementary: the log is the durable per-event record, this
|
|
12
|
+
* is the cheap live aggregate.
|
|
13
|
+
*
|
|
14
|
+
* Design rules (same discipline as logger.js):
|
|
15
|
+
* - Zero dependencies. Plain objects + integers.
|
|
16
|
+
* - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
|
|
17
|
+
* - In-memory only; no files, no timers, no network.
|
|
18
|
+
*
|
|
19
|
+
* CommonJS only.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Distinct byTool entries are CAPPED: the key is the tools/call name exactly as the client sent it,
|
|
23
|
+
// including names that never existed, so an unbounded map let a client spraying fresh names grow
|
|
24
|
+
// the gateway's memory for the life of the process. Names seen before the cap keep their own
|
|
25
|
+
// counters; everything after lands in one overflow bucket. Totals stay exact either way.
|
|
26
|
+
const MAX_DISTINCT_TOOLS = 1000;
|
|
27
|
+
const OVERFLOW_KEY = '(other)';
|
|
28
|
+
|
|
29
|
+
const state = {
|
|
30
|
+
startedAt: new Date().toISOString(),
|
|
31
|
+
calls: 0,
|
|
32
|
+
errors: 0,
|
|
33
|
+
byTool: Object.create(null), // name -> { calls, errors }
|
|
34
|
+
distinct: 0, // entries in byTool (the overflow bucket counts as one)
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
|
|
39
|
+
* PreToolUse denial, which both surface as an error envelope) increments the error counters.
|
|
40
|
+
* @param {{ tool?:string, ok?:boolean }} ev
|
|
41
|
+
*/
|
|
42
|
+
function record(ev) {
|
|
43
|
+
try {
|
|
44
|
+
const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
|
|
45
|
+
const ok = !(ev && ev.ok === false);
|
|
46
|
+
state.calls += 1;
|
|
47
|
+
if (!ok) state.errors += 1;
|
|
48
|
+
let t = state.byTool[tool];
|
|
49
|
+
if (!t && state.distinct >= MAX_DISTINCT_TOOLS && tool !== OVERFLOW_KEY) {
|
|
50
|
+
t = state.byTool[OVERFLOW_KEY]; // at the cap: an unseen name counts under the overflow bucket
|
|
51
|
+
if (!t) { t = { calls: 0, errors: 0 }; state.byTool[OVERFLOW_KEY] = t; state.distinct += 1; }
|
|
52
|
+
} else if (!t) {
|
|
53
|
+
t = { calls: 0, errors: 0 };
|
|
54
|
+
state.byTool[tool] = t;
|
|
55
|
+
state.distinct += 1;
|
|
56
|
+
}
|
|
57
|
+
t.calls += 1;
|
|
58
|
+
if (!ok) t.errors += 1;
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
/* instrumentation must never break the caller */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
|
|
66
|
+
* @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
|
|
67
|
+
*/
|
|
68
|
+
function snapshot() {
|
|
69
|
+
try {
|
|
70
|
+
// Null-prototype like the source: into a plain {} a key of "__proto__" sets the PROTOTYPE
|
|
71
|
+
// instead of a property, so that tool's counter silently vanished from /health and the UI.
|
|
72
|
+
const byTool = Object.create(null);
|
|
73
|
+
for (const k of Object.keys(state.byTool)) {
|
|
74
|
+
byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
|
|
75
|
+
}
|
|
76
|
+
return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
|
|
77
|
+
} catch (_e) {
|
|
78
|
+
return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
|
|
83
|
+
function reset() {
|
|
84
|
+
state.calls = 0;
|
|
85
|
+
state.errors = 0;
|
|
86
|
+
state.byTool = Object.create(null);
|
|
87
|
+
state.distinct = 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { record, snapshot, reset };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* output-cap.js - keep a captured stream within a byte cap.
|
|
4
|
+
*
|
|
5
|
+
* A child's output arrives in chunks (64 KiB pipe reads on Windows). Appending a whole chunk and checking the cap
|
|
6
|
+
* afterwards lets the captured text run past the cap by up to one chunk. takeWithinCap returns the part of a chunk
|
|
7
|
+
* that still fits, ending on a whole character so a multi-byte UTF-8 character is never split.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} text the decoded chunk
|
|
12
|
+
* @param {number} used bytes already captured
|
|
13
|
+
* @param {number} max the cap in bytes
|
|
14
|
+
* @returns {string} the leading part of text whose UTF-8 size fits in max - used
|
|
15
|
+
*/
|
|
16
|
+
function takeWithinCap(text, used, max) {
|
|
17
|
+
const room = max - used;
|
|
18
|
+
if (room <= 0) return '';
|
|
19
|
+
if (Buffer.byteLength(text, 'utf8') <= room) return text;
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
let end = 0;
|
|
22
|
+
for (const ch of text) { // by code point, so a surrogate pair is never split either
|
|
23
|
+
const n = Buffer.byteLength(ch, 'utf8');
|
|
24
|
+
if (bytes + n > room) break;
|
|
25
|
+
bytes += n;
|
|
26
|
+
end += ch.length;
|
|
27
|
+
}
|
|
28
|
+
return text.slice(0, end);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { takeWithinCap };
|