|
| 1 | +import fs from 'node:fs' |
| 2 | + |
1 | 3 | /** |
2 | 4 | * Resolve collector options from CLI args and environment. |
3 | 5 | * |
@@ -39,3 +41,229 @@ export function resolveOptions(argv, env) { |
39 | 41 |
|
40 | 42 | return options |
41 | 43 | } |
| 44 | + |
| 45 | +/** |
| 46 | + * @typedef {object} OtelConfig |
| 47 | + * @property {string} listen - host:port for the OTLP receiver (e.g. '0.0.0.0:4318'). |
| 48 | + */ |
| 49 | + |
| 50 | +/** |
| 51 | + * @typedef {object} UpstreamMatch |
| 52 | + * @property {string} path_prefix - Request path prefix that selects this upstream. |
| 53 | + */ |
| 54 | + |
| 55 | +/** |
| 56 | + * @typedef {object} UpstreamConfig |
| 57 | + * @property {string} base_url - Origin to forward matched requests to. |
| 58 | + * @property {UpstreamMatch} match - Match rule for routing requests to this upstream. |
| 59 | + */ |
| 60 | + |
| 61 | +/** |
| 62 | + * @typedef {object} ProxyConfig |
| 63 | + * @property {string} listen - host:port the proxy listens on. |
| 64 | + * @property {Object<string, UpstreamConfig>} upstreams - Named upstream targets. |
| 65 | + * @property {string[]} [redact_headers] - Header names to redact in recorded traffic. |
| 66 | + */ |
| 67 | + |
| 68 | +/** |
| 69 | + * @typedef {object} FileSinkConfig |
| 70 | + * @property {'file'} type - Sink kind. Only 'file' is supported in v0. |
| 71 | + * @property {string} dir - Directory where recordings are written. |
| 72 | + */ |
| 73 | + |
| 74 | +/** |
| 75 | + * @typedef {object} CollectivusConfig |
| 76 | + * @property {OtelConfig} [otel] - OTLP receiver. Omit to disable. |
| 77 | + * @property {ProxyConfig} [proxy] - Proxy listener. Omit to disable. |
| 78 | + * @property {FileSinkConfig} [sink] - Sink for proxy recordings. Required when `proxy` is set. |
| 79 | + */ |
| 80 | + |
| 81 | +export class ConfigError extends Error { |
| 82 | + /** |
| 83 | + * @param {string} message |
| 84 | + * @param {{ pointer?: string }} [opts] |
| 85 | + */ |
| 86 | + constructor(message, opts = {}) { |
| 87 | + const { pointer } = opts |
| 88 | + super(pointer ? `${pointer}: ${message}` : message) |
| 89 | + this.name = 'ConfigError' |
| 90 | + /** @type {string | undefined} */ |
| 91 | + this.pointer = pointer |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +const ALLOWED_TOP_KEYS = new Set(['otel', 'proxy', 'sink']) |
| 96 | +const ALLOWED_PROXY_KEYS = new Set(['listen', 'upstreams', 'redact_headers']) |
| 97 | +const ALLOWED_UPSTREAM_KEYS = new Set(['base_url', 'match']) |
| 98 | +const ALLOWED_SINK_KEYS = new Set(['type', 'dir']) |
| 99 | + |
| 100 | +/** |
| 101 | + * Load and validate a collectivus JSON config file. |
| 102 | + * |
| 103 | + * @param {string} configPath - Absolute or relative path to a JSON config file. |
| 104 | + * @returns {CollectivusConfig} The parsed and validated config. |
| 105 | + * @throws {ConfigError} when the file is missing, JSON is invalid, or the schema check fails. |
| 106 | + */ |
| 107 | +export function loadConfig(configPath) { |
| 108 | + let raw |
| 109 | + try { |
| 110 | + raw = fs.readFileSync(configPath, 'utf8') |
| 111 | + } catch (err) { |
| 112 | + const code = err && typeof err === 'object' && 'code' in err ? err.code : null |
| 113 | + if (code === 'ENOENT') { |
| 114 | + throw new ConfigError(`config file not found: ${configPath}`) |
| 115 | + } |
| 116 | + const msg = err instanceof Error ? err.message : String(err) |
| 117 | + throw new ConfigError(`failed to read ${configPath}: ${msg}`) |
| 118 | + } |
| 119 | + |
| 120 | + let parsed |
| 121 | + try { |
| 122 | + parsed = JSON.parse(raw) |
| 123 | + } catch (err) { |
| 124 | + const msg = err instanceof Error ? err.message : String(err) |
| 125 | + const location = jsonErrorLocation(raw, msg) |
| 126 | + throw new ConfigError(`invalid JSON in ${configPath}${location}: ${msg}`) |
| 127 | + } |
| 128 | + |
| 129 | + validateConfig(parsed) |
| 130 | + return parsed |
| 131 | +} |
| 132 | + |
| 133 | +/** |
| 134 | + * Extract `at line N, column M` from a `JSON.parse` error message. |
| 135 | + * V8 reports `position N`; older runtimes may report `line N column M`. If we |
| 136 | + * can't find an offset, return an empty string and leave the location off. |
| 137 | + * |
| 138 | + * @param {string} raw - The original JSON source. |
| 139 | + * @param {string} msg - The error message from JSON.parse. |
| 140 | + * @returns {string} A leading-space location string, or '' when not derivable. |
| 141 | + */ |
| 142 | +function jsonErrorLocation(raw, msg) { |
| 143 | + const posMatch = /position (\d+)/.exec(msg) |
| 144 | + if (posMatch) { |
| 145 | + const offset = Number.parseInt(posMatch[1], 10) |
| 146 | + const before = raw.slice(0, Math.min(offset, raw.length)) |
| 147 | + const line = before.split('\n').length |
| 148 | + const lastNewline = before.lastIndexOf('\n') |
| 149 | + const column = lastNewline === -1 ? before.length + 1 : before.length - lastNewline |
| 150 | + return ` at line ${line}, column ${column}` |
| 151 | + } |
| 152 | + const lineMatch = /line (\d+) column (\d+)/.exec(msg) |
| 153 | + if (lineMatch) return ` at line ${lineMatch[1]}, column ${lineMatch[2]}` |
| 154 | + return '' |
| 155 | +} |
| 156 | + |
| 157 | +/** |
| 158 | + * @param {unknown} cfg |
| 159 | + * @returns {asserts cfg is CollectivusConfig} |
| 160 | + */ |
| 161 | +function validateConfig(cfg) { |
| 162 | + assertObject(cfg, '') |
| 163 | + assertOnlyKeys(cfg, ALLOWED_TOP_KEYS, '') |
| 164 | + |
| 165 | + if (cfg.otel !== undefined) validateOtel(cfg.otel) |
| 166 | + if (cfg.proxy !== undefined) validateProxy(cfg.proxy) |
| 167 | + if (cfg.proxy !== undefined && cfg.sink === undefined) { |
| 168 | + throw new ConfigError('sink is required when proxy is configured', { pointer: '/sink' }) |
| 169 | + } |
| 170 | + if (cfg.sink !== undefined) validateSink(cfg.sink) |
| 171 | +} |
| 172 | + |
| 173 | +/** @param {unknown} otel */ |
| 174 | +function validateOtel(otel) { |
| 175 | + assertObject(otel, '/otel') |
| 176 | + assertOnlyKeys(otel, new Set(['listen']), '/otel') |
| 177 | + assertNonEmptyString(otel.listen, '/otel/listen') |
| 178 | +} |
| 179 | + |
| 180 | +/** @param {unknown} proxy */ |
| 181 | +function validateProxy(proxy) { |
| 182 | + assertObject(proxy, '/proxy') |
| 183 | + assertOnlyKeys(proxy, ALLOWED_PROXY_KEYS, '/proxy') |
| 184 | + assertNonEmptyString(proxy.listen, '/proxy/listen') |
| 185 | + |
| 186 | + if (proxy.upstreams === undefined) { |
| 187 | + throw new ConfigError('upstreams is required', { pointer: '/proxy/upstreams' }) |
| 188 | + } |
| 189 | + assertObject(proxy.upstreams, '/proxy/upstreams') |
| 190 | + const names = Object.keys(proxy.upstreams) |
| 191 | + if (names.length === 0) { |
| 192 | + throw new ConfigError('at least one upstream is required', { pointer: '/proxy/upstreams' }) |
| 193 | + } |
| 194 | + for (const name of names) { |
| 195 | + validateUpstream(proxy.upstreams[name], `/proxy/upstreams/${name}`) |
| 196 | + } |
| 197 | + |
| 198 | + if (proxy.redact_headers !== undefined) { |
| 199 | + if (!Array.isArray(proxy.redact_headers)) { |
| 200 | + throw new ConfigError('must be an array of strings', { pointer: '/proxy/redact_headers' }) |
| 201 | + } |
| 202 | + proxy.redact_headers.forEach(function(h, i) { |
| 203 | + if (typeof h !== 'string' || h.length === 0) { |
| 204 | + throw new ConfigError('must be a non-empty string', { pointer: `/proxy/redact_headers/${i}` }) |
| 205 | + } |
| 206 | + }) |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +/** |
| 211 | + * @param {unknown} upstream |
| 212 | + * @param {string} pointer |
| 213 | + */ |
| 214 | +function validateUpstream(upstream, pointer) { |
| 215 | + assertObject(upstream, pointer) |
| 216 | + assertOnlyKeys(upstream, ALLOWED_UPSTREAM_KEYS, pointer) |
| 217 | + assertNonEmptyString(upstream.base_url, `${pointer}/base_url`) |
| 218 | + if (upstream.match === undefined) { |
| 219 | + throw new ConfigError('match is required', { pointer: `${pointer}/match` }) |
| 220 | + } |
| 221 | + assertObject(upstream.match, `${pointer}/match`) |
| 222 | + assertOnlyKeys(upstream.match, new Set(['path_prefix']), `${pointer}/match`) |
| 223 | + assertNonEmptyString(upstream.match.path_prefix, `${pointer}/match/path_prefix`) |
| 224 | +} |
| 225 | + |
| 226 | +/** @param {unknown} sink */ |
| 227 | +function validateSink(sink) { |
| 228 | + assertObject(sink, '/sink') |
| 229 | + assertOnlyKeys(sink, ALLOWED_SINK_KEYS, '/sink') |
| 230 | + if (sink.type !== 'file') { |
| 231 | + throw new ConfigError('only sink type "file" is supported in v0', { pointer: '/sink/type' }) |
| 232 | + } |
| 233 | + assertNonEmptyString(sink.dir, '/sink/dir') |
| 234 | +} |
| 235 | + |
| 236 | +/** |
| 237 | + * @param {unknown} value |
| 238 | + * @param {string} pointer |
| 239 | + * @returns {asserts value is Record<string, unknown>} |
| 240 | + */ |
| 241 | +function assertObject(value, pointer) { |
| 242 | + if (value === null || typeof value !== 'object' || Array.isArray(value)) { |
| 243 | + throw new ConfigError('must be an object', { pointer: pointer || '/' }) |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +/** |
| 248 | + * @param {Record<string, unknown>} obj |
| 249 | + * @param {Set<string>} allowed |
| 250 | + * @param {string} pointer - Pointer to the object being checked ('' for root). |
| 251 | + */ |
| 252 | +function assertOnlyKeys(obj, allowed, pointer) { |
| 253 | + for (const key of Object.keys(obj)) { |
| 254 | + if (!allowed.has(key)) { |
| 255 | + throw new ConfigError(`unknown key "${key}"`, { pointer: `${pointer}/${key}` }) |
| 256 | + } |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +/** |
| 261 | + * @param {unknown} value |
| 262 | + * @param {string} pointer |
| 263 | + * @returns {asserts value is string} |
| 264 | + */ |
| 265 | +function assertNonEmptyString(value, pointer) { |
| 266 | + if (typeof value !== 'string' || value.length === 0) { |
| 267 | + throw new ConfigError('must be a non-empty string', { pointer }) |
| 268 | + } |
| 269 | +} |
0 commit comments