drop2run 0.6.0 → 0.7.0

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/README.md CHANGED
@@ -82,7 +82,7 @@ server on `127.0.0.1`, which is what keeps it out of clipboards and scrollback.
82
82
 
83
83
  ### `--device`, for a machine with no browser
84
84
 
85
- `drop2run login --device` prints a short code and waits. Enter it at <https://dropto.run/device> from any
85
+ `drop2run login --device` prints a short code and waits. Enter it at <https://app.dropto.run/device> from any
86
86
  machine you are already signed in on — a phone will do — and the terminal picks up its token.
87
87
 
88
88
  The short code is not the credential; it names the pending request, so somebody reading it over your
@@ -92,7 +92,7 @@ works once.
92
92
  ### CI
93
93
 
94
94
  Neither flow works in CI — nothing there can open a browser or approve anything. Create a token at
95
- <https://dropto.run/account/tokens> and set it in the environment:
95
+ <https://app.dropto.run/account/tokens> and set it in the environment:
96
96
 
97
97
  ```
98
98
  DROP2RUN_TOKEN=d2r_...
@@ -110,7 +110,7 @@ account" is answerable in an issue report or a CI log.
110
110
 
111
111
  Both need a browser session, and that is deliberate rather than missing. A token that can mint tokens is
112
112
  not a leaked credential but a permanent one: whoever takes it makes a second, and revoking the first
113
- changes nothing. Make and revoke tokens at <https://dropto.run/account/tokens>.
113
+ changes nothing. Make and revoke tokens at <https://app.dropto.run/account/tokens>.
114
114
 
115
115
  `token list` does exist and answers what a terminal can answer — which machines hold a credential, and
116
116
  which of them has not used it since it was made. It prints prefixes, never secrets.
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ function configPath() {
10
10
  return join(homedir(), ".config", "drop2run", "config.json");
11
11
  }
12
12
  const DEFAULT_API_BASE_URL = "https://dropto.run/api";
13
+ const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
14
+ const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
13
15
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
14
16
  let stored = {};
15
17
  try {
@@ -33,7 +35,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
33
35
  return url.replace(/\/+$/, "");
34
36
  }
35
37
  function dashboardUrlFor(apiBaseUrl) {
36
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
38
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
39
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
37
40
  }
38
41
  function saveToken(token, path = configPath()) {
39
42
  let existing = {};
@@ -76,14 +79,14 @@ function missingCredentialsMessage(surface = "cli") {
76
79
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
77
80
  const byHand = surface === "mcp" ? [
78
81
  "To set one without signing in here, create a token at",
79
- "https://dropto.run/account/tokens and either:",
82
+ "https://app.dropto.run/account/tokens and either:",
80
83
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
81
84
  " server reads on every call and needs no restart, or",
82
85
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
83
86
  " the environment it was started in."
84
87
  ] : [
85
88
  "For CI, or where no browser can be opened, create a token at",
86
- "https://dropto.run/account/tokens and either:",
89
+ "https://app.dropto.run/account/tokens and either:",
87
90
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
88
91
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
89
92
  ];
@@ -409,6 +412,12 @@ function writeProject(project, directory = process.cwd()) {
409
412
  `);
410
413
  return path;
411
414
  }
415
+ function sizeOf(content) {
416
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
417
+ }
418
+ async function bytesOf(content) {
419
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
420
+ }
412
421
  class DeployError extends Error {
413
422
  /**
414
423
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -445,20 +454,55 @@ const ClientErrorCode = {
445
454
  /** The user cancelled. */
446
455
  Cancelled: "cancelled",
447
456
  /** An upload kept failing after every retry. */
448
- UploadFailed: "upload_failed"
457
+ UploadFailed: "upload_failed",
458
+ /**
459
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
460
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
461
+ * names memory.
462
+ */
463
+ OutOfMemory: "out_of_memory",
464
+ /**
465
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
466
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
467
+ */
468
+ ReadFailed: "read_failed",
469
+ /**
470
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
471
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
472
+ * about whether the server acted, which is why only an idempotent call may retry on it.
473
+ */
474
+ NetworkFailed: "network_failed"
449
475
  };
