@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,8 +1,13 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
2
3
|
import { Readable, Transform } from 'node:stream';
|
|
3
4
|
import nodeFetch from 'node-fetch';
|
|
4
5
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
RateLimiter,
|
|
8
|
+
type FetchLike,
|
|
9
|
+
type RateLimitWait,
|
|
10
|
+
} from './rate-limiter.js';
|
|
6
11
|
import {
|
|
7
12
|
parseTestLogs,
|
|
8
13
|
type ParsedTestLogs,
|
|
@@ -90,6 +95,109 @@ export interface OpenCloudClientOptions {
|
|
|
90
95
|
*/
|
|
91
96
|
export type PlaceVersionType = 'saved' | 'published';
|
|
92
97
|
|
|
98
|
+
/** Who owns a newly created asset. */
|
|
99
|
+
export type AssetCreator = { userId: number } | { groupId: number };
|
|
100
|
+
|
|
101
|
+
export interface AssetFile {
|
|
102
|
+
filePath: string;
|
|
103
|
+
/** MIME type of the file. The Assets API picks its importer from it. */
|
|
104
|
+
contentType: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CreateAssetOptions extends AssetFile {
|
|
108
|
+
/** `Model`, `Image`, `Audio`, ... as the Assets API spells them. */
|
|
109
|
+
assetType: string;
|
|
110
|
+
displayName: string;
|
|
111
|
+
description: string;
|
|
112
|
+
creator: AssetCreator;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The long-running operation every asset upload returns. Creating an asset is
|
|
117
|
+
* not synchronous — the file is imported and moderated first — so the id only
|
|
118
|
+
* exists once the operation reports `done`.
|
|
119
|
+
*/
|
|
120
|
+
export interface AssetOperation {
|
|
121
|
+
/** `operations/{operationId}` */
|
|
122
|
+
path: string;
|
|
123
|
+
done?: boolean;
|
|
124
|
+
response?: {
|
|
125
|
+
/** A string in practice, though the API's own schema calls it an integer. */
|
|
126
|
+
assetId?: string | number;
|
|
127
|
+
revisionId?: string;
|
|
128
|
+
moderationResult?: { moderationState?: string };
|
|
129
|
+
};
|
|
130
|
+
error?: { code?: number | string; message?: string };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface UploadedAsset {
|
|
134
|
+
assetId: number;
|
|
135
|
+
revisionId?: string;
|
|
136
|
+
/**
|
|
137
|
+
* `Approved`, `Reviewing`, ... exactly as the API reports it — which is not
|
|
138
|
+
* the `MODERATION_STATE_APPROVED` spelling its documentation shows. Audio
|
|
139
|
+
* routinely finishes uploading while still `Reviewing`.
|
|
140
|
+
*/
|
|
141
|
+
moderationState?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ASSETS_API_URL = 'https://apis.roblox.com/assets/v1';
|
|
145
|
+
const ASSET_OPERATION_POLL_MS = 2000;
|
|
146
|
+
const ASSET_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
147
|
+
|
|
148
|
+
/** Pull the human-readable message out of an Open Cloud error body. */
|
|
149
|
+
function _extractApiMessage(body: string): string {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(body);
|
|
152
|
+
if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
|
|
153
|
+
return parsed.errors[0].message;
|
|
154
|
+
}
|
|
155
|
+
if (typeof parsed.message === 'string') {
|
|
156
|
+
return parsed.message;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Not JSON
|
|
160
|
+
}
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatAssetApiError(
|
|
165
|
+
action: string,
|
|
166
|
+
status: number,
|
|
167
|
+
statusText: string,
|
|
168
|
+
body: string,
|
|
169
|
+
method: string,
|
|
170
|
+
url: string
|
|
171
|
+
): string {
|
|
172
|
+
if (status !== 401 && status !== 403) {
|
|
173
|
+
return `${action} failed: ${status} ${statusText}: ${body}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const apiMessage = _extractApiMessage(body);
|
|
177
|
+
const headline = apiMessage
|
|
178
|
+
? `${action} failed: ${apiMessage} (${status})`
|
|
179
|
+
: `${action} failed: ${status} ${statusText}`;
|
|
180
|
+
|
|
181
|
+
const dim = OutputHelper.formatDim;
|
|
182
|
+
const info = OutputHelper.formatInfo;
|
|
183
|
+
|
|
184
|
+
return [
|
|
185
|
+
OutputHelper.formatError(headline),
|
|
186
|
+
'',
|
|
187
|
+
` ${dim('Scope:')} asset:read, asset:write`,
|
|
188
|
+
'',
|
|
189
|
+
OutputHelper.formatWarning(
|
|
190
|
+
'Ensure the API key has the Assets API with both Read and Write, and ' +
|
|
191
|
+
'that its owner may create assets for the user or group named as creator.'
|
|
192
|
+
),
|
|
193
|
+
'',
|
|
194
|
+
` ${dim('Credentials:')} ${info(
|
|
195
|
+
'https://create.roblox.com/dashboard/credentials'
|
|
196
|
+
)}`,
|
|
197
|
+
` ${dim('Route:')} ${dim(`${method} ${url}`)}`,
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
93
201
|
function formatAuthError(
|
|
94
202
|
action: string,
|
|
95
203
|
scope: string,
|
|
@@ -101,18 +209,7 @@ function formatAuthError(
|
|
|
101
209
|
method: string,
|
|
102
210
|
url: string
|
|
103
211
|
): string {
|
|
104
|
-
|
|
105
|
-
let apiMessage = '';
|
|
106
|
-
try {
|
|
107
|
-
const parsed = JSON.parse(body);
|
|
108
|
-
if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
|
|
109
|
-
apiMessage = parsed.errors[0].message;
|
|
110
|
-
} else if (typeof parsed.message === 'string') {
|
|
111
|
-
apiMessage = parsed.message;
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Not JSON
|
|
115
|
-
}
|
|
212
|
+
const apiMessage = _extractApiMessage(body);
|
|
116
213
|
|
|
117
214
|
const headline = apiMessage
|
|
118
215
|
? `${action} failed: ${apiMessage} (${status})`
|
|
@@ -171,7 +268,8 @@ export class OpenCloudClient {
|
|
|
171
268
|
placeId: number,
|
|
172
269
|
rbxlPath: string,
|
|
173
270
|
publish?: boolean,
|
|
174
|
-
onProgress?: (transferredBytes: number, totalBytes: number) => void
|
|
271
|
+
onProgress?: (transferredBytes: number, totalBytes: number) => void,
|
|
272
|
+
onRateLimitWait?: (wait: RateLimitWait) => void
|
|
175
273
|
): Promise<number> {
|
|
176
274
|
OutputHelper.verbose(
|
|
177
275
|
`Uploading to https://www.roblox.com/games/${placeId}/place ...`
|
|
@@ -217,6 +315,7 @@ export class OpenCloudClient {
|
|
|
217
315
|
|
|
218
316
|
const response = await this._rateLimiter.fetchAsync(url, makeUploadInit, {
|
|
219
317
|
fetchImpl: nodeFetch as unknown as FetchLike,
|
|
318
|
+
onWait: onRateLimitWait,
|
|
220
319
|
});
|
|
221
320
|
|
|
222
321
|
if (!response.ok) {
|
|
@@ -269,7 +368,8 @@ export class OpenCloudClient {
|
|
|
269
368
|
placeId: number,
|
|
270
369
|
placeVersion: number,
|
|
271
370
|
script: string,
|
|
272
|
-
timeoutMs?: number
|
|
371
|
+
timeoutMs?: number,
|
|
372
|
+
onRateLimitWait?: (wait: RateLimitWait) => void
|
|
273
373
|
): Promise<LuauTask> {
|
|
274
374
|
const apiKey = await this._resolveApiKeyAsync();
|
|
275
375
|
const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}/places/${placeId}/versions/${placeVersion}/luau-execution-session-tasks`;
|
|
@@ -293,14 +393,18 @@ export class OpenCloudClient {
|
|
|
293
393
|
body.timeout = `${clampedSeconds}s`;
|
|
294
394
|
}
|
|
295
395
|
|
|
296
|
-
const response = await this._rateLimiter.fetchAsync(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
396
|
+
const response = await this._rateLimiter.fetchAsync(
|
|
397
|
+
url,
|
|
398
|
+
{
|
|
399
|
+
method: 'POST',
|
|
400
|
+
headers: {
|
|
401
|
+
'Content-Type': 'application/json',
|
|
402
|
+
'X-API-Key': apiKey,
|
|
403
|
+
},
|
|
404
|
+
body: JSON.stringify(body),
|
|
301
405
|
},
|
|
302
|
-
|
|
303
|
-
|
|
406
|
+
{ onWait: onRateLimitWait }
|
|
407
|
+
);
|
|
304
408
|
|
|
305
409
|
if (!response.ok) {
|
|
306
410
|
const text = await response.text();
|
|
@@ -329,7 +433,8 @@ export class OpenCloudClient {
|
|
|
329
433
|
|
|
330
434
|
async pollTaskCompletionAsync(
|
|
331
435
|
taskPath: string,
|
|
332
|
-
onProgress?: (state: LuauTask['state']) => void
|
|
436
|
+
onProgress?: (state: LuauTask['state']) => void,
|
|
437
|
+
onRateLimitWait?: (wait: RateLimitWait) => void
|
|
333
438
|
): Promise<LuauTask> {
|
|
334
439
|
const apiKey = await this._resolveApiKeyAsync();
|
|
335
440
|
const pollIntervalMs = 3000;
|
|
@@ -342,7 +447,8 @@ export class OpenCloudClient {
|
|
|
342
447
|
headers: {
|
|
343
448
|
'X-API-Key': apiKey,
|
|
344
449
|
},
|
|
345
|
-
}
|
|
450
|
+
},
|
|
451
|
+
{ onWait: onRateLimitWait }
|
|
346
452
|
);
|
|
347
453
|
|
|
348
454
|
if (!response.ok) {
|
|
@@ -682,6 +788,217 @@ export class OpenCloudClient {
|
|
|
682
788
|
`. Pin an explicit version number in deploy.nevermore.json, or publish the place once.`
|
|
683
789
|
);
|
|
684
790
|
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Start uploading a file as a new asset via the Open Cloud Assets API
|
|
794
|
+
* (`asset:read` and `asset:write`). Returns the operation to hand to
|
|
795
|
+
* {@link pollAssetOperationAsync}; the asset has no id until that finishes.
|
|
796
|
+
*/
|
|
797
|
+
async createAssetAsync(options: CreateAssetOptions): Promise<AssetOperation> {
|
|
798
|
+
const { creator } = options;
|
|
799
|
+
return this._sendAssetAsync(
|
|
800
|
+
'POST',
|
|
801
|
+
`${ASSETS_API_URL}/assets`,
|
|
802
|
+
{
|
|
803
|
+
assetType: options.assetType,
|
|
804
|
+
displayName: options.displayName,
|
|
805
|
+
description: options.description,
|
|
806
|
+
creationContext: {
|
|
807
|
+
creator:
|
|
808
|
+
'userId' in creator
|
|
809
|
+
? { userId: String(creator.userId) }
|
|
810
|
+
: { groupId: String(creator.groupId) },
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
options
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Start replacing an existing asset's file, keeping its id — the upload
|
|
819
|
+
* becomes a new version of the same asset. The API only does this for models.
|
|
820
|
+
*/
|
|
821
|
+
async updateAssetContentAsync(
|
|
822
|
+
assetId: number,
|
|
823
|
+
file: AssetFile
|
|
824
|
+
): Promise<AssetOperation> {
|
|
825
|
+
return this._sendAssetAsync(
|
|
826
|
+
'PATCH',
|
|
827
|
+
`${ASSETS_API_URL}/assets/${assetId}`,
|
|
828
|
+
{ assetId },
|
|
829
|
+
file
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Wait for an asset upload to finish and return the asset it produced.
|
|
835
|
+
*
|
|
836
|
+
* Throws if the operation reports an error or outlives `timeoutMs`. A timeout
|
|
837
|
+
* is not a failed upload: Roblox may still finish creating the asset, which
|
|
838
|
+
* is why the message says to look before uploading again.
|
|
839
|
+
*/
|
|
840
|
+
async pollAssetOperationAsync(
|
|
841
|
+
operation: AssetOperation,
|
|
842
|
+
timeoutMs: number = ASSET_OPERATION_TIMEOUT_MS
|
|
843
|
+
): Promise<UploadedAsset> {
|
|
844
|
+
const apiKey = await this._resolveApiKeyAsync();
|
|
845
|
+
const url = `${ASSETS_API_URL}/${operation.path}`;
|
|
846
|
+
const maxPolls = Math.ceil(timeoutMs / ASSET_OPERATION_POLL_MS);
|
|
847
|
+
|
|
848
|
+
let current = operation;
|
|
849
|
+
for (let poll = 0; !current.done && poll < maxPolls; poll++) {
|
|
850
|
+
await new Promise((resolve) =>
|
|
851
|
+
setTimeout(resolve, ASSET_OPERATION_POLL_MS)
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
const response = await this._rateLimiter.fetchAsync(url, {
|
|
855
|
+
method: 'GET',
|
|
856
|
+
headers: { 'x-api-key': apiKey },
|
|
857
|
+
});
|
|
858
|
+
if (!response.ok) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
formatAssetApiError(
|
|
861
|
+
'Read asset upload status',
|
|
862
|
+
response.status,
|
|
863
|
+
response.statusText,
|
|
864
|
+
await response.text(),
|
|
865
|
+
'GET',
|
|
866
|
+
url
|
|
867
|
+
)
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
current = (await response.json()) as AssetOperation;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!current.done) {
|
|
874
|
+
throw new Error(
|
|
875
|
+
`Asset upload ${operation.path} did not finish within ` +
|
|
876
|
+
`${Math.round(timeoutMs / 1000)}s. Roblox may still create the ` +
|
|
877
|
+
`asset — check https://create.roblox.com/dashboard/creations ` +
|
|
878
|
+
`before uploading it again.`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
if (current.error) {
|
|
882
|
+
throw new Error(
|
|
883
|
+
`Asset upload failed: ${
|
|
884
|
+
current.error.message ?? JSON.stringify(current.error)
|
|
885
|
+
}`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const assetId = Number(current.response?.assetId);
|
|
890
|
+
if (!Number.isSafeInteger(assetId) || assetId < 1) {
|
|
891
|
+
throw new Error(
|
|
892
|
+
`Asset upload ${operation.path} finished without an asset id: ` +
|
|
893
|
+
JSON.stringify(current)
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
assetId,
|
|
899
|
+
revisionId: current.response?.revisionId,
|
|
900
|
+
moderationState: current.response?.moderationResult?.moderationState,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Who owns a universe: the user or group its assets should belong to if the
|
|
906
|
+
* experience is to use them. Needs no scope beyond a valid key.
|
|
907
|
+
*/
|
|
908
|
+
async getUniverseOwnerAsync(universeId: number): Promise<AssetCreator> {
|
|
909
|
+
const apiKey = await this._resolveApiKeyAsync();
|
|
910
|
+
const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}`;
|
|
911
|
+
|
|
912
|
+
const response = await this._rateLimiter.fetchAsync(url, {
|
|
913
|
+
method: 'GET',
|
|
914
|
+
headers: { 'x-api-key': apiKey },
|
|
915
|
+
});
|
|
916
|
+
if (!response.ok) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`Read universe ${universeId} failed: ${response.status} ${response.statusText}`
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Exactly one of the two is present: `users/123` or `groups/456`.
|
|
923
|
+
const universe = (await response.json()) as {
|
|
924
|
+
user?: string;
|
|
925
|
+
group?: string;
|
|
926
|
+
};
|
|
927
|
+
const userId = Number(universe.user?.match(/^users\/(\d+)$/)?.[1]);
|
|
928
|
+
if (Number.isSafeInteger(userId)) {
|
|
929
|
+
return { userId };
|
|
930
|
+
}
|
|
931
|
+
const groupId = Number(universe.group?.match(/^groups\/(\d+)$/)?.[1]);
|
|
932
|
+
if (Number.isSafeInteger(groupId)) {
|
|
933
|
+
return { groupId };
|
|
934
|
+
}
|
|
935
|
+
throw new Error(`Universe ${universeId} did not report an owner`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The user the API key acts as — whoever created it. */
|
|
939
|
+
async getApiKeyUserIdAsync(): Promise<number> {
|
|
940
|
+
const apiKey = await this._resolveApiKeyAsync();
|
|
941
|
+
const url = 'https://apis.roblox.com/api-keys/v1/introspect';
|
|
942
|
+
|
|
943
|
+
// The key travels in the body here, not the usual header.
|
|
944
|
+
const response = await this._rateLimiter.fetchAsync(url, {
|
|
945
|
+
method: 'POST',
|
|
946
|
+
headers: { 'Content-Type': 'application/json' },
|
|
947
|
+
body: JSON.stringify({ apiKey }),
|
|
948
|
+
});
|
|
949
|
+
if (!response.ok) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
`Read API key owner failed: ${response.status} ${response.statusText}`
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const key = (await response.json()) as { authorizedUserId?: number };
|
|
956
|
+
if (!Number.isSafeInteger(key.authorizedUserId)) {
|
|
957
|
+
throw new Error('The API key did not report which user it belongs to');
|
|
958
|
+
}
|
|
959
|
+
return key.authorizedUserId!;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private async _sendAssetAsync(
|
|
963
|
+
method: 'POST' | 'PATCH',
|
|
964
|
+
url: string,
|
|
965
|
+
request: Record<string, unknown>,
|
|
966
|
+
file: AssetFile
|
|
967
|
+
): Promise<AssetOperation> {
|
|
968
|
+
const apiKey = await this._resolveApiKeyAsync();
|
|
969
|
+
const fileBuffer = await fs.readFile(file.filePath);
|
|
970
|
+
|
|
971
|
+
const form = new FormData();
|
|
972
|
+
form.append('request', JSON.stringify(request));
|
|
973
|
+
form.append(
|
|
974
|
+
'fileContent',
|
|
975
|
+
new Blob([new Uint8Array(fileBuffer)], { type: file.contentType }),
|
|
976
|
+
path.basename(file.filePath)
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// No Content-Type header: fetch writes the multipart boundary itself, and
|
|
980
|
+
// setting one by hand sends a body the API cannot split into parts.
|
|
981
|
+
const response = await this._rateLimiter.fetchAsync(url, {
|
|
982
|
+
method,
|
|
983
|
+
headers: { 'x-api-key': apiKey },
|
|
984
|
+
body: form,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
if (!response.ok) {
|
|
988
|
+
throw new Error(
|
|
989
|
+
formatAssetApiError(
|
|
990
|
+
method === 'POST' ? 'Create asset' : 'Update asset',
|
|
991
|
+
response.status,
|
|
992
|
+
response.statusText,
|
|
993
|
+
await response.text(),
|
|
994
|
+
method,
|
|
995
|
+
url
|
|
996
|
+
)
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return (await response.json()) as AssetOperation;
|
|
1001
|
+
}
|
|
685
1002
|
}
|
|
686
1003
|
|
|
687
1004
|
/** How the Assets API reports one entry of a place's version history. */
|
|
@@ -5,10 +5,11 @@ vi.mock('@quenty/cli-output-helpers', () => ({
|
|
|
5
5
|
warn: vi.fn(),
|
|
6
6
|
info: vi.fn(),
|
|
7
7
|
error: vi.fn(),
|
|
8
|
+
verbose: vi.fn(),
|
|
8
9
|
},
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
|
-
import { RateLimiter } from './rate-limiter.js';
|
|
12
|
+
import { formatRateLimitWait, RateLimiter } from './rate-limiter.js';
|
|
12
13
|
|
|
13
14
|
interface Deferred<T> {
|
|
14
15
|
promise: Promise<T>;
|
|
@@ -105,9 +106,10 @@ describe('RateLimiter', () => {
|
|
|
105
106
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
106
107
|
});
|
|
107
108
|
|
|
108
|
-
it('blocks the next request when x-ratelimit-remaining hits 0 until reset elapses', async () => {
|
|
109
|
+
it('blocks the next request to the same endpoint when x-ratelimit-remaining hits 0 until reset elapses', async () => {
|
|
109
110
|
vi.useFakeTimers();
|
|
110
111
|
const limiter = new RateLimiter();
|
|
112
|
+
const onWait = vi.fn();
|
|
111
113
|
|
|
112
114
|
fetchMock
|
|
113
115
|
.mockResolvedValueOnce(
|
|
@@ -118,20 +120,83 @@ describe('RateLimiter', () => {
|
|
|
118
120
|
)
|
|
119
121
|
.mockResolvedValueOnce(makeResponse(200));
|
|
120
122
|
|
|
121
|
-
await limiter.fetchAsync(
|
|
123
|
+
await limiter.fetchAsync(
|
|
124
|
+
'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-session-tasks',
|
|
125
|
+
{ method: 'POST' }
|
|
126
|
+
);
|
|
122
127
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
123
128
|
|
|
124
|
-
const second = limiter.fetchAsync(
|
|
129
|
+
const second = limiter.fetchAsync(
|
|
130
|
+
'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/4/luau-execution-session-tasks',
|
|
131
|
+
{ method: 'POST' },
|
|
132
|
+
{ onWait }
|
|
133
|
+
);
|
|
125
134
|
|
|
126
135
|
// Without advancing time, the second fetch is parked in _waitIfNeededAsync
|
|
127
136
|
await vi.advanceTimersByTimeAsync(0);
|
|
128
137
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(onWait).toHaveBeenCalledWith(
|
|
139
|
+
expect.objectContaining({ reason: 'quota', waitMs: 5000 })
|
|
140
|
+
);
|
|
129
141
|
|
|
130
142
|
await vi.advanceTimersByTimeAsync(5000);
|
|
131
143
|
await second;
|
|
132
144
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
133
145
|
});
|
|
134
146
|
|
|
147
|
+
it('does not let a spent quota on one endpoint stall another', async () => {
|
|
148
|
+
// Open Cloud meters task creation (5/min) and task polling (200/min)
|
|
149
|
+
// separately; creation running dry must not park the poll.
|
|
150
|
+
vi.useFakeTimers();
|
|
151
|
+
const limiter = new RateLimiter();
|
|
152
|
+
|
|
153
|
+
fetchMock
|
|
154
|
+
.mockResolvedValueOnce(
|
|
155
|
+
makeResponse(200, {
|
|
156
|
+
'x-ratelimit-remaining': '0',
|
|
157
|
+
'x-ratelimit-reset': '60',
|
|
158
|
+
})
|
|
159
|
+
)
|
|
160
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
161
|
+
|
|
162
|
+
await limiter.fetchAsync(
|
|
163
|
+
'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-session-tasks',
|
|
164
|
+
{ method: 'POST' }
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const poll = limiter.fetchAsync(
|
|
168
|
+
'https://apis.roblox.com/cloud/v2/universes/1/places/2/versions/3/luau-execution-sessions/12b4bf19-8ddc-4885-9e54-fa40bb0faca8/tasks/12b4bf19-8ddc-4885-9e54-fa40bb0faca8',
|
|
169
|
+
{ method: 'GET' }
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
173
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
174
|
+
expect((await poll).status).toBe(200);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('reports retry waits to onWait with the triggering status', async () => {
|
|
178
|
+
vi.useFakeTimers();
|
|
179
|
+
const limiter = new RateLimiter();
|
|
180
|
+
const onWait = vi.fn();
|
|
181
|
+
|
|
182
|
+
fetchMock
|
|
183
|
+
.mockResolvedValueOnce(makeResponse(429, { 'retry-after': '5' }))
|
|
184
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
185
|
+
|
|
186
|
+
const promise = limiter.fetchAsync(
|
|
187
|
+
'https://apis.roblox.com/cloud/v2/a',
|
|
188
|
+
undefined,
|
|
189
|
+
{ onWait }
|
|
190
|
+
);
|
|
191
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
192
|
+
await promise;
|
|
193
|
+
|
|
194
|
+
expect(onWait).toHaveBeenCalledTimes(1);
|
|
195
|
+
expect(onWait).toHaveBeenCalledWith(
|
|
196
|
+
expect.objectContaining({ reason: 'retry', status: 429, waitMs: 5000 })
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
135
200
|
it('returns the last 429 response after exhausting all retries without sleeping past the final attempt', async () => {
|
|
136
201
|
vi.useFakeTimers();
|
|
137
202
|
const limiter = new RateLimiter({ maxRetries: 3 });
|
|
@@ -228,3 +293,40 @@ describe('RateLimiter', () => {
|
|
|
228
293
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
229
294
|
});
|
|
230
295
|
});
|
|
296
|
+
|
|
297
|
+
describe('formatRateLimitWait', () => {
|
|
298
|
+
it('says whether the wait is a rate limit or a retry', () => {
|
|
299
|
+
expect(
|
|
300
|
+
formatRateLimitWait({ route: 'r', waitMs: 54_000, reason: 'quota' })
|
|
301
|
+
).toBe('rate limited 54s');
|
|
302
|
+
expect(
|
|
303
|
+
formatRateLimitWait({
|
|
304
|
+
route: 'r',
|
|
305
|
+
waitMs: 5_500,
|
|
306
|
+
reason: 'retry',
|
|
307
|
+
status: 429,
|
|
308
|
+
})
|
|
309
|
+
).toBe('rate limited 6s');
|
|
310
|
+
expect(
|
|
311
|
+
formatRateLimitWait({
|
|
312
|
+
route: 'r',
|
|
313
|
+
waitMs: 1_000,
|
|
314
|
+
reason: 'retry',
|
|
315
|
+
status: 503,
|
|
316
|
+
})
|
|
317
|
+
).toBe('retrying in 1s');
|
|
318
|
+
expect(
|
|
319
|
+
formatRateLimitWait({ route: 'r', waitMs: 2_000, reason: 'retry' })
|
|
320
|
+
).toBe('retrying in 2s');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('fits the spinner status column while scheduling', () => {
|
|
324
|
+
// "◇ Scheduling " is 13 columns; the status cell is 32.
|
|
325
|
+
const longest = formatRateLimitWait({
|
|
326
|
+
route: 'r',
|
|
327
|
+
waitMs: 300_000,
|
|
328
|
+
reason: 'quota',
|
|
329
|
+
});
|
|
330
|
+
expect(`◇ Scheduling (${longest})`.length).toBeLessThanOrEqual(32);
|
|
331
|
+
});
|
|
332
|
+
});
|