@tdxvolt/volt-drive-mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,290 @@
1
- # Temporary Holding Version
1
+ # @tdxvolt/volt-drive-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server that lets AI assistants (Cursor,
4
+ Claude Code, Claude Desktop, ...) browse, search and edit your **VoltDrive**
5
+ drives.
6
+
7
+ ```
8
+ AI client --MCP (stdio or HTTP)--> volt-drive-mcp --gRPC--> your Volt <--sync--> VoltDrive
9
+ ```
10
+
11
+ It connects to a Volt using your `volt.config.json`, so it acts as the identity
12
+ in that file and sees the drives that identity can access. Edits go through
13
+ Volt's sync layer, so they merge with concurrent edits and appear on every
14
+ device that has the drive.
15
+
16
+ **Requirements:** Node.js 20 or later, and a `volt.config.json` for a Volt that
17
+ can access the drive(s) you want to use.
18
+
19
+ ## Quick start
20
+
21
+ ### 1. Check it works
22
+
23
+ Run this once from a terminal (use the real path to your config):
24
+
25
+ ```sh
26
+ npx -y @tdxvolt/volt-drive-mcp --config /absolute/path/to/volt.config.json --read-only
27
+ ```
28
+
29
+ You should see something like:
30
+
31
+ ```
32
+ [volt-drive-mcp] info: serving VoltDrive over stdio (read-only)
33
+ [volt-drive-mcp] info: connecting to the Volt using /absolute/path/to/volt.config.json
34
+ [volt-drive-mcp] info: connected to <your volt>
35
+ ```
36
+
37
+ It then waits silently: it is an MCP server and speaks JSON-RPC on stdin/stdout,
38
+ so it isn't meant to be used interactively. Press Ctrl+C to stop it. To try the
39
+ tools by hand, see [Trying the tools](#trying-the-tools).
40
+
41
+ ### 2. Add it to your AI client
42
+
43
+ Always use an **absolute path** for `--config`: clients start the server from a
44
+ directory you don't control, so relative paths won't resolve.
45
+
46
+ **Cursor**: add to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json`
47
+ (one project), then check *Settings → Tools & MCP* for a green dot:
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "voltdrive": {
53
+ "command": "npx",
54
+ "args": [
55
+ "-y", "@tdxvolt/volt-drive-mcp",
56
+ "--config", "/absolute/path/to/volt.config.json",
57
+ "--read-only"
58
+ ]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ **Claude Code**:
65
+
66
+ ```sh
67
+ claude mcp add voltdrive -- npx -y @tdxvolt/volt-drive-mcp \
68
+ --config /absolute/path/to/volt.config.json --read-only
69
+ ```
70
+
71
+ **Claude Desktop**: add the same `command` and `args` as the Cursor example under
72
+ `mcpServers` in `claude_desktop_config.json`, then restart the app.
73
+
74
+ ### 3. Use it
75
+
76
+ Ask the assistant to use your drive in plain language. It calls the tools
77
+ itself, and most clients show each call and ask for approval first.
78
+
79
+ - "List my drives, then show me the top-level folders of *Project Files*."
80
+ - "Read `notes/meeting.md` from my VoltDrive and summarise the action items."
81
+ - "Search my drive for files that mention 'invoice'."
82
+ - With writes enabled: "Create `notes/summary.md` in my drive with a summary of
83
+ this conversation", or "Move `drafts/old.md` to `archive/old.md`".
84
+
85
+ Mention the drive ("on my VoltDrive", "in the *Project Files* drive") so the
86
+ assistant picks these tools rather than searching your local workspace.
87
+
88
+ ### Enabling writes
89
+
90
+ The examples above use `--read-only`, which removes every tool that changes a
91
+ drive. Once you're happy, remove `--read-only` from `args` and restart the
92
+ server. Keep your client's per-call approval on for write tools, and see
93
+ [Safety](#safety-and-identity).
94
+
95
+ ### Limiting which drives are visible
96
+
97
+ By default every drive the identity can see is available. To restrict it, add
98
+ `--drive` (repeatable) with a drive id, alias or name:
99
+
100
+ ```json
101
+ "args": ["-y", "@tdxvolt/volt-drive-mcp", "--config", "/absolute/path/to/volt.config.json",
102
+ "--drive", "3bfde0b4-04c0-4620-8027-1b191b048503", "--read-only"]
103
+ ```
104
+
105
+ Run the assistant's `list_drives` tool (or the command in
106
+ [Trying the tools](#trying-the-tools)) to find ids and names.
107
+
108
+ ## Options
109
+
110
+ | Option | Environment variable | Meaning |
111
+ | --- | --- | --- |
112
+ | `-c, --config <file>` | `VOLT_DRIVE_MCP_CONFIG` | Path to `volt.config.json`. Default `./volt.config.json` in the working directory. |
113
+ | `--read-only` | `VOLT_DRIVE_MCP_READ_ONLY=1` | Do not offer any tool that changes a drive. |
114
+ | `-d, --drive <id\|alias\|name>` | `VOLT_DRIVE_MCP_DRIVES=a,b` | Only expose these drives (repeatable). |
115
+ | `--max-file-bytes <n>` | | Largest file that can be read or written. Default 10 MiB. A drive's own maximum file size also applies if it is smaller. |
116
+ | `--max-response-chars <n>` | | Most characters one read returns. Default 100000. Longer files come back in pages. |
117
+ | `-h, --help` | | Show usage. |
118
+
119
+ Logs go to **stderr** only (stdout carries the protocol). Set
120
+ `VOLT_DRIVE_MCP_LOG=debug` for more detail. Your client's MCP log shows them.
121
+
122
+ The server connects to the Volt in the background. The client's handshake
123
+ completes immediately, and if the Volt can't be reached each tool call retries
124
+ and reports why.
125
+
126
+ ## Tools
127
+
128
+ Every tool takes a `drive` argument (id, alias or name). Paths are relative to
129
+ the drive root, use `/`, and cannot contain `..`.
130
+
131
+ **Read tools** (always available):
132
+
133
+ | Tool | What it does |
134
+ | --- | --- |
135
+ | `list_drives` | The drives this identity can see. Start here. |
136
+ | `get_drive_status` | File and folder counts, size, drive settings, sync state. |
137
+ | `list_directory` | Files and folders in a folder, optionally recursive. |
138
+ | `read_file` | A file's text, in pages for large files, or base64 bytes for binary files. |
139
+ | `search_files` | Find files by name or glob, and/or by text inside them. |
140
+ | `list_file_versions` | Saved versions of a file (needs file versions enabled on the drive). |
141
+ | `read_file_version` | A file's text as of a saved version. |
142
+
143
+ **Write tools** (omitted with `--read-only`):
144
+
145
+ | Tool | What it does |
146
+ | --- | --- |
147
+ | `write_file` | Create a file, or replace one with `overwrite: true`. Text changes are applied as a minimal diff so they merge with concurrent edits. |
148
+ | `create_folder` | Create a folder and any missing parents. |
149
+ | `move_path` | Rename or move a file or folder. The destination must not exist. |
150
+ | `delete_path` | Delete a file, or a folder with `recursive: true`. |
151
+ | `create_file_version` | Save a named version of a file. |
152
+ | `revert_file_version` | Restore a file's content from a saved version. |
153
+
154
+ Behaviour worth knowing:
155
+
156
+ - **Large files** are returned in pieces of up to `--max-response-chars`. The
157
+ result says when it is truncated and gives the `offset` to continue from.
158
+ - **Binary files** are stored as blobs. `read_file` returns their metadata, or
159
+ the bytes with `encoding: "base64"`. Whether a drive accepts binary files is
160
+ its *include binary files* setting, which the connector respects: writing
161
+ binary data to a drive that ignores them fails with an explanation.
162
+ - **Files still syncing** to your Volt (for example just after cloning a large
163
+ drive) are waited for, and reported as not yet synced if they don't arrive,
164
+ rather than returned empty.
165
+ - **File versions** only work on drives with versioning enabled; otherwise the
166
+ tools say so.
167
+ - **Search** scans text files only. Binary and very large files are skipped and
168
+ counted in the result.
169
+
170
+ ## Trying the tools
171
+
172
+ To exercise the server without an AI client:
173
+
174
+ ```sh
175
+ # Web UI: pick a tool, fill in arguments, see the result
176
+ npx @modelcontextprotocol/inspector npx -y @tdxvolt/volt-drive-mcp -- \
177
+ --config /absolute/path/to/volt.config.json --read-only
178
+
179
+ # One call from the terminal, no browser
180
+ npx @modelcontextprotocol/inspector --cli --method tools/call --tool-name list_drives \
181
+ npx -y @tdxvolt/volt-drive-mcp -- --config /absolute/path/to/volt.config.json --read-only
182
+ ```
183
+
184
+ The Inspector has its own `--config` option, so the connector's flags must come
185
+ after the `--`. The Inspector's web UI prints a `http://127.0.0.1:6274?…` URL
186
+ to open; click **Connect**, then **Tools**.
187
+
188
+ ## Streamable HTTP (shared service)
189
+
190
+ By default the AI client starts its own copy of the server over stdio, which is
191
+ simplest. To run one shared server that several clients connect to instead:
192
+
193
+ ```sh
194
+ export VOLT_DRIVE_MCP_TOKEN="$(openssl rand -hex 32)"
195
+ npx -y -p @tdxvolt/volt-drive-mcp volt-drive-mcp-http \
196
+ --config /absolute/path/to/volt.config.json --read-only --port 3333
197
+ ```
198
+
199
+ Clients then use the URL and token (Cursor shown):
200
+
201
+ ```json
202
+ {
203
+ "mcpServers": {
204
+ "voltdrive": {
205
+ "url": "http://127.0.0.1:3333/mcp",
206
+ "headers": { "Authorization": "Bearer <the token>" }
207
+ }
208
+ }
209
+ }
210
+ ```
211
+
212
+ | Option | Meaning |
213
+ | --- | --- |
214
+ | `--host <addr>` | Address to listen on. Default `127.0.0.1` (this machine only). |
215
+ | `-p, --port <n>` | Port. Default `3333`. |
216
+ | `--path <path>` | Endpoint path. Default `/mcp`. |
217
+ | `--token <secret>` / `VOLT_DRIVE_MCP_TOKEN` | Bearer token clients must send. **Required** when `--host` is not a loopback address. |
218
+ | `--allowed-host <name>` | Extra `Host` header value to accept (repeatable), for use behind a reverse proxy. |
219
+
220
+ All options above also apply (`--config`, `--read-only`, `--drive`, ...). The
221
+ HTTP server is stateless and shares one Volt connection between requests. It
222
+ accepts only `POST`, compares the token in constant time, and on loopback
223
+ rejects unexpected `Host` and `Origin` headers (DNS rebinding protection). It
224
+ does **not** provide TLS: put it behind a reverse proxy before exposing it
225
+ beyond your own machine. Everyone with the token shares the one Volt identity.
226
+
227
+ ## Safety and identity
228
+
229
+ - **The connector acts as the identity in `volt.config.json`.** Anything that
230
+ identity can do in a drive, the AI client can do through the connector.
231
+ Prefer a dedicated identity that has only the drives you want to expose, and
232
+ use `--read-only` and `--drive` to narrow things further. Volt's own access
233
+ policy still applies: permission failures come back as tool errors.
234
+ - Write tools carry MCP annotations (`readOnlyHint`, `destructiveHint`,
235
+ `idempotentHint`) so clients can ask before running them. `delete_path` won't
236
+ remove a non-empty folder unless `recursive: true`, and `write_file` won't
237
+ replace a file unless `overwrite: true`. Changes sync to everyone who has the
238
+ drive; use `create_file_version` first if you may want to roll back.
239
+ - File contents are untrusted input to the assistant: a document can contain
240
+ instructions aimed at it. Only grant write access to drives you trust.
241
+ - Never put the token or `volt.config.json` in a shared or committed
242
+ configuration file.
243
+
244
+ ## Troubleshooting
245
+
246
+ | Symptom | Likely cause and fix |
247
+ | --- | --- |
248
+ | Server shows red / "failed to start" in the client | Run the command from [step 1](#1-check-it-works) in a terminal to see the error. Check Node is 20+ (`node -v`) and `npx` is on the PATH the client uses. |
249
+ | Tool calls fail with "could not connect to the Volt: …" | The config path is wrong or relative, or the Volt isn't running or reachable. The message includes the underlying reason; the server retries on the next call. |
250
+ | `list_drives` returns no drives | The identity in the config has no access to any drive on that Volt, or `--drive` doesn't match any of them. |
251
+ | "File versions are disabled on this drive" | Turn on file versions in the drive's settings. |
252
+ | "this drive ignores binary files" | Enable *include binary files* in the drive's settings, or write text only. |
253
+ | "not finished syncing to this Volt yet" | The file's content hasn't arrived from the other devices. Try again shortly. |
254
+ | `permission denied` style errors on write | The identity can read the drive but not write it. |
255
+
256
+ ## Development
257
+
258
+ ```sh
259
+ git clone https://github.com/tdxvolt/tdxvolt-volt-drive-mcp
260
+ cd tdxvolt-volt-drive-mcp
261
+ npm install
262
+ npm test # unit and protocol tests, no Volt needed
263
+ npm run inspect -- --config /path/to/volt.config.json --read-only # MCP Inspector
264
+ node bin/stdio.js --config /path/to/volt.config.json # run directly
265
+ ```
266
+
267
+ `npm test` runs the whole stack (MCP client, server, drive logic, sync) against
268
+ an in-memory fake Volt in `test/helpers/fake-volt.js`.
269
+
270
+ `scripts/e2e.js` checks the real thing. It creates a throwaway drive on a
271
+ running Volt, drives every tool through a spawned stdio server, then deletes
272
+ the drive:
273
+
274
+ ```sh
275
+ npm run e2e -- --config /path/to/volt.config.json
276
+ # --keep keeps the drive afterwards; --no-binary skips the binary-file step
277
+ ```
278
+
279
+ Layout: `src/drive-service.js` holds the transport-independent logic,
280
+ `src/server.js` defines the MCP tools on top of it, and `bin/stdio.js` and
281
+ `bin/http.js` (with `src/http.js`) are the two entry points.
282
+
283
+ ### Data model
284
+
285
+ A drive is a Volt synapse of kind `volt:drive-database`. Its `__meta` document
286
+ has an `idx` map of `documentId -> { json: { name, s }, __hash, __schema,
287
+ __timestamp }`; folders live in a `folders` document. Text files are a Yjs
288
+ `content` text per document; binary files are blobs addressed by SHA-256.
289
+ Metadata is written with `SetSynapseDocumentMetadata`, which must restate the
290
+ document's schema on every call, even for a rename.
package/bin/http.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Serve VoltDrive to MCP clients over Streamable HTTP. Unlike the stdio
5
+ * entry point this runs as a long-lived service that several clients can
6
+ * share; see the README for the security notes.
7
+ */
8
+
9
+ import { COMMON_USAGE, HTTP_USAGE, parseConfig } from "../src/config.js";
10
+ import { createDriveService } from "../src/connect.js";
11
+ import { startHttpServer } from "../src/http.js";
12
+ import { createLogger, redirectConsoleToStderr } from "../src/logger.js";
13
+
14
+ redirectConsoleToStderr();
15
+ const logger = createLogger();
16
+
17
+ const usage = `Usage: volt-drive-mcp-http [options]\n\n${COMMON_USAGE}\n\n${HTTP_USAGE}\n`;
18
+
19
+ async function main() {
20
+ let config;
21
+ try {
22
+ config = parseConfig(process.argv.slice(2), { http: true });
23
+ } catch (err) {
24
+ logger.error(err.message);
25
+ process.stderr.write(`\n${usage}`);
26
+ process.exit(2);
27
+ }
28
+
29
+ if (config.help) {
30
+ process.stderr.write(usage);
31
+ process.exit(0);
32
+ }
33
+
34
+ const service = createDriveService(config.configPath, {
35
+ options: config.options,
36
+ logger
37
+ });
38
+
39
+ let started;
40
+ try {
41
+ started = await startHttpServer(service, config.http, { logger });
42
+ } catch (err) {
43
+ logger.error(err.message);
44
+ process.exit(1);
45
+ }
46
+
47
+ const { httpServer, port, settings } = started;
48
+ logger.info(
49
+ `serving VoltDrive at http://${settings.host}:${port}${settings.path}` +
50
+ `${service.readOnly ? " (read-only)" : ""}` +
51
+ `${settings.token ? " (bearer token required)" : " (no token: loopback only)"}`
52
+ );
53
+
54
+ service.ensureConnected().catch((err) => logger.error(err.message));
55
+
56
+ let shuttingDown = false;
57
+ const shutdown = async (signal) => {
58
+ if (shuttingDown) {
59
+ return;
60
+ }
61
+ shuttingDown = true;
62
+ logger.info(`shutting down (${signal})`);
63
+ httpServer.close();
64
+ httpServer.closeAllConnections?.();
65
+ try {
66
+ await service.close();
67
+ } finally {
68
+ process.exit(0);
69
+ }
70
+ };
71
+ process.on("SIGINT", () => shutdown("SIGINT"));
72
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
73
+ }
74
+
75
+ main().catch((err) => {
76
+ logger.error(err?.stack ?? String(err));
77
+ process.exit(1);
78
+ });
package/bin/stdio.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Serve VoltDrive to an MCP client over stdio. The client (Cursor, Claude
5
+ * Desktop, ...) spawns this process and talks to it over stdin/stdout, so
6
+ * nothing else may ever write to stdout.
7
+ */
8
+
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { COMMON_USAGE, parseConfig } from "../src/config.js";
11
+ import { createDriveService } from "../src/connect.js";
12
+ import { createLogger, redirectConsoleToStderr } from "../src/logger.js";
13
+ import { createServer } from "../src/server.js";
14
+
15
+ redirectConsoleToStderr();
16
+ const logger = createLogger();
17
+
18
+ async function main() {
19
+ let config;
20
+ try {
21
+ config = parseConfig(process.argv.slice(2));
22
+ } catch (err) {
23
+ logger.error(err.message);
24
+ process.stderr.write(`\nUsage: volt-drive-mcp [options]\n\n${COMMON_USAGE}\n`);
25
+ process.exit(2);
26
+ }
27
+
28
+ if (config.help) {
29
+ process.stderr.write(`Usage: volt-drive-mcp [options]\n\n${COMMON_USAGE}\n`);
30
+ process.exit(0);
31
+ }
32
+
33
+ const service = createDriveService(config.configPath, {
34
+ options: config.options,
35
+ logger
36
+ });
37
+
38
+ const server = createServer(service, { logger });
39
+ await server.connect(new StdioServerTransport());
40
+ logger.info(
41
+ `serving VoltDrive over stdio${service.readOnly ? " (read-only)" : ""}`
42
+ );
43
+
44
+ // Connect to the Volt in the background: the MCP handshake must not wait for
45
+ // it. If it fails here, each tool call will retry and report the reason.
46
+ service.ensureConnected().catch((err) => logger.error(err.message));
47
+
48
+ let shuttingDown = false;
49
+ const shutdown = async (signal) => {
50
+ if (shuttingDown) {
51
+ return;
52
+ }
53
+ shuttingDown = true;
54
+ logger.info(`shutting down (${signal})`);
55
+ try {
56
+ await server.close();
57
+ await service.close();
58
+ } finally {
59
+ process.exit(0);
60
+ }
61
+ };
62
+
63
+ process.on("SIGINT", () => shutdown("SIGINT"));
64
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
65
+ // The client closing our stdin is the normal way a stdio server is stopped.
66
+ process.stdin.on("end", () => shutdown("stdin closed"));
67
+ }
68
+
69
+ main().catch((err) => {
70
+ logger.error(err?.stack ?? String(err));
71
+ process.exit(1);
72
+ });
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@tdxvolt/volt-drive-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol (MCP) server that exposes VoltDrive drives to AI clients",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "voltdrive",
9
+ "tdxvolt",
10
+ "volt"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/tdxvolt/tdxvolt-volt-drive-mcp.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/tdxvolt/tdxvolt-volt-drive-mcp/issues"
18
+ },
19
+ "homepage": "https://github.com/tdxvolt/tdxvolt-volt-drive-mcp#readme",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "type": "module",
24
+ "main": "./src/index.js",
25
+ "bin": {
26
+ "volt-drive-mcp": "bin/stdio.js",
27
+ "volt-drive-mcp-http": "bin/http.js"
28
+ },
29
+ "exports": {
30
+ ".": "./src/index.js",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src"
36
+ ],
37
+ "scripts": {
38
+ "start": "node ./bin/stdio.js",
39
+ "start:http": "node ./bin/http.js",
40
+ "inspect": "npx @modelcontextprotocol/inspector node ./bin/stdio.js --",
41
+ "test": "node --test \"test/*.test.js\"",
42
+ "e2e": "node ./scripts/e2e.js",
43
+ "prepublishOnly": "npm test"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "author": "toby.ealden@gmail.com",
49
+ "license": "ISC",
50
+ "dependencies": {
51
+ "@grpc/grpc-js": "^1.12.6",
52
+ "@modelcontextprotocol/sdk": "^1.20.0",
53
+ "@tdxvolt/volt-client-grpc": "^0.20.36",
54
+ "yjs": "^13.6.18",
55
+ "zod": "^3.25.0"
56
+ }
57
+ }
package/src/blob.js ADDED
@@ -0,0 +1,192 @@
1
+ import { concatBytes, toBytes } from "./bytes.js";
2
+ import { DriveError, timeout as timeoutError } from "./errors.js";
3
+
4
+ /** Match the native client's file transfer block size. */
5
+ const BLOB_CHUNK_BYTES = 512 * 1024;
6
+
7
+ /** Abort a transfer when nothing has happened for this long. */
8
+ const STALL_TIMEOUT_MS = 60_000;
9
+
10
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
11
+
12
+ function toError(err) {
13
+ return err instanceof Error ? err : new Error(String(err));
14
+ }
15
+
16
+ function statusMessage(response) {
17
+ const message = response?.status?.message;
18
+ return message ? String(message) : "";
19
+ }
20
+
21
+ /**
22
+ * Whether the synapse blob store already holds `hash`.
23
+ *
24
+ * Passing `documentId` authorises the call against that one document, so an
25
+ * identity holding access to just that document can use its blob.
26
+ */
27
+ export async function hasBlob(client, synapseId, hash, documentId) {
28
+ const response = await client.HasSynapseBlob({
29
+ synapse_id: synapseId,
30
+ hash,
31
+ ...(documentId ? { document_id: documentId } : {})
32
+ });
33
+ return Boolean(response?.exists);
34
+ }
35
+
36
+ /** Upload `bytes` to the blob store (streamed in blocks). */
37
+ export async function putBlob(client, synapseId, hash, bytes, documentId) {
38
+ if (bytes.byteLength === 0) {
39
+ return;
40
+ }
41
+
42
+ const call = client.PutSynapseBlob({
43
+ start: {
44
+ synapse_id: synapseId,
45
+ hash,
46
+ size: bytes.byteLength,
47
+ ...(documentId ? { document_id: documentId } : {})
48
+ }
49
+ });
50
+
51
+ await new Promise((resolve, reject) => {
52
+ let settled = false;
53
+ let stallTimer = null;
54
+
55
+ const finish = (err) => {
56
+ if (settled) {
57
+ return;
58
+ }
59
+ settled = true;
60
+ clearTimeout(stallTimer);
61
+ if (err) {
62
+ reject(err);
63
+ } else {
64
+ resolve();
65
+ }
66
+ };
67
+
68
+ const armStall = () => {
69
+ clearTimeout(stallTimer);
70
+ stallTimer = setTimeout(
71
+ () => finish(timeoutError("blob upload stalled")),
72
+ STALL_TIMEOUT_MS
73
+ );
74
+ };
75
+
76
+ call.on("error", (err) => finish(toError(err)));
77
+ call.on("data", (payload) => {
78
+ const message = statusMessage(payload);
79
+ if (message) {
80
+ finish(new DriveError(message));
81
+ }
82
+ });
83
+ call.on("end", () => finish());
84
+
85
+ armStall();
86
+
87
+ (async () => {
88
+ // The initial `start` message is written on the next tick, so let it go
89
+ // out before any data blocks.
90
+ await tick();
91
+ for (let offset = 0; offset < bytes.byteLength; offset += BLOB_CHUNK_BYTES) {
92
+ if (settled) {
93
+ return;
94
+ }
95
+ call.write({
96
+ block: Buffer.from(
97
+ bytes.buffer,
98
+ bytes.byteOffset + offset,
99
+ Math.min(BLOB_CHUNK_BYTES, bytes.byteLength - offset)
100
+ )
101
+ });
102
+ armStall();
103
+ await tick();
104
+ }
105
+ call.end();
106
+ })().catch((err) => finish(toError(err)));
107
+ });
108
+ }
109
+
110
+ /** Upload `bytes` unless a blob with this hash already exists. */
111
+ export async function ensureBlob(client, synapseId, hash, bytes, documentId) {
112
+ if (bytes.byteLength === 0) {
113
+ return;
114
+ }
115
+ if (await hasBlob(client, synapseId, hash, documentId)) {
116
+ return;
117
+ }
118
+ await putBlob(client, synapseId, hash, bytes, documentId);
119
+ }
120
+
121
+ /** Download the blob `hash`. */
122
+ export function getBlob(client, synapseId, hash, documentId, { maxBytes } = {}) {
123
+ if (!hash) {
124
+ return Promise.resolve(new Uint8Array());
125
+ }
126
+
127
+ const call = client.GetSynapseBlob({
128
+ synapse_id: synapseId,
129
+ hash,
130
+ ...(documentId ? { document_id: documentId } : {})
131
+ });
132
+
133
+ return new Promise((resolve, reject) => {
134
+ const chunks = [];
135
+ let received = 0;
136
+ let settled = false;
137
+ let stallTimer = null;
138
+
139
+ const finish = (err, bytes) => {
140
+ if (settled) {
141
+ return;
142
+ }
143
+ settled = true;
144
+ clearTimeout(stallTimer);
145
+ if (err) {
146
+ try {
147
+ call.end();
148
+ } catch {
149
+ // stream already closed
150
+ }
151
+ reject(err);
152
+ } else {
153
+ resolve(bytes);
154
+ }
155
+ };
156
+
157
+ const armStall = () => {
158
+ clearTimeout(stallTimer);
159
+ stallTimer = setTimeout(
160
+ () => finish(timeoutError(`file transfer stalled after ${received} bytes`)),
161
+ STALL_TIMEOUT_MS
162
+ );
163
+ };
164
+
165
+ call.on("error", (err) => finish(toError(err)));
166
+ call.on("data", (payload) => {
167
+ const message = statusMessage(payload);
168
+ if (message) {
169
+ finish(new DriveError(message));
170
+ return;
171
+ }
172
+ if (payload?.block && payload.block.length !== 0) {
173
+ try {
174
+ const block = toBytes(payload.block);
175
+ chunks.push(block);
176
+ received += block.length;
177
+ } catch (err) {
178
+ finish(toError(err));
179
+ return;
180
+ }
181
+ if (maxBytes && received > maxBytes) {
182
+ finish(new DriveError(`file exceeds the ${maxBytes} byte limit`, "too_large"));
183
+ return;
184
+ }
185
+ armStall();
186
+ }
187
+ });
188
+ call.on("end", () => finish(null, concatBytes(chunks)));
189
+
190
+ armStall();
191
+ });
192
+ }