476
+ function describeError(error) {
477
+ const parts = [];
478
+ let current = error;
479
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
480
+ if (current instanceof Error) {
481
+ const code = current.code;
482
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
483
+ current = current.cause;
484
+ } else {
485
+ parts.push(String(current));
486
+ break;
487
+ }
488
+ }
489
+ return parts.join(" ← ");
490
+ }
450
491
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
451
492
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
493
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
494
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
452
495
  async function prepareDeploy(siteId, files, options, signal) {
453
496
  const manifest = files.map((file) => ({
454
497
  path: file.path,
455
498
  sha256: file.sha256,
456
- size: file.bytes.length
499
+ size: sizeOf(file.bytes)
457
500
  }));
458
501
  const prepared = await request(
459
502
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
460
503
  { files: manifest },
461
- options
504
+ options,
505
+ signal
462
506
  );
463
507
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
464
508
  }
@@ -468,31 +512,65 @@ function absolute(uploadUrl, baseUrl) {
468
512
  return new URL(uploadUrl, baseUrl).toString();
469
513
  }
470
514
  async function completeDeploy(siteId, deployId, options, signal, name) {
471
- return request(
472
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
473
- // A bodyless POST when there is no name to send, which is what this call has always been. The
474
- // server treats an absent body and an absent field identically, so nothing depends on which of
475
- // the two a client picks.
476
- name === null || name === void 0 ? void 0 : { name },
477
- options
478
- );
515
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
516
+ const body = name === null || name === void 0 ? void 0 : { name };
517
+ const delay = options.delay ?? wait;
518
+ for (let attempt = 0; ; attempt++) {
519
+ try {
520
+ return await request(path, body, options, signal);
521
+ } catch (error) {
522
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
523
+ if (!unanswered || attempt >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
524
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt] ?? 1e3);
525
+ }
526
+ }
527
+ }
528
+ function wait(ms) {
529
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
479
530
  }
480
531
  async function request(path, body, options, signal) {
481
532
  const doFetch = options.fetch ?? globalFetch$1();
482
533
  const base = options.baseUrl ?? "/api";
534
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
483
535
  const headers = {};
484
536
  if (body !== void 0) headers["Content-Type"] = "application/json";
485
537
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
486
538
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
487
539
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
488
- const response = await doFetch(`${base}/${path}`, {
489
- method: "POST",
490
- headers,
491
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
492
- ...{}
493
- });
494
- if (!response.ok) throw await problemToError(response);
495
- return await response.json();
540
+ const controller = new AbortController();
541
+ let timedOut = false;
542
+ const timer = setTimeout(() => {
543
+ timedOut = true;
544
+ controller.abort();
545
+ }, timeoutMs);
546
+ try {
547
+ const response = await doFetch(`${base}/${path}`, {
548
+ method: "POST",
549
+ headers,
550
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
551
+ signal: controller.signal
552
+ });
553
+ if (!response.ok) throw await problemToError(response);
554
+ return await response.json();
555
+ } catch (error) {
556
+ if (error instanceof DeployError || signal?.aborted) throw error;
557
+ if (timedOut) {
558
+ throw new DeployError(
559
+ ClientErrorCode.NetworkFailed,
560
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
561
+ { path, cause: describeError(error) }
562
+ );
563
+ }
564
+ if (error instanceof TypeError) {
565
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
566
+ path,
567
+ cause: describeError(error)
568
+ });
569
+ }
570
+ throw error;
571
+ } finally {
572
+ clearTimeout(timer);
573
+ }
496
574
  }
