@quenty/nevermore-cli 4.48.0 → 4.50.0

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 (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
  3. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-test-command.js +11 -0
  5. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  6. package/dist/utils/build/place-recompression.d.ts +25 -0
  7. package/dist/utils/build/place-recompression.d.ts.map +1 -0
  8. package/dist/utils/build/place-recompression.js +166 -0
  9. package/dist/utils/build/place-recompression.js.map +1 -0
  10. package/dist/utils/build/place-recompression.test.d.ts +2 -0
  11. package/dist/utils/build/place-recompression.test.d.ts.map +1 -0
  12. package/dist/utils/build/place-recompression.test.js +68 -0
  13. package/dist/utils/build/place-recompression.test.js.map +1 -0
  14. package/dist/utils/job-context/batch-script-job-context.d.ts +20 -8
  15. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  16. package/dist/utils/job-context/batch-script-job-context.js +94 -22
  17. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  18. package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -0
  19. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  20. package/dist/utils/open-cloud/open-cloud-client.js +37 -1
  21. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  22. package/dist/utils/testing/batch-run.e2e.test.js +78 -13
  23. package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
  24. package/dist/utils/testing/runner/combined-project-generator.d.ts +33 -5
  25. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  26. package/dist/utils/testing/runner/combined-project-generator.js +116 -19
  27. package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
  28. package/dist/utils/testing/runner/combined-project-generator.test.d.ts +2 -0
  29. package/dist/utils/testing/runner/combined-project-generator.test.d.ts.map +1 -0
  30. package/dist/utils/testing/runner/combined-project-generator.test.js +26 -0
  31. package/dist/utils/testing/runner/combined-project-generator.test.js.map +1 -0
  32. package/package.json +6 -6
  33. package/src/commands/batch-command/batch-test-command.ts +14 -0
  34. package/src/utils/build/place-recompression.test.ts +104 -0
  35. package/src/utils/build/place-recompression.ts +236 -0
  36. package/src/utils/job-context/batch-script-job-context.ts +140 -31
  37. package/src/utils/open-cloud/open-cloud-client.ts +58 -1
  38. package/src/utils/testing/batch-run.e2e.test.ts +97 -13
  39. package/src/utils/testing/runner/combined-project-generator.test.ts +30 -0
  40. package/src/utils/testing/runner/combined-project-generator.ts +198 -30
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,236 @@
1
+ import * as zlib from 'zlib';
2
+
3
+ /**
4
+ * Rewrite a binary place file's compressed chunks as long-window zstd.
5
+ *
6
+ * Every script's source lives in one `PROP` chunk per class, and Rojo and Lune
7
+ * compress chunks as LZ4, whose 64 KB window cannot reach a module the place
8
+ * already holds a copy of. A Nevermore place carries its dependencies by copy
9
+ * — a combined batch place a copy per package — so the same modules repeat
10
+ * many times over and LZ4 stores every one. zstd with a long window and
11
+ * long-distance matching finds them: a 40-package batch place measured 38.3 MB
12
+ * as LZ4 and 1.3 MB recompressed, and ran identically.
13
+ *
14
+ * The binary format picks the codec per chunk from its leading bytes, so this
15
+ * changes how the file is stored, not what it holds. Chunk payloads are
16
+ * decompressed and recompressed byte for byte; nothing is parsed.
17
+ */
18
+
19
+ /** zstd frame magic; the format reads any other compressed payload as LZ4. */
20
+ const ZSTD_MAGIC = 0xfd2fb528;
21
+
22
+ const PLACE_MAGIC = Buffer.from('<roblox!', 'latin1');
23
+ const HEADER_LENGTH = 32;
24
+ const CHUNK_HEADER_LENGTH = 16;
25
+
26
+ /**
27
+ * 2^27, zstd's default decoder window limit. A larger window would need the
28
+ * decoder to opt in, and this is the size Roblox has been seen to decode.
29
+ */
30
+ const WINDOW_LOG = 27;
31
+ /** Level 9 is within 15% of level 19's size here at a fourteenth of the time. */
32
+ const COMPRESSION_LEVEL = 9;
33
+
34
+ /**
35
+ * `zstdCompressSync` landed in Node 22.15 / 23.8 and the installed typings
36
+ * predate it, so it is looked up rather than imported.
37
+ */
38
+ interface ZstdZlib {
39
+ zstdCompressSync(
40
+ buffer: Buffer,
41
+ options: { params: Record<number, number> }
42
+ ): Buffer;
43
+ zstdDecompressSync(buffer: Buffer): Buffer;
44
+ constants: Record<string, number>;
45
+ }
46
+
47
+ export type PlaceRecompressionResult =
48
+ | { kind: 'recompressed'; data: Buffer; originalBytes: number }
49
+ /** Not a binary place, or every compressed chunk is already zstd. */
50
+ | { kind: 'unchanged'; data: Buffer }
51
+ | { kind: 'unsupported'; data: Buffer; reason: string };
52
+
53
+ /** Whether this Node can recompress places at all. */
54
+ export function getZstdSupport():
55
+ | { supported: true }
56
+ | { supported: false; reason: string } {
57
+ const z = zlib as unknown as Partial<ZstdZlib>;
58
+ if (
59
+ typeof z.zstdCompressSync === 'function' &&
60
+ typeof z.zstdDecompressSync === 'function'
61
+ ) {
62
+ return { supported: true };
63
+ }
64
+ return {
65
+ supported: false,
66
+ reason: `Node ${process.version} has no zstd (needs 22.15+ or 23.8+)`,
67
+ };
68
+ }
69
+
70
+ export function recompressPlace(data: Buffer): PlaceRecompressionResult {
71
+ if (
72
+ data.length < HEADER_LENGTH ||
73
+ !data.subarray(0, PLACE_MAGIC.length).equals(PLACE_MAGIC)
74
+ ) {
75
+ // An XML place, or not a place: nothing to recompress.
76
+ return { kind: 'unchanged', data };
77
+ }
78
+
79
+ const chunks = _readChunks(data);
80
+ if (!chunks.some((c) => c.compressedLength > 0 && !c.isZstd)) {
81
+ return { kind: 'unchanged', data };
82
+ }
83
+
84
+ const support = getZstdSupport();
85
+ if (!support.supported) {
86
+ return { kind: 'unsupported', data, reason: support.reason };
87
+ }
88
+ const z = zlib as unknown as ZstdZlib;
89
+ const params = {
90
+ [z.constants.ZSTD_c_compressionLevel]: COMPRESSION_LEVEL,
91
+ [z.constants.ZSTD_c_windowLog]: WINDOW_LOG,
92
+ [z.constants.ZSTD_c_enableLongDistanceMatching]: 1,
93
+ };
94
+
95
+ const parts: Buffer[] = [data.subarray(0, HEADER_LENGTH)];
96
+ for (const chunk of chunks) {
97
+ if (chunk.compressedLength === 0 || chunk.isZstd) {
98
+ parts.push(data.subarray(chunk.start, chunk.end));
99
+ continue;
100
+ }
101
+
102
+ const raw = decompressLz4Block(chunk.payload, chunk.uncompressedLength);
103
+ const compressed = z.zstdCompressSync(raw, { params });
104
+
105
+ const header = Buffer.from(
106
+ data.subarray(chunk.start, chunk.start + CHUNK_HEADER_LENGTH)
107
+ );
108
+ header.writeUInt32LE(compressed.length, 4);
109
+ parts.push(header, compressed);
110
+ }
111
+
112
+ return {
113
+ kind: 'recompressed',
114
+ data: Buffer.concat(parts),
115
+ originalBytes: data.length,
116
+ };
117
+ }
118
+
119
+ interface PlaceChunk {
120
+ /** Offset of the chunk header. */
121
+ start: number;
122
+ /** Offset just past the payload. */
123
+ end: number;
124
+ compressedLength: number;
125
+ uncompressedLength: number;
126
+ payload: Buffer;
127
+ isZstd: boolean;
128
+ }
129
+
130
+ function _readChunks(data: Buffer): PlaceChunk[] {
131
+ const chunks: PlaceChunk[] = [];
132
+ let pos = HEADER_LENGTH;
133
+ let sawEnd = false;
134
+
135
+ while (!sawEnd) {
136
+ if (pos + CHUNK_HEADER_LENGTH > data.length) {
137
+ throw new Error('Place file is truncated before its END chunk');
138
+ }
139
+
140
+ const name = data.toString('latin1', pos, pos + 4);
141
+ const compressedLength = data.readUInt32LE(pos + 4);
142
+ const uncompressedLength = data.readUInt32LE(pos + 8);
143
+ const payloadStart = pos + CHUNK_HEADER_LENGTH;
144
+ const end = payloadStart + (compressedLength || uncompressedLength);
145
+ if (end > data.length) {
146
+ throw new Error(
147
+ `Place file is truncated inside its ${name.trim()} chunk`
148
+ );
149
+ }
150
+
151
+ const payload = data.subarray(payloadStart, end);
152
+ chunks.push({
153
+ start: pos,
154
+ end,
155
+ compressedLength,
156
+ uncompressedLength,
157
+ payload,
158
+ isZstd: compressedLength >= 4 && payload.readUInt32LE(0) === ZSTD_MAGIC,
159
+ });
160
+
161
+ pos = end;
162
+ sawEnd = name === 'END\0';
163
+ }
164
+
165
+ // Anything after END is not the format's, but it is kept rather than dropped.
166
+ if (pos < data.length) {
167
+ chunks.push({
168
+ start: pos,
169
+ end: data.length,
170
+ compressedLength: 0,
171
+ uncompressedLength: data.length - pos,
172
+ payload: data.subarray(pos),
173
+ isZstd: false,
174
+ });
175
+ }
176
+
177
+ return chunks;
178
+ }
179
+
180
+ /** Decode one raw LZ4 block, the format's default chunk compression. */
181
+ export function decompressLz4Block(
182
+ src: Buffer,
183
+ uncompressedLength: number
184
+ ): Buffer {
185
+ const out = Buffer.alloc(uncompressedLength);
186
+ let s = 0;
187
+ let d = 0;
188
+
189
+ while (s < src.length) {
190
+ const token = src[s++]!;
191
+
192
+ let literalLength = token >> 4;
193
+ if (literalLength === 15) {
194
+ let b: number;
195
+ do {
196
+ b = src[s++]!;
197
+ literalLength += b;
198
+ } while (b === 255);
199
+ }
200
+ src.copy(out, d, s, s + literalLength);
201
+ s += literalLength;
202
+ d += literalLength;
203
+
204
+ // The last sequence is literals only.
205
+ if (s >= src.length) break;
206
+
207
+ const offset = src[s]! | (src[s + 1]! << 8);
208
+ s += 2;
209
+ if (offset === 0 || offset > d) {
210
+ throw new Error('Corrupt LZ4 block: match offset out of range');
211
+ }
212
+
213
+ let matchLength = token & 15;
214
+ if (matchLength === 15) {
215
+ let b: number;
216
+ do {
217
+ b = src[s++]!;
218
+ matchLength += b;
219
+ } while (b === 255);
220
+ }
221
+ matchLength += 4;
222
+
223
+ // Byte by byte: a match may overlap the bytes it is producing.
224
+ for (let i = 0; i < matchLength; i++) {
225
+ out[d] = out[d - offset]!;
226
+ d++;
227
+ }
228
+ }
229
+
230
+ if (d !== uncompressedLength) {
231
+ throw new Error(
232
+ `Corrupt LZ4 block: decoded ${d} bytes, expected ${uncompressedLength}`
233
+ );
234
+ }
235
+ return out;
236
+ }
@@ -18,7 +18,9 @@ import { type BuildPlaceOptions } from '../build/build.js';
18
18
  import { type BatchTarget } from '../batch/changed-packages-utils.js';
19
19
  import {
20
20
  type CombinedBuildProgress,
21
+ type CombinedPlaceChunk,
21
22
  type CombinedProjectResult,
23
+ formatMegabytes,
22
24
  generateCombinedProjectAsync,
23
25
  } from '../testing/runner/combined-project-generator.js';
24
26
  import {
@@ -40,16 +42,22 @@ class BatchDeployment implements Deployment {
40
42
  ) {}
41
43
  }
42
44
 
43
- interface CombinedBuildState {
44
- combinedResult: CombinedProjectResult;
45
- rbxlPath: string;
45
+ /** One combined place's upload and execution, each started at most once. */
46
+ interface ChunkRun {
47
+ chunk: CombinedPlaceChunk;
48
+ deployPromise?: Promise<Deployment>;
49
+ deployment?: Deployment;
50
+ executionPromise?: Promise<BatchExecutionOutcome>;
46
51
  }
47
52
 
48
53
  /**
49
- * JobContext wrapper that batches all packages into a single build, upload, and
50
- * execution. Wraps an inner context (cloud or local) and intercepts all methods
51
- * using the lazy-promise pattern: the first concurrent call triggers the shared
52
- * operation, all others await the same promise.
54
+ * JobContext wrapper that batches all packages into a single build, and as few
55
+ * uploads and executions as the place size budget allows — one, unless the
56
+ * combined place is too big even recompressed, in which case it is split into
57
+ * chunks that each upload and run on their own, in parallel. Wraps an inner
58
+ * context (cloud or local) and intercepts all methods using the lazy-promise
59
+ * pattern: the first concurrent call triggers the shared operation, all others
60
+ * await the same promise.
53
61
  *
54
62
  * Note that a `basePlace` cannot be honoured here: every package is combined
55
63
  * into one place, so there is no per-package place to merge Studio content
@@ -86,16 +94,16 @@ export class BatchScriptJobContext implements JobContext {
86
94
  private _batchPlaceId?: number;
87
95
  private _batchUniverseId?: number;
88
96
  private _batchTimeoutMs: number;
97
+ private _maxChunkBytes?: number;
89
98
  private _reporter?: Reporter;
90
99
 
91
100
  // Lazy-promise state
92
- private _combinedBuildPromise?: Promise<CombinedBuildState>;
93
- private _deployPromise?: Promise<Deployment>;
101
+ private _combinedBuildPromise?: Promise<CombinedProjectResult>;
102
+ private _chunkRuns?: ChunkRun[];
94
103
  private _executionPromise?: Promise<BatchExecutionOutcome>;
95
104
 
96
105
  // State for cleanup
97
106
  private _combinedBuildContext?: BuildContext;
98
- private _sharedDeployment?: Deployment;
99
107
 
100
108
  constructor(
101
109
  inner: JobContext,
@@ -105,6 +113,11 @@ export class BatchScriptJobContext implements JobContext {
105
113
  batchPlaceId?: number;
106
114
  batchUniverseId?: number;
107
115
  batchTimeoutMs?: number;
116
+ /**
117
+ * Per-place size budget; see `DEFAULT_MAX_CHUNK_BYTES`. Only an upload
118
+ * is size-limited, so a local run passes `Infinity`.
119
+ */
120
+ maxChunkBytes?: number;
108
121
  reporter?: Reporter;
109
122
  }
110
123
  ) {
@@ -114,6 +127,7 @@ export class BatchScriptJobContext implements JobContext {
114
127
  this._batchPlaceId = options?.batchPlaceId;
115
128
  this._batchUniverseId = options?.batchUniverseId;
116
129
  this._batchTimeoutMs = options?.batchTimeoutMs ?? 300_000;
130
+ this._maxChunkBytes = options?.maxChunkBytes;
117
131
  this._reporter = options?.reporter;
118
132
 
119
133
  const withBasePlace = batchTargets.filter((t) => t.target.basePlace);
@@ -130,12 +144,14 @@ export class BatchScriptJobContext implements JobContext {
130
144
  }
131
145
 
132
146
  async buildPlaceAsync(options: BuildPlaceOptions): Promise<BuiltPlace> {
133
- const buildState = await this._getCombinedBuildAsync();
147
+ const chunkRun = options.packageName
148
+ ? await this._getChunkRunAsync(options.packageName)
149
+ : (await this._getChunkRunsAsync())[0]!;
134
150
  // The script content baked into BuiltPlace.target is read by
135
151
  // runSingleTestAsync but discarded in aggregated batch mode — we just
136
152
  // echo back the caller's resolved place.
137
153
  return {
138
- rbxlPath: buildState.rbxlPath,
154
+ rbxlPath: chunkRun.chunk.rbxlPath,
139
155
  target: options.target,
140
156
  };
141
157
  }
@@ -143,7 +159,8 @@ export class BatchScriptJobContext implements JobContext {
143
159
  async deployBuiltPlaceAsync(
144
160
  options: DeployPlaceOptions
145
161
  ): Promise<Deployment> {
146
- const innerDeployment = await this._getSharedDeploymentAsync();
162
+ const chunkRun = await this._getChunkRunAsync(options.packageName);
163
+ const innerDeployment = await this._getChunkDeploymentAsync(chunkRun);
147
164
 
148
165
  return new BatchDeployment(options.packageName, innerDeployment);
149
166
  }
@@ -208,10 +225,11 @@ export class BatchScriptJobContext implements JobContext {
208
225
  }
209
226
 
210
227
  async disposeAsync(): Promise<void> {
211
- // Release shared inner deployment
212
- if (this._sharedDeployment) {
228
+ // Release each chunk's inner deployment
229
+ for (const { deployment } of this._chunkRuns ?? []) {
230
+ if (!deployment) continue;
213
231
  try {
214
- await this._inner.releaseAsync(this._sharedDeployment);
232
+ await this._inner.releaseAsync(deployment);
215
233
  } catch {
216
234
  // best effort
217
235
  }
@@ -231,14 +249,14 @@ export class BatchScriptJobContext implements JobContext {
231
249
 
232
250
  // ---- Lazy-promise internals ----
233
251
 
234
- private _getCombinedBuildAsync(): Promise<CombinedBuildState> {
252
+ private _getCombinedBuildAsync(): Promise<CombinedProjectResult> {
235
253
  if (!this._combinedBuildPromise) {
236
254
  this._combinedBuildPromise = this._doCombinedBuildAsync();
237
255
  }
238
256
  return this._combinedBuildPromise;
239
257
  }
240
258
 
241
- private async _doCombinedBuildAsync(): Promise<CombinedBuildState> {
259
+ private async _doCombinedBuildAsync(): Promise<CombinedProjectResult> {
242
260
  OutputHelper.verbose('Building combined batch place...');
243
261
 
244
262
  // Set all packages to "waiting" — they're queued for building
@@ -276,37 +294,61 @@ export class BatchScriptJobContext implements JobContext {
276
294
  repoRoot: this._repoRoot,
277
295
  batchPlaceId: this._batchPlaceId,
278
296
  batchUniverseId: this._batchUniverseId,
297
+ maxChunkBytes: this._maxChunkBytes,
279
298
  progress,
280
299
  });
281
300
 
282
301
  this._combinedBuildContext = combinedResult.buildContext;
302
+ this._chunkRuns = combinedResult.chunks.map((chunk) => ({ chunk }));
283
303
 
284
- return { combinedResult, rbxlPath: combinedResult.rbxlPath };
304
+ return combinedResult;
285
305
  }
286
306
 
287
- private _getSharedDeploymentAsync(): Promise<Deployment> {
288
- if (!this._deployPromise) {
289
- this._deployPromise = this._doSharedDeployAsync();
307
+ private async _getChunkRunsAsync(): Promise<ChunkRun[]> {
308
+ await this._getCombinedBuildAsync();
309
+ return this._chunkRuns!;
310
+ }
311
+
312
+ private async _getChunkRunAsync(packageName: string): Promise<ChunkRun> {
313
+ const chunkRuns = await this._getChunkRunsAsync();
314
+ const chunkRun = chunkRuns.find((run) =>
315
+ run.chunk.slugMap.has(packageName)
316
+ );
317
+ if (!chunkRun) {
318
+ throw new Error(`${packageName} is not part of this batch`);
290
319
  }
291
- return this._deployPromise;
320
+ return chunkRun;
292
321
  }
293
322
 
294
- private async _doSharedDeployAsync(): Promise<Deployment> {
295
- const buildState = await this._getCombinedBuildAsync();
296
- const { primaryTarget } = buildState.combinedResult;
323
+ private _getChunkDeploymentAsync(chunkRun: ChunkRun): Promise<Deployment> {
324
+ if (!chunkRun.deployPromise) {
325
+ chunkRun.deployPromise = this._doChunkDeployAsync(chunkRun);
326
+ // A failed upload is retried with its chunk, not remembered as failed
327
+ chunkRun.deployPromise.catch(() => {
328
+ chunkRun.deployPromise = undefined;
329
+ });
330
+ }
331
+ return chunkRun.deployPromise;
332
+ }
333
+
334
+ private async _doChunkDeployAsync(chunkRun: ChunkRun): Promise<Deployment> {
335
+ const { primaryTarget } = await this._getCombinedBuildAsync();
297
336
 
298
337
  const builtPlace: BuiltPlace = {
299
- rbxlPath: buildState.rbxlPath,
338
+ rbxlPath: chunkRun.chunk.rbxlPath,
300
339
  target: primaryTarget,
301
340
  };
302
341
 
342
+ // Every chunk uploads to the same place. Each upload is its own version and
343
+ // each execution is pinned to the version it was given, so a later upload
344
+ // cannot change what an earlier chunk runs against.
303
345
  const deployment = await this._inner.deployBuiltPlaceAsync({
304
346
  builtPlace,
305
347
  packageName: '_batch_',
306
348
  packagePath: this._repoRoot,
307
349
  });
308
350
 
309
- this._sharedDeployment = deployment;
351
+ chunkRun.deployment = deployment;
310
352
  return deployment;
311
353
  }
312
354
 
@@ -322,9 +364,48 @@ export class BatchScriptJobContext implements JobContext {
322
364
  }
323
365
 
324
366
  private async _doBatchExecutionAsync(): Promise<BatchExecutionOutcome> {
325
- const buildState = await this._getCombinedBuildAsync();
326
- const deployment = await this._getSharedDeploymentAsync();
327
- const { slugMap } = buildState.combinedResult;
367
+ const chunkRuns = await this._getChunkRunsAsync();
368
+
369
+ if (chunkRuns.length > 1) {
370
+ const sizes = chunkRuns
371
+ .map(
372
+ ({ chunk }) =>
373
+ `${chunk.slugMap.size} packages, ${formatMegabytes(
374
+ chunk.sizeBytes
375
+ )}`
376
+ )
377
+ .join('; ');
378
+ OutputHelper.info(`Running ${chunkRuns.length} places: ${sizes}`);
379
+ }
380
+
381
+ // All at once: every upload is its own version and each execution is
382
+ // pinned to the version it was given, so the places cannot interfere.
383
+ // Promise.all keeps chunk order, so the merged log stays in package order.
384
+ const outcomes = await Promise.all(
385
+ chunkRuns.map((chunkRun) => this._getChunkExecutionAsync(chunkRun))
386
+ );
387
+
388
+ return _mergeOutcomes(outcomes);
389
+ }
390
+
391
+ private _getChunkExecutionAsync(
392
+ chunkRun: ChunkRun
393
+ ): Promise<BatchExecutionOutcome> {
394
+ if (!chunkRun.executionPromise) {
395
+ chunkRun.executionPromise = this._doChunkExecutionAsync(chunkRun);
396
+ // Cleared on rejection, so a retry re-runs only the chunks that failed
397
+ chunkRun.executionPromise.catch(() => {
398
+ chunkRun.executionPromise = undefined;
399
+ });
400
+ }
401
+ return chunkRun.executionPromise;
402
+ }
403
+
404
+ private async _doChunkExecutionAsync(
405
+ chunkRun: ChunkRun
406
+ ): Promise<BatchExecutionOutcome> {
407
+ const deployment = await this._getChunkDeploymentAsync(chunkRun);
408
+ const { slugMap } = chunkRun.chunk;
328
409
 
329
410
  // Build the batch Luau script from the template
330
411
  const templatePath = resolveTemplatePath(
@@ -404,3 +485,31 @@ export class BatchScriptJobContext implements JobContext {
404
485
  };
405
486
  }
406
487
  }
488
+
489
+ /** Fold each chunk's outcome into one, in chunk order. */
490
+ function _mergeOutcomes(
491
+ outcomes: BatchExecutionOutcome[]
492
+ ): BatchExecutionOutcome {
493
+ if (outcomes.length === 1) {
494
+ return outcomes[0]!;
495
+ }
496
+
497
+ const allStats = outcomes.map((o) => o.stats);
498
+ const stats = allStats.every((s): s is LogFetchStats => s !== undefined)
499
+ ? allStats.reduce((sum, s) => ({
500
+ requests: sum.requests + s.requests,
501
+ pages: sum.pages + s.pages,
502
+ entries: sum.entries + s.entries,
503
+ messages: sum.messages + s.messages,
504
+ chars: sum.chars + s.chars,
505
+ }))
506
+ : undefined;
507
+
508
+ return {
509
+ results: new Map(outcomes.flatMap((o) => [...o.results])),
510
+ lines: outcomes.flatMap((o) => o.lines),
511
+ stats,
512
+ diagnostics: outcomes.flatMap((o) => o.diagnostics),
513
+ slugToPackage: new Map(outcomes.flatMap((o) => [...o.slugToPackage])),
514
+ };
515
+ }
@@ -13,6 +13,20 @@ import {
13
13
  type ParsedTestLogs,
14
14
  } from '../testing/test-log-parser.js';
15
15
  import { type LogFetchStats } from '../testing/log-fetch-stats.js';
16
+ import { recompressPlace } from '../build/place-recompression.js';
17
+
18
+ /**
19
+ * The largest place file the place version endpoint accepts, measured: 150 MiB
20
+ * exactly uploads, one byte more is refused from `Content-Length` before the
21
+ * body is sent, as `400 InvalidRequest: "Request upload was interrupted before
22
+ * completion."` — and somewhere past ~200 MB as `413 Payload Too Large`.
23
+ * Neither says anything about size, so it is checked here first.
24
+ *
25
+ * Not the 100 MB Roblox documents as supported
26
+ * (https://create.roblox.com/docs/projects/place-files); that is where Studio
27
+ * saving "might fail", not where the API cuts off.
28
+ */
29
+ export const PLACE_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
16
30
 
17
31
  /**
18
32
  * One engine message, with the severity Open Cloud reported for it.
@@ -276,8 +290,20 @@ export class OpenCloudClient {
276
290
  );
277
291
 
278
292
  const apiKey = await this._resolveApiKeyAsync();
279
- const fileBuffer = await fs.readFile(rbxlPath);
293
+ const fileBuffer = _recompressForUpload(
294
+ rbxlPath,
295
+ await fs.readFile(rbxlPath)
296
+ );
280
297
  const totalBytes = fileBuffer.length;
298
+ if (totalBytes > PLACE_UPLOAD_MAX_BYTES) {
299
+ throw new Error(
300
+ `Upload failed: ${path.basename(rbxlPath)} is ${_formatMegabytes(
301
+ totalBytes
302
+ )}, over the Open Cloud ${_formatMegabytes(
303
+ PLACE_UPLOAD_MAX_BYTES
304
+ )} place upload limit`
305
+ );
306
+ }
281
307
  const versionType = publish ? 'Published' : 'Saved';
282
308
  const url = `https://apis.roblox.com/universes/v1/${universeId}/places/${placeId}/versions?versionType=${versionType}`;
283
309
 
@@ -1097,3 +1123,34 @@ async function _readBodyWithProgressAsync(
1097
1123
 
1098
1124
  return Buffer.concat(chunks);
1099
1125
  }
1126
+
1127
+ /**
1128
+ * The bytes to upload for a place file: recompressed when that is possible,
1129
+ * the file as read when it is not. An upload never fails for want of zstd —
1130
+ * it only goes up larger.
1131
+ */
1132
+ function _recompressForUpload(rbxlPath: string, fileBuffer: Buffer): Buffer {
1133
+ const name = path.basename(rbxlPath);
1134
+ const result = recompressPlace(fileBuffer);
1135
+
1136
+ if (result.kind === 'recompressed') {
1137
+ OutputHelper.verbose(
1138
+ `Recompressed ${name}: ${_formatMegabytes(
1139
+ result.originalBytes
1140
+ )} -> ${_formatMegabytes(result.data.length)}`
1141
+ );
1142
+ } else if (result.kind === 'unsupported') {
1143
+ OutputHelper.warn(
1144
+ `Uploading ${name} as built (${_formatMegabytes(
1145
+ fileBuffer.length
1146
+ )}) without recompressing it: ${result.reason}`
1147
+ );
1148
+ }
1149
+
1150
+ return result.data;
1151
+ }
1152
+
1153
+ /** In Roblox's MB, which are 1024 * 1024 bytes. */
1154
+ function _formatMegabytes(bytes: number): string {
1155
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1156
+ }