@quenty/nevermore-cli 4.46.0 → 4.47.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/CHANGELOG.md +13 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +4 -3
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/deploy-command/asset-command.d.ts +44 -0
- package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
- package/dist/commands/deploy-command/asset-command.js +182 -0
- package/dist/commands/deploy-command/asset-command.js.map +1 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +62 -0
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/utils/assets/asset-creator.d.ts +32 -0
- package/dist/utils/assets/asset-creator.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.js +62 -0
- package/dist/utils/assets/asset-creator.js.map +1 -0
- package/dist/utils/assets/asset-creator.test.d.ts +2 -0
- package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.test.js +71 -0
- package/dist/utils/assets/asset-creator.test.js.map +1 -0
- package/dist/utils/assets/asset-formats.d.ts +28 -0
- package/dist/utils/assets/asset-formats.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.js +72 -0
- package/dist/utils/assets/asset-formats.js.map +1 -0
- package/dist/utils/assets/asset-formats.test.d.ts +2 -0
- package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.test.js +43 -0
- package/dist/utils/assets/asset-formats.test.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
- package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.js +43 -0
- package/dist/utils/assets/asset-upload-plan.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.js +57 -0
- package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
- package/dist/utils/build/upload.d.ts.map +1 -1
- package/dist/utils/build/upload.js +4 -1
- package/dist/utils/build/upload.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +5 -3
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +81 -4
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +172 -11
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
- package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.js +98 -20
- package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
- package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +8 -7
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.js +1 -1
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/batch-command/batch-deploy-command.ts +11 -3
- package/src/commands/deploy-command/asset-command.ts +279 -0
- package/src/commands/deploy-command/index.ts +85 -0
- package/src/utils/assets/asset-creator.test.ts +106 -0
- package/src/utils/assets/asset-creator.ts +96 -0
- package/src/utils/assets/asset-formats.test.ts +57 -0
- package/src/utils/assets/asset-formats.ts +102 -0
- package/src/utils/assets/asset-upload-plan.test.ts +75 -0
- package/src/utils/assets/asset-upload-plan.ts +79 -0
- package/src/utils/build/upload.ts +5 -1
- package/src/utils/job-context/cloud-job-context.ts +24 -15
- package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
- package/src/utils/open-cloud/open-cloud-client.ts +341 -24
- package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
- package/src/utils/open-cloud/rate-limiter.ts +150 -21
- package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
- package/src/utils/testing/runner/test-runner.test.ts +1 -1
- package/src/utils/testing/runner/test-runner.ts +8 -9
- package/templates/batch-test-runner.lua +176 -172
- package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
- package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
- package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
2
|
+
import { type Reporter } from '@quenty/cli-output-helpers/reporting';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Extracts a short route label from a URL for logging.
|
|
@@ -18,6 +19,84 @@ function _extractRoute(url: string | URL | Request): string {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
const ID_SEGMENT_PATTERN =
|
|
23
|
+
/^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Identifies the quota a request draws from. Open Cloud meters each endpoint
|
|
27
|
+
* separately (task creation: 5/min, task polling: 200/min, …), so the key is
|
|
28
|
+
* the method plus the path with ids collapsed — every poll of every task
|
|
29
|
+
* shares one bucket, and it is not the bucket task creation uses.
|
|
30
|
+
*/
|
|
31
|
+
function _bucketKey(url: string | URL | Request, init?: RequestInit): string {
|
|
32
|
+
try {
|
|
33
|
+
const urlStr =
|
|
34
|
+
typeof url === 'string' ? url : url instanceof URL ? url.href : url.url;
|
|
35
|
+
const parsed = new URL(urlStr);
|
|
36
|
+
const path = parsed.pathname
|
|
37
|
+
.split('/')
|
|
38
|
+
.map((segment) => (ID_SEGMENT_PATTERN.test(segment) ? ':id' : segment))
|
|
39
|
+
.join('/');
|
|
40
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
41
|
+
return `${method} ${parsed.host}${path}`;
|
|
42
|
+
} catch {
|
|
43
|
+
return 'unknown';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A wait the limiter is about to sleep through before sending a request. */
|
|
48
|
+
export interface RateLimitWait {
|
|
49
|
+
route: string;
|
|
50
|
+
waitMs: number;
|
|
51
|
+
/**
|
|
52
|
+
* `quota`: a prior response said this endpoint's quota is spent.
|
|
53
|
+
* `retry`: this request failed (429, 5xx, transport) and is backing off.
|
|
54
|
+
*/
|
|
55
|
+
reason: 'quota' | 'retry';
|
|
56
|
+
/** HTTP status that triggered a `retry` wait, absent for transport errors. */
|
|
57
|
+
status?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds an `onWait` that shows the wait as the package's progress label, so
|
|
62
|
+
* a spinner stuck on "Scheduling" reads as rate limiting rather than a hang.
|
|
63
|
+
*/
|
|
64
|
+
export function reportRateLimitWait(
|
|
65
|
+
reporter: Reporter,
|
|
66
|
+
packageName: string
|
|
67
|
+
): (wait: RateLimitWait) => void {
|
|
68
|
+
return (wait) => {
|
|
69
|
+
reporter.onPackageProgressUpdate(packageName, {
|
|
70
|
+
kind: 'steps',
|
|
71
|
+
completed: 0,
|
|
72
|
+
total: 0,
|
|
73
|
+
label: formatRateLimitWait(wait),
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Kept short enough that "◇ Scheduling (…)" fits the spinner's 32-column
|
|
80
|
+
* status cell: a longer label wraps on an 80-column terminal, and the
|
|
81
|
+
* spinner's cursor rewind doesn't count wrapped lines. The `--verbose`
|
|
82
|
+
* warnings carry the status code and route.
|
|
83
|
+
*/
|
|
84
|
+
export function formatRateLimitWait(wait: RateLimitWait): string {
|
|
85
|
+
if (wait.reason === 'quota') {
|
|
86
|
+
return `rate limited ${Math.ceil(wait.waitMs / 1000)}s`;
|
|
87
|
+
}
|
|
88
|
+
const waitSec = Math.round(wait.waitMs / 1000);
|
|
89
|
+
if (wait.status === 429) {
|
|
90
|
+
return `rate limited ${waitSec}s`;
|
|
91
|
+
}
|
|
92
|
+
return `retrying in ${waitSec}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface QuotaState {
|
|
96
|
+
remaining: number | null;
|
|
97
|
+
resetAtMs: number | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
21
100
|
/**
|
|
22
101
|
* Minimal fetch shape the limiter drives. Defaults to the global `fetch`
|
|
23
102
|
* (undici). Callers can inject an alternative — e.g. node-fetch, whose Node
|
|
@@ -53,6 +132,7 @@ export interface RateLimiterOptions {
|
|
|
53
132
|
* Shared rate limiter for Roblox Open Cloud API requests.
|
|
54
133
|
*
|
|
55
134
|
* - Tracks `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds until reset)
|
|
135
|
+
* per endpoint, so a spent task-creation quota doesn't stall task polling
|
|
56
136
|
* - Caps concurrent in-flight requests (default 4) via a semaphore so callers
|
|
57
137
|
* benefit from parallelism (e.g. multi-place deploys) while still getting
|
|
58
138
|
* header feedback before each wave widens past the quota
|
|
@@ -60,8 +140,7 @@ export interface RateLimiterOptions {
|
|
|
60
140
|
* concurrent callers don't all wake up on the same millisecond and re-collide
|
|
61
141
|
*/
|
|
62
142
|
export class RateLimiter {
|
|
63
|
-
private
|
|
64
|
-
private _resetAtMs: number | null = null;
|
|
143
|
+
private _quotas = new Map<string, QuotaState>();
|
|
65
144
|
|
|
66
145
|
/** Queue of callers waiting to send a request. */
|
|
67
146
|
private _queue: Array<() => void> = [];
|
|
@@ -77,38 +156,71 @@ export class RateLimiter {
|
|
|
77
156
|
this._maxRetries = Math.max(1, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
78
157
|
}
|
|
79
158
|
|
|
80
|
-
private _updateFromHeaders(
|
|
159
|
+
private _updateFromHeaders(
|
|
160
|
+
bucketKey: string,
|
|
161
|
+
route: string,
|
|
162
|
+
status: number,
|
|
163
|
+
headers: Headers
|
|
164
|
+
): void {
|
|
81
165
|
const remaining = headers.get('x-ratelimit-remaining');
|
|
82
166
|
const reset = headers.get('x-ratelimit-reset');
|
|
83
167
|
|
|
168
|
+
const rateLimitHeaders: string[] = [];
|
|
169
|
+
headers.forEach((value, key) => {
|
|
170
|
+
if (key.toLowerCase().includes('ratelimit') || key === 'retry-after') {
|
|
171
|
+
rateLimitHeaders.push(`${key}=${value}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
OutputHelper.verbose(
|
|
175
|
+
`[rate-limit] ${status} ${route} ${
|
|
176
|
+
rateLimitHeaders.join(' ') || '(no rate-limit headers)'
|
|
177
|
+
}`
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (remaining === null && reset === null) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let quota = this._quotas.get(bucketKey);
|
|
185
|
+
if (!quota) {
|
|
186
|
+
quota = { remaining: null, resetAtMs: null };
|
|
187
|
+
this._quotas.set(bucketKey, quota);
|
|
188
|
+
}
|
|
189
|
+
|
|
84
190
|
if (remaining !== null) {
|
|
85
|
-
|
|
191
|
+
quota.remaining = parseInt(remaining, 10);
|
|
86
192
|
}
|
|
87
193
|
|
|
88
194
|
// x-ratelimit-reset is seconds until reset, not an epoch timestamp
|
|
89
195
|
if (reset !== null) {
|
|
90
196
|
const resetSec = parseFloat(reset);
|
|
91
|
-
|
|
197
|
+
quota.resetAtMs = Date.now() + resetSec * 1000;
|
|
92
198
|
}
|
|
93
199
|
}
|
|
94
200
|
|
|
95
|
-
private async _waitIfNeededAsync(
|
|
201
|
+
private async _waitIfNeededAsync(
|
|
202
|
+
bucketKey: string,
|
|
203
|
+
route: string,
|
|
204
|
+
onWait?: (wait: RateLimitWait) => void
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const quota = this._quotas.get(bucketKey);
|
|
96
207
|
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
208
|
+
quota &&
|
|
209
|
+
quota.remaining !== null &&
|
|
210
|
+
quota.remaining <= 0 &&
|
|
211
|
+
quota.resetAtMs !== null
|
|
100
212
|
) {
|
|
101
|
-
const waitMs =
|
|
213
|
+
const waitMs = quota.resetAtMs - Date.now();
|
|
102
214
|
if (waitMs > 0) {
|
|
103
215
|
const waitSec = Math.ceil(waitMs / 1000);
|
|
104
216
|
OutputHelper.warn(
|
|
105
217
|
`Rate limit reached on ${route}. Waiting ${waitSec}s (Roblox requested)...`
|
|
106
218
|
);
|
|
219
|
+
onWait?.({ route, waitMs, reason: 'quota' });
|
|
107
220
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
108
221
|
}
|
|
109
222
|
// Reset after waiting so the next request can proceed
|
|
110
|
-
this.
|
|
111
|
-
this._resetAtMs = null;
|
|
223
|
+
this._quotas.delete(bucketKey);
|
|
112
224
|
}
|
|
113
225
|
}
|
|
114
226
|
|
|
@@ -146,36 +258,47 @@ export class RateLimiter {
|
|
|
146
258
|
* - Retries on 429, retryable 5xx (502/503/504), and transport errors
|
|
147
259
|
* with jittered back-off
|
|
148
260
|
* - Tracks rate-limit headers from every response
|
|
261
|
+
* - Tells `onWait` before every sleep, so callers can say why they stalled
|
|
149
262
|
*/
|
|
150
263
|
async fetchAsync(
|
|
151
264
|
url: string | URL | Request,
|
|
152
265
|
init?: RequestInit | (() => RequestInit),
|
|
153
|
-
options?: {
|
|
266
|
+
options?: {
|
|
267
|
+
fetchImpl?: FetchLike;
|
|
268
|
+
onWait?: (wait: RateLimitWait) => void;
|
|
269
|
+
}
|
|
154
270
|
): Promise<Response> {
|
|
155
271
|
await this._acquireAsync();
|
|
156
272
|
|
|
157
273
|
const route = _extractRoute(url);
|
|
158
274
|
const doFetch = options?.fetchImpl ?? fetch;
|
|
275
|
+
const onWait = options?.onWait;
|
|
159
276
|
|
|
160
277
|
try {
|
|
161
278
|
let lastResponse: Response | null = null;
|
|
162
279
|
let lastError: unknown = null;
|
|
163
280
|
|
|
164
281
|
for (let attempt = 0; attempt < this._maxRetries; attempt++) {
|
|
165
|
-
|
|
282
|
+
// Resolve init per attempt so a caller streaming a one-shot body
|
|
283
|
+
// (e.g. a progress-reporting upload) hands us a fresh, un-disturbed
|
|
284
|
+
// stream on every retry; a reused stream throws "body ... disturbed
|
|
285
|
+
// or locked". Plain-object inits (Uint8Array/string bodies) are just
|
|
286
|
+
// returned as-is and replay safely.
|
|
287
|
+
const resolvedInit = typeof init === 'function' ? init() : init;
|
|
288
|
+
const bucketKey = _bucketKey(url, resolvedInit);
|
|
289
|
+
await this._waitIfNeededAsync(bucketKey, route, onWait);
|
|
166
290
|
|
|
167
291
|
let response: Response | null = null;
|
|
168
292
|
let transportError: unknown = null;
|
|
169
293
|
|
|
170
294
|
try {
|
|
171
|
-
// Resolve init per attempt so a caller streaming a one-shot body
|
|
172
|
-
// (e.g. a progress-reporting upload) hands us a fresh, un-disturbed
|
|
173
|
-
// stream on every retry; a reused stream throws "body ... disturbed
|
|
174
|
-
// or locked". Plain-object inits (Uint8Array/string bodies) are just
|
|
175
|
-
// returned as-is and replay safely.
|
|
176
|
-
const resolvedInit = typeof init === 'function' ? init() : init;
|
|
177
295
|
response = await doFetch(url, resolvedInit);
|
|
178
|
-
this._updateFromHeaders(
|
|
296
|
+
this._updateFromHeaders(
|
|
297
|
+
bucketKey,
|
|
298
|
+
route,
|
|
299
|
+
response.status,
|
|
300
|
+
response.headers
|
|
301
|
+
);
|
|
179
302
|
} catch (err) {
|
|
180
303
|
transportError = err;
|
|
181
304
|
}
|
|
@@ -204,6 +327,12 @@ export class RateLimiter {
|
|
|
204
327
|
this._maxRetries
|
|
205
328
|
}). Retrying in ${waitSec.toFixed(1)}s...`
|
|
206
329
|
);
|
|
330
|
+
onWait?.({
|
|
331
|
+
route,
|
|
332
|
+
waitMs: waitSec * 1000,
|
|
333
|
+
reason: 'retry',
|
|
334
|
+
status: response?.status,
|
|
335
|
+
});
|
|
207
336
|
await new Promise((resolve) => setTimeout(resolve, waitSec * 1000));
|
|
208
337
|
}
|
|
209
338
|
|
|
@@ -317,9 +317,9 @@ describe('parseBatchTestLogs', () => {
|
|
|
317
317
|
SLUG_MAP
|
|
318
318
|
);
|
|
319
319
|
|
|
320
|
-
expect(
|
|
321
|
-
|
|
322
|
-
);
|
|
320
|
+
expect(
|
|
321
|
+
warnings().some((w) => w.includes('fell back to log scraping'))
|
|
322
|
+
).toBe(false);
|
|
323
323
|
});
|
|
324
324
|
|
|
325
325
|
it('passes every package whose returned counts are clean', () => {
|
|
@@ -203,7 +203,7 @@ describe('runSingleTestAsync', () => {
|
|
|
203
203
|
|
|
204
204
|
await runAsync({ success: true, returnValues: [] }, PASSING_LOGS);
|
|
205
205
|
|
|
206
|
-
expect(warnings().some((w) => w.includes('
|
|
206
|
+
expect(warnings().some((w) => w.includes('counts read from logs'))).toBe(
|
|
207
207
|
true
|
|
208
208
|
);
|
|
209
209
|
});
|
|
@@ -313,15 +313,14 @@ function reportCountsProvenance(options: {
|
|
|
313
313
|
return;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
316
|
+
const counts = scrapedCounts
|
|
317
|
+
? 'counts read from logs, which Open Cloud truncates on long runs'
|
|
318
|
+
: 'no counts, the run returned none and the logs held none';
|
|
319
|
+
const remedy = hadReturnChannel
|
|
320
|
+
? 'For exact counts, end the test script with `return results` (docs/testing/testing.md).'
|
|
321
|
+
: 'Open Cloud returned nothing from the run.';
|
|
322
|
+
OutputHelper.warn(`${packageName}: ${counts}. ${remedy}`);
|
|
323
|
+
OutputHelper.verbose(`${packageName}: log volume ${logVolume}`);
|
|
325
324
|
}
|
|
326
325
|
|
|
327
326
|
/**
|