4.1.0's HTTP RPC for browsers capped at inline payloads (≤ 256 KiB).
4.2.0 unlocks streams: server queues outbound chunks + control
envelopes per peer, browser long-polls the queue. Browser-to-server
writes ride the existing /v1/transfer/<id>/chunk POST routes
unchanged.
For Dispatch this unlocks mod-jar uploads (50 MB) and world-backup
downloads (100+ MB) — the actual reason browser-side @shade/files
matters.
### New API
@shade/sdk:
- shade.transferQueueRoute(opts?) — Hono app with /queue +
/v1/transfer/* routes. Auto-configures the queue transport.
- shade.configureTransfers extended: transport + envelopeTransport
override slots; resolveBaseUrl optional when both supplied.
@shade/transfer:
- OutboundQueue — per-peer monotonic event log with long-poll
semantics, idle-eviction GC, ring-buffered to maxEventsPerPeer.
- QueueTransferTransport — enqueues instead of POSTing.
@shade/files:
- httpClient({ outboundQueueUrl, transferBaseUrl }) — when set,
starts a long-poll drainer + builds a streams-bridge. fs.read /
fs.write of >256 KiB work end-to-end.
- startQueueDrainer(shade, opts) — exported helper for advanced
consumers driving their own drainer.
### Implementation notes
- ClientStreamsBridge's TransformStream had HWM=0 by default which
stalled the drainer's await chain at chunk 4 (writer.write pended
before the consumer's reader was attached). Bumped to HWM=64 so
the receive loop can buffer ahead of the consumer.
### Tests
3 new integration tests in tests/integration/http-rpc-streams.test.ts:
4 MiB streamed read round-trip, inline-only error path, idle-timeout
long-poll behaviour.
Wire-compatible. Source-compatible. Lockstep bump to 4.2.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
/**
|
|
* Browser-side drainer for the pull-mode outbound queue.
|
|
*
|
|
* Background task that long-polls the server's `/queue` endpoint,
|
|
* decodes each event, and dispatches it into the consumer's Shade
|
|
* instance via `shade.acceptTransferEnvelope`. Same wire-shape as the
|
|
* server-to-server case where the engine receives chunks via direct
|
|
* HTTP POSTs — we just flip the direction so the browser pulls
|
|
* instead of accepts.
|
|
*/
|
|
import type { ShadeBridge } from '../integration/shade-bridge.js';
|
|
|
|
export interface QueueDrainerOptions {
|
|
/**
|
|
* Server endpoint that hosts `transferQueueRoute()`. Typically:
|
|
* `https://server.example.com/api/v1/shade-files/queue`.
|
|
*/
|
|
outboundQueueUrl: string;
|
|
/** Peer the queue is pulled FROM (the server's address). */
|
|
peerAddress: string;
|
|
/** Address we identify ourselves with on the long-poll. */
|
|
senderAddress: string;
|
|
/** Optional `fetch` override. Default `globalThis.fetch`. */
|
|
fetch?: typeof globalThis.fetch;
|
|
/** Extra headers applied to every poll request. */
|
|
headers?: Record<string, string>;
|
|
/**
|
|
* Long-poll request timeout (server-side block). Default 30_000.
|
|
* Server clamps to its own `maxBlockMs` (default 55_000).
|
|
*/
|
|
blockMs?: number;
|
|
/**
|
|
* Backoff after a network error before re-polling. Default 2_000.
|
|
* Doubles up to `maxBackoffMs` on consecutive failures.
|
|
*/
|
|
initialBackoffMs?: number;
|
|
maxBackoffMs?: number;
|
|
/**
|
|
* Called when a poll cycle fails. Defaults to logging via `console.error`.
|
|
* Throwing from this hook does NOT stop the drainer — it backs off
|
|
* and retries.
|
|
*/
|
|
onError?: (err: unknown) => void;
|
|
}
|
|
|
|
export interface QueueDrainerHandle {
|
|
/** Stop the drainer. Pending fetch is aborted; the loop exits. */
|
|
stop(): void;
|
|
/** Promise that resolves once the drainer has fully stopped. */
|
|
stopped: Promise<void>;
|
|
}
|
|
|
|
interface PolledEvent {
|
|
id: number;
|
|
timestampMs: number;
|
|
kind: 'envelope' | 'chunk';
|
|
bytesB64: string;
|
|
meta?: { streamId: string; laneId: number; seq: number };
|
|
}
|
|
|
|
interface PollResponseBody {
|
|
events: PolledEvent[];
|
|
nextSince: number;
|
|
}
|
|
|
|
const DEFAULT_BLOCK_MS = 30_000;
|
|
const DEFAULT_INITIAL_BACKOFF_MS = 2_000;
|
|
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
|
|
/**
|
|
* Start a long-poll loop that drains queued envelopes + chunks from
|
|
* the server and dispatches them into the local Shade instance.
|
|
*
|
|
* Returns a handle the caller can use to stop the drainer when the
|
|
* `httpClient` is closed (e.g. on tab unload).
|
|
*/
|
|
export function startQueueDrainer(
|
|
shade: ShadeBridge,
|
|
options: QueueDrainerOptions,
|
|
): QueueDrainerHandle {
|
|
if (shade.acceptTransferEnvelope === undefined) {
|
|
throw new Error(
|
|
'startQueueDrainer: shade.acceptTransferEnvelope is required for pull-mode streams. The supplied ShadeBridge implementation must surface it.',
|
|
);
|
|
}
|
|
const accept = shade.acceptTransferEnvelope.bind(shade);
|
|
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
const blockMs = options.blockMs ?? DEFAULT_BLOCK_MS;
|
|
const onError = options.onError ?? ((err: unknown) => console.error('[shade.files queue-drainer]', err));
|
|
const initialBackoffMs = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
const ac = new AbortController();
|
|
let stopped = false;
|
|
let resolveStopped!: () => void;
|
|
const stoppedPromise = new Promise<void>((r) => {
|
|
resolveStopped = r;
|
|
});
|
|
|
|
void (async () => {
|
|
let since = 0;
|
|
let backoff = initialBackoffMs;
|
|
while (!stopped) {
|
|
try {
|
|
const response = await fetchFn(options.outboundQueueUrl, {
|
|
method: 'POST',
|
|
signal: ac.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Shade-Sender-Address': options.senderAddress,
|
|
...(options.headers ?? {}),
|
|
},
|
|
body: JSON.stringify({ since, blockMs }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`queue poll → ${response.status} ${response.statusText}`);
|
|
}
|
|
const body = (await response.json()) as PollResponseBody;
|
|
if (Array.isArray(body.events) && body.events.length > 0) {
|
|
for (const event of body.events) {
|
|
if (stopped) break;
|
|
try {
|
|
const bytes = base64ToBytes(event.bytesB64);
|
|
await accept(options.peerAddress, bytes);
|
|
} catch (err) {
|
|
// Per-event dispatch failure should not kill the loop —
|
|
// resume picks up missing chunks via @shade/transfer's
|
|
// built-in lane-resume protocol.
|
|
onError(err);
|
|
}
|
|
}
|
|
}
|
|
since = typeof body.nextSince === 'number' ? body.nextSince : since;
|
|
backoff = initialBackoffMs;
|
|
} catch (err) {
|
|
if (stopped || ac.signal.aborted) break;
|
|
onError(err);
|
|
// Exponential backoff with jitter — caps at maxBackoffMs.
|
|
const jitter = Math.floor(Math.random() * Math.min(backoff, 1_000));
|
|
await new Promise<void>((r) => {
|
|
const t = setTimeout(r, backoff + jitter);
|
|
(t as unknown as { unref?: () => void }).unref?.();
|
|
ac.signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
clearTimeout(t);
|
|
r();
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
backoff = Math.min(maxBackoffMs, backoff * 2);
|
|
}
|
|
}
|
|
resolveStopped();
|
|
})();
|
|
|
|
return {
|
|
stop(): void {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
ac.abort(new Error('queue drainer stopped'));
|
|
},
|
|
stopped: stoppedPromise,
|
|
};
|
|
}
|
|
|
|
function base64ToBytes(b64: string): Uint8Array {
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|