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.
Files changed (3) hide show
  1. package/dist/altd.js +145 -80
  2. package/package.json +1 -1
  3. 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 {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
14
- * @param {object} [opts]
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
- this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
21
- this.tail = opts.tail
22
- ?? new Tail(file, {
119
+ const {
120
+ spawnImpl = nodeSpawn,
121
+ tail = new Tail(file, {
23
122
  alwaysStat: true,
24
123
  ignoreInitial: true,
25
124
  persistent: true,
26
- });
27
- this.maxConcurrent = opts.maxConcurrent ?? Infinity;
28
- this.minIntervalMs = opts.minIntervalMs ?? 0;
29
- this.maxParts = opts.maxParts ?? 64;
30
- this.maxPartLength = opts.maxPartLength ?? 1024;
31
- this.maxArgLength = opts.maxArgLength ?? this.maxPartLength;
32
- this.maxPathLength = opts.maxPathLength ?? 8192;
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 m = line.match(
48
- /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
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
- if (typeof pathname !== "string" || pathname === "") return [];
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
- if (!Array.isArray(parsed) || parsed.length === 0) return null;
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
- this.activeCount = Math.max(0, this.activeCount - 1);
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
- this.tail.on("line", (line) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altd",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Access log tail dispatcher",
5
5
  "type": "module",
6
6
  "bin": {
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 {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
14
- * @param {object} [opts]
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
- this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
21
- this.tail = opts.tail
22
- ?? new Tail(file, {
119
+ const {
120
+ spawnImpl = nodeSpawn,
121
+ tail = new Tail(file, {
23
122
  alwaysStat: true,
24
123
  ignoreInitial: true,
25
124
  persistent: true,
26
- });
27
- this.maxConcurrent = opts.maxConcurrent ?? Infinity;
28
- this.minIntervalMs = opts.minIntervalMs ?? 0;
29
- this.maxParts = opts.maxParts ?? 64;
30
- this.maxPartLength = opts.maxPartLength ?? 1024;
31
- this.maxArgLength = opts.maxArgLength ?? this.maxPartLength;
32
- this.maxPathLength = opts.maxPathLength ?? 8192;
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 m = line.match(
48
- /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
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
- if (typeof pathname !== "string" || pathname === "") return [];
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
- if (!Array.isArray(parsed) || parsed.length === 0) return null;
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
- this.activeCount = Math.max(0, this.activeCount - 1);
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
- this.tail.on("line", (line) => {
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
  });