altd 0.1.0 → 0.1.2
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/dist/altd.js +145 -80
- package/package.json +1 -1
- package/src/altd.js +145 -80
package/dist/altd.js
CHANGED
|
@@ -1,35 +1,144 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import util from "node:util";
|
|
3
|
-
|
|
4
|
-
if (util.isArray !== Array.isArray) {
|
|
5
|
-
util.isArray = Array.isArray;
|
|
6
|
-
}
|
|
7
2
|
|
|
8
3
|
const { default: Tail } = await import("nodejs-tail");
|
|
9
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} CommandRegistryEntry
|
|
7
|
+
* @property {string} execPath
|
|
8
|
+
* @property {(rawArgs: string[]) => string[]} [buildArgs]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} DispatcherOptions
|
|
13
|
+
* @property {(command: string, args: string[], options: object) => import("node:child_process").ChildProcess} [spawnImpl]
|
|
14
|
+
* @property {{ on: Function, watch: Function, unwatch?: Function }} [tail]
|
|
15
|
+
* @property {number} [maxConcurrent]
|
|
16
|
+
* @property {number} [minIntervalMs]
|
|
17
|
+
* @property {number} [maxParts]
|
|
18
|
+
* @property {number} [maxPartLength]
|
|
19
|
+
* @property {number} [maxArgLength]
|
|
20
|
+
* @property {number} [maxPathLength]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const REQUEST_LINE_REGEX =
|
|
24
|
+
/\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract a raw request target from an access log line.
|
|
28
|
+
* @param {string} line
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
const parseRequestTarget = (line) => {
|
|
32
|
+
if (typeof line !== "string") return "";
|
|
33
|
+
const match = line.match(REQUEST_LINE_REGEX);
|
|
34
|
+
return match ? match[2] : "";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a raw request target into a safe pathname.
|
|
39
|
+
* @param {string} rawTarget
|
|
40
|
+
* @param {number} maxPathLength
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
const toSafePathname = (rawTarget, maxPathLength) => {
|
|
44
|
+
if (!rawTarget) return "";
|
|
45
|
+
try {
|
|
46
|
+
const base = rawTarget.startsWith("http://")
|
|
47
|
+
|| rawTarget.startsWith("https://")
|
|
48
|
+
? undefined
|
|
49
|
+
: "http://localhost";
|
|
50
|
+
const url = base ? new URL(rawTarget, base) : new URL(rawTarget);
|
|
51
|
+
const { pathname } = url;
|
|
52
|
+
if (!pathname || pathname.length > maxPathLength) return "";
|
|
53
|
+
return pathname;
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decode a path into command/args with size limits.
|
|
61
|
+
* @param {string} pathname
|
|
62
|
+
* @param {number} maxParts
|
|
63
|
+
* @param {number} maxPartLength
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
const decodePathParts = (pathname, maxParts, maxPartLength) => {
|
|
67
|
+
if (typeof pathname !== "string" || pathname === "") return [];
|
|
68
|
+
if (!pathname.startsWith("/")) return [];
|
|
69
|
+
|
|
70
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
71
|
+
if (parts.length === 0 || parts.length > maxParts) return [];
|
|
72
|
+
|
|
73
|
+
const decoded = [];
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
try {
|
|
76
|
+
const value = decodeURIComponent(part);
|
|
77
|
+
if (value.length > maxPartLength) return [];
|
|
78
|
+
if (part.length > maxPartLength) return [];
|
|
79
|
+
decoded.push(value);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return decoded;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a parsed command into an exec path and args.
|
|
89
|
+
* @param {string[]} parsed
|
|
90
|
+
* @param {Record<string, CommandRegistryEntry>} registry
|
|
91
|
+
* @param {number} maxArgLength
|
|
92
|
+
* @returns {{execPath: string, args: string[]} | null}
|
|
93
|
+
*/
|
|
94
|
+
const resolveExecutionFromRegistry = (parsed, registry, maxArgLength) => {
|
|
95
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
96
|
+
|
|
97
|
+
const [cmd, ...rawArgs] = parsed;
|
|
98
|
+
const entry = registry[cmd];
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
|
|
101
|
+
const { execPath, buildArgs = (args) => args } = entry;
|
|
102
|
+
const args = buildArgs(rawArgs);
|
|
103
|
+
if (!Array.isArray(args)) return null;
|
|
104
|
+
if (args.some((arg) => arg.length > maxArgLength)) return null;
|
|
105
|
+
|
|
106
|
+
return { execPath, args };
|
|
107
|
+
};
|
|
108
|
+
|
|
10
109
|
export default class AccessLogTailDispatcher {
|
|
11
110
|
/**
|
|
12
111
|
* @param {string} file access_log
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {
|
|
112
|
+
* @param {Record<string, CommandRegistryEntry>} commandRegistry
|
|
113
|
+
* @param {DispatcherOptions} [opts]
|
|
15
114
|
*/
|
|
16
115
|
constructor(file, commandRegistry, opts = {}) {
|
|
17
116
|
this.file = file;
|
|
18
117
|
this.registry = commandRegistry;
|
|
19
118
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
119
|
+
const {
|
|
120
|
+
spawnImpl = nodeSpawn,
|
|
121
|
+
tail = new Tail(file, {
|
|
23
122
|
alwaysStat: true,
|
|
24
123
|
ignoreInitial: true,
|
|
25
124
|
persistent: true,
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
125
|
+
}),
|
|
126
|
+
maxConcurrent = Infinity,
|
|
127
|
+
minIntervalMs = 0,
|
|
128
|
+
maxParts = 64,
|
|
129
|
+
maxPartLength = 1024,
|
|
130
|
+
maxArgLength = maxPartLength,
|
|
131
|
+
maxPathLength = 8192,
|
|
132
|
+
} = opts;
|
|
133
|
+
|
|
134
|
+
this.spawnImpl = spawnImpl;
|
|
135
|
+
this.tail = tail;
|
|
136
|
+
this.maxConcurrent = maxConcurrent;
|
|
137
|
+
this.minIntervalMs = minIntervalMs;
|
|
138
|
+
this.maxParts = maxParts;
|
|
139
|
+
this.maxPartLength = maxPartLength;
|
|
140
|
+
this.maxArgLength = maxArgLength;
|
|
141
|
+
this.maxPathLength = maxPathLength;
|
|
33
142
|
this.activeCount = 0;
|
|
34
143
|
this.lastExecAt = Number.NEGATIVE_INFINITY;
|
|
35
144
|
}
|
|
@@ -41,27 +150,9 @@ export default class AccessLogTailDispatcher {
|
|
|
41
150
|
* @returns {string} pathname like "/a/b"
|
|
42
151
|
*/
|
|
43
152
|
extractPath(line) {
|
|
44
|
-
if (typeof line !== "string") return "";
|
|
45
|
-
|
|
46
153
|
// Find something like: GET /foo/bar HTTP/1.1
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
);
|
|
50
|
-
if (!m) return "";
|
|
51
|
-
|
|
52
|
-
const rawTarget = m[2];
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const base = rawTarget.startsWith("http://")
|
|
56
|
-
|| rawTarget.startsWith("https://")
|
|
57
|
-
? undefined
|
|
58
|
-
: "http://localhost";
|
|
59
|
-
const url = base ? new URL(rawTarget, base) : new URL(rawTarget);
|
|
60
|
-
if (!url.pathname || url.pathname.length > this.maxPathLength) return "";
|
|
61
|
-
return url.pathname;
|
|
62
|
-
} catch {
|
|
63
|
-
return "";
|
|
64
|
-
}
|
|
154
|
+
const rawTarget = parseRequestTarget(line);
|
|
155
|
+
return toSafePathname(rawTarget, this.maxPathLength);
|
|
65
156
|
}
|
|
66
157
|
|
|
67
158
|
/**
|
|
@@ -70,25 +161,7 @@ export default class AccessLogTailDispatcher {
|
|
|
70
161
|
* @returns {string[]}
|
|
71
162
|
*/
|
|
72
163
|
parseCommand(pathname) {
|
|
73
|
-
|
|
74
|
-
if (!pathname.startsWith("/")) return [];
|
|
75
|
-
|
|
76
|
-
const parts = pathname.split("/").filter(Boolean);
|
|
77
|
-
if (parts.length === 0) return [];
|
|
78
|
-
if (parts.length > this.maxParts) return [];
|
|
79
|
-
|
|
80
|
-
const decoded = [];
|
|
81
|
-
for (const p of parts) {
|
|
82
|
-
if (p.length > this.maxPartLength) return [];
|
|
83
|
-
try {
|
|
84
|
-
const value = decodeURIComponent(p);
|
|
85
|
-
if (value.length > this.maxPartLength) return [];
|
|
86
|
-
decoded.push(value);
|
|
87
|
-
} catch {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return decoded;
|
|
164
|
+
return decodePathParts(pathname, this.maxParts, this.maxPartLength);
|
|
92
165
|
}
|
|
93
166
|
|
|
94
167
|
/**
|
|
@@ -97,21 +170,15 @@ export default class AccessLogTailDispatcher {
|
|
|
97
170
|
* @returns {{execPath:string,args:string[]}|null}
|
|
98
171
|
*/
|
|
99
172
|
resolveExecution(parsed) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const [cmd, ...rawArgs] = parsed;
|
|
103
|
-
|
|
104
|
-
const entry = this.registry[cmd];
|
|
105
|
-
if (!entry) return null;
|
|
106
|
-
|
|
107
|
-
const buildArgs = entry.buildArgs ?? ((args) => args);
|
|
108
|
-
const args = buildArgs(rawArgs);
|
|
109
|
-
if (!Array.isArray(args)) return null;
|
|
110
|
-
if (args.some((arg) => arg.length > this.maxArgLength)) return null;
|
|
111
|
-
|
|
112
|
-
return { execPath: entry.execPath, args };
|
|
173
|
+
return resolveExecutionFromRegistry(parsed, this.registry, this.maxArgLength);
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Spawn a whitelisted command if concurrency/interval limits allow it.
|
|
178
|
+
* @param {string} execPath
|
|
179
|
+
* @param {string[]} args
|
|
180
|
+
* @returns {void}
|
|
181
|
+
*/
|
|
115
182
|
spawnCommand(execPath, args) {
|
|
116
183
|
if (this.activeCount >= this.maxConcurrent) return;
|
|
117
184
|
const now = Date.now();
|
|
@@ -124,31 +191,29 @@ export default class AccessLogTailDispatcher {
|
|
|
124
191
|
});
|
|
125
192
|
this.activeCount += 1;
|
|
126
193
|
this.lastExecAt = now;
|
|
194
|
+
const decrementActive = () => {
|
|
195
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
196
|
+
};
|
|
127
197
|
|
|
128
198
|
proc.on("error", (err) => {
|
|
129
199
|
console.error("[spawn error]", err);
|
|
130
200
|
});
|
|
131
|
-
proc.on("close",
|
|
132
|
-
|
|
133
|
-
});
|
|
134
|
-
proc.on("exit", () => {
|
|
135
|
-
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
136
|
-
});
|
|
201
|
+
proc.on("close", decrementActive);
|
|
202
|
+
proc.on("exit", decrementActive);
|
|
137
203
|
}
|
|
138
204
|
|
|
139
|
-
/**
|
|
140
|
-
* Start watching
|
|
141
|
-
*/
|
|
205
|
+
/** Start watching the access log for new lines. */
|
|
142
206
|
run() {
|
|
143
|
-
|
|
207
|
+
const handleLine = (line) => {
|
|
144
208
|
const pathname = this.extractPath(line);
|
|
145
209
|
const parsed = this.parseCommand(pathname);
|
|
146
210
|
const exec = this.resolveExecution(parsed);
|
|
147
211
|
if (!exec) return;
|
|
148
212
|
|
|
149
213
|
this.spawnCommand(exec.execPath, exec.args);
|
|
150
|
-
}
|
|
214
|
+
};
|
|
151
215
|
|
|
216
|
+
this.tail.on("line", handleLine);
|
|
152
217
|
this.tail.on("close", () => {
|
|
153
218
|
console.log("watching stopped");
|
|
154
219
|
});
|
package/package.json
CHANGED
package/src/altd.js
CHANGED
|
@@ -1,35 +1,144 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import util from "node:util";
|
|
3
|
-
|
|
4
|
-
if (util.isArray !== Array.isArray) {
|
|
5
|
-
util.isArray = Array.isArray;
|
|
6
|
-
}
|
|
7
2
|
|
|
8
3
|
const { default: Tail } = await import("nodejs-tail");
|
|
9
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} CommandRegistryEntry
|
|
7
|
+
* @property {string} execPath
|
|
8
|
+
* @property {(rawArgs: string[]) => string[]} [buildArgs]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} DispatcherOptions
|
|
13
|
+
* @property {(command: string, args: string[], options: object) => import("node:child_process").ChildProcess} [spawnImpl]
|
|
14
|
+
* @property {{ on: Function, watch: Function, unwatch?: Function }} [tail]
|
|
15
|
+
* @property {number} [maxConcurrent]
|
|
16
|
+
* @property {number} [minIntervalMs]
|
|
17
|
+
* @property {number} [maxParts]
|
|
18
|
+
* @property {number} [maxPartLength]
|
|
19
|
+
* @property {number} [maxArgLength]
|
|
20
|
+
* @property {number} [maxPathLength]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const REQUEST_LINE_REGEX =
|
|
24
|
+
/\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract a raw request target from an access log line.
|
|
28
|
+
* @param {string} line
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
const parseRequestTarget = (line) => {
|
|
32
|
+
if (typeof line !== "string") return "";
|
|
33
|
+
const match = line.match(REQUEST_LINE_REGEX);
|
|
34
|
+
return match ? match[2] : "";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a raw request target into a safe pathname.
|
|
39
|
+
* @param {string} rawTarget
|
|
40
|
+
* @param {number} maxPathLength
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
const toSafePathname = (rawTarget, maxPathLength) => {
|
|
44
|
+
if (!rawTarget) return "";
|
|
45
|
+
try {
|
|
46
|
+
const base = rawTarget.startsWith("http://")
|
|
47
|
+
|| rawTarget.startsWith("https://")
|
|
48
|
+
? undefined
|
|
49
|
+
: "http://localhost";
|
|
50
|
+
const url = base ? new URL(rawTarget, base) : new URL(rawTarget);
|
|
51
|
+
const { pathname } = url;
|
|
52
|
+
if (!pathname || pathname.length > maxPathLength) return "";
|
|
53
|
+
return pathname;
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decode a path into command/args with size limits.
|
|
61
|
+
* @param {string} pathname
|
|
62
|
+
* @param {number} maxParts
|
|
63
|
+
* @param {number} maxPartLength
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
const decodePathParts = (pathname, maxParts, maxPartLength) => {
|
|
67
|
+
if (typeof pathname !== "string" || pathname === "") return [];
|
|
68
|
+
if (!pathname.startsWith("/")) return [];
|
|
69
|
+
|
|
70
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
71
|
+
if (parts.length === 0 || parts.length > maxParts) return [];
|
|
72
|
+
|
|
73
|
+
const decoded = [];
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
try {
|
|
76
|
+
const value = decodeURIComponent(part);
|
|
77
|
+
if (value.length > maxPartLength) return [];
|
|
78
|
+
if (part.length > maxPartLength) return [];
|
|
79
|
+
decoded.push(value);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return decoded;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a parsed command into an exec path and args.
|
|
89
|
+
* @param {string[]} parsed
|
|
90
|
+
* @param {Record<string, CommandRegistryEntry>} registry
|
|
91
|
+
* @param {number} maxArgLength
|
|
92
|
+
* @returns {{execPath: string, args: string[]} | null}
|
|
93
|
+
*/
|
|
94
|
+
const resolveExecutionFromRegistry = (parsed, registry, maxArgLength) => {
|
|
95
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
96
|
+
|
|
97
|
+
const [cmd, ...rawArgs] = parsed;
|
|
98
|
+
const entry = registry[cmd];
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
|
|
101
|
+
const { execPath, buildArgs = (args) => args } = entry;
|
|
102
|
+
const args = buildArgs(rawArgs);
|
|
103
|
+
if (!Array.isArray(args)) return null;
|
|
104
|
+
if (args.some((arg) => arg.length > maxArgLength)) return null;
|
|
105
|
+
|
|
106
|
+
return { execPath, args };
|
|
107
|
+
};
|
|
108
|
+
|
|
10
109
|
export default class AccessLogTailDispatcher {
|
|
11
110
|
/**
|
|
12
111
|
* @param {string} file access_log
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {
|
|
112
|
+
* @param {Record<string, CommandRegistryEntry>} commandRegistry
|
|
113
|
+
* @param {DispatcherOptions} [opts]
|
|
15
114
|
*/
|
|
16
115
|
constructor(file, commandRegistry, opts = {}) {
|
|
17
116
|
this.file = file;
|
|
18
117
|
this.registry = commandRegistry;
|
|
19
118
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
119
|
+
const {
|
|
120
|
+
spawnImpl = nodeSpawn,
|
|
121
|
+
tail = new Tail(file, {
|
|
23
122
|
alwaysStat: true,
|
|
24
123
|
ignoreInitial: true,
|
|
25
124
|
persistent: true,
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
125
|
+
}),
|
|
126
|
+
maxConcurrent = Infinity,
|
|
127
|
+
minIntervalMs = 0,
|
|
128
|
+
maxParts = 64,
|
|
129
|
+
maxPartLength = 1024,
|
|
130
|
+
maxArgLength = maxPartLength,
|
|
131
|
+
maxPathLength = 8192,
|
|
132
|
+
} = opts;
|
|
133
|
+
|
|
134
|
+
this.spawnImpl = spawnImpl;
|
|
135
|
+
this.tail = tail;
|
|
136
|
+
this.maxConcurrent = maxConcurrent;
|
|
137
|
+
this.minIntervalMs = minIntervalMs;
|
|
138
|
+
this.maxParts = maxParts;
|
|
139
|
+
this.maxPartLength = maxPartLength;
|
|
140
|
+
this.maxArgLength = maxArgLength;
|
|
141
|
+
this.maxPathLength = maxPathLength;
|
|
33
142
|
this.activeCount = 0;
|
|
34
143
|
this.lastExecAt = Number.NEGATIVE_INFINITY;
|
|
35
144
|
}
|
|
@@ -41,27 +150,9 @@ export default class AccessLogTailDispatcher {
|
|
|
41
150
|
* @returns {string} pathname like "/a/b"
|
|
42
151
|
*/
|
|
43
152
|
extractPath(line) {
|
|
44
|
-
if (typeof line !== "string") return "";
|
|
45
|
-
|
|
46
153
|
// Find something like: GET /foo/bar HTTP/1.1
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
);
|
|
50
|
-
if (!m) return "";
|
|
51
|
-
|
|
52
|
-
const rawTarget = m[2];
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const base = rawTarget.startsWith("http://")
|
|
56
|
-
|| rawTarget.startsWith("https://")
|
|
57
|
-
? undefined
|
|
58
|
-
: "http://localhost";
|
|
59
|
-
const url = base ? new URL(rawTarget, base) : new URL(rawTarget);
|
|
60
|
-
if (!url.pathname || url.pathname.length > this.maxPathLength) return "";
|
|
61
|
-
return url.pathname;
|
|
62
|
-
} catch {
|
|
63
|
-
return "";
|
|
64
|
-
}
|
|
154
|
+
const rawTarget = parseRequestTarget(line);
|
|
155
|
+
return toSafePathname(rawTarget, this.maxPathLength);
|
|
65
156
|
}
|
|
66
157
|
|
|
67
158
|
/**
|
|
@@ -70,25 +161,7 @@ export default class AccessLogTailDispatcher {
|
|
|
70
161
|
* @returns {string[]}
|
|
71
162
|
*/
|
|
72
163
|
parseCommand(pathname) {
|
|
73
|
-
|
|
74
|
-
if (!pathname.startsWith("/")) return [];
|
|
75
|
-
|
|
76
|
-
const parts = pathname.split("/").filter(Boolean);
|
|
77
|
-
if (parts.length === 0) return [];
|
|
78
|
-
if (parts.length > this.maxParts) return [];
|
|
79
|
-
|
|
80
|
-
const decoded = [];
|
|
81
|
-
for (const p of parts) {
|
|
82
|
-
if (p.length > this.maxPartLength) return [];
|
|
83
|
-
try {
|
|
84
|
-
const value = decodeURIComponent(p);
|
|
85
|
-
if (value.length > this.maxPartLength) return [];
|
|
86
|
-
decoded.push(value);
|
|
87
|
-
} catch {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return decoded;
|
|
164
|
+
return decodePathParts(pathname, this.maxParts, this.maxPartLength);
|
|
92
165
|
}
|
|
93
166
|
|
|
94
167
|
/**
|
|
@@ -97,21 +170,15 @@ export default class AccessLogTailDispatcher {
|
|
|
97
170
|
* @returns {{execPath:string,args:string[]}|null}
|
|
98
171
|
*/
|
|
99
172
|
resolveExecution(parsed) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const [cmd, ...rawArgs] = parsed;
|
|
103
|
-
|
|
104
|
-
const entry = this.registry[cmd];
|
|
105
|
-
if (!entry) return null;
|
|
106
|
-
|
|
107
|
-
const buildArgs = entry.buildArgs ?? ((args) => args);
|
|
108
|
-
const args = buildArgs(rawArgs);
|
|
109
|
-
if (!Array.isArray(args)) return null;
|
|
110
|
-
if (args.some((arg) => arg.length > this.maxArgLength)) return null;
|
|
111
|
-
|
|
112
|
-
return { execPath: entry.execPath, args };
|
|
173
|
+
return resolveExecutionFromRegistry(parsed, this.registry, this.maxArgLength);
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Spawn a whitelisted command if concurrency/interval limits allow it.
|
|
178
|
+
* @param {string} execPath
|
|
179
|
+
* @param {string[]} args
|
|
180
|
+
* @returns {void}
|
|
181
|
+
*/
|
|
115
182
|
spawnCommand(execPath, args) {
|
|
116
183
|
if (this.activeCount >= this.maxConcurrent) return;
|
|
117
184
|
const now = Date.now();
|
|
@@ -124,31 +191,29 @@ export default class AccessLogTailDispatcher {
|
|
|
124
191
|
});
|
|
125
192
|
this.activeCount += 1;
|
|
126
193
|
this.lastExecAt = now;
|
|
194
|
+
const decrementActive = () => {
|
|
195
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
196
|
+
};
|
|
127
197
|
|
|
128
198
|
proc.on("error", (err) => {
|
|
129
199
|
console.error("[spawn error]", err);
|
|
130
200
|
});
|
|
131
|
-
proc.on("close",
|
|
132
|
-
|
|
133
|
-
});
|
|
134
|
-
proc.on("exit", () => {
|
|
135
|
-
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
136
|
-
});
|
|
201
|
+
proc.on("close", decrementActive);
|
|
202
|
+
proc.on("exit", decrementActive);
|
|
137
203
|
}
|
|
138
204
|
|
|
139
|
-
/**
|
|
140
|
-
* Start watching
|
|
141
|
-
*/
|
|
205
|
+
/** Start watching the access log for new lines. */
|
|
142
206
|
run() {
|
|
143
|
-
|
|
207
|
+
const handleLine = (line) => {
|
|
144
208
|
const pathname = this.extractPath(line);
|
|
145
209
|
const parsed = this.parseCommand(pathname);
|
|
146
210
|
const exec = this.resolveExecution(parsed);
|
|
147
211
|
if (!exec) return;
|
|
148
212
|
|
|
149
213
|
this.spawnCommand(exec.execPath, exec.args);
|
|
150
|
-
}
|
|
214
|
+
};
|
|
151
215
|
|
|
216
|
+
this.tail.on("line", handleLine);
|
|
152
217
|
this.tail.on("close", () => {
|
|
153
218
|
console.log("watching stopped");
|
|
154
219
|
});
|