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.
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+
3
+ import {EventEmitter} from "node:events"
4
+ import {spawn} from "node:child_process"
5
+
6
+ /**
7
+ * @typedef {import("./json.js").JsonValue} JsonValue
8
+ * @typedef {"starting" | "running" | "stopping" | "stopped" | "failed"} ManagedProcessState
9
+ * @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
10
+ * @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
11
+ * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, restartDelayMs: number, shouldRestart: () => boolean, stopTimeoutMs: number}} ManagedProcessDefinition
12
+ * @typedef {{command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, logs: ManagedProcessLog[], pid: number | undefined, state: ManagedProcessState}} ManagedProcessStatus
13
+ */
14
+
15
+ export default class ManagedProcess extends EventEmitter {
16
+ /**
17
+ * @param {object} args - Options.
18
+ * @param {string} args.command - Shell command.
19
+ * @param {string | undefined} args.cwd - Working directory.
20
+ * @param {Record<string, string | undefined>} args.env - Environment.
21
+ * @param {string} args.id - Process id.
22
+ * @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger callback.
23
+ * @param {number} args.restartDelayMs - Restart delay.
24
+ * @param {() => boolean} args.shouldRestart - Restart policy callback.
25
+ * @param {number} args.stopTimeoutMs - Stop timeout.
26
+ */
27
+ constructor({command, cwd, env, id, logger, restartDelayMs, shouldRestart, stopTimeoutMs}) {
28
+ super()
29
+
30
+ this.command = command
31
+ this.cwd = cwd
32
+ this.env = env
33
+ this.id = id
34
+ this.logger = logger
35
+ this.restartDelayMs = restartDelayMs
36
+ this.shouldRestart = shouldRestart
37
+ this.stopTimeoutMs = stopTimeoutMs
38
+ this.state = /** @type {ManagedProcessState} */ ("stopped")
39
+ this.logs = /** @type {ManagedProcessLog[]} */ ([])
40
+ this.intentionalStop = false
41
+ this.restartTimer = undefined
42
+ this.child = undefined
43
+ this.exitPromise = undefined
44
+ this.pid = undefined
45
+ this.exitCode = undefined
46
+ this.exitSignal = undefined
47
+ }
48
+
49
+ /** @returns {Promise<void>} Resolves after spawn. */
50
+ async start() {
51
+ if (this.child) return
52
+
53
+ this.intentionalStop = false
54
+ this.exitCode = undefined
55
+ this.exitSignal = undefined
56
+ this.state = "starting"
57
+
58
+ await new Promise((resolve, reject) => {
59
+ const child = spawn(this.command, {
60
+ cwd: this.cwd,
61
+ detached: true,
62
+ env: {...process.env, ...this.env},
63
+ shell: true,
64
+ stdio: ["ignore", "pipe", "pipe"]
65
+ })
66
+
67
+ this.child = child
68
+ this.pid = child.pid
69
+ this.exitPromise = new Promise((exitResolve) => {
70
+ child.once("exit", (code, signal) => {
71
+ this.onExit(code, signal)
72
+ exitResolve(undefined)
73
+ })
74
+ })
75
+
76
+ child.once("spawn", () => {
77
+ this.state = "running"
78
+ this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null})
79
+ this.emit("started")
80
+ resolve(undefined)
81
+ })
82
+ child.once("error", (error) => {
83
+ this.state = "failed"
84
+ reject(error)
85
+ })
86
+ child.stdout.setEncoding("utf8")
87
+ child.stderr.setEncoding("utf8")
88
+ child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk))
89
+ child.stderr.on("data", (chunk) => this.appendLog("stderr", chunk))
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Updates the command template used for future restarts without touching the currently running child.
95
+ * @param {ManagedProcessDefinition} definition - Replacement process definition.
96
+ * @returns {void}
97
+ */
98
+ updateDefinition(definition) {
99
+ this.command = definition.command
100
+ this.cwd = definition.cwd
101
+ this.env = definition.env
102
+ this.logger = definition.logger
103
+ this.restartDelayMs = definition.restartDelayMs
104
+ this.shouldRestart = definition.shouldRestart
105
+ this.stopTimeoutMs = definition.stopTimeoutMs
106
+ }
107
+
108
+ /**
109
+ * @param {"stdout" | "stderr"} stream - Stream name.
110
+ * @param {string} chunk - Output chunk.
111
+ * @returns {void}
112
+ */
113
+ appendLog(stream, chunk) {
114
+ for (const line of String(chunk).split(/\r?\n/)) {
115
+ if (!line) continue
116
+
117
+ this.logs.push({at: new Date().toISOString(), line, stream})
118
+
119
+ if (this.logs.length > 200) {
120
+ this.logs.splice(0, this.logs.length - 200)
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @param {number | null} code - Exit code.
127
+ * @param {ProcessExitSignal} signal - Exit signal.
128
+ * @returns {void}
129
+ */
130
+ onExit(code, signal) {
131
+ const wasIntentional = this.intentionalStop
132
+
133
+ this.exitCode = code
134
+ this.exitSignal = signal
135
+ this.child = undefined
136
+ this.pid = undefined
137
+ this.exitPromise = undefined
138
+ this.state = wasIntentional ? "stopped" : "failed"
139
+ this.logger("process exited", {code, id: this.id, signal})
140
+ this.emit("exit", {code, signal})
141
+
142
+ if (!wasIntentional && this.shouldRestart()) {
143
+ this.restartTimer = setTimeout(() => {
144
+ this.restartTimer = undefined
145
+ this.start().catch((error) => {
146
+ this.logger("process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
147
+ })
148
+ }, this.restartDelayMs)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * @param {{timeoutMs?: number}} [options] - Stop options.
154
+ * @returns {Promise<void>} Resolves when stopped.
155
+ */
156
+ async stop(options = {}) {
157
+ this.intentionalStop = true
158
+
159
+ if (this.restartTimer) {
160
+ clearTimeout(this.restartTimer)
161
+ this.restartTimer = undefined
162
+ }
163
+
164
+ const child = this.child
165
+
166
+ if (!child || !child.pid) {
167
+ this.state = "stopped"
168
+ return
169
+ }
170
+
171
+ this.state = "stopping"
172
+ this.killProcessGroup("SIGTERM")
173
+ const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
174
+ const stopped = await this.waitForExit(timeoutMs)
175
+
176
+ if (!stopped) {
177
+ this.logger("process stop timed out; sending SIGKILL", {id: this.id, pid: child.pid})
178
+ this.killProcessGroup("SIGKILL")
179
+ await this.waitForExit(5000)
180
+ }
181
+
182
+ this.state = "stopped"
183
+ }
184
+
185
+ /**
186
+ * @param {"SIGTERM" | "SIGKILL"} signal - Signal to send.
187
+ * @returns {void}
188
+ */
189
+ killProcessGroup(signal) {
190
+ if (!this.child || !this.child.pid) return
191
+
192
+ try {
193
+ process.kill(-this.child.pid, signal)
194
+ } catch (error) {
195
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return
196
+ throw error
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @param {number} timeoutMs - Timeout.
202
+ * @returns {Promise<boolean>} True when the process exited before timeout.
203
+ */
204
+ async waitForExit(timeoutMs) {
205
+ if (!this.exitPromise) return true
206
+
207
+ let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
208
+ const timeoutPromise = new Promise((resolve) => {
209
+ timer = setTimeout(() => resolve(false), timeoutMs)
210
+ })
211
+ const exitPromise = this.exitPromise.then(() => true)
212
+ const result = await Promise.race([exitPromise, timeoutPromise])
213
+
214
+ if (timer) clearTimeout(timer)
215
+
216
+ return Boolean(result)
217
+ }
218
+
219
+ /** @returns {ManagedProcessStatus} Status payload. */
220
+ status() {
221
+ return {
222
+ command: this.command,
223
+ cwd: this.cwd,
224
+ exitCode: this.exitCode,
225
+ exitSignal: this.exitSignal,
226
+ id: this.id,
227
+ logs: this.logs.slice(-20),
228
+ pid: this.pid,
229
+ state: this.state
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+
3
+ import net from "node:net"
4
+
5
+ /**
6
+ * @typedef {{from: number, to: number}} PortRange
7
+ */
8
+
9
+ /**
10
+ * Tests whether a port can be bound.
11
+ * @param {object} args - Options.
12
+ * @param {string} args.host - Bind host.
13
+ * @param {number} args.port - Candidate port.
14
+ * @returns {Promise<{port: number} | {code: string}>} The bound port, or the bind error code.
15
+ */
16
+ async function tryPort({host, port}) {
17
+ const server = net.createServer()
18
+
19
+ return await new Promise((resolve) => {
20
+ server.once("error", (error) => {
21
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "EUNKNOWN"
22
+
23
+ resolve({code})
24
+ })
25
+ server.listen(port, host, () => {
26
+ const address = server.address()
27
+ const boundPort = address && typeof address === "object" ? address.port : port
28
+
29
+ server.close(() => resolve({port: boundPort}))
30
+ })
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Finds an available port in a range.
36
+ * @param {object} args - Options.
37
+ * @param {string} args.host - Bind host.
38
+ * @param {PortRange | undefined} args.range - Candidate range.
39
+ * @param {Set<number>} args.usedPorts - Ports already allocated by this deploy.
40
+ * @returns {Promise<number>} Available port.
41
+ */
42
+ export async function findAvailablePort({host, range, usedPorts}) {
43
+ if (!range || range.from === 0 || range.to === 0) {
44
+ const result = await tryPort({host, port: 0})
45
+
46
+ if (!("port" in result)) throw new Error(`Unable to allocate an ephemeral port on ${host} (${result.code})`)
47
+ usedPorts.add(result.port)
48
+
49
+ return result.port
50
+ }
51
+
52
+ let reserved = 0
53
+ let inUse = 0
54
+ let unavailable = 0
55
+ /** @type {string | undefined} */
56
+ let lastErrorCode
57
+
58
+ for (let port = range.from; port <= range.to; port += 1) {
59
+ if (usedPorts.has(port)) {
60
+ reserved += 1
61
+ continue
62
+ }
63
+
64
+ const result = await tryPort({host, port})
65
+
66
+ if ("port" in result) {
67
+ usedPorts.add(result.port)
68
+ return result.port
69
+ }
70
+
71
+ if (result.code === "EADDRINUSE") {
72
+ inUse += 1
73
+ } else {
74
+ unavailable += 1
75
+ lastErrorCode = result.code
76
+ }
77
+ }
78
+
79
+ const total = range.to - range.from + 1
80
+ const details = [`${reserved} reserved by this deploy`, `${inUse} already in use`]
81
+
82
+ if (unavailable > 0) details.push(`${unavailable} could not be bound (e.g. ${lastErrorCode})`)
83
+
84
+ throw new Error(
85
+ `No available ports in range ${range.from}-${range.to} (${total} port${total === 1 ? "" : "s"} on ${host}): ` +
86
+ `${details.join(", ")}. Widen the port range, free a port, or check bind permissions.`
87
+ )
88
+ }
@@ -0,0 +1,287 @@
1
+ // @ts-check
2
+
3
+ import {EventEmitter} from "node:events"
4
+ import ManagedProcess from "./managed-process.js"
5
+ import {findAvailablePort} from "./port-allocator.js"
6
+ import {renderObject, renderTemplate} from "./template.js"
7
+ import {waitForHealth} from "./health.js"
8
+
9
+ /**
10
+ * @typedef {import("./json.js").JsonValue} JsonValue
11
+ * @typedef {"starting" | "active" | "draining" | "stopped" | "failed"} ReleaseState
12
+ * @typedef {{http: number, websocket: number}} ReleaseConnections
13
+ * @typedef {{activatedAt: string | undefined, connectionCount: number, connections: ReleaseConnections, drainStartedAt: string | undefined, ports: Record<string, number>, processes: import("./managed-process.js").ManagedProcessStatus[], releaseId: string, releasePath: string, revision: string, state: ReleaseState, stoppedAt: string | undefined}} ReleaseStatus
14
+ * @typedef {{shouldRestart?: () => boolean}} BuildProcessOptions
15
+ */
16
+
17
+ /**
18
+ * @param {string} id - Process id.
19
+ * @returns {string} Environment suffix.
20
+ */
21
+ function envId(id) {
22
+ return id.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()
23
+ }
24
+
25
+ export default class ReleaseGroup extends EventEmitter {
26
+ /**
27
+ * @param {object} args - Options.
28
+ * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
29
+ * @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger.
30
+ * @param {string} args.releaseId - Release id.
31
+ * @param {string} args.releasePath - Release path.
32
+ * @param {string | undefined} args.revision - Revision.
33
+ * @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
34
+ */
35
+ constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}}) {
36
+ super()
37
+
38
+ this.config = config
39
+ this.logger = logger
40
+ this.releaseId = releaseId
41
+ this.releasePath = releasePath
42
+ this.revision = revision || releaseId
43
+ this.state = /** @type {ReleaseState} */ ("starting")
44
+ this.connectionCount = 0
45
+ this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
46
+ this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
47
+ this.ports = /** @type {Record<string, number>} */ ({})
48
+ this.servicePorts = servicePorts
49
+ this.portsAllocated = false
50
+ this.drainStartedAt = /** @type {string | undefined} */ (undefined)
51
+ this.activatedAt = /** @type {string | undefined} */ (undefined)
52
+ this.stoppedAt = /** @type {string | undefined} */ (undefined)
53
+ }
54
+
55
+ /** @returns {Promise<void>} Starts release-owned processes and health checks the proxied process. */
56
+ async start() {
57
+ this.state = "starting"
58
+
59
+ try {
60
+ await this.allocatePorts()
61
+
62
+ for (const processConfig of this.releaseProcessStartOrder()) {
63
+ const processInstance = this.buildProcess(processConfig)
64
+ this.processes.set(processConfig.id, processInstance)
65
+ await processInstance.start()
66
+
67
+ if (processConfig.policy === "proxied" && processConfig.port && processConfig.health) {
68
+ await waitForHealth({
69
+ health: processConfig.health,
70
+ host: this.config.proxy.host,
71
+ port: this.ports[processConfig.id]
72
+ })
73
+ }
74
+ }
75
+ } catch (error) {
76
+ this.state = "failed"
77
+ await this.stop()
78
+ throw error
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Starts companions before the proxied process so release-local dependencies are available before health checks.
84
+ * @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
85
+ */
86
+ releaseProcessStartOrder() {
87
+ const releaseProcesses = this.config.processes.filter((processConfig) => !["singleton", "service"].includes(processConfig.policy))
88
+ const companionProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "companion")
89
+ const proxiedProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "proxied")
90
+
91
+ return [...companionProcesses, ...proxiedProcesses]
92
+ }
93
+
94
+ /** @returns {void} Marks this release active. */
95
+ activate() {
96
+ this.state = "active"
97
+ this.activatedAt = new Date().toISOString()
98
+ }
99
+
100
+ /** @returns {Promise<void>} Allocates all configured per-process ports. */
101
+ async allocatePorts() {
102
+ if (this.portsAllocated) return
103
+
104
+ const usedPorts = /** @type {Set<number>} */ (new Set())
105
+
106
+ for (const processConfig of this.config.processes) {
107
+ if (!processConfig.port) continue
108
+ if (processConfig.policy === "service" && this.servicePorts[processConfig.id] !== undefined) {
109
+ this.ports[processConfig.id] = this.servicePorts[processConfig.id]
110
+ usedPorts.add(this.servicePorts[processConfig.id])
111
+ continue
112
+ }
113
+
114
+ this.ports[processConfig.id] = await findAvailablePort({
115
+ host: this.config.proxy.host,
116
+ range: processConfig.port,
117
+ usedPorts
118
+ })
119
+ }
120
+
121
+ this.portsAllocated = true
122
+ }
123
+
124
+ /**
125
+ * Builds a managed process from config.
126
+ * @param {import("./config.js").ProcessConfig} processConfig - Process config.
127
+ * @param {BuildProcessOptions} [options] - Build options.
128
+ * @returns {ManagedProcess} Managed process.
129
+ */
130
+ buildProcess(processConfig, options = {}) {
131
+ const context = this.contextForProcess(processConfig)
132
+ const renderedEnv = /** @type {Record<string, string>} */ (renderObject(processConfig.env, context))
133
+ const processEnv = {
134
+ ...this.baseEnvironment(processConfig),
135
+ ...renderedEnv
136
+ }
137
+
138
+ return new ManagedProcess({
139
+ command: renderTemplate(processConfig.command, context),
140
+ cwd: processConfig.cwd ? renderTemplate(processConfig.cwd, context) : this.releasePath,
141
+ env: processEnv,
142
+ id: processConfig.id,
143
+ logger: (message, data = {}) => this.logger(message, {processId: processConfig.id, releaseId: this.releaseId, ...data}),
144
+ restartDelayMs: processConfig.restartDelayMs,
145
+ shouldRestart: options.shouldRestart || (() => this.state === "active" || this.state === "starting"),
146
+ stopTimeoutMs: processConfig.gracefulStopMs
147
+ })
148
+ }
149
+
150
+ /**
151
+ * @param {import("./config.js").ProcessConfig} processConfig - Process config.
152
+ * @returns {Record<string, string>} Base environment.
153
+ */
154
+ baseEnvironment(processConfig) {
155
+ /** @type {Record<string, string>} */
156
+ const env = {
157
+ ROLLBRIDGE_APPLICATION: this.config.application,
158
+ ROLLBRIDGE_PROCESS_ID: processConfig.id,
159
+ ROLLBRIDGE_RELEASE_ID: this.releaseId,
160
+ ROLLBRIDGE_RELEASE_PATH: this.releasePath,
161
+ ROLLBRIDGE_REVISION: this.revision
162
+ }
163
+
164
+ if (this.ports[processConfig.id] !== undefined) {
165
+ env.ROLLBRIDGE_PORT = String(this.ports[processConfig.id])
166
+ }
167
+
168
+ for (const [processId, port] of Object.entries(this.ports)) {
169
+ env[`ROLLBRIDGE_${envId(processId)}_PORT`] = String(port)
170
+ }
171
+
172
+ return env
173
+ }
174
+
175
+ /**
176
+ * @param {import("./config.js").ProcessConfig} processConfig - Process config.
177
+ * @returns {Record<string, JsonValue>} Template context.
178
+ */
179
+ contextForProcess(processConfig) {
180
+ return {
181
+ application: this.config.application,
182
+ port: this.ports[processConfig.id],
183
+ ports: this.ports,
184
+ processId: processConfig.id,
185
+ proxy: {
186
+ host: this.config.proxy.host,
187
+ port: this.config.proxy.port
188
+ },
189
+ releaseId: this.releaseId,
190
+ releasePath: this.releasePath,
191
+ revision: this.revision
192
+ }
193
+ }
194
+
195
+ /**
196
+ * @returns {{process: ManagedProcess, target: string}} Proxied process target.
197
+ */
198
+ proxyTarget() {
199
+ const processConfig = this.config.processes.find((candidate) => candidate.policy === "proxied")
200
+
201
+ if (!processConfig) throw new Error("No proxied process configured")
202
+
203
+ const processInstance = this.processes.get(processConfig.id)
204
+ const port = this.ports[processConfig.id]
205
+
206
+ if (!processInstance || !port) {
207
+ throw new Error(`Proxied process ${processConfig.id} is not running`)
208
+ }
209
+
210
+ return {
211
+ process: processInstance,
212
+ target: `http://${this.config.proxy.host}:${port}`
213
+ }
214
+ }
215
+
216
+ /**
217
+ * @param {"http" | "websocket"} type - Connection type.
218
+ * @returns {() => void} Release callback.
219
+ */
220
+ retainConnection(type) {
221
+ this.connectionCount += 1
222
+ this.connections[type] += 1
223
+ let released = false
224
+
225
+ return () => {
226
+ if (released) return
227
+
228
+ released = true
229
+ this.connectionCount -= 1
230
+ this.connections[type] -= 1
231
+
232
+ if (this.connectionCount === 0) {
233
+ this.emit("drained")
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Starts draining and stops once existing connections close or timeout.
240
+ * @param {number} timeoutMs - Drain timeout.
241
+ * @returns {Promise<void>} Resolves when stopped.
242
+ */
243
+ async drainAndStop(timeoutMs) {
244
+ if (this.state === "stopped") return
245
+
246
+ this.state = "draining"
247
+ this.drainStartedAt = new Date().toISOString()
248
+
249
+ if (this.connectionCount > 0) {
250
+ await new Promise((resolve) => {
251
+ const timer = setTimeout(resolve, timeoutMs)
252
+ this.once("drained", () => {
253
+ clearTimeout(timer)
254
+ resolve(undefined)
255
+ })
256
+ })
257
+ }
258
+
259
+ await this.stop()
260
+ }
261
+
262
+ /** @returns {Promise<void>} Stops all release-owned processes. */
263
+ async stop() {
264
+ const stopTasks = [...this.processes.values()].map((processInstance) => processInstance.stop())
265
+
266
+ await Promise.allSettled(stopTasks)
267
+ this.state = "stopped"
268
+ this.stoppedAt = new Date().toISOString()
269
+ }
270
+
271
+ /** @returns {ReleaseStatus} Status payload. */
272
+ status() {
273
+ return {
274
+ activatedAt: this.activatedAt,
275
+ connectionCount: this.connectionCount,
276
+ connections: {...this.connections},
277
+ drainStartedAt: this.drainStartedAt,
278
+ ports: {...this.ports},
279
+ processes: [...this.processes.values()].map((processInstance) => processInstance.status()),
280
+ releaseId: this.releaseId,
281
+ releasePath: this.releasePath,
282
+ revision: this.revision,
283
+ state: this.state,
284
+ stoppedAt: this.stoppedAt
285
+ }
286
+ }
287
+ }
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {import("./json.js").JsonValue} JsonValue
5
+ * @typedef {Record<string, JsonValue>} TemplateContext
6
+ */
7
+
8
+ /**
9
+ * Resolves a dotted template key against the context.
10
+ * @param {string} key - Dotted key from a template expression.
11
+ * @param {TemplateContext} context - Template context.
12
+ * @returns {JsonValue} Resolved value.
13
+ */
14
+ export function resolveTemplateValue(key, context) {
15
+ const parts = key.split(".")
16
+ let current = /** @type {JsonValue} */ (context)
17
+
18
+ for (const part of parts) {
19
+ if (!current || typeof current !== "object") {
20
+ return undefined
21
+ }
22
+
23
+ current = /** @type {Record<string, JsonValue>} */ (current)[part]
24
+ }
25
+
26
+ return current
27
+ }
28
+
29
+ /**
30
+ * Renders `{{key}}` placeholders in a string.
31
+ * @param {string} value - Template string.
32
+ * @param {TemplateContext} context - Template context.
33
+ * @returns {string} Rendered string.
34
+ */
35
+ export function renderTemplate(value, context) {
36
+ return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => {
37
+ const resolved = resolveTemplateValue(key, context)
38
+
39
+ if (resolved === undefined || resolved === null) {
40
+ throw new Error(`Missing template value for ${match}`)
41
+ }
42
+
43
+ return String(resolved)
44
+ })
45
+ }
46
+
47
+ /**
48
+ * Renders all string values in a plain JSON-like object.
49
+ * @param {JsonValue} value - Value to render.
50
+ * @param {TemplateContext} context - Template context.
51
+ * @returns {JsonValue} Rendered value.
52
+ */
53
+ export function renderObject(value, context) {
54
+ if (typeof value === "string") {
55
+ return renderTemplate(value, context)
56
+ }
57
+
58
+ if (Array.isArray(value)) {
59
+ return value.map((entry) => renderObject(entry, context))
60
+ }
61
+
62
+ if (value && typeof value === "object") {
63
+ /** @type {Record<string, JsonValue>} */
64
+ const rendered = {}
65
+
66
+ for (const [key, entry] of Object.entries(value)) {
67
+ rendered[key] = renderObject(entry, context)
68
+ }
69
+
70
+ return rendered
71
+ }
72
+
73
+ return value
74
+ }
package/tensorbuzz.yml ADDED
@@ -0,0 +1,4 @@
1
+ before_script:
2
+ - npm install
3
+ script:
4
+ - npm run all-checks