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>
2026-05-03 22:08:14 +02:00
/ * *
* Browser - friendly request - response ` FileClient ` for ` @shade/files ` .
*
* The default ` shade.files.client(peer) ` ships RPC envelopes via
* ` Shade.send ` + ` Shade.deliverControlEnvelope ` , which means the
* server has to be able to call back outbound to the client . That
* doesn ' t work for browser tabs ( no inbound HTTP listener ) . This
* client posts each RPC envelope to a single server endpoint and
* reads the encrypted response from the same HTTP response — pure
* request - response , no inbound channel required .
*
* Inline payloads only ( ≤ 256 KiB ) . For larger reads / writes , use the
* stateful path : ` shade.files.client(peer) ` server - to - server , with
* ` @shade/transfer ` chunk routes for content I / O .
*
* @see { @link createFilesRpcRoute } for the matching server - side route .
* /
import type { ZodTypeAny } from 'zod' ;
import { decodeEnvelope , encodeEnvelope as encodeWireEnvelope } from '@shade/proto' ;
import type { ShadeBridge } from '../integration/shade-bridge.js' ;
import {
encodeEnvelope as encodeRpcEnvelope ,
tryParseEnvelope ,
} from '../protocol/envelope-codec.js' ;
import {
KIND_CUSTOM_V1 ,
KIND_DELETE_V1 ,
KIND_GET_THUMBNAIL_V1 ,
KIND_LIST_V1 ,
KIND_MKDIR_V1 ,
KIND_MOVE_V1 ,
KIND_READ_V1 ,
KIND_STAT_V1 ,
KIND_WRITE_V1 ,
} from '../protocol/kinds.js' ;
import {
CustomArgsSchema ,
CustomResultSchema ,
DeleteArgsSchema ,
DeleteResultSchema ,
GetThumbnailArgsSchema ,
GetThumbnailResultSchema ,
ListArgsSchema ,
ListResultSchema ,
MkdirArgsSchema ,
MkdirResultSchema ,
MoveArgsSchema ,
MoveResultSchema ,
ReadArgsSchema ,
ReadResultSchema ,
StatArgsSchema ,
StatResultSchema ,
WriteArgsSchema ,
WriteResultSchema ,
type ListResult ,
type MkdirResult ,
type DeleteResult ,
type MoveResult ,
type StatResult ,
type ThumbnailSize ,
type WriteResult ,
} from '../schemas/ops.js' ;
import {
fileErrorFromPayload ,
CancelledError ,
InternalFileError ,
ConflictError ,
} from '../schemas/errors.js' ;
import { buildRpcRequest } from '../protocol/rpc-builder.js' ;
import { decideInline , INLINE_THRESHOLD , type WriteSource } from './inline-threshold.js' ;
import { base64ToBytes , bytesToBase64 } from '../protocol/canonical.js' ;
2026-05-03 23:27:06 +02:00
import { startQueueDrainer , type QueueDrainerHandle } from './queue-drainer.js' ;
import {
createClientStreamsBridge ,
type ClientStreamsBridge ,
} from './streams-bridge.js' ;
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>
2026-05-03 22:08:14 +02:00
import type {
FileClient ,
ReadOpts ,
ReadOutput ,
ThumbnailResult ,
WriteOpts ,
CreateFileClientOptions ,
BaseOpts ,
} from './client.js' ;
export interface FilesHttpClientOptions
2026-05-03 23:27:06 +02:00
extends Omit < CreateFileClientOptions , 'streamsBridge' > {
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>
2026-05-03 22:08:14 +02:00
/ * *
* Server endpoint that hosts ` createFilesRpcRoute(...) ` . Typically :
* ` https://server.example.com/api/v1/shade-files/rpc ` .
* /
rpcUrl : string ;
/ * *
* Optional ` fetch ` override . Defaults to ` globalThis.fetch ` . Wire a
* custom ` fetch ` to thread auth - cookies , CSRF tokens , or
* service - worker interception .
* /
fetch? : typeof globalThis . fetch ;
/ * *
* Extra HTTP headers applied to every RPC POST . Useful for app - level
* auth ( CSRF , session cookies via custom header , etc . ) — these are
* orthogonal to the ratchet authentication on the envelope itself .
* /
headers? : Record < string , string > ;
2026-05-03 23:27:06 +02:00
/ * *
* Server endpoint that hosts ` transferQueueRoute() ` ' s long - poll
* endpoint . Typically :
* ` https://server.example.com/api/v1/shade-files/queue ` .
*
* When supplied , the client starts a background long - poll that
* drains queued envelopes + chunks from the server and dispatches
* them via ` shade.acceptTransferEnvelope ` . This unlocks
* * * streamed reads * * ( > 256 KiB ) for browser - style consumers .
* /
outboundQueueUrl? : string ;
/ * *
* Base URL for outbound transfer routes ( browser → server ) . Required
* alongside ` outboundQueueUrl ` to enable streamed writes . Typically :
* ` https://server.example.com/api/v1/shade-files ` .
*
* The client POSTs :
* - chunks to ` <base>/v1/transfer/<streamId>/chunk `
* - control envelopes to ` <base>/v1/transfer/control `
* /
transferBaseUrl? : string ;
/ * *
* Long - poll block timeout , milliseconds . Default 30 _000 . Server
* clamps to its own ` maxBlockMs ` ( default 55 _000 ) .
* /
queueBlockMs? : number ;
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>
2026-05-03 22:08:14 +02:00
}
interface RoundTripOpts {
signal? : AbortSignal ;
timeoutMs? : number ;
idempotencyKey? : string ;
}
/ * *
* Create a request - response ` FileClient ` bound to ` peerAddress ` and a
* server - side RPC URL . The session must already be established
* ( via ` shade.initSessionFromBundle(peerAddress, bundle) ` or an
* incoming first - message ) . Otherwise the first RPC will fail with
* "decrypt failed: no session for peer" .
2026-05-03 23:27:06 +02:00
*
* When ` outboundQueueUrl ` + ` transferBaseUrl ` are supplied , the
* client also unlocks * * streamed reads / writes * * for files larger than
* the inline threshold ( 256 KiB ) . The browser polls the server ' s
* outbound queue for chunks / envelopes and POSTs its own outbound
* chunks to the server ' s transfer - receive routes .
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>
2026-05-03 22:08:14 +02:00
* /
export function createFilesHttpClient (
shade : ShadeBridge ,
peerAddress : string ,
options : FilesHttpClientOptions ,
) : FileClient {
const rpcUrl = options . rpcUrl ;
const fetchFn = options . fetch ? ? globalThis . fetch . bind ( globalThis ) ;
const extraHeaders = options . headers ? ? { } ;
const defaultTimeoutMs = options . defaultTimeoutMs ? ? 30 _000 ;
2026-05-03 23:27:06 +02:00
const ioTimeoutMs = options . ioTimeoutMs ? ? 60 _000 ;
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>
2026-05-03 22:08:14 +02:00
const signRequest = options . signRequest ;
const senderAddress = shade . myAddress ;
2026-05-03 23:27:06 +02:00
// ─── Streamed-mode bootstrap ─────────────────────────────────
//
// When `outboundQueueUrl` is supplied, the client:
// 1. Configures `shade.configureTransfers(...)` so outbound
// chunks POST to `<transferBaseUrl>/v1/transfer/<streamId>/chunk`
// and outbound control envelopes POST to
// `<transferBaseUrl>/v1/transfer/control`.
// 2. Spawns a streams-bridge so streamed reads can be awaited.
// 3. Starts a long-poll drainer that pulls queued envelopes +
// chunks from the server and dispatches via
// `shade.acceptTransferEnvelope`.
let drainer : QueueDrainerHandle | null = null ;
let streamsBridgePromise : Promise < ClientStreamsBridge > | null = null ;
let streamsBridge : ClientStreamsBridge | null = null ;
if ( options . outboundQueueUrl !== undefined ) {
const outboundQueueUrl = options . outboundQueueUrl ;
if ( options . transferBaseUrl === undefined ) {
throw new Error (
'createFilesHttpClient: outboundQueueUrl was supplied without transferBaseUrl. Pass `transferBaseUrl` (the server prefix that hosts /v1/transfer/...) so outbound chunks have a destination.' ,
) ;
}
if ( shade . configureTransfers === undefined ) {
throw new Error (
'createFilesHttpClient: shade.configureTransfers is required for streamed mode (the underlying ShadeBridge must surface it).' ,
) ;
}
const transferBaseUrl = options . transferBaseUrl . replace ( /\/$/ , '' ) ;
shade . configureTransfers ( {
resolveBaseUrl : async ( peer ) = > {
if ( peer !== peerAddress ) {
throw new Error (
` httpClient is bound to peer " ${ peerAddress } " — refusing to resolve outgoing chunks for " ${ peer } " without a multi-peer registry. Use shade.files.client(peer) for server-to-server multi-peer. ` ,
) ;
}
return transferBaseUrl ;
} ,
} ) ;
// Build the streams-bridge eagerly. The engine's incoming-transfer
// subscription has to be in place BEFORE the drainer dispatches the
// first stream-init envelope, otherwise the engine emits the
// IncomingTransfer to zero handlers and the read silently never
// accepts. We kick off the drainer once the bridge has subscribed.
streamsBridgePromise = createClientStreamsBridge ( shade ) . then ( ( bridge ) = > {
streamsBridge = bridge ;
drainer = startQueueDrainer ( shade , {
outboundQueueUrl ,
peerAddress ,
senderAddress ,
. . . ( options . fetch !== undefined ? { fetch : options.fetch } : { } ) ,
. . . ( options . headers !== undefined ? { headers : options.headers } : { } ) ,
. . . ( options . queueBlockMs !== undefined ? { blockMs : options.queueBlockMs } : { } ) ,
} ) ;
return bridge ;
} ) ;
// Surface bridge-construction failures eagerly via a rejected
// promise the next read/write picks up.
streamsBridgePromise . catch ( ( ) = > {
/* observed via getStreamsBridge() */
} ) ;
}
async function getStreamsBridge ( ) : Promise < ClientStreamsBridge > {
if ( streamsBridge !== null ) return streamsBridge ;
if ( streamsBridgePromise === null ) {
throw new ConflictError (
` http RPC client supports inline writes/reads only (≤ ${ INLINE_THRESHOLD } bytes) — pass { outboundQueueUrl, transferBaseUrl } to enable streamed transfers. ` ,
) ;
}
streamsBridge = await streamsBridgePromise ;
return streamsBridge ;
}
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>
2026-05-03 22:08:14 +02:00
/ * *
* Encrypt + POST + decrypt + parse one RPC round - trip .
*
* Throws a typed ` FileError ` subclass when the server returns an
* encrypted ` RpcError ` , or ` InternalFileError ` for transport - level
* failures ( network , 4 xx / 5 xx , malformed body ) .
* /
async function roundTrip < TResult > (
kind : string ,
op : 'list' | 'stat' | 'mkdir' | 'delete' | 'move' | 'read' | 'write' | 'getThumbnail' | 'custom' ,
args : unknown ,
resultSchema : ZodTypeAny ,
opts : RoundTripOpts | undefined ,
) : Promise < TResult > {
const requestEnv = await buildRpcRequest ( {
senderAddress ,
kind ,
op ,
args ,
. . . ( opts ? . idempotencyKey !== undefined ? { idempotencyKey : opts.idempotencyKey } : { } ) ,
. . . ( signRequest !== undefined ? { signRequest } : { } ) ,
} ) ;
const plaintext = encodeRpcEnvelope ( requestEnv ) ;
const ratchetEnvelope = await shade . send ( peerAddress , plaintext ) ;
const wireBytes = encodeWireEnvelope ( ratchetEnvelope ) ;
const ac = new AbortController ( ) ;
const timeoutMs = opts ? . timeoutMs ? ? defaultTimeoutMs ;
const timer = setTimeout (
( ) = > ac . abort ( new Error ( ` RPC timeout after ${ timeoutMs } ms ` ) ) ,
timeoutMs ,
) ;
( timer as unknown as { unref ? : ( ) = > void } ) . unref ? . ( ) ;
if ( opts ? . signal !== undefined ) {
const userSignal = opts . signal ;
if ( userSignal . aborted ) ac . abort ( userSignal . reason ) ;
else userSignal . addEventListener ( 'abort' , ( ) = > ac . abort ( userSignal . reason ) , { once : true } ) ;
}
let response : Response ;
try {
// Wrap the wire bytes in a Blob so the body type satisfies the
// common-denominator `BodyInit` across DOM, Bun, and node-fetch
// (some runtimes accept `Uint8Array` directly, others don't).
// Cast through `unknown` because TS's `bun-types` and `lib.dom`
// disagree about whether `Uint8Array<ArrayBufferLike>` is itself
// a `BlobPart`; the runtime accepts it on every platform.
response = await fetchFn ( rpcUrl , {
method : 'POST' ,
body : new Blob ( [ wireBytes as unknown as ArrayBuffer ] ) ,
signal : ac.signal ,
headers : {
'Content-Type' : 'application/octet-stream' ,
'X-Shade-Sender-Address' : senderAddress ,
. . . extraHeaders ,
} ,
} ) ;
} catch ( err ) {
clearTimeout ( timer ) ;
if ( ( err as Error ) . name === 'AbortError' ) {
throw new CancelledError ( ` RPC ${ kind } aborted: ${ ( err as Error ) . message } ` ) ;
}
throw new InternalFileError ( ` RPC ${ kind } fetch failed: ${ ( err as Error ) . message } ` ) ;
}
clearTimeout ( timer ) ;
if ( ! response . ok ) {
let body : { error? : string } | null = null ;
try {
body = ( await response . json ( ) ) as { error? : string } ;
} catch {
/* server emitted non-JSON body */
}
throw new InternalFileError (
` RPC ${ kind } → ${ response . status } ${ response . statusText } : ${
body ? . error ? ? '(no error body)'
} ` ,
) ;
}
const ab = await response . arrayBuffer ( ) ;
if ( ab . byteLength === 0 ) {
throw new InternalFileError ( ` RPC ${ kind } : empty response body ` ) ;
}
let responseRatchet ;
try {
responseRatchet = decodeEnvelope ( new Uint8Array ( ab ) ) ;
} catch ( err ) {
throw new InternalFileError (
` RPC ${ kind } : response body is not a valid wire envelope: ${ ( err as Error ) . message } ` ,
) ;
}
let responsePlaintext : string ;
try {
responsePlaintext = await shade . receive ( peerAddress , responseRatchet ) ;
} catch ( err ) {
throw new InternalFileError (
` RPC ${ kind } : response decrypt failed: ${ ( err as Error ) . message } ` ,
) ;
}
const classified = tryParseEnvelope ( responsePlaintext ) ;
if ( classified === null ) {
throw new InternalFileError (
` RPC ${ kind } : response plaintext is not a valid @shade/files envelope ` ,
) ;
}
if ( classified . kind === 'error' ) {
throw fileErrorFromPayload ( classified . envelope . error ) ;
}
if ( classified . kind !== 'response' ) {
throw new InternalFileError (
` RPC ${ kind } : unexpected response envelope kind: ${ classified . kind } ` ,
) ;
}
if ( classified . envelope . id !== requestEnv . id ) {
throw new InternalFileError (
` RPC ${ kind } : response correlation id mismatch (got ${ classified . envelope . id } , expected ${ requestEnv . id } ) ` ,
) ;
}
return resultSchema . parse ( classified . envelope . result ) as TResult ;
}
return {
async list ( path , opts ) : Promise < ListResult > {
const args = ListArgsSchema . parse ( {
path ,
. . . ( opts ? . cursor !== undefined ? { cursor : opts.cursor } : { } ) ,
. . . ( opts ? . pageSize !== undefined ? { pageSize : opts.pageSize } : { } ) ,
. . . ( opts ? . filter !== undefined ? { filter : opts.filter } : { } ) ,
} ) ;
return await roundTrip < ListResult > (
KIND_LIST_V1 ,
'list' ,
args ,
ListResultSchema ,
opts ,
) ;
} ,
async stat ( path , opts ) : Promise < StatResult > {
const args = StatArgsSchema . parse ( { path } ) ;
return await roundTrip < StatResult > ( KIND_STAT_V1 , 'stat' , args , StatResultSchema , opts ) ;
} ,
async mkdir ( path , opts ) : Promise < MkdirResult > {
const args = MkdirArgsSchema . parse ( {
path ,
. . . ( opts ? . recursive !== undefined ? { recursive : opts.recursive } : { } ) ,
} ) ;
return await roundTrip < MkdirResult > (
KIND_MKDIR_V1 ,
'mkdir' ,
args ,
MkdirResultSchema ,
opts ,
) ;
} ,
async delete ( path , opts ) : Promise < DeleteResult > {
const args = DeleteArgsSchema . parse ( {
path ,
. . . ( opts ? . recursive !== undefined ? { recursive : opts.recursive } : { } ) ,
} ) ;
return await roundTrip < DeleteResult > (
KIND_DELETE_V1 ,
'delete' ,
args ,
DeleteResultSchema ,
opts ,
) ;
} ,
async move ( src , dst , opts ) : Promise < MoveResult > {
const args = MoveArgsSchema . parse ( {
src ,
dst ,
. . . ( opts ? . overwrite !== undefined ? { overwrite : opts.overwrite } : { } ) ,
} ) ;
return await roundTrip < MoveResult > ( KIND_MOVE_V1 , 'move' , args , MoveResultSchema , opts ) ;
} ,
async read ( path , opts : ReadOpts = { } ) : Promise < ReadOutput > {
const args = ReadArgsSchema . parse ( {
path ,
. . . ( opts . range !== undefined ? { range : opts.range } : { } ) ,
. . . ( opts . preferInline !== undefined ? { preferInline : opts.preferInline } : { } ) ,
} ) ;
const wire = await roundTrip < import ( '../schemas/ops.js' ) .ReadResult > (
KIND_READ_V1 ,
'read' ,
args ,
ReadResultSchema ,
opts ,
) ;
2026-05-03 23:27:06 +02:00
if ( wire . kind === 'inline' ) {
const bytes = base64ToBytes ( wire . bytesB64 ) ;
const out : ReadOutput = {
kind : 'inline' ,
bytes ,
size : wire.size ,
sha256 : wire.sha256 ,
. . . ( wire . contentType !== undefined ? { contentType : wire.contentType } : { } ) ,
} ;
return out ;
}
// Streamed read — only supported when the queue drainer is wired.
if ( drainer === null ) {
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>
2026-05-03 22:08:14 +02:00
throw new InternalFileError (
2026-05-03 23:27:06 +02:00
` http RPC client received a streamed read (size ${ wire . size } ) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads. ` ,
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>
2026-05-03 22:08:14 +02:00
) ;
}
2026-05-03 23:27:06 +02:00
const bridge = await getStreamsBridge ( ) ;
const bridgeSignal = opts . signal ? ? new AbortController ( ) . signal ;
const parked = await bridge . awaitRead ( wire . streamId , {
expectedFrom : peerAddress ,
signal : bridgeSignal ,
timeoutMs : ioTimeoutMs ,
} ) ;
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>
2026-05-03 22:08:14 +02:00
const out : ReadOutput = {
2026-05-03 23:27:06 +02:00
kind : 'streams' ,
stream : parked.readable ,
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>
2026-05-03 22:08:14 +02:00
size : wire.size ,
sha256 : wire.sha256 ,
. . . ( wire . contentType !== undefined ? { contentType : wire.contentType } : { } ) ,
2026-05-03 23:27:06 +02:00
done : async ( ) = > {
await parked . done ;
} ,
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>
2026-05-03 22:08:14 +02:00
} ;
return out ;
} ,
async write ( path , input : WriteSource , opts : WriteOpts = { } ) : Promise < WriteResult > {
const decision = await decideInline ( input ) ;
const overwrite = opts . overwrite ? ? false ;
const contentType = opts . contentType ? ? decision . contentType ;
2026-05-03 23:27:06 +02:00
if ( decision . kind === 'inline' || opts . forceInline === true ) {
const bytes = decision . kind === 'inline' ? decision.bytes : null ;
if ( bytes === null ) {
// forceInline === true with a streams-typed decision —
// decideInline always produced a `streams` shape because the
// input was a bare ReadableStream. We can't drain a stream
// synchronously here without a streams-bridge.
throw new ConflictError (
'http RPC client cannot forceInline a streamed input — pass a Uint8Array / Blob, or pre-buffer the stream.' ,
) ;
}
if ( bytes . byteLength > INLINE_THRESHOLD ) {
throw new ConflictError (
` inline write exceeds ${ INLINE_THRESHOLD } -byte threshold (got ${ bytes . byteLength } ); pass forceInline=true to override ` ,
) ;
}
const args = WriteArgsSchema . parse ( {
kind : 'inline' ,
path ,
bytesB64 : bytesToBase64 ( bytes ) ,
. . . ( contentType !== undefined ? { contentType } : { } ) ,
overwrite ,
} ) ;
return await roundTrip < WriteResult > (
KIND_WRITE_V1 ,
'write' ,
args ,
WriteResultSchema ,
opts ,
) ;
}
// Streamed write — requires the queue drainer + streams-bridge.
if ( drainer === null ) {
throw new ConflictError (
` http RPC client supports inline writes only (≤ ${ INLINE_THRESHOLD } bytes). The supplied input was promoted to streams (size ${ decision . size ? ? 'unknown' } ). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes. ` ,
) ;
}
const bridge = await getStreamsBridge ( ) ;
const size = decision . size ;
if ( size === undefined ) {
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>
2026-05-03 22:08:14 +02:00
throw new ConflictError (
2026-05-03 23:27:06 +02:00
'streams write requires a known plaintext size; pass `{ stream, size }` instead of a bare ReadableStream' ,
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>
2026-05-03 22:08:14 +02:00
) ;
}
2026-05-03 23:27:06 +02:00
const { writeId , handle } = await bridge . initiateWrite ( {
peer : peerAddress ,
stream : decision.stream ,
size ,
. . . ( contentType !== undefined ? { contentType } : { } ) ,
name : path ,
. . . ( opts . signal !== undefined ? { signal : opts.signal } : { } ) ,
} ) ;
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>
2026-05-03 22:08:14 +02:00
const args = WriteArgsSchema . parse ( {
2026-05-03 23:27:06 +02:00
kind : 'streams' ,
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>
2026-05-03 22:08:14 +02:00
path ,
2026-05-03 23:27:06 +02:00
size ,
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>
2026-05-03 22:08:14 +02:00
. . . ( contentType !== undefined ? { contentType } : { } ) ,
overwrite ,
2026-05-03 23:27:06 +02:00
writeId ,
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>
2026-05-03 22:08:14 +02:00
} ) ;
2026-05-03 23:27:06 +02:00
try {
const [ result ] = await Promise . all ( [
roundTrip < WriteResult > ( KIND_WRITE_V1 , 'write' , args , WriteResultSchema , opts ) ,
handle . done ( ) ,
] ) ;
return result ;
} catch ( err ) {
await handle . abort ( 'rpc-failed' ) . catch ( ( ) = > undefined ) ;
throw err ;
}
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>
2026-05-03 22:08:14 +02:00
} ,
async getThumbnail ( path , size : ThumbnailSize , opts ) : Promise < ThumbnailResult > {
const args = GetThumbnailArgsSchema . parse ( {
path ,
size ,
. . . ( opts ? . format !== undefined ? { format : opts.format } : { } ) ,
} ) ;
const raw = await roundTrip < import ( '../schemas/ops.js' ) .GetThumbnailResult > (
KIND_GET_THUMBNAIL_V1 ,
'getThumbnail' ,
args ,
GetThumbnailResultSchema ,
opts ,
) ;
return {
bytes : base64ToBytes ( raw . bytesB64 ) ,
format : raw.format ,
width : raw.width ,
height : raw.height ,
sha256 : raw.sha256 ,
} ;
} ,
async custom ( name , args , opts? : BaseOpts ) : Promise < unknown > {
const wireArgs = CustomArgsSchema . parse ( { name , args } ) ;
return await roundTrip ( KIND_CUSTOM_V1 , 'custom' , wireArgs , CustomResultSchema , opts ) ;
} ,
close ( ) : void {
2026-05-03 23:27:06 +02:00
// Stop the long-poll drainer + tear down the streams-bridge if
// we built one. Idempotent — safe to call multiple times.
drainer ? . stop ( ) ;
drainer = null ;
if ( streamsBridge !== null ) {
void streamsBridge . destroy ( ) . catch ( ( ) = > undefined ) ;
streamsBridge = null ;
}
streamsBridgePromise = null ;
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>
2026-05-03 22:08:14 +02:00
} ,
} as FileClient ;
}