497
575
  function globalFetch$1() {
498
576
  return globalThis.fetch.bind(globalThis);
@@ -991,17 +1069,29 @@ function extensionOf(path) {
991
1069
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
992
1070
  }
993
1071
  async function sha256Hex(bytes) {
994
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1072
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
995
1073
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
996
1074
  }
997
1075
  async function hashAll(files, onProgress, signal) {
998
1076
  const hashed = [];
999
1077
  for (const [index, file] of files.entries()) {
1000
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1078
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
1001
1079
  onProgress?.(index + 1, files.length);
1002
1080
  }
1003
1081
  return hashed;
1004
1082
  }
1083
+ async function readForHash(file) {
1084
+ try {
1085
+ return await bytesOf(file.bytes);
1086
+ } catch (error) {
1087
+ if (error instanceof RangeError) throw error;
1088
+ throw new DeployError(
1089
+ ClientErrorCode.ReadFailed,
1090
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1091
+ { path: file.path, cause: describeError(error) }
1092
+ );
1093
+ }
1094
+ }
1005
1095
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
1006
1096
  const IGNORED_DIRECTORIES = [
1007
1097
  "__MACOSX",
@@ -1011,7 +1101,11 @@ const IGNORED_DIRECTORIES = [
1011
1101
  ".hg",
1012
1102
  ".idea",
1013
1103
  ".vscode",
1014
- ".claude"
1104
+ ".claude",
1105
+ ".agents",
1106
+ ".cursor",
1107
+ ".codex",
1108
+ ".gemini"
1015
1109
  ];
1016
1110
  function shouldIgnore(path) {
1017
1111
  const segments = path.split("/");
@@ -1073,7 +1167,7 @@ function checkLimits(files, limits) {
1073
1167
  );
1074
1168
  }
1075
1169
  const refusal = sizeRefusal(
1076
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1170
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1077
1171
  limits
1078
1172
  );
1079
1173
  if (refusal) throw refusal;
@@ -1114,13 +1208,24 @@ const HEAD_BYTES = 64 * 1024;
1114
1208
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1115
1209
  function suggestSiteName(files) {
1116
1210
  const index = files.find((file) => file.path === "index.html");
1117
- if (index === void 0) return null;
1211
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1118
1212
  try {
1119
1213
  return nameFromHtml(decodeHead(index.bytes));
1120
1214
  } catch {
1121
1215
  return null;
1122
1216
  }
1123
1217
  }
1218
+ async function readSiteName(files) {
1219
+ const index = files.find((file) => file.path === "index.html");
1220
+ if (index === void 0) return null;
1221
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1222
+ try {
1223
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1224
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1124
1229
  function decodeHead(bytes) {
1125
1230
  return new TextDecoder("utf-8", { fatal: false }).decode(
1126
1231
  bytes.subarray(0, HEAD_BYTES)
@@ -1152,7 +1257,7 @@ function matchTag(html, tag) {
1152
1257
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1153
1258
  html
1154
1259
  );
1155
- return match?.[1] ?? null;
1260
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1156
1261
  }
1157
1262
  function matchOgSiteName(html) {
1158
1263
  const patterns = [
@@ -1161,10 +1266,56 @@ function matchOgSiteName(html) {
1161
1266
  ];
1162
1267
  for (const pattern of patterns) {
1163
1268
  const match = pattern.exec(html);
1164
- if (match?.[1] !== void 0) return match[1];
1269
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1165
1270
  }
1166
1271
  return null;
1167
1272
  }
1273
+ const LATIN1_ENTITIES = "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml".split(" ");
1274
+ const NAMED_ENTITIES = new Map([
1275
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1276
+ ["amp", 38],
1277
+ ["lt", 60],
1278
+ ["gt", 62],
1279
+ ["quot", 34],
1280
+ ["apos", 39],
1281
+ ["ensp", 8194],
1282
+ ["emsp", 8195],
1283
+ ["thinsp", 8201],
1284
+ ["ndash", 8211],
1285
+ ["mdash", 8212],
1286
+ ["lsquo", 8216],
1287
+ ["rsquo", 8217],
1288
+ ["sbquo", 8218],
1289
+ ["ldquo", 8220],
1290
+ ["rdquo", 8221],
1291
+ ["bdquo", 8222],
1292
+ ["dagger", 8224],
1293
+ ["Dagger", 8225],
1294
+ ["bull", 8226],
1295
+ ["hellip", 8230],
1296
+ ["prime", 8242],
1297
+ ["lsaquo", 8249],
1298
+ ["rsaquo", 8250],
1299
+ ["euro", 8364],
1300
+ ["trade", 8482],
1301
+ ["larr", 8592],
1302
+ ["rarr", 8594],
1303
+ ["hearts", 9829]
1304
+ ]);
1305
+ function decodeEntities(text) {
1306
+ return text.replace(
1307
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1308
+ (whole, decimal, hex, name) => {
1309
+ if (name !== void 0) {
1310
+ const code2 = NAMED_ENTITIES.get(name);
1311
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1312
+ }
1313
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1314
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1315
+ return String.fromCodePoint(valid ? code : 65533);
1316
+ }
1317
+ );
1318
+ }
1168
1319
  const INVISIBLE = new RegExp(
1169
1320
  `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}${String.fromCharCode(8203)}-${String.fromCharCode(8207)}${String.fromCharCode(8232)}-${String.fromCharCode(8238)}${String.fromCharCode(8288)}-${String.fromCharCode(8292)}${String.fromCharCode(8294)}-${String.fromCharCode(8303)}${String.fromCharCode(65279)}]`,
1170
1321
  "g"
@@ -1206,46 +1357,105 @@ function trimSuffix(title) {
1206
1357
  return title;
1207
1358
  }
1208
1359
  const DEFAULT_CONCURRENCY = 8;
1360
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1209
1361
  const DEFAULT_RETRIES = 3;
1210
1362
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1211
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1212
- const doFetch = deps.fetch ?? globalFetch();
1363
+ const DEFAULT_STALL_MS = 3e4;
1364
+ const PROGRESS_INTERVAL_MS = 200;
1365
+ class StalledError extends Error {
1366
+ /** @param seconds How long nothing moved. */
1367
+ constructor(seconds) {
1368
+ super(`No data moved for ${seconds}s.`);
1369
+ this.name = "StalledError";
1370
+ }
1371
+ }
1372
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1373
+ const put = deps.put ?? defaultTransport(deps.fetch);
1213
1374
  const delay = deps.delay ?? defaultDelay;
1214
1375
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1215
1376
  const retries = deps.retries ?? DEFAULT_RETRIES;
1377
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1378
+ const now = deps.now ?? Date.now;
1379
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1216
1380
  const byPath = new Map(files.map((file) => [file.path, file]));
1217
1381
  let nextIndex = 0;
1218
1382
  let done = 0;
1219
- let uploadedBytes = 0;
1383
+ let failed = false;
1384
+ let reserved = 0;
1385
+ let waiting = [];
1386
+ const acquire = async (size) => {
1387
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1388
+ await new Promise((resolve2) => waiting.push(resolve2));
1389
+ }
1390
+ reserved += size;
1391
+ };
1392
+ const release = (size) => {
1393
+ reserved -= size;
1394
+ const woken = waiting;
1395
+ waiting = [];
1396
+ for (const wake of woken) wake();
1397
+ };
1398
+ let landedBytes = 0;
1399
+ const inFlight = /* @__PURE__ */ new Map();
1400
+ let lastReport = Number.NEGATIVE_INFINITY;
1401
+ const report = (path) => {
1402
+ if (onProgress === void 0) return;
1403
+ const at = now();
1404
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1405
+ lastReport = at;
1406
+ let bytes = landedBytes;
1407
+ for (const sent of inFlight.values()) bytes += sent;
1408
+ onProgress(done, targets.length, bytes, path);
1409
+ };
1220
1410
  const worker = async () => {
1221
1411
  for (; ; ) {
1412
+ if (failed) return;
1222
1413
  const index = nextIndex++;
1223
1414
  const target = targets[index];
1224
1415
  if (target === void 0) return;
1225
1416
  const file = byPath.get(target.path);
1226
1417
  if (file === void 0) {
1418
+ failed = true;
1227
1419
  throw new DeployError(
1228
1420
  ClientErrorCode.UploadFailed,
1229
1421
  `The server asked for ${target.path}, which is not in the manifest.`,
1230
1422
  { path: target.path }
1231
1423
  );
1232
1424
  }
1233
- await uploadOne(target, file, {
1234
- doFetch,
1235
- delay,
1236
- retries,
1237
- signal,
1238
- uploadUrl
1239
- });
1425
+ const size = sizeOf(file.bytes);
1426
+ await acquire(size);
1427
+ try {
1428
+ if (failed) return;
1429
+ signal?.throwIfAborted();
1430
+ await uploadOne(target, file, {
1431
+ put,
1432
+ delay,
1433
+ retries,
1434
+ signal,
1435
+ uploadUrl,
1436
+ stallMs,
1437
+ onSent: (sent) => {
1438
+ inFlight.set(index, sent);
1439
+ report();
1440
+ },
1441
+ onRetry: (attempt, stalled) => {
1442
+ inFlight.set(index, 0);
1443
+ onRetry?.(target.path, attempt, retries + 1, stalled);
1444
+ }
1445
+ });
1446
+ } catch (error) {
1447
+ failed = true;
1448
+ throw error;
1449
+ } finally {
1450
+ release(size);
1451
+ }
1452
+ inFlight.delete(index);
1240
1453
  done += 1;
1241
- uploadedBytes += file.bytes.length;
1242
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1454
+ landedBytes += size;
1455
+ report(file.path);
1243
1456
  }
1244
1457
  };
1245
- const workers = Array.from(
1246
- { length: Math.min(concurrency, targets.length) },
1247
- worker
1248
- );
1458
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1249
1459
  try {
1250
1460
  await Promise.all(workers);
1251
1461
  } catch (error) {
@@ -1257,39 +1467,122 @@ async function uploadOne(target, file, context) {
1257
1467
  for (let attempt = 0; attempt <= context.retries; attempt++) {
1258
1468
  context.signal?.throwIfAborted();
1259
1469
  if (attempt > 0) {
1470
+ context.onRetry(attempt + 1, lastError instanceof StalledError);
1260
1471
  await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1261
1472
  context.signal?.throwIfAborted();
1262
1473
  }
1263
1474
  try {
1264
- const response = await context.doFetch(context.uploadUrl, {
1265
- method: "PUT",
1266
- body: new Blob([file.bytes.slice().buffer]),
1267
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1268
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1269
- // which is exactly the length the token names.
1270
- headers: { Authorization: `Bearer ${target.token}` },
1271
- ...context.signal ? { signal: context.signal } : {}
1475
+ const status = await context.put({
1476
+ url: context.uploadUrl,
1477
+ token: target.token,
1478
+ body: untyped(file.bytes),
1479
+ signal: context.signal,
1480
+ onSent: context.onSent,
1481
+ stallMs: context.stallMs
1272
1482
  });
1273
- if (response.ok) return;
1274
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1483
+ if (status >= 200 && status < 300) return;
1484
+ if (status === 401 || status === 403 || status === 429) {
1275
1485
  throw new DeployError(
1276
1486
  ClientErrorCode.UploadFailed,
1277
1487
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1278
- { path: target.path, status: response.status }
1488
+ { path: target.path, status }
1279
1489
  );
1280
1490
  }
1281
- lastError = new Error(`HTTP ${response.status}`);
1491
+ if (status === 413) {
1492
+ throw new DeployError(
1493
+ ClientErrorCode.UploadFailed,
1494
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1495
+ { path: target.path, status }
1496
+ );
1497
+ }
1498
+ lastError = new Error(`HTTP ${status}`);
1282
1499
  } catch (error) {
1283
1500
  if (error instanceof DeployError) throw error;
1284
1501
  if (isAbort(error)) throw cancelled();
1285
1502
  lastError = error;
1286
1503
  }
1287
1504
  }
1288
- throw new DeployError(
1289
- ClientErrorCode.UploadFailed,
1290
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1291
- { path: target.path, cause: String(lastError) }
1292
- );
1505
+ const message = lastError instanceof StalledError ? `Upload of ${target.path} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${target.path} after ${context.retries + 1} attempts.`;
1506
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1507
+ path: target.path,
1508
+ cause: describeError(lastError)
1509
+ });
1510
+ }
1511
+ function untyped(content) {
1512
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1513
+ return content.slice(0, content.size);
1514
+ }
1515
+ function defaultTransport(doFetch) {
1516
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1517
+ const Xhr = globalThis.XMLHttpRequest;
1518
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1519
+ }
1520
+ function fetchTransport(doFetch) {
1521
+ return async ({ url, token, body, signal }) => {
1522
+ const response = await doFetch(url, {
1523
+ method: "PUT",
1524
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1525
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1526
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1527
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1528
+ // streams it from disk.
1529
+ body,
1530
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1531
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1532
+ // is exactly the length the token names.
1533
+ headers: { Authorization: `Bearer ${token}` },
1534
+ ...signal ? { signal } : {}
1535
+ });
1536
+ return response.status;
1537
+ };
1538
+ }
1539
+ function xhrTransport(Xhr) {
1540
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve2, reject) => {
1541
+ const xhr = new Xhr();
1542
+ let settled = false;
1543
+ let timer;
1544
+ const settle = (finish) => {
1545
+ if (settled) return;
1546
+ settled = true;
1547
+ clearTimeout(timer);
1548
+ signal?.removeEventListener("abort", onAbort);
1549
+ finish();
1550
+ };
1551
+ const arm = () => {
1552
+ clearTimeout(timer);
1553
+ timer = setTimeout(() => {
1554
+ xhr.abort();
1555
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1556
+ }, stallMs);
1557
+ };
1558
+ function onAbort() {
1559
+ xhr.abort();
1560
+ settle(() => reject(abortReason(signal)));
1561
+ }
1562
+ if (signal?.aborted) {
1563
+ reject(abortReason(signal));
1564
+ return;
1565
+ }
1566
+ signal?.addEventListener("abort", onAbort, { once: true });
1567
+ xhr.open("PUT", url);
1568
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1569
+ xhr.upload.onprogress = (event) => {
1570
+ arm();
1571
+ onSent(event.loaded);
1572
+ };
1573
+ xhr.onprogress = arm;
1574
+ xhr.onload = () => settle(() => resolve2(xhr.status));
1575
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1576
+ arm();
1577
+ xhr.send(body);
1578
+ });
1579
+ }
1580
+ function abortReason(signal) {
1581
+ const reason = signal?.reason;
1582
+ if (reason instanceof Error) return reason;
1583
+ const error = new Error("Aborted.");
1584
+ error.name = "AbortError";
1585
+ return error;
1293
1586
  }
1294
1587
  function globalFetch() {
1295
1588
  return globalThis.fetch.bind(globalThis);
@@ -1303,9 +1596,17 @@ function isAbort(error) {
1303
1596
  function cancelled() {
1304
1597
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1305
1598
  }
1599
+ function outOfMemory(detail) {
1600
+ return new DeployError(
1601
+ ClientErrorCode.OutOfMemory,
1602
+ "This browser ran out of memory reading what you dropped. Drop the unzipped folder instead, or try again in a desktop browser with fewer tabs open.",
1603
+ detail
1604
+ );
1605
+ }
1306
1606
  function asDeployError(error) {
1307
1607
  if (error instanceof DeployError) return error;
1308
1608
  if (isAbort(error)) return cancelled();
1609
+ if (error instanceof RangeError) return outOfMemory(error.message);
1309
1610
  return new DeployError(
1310
1611
  ClientErrorCode.UploadFailed,
1311
1612
  error instanceof Error ? error.message : "Upload failed.",
@@ -1314,7 +1615,11 @@ function asDeployError(error) {
1314
1615
  }
1315
1616
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1316
1617
  try {
1317
- const collected = await source(signal);
1618
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1619
+ const collected = await source(
1620
+ signal,
1621
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1622
+ );
1318
1623
  onProgress({ type: "collecting", files: collected.length });
1319
1624
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1320
1625
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1330,6 +1635,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1330
1635
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1331
1636
  return;
1332
1637
  }
1638
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1639
+ const totalBytes = prepared.upload.reduce(
1640
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1641
+ 0
1642
+ );
1643
+ if (prepared.upload.length > 0) {
1644
+ onProgress({
1645
+ type: "uploading",
1646
+ done: prepared.reused,
1647
+ total: prepared.total,
1648
+ bytes: 0,
1649
+ totalBytes,
1650
+ reused: prepared.reused
1651
+ });
1652
+ }
1333
1653
  await uploadAll(
1334
1654
  prepared.upload,
1335
1655
  prepared.uploadUrl,
@@ -1339,11 +1659,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1339
1659
  done: prepared.reused + done,
1340
1660
  total: prepared.total,
1341
1661
  bytes,
1662
+ totalBytes,
1342
1663
  reused: prepared.reused,
1343
- path
1664
+ ...path === void 0 ? {} : { path }
1344
1665
  }),
1345
1666
  signal,
1346
- options.upload
1667
+ options.upload,
1668
+ (path, attempt, attempts, stalled) => onProgress({ type: "retrying", path, attempt, attempts, stalled })
1347
1669
  );
1348
1670
  onProgress({ type: "completing", reused: prepared.reused });
1349
1671
  const completed = await completeDeploy(
@@ -1351,7 +1673,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1351
1673
  prepared.deployId,
1352
1674
  options,
1353
1675
  signal,
1354
- suggestSiteName(files)
1676
+ await readSiteName(files)
1355
1677
  );
1356
1678
  onProgress({ type: "done", url: completed.url, name: completed.name });
1357
1679
  } catch (error) {
@@ -1488,6 +1810,10 @@ function shortenPath(path, width) {
1488
1810
  }
1489
1811
  function describe(event, width = 80) {
1490
1812
  switch (event.type) {
1813
+ case "reading":
1814
+ return event.total > 0 ? `Reading ${formatBytes(event.bytes)}/${formatBytes(event.total)}` : "Reading files";
1815
+ case "retrying":
1816
+ return `${event.stalled ? "Upload stalled" : "Upload failed"}, retrying ${shortenPath(event.path, Math.max(12, width - 40))} (attempt ${event.attempt}/${event.attempts})`;
1491
1817
  case "collecting":
1492
1818
  return `Collected ${event.files.toLocaleString()} ${event.files === 1 ? "file" : "files"}`;
1493
1819
  case "hashing":
@@ -1648,7 +1974,7 @@ async function whoami() {
1648
1974
  });
1649
1975
  if (!response.ok) {
1650
1976
  return failure(
1651
- response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://dropto.run/account/tokens." : `The API answered ${response.status}.`
1977
+ response.status === 401 ? "This token is not valid any more. It may have been revoked or expired — create another at https://app.dropto.run/account/tokens." : `The API answered ${response.status}.`
1652
1978
  );
1653
1979
  }
1654
1980
  const me = await response.json();
@@ -1925,10 +2251,10 @@ Where a site comes from
1925
2251
  Signing in
1926
2252
  \`login\` opens a browser and listens on 127.0.0.1, so it needs both on this
1927
2253
  machine. Over SSH, in a container or under WSL, use \`login --device\`: it
1928
- prints a short code to enter at https://dropto.run/device from anywhere.
2254
+ prints a short code to enter at https://app.dropto.run/device from anywhere.
1929
2255
 
1930
2256
  For CI, neither flow applies — create a token at
1931
- https://dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
2257
+ https://app.dropto.run/account/tokens and either set DROP2RUN_TOKEN in your
1932
2258
  environment or put it in ~/.config/drop2run/config.json as {"token": "d2r_..."}.
1933
2259
  `;
1934
2260
  function flagValue(args, flag) {
@@ -1983,7 +2309,7 @@ async function run(argv) {
1983
2309
  const positional = positionals(argv);
1984
2310
  const [command, ...rest] = positional;
1985
2311
  if (argv.includes("--version")) {
1986
- const { version } = await import("./package-bzaCMW_E.js").then(
2312
+ const { version } = await import("./package-D8mWS-nC.js").then(
1987
2313
  (module) => module.default
1988
2314
  );
1989
2315
  return { text: version, json: { version }, code: 0 };
@@ -2043,7 +2369,7 @@ async function dispatch(command, rest, site, json, device, confirmed, newSite =
2043
2369
  case "token":
2044
2370
  case "tokens":
2045
2371
  return rest[0] === void 0 || rest[0] === "list" ? await tokens() : {
2046
- text: `\`token ${rest[0]}\` does not exist. Only \`token list\` does: creating and revoking a token need a browser session, at https://dropto.run/account/tokens.`,
2372
+ text: `\`token ${rest[0]}\` does not exist. Only \`token list\` does: creating and revoking a token need a browser session, at https://app.dropto.run/account/tokens.`,
2047
2373
  json: { error: `Unknown token subcommand "${rest[0]}".` },
2048
2374
  code: 1
2049
2375
  };
@@ -1,5 +1,5 @@
1
1
  const name = "drop2run";
2
- const version = "0.6.0";
2
+ const version = "0.7.0";
3
3
  const description = "Publish a page, a folder of documents or a built site to Drop2Run from the command line.";
4
4
  const license = "MIT";
5
5
  const type = "module";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drop2run",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Publish a page, a folder of documents or a built site to Drop2Run from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",