release(v4.1.0): browser-friendly HTTP RPC for @shade/files
Default shade.files.client(peer) requires both peers to be mutually
addressable over HTTP — the response round-trips through
Shade.deliverControlEnvelope (POST to peer's /v1/transfer/control).
Browser tabs can't host an HTTP server, so they couldn't consume
@shade/files at all. Dispatch's filutforsker (admin-panel browser UI)
is the canonical use-case.
This release adds a parallel request-response transport: one POST per
RPC, encrypted envelope in the body, encrypted response in the same
HTTP response. No inbound channel needed on the client.
### New API
- shade.files.rpcRoute(opts?) — Hono app exposing POST /rpc.
- shade.files.httpClient(peer, opts) — request-response FileClient.
- FilesNamespace.serve(handler, { inlineOnly: true }) — skip streams-
bridge (and its configureTransfers pre-condition); also skip
channel-based dispatch so requests aren't double-dispatched.
### Limitations (v1)
Inline only (≤ 256 KiB). Streamed reads/writes throw clear errors
directing to shade.files.client(peer) on a server-to-server deploy.
### Tests
7 integration tests in tests/integration/http-rpc.test.ts covering
round-trip + negative cases (sender header, empty/garbage body,
maxBodyBytes, rpcRoute-without-serve).
### Symmetry
Mirrors @shade/server's shade-auth-middleware: encrypted envelope in
request body, decrypted via existing ratchet, response in same HTTP
roundtrip. No WebSocket, no SSE, no outbound from server.
Wire-compatible. Source-compatible. Lockstep bump to 4.1.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
docs/files.md
105
docs/files.md
@@ -53,6 +53,111 @@ if (result.kind === 'inline') console.log(result.bytes.byteLength);
|
||||
else for await (const _chunk of /* result.stream */) { /* ... */ }
|
||||
```
|
||||
|
||||
## HTTP RPC — browser-friendly request-response (4.1+)
|
||||
|
||||
The default `shade.files.client(peer)` requires both peers to be mutually
|
||||
addressable over HTTP — the response to a `list`/`read` etc. round-trips
|
||||
through `Shade.deliverControlEnvelope`, which POSTs to the peer's
|
||||
`/v1/transfer/control` endpoint. **That doesn't work for browsers** —
|
||||
a tab can't host an HTTP server, so the server cannot call back outbound.
|
||||
|
||||
`@shade/files` 4.1 ships a parallel **request-response** transport that
|
||||
lets browser-style clients fully consume the file-RPC surface without
|
||||
any inbound channel. It mirrors the way `@shade/server`'s
|
||||
`shade-auth-middleware` works: one POST per RPC, encrypted envelope in
|
||||
the request body, encrypted response in the same HTTP response.
|
||||
|
||||
### Server side — mount the RPC route
|
||||
|
||||
```ts
|
||||
// 1. Register the file handler. `inlineOnly: true` skips the
|
||||
// streams-bridge (which would require @shade/transfer).
|
||||
await shade.files.serve(handlerConfig, { inlineOnly: true });
|
||||
|
||||
// 2. Mount the route on your Hono app under any base path.
|
||||
app.route('/api/v1/shade-files', shade.files.rpcRoute());
|
||||
// ^^^^^^^^^^^^^^
|
||||
// POST <base>/rpc
|
||||
```
|
||||
|
||||
`rpcRoute()` accepts:
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---------------------|---------|----------------------------------------------------------------------------------------------------|
|
||||
| `maxBodyBytes` | 1 MiB | Max request body. The protocol caps inline payloads at 256 KiB; the headroom is for base64 inflation + custom-op envelopes. |
|
||||
| `acceptFirstMessage`| `false` | Accept `0x01` PreKeyMessage envelopes — required when the RPC route also doubles as the X3DH handshake (browser's first-ever request). |
|
||||
|
||||
### Browser client
|
||||
|
||||
```ts
|
||||
import { createShade } from '@shade/sdk';
|
||||
|
||||
const shade = await createShade({
|
||||
prekeyServer: 'https://shade.example.com',
|
||||
storage: 'memory',
|
||||
address: 'alice@example.com',
|
||||
});
|
||||
|
||||
const fs = shade.files.httpClient('bob@example.com', {
|
||||
rpcUrl: 'https://dispatch.example.com/api/v1/shade-files/rpc',
|
||||
// Optional: thread CSRF / auth tokens, override fetch, etc.
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
|
||||
await fs.mkdir('/photos');
|
||||
await fs.write('/photos/cover.png', new Uint8Array([/* ... */]), {
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const result = await fs.read('/photos/cover.png');
|
||||
```
|
||||
|
||||
### What works in HTTP-RPC mode
|
||||
|
||||
- `list`, `stat`, `mkdir`, `delete`, `move`, `getThumbnail`, `custom<K>` — full parity.
|
||||
- `write` — **inline only** (≤ 256 KiB plaintext). Larger inputs throw `ConflictError`.
|
||||
- `read` — **inline only**. If the server returns a streamed `read` result, the client throws `InternalFileError` directing callers to the stateful pathway.
|
||||
|
||||
### Wire contract
|
||||
|
||||
```
|
||||
POST /rpc HTTP/1.1
|
||||
Content-Type: application/octet-stream
|
||||
X-Shade-Sender-Address: alice@example.com
|
||||
|
||||
<wire-encoded ShadeEnvelope (0x01 first-time, 0x02 after) wrapping
|
||||
JSON-encoded RpcRequest>
|
||||
|
||||
────
|
||||
|
||||
200 OK
|
||||
Content-Type: application/octet-stream
|
||||
|
||||
<wire-encoded ShadeEnvelope (0x02) wrapping JSON-encoded RpcResponse | RpcError>
|
||||
```
|
||||
|
||||
Transport-level failures (no session, undecryptable envelope, body too
|
||||
big) return JSON `{ "error": "..." }` with appropriate 4xx status.
|
||||
Application-level failures (file not found, permission denied) ship
|
||||
encrypted `RpcError` envelopes — the client maps them back to typed
|
||||
`FileError` subclasses (`NotFoundError`, `ConflictError`, etc.).
|
||||
|
||||
### Symmetry with `@shade/server`
|
||||
|
||||
The shape mirrors `@shade/server`'s shade-auth-middleware: encrypted
|
||||
envelope rides the request body, server decrypts via the existing
|
||||
ratchet session, performs the protected operation, returns an encrypted
|
||||
envelope in the response. No bidirectional channel required, no
|
||||
WebSocket, no SSE.
|
||||
|
||||
### When to use which
|
||||
|
||||
| Setup | Use |
|
||||
|-----------------------------------------------|-----------------------------------------------|
|
||||
| Browser client ↔ Bun/Hono server | `httpClient()` + `rpcRoute()` |
|
||||
| Server ↔ server (both can host HTTP) | `client()` (default) — supports streams |
|
||||
| Service-worker / extension ↔ server | `httpClient()` (no inbound listener) |
|
||||
| CLI / daemon ↔ daemon | Either; `client()` if you need streams |
|
||||
|
||||
## Op surface
|
||||
|
||||
| Op | Args | Result |
|
||||
|
||||
Reference in New Issue
Block a user