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 +3 -3
- package/dist/index.js +397 -71
- package/dist/{package-bzaCMW_E.js → package-D8mWS-nC.js} +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
signal
|
|
1238
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
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
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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 (
|
|
1274
|
-
if (
|
|
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
|
|
1488
|
+
{ path: target.path, status }
|
|
1279
1489
|
);
|
|
1280
1490
|
}
|
|
1281
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
};
|