@drop2run/mcp 0.6.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 +28 -4
- package/dist/index.js +585 -149
- 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
|
|
@@ -67,6 +85,7 @@ lose it.
|
|
|
67
85
|
| `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
|
|
68
86
|
| `publish_dir` | Publishes a folder, given its absolute path |
|
|
69
87
|
| `list_sites` | Lists the sites on the account |
|
|
88
|
+
| `list_folders` | Lists the account's folders, each with the path `folder` accepts |
|
|
70
89
|
| `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
|
|
71
90
|
|
|
72
91
|
Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
|
|
@@ -78,6 +97,11 @@ afterwards, and a site made under a guessed one has to be deleted by hand. `site
|
|
|
78
97
|
opposites — one publishes over a site that exists, the other creates one that does not — so passing both
|
|
79
98
|
is refused rather than resolved.
|
|
80
99
|
|
|
100
|
+
And both take an optional `folder`, which files the new site in one of the account's dashboard folders —
|
|
101
|
+
by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdomain` it only describes a new
|
|
102
|
+
site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
|
|
103
|
+
with the list of those that do, and nothing is published.
|
|
104
|
+
|
|
81
105
|
Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
|
|
82
106
|
again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
|
|
83
107
|
to with `replace`.
|
package/dist/index.js
CHANGED
|
@@ -8,64 +8,13 @@ import { createServer as createServer$1 } from "node:http";
|
|
|
8
8
|
import { realpath, readdir, stat, readFile } from "node:fs/promises";
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
async function call(credentials, path, init = {}) {
|
|
12
|
-
const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
|
|
13
|
-
...init,
|
|
14
|
-
headers: {
|
|
15
|
-
...init.headers,
|
|
16
|
-
Authorization: `Bearer ${credentials.token}`,
|
|
17
|
-
...init.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
if (!response.ok) {
|
|
21
|
-
const problem = await response.json().catch(() => null);
|
|
22
|
-
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
23
|
-
}
|
|
24
|
-
return await response.json();
|
|
25
|
-
}
|
|
26
|
-
async function findSite(credentials, site) {
|
|
27
|
-
const wanted = site.trim().toLowerCase();
|
|
28
|
-
const found = (await listSites(credentials)).find(
|
|
29
|
-
(candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
|
|
30
|
-
);
|
|
31
|
-
if (found === void 0) {
|
|
32
|
-
throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
|
|
33
|
-
}
|
|
34
|
-
return found;
|
|
35
|
-
}
|
|
36
|
-
async function deleteSite(credentials, siteId) {
|
|
37
|
-
const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
|
|
38
|
-
method: "DELETE",
|
|
39
|
-
headers: { Authorization: `Bearer ${credentials.token}` }
|
|
40
|
-
});
|
|
41
|
-
if (!response.ok) {
|
|
42
|
-
const problem = await response.json().catch(() => null);
|
|
43
|
-
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
async function listSites(credentials) {
|
|
47
|
-
const body = await call(credentials, "sites");
|
|
48
|
-
return body.sites;
|
|
49
|
-
}
|
|
50
|
-
async function createSite(credentials, subdomain) {
|
|
51
|
-
const created = await call(
|
|
52
|
-
credentials,
|
|
53
|
-
"sites",
|
|
54
|
-
{
|
|
55
|
-
method: "POST",
|
|
56
|
-
// An absent field rather than a null one, because the API reads null, empty and missing the
|
|
57
|
-
// same way and a body carrying only what was asked for is the one that reads correctly in a
|
|
58
|
-
// log.
|
|
59
|
-
body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
|
|
60
|
-
}
|
|
61
|
-
);
|
|
62
|
-
return { ...created, name: null };
|
|
63
|
-
}
|
|
64
11
|
const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
|
|
65
12
|
function configPath() {
|
|
66
13
|
return join(homedir(), ".config", "drop2run", "config.json");
|
|
67
14
|
}
|
|
68
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";
|
|
69
18
|
function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
|
|
70
19
|
let stored = {};
|
|
71
20
|
try {
|
|
@@ -89,7 +38,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
|
|
|
89
38
|
return url.replace(/\/+$/, "");
|
|
90
39
|
}
|
|
91
40
|
function dashboardUrlFor(apiBaseUrl) {
|
|
92
|
-
|
|
41
|
+
const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
42
|
+
return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
|
|
93
43
|
}
|
|
94
44
|
function saveToken(token, path = configPath()) {
|
|
95
45
|
let existing = {};
|
|
@@ -116,14 +66,14 @@ function missingCredentialsMessage(surface = "cli") {
|
|
|
116
66
|
] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
|
|
117
67
|
const byHand = surface === "mcp" ? [
|
|
118
68
|
"To set one without signing in here, create a token at",
|
|
119
|
-
"https://dropto.run/account/tokens and either:",
|
|
69
|
+
"https://app.dropto.run/account/tokens and either:",
|
|
120
70
|
` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
|
|
121
71
|
" server reads on every call and needs no restart, or",
|
|
122
72
|
` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
|
|
123
73
|
" the environment it was started in."
|
|
124
74
|
] : [
|
|
125
75
|
"For CI, or where no browser can be opened, create a token at",
|
|
126
|
-
"https://dropto.run/account/tokens and either:",
|
|
76
|
+
"https://app.dropto.run/account/tokens and either:",
|
|
127
77
|
` - set ${TOKEN_VARIABLE} in the environment, or`,
|
|
128
78
|
` - put it in ${configPath()} as {"token": "d2r_..."}`
|
|
129
79
|
];
|
|
@@ -137,6 +87,131 @@ function missingCredentialsMessage(surface = "cli") {
|
|
|
137
87
|
"The token is shown once when it is created and cannot be recovered afterwards."
|
|
138
88
|
].join("\n");
|
|
139
89
|
}
|
|
90
|
+
async function call(credentials, path, init = {}) {
|
|
91
|
+
const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
|
|
92
|
+
...init,
|
|
93
|
+
headers: {
|
|
94
|
+
...init.headers,
|
|
95
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
96
|
+
...init.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const problem = await response.json().catch(() => null);
|
|
101
|
+
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
102
|
+
}
|
|
103
|
+
return await response.json();
|
|
104
|
+
}
|
|
105
|
+
async function findSite(credentials, site) {
|
|
106
|
+
const found = await lookUpSite(credentials, site);
|
|
107
|
+
if (found === void 0) {
|
|
108
|
+
throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
|
|
113
|
+
async function lookUpSite(credentials, site) {
|
|
114
|
+
const wanted = site.trim().toLowerCase();
|
|
115
|
+
if (wanted === "") return void 0;
|
|
116
|
+
const { sites } = await call(
|
|
117
|
+
credentials,
|
|
118
|
+
`sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
|
|
119
|
+
);
|
|
120
|
+
const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
|
|
121
|
+
if (bySubdomain !== void 0) return bySubdomain;
|
|
122
|
+
if (!SITE_ID_SHAPE.test(wanted)) return void 0;
|
|
123
|
+
const response = await fetch(
|
|
124
|
+
`${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
|
|
125
|
+
{ headers: { Authorization: `Bearer ${credentials.token}` } }
|
|
126
|
+
);
|
|
127
|
+
if (response.status === 404) return void 0;
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const problem = await response.json().catch(() => null);
|
|
130
|
+
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
131
|
+
}
|
|
132
|
+
const detail = await response.json();
|
|
133
|
+
return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
|
|
134
|
+
}
|
|
135
|
+
async function deleteSite(credentials, siteId) {
|
|
136
|
+
const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
|
|
137
|
+
method: "DELETE",
|
|
138
|
+
headers: { Authorization: `Bearer ${credentials.token}` }
|
|
139
|
+
});
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const problem = await response.json().catch(() => null);
|
|
142
|
+
throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const SITES_PER_REQUEST = 100;
|
|
146
|
+
async function listSites(credentials) {
|
|
147
|
+
const body = await call(
|
|
148
|
+
credentials,
|
|
149
|
+
`sites?pageSize=${SITES_PER_REQUEST}`
|
|
150
|
+
);
|
|
151
|
+
return { sites: body.sites, total: body.total };
|
|
152
|
+
}
|
|
153
|
+
async function createSite(credentials, subdomain, folderId) {
|
|
154
|
+
const created = await call(
|
|
155
|
+
credentials,
|
|
156
|
+
"sites",
|
|
157
|
+
{
|
|
158
|
+
method: "POST",
|
|
159
|
+
// Absent fields rather than null ones, because the API reads null, empty and missing the same
|
|
160
|
+
// way and a body carrying only what was asked for is the one that reads correctly in a log.
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
...subdomain === void 0 ? {} : { subdomain },
|
|
163
|
+
...folderId === void 0 ? {} : { folderId }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
return { ...created, name: null };
|
|
168
|
+
}
|
|
169
|
+
async function listFolders(credentials) {
|
|
170
|
+
const body = await call(credentials, "site-folders");
|
|
171
|
+
const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
|
|
172
|
+
const pathOf = (folderId) => {
|
|
173
|
+
const names = [];
|
|
174
|
+
let cursor = byId.get(folderId);
|
|
175
|
+
while (cursor !== void 0 && names.length <= byId.size) {
|
|
176
|
+
names.unshift(cursor.name);
|
|
177
|
+
cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
|
|
178
|
+
}
|
|
179
|
+
return names.join("/");
|
|
180
|
+
};
|
|
181
|
+
return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
|
|
182
|
+
}
|
|
183
|
+
function comparablePath(path) {
|
|
184
|
+
return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
|
|
185
|
+
}
|
|
186
|
+
const FOLDERS_LISTED_ON_MISS = 20;
|
|
187
|
+
async function resolveFolder(credentials, folder) {
|
|
188
|
+
const wanted = comparablePath(folder);
|
|
189
|
+
if (wanted === "") {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const folders = await listFolders(credentials);
|
|
195
|
+
const byId = folders.find((candidate) => candidate.folderId.toLowerCase() === wanted);
|
|
196
|
+
if (byId !== void 0) return byId.folderId;
|
|
197
|
+
const matches = folders.filter((candidate) => comparablePath(candidate.path) === wanted);
|
|
198
|
+
if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
|
|
199
|
+
if (matches.length > 1) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (folders.length === 0) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const listed = folders.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
|
|
210
|
+
const more = folders.length - listed.length;
|
|
211
|
+
throw new Error(
|
|
212
|
+
`No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
140
215
|
function newAttempt() {
|
|
141
216
|
const verifier = randomBytes(32).toString("base64url");
|
|
142
217
|
return {
|
|
@@ -272,6 +347,12 @@ async function exchange(apiBaseUrl, code, verifier) {
|
|
|
272
347
|
const body = await response.json();
|
|
273
348
|
return { token: body.token, name: body.name, email: body.email };
|
|
274
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
|
+
}
|
|
275
356
|
class DeployError extends Error {
|
|
276
357
|
/**
|
|
277
358
|
* @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
|
|
@@ -308,20 +389,55 @@ const ClientErrorCode = {
|
|
|
308
389
|
/** The user cancelled. */
|
|
309
390
|
Cancelled: "cancelled",
|
|
310
391
|
/** An upload kept failing after every retry. */
|
|
311
|
-
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"
|
|
312
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
|
+
}
|
|
313
426
|
const ACCOUNT_HEADER = "X-Drop2Run-Account";
|
|
314
427
|
const CLAIM_TOKEN_HEADER = "X-Claim-Token";
|
|
428
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
429
|
+
const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
|
|
315
430
|
async function prepareDeploy(siteId, files, options, signal) {
|
|
316
431
|
const manifest = files.map((file) => ({
|
|
317
432
|
path: file.path,
|
|
318
433
|
sha256: file.sha256,
|
|
319
|
-
size: file.bytes
|
|
434
|
+
size: sizeOf(file.bytes)
|
|
320
435
|
}));
|
|
321
436
|
const prepared = await request(
|
|
322
437
|
`sites/${encodeURIComponent(siteId)}/deploys/prepare`,
|
|
323
438
|
{ files: manifest },
|
|
324
|
-
options
|
|
439
|
+
options,
|
|
440
|
+
signal
|
|
325
441
|
);
|
|
326
442
|
return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
|
|
327
443
|
}
|
|
@@ -331,31 +447,65 @@ function absolute(uploadUrl, baseUrl) {
|
|
|
331
447
|
return new URL(uploadUrl, baseUrl).toString();
|
|
332
448
|
}
|
|
333
449
|
async function completeDeploy(siteId, deployId, options, signal, name) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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));
|
|
342
465
|
}
|
|
343
466
|
async function request(path, body, options, signal) {
|
|
344
467
|
const doFetch = options.fetch ?? globalFetch$1();
|
|
345
468
|
const base = options.baseUrl ?? "/api";
|
|
469
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
346
470
|
const headers = {};
|
|
347
471
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
348
472
|
if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
|
|
349
473
|
if (options.token) headers.Authorization = `Bearer ${options.token}`;
|
|
350
474
|
if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
}
|
|
359
509
|
}
|
|
360
510
|
function globalFetch$1() {
|
|
361
511
|
return globalThis.fetch.bind(globalThis);
|
|
@@ -854,17 +1004,29 @@ function extensionOf(path) {
|
|
|
854
1004
|
return dot <= 0 ? "" : name.slice(dot).toLowerCase();
|
|
855
1005
|
}
|
|
856
1006
|
async function sha256Hex(bytes) {
|
|
857
|
-
const digest = await crypto.subtle.digest("SHA-256", bytes
|
|
1007
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
858
1008
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
859
1009
|
}
|
|
860
1010
|
async function hashAll(files, onProgress, signal) {
|
|
861
1011
|
const hashed = [];
|
|
862
1012
|
for (const [index, file] of files.entries()) {
|
|
863
|
-
hashed.push({ ...file, sha256: await sha256Hex(file
|
|
1013
|
+
hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
|
|
864
1014
|
onProgress?.(index + 1, files.length);
|
|
865
1015
|
}
|
|
866
1016
|
return hashed;
|
|
867
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
|
+
}
|
|
868
1030
|
const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
|
|
869
1031
|
const IGNORED_DIRECTORIES = [
|
|
870
1032
|
"__MACOSX",
|
|
@@ -874,7 +1036,11 @@ const IGNORED_DIRECTORIES = [
|
|
|
874
1036
|
".hg",
|
|
875
1037
|
".idea",
|
|
876
1038
|
".vscode",
|
|
877
|
-
".claude"
|
|
1039
|
+
".claude",
|
|
1040
|
+
".agents",
|
|
1041
|
+
".cursor",
|
|
1042
|
+
".codex",
|
|
1043
|
+
".gemini"
|
|
878
1044
|
];
|
|
879
1045
|
function shouldIgnore(path) {
|
|
880
1046
|
const segments = path.split("/");
|
|
@@ -936,7 +1102,7 @@ function checkLimits(files, limits) {
|
|
|
936
1102
|
);
|
|
937
1103
|
}
|
|
938
1104
|
const refusal = sizeRefusal(
|
|
939
|
-
files.map((file) => ({ path: file.path, size: file.bytes
|
|
1105
|
+
files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
|
|
940
1106
|
limits
|
|
941
1107
|
);
|
|
942
1108
|
if (refusal) throw refusal;
|
|
@@ -977,13 +1143,24 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
977
1143
|
const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
|
|
978
1144
|
function suggestSiteName(files) {
|
|
979
1145
|
const index = files.find((file) => file.path === "index.html");
|
|
980
|
-
if (index === void 0) return null;
|
|
1146
|
+
if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
|
|
981
1147
|
try {
|
|
982
1148
|
return nameFromHtml(decodeHead(index.bytes));
|
|
983
1149
|
} catch {
|
|
984
1150
|
return null;
|
|
985
1151
|
}
|
|
986
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
|
+
}
|
|
987
1164
|
function decodeHead(bytes) {
|
|
988
1165
|
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
989
1166
|
bytes.subarray(0, HEAD_BYTES)
|
|
@@ -1015,7 +1192,7 @@ function matchTag(html, tag) {
|
|
|
1015
1192
|
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
|
|
1016
1193
|
html
|
|
1017
1194
|
);
|
|
1018
|
-
return match?.[1]
|
|
1195
|
+
return match?.[1] === void 0 ? null : decodeEntities(match[1]);
|
|
1019
1196
|
}
|
|
1020
1197
|
function matchOgSiteName(html) {
|
|
1021
1198
|
const patterns = [
|
|
@@ -1024,10 +1201,56 @@ function matchOgSiteName(html) {
|
|
|
1024
1201
|
];
|
|
1025
1202
|
for (const pattern of patterns) {
|
|
1026
1203
|
const match = pattern.exec(html);
|
|
1027
|
-
if (match?.[1] !== void 0) return match[1];
|
|
1204
|
+
if (match?.[1] !== void 0) return decodeEntities(match[1]);
|
|
1028
1205
|
}
|
|
1029
1206
|
return null;
|
|
1030
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
|
+
}
|
|
1031
1254
|
const INVISIBLE = new RegExp(
|
|
1032
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)}]`,
|
|
1033
1256
|
"g"
|
|
@@ -1069,46 +1292,105 @@ function trimSuffix(title) {
|
|
|
1069
1292
|
return title;
|
|
1070
1293
|
}
|
|
1071
1294
|
const DEFAULT_CONCURRENCY = 8;
|
|
1295
|
+
const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
|
|
1072
1296
|
const DEFAULT_RETRIES = 3;
|
|
1073
1297
|
const BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
1074
|
-
|
|
1075
|
-
|
|
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);
|
|
1076
1309
|
const delay = deps.delay ?? defaultDelay;
|
|
1077
1310
|
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
1078
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;
|
|
1079
1315
|
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
1080
1316
|
let nextIndex = 0;
|
|
1081
1317
|
let done = 0;
|
|
1082
|
-
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
|
+
};
|
|
1083
1345
|
const worker = async () => {
|
|
1084
1346
|
for (; ; ) {
|
|
1347
|
+
if (failed) return;
|
|
1085
1348
|
const index = nextIndex++;
|
|
1086
1349
|
const target = targets[index];
|
|
1087
1350
|
if (target === void 0) return;
|
|
1088
1351
|
const file = byPath.get(target.path);
|
|
1089
1352
|
if (file === void 0) {
|
|
1353
|
+
failed = true;
|
|
1090
1354
|
throw new DeployError(
|
|
1091
1355
|
ClientErrorCode.UploadFailed,
|
|
1092
1356
|
`The server asked for ${target.path}, which is not in the manifest.`,
|
|
1093
1357
|
{ path: target.path }
|
|
1094
1358
|
);
|
|
1095
1359
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
signal
|
|
1101
|
-
|
|
1102
|
-
|
|
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);
|
|
1103
1388
|
done += 1;
|
|
1104
|
-
|
|
1105
|
-
|
|
1389
|
+
landedBytes += size;
|
|
1390
|
+
report2(file.path);
|
|
1106
1391
|
}
|
|
1107
1392
|
};
|
|
1108
|
-
const workers = Array.from(
|
|
1109
|
-
{ length: Math.min(concurrency, targets.length) },
|
|
1110
|
-
worker
|
|
1111
|
-
);
|
|
1393
|
+
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
|
|
1112
1394
|
try {
|
|
1113
1395
|
await Promise.all(workers);
|
|
1114
1396
|
} catch (error) {
|
|
@@ -1120,39 +1402,122 @@ async function uploadOne(target, file, context) {
|
|
|
1120
1402
|
for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
|
|
1121
1403
|
context.signal?.throwIfAborted();
|
|
1122
1404
|
if (attempt2 > 0) {
|
|
1405
|
+
context.onRetry(attempt2 + 1, lastError instanceof StalledError);
|
|
1123
1406
|
await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
|
|
1124
1407
|
context.signal?.throwIfAborted();
|
|
1125
1408
|
}
|
|
1126
1409
|
try {
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
...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
|
|
1135
1417
|
});
|
|
1136
|
-
if (
|
|
1137
|
-
if (
|
|
1418
|
+
if (status >= 200 && status < 300) return;
|
|
1419
|
+
if (status === 401 || status === 403 || status === 429) {
|
|
1138
1420
|
throw new DeployError(
|
|
1139
1421
|
ClientErrorCode.UploadFailed,
|
|
1140
1422
|
`Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
|
|
1141
|
-
{ path: target.path, status
|
|
1423
|
+
{ path: target.path, status }
|
|
1142
1424
|
);
|
|
1143
1425
|
}
|
|
1144
|
-
|
|
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}`);
|
|
1145
1434
|
} catch (error) {
|
|
1146
1435
|
if (error instanceof DeployError) throw error;
|
|
1147
1436
|
if (isAbort(error)) throw cancelled();
|
|
1148
1437
|
lastError = error;
|
|
1149
1438
|
}
|
|
1150
1439
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
);
|
|
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;
|
|
1156
1521
|
}
|
|
1157
1522
|
function globalFetch() {
|
|
1158
1523
|
return globalThis.fetch.bind(globalThis);
|
|
@@ -1166,9 +1531,17 @@ function isAbort(error) {
|
|
|
1166
1531
|
function cancelled() {
|
|
1167
1532
|
return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
|
|
1168
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
|
+
}
|
|
1169
1541
|
function asDeployError(error) {
|
|
1170
1542
|
if (error instanceof DeployError) return error;
|
|
1171
1543
|
if (isAbort(error)) return cancelled();
|
|
1544
|
+
if (error instanceof RangeError) return outOfMemory(error.message);
|
|
1172
1545
|
return new DeployError(
|
|
1173
1546
|
ClientErrorCode.UploadFailed,
|
|
1174
1547
|
error instanceof Error ? error.message : "Upload failed.",
|
|
@@ -1177,7 +1550,11 @@ function asDeployError(error) {
|
|
|
1177
1550
|
}
|
|
1178
1551
|
async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
1179
1552
|
try {
|
|
1180
|
-
|
|
1553
|
+
onProgress({ type: "reading", bytes: 0, total: 0 });
|
|
1554
|
+
const collected = await source(
|
|
1555
|
+
signal,
|
|
1556
|
+
(bytes, total) => onProgress({ type: "reading", bytes, total })
|
|
1557
|
+
);
|
|
1181
1558
|
onProgress({ type: "collecting", files: collected.length });
|
|
1182
1559
|
const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
|
|
1183
1560
|
const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
|
|
@@ -1193,6 +1570,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1193
1570
|
onProgress({ type: "unchanged", url: prepared.url ?? "" });
|
|
1194
1571
|
return;
|
|
1195
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
|
+
}
|
|
1196
1588
|
await uploadAll(
|
|
1197
1589
|
prepared.upload,
|
|
1198
1590
|
prepared.uploadUrl,
|
|
@@ -1202,11 +1594,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1202
1594
|
done: prepared.reused + done,
|
|
1203
1595
|
total: prepared.total,
|
|
1204
1596
|
bytes,
|
|
1597
|
+
totalBytes,
|
|
1205
1598
|
reused: prepared.reused,
|
|
1206
|
-
path
|
|
1599
|
+
...path === void 0 ? {} : { path }
|
|
1207
1600
|
}),
|
|
1208
1601
|
signal,
|
|
1209
|
-
options.upload
|
|
1602
|
+
options.upload,
|
|
1603
|
+
(path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
|
|
1210
1604
|
);
|
|
1211
1605
|
onProgress({ type: "completing", reused: prepared.reused });
|
|
1212
1606
|
const completed = await completeDeploy(
|
|
@@ -1214,7 +1608,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
|
|
|
1214
1608
|
prepared.deployId,
|
|
1215
1609
|
options,
|
|
1216
1610
|
signal,
|
|
1217
|
-
|
|
1611
|
+
await readSiteName(files)
|
|
1218
1612
|
);
|
|
1219
1613
|
onProgress({ type: "done", url: completed.url, name: completed.name });
|
|
1220
1614
|
} catch (error) {
|
|
@@ -1269,20 +1663,23 @@ function escapesRoot(root, candidate) {
|
|
|
1269
1663
|
const inside = relative(root, candidate);
|
|
1270
1664
|
return inside === "" || inside.startsWith("..");
|
|
1271
1665
|
}
|
|
1272
|
-
async function resolveSite(credentials, site,
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
return await createSite(credentials, subdomain);
|
|
1666
|
+
async function resolveSite(credentials, site, newSite) {
|
|
1667
|
+
const { subdomain, folder } = newSite;
|
|
1668
|
+
if (site !== void 0 && subdomain !== void 0) {
|
|
1669
|
+
throw new Error(
|
|
1670
|
+
"Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
|
|
1671
|
+
);
|
|
1280
1672
|
}
|
|
1281
|
-
if (site
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1673
|
+
if (site !== void 0 && folder !== void 0) {
|
|
1674
|
+
throw new Error(
|
|
1675
|
+
"A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
if (site === void 0) {
|
|
1679
|
+
const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
|
|
1680
|
+
return await createSite(credentials, subdomain, folderId);
|
|
1681
|
+
}
|
|
1682
|
+
const found = await lookUpSite(credentials, site);
|
|
1286
1683
|
if (found === void 0) {
|
|
1287
1684
|
throw new Error(
|
|
1288
1685
|
`No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
|
|
@@ -1290,8 +1687,8 @@ async function resolveSite(credentials, site, subdomain) {
|
|
|
1290
1687
|
}
|
|
1291
1688
|
return found;
|
|
1292
1689
|
}
|
|
1293
|
-
async function publish(credentials, source, site, onProgress,
|
|
1294
|
-
const target = await resolveSite(credentials, site,
|
|
1690
|
+
async function publish(credentials, source, site, onProgress, newSite = {}) {
|
|
1691
|
+
const target = await resolveSite(credentials, site, newSite);
|
|
1295
1692
|
let files = 0;
|
|
1296
1693
|
let url = target.url;
|
|
1297
1694
|
let unchanged = false;
|
|
@@ -1321,11 +1718,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
|
|
|
1321
1718
|
});
|
|
1322
1719
|
return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
|
|
1323
1720
|
}
|
|
1324
|
-
async function publishDirectory(credentials, directory, site, onProgress,
|
|
1721
|
+
async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
|
|
1325
1722
|
const found = await stat(directory).catch(() => null);
|
|
1326
1723
|
if (found === null) throw new Error(`There is no folder at ${directory}.`);
|
|
1327
1724
|
if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
|
|
1328
|
-
return await publish(credentials, directorySource(directory), site, onProgress,
|
|
1725
|
+
return await publish(credentials, directorySource(directory), site, onProgress, newSite);
|
|
1329
1726
|
}
|
|
1330
1727
|
function cleanPath(path) {
|
|
1331
1728
|
const cleaned = path.trim().replaceAll("\\", "/").replace(/^\.?\//, "");
|
|
@@ -1339,7 +1736,7 @@ function cleanPath(path) {
|
|
|
1339
1736
|
}
|
|
1340
1737
|
return cleaned;
|
|
1341
1738
|
}
|
|
1342
|
-
async function publishFiles(credentials, files, site,
|
|
1739
|
+
async function publishFiles(credentials, files, site, newSite = {}) {
|
|
1343
1740
|
const encoder = new TextEncoder();
|
|
1344
1741
|
const collected = files.map((file) => ({
|
|
1345
1742
|
path: cleanPath(file.path),
|
|
@@ -1350,7 +1747,7 @@ async function publishFiles(credentials, files, site, subdomain) {
|
|
|
1350
1747
|
if (seen.has(file.path)) throw new Error(`Two files were given the same path: ${file.path}`);
|
|
1351
1748
|
seen.add(file.path);
|
|
1352
1749
|
}
|
|
1353
|
-
return publish(credentials, async () => collected, site, void 0,
|
|
1750
|
+
return publish(credentials, async () => collected, site, void 0, newSite);
|
|
1354
1751
|
}
|
|
1355
1752
|
const DEFAULT_WAIT_SECONDS = 120;
|
|
1356
1753
|
const MAX_WAIT_SECONDS = 240;
|
|
@@ -1528,7 +1925,7 @@ async function signInWithCode(waitSeconds, replace = false) {
|
|
|
1528
1925
|
"Call this tool again to keep waiting. Nothing has been changed yet."
|
|
1529
1926
|
].join("\n");
|
|
1530
1927
|
}
|
|
1531
|
-
const SERVER_VERSION = "0.
|
|
1928
|
+
const SERVER_VERSION = "0.8.0";
|
|
1532
1929
|
const CREDENTIAL_WRITE = {
|
|
1533
1930
|
readOnlyHint: false,
|
|
1534
1931
|
destructiveHint: false,
|
|
@@ -1566,6 +1963,15 @@ const SITE_OUTPUT = z.object({
|
|
|
1566
1963
|
url: z.string().describe("Its live URL."),
|
|
1567
1964
|
name: z.string().nullable().describe("What it is called, or null when it has no name.")
|
|
1568
1965
|
});
|
|
1966
|
+
const FOLDER_OUTPUT = z.object({
|
|
1967
|
+
folderId: z.string().describe("Identifier of the folder, which `folder` accepts."),
|
|
1968
|
+
path: z.string().describe(
|
|
1969
|
+
"Every name from the top level down to it, joined with /, which `folder` accepts too."
|
|
1970
|
+
)
|
|
1971
|
+
});
|
|
1972
|
+
const FOLDER_INPUT = z.string().optional().describe(
|
|
1973
|
+
"Folder to file the new site in, when the person asked for one: its path of names such as Clients/Acme, or its id from list_folders. Only for a new site — do not pass it together with `site`; moving an existing site is done in the dashboard. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
|
|
1974
|
+
);
|
|
1569
1975
|
function say(text, isError = false) {
|
|
1570
1976
|
return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
|
|
1571
1977
|
}
|
|
@@ -1613,6 +2019,10 @@ function createServer() {
|
|
|
1613
2019
|
"be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
|
|
1614
2020
|
"exists, the other creates one that does not. Never pass both.",
|
|
1615
2021
|
"",
|
|
2022
|
+
"A new site goes at the top level of the dashboard unless `folder` files it somewhere. Pass",
|
|
2023
|
+
"it when the person named a folder, as its path (Clients/Acme) or its id; list_folders shows",
|
|
2024
|
+
"what exists. Like `subdomain`, it only describes a new site, so never pass it with `site`.",
|
|
2025
|
+
"",
|
|
1616
2026
|
"A site needs an index.html at its top level, or at least one document (.md, .markdown,",
|
|
1617
2027
|
".pdf, .docx, .xlsx or .epub), which publishes as a documents site and is read through a",
|
|
1618
2028
|
"viewer. So one page goes at index.html, and a single note is a whole publish that needs",
|
|
@@ -1674,13 +2084,14 @@ function createServer() {
|
|
|
1674
2084
|
site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
|
|
1675
2085
|
subdomain: z.string().optional().describe(
|
|
1676
2086
|
"Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
|
|
1677
|
-
)
|
|
2087
|
+
),
|
|
2088
|
+
folder: FOLDER_INPUT
|
|
1678
2089
|
},
|
|
1679
2090
|
outputSchema: PUBLISH_OUTPUT,
|
|
1680
2091
|
annotations: REPLACES_A_SITE
|
|
1681
2092
|
},
|
|
1682
|
-
({ files, site, subdomain }) => withCredentials(async (credentials) => {
|
|
1683
|
-
const result = await publishFiles(credentials, files, site, subdomain);
|
|
2093
|
+
({ files, site, subdomain, folder }) => withCredentials(async (credentials) => {
|
|
2094
|
+
const result = await publishFiles(credentials, files, site, { subdomain, folder });
|
|
1684
2095
|
return report(describe(result), { ...result });
|
|
1685
2096
|
})
|
|
1686
2097
|
);
|
|
@@ -1694,13 +2105,17 @@ function createServer() {
|
|
|
1694
2105
|
site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
|
|
1695
2106
|
subdomain: z.string().optional().describe(
|
|
1696
2107
|
"Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
|
|
1697
|
-
)
|
|
2108
|
+
),
|
|
2109
|
+
folder: FOLDER_INPUT
|
|
1698
2110
|
},
|
|
1699
2111
|
outputSchema: PUBLISH_OUTPUT,
|
|
1700
2112
|
annotations: REPLACES_A_SITE
|
|
1701
2113
|
},
|
|
1702
|
-
({ path, site, subdomain }) => withCredentials(async (credentials) => {
|
|
1703
|
-
const result = await publishDirectory(credentials, path, site, void 0,
|
|
2114
|
+
({ path, site, subdomain, folder }) => withCredentials(async (credentials) => {
|
|
2115
|
+
const result = await publishDirectory(credentials, path, site, void 0, {
|
|
2116
|
+
subdomain,
|
|
2117
|
+
folder
|
|
2118
|
+
});
|
|
1704
2119
|
return report(describe(result), { ...result });
|
|
1705
2120
|
})
|
|
1706
2121
|
);
|
|
@@ -1741,15 +2156,36 @@ function createServer() {
|
|
|
1741
2156
|
title: "List sites",
|
|
1742
2157
|
description: "Lists the sites on this account, so a publish can go to one of them.",
|
|
1743
2158
|
inputSchema: {},
|
|
1744
|
-
outputSchema: {
|
|
2159
|
+
outputSchema: {
|
|
2160
|
+
sites: z.array(SITE_OUTPUT).describe("The account's sites, newest first — every one unless `total` is larger."),
|
|
2161
|
+
total: z.number().int().describe("How many sites the account holds. Larger than `sites` only past 100.")
|
|
2162
|
+
},
|
|
1745
2163
|
annotations: READS_ONLY
|
|
1746
2164
|
},
|
|
1747
2165
|
() => withCredentials(async (credentials) => {
|
|
1748
|
-
const sites = await listSites(credentials);
|
|
2166
|
+
const { sites, total } = await listSites(credentials);
|
|
1749
2167
|
const text = sites.length === 0 ? "No sites on this account yet. Publishing without a `site` creates one." : sites.map(
|
|
1750
2168
|
(site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`
|
|
1751
|
-
).join("\n")
|
|
1752
|
-
|
|
2169
|
+
).join("\n") + (total > sites.length ? `
|
|
2170
|
+
… and ${total - sites.length} more; these are the newest ${sites.length} of ${total}.` : "");
|
|
2171
|
+
return report(text, { sites: sites.map((site) => ({ ...site })), total });
|
|
2172
|
+
})
|
|
2173
|
+
);
|
|
2174
|
+
server.registerTool(
|
|
2175
|
+
"list_folders",
|
|
2176
|
+
{
|
|
2177
|
+
title: "List folders",
|
|
2178
|
+
description: "Lists the folders on this account, each with the path a publish's `folder` accepts. Folders are made and rearranged in the dashboard; no tool here creates one.",
|
|
2179
|
+
inputSchema: {},
|
|
2180
|
+
outputSchema: {
|
|
2181
|
+
folders: z.array(FOLDER_OUTPUT).describe("Every folder on this account, ordered by path.")
|
|
2182
|
+
},
|
|
2183
|
+
annotations: READS_ONLY
|
|
2184
|
+
},
|
|
2185
|
+
() => withCredentials(async (credentials) => {
|
|
2186
|
+
const folders = await listFolders(credentials);
|
|
2187
|
+
const text = folders.length === 0 ? "No folders on this account. A new site goes at the top level; folders are made in the dashboard." : folders.map((folder) => `${folder.path} — ${folder.folderId}`).join("\n");
|
|
2188
|
+
return report(text, { folders: folders.map((folder) => ({ ...folder })) });
|
|
1753
2189
|
})
|
|
1754
2190
|
);
|
|
1755
2191
|
return server;
|
package/package.json
CHANGED