@shield-cult/build-orchestrator 0.0.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.
Files changed (4) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +15 -0
  3. package/main.mts +443 -0
  4. package/package.json +21 -0
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, Shield Cult Studios
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # Build Orchestrator
2
+
3
+ A simple build orchestrator. Supports:
4
+ - Arbitrary entrypoints
5
+ - Arbitrary dependencies between entrypoints
6
+ - Arbitrary entrypoint change detection
7
+ - Rebuilding only entrypoints that changed/had their dependencies change
8
+ - Greedy rebuild that tries to merge changes into ongoing builds as long as they can be done seamlessly
9
+ - Generates a full report of the current build status
10
+
11
+ And finally - leaves the actual build logic for every entrypoint entirely up to the user - plug it into your favorite tool no questions asked - this is just an orchestrator.
12
+
13
+ ## Why
14
+
15
+ We needed this in at least two separate Shield Cult projects, and that's before even considering personal projects of our members. That's really it.
package/main.mts ADDED
@@ -0,0 +1,443 @@
1
+ import EventEmitter from "events";
2
+
3
+ export function Debouncer<Params extends unknown[]>(callback: (...params: Params) => unknown, timeoutMs: number) {
4
+ let timeout: NodeJS.Timeout | undefined = undefined
5
+ return {
6
+ Exec: (...params: Params) => (timeout && clearTimeout(timeout), timeout = setTimeout(() => callback(...params), timeoutMs)),
7
+ Terminate: () => (timeout && clearTimeout(timeout), timeout = undefined),
8
+ }
9
+ }
10
+
11
+ type Debouncer<Params extends unknown[] = []> = ReturnType<typeof Debouncer<Params>>;
12
+
13
+ // Allow adding new entrypoints/deps in real time (forces rebuild)
14
+ // Allow rebuild debounce
15
+
16
+ export type BuildResult<Metadata extends object, ErrorCode extends string> = BuilderBuildResult<Metadata, ErrorCode> | (Partial<Metadata> & {
17
+ state: 'built'
18
+ buildStyle: 'cached'
19
+ })
20
+
21
+ export type BuilderBuildResult<Metadata extends object, ErrorCode extends string> = Partial<Metadata> &
22
+ (
23
+ | {
24
+ state: 'built'
25
+ buildStyle: 'full'
26
+ }
27
+ | {
28
+ state: 'error'
29
+ errors: BuildError<ErrorCode>[]
30
+ }
31
+ )
32
+
33
+ export type BuildStatus<ItemId extends string, Metadata extends object, ErrorCode extends string> = {
34
+ /** The last _successful_ build id - does not update when the build errors even as the rest of the data does */
35
+ _lastBuildId: number
36
+ _lastChangedBuildId: number
37
+ _buildData:
38
+ {
39
+ // The builder that processed this entrypoint
40
+ builder: BuilderInstance<ItemId, Metadata, ErrorCode>
41
+ } &
42
+ (
43
+ | {
44
+ // This state can only happen before the first build
45
+ state: 'pending'
46
+ }
47
+ | BuildResult<Metadata, ErrorCode>
48
+ )
49
+ }
50
+
51
+ export type BuildError<ErrorCode extends string> = {
52
+ errorType: 'compile-error' | 'validation-error' | 'dependency-failure';
53
+ errorCode: ErrorCode | 'build-aborted' | 'missing-dependencies' | 'errored-dependencies' | 'unknown-error';
54
+ message: string;
55
+ location?: string;
56
+ }
57
+
58
+ /**
59
+ * The different changes that can occur to an entrypoint
60
+ *
61
+ * - discovered - register as new entrypoint and force clear _all_ caches
62
+ * - changed - rebuild entrypoint and all of its dependencies
63
+ * - dependencies-changed - refresh dependencies cache for entrypoint and then rebuild
64
+ * - deleted - entrypoint deleted - remove any reference in all caches
65
+ */
66
+ export type EntrypointChanged = 'discovered' | 'changed' | 'dependencies-changed' | 'deleted'
67
+
68
+ export type BuilderInstance<ItemId extends string, Metadata extends object, ErrorCode extends string> = {
69
+ builderType: string;
70
+ itemId: ItemId;
71
+ dependencies: () => ItemId[];
72
+ watch: (onChange: (change: Extract<EntrypointChanged, 'changed' | 'dependencies-changed'>) => void) => Promise<void>;
73
+ build: (metadata: Partial<Metadata>) => Promise<BuilderBuildResult<Metadata, ErrorCode>>
74
+ dispose: (metadata: Partial<Metadata>) => Promise<void>
75
+ }
76
+
77
+ export type Builder<ItemId extends string, Metadata extends object, ErrorCode extends string> = (itemId: ItemId, prod: boolean) =>
78
+ BuilderInstance<ItemId, Metadata, ErrorCode>;
79
+
80
+ export type BuildStatusCode = 'started' | 'interrupted' | 'finished';
81
+
82
+ export default class BuildOrchestrator<ItemIds extends string, Metadata extends object, ErrorCode extends string> {
83
+ private _currentBuildId = 0;
84
+ private _isCurrentlyBuilding = false;
85
+ private _lastBuildSucceeded = false;
86
+ private _currentlyBuilding = new Map<ItemIds, AbortController>();
87
+ private _remainingBuildItems = new Set<ItemIds>();
88
+ private _buildStatus: {
89
+ [Key in ItemIds]?: BuildStatus<Key, Metadata, ErrorCode>
90
+ } = {};
91
+
92
+ private _dependsOn: {
93
+ [Key in ItemIds]?: Set<ItemIds>
94
+ } = {};
95
+ private _usedBy: {
96
+ [Key in ItemIds]?: Set<ItemIds>
97
+ } = {};
98
+
99
+ private readonly _prod: boolean;
100
+ private readonly _watch: boolean;
101
+ private readonly _buildDebounce: Debouncer;
102
+
103
+ public constructor(
104
+ prod: boolean,
105
+ watch: boolean,
106
+ debounceMs: number,
107
+ ) {
108
+ this._prod = prod;
109
+ this._watch = watch;
110
+ this._buildDebounce = Debouncer(() => this.StartBuild(), debounceMs);
111
+ }
112
+
113
+ public get IsCurrentlyBuilding() {
114
+ return this._isCurrentlyBuilding;
115
+ }
116
+
117
+ public get BuildReport(): { readonly [Key in ItemIds]?: Readonly<BuildStatus<Key, Metadata, ErrorCode>> } {
118
+ return this._buildStatus;
119
+ }
120
+
121
+ public get IsValid() {
122
+ return !this.IsCurrentlyBuilding && this._lastBuildSucceeded;
123
+ }
124
+
125
+ public readonly Events = new EventEmitter<{
126
+ 'build-status-changed': [BuildStatusCode];
127
+ }>();
128
+
129
+
130
+ public ForceRebuild() {
131
+ this._buildDebounce.Terminate();
132
+ this.InterruptBuild();
133
+ const allItems = Object.keys(this._buildStatus) as ItemIds[];
134
+ allItems.forEach(itemId => this._buildStatus[itemId]!._lastChangedBuildId = this._currentBuildId + 1);
135
+ this._buildDebounce.Exec();
136
+ }
137
+
138
+ private StartBuild() {
139
+ // We can't start a build while we're already in the middle of one
140
+ if (this.IsCurrentlyBuilding) return;
141
+ this.Events.emit('build-status-changed', 'started');
142
+ // Increment the build id
143
+ this._currentBuildId++;
144
+ this._isCurrentlyBuilding = true;
145
+ const allItems = Object.keys(this._buildStatus) as ItemIds[];
146
+ const rootItems = allItems.filter(itemId => !this._dependsOn[itemId]);
147
+
148
+ // Mark all items as planned for building
149
+ allItems.forEach(itemId => this._remainingBuildItems.add(itemId));
150
+ // Initiate build for all root entry points
151
+ rootItems.forEach(itemId => this.TryRebuildEntrypoint(itemId));
152
+ // Try to finish the build (for cases where no root entrypoints were found)
153
+ this.TryFinishBuild();
154
+ }
155
+
156
+ private TryRebuildEntrypoint<ItemId extends ItemIds>(itemId: ItemId, dependencyChanged: boolean = false) {
157
+ const dependsOn = this._dependsOn[itemId];
158
+ // This should always exist
159
+ const status = this._buildStatus[itemId]!;
160
+ const builder = status._buildData.builder;
161
+
162
+ if(dependencyChanged) status._lastChangedBuildId = this._currentBuildId;
163
+ // If there exists a dependency which is still not built, OR missing, OR currently building, OR errored on its current build - we cannot build ourselves
164
+ if ([...dependsOn ?? []].some(dep => this._remainingBuildItems.has(dep) || !this._buildStatus[dep] || this._currentlyBuilding.has(dep) || this._buildStatus[dep]._buildData.state === 'error')) {
165
+ return;
166
+ }
167
+
168
+ if (this._remainingBuildItems.delete(itemId)) {
169
+ // Can the build be cached
170
+ if (status._lastBuildId >= status._lastChangedBuildId) {
171
+ status._lastBuildId = this._currentBuildId;
172
+ const report = {
173
+ state: "built",
174
+ buildStyle: "cached",
175
+ } satisfies BuildResult<Metadata, ErrorCode>;
176
+
177
+ status._buildData = {
178
+ ...status._buildData,
179
+ ...report,
180
+ }
181
+
182
+ this.OnEntrypointBuilt(itemId, true);
183
+ }
184
+ // If it cannot be cached, initiate a full build
185
+ else {
186
+ const abortController = new AbortController();
187
+ this._currentlyBuilding.set(itemId, abortController);
188
+
189
+ new Promise<BuildResult<Metadata, ErrorCode>>((resolve, reject) => {
190
+ abortController.signal.addEventListener("abort", () => reject("build-aborted"));
191
+ builder.build(status._buildData as Metadata).then(resolve, reject);
192
+ }).then(result => {
193
+ if(this._currentlyBuilding.get(itemId) !== abortController) {
194
+ // Item was deleted
195
+ return;
196
+ }
197
+ status._buildData = {
198
+ builder,
199
+ ...result,
200
+ }
201
+
202
+ // If the build succeeded, we run post build normally
203
+ if (result.state === 'built') {
204
+ status._lastBuildId = this._currentBuildId;
205
+ this.OnEntrypointBuilt(itemId, true, true);
206
+ }
207
+ // Otherwise, we try to finish the build
208
+ else {
209
+ this.OnEntrypointBuilt(itemId, false);
210
+ }
211
+ }, failure => {
212
+ if(this._currentlyBuilding.get(itemId) !== abortController) {
213
+ // Item was deleted
214
+ return;
215
+ }
216
+ const report = {
217
+ state: "error",
218
+ errors: [{
219
+ errorType: "compile-error",
220
+ errorCode: failure === "build-aborted" ? "build-aborted" : "unknown-error",
221
+ message: failure === "build-aborted" ? "The build was aborted" : `An error occured - ${failure}`,
222
+ }]
223
+ } satisfies BuildResult<Metadata, ErrorCode>;
224
+
225
+ status._buildData = {
226
+ // This can technically carry over unexpected fields, but also the build should never actually error in normal usage so it's not crucial
227
+ ...status._buildData,
228
+ ...report,
229
+ }
230
+
231
+ // Try to finish the build
232
+ this.OnEntrypointBuilt(itemId, false);
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ private OnEntrypointBuilt<ItemId extends ItemIds>(itemId: ItemId, success: boolean, changed: boolean = false) {
239
+ this._currentlyBuilding.delete(itemId);
240
+ if (success) {
241
+ const usedBy = this._usedBy[itemId];
242
+ if (usedBy) {
243
+ [...usedBy].forEach((user) => this.TryRebuildEntrypoint(user, changed));
244
+ }
245
+ }
246
+ setImmediate(() => this.TryFinishBuild());
247
+ }
248
+
249
+ private InterruptEntrypointBuild<ItemId extends ItemIds>(itemId: ItemId) {
250
+ this._currentlyBuilding.get(itemId)?.abort();
251
+ }
252
+
253
+ private InterruptBuild() {
254
+ [...this._currentlyBuilding.keys()].forEach(itemId => this.InterruptEntrypointBuild(itemId));
255
+ this.TryFinishBuild();
256
+ }
257
+
258
+ private TryFinishBuild() {
259
+ // We can't finish a build when no build is ongoing
260
+ if (!this.IsCurrentlyBuilding) return;
261
+ // A build can only be finished when no entrypoints are currently being built
262
+ if (this._currentlyBuilding.size === 0) {
263
+ this._isCurrentlyBuilding = false;
264
+ // The build succeeds if no entry points errored, AND we have no remaining build items
265
+ this._lastBuildSucceeded = this._remainingBuildItems.size === 0 && !(Object.keys(this._buildStatus) as ItemIds[]).some(itemId => this._buildStatus[itemId]!._buildData.state === "error");
266
+
267
+ this.Events.emit('build-status-changed', this._lastBuildSucceeded ? 'finished' : 'interrupted');
268
+ // Now, if we did have remaining build items, it means they were waiting for dependencies that never built
269
+ // As such, we mark those appropriately
270
+ if (!this._lastBuildSucceeded) {
271
+ [...this._remainingBuildItems].forEach(itemId => {
272
+ // This should always exist
273
+ const status = this._buildStatus[itemId]!;
274
+ const dependencies = [...this._dependsOn[itemId] ?? []];
275
+
276
+ const missingDeps = dependencies.filter(dep => !this._buildStatus[dep]);
277
+ const erroredDeps = dependencies.filter(dep => this._remainingBuildItems.has(dep) || this._buildStatus[dep]?._buildData.state === "error");
278
+ const report = {
279
+ state: "error",
280
+ errors: ([
281
+ missingDeps.length > 0 && {
282
+ errorType: "dependency-failure",
283
+ errorCode: 'missing-dependencies',
284
+ message: `The following dependencies used by this entrypoint were not located - [${missingDeps.join(", ")}]`,
285
+ },
286
+ erroredDeps.length > 0 && {
287
+ errorType: "dependency-failure",
288
+ errorCode: 'errored-dependencies',
289
+ message: `The following dependencies used by this entrypoint did not build correctly - [${erroredDeps.join(", ")}]`,
290
+ },
291
+ dependencies.length === 0 && {
292
+ errorType: "compile-error",
293
+ errorCode: 'build-aborted',
294
+ message: 'The build was forcefuly cancelled before this root item could be built'
295
+ },
296
+ ] satisfies (BuildError<ErrorCode> | false)[]).filter(Boolean) as BuildError<ErrorCode>[]
297
+ } satisfies BuildResult<Metadata, ErrorCode>
298
+
299
+ status._buildData = {
300
+ ...status._buildData,
301
+ ...report,
302
+ }
303
+
304
+ status._lastChangedBuildId = this._currentBuildId;
305
+ })
306
+ }
307
+
308
+ this._remainingBuildItems.clear();
309
+ }
310
+ }
311
+
312
+ public async AddEntrypoint<ItemId extends ItemIds>(itemId: ItemId, builder: Builder<ItemId, Metadata, ErrorCode>) {
313
+ await this.RemoveEntrypoint(itemId);
314
+
315
+ const builderInstance = builder(itemId, this._prod);
316
+ this._buildStatus[itemId] = {
317
+ _lastBuildId: -1,
318
+ _lastChangedBuildId: this._currentBuildId,
319
+ _buildData: {
320
+ builder: builderInstance,
321
+ state: "pending",
322
+ }
323
+ } satisfies BuildStatus<ItemId, Metadata, ErrorCode>;
324
+
325
+ this.RefreshDependencies(itemId, builderInstance.dependencies());
326
+ if (this._watch) await builderInstance.watch((change) => this.OnEntrypointChanged(itemId, change));
327
+ this.OnEntrypointChanged(itemId, "discovered");
328
+ }
329
+
330
+ public async RemoveEntrypoint<ItemId extends ItemIds>(itemId: ItemId) {
331
+ if (!this._buildStatus[itemId]) return;
332
+
333
+ const dependencies = this._dependsOn[itemId];
334
+ if (dependencies) {
335
+ [...dependencies].forEach(dep => this.RemoveDependency(dep, itemId));
336
+ }
337
+ delete this._dependsOn[itemId];
338
+ this.OnEntrypointChanged(itemId, "deleted");
339
+ }
340
+
341
+ private RefreshDependencies<ItemId extends ItemIds>(itemId: ItemId, dependencies: ItemIds[]) {
342
+ const oldDependencies = new Set<ItemIds>(this._dependsOn[itemId] ?? []);
343
+ if (dependencies.length > 0) {
344
+ dependencies.forEach(dep => {
345
+ this._usedBy[dep] ??= new Set();
346
+ this._usedBy[dep].add(itemId);
347
+ oldDependencies.delete(dep);
348
+ });
349
+ this._dependsOn[itemId] = new Set(dependencies);
350
+ } else {
351
+ delete this._dependsOn[itemId];
352
+ }
353
+
354
+ [...oldDependencies].forEach(dep => this.RemoveDependency(dep, itemId));
355
+ }
356
+
357
+ private RemoveDependency(dep: ItemIds, itemId: ItemIds) {
358
+ const set = this._usedBy[dep];
359
+ if (set) set.delete(itemId);
360
+ if (set && set.size === 0) delete this._usedBy[dep];
361
+ }
362
+
363
+ private OnEntrypointChanged<ItemId extends ItemIds>(itemId: ItemId, change: EntrypointChanged) {
364
+ // This method only does anything significant in watch mode - to build in non-watch mode simply call build when you're ready
365
+ if (!this._watch) return;
366
+
367
+ const isCurrentlyBuilding = this.IsCurrentlyBuilding;
368
+
369
+ let rebuildEntrypoint = false;
370
+ let fullRebuild = !isCurrentlyBuilding; // If we aren't building and a change happened - rebuild
371
+
372
+ switch (change) {
373
+ case "dependencies-changed":
374
+ this.RefreshDependencies(itemId, this._buildStatus[itemId]?._buildData.builder.dependencies() ?? []);
375
+ case "changed":
376
+ const status = this._buildStatus[itemId]!;
377
+ if (isCurrentlyBuilding) {
378
+ // If we're currently building this entrypoint (or already finished it) - restart the build
379
+ if (this._currentlyBuilding.has(itemId) || !this._remainingBuildItems.has(itemId)) {
380
+ fullRebuild ||= true;
381
+ // In case we force a rebuild, we register this entrypoint as having changed in the next rebuild
382
+ status._lastChangedBuildId = this._currentBuildId + 1;
383
+ } else {
384
+ // If this entrypoint wasn't built yet - just make sure it is rebuilt
385
+ rebuildEntrypoint = true;
386
+ // In case the entrypoint wasn't built yet, we can also try to greedily set it to having changed in this build
387
+ status._lastChangedBuildId = this._currentBuildId;
388
+ }
389
+ } else {
390
+ // Mark this entrypoint as changed for the next rebuild
391
+ status._lastChangedBuildId = this._currentBuildId + 1;
392
+ }
393
+ break;
394
+ case "discovered":
395
+ // We only care if we're currently building to update the running build - since every new build recalculates the build plan anyways
396
+ if (isCurrentlyBuilding) {
397
+ // If we're currently building - simply add this entrypoint to the plan and try to build it
398
+ rebuildEntrypoint = true;
399
+ }
400
+ break;
401
+ case "deleted":
402
+ if (isCurrentlyBuilding) {
403
+ if (this._remainingBuildItems.delete(itemId)) {
404
+ // We successfully prevented this entrypoint from building
405
+ }
406
+ else if (this._currentlyBuilding.has(itemId)) {
407
+ // We successfully interrupted this entrypoint from building, the build is still salvagable
408
+ this.InterruptEntrypointBuild(itemId);
409
+ this._currentlyBuilding.delete(itemId);
410
+ }
411
+ else {
412
+ // The entrypoint was already build and already polluted the build - force a full rebuild
413
+ fullRebuild = true;
414
+ }
415
+ }
416
+
417
+ // Now we can run the post-deletion logic for the entrypoing
418
+ const buildData = this._buildStatus[itemId]!._buildData;
419
+ const builder = buildData!.builder;
420
+ // TODO - introduce some sort of mechanism to make sure an entrypoing can't be re-added until its dispose method finishes running
421
+ // In practice, for now this shouldn't really cause any issues due to debounces
422
+ builder.dispose(buildData as BuildResult<Metadata, ErrorCode>);
423
+ delete this._buildStatus[itemId];
424
+ }
425
+
426
+ if (fullRebuild) {
427
+ if (isCurrentlyBuilding) {
428
+ this.InterruptBuild();
429
+ }
430
+ this._buildDebounce.Exec();
431
+ } else if (rebuildEntrypoint) {
432
+ this._remainingBuildItems.add(itemId);
433
+ this.TryRebuildEntrypoint(itemId);
434
+ }
435
+ }
436
+
437
+ public Dispose() {
438
+ this.InterruptBuild();
439
+ this._buildDebounce.Terminate();
440
+ this.Events.removeAllListeners();
441
+ (Object.keys(this._buildStatus) as ItemIds[]).forEach(itemId => this.RemoveEntrypoint(itemId));
442
+ }
443
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@shield-cult/build-orchestrator",
3
+ "version": "0.0.1",
4
+ "author": "shield-cult-studios",
5
+ "description": "Standard build orchestrator used by Shield Cult projects",
6
+ "type": "module",
7
+ "main": "main.mts",
8
+ "scripts": {
9
+ "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test tests/**/*.test.mts"
10
+ },
11
+ "license": "ISC",
12
+ "devDependencies": {
13
+ "@types/node": "^25.9.3"
14
+ },
15
+ "files": [
16
+ "main.mts",
17
+ "package.json",
18
+ "LICENSE",
19
+ "README.md"
20
+ ]
21
+ }