@buildinternet/uploads 0.46.1 → 0.46.3
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/client.js +95 -15
- package/dist/commands/login.d.ts +4 -4
- package/dist/commands/login.js +5 -5
- package/dist/config.d.ts +8 -1
- package/dist/config.js +10 -3
- package/dist/session-cli-version.js +1 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -4,14 +4,104 @@ import { UploadsError } from "./errors.js";
|
|
|
4
4
|
import { buildScreenshotKey } from "./keys.js";
|
|
5
5
|
import { packageVersion } from "./package-version.js";
|
|
6
6
|
import { resolveEmbedUrl } from "./public-urls.js";
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// --- Request resilience (issue #809) ---------------------------------------
|
|
8
|
+
//
|
|
9
|
+
// Bare `fetch` has no timeout — undici's default header timeout is ~5
|
|
10
|
+
// minutes, so a backend stall (see the 2026-08-23 D1 stall incident, #808)
|
|
11
|
+
// hangs the CLI silently for that whole window instead of failing fast like
|
|
12
|
+
// the workers now do (#805-#807). Every core API call routes through
|
|
13
|
+
// `resilientFetch` for a bounded timeout and a single bounded retry.
|
|
14
|
+
//
|
|
15
|
+
// Timeouts: short for JSON control calls, longer for the one content-bytes
|
|
16
|
+
// call (file `put`), matching the side-channel calls' pattern (telemetry.ts,
|
|
17
|
+
// update-check.ts already carry AbortController timeouts — this closes the
|
|
18
|
+
// gap on the core path).
|
|
19
|
+
const JSON_TIMEOUT_MS = 15_000;
|
|
20
|
+
const CONTENT_TIMEOUT_MS = 60_000;
|
|
21
|
+
// At most one retry. Retried on network errors, 503, and 429 — and only for
|
|
22
|
+
// GET/PUT, since a `put` is byte-idempotent (same key, same bytes) but a
|
|
23
|
+
// POST/DELETE/PATCH might not be, and telling "no bytes sent" apart from "the
|
|
24
|
+
// mutation already landed" isn't reliable enough to risk a double-apply.
|
|
25
|
+
const MAX_ATTEMPTS = 2;
|
|
26
|
+
const RETRYABLE_METHODS = new Set(["GET", "PUT"]);
|
|
27
|
+
const RETRYABLE_STATUSES = new Set([429, 503]);
|
|
28
|
+
const DEFAULT_RETRY_DELAY_MS = 2_000;
|
|
29
|
+
// Cap an honored X-Retry-After so a large server-suggested backoff can't
|
|
30
|
+
// stall a hook/CI step for minutes.
|
|
31
|
+
const MAX_RETRY_AFTER_DELAY_MS = 10_000;
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
/** Seconds-valued retry delay from the response, capped; falls back to the default backoff. */
|
|
36
|
+
function retryDelayMs(res) {
|
|
37
|
+
const raw = res?.headers.get("x-retry-after") ?? res?.headers.get("retry-after");
|
|
38
|
+
const seconds = raw ? Number(raw) : NaN;
|
|
39
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
40
|
+
return Math.min(seconds * 1000, MAX_RETRY_AFTER_DELAY_MS);
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_RETRY_DELAY_MS;
|
|
43
|
+
}
|
|
44
|
+
/** One-line stderr notice so a hook/CI log explains the pause (issue #809). */
|
|
45
|
+
function printRetryNotice(label, delayMs) {
|
|
46
|
+
const seconds = delayMs % 1000 === 0 ? `${delayMs / 1000}s` : `${Math.round(delayMs) / 1000}s`;
|
|
47
|
+
process.stderr.write(`warning: uploads.sh ${label}, retrying in ${seconds}…\n`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* fetch with a per-request AbortController timeout. A timeout (or any other
|
|
51
|
+
* fetch rejection) surfaces as `UploadsError` code `NETWORK`, naming the
|
|
52
|
+
* timeout so hook/CI logs are diagnosable.
|
|
53
|
+
*/
|
|
54
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
57
|
try {
|
|
10
|
-
|
|
58
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
11
59
|
}
|
|
12
60
|
catch (err) {
|
|
61
|
+
if (controller.signal.aborted) {
|
|
62
|
+
throw new UploadsError(`request timed out after ${timeoutMs}ms`, "NETWORK");
|
|
63
|
+
}
|
|
13
64
|
throw new UploadsError(err instanceof Error ? err.message : "network request failed", "NETWORK");
|
|
14
65
|
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The shared resilience core for both `jsonRequest` and
|
|
72
|
+
* `createUploadsClient`'s `request`: bounded timeout + bounded retry on
|
|
73
|
+
* network errors, 503, and 429, honoring `X-Retry-After` when present.
|
|
74
|
+
* Never retries a non-idempotent method (see `RETRYABLE_METHODS` above).
|
|
75
|
+
* Returns the raw `Response` on the final attempt — callers still handle
|
|
76
|
+
* `!res.ok` themselves via `parseErrorResponse`.
|
|
77
|
+
*/
|
|
78
|
+
async function resilientFetch(method, url, init, timeoutMs) {
|
|
79
|
+
const retryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
80
|
+
for (let attempt = 1;; attempt++) {
|
|
81
|
+
let res;
|
|
82
|
+
let networkErr;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetchWithTimeout(url, init, timeoutMs);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
networkErr = err;
|
|
88
|
+
}
|
|
89
|
+
const canRetry = retryable &&
|
|
90
|
+
attempt < MAX_ATTEMPTS &&
|
|
91
|
+
(networkErr !== undefined || (res !== undefined && RETRYABLE_STATUSES.has(res.status)));
|
|
92
|
+
if (!canRetry) {
|
|
93
|
+
if (networkErr)
|
|
94
|
+
throw networkErr;
|
|
95
|
+
return res;
|
|
96
|
+
}
|
|
97
|
+
const delayMs = retryDelayMs(res);
|
|
98
|
+
printRetryNotice(res ? `responded ${res.status}` : "request failed", delayMs);
|
|
99
|
+
await sleep(delayMs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function jsonRequest(url, init) {
|
|
103
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
104
|
+
const res = await resilientFetch(method, url, init, JSON_TIMEOUT_MS);
|
|
15
105
|
if (!res.ok)
|
|
16
106
|
throw await parseErrorResponse(res);
|
|
17
107
|
return (await res.json());
|
|
@@ -280,18 +370,7 @@ export function createUploadsClient(config) {
|
|
|
280
370
|
if (opts?.auth !== false) {
|
|
281
371
|
headers.Authorization = `Bearer ${config.token}`;
|
|
282
372
|
}
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
res = await fetch(path, {
|
|
286
|
-
method,
|
|
287
|
-
headers,
|
|
288
|
-
body: opts?.body,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
const message = err instanceof Error ? err.message : "network request failed";
|
|
293
|
-
throw new UploadsError(message, "NETWORK");
|
|
294
|
-
}
|
|
373
|
+
const res = await resilientFetch(method, path, { method, headers, body: opts?.body }, opts?.longTimeout ? CONTENT_TIMEOUT_MS : JSON_TIMEOUT_MS);
|
|
295
374
|
if (!res.ok) {
|
|
296
375
|
throw await parseErrorResponse(res);
|
|
297
376
|
}
|
|
@@ -383,6 +462,7 @@ export function createUploadsClient(config) {
|
|
|
383
462
|
const result = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
|
|
384
463
|
body,
|
|
385
464
|
headers,
|
|
465
|
+
longTimeout: true,
|
|
386
466
|
});
|
|
387
467
|
if (result.url == null) {
|
|
388
468
|
throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -17,10 +17,10 @@ export declare function resolveEnrollmentCode(parsed: ReturnType<typeof parseCom
|
|
|
17
17
|
hiddenPrompt: () => Promise<string>;
|
|
18
18
|
}): Promise<string>;
|
|
19
19
|
/**
|
|
20
|
-
* Auth
|
|
21
|
-
*
|
|
22
|
-
* auth runs on a different loopback port
|
|
23
|
-
* --auth-url / UPLOADS_AUTH_URL.
|
|
20
|
+
* Auth base URL: explicit flag > UPLOADS_AUTH_URL > derived from the API base
|
|
21
|
+
* (`api.<domain>` → `<domain>`, same-origin auth since #731) > the production
|
|
22
|
+
* default. Local multi-worker dev (where auth runs on a different loopback port
|
|
23
|
+
* than the API) needs an explicit --auth-url / UPLOADS_AUTH_URL.
|
|
24
24
|
*/
|
|
25
25
|
export declare function resolveAuthUrl(parsed: ReturnType<typeof parseCommandArgs>, apiUrl: string): string;
|
|
26
26
|
export interface DeviceLoginIo {
|
package/dist/commands/login.js
CHANGED
|
@@ -25,7 +25,7 @@ Options:
|
|
|
25
25
|
--scopes <list> Comma-separated scopes (default:
|
|
26
26
|
files:read,files:write,files:delete)
|
|
27
27
|
--label <text> Token label (default: this machine's hostname)
|
|
28
|
-
--auth-url <url> Auth base (default: https://
|
|
28
|
+
--auth-url <url> Auth base (default: https://uploads.sh)
|
|
29
29
|
--no-open Don't try to open a browser automatically
|
|
30
30
|
--code <code> Fallback: use a pre-existing enrollment code instead of
|
|
31
31
|
device login (visible in shell history)
|
|
@@ -160,10 +160,10 @@ function hasEnrollmentSource(parsed) {
|
|
|
160
160
|
Boolean(process.env.UPLOADS_ENROLLMENT_CODE));
|
|
161
161
|
}
|
|
162
162
|
/**
|
|
163
|
-
* Auth
|
|
164
|
-
*
|
|
165
|
-
* auth runs on a different loopback port
|
|
166
|
-
* --auth-url / UPLOADS_AUTH_URL.
|
|
163
|
+
* Auth base URL: explicit flag > UPLOADS_AUTH_URL > derived from the API base
|
|
164
|
+
* (`api.<domain>` → `<domain>`, same-origin auth since #731) > the production
|
|
165
|
+
* default. Local multi-worker dev (where auth runs on a different loopback port
|
|
166
|
+
* than the API) needs an explicit --auth-url / UPLOADS_AUTH_URL.
|
|
167
167
|
*/
|
|
168
168
|
export function resolveAuthUrl(parsed, apiUrl) {
|
|
169
169
|
const explicit = flagString(parsed.flags, "--auth-url") ?? process.env.UPLOADS_AUTH_URL;
|
package/dist/config.d.ts
CHANGED
|
@@ -4,7 +4,14 @@ export interface UploadsClientConfig {
|
|
|
4
4
|
workspace: string;
|
|
5
5
|
token: string;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Derive the auth base URL from an API base. Since #731 the auth endpoints are
|
|
9
|
+
* served same-origin on the app origin (`<origin>/api/auth`, web proxies to the
|
|
10
|
+
* auth worker), not an `auth.` subdomain — so the canonical `api.<domain>`
|
|
11
|
+
* shape maps to its parent (`api.uploads.sh` → `uploads.sh`). Falls back to the
|
|
12
|
+
* production origin. Local multi-worker dev, where auth listens on a different
|
|
13
|
+
* loopback port, still needs an explicit `--auth-url` / `UPLOADS_AUTH_URL`.
|
|
14
|
+
*/
|
|
8
15
|
export declare function authUrlFromApi(apiUrl: string): string;
|
|
9
16
|
export declare const DEFAULT_API_URL = "https://api.uploads.sh";
|
|
10
17
|
export declare const DEFAULT_WORKSPACE = "default";
|
package/dist/config.js
CHANGED
|
@@ -2,19 +2,26 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { loadConfigFile, resolveConfigPath } from "./config-file.js";
|
|
3
3
|
import { UploadsError } from "./errors.js";
|
|
4
4
|
export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, removeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, resolveScreenshotDefaults, UPLOADS_CONFIG_KEYS, } from "./config-file.js";
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Derive the auth base URL from an API base. Since #731 the auth endpoints are
|
|
7
|
+
* served same-origin on the app origin (`<origin>/api/auth`, web proxies to the
|
|
8
|
+
* auth worker), not an `auth.` subdomain — so the canonical `api.<domain>`
|
|
9
|
+
* shape maps to its parent (`api.uploads.sh` → `uploads.sh`). Falls back to the
|
|
10
|
+
* production origin. Local multi-worker dev, where auth listens on a different
|
|
11
|
+
* loopback port, still needs an explicit `--auth-url` / `UPLOADS_AUTH_URL`.
|
|
12
|
+
*/
|
|
6
13
|
export function authUrlFromApi(apiUrl) {
|
|
7
14
|
try {
|
|
8
15
|
const url = new URL(apiUrl);
|
|
9
16
|
if (url.hostname.startsWith("api.")) {
|
|
10
|
-
url.hostname =
|
|
17
|
+
url.hostname = url.hostname.slice(4);
|
|
11
18
|
return url.origin;
|
|
12
19
|
}
|
|
13
20
|
}
|
|
14
21
|
catch {
|
|
15
22
|
// fall through
|
|
16
23
|
}
|
|
17
|
-
return "https://
|
|
24
|
+
return "https://uploads.sh";
|
|
18
25
|
}
|
|
19
26
|
export const DEFAULT_API_URL = "https://api.uploads.sh";
|
|
20
27
|
export const DEFAULT_WORKSPACE = "default";
|
|
@@ -47,7 +47,7 @@ export async function syncSessionCliVersion(opts = {}) {
|
|
|
47
47
|
const apiUrl = opts.apiUrl ?? fromFile.UPLOADS_API_URL ?? process.env.UPLOADS_API_URL;
|
|
48
48
|
const authUrl = (opts.authUrl ??
|
|
49
49
|
process.env.UPLOADS_AUTH_URL ??
|
|
50
|
-
(apiUrl ? authUrlFromApi(apiUrl) : "https://
|
|
50
|
+
(apiUrl ? authUrlFromApi(apiUrl) : "https://uploads.sh")).replace(/\/$/, "");
|
|
51
51
|
const controller = new AbortController();
|
|
52
52
|
const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
|
|
53
53
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.3",
|
|
4
4
|
"description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^26.1.0",
|
|
50
50
|
"ai": "^6.0.0",
|
|
51
|
-
"files-sdk": "^2.2.
|
|
51
|
+
"files-sdk": "^2.2.5",
|
|
52
52
|
"typescript": "^7.0.2",
|
|
53
53
|
"vitest": "^4.1.10"
|
|
54
54
|
},
|