@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 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
- async function jsonRequest(url, init) {
8
- let res;
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
- res = await fetch(url, init);
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
- let res;
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);
@@ -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 worker base URL: explicit flag > UPLOADS_AUTH_URL > swap an `api.` host
21
- * label for `auth.` > the production default. Local multi-worker dev (where
22
- * auth runs on a different loopback port than the API) needs an explicit
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 {
@@ -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://auth.uploads.sh)
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 worker base URL: explicit flag > UPLOADS_AUTH_URL > swap an `api.` host
164
- * label for `auth.` > the production default. Local multi-worker dev (where
165
- * auth runs on a different loopback port than the API) needs an explicit
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
- /** Derive auth origin from an API base (`api.` → `auth.`), else production default. */
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
- /** Derive auth origin from an API base (`api.` → `auth.`), else production default. */
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 = `auth.${url.hostname.slice(4)}`;
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://auth.uploads.sh";
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://auth.uploads.sh")).replace(/\/$/, "");
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.1",
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.4",
51
+ "files-sdk": "^2.2.5",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },