@drop2run/mcp 0.7.0 → 0.8.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 +22 -4
- package/dist/index.js +389 -67
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @drop2run/mcp
|
|
2
2
|
|
|
3
|
-
An MCP server that lets
|
|
3
|
+
An MCP server that lets any MCP client — Claude, Cursor, VS Code, Codex, Gemini CLI — publish what it made to
|
|
4
4
|
[Drop2Run](https://dropto.run): an HTML page, a markdown note, a folder of documents or a built site. It
|
|
5
5
|
returns a live HTTPS URL.
|
|
6
6
|
|
|
@@ -35,11 +35,29 @@ add this to `claude_desktop_config.json` and restart the app:
|
|
|
35
35
|
That file is at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, and
|
|
36
36
|
`%APPDATA%\Claude\claude_desktop_config.json` on Windows.
|
|
37
37
|
|
|
38
|
+
**Cursor** — the same `mcpServers` block with `"type": "stdio"` added, in `~/.cursor/mcp.json` (or `.cursor/mcp.json` for one
|
|
39
|
+
project). **Gemini CLI** — the same block, in `~/.gemini/settings.json`.
|
|
40
|
+
|
|
41
|
+
**VS Code** — one command:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
code --add-mcp '{"name":"drop2run","command":"npx","args":["-y","@drop2run/mcp"]}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Codex** — one command:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
codex mcp add drop2run -- npx -y @drop2run/mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Zed and the rest: [dropto.run/docs/agents](https://dropto.run/docs/agents) has the file and the shape
|
|
54
|
+
for each client.
|
|
55
|
+
|
|
38
56
|
Nothing is installed globally either way — `npx` fetches the package when the client starts it.
|
|
39
57
|
|
|
40
58
|
## Sign in
|
|
41
59
|
|
|
42
|
-
Ask for a publish. If there is no token yet,
|
|
60
|
+
Ask for a publish. If there is no token yet, the agent calls the `login` tool, a browser tab opens, you
|
|
43
61
|
approve the sign-in, and the publish carries on. Nothing to install, nothing to paste.
|
|
44
62
|
|
|
45
63
|
The token lands in `~/.config/drop2run/config.json` with mode `0600` — the same file the `drop2run`
|
|
@@ -47,9 +65,9 @@ CLI uses, so signing in once covers both. It is read on every call rather than a
|
|
|
47
65
|
effect without restarting the client.
|
|
48
66
|
|
|
49
67
|
**No browser on this machine?** In a container, over SSH, on a remote host, `login_code` gives a short
|
|
50
|
-
code to enter at <https://dropto.run/device> from any other device.
|
|
68
|
+
code to enter at <https://app.dropto.run/device> from any other device.
|
|
51
69
|
|
|
52
|
-
**Or set a token yourself.** Create one at <https://dropto.run/account/tokens> and either put it in
|
|
70
|
+
**Or set a token yourself.** Create one at <https://app.dropto.run/account/tokens> and either put it in
|
|
53
71
|
`~/.config/drop2run/config.json` as `{"token": "d2r_..."}`, which needs no restart, or set
|
|
54
72
|
`DROP2RUN_TOKEN` in the environment the client starts the server in. The environment takes precedence
|
|
55
73
|
over the file — and because it is read once at startup, setting it in a shell afterwards changes
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ function configPath() {
|
|
|
13
13
|
return join(homedir(), ".config", "drop2run", "config.json");
|
|
14
14
|
}
|
|
15
15
|
const DEFAULT_API_BASE_URL = "https://dropto.run/api";
|
|
16
|
+
const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
|
|
17
|
+
const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
|
|
16
18
|
function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
|
|
17
19
|
let stored = {};
|
|
18
20
|
try {
|
|
@@ -36,7 +38,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
|
|
|
36
38
|
return url.replace(/\/+$/, "");
|
|
37
39
|
}
|
|
38
40
|
function dashboardUrlFor(apiBaseUrl) {
|
|
39
|
-
|
|
41
|
+
const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
42
|
+
return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
|
|
40
43
|
}
|
|
41
44
|
function saveToken(token, path = configPath()) {
|
|
42
45
|
let existing = {};
|
|
@@ -63,14 +66,14 @@ function missingCredentialsMessage(surface = "cli") {
|
|
|
63
66
|
] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
|
|
64
67
|
const byHand = surface === "mcp" ? [
|
|
65
68
|
"To set one without signing in here, create a token at",
|
|
66
|
-
"https://dropto.run/account/tokens and either:",
|
|
69
|
+
"https://app.dropto.run/account/tokens and either:",
|
|
67
70
|
` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
|
|
68
71
|
" server reads on every call and needs no restart, or",
|
|
69
72
|
` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
|
|
70
73
|
" the environment it was started in."
|
|
71
74
|
] : [
|
|
72
75
|
"For CI, or where no browser can be opened, create a token at",
|
|
73
|
-
"https://dropto.run/account/tokens and either:",
|
|
76
|
+
"https://app.dropto.run/account/tokens and either:",
|
|
74
77
|
` - set ${TOKEN_VARIABLE} in the environment, or`,
|
|
75
78
|
` - put it in ${configPath()} as {"token": "d2r_..."}`
|
|
76
79
|
];
|
|
@@ -344,6 +347,12 @@ async function exchange(apiBaseUrl, code, verifier) {
|
|
|
344
347
|
const body = await response.json();
|
|
345
348
|
return { token: body.token, name: body.name, email: body.email };
|
|
346
349
|
}
|
|
350
|
+
function sizeOf(content) {
|
|
351
|
+
return ArrayBuffer.isView(content) ? content.byteLength : content.size;
|
|
352
|
+
}
|
|
353
|
+
async function bytesOf(content) {
|
|
354
|
+
return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
|
|
355
|
+
}
|
|
347
356
|
class DeployError extends Error {
|
|
348
357
|
/**
|
|
349
358
|
* @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
|
|
@@ -380,20 +389,55 @@ const ClientErrorCode = {
|
|
|
380
389
|
/** The user cancelled. */
|
|
381
390
|
Cancelled: "cancelled",
|
|
382
391
|
/** An upload kept failing after every retry. */
|
|
383
|
-
UploadFailed: "upload_failed"
|
|
392
|
+
UploadFailed: "upload_failed",
|
|
393
|
+
/**
|
|
394
|
+
* The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
|
|
395
|
+
* large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
|
|
396
|
+
* names memory.
|
|
397
|
+
*/
|
|
398
|
+
OutOfMemory: "out_of_memory",
|
|
399
|
+
/**
|
|
400
|
+
* A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
|
|
401
|
+
* then, so one moved, deleted or changed after the drop fails here rather than at the drop.
|
|
402
|
+
*/
|
|
403
|
+
ReadFailed: "read_failed",
|
|
404
|
+
/**
|
|
405
|
+
* A control-plane call got no answer: the connection failed before a response arrived, or the
|
|
406
|
+
* request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
|
|
407
|
+
* about whether the server acted, which is why only an idempotent call may retry on it.
|
|
408
|
+
*/
|
|
409
|
+
NetworkFailed: "network_failed"
|
|
384
410
|
};
|
|
411
|
+
function describeError(error) {
|
|
412
|
+
const parts = [];
|
|
413
|
+
let current = error;
|
|
414
|
+
for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
|
|
415
|
+
if (current instanceof Error) {
|
|
416
|
+
const code = current.code;
|
|
417
|
+
parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
|
|
418
|
+
current = current.cause;
|
|
419
|
+
} else {
|
|
420
|
+
parts.push(String(current));
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return parts.join(" ← ");
|
|
425
|
+
}
|
|
385
426
|
const ACCOUNT_HEADER = "X-Drop2Run-Account";
|
|
386
427
|
const CLAIM_TOKEN_HEADER = "X-Claim-Token";
|
|
428
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
429
|
+
const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
|
|
387
430
|
async function prepareDeploy(siteId, files, options, signal) {
|
|
388
431
|
const manifest = files.map((file) => ({
|
|
389
432
|
path: file.path,
|
|
390
433
|
sha256: file.sha256,
|
|
391
|
-
size: file.bytes
|
|
434
|
+
size: sizeOf(file.bytes)
|
|
392
435
|
}));
|
|
393
436
|
const prepared = await request(
|
|
394
437
|
`sites/${encodeURIComponent(siteId)}/deploys/prepare`,
|
|
395
438
|
{ files: manifest },
|
|
396
|
-
options
|
|
439
|
+
options,
|
|
440
|
+
signal
|
|
397
441
|
);
|
|
398
442
|
return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
|
|
399
443
|
}
|
|
@@ -403,31 +447,65 @@ function absolute(uploadUrl, baseUrl) {
|
|
|
403
447
|
return new URL(uploadUrl, baseUrl).toString();
|
|
404
448
|
}
|
|
405
449
|
async function completeDeploy(siteId, deployId, options, signal, name) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
450
|
+
const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
|
|
451
|
+
const body = name === null || name === void 0 ? void 0 : { name };
|
|
452
|
+
const delay = options.delay ?? wait;
|
|
453
|
+
for (let attempt2 = 0; ; attempt2++) {
|
|
454
|
+
try {
|
|
455
|
+
return await request(path, body, options, signal);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
|
|
458
|
+
if (!unanswered || attempt2 >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
|
|
459
|
+
await delay(COMPLETE_RETRY_DELAYS_MS[attempt2] ?? 1e3);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function wait(ms) {
|
|
464
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
414
465
|
}
|
|
415
466
|
async function request(path, body, options, signal) {
|
|
416
467
|
const doFetch = options.fetch ?? globalFetch$1();
|
|
417
468
|
const base = options.baseUrl ?? "/api";
|
|
469
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
418
470
|
const headers = {};
|
|
419
471
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
420
472
|
if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
|
|
421
473
|
if (options.token) headers.Authorization = `Bearer ${options.token}`;
|
|
422
474
|
if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
|
|
475
|
+
const controller = new AbortController();
|
|
476
|
+
let timedOut = false;
|
|
477
|
+
const timer = setTimeout(() => {
|
|
478
|
+
timedOut = true;
|
|
479
|
+
controller.abort();
|
|
480
|
+
}, timeoutMs);
|
|
481
|
+
try {
|
|
482
|
+
const response = await doFetch(`${base}/${path}`, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
headers,
|
|
485
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
486
|
+
signal: controller.signal
|
|
487
|
+
});
|
|
488
|
+
if (!response.ok) throw await problemToError(response);
|
|
489
|
+
return await response.json();
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error instanceof DeployError || signal?.aborted) throw error;
|
|
492
|
+
if (timedOut) {
|
|
493
|
+
throw new DeployError(
|
|
494
|
+
ClientErrorCode.NetworkFailed,
|
|
495
|
+
`The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
|
|
496
|
+
{ path, cause: describeError(error) }
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (error instanceof TypeError) {
|
|
500
|
+
throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
|
|
501
|
+
path,
|
|
502
|
+
cause: describeError(error)
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
throw error;
|
|
506
|
+
} finally {
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
}
|
|
431
509
|
}
|
|
432
510
|
function globalFetch$1() {
|
|
433
511
|
return globalThis.fetch.bind(globalThis);
|
|
@@ -926,17 +1004,29 @@ function extensionOf(path) {
|
|
|
926
1004
|
return dot <= 0 ? "" : name.slice(dot).toLowerCase();
|
|
927
1005
|
}
|
|
928
1006
|
async function sha256Hex(bytes) {
|
|
929
|
-
const digest = await crypto.subtle.digest("SHA-256", bytes
|
|
1007
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
930
1008
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
931
1009
|
}
|
|
932
1010
|
async function hashAll(files, onProgress, signal) {
|
|
933
1011
|
const hashed = [];
|
|
934
1012
|
for (const [index, file] of files.entries()) {
|
|
935
|
-
hashed.push({ ...file, sha256: await sha256Hex(file
|
|
1013
|
+
hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
|
|
936
1014
|
onProgress?.(index + 1, files.length);
|
|
937
1015
|
}
|
|
938
1016
|
return hashed;
|
|
939
1017
|
}
|
|
1018
|
+
async function readForHash(file) {
|
|
1019
|
+
try {
|
|
1020
|
+
return await bytesOf(file.bytes);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (error instanceof RangeError) throw error;
|
|
1023
|
+
throw new DeployError(
|
|
1024
|
+
ClientErrorCode.ReadFailed,
|
|
1025
|
+
`Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
|
|
1026
|
+
{ path: file.path, cause: describeError(error) }
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
940
1030
|
const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
|
|
941
1031
|
const IGNORED_DIRECTORIES = [
|
|
942
1032
|
"__MACOSX",
|
|
@@ -946,7 +1036,11 @@ const IGNORED_DIRECTORIES = [
|
|
|
946
1036
|
".hg",
|
|
947
1037
|
".idea",
|
|
948
1038
|
".vscode",
|
|
949
|
-
".claude"
|
|
1039
|
+
".claude",
|
|
1040
|
+
".agents",
|
|
1041
|
+
".cursor",
|
|
1042
|
+
".codex",
|
|
1043
|
+
".gemini"
|
|
950
1044
|
];
|
|
951
1045
|
function shouldIgnore(path) {
|
|
952
1046
|
const segments = path.split("/");
|
|
@@ -1008,7 +1102,7 @@ function checkLimits(files, limits) {
|
|
|
1008
1102
|
);
|
|
1009
1103
|
}
|
|
1010
1104
|
const refusal = sizeRefusal(
|
|
1011
|
-
files.map((file) => ({ path: file.path, size: file.bytes
|
|
1105
|
+
files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
|
|
1012
1106
|
limits
|
|
1013
1107
|
);
|
|
1014
1108
|
if (refusal) throw refusal;
|
|
@@ -1049,13 +1143,24 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
1049
1143
|
const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
|
|
1050
1144
|
function suggestSiteName(files) {
|
|
1051
1145
|
const index = files.find((file) => file.path === "index.html");
|
|
1052
|
-
if (index === void 0) return null;
|
|
1146
|
+
if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
|
|
1053
1147
|
try {
|
|
1054
1148
|
return nameFromHtml(decodeHead(index.bytes));
|
|
1055
1149
|
} catch {
|
|
1056
1150
|
return null;
|
|
1057
1151
|
}
|
|
1058
1152
|
}
|
|
1153
|
+
async function readSiteName(files) {
|
|
1154
|
+
const index = files.find((file) => file.path === "index.html");
|
|
1155
|
+
if (index === void 0) return null;
|
|
1156
|
+
if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
|
|
1157
|
+
try {
|
|
1158
|
+
const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
|
|
1159
|
+
return suggestSiteName([{ path: index.path, bytes: head }]);
|
|
1160
|
+
} catch {
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1059
1164
|
function decodeHead(bytes) {
|
|
1060
1165
|
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
1061
1166
|
bytes.subarray(0, HEAD_BYTES)
|
|
@@ -1087,7 +1192,7 @@ function matchTag(html, tag) {
|
|
|
1087
1192
|
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
|
|
1088
1193
|
html
|
|
1089
1194
|
);
|
|
1090
|
-
return match?.[1]
|
|
1195
|
+
return match?.[1] === void 0 ? null : decodeEntities(match[1]);
|
|
1091
1196
|
}
|
|
1092
1197
|
function matchOgSiteName(html) {
|
|
1093
1198
|
const patterns = [
|
|
@@ -1096,10 +1201,56 @@ function matchOgSiteName(html) {
|
|
|
1096
1201
|
];
|
|
1097
1202
|
for (const pattern of patterns) {
|
|
1098
1203
|
const match = pattern.exec(html);
|
|
1099
|
-
if (match?.[1] !== void 0) return match[1];
|
|
1204
|
+
if (match?.[1] !== void 0) return decodeEntities(match[1]);
|
|
1100
1205
|
}
|
|
1101
1206
|
return null;
|
|
1102
1207
|
}
|
|
1208
|
+
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(" ");
|
|
1209
|
+
const NAMED_ENTITIES = new Map([
|
|
1210
|
+
...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
|
|
1211
|
+
["amp", 38],
|
|
1212
|
+
["lt", 60],
|
|
1213
|
+
["gt", 62],
|
|
1214
|
+
["quot", 34],
|
|
1215
|
+
["apos", 39],
|
|
1216
|
+
["ensp", 8194],
|
|
1217
|
+
["emsp", 8195],
|
|
1218
|
+
["thinsp", 8201],
|
|
1219
|
+
["ndash", 8211],
|
|
1220
|
+
["mdash", 8212],
|
|
1221
|
+
["lsquo", 8216],
|
|
1222
|
+
["rsquo", 8217],
|
|
1223
|
+
["sbquo", 8218],
|
|
1224
|
+
["ldquo", 8220],
|
|
1225
|
+
["rdquo", 8221],
|
|
1226
|
+
["bdquo", 8222],
|
|
1227
|
+
["dagger", 8224],
|
|
1228
|
+
["Dagger", 8225],
|
|
1229
|
+
["bull", 8226],
|
|
1230
|
+
["hellip", 8230],
|
|
1231
|
+
["prime", 8242],
|
|
1232
|
+
["lsaquo", 8249],
|
|
1233
|
+
["rsaquo", 8250],
|
|
1234
|
+
["euro", 8364],
|
|
1235
|
+
["trade", 8482],
|
|
1236
|
+
["larr", 8592],
|
|
1237
|
+
["rarr", 8594],
|
|
1238
|
+
["hearts", 9829]
|
|
1239
|
+
]);
|
|
1240
|
+
function decodeEntities(text) {
|
|
1241
|
+
return text.replace(
|
|
1242
|
+
/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
|
|
1243
|
+
(whole, decimal, hex, name) => {
|
|
1244
|
+
if (name !== void 0) {
|
|
1245
|
+
const code2 = NAMED_ENTITIES.get(name);
|
|
1246
|
+
return code2 === void 0 ? whole : String.fromCodePoint(code2);
|
|
1247
|
+
}
|
|
1248
|
+
const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
|
|
1249
|
+
const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
|
|
1250
|
+
return String.fromCodePoint(valid ? code : 65533);
|
|
1251
|
+
}
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1103
1254
|
const INVISIBLE = new RegExp(
|
|
1104
1255
|
`[${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)}]`,
|
|
1105
1256
|
"g"
|
|
@@ -1141,46 +1292,105 @@ function trimSuffix(title) {
|
|
|
1141
1292
|
return title;
|
|
1142
1293
|
}
|
|
1143
1294
|
const DEFAULT_CONCURRENCY = 8;
|
|
1295
|
+
const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
|
|
1144
1296
|
const DEFAULT_RETRIES = 3;
|
|
1145
1297
|
const BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
1146
|
-
|
|
1147
|
-
|
|
1298
|
+
const DEFAULT_STALL_MS = 3e4;
|
|
1299
|
+
const PROGRESS_INTERVAL_MS = 200;
|
|
1300
|
+
class StalledError extends Error {
|
|
1301
|
+
/** @param seconds How long nothing moved. */
|
|
1302
|
+
constructor(seconds) {
|
|
1303
|
+
super(`No data moved for ${seconds}s.`);
|
|
1304
|
+
this.name = "StalledError";
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
|
|
1308
|
+
const put = deps.put ?? defaultTransport(deps.fetch);
|
|
1148
1309
|
const delay = deps.delay ?? defaultDelay;
|
|
1149
1310
|
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
1150
1311
|
const retries = deps.retries ?? DEFAULT_RETRIES;
|
|
1312
|
+
const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
|
|
1313
|
+
const now = deps.now ?? Date.now;
|
|
1314
|
+
const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
|
|
1151
1315
|
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
1152
1316
|
let nextIndex = 0;
|
|
1153
1317
|
let done = 0;
|
|
1154
|
-
let
|
|
1318
|
+
let failed = false;
|
|
1319
|
+
let reserved = 0;
|
|
1320
|
+
let waiting = [];
|
|
1321
|
+
const acquire = async (size) => {
|
|
1322
|
+
while (reserved > 0 && reserved + size > maxBytesInFlight) {
|
|
1323
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
1324
|
+
}
|
|
1325
|
+
reserved += size;
|
|
1326
|
+
};
|
|
1327
|
+
const release = (size) => {
|
|
1328
|
+
reserved -= size;
|
|
1329
|
+
const woken = waiting;
|
|
1330
|
+
waiting = [];
|
|
1331
|
+
for (const wake of woken) wake();
|
|
1332
|
+
};
|
|
1333
|
+
let landedBytes = 0;
|
|
1334
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
1335
|
+
let lastReport = Number.NEGATIVE_INFINITY;
|
|
1336
|
+
const report2 = (path) => {
|
|
1337
|
+
if (onProgress === void 0) return;
|
|
1338
|
+
const at = now();
|
|
1339
|
+
if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
|
|
1340
|
+
lastReport = at;
|
|
1341
|
+
let bytes = landedBytes;
|
|
1342
|
+
for (const sent of inFlight.values()) bytes += sent;
|
|
1343
|
+
onProgress(done, targets.length, bytes, path);
|
|
1344
|
+
};
|
|
1155
1345
|
const worker = async () => {
|
|
1156
1346
|
for (; ; ) {
|
|
1347
|
+
if (failed) return;
|
|
1157
1348
|
const index = nextIndex++;
|
|
1158
1349
|
const target = targets[index];
|
|
1159
1350
|
if (target === void 0) return;
|
|
1160
1351
|
const file = byPath.get(target.path);
|
|
1161
1352
|
if (file === void 0) {
|
|
1353
|
+
failed = true;
|
|
1162
1354
|
throw new DeployError(
|
|
1163
1355
|
ClientErrorCode.UploadFailed,
|
|
1164
1356
|
`The server asked for ${target.path}, which is not in the manifest.`,
|
|
1165
1357
|
{ path: target.path }
|
|
1166
1358
|
);
|
|
1167
1359
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
signal
|
|
1173
|
-
|
|
1174
|
-
|
|
1360
|
+
const size = sizeOf(file.bytes);
|
|
1361
|
+
await acquire(size);
|
|
1362
|
+
try {
|
|
1363
|
+
if (failed) return;
|
|
1364
|
+
signal?.throwIfAborted();
|
|
1365
|
+
await uploadOne(target, file, {
|
|
1366
|
+
put,
|
|
1367
|
+
delay,
|
|
1368
|
+
retries,
|
|
1369
|
+
signal,
|
|
1370
|
+
uploadUrl,
|
|
1371
|
+
stallMs,
|
|
1372
|
+
onSent: (sent) => {
|
|
1373
|
+
inFlight.set(index, sent);
|
|
1374
|
+
report2();
|
|
1375
|
+
},
|
|
1376
|
+
onRetry: (attempt2, stalled) => {
|
|
1377
|
+
inFlight.set(index, 0);
|
|
1378
|
+
onRetry?.(target.path, attempt2, retries + 1, stalled);
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
failed = true;
|
|
1383
|
+
throw error;
|
|
1384
|
+
} finally {
|
|
1385
|
+
release(size);
|
|
1386
|
+
}
|
|
1387
|
+
inFlight.delete(index);
|
|
1175
1388
|
done += 1;
|
|
1176
|
-
|
|
1177
|
-
|
|
1389
|
+
landedBytes += size;
|
|
1390
|
+
report2(file.path);
|
|
1178
1391
|
}
|
|
1179
1392
|
};
|
|
1180
|
-
const workers = Array.from(
|
|
1181
|
-
{ length: Math.min(concurrency, targets.length) },
|
|
1182
|
-
worker
|
|
1183
|
-
);
|
|
1393
|
+
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
|
|
1184
1394
|
try {
|
|
1185
1395
|
await Promise.all(workers);
|
|
1186
1396
|
} catch (error) {
|
|
@@ -1192,39 +1402,122 @@ async function uploadOne(target, file, context) {
|
|
|
1192
1402
|
for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
|
|
1193
1403
|
context.signal?.throwIfAborted();
|
|
1194
1404
|
if (attempt2 > 0) {
|
|
1405
|
+
context.onRetry(attempt2 + 1, lastError instanceof StalledError);
|
|
1195
1406
|
await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
|
|
1196
1407
|
context.signal?.throwIfAborted();
|
|
1197
1408
|
}
|
|
1198
1409
|
try {
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
...context.signal ? { signal: context.signal } : {}
|
|
1410
|
+
const status = await context.put({
|
|
1411
|
+
url: context.uploadUrl,
|
|
1412
|
+
token: target.token,
|
|
1413
|
+
body: untyped(file.bytes),
|
|
1414
|
+
signal: context.signal,
|
|
1415
|
+
onSent: context.onSent,
|
|
1416
|
+
stallMs: context.stallMs
|
|
1207
1417
|
});
|
|
1208
|
-
if (
|
|
1209
|
-
if (
|
|
1418
|
+
if (status >= 200 && status < 300) return;
|
|
1419
|
+
if (status === 401 || status === 403 || status === 429) {
|
|
1210
1420
|
throw new DeployError(
|
|
1211
1421
|
ClientErrorCode.UploadFailed,
|
|
1212
1422
|
`Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
|
|
1213
|
-
{ path: target.path, status
|
|
1423
|
+
{ path: target.path, status }
|
|
1214
1424
|
);
|
|
1215
1425
|
}
|
|
1216
|
-
|
|
1426
|
+
if (status === 413) {
|
|
1427
|
+
throw new DeployError(
|
|
1428
|
+
ClientErrorCode.UploadFailed,
|
|
1429
|
+
`${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
|
|
1430
|
+
{ path: target.path, status }
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
lastError = new Error(`HTTP ${status}`);
|
|
1217
1434
|
} catch (error) {
|
|
1218
1435
|
if (error instanceof DeployError) throw error;
|
|
1219
1436
|
if (isAbort(error)) throw cancelled();
|
|
1220
1437
|
lastError = error;
|
|
1221
1438
|
}
|
|
1222
1439
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
);
|
|
1440
|
+
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.`;
|
|
1441
|
+
throw new DeployError(ClientErrorCode.UploadFailed, message, {
|
|
1442
|
+
path: target.path,
|
|
1443
|
+
cause: describeError(lastError)
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
function untyped(content) {
|
|
1447
|
+
if (ArrayBuffer.isView(content) || content.type === "") return content;
|
|
1448
|
+
return content.slice(0, content.size);
|
|
1449
|
+
}
|
|
1450
|
+
function defaultTransport(doFetch) {
|
|
1451
|
+
if (doFetch !== void 0) return fetchTransport(doFetch);
|
|
1452
|
+
const Xhr = globalThis.XMLHttpRequest;
|
|
1453
|
+
return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
|
|
1454
|
+
}
|
|
1455
|
+
function fetchTransport(doFetch) {
|
|
1456
|
+
return async ({ url, token, body, signal }) => {
|
|
1457
|
+
const response = await doFetch(url, {
|
|
1458
|
+
method: "PUT",
|
|
1459
|
+
// The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
|
|
1460
|
+
// full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
|
|
1461
|
+
// 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
|
|
1462
|
+
// SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
|
|
1463
|
+
// streams it from disk.
|
|
1464
|
+
body,
|
|
1465
|
+
// The permit is the whole request. `Content-Length` is required by the Worker and cannot be
|
|
1466
|
+
// set here — it is a forbidden header name — but the runtime derives it from the body, which
|
|
1467
|
+
// is exactly the length the token names.
|
|
1468
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1469
|
+
...signal ? { signal } : {}
|
|
1470
|
+
});
|
|
1471
|
+
return response.status;
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function xhrTransport(Xhr) {
|
|
1475
|
+
return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
|
|
1476
|
+
const xhr = new Xhr();
|
|
1477
|
+
let settled = false;
|
|
1478
|
+
let timer;
|
|
1479
|
+
const settle = (finish) => {
|
|
1480
|
+
if (settled) return;
|
|
1481
|
+
settled = true;
|
|
1482
|
+
clearTimeout(timer);
|
|
1483
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1484
|
+
finish();
|
|
1485
|
+
};
|
|
1486
|
+
const arm = () => {
|
|
1487
|
+
clearTimeout(timer);
|
|
1488
|
+
timer = setTimeout(() => {
|
|
1489
|
+
xhr.abort();
|
|
1490
|
+
settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
|
|
1491
|
+
}, stallMs);
|
|
1492
|
+
};
|
|
1493
|
+
function onAbort() {
|
|
1494
|
+
xhr.abort();
|
|
1495
|
+
settle(() => reject(abortReason(signal)));
|
|
1496
|
+
}
|
|
1497
|
+
if (signal?.aborted) {
|
|
1498
|
+
reject(abortReason(signal));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1502
|
+
xhr.open("PUT", url);
|
|
1503
|
+
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
|
1504
|
+
xhr.upload.onprogress = (event) => {
|
|
1505
|
+
arm();
|
|
1506
|
+
onSent(event.loaded);
|
|
1507
|
+
};
|
|
1508
|
+
xhr.onprogress = arm;
|
|
1509
|
+
xhr.onload = () => settle(() => resolve(xhr.status));
|
|
1510
|
+
xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
|
|
1511
|
+
arm();
|
|
1512
|
+
xhr.send(body);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
function abortReason(signal) {
|
|
1516
|
+
const reason = signal?.reason;
|
|
1517
|
+
if (reason instanceof Error) return reason;
|
|
1518
|
+
const error = new Error("Aborted.");
|
|
1519
|
+
error.name = "AbortError";
|
|
1520
|
+
return error;
|
|
1228
1521
|
}
|
|
1229
1522
|
function globalFetch() {
|
|
1230
1523
|
return globalThis.fetch.bind(globalThis);
|
|
@@ -1238,9 +1531,17 @@ function isAbort(error) {
|
|
|
1238
1531
|
function cancelled() {
|
|
1239
1532
|
return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
|
|
1240
1533
|
}
|
|
1534
|
+
function outOfMemory(detail) {
|
|
1535
|
+
return new DeployError(
|
|
1536
|
+
ClientErrorCode.OutOfMemory,
|
|
1537
|
+
"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.",
|
|
1538
|
+
detail
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1241
1541
|
function asDeployError(error) {
|
|
1242
1542
|
if (error instanceof DeployError) return error;
|
|
1243
1543
|
if (isAbort(error)) return cancelled();
|
|
1544
|
+
if (error instanceof RangeError) return outOfMemory(error.message);
|
|
1244
1545
|
return new DeployError(
|
|
1245
1546
|
ClientErrorCode.UploadFailed,
|
|
1246
1547
|
error instanceof Error ? error.message : "Upload failed.",
|
|
@@ -1249,7 +1550,11 @@ function asDeployError(error) {
|
|
|
1249
1550
|
}
|
|
1250
1551
|
async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
1251
1552
|
try {
|
|
1252
|
-
|
|
1553
|
+
onProgress({ type: "reading", bytes: 0, total: 0 });
|
|
1554
|
+
const collected = await source(
|
|
1555
|
+
signal,
|
|
1556
|
+
(bytes, total) => onProgress({ type: "reading", bytes, total })
|
|
1557
|
+
);
|
|
1253
1558
|
onProgress({ type: "collecting", files: collected.length });
|
|
1254
1559
|
const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
|
|
1255
1560
|
const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
|
|
@@ -1265,6 +1570,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1265
1570
|
onProgress({ type: "unchanged", url: prepared.url ?? "" });
|
|
1266
1571
|
return;
|
|
1267
1572
|
}
|
|
1573
|
+
const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
|
|
1574
|
+
const totalBytes = prepared.upload.reduce(
|
|
1575
|
+
(sum, target) => sum + (sizes.get(target.path) ?? 0),
|
|
1576
|
+
0
|
|
1577
|
+
);
|
|
1578
|
+
if (prepared.upload.length > 0) {
|
|
1579
|
+
onProgress({
|
|
1580
|
+
type: "uploading",
|
|
1581
|
+
done: prepared.reused,
|
|
1582
|
+
total: prepared.total,
|
|
1583
|
+
bytes: 0,
|
|
1584
|
+
totalBytes,
|
|
1585
|
+
reused: prepared.reused
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1268
1588
|
await uploadAll(
|
|
1269
1589
|
prepared.upload,
|
|
1270
1590
|
prepared.uploadUrl,
|
|
@@ -1274,11 +1594,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1274
1594
|
done: prepared.reused + done,
|
|
1275
1595
|
total: prepared.total,
|
|
1276
1596
|
bytes,
|
|
1597
|
+
totalBytes,
|
|
1277
1598
|
reused: prepared.reused,
|
|
1278
|
-
path
|
|
1599
|
+
...path === void 0 ? {} : { path }
|
|
1279
1600
|
}),
|
|
1280
1601
|
signal,
|
|
1281
|
-
options.upload
|
|
1602
|
+
options.upload,
|
|
1603
|
+
(path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
|
|
1282
1604
|
);
|
|
1283
1605
|
onProgress({ type: "completing", reused: prepared.reused });
|
|
1284
1606
|
const completed = await completeDeploy(
|
|
@@ -1286,7 +1608,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1286
1608
|
prepared.deployId,
|
|
1287
1609
|
options,
|
|
1288
1610
|
signal,
|
|
1289
|
-
|
|
1611
|
+
await readSiteName(files)
|
|
1290
1612
|
);
|
|
1291
1613
|
onProgress({ type: "done", url: completed.url, name: completed.name });
|
|
1292
1614
|
} catch (error) {
|
|
@@ -1603,7 +1925,7 @@ async function signInWithCode(waitSeconds, replace = false) {
|
|
|
1603
1925
|
"Call this tool again to keep waiting. Nothing has been changed yet."
|
|
1604
1926
|
].join("\n");
|
|
1605
1927
|
}
|
|
1606
|
-
const SERVER_VERSION = "0.
|
|
1928
|
+
const SERVER_VERSION = "0.8.0";
|
|
1607
1929
|
const CREDENTIAL_WRITE = {
|
|
1608
1930
|
readOnlyHint: false,
|
|
1609
1931
|
destructiveHint: false,
|
package/package.json
CHANGED