@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.
- package/LICENSE +15 -0
- package/README.md +15 -0
- package/main.mts +443 -0
- 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
|
+
}
|