rollbridge 0.1.1

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/src/config.js ADDED
@@ -0,0 +1,414 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+ import path from "node:path"
5
+ import {pathToFileURL} from "node:url"
6
+
7
+ /**
8
+ * @typedef {import("./json.js").JsonValue} JsonValue
9
+ * @typedef {{from: number, to: number}} PortRange
10
+ * @typedef {{path: string, timeoutMs: number, intervalMs: number}} HealthConfig
11
+ * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
12
+ * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, policy: ProcessPolicy, port?: PortRange, restartDelayMs: number, command: string}} ProcessConfig
13
+ * @typedef {{path: string}} ControlConfig
14
+ * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number}} ProxyConfig
15
+ * @typedef {{application: string, control: ControlConfig, processes: ProcessConfig[], proxy: ProxyConfig}} RollbridgeConfig
16
+ * @typedef {{fix: string, message: string}} ConfigIssue
17
+ */
18
+
19
+ const PROCESS_POLICIES = new Set(["proxied", "companion", "singleton", "service"])
20
+ const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
21
+
22
+ /**
23
+ * Imports a JavaScript config module without validating it.
24
+ *
25
+ * The module must `export default` either a config object or a function (sync or
26
+ * async) that returns one.
27
+ * @param {string} configPath - Config path.
28
+ * @returns {Promise<{absolutePath: string, rawConfig: JsonValue}>} Imported config.
29
+ */
30
+ export async function parseConfigFile(configPath) {
31
+ const absolutePath = path.resolve(configPath)
32
+ const moduleNamespace = await import(pathToFileURL(absolutePath).href)
33
+ const exported = moduleNamespace.default
34
+
35
+ if (exported === undefined) {
36
+ throw new Error(`Config module ${absolutePath} must export a default config object or function`)
37
+ }
38
+
39
+ const rawConfig = typeof exported === "function" ? await exported() : exported
40
+
41
+ return {absolutePath, rawConfig}
42
+ }
43
+
44
+ /**
45
+ * Loads a JavaScript config module.
46
+ * @param {string} configPath - Config path.
47
+ * @returns {Promise<RollbridgeConfig>} Normalized config.
48
+ */
49
+ export async function loadConfig(configPath) {
50
+ const {absolutePath, rawConfig} = await parseConfigFile(configPath)
51
+
52
+ return normalizeConfig(rawConfig, absolutePath)
53
+ }
54
+
55
+ /**
56
+ * Resolves the config path to use, falling back to a default lookup order when none is given.
57
+ * @param {string | undefined} configPath - Explicit `--config` path, if provided.
58
+ * @param {string} [cwd] - Directory to search for default config files.
59
+ * @returns {Promise<string>} The explicit path, or the first existing default config file.
60
+ */
61
+ export async function resolveConfigPath(configPath, cwd = process.cwd()) {
62
+ if (configPath !== undefined) return configPath
63
+
64
+ for (const filename of DEFAULT_CONFIG_FILENAMES) {
65
+ const candidate = path.join(cwd, filename)
66
+
67
+ if (await configFileExists(candidate)) return candidate
68
+ }
69
+
70
+ throw new Error(`No config file found in ${cwd}. Pass --config <path> or add one of: ${DEFAULT_CONFIG_FILENAMES.join(", ")}.`)
71
+ }
72
+
73
+ /**
74
+ * @param {string} filePath - Candidate config file path.
75
+ * @returns {Promise<boolean>} True when the path exists and is a regular file.
76
+ */
77
+ async function configFileExists(filePath) {
78
+ try {
79
+ const stats = await fs.stat(filePath)
80
+
81
+ return stats.isFile()
82
+ } catch (error) {
83
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false
84
+
85
+ throw error
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Normalizes a raw config object, throwing when validation fails.
91
+ * @param {JsonValue} rawConfig - Parsed config.
92
+ * @param {string} [configPath] - Source path.
93
+ * @returns {RollbridgeConfig} Normalized config.
94
+ */
95
+ export function normalizeConfig(rawConfig, configPath = process.cwd()) {
96
+ const {config, issues} = validateConfig(rawConfig, configPath)
97
+
98
+ if (issues.length > 0) {
99
+ throw new Error(`Invalid Rollbridge config:\n${issues.map((issue) => ` - ${issue.message}`).join("\n")}`)
100
+ }
101
+
102
+ return config
103
+ }
104
+
105
+ /**
106
+ * Validates a raw config object and collects every issue instead of throwing on the first one.
107
+ * @param {JsonValue} rawConfig - Parsed config.
108
+ * @param {string} [configPath] - Source path.
109
+ * @returns {{config: RollbridgeConfig, issues: ConfigIssue[]}} Best-effort config and any issues.
110
+ */
111
+ export function validateConfig(rawConfig, configPath = process.cwd()) {
112
+ /** @type {ConfigIssue[]} */
113
+ const issues = []
114
+ const source = isPlainObject(rawConfig) ? rawConfig : /** @type {Record<string, JsonValue>} */ ({})
115
+
116
+ if (!isPlainObject(rawConfig)) {
117
+ issues.push({fix: "Export a default config object with application, proxy, and processes keys.", message: "Config must be an object"})
118
+ }
119
+
120
+ const application = normalizeString(source.application, "application", issues, {default: path.basename(path.dirname(configPath))})
121
+ const proxySource = objectAt(source.proxy, "proxy", issues)
122
+ const controlSource = objectAt(source.control, "control", issues, {})
123
+ const processesSource = arrayAt(source.processes, "processes", issues)
124
+ const proxy = normalizeProxy(proxySource, issues)
125
+ const control = {
126
+ path: normalizeString(controlSource.path, "control.path", issues, {default: `/tmp/rollbridge-${application}.sock`})
127
+ }
128
+ const processes = processesSource.map((processSource, index) => normalizeProcess(processSource, index, proxy, issues))
129
+
130
+ validateProcessSet(processes, issues)
131
+
132
+ return {config: {application, control, processes, proxy}, issues}
133
+ }
134
+
135
+ /**
136
+ * @param {Record<string, JsonValue>} source - Raw proxy config.
137
+ * @param {ConfigIssue[]} issues - Issue collector.
138
+ * @returns {ProxyConfig} Normalized proxy config.
139
+ */
140
+ function normalizeProxy(source, issues) {
141
+ return {
142
+ drainTimeoutMs: normalizeNumber(source.drainTimeoutMs, "proxy.drainTimeoutMs", issues, {default: 60000}),
143
+ forceStopTimeoutMs: normalizeNumber(source.forceStopTimeoutMs, "proxy.forceStopTimeoutMs", issues, {default: 10000}),
144
+ healthPath: normalizeString(source.healthPath, "proxy.healthPath", issues, {default: "/ping"}),
145
+ healthTimeoutMs: normalizeNumber(source.healthTimeoutMs, "proxy.healthTimeoutMs", issues, {default: 30000}),
146
+ host: normalizeString(source.host, "proxy.host", issues, {default: "127.0.0.1"}),
147
+ port: normalizeNumber(source.port, "proxy.port", issues, {default: 8182})
148
+ }
149
+ }
150
+
151
+ /**
152
+ * @param {JsonValue} value - Raw process config.
153
+ * @param {number} index - Process index.
154
+ * @param {ProxyConfig} proxy - Proxy config defaults.
155
+ * @param {ConfigIssue[]} issues - Issue collector.
156
+ * @returns {ProcessConfig} Normalized process config.
157
+ */
158
+ function normalizeProcess(value, index, proxy, issues) {
159
+ if (!isPlainObject(value)) {
160
+ issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
161
+
162
+ return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", policy: "companion", port: undefined, restartDelayMs: 1000}
163
+ }
164
+
165
+ const source = value
166
+
167
+ return {
168
+ command: normalizeString(source.command, `processes[${index}].command`, issues),
169
+ cwd: source.cwd === undefined ? undefined : normalizeString(source.cwd, `processes[${index}].cwd`, issues),
170
+ env: normalizeEnv(source.env, `processes[${index}].env`, issues),
171
+ gracefulStopMs: normalizeNumber(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, issues, {default: proxy.forceStopTimeoutMs}),
172
+ health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
173
+ id: normalizeString(source.id, `processes[${index}].id`, issues),
174
+ policy: normalizePolicy(source.policy, `processes[${index}].policy`, issues),
175
+ port: normalizePortRange(source.port, `processes[${index}].port`, issues),
176
+ restartDelayMs: normalizeNumber(source.restartDelayMs, `processes[${index}].restartDelayMs`, issues, {default: 1000})
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Validates cross-process rules: unique ids, exactly one proxied process, and proxied ports.
182
+ * @param {ProcessConfig[]} processes - Normalized processes.
183
+ * @param {ConfigIssue[]} issues - Issue collector.
184
+ * @returns {void}
185
+ */
186
+ function validateProcessSet(processes, issues) {
187
+ const seenIds = /** @type {Set<string>} */ (new Set())
188
+
189
+ for (const processConfig of processes) {
190
+ if (!processConfig.id) continue
191
+
192
+ if (seenIds.has(processConfig.id)) {
193
+ issues.push({fix: `Give each process a unique id; "${processConfig.id}" is used more than once.`, message: `Duplicate process id: ${processConfig.id}`})
194
+ }
195
+
196
+ seenIds.add(processConfig.id)
197
+ }
198
+
199
+ const proxiedProcesses = processes.filter((processConfig) => processConfig.policy === "proxied")
200
+
201
+ if (proxiedProcesses.length !== 1) {
202
+ issues.push({fix: "Mark exactly one process with policy: proxied so Rollbridge knows where to forward traffic.", message: `Config must define exactly one proxied process; found ${proxiedProcesses.length}`})
203
+ }
204
+
205
+ for (const processConfig of proxiedProcesses) {
206
+ if (processConfig.port) continue
207
+
208
+ issues.push({fix: `Add a port range to the proxied process "${processConfig.id || "(unnamed)"}", e.g. port: {from: 18000, to: 18099}.`, message: `Proxied process ${processConfig.id || "(unnamed)"} must define a port range`})
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {JsonValue} value - Raw policy.
214
+ * @param {string} key - Config key.
215
+ * @param {ConfigIssue[]} issues - Issue collector.
216
+ * @returns {ProcessPolicy} Normalized policy.
217
+ */
218
+ function normalizePolicy(value, key, issues) {
219
+ const policy = normalizeString(value, key, issues, {default: "companion"})
220
+
221
+ if (!PROCESS_POLICIES.has(policy)) {
222
+ issues.push({fix: `Set ${key} to one of: ${[...PROCESS_POLICIES].join(", ")}.`, message: `${key} must be one of: ${[...PROCESS_POLICIES].join(", ")}`})
223
+
224
+ return "companion"
225
+ }
226
+
227
+ return /** @type {ProcessPolicy} */ (policy)
228
+ }
229
+
230
+ /**
231
+ * @param {JsonValue} value - Raw health config.
232
+ * @param {string} key - Config key.
233
+ * @param {ProxyConfig} proxy - Proxy defaults.
234
+ * @param {ConfigIssue[]} issues - Issue collector.
235
+ * @returns {HealthConfig | undefined} Normalized health config.
236
+ */
237
+ function normalizeHealth(value, key, proxy, issues) {
238
+ if (value === false || value === null) return undefined
239
+
240
+ if (value !== undefined && !isPlainObject(value)) {
241
+ issues.push({fix: `Set ${key} to a mapping with path, timeoutMs, and intervalMs, or false to disable.`, message: `${key} must be an object`})
242
+
243
+ return undefined
244
+ }
245
+
246
+ const source = value === undefined ? /** @type {Record<string, JsonValue>} */ ({}) : value
247
+
248
+ return {
249
+ intervalMs: normalizeNumber(source.intervalMs, `${key}.intervalMs`, issues, {default: 250}),
250
+ path: normalizeString(source.path, `${key}.path`, issues, {default: proxy.healthPath}),
251
+ timeoutMs: normalizeNumber(source.timeoutMs, `${key}.timeoutMs`, issues, {default: proxy.healthTimeoutMs})
252
+ }
253
+ }
254
+
255
+ /**
256
+ * @param {JsonValue} value - Raw env config.
257
+ * @param {string} key - Config key.
258
+ * @param {ConfigIssue[]} issues - Issue collector.
259
+ * @returns {Record<string, string>} Normalized env.
260
+ */
261
+ function normalizeEnv(value, key, issues) {
262
+ if (value === undefined || value === null) return {}
263
+
264
+ if (!isPlainObject(value)) {
265
+ issues.push({fix: `Set ${key} to a mapping of string environment values.`, message: `${key} must be an object`})
266
+
267
+ return {}
268
+ }
269
+
270
+ /** @type {Record<string, string>} */
271
+ const env = {}
272
+
273
+ for (const [envKey, envValue] of Object.entries(value)) {
274
+ env[envKey] = normalizeString(envValue, `${key}.${envKey}`, issues)
275
+ }
276
+
277
+ return env
278
+ }
279
+
280
+ /**
281
+ * @param {JsonValue} value - Raw port range.
282
+ * @param {string} key - Config key.
283
+ * @param {ConfigIssue[]} issues - Issue collector.
284
+ * @returns {PortRange | undefined} Normalized range.
285
+ */
286
+ function normalizePortRange(value, key, issues) {
287
+ if (value === undefined || value === null) return undefined
288
+
289
+ if (typeof value === "number") {
290
+ if (!Number.isFinite(value) || value < 0) {
291
+ issues.push({fix: `Set ${key} to a positive port number or a {from, to} range.`, message: `${key} must be a positive port or valid {from, to} range`})
292
+
293
+ return undefined
294
+ }
295
+
296
+ return {from: value, to: value}
297
+ }
298
+
299
+ if (!isPlainObject(value)) {
300
+ issues.push({fix: `Set ${key} to a port number or {from, to} range.`, message: `${key} must be a positive port or valid {from, to} range`})
301
+
302
+ return undefined
303
+ }
304
+
305
+ const from = normalizeNumber(value.from, `${key}.from`, issues, {default: 0})
306
+ const to = normalizeNumber(value.to, `${key}.to`, issues, {default: from})
307
+
308
+ if (from < 0 || to < 0 || to < from) {
309
+ issues.push({fix: `Set ${key}.from and ${key}.to to a positive ascending range, e.g. {from: 18000, to: 18099}.`, message: `${key} must be a positive port or valid {from, to} range`})
310
+
311
+ return undefined
312
+ }
313
+
314
+ return {from, to}
315
+ }
316
+
317
+ /**
318
+ * @param {JsonValue} value - Raw value.
319
+ * @param {string} key - Config key.
320
+ * @param {ConfigIssue[]} issues - Issue collector.
321
+ * @param {{default?: string}} [options] - Options.
322
+ * @returns {string} Normalized string, or a placeholder when invalid.
323
+ */
324
+ function normalizeString(value, key, issues, options = {}) {
325
+ if (value === undefined || value === null) {
326
+ if (options.default !== undefined) return options.default
327
+
328
+ issues.push({fix: `Set ${key} to a string value.`, message: `${key} is required`})
329
+
330
+ return ""
331
+ }
332
+
333
+ if (typeof value !== "string") {
334
+ issues.push({fix: `Set ${key} to a string value.`, message: `${key} must be a string`})
335
+
336
+ return options.default ?? ""
337
+ }
338
+
339
+ return value
340
+ }
341
+
342
+ /**
343
+ * @param {JsonValue} value - Raw value.
344
+ * @param {string} key - Config key.
345
+ * @param {ConfigIssue[]} issues - Issue collector.
346
+ * @param {{default?: number}} [options] - Options.
347
+ * @returns {number} Normalized number, or a placeholder when invalid.
348
+ */
349
+ function normalizeNumber(value, key, issues, options = {}) {
350
+ if (value === undefined || value === null) {
351
+ if (options.default !== undefined) return options.default
352
+
353
+ issues.push({fix: `Set ${key} to a number.`, message: `${key} is required`})
354
+
355
+ return 0
356
+ }
357
+
358
+ if (typeof value !== "number" || !Number.isFinite(value)) {
359
+ issues.push({fix: `Set ${key} to a finite number.`, message: `${key} must be a number`})
360
+
361
+ return options.default ?? 0
362
+ }
363
+
364
+ return value
365
+ }
366
+
367
+ /**
368
+ * @param {JsonValue} value - Raw object.
369
+ * @param {string} key - Config key.
370
+ * @param {ConfigIssue[]} issues - Issue collector.
371
+ * @param {Record<string, JsonValue>} [defaultValue] - Default when missing.
372
+ * @returns {Record<string, JsonValue>} Normalized object, or a placeholder when invalid.
373
+ */
374
+ function objectAt(value, key, issues, defaultValue) {
375
+ if (value === undefined || value === null) {
376
+ if (defaultValue) return defaultValue
377
+
378
+ issues.push({fix: `Add a ${key} mapping to the config.`, message: `${key} is required`})
379
+
380
+ return {}
381
+ }
382
+
383
+ if (!isPlainObject(value)) {
384
+ issues.push({fix: `Set ${key} to a mapping.`, message: `${key} must be an object`})
385
+
386
+ return defaultValue ?? {}
387
+ }
388
+
389
+ return value
390
+ }
391
+
392
+ /**
393
+ * @param {JsonValue} value - Raw array.
394
+ * @param {string} key - Config key.
395
+ * @param {ConfigIssue[]} issues - Issue collector.
396
+ * @returns {JsonValue[]} Normalized array, or an empty array when invalid.
397
+ */
398
+ function arrayAt(value, key, issues) {
399
+ if (!Array.isArray(value)) {
400
+ issues.push({fix: `Set ${key} to a list of process definitions.`, message: `${key} must be an array`})
401
+
402
+ return []
403
+ }
404
+
405
+ return value
406
+ }
407
+
408
+ /**
409
+ * @param {JsonValue} value - Value.
410
+ * @returns {value is Record<string, JsonValue>} True for non-null, non-array objects.
411
+ */
412
+ function isPlainObject(value) {
413
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value)
414
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+
3
+ import net from "node:net"
4
+
5
+ /**
6
+ * @typedef {import("./json.js").JsonValue} JsonValue
7
+ */
8
+
9
+ /**
10
+ * Sends a command to a Rollbridge daemon.
11
+ * @param {object} args - Options.
12
+ * @param {Record<string, JsonValue>} args.command - Command payload.
13
+ * @param {string} args.path - Control socket path.
14
+ * @returns {Promise<Record<string, JsonValue>>} Response payload.
15
+ */
16
+ export async function sendControlCommand({command, path}) {
17
+ return await new Promise((resolve, reject) => {
18
+ const socket = net.createConnection(path)
19
+ let buffer = ""
20
+
21
+ socket.setEncoding("utf8")
22
+ socket.once("error", reject)
23
+ socket.on("data", (chunk) => {
24
+ buffer += chunk
25
+ const newlineIndex = buffer.indexOf("\n")
26
+
27
+ if (newlineIndex < 0) return
28
+
29
+ const line = buffer.slice(0, newlineIndex)
30
+ const response = JSON.parse(line)
31
+
32
+ socket.end()
33
+
34
+ if (response.status === "error") {
35
+ reject(new Error(String(response.error || "Unknown Rollbridge error")))
36
+ } else {
37
+ resolve(response)
38
+ }
39
+ })
40
+ socket.write(`${JSON.stringify(command)}\n`)
41
+ })
42
+ }