@gpc-cli/api 1.0.20 → 1.0.22
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/dist/index.d.ts +29 -3
- package/dist/index.js +458 -81
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21,8 +21,334 @@ var PlayApiError = class extends Error {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
// src/http.ts
|
|
24
|
-
import { readFile } from "fs/promises";
|
|
24
|
+
import { readFile, stat as stat2 } from "fs/promises";
|
|
25
25
|
import { resolve, isAbsolute } from "path";
|
|
26
|
+
|
|
27
|
+
// src/resumable-upload.ts
|
|
28
|
+
import { open, stat } from "fs/promises";
|
|
29
|
+
var CHUNK_ALIGNMENT = 256 * 1024;
|
|
30
|
+
var DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
|
|
31
|
+
var RESUMABLE_THRESHOLD = 5 * 1024 * 1024;
|
|
32
|
+
function envInt(name) {
|
|
33
|
+
const val = process.env[name];
|
|
34
|
+
if (val === void 0) return void 0;
|
|
35
|
+
const n = Number(val);
|
|
36
|
+
return Number.isFinite(n) ? n : void 0;
|
|
37
|
+
}
|
|
38
|
+
function resolveChunkSize(explicit) {
|
|
39
|
+
const size = explicit ?? envInt("GPC_UPLOAD_CHUNK_SIZE") ?? DEFAULT_CHUNK_SIZE;
|
|
40
|
+
if (size < CHUNK_ALIGNMENT || size % CHUNK_ALIGNMENT !== 0) {
|
|
41
|
+
throw new PlayApiError(
|
|
42
|
+
`Chunk size must be a multiple of 256 KB (got ${size} bytes)`,
|
|
43
|
+
"UPLOAD_INVALID_CHUNK_SIZE",
|
|
44
|
+
void 0,
|
|
45
|
+
`Use a multiple of 262144 (256 KB). Common values: 1048576 (1 MB), 8388608 (8 MB), 16777216 (16 MB).`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return size;
|
|
49
|
+
}
|
|
50
|
+
function jitteredDelay(base, attempt, max) {
|
|
51
|
+
const exponential = base * 2 ** attempt;
|
|
52
|
+
const capped = Math.min(exponential, max);
|
|
53
|
+
return capped * (0.5 + Math.random() * 0.5);
|
|
54
|
+
}
|
|
55
|
+
async function resumableUpload(uploadUrl, filePath, contentType, ctx, options) {
|
|
56
|
+
const chunkSize = resolveChunkSize(options?.chunkSize);
|
|
57
|
+
const maxResumeAttempts = options?.maxResumeAttempts ?? 5;
|
|
58
|
+
const onProgress = options?.onProgress;
|
|
59
|
+
const fileStats = await stat(filePath);
|
|
60
|
+
const totalBytes = fileStats.size;
|
|
61
|
+
let sessionUri = options?.resumeSessionUri;
|
|
62
|
+
if (!sessionUri) {
|
|
63
|
+
sessionUri = await initiateSession(uploadUrl, contentType, totalBytes, ctx);
|
|
64
|
+
}
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
let offset = 0;
|
|
67
|
+
if (options?.resumeSessionUri) {
|
|
68
|
+
offset = await queryProgress(sessionUri, totalBytes, ctx);
|
|
69
|
+
}
|
|
70
|
+
let fh;
|
|
71
|
+
try {
|
|
72
|
+
fh = await open(filePath, "r");
|
|
73
|
+
const chunkBuffer = Buffer.alloc(chunkSize);
|
|
74
|
+
while (offset < totalBytes) {
|
|
75
|
+
const remaining = totalBytes - offset;
|
|
76
|
+
const bytesToRead = Math.min(chunkSize, remaining);
|
|
77
|
+
const { bytesRead } = await fh.read(chunkBuffer, 0, bytesToRead, offset);
|
|
78
|
+
if (bytesRead === 0) break;
|
|
79
|
+
const chunk = Buffer.from(chunkBuffer.buffer, chunkBuffer.byteOffset, bytesRead);
|
|
80
|
+
const rangeEnd = offset + bytesRead - 1;
|
|
81
|
+
const contentRange = `bytes ${offset}-${rangeEnd}/${totalBytes}`;
|
|
82
|
+
let result;
|
|
83
|
+
for (let attempt = 0; attempt <= maxResumeAttempts; attempt++) {
|
|
84
|
+
if (attempt > 0) {
|
|
85
|
+
const delay = jitteredDelay(1e3, attempt - 1, 3e4);
|
|
86
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
87
|
+
try {
|
|
88
|
+
const serverOffset = await queryProgress(sessionUri, totalBytes, ctx);
|
|
89
|
+
if (serverOffset >= totalBytes) {
|
|
90
|
+
const completionResult = await fetchCompletionResponse(sessionUri, totalBytes, ctx);
|
|
91
|
+
if (completionResult) {
|
|
92
|
+
result = completionResult;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
result = { complete: true, response: { data: {}, status: 200 } };
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (serverOffset >= offset + bytesRead) {
|
|
99
|
+
result = { complete: false };
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (serverOffset > offset) {
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
ctx.onRetry?.({
|
|
107
|
+
attempt,
|
|
108
|
+
method: "PUT",
|
|
109
|
+
path: sessionUri,
|
|
110
|
+
error: `Chunk upload failed at offset ${offset}, retrying`,
|
|
111
|
+
delayMs: Math.round(delay),
|
|
112
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
result = await sendChunk(sessionUri, chunk, contentRange, ctx);
|
|
116
|
+
if (result) break;
|
|
117
|
+
}
|
|
118
|
+
if (!result) {
|
|
119
|
+
throw new PlayApiError(
|
|
120
|
+
`Upload failed: chunk at offset ${offset} could not be sent after ${maxResumeAttempts + 1} attempts`,
|
|
121
|
+
"UPLOAD_CHUNK_FAILED",
|
|
122
|
+
void 0,
|
|
123
|
+
`The upload session is still valid for up to 1 week. Resume with: --resume-uri "${sessionUri}"`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
offset += bytesRead;
|
|
127
|
+
if (onProgress) {
|
|
128
|
+
const elapsed = (Date.now() - startTime) / 1e3;
|
|
129
|
+
const bytesPerSecond = elapsed > 0 ? offset / elapsed : 0;
|
|
130
|
+
const remainingBytes = totalBytes - offset;
|
|
131
|
+
const etaSeconds = bytesPerSecond > 0 ? remainingBytes / bytesPerSecond : 0;
|
|
132
|
+
onProgress({
|
|
133
|
+
bytesUploaded: offset,
|
|
134
|
+
totalBytes,
|
|
135
|
+
percent: Math.round(offset / totalBytes * 100),
|
|
136
|
+
bytesPerSecond: Math.round(bytesPerSecond),
|
|
137
|
+
etaSeconds: Math.round(etaSeconds)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (result.complete && result.response) {
|
|
141
|
+
return result.response;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const serverOffset = await queryProgress(sessionUri, totalBytes, ctx);
|
|
146
|
+
if (serverOffset >= totalBytes) {
|
|
147
|
+
const completionResult = await fetchCompletionResponse(sessionUri, totalBytes, ctx);
|
|
148
|
+
if (completionResult?.response) {
|
|
149
|
+
return completionResult.response;
|
|
150
|
+
}
|
|
151
|
+
return { data: {}, status: 200 };
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
throw new PlayApiError(
|
|
156
|
+
"Upload finished sending all bytes but did not receive a completion response",
|
|
157
|
+
"UPLOAD_NO_COMPLETION",
|
|
158
|
+
void 0,
|
|
159
|
+
`The upload session may still be valid. Resume with: --resume-uri "${sessionUri}"`
|
|
160
|
+
);
|
|
161
|
+
} finally {
|
|
162
|
+
await fh?.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function initiateSession(uploadUrl, contentType, totalBytes, ctx) {
|
|
166
|
+
const token = await ctx.getAccessToken();
|
|
167
|
+
const url = uploadUrl.includes("?") ? `${uploadUrl}&uploadType=resumable` : `${uploadUrl}?uploadType=resumable`;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
const timer = setTimeout(() => controller.abort(), 6e4);
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetch(url, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${token}`,
|
|
176
|
+
"X-Upload-Content-Type": contentType,
|
|
177
|
+
"X-Upload-Content-Length": String(totalBytes),
|
|
178
|
+
"Content-Length": "0"
|
|
179
|
+
},
|
|
180
|
+
signal: controller.signal
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const body = await response.text();
|
|
187
|
+
throw new PlayApiError(
|
|
188
|
+
`Failed to initiate resumable upload: ${response.status} ${body.slice(0, 200)}`,
|
|
189
|
+
"UPLOAD_INITIATE_FAILED",
|
|
190
|
+
response.status,
|
|
191
|
+
"Check that the package name, edit ID, and credentials are correct."
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const location = response.headers.get("Location");
|
|
195
|
+
if (!location) {
|
|
196
|
+
throw new PlayApiError(
|
|
197
|
+
"Resumable upload initiation did not return a session URI (Location header missing)",
|
|
198
|
+
"UPLOAD_NO_SESSION_URI",
|
|
199
|
+
response.status,
|
|
200
|
+
"This is a Google API issue. Try again."
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return location;
|
|
204
|
+
}
|
|
205
|
+
async function sendChunk(sessionUri, chunk, contentRange, ctx) {
|
|
206
|
+
const token = await ctx.getAccessToken();
|
|
207
|
+
const chunkTimeoutMs = 3e4 + Math.ceil(chunk.byteLength / (1024 * 1024)) * 1e3;
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => controller.abort(), chunkTimeoutMs);
|
|
210
|
+
let response;
|
|
211
|
+
try {
|
|
212
|
+
response = await fetch(sessionUri, {
|
|
213
|
+
method: "PUT",
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: `Bearer ${token}`,
|
|
216
|
+
"Content-Length": String(chunk.byteLength),
|
|
217
|
+
"Content-Range": contentRange
|
|
218
|
+
},
|
|
219
|
+
body: chunk,
|
|
220
|
+
signal: controller.signal
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
return void 0;
|
|
224
|
+
} finally {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
}
|
|
227
|
+
if (response.status === 200 || response.status === 201) {
|
|
228
|
+
const text = await response.text();
|
|
229
|
+
let data;
|
|
230
|
+
try {
|
|
231
|
+
data = text ? JSON.parse(text) : {};
|
|
232
|
+
} catch {
|
|
233
|
+
data = {};
|
|
234
|
+
}
|
|
235
|
+
return { complete: true, response: { data, status: response.status } };
|
|
236
|
+
}
|
|
237
|
+
if (response.status === 308) {
|
|
238
|
+
await response.body?.cancel();
|
|
239
|
+
return { complete: false };
|
|
240
|
+
}
|
|
241
|
+
if (response.status === 404) {
|
|
242
|
+
throw new PlayApiError(
|
|
243
|
+
"Upload session not found. The session may have expired.",
|
|
244
|
+
"UPLOAD_SESSION_NOT_FOUND",
|
|
245
|
+
404,
|
|
246
|
+
"Start a new upload. Resumable upload sessions are valid for up to 1 week."
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (response.status === 410) {
|
|
250
|
+
throw new PlayApiError(
|
|
251
|
+
"Upload session has expired.",
|
|
252
|
+
"UPLOAD_SESSION_EXPIRED",
|
|
253
|
+
410,
|
|
254
|
+
"Start a new upload from the beginning."
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (response.status === 401) {
|
|
258
|
+
await response.body?.cancel();
|
|
259
|
+
return void 0;
|
|
260
|
+
}
|
|
261
|
+
if (response.status === 429 || response.status >= 500) {
|
|
262
|
+
await response.body?.cancel();
|
|
263
|
+
return void 0;
|
|
264
|
+
}
|
|
265
|
+
const body = await response.text();
|
|
266
|
+
throw new PlayApiError(
|
|
267
|
+
`Upload chunk failed with status ${response.status}: ${body.slice(0, 200)}`,
|
|
268
|
+
`UPLOAD_HTTP_${response.status}`,
|
|
269
|
+
response.status,
|
|
270
|
+
"The upload encountered an unexpected error."
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
async function fetchCompletionResponse(sessionUri, totalBytes, ctx) {
|
|
274
|
+
const token = await ctx.getAccessToken();
|
|
275
|
+
const controller = new AbortController();
|
|
276
|
+
const timer = setTimeout(() => controller.abort(), 3e4);
|
|
277
|
+
try {
|
|
278
|
+
const response = await fetch(sessionUri, {
|
|
279
|
+
method: "PUT",
|
|
280
|
+
headers: {
|
|
281
|
+
Authorization: `Bearer ${token}`,
|
|
282
|
+
"Content-Length": "0",
|
|
283
|
+
"Content-Range": `bytes */${totalBytes}`
|
|
284
|
+
},
|
|
285
|
+
signal: controller.signal
|
|
286
|
+
});
|
|
287
|
+
if (response.status === 200 || response.status === 201) {
|
|
288
|
+
const text = await response.text();
|
|
289
|
+
let data;
|
|
290
|
+
try {
|
|
291
|
+
data = text ? JSON.parse(text) : {};
|
|
292
|
+
} catch {
|
|
293
|
+
data = {};
|
|
294
|
+
}
|
|
295
|
+
return { complete: true, response: { data, status: response.status } };
|
|
296
|
+
}
|
|
297
|
+
await response.body?.cancel();
|
|
298
|
+
return void 0;
|
|
299
|
+
} catch {
|
|
300
|
+
return void 0;
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function queryProgress(sessionUri, totalBytes, ctx) {
|
|
306
|
+
const token = await ctx.getAccessToken();
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
const timer = setTimeout(() => controller.abort(), 3e4);
|
|
309
|
+
let response;
|
|
310
|
+
try {
|
|
311
|
+
response = await fetch(sessionUri, {
|
|
312
|
+
method: "PUT",
|
|
313
|
+
headers: {
|
|
314
|
+
Authorization: `Bearer ${token}`,
|
|
315
|
+
"Content-Length": "0",
|
|
316
|
+
"Content-Range": `bytes */${totalBytes}`
|
|
317
|
+
},
|
|
318
|
+
signal: controller.signal
|
|
319
|
+
});
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
}
|
|
323
|
+
if (response.status === 308) {
|
|
324
|
+
await response.body?.cancel();
|
|
325
|
+
const range = response.headers.get("Range");
|
|
326
|
+
if (range) {
|
|
327
|
+
const match = range.match(/bytes=0-(\d+)/);
|
|
328
|
+
if (match) {
|
|
329
|
+
return Number(match[1]) + 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
if (response.status === 200 || response.status === 201) {
|
|
335
|
+
await response.body?.cancel();
|
|
336
|
+
return totalBytes;
|
|
337
|
+
}
|
|
338
|
+
if (response.status === 404 || response.status === 410) {
|
|
339
|
+
await response.body?.cancel();
|
|
340
|
+
throw new PlayApiError(
|
|
341
|
+
"Upload session has expired while querying progress.",
|
|
342
|
+
"UPLOAD_SESSION_EXPIRED",
|
|
343
|
+
response.status,
|
|
344
|
+
"Start a new upload from the beginning."
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
await response.body?.cancel();
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/http.ts
|
|
26
352
|
function stripHtml(text) {
|
|
27
353
|
return text.replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
|
|
28
354
|
}
|
|
@@ -60,14 +386,14 @@ function validateFilePath(filePath) {
|
|
|
60
386
|
var BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
|
|
61
387
|
var UPLOAD_BASE_URL = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications";
|
|
62
388
|
var INTERNAL_SHARING_UPLOAD_BASE_URL = "https://androidpublisher.googleapis.com/upload/internalappsharing/v3/applications";
|
|
63
|
-
function
|
|
389
|
+
function envInt2(name) {
|
|
64
390
|
const val = process.env[name];
|
|
65
391
|
if (val === void 0) return void 0;
|
|
66
392
|
const n = Number(val);
|
|
67
393
|
return Number.isFinite(n) ? n : void 0;
|
|
68
394
|
}
|
|
69
395
|
function resolveOption(explicit, envName, fallback) {
|
|
70
|
-
return explicit ??
|
|
396
|
+
return explicit ?? envInt2(envName) ?? fallback;
|
|
71
397
|
}
|
|
72
398
|
function mapStatusToError(status, body) {
|
|
73
399
|
switch (status) {
|
|
@@ -120,17 +446,17 @@ function mapStatusToError(status, body) {
|
|
|
120
446
|
}
|
|
121
447
|
}
|
|
122
448
|
function isRetryable(status) {
|
|
123
|
-
return status === 429 || status >= 500;
|
|
449
|
+
return status === 408 || status === 429 || status >= 500;
|
|
124
450
|
}
|
|
125
|
-
function
|
|
451
|
+
function jitteredDelay2(base, attempt, max) {
|
|
126
452
|
const exponential = base * 2 ** attempt;
|
|
127
453
|
const capped = Math.min(exponential, max);
|
|
128
454
|
return capped * (0.5 + Math.random() * 0.5);
|
|
129
455
|
}
|
|
130
456
|
function createHttpClient(options) {
|
|
131
|
-
const maxRetries = resolveOption(options.maxRetries, "GPC_MAX_RETRIES",
|
|
457
|
+
const maxRetries = resolveOption(options.maxRetries, "GPC_MAX_RETRIES", 5);
|
|
132
458
|
const timeout = resolveOption(options.timeout, "GPC_TIMEOUT", 3e4);
|
|
133
|
-
const uploadTimeoutExplicit = options.uploadTimeout ??
|
|
459
|
+
const uploadTimeoutExplicit = options.uploadTimeout ?? envInt2("GPC_UPLOAD_TIMEOUT");
|
|
134
460
|
const baseDelay = resolveOption(options.baseDelay, "GPC_BASE_DELAY", 1e3);
|
|
135
461
|
const maxDelay = resolveOption(options.maxDelay, "GPC_MAX_DELAY", 6e4);
|
|
136
462
|
const onRetry = options.onRetry;
|
|
@@ -144,7 +470,7 @@ function createHttpClient(options) {
|
|
|
144
470
|
let lastError;
|
|
145
471
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
146
472
|
if (attempt > 0) {
|
|
147
|
-
const delay =
|
|
473
|
+
const delay = jitteredDelay2(baseDelay, attempt - 1, maxDelay);
|
|
148
474
|
await new Promise((r) => setTimeout(r, delay));
|
|
149
475
|
}
|
|
150
476
|
const controller = new AbortController();
|
|
@@ -181,7 +507,7 @@ function createHttpClient(options) {
|
|
|
181
507
|
);
|
|
182
508
|
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
183
509
|
lastError = err;
|
|
184
|
-
const delay =
|
|
510
|
+
const delay = jitteredDelay2(baseDelay, attempt, maxDelay);
|
|
185
511
|
onRetry?.({
|
|
186
512
|
attempt: attempt + 1,
|
|
187
513
|
method,
|
|
@@ -217,7 +543,7 @@ function createHttpClient(options) {
|
|
|
217
543
|
method,
|
|
218
544
|
path,
|
|
219
545
|
error: timeoutErr.message,
|
|
220
|
-
delayMs: Math.round(
|
|
546
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
221
547
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
222
548
|
});
|
|
223
549
|
continue;
|
|
@@ -237,7 +563,7 @@ function createHttpClient(options) {
|
|
|
237
563
|
method,
|
|
238
564
|
path,
|
|
239
565
|
error: networkErr.message,
|
|
240
|
-
delayMs: Math.round(
|
|
566
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
241
567
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
242
568
|
});
|
|
243
569
|
continue;
|
|
@@ -268,7 +594,7 @@ function createHttpClient(options) {
|
|
|
268
594
|
let lastError;
|
|
269
595
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
270
596
|
if (attempt > 0) {
|
|
271
|
-
const delay =
|
|
597
|
+
const delay = jitteredDelay2(baseDelay, attempt - 1, maxDelay);
|
|
272
598
|
await new Promise((r) => setTimeout(r, delay));
|
|
273
599
|
}
|
|
274
600
|
const controller = new AbortController();
|
|
@@ -302,7 +628,7 @@ function createHttpClient(options) {
|
|
|
302
628
|
);
|
|
303
629
|
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
304
630
|
lastError = err;
|
|
305
|
-
const delay =
|
|
631
|
+
const delay = jitteredDelay2(baseDelay, attempt, maxDelay);
|
|
306
632
|
onRetry?.({
|
|
307
633
|
attempt: attempt + 1,
|
|
308
634
|
method: "POST",
|
|
@@ -339,7 +665,7 @@ function createHttpClient(options) {
|
|
|
339
665
|
method: "POST",
|
|
340
666
|
path: `upload ${path}`,
|
|
341
667
|
error: timeoutErr.message,
|
|
342
|
-
delayMs: Math.round(
|
|
668
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
343
669
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
344
670
|
});
|
|
345
671
|
continue;
|
|
@@ -359,7 +685,7 @@ function createHttpClient(options) {
|
|
|
359
685
|
method: "POST",
|
|
360
686
|
path: `upload ${path}`,
|
|
361
687
|
error: networkErr.message,
|
|
362
|
-
delayMs: Math.round(
|
|
688
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
363
689
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
364
690
|
});
|
|
365
691
|
continue;
|
|
@@ -395,6 +721,43 @@ function createHttpClient(options) {
|
|
|
395
721
|
upload(path, filePath, contentType) {
|
|
396
722
|
return uploadRequest(path, filePath, contentType);
|
|
397
723
|
},
|
|
724
|
+
async uploadResumable(path, filePath, contentType, uploadOptions) {
|
|
725
|
+
const safeFilePath = validateFilePath(filePath);
|
|
726
|
+
const fileStats = await stat2(safeFilePath);
|
|
727
|
+
const threshold = envInt2("GPC_UPLOAD_RESUMABLE_THRESHOLD") ?? RESUMABLE_THRESHOLD;
|
|
728
|
+
if (fileStats.size < threshold && !uploadOptions?.resumeSessionUri) {
|
|
729
|
+
uploadOptions?.onProgress?.({
|
|
730
|
+
bytesUploaded: 0,
|
|
731
|
+
totalBytes: fileStats.size,
|
|
732
|
+
percent: 0,
|
|
733
|
+
bytesPerSecond: 0,
|
|
734
|
+
etaSeconds: 0
|
|
735
|
+
});
|
|
736
|
+
const result = await uploadRequest(path, safeFilePath, contentType);
|
|
737
|
+
uploadOptions?.onProgress?.({
|
|
738
|
+
bytesUploaded: fileStats.size,
|
|
739
|
+
totalBytes: fileStats.size,
|
|
740
|
+
percent: 100,
|
|
741
|
+
bytesPerSecond: 0,
|
|
742
|
+
etaSeconds: 0
|
|
743
|
+
});
|
|
744
|
+
return result;
|
|
745
|
+
}
|
|
746
|
+
const uploadUrl = `${UPLOAD_BASE_URL}${path}`;
|
|
747
|
+
return resumableUpload(
|
|
748
|
+
uploadUrl,
|
|
749
|
+
safeFilePath,
|
|
750
|
+
contentType,
|
|
751
|
+
{
|
|
752
|
+
getAccessToken: () => options.auth.getAccessToken(),
|
|
753
|
+
maxRetries,
|
|
754
|
+
baseDelay,
|
|
755
|
+
maxDelay,
|
|
756
|
+
onRetry
|
|
757
|
+
},
|
|
758
|
+
uploadOptions
|
|
759
|
+
);
|
|
760
|
+
},
|
|
398
761
|
uploadInternal(path, filePath, contentType) {
|
|
399
762
|
return uploadRequest(path, filePath, contentType, INTERNAL_SHARING_UPLOAD_BASE_URL);
|
|
400
763
|
},
|
|
@@ -488,11 +851,12 @@ function createApiClient(options) {
|
|
|
488
851
|
);
|
|
489
852
|
return data.bundles;
|
|
490
853
|
},
|
|
491
|
-
async upload(packageName, editId, filePath) {
|
|
492
|
-
const { data } = await http.
|
|
854
|
+
async upload(packageName, editId, filePath, uploadOptions) {
|
|
855
|
+
const { data } = await http.uploadResumable(
|
|
493
856
|
`/${packageName}/edits/${editId}/bundles`,
|
|
494
857
|
filePath,
|
|
495
|
-
"application/octet-stream"
|
|
858
|
+
"application/octet-stream",
|
|
859
|
+
uploadOptions
|
|
496
860
|
);
|
|
497
861
|
if (!data || !data.versionCode) {
|
|
498
862
|
throw new PlayApiError(
|
|
@@ -769,7 +1133,6 @@ function createApiClient(options) {
|
|
|
769
1133
|
async list(packageName, options2) {
|
|
770
1134
|
const params = {};
|
|
771
1135
|
if (options2?.token) params["token"] = options2.token;
|
|
772
|
-
if (options2?.maxResults) params["maxResults"] = String(options2.maxResults);
|
|
773
1136
|
const hasParams = Object.keys(params).length > 0;
|
|
774
1137
|
const { data } = await http.get(
|
|
775
1138
|
`/${packageName}/inappproducts`,
|
|
@@ -843,6 +1206,12 @@ function createApiClient(options) {
|
|
|
843
1206
|
return data;
|
|
844
1207
|
},
|
|
845
1208
|
async getSubscriptionV1(packageName, subscriptionId, token) {
|
|
1209
|
+
if (typeof process !== "undefined" && process.emitWarning) {
|
|
1210
|
+
process.emitWarning(
|
|
1211
|
+
"purchases.subscriptions.get (v1) is deprecated by Google (shutdown Aug 2027). Use getSubscriptionV2() instead.",
|
|
1212
|
+
"DeprecationWarning"
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
846
1215
|
const { data } = await http.get(
|
|
847
1216
|
`/${packageName}/purchases/subscriptions/${subscriptionId}/tokens/${token}`
|
|
848
1217
|
);
|
|
@@ -1138,12 +1507,78 @@ function createApiClient(options) {
|
|
|
1138
1507
|
};
|
|
1139
1508
|
}
|
|
1140
1509
|
|
|
1510
|
+
// src/rate-limiter.ts
|
|
1511
|
+
var RATE_LIMIT_BUCKETS = {
|
|
1512
|
+
default: { name: "default", maxTokens: 200, refillRate: 200, refillIntervalMs: 1e3 },
|
|
1513
|
+
reviewsGet: { name: "reviewsGet", maxTokens: 200, refillRate: 200, refillIntervalMs: 36e5 },
|
|
1514
|
+
reviewsPost: {
|
|
1515
|
+
name: "reviewsPost",
|
|
1516
|
+
maxTokens: 2e3,
|
|
1517
|
+
refillRate: 2e3,
|
|
1518
|
+
refillIntervalMs: 864e5
|
|
1519
|
+
},
|
|
1520
|
+
voidedBurst: { name: "voidedBurst", maxTokens: 30, refillRate: 30, refillIntervalMs: 3e4 },
|
|
1521
|
+
voidedDaily: {
|
|
1522
|
+
name: "voidedDaily",
|
|
1523
|
+
maxTokens: 6e3,
|
|
1524
|
+
refillRate: 6e3,
|
|
1525
|
+
refillIntervalMs: 864e5
|
|
1526
|
+
},
|
|
1527
|
+
reporting: { name: "reporting", maxTokens: 10, refillRate: 10, refillIntervalMs: 1e3 }
|
|
1528
|
+
};
|
|
1529
|
+
function createRateLimiter(buckets) {
|
|
1530
|
+
const states = /* @__PURE__ */ new Map();
|
|
1531
|
+
if (buckets) {
|
|
1532
|
+
for (const bucket of buckets) {
|
|
1533
|
+
states.set(bucket.name, {
|
|
1534
|
+
tokens: bucket.maxTokens,
|
|
1535
|
+
lastRefillTime: Date.now(),
|
|
1536
|
+
config: bucket
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
async acquire(bucket) {
|
|
1542
|
+
const state = states.get(bucket);
|
|
1543
|
+
if (!state) return;
|
|
1544
|
+
const now = Date.now();
|
|
1545
|
+
const elapsed = now - state.lastRefillTime;
|
|
1546
|
+
const refill = Math.floor(
|
|
1547
|
+
elapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1548
|
+
);
|
|
1549
|
+
if (refill > 0) {
|
|
1550
|
+
state.tokens = Math.min(state.config.maxTokens, state.tokens + refill);
|
|
1551
|
+
state.lastRefillTime = now;
|
|
1552
|
+
}
|
|
1553
|
+
if (state.tokens > 0) {
|
|
1554
|
+
state.tokens--;
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const tokensNeeded = 1;
|
|
1558
|
+
const waitMs = Math.ceil(
|
|
1559
|
+
tokensNeeded / state.config.refillRate * state.config.refillIntervalMs
|
|
1560
|
+
);
|
|
1561
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1562
|
+
const afterWait = Date.now();
|
|
1563
|
+
const totalElapsed = afterWait - state.lastRefillTime;
|
|
1564
|
+
const newTokens = Math.floor(
|
|
1565
|
+
totalElapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1566
|
+
);
|
|
1567
|
+
state.tokens = Math.min(state.config.maxTokens, newTokens) - 1;
|
|
1568
|
+
state.lastRefillTime = afterWait;
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1141
1573
|
// src/reporting-client.ts
|
|
1142
1574
|
var REPORTING_BASE_URL = "https://playdeveloperreporting.googleapis.com/v1beta1";
|
|
1143
1575
|
function createReportingClient(options) {
|
|
1144
1576
|
const http = createHttpClient({ ...options, baseUrl: REPORTING_BASE_URL });
|
|
1577
|
+
const reportingBucket = RATE_LIMIT_BUCKETS["reporting"];
|
|
1578
|
+
const limiter = options.rateLimiter ?? createRateLimiter(reportingBucket ? [reportingBucket] : []);
|
|
1145
1579
|
return {
|
|
1146
1580
|
async queryMetricSet(packageName, metricSet, query) {
|
|
1581
|
+
await limiter.acquire("reporting");
|
|
1147
1582
|
const { data } = await http.post(
|
|
1148
1583
|
`/apps/${packageName}/${metricSet}:query`,
|
|
1149
1584
|
query
|
|
@@ -1151,10 +1586,12 @@ function createReportingClient(options) {
|
|
|
1151
1586
|
return data;
|
|
1152
1587
|
},
|
|
1153
1588
|
async getAnomalies(packageName) {
|
|
1589
|
+
await limiter.acquire("reporting");
|
|
1154
1590
|
const { data } = await http.get(`/apps/${packageName}/anomalies`);
|
|
1155
1591
|
return data;
|
|
1156
1592
|
},
|
|
1157
1593
|
async searchErrorIssues(packageName, filter, pageSize, pageToken) {
|
|
1594
|
+
await limiter.acquire("reporting");
|
|
1158
1595
|
const params = {};
|
|
1159
1596
|
if (filter) params["filter"] = filter;
|
|
1160
1597
|
if (pageSize) params["pageSize"] = String(pageSize);
|
|
@@ -1166,6 +1603,7 @@ function createReportingClient(options) {
|
|
|
1166
1603
|
return data;
|
|
1167
1604
|
},
|
|
1168
1605
|
async searchErrorReports(packageName, issueName, pageSize, pageToken) {
|
|
1606
|
+
await limiter.acquire("reporting");
|
|
1169
1607
|
const params = {};
|
|
1170
1608
|
if (pageSize) params["pageSize"] = String(pageSize);
|
|
1171
1609
|
if (pageToken) params["pageToken"] = pageToken;
|
|
@@ -1313,68 +1751,6 @@ function createEnterpriseClient(options) {
|
|
|
1313
1751
|
};
|
|
1314
1752
|
}
|
|
1315
1753
|
|
|
1316
|
-
// src/rate-limiter.ts
|
|
1317
|
-
var RATE_LIMIT_BUCKETS = {
|
|
1318
|
-
default: { name: "default", maxTokens: 200, refillRate: 200, refillIntervalMs: 1e3 },
|
|
1319
|
-
reviewsGet: { name: "reviewsGet", maxTokens: 200, refillRate: 200, refillIntervalMs: 36e5 },
|
|
1320
|
-
reviewsPost: {
|
|
1321
|
-
name: "reviewsPost",
|
|
1322
|
-
maxTokens: 2e3,
|
|
1323
|
-
refillRate: 2e3,
|
|
1324
|
-
refillIntervalMs: 864e5
|
|
1325
|
-
},
|
|
1326
|
-
voidedBurst: { name: "voidedBurst", maxTokens: 30, refillRate: 30, refillIntervalMs: 3e4 },
|
|
1327
|
-
voidedDaily: {
|
|
1328
|
-
name: "voidedDaily",
|
|
1329
|
-
maxTokens: 6e3,
|
|
1330
|
-
refillRate: 6e3,
|
|
1331
|
-
refillIntervalMs: 864e5
|
|
1332
|
-
}
|
|
1333
|
-
};
|
|
1334
|
-
function createRateLimiter(buckets) {
|
|
1335
|
-
const states = /* @__PURE__ */ new Map();
|
|
1336
|
-
if (buckets) {
|
|
1337
|
-
for (const bucket of buckets) {
|
|
1338
|
-
states.set(bucket.name, {
|
|
1339
|
-
tokens: bucket.maxTokens,
|
|
1340
|
-
lastRefillTime: Date.now(),
|
|
1341
|
-
config: bucket
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
return {
|
|
1346
|
-
async acquire(bucket) {
|
|
1347
|
-
const state = states.get(bucket);
|
|
1348
|
-
if (!state) return;
|
|
1349
|
-
const now = Date.now();
|
|
1350
|
-
const elapsed = now - state.lastRefillTime;
|
|
1351
|
-
const refill = Math.floor(
|
|
1352
|
-
elapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1353
|
-
);
|
|
1354
|
-
if (refill > 0) {
|
|
1355
|
-
state.tokens = Math.min(state.config.maxTokens, state.tokens + refill);
|
|
1356
|
-
state.lastRefillTime = now;
|
|
1357
|
-
}
|
|
1358
|
-
if (state.tokens > 0) {
|
|
1359
|
-
state.tokens--;
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
const tokensNeeded = 1;
|
|
1363
|
-
const waitMs = Math.ceil(
|
|
1364
|
-
tokensNeeded / state.config.refillRate * state.config.refillIntervalMs
|
|
1365
|
-
);
|
|
1366
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
1367
|
-
const afterWait = Date.now();
|
|
1368
|
-
const totalElapsed = afterWait - state.lastRefillTime;
|
|
1369
|
-
const newTokens = Math.floor(
|
|
1370
|
-
totalElapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1371
|
-
);
|
|
1372
|
-
state.tokens = Math.min(state.config.maxTokens, newTokens) - 1;
|
|
1373
|
-
state.lastRefillTime = afterWait;
|
|
1374
|
-
}
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
1754
|
// src/paginate.ts
|
|
1379
1755
|
async function* paginate(fetchPage, options) {
|
|
1380
1756
|
let pageToken = options?.startPageToken;
|
|
@@ -1429,6 +1805,7 @@ async function paginateParallel(fetchPage, pageTokens, concurrency = 4) {
|
|
|
1429
1805
|
export {
|
|
1430
1806
|
PlayApiError,
|
|
1431
1807
|
RATE_LIMIT_BUCKETS,
|
|
1808
|
+
RESUMABLE_THRESHOLD,
|
|
1432
1809
|
createApiClient,
|
|
1433
1810
|
createEnterpriseClient,
|
|
1434
1811
|
createGamesClient,